From 033b271179e4f39f4e2eeace3031dbdb513a7e7b Mon Sep 17 00:00:00 2001
From: superops
Date: Fri, 9 Oct 2026 19:18:03 +0800
Subject: [PATCH] feat(studio): integrate managed MPA runtime lifecycle
Refresh the managed MPA and Studio runtime lifecycle integration on top of the latest upstream main, including the fork named-creation update, rebuilt web assets, the official TruffleHog scan configuration, and the task-cancellation regression hardening.
---
.agents/plugins/marketplace.json | 20 -
.gitattributes | 1 +
.github/workflows/secrets-scan.yaml | 2 +-
.gitignore | 19 +
.gitleaks.toml | 5 +
.pre-commit-config.yaml | 9 +-
.trufflehog-exclude-paths.txt | 1 +
AGENTS.md | 68 +-
contracts/mpa-p0/benchmark-report.schema.json | 23 +
contracts/mpa-p0/compatibility-matrix.json | 20 +
examples/piagent_with_mcp/README.md | 12 +-
.../piagent-mcp-agentkit.yaml | 14 +-
frontend/README.md | 208 +-
frontend/package-lock.json | 7 +
frontend/package.json | 6 +-
frontend/scripts/verifyBuiltAssets.mjs | 5 +
frontend/server/mpa/__init__.py | 74 +
frontend/server/mpa/operations.py | 452 ++++
frontend/server/mpa/routes.py | 1053 ++++++++
frontend/server/mpa/runtime_client.py | 368 +++
frontend/server/mpa/service.py | 323 +++
frontend/server/mpa/test_scenarios.py | 1654 +++++++++++++
frontend/server/mpa_a2a.py | 51 +
frontend/server/mpa_agent_info.py | 134 ++
frontend/server/mpa_creation.py | 222 ++
frontend/server/mpa_cron.py | 196 ++
frontend/server/mpa_identity_callback.py | 438 ++++
frontend/server/runtime_logs.py | 5 +-
frontend/server/skills/documents.py | 59 +
frontend/server/skills/version_routes.py | 40 +
frontend/server/skills/versions.py | 72 +
frontend/src/App.tsx | 1017 ++++++--
frontend/src/adk/channels.test.ts | 15 +
frontend/src/adk/client.ts | 1386 ++++++++++-
frontend/src/adk/connections.ts | 32 +-
frontend/src/adk/deploymentI18n.ts | 3 +
frontend/src/adk/mpa-identity-prewarm.test.ts | 107 +
frontend/src/adk/mpaCreation.ts | 106 +
frontend/src/adk/mpaCreationImages.ts | 10 +
frontend/src/adk/mpaCreationResources.ts | 38 +
frontend/src/adk/mpaCronTasks.ts | 210 ++
frontend/src/adk/skillDocuments.ts | 69 +
frontend/src/adk/sse.ts | 25 +-
frontend/src/adk/wecom-sdk.d.ts | 3 +
frontend/src/adk/wecomAuthorization.ts | 77 +
.../mpa-channels/MpaChannelsAutomation.css | 153 ++
.../mpa-channels/MpaChannelsAutomation.tsx | 268 +++
.../mpa-channels/MpaChannelsIcon.tsx | 22 +
frontend/src/automations/mpaChannels.ts | 10 +
frontend/src/automations/registry.ts | 2 +
frontend/src/automations/types.ts | 25 +-
frontend/src/blocks.ts | 644 ++++-
.../ConversationFlow/StudioConversation.tsx | 5 +
.../layouts/ModalLayout/ModalLayout.tsx | 10 +-
.../components/primitives/Button/Button.css | 5 +
frontend/src/create/CustomCreate.tsx | 267 ++-
frontend/src/create/NewAgentWorkbench.tsx | 26 +-
frontend/src/create/agentDraftStorage.ts | 2 +
frontend/src/create/skills/skillspace.ts | 6 +-
frontend/src/cronjobs/CronJobs.tsx | 12 +-
frontend/src/cronjobs/MpaCronTasks.css | 427 ++++
frontend/src/cronjobs/MpaCronTasks.tsx | 621 +++++
frontend/src/cronjobs/MpaTaskDetail.tsx | 147 ++
frontend/src/cronjobs/MpaTaskEditor.tsx | 346 +++
frontend/src/cronjobs/MpaTaskIcons.tsx | 31 +
frontend/src/cronjobs/mpaSchedule.ts | 185 ++
frontend/src/i18n/resources/en-US/adk.json | 14 +-
.../src/i18n/resources/en-US/automations.json | 22 +
.../i18n/resources/en-US/conversation.json | 99 +-
frontend/src/i18n/resources/en-US/create.json | 12 +-
.../src/i18n/resources/en-US/cronjobs.json | 107 +
.../src/i18n/resources/en-US/newChat.json | 1 +
frontend/src/i18n/resources/en-US/ui.json | 338 ++-
.../i18n/resources/en-US/workspaceTools.json | 40 +-
frontend/src/i18n/resources/zh-CN/adk.json | 14 +-
.../src/i18n/resources/zh-CN/automations.json | 22 +
.../i18n/resources/zh-CN/conversation.json | 99 +-
frontend/src/i18n/resources/zh-CN/create.json | 12 +-
.../src/i18n/resources/zh-CN/cronjobs.json | 107 +
.../src/i18n/resources/zh-CN/newChat.json | 1 +
frontend/src/i18n/resources/zh-CN/ui.json | 338 ++-
.../i18n/resources/zh-CN/workspaceTools.json | 40 +-
frontend/src/main.tsx | 6 +
frontend/src/styles.css | 294 ++-
frontend/src/transcriptReconcile.ts | 29 +
frontend/src/transcriptRows.ts | 69 +
frontend/src/ui/AgentSelector.tsx | 7 +-
frontend/src/ui/AgentTopology.tsx | 421 +---
frontend/src/ui/AgentWorkspace.css | 280 ++-
frontend/src/ui/AgentWorkspace.tsx | 1549 +++++++++++-
frontend/src/ui/Applications.tsx | 16 +-
frontend/src/ui/Blocks.tsx | 239 +-
frontend/src/ui/Composer.tsx | 104 +-
frontend/src/ui/DeploymentSelect.tsx | 6 +-
frontend/src/ui/EnvironmentCenter.tsx | 16 +-
frontend/src/ui/GithubCicdPanel.tsx | 6 +
frontend/src/ui/ManageAgents.tsx | 18 +-
frontend/src/ui/Markdown.tsx | 9 +-
frontend/src/ui/MyAgents.tsx | 97 +-
frontend/src/ui/ProjectPreview.css | 11 +
frontend/src/ui/ProjectPreview.tsx | 309 ++-
frontend/src/ui/ResourceCollection.css | 5 +-
frontend/src/ui/RuntimeChannels.css | 304 +++
frontend/src/ui/RuntimeChannels.tsx | 933 ++++++++
frontend/src/ui/RuntimeLogsDialog.css | 34 +
frontend/src/ui/RuntimeLogsDialog.tsx | 42 +
frontend/src/ui/SandboxFileLink.tsx | 65 +
frontend/src/ui/Sidebar.tsx | 2 +-
frontend/src/ui/StudioConfirmDialog.tsx | 6 +-
frontend/src/ui/TokenUsageIndicator.tsx | 21 +
frontend/src/ui/WorkspaceCenter.tsx | 19 +-
.../ui/builtin-tools/codexSandboxProgress.ts | 13 +-
.../ui/mpa-agent-info/MpaAgentInfoRail.tsx | 151 ++
.../ui/mpa-agent-info/SkillDocumentEditor.css | 81 +
.../ui/mpa-agent-info/SkillDocumentEditor.tsx | 224 ++
.../src/ui/mpa-create/MpaCreateDialog.css | 171 ++
.../src/ui/mpa-create/MpaCreateDialog.tsx | 741 ++++++
.../ui/new-chat-modes/NewChatAgentPicker.tsx | 75 +-
.../new-chat-modes/NewChatCompactSelect.tsx | 102 +-
.../ui/new-chat-modes/new-chat-workspace.css | 13 +-
.../src/ui/tool-activity/ToolActivityCard.tsx | 380 +++
.../ui/tool-activity/ToolActivityIcons.tsx | 121 +
frontend/src/ui/tool-activity/model.ts | 470 ++++
.../src/ui/tool-activity/tool-activity.css | 276 +++
frontend/tests/a2aWaiting.test.tsx | 60 +
frontend/tests/adkI18n.test.mjs | 21 +
frontend/tests/agentInfoRail.test.mjs | 133 +-
frontend/tests/agentWorkspace.test.mjs | 223 +-
frontend/tests/codexSandboxProgress.test.mjs | 45 +-
frontend/tests/composerPresentation.test.mjs | 5 +-
frontend/tests/deploymentConfigUi.test.mjs | 2 +-
frontend/tests/githubCicdPanel.test.mjs | 13 +
.../tests/intelligentDevelopment.test.mjs | 4 +-
frontend/tests/issueFeedback.test.mjs | 2 +-
.../tests/manageAgentsConnection.test.mjs | 14 +-
frontend/tests/messageFeedback.test.mjs | 2 +-
frontend/tests/mpaA2aTranscript.test.mjs | 106 +
frontend/tests/mpaAgentInfoRail.test.tsx | 230 ++
frontend/tests/mpaChannelsAutomation.test.tsx | 439 ++++
.../tests/mpaContentPreservation.test.mjs | 154 ++
frontend/tests/mpaCreation.test.tsx | 924 +++++++
frontend/tests/mpaCronTasks.test.tsx | 99 +
frontend/tests/mpaManagement.test.tsx | 959 ++++++++
frontend/tests/mpaResponseGrouping.test.mjs | 76 +
frontend/tests/mpaSessionConfigUi.test.mjs | 104 +
.../mpaSessionExecutionConfigClient.test.mjs | 42 +
frontend/tests/mpaSessionProtocol.test.mjs | 221 ++
frontend/tests/myAgents.test.mjs | 36 +-
frontend/tests/newAgentWorkbench.test.mjs | 80 +-
frontend/tests/newChatAgentPicker.test.mjs | 48 +-
frontend/tests/newChatComposerLayout.test.mjs | 118 +-
.../tests/newChatModeCapabilities.test.mjs | 2 +-
.../tests/parallelStreamAggregation.test.mjs | 265 ++
frontend/tests/parseSSE.test.mjs | 12 +-
frontend/tests/privateRuntimeDeploy.test.mjs | 2 +-
frontend/tests/responseAnnotation.test.mjs | 6 +-
frontend/tests/runSseAbort.test.mjs | 788 +++++-
frontend/tests/runSseCompletion.test.mjs | 113 +-
frontend/tests/runtimeAgentIdentity.test.mjs | 5 +-
frontend/tests/runtimeChannels.test.tsx | 979 ++++++++
...runtimeControlAndEnvironmentState.test.mjs | 30 +
frontend/tests/runtimeLogs.test.mjs | 12 +
frontend/tests/runtimeScalingDeploy.test.mjs | 54 +-
frontend/tests/sandboxDownload.test.tsx | 74 +
frontend/tests/sandboxDownloadClient.test.ts | 25 +
.../tests/sessionEnvironmentMount.test.mjs | 10 +-
frontend/tests/skillCenter.test.mjs | 5 +-
frontend/tests/skillDocumentEditor.test.tsx | 161 ++
frontend/tests/stopGeneration.test.mjs | 17 +-
frontend/tests/studioAccess.test.mjs | 8 +-
frontend/tests/studioBffTools.test.mjs | 36 +-
frontend/tests/studioConversation.test.tsx | 11 +-
frontend/tests/tokenUsageIndicator.test.mjs | 6 +
frontend/tests/toolActivityModel.test.mjs | 844 +++++++
frontend/tests/toolBlockDefaultOpen.test.mjs | 152 +-
frontend/tests/viteProxy.test.mjs | 7 +
frontend/vite.config.ts | 1 +
frontend/vitest.a2a-wait.config.ts | 8 +
frontend/vitest.mpa-cron.config.ts | 17 +
frontend/vitest.sandbox-download.config.ts | 13 +
.../proposal.md | 19 +
.../mpa-agent-studio-a2a-streaming/tasks.md | 23 +
.../mpa-p0-productionization/proposal.md | 70 +
.../changes/mpa-p0-productionization/tasks.md | 342 +++
.../proposal.md | 20 +
.../tasks.md | 16 +
prd-spec/README.md | 130 +
prd-spec/README.zh.md | 130 +
.../2026-09-12-a2a-first-event-heartbeat.md | 51 +
...2026-09-12-a2a-first-event-heartbeat.zh.md | 51 +
.../2026-09-23-aidap-workspace-readiness.md | 33 +
...2026-09-23-aidap-workspace-readiness.zh.md | 33 +
.../2026-09-23-mpa-a2a-discovery-default.md | 48 +
...2026-09-23-mpa-a2a-discovery-default.zh.md | 48 +
.../2026-09-23-mpa-registry-tls-url.md | 35 +
.../2026-09-23-mpa-registry-tls-url.zh.md | 35 +
.../a2a-long-running/2026-09-15-wait.md | 17 +
.../a2a-long-running/2026-09-15-wait.zh.md | 17 +
.../2026-09-15-channel-group-actions.md | 17 +
.../2026-09-15-channel-group-actions.zh.md | 17 +
.../2026-09-15-channel-qr-lifetime.md | 21 +
.../2026-09-15-channel-qr-lifetime.zh.md | 21 +
.../2026-09-14-chat-model-dropdown.md | 56 +
.../2026-09-14-chat-model-dropdown.zh.md | 56 +
.../2026-09-17-replay.md | 13 +
.../2026-09-17-replay.zh.md | 13 +
.../2026-09-14-mpa-agent-filtering.md | 61 +
.../2026-09-14-mpa-agent-filtering.zh.md | 61 +
.../2026-09-15-mpa-channel-visibility.md | 28 +
.../2026-09-15-mpa-channel-visibility.zh.md | 28 +
.../2026-09-30-explicit-new-request.md | 56 +
.../2026-09-30-explicit-new-request.zh.md | 47 +
.../2026-09-30-recover-step-navigation.md | 45 +
.../2026-09-30-recover-step-navigation.zh.md | 43 +
.../mpa-default-jwt/2026-09-15-default-jwt.md | 9 +
.../2026-09-15-default-jwt.zh.md | 9 +
.../2026-09-30-studio-key-auth-default.md | 98 +
.../2026-09-30-studio-key-auth-default.zh.md | 87 +
...-09-29-ensure-managed-workload-identity.md | 80 +
...-29-ensure-managed-workload-identity.zh.md | 73 +
.../2026-09-29-set-fresh-runtime-command.md | 50 +
...2026-09-29-set-fresh-runtime-command.zh.md | 45 +
...6-09-29-runtime-initial-tool-attachment.md | 90 +
...9-29-runtime-initial-tool-attachment.zh.md | 75 +
...09-12-mpa-runtime-integration-hardening.md | 160 ++
...12-mpa-runtime-integration-hardening.zh.md | 160 ++
.../2026-09-13-dev-loop-execution.md | 40 +
.../2026-09-13-dev-loop-execution.zh.md | 40 +
.../2026-09-13-dev-loop-review.md | 31 +
.../2026-09-13-dev-loop-review.zh.md | 31 +
.../2026-09-13-functional-validation-cases.md | 23 +
...26-09-13-functional-validation-cases.zh.md | 23 +
.../2026-10-07-persistent-mcp-secret.md | 78 +
.../2026-10-07-persistent-mcp-secret.zh.md | 78 +
.../2026-09-29-preserve-runtime-command.md | 60 +
.../2026-09-29-preserve-runtime-command.zh.md | 51 +
.../2026-09-29-typed-event-dedup.md | 46 +
.../2026-09-29-typed-event-dedup.zh.md | 46 +
.../2026-09-28-registry-owned-network.md | 115 +
.../2026-09-28-registry-owned-network.zh.md | 98 +
.../2026-09-21-mpa-test-types.md | 50 +
.../2026-09-21-mpa-test-types.zh.md | 50 +
.../2026-09-28-agent-derived-worker-name.md | 52 +
...2026-09-28-agent-derived-worker-name.zh.md | 52 +
.../2026-09-20-worker-metadata-visibility.md | 53 +
...026-09-20-worker-metadata-visibility.zh.md | 53 +
.../2026-09-20-worker-retry.md | 56 +
.../2026-09-20-worker-retry.zh.md | 56 +
.../2026-09-30-worker-metadata-deadline.md | 54 +
.../2026-09-30-worker-metadata-deadline.zh.md | 54 +
.../2026-09-17-default-db-spans.md | 9 +
.../2026-09-17-default-db-spans.zh.md | 9 +
.../2026-09-22-mpa-a2a-final.md | 56 +
.../2026-09-22-mpa-a2a-final.zh.md | 56 +
.../2026-09-22-mpa-a2a-reasoning.md | 34 +
.../2026-09-22-mpa-a2a-reasoning.zh.md | 34 +
.../2026-09-22-mpa-direct-answer-reasoning.md | 50 +
...26-09-22-mpa-direct-answer-reasoning.zh.md | 50 +
.../2026-09-22-mpa-reasoning-snapshots.md | 36 +
.../2026-09-22-mpa-reasoning-snapshots.zh.md | 36 +
.../2026-09-22-mpa-wrapper-preview.md | 46 +
.../2026-09-22-mpa-wrapper-preview.zh.md | 46 +
.../2026-09-22-preserve-followup-content.md | 35 +
...2026-09-22-preserve-followup-content.zh.md | 35 +
.../2026-09-22-preserve-sandbox-reasoning.md | 36 +
...026-09-22-preserve-sandbox-reasoning.zh.md | 36 +
...026-09-22-studio-codex-commentary-dedup.md | 113 +
...-09-22-studio-codex-commentary-dedup.zh.md | 113 +
.../studio-cron-e2e/2026-09-15-integration.md | 17 +
.../2026-09-15-integration.zh.md | 17 +
.../2026-09-30-prefer-a2a.md | 97 +
.../2026-09-30-prefer-a2a.zh.md | 83 +
...026-09-23-complete-aidap-runtime-policy.md | 86 +
...-09-23-complete-aidap-runtime-policy.zh.md | 86 +
...09-23-complete-mpa-cloud-runtime-policy.md | 94 +
...23-complete-mpa-cloud-runtime-policy.zh.md | 94 +
.../2026-09-30-hide-artifact-entry.md | 75 +
.../2026-09-30-hide-artifact-entry.zh.md | 62 +
...09-24-start-another-agent-after-failure.md | 55 +
...24-start-another-agent-after-failure.zh.md | 55 +
.../2026-09-20-dialog-theme.md | 50 +
.../2026-09-20-dialog-theme.zh.md | 50 +
.../2026-09-20-studio-mpa-profile-errors.md | 97 +
...2026-09-20-studio-mpa-profile-errors.zh.md | 97 +
.../2026-09-23-use-temporary-state.md | 82 +
.../2026-09-23-use-temporary-state.zh.md | 82 +
.../2026-10-07-align-sdk-wheel.md | 99 +
.../2026-10-07-align-sdk-wheel.zh.md | 87 +
.../2026-09-22-a2a-session-routing.md | 51 +
.../2026-09-22-a2a-session-routing.zh.md | 51 +
.../2026-09-22-studio-startup-assets.md | 194 ++
.../2026-09-22-studio-startup-assets.zh.md | 194 ++
.../2026-09-17-session-trace.md | 13 +
.../2026-09-17-session-trace.zh.md | 13 +
.../2026-09-23-rebuild-corrupted-assets.md | 37 +
.../2026-09-23-rebuild-corrupted-assets.zh.md | 37 +
.../2026-09-23-yaml-secret-guard.md | 72 +
.../2026-09-23-yaml-secret-guard.zh.md | 72 +
.../2026-09-15-channel-auth-methods.md | 34 +
.../2026-09-15-channel-auth-methods.zh.md | 34 +
.../2026-09-15-channel-configuration-modes.md | 73 +
...26-09-15-channel-configuration-modes.zh.md | 73 +
.../2026-09-15-channel-qr-regeneration.md | 36 +
.../2026-09-15-channel-qr-regeneration.zh.md | 36 +
.../2026-09-15-message-channel-binding.md | 61 +
.../2026-09-15-message-channel-binding.zh.md | 61 +
.../2026-09-15-message-channel-workspace.md | 50 +
...2026-09-15-message-channel-workspace.zh.md | 50 +
.../2026-09-18-mpa-agent-info-rail.md | 94 +
.../2026-09-18-mpa-agent-info-rail.zh.md | 94 +
...-10-mpa-agent-oneclick-provision-design.md | 221 ++
...-mpa-agent-oneclick-provision-design.zh.md | 221 ++
...t-oneclick-provision-verification-cases.md | 182 ++
...neclick-provision-verification-cases.zh.md | 159 ++
...rovision-orchestration-alignment-design.md | 228 ++
...ision-orchestration-alignment-design.zh.md | 226 ++
...2-mpa-agent-studio-a2a-streaming-design.md | 230 ++
...pa-agent-studio-a2a-streaming-design.zh.md | 230 ++
...studio-a2a-streaming-verification-cases.md | 48 +
...dio-a2a-streaming-verification-cases.zh.md | 48 +
.../2026-09-20-studio-mpa-creation.md | 216 ++
.../2026-09-20-studio-mpa-creation.zh.md | 216 ++
.../2026-09-23-mpa-tos-output-mount.md | 77 +
.../2026-09-23-mpa-tos-output-mount.zh.md | 66 +
.../2026-09-23-studio-builtin-mpa-profile.md | 52 +
...026-09-23-studio-builtin-mpa-profile.zh.md | 52 +
.../2026-09-23-studio-mpa-openviking-key.md | 42 +
...2026-09-23-studio-mpa-openviking-key.zh.md | 42 +
...26-09-23-studio-mpa-three-step-creation.md | 55 +
...09-23-studio-mpa-three-step-creation.zh.md | 55 +
.../2026-10-09-named-studio-creation.md | 83 +
.../2026-10-09-named-studio-creation.zh.md | 83 +
.../evidence/2026-09-11-code-review.zh.md | 57 +
.../2026-09-11-verification-execution.zh.md | 73 +
.../mpa-create.config.example.yaml | 146 ++
.../2026-09-16-mpa-channels-automation.md | 103 +
.../2026-09-16-mpa-channels-automation.zh.md | 103 +
.../2026-09-15-mpa-feishu-channels.md | 31 +
.../2026-09-15-mpa-feishu-channels.zh.md | 31 +
.../2026-09-15-dev-loop-spec-review.md | 56 +
.../2026-09-15-dev-loop-spec-review.zh.md | 56 +
.../2026-09-15-functional-validation-cases.md | 286 +++
...26-09-15-functional-validation-cases.zh.md | 286 +++
.../2026-09-15-m0-evidence.md | 26 +
.../2026-09-15-m0-evidence.zh.md | 26 +
...6-09-15-mpa-p0-productionization-design.md | 438 ++++
...9-15-mpa-p0-productionization-design.zh.md | 438 ++++
.../mpa-p0-productionization/browser-cases.md | 101 +
.../browser-cases.zh.md | 101 +
.../2026-09-22-mpa-response-grouping.md | 38 +
.../2026-09-22-mpa-response-grouping.zh.md | 38 +
.../2026-09-21-deployment-account-pg.md | 89 +
.../2026-09-21-deployment-account-pg.zh.md | 89 +
.../2026-09-23-auto-pg-workspaces.md | 64 +
.../2026-09-23-auto-pg-workspaces.zh.md | 64 +
.../2026-09-23-space-scoped-manual-pg.md | 49 +
.../2026-09-23-space-scoped-manual-pg.zh.md | 49 +
.../2026-09-23-two-pg-workspaces.md | 57 +
.../2026-09-23-two-pg-workspaces.zh.md | 57 +
...6-09-13-mpa-studio-control-plane-design.md | 170 ++
...9-13-mpa-studio-control-plane-design.zh.md | 170 ++
...tudio-control-plane-implementation-plan.md | 139 ++
...io-control-plane-implementation-plan.zh.md | 130 +
...2026-09-20-mpa-studio-workload-identity.md | 124 +
...6-09-20-mpa-studio-workload-identity.zh.md | 124 +
.../2026-09-15-mono-task-management.md | 54 +
.../2026-09-15-mono-task-management.zh.md | 54 +
.../2026-09-15-remove-agent-input.md | 5 +
.../2026-09-15-remove-agent-input.zh.md | 5 +
.../features/studio-mpa-cron-tasks/README.md | 46 +
.../studio-mpa-cron-tasks/README.zh.md | 46 +
.../2026-09-23-deploy-identity-reuse.md | 35 +
.../2026-09-23-deploy-identity-reuse.zh.md | 35 +
.../2026-09-23-studio-mpa-prewarm.md | 31 +
.../2026-09-23-studio-mpa-prewarm.zh.md | 31 +
.../2026-09-20-main-integration.md | 9 +
.../2026-09-20-main-integration.zh.md | 9 +
.../2026-09-28-studio-mpa-source-filter.md | 128 +
.../2026-09-28-studio-mpa-source-filter.zh.md | 128 +
.../2026-09-23-retain-id-token.md | 39 +
.../2026-09-23-retain-id-token.zh.md | 39 +
.../2026-09-17-history-download.md | 36 +
.../2026-09-17-history-download.zh.md | 36 +
...udio-tool-activity-visualization-design.md | 276 +++
...o-tool-activity-visualization-design.zh.md | 276 +++
pyproject.toml | 2 +-
scripts/benchmark-mpa-p0.py | 121 +
scripts/mpa_p0_contract.py | 62 +
scripts/mpa_p0_e2e.py | 364 +++
scripts/mpa_p0_vc21_driver.py | 1597 +++++++++++++
scripts/run-mpa-p0-browser-fixture.sh | 26 +
scripts/scan_yaml_secrets.py | 164 ++
scripts/verify-mpa-p0-contract.sh | 5 +
scripts/verify-mpa-p0-e2e.sh | 13 +
specs/README.md | 114 +
specs/README.zh.md | 114 +
specs/mpa-channels/README.md | 41 +
specs/mpa-channels/README.zh.md | 41 +
specs/mpa-runtime-control/README.md | 193 ++
specs/mpa-runtime-control/README.zh.md | 193 ++
specs/mpa-runtime-provisioning/README.md | 122 +
specs/mpa-runtime-provisioning/README.zh.md | 112 +
specs/studio-mpa-control-plane/README.md | 131 +
specs/studio-mpa-control-plane/README.zh.md | 131 +
specs/studio-mpa-creation/README.md | 138 ++
specs/studio-mpa-creation/README.zh.md | 136 ++
specs/studio-mpa-cron-tasks/README.md | 36 +
specs/studio-mpa-cron-tasks/README.zh.md | 36 +
specs/studio-mpa-prewarm/README.md | 18 +
specs/studio-mpa-prewarm/README.zh.md | 18 +
specs/studio-oauth-session/README.md | 26 +
specs/studio-oauth-session/README.zh.md | 26 +
specs/studio-runtime-diagnostics/README.md | 63 +
specs/studio-runtime-diagnostics/README.zh.md | 63 +
specs/studio-sandbox-download/README.md | 21 +
specs/studio-sandbox-download/README.zh.md | 21 +
specs/studio-skill-document/README.md | 23 +
specs/studio-skill-document/README.zh.md | 23 +
specs/studio-tool-activity/README.md | 124 +
specs/studio-tool-activity/README.zh.md | 124 +
tests/auth/test_oauth2_auth.py | 105 +
tests/cli/test_cli_mpa.py | 685 ++++++
tests/cli/test_cli_mpa_control.py | 765 ++++++
tests/cli/test_frontend_apmplus_trace.py | 20 +-
tests/cli/test_frontend_deploy_iam.py | 86 +
tests/cli/test_frontend_runtime_proxy.py | 1671 ++++++++++++-
tests/cli/test_github_cicd.py | 309 +++
tests/cli/test_mpa_a2a_url.py | 74 +
tests/cli/test_runtime_a2a_stream.py | 1271 ++++++++++
tests/cli/test_studio_dependencies.py | 74 +
tests/cli/test_studio_deploy_target.py | 213 +-
tests/cli/test_studio_rbac.py | 1067 ++++++++-
tests/cli/test_studio_startup_imports.py | 7 +-
tests/cli/test_studio_tos_sdk_dependency.py | 36 +
tests/cli/test_studio_trace_pagination.py | 89 +
tests/cli/test_studio_update.py | 29 +-
.../extensions/harness/test_sidecar_wheel.py | 4 +-
.../mpa/test_agent_operations_service.py | 320 +++
tests/frontend/server/mpa/test_mpa_module.py | 68 +
.../server/mpa/test_operations_repository.py | 242 ++
.../server/mpa/test_runtime_profile_client.py | 340 +++
.../server/mpa/test_runtime_profile_routes.py | 969 ++++++++
.../server/mpa/test_scenario_harness.py | 1021 ++++++++
tests/frontend/server/skills/test_versions.py | 131 +
tests/frontend/server/test_mpa_agent_info.py | 177 ++
tests/frontend/server/test_mpa_cron.py | 133 ++
.../server/test_mpa_identity_callback.py | 452 ++++
tests/frontend/server/test_runtime_logs.py | 26 +
tests/integrations/mpa_managed/__init__.py | 13 +
.../mpa_managed/fakes_deployment_network.py | 110 +
.../mpa_managed/test_admin_database.py | 273 +++
.../mpa_managed/test_agent_deployment.py | 795 ++++++
.../integrations/mpa_managed/test_auto_pg.py | 806 +++++++
tests/integrations/mpa_managed/test_config.py | 586 +++++
.../mpa_managed/test_creation_images.py | 300 +++
.../test_deployment_database_unit.py | 384 +++
.../mpa_managed/test_deployment_network.py | 390 +++
.../test_deployment_network_cloud.py | 174 ++
.../integrations/mpa_managed/test_gateway.py | 292 +++
.../mpa_managed/test_gateway_cloud.py | 44 +
.../mpa_managed/test_named_creation.py | 210 ++
.../mpa_managed/test_registry_url.py | 108 +
tests/integrations/mpa_managed/test_routes.py | 189 ++
.../test_runtime_deployment_edges.py | 255 ++
.../integrations/mpa_managed/test_service.py | 609 +++++
.../test_studio_shared_resources.py | 146 ++
tests/integrations/mpa_managed/test_tags.py | 53 +
tests/integrations/mpa_managed/test_tasks.py | 483 ++++
tests/integrations/mpa_managed/test_worker.py | 340 +++
.../mpa_managed/test_worker_metadata.py | 300 +++
.../mpa_managed/test_worker_recovery.py | 459 ++++
.../test_mpa_compatibility_manifest.py | 100 +
.../test_mpa_control_plane_client.py | 605 +++++
tests/integrations/test_mpa_identity.py | 170 ++
tests/integrations/test_mpa_meta_seed.py | 240 ++
tests/integrations/test_mpa_p0_benchmark.py | 100 +
tests/integrations/test_mpa_p0_e2e_runner.py | 485 ++++
tests/integrations/test_mpa_p0_vc21_driver.py | 377 +++
tests/integrations/test_mpa_provision_env.py | 275 +++
tests/integrations/test_mpa_runtime.py | 315 +++
tests/integrations/test_mpa_skill_space.py | 62 +
tests/integrations/test_mpa_tool.py | 266 +++
tests/integrations/test_mpa_verify.py | 172 ++
.../test_ve_faas_deploy_image_key_auth.py | 156 ++
tests/scripts/test_scan_yaml_secrets.py | 68 +
tests/test_mpa_channel_proxy_policy.py | 60 +
tests/test_release_progress.py | 51 +-
uv.lock | 2 +-
veadk/auth/middleware/oauth2_auth.py | 24 +-
veadk/cli/cli.py | 2 +
veadk/cli/cli_frontend.py | 2127 ++++++++++++++++-
veadk/cli/cli_mpa.py | 826 +++++++
veadk/cli/cli_mpa_admin.py | 96 +
veadk/cli/cli_mpa_control.py | 617 +++++
veadk/cli/frontend_apmplus_trace.py | 235 +-
veadk/cli/frontend_deploy_iam.py | 6 +-
veadk/cli/frontend_deploy_policy.py | 25 +-
veadk/cli/mpa_p0_contract.py | 163 ++
veadk/cli/runtime_a2a_stream.py | 890 +++++++
veadk/cli/studio_dependencies.py | 10 +-
veadk/integrations/mpa/__init__.py | 43 +
veadk/integrations/mpa/channel_proxy.py | 45 +
.../integrations/mpa/control_plane_client.py | 555 +++++
veadk/integrations/mpa/managed/README.md | 232 ++
veadk/integrations/mpa/managed/README.zh.md | 228 ++
veadk/integrations/mpa/managed/__init__.py | 15 +
.../mpa/managed/admin_database.py | 191 ++
veadk/integrations/mpa/managed/config.py | 741 ++++++
veadk/integrations/mpa/managed/credentials.py | 66 +
veadk/integrations/mpa/managed/database.py | 377 +++
veadk/integrations/mpa/managed/diagnostics.py | 224 ++
veadk/integrations/mpa/managed/gateway.py | 180 ++
.../integrations/mpa/managed/gateway_cloud.py | 170 ++
veadk/integrations/mpa/managed/metadata.py | 208 ++
veadk/integrations/mpa/managed/network.py | 323 +++
.../integrations/mpa/managed/network_cloud.py | 139 ++
.../integrations/mpa/managed/pg_bootstrap.py | 335 +++
veadk/integrations/mpa/managed/pg_cloud.py | 366 +++
veadk/integrations/mpa/managed/registry.py | 142 ++
veadk/integrations/mpa/managed/runner.py | 83 +
veadk/integrations/mpa/managed/runtime.py | 653 +++++
veadk/integrations/mpa/managed/service.py | 389 +++
veadk/integrations/mpa/managed/skills.py | 136 ++
.../mpa/managed/studio_profile.py | 93 +
veadk/integrations/mpa/managed/tasks.py | 466 ++++
veadk/integrations/mpa/managed/worker.py | 315 +++
veadk/integrations/mpa/mpa_identity.py | 107 +
veadk/integrations/mpa/mpa_meta_seed.py | 273 +++
veadk/integrations/mpa/mpa_provision.py | 268 +++
veadk/integrations/mpa/mpa_runtime.py | 364 +++
veadk/integrations/mpa/mpa_skill_space.py | 77 +
veadk/integrations/mpa/mpa_tool.py | 288 +++
veadk/integrations/mpa/mpa_verify.py | 120 +
veadk/integrations/mpa/tags.py | 63 +
veadk/integrations/ve_faas/ve_faas.py | 113 +-
.../ve_identity/identity_client.py | 119 +-
veadk/webui/assets/app/index-BNPwMQXG.js | 1398 +++++++++++
veadk/webui/assets/app/index-BcpSnsFp.js | 1372 -----------
...BQpKN2_Y.js => CodeDiffEditor-DA-6OJrJ.js} | 2 +-
...VzGB.js => FileExplorerEditor-B8Ub9k9b.js} | 2 +-
...G9.js => MarkdownPromptEditor-D6ctFDfi.js} | 2 +-
....js => QuickAgentCreateDialog-04pJjJQJ.js} | 2 +-
.../{arc-CE0biH39.js => arc-fsSJ4XQX.js} | 2 +-
...hannel-CknSfctO.js => channel-Bbp-1bKw.js} | 2 +-
...ex.es-YSRnAMpa.js => index.es-JsJulYHK.js} | 2 +-
...n-CmNSIheP.js => jspdf.es.min-aFbI3tiR.js} | 6 +-
...{linear-D7YqIViL.js => linear-Dd6OAXqp.js} | 2 +-
veadk/webui/assets/styles/index-CUN-lyX5.css | 10 -
veadk/webui/assets/styles/index-_lutWX9f.css | 10 +
...en.js => abnfDiagram-N423BO3Z-RhYMe2zl.js} | 2 +-
... architectureDiagram-T3A2C74G-C47wDTji.js} | 2 +-
...0.js => blockDiagram-VBNYF7ZC-BAJbaVfQ.js} | 2 +-
...gw8b.js => c4Diagram-5PPSVZJV-CJepdaIg.js} | 2 +-
...Dbxo18XS.js => chunk-2GRJ4B5K-CvII-2ZL.js} | 2 +-
...DWO0x-gt.js => chunk-2Q5K7J3B-nvFjhS5b.js} | 2 +-
...BThWW3Mz.js => chunk-5RXB4S5H-CY1YEJD3.js} | 2 +-
...spyYz9_w.js => chunk-5VM5RSS4-BDBKcK2Z.js} | 2 +-
...D2AB22Qb.js => chunk-6Q2QTUOP-zEQK1XLO.js} | 2 +-
...WqpbbvGd.js => chunk-GF5L2VYU-Cv1ZgaH8.js} | 2 +-
...saJxckiD.js => chunk-JWPE2WC7-DD-BFVC6.js} | 2 +-
...COhTnu0H.js => chunk-KBJHAD2P-DtNDDRN1.js} | 2 +-
...DYejWv7S.js => chunk-RYQCIY6F-DL3iLUXF.js} | 2 +-
...1cbgwOld.js => chunk-XXDRQBXY-B8qTF1qQ.js} | 2 +-
.../mermaid/classDiagram-JCYQIIEL-DSyq-_Sn.js | 1 -
.../mermaid/classDiagram-JCYQIIEL-DtPkX3wA.js | 1 +
.../classDiagram-v2-OCEON4UE-DSyq-_Sn.js | 1 -
.../classDiagram-v2-OCEON4UE-DtPkX3wA.js | 1 +
...N.js => cose-bilkent-JH36ORCC-BNlKHYzG.js} | 2 +-
...IfZIRn.js => cynefin-OW5HDTMX-DBq9B5VP.js} | 2 +-
...js => cynefinDiagram-MW4NZA55-DScg9_Ly.js} | 2 +-
...C1L5FQ8P.js => dagre-VZM6K2ZE-shxLHn3p.js} | 2 +-
...2YFcSV.js => diagram-7IWD3JNH-C3mTV4cL.js} | 2 +-
...r1x0nn.js => diagram-B4RE2ZJO-D66216sR.js} | 2 +-
...s8ciIO.js => diagram-LBJQPF4R-BZA2jWUX.js} | 2 +-
...vOmh8B.js => diagram-Q27KOJAE-Cpjz8RT3.js} | 2 +-
...0y6jJ-.js => diagram-UB23O5K3-2zSFzlV6.js} | 2 +-
...OR.js => ebnfDiagram-BXEA7PRR-_XPQYYfQ.js} | 2 +-
...5efF.js => erDiagram-JOGREHBK-CKTuwmcS.js} | 2 +-
...Gr.js => flowDiagram-UKHOOZJN-BtIca__0.js} | 2 +-
...-.js => ganttDiagram-PKOTCBZU-BsTpL-yb.js} | 2 +-
...s => gitGraphDiagram-DS77QQ5N-DupWgFfq.js} | 2 +-
...uy.js => infoDiagram-6WML65LV-Dl6zW5rI.js} | 2 +-
...s => ishikawaDiagram-WSZJBQD7-NBIjIwSA.js} | 2 +-
...js => journeyDiagram-NVQOT4AX-D1RTQQch.js} | 2 +-
...=> kanban-definition-27J2QSJJ-C8voMCH4.js} | 2 +-
...e-Bi7HkArr.js => mermaid.core-y4TF8bTw.js} | 8 +-
...> mindmap-definition-FAOFIHXS-oi9y3HSE.js} | 2 +-
...TmZ.js => pegDiagram-VL7TDLO6-CYzndrT-.js} | 2 +-
...1Wx.js => pieDiagram-7S7Q4E2Y-B1kVpjEf.js} | 2 +-
...s => quadrantDiagram-CIZ2JOQS-Ce1mswm1.js} | 2 +-
...s => railroadDiagram-AXF67PYL-VReUdmSC.js} | 2 +-
...> requirementDiagram-LRYGKXZP-BMkVtH2s.js} | 2 +-
....js => sankeyDiagram-W5VNT64P-gHIvLiGA.js} | 2 +-
...s => sequenceDiagram-SI44F4Z6-D6lfXAnD.js} | 2 +-
...N6.js => sizeCapture-X5ZJPWSS-DguhcNs8.js} | 2 +-
...p.js => stateDiagram-OKZ733FA-CPFdYVIx.js} | 2 +-
.../stateDiagram-v2-UEYNNEHI-CBC6HwPm.js | 1 +
.../stateDiagram-v2-UEYNNEHI-D7MXfWGS.js | 1 -
...g1pE.js => swimlanes-SLNWSIFB-DEO6uzPS.js} | 4 +-
.../swimlanesDiagram-ULZ7WXOC-C7KIOtds.js | 8 -
.../swimlanesDiagram-ULZ7WXOC-DIP88zHc.js | 8 +
... timeline-definition-Z64GVDOM-CKcwRGgc.js} | 2 +-
...kd.js => vennDiagram-T6HMQDX7-DpGQcsH1.js} | 2 +-
...js => wardleyDiagram-T6FBY63Y-CUlxVcoQ.js} | 2 +-
...js => xychartDiagram-ELKLHX3M-Cqf4o1Vh.js} | 2 +-
...{style-LQeYgvcd.css => style-DQO54G3J.css} | 2 +-
veadk/webui/index.html | 4 +-
veadk/webui/website-integration.js | 1228 +++++-----
608 files changed, 83897 insertions(+), 3760 deletions(-)
delete mode 100644 .agents/plugins/marketplace.json
create mode 100644 .trufflehog-exclude-paths.txt
create mode 100644 contracts/mpa-p0/benchmark-report.schema.json
create mode 100644 contracts/mpa-p0/compatibility-matrix.json
create mode 100644 frontend/server/mpa/__init__.py
create mode 100644 frontend/server/mpa/operations.py
create mode 100644 frontend/server/mpa/routes.py
create mode 100644 frontend/server/mpa/runtime_client.py
create mode 100644 frontend/server/mpa/service.py
create mode 100644 frontend/server/mpa/test_scenarios.py
create mode 100644 frontend/server/mpa_a2a.py
create mode 100644 frontend/server/mpa_agent_info.py
create mode 100644 frontend/server/mpa_creation.py
create mode 100644 frontend/server/mpa_cron.py
create mode 100644 frontend/server/mpa_identity_callback.py
create mode 100644 frontend/server/skills/documents.py
create mode 100644 frontend/src/adk/channels.test.ts
create mode 100644 frontend/src/adk/mpa-identity-prewarm.test.ts
create mode 100644 frontend/src/adk/mpaCreation.ts
create mode 100644 frontend/src/adk/mpaCreationImages.ts
create mode 100644 frontend/src/adk/mpaCreationResources.ts
create mode 100644 frontend/src/adk/mpaCronTasks.ts
create mode 100644 frontend/src/adk/skillDocuments.ts
create mode 100644 frontend/src/adk/wecom-sdk.d.ts
create mode 100644 frontend/src/adk/wecomAuthorization.ts
create mode 100644 frontend/src/automations/mpa-channels/MpaChannelsAutomation.css
create mode 100644 frontend/src/automations/mpa-channels/MpaChannelsAutomation.tsx
create mode 100644 frontend/src/automations/mpa-channels/MpaChannelsIcon.tsx
create mode 100644 frontend/src/automations/mpaChannels.ts
create mode 100644 frontend/src/cronjobs/MpaCronTasks.css
create mode 100644 frontend/src/cronjobs/MpaCronTasks.tsx
create mode 100644 frontend/src/cronjobs/MpaTaskDetail.tsx
create mode 100644 frontend/src/cronjobs/MpaTaskEditor.tsx
create mode 100644 frontend/src/cronjobs/MpaTaskIcons.tsx
create mode 100644 frontend/src/cronjobs/mpaSchedule.ts
create mode 100644 frontend/src/transcriptReconcile.ts
create mode 100644 frontend/src/ui/RuntimeChannels.css
create mode 100644 frontend/src/ui/RuntimeChannels.tsx
create mode 100644 frontend/src/ui/SandboxFileLink.tsx
create mode 100644 frontend/src/ui/mpa-agent-info/MpaAgentInfoRail.tsx
create mode 100644 frontend/src/ui/mpa-agent-info/SkillDocumentEditor.css
create mode 100644 frontend/src/ui/mpa-agent-info/SkillDocumentEditor.tsx
create mode 100644 frontend/src/ui/mpa-create/MpaCreateDialog.css
create mode 100644 frontend/src/ui/mpa-create/MpaCreateDialog.tsx
create mode 100644 frontend/src/ui/tool-activity/ToolActivityCard.tsx
create mode 100644 frontend/src/ui/tool-activity/ToolActivityIcons.tsx
create mode 100644 frontend/src/ui/tool-activity/model.ts
create mode 100644 frontend/src/ui/tool-activity/tool-activity.css
create mode 100644 frontend/tests/a2aWaiting.test.tsx
create mode 100644 frontend/tests/mpaA2aTranscript.test.mjs
create mode 100644 frontend/tests/mpaAgentInfoRail.test.tsx
create mode 100644 frontend/tests/mpaChannelsAutomation.test.tsx
create mode 100644 frontend/tests/mpaContentPreservation.test.mjs
create mode 100644 frontend/tests/mpaCreation.test.tsx
create mode 100644 frontend/tests/mpaCronTasks.test.tsx
create mode 100644 frontend/tests/mpaManagement.test.tsx
create mode 100644 frontend/tests/mpaResponseGrouping.test.mjs
create mode 100644 frontend/tests/mpaSessionConfigUi.test.mjs
create mode 100644 frontend/tests/mpaSessionExecutionConfigClient.test.mjs
create mode 100644 frontend/tests/mpaSessionProtocol.test.mjs
create mode 100644 frontend/tests/runtimeChannels.test.tsx
create mode 100644 frontend/tests/runtimeControlAndEnvironmentState.test.mjs
create mode 100644 frontend/tests/sandboxDownload.test.tsx
create mode 100644 frontend/tests/sandboxDownloadClient.test.ts
create mode 100644 frontend/tests/skillDocumentEditor.test.tsx
create mode 100644 frontend/tests/toolActivityModel.test.mjs
create mode 100644 frontend/vitest.a2a-wait.config.ts
create mode 100644 frontend/vitest.mpa-cron.config.ts
create mode 100644 frontend/vitest.sandbox-download.config.ts
create mode 100644 openspec/changes/mpa-agent-studio-a2a-streaming/proposal.md
create mode 100644 openspec/changes/mpa-agent-studio-a2a-streaming/tasks.md
create mode 100644 openspec/changes/mpa-p0-productionization/proposal.md
create mode 100644 openspec/changes/mpa-p0-productionization/tasks.md
create mode 100644 openspec/changes/mpa-runtime-integration-hardening/proposal.md
create mode 100644 openspec/changes/mpa-runtime-integration-hardening/tasks.md
create mode 100644 prd-spec/README.md
create mode 100644 prd-spec/README.zh.md
create mode 100644 prd-spec/bugfixes/2026-09-12-a2a-first-event-heartbeat.md
create mode 100644 prd-spec/bugfixes/2026-09-12-a2a-first-event-heartbeat.zh.md
create mode 100644 prd-spec/bugfixes/2026-09-23-aidap-workspace-readiness.md
create mode 100644 prd-spec/bugfixes/2026-09-23-aidap-workspace-readiness.zh.md
create mode 100644 prd-spec/bugfixes/2026-09-23-mpa-a2a-discovery-default.md
create mode 100644 prd-spec/bugfixes/2026-09-23-mpa-a2a-discovery-default.zh.md
create mode 100644 prd-spec/bugfixes/2026-09-23-mpa-registry-tls-url.md
create mode 100644 prd-spec/bugfixes/2026-09-23-mpa-registry-tls-url.zh.md
create mode 100644 prd-spec/bugfixes/a2a-long-running/2026-09-15-wait.md
create mode 100644 prd-spec/bugfixes/a2a-long-running/2026-09-15-wait.zh.md
create mode 100644 prd-spec/bugfixes/channel-group-actions/2026-09-15-channel-group-actions.md
create mode 100644 prd-spec/bugfixes/channel-group-actions/2026-09-15-channel-group-actions.zh.md
create mode 100644 prd-spec/bugfixes/channel-qr-lifetime/2026-09-15-channel-qr-lifetime.md
create mode 100644 prd-spec/bugfixes/channel-qr-lifetime/2026-09-15-channel-qr-lifetime.zh.md
create mode 100644 prd-spec/bugfixes/chat-model-dropdown/2026-09-14-chat-model-dropdown.md
create mode 100644 prd-spec/bugfixes/chat-model-dropdown/2026-09-14-chat-model-dropdown.zh.md
create mode 100644 prd-spec/bugfixes/history-turn-identity/2026-09-17-replay.md
create mode 100644 prd-spec/bugfixes/history-turn-identity/2026-09-17-replay.zh.md
create mode 100644 prd-spec/bugfixes/mpa-agent-filtering/2026-09-14-mpa-agent-filtering.md
create mode 100644 prd-spec/bugfixes/mpa-agent-filtering/2026-09-14-mpa-agent-filtering.zh.md
create mode 100644 prd-spec/bugfixes/mpa-channel-visibility/2026-09-15-mpa-channel-visibility.md
create mode 100644 prd-spec/bugfixes/mpa-channel-visibility/2026-09-15-mpa-channel-visibility.zh.md
create mode 100644 prd-spec/bugfixes/mpa-create-navigation/2026-09-30-explicit-new-request.md
create mode 100644 prd-spec/bugfixes/mpa-create-navigation/2026-09-30-explicit-new-request.zh.md
create mode 100644 prd-spec/bugfixes/mpa-create-navigation/2026-09-30-recover-step-navigation.md
create mode 100644 prd-spec/bugfixes/mpa-create-navigation/2026-09-30-recover-step-navigation.zh.md
create mode 100644 prd-spec/bugfixes/mpa-default-jwt/2026-09-15-default-jwt.md
create mode 100644 prd-spec/bugfixes/mpa-default-jwt/2026-09-15-default-jwt.zh.md
create mode 100644 prd-spec/bugfixes/mpa-default-jwt/2026-09-30-studio-key-auth-default.md
create mode 100644 prd-spec/bugfixes/mpa-default-jwt/2026-09-30-studio-key-auth-default.zh.md
create mode 100644 prd-spec/bugfixes/mpa-managed-workload-identity/2026-09-29-ensure-managed-workload-identity.md
create mode 100644 prd-spec/bugfixes/mpa-managed-workload-identity/2026-09-29-ensure-managed-workload-identity.zh.md
create mode 100644 prd-spec/bugfixes/mpa-runtime-fresh-command/2026-09-29-set-fresh-runtime-command.md
create mode 100644 prd-spec/bugfixes/mpa-runtime-fresh-command/2026-09-29-set-fresh-runtime-command.zh.md
create mode 100644 prd-spec/bugfixes/mpa-runtime-initial-tool/2026-09-29-runtime-initial-tool-attachment.md
create mode 100644 prd-spec/bugfixes/mpa-runtime-initial-tool/2026-09-29-runtime-initial-tool-attachment.zh.md
create mode 100644 prd-spec/bugfixes/mpa-runtime-integration/2026-09-12-mpa-runtime-integration-hardening.md
create mode 100644 prd-spec/bugfixes/mpa-runtime-integration/2026-09-12-mpa-runtime-integration-hardening.zh.md
create mode 100644 prd-spec/bugfixes/mpa-runtime-integration/2026-09-13-dev-loop-execution.md
create mode 100644 prd-spec/bugfixes/mpa-runtime-integration/2026-09-13-dev-loop-execution.zh.md
create mode 100644 prd-spec/bugfixes/mpa-runtime-integration/2026-09-13-dev-loop-review.md
create mode 100644 prd-spec/bugfixes/mpa-runtime-integration/2026-09-13-dev-loop-review.zh.md
create mode 100644 prd-spec/bugfixes/mpa-runtime-integration/2026-09-13-functional-validation-cases.md
create mode 100644 prd-spec/bugfixes/mpa-runtime-integration/2026-09-13-functional-validation-cases.zh.md
create mode 100644 prd-spec/bugfixes/mpa-runtime-mcp-secret/2026-10-07-persistent-mcp-secret.md
create mode 100644 prd-spec/bugfixes/mpa-runtime-mcp-secret/2026-10-07-persistent-mcp-secret.zh.md
create mode 100644 prd-spec/bugfixes/mpa-runtime-reference-command/2026-09-29-preserve-runtime-command.md
create mode 100644 prd-spec/bugfixes/mpa-runtime-reference-command/2026-09-29-preserve-runtime-command.zh.md
create mode 100644 prd-spec/bugfixes/mpa-sandbox-event-identity/2026-09-29-typed-event-dedup.md
create mode 100644 prd-spec/bugfixes/mpa-sandbox-event-identity/2026-09-29-typed-event-dedup.zh.md
create mode 100644 prd-spec/bugfixes/mpa-shared-network-bootstrap/2026-09-28-registry-owned-network.md
create mode 100644 prd-spec/bugfixes/mpa-shared-network-bootstrap/2026-09-28-registry-owned-network.zh.md
create mode 100644 prd-spec/bugfixes/mpa-test-types/2026-09-21-mpa-test-types.md
create mode 100644 prd-spec/bugfixes/mpa-test-types/2026-09-21-mpa-test-types.zh.md
create mode 100644 prd-spec/bugfixes/mpa-worker-naming/2026-09-28-agent-derived-worker-name.md
create mode 100644 prd-spec/bugfixes/mpa-worker-naming/2026-09-28-agent-derived-worker-name.zh.md
create mode 100644 prd-spec/bugfixes/mpa-worker-retry/2026-09-20-worker-metadata-visibility.md
create mode 100644 prd-spec/bugfixes/mpa-worker-retry/2026-09-20-worker-metadata-visibility.zh.md
create mode 100644 prd-spec/bugfixes/mpa-worker-retry/2026-09-20-worker-retry.md
create mode 100644 prd-spec/bugfixes/mpa-worker-retry/2026-09-20-worker-retry.zh.md
create mode 100644 prd-spec/bugfixes/mpa-worker-retry/2026-09-30-worker-metadata-deadline.md
create mode 100644 prd-spec/bugfixes/mpa-worker-retry/2026-09-30-worker-metadata-deadline.zh.md
create mode 100644 prd-spec/bugfixes/runtime-disable-db-spans/2026-09-17-default-db-spans.md
create mode 100644 prd-spec/bugfixes/runtime-disable-db-spans/2026-09-17-default-db-spans.zh.md
create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-mpa-a2a-final.md
create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-mpa-a2a-final.zh.md
create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-mpa-a2a-reasoning.md
create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-mpa-a2a-reasoning.zh.md
create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-mpa-direct-answer-reasoning.md
create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-mpa-direct-answer-reasoning.zh.md
create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-mpa-reasoning-snapshots.md
create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-mpa-reasoning-snapshots.zh.md
create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-mpa-wrapper-preview.md
create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-mpa-wrapper-preview.zh.md
create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-preserve-followup-content.md
create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-preserve-followup-content.zh.md
create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-preserve-sandbox-reasoning.md
create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-preserve-sandbox-reasoning.zh.md
create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-studio-codex-commentary-dedup.md
create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-studio-codex-commentary-dedup.zh.md
create mode 100644 prd-spec/bugfixes/studio-cron-e2e/2026-09-15-integration.md
create mode 100644 prd-spec/bugfixes/studio-cron-e2e/2026-09-15-integration.zh.md
create mode 100644 prd-spec/bugfixes/studio-mpa-a2a-preference/2026-09-30-prefer-a2a.md
create mode 100644 prd-spec/bugfixes/studio-mpa-a2a-preference/2026-09-30-prefer-a2a.zh.md
create mode 100644 prd-spec/bugfixes/studio-mpa-aidap-permissions/2026-09-23-complete-aidap-runtime-policy.md
create mode 100644 prd-spec/bugfixes/studio-mpa-aidap-permissions/2026-09-23-complete-aidap-runtime-policy.zh.md
create mode 100644 prd-spec/bugfixes/studio-mpa-aidap-permissions/2026-09-23-complete-mpa-cloud-runtime-policy.md
create mode 100644 prd-spec/bugfixes/studio-mpa-aidap-permissions/2026-09-23-complete-mpa-cloud-runtime-policy.zh.md
create mode 100644 prd-spec/bugfixes/studio-mpa-artifact-entry/2026-09-30-hide-artifact-entry.md
create mode 100644 prd-spec/bugfixes/studio-mpa-artifact-entry/2026-09-30-hide-artifact-entry.zh.md
create mode 100644 prd-spec/bugfixes/studio-mpa-creation/2026-09-24-start-another-agent-after-failure.md
create mode 100644 prd-spec/bugfixes/studio-mpa-creation/2026-09-24-start-another-agent-after-failure.zh.md
create mode 100644 prd-spec/bugfixes/studio-mpa-dialog-theme/2026-09-20-dialog-theme.md
create mode 100644 prd-spec/bugfixes/studio-mpa-dialog-theme/2026-09-20-dialog-theme.zh.md
create mode 100644 prd-spec/bugfixes/studio-mpa-profile-errors/2026-09-20-studio-mpa-profile-errors.md
create mode 100644 prd-spec/bugfixes/studio-mpa-profile-errors/2026-09-20-studio-mpa-profile-errors.zh.md
create mode 100644 prd-spec/bugfixes/studio-mpa-writable-state/2026-09-23-use-temporary-state.md
create mode 100644 prd-spec/bugfixes/studio-mpa-writable-state/2026-09-23-use-temporary-state.zh.md
create mode 100644 prd-spec/bugfixes/studio-openviking-wheel/2026-10-07-align-sdk-wheel.md
create mode 100644 prd-spec/bugfixes/studio-openviking-wheel/2026-10-07-align-sdk-wheel.zh.md
create mode 100644 prd-spec/bugfixes/studio-session-protocol/2026-09-22-a2a-session-routing.md
create mode 100644 prd-spec/bugfixes/studio-session-protocol/2026-09-22-a2a-session-routing.zh.md
create mode 100644 prd-spec/bugfixes/studio-startup-assets/2026-09-22-studio-startup-assets.md
create mode 100644 prd-spec/bugfixes/studio-startup-assets/2026-09-22-studio-startup-assets.zh.md
create mode 100644 prd-spec/bugfixes/studio-trace-pagination/2026-09-17-session-trace.md
create mode 100644 prd-spec/bugfixes/studio-trace-pagination/2026-09-17-session-trace.zh.md
create mode 100644 prd-spec/bugfixes/studio-webui-build-integrity/2026-09-23-rebuild-corrupted-assets.md
create mode 100644 prd-spec/bugfixes/studio-webui-build-integrity/2026-09-23-rebuild-corrupted-assets.zh.md
create mode 100644 prd-spec/bugfixes/yaml-secret-guard/2026-09-23-yaml-secret-guard.md
create mode 100644 prd-spec/bugfixes/yaml-secret-guard/2026-09-23-yaml-secret-guard.zh.md
create mode 100644 prd-spec/features/channel-auth-methods/2026-09-15-channel-auth-methods.md
create mode 100644 prd-spec/features/channel-auth-methods/2026-09-15-channel-auth-methods.zh.md
create mode 100644 prd-spec/features/channel-configuration-modes/2026-09-15-channel-configuration-modes.md
create mode 100644 prd-spec/features/channel-configuration-modes/2026-09-15-channel-configuration-modes.zh.md
create mode 100644 prd-spec/features/channel-qr-regeneration/2026-09-15-channel-qr-regeneration.md
create mode 100644 prd-spec/features/channel-qr-regeneration/2026-09-15-channel-qr-regeneration.zh.md
create mode 100644 prd-spec/features/message-channel-binding/2026-09-15-message-channel-binding.md
create mode 100644 prd-spec/features/message-channel-binding/2026-09-15-message-channel-binding.zh.md
create mode 100644 prd-spec/features/message-channel-workspace/2026-09-15-message-channel-workspace.md
create mode 100644 prd-spec/features/message-channel-workspace/2026-09-15-message-channel-workspace.zh.md
create mode 100644 prd-spec/features/mpa-agent-info-rail/2026-09-18-mpa-agent-info-rail.md
create mode 100644 prd-spec/features/mpa-agent-info-rail/2026-09-18-mpa-agent-info-rail.zh.md
create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-10-mpa-agent-oneclick-provision-design.md
create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-10-mpa-agent-oneclick-provision-design.zh.md
create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-11-mpa-agent-oneclick-provision-verification-cases.md
create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-11-mpa-agent-oneclick-provision-verification-cases.zh.md
create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-11-mpa-agent-provision-orchestration-alignment-design.md
create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-11-mpa-agent-provision-orchestration-alignment-design.zh.md
create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-12-mpa-agent-studio-a2a-streaming-design.md
create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-12-mpa-agent-studio-a2a-streaming-design.zh.md
create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-12-mpa-agent-studio-a2a-streaming-verification-cases.md
create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-12-mpa-agent-studio-a2a-streaming-verification-cases.zh.md
create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-20-studio-mpa-creation.md
create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-20-studio-mpa-creation.zh.md
create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-23-mpa-tos-output-mount.md
create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-23-mpa-tos-output-mount.zh.md
create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-23-studio-builtin-mpa-profile.md
create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-23-studio-builtin-mpa-profile.zh.md
create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-23-studio-mpa-openviking-key.md
create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-23-studio-mpa-openviking-key.zh.md
create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-23-studio-mpa-three-step-creation.md
create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-23-studio-mpa-three-step-creation.zh.md
create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-10-09-named-studio-creation.md
create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-10-09-named-studio-creation.zh.md
create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/evidence/2026-09-11-code-review.zh.md
create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/evidence/2026-09-11-verification-execution.zh.md
create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/mpa-create.config.example.yaml
create mode 100644 prd-spec/features/mpa-channels-automation/2026-09-16-mpa-channels-automation.md
create mode 100644 prd-spec/features/mpa-channels-automation/2026-09-16-mpa-channels-automation.zh.md
create mode 100644 prd-spec/features/mpa-feishu-channels/2026-09-15-mpa-feishu-channels.md
create mode 100644 prd-spec/features/mpa-feishu-channels/2026-09-15-mpa-feishu-channels.zh.md
create mode 100644 prd-spec/features/mpa-p0-productionization/2026-09-15-dev-loop-spec-review.md
create mode 100644 prd-spec/features/mpa-p0-productionization/2026-09-15-dev-loop-spec-review.zh.md
create mode 100644 prd-spec/features/mpa-p0-productionization/2026-09-15-functional-validation-cases.md
create mode 100644 prd-spec/features/mpa-p0-productionization/2026-09-15-functional-validation-cases.zh.md
create mode 100644 prd-spec/features/mpa-p0-productionization/2026-09-15-m0-evidence.md
create mode 100644 prd-spec/features/mpa-p0-productionization/2026-09-15-m0-evidence.zh.md
create mode 100644 prd-spec/features/mpa-p0-productionization/2026-09-15-mpa-p0-productionization-design.md
create mode 100644 prd-spec/features/mpa-p0-productionization/2026-09-15-mpa-p0-productionization-design.zh.md
create mode 100644 prd-spec/features/mpa-p0-productionization/browser-cases.md
create mode 100644 prd-spec/features/mpa-p0-productionization/browser-cases.zh.md
create mode 100644 prd-spec/features/mpa-response-grouping/2026-09-22-mpa-response-grouping.md
create mode 100644 prd-spec/features/mpa-response-grouping/2026-09-22-mpa-response-grouping.zh.md
create mode 100644 prd-spec/features/mpa-serverless-pg/2026-09-21-deployment-account-pg.md
create mode 100644 prd-spec/features/mpa-serverless-pg/2026-09-21-deployment-account-pg.zh.md
create mode 100644 prd-spec/features/mpa-space-scoped-resources/2026-09-23-auto-pg-workspaces.md
create mode 100644 prd-spec/features/mpa-space-scoped-resources/2026-09-23-auto-pg-workspaces.zh.md
create mode 100644 prd-spec/features/mpa-space-scoped-resources/2026-09-23-space-scoped-manual-pg.md
create mode 100644 prd-spec/features/mpa-space-scoped-resources/2026-09-23-space-scoped-manual-pg.zh.md
create mode 100644 prd-spec/features/mpa-space-scoped-resources/2026-09-23-two-pg-workspaces.md
create mode 100644 prd-spec/features/mpa-space-scoped-resources/2026-09-23-two-pg-workspaces.zh.md
create mode 100644 prd-spec/features/mpa-studio-control-plane/2026-09-13-mpa-studio-control-plane-design.md
create mode 100644 prd-spec/features/mpa-studio-control-plane/2026-09-13-mpa-studio-control-plane-design.zh.md
create mode 100644 prd-spec/features/mpa-studio-control-plane/2026-09-13-mpa-studio-control-plane-implementation-plan.md
create mode 100644 prd-spec/features/mpa-studio-control-plane/2026-09-13-mpa-studio-control-plane-implementation-plan.zh.md
create mode 100644 prd-spec/features/mpa-studio-workload-identity/2026-09-20-mpa-studio-workload-identity.md
create mode 100644 prd-spec/features/mpa-studio-workload-identity/2026-09-20-mpa-studio-workload-identity.zh.md
create mode 100644 prd-spec/features/studio-mpa-cron-tasks/2026-09-15-mono-task-management.md
create mode 100644 prd-spec/features/studio-mpa-cron-tasks/2026-09-15-mono-task-management.zh.md
create mode 100644 prd-spec/features/studio-mpa-cron-tasks/2026-09-15-remove-agent-input.md
create mode 100644 prd-spec/features/studio-mpa-cron-tasks/2026-09-15-remove-agent-input.zh.md
create mode 100644 prd-spec/features/studio-mpa-cron-tasks/README.md
create mode 100644 prd-spec/features/studio-mpa-cron-tasks/README.zh.md
create mode 100644 prd-spec/features/studio-mpa-identity-reuse/2026-09-23-deploy-identity-reuse.md
create mode 100644 prd-spec/features/studio-mpa-identity-reuse/2026-09-23-deploy-identity-reuse.zh.md
create mode 100644 prd-spec/features/studio-mpa-login/2026-09-23-studio-mpa-prewarm.md
create mode 100644 prd-spec/features/studio-mpa-login/2026-09-23-studio-mpa-prewarm.zh.md
create mode 100644 prd-spec/features/studio-mpa-main-integration/2026-09-20-main-integration.md
create mode 100644 prd-spec/features/studio-mpa-main-integration/2026-09-20-main-integration.zh.md
create mode 100644 prd-spec/features/studio-mpa-source-filter/2026-09-28-studio-mpa-source-filter.md
create mode 100644 prd-spec/features/studio-mpa-source-filter/2026-09-28-studio-mpa-source-filter.zh.md
create mode 100644 prd-spec/features/studio-oidc-id-token/2026-09-23-retain-id-token.md
create mode 100644 prd-spec/features/studio-oidc-id-token/2026-09-23-retain-id-token.zh.md
create mode 100644 prd-spec/features/studio-sandbox-download/2026-09-17-history-download.md
create mode 100644 prd-spec/features/studio-sandbox-download/2026-09-17-history-download.zh.md
create mode 100644 prd-spec/features/studio-tool-activity-visualization/2026-09-12-studio-tool-activity-visualization-design.md
create mode 100644 prd-spec/features/studio-tool-activity-visualization/2026-09-12-studio-tool-activity-visualization-design.zh.md
create mode 100755 scripts/benchmark-mpa-p0.py
create mode 100644 scripts/mpa_p0_contract.py
create mode 100644 scripts/mpa_p0_e2e.py
create mode 100755 scripts/mpa_p0_vc21_driver.py
create mode 100755 scripts/run-mpa-p0-browser-fixture.sh
create mode 100755 scripts/scan_yaml_secrets.py
create mode 100755 scripts/verify-mpa-p0-contract.sh
create mode 100755 scripts/verify-mpa-p0-e2e.sh
create mode 100644 specs/README.md
create mode 100644 specs/README.zh.md
create mode 100644 specs/mpa-channels/README.md
create mode 100644 specs/mpa-channels/README.zh.md
create mode 100644 specs/mpa-runtime-control/README.md
create mode 100644 specs/mpa-runtime-control/README.zh.md
create mode 100644 specs/mpa-runtime-provisioning/README.md
create mode 100644 specs/mpa-runtime-provisioning/README.zh.md
create mode 100644 specs/studio-mpa-control-plane/README.md
create mode 100644 specs/studio-mpa-control-plane/README.zh.md
create mode 100644 specs/studio-mpa-creation/README.md
create mode 100644 specs/studio-mpa-creation/README.zh.md
create mode 100644 specs/studio-mpa-cron-tasks/README.md
create mode 100644 specs/studio-mpa-cron-tasks/README.zh.md
create mode 100644 specs/studio-mpa-prewarm/README.md
create mode 100644 specs/studio-mpa-prewarm/README.zh.md
create mode 100644 specs/studio-oauth-session/README.md
create mode 100644 specs/studio-oauth-session/README.zh.md
create mode 100644 specs/studio-runtime-diagnostics/README.md
create mode 100644 specs/studio-runtime-diagnostics/README.zh.md
create mode 100644 specs/studio-sandbox-download/README.md
create mode 100644 specs/studio-sandbox-download/README.zh.md
create mode 100644 specs/studio-skill-document/README.md
create mode 100644 specs/studio-skill-document/README.zh.md
create mode 100644 specs/studio-tool-activity/README.md
create mode 100644 specs/studio-tool-activity/README.zh.md
create mode 100644 tests/cli/test_cli_mpa.py
create mode 100644 tests/cli/test_cli_mpa_control.py
create mode 100644 tests/cli/test_mpa_a2a_url.py
create mode 100644 tests/cli/test_runtime_a2a_stream.py
create mode 100644 tests/cli/test_studio_dependencies.py
create mode 100644 tests/cli/test_studio_tos_sdk_dependency.py
create mode 100644 tests/cli/test_studio_trace_pagination.py
create mode 100644 tests/frontend/server/mpa/test_agent_operations_service.py
create mode 100644 tests/frontend/server/mpa/test_mpa_module.py
create mode 100644 tests/frontend/server/mpa/test_operations_repository.py
create mode 100644 tests/frontend/server/mpa/test_runtime_profile_client.py
create mode 100644 tests/frontend/server/mpa/test_runtime_profile_routes.py
create mode 100644 tests/frontend/server/mpa/test_scenario_harness.py
create mode 100644 tests/frontend/server/test_mpa_agent_info.py
create mode 100644 tests/frontend/server/test_mpa_cron.py
create mode 100644 tests/frontend/server/test_mpa_identity_callback.py
create mode 100644 tests/integrations/mpa_managed/__init__.py
create mode 100644 tests/integrations/mpa_managed/fakes_deployment_network.py
create mode 100644 tests/integrations/mpa_managed/test_admin_database.py
create mode 100644 tests/integrations/mpa_managed/test_agent_deployment.py
create mode 100644 tests/integrations/mpa_managed/test_auto_pg.py
create mode 100644 tests/integrations/mpa_managed/test_config.py
create mode 100644 tests/integrations/mpa_managed/test_creation_images.py
create mode 100644 tests/integrations/mpa_managed/test_deployment_database_unit.py
create mode 100644 tests/integrations/mpa_managed/test_deployment_network.py
create mode 100644 tests/integrations/mpa_managed/test_deployment_network_cloud.py
create mode 100644 tests/integrations/mpa_managed/test_gateway.py
create mode 100644 tests/integrations/mpa_managed/test_gateway_cloud.py
create mode 100644 tests/integrations/mpa_managed/test_named_creation.py
create mode 100644 tests/integrations/mpa_managed/test_registry_url.py
create mode 100644 tests/integrations/mpa_managed/test_routes.py
create mode 100644 tests/integrations/mpa_managed/test_runtime_deployment_edges.py
create mode 100644 tests/integrations/mpa_managed/test_service.py
create mode 100644 tests/integrations/mpa_managed/test_studio_shared_resources.py
create mode 100644 tests/integrations/mpa_managed/test_tags.py
create mode 100644 tests/integrations/mpa_managed/test_tasks.py
create mode 100644 tests/integrations/mpa_managed/test_worker.py
create mode 100644 tests/integrations/mpa_managed/test_worker_metadata.py
create mode 100644 tests/integrations/mpa_managed/test_worker_recovery.py
create mode 100644 tests/integrations/test_mpa_compatibility_manifest.py
create mode 100644 tests/integrations/test_mpa_control_plane_client.py
create mode 100644 tests/integrations/test_mpa_identity.py
create mode 100644 tests/integrations/test_mpa_meta_seed.py
create mode 100644 tests/integrations/test_mpa_p0_benchmark.py
create mode 100644 tests/integrations/test_mpa_p0_e2e_runner.py
create mode 100644 tests/integrations/test_mpa_p0_vc21_driver.py
create mode 100644 tests/integrations/test_mpa_provision_env.py
create mode 100644 tests/integrations/test_mpa_runtime.py
create mode 100644 tests/integrations/test_mpa_skill_space.py
create mode 100644 tests/integrations/test_mpa_tool.py
create mode 100644 tests/integrations/test_mpa_verify.py
create mode 100644 tests/integrations/test_ve_faas_deploy_image_key_auth.py
create mode 100644 tests/scripts/test_scan_yaml_secrets.py
create mode 100644 tests/test_mpa_channel_proxy_policy.py
create mode 100644 veadk/cli/cli_mpa.py
create mode 100644 veadk/cli/cli_mpa_admin.py
create mode 100644 veadk/cli/cli_mpa_control.py
create mode 100644 veadk/cli/mpa_p0_contract.py
create mode 100644 veadk/cli/runtime_a2a_stream.py
create mode 100644 veadk/integrations/mpa/__init__.py
create mode 100644 veadk/integrations/mpa/channel_proxy.py
create mode 100644 veadk/integrations/mpa/control_plane_client.py
create mode 100644 veadk/integrations/mpa/managed/README.md
create mode 100644 veadk/integrations/mpa/managed/README.zh.md
create mode 100644 veadk/integrations/mpa/managed/__init__.py
create mode 100644 veadk/integrations/mpa/managed/admin_database.py
create mode 100644 veadk/integrations/mpa/managed/config.py
create mode 100644 veadk/integrations/mpa/managed/credentials.py
create mode 100644 veadk/integrations/mpa/managed/database.py
create mode 100644 veadk/integrations/mpa/managed/diagnostics.py
create mode 100644 veadk/integrations/mpa/managed/gateway.py
create mode 100644 veadk/integrations/mpa/managed/gateway_cloud.py
create mode 100644 veadk/integrations/mpa/managed/metadata.py
create mode 100644 veadk/integrations/mpa/managed/network.py
create mode 100644 veadk/integrations/mpa/managed/network_cloud.py
create mode 100644 veadk/integrations/mpa/managed/pg_bootstrap.py
create mode 100644 veadk/integrations/mpa/managed/pg_cloud.py
create mode 100644 veadk/integrations/mpa/managed/registry.py
create mode 100644 veadk/integrations/mpa/managed/runner.py
create mode 100644 veadk/integrations/mpa/managed/runtime.py
create mode 100644 veadk/integrations/mpa/managed/service.py
create mode 100644 veadk/integrations/mpa/managed/skills.py
create mode 100644 veadk/integrations/mpa/managed/studio_profile.py
create mode 100644 veadk/integrations/mpa/managed/tasks.py
create mode 100644 veadk/integrations/mpa/managed/worker.py
create mode 100644 veadk/integrations/mpa/mpa_identity.py
create mode 100644 veadk/integrations/mpa/mpa_meta_seed.py
create mode 100644 veadk/integrations/mpa/mpa_provision.py
create mode 100644 veadk/integrations/mpa/mpa_runtime.py
create mode 100644 veadk/integrations/mpa/mpa_skill_space.py
create mode 100644 veadk/integrations/mpa/mpa_tool.py
create mode 100644 veadk/integrations/mpa/mpa_verify.py
create mode 100644 veadk/integrations/mpa/tags.py
create mode 100644 veadk/webui/assets/app/index-BNPwMQXG.js
delete mode 100644 veadk/webui/assets/app/index-BcpSnsFp.js
rename veadk/webui/assets/chunks/{CodeDiffEditor-BQpKN2_Y.js => CodeDiffEditor-DA-6OJrJ.js} (99%)
rename veadk/webui/assets/chunks/{FileExplorerEditor-D8Z1VzGB.js => FileExplorerEditor-B8Ub9k9b.js} (96%)
rename veadk/webui/assets/chunks/{MarkdownPromptEditor-B5fLU-G9.js => MarkdownPromptEditor-D6ctFDfi.js} (99%)
rename veadk/webui/assets/chunks/{QuickAgentCreateDialog-6X2BnBqv.js => QuickAgentCreateDialog-04pJjJQJ.js} (97%)
rename veadk/webui/assets/chunks/{arc-CE0biH39.js => arc-fsSJ4XQX.js} (98%)
rename veadk/webui/assets/chunks/{channel-CknSfctO.js => channel-Bbp-1bKw.js} (53%)
rename veadk/webui/assets/chunks/{index.es-YSRnAMpa.js => index.es-JsJulYHK.js} (99%)
rename veadk/webui/assets/chunks/{jspdf.es.min-CmNSIheP.js => jspdf.es.min-aFbI3tiR.js} (99%)
rename veadk/webui/assets/chunks/{linear-D7YqIViL.js => linear-Dd6OAXqp.js} (98%)
delete mode 100644 veadk/webui/assets/styles/index-CUN-lyX5.css
create mode 100644 veadk/webui/assets/styles/index-_lutWX9f.css
rename veadk/webui/assets/visualizations/mermaid/{abnfDiagram-N423BO3Z-C9m-vNen.js => abnfDiagram-N423BO3Z-RhYMe2zl.js} (86%)
rename veadk/webui/assets/visualizations/mermaid/{architectureDiagram-T3A2C74G-DlHQ6hTt.js => architectureDiagram-T3A2C74G-C47wDTji.js} (99%)
rename veadk/webui/assets/visualizations/mermaid/{blockDiagram-VBNYF7ZC-Df9FWTd0.js => blockDiagram-VBNYF7ZC-BAJbaVfQ.js} (99%)
rename veadk/webui/assets/visualizations/mermaid/{c4Diagram-5PPSVZJV-h7qFgw8b.js => c4Diagram-5PPSVZJV-CJepdaIg.js} (99%)
rename veadk/webui/assets/visualizations/mermaid/{chunk-2GRJ4B5K-Dbxo18XS.js => chunk-2GRJ4B5K-CvII-2ZL.js} (93%)
rename veadk/webui/assets/visualizations/mermaid/{chunk-2Q5K7J3B-DWO0x-gt.js => chunk-2Q5K7J3B-nvFjhS5b.js} (66%)
rename veadk/webui/assets/visualizations/mermaid/{chunk-5RXB4S5H-BThWW3Mz.js => chunk-5RXB4S5H-CY1YEJD3.js} (99%)
rename veadk/webui/assets/visualizations/mermaid/{chunk-5VM5RSS4-spyYz9_w.js => chunk-5VM5RSS4-BDBKcK2Z.js} (83%)
rename veadk/webui/assets/visualizations/mermaid/{chunk-6Q2QTUOP-D2AB22Qb.js => chunk-6Q2QTUOP-zEQK1XLO.js} (99%)
rename veadk/webui/assets/visualizations/mermaid/{chunk-GF5L2VYU-WqpbbvGd.js => chunk-GF5L2VYU-Cv1ZgaH8.js} (99%)
rename veadk/webui/assets/visualizations/mermaid/{chunk-JWPE2WC7-saJxckiD.js => chunk-JWPE2WC7-DD-BFVC6.js} (78%)
rename veadk/webui/assets/visualizations/mermaid/{chunk-KBJHAD2P-COhTnu0H.js => chunk-KBJHAD2P-DtNDDRN1.js} (87%)
rename veadk/webui/assets/visualizations/mermaid/{chunk-RYQCIY6F-DYejWv7S.js => chunk-RYQCIY6F-DL3iLUXF.js} (99%)
rename veadk/webui/assets/visualizations/mermaid/{chunk-XXDRQBXY-1cbgwOld.js => chunk-XXDRQBXY-B8qTF1qQ.js} (53%)
delete mode 100644 veadk/webui/assets/visualizations/mermaid/classDiagram-JCYQIIEL-DSyq-_Sn.js
create mode 100644 veadk/webui/assets/visualizations/mermaid/classDiagram-JCYQIIEL-DtPkX3wA.js
delete mode 100644 veadk/webui/assets/visualizations/mermaid/classDiagram-v2-OCEON4UE-DSyq-_Sn.js
create mode 100644 veadk/webui/assets/visualizations/mermaid/classDiagram-v2-OCEON4UE-DtPkX3wA.js
rename veadk/webui/assets/visualizations/mermaid/{cose-bilkent-JH36ORCC-BoIT7_DN.js => cose-bilkent-JH36ORCC-BNlKHYzG.js} (99%)
rename veadk/webui/assets/visualizations/mermaid/{cynefin-OW5HDTMX-BbIfZIRn.js => cynefin-OW5HDTMX-DBq9B5VP.js} (99%)
rename veadk/webui/assets/visualizations/mermaid/{cynefinDiagram-MW4NZA55-C5jnTUMv.js => cynefinDiagram-MW4NZA55-DScg9_Ly.js} (98%)
rename veadk/webui/assets/visualizations/mermaid/{dagre-VZM6K2ZE-C1L5FQ8P.js => dagre-VZM6K2ZE-shxLHn3p.js} (97%)
rename veadk/webui/assets/visualizations/mermaid/{diagram-7IWD3JNH-B72YFcSV.js => diagram-7IWD3JNH-C3mTV4cL.js} (96%)
rename veadk/webui/assets/visualizations/mermaid/{diagram-B4RE2ZJO-MAr1x0nn.js => diagram-B4RE2ZJO-D66216sR.js} (97%)
rename veadk/webui/assets/visualizations/mermaid/{diagram-LBJQPF4R-Rjs8ciIO.js => diagram-LBJQPF4R-BZA2jWUX.js} (94%)
rename veadk/webui/assets/visualizations/mermaid/{diagram-Q27KOJAE-DwvOmh8B.js => diagram-Q27KOJAE-Cpjz8RT3.js} (98%)
rename veadk/webui/assets/visualizations/mermaid/{diagram-UB23O5K3-Bg0y6jJ-.js => diagram-UB23O5K3-2zSFzlV6.js} (95%)
rename veadk/webui/assets/visualizations/mermaid/{ebnfDiagram-BXEA7PRR-D5w4LlOR.js => ebnfDiagram-BXEA7PRR-_XPQYYfQ.js} (87%)
rename veadk/webui/assets/visualizations/mermaid/{erDiagram-JOGREHBK-COvj5efF.js => erDiagram-JOGREHBK-CKTuwmcS.js} (99%)
rename veadk/webui/assets/visualizations/mermaid/{flowDiagram-UKHOOZJN-DidqPOGr.js => flowDiagram-UKHOOZJN-BtIca__0.js} (99%)
rename veadk/webui/assets/visualizations/mermaid/{ganttDiagram-PKOTCBZU-B_p1i0y-.js => ganttDiagram-PKOTCBZU-BsTpL-yb.js} (99%)
rename veadk/webui/assets/visualizations/mermaid/{gitGraphDiagram-DS77QQ5N-TSpq_MFk.js => gitGraphDiagram-DS77QQ5N-DupWgFfq.js} (99%)
rename veadk/webui/assets/visualizations/mermaid/{infoDiagram-6WML65LV-oYF6Pzuy.js => infoDiagram-6WML65LV-Dl6zW5rI.js} (69%)
rename veadk/webui/assets/visualizations/mermaid/{ishikawaDiagram-WSZJBQD7-BDCXOoKk.js => ishikawaDiagram-WSZJBQD7-NBIjIwSA.js} (99%)
rename veadk/webui/assets/visualizations/mermaid/{journeyDiagram-NVQOT4AX-DWIPXEBZ.js => journeyDiagram-NVQOT4AX-D1RTQQch.js} (98%)
rename veadk/webui/assets/visualizations/mermaid/{kanban-definition-27J2QSJJ-Cm4a64NX.js => kanban-definition-27J2QSJJ-C8voMCH4.js} (99%)
rename veadk/webui/assets/visualizations/mermaid/{mermaid.core-Bi7HkArr.js => mermaid.core-y4TF8bTw.js} (98%)
rename veadk/webui/assets/visualizations/mermaid/{mindmap-definition-FAOFIHXS-B9xiPhGv.js => mindmap-definition-FAOFIHXS-oi9y3HSE.js} (98%)
rename veadk/webui/assets/visualizations/mermaid/{pegDiagram-VL7TDLO6-CvPK3TmZ.js => pegDiagram-VL7TDLO6-CYzndrT-.js} (87%)
rename veadk/webui/assets/visualizations/mermaid/{pieDiagram-7S7Q4E2Y-BBEgW1Wx.js => pieDiagram-7S7Q4E2Y-B1kVpjEf.js} (96%)
rename veadk/webui/assets/visualizations/mermaid/{quadrantDiagram-CIZ2JOQS-CSu8pZ53.js => quadrantDiagram-CIZ2JOQS-Ce1mswm1.js} (99%)
rename veadk/webui/assets/visualizations/mermaid/{railroadDiagram-AXF67PYL-BhMJCPkT.js => railroadDiagram-AXF67PYL-VReUdmSC.js} (84%)
rename veadk/webui/assets/visualizations/mermaid/{requirementDiagram-LRYGKXZP-DOJBZSkq.js => requirementDiagram-LRYGKXZP-BMkVtH2s.js} (99%)
rename veadk/webui/assets/visualizations/mermaid/{sankeyDiagram-W5VNT64P-B08R9g7X.js => sankeyDiagram-W5VNT64P-gHIvLiGA.js} (99%)
rename veadk/webui/assets/visualizations/mermaid/{sequenceDiagram-SI44F4Z6-DSt-dqxP.js => sequenceDiagram-SI44F4Z6-D6lfXAnD.js} (99%)
rename veadk/webui/assets/visualizations/mermaid/{sizeCapture-X5ZJPWSS-BFuu49N6.js => sizeCapture-X5ZJPWSS-DguhcNs8.js} (87%)
rename veadk/webui/assets/visualizations/mermaid/{stateDiagram-OKZ733FA-DC7wJZBp.js => stateDiagram-OKZ733FA-CPFdYVIx.js} (96%)
create mode 100644 veadk/webui/assets/visualizations/mermaid/stateDiagram-v2-UEYNNEHI-CBC6HwPm.js
delete mode 100644 veadk/webui/assets/visualizations/mermaid/stateDiagram-v2-UEYNNEHI-D7MXfWGS.js
rename veadk/webui/assets/visualizations/mermaid/{swimlanes-SLNWSIFB-D-zZg1pE.js => swimlanes-SLNWSIFB-DEO6uzPS.js} (99%)
delete mode 100644 veadk/webui/assets/visualizations/mermaid/swimlanesDiagram-ULZ7WXOC-C7KIOtds.js
create mode 100644 veadk/webui/assets/visualizations/mermaid/swimlanesDiagram-ULZ7WXOC-DIP88zHc.js
rename veadk/webui/assets/visualizations/mermaid/{timeline-definition-Z64GVDOM-B_CXg3l7.js => timeline-definition-Z64GVDOM-CKcwRGgc.js} (99%)
rename veadk/webui/assets/visualizations/mermaid/{vennDiagram-T6HMQDX7-Dr36JOkd.js => vennDiagram-T6HMQDX7-DpGQcsH1.js} (99%)
rename veadk/webui/assets/visualizations/mermaid/{wardleyDiagram-T6FBY63Y-B8ov1MsF.js => wardleyDiagram-T6FBY63Y-CUlxVcoQ.js} (99%)
rename veadk/webui/assets/visualizations/mermaid/{xychartDiagram-ELKLHX3M-C5Www8f1.js => xychartDiagram-ELKLHX3M-Cqf4o1Vh.js} (99%)
rename veadk/webui/assets/widget/{style-LQeYgvcd.css => style-DQO54G3J.css} (67%)
diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json
deleted file mode 100644
index ee49fa2d5..000000000
--- a/.agents/plugins/marketplace.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "name": "veadk-python",
- "interface": {
- "displayName": "VeADK Python"
- },
- "plugins": [
- {
- "name": "agentkit-studio",
- "source": {
- "source": "local",
- "path": "./plugins/agentkit-studio"
- },
- "policy": {
- "installation": "AVAILABLE",
- "authentication": "ON_INSTALL"
- },
- "category": "Productivity"
- }
- ]
-}
diff --git a/.gitattributes b/.gitattributes
index 8b5f008ca..2367cc7fb 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,3 +1,4 @@
# Minified build artifacts can contain semantic whitespace inside template literals.
veadk/webui/assets/**/*.js -whitespace
+veadk/webui/website-integration.js -whitespace
*.sh text eol=lf
diff --git a/.github/workflows/secrets-scan.yaml b/.github/workflows/secrets-scan.yaml
index 78454398c..c6a1728e9 100644
--- a/.github/workflows/secrets-scan.yaml
+++ b/.github/workflows/secrets-scan.yaml
@@ -32,4 +32,4 @@ jobs:
- name: Secret Scanning
uses: trufflesecurity/trufflehog@7dc056a193116ba8d82154bf0549381c8fb8545c # v3.88.14
with:
- extra_args: --results=verified,unknown
+ extra_args: --results=verified,unknown --exclude-paths=.trufflehog-exclude-paths.txt
diff --git a/.gitignore b/.gitignore
index d0c2ee9e4..d13eb1d30 100644
--- a/.gitignore
+++ b/.gitignore
@@ -206,12 +206,31 @@ cython_debug/
.trae
.claude
.agents
+/.dbg/
+/debug-*.md
.adk/
**/.adk/
+
+# Local AI/design and verification artifacts. Keep evidence summaries in
+# PRD/spec documents, not raw screenshots, traces, manifests, or run logs.
+/.artifacts/
+/.superpowers/
+/evidence/
+
# AgentKit deploy config (contains account-specific secrets)
agentkit.yaml
agentkit*.yaml
.agentkit/
+# veadk mpa create config (contains PG/model/OpenViking/Feishu secrets).
+# Only the committed *.example.yaml template is allowed.
+mpa-create.config.yaml
+mpa-create.config*.yaml
+!mpa-create.config.example.yaml
+**/mpa-create.config.yaml
+**/mpa-create.config*.yaml
+!**/mpa-create.config.example.yaml
+
# PiAgent release archives are downloaded and verified during image builds.
examples/piagent_with_mcp/vendor/pi-*.tar.gz
+.gstack/
diff --git a/.gitleaks.toml b/.gitleaks.toml
index 45b030c2f..49f52c7d5 100644
--- a/.gitleaks.toml
+++ b/.gitleaks.toml
@@ -72,6 +72,11 @@ id = "empty-env-key-pattern"
description = "Empty environment variables with KEY pattern"
regex = '''os\.environ\[".*?KEY"\]\s*=\s*".+"'''
+[[rules]]
+id = "hardcoded-api-key-token-secret"
+description = "Hardcoded API key, token, secret, password, or credential assignment"
+regex = '''(?i)\b(api[_-]?key|apikey|access[_-]?key|secret[_-]?key|client[_-]?secret|auth[_-]?token|access[_-]?token|refresh[_-]?token|session[_-]?token|bearer[_-]?token|password|credential)\b["'\s:=]{0,16}["'][A-Za-z0-9_./+=:@%-]{16,}["']'''
+
[allowlist]
# veadk/webui is the built (minified) web UI bundle — generated output reviewed
# via its source under frontend/, not hand-written. Minified vendor libs
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 44d1a3dfc..0cd97794a 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -13,4 +13,11 @@ repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.24.2
hooks:
- - id: gitleaks
\ No newline at end of file
+ - id: gitleaks
+- repo: local
+ hooks:
+ - id: yaml-secret-scan
+ name: Detect concrete secrets in YAML
+ entry: scripts/scan_yaml_secrets.py
+ language: python
+ types_or: [yaml]
diff --git a/.trufflehog-exclude-paths.txt b/.trufflehog-exclude-paths.txt
new file mode 100644
index 000000000..798293f90
--- /dev/null
+++ b/.trufflehog-exclude-paths.txt
@@ -0,0 +1 @@
+^tests/integrations/mpa_managed/test_(admin_database|auto_pg|config|deployment_database_unit|registry_url|service)\.py$
diff --git a/AGENTS.md b/AGENTS.md
index 68b82a6e0..71e4f2758 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,9 +1,63 @@
# Agent Instructions
-- Do not use `codex/` as a branch name prefix in this repository. Use semantic branch prefixes such as `feat/`, `fix/`, `chore/`, or `docs/`; for example, `feat/pr-748-dev`.
-- Before modifying frontend code, read `frontend/SPEC.md`.
-- When the user describes a requirement, propose an implementation plan before changing code, and wait for the user to be satisfied before making edits.
-- After creating or switching to a new branch, run `git pull` before development. Branch names must use semantic prefixes and reflect the requirement content.
-- When committing, include only changes related to the current feature or fix.
-- Before committing, fetch the latest remote code, rebase the branch onto it, then run pre-commit and unit tests.
-- When execution hits a pitfall such as insufficient permissions or missing dependencies, ask the user whether to document that pitfall.
+VeADK includes a Python SDK, CLI/runtime integrations, and AgentKit Studio with a TypeScript/React frontend and supporting Python services. Base decisions on the relevant implementation, tests, and existing contracts rather than assumptions from another repository.
+
+## Language and document ownership
+
+- Keep this repository-wide development standard in English. Use English for new code identifiers, comments, and docstrings; user-facing text follows the owning area's localization rules.
+- Read [prd-spec/README.md](prd-spec/README.md) before creating or updating a change design. Every PRD/design must have complete English `.md` and Chinese `.zh.md` counterparts, sharing the same directory, date, and basename.
+- Read [specs/README.md](specs/README.md) before defining or changing a component contract. Maintain each component under `specs//README.md` and `README.zh.md`.
+- Keep both languages semantically equivalent and update them in the same change. Preserve identifiers, commands, examples, limits, and acceptance criteria across translations. Missing or conflicting counterparts block approval and delivery.
+- PRDs own change-specific intent, scope, tasks, and acceptance; component specs own maintained responsibilities, interfaces, state, data, and failure semantics. Link them rather than duplicate contracts.
+- Before modifying frontend code, read `frontend/SPEC.md`; also apply it to its supporting backend interfaces as specified there. It remains the Studio development standard and is not replaced or translated by this document.
+- Preserve existing user-documentation conventions: the documentation site uses Chinese `.mdx` and English `.en.mdx`; bilingual module/example READMEs use English `README.md` and Chinese `README.zh.md`.
+
+## Required development workflow
+
+1. **Inspect and agree on scope.** Read the affected implementation, tests, and contracts. When the user describes a requirement, propose an implementation plan and wait for the user to be satisfied before making edits. Distinguish verified facts from assumptions.
+2. **Design before implementation.** Features, refactors, and bugfixes require a bilingual PRD under `prd-spec/features/`, `prd-spec/refactors/`, or `prd-spec/bugfixes/`. Include background/evidence, goals, non-goals, scenarios, requirements, design, affected files, tasks, tests, risks, and acceptance criteria.
+3. **Assess component contracts.** Create or update affected bilingual component specs for changes to ownership, APIs, configuration, state/data, events, permissions, security, compatibility, runtime behavior, or observability. Record a justified no-impact conclusion when no component contract changes. Do not backfill unrelated components.
+4. **Clear design review.** Review PRD/spec consistency, feasibility, boundaries, errors, security, compatibility, testability, and bilingual equivalence. Use the available `review-spec` skill; if unavailable, perform and record the same review directly. Resolve blockers and record user approval before modifying production code or tests. A generic skill's Chinese-only default does not override this repository's bilingual rule.
+5. **Implement against the approved design.** Map changes to requirements/tasks, write a failing regression or contract test first, then implement and refactor within scope. Contract or scope changes return to design review before implementation proceeds.
+6. **Verify and review.** Run affected tests first, then required repository and component gates. Review both implementation correctness and edge cases, resource handling, security, test quality, and contract alignment. Fix blocking findings and rerun affected checks.
+7. **Reconcile and deliver.** Synchronize both document languages, component contracts, affected user docs/examples, and required generated artifacts. Record actual verification commands, results, unverified areas, and remaining risks. All agreed tasks and acceptance criteria must be satisfied before declaring completion; deferred scope requires user agreement.
+
+Pure documentation, translation, or formatting changes that preserve runtime behavior and public contracts do not need a recursive PRD. They still require an agreed approach, applicable bilingual updates, and proportionate checks. This exception includes maintaining these development guidelines; it does not exempt code or behavioral changes.
+
+## Implementation boundaries
+
+- Make the smallest complete change that satisfies the approved requirement. Reuse existing modules and dependencies; avoid unrelated cleanup, speculative abstractions, and partially wired features.
+- Treat public Python imports/signatures, CLI/configuration behavior, generated projects, Studio API types, and runtime/event contracts as consumer-facing boundaries. Breaking changes require explicit approval and a plan for affected callers and data; do not copy another project's blanket no-compatibility policy.
+- Cover asynchronous cancellation, timeouts, late responses/events, terminal states, and resource cleanup where affected. Do not turn errors into silent success or empty results.
+- Keep real credentials, signed URLs, personal data, and unredacted production logs out of source, design documents, reports, test fixtures, and generated artifacts.
+- Isolate tests from real user state and external services by default. Real cloud/provider operations need explicit authorization and an isolated smoke/E2E procedure; report simulated and live verification separately.
+
+## Verification gates
+
+Use the repository-local Python environment and current `pyproject.toml`, `pytest.ini`, package scripts, and CI workflows as command references. Do not invent `make` targets, copy another repository's coverage thresholds, or treat an unavailable check as passing.
+
+| Change area | Required verification |
+| --- | --- |
+| Python SDK, CLI, and backend | Targeted tests via `uv run --extra dev pytest `; broader regression tests when shared contracts or dependencies are affected. |
+| Default parallel Python regression | `uv run --extra dev pytest -n 2 -m "not codex_smoke and not piagent_smoke"`; adjust worker count to available resources and record the selection. Explicit smoke runs are separate. |
+| Codex runtime smoke | When affected and the environment is prepared: `CODEX_RUN_SMOKE=1 uv run --extra dev pytest -m codex_smoke -p no:xdist -rs`. This test starts real processes and binds ports; never run it under parallel pytest. Verify it actually ran rather than skipped. |
+| Studio frontend | `npm --prefix frontend test` and `npm --prefix frontend run build`, plus real browser checks of the normal flow and affected loading, empty, error, cancellation, retry, keyboard/IME, and narrow-window cases. Follow `frontend/SPEC.md` for additional checks and release asset requirements. |
+| Studio backend or generated Python | Targeted Python tests plus Ruff and Pyright for changed Python files, as required by `frontend/SPEC.md`; verify generated code, dependencies, configuration, and deployment payloads together. |
+| Harness/sidecar contracts | The affected checks and existing coverage thresholds in `.github/workflows/harness-sidecar-release-gate.yaml`, including `npm --prefix frontend run test:harness-sidecar-coverage` when frontend sidecar contracts change. |
+| PRD/spec and guideline-only changes | Check paired-language completeness, contract/identifier consistency, relative links, referenced paths/commands, and diff whitespace. No runtime test result may be claimed from documentation checks. |
+| User documentation site | The affected Markdown/MDX checks and build/type checks defined in `docs/package.json`; do not apply site-specific tooling to unrelated plain Markdown without checking its scope. |
+
+- Before committing, run `uv run --extra dev pre-commit run --all-files` and unit tests after synchronizing the branch as described below. Pre-commit includes Ruff and secret scanning; it does not replace browser, integration, or runtime verification.
+- Prefer targeted checks during iteration. Expand to affected integration/E2E and regression gates for shared or high-risk changes and final delivery; explain any omitted required check.
+- Record check outcomes as `pass`, `fail`, `blocked`, `not_run`, or `not_applicable`, with reasons for the last three. Include the tested revision/diff scope and execution date. Skipped tests do not prove the relevant behavior.
+- Keep change-specific review and verification summaries in the bilingual PRD by default. Separate evidence artifacts are optional when useful; OpenSpec and a multi-report hierarchy are not prerequisites.
+
+## Git and execution safety
+
+- Do not use `codex/` as a branch name prefix. Use semantic prefixes such as `feat/`, `fix/`, `chore/`, or `docs/`, with a name reflecting the requirement.
+- After creating or switching to a new branch, run `git pull` before development. If no upstream exists or synchronization fails, resolve the target explicitly rather than guessing or bypassing the requirement.
+- Before committing, fetch the latest remote code, rebase the branch onto the intended remote base, then run pre-commit and unit tests. Preserve unrelated user changes and stop if safe synchronization requires a user decision.
+- Commit only changes related to the current feature or fix. Commit, push, PR creation, publication, and deployment require user authorization; approval of a design does not authorize them.
+- Never commit real security-sensitive information or private data to GitHub. This includes API keys, access keys, secret keys, bearer/session/refresh tokens, cookies, signed URLs, private certificates, production logs with credentials, personal data dumps, and cloud account secrets. Use placeholders, environment variables, secret references, or documented local-only setup instead.
+- The repository pre-commit configuration must reject hardcoded API key and token material before a commit is created. If secret scanning reports a finding, stop and remove or redact the value rather than bypassing the hook.
+- When execution hits a pitfall such as insufficient permissions or missing dependencies, ask the user whether to document that pitfall. Report blocked checks honestly and do not alter machine-global configuration or bypass gates to hide the issue.
diff --git a/contracts/mpa-p0/benchmark-report.schema.json b/contracts/mpa-p0/benchmark-report.schema.json
new file mode 100644
index 000000000..eef25507c
--- /dev/null
+++ b/contracts/mpa-p0/benchmark-report.schema.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "MPA P0 benchmark report",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "schemaVersion", "revision", "endpoint", "warmupCount",
+ "requestCount", "concurrency", "p50Milliseconds",
+ "p95Milliseconds", "maxMilliseconds", "errorRate"
+ ],
+ "properties": {
+ "schemaVersion": {"const": 1},
+ "revision": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
+ "endpoint": {"type": "string", "minLength": 1},
+ "warmupCount": {"const": 5},
+ "requestCount": {"const": 100},
+ "concurrency": {"const": 10},
+ "p50Milliseconds": {"type": "number", "minimum": 0},
+ "p95Milliseconds": {"type": "number", "minimum": 0},
+ "maxMilliseconds": {"type": "number", "minimum": 0},
+ "errorRate": {"type": "number", "minimum": 0, "maximum": 1}
+ }
+}
diff --git a/contracts/mpa-p0/compatibility-matrix.json b/contracts/mpa-p0/compatibility-matrix.json
new file mode 100644
index 000000000..76d5b012b
--- /dev/null
+++ b/contracts/mpa-p0/compatibility-matrix.json
@@ -0,0 +1,20 @@
+{
+ "schemaVersion": 1,
+ "rules": [
+ {
+ "id": "p0-codex-rest-v1",
+ "mpaProfileSchemaVersion": 1,
+ "agentkitSdkVersion": "0.8.5",
+ "runtimeExecutionCapability": "urn:veadk:mpa:execution:v1",
+ "sessionExecutionConfigSchemaVersion": 1,
+ "workerProtocol": "codex",
+ "workerRequiredEndpoints": [
+ "/health",
+ "/ready",
+ "/v1/sessions",
+ "/v1/sessions/{session_id}/turns",
+ "/v1/sessions/{session_id}/events"
+ ]
+ }
+ ]
+}
diff --git a/examples/piagent_with_mcp/README.md b/examples/piagent_with_mcp/README.md
index 1491ced62..2ef36b4c2 100644
--- a/examples/piagent_with_mcp/README.md
+++ b/examples/piagent_with_mcp/README.md
@@ -34,7 +34,7 @@ piagent_with_mcp/
├── app.py # deployable FastAPI app on :8000
├── Dockerfile # AgentKit cloud build image
├── requirements.txt
-├── piagent-mcp-agentkit.yaml # veadk agentkit launch config
+├── piagent-mcp-agentkit.yaml # local-only veadk agentkit launch config
├── vendor/
│ └── veadk_python-*.whl # local VeADK build installed by Dockerfile
└── agents/
@@ -124,10 +124,20 @@ Then launch from this example directory:
```bash
cd examples/piagent_with_mcp
+veadk agentkit config \
+ --agent_name piagent_with_mcp --entry_point app.py \
+ --language Python --language_version 3.12 \
+ --launch_type cloud --region cn-beijing \
+ --tos_bucket Auto \
+ --runtime_name piagent_with_mcp --runtime_apikey_name Auto
veadk agentkit launch --config-file piagent-mcp-agentkit.yaml --platform linux/amd64
veadk agentkit status --config-file piagent-mcp-agentkit.yaml
```
+Move the generated `agentkit.yaml` to `piagent-mcp-agentkit.yaml` if you want
+to follow the commands below. Keep that file local: it can contain runtime IDs,
+account-specific endpoints, and API keys after launch.
+
Invoke after the runtime is ready:
```bash
diff --git a/examples/piagent_with_mcp/piagent-mcp-agentkit.yaml b/examples/piagent_with_mcp/piagent-mcp-agentkit.yaml
index 53532ac88..0912795bb 100644
--- a/examples/piagent_with_mcp/piagent-mcp-agentkit.yaml
+++ b/examples/piagent_with_mcp/piagent-mcp-agentkit.yaml
@@ -17,18 +17,18 @@ launch_types:
cr_namespace_name: agentkit
cr_repo_name: piagent-with-mcp
cr_auto_create_instance_type: Micro
- cr_image_full_url: agentkit-platform-2107625663-cn-beijing.cr.volces.com/agentkit/piagent-with-mcp:20260715155044
+ cr_image_full_url: //piagent-with-mcp:
build_timeout: 3600
cp_workspace_name: agentkit-cli-workspace
cp_pipeline_name: piagent-with-mcp
- cp_pipeline_id: f472fcdc6f4848fe8889c0855caaa2d0
- runtime_id: r-yeqksmpybko2eybtg5z8
+ cp_pipeline_id:
+ runtime_id:
runtime_name: piagent-with-mcp
- runtime_role_name: AgentKit_Runtime_Default_ServiceRole_j1gzh8c
+ runtime_role_name:
runtime_auth_type: key_auth
- runtime_apikey_name: API-KEY-da6ikern
- runtime_apikey: BNwxzuKr9hoKhJwJgm2laluVs_yN_BIfzQKN
+ runtime_apikey_name:
+ runtime_apikey:
runtime_jwt_allowed_clients: []
- runtime_endpoint: https://scd6b76f5sagn55rj2dh1.apigateway-cn-beijing.volceapi.com
+ runtime_endpoint:
docker_build:
platform: linux/amd64
diff --git a/frontend/README.md b/frontend/README.md
index bbcd90d68..2196362ee 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -5,6 +5,15 @@ server that `veadk frontend` launches — no separate backend.
## Release notifications
+The MPA creation wizard supports clicking step headings and returning to earlier
+settings after restoring a submitted draft. Submitted settings remain read-only;
+retry retains the original identity. Any submitted draft offers New Agent to
+open a fresh, editable request. It replaces the browser recovery draft without
+cancelling the old task; active-task admission limits still apply on submission.
+
+恢复已提交草稿时配置保持只读,重试沿用原身份。“新建另一个智能体”会生成新请求,
+开放编辑并替换浏览器恢复草稿,不取消旧任务;提交仍受服务端活动任务限制。
+
The release workflow sends one Feishu card after both cloud providers finish
publishing. A separate VeFaaS Webhook discovers the app bot’s group memberships
and persists delivery results to avoid duplicate notifications on retries.
@@ -12,6 +21,23 @@ See [deployment and operation](service/studio_release_notifier/README.md).
## Features
+Runtime connections discovered as `a2a-default` keep the A2A session protocol,
+including when the agent is categorized as MPA. Session creation, history and
+chat use the same bridge and do not query native MPA execution configuration.
+Native MPA apps continue to use their Profile and session APIs; authentication
+errors never cause an automatic protocol switch.
+
+For MPA agents connected through A2A, Studio uses the sandbox's completed answer
+once and omits only exact complete outer-model answer copies for the same task.
+Distinct supplementary text, reasoning, and partial output remain available. Sandbox tool activity
+and token usage remain available. This normalization runs in the Studio backend;
+restart the local Studio process after updating it. Ordinary ADK chat is unaffected.
+
+MPA A2A transcripts also reconcile repeated consolidated outer reasoning
+snapshots. Empty-response notices are evaluated across the whole user request,
+so an intermediate reasoning fragment does not report an empty reply when tools
+or an answer follow. General agents retain their existing per-turn behavior.
+
- **Ark API Keys**: Each page load reads the current API Key list directly from
the cloud provider. Enabled, disabled, all-permission and custom-permission
keys are listed with their status and permission scope beneath the name.
@@ -26,7 +52,6 @@ See [deployment and operation](service/studio_release_notifier/README.md).
Overlapping identical requests share one in-flight cloud query, including
failures; later API Key list reads still fetch the latest cloud data.
This behavior applies to both Volcengine and BytePlus
-
- **Agent publication review**: Developers deploy privately and apply from an
Agent card. The review center's Agent tab lets administrators inspect the
submitted Runtime metadata, approve with an optional comment, or return with
@@ -1093,6 +1118,25 @@ When `--user-pool-id` and `--allowed-client-id` are omitted, deployment creates
or reuses them in the selected `--region` and prints the resolved IDs. Pass both
options to keep using existing Identity resources.
+The cloud deploy and update commands register both `/oauth2/callback` for Studio
+SSO and `/oauth/callback` for MPA user authorization on that same client. An MPA
+Runtime can therefore reuse the deployed Studio user pool and client and set its
+identity callback URL to `https:///oauth/callback`. The callback
+validates the MPA relay state without requiring a Studio browser session,
+redirects the browser through the configured UserPool, then locates exactly one
+key-auth Runtime by `MPA_AGENT_ID` and calls its `/identity/oauth/callback`
+endpoint from the server. The browser never receives the Runtime endpoint or
+API key.
+
+For Studio web chat with an MPA Runtime, the Studio server refreshes the signed-in
+user's UserPool session before each run and prewarms the Runtime's
+`/identity/sessions/put` using the server-held Runtime key. The ID token stays
+server-side and out of the browser session cookie. This requires Studio and MPA
+to use the same UserPool client, a refresh-backed Studio login, and an MPA
+Runtime that recognizes Studio's `x-user-id` as the user identity. If the
+handoff fails, chat continues and MPA can ask the user to log in normally.
+Feishu chat keeps its separate manual login flow.
+
After automatic provisioning, the success summary lists every Sandbox type and
Tool ID, the private Studio TOS address, and the resolved Identity user pool and
client IDs. It also links to the matching Volcengine or BytePlus Identity
@@ -1537,6 +1581,92 @@ DatePicker 在基础组件中展示日期与日期时间选择;IndexLayout 在
`npm run dev:components` 启动独立组件库,在布局分组打开 App layout 可查看完整侧栏
支持 240px / 56px 展开折叠、会话菜单、账号区域与默认深色的主题切换
全屏入口为 `/components-preview/?fullscreen=app-layout#app-layout`,详见 [组件预览说明](src/components-preview/README.md)
+### MPA 消息渠道
+
+在“自动化 → 消息渠道”打开“MPA智能体消息渠道”,与飞书机器人创建、网站集成并列。选择已有 MPA 智能体后配置飞书、企业微信、钉钉;原智能体详情不再显示消息渠道栏目。列表按当前账号授权范围加载,选项展示名称、描述、“创建者 | 相对创建时间”,以及地域和 Runtime ID,支持按名称、Runtime ID、地域搜索已加载结果,并可加载更多;切换智能体会清理临时二维码和凭据,已有绑定继续保留。三个渠道均提供“极速配置”和“手动配置”,默认极速配置,切换方式后仅显示对应内容。极速配置沿用飞书、钉钉扫码绑定和企业微信官方 SDK 弹窗授权;手动配置分别填写飞书 App ID / App Secret、钉钉 Client ID / Client Secret、企业微信 Bot ID / Secret;仅飞书提供允许群列表,并独立展示消息网关、入站路由和回复投递状态。钉钉与企业微信无需配置本地群白名单。所有渠道均可解绑,旧 Runtime 必须升级后才能使用新增绑定接口。需要 Studio 管理员身份、Runtime 的 `CHANNEL_ADMIN_AUTH_MODE=runtime_key`,以及通过部署配置注入并在更新时保留的 `CHANNEL_STATE_ENCRYPTION_KEY`(Fernet key)。渠道数据库必须独立于其他 MPA 实例。飞书和钉钉的扫码凭据由服务端获取;企微 SDK 授权结果仅在浏览器内存中短暂存在,立即提交 Runtime 保存并完成注册,取消、切页和五分钟超时会清理弹窗。配置完成不等于收发验证通过;部署后请单独验收私聊和授权群 @ 对话。详见 [接口契约](../specs/mpa-channels/README.zh.md)。
+
+已绑定飞书、钉钉或企业微信时,可点击“重新生成二维码”展开“消息渠道扫码配对”。生成二维码不会先解绑当前机器人;飞书扫码并绑定成功后替换原机器人,可在授权流程选择绑定存量机器人或新建机器人。
+
+企业微信的“重新生成二维码”会重新打开企微扫码授权窗口,成功绑定后更新连接,取消授权或失败保留原机器人。
+
+配对二维码仅在当前渠道面板临时展示,不显示“等待扫码”或“打开授权页面”。切换渠道、离开页面、浏览器刷新或面板刷新后不恢复旧二维码,需要重新点击生成;已绑定机器人配置保留。
+
+三个渠道的解除连接操作统一为“解除绑定”;已绑定企业微信的“重新生成二维码”与“解除绑定”横向排列,与飞书和钉钉一致。
+
+飞书群聊权限列表不再提供“编辑”;每行右侧的 × 按钮用于移除该群权限。
+
+配置方式不跨页面或 Runtime 保存。切换方式会清空未提交凭据、隐藏二维码并停止前端轮询或企微授权等待;注册提交进行中禁止切换。已绑定机器人也可以通过手动配置替换,失败保留现有绑定。飞书与钉钉手动配置须由 Runtime 的 `credentialBindingChannels` 声明支持;旧镜像显示升级提示。
+
+### MPA Runtime scheduled tasks
+
+Select a connected cloud Runtime and open the Runtime scheduled tasks tab. The list/calendar supports search, status filtering, pagination, creation, editing, copying, deletion, enable/disable, manual execution, and execution history. Studio retains gateway authentication and Runtime authorization, forwards the authenticated Studio user as `x-user-id`, and does not require JWT input or TOP credential exchange. With MPA JWT disabled, task ownership remains scoped to that user. The Runtime resolves the default executing Agent; no Agent ID input is required. Errors are not rendered as empty results. Complex Cron expressions show the server's next execution in the calendar.
+
+Run `npm run test:mpa-cron-coverage` and `python -m pytest tests/frontend/server/test_mpa_cron.py` for focused regression checks.
+
+### A2A 长耗时请求
+
+连接 A2A Runtime 后,对话会显示等待响应、排队或执行中的状态。收到有效状态后会继续等待最终回复,不因任务耗时超过 30 秒而自动中断。等待响应不代表 Runtime 已接受任务;错误或连接中断也不代表后台任务已取消,请先确认任务状态再重试创建等操作。
+
+### Sandbox file downloads
+
+Assistant Markdown links under `/data/output/` or `/data/workspace/` appear as download buttons in conversation history and streaming messages. Studio uses the current Runtime and session through its authenticated proxy; files remain available only while that Sandbox and file exist. Legacy `` and `` payloads are hidden in conversation rendering. A failed download can be retried by clicking the button again. Run `npm run test:sandbox-download-coverage` for the focused regression suite.
+
+
+### MPA conversation information rail / MPA 会话信息侧栏
+
+Only MPA Runtimes show the right conversation rail. AGENTS.md displays the actual Runtime document through the authenticated Studio metadata endpoint. Upgrade the MPA Runtime image to expose `/api/v1/studio/agent-info`; Studio alone cannot add the endpoint to deployed agents. Runtime credentials stay server-side.
+
+Skills show only the bound Skill Spaces, with pagination. Temporary mounting has been removed, and old temporary selections are excluded from MPA messages. Click a skill name to view its latest SKILL.md. Authorized owners/admins can save a new version to that space; shared/review spaces remain read-only. Saving preserves other files, rejects stale versions and changes to the frontmatter name, and can affect other agents bound to the same space. Running sessions may need a cache refresh or a new session. Use **Refresh information** to reload the rail; environment controls remain available.
+
+右侧会话信息栏仅对 MPA Runtime 展示。AGENTS.md 通过受认证的 Studio 元信息接口显示真实正文。需要更新 MPA Runtime 镜像以提供 `/api/v1/studio/agent-info`;单独更新 Studio 无法给已部署智能体增加此接口。Runtime 凭据仅保留在服务端。
+
+技能仅展示绑定空间的内容并支持分页。已取消临时挂载,旧的临时技能选择不会再发送给 MPA。点击技能名称查看最新 SKILL.md;有权限的所有者/管理员可保存新版本到该空间,共享/审核空间保持只读。保存保留其他文件,拒绝过期版本及 frontmatter 名称变化,可能影响绑定同一空间的其他智能体。运行中的会话可能需要等待缓存刷新或新建会话后生效。点击“刷新信息”重新加载侧栏;环境操作保持可用。
+
+### Production component themes
+
+The production entry loads both component token stylesheets before rendering. When the host does not set `data-theme` on ``, Studio uses `light` to match its existing page; an explicit `light` or `dark` value is preserved. This keeps portalled dialogs, their text, inputs and buttons on the same palette. Component-preview defaults are unchanged.
+
+生产入口在渲染前加载两份组件主题样式。宿主未在 `` 上设置 `data-theme` 时沿用浅色页面;显式浅色或深色设置保持不变,确保弹窗、文字、输入框和按钮配色一致。组件预览的默认主题不变。
+
+### Named MPA creation / MPA 名称创建
+
+Studio resolves shared VPC/subnet and APIG/IM Gateway from the account/region
+records in `mpa_admin_workspace/mpa_admin_db`. It creates and persists missing
+resources, then reuses them for later agents. The built-in profile does not pin
+existing resource IDs. Existing records are preserved; this does not migrate
+agents or bypass resource quotas.
+
+Studio 从 `mpa_admin_workspace/mpa_admin_db` 的账号/地域记录解析共享 VPC/子网及
+APIG/IM Gateway;缺失时创建并保存,后续智能体复用。内置配置不固定已有资源 ID。
+已有记录保持不变;本次不迁移智能体或绕过资源配额。
+
+With `managed.postgres` configured, the PG step uses a shared business Workspace with a separate database per MPA. Shared account/region resource records live in `mpa_admin_workspace/mpa_admin_db`. With `managed.postgres.mode: auto`, deployment STS creates/reuses both Workspaces and the PG step needs no connection input. Manual profiles remain supported. See the [setup and registry migration instructions](../veadk/integrations/mpa/managed/README.md).
+
+配置 `managed.postgres` 后,PG 步骤使用共享业务 Workspace,每个 MPA 保留独立业务库。账号/地域共享资源记录存入 `mpa_admin_workspace/mpa_admin_db`。设置 `managed.postgres.mode: auto` 后,由部署 STS 创建/复用两个 Workspace,PG 步骤无需填写连接信息;仍兼容手动配置。参见[配置和注册库迁移说明](../veadk/integrations/mpa/managed/README.zh.md)。
+
+The MPA creation dialog asks for a Runtime name (4–64 ASCII letters, digits, underscores or hyphens) and description. The server generates the stable Agent ID; neither ID nor image inputs are shown. Fresh requests use the built-in `mpa/mpa_agent:latest` and `mpa/mpa_codex_worker:latest` images. Submitted legacy drafts retain their original request and images for retry. Existing Runtimes are unchanged. See [managed creation](../veadk/integrations/mpa/managed/README.md).
+
+The dialog now uses three steps: basics (required Runtime name and description), automatic PostgreSQL preparation, and optional OpenViking HTTPS URL/resource ID/API Key. It links to the relevant Volcengine console pages. The server obtains PostgreSQL Workspace connections through deployment credentials. OpenViking is enabled only when the HTTPS URL, resource ID and masked API Key are all entered; leaving all three blank omits its four Runtime variables, including any values inherited from a template or reference Runtime. The key is never saved in browser drafts or task SQLite. Only the final step submits; nonsecret draft choices and request identity survive reopening in the same browser session. See the [creation contract](../specs/studio-mpa-creation/README.md).
+
+创建弹窗现分为基础信息(必填 Runtime 名称和描述)、PostgreSQL 自动准备、可选 OpenViking HTTPS 地址/资源 ID/API Key 三步,并提供对应的火山引擎控制台入口。服务端通过部署凭据获取 PostgreSQL Workspace 连接。仅在 HTTPS 地址、资源 ID 和遮罩的 API Key 三项均填写时启用 OpenViking;三项全空时,新 Runtime 不注入其四个变量,也不继承模板或参考 Runtime 中的旧值。密钥不保存到浏览器草稿或任务 SQLite。仅最后一步提交,非密钥草稿与请求身份在同一浏览器会话中可恢复。参见[创建契约](../specs/studio-mpa-creation/README.zh.md)。
+
+After a confirmed failure or cancellation, the dialog retains the old task for same-ID retry. “Create another agent” generates a new request UUID and blank name; the server derives the new agent ID on submission and returns to the first step. It replaces only the browser draft, not any cloud resources from the previous attempt. Running or uncertain submissions cannot switch identities.
+
+失败或取消后,创建弹窗保留原任务供同 ID 重试;点击“新建另一个智能体”会生成新的请求 UUID 和空白名称并返回第一步,服务端在提交时派生新智能体 ID。该操作只替换浏览器草稿,不会删除上次创建的云资源。运行中或提交结果不明时不能切换到新身份。
+
+MPA 创建弹窗填写 Runtime 名称(4–64 个 ASCII 字母、数字、下划线或连字符)和描述。服务端生成稳定的智能体 ID,不再显示 ID 和镜像输入框。新请求使用内置 `mpa/mpa_agent:latest` 和 `mpa/mpa_codex_worker:latest` 镜像。已提交旧草稿保留原请求和镜像用于重试。现有 Runtime 不变。参见[托管创建说明](../veadk/integrations/mpa/managed/README.zh.md)。
+
+MPA A2A responses now group adjacent assistant fragments into one reply with a single action row. Copy/share include the grouped response; feedback remains attached to the final answer event, and the trace entry retains the session timeline through the latest fragment. Reported request tokens are deduplicated by usage source/event, including trailing usage updates. General-agent rendering is unchanged.
+
+MPA grouped replies hide exact answer mirrors only in their derived view: an extended fragment reappears intact, including its original prefix. General Codex tool activity preserves commentary and subsequent model text under its original behavior.
+
+### MPA A2A shared gateway compatibility
+
+MPA conversations (native or A2A) hide the separate “会话产物” entry because it does not read the Worker `/data/output` mount. General Runtime sessions retain the entry. Existing MPA Markdown file-download links are unchanged.
+
+Fresh flat MPA creation defaults to `ENABLE_A2A=true` and `DISABLE_JWT_AUTH=true`; explicit `false` overrides and referenced Runtime/template environments are preserved. The inner legacy REST JWT gate is bypassed, including its admin checks and header-based identity paths; trusted callers must control identity headers. Outer gateway key-auth is unchanged. Studio now prefers a valid Agent Card for tagged MPA Runtimes, selecting `a2a-default` even when the Runtime also advertises ADK. General Runtimes and MPA without a usable card keep existing discovery behavior. This avoids native Profile/session preflight on the A2A path; it does not enable `MPA_AGENTKIT_MODE`, change independent management-endpoint authentication or migrate old sessions. If a tagged MPA Runtime's agent card omits the shared gateway `/runtime/` prefix from its same-origin `/a2a/jsonrpc` URL, the Studio backend still restores the prefix from the control-plane endpoint for chat and history requests. General-agent URLs are unchanged. Existing Runtimes need an explicit configuration release for environment changes; reconnect to refresh discovery. Restart Studio after this backend update; no frontend rebuild is required.
+
+新建 MPA 沙箱模板按智能体 ID 命名,并将 `-` 替换为 `_`(例如 `mi-example` → `mi_example`);已有模板保持原绑定,未完成的旧创建任务继续按原名称重试。
### 智能构建首页与任务找回
@@ -1618,3 +1748,79 @@ it. Deleting a default set stops feedback writes to that set and prevents backgr
List filtering and counts currently scan only the selected Runtime's objects,
with bounded parallel reads. This is intended for the initial dataset sizes;
large collections will need a separate rebuildable query index.
+### MPA 消息渠道
+
+在“自动化 → 消息渠道”打开“MPA智能体消息渠道”,与飞书机器人创建、网站集成并列。选择已有 MPA 智能体后配置飞书、企业微信、钉钉;原智能体详情不再显示消息渠道栏目。列表按当前账号授权范围加载,选项展示名称、描述、“创建者 | 相对创建时间”,以及地域和 Runtime ID,支持按名称、Runtime ID、地域搜索已加载结果,并可加载更多;切换智能体会清理临时二维码和凭据,已有绑定继续保留。三个渠道均提供“极速配置”和“手动配置”,默认极速配置,切换方式后仅显示对应内容。极速配置沿用飞书、钉钉扫码绑定和企业微信官方 SDK 弹窗授权;手动配置分别填写飞书 App ID / App Secret、钉钉 Client ID / Client Secret、企业微信 Bot ID / Secret;仅飞书提供允许群列表,并独立展示消息网关、入站路由和回复投递状态。钉钉与企业微信无需配置本地群白名单。所有渠道均可解绑,旧 Runtime 必须升级后才能使用新增绑定接口。需要 Studio 管理员身份、Runtime 的 `CHANNEL_ADMIN_AUTH_MODE=runtime_key`,以及通过部署配置注入并在更新时保留的 `CHANNEL_STATE_ENCRYPTION_KEY`(Fernet key)。渠道数据库必须独立于其他 MPA 实例。飞书和钉钉的扫码凭据由服务端获取;企微 SDK 授权结果仅在浏览器内存中短暂存在,立即提交 Runtime 保存并完成注册,取消、切页和五分钟超时会清理弹窗。配置完成不等于收发验证通过;部署后请单独验收私聊和授权群 @ 对话。详见 [接口契约](../specs/mpa-channels/README.zh.md)。
+
+已绑定飞书、钉钉或企业微信时,可点击“重新生成二维码”展开“消息渠道扫码配对”。生成二维码不会先解绑当前机器人;飞书扫码并绑定成功后替换原机器人,可在授权流程选择绑定存量机器人或新建机器人。
+
+企业微信的“重新生成二维码”会重新打开企微扫码授权窗口,成功绑定后更新连接,取消授权或失败保留原机器人。
+
+配对二维码仅在当前渠道面板临时展示,不显示“等待扫码”或“打开授权页面”。切换渠道、离开页面、浏览器刷新或面板刷新后不恢复旧二维码,需要重新点击生成;已绑定机器人配置保留。
+
+三个渠道的解除连接操作统一为“解除绑定”;已绑定企业微信的“重新生成二维码”与“解除绑定”横向排列,与飞书和钉钉一致。
+
+飞书群聊权限列表不再提供“编辑”;每行右侧的 × 按钮用于移除该群权限。
+
+配置方式不跨页面或 Runtime 保存。切换方式会清空未提交凭据、隐藏二维码并停止前端轮询或企微授权等待;注册提交进行中禁止切换。已绑定机器人也可以通过手动配置替换,失败保留现有绑定。飞书与钉钉手动配置须由 Runtime 的 `credentialBindingChannels` 声明支持;旧镜像显示升级提示。
+
+### MPA Runtime scheduled tasks
+
+Select a connected cloud Runtime and open the Runtime scheduled tasks tab. The list/calendar supports search, status filtering, pagination, creation, editing, copying, deletion, enable/disable, manual execution, and execution history. Studio retains gateway authentication and Runtime authorization, forwards the authenticated Studio user as `x-user-id`, and does not require JWT input or TOP credential exchange. With MPA JWT disabled, task ownership remains scoped to that user. The Runtime resolves the default executing Agent; no Agent ID input is required. Errors are not rendered as empty results. Complex Cron expressions show the server's next execution in the calendar.
+
+Run `npm run test:mpa-cron-coverage` and `python -m pytest tests/frontend/server/test_mpa_cron.py` for focused regression checks.
+
+### A2A 长耗时请求
+
+连接 A2A Runtime 后,对话会显示等待响应、排队或执行中的状态。收到有效状态后会继续等待最终回复,不因任务耗时超过 30 秒而自动中断。等待响应不代表 Runtime 已接受任务;错误或连接中断也不代表后台任务已取消,请先确认任务状态再重试创建等操作。
+
+### Sandbox file downloads
+
+Assistant Markdown links under `/data/output/` or `/data/workspace/` appear as download buttons in conversation history and streaming messages. Studio uses the current Runtime and session through its authenticated proxy; files remain available only while that Sandbox and file exist. Legacy `` and `` payloads are hidden in conversation rendering. A failed download can be retried by clicking the button again. Run `npm run test:sandbox-download-coverage` for the focused regression suite.
+
+
+### MPA conversation information rail / MPA 会话信息侧栏
+
+Only MPA Runtimes show the right conversation rail. AGENTS.md displays the actual Runtime document through the authenticated Studio metadata endpoint. Upgrade the MPA Runtime image to expose `/api/v1/studio/agent-info`; Studio alone cannot add the endpoint to deployed agents. Runtime credentials stay server-side.
+
+Skills show only the bound Skill Spaces, with pagination. Temporary mounting has been removed, and old temporary selections are excluded from MPA messages. Click a skill name to view its latest SKILL.md. Authorized owners/admins can save a new version to that space; shared/review spaces remain read-only. Saving preserves other files, rejects stale versions and changes to the frontmatter name, and can affect other agents bound to the same space. Running sessions may need a cache refresh or a new session. Use **Refresh information** to reload the rail; environment controls remain available.
+
+右侧会话信息栏仅对 MPA Runtime 展示。AGENTS.md 通过受认证的 Studio 元信息接口显示真实正文。需要更新 MPA Runtime 镜像以提供 `/api/v1/studio/agent-info`;单独更新 Studio 无法给已部署智能体增加此接口。Runtime 凭据仅保留在服务端。
+
+技能仅展示绑定空间的内容并支持分页。已取消临时挂载,旧的临时技能选择不会再发送给 MPA。点击技能名称查看最新 SKILL.md;有权限的所有者/管理员可保存新版本到该空间,共享/审核空间保持只读。保存保留其他文件,拒绝过期版本及 frontmatter 名称变化,可能影响绑定同一空间的其他智能体。运行中的会话可能需要等待缓存刷新或新建会话后生效。点击“刷新信息”重新加载侧栏;环境操作保持可用。
+
+### Production component themes
+
+The production entry loads both component token stylesheets before rendering. When the host does not set `data-theme` on ``, Studio uses `light` to match its existing page; an explicit `light` or `dark` value is preserved. This keeps portalled dialogs, their text, inputs and buttons on the same palette. Component-preview defaults are unchanged.
+
+生产入口在渲染前加载两份组件主题样式。宿主未在 `` 上设置 `data-theme` 时沿用浅色页面;显式浅色或深色设置保持不变,确保弹窗、文字、输入框和按钮配色一致。组件预览的默认主题不变。
+
+### Named MPA creation / MPA 名称创建
+
+With `managed.postgres` configured, the PG step uses a shared business Workspace with a separate database per MPA. Shared account/region resource records live in `mpa_admin_workspace/mpa_admin_db`. With `managed.postgres.mode: auto`, deployment STS creates/reuses both Workspaces and the PG step needs no connection input. Manual profiles remain supported. See the [setup and registry migration instructions](../veadk/integrations/mpa/managed/README.md).
+
+配置 `managed.postgres` 后,PG 步骤使用共享业务 Workspace,每个 MPA 保留独立业务库。账号/地域共享资源记录存入 `mpa_admin_workspace/mpa_admin_db`。设置 `managed.postgres.mode: auto` 后,由部署 STS 创建/复用两个 Workspace,PG 步骤无需填写连接信息;仍兼容手动配置。参见[配置和注册库迁移说明](../veadk/integrations/mpa/managed/README.zh.md)。
+
+The MPA creation dialog asks for a Runtime name (4–64 ASCII letters, digits, underscores or hyphens) and description. The server generates the stable Agent ID; neither ID nor image inputs are shown. Fresh requests use the built-in `mpa/mpa_agent:latest` and `mpa/mpa_codex_worker:latest` images. Submitted legacy drafts retain their original request and images for retry. Existing Runtimes are unchanged. See [managed creation](../veadk/integrations/mpa/managed/README.md).
+
+The dialog now uses three steps: basics (required Runtime name and description), automatic PostgreSQL preparation, and optional OpenViking HTTPS URL/resource ID/API Key. It links to the relevant Volcengine console pages. The server obtains PostgreSQL Workspace connections through deployment credentials. OpenViking is enabled only when the HTTPS URL, resource ID and masked API Key are all entered; leaving all three blank omits its four Runtime variables, including any values inherited from a template or reference Runtime. The key is never saved in browser drafts or task SQLite. Only the final step submits; nonsecret draft choices and request identity survive reopening in the same browser session. See the [creation contract](../specs/studio-mpa-creation/README.md).
+
+创建弹窗现分为基础信息(必填 Runtime 名称和描述)、PostgreSQL 自动准备、可选 OpenViking HTTPS 地址/资源 ID/API Key 三步,并提供对应的火山引擎控制台入口。服务端通过部署凭据获取 PostgreSQL Workspace 连接。仅在 HTTPS 地址、资源 ID 和遮罩的 API Key 三项均填写时启用 OpenViking;三项全空时,新 Runtime 不注入其四个变量,也不继承模板或参考 Runtime 中的旧值。密钥不保存到浏览器草稿或任务 SQLite。仅最后一步提交,非密钥草稿与请求身份在同一浏览器会话中可恢复。参见[创建契约](../specs/studio-mpa-creation/README.zh.md)。
+
+After a confirmed failure or cancellation, the dialog retains the old task for same-ID retry. “Create another agent” generates a new request UUID and blank name; the server derives the new agent ID on submission and returns to the first step. It replaces only the browser draft, not any cloud resources from the previous attempt. Running or uncertain submissions cannot switch identities.
+
+失败或取消后,创建弹窗保留原任务供同 ID 重试;点击“新建另一个智能体”会生成新的请求 UUID 和空白名称并返回第一步,服务端在提交时派生新智能体 ID。该操作只替换浏览器草稿,不会删除上次创建的云资源。运行中或提交结果不明时不能切换到新身份。
+
+MPA 创建弹窗填写 Runtime 名称(4–64 个 ASCII 字母、数字、下划线或连字符)和描述。服务端生成稳定的智能体 ID,不再显示 ID 和镜像输入框。新请求使用内置 `mpa/mpa_agent:latest` 和 `mpa/mpa_codex_worker:latest` 镜像。已提交旧草稿保留原请求和镜像用于重试。现有 Runtime 不变。参见[托管创建说明](../veadk/integrations/mpa/managed/README.zh.md)。
+
+MPA A2A responses now group adjacent assistant fragments into one reply with a single action row. Copy/share include the grouped response; feedback remains attached to the final answer event, and the trace entry retains the session timeline through the latest fragment. Reported request tokens are deduplicated by usage source/event, including trailing usage updates. General-agent rendering is unchanged.
+
+MPA grouped replies hide exact answer mirrors only in their derived view: an extended fragment reappears intact, including its original prefix. General Codex tool activity preserves commentary and subsequent model text under its original behavior.
+
+### MPA A2A shared gateway compatibility
+
+MPA creation defaults to A2A discovery (`ENABLE_A2A=true`, `DISABLE_JWT_AUTH=false`). When a tagged MPA Runtime's agent card omits the shared gateway `/runtime/` prefix from its same-origin `/a2a/jsonrpc` URL, the Studio backend restores the prefix from the control-plane endpoint for chat and history requests. General-agent URLs are unchanged. Existing Runtimes need an explicit configuration release; reconnect to refresh discovery. Restart Studio after this backend update; no frontend rebuild is required.
+
+MPA name validation highlights invalid input and its message in the theme danger color, offers a valid-name placeholder, and visibly disables Next until corrected. Shared Button disabled states are dimmed consistently; loading retains its own appearance.
+
+MPA 名称校验使用主题错误色显示非法输入及错误提示,并提供合法名称示例;名称修正前“下一步”明显置灰。共享 Button 的禁用态统一降低透明度,加载态保留原有外观。
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 39d46b9b0..35df7cda4 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -23,6 +23,7 @@
"@mdxeditor/editor": "^4.1.0",
"@openai/apps-sdk-ui": "^0.2.2",
"@uiw/react-codemirror": "^4.25.11",
+ "@wecom/wecom-aibot-sdk": "0.1.0",
"@xyflow/react": "^12.11.0",
"echarts": "6.1.0",
"highlight.js": "^11.11.1",
@@ -5550,6 +5551,12 @@
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/@wecom/wecom-aibot-sdk": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@wecom/wecom-aibot-sdk/-/wecom-aibot-sdk-0.1.0.tgz",
+ "integrity": "sha512-rqYS3F0t8+433tQGJCU2pTOBHZD28AYnCG34T7p/4TuMeGsayPTfu2Lef09PZf5ISkJEFYTy0F5GAnajTuzfGA==",
+ "license": "MIT"
+ },
"node_modules/@xyflow/react": {
"version": "12.11.0",
"resolved": "https://registry.npmmirror.com/@xyflow/react/-/react-12.11.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 1f7dc74f0..42c468208 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -7,8 +7,11 @@
"scripts": {
"dev": "vite",
"build": "node scripts/build.mjs",
- "test": "node --test tests/*.test.mjs",
+ "test": "node --test tests/*.test.mjs && npm run test:mpa-agent-info",
+ "test:mpa-agent-info": "vitest run tests/mpaAgentInfoRail.test.tsx tests/skillDocumentEditor.test.tsx tests/mpaCreation.test.tsx",
"check:i18n": "node scripts/check-i18n.mjs",
+ "test:sandbox-download-coverage": "vitest run --coverage -c vitest.sandbox-download.config.ts",
+ "test:mpa-cron-coverage": "vitest run --coverage -c vitest.mpa-cron.config.ts",
"test:webui-assets": "node scripts/verifyBuiltAssets.mjs",
"test:visualizations": "node scripts/testVisualizationCompatibility.mjs",
"test:harness-sidecar-coverage": "vitest run --coverage -c vitest.harness-sidecar.config.ts",
@@ -31,6 +34,7 @@
"@mdxeditor/editor": "^4.1.0",
"@openai/apps-sdk-ui": "^0.2.2",
"@uiw/react-codemirror": "^4.25.11",
+ "@wecom/wecom-aibot-sdk": "0.1.0",
"@xyflow/react": "^12.11.0",
"echarts": "6.1.0",
"highlight.js": "^11.11.1",
diff --git a/frontend/scripts/verifyBuiltAssets.mjs b/frontend/scripts/verifyBuiltAssets.mjs
index f277f0817..bea489736 100644
--- a/frontend/scripts/verifyBuiltAssets.mjs
+++ b/frontend/scripts/verifyBuiltAssets.mjs
@@ -17,6 +17,7 @@ const expectedDirectories = [
"assets/visualizations/echarts",
"assets/visualizations/mermaid",
];
+const unresolvedConflictMarker = /^(?:<{7,}|\|{7,}|={7,}|>{7,})(?: .*)?\r?$/m;
async function filesUnder(directory) {
const entries = await readdir(directory, { withFileTypes: true });
@@ -64,6 +65,10 @@ function verifyReference(fromFile, reference) {
for (const relativeFile of relativeFiles) {
if (!/\.(?:html|css|js)$/.test(relativeFile)) continue;
const contents = await readFile(path.join(webuiRoot, relativeFile), "utf8");
+ assert.ok(
+ !unresolvedConflictMarker.test(contents),
+ `${relativeFile} contains an unresolved Git conflict marker`,
+ );
if (relativeFile.endsWith(".js")) {
for (const reference of extractJavaScriptImports(contents, relativeFile)) {
verifyReference(relativeFile, reference);
diff --git a/frontend/server/mpa/__init__.py b/frontend/server/mpa/__init__.py
new file mode 100644
index 000000000..1f8e50116
--- /dev/null
+++ b/frontend/server/mpa/__init__.py
@@ -0,0 +1,74 @@
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Studio MPA management composition helpers."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from typing import Any
+
+from frontend.server.storage import (
+ STUDIO_STORAGE_UNAVAILABLE_REASON,
+ StudioProvider,
+ StudioStorageConfig,
+)
+from frontend.server.storage.tos import CredentialResolver, create_tos_client_factory
+
+from .operations import InMemoryMpaOperationRepository, TosMpaOperationRepository
+from .runtime_client import MpaRuntimeClient
+from .routes import mount_mpa_profile_routes
+from .service import MpaAgentOperationService
+
+
+def create_operation_service(
+ *,
+ provider: StudioProvider = "volcengine",
+ resolve_credentials: CredentialResolver | None = None,
+ client_factory: Callable[[], Any] | None = None,
+ runtime_client: MpaRuntimeClient | None = None,
+ allow_in_memory: bool = False,
+) -> MpaAgentOperationService | None:
+ """Compose the MPA operation service.
+
+ Production uses TOS for durable CAS. Local Studio development can opt into a
+ process-local repository so profile application remains testable without TOS.
+ """
+ storage = StudioStorageConfig.from_env(provider)
+ if not storage.configured:
+ if not allow_in_memory:
+ return None
+ return MpaAgentOperationService(
+ repository=InMemoryMpaOperationRepository(),
+ runtime_client=runtime_client or MpaRuntimeClient(),
+ )
+ if client_factory is None:
+ if resolve_credentials is None:
+ return None
+ client_factory = create_tos_client_factory(storage, resolve_credentials)
+ return MpaAgentOperationService(
+ repository=TosMpaOperationRepository(
+ bucket=storage.bucket,
+ client_factory=client_factory,
+ ),
+ runtime_client=runtime_client or MpaRuntimeClient(),
+ )
+
+
+__all__ = [
+ "MpaAgentOperationService",
+ "STUDIO_STORAGE_UNAVAILABLE_REASON",
+ "create_operation_service",
+ "mount_mpa_profile_routes",
+]
diff --git a/frontend/server/mpa/operations.py b/frontend/server/mpa/operations.py
new file mode 100644
index 000000000..a41e7f11b
--- /dev/null
+++ b/frontend/server/mpa/operations.py
@@ -0,0 +1,452 @@
+# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""TOS-CAS persistence for Studio MPA lifecycle operations."""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import json
+from collections.abc import Callable
+from copy import deepcopy
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Any, Generic, Literal, TypeVar
+from urllib.parse import quote
+
+from pydantic import BaseModel, ConfigDict, Field
+
+_MAX_OBJECT_BYTES = 2 * 1024 * 1024
+MPA_OPERATION_ROOT_PREFIX = "veadk-studio/v4"
+_Value = TypeVar("_Value", bound=BaseModel)
+_TERMINAL_STATUSES = {"succeeded", "failed_terminal", "cancelled"}
+
+
+class MpaOperationNotFound(LookupError):
+ """The requested MPA lifecycle operation does not exist."""
+
+
+class MpaOperationConflict(RuntimeError):
+ """The MPA operation changed concurrently or idempotency does not match."""
+
+
+@dataclass(frozen=True)
+class Stored(Generic[_Value]):
+ value: _Value
+ etag: str
+ created: bool = False
+
+
+class MpaLifecycleOperation(BaseModel):
+ model_config = ConfigDict(populate_by_name=True)
+
+ operation_id: str = Field(default="", alias="operationId")
+ operation_kind: Literal["create", "update"] = Field(alias="operationKind")
+ owner_id: str = Field(alias="ownerId", min_length=1)
+ target_key: str = Field(alias="targetKey", min_length=1)
+ idempotency_key_hash: str = Field(alias="idempotencyKeyHash", min_length=1)
+ request_hash: str = Field(alias="requestHash", min_length=1)
+ stage: str
+ status: Literal[
+ "active",
+ "succeeded",
+ "failed_retryable",
+ "failed_terminal",
+ "cancelled",
+ ]
+ mpa_instance_id: str = Field(default="", alias="mpaInstanceId")
+ runtime_id: str = Field(default="", alias="runtimeId")
+ runtime_region: str = Field(default="", alias="runtimeRegion")
+ runtime_revision: str = Field(default="", alias="runtimeRevision")
+ profile_digest: str = Field(default="", alias="profileDigest")
+ profile_revision: int | None = Field(default=None, alias="profileRevision")
+ profile_operation_id: str = Field(default="", alias="profileOperationId")
+ safe_error_code: str = Field(default="", alias="safeErrorCode")
+ retry_count: int = Field(default=0, alias="retryCount")
+ created_at: datetime = Field(alias="createdAt")
+ updated_at: datetime = Field(alias="updatedAt")
+
+
+def operation_id_for(
+ *,
+ owner_id: str,
+ operation_kind: str,
+ target_key: str,
+ idempotency_key_hash: str,
+) -> str:
+ canonical = json.dumps(
+ {
+ "ownerId": owner_id,
+ "operationKind": operation_kind,
+ "targetKey": target_key,
+ "idempotencyKeyHash": idempotency_key_hash,
+ },
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode("utf-8")
+ return f"mpaop_{hashlib.sha256(canonical).hexdigest()[:32]}"
+
+
+def _status_code(error: Exception) -> int | None:
+ value = getattr(error, "status_code", None)
+ try:
+ return int(value) if value is not None else None
+ except (TypeError, ValueError):
+ return None
+
+
+class TosMpaOperationRepository:
+ """Persist MPA lifecycle operations with TOS conditional writes."""
+
+ def __init__(
+ self,
+ *,
+ bucket: str,
+ client_factory: Callable[[], Any],
+ root_prefix: str = MPA_OPERATION_ROOT_PREFIX,
+ ) -> None:
+ if not bucket.strip():
+ raise ValueError("TOS MPA operation storage requires a bucket.")
+ self._bucket = bucket
+ self._client_factory = client_factory
+ self._root_prefix = root_prefix.strip("/")
+
+ async def create_operation(
+ self, operation: MpaLifecycleOperation
+ ) -> Stored[MpaLifecycleOperation]:
+ return await asyncio.to_thread(self._create_operation, operation)
+
+ async def get_operation(
+ self, owner_id: str, operation_id: str
+ ) -> Stored[MpaLifecycleOperation]:
+ return await asyncio.to_thread(self._get_operation, owner_id, operation_id)
+
+ async def update_operation(
+ self,
+ operation: MpaLifecycleOperation,
+ etag: str,
+ ) -> Stored[MpaLifecycleOperation]:
+ return await asyncio.to_thread(self._update_operation, operation, etag)
+
+ async def list_active_operations(
+ self, owner_id: str
+ ) -> list[MpaLifecycleOperation]:
+ return await asyncio.to_thread(self._list_active_operations, owner_id)
+
+ def _create_operation(
+ self, operation: MpaLifecycleOperation
+ ) -> Stored[MpaLifecycleOperation]:
+ resolved = self._with_operation_id(operation)
+ key = self._operation_key(resolved.owner_id, resolved.operation_id)
+ client = self._client_factory()
+ try:
+ return self._create(client, key, resolved)
+ except MpaOperationConflict:
+ current = self._read(client, key, MpaLifecycleOperation)
+ if (
+ current.value.idempotency_key_hash == resolved.idempotency_key_hash
+ and current.value.request_hash == resolved.request_hash
+ and current.value.operation_kind == resolved.operation_kind
+ and current.value.target_key == resolved.target_key
+ ):
+ return current
+ raise MpaOperationConflict(
+ "MPA operation idempotency key was reused with different input."
+ )
+
+ def _get_operation(
+ self, owner_id: str, operation_id: str
+ ) -> Stored[MpaLifecycleOperation]:
+ return self._read(
+ self._client_factory(),
+ self._operation_key(owner_id, operation_id),
+ MpaLifecycleOperation,
+ )
+
+ def _update_operation(
+ self,
+ operation: MpaLifecycleOperation,
+ etag: str,
+ ) -> Stored[MpaLifecycleOperation]:
+ resolved = self._with_operation_id(operation)
+ return self._replace(
+ self._client_factory(),
+ self._operation_key(resolved.owner_id, resolved.operation_id),
+ resolved,
+ etag,
+ )
+
+ def _list_active_operations(self, owner_id: str) -> list[MpaLifecycleOperation]:
+ client = self._client_factory()
+ operations: list[MpaLifecycleOperation] = []
+ for key in self._list_keys(client, f"{self._owner_prefix(owner_id)}/"):
+ if not key.endswith(".json"):
+ continue
+ try:
+ operation = self._read(client, key, MpaLifecycleOperation).value
+ except MpaOperationNotFound:
+ continue
+ if operation.status not in _TERMINAL_STATUSES:
+ operations.append(operation)
+ return sorted(operations, key=lambda item: item.updated_at, reverse=True)
+
+ def _with_operation_id(
+ self, operation: MpaLifecycleOperation
+ ) -> MpaLifecycleOperation:
+ if operation.operation_id:
+ return operation
+ return operation.model_copy(
+ update={
+ "operation_id": operation_id_for(
+ owner_id=operation.owner_id,
+ operation_kind=operation.operation_kind,
+ target_key=operation.target_key,
+ idempotency_key_hash=operation.idempotency_key_hash,
+ )
+ }
+ )
+
+ def _create(self, client: Any, key: str, value: _Value) -> Stored[_Value]:
+ content = self._encode(value)
+ try:
+ output = client.put_object(
+ bucket=self._bucket,
+ key=key,
+ content=content,
+ content_length=len(content),
+ content_type="application/json",
+ forbid_overwrite=True,
+ )
+ except Exception as error:
+ if _status_code(error) in {409, 412}:
+ raise MpaOperationConflict("MPA operation already exists.") from error
+ raise
+ return Stored(
+ value=value, etag=self._write_etag(output, client, key), created=True
+ )
+
+ def _replace(
+ self, client: Any, key: str, value: _Value, etag: str
+ ) -> Stored[_Value]:
+ content = self._encode(value)
+ try:
+ output = client.put_object(
+ bucket=self._bucket,
+ key=key,
+ content=content,
+ content_length=len(content),
+ content_type="application/json",
+ if_match=etag,
+ )
+ except Exception as error:
+ if _status_code(error) in {409, 412}:
+ raise MpaOperationConflict(
+ "MPA operation changed concurrently; reload and try again."
+ ) from error
+ if _status_code(error) == 404:
+ raise MpaOperationNotFound("MPA operation does not exist.") from error
+ raise
+ return Stored(value=value, etag=self._write_etag(output, client, key))
+
+ def _read(self, client: Any, key: str, model: type[_Value]) -> Stored[_Value]:
+ payload, etag = self._read_payload(client, key)
+ return Stored(value=model.model_validate(payload), etag=etag)
+
+ def _read_payload(self, client: Any, key: str) -> tuple[dict[str, Any], str]:
+ try:
+ response = client.get_object(bucket=self._bucket, key=key)
+ except Exception as error:
+ if _status_code(error) == 404:
+ raise MpaOperationNotFound("MPA operation does not exist.") from error
+ raise
+ content = response.read(_MAX_OBJECT_BYTES + 1)
+ if not isinstance(content, bytes) or len(content) > _MAX_OBJECT_BYTES:
+ raise ValueError("MPA operation object is invalid or too large.")
+ etag = self._etag(response)
+ if not etag:
+ raise RuntimeError("TOS MPA operation object did not include an ETag.")
+ payload = json.loads(content)
+ if not isinstance(payload, dict):
+ raise TypeError("MPA operation object must be a JSON object.")
+ return payload, etag
+
+ def _list_keys(self, client: Any, prefix: str) -> list[str]:
+ continuation_token = ""
+ keys: list[str] = []
+ while True:
+ output = client.list_objects_type2(
+ bucket=self._bucket,
+ prefix=prefix,
+ continuation_token=continuation_token,
+ max_keys=1000,
+ )
+ keys.extend(
+ str(item.key) for item in (getattr(output, "contents", None) or [])
+ )
+ if not getattr(output, "is_truncated", False):
+ return keys
+ continuation_token = str(
+ getattr(output, "next_continuation_token", "") or ""
+ )
+ if not continuation_token:
+ raise RuntimeError(
+ "TOS truncated an MPA operation listing without a continuation token."
+ )
+
+ @staticmethod
+ def _encode(value: BaseModel) -> bytes:
+ content = json.dumps(
+ value.model_dump(mode="json", by_alias=True),
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode("utf-8")
+ if len(content) > _MAX_OBJECT_BYTES:
+ raise ValueError("MPA operation object is too large.")
+ return content
+
+ def _write_etag(self, output: Any, client: Any, key: str) -> str:
+ etag = self._etag(output)
+ if etag:
+ return etag
+ response = client.get_object(bucket=self._bucket, key=key)
+ etag = self._etag(response)
+ if not etag:
+ raise RuntimeError("TOS MPA operation write did not include an ETag.")
+ return etag
+
+ @staticmethod
+ def _etag(value: Any) -> str:
+ direct = getattr(value, "etag", None)
+ if direct:
+ return str(direct)
+ return str(getattr(getattr(value, "meta", None), "etag", "") or "")
+
+ def _operation_key(self, owner_id: str, operation_id: str) -> str:
+ return f"{self._owner_prefix(owner_id)}/{self._part(operation_id)}.json"
+
+ def _owner_prefix(self, owner_id: str) -> str:
+ return f"{self._root_prefix}/users/{self._part(owner_id)}/mpa-agent-operations"
+
+ @staticmethod
+ def _part(value: str) -> str:
+ text = str(value or "").strip()
+ if not text:
+ raise ValueError("TOS MPA operation key segment cannot be empty.")
+ return quote(text, safe="")
+
+
+class InMemoryMpaOperationRepository:
+ """Process-local MPA operation store for local Studio development."""
+
+ def __init__(self) -> None:
+ self._records: dict[str, tuple[MpaLifecycleOperation, str]] = {}
+ self._version = 0
+ self._lock = asyncio.Lock()
+
+ async def create_operation(
+ self, operation: MpaLifecycleOperation
+ ) -> Stored[MpaLifecycleOperation]:
+ async with self._lock:
+ resolved = self._with_operation_id(operation)
+ current = self._records.get(resolved.operation_id)
+ if current is not None:
+ existing, etag = current
+ if (
+ existing.idempotency_key_hash == resolved.idempotency_key_hash
+ and existing.request_hash == resolved.request_hash
+ and existing.operation_kind == resolved.operation_kind
+ and existing.target_key == resolved.target_key
+ ):
+ return Stored(value=deepcopy(existing), etag=etag, created=False)
+ raise MpaOperationConflict(
+ "MPA operation idempotency key was reused with different input."
+ )
+ etag = self._next_etag()
+ self._records[resolved.operation_id] = (deepcopy(resolved), etag)
+ return Stored(value=deepcopy(resolved), etag=etag, created=True)
+
+ async def get_operation(
+ self, owner_id: str, operation_id: str
+ ) -> Stored[MpaLifecycleOperation]:
+ async with self._lock:
+ current = self._records.get(operation_id)
+ if current is None or current[0].owner_id != owner_id:
+ raise MpaOperationNotFound("MPA operation does not exist.")
+ operation, etag = current
+ return Stored(value=deepcopy(operation), etag=etag)
+
+ async def update_operation(
+ self,
+ operation: MpaLifecycleOperation,
+ etag: str,
+ ) -> Stored[MpaLifecycleOperation]:
+ async with self._lock:
+ resolved = self._with_operation_id(operation)
+ current = self._records.get(resolved.operation_id)
+ if current is None:
+ raise MpaOperationNotFound("MPA operation does not exist.")
+ _existing, current_etag = current
+ if current_etag != etag:
+ raise MpaOperationConflict(
+ "MPA operation changed concurrently; reload and try again."
+ )
+ next_etag = self._next_etag()
+ self._records[resolved.operation_id] = (deepcopy(resolved), next_etag)
+ return Stored(value=deepcopy(resolved), etag=next_etag)
+
+ async def list_active_operations(
+ self, owner_id: str
+ ) -> list[MpaLifecycleOperation]:
+ async with self._lock:
+ operations = [
+ deepcopy(operation)
+ for operation, _etag in self._records.values()
+ if operation.owner_id == owner_id
+ and operation.status not in _TERMINAL_STATUSES
+ ]
+ return sorted(operations, key=lambda item: item.updated_at, reverse=True)
+
+ def _with_operation_id(
+ self, operation: MpaLifecycleOperation
+ ) -> MpaLifecycleOperation:
+ if operation.operation_id:
+ return operation
+ return operation.model_copy(
+ update={
+ "operation_id": operation_id_for(
+ owner_id=operation.owner_id,
+ operation_kind=operation.operation_kind,
+ target_key=operation.target_key,
+ idempotency_key_hash=operation.idempotency_key_hash,
+ )
+ }
+ )
+
+ def _next_etag(self) -> str:
+ self._version += 1
+ return f'"mem-{self._version}"'
+
+
+__all__ = [
+ "InMemoryMpaOperationRepository",
+ "MpaLifecycleOperation",
+ "MpaOperationConflict",
+ "MpaOperationNotFound",
+ "MPA_OPERATION_ROOT_PREFIX",
+ "Stored",
+ "TosMpaOperationRepository",
+ "operation_id_for",
+]
diff --git a/frontend/server/mpa/routes.py b/frontend/server/mpa/routes.py
new file mode 100644
index 000000000..5c70a55b1
--- /dev/null
+++ b/frontend/server/mpa/routes.py
@@ -0,0 +1,1053 @@
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Studio BFF routes for MPA Profile application."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+import inspect
+from typing import Any
+
+from fastapi import Header, HTTPException, Query, Request, Response, status
+from pydantic import BaseModel, ConfigDict, Field
+
+from frontend.server.mpa.runtime_client import (
+ MpaExecutionConfigChange,
+ MpaProfile,
+ MpaProfileApplyRequest,
+ MpaRuntimeClient,
+ MpaRuntimeError,
+)
+from frontend.server.mpa.service import (
+ MpaAgentOperationRequest,
+ MpaAgentOperationService,
+)
+from frontend.server.mpa.operations import MpaOperationConflict
+from frontend.server.mpa.operations import MpaOperationNotFound
+from veadk.integrations.mpa.control_plane_client import (
+ MpaAgentOperationRequest as MpaAgentOperationBody,
+)
+
+RuntimeAuthorizer = Callable[[Request, str, str], Any]
+RuntimeConnectionResolver = Callable[
+ [Request, str, str, Any], tuple[str, str, str, str]
+]
+RuntimeBindingResolver = Callable[[Request, str, str], Any]
+_ACTIVE_SESSION_STATUSES = {"queued", "running", "pausing", "paused", "resuming"}
+
+
+@dataclass(frozen=True)
+class MpaRuntimeBinding:
+ runtime_id: str
+ region: str
+ runtime: Any
+ visible: dict[str, Any]
+
+
+class MpaProfileApplyBody(BaseModel):
+ model_config = ConfigDict(populate_by_name=True, extra="forbid")
+
+ runtime_id: str = Field(alias="runtimeId", min_length=1)
+ region: str = "cn-beijing"
+ mpa_instance_id: str = Field(default="", alias="mpaInstanceId")
+ source_profile_id: str = Field(alias="sourceProfileId", min_length=1)
+ profile: MpaProfile
+ create: bool = False
+ runtime_revision: str = Field(default="", alias="runtimeRevision")
+
+
+class MpaRuntimeSessionBody(BaseModel):
+ model_config = ConfigDict(populate_by_name=True, extra="forbid")
+
+ runtime_id: str = Field(alias="runtimeId", min_length=1)
+ region: str = "cn-beijing"
+
+
+class MpaSessionExecutionConfigPatchBody(MpaRuntimeSessionBody):
+ changes: list[MpaExecutionConfigChange] = Field(default_factory=list)
+
+
+class MpaSessionProfileUpgradeBody(MpaRuntimeSessionBody):
+ target_profile_revision: int = Field(alias="targetProfileRevision", ge=1)
+
+
+def mount_mpa_profile_routes(
+ app: Any,
+ *,
+ runtime_client: MpaRuntimeClient,
+ authorize_runtime: RuntimeAuthorizer,
+ resolve_runtime_connection: RuntimeConnectionResolver,
+ operation_service: MpaAgentOperationService | None = None,
+ owner_resolver: Callable[[Request], str] | None = None,
+ resolve_runtime_bindings: RuntimeBindingResolver | None = None,
+) -> None:
+ def owner_id(request: Request) -> str:
+ return owner_resolver(request) if owner_resolver is not None else "local"
+
+ def operation_request(
+ *,
+ request: Request,
+ mpa_instance_id: str,
+ body: MpaAgentOperationBody,
+ operation_kind: str,
+ create: bool,
+ ) -> MpaAgentOperationRequest:
+ runtime = authorize_runtime(request, body.runtime_id, body.region)
+ endpoint, authorization, _auth_type, _network_type = resolve_runtime_connection(
+ request,
+ body.runtime_id,
+ body.region,
+ runtime,
+ )
+ return MpaAgentOperationRequest(
+ ownerId=owner_id(request),
+ operationKind=operation_kind,
+ targetKey=body.target_key or mpa_instance_id,
+ runtimeId=body.runtime_id,
+ runtimeRegion=body.region,
+ runtimeEndpoint=endpoint,
+ runtimeAuthorization=authorization,
+ mpaInstanceId=mpa_instance_id,
+ sourceProfileId=body.source_profile_id,
+ profile=body.profile,
+ create=create,
+ runtimeRevision=body.runtime_revision,
+ )
+
+ @app.post(
+ "/web/mpa/agents",
+ status_code=status.HTTP_202_ACCEPTED,
+ )
+ async def create_mpa_agent(
+ body: MpaAgentOperationBody, request: Request
+ ) -> dict[str, Any]:
+ if operation_service is None:
+ raise HTTPException(
+ status_code=503, detail="mpa_operation_service_unavailable"
+ )
+ idempotency_key = request.headers.get("Idempotency-Key", "").strip()
+ if not idempotency_key:
+ raise HTTPException(
+ status_code=400,
+ detail={
+ "code": "idempotency_key_required",
+ "message": "idempotency_key_required",
+ },
+ )
+ try:
+ operation = await operation_service.start(
+ operation_request(
+ request=request,
+ mpa_instance_id=body.mpa_instance_id or body.runtime_id,
+ body=body,
+ operation_kind="create",
+ create=True,
+ ),
+ idempotency_key=idempotency_key,
+ )
+ except MpaOperationConflict as error:
+ _raise_operation_conflict(error)
+ return _operation_payload(operation)
+
+ @app.patch(
+ "/web/mpa/agents/{mpa_instance_id}",
+ status_code=status.HTTP_202_ACCEPTED,
+ )
+ async def update_mpa_agent(
+ mpa_instance_id: str,
+ body: MpaAgentOperationBody,
+ request: Request,
+ ) -> dict[str, Any]:
+ if operation_service is None:
+ raise HTTPException(
+ status_code=503, detail="mpa_operation_service_unavailable"
+ )
+ idempotency_key = request.headers.get("Idempotency-Key", "").strip()
+ if not idempotency_key:
+ raise HTTPException(
+ status_code=400,
+ detail={
+ "code": "idempotency_key_required",
+ "message": "idempotency_key_required",
+ },
+ )
+ try:
+ operation = await operation_service.start(
+ operation_request(
+ request=request,
+ mpa_instance_id=mpa_instance_id,
+ body=body,
+ operation_kind="update",
+ create=False,
+ ),
+ idempotency_key=idempotency_key,
+ )
+ except MpaOperationConflict as error:
+ _raise_operation_conflict(error)
+ return _operation_payload(operation)
+
+ @app.get("/web/mpa/agent-operations")
+ async def list_agent_operations(
+ request: Request,
+ status_filter: str = Query(default="active", alias="status"),
+ ) -> dict[str, Any]:
+ if operation_service is None:
+ raise HTTPException(
+ status_code=503, detail="mpa_operation_service_unavailable"
+ )
+ if status_filter != "active":
+ raise HTTPException(status_code=400, detail="unsupported operation status")
+ operations = await operation_service.list_active(owner_id(request))
+ return {
+ "operations": [_operation_payload(operation) for operation in operations]
+ }
+
+ @app.get("/web/mpa/agent-operations/{operation_id}")
+ async def get_agent_operation(
+ operation_id: str, request: Request
+ ) -> dict[str, Any]:
+ if operation_service is None:
+ raise HTTPException(
+ status_code=503, detail="mpa_operation_service_unavailable"
+ )
+ try:
+ operation = await operation_service.get(owner_id(request), operation_id)
+ except MpaOperationNotFound as error:
+ raise HTTPException(
+ status_code=404,
+ detail={
+ "code": "mpa_operation_not_found",
+ "message": "mpa_operation_not_found",
+ },
+ ) from error
+ return _operation_payload(operation)
+
+ @app.get("/web/mpa/agents/{mpa_instance_id}/view")
+ async def mpa_agent_view(
+ mpa_instance_id: str,
+ request: Request,
+ response: Response,
+ runtime_id: str = Query(default="", alias="runtimeId"),
+ region: str = Query(default="all"),
+ ) -> dict[str, Any]:
+ owner = owner_id(request)
+ active_operation = await _active_operation(
+ operation_service,
+ owner,
+ mpa_instance_id,
+ )
+ if runtime_id.strip():
+ normalized_region = (
+ region
+ if region.strip().lower() not in {"", "all", "*"}
+ else "cn-beijing"
+ )
+ runtime = authorize_runtime(request, runtime_id.strip(), normalized_region)
+ bindings = [
+ MpaRuntimeBinding(
+ runtime_id=runtime_id.strip(),
+ region=normalized_region,
+ runtime=runtime,
+ visible=_runtime_visible_payload(
+ runtime,
+ runtime_id.strip(),
+ normalized_region,
+ mpa_instance_id=mpa_instance_id,
+ ),
+ )
+ ]
+ else:
+ bindings = await _runtime_bindings(
+ request=request,
+ mpa_instance_id=mpa_instance_id,
+ region=region,
+ authorize_runtime=authorize_runtime,
+ resolve_runtime_bindings=resolve_runtime_bindings,
+ )
+ if not bindings:
+ return _mpa_agent_view_payload(
+ mpa_instance_id=mpa_instance_id,
+ binding_status="runtime_missing",
+ active_operation=active_operation,
+ safe_error_code="runtime_missing",
+ )
+ if len(bindings) > 1:
+ return _mpa_agent_view_payload(
+ mpa_instance_id=mpa_instance_id,
+ binding_status="binding_ambiguous",
+ active_operation=active_operation,
+ bindings=[binding.visible for binding in bindings],
+ safe_error_code="binding_ambiguous",
+ )
+
+ binding = bindings[0]
+ try:
+ endpoint, bearer_token, _auth_type, _network_type = (
+ resolve_runtime_connection(
+ request,
+ binding.runtime_id,
+ binding.region,
+ binding.runtime,
+ )
+ )
+ profile_status_result = await runtime_client.profile_status(
+ endpoint=endpoint,
+ mpa_instance_id=mpa_instance_id,
+ bearer_token=_bearer_value(bearer_token),
+ )
+ except MpaRuntimeError as error:
+ if error.status_code == 404:
+ return _mpa_agent_view_payload(
+ mpa_instance_id=mpa_instance_id,
+ binding_status="orphan_runtime",
+ runtime=binding.visible,
+ active_operation=active_operation,
+ safe_error_code=error.code,
+ )
+ if error.code == "runtime_legacy_auth_unsupported":
+ return _mpa_agent_view_payload(
+ mpa_instance_id=mpa_instance_id,
+ binding_status="bound",
+ runtime=binding.visible,
+ active_operation=active_operation,
+ safe_error_code=error.code,
+ )
+ _raise_runtime_error(error)
+ payload = _result_payload(profile_status_result)
+ if etag := str(payload.get("etag") or ""):
+ response.headers["ETag"] = etag
+ return _mpa_agent_view_payload(
+ mpa_instance_id=mpa_instance_id,
+ binding_status="bound",
+ runtime=binding.visible,
+ profile=payload,
+ active_operation=active_operation,
+ )
+
+ @app.get("/web/mpa/agents/{mpa_instance_id}/delete-preview")
+ async def mpa_agent_delete_preview(
+ mpa_instance_id: str,
+ request: Request,
+ runtime_id: str = Query(default="", alias="runtimeId"),
+ region: str = Query(default="all"),
+ ) -> dict[str, Any]:
+ return await _mpa_agent_delete_preview_payload(
+ request=request,
+ mpa_instance_id=mpa_instance_id,
+ runtime_id=runtime_id,
+ region=region,
+ runtime_client=runtime_client,
+ authorize_runtime=authorize_runtime,
+ resolve_runtime_connection=resolve_runtime_connection,
+ operation_service=operation_service,
+ owner=owner_id(request),
+ resolve_runtime_bindings=resolve_runtime_bindings,
+ )
+
+ @app.post(
+ "/web/mpa/agent-operations/{operation_id}/retry",
+ status_code=status.HTTP_202_ACCEPTED,
+ )
+ async def retry_agent_operation(
+ operation_id: str,
+ body: MpaAgentOperationBody,
+ request: Request,
+ ) -> dict[str, Any]:
+ if operation_service is None:
+ raise HTTPException(
+ status_code=503, detail="mpa_operation_service_unavailable"
+ )
+ operation_kind = str(body.operation_kind or "").strip() or (
+ "update" if body.runtime_revision.strip() else "create"
+ )
+ if operation_kind not in {"create", "update"}:
+ raise HTTPException(status_code=400, detail="invalid operationKind")
+ mpa_instance_id = body.mpa_instance_id or (
+ body.target_key if operation_kind == "update" else body.runtime_id
+ )
+ if not mpa_instance_id:
+ raise HTTPException(status_code=400, detail="mpaInstanceId is required")
+ try:
+ operation = await operation_service.retry(
+ operation_request(
+ request=request,
+ mpa_instance_id=mpa_instance_id,
+ body=body,
+ operation_kind=operation_kind,
+ create=operation_kind == "create",
+ ),
+ operation_id=operation_id,
+ )
+ except MpaOperationConflict as error:
+ _raise_operation_conflict(error)
+ return _operation_payload(operation)
+
+ @app.put(
+ "/web/mpa/agents/{mpa_instance_id}/profile",
+ status_code=status.HTTP_202_ACCEPTED,
+ )
+ async def apply_profile(
+ mpa_instance_id: str,
+ body: MpaProfileApplyBody,
+ request: Request,
+ response: Response,
+ ) -> dict[str, Any]:
+ idempotency_key = request.headers.get("Idempotency-Key", "").strip()
+ if not idempotency_key:
+ raise HTTPException(
+ status_code=400,
+ detail={
+ "code": "idempotency_key_required",
+ "message": "idempotency_key_required",
+ },
+ )
+ if not body.create and not body.runtime_revision.strip():
+ raise HTTPException(
+ status_code=400,
+ detail="runtimeRevision is required for update",
+ )
+ runtime = authorize_runtime(request, body.runtime_id, body.region)
+ endpoint, bearer_token, _auth_type, _network_type = resolve_runtime_connection(
+ request,
+ body.runtime_id,
+ body.region,
+ runtime,
+ )
+ try:
+ result = await runtime_client.apply_profile(
+ endpoint=endpoint,
+ mpa_instance_id=mpa_instance_id,
+ bearer_token=_bearer_value(bearer_token),
+ idempotency_key=idempotency_key,
+ profile=MpaProfileApplyRequest.from_profile(
+ source_profile_id=body.source_profile_id,
+ profile=body.profile,
+ ),
+ create=body.create,
+ runtime_revision=body.runtime_revision.strip(),
+ )
+ except MpaRuntimeError as error:
+ _raise_runtime_error(error)
+ payload = _result_payload(result)
+ if etag := str(payload.get("etag") or ""):
+ response.headers["ETag"] = etag
+ return payload
+
+ @app.get("/web/mpa/agents/{mpa_instance_id}/profile-status")
+ async def profile_status(
+ mpa_instance_id: str,
+ request: Request,
+ response: Response,
+ runtime_id: str = Query(..., alias="runtimeId", min_length=1),
+ region: str = Query(default="cn-beijing"),
+ ) -> dict[str, Any]:
+ runtime = authorize_runtime(request, runtime_id, region)
+ endpoint, bearer_token, _auth_type, _network_type = resolve_runtime_connection(
+ request,
+ runtime_id,
+ region,
+ runtime,
+ )
+ try:
+ result = await runtime_client.profile_status(
+ endpoint=endpoint,
+ mpa_instance_id=mpa_instance_id,
+ bearer_token=_bearer_value(bearer_token),
+ )
+ except MpaRuntimeError as error:
+ _raise_runtime_error(error)
+ payload = _result_payload(result)
+ if etag := str(payload.get("etag") or ""):
+ response.headers["ETag"] = etag
+ return payload
+
+ @app.get("/web/mpa/sessions/{session_id}/execution-config")
+ async def get_session_execution_config(
+ session_id: str,
+ request: Request,
+ response: Response,
+ runtime_id: str = Query(..., alias="runtimeId", min_length=1),
+ region: str = Query(default="cn-beijing"),
+ ) -> dict[str, Any]:
+ runtime = authorize_runtime(request, runtime_id, region)
+ endpoint, bearer_token, _auth_type, _network_type = resolve_runtime_connection(
+ request,
+ runtime_id,
+ region,
+ runtime,
+ )
+ try:
+ result = await runtime_client.session_execution_config(
+ endpoint=endpoint,
+ session_id=session_id,
+ bearer_token=_bearer_value(bearer_token),
+ )
+ except MpaRuntimeError as error:
+ _raise_runtime_error(error)
+ payload = _result_payload(result)
+ if etag := str(payload.get("etag") or ""):
+ response.headers["ETag"] = etag
+ return payload
+
+ @app.patch("/web/mpa/sessions/{session_id}/execution-config")
+ async def patch_session_execution_config(
+ session_id: str,
+ body: MpaSessionExecutionConfigPatchBody,
+ request: Request,
+ response: Response,
+ if_match: str | None = Header(default=None, alias="If-Match"),
+ ) -> dict[str, Any]:
+ if not if_match:
+ raise HTTPException(
+ status_code=428,
+ detail={
+ "code": "precondition_required",
+ "message": "precondition_required",
+ },
+ )
+ runtime = authorize_runtime(request, body.runtime_id, body.region)
+ endpoint, bearer_token, _auth_type, _network_type = resolve_runtime_connection(
+ request,
+ body.runtime_id,
+ body.region,
+ runtime,
+ )
+ try:
+ result = await runtime_client.patch_session_execution_config(
+ endpoint=endpoint,
+ session_id=session_id,
+ bearer_token=_bearer_value(bearer_token),
+ if_match=if_match,
+ changes=body.changes,
+ )
+ except MpaRuntimeError as error:
+ _raise_runtime_error(error)
+ payload = _result_payload(result)
+ if etag := str(payload.get("etag") or ""):
+ response.headers["ETag"] = etag
+ return payload
+
+ @app.post("/web/mpa/sessions/{session_id}/profile-upgrade")
+ async def upgrade_session_profile(
+ session_id: str,
+ body: MpaSessionProfileUpgradeBody,
+ request: Request,
+ response: Response,
+ idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
+ if_match: str | None = Header(default=None, alias="If-Match"),
+ ) -> dict[str, Any]:
+ if not idempotency_key:
+ raise HTTPException(
+ status_code=400,
+ detail={
+ "code": "idempotency_key_required",
+ "message": "idempotency_key_required",
+ },
+ )
+ if not if_match:
+ raise HTTPException(
+ status_code=428,
+ detail={
+ "code": "precondition_required",
+ "message": "precondition_required",
+ },
+ )
+ runtime = authorize_runtime(request, body.runtime_id, body.region)
+ endpoint, bearer_token, _auth_type, _network_type = resolve_runtime_connection(
+ request,
+ body.runtime_id,
+ body.region,
+ runtime,
+ )
+ try:
+ result = await runtime_client.upgrade_session_profile(
+ endpoint=endpoint,
+ session_id=session_id,
+ bearer_token=_bearer_value(bearer_token),
+ if_match=if_match,
+ idempotency_key=idempotency_key,
+ target_profile_revision=body.target_profile_revision,
+ )
+ except MpaRuntimeError as error:
+ _raise_runtime_error(error)
+ payload = _result_payload(result)
+ if etag := str(payload.get("etag") or ""):
+ response.headers["ETag"] = etag
+ return payload
+
+
+def _bearer_value(value: str) -> str:
+ token = value.strip()
+ if token.lower().startswith("bearer "):
+ return token[7:].strip()
+ return token
+
+
+def _result_payload(result: Any) -> dict[str, Any]:
+ if hasattr(result, "model_dump"):
+ return result.model_dump(mode="json", by_alias=True)
+ if isinstance(result, dict):
+ return dict(result)
+ raise HTTPException(status_code=502, detail="runtime_invalid_response")
+
+
+def _raise_runtime_error(error: MpaRuntimeError) -> None:
+ detail = {
+ "code": error.code,
+ "message": error.code,
+ "requestId": error.request_id,
+ }
+ if error.current_state:
+ detail["currentState"] = error.current_state
+ raise HTTPException(status_code=error.status_code, detail=detail) from error
+
+
+def _raise_operation_conflict(error: MpaOperationConflict) -> None:
+ raise HTTPException(
+ status_code=409,
+ detail={
+ "code": "idempotency_mismatch",
+ "message": "idempotency_mismatch",
+ },
+ ) from error
+
+
+def _operation_payload(operation: Any) -> dict[str, Any]:
+ if hasattr(operation, "model_dump"):
+ return operation.model_dump(mode="json", by_alias=True)
+ if isinstance(operation, dict):
+ return dict(operation)
+ raise HTTPException(status_code=502, detail="mpa_operation_invalid_response")
+
+
+async def _active_operation(
+ operation_service: MpaAgentOperationService | None,
+ owner_id: str,
+ mpa_instance_id: str,
+) -> dict[str, Any] | None:
+ if operation_service is None:
+ return None
+ operations = await operation_service.list_active(owner_id)
+ for operation in operations:
+ payload = _operation_payload(operation)
+ if (
+ str(payload.get("mpaInstanceId") or "") == mpa_instance_id
+ or str(payload.get("runtimeId") or "") == mpa_instance_id
+ or str(payload.get("targetKey") or "") == mpa_instance_id
+ ):
+ return payload
+ return None
+
+
+def _operation_blocks_delete(
+ operation: dict[str, Any] | None,
+ *,
+ binding_status: str,
+ profile: dict[str, Any] | None,
+) -> bool:
+ """Return whether a lifecycle operation still represents unsafe deletion work."""
+ if not operation:
+ return False
+ status = str(operation.get("status") or "")
+ if status == "active":
+ return True
+ if status != "failed_retryable":
+ return False
+ if binding_status != "bound":
+ return True
+ if not profile:
+ return True
+ return str(profile.get("status") or "").casefold() != "applied"
+
+
+async def _runtime_bindings(
+ *,
+ request: Request,
+ mpa_instance_id: str,
+ region: str,
+ authorize_runtime: RuntimeAuthorizer,
+ resolve_runtime_bindings: RuntimeBindingResolver | None,
+) -> list[MpaRuntimeBinding]:
+ if resolve_runtime_bindings is None:
+ if region.strip().lower() in {"", "all", "*"}:
+ return []
+ runtime = authorize_runtime(request, mpa_instance_id, region)
+ return [
+ MpaRuntimeBinding(
+ runtime_id=mpa_instance_id,
+ region=region,
+ runtime=runtime,
+ visible=_runtime_visible_payload(
+ runtime,
+ mpa_instance_id,
+ region,
+ mpa_instance_id=mpa_instance_id,
+ ),
+ )
+ ]
+ result = resolve_runtime_bindings(request, mpa_instance_id, region)
+ if inspect.isawaitable(result):
+ result = await result
+ return [
+ _coerce_runtime_binding(item, mpa_instance_id, region) for item in result or []
+ ]
+
+
+async def _resolve_delete_binding(
+ *,
+ request: Request,
+ mpa_instance_id: str,
+ runtime_id: str,
+ region: str,
+ authorize_runtime: RuntimeAuthorizer,
+ resolve_runtime_bindings: RuntimeBindingResolver | None,
+) -> tuple[str, MpaRuntimeBinding | None, list[MpaRuntimeBinding]]:
+ requested_runtime_id = runtime_id.strip()
+ if requested_runtime_id:
+ normalized_region = (
+ region if region.strip().lower() not in {"", "all", "*"} else "cn-beijing"
+ )
+ runtime = authorize_runtime(request, requested_runtime_id, normalized_region)
+ return (
+ "bound",
+ MpaRuntimeBinding(
+ runtime_id=requested_runtime_id,
+ region=normalized_region,
+ runtime=runtime,
+ visible=_runtime_visible_payload(
+ runtime,
+ requested_runtime_id,
+ normalized_region,
+ mpa_instance_id=mpa_instance_id,
+ ),
+ ),
+ [],
+ )
+ bindings = await _runtime_bindings(
+ request=request,
+ mpa_instance_id=mpa_instance_id,
+ region=region,
+ authorize_runtime=authorize_runtime,
+ resolve_runtime_bindings=resolve_runtime_bindings,
+ )
+ if not bindings:
+ return "runtime_missing", None, []
+ if len(bindings) > 1:
+ return "binding_ambiguous", None, bindings
+ return "bound", bindings[0], []
+
+
+async def _mpa_agent_delete_preview_payload(
+ *,
+ request: Request,
+ mpa_instance_id: str,
+ runtime_id: str,
+ region: str,
+ runtime_client: MpaRuntimeClient,
+ authorize_runtime: RuntimeAuthorizer,
+ resolve_runtime_connection: RuntimeConnectionResolver,
+ operation_service: MpaAgentOperationService | None,
+ owner: str,
+ resolve_runtime_bindings: RuntimeBindingResolver | None,
+) -> dict[str, Any]:
+ active_operation = await _active_operation(
+ operation_service, owner, mpa_instance_id
+ )
+ binding_status, binding, ambiguous_bindings = await _resolve_delete_binding(
+ request=request,
+ mpa_instance_id=mpa_instance_id,
+ runtime_id=runtime_id,
+ region=region,
+ authorize_runtime=authorize_runtime,
+ resolve_runtime_bindings=resolve_runtime_bindings,
+ )
+ blockers: list[str] = []
+ if binding_status != "bound":
+ blockers.append(binding_status)
+ if _operation_blocks_delete(
+ active_operation,
+ binding_status=binding_status,
+ profile=None,
+ ):
+ blockers.append("active_operation")
+ return _delete_preview_payload(
+ mpa_instance_id=mpa_instance_id,
+ binding_status=binding_status,
+ runtime=None,
+ profile=None,
+ active_operation=active_operation,
+ bindings=[item.visible for item in ambiguous_bindings],
+ blockers=blockers,
+ sessions=[],
+ )
+
+ assert binding is not None
+ profile: dict[str, Any] | None = None
+ sessions: list[Any] = []
+ try:
+ endpoint, bearer_token, _auth_type, _network_type = resolve_runtime_connection(
+ request,
+ binding.runtime_id,
+ binding.region,
+ binding.runtime,
+ )
+ profile = _result_payload(
+ await runtime_client.profile_status(
+ endpoint=endpoint,
+ mpa_instance_id=mpa_instance_id,
+ bearer_token=_bearer_value(bearer_token),
+ )
+ )
+ sessions = await runtime_client.list_sessions(
+ endpoint=endpoint,
+ bearer_token=_bearer_value(bearer_token),
+ include_a2a=True,
+ )
+ except MpaRuntimeError as error:
+ if error.status_code == 404:
+ blockers.append("orphan_runtime")
+ binding_status = "orphan_runtime"
+ else:
+ _raise_runtime_error(error)
+ if _operation_blocks_delete(
+ active_operation,
+ binding_status=binding_status,
+ profile=profile,
+ ):
+ blockers.append("active_operation")
+ if _active_session_payloads(sessions):
+ blockers.append("active_sessions")
+ return _delete_preview_payload(
+ mpa_instance_id=mpa_instance_id,
+ binding_status=binding_status,
+ runtime=binding.visible,
+ profile=profile,
+ active_operation=active_operation,
+ bindings=[],
+ blockers=blockers,
+ sessions=sessions,
+ )
+
+
+def _session_payload(session: Any) -> dict[str, Any]:
+ if hasattr(session, "model_dump"):
+ payload = session.model_dump(mode="json", by_alias=True)
+ elif isinstance(session, dict):
+ payload = dict(session)
+ else:
+ payload = {
+ "sessionId": str(
+ getattr(session, "session_id", "") or getattr(session, "id", "")
+ ),
+ "status": str(getattr(session, "status", "") or ""),
+ "appName": str(getattr(session, "app_name", "") or ""),
+ }
+ session_id = str(
+ payload.get("sessionId") or payload.get("session_id") or payload.get("id") or ""
+ )
+ result = {
+ "sessionId": session_id,
+ "status": str(payload.get("status") or ""),
+ "appName": str(payload.get("appName") or payload.get("app_name") or ""),
+ }
+ if bool(payload.get("adminDebug") or payload.get("admin_debug")):
+ result["adminDebug"] = True
+ return result
+
+
+def _active_session_payloads(sessions: list[Any]) -> list[dict[str, Any]]:
+ return [
+ payload
+ for payload in (_session_payload(session) for session in sessions)
+ if payload["status"].casefold() in _ACTIVE_SESSION_STATUSES
+ ]
+
+
+def _delete_preview_payload(
+ *,
+ mpa_instance_id: str,
+ binding_status: str,
+ runtime: dict[str, Any] | None,
+ profile: dict[str, Any] | None,
+ active_operation: dict[str, Any] | None,
+ bindings: list[dict[str, Any]],
+ blockers: list[str],
+ sessions: list[Any],
+) -> dict[str, Any]:
+ session_payloads = [_session_payload(session) for session in sessions]
+ active_sessions = _active_session_payloads(sessions)
+ idle_sessions = [
+ item
+ for item in session_payloads
+ if item["status"].casefold() not in _ACTIVE_SESSION_STATUSES
+ ]
+ debug_count = sum(1 for item in session_payloads if item.get("adminDebug") is True)
+ can_delete = (
+ binding_status == "bound"
+ and not blockers
+ and bool((runtime or {}).get("canManage", True))
+ )
+ return {
+ "mpaInstanceId": mpa_instance_id,
+ "bindingStatus": binding_status,
+ "runtime": runtime,
+ "profile": profile,
+ "activeOperation": active_operation,
+ "bindings": bindings,
+ "canDelete": can_delete,
+ "blockers": blockers,
+ "sessionCounts": {
+ "total": len(session_payloads),
+ "active": len(active_sessions),
+ "idle": len(idle_sessions),
+ "debug": debug_count,
+ },
+ "activeSessions": active_sessions,
+ "cleanupPlan": [
+ {
+ "stage": "runtime_sessions",
+ "description": "Delete visible idle Runtime sessions before deleting the Runtime resource.",
+ "count": len(idle_sessions),
+ },
+ {
+ "stage": "runtime_diagnostics",
+ "description": "Runtime session cleanup removes associated diagnostics and mounted metadata.",
+ "count": len(session_payloads),
+ },
+ {
+ "stage": "agentkit_runtime",
+ "description": "Delete the AgentKit Runtime resource after Runtime-side cleanup is complete.",
+ "count": 1 if binding_status in {"bound", "orphan_runtime"} else 0,
+ },
+ ],
+ }
+
+
+def _coerce_runtime_binding(
+ item: Any,
+ mpa_instance_id: str,
+ requested_region: str,
+) -> MpaRuntimeBinding:
+ if isinstance(item, MpaRuntimeBinding):
+ return item
+ if isinstance(item, dict):
+ runtime = item.get("runtime") or item
+ runtime_id = str(item.get("runtimeId") or item.get("runtime_id") or "")
+ region = str(item.get("region") or requested_region or "")
+ visible = dict(item.get("visible") or item)
+ visible.pop("runtime", None)
+ resolved_runtime_id = runtime_id or str(
+ visible.get("runtimeId") or mpa_instance_id
+ )
+ visible.setdefault("mpaInstanceId", mpa_instance_id)
+ return MpaRuntimeBinding(
+ runtime_id=resolved_runtime_id,
+ region=region,
+ runtime=runtime,
+ visible={**visible, "runtimeId": resolved_runtime_id, "region": region},
+ )
+ runtime_id = str(getattr(item, "runtime_id", "") or mpa_instance_id)
+ region = str(getattr(item, "region", "") or requested_region or "")
+ return MpaRuntimeBinding(
+ runtime_id=runtime_id,
+ region=region,
+ runtime=item,
+ visible=_runtime_visible_payload(
+ item,
+ runtime_id,
+ region,
+ mpa_instance_id=mpa_instance_id,
+ ),
+ )
+
+
+def _runtime_visible_payload(
+ runtime: Any,
+ runtime_id: str,
+ region: str,
+ *,
+ mpa_instance_id: str = "",
+) -> dict[str, Any]:
+ return {
+ "runtimeId": runtime_id,
+ "region": region,
+ "mpaInstanceId": mpa_instance_id
+ or str(getattr(runtime, "mpa_instance_id", "") or ""),
+ "name": str(getattr(runtime, "name", "") or runtime_id),
+ "status": str(getattr(runtime, "status", "") or ""),
+ "currentVersion": getattr(runtime, "current_version_number", None),
+ "readinessPhase": str(getattr(runtime, "readiness_phase", "") or ""),
+ "agentCategory": "mpa",
+ }
+
+
+def _mpa_agent_view_payload(
+ *,
+ mpa_instance_id: str,
+ binding_status: str,
+ runtime: dict[str, Any] | None = None,
+ profile: dict[str, Any] | None = None,
+ active_operation: dict[str, Any] | None = None,
+ bindings: list[dict[str, Any]] | None = None,
+ safe_error_code: str = "",
+) -> dict[str, Any]:
+ can_manage = bool((runtime or {}).get("canManage", True))
+ can_read = binding_status in {"bound", "orphan_runtime"}
+ auth_unsupported = safe_error_code == "runtime_legacy_auth_unsupported"
+ can_write = (
+ binding_status in {"bound", "orphan_runtime"}
+ and can_manage
+ and not auth_unsupported
+ )
+ operation_status = str((active_operation or {}).get("status") or "")
+ operation_blocks_debug = (
+ operation_status in {"active", "failed_retryable"} or auth_unsupported
+ )
+ return {
+ "mpaInstanceId": mpa_instance_id,
+ "bindingStatus": binding_status,
+ "runtime": runtime,
+ "profile": profile,
+ "activeOperation": active_operation,
+ "bindings": bindings or [],
+ "capabilities": {
+ "canRead": can_read,
+ "canWrite": can_write,
+ "canDebug": binding_status == "bound" and not operation_blocks_debug,
+ },
+ "safeError": (
+ {"code": safe_error_code, "message": safe_error_code}
+ if safe_error_code
+ else None
+ ),
+ }
+
+
+__all__ = [
+ "MpaAgentOperationBody",
+ "MpaProfileApplyBody",
+ "MpaRuntimeBinding",
+ "MpaRuntimeSessionBody",
+ "MpaSessionExecutionConfigPatchBody",
+ "MpaSessionProfileUpgradeBody",
+ "_ACTIVE_SESSION_STATUSES",
+ "_active_operation",
+ "_delete_preview_payload",
+ "_mpa_agent_delete_preview_payload",
+ "_operation_blocks_delete",
+ "_session_payload",
+ "mount_mpa_profile_routes",
+]
diff --git a/frontend/server/mpa/runtime_client.py b/frontend/server/mpa/runtime_client.py
new file mode 100644
index 000000000..2409d1015
--- /dev/null
+++ b/frontend/server/mpa/runtime_client.py
@@ -0,0 +1,368 @@
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Typed Studio-to-mpa-agent Profile transport."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from typing import Any
+
+import httpx
+from pydantic import BaseModel, ConfigDict, Field
+
+from veadk.integrations.mpa.control_plane_client import (
+ MpaExecutionConfigChange,
+ MpaProfile,
+ MpaSessionExecutionConfig,
+)
+
+
+class MpaProfileApplyRequest(BaseModel):
+ model_config = ConfigDict(populate_by_name=True, extra="forbid")
+
+ source_profile_id: str = Field(alias="sourceProfileId", min_length=1)
+ source_profile_digest: str = Field(alias="sourceProfileDigest")
+ profile: MpaProfile
+
+ @classmethod
+ def from_profile(
+ cls, *, source_profile_id: str, profile: MpaProfile
+ ) -> "MpaProfileApplyRequest":
+ canonical = json.dumps(
+ profile.model_dump(by_alias=True),
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode("utf-8")
+ return cls(
+ sourceProfileId=source_profile_id,
+ sourceProfileDigest=f"sha256:{hashlib.sha256(canonical).hexdigest()}",
+ profile=profile,
+ )
+
+
+class MpaProfileResult(BaseModel):
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
+
+ operation_id: str = Field(alias="operationId")
+ status: str
+ etag: str = ""
+
+
+class MpaRuntimeSessionSummary(BaseModel):
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
+
+ id: str = ""
+ session_id: str = Field(default="", alias="sessionId")
+ app_name: str = Field(default="", alias="appName")
+ user_id: str = Field(default="", alias="userId")
+ status: str = ""
+ updated_at: str = Field(default="", alias="updatedAt")
+ mpa_instance_id: str = Field(default="", alias="mpaInstanceId")
+ profile_revision: int | None = Field(default=None, alias="profileRevision")
+ admin_debug: bool = Field(default=False, alias="adminDebug")
+
+ @property
+ def stable_id(self) -> str:
+ return self.session_id or self.id
+
+
+class MpaRuntimeError(RuntimeError):
+ def __init__(
+ self,
+ code: str,
+ *,
+ status_code: int,
+ request_id: str = "",
+ current_state: dict[str, Any] | None = None,
+ ) -> None:
+ super().__init__("MPA Runtime request failed")
+ self.code = code
+ self.status_code = status_code
+ self.request_id = request_id
+ self.current_state = current_state
+
+
+def _runtime_error_code(
+ payload: dict[str, Any], *, method: str, url: str, status_code: int
+) -> str:
+ error = payload.get("error") if isinstance(payload, dict) else {}
+ detail = payload.get("detail") if isinstance(payload, dict) else {}
+ code = error.get("code") if isinstance(error, dict) else None
+ if code is None and isinstance(detail, dict):
+ code = detail.get("code")
+ if code is None and isinstance(detail, str):
+ if "X-Jwt-Token" in detail:
+ return "runtime_legacy_auth_unsupported"
+ if (
+ status_code == 404
+ and method.upper() == "GET"
+ and url.rstrip("/").endswith("/profile-status")
+ ):
+ return "profile_not_found"
+ if detail.strip():
+ return detail.strip()
+ return str(code or "runtime_request_failed")
+
+
+class MpaRuntimeClient:
+ def __init__(self, *, transport: httpx.AsyncClient | None = None) -> None:
+ self._transport = transport
+
+ async def apply_profile(
+ self,
+ *,
+ endpoint: str,
+ mpa_instance_id: str,
+ bearer_token: str,
+ idempotency_key: str,
+ profile: MpaProfileApplyRequest,
+ create: bool = False,
+ runtime_revision: str = "",
+ ) -> MpaProfileResult:
+ if create and runtime_revision:
+ raise ValueError("create cannot carry a runtime revision")
+ if not create and not runtime_revision:
+ raise ValueError("update requires a runtime revision")
+ headers = {
+ "Authorization": f"Bearer {bearer_token}",
+ "Idempotency-Key": idempotency_key,
+ "If-None-Match" if create else "If-Match": "*"
+ if create
+ else runtime_revision,
+ }
+ response = await self._request(
+ "PUT",
+ f"{endpoint.rstrip('/')}/api/v1/agents/{mpa_instance_id}/profile",
+ headers=headers,
+ json=profile.model_dump(by_alias=True),
+ )
+ return self._result(response)
+
+ async def profile_status(
+ self, *, endpoint: str, mpa_instance_id: str, bearer_token: str
+ ) -> MpaProfileResult:
+ response = await self._request(
+ "GET",
+ f"{endpoint.rstrip('/')}/api/v1/agents/{mpa_instance_id}/profile-status",
+ headers={"Authorization": f"Bearer {bearer_token}"},
+ )
+ return self._result(response)
+
+ async def execution_smoke(
+ self,
+ *,
+ endpoint: str,
+ mpa_instance_id: str,
+ bearer_token: str,
+ idempotency_key: str,
+ ) -> dict[str, Any]:
+ del mpa_instance_id
+ response = await self._request(
+ "POST",
+ f"{endpoint.rstrip('/')}/api/v1/readiness/execution-smoke",
+ headers={
+ "Authorization": f"Bearer {bearer_token}",
+ "Idempotency-Key": idempotency_key,
+ },
+ json={},
+ )
+ payload = response.json()
+ if not isinstance(payload, dict):
+ raise MpaRuntimeError("runtime_invalid_response", status_code=502)
+ return payload
+
+ async def session_execution_config(
+ self,
+ *,
+ endpoint: str,
+ session_id: str,
+ bearer_token: str,
+ ) -> MpaSessionExecutionConfig:
+ response = await self._request(
+ "GET",
+ f"{endpoint.rstrip('/')}/api/v1/sessions/{session_id}/execution-config",
+ headers={"Authorization": f"Bearer {bearer_token}"},
+ )
+ return self._execution_config_result(response)
+
+ async def list_sessions(
+ self,
+ *,
+ endpoint: str,
+ bearer_token: str,
+ include_a2a: bool = True,
+ ) -> list[MpaRuntimeSessionSummary]:
+ response = await self._request(
+ "GET",
+ f"{endpoint.rstrip('/')}/api/v1/sessions",
+ headers={"Authorization": f"Bearer {bearer_token}"},
+ params={"include_a2a": "true" if include_a2a else "false"},
+ )
+ return self._session_list_result(response)
+
+ async def delete_session(
+ self,
+ *,
+ endpoint: str,
+ session_id: str,
+ bearer_token: str,
+ ) -> None:
+ await self._request(
+ "DELETE",
+ f"{endpoint.rstrip('/')}/api/v1/sessions/{session_id}",
+ headers={"Authorization": f"Bearer {bearer_token}"},
+ )
+
+ async def patch_session_execution_config(
+ self,
+ *,
+ endpoint: str,
+ session_id: str,
+ bearer_token: str,
+ if_match: str,
+ changes: list[MpaExecutionConfigChange],
+ ) -> MpaSessionExecutionConfig:
+ response = await self._request(
+ "PATCH",
+ f"{endpoint.rstrip('/')}/api/v1/sessions/{session_id}/execution-config",
+ headers={
+ "Authorization": f"Bearer {bearer_token}",
+ "If-Match": if_match,
+ },
+ json={"changes": [change.model_dump(mode="json") for change in changes]},
+ )
+ return self._execution_config_result(response)
+
+ async def upgrade_session_profile(
+ self,
+ *,
+ endpoint: str,
+ session_id: str,
+ bearer_token: str,
+ if_match: str,
+ idempotency_key: str,
+ target_profile_revision: int,
+ ) -> MpaSessionExecutionConfig:
+ response = await self._request(
+ "POST",
+ f"{endpoint.rstrip('/')}/api/v1/sessions/{session_id}/profile-upgrade",
+ headers={
+ "Authorization": f"Bearer {bearer_token}",
+ "If-Match": if_match,
+ "Idempotency-Key": idempotency_key,
+ },
+ json={"targetProfileRevision": int(target_profile_revision)},
+ )
+ return self._execution_config_result(response)
+
+ async def _request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
+ try:
+ if self._transport is not None:
+ response = await self._transport.request(method, url, **kwargs)
+ else:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ response = await client.request(method, url, **kwargs)
+ except httpx.HTTPError as error:
+ raise MpaRuntimeError("runtime_unavailable", status_code=503) from error
+ if response.status_code >= 400:
+ try:
+ payload = response.json()
+ except ValueError:
+ payload = {}
+ detail = payload.get("detail") if isinstance(payload, dict) else {}
+ error = payload.get("error") if isinstance(payload, dict) else {}
+ current_state = None
+ if isinstance(error, dict) and isinstance(error.get("currentState"), dict):
+ current_state = dict(error["currentState"])
+ elif isinstance(detail, dict) and isinstance(
+ detail.get("currentState"), dict
+ ):
+ current_state = dict(detail["currentState"])
+ raise MpaRuntimeError(
+ _runtime_error_code(
+ payload,
+ method=method,
+ url=url,
+ status_code=response.status_code,
+ ),
+ status_code=response.status_code,
+ request_id=response.headers.get("X-Request-Id", ""),
+ current_state=current_state,
+ )
+ return response
+
+ @staticmethod
+ def _result(response: httpx.Response) -> MpaProfileResult:
+ try:
+ payload = response.json()
+ if not isinstance(payload, dict):
+ raise ValueError
+ payload = dict(payload)
+ payload["etag"] = response.headers.get(
+ "ETag", str(payload.get("etag") or "")
+ )
+ return MpaProfileResult(**payload)
+ except (ValueError, TypeError) as error:
+ raise MpaRuntimeError(
+ "runtime_invalid_response", status_code=502
+ ) from error
+
+ @staticmethod
+ def _execution_config_result(response: httpx.Response) -> MpaSessionExecutionConfig:
+ try:
+ payload = response.json()
+ if not isinstance(payload, dict):
+ raise ValueError
+ payload = dict(payload)
+ payload["etag"] = response.headers.get(
+ "ETag", str(payload.get("etag") or "")
+ )
+ return MpaSessionExecutionConfig(**payload)
+ except (ValueError, TypeError) as error:
+ raise MpaRuntimeError(
+ "runtime_invalid_response", status_code=502
+ ) from error
+
+ @staticmethod
+ def _session_list_result(
+ response: httpx.Response,
+ ) -> list[MpaRuntimeSessionSummary]:
+ try:
+ payload = response.json()
+ if isinstance(payload, list):
+ items = payload
+ elif isinstance(payload, dict):
+ raw_items = (
+ payload.get("sessions")
+ or payload.get("items")
+ or payload.get("data")
+ or []
+ )
+ if not isinstance(raw_items, list):
+ raise ValueError
+ items = raw_items
+ else:
+ raise ValueError
+ return [
+ MpaRuntimeSessionSummary(**item)
+ for item in items
+ if isinstance(item, dict)
+ ]
+ except (ValueError, TypeError) as error:
+ raise MpaRuntimeError(
+ "runtime_invalid_response", status_code=502
+ ) from error
diff --git a/frontend/server/mpa/service.py b/frontend/server/mpa/service.py
new file mode 100644
index 000000000..0b4693326
--- /dev/null
+++ b/frontend/server/mpa/service.py
@@ -0,0 +1,323 @@
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""MPA Agent lifecycle orchestration for Studio."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from collections.abc import Callable
+from datetime import datetime, timezone
+from typing import Any, Literal, Protocol
+
+from pydantic import BaseModel, ConfigDict, Field
+
+from frontend.server.mpa.operations import (
+ MpaLifecycleOperation,
+ MpaOperationConflict,
+ Stored,
+)
+from frontend.server.mpa.runtime_client import (
+ MpaProfile,
+ MpaProfileApplyRequest,
+ MpaRuntimeClient,
+ MpaRuntimeError,
+)
+
+
+class MpaOperationRepository(Protocol):
+ async def create_operation(
+ self, operation: MpaLifecycleOperation
+ ) -> Stored[MpaLifecycleOperation]: ...
+
+ async def get_operation(
+ self, owner_id: str, operation_id: str
+ ) -> Stored[MpaLifecycleOperation]: ...
+
+ async def update_operation(
+ self,
+ operation: MpaLifecycleOperation,
+ etag: str,
+ ) -> Stored[MpaLifecycleOperation]: ...
+
+ async def list_active_operations(
+ self, owner_id: str
+ ) -> list[MpaLifecycleOperation]: ...
+
+
+class MpaRuntimeOrchestrationClient(Protocol):
+ async def apply_profile(self, **kwargs: Any) -> Any: ...
+
+ async def execution_smoke(self, **kwargs: Any) -> Any: ...
+
+
+class MpaAgentOperationRequest(BaseModel):
+ model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True)
+
+ owner_id: str = Field(alias="ownerId", min_length=1)
+ operation_kind: Literal["create", "update"] = Field(alias="operationKind")
+ target_key: str = Field(alias="targetKey", min_length=1)
+ runtime_id: str = Field(alias="runtimeId", min_length=1)
+ runtime_region: str = Field(default="cn-beijing", alias="runtimeRegion")
+ runtime_endpoint: str = Field(alias="runtimeEndpoint", min_length=1)
+ runtime_authorization: str = Field(alias="runtimeAuthorization", min_length=1)
+ mpa_instance_id: str = Field(alias="mpaInstanceId", min_length=1)
+ source_profile_id: str = Field(alias="sourceProfileId", min_length=1)
+ profile: MpaProfile
+ create: bool = False
+ runtime_revision: str = Field(default="", alias="runtimeRevision")
+
+
+class MpaAgentOperationService:
+ """Drive MPA create/update operation stages using idempotent Runtime APIs."""
+
+ def __init__(
+ self,
+ *,
+ repository: MpaOperationRepository,
+ runtime_client: MpaRuntimeOrchestrationClient | MpaRuntimeClient,
+ now: Callable[[], datetime] | None = None,
+ ) -> None:
+ self._repository = repository
+ self._runtime_client = runtime_client
+ self._now = now or (lambda: datetime.now(timezone.utc))
+
+ async def start(
+ self,
+ request: MpaAgentOperationRequest,
+ *,
+ idempotency_key: str,
+ ) -> MpaLifecycleOperation:
+ operation = MpaLifecycleOperation(
+ operationKind=request.operation_kind,
+ ownerId=request.owner_id,
+ targetKey=request.target_key,
+ idempotencyKeyHash=_hash_text(idempotency_key),
+ requestHash=_request_hash(request),
+ stage=(
+ "runtime_preparing"
+ if request.operation_kind == "create"
+ else "profile_applying"
+ ),
+ status="active",
+ mpaInstanceId=request.mpa_instance_id,
+ runtimeId=request.runtime_id,
+ runtimeRegion=request.runtime_region,
+ runtimeRevision=request.runtime_revision,
+ profileDigest=_profile_apply_request(request).source_profile_digest,
+ createdAt=self._now(),
+ updatedAt=self._now(),
+ )
+ stored = await self._repository.create_operation(operation)
+ if not stored.created:
+ return stored.value
+ if stored.value.status not in {"active", "failed_retryable"}:
+ return stored.value
+ if (
+ stored.value.request_hash != operation.request_hash
+ or stored.value.idempotency_key_hash != operation.idempotency_key_hash
+ ):
+ raise MpaOperationConflict(
+ "MPA operation idempotency key was reused with different input."
+ )
+ if stored.value.stage not in {
+ "runtime_preparing",
+ "profile_applying",
+ "smoke_running",
+ }:
+ return stored.value
+ return await self._advance(stored, request)
+
+ async def retry(
+ self,
+ request: MpaAgentOperationRequest,
+ *,
+ operation_id: str,
+ ) -> MpaLifecycleOperation:
+ stored = await self._repository.get_operation(request.owner_id, operation_id)
+ if stored.value.status != "failed_retryable":
+ return stored.value
+ return await self.resume(request, operation_id=operation_id)
+
+ async def resume(
+ self,
+ request: MpaAgentOperationRequest,
+ *,
+ operation_id: str,
+ ) -> MpaLifecycleOperation:
+ stored = await self._repository.get_operation(request.owner_id, operation_id)
+ if stored.value.request_hash != _request_hash(request):
+ raise MpaOperationConflict("MPA operation request does not match.")
+ if stored.value.status not in {"active", "failed_retryable"}:
+ return stored.value
+ next_operation = stored.value.model_copy(
+ update={
+ "status": "active",
+ "safe_error_code": "",
+ "retry_count": stored.value.retry_count + 1,
+ "updated_at": self._now(),
+ }
+ )
+ stored = await self._repository.update_operation(next_operation, stored.etag)
+ return await self._advance(stored, request)
+
+ async def list_active(self, owner_id: str) -> list[MpaLifecycleOperation]:
+ return await self._repository.list_active_operations(owner_id)
+
+ async def get(self, owner_id: str, operation_id: str) -> MpaLifecycleOperation:
+ stored = await self._repository.get_operation(owner_id, operation_id)
+ return stored.value
+
+ async def _advance(
+ self,
+ stored: Stored[MpaLifecycleOperation],
+ request: MpaAgentOperationRequest,
+ ) -> MpaLifecycleOperation:
+ operation = stored.value
+ etag = stored.etag
+ try:
+ if operation.stage == "runtime_preparing":
+ operation, etag = await self._store_stage(
+ operation, etag, stage="profile_applying"
+ )
+ if operation.stage == "profile_applying":
+ profile_result = await self._runtime_client.apply_profile(
+ endpoint=request.runtime_endpoint,
+ mpa_instance_id=request.mpa_instance_id,
+ bearer_token=_bearer_value(request.runtime_authorization),
+ idempotency_key=f"{operation.operation_id}-profile",
+ profile=_profile_apply_request(request),
+ create=request.create,
+ runtime_revision=request.runtime_revision,
+ )
+ profile_payload = _payload(profile_result)
+ operation, etag = await self._store_stage(
+ operation,
+ etag,
+ stage=(
+ "smoke_running"
+ if request.operation_kind == "create"
+ else "succeeded"
+ ),
+ profile_operation_id=str(profile_payload.get("operationId") or ""),
+ profile_revision=_int_or_none(
+ profile_payload.get("profileRevision")
+ ),
+ runtime_revision=str(
+ profile_payload.get("runtimeRevision")
+ or profile_payload.get("etag")
+ or ""
+ ),
+ )
+ if request.operation_kind == "update":
+ operation, _etag = await self._store_stage(
+ operation, etag, status="succeeded", stage="succeeded"
+ )
+ return operation
+ if operation.stage == "smoke_running":
+ smoke_result = await self._runtime_client.execution_smoke(
+ endpoint=request.runtime_endpoint,
+ mpa_instance_id=request.mpa_instance_id,
+ bearer_token=_bearer_value(request.runtime_authorization),
+ idempotency_key=f"{operation.operation_id}-smoke",
+ )
+ smoke_payload = _payload(smoke_result)
+ if str(smoke_payload.get("status") or "") != "passed":
+ raise MpaRuntimeError(
+ str(
+ smoke_payload.get("lastErrorCode") or "not_execution_ready"
+ ),
+ status_code=503,
+ )
+ operation, _etag = await self._store_stage(
+ operation, etag, status="succeeded", stage="runnable"
+ )
+ return operation
+ except MpaRuntimeError as error:
+ failed, _etag = await self._store_stage(
+ operation,
+ etag,
+ status="failed_retryable",
+ safe_error_code=error.code,
+ )
+ return failed
+
+ async def _store_stage(
+ self,
+ operation: MpaLifecycleOperation,
+ etag: str,
+ **changes: Any,
+ ) -> tuple[MpaLifecycleOperation, str]:
+ updated = operation.model_copy(
+ update={
+ **changes,
+ "updated_at": self._now(),
+ }
+ )
+ stored = await self._repository.update_operation(updated, etag)
+ return stored.value, stored.etag
+
+
+def _profile_apply_request(
+ request: MpaAgentOperationRequest,
+) -> MpaProfileApplyRequest:
+ return MpaProfileApplyRequest.from_profile(
+ source_profile_id=request.source_profile_id,
+ profile=request.profile,
+ )
+
+
+def _request_hash(request: MpaAgentOperationRequest) -> str:
+ payload = request.model_dump(mode="json", by_alias=True)
+ payload.pop("runtimeEndpoint", None)
+ payload.pop("runtimeAuthorization", None)
+ return _hash_json(payload)
+
+
+def _hash_text(value: str) -> str:
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
+
+
+def _hash_json(value: Any) -> str:
+ return hashlib.sha256(
+ json.dumps(
+ value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
+ ).encode("utf-8")
+ ).hexdigest()
+
+
+def _payload(value: Any) -> dict[str, Any]:
+ if hasattr(value, "model_dump"):
+ return value.model_dump(mode="json", by_alias=True)
+ if isinstance(value, dict):
+ return value
+ return {}
+
+
+def _int_or_none(value: Any) -> int | None:
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ return None
+
+
+def _bearer_value(value: str) -> str:
+ token = value.strip()
+ if token.lower().startswith("bearer "):
+ return token[7:].strip()
+ return token
+
+
+__all__ = ["MpaAgentOperationRequest", "MpaAgentOperationService"]
diff --git a/frontend/server/mpa/test_scenarios.py b/frontend/server/mpa/test_scenarios.py
new file mode 100644
index 000000000..702764b13
--- /dev/null
+++ b/frontend/server/mpa/test_scenarios.py
@@ -0,0 +1,1654 @@
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Loopback-only scenario controls for browser contract tests."""
+
+from __future__ import annotations
+
+import asyncio
+import hmac
+import json
+import os
+import stat
+import threading
+import time
+from copy import deepcopy
+from pathlib import Path
+from re import fullmatch
+from typing import Any
+
+from fastapi import FastAPI, HTTPException, Request, Response
+from fastapi.responses import JSONResponse, StreamingResponse
+from pydantic import BaseModel, Field
+
+
+class ScenarioRequest(BaseModel):
+ barriers: list[str] = Field(default_factory=list)
+
+
+class _ScenarioState:
+ def __init__(self) -> None:
+ self._condition = threading.Condition()
+ self._active = ""
+ self._scenarios: dict[str, dict[str, Any]] = {}
+
+ def replace(self, scenario: str, barriers: list[str]) -> dict[str, Any]:
+ with self._condition:
+ state = {
+ "barriers": {name: "blocked" for name in barriers},
+ "calls": {},
+ "data": _default_scenario_data(scenario),
+ }
+ self._scenarios[scenario] = state
+ self._active = scenario
+ self._condition.notify_all()
+ return self._copy(state)
+
+ def get(self, scenario: str) -> dict[str, Any]:
+ with self._condition:
+ state = self._scenarios.get(scenario)
+ if state is None:
+ raise KeyError(scenario)
+ return self._copy(state)
+
+ def active(self) -> tuple[str, dict[str, Any]] | None:
+ with self._condition:
+ if not self._active:
+ return None
+ state = self._scenarios.get(self._active)
+ if state is None:
+ return None
+ return self._active, self._copy(state)
+
+ def names(self) -> list[str]:
+ with self._condition:
+ return sorted(self._scenarios)
+
+ def count(self, scenario: str, call: str) -> int:
+ with self._condition:
+ state = self._require(scenario)
+ calls = state["calls"]
+ calls[call] = int(calls.get(call, 0)) + 1
+ self._condition.notify_all()
+ return int(calls[call])
+
+ def append_payload(self, scenario: str, key: str, payload: dict[str, Any]) -> None:
+ with self._condition:
+ state = self._require(scenario)
+ payloads = state["data"].setdefault("payloads", {})
+ items = payloads.setdefault(key, [])
+ items.append(deepcopy(payload))
+ self._condition.notify_all()
+
+ def mark_operation_complete(self, scenario: str) -> None:
+ with self._condition:
+ state = self._require(scenario)
+ state["data"]["operationComplete"] = True
+ self._condition.notify_all()
+
+ def release(self, scenario: str, barrier: str) -> None:
+ with self._condition:
+ state = self._require(scenario)
+ if barrier not in state["barriers"]:
+ raise KeyError(barrier)
+ state["barriers"][barrier] = "released"
+ if barrier == "operation_complete":
+ state["data"]["operationComplete"] = True
+ if scenario == "turn_lifecycle":
+ participants = state["data"].setdefault("participants", {})
+ participant_id = {
+ "participant_ack": "primary",
+ "primary_ack": "primary",
+ "worker_1_ack": "worker-1",
+ "worker_2_ack": "worker-2",
+ }.get(barrier)
+ if participant_id and participant_id in participants:
+ participants[participant_id] = "paused"
+ self._condition.notify_all()
+
+ def wait_if_blocked(
+ self, scenario: str, barrier: str, timeout: float = 30.0
+ ) -> None:
+ with self._condition:
+ state = self._require(scenario)
+ if barrier not in state["barriers"]:
+ return
+ deadline = time.monotonic() + timeout
+ while state["barriers"].get(barrier) != "released":
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise TimeoutError(barrier)
+ self._condition.wait(timeout=remaining)
+
+ def control(self, scenario: str) -> dict[str, Any]:
+ with self._condition:
+ state = self._require(scenario)
+ return deepcopy(state["data"]["turnControl"])
+
+ def control_action(
+ self,
+ scenario: str,
+ action: str,
+ *,
+ idempotency_key: str,
+ expected_generation: int,
+ ) -> tuple[dict[str, Any], int]:
+ with self._condition:
+ state = self._require(scenario)
+ data = state["data"]
+ control = data["turnControl"]
+ calls = state["calls"]
+ key = f"{action}:{idempotency_key}"
+ replays = data.setdefault("controlReplays", {})
+ if key in replays:
+ calls[f"control-{action}-replay"] = (
+ int(calls.get(f"control-{action}-replay", 0)) + 1
+ )
+ return deepcopy(replays[key]), 200
+ calls[f"control-{action}"] = int(calls.get(f"control-{action}", 0)) + 1
+ payload = {
+ "action": action,
+ "expectedGeneration": expected_generation,
+ "idempotencyKey": idempotency_key,
+ }
+ data.setdefault("payloads", {}).setdefault("control", []).append(payload)
+ if expected_generation != int(control["generation"]):
+ return deepcopy(control), 409
+ if action == "pause":
+ if control["state"] != "running":
+ return deepcopy(control), 409
+ control.update(
+ {
+ "state": "pausing",
+ "generation": int(control["generation"]) + 1,
+ "desiredState": "paused",
+ "allowedActions": [],
+ "resumeDisposition": "same_turn",
+ }
+ )
+ data["participants"] = {
+ "primary": "running",
+ "worker-1": "running",
+ "worker-2": "running",
+ }
+ replays[key] = deepcopy(control)
+ self._condition.notify_all()
+ return deepcopy(control), 200
+ if action == "resume":
+ if control.get("resumeDisposition") == "new_turn_required":
+ return deepcopy(control), 200
+ if control["state"] != "paused":
+ return deepcopy(control), 409
+ control.update(
+ {
+ "state": "running",
+ "generation": int(control["generation"]) + 1,
+ "desiredState": "running",
+ "safePoint": None,
+ "checkpoint": None,
+ "allowedActions": ["pause"],
+ "resumeDisposition": "same_turn",
+ "continuationPrompt": None,
+ }
+ )
+ data["participants"] = {
+ "primary": "running",
+ "worker-1": "running",
+ "worker-2": "running",
+ }
+ replays[key] = deepcopy(control)
+ self._condition.notify_all()
+ return deepcopy(control), 200
+ raise ValueError(action)
+
+ def maybe_advance_pause(self, scenario: str) -> None:
+ with self._condition:
+ state = self._require(scenario)
+ control = state["data"]["turnControl"]
+ participants = state["data"].get("participants", {})
+ if (
+ control["state"] == "pausing"
+ and participants
+ and all(value == "paused" for value in participants.values())
+ ):
+ control.update(
+ {
+ "state": "paused",
+ "desiredState": "paused",
+ "safePoint": "after_tool",
+ "checkpoint": {"participants": sorted(participants)},
+ "allowedActions": ["resume"],
+ "pausedAt": "2026-09-15T00:00:00Z",
+ "resumableUntil": "2026-09-15T00:05:00Z",
+ "resumeDisposition": "same_turn",
+ }
+ )
+ self._condition.notify_all()
+
+ def seed_new_turn_required(self, scenario: str) -> dict[str, Any]:
+ with self._condition:
+ state = self._require(scenario)
+ control = state["data"]["turnControl"]
+ control.update(
+ {
+ "state": "interrupted",
+ "generation": int(control["generation"]) + 1,
+ "desiredState": "running",
+ "allowedActions": ["resume"],
+ "pausedAt": "2026-09-15T00:00:00Z",
+ "resumableUntil": "2026-09-15T00:05:00Z",
+ "resumeDisposition": "new_turn_required",
+ "continuationPrompt": (
+ "Continue from the last safe point without repeating "
+ "completed side effects."
+ ),
+ }
+ )
+ self._condition.notify_all()
+ return deepcopy(control)
+
+ def continue_turn(
+ self,
+ scenario: str,
+ *,
+ idempotency_key: str,
+ expected_generation: int,
+ ) -> tuple[dict[str, Any], int]:
+ with self._condition:
+ state = self._require(scenario)
+ data = state["data"]
+ control = data["turnControl"]
+ calls = state["calls"]
+ key = f"continue:{idempotency_key}"
+ replays = data.setdefault("continuationReplays", {})
+ if key in replays:
+ calls["runtime-continue-replay"] = (
+ int(calls.get("runtime-continue-replay", 0)) + 1
+ )
+ return deepcopy(replays[key]), 200
+ calls["runtime-continue"] = int(calls.get("runtime-continue", 0)) + 1
+ data.setdefault("payloads", {}).setdefault("continue", []).append(
+ {
+ "expectedGeneration": expected_generation,
+ "idempotencyKey": idempotency_key,
+ }
+ )
+ if expected_generation != int(control["generation"]):
+ return deepcopy(control), 409
+ if control.get("resumeDisposition") != "new_turn_required":
+ return {
+ "code": "turn_not_continuable",
+ "message": "turn_not_continuable",
+ }, 409
+ result = {
+ "taskId": control["taskId"],
+ "sessionId": data["sessionId"],
+ "invocationId": f"{scenario}-continued-invocation",
+ "turnId": f"{scenario}-continued-turn",
+ "operationId": f"{scenario}-continued-operation",
+ "executionConfigRevision": data["executionConfig"]["revision"],
+ "continuationOf": f"{scenario}-turn",
+ "idempotentReplay": False,
+ }
+ replays[key] = {**result, "idempotentReplay": True}
+ data.setdefault("events", []).append(
+ {
+ "id": "event-after-continue",
+ "invocationId": result["invocationId"],
+ "author": data["appName"],
+ "partial": False,
+ "content": {
+ "role": "model",
+ "parts": [{"text": "Continued after safe point"}],
+ },
+ "turnComplete": True,
+ }
+ )
+ self._condition.notify_all()
+ return result, 200
+
+ def execution_config(self) -> dict[str, Any]:
+ with self._condition:
+ state = self._require_active_session()
+ return deepcopy(state["data"]["executionConfig"])
+
+ def session_events(self) -> list[dict[str, Any]]:
+ with self._condition:
+ state = self._require_active_session()
+ return deepcopy(state["data"].get("events", []))
+
+ def persist_session_event(self, event: dict[str, Any]) -> None:
+ with self._condition:
+ state = self._require_active_session()
+ events = state["data"].setdefault("events", [])
+ event_id = str(event.get("id") or "")
+ if event_id and any(
+ str(item.get("id") or "") == event_id for item in events
+ ):
+ return
+ events.append(deepcopy(event))
+ self._condition.notify_all()
+
+ def update_execution_config(
+ self,
+ *,
+ if_match: str,
+ changes: list[dict[str, Any]],
+ ) -> tuple[dict[str, Any], bool]:
+ with self._condition:
+ state = self._require_active_session()
+ config = state["data"]["executionConfig"]
+ if if_match != config["etag"]:
+ return deepcopy(config), False
+ next_revision = int(config["revision"]) + 1
+ overrides = deepcopy(config["overrides"])
+ effective_refs = deepcopy(config["effectiveRefs"])
+ for change in changes:
+ category = str(change.get("category") or "")
+ mode = str(change.get("mode") or "")
+ if category == "model":
+ if mode == "inherit":
+ overrides.pop("model", None)
+ effective_refs["model"] = {
+ "id": _profile_model_for(config["profileRevision"])
+ }
+ elif mode == "replace":
+ value = change.get("value")
+ model_id = (
+ str(value.get("id") or "").strip()
+ if isinstance(value, dict)
+ else ""
+ )
+ if model_id:
+ overrides["model"] = {
+ "mode": mode,
+ "value": {"id": model_id},
+ }
+ effective_refs["model"] = {"id": model_id}
+ elif category == "mcpServers":
+ if mode == "clear":
+ overrides["mcpServers"] = {"mode": "clear"}
+ effective_refs["mcpServers"] = []
+ elif mode == "inherit":
+ overrides.pop("mcpServers", None)
+ effective_refs["mcpServers"] = _profile_mcp_for(
+ config["profileRevision"]
+ )
+ config.update(
+ {
+ "revision": next_revision,
+ "etag": f'"{next_revision}"',
+ "overrides": overrides,
+ "effectiveRefs": effective_refs,
+ "updatedAt": "2026-09-15T01:00:00Z",
+ }
+ )
+ self._condition.notify_all()
+ return deepcopy(config), True
+
+ def upgrade_profile(
+ self,
+ *,
+ if_match: str,
+ idempotency_key: str,
+ target_profile_revision: int,
+ ) -> tuple[dict[str, Any], int, str]:
+ with self._condition:
+ state = self._require_active_session()
+ data = state["data"]
+ upgrades = data.setdefault("profileUpgrades", {})
+ prior = upgrades.get(idempotency_key)
+ if prior is not None:
+ if int(prior["targetProfileRevision"]) != target_profile_revision:
+ return (
+ deepcopy(data["executionConfig"]),
+ 409,
+ "idempotency_mismatch",
+ )
+ return deepcopy(prior["result"]), 200, ""
+ config = data["executionConfig"]
+ if if_match != config["etag"]:
+ return deepcopy(config), 412, "execution_config_changed"
+ latest = int(data["latestProfileRevision"])
+ current = int(config["profileRevision"])
+ if target_profile_revision <= current:
+ return deepcopy(config), 409, "downgrade_not_allowed"
+ if target_profile_revision != latest:
+ return deepcopy(config), 409, "profile_revision_unavailable"
+ next_revision = int(config["revision"]) + 1
+ effective_refs = deepcopy(config["effectiveRefs"])
+ if "model" not in config["overrides"]:
+ effective_refs["model"] = {
+ "id": _profile_model_for(target_profile_revision)
+ }
+ if "mcpServers" not in config["overrides"]:
+ effective_refs["mcpServers"] = _profile_mcp_for(target_profile_revision)
+ config.update(
+ {
+ "revision": next_revision,
+ "etag": f'"{next_revision}"',
+ "profileRevision": target_profile_revision,
+ "profileDefaultRevision": target_profile_revision,
+ "effectiveRefs": effective_refs,
+ "updatedAt": "2026-09-15T01:00:00Z",
+ }
+ )
+ result = deepcopy(config)
+ upgrades[idempotency_key] = {
+ "targetProfileRevision": target_profile_revision,
+ "result": result,
+ }
+ self._condition.notify_all()
+ return result, 200, ""
+
+ def _require(self, scenario: str) -> dict[str, Any]:
+ state = self._scenarios.get(scenario)
+ if state is None:
+ raise KeyError(scenario)
+ return state
+
+ def _require_active_session(self) -> dict[str, Any]:
+ if self._active not in {
+ "session_revision_6",
+ "cursor_expired",
+ "turn_lifecycle",
+ }:
+ raise KeyError(self._active)
+ return self._require(self._active)
+
+ @staticmethod
+ def _copy(state: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "barriers": dict(state["barriers"]),
+ "calls": dict(state["calls"]),
+ "data": deepcopy(state["data"]),
+ }
+
+
+def _read_token(path: Path) -> str:
+ mode = stat.S_IMODE(path.stat().st_mode)
+ if mode & 0o077:
+ raise ValueError("scenario token file must have mode 0600")
+ token = path.read_text(encoding="utf-8").strip()
+ if not token:
+ raise ValueError("scenario token file must not be empty")
+ return token
+
+
+def _profile_model_for(profile_revision: int) -> str:
+ return "model-upgraded" if profile_revision >= 7 else "model-base"
+
+
+def _profile_mcp_for(profile_revision: int) -> list[dict[str, str]]:
+ if profile_revision >= 7:
+ return [{"id": "mcp-upgraded"}]
+ return [{"id": "mcp-default"}]
+
+
+def _default_execution_config(session_id: str = "session-s2") -> dict[str, Any]:
+ return {
+ "appName": "a2a-default",
+ "sessionId": session_id,
+ "revision": 6,
+ "etag": '"6"',
+ "mpaInstanceId": "runtime-mpa-s2",
+ "profileRevision": 6,
+ "profileDefaultRevision": 6,
+ "overrides": {},
+ "effectiveRefs": {
+ "model": {"id": _profile_model_for(6)},
+ "mcpServers": _profile_mcp_for(6),
+ },
+ "invalidRefs": [],
+ "updatedBy": "scenario-user",
+ "createdAt": "2026-09-15T00:00:00Z",
+ "updatedAt": "2026-09-15T00:00:00Z",
+ }
+
+
+def _default_scenario_data(scenario: str) -> dict[str, Any]:
+ session_id = (
+ "session-s2" if scenario == "session_revision_6" else f"{scenario}-session"
+ )
+ data = {
+ "runtimeId": "runtime-mpa-s2",
+ "runtimeName": "MPA S2 Fixture",
+ "region": "cn-beijing",
+ "appName": "a2a-default",
+ "sessionId": session_id,
+ "profileRevision": 6,
+ "latestProfileRevision": 7,
+ "executionConfig": _default_execution_config(session_id),
+ "profileUpgrades": {},
+ "events": [],
+ "payloads": {},
+ "turnControl": {
+ "taskId": f"{scenario}-task",
+ "state": "running",
+ "generation": 0,
+ "desiredState": "running",
+ "safePoint": None,
+ "checkpoint": None,
+ "allowedActions": ["pause"],
+ "idempotentReplay": False,
+ "pausedAt": None,
+ "resumableUntil": None,
+ "resumeDisposition": "same_turn",
+ "continuationPrompt": None,
+ },
+ "participants": {
+ "primary": "running",
+ "worker-1": "running",
+ "worker-2": "running",
+ },
+ }
+ if scenario == "cursor_expired":
+ data["events"] = [
+ {
+ "id": "event-before-refresh",
+ "author": data["appName"],
+ "partial": False,
+ "content": {
+ "role": "model",
+ "parts": [{"text": "Before refresh"}],
+ },
+ "turnComplete": True,
+ }
+ ]
+ return data
+
+
+def _runtime_item(data: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "name": data["runtimeName"],
+ "runtimeId": data["runtimeId"],
+ "status": "Ready",
+ "createdAt": "2026-09-15T00:00:00Z",
+ "description": "Browser scenario MPA Runtime",
+ "cpuMilli": 2000,
+ "memoryMb": 4096,
+ "currentVersion": data["latestProfileRevision"],
+ "agentCategory": "mpa",
+ "region": data["region"],
+ "author": "scenario-user",
+ "isMine": True,
+ "canDelete": False,
+ "canManage": True,
+ "canPublish": False,
+ "visibility": "private",
+ "reviewStatus": "",
+ }
+
+
+def _agent_info(data: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "appName": data["appName"],
+ "name": data["runtimeName"],
+ "description": "Browser scenario MPA Agent",
+ "type": "llm",
+ "model": _profile_model_for(data["profileRevision"]),
+ "selectableModels": ["model-base", "model-upgraded", "model-browser-a"],
+ "turnLifecycleControl": (
+ {
+ "actions": ["pause", "resume"],
+ "pauseMode": "cooperative-safe-point",
+ "processReplacementResume": False,
+ }
+ if data["sessionId"].startswith("turn_lifecycle-")
+ else None
+ ),
+ "resourceTopology": None,
+ "tools": [],
+ "skills": [],
+ "skillsPreviewSupported": True,
+ "subAgents": [],
+ "components": [],
+ "searchSources": [],
+ }
+
+
+def _adk_session(
+ data: dict[str, Any], user_id: str = "scenario-user"
+) -> dict[str, Any]:
+ return {
+ "id": data["sessionId"],
+ "userId": user_id,
+ "lastUpdateTime": 1789490000,
+ "events": deepcopy(data.get("events", [])),
+ "state": {},
+ }
+
+
+_CREATION_FAILURES = {
+ "profile_apply_failed": ("profile_applying", "profile_apply_failed"),
+ "smoke_worker_not_ready": ("smoke_running", "smoke_worker_not_ready"),
+ "smoke_output_mismatch": ("smoke_running", "smoke_output_mismatch"),
+ "cleanup_failed": ("smoke_running", "cleanup_failed"),
+}
+
+
+def _scenario_operation(
+ scenario: str, data: dict[str, Any], *, retried: bool = False
+) -> dict[str, Any]:
+ operation = {
+ "operationId": f"mpaop-{scenario}",
+ "operationKind": "create",
+ "ownerId": "scenario-user",
+ "targetKey": f"runtime:{data['region']}:{data['runtimeName']}",
+ "stage": "runnable",
+ "status": "succeeded",
+ "mpaInstanceId": data["runtimeId"],
+ "runtimeId": data["runtimeId"],
+ "runtimeRegion": data["region"],
+ "runtimeRevision": str(data["latestProfileRevision"]),
+ "profileRevision": data["latestProfileRevision"],
+ "profileOperationId": "scenario-profile",
+ "retryCount": 1 if retried else 0,
+ }
+ if scenario in _CREATION_FAILURES and not retried:
+ stage, error_code = _CREATION_FAILURES[scenario]
+ operation.update(
+ {
+ "stage": stage,
+ "status": "failed_retryable",
+ "safeErrorCode": error_code,
+ "profileRevision": (
+ None
+ if scenario == "profile_apply_failed"
+ else data["latestProfileRevision"]
+ ),
+ }
+ )
+ return operation
+
+
+def mount_test_scenario_routes(app: FastAPI, *, token_file: Path) -> bool:
+ """Mount test routes only when both isolation switches are explicit."""
+ if not (
+ os.environ.get("APP_ENV") == "test"
+ and os.environ.get("VEADK_MPA_TEST_SCENARIOS") == "1"
+ ):
+ return False
+ token = _read_token(token_file)
+ state = _ScenarioState()
+
+ def authorize_loopback(request: Request) -> None:
+ remote = request.headers.get("X-MPA-Test-Remote-Addr")
+ if remote is None and request.client is not None:
+ remote = request.client.host
+ if remote not in {"127.0.0.1", "::1", "testclient"}:
+ raise HTTPException(status_code=403, detail="loopback_required")
+
+ def authorize(request: Request) -> None:
+ authorize_loopback(request)
+ supplied = request.headers.get("Authorization", "")
+ expected = f"Bearer {token}"
+ if not hmac.compare_digest(supplied, expected):
+ raise HTTPException(status_code=401, detail="invalid_scenario_token")
+
+ def active_mpa_scenario(
+ request: Request,
+ ) -> tuple[str, dict[str, Any]] | None:
+ authorize_loopback(request)
+ active = state.active()
+ if active is None:
+ return None
+ return active[0], active[1]["data"]
+
+ def active_s2_data(request: Request) -> dict[str, Any] | None:
+ active = active_mpa_scenario(request)
+ if active is None or active[0] != "session_revision_6":
+ return None
+ return active[1]
+
+ def active_mpa_data(request: Request) -> dict[str, Any] | None:
+ active = active_mpa_scenario(request)
+ if active is None:
+ return None
+ return active[1]
+
+ def current_state_response(
+ config: dict[str, Any], status_code: int, code: str
+ ) -> JSONResponse:
+ return JSONResponse(
+ {
+ "detail": {
+ "code": code,
+ "message": code,
+ "requestId": "scenario-request",
+ "currentState": config,
+ }
+ },
+ status_code=status_code,
+ )
+
+ @app.get("/web/runtime-name-availability")
+ def fixture_runtime_name_availability(
+ request: Request, name: str = "", region: str = ""
+ ) -> dict[str, bool]:
+ active = active_mpa_scenario(request)
+ if active is None:
+ raise HTTPException(status_code=404, detail="scenario_not_found")
+ scenario, _data = active
+ state.count(scenario, "runtime-name-availability")
+ state.append_payload(
+ scenario,
+ "runtime-name-availability",
+ {"name": name, "region": region},
+ )
+ return {"available": True}
+
+ @app.post("/web/generated-agent-projects")
+ async def fixture_generated_agent_projects(request: Request) -> dict[str, Any]:
+ active = active_mpa_scenario(request)
+ if active is None:
+ raise HTTPException(status_code=404, detail="scenario_not_found")
+ scenario, _data = active
+ payload = await request.json()
+ state.count(scenario, "generated-agent-projects")
+ state.append_payload(scenario, "generated-agent-projects", payload)
+ draft = payload.get("draft") if isinstance(payload, dict) else {}
+ name = str(draft.get("name") or "scenario-agent")
+ project_name = name.strip().lower().replace("-", "_").replace(" ", "_")
+ return {
+ "name": project_name or "scenario_agent",
+ "files": [
+ {
+ "path": "app.py",
+ "content": "# browser scenario project\n",
+ }
+ ],
+ }
+
+ @app.post("/web/generated-agent-drafts")
+ async def fixture_generated_agent_drafts(request: Request) -> dict[str, Any]:
+ active = active_mpa_scenario(request)
+ if active is None:
+ raise HTTPException(status_code=404, detail="scenario_not_found")
+ scenario, _data = active
+ payload = await request.json()
+ state.count(scenario, "generated-agent-drafts")
+ state.append_payload(scenario, "generated-agent-drafts", payload)
+ return {
+ "draft": {
+ "name": "ime_generated",
+ "description": "IME scenario",
+ "instruction": "Generated after composition ends.",
+ "agentType": "llm",
+ "subAgents": [],
+ },
+ "summary": "IME scenario generated",
+ "unresolvedItems": [],
+ }
+
+ @app.post("/web/deploy-agentkit")
+ async def fixture_deploy_agentkit(request: Request) -> StreamingResponse:
+ active = active_mpa_scenario(request)
+ if active is None:
+ raise HTTPException(status_code=404, detail="scenario_not_found")
+ scenario, data = active
+ payload = await request.json()
+ state.count(scenario, "deploy-agentkit")
+ state.append_payload(scenario, "deploy-agentkit", payload)
+
+ async def body():
+ progress = {
+ "level": "info",
+ "phase": "deploy",
+ "message": "Scenario deployment isolated",
+ "pct": 50,
+ "runtimeName": data["runtimeName"],
+ }
+ final = {
+ "done": True,
+ "success": True,
+ "agentName": data["appName"],
+ "runtimeName": data["runtimeName"],
+ "runtimeId": data["runtimeId"],
+ "mpaInstanceId": data["runtimeId"],
+ "region": data["region"],
+ "version": data["latestProfileRevision"],
+ }
+ yield f"data: {json.dumps(progress)}\n\n".encode("utf-8")
+ try:
+ await asyncio.to_thread(
+ state.wait_if_blocked, scenario, "deploy_complete"
+ )
+ except TimeoutError:
+ return
+ yield f"data: {json.dumps(final)}\n\n".encode("utf-8")
+
+ return StreamingResponse(body(), media_type="text/event-stream")
+
+ @app.post("/web/mpa/agents", status_code=202)
+ async def fixture_create_mpa_agent(request: Request) -> dict[str, Any]:
+ active = active_mpa_scenario(request)
+ if active is None:
+ raise HTTPException(status_code=404, detail="scenario_not_found")
+ scenario, data = active
+ payload = await request.json()
+ state.count(scenario, "mpa-agent-create")
+ state.append_payload(scenario, "mpa-agent-create", payload)
+ if scenario == "create_success":
+ try:
+ await asyncio.to_thread(
+ state.wait_if_blocked, scenario, "operation_complete"
+ )
+ except TimeoutError as error:
+ raise HTTPException(
+ status_code=504, detail="scenario_barrier_timeout"
+ ) from error
+ state.mark_operation_complete(scenario)
+ if "operation_complete" in state.get(scenario)["barriers"]:
+ state.count(scenario, "mpa-operation-complete")
+ return _scenario_operation(scenario, data)
+
+ @app.get("/web/mpa/agent-operations")
+ def fixture_list_mpa_operations(
+ request: Request, status: str = ""
+ ) -> dict[str, Any]:
+ active = active_mpa_scenario(request)
+ if active is None:
+ return {"operations": []}
+ scenario, data = active
+ state.count(scenario, "mpa-operation-list")
+ scenario_state = state.get(scenario)
+ if scenario_state["calls"].get("mpa-agent-create", 0) == 0:
+ return {"operations": []}
+ operation_complete = bool(
+ scenario_state["data"].get("operationComplete")
+ or scenario_state["calls"].get("mpa-operation-complete", 0) > 0
+ )
+ operation = _scenario_operation(
+ scenario,
+ data,
+ retried=scenario_state["calls"].get("mpa-operation-retry-complete", 0) > 0,
+ )
+ if scenario == "create_success" and not operation_complete:
+ operation.update({"status": "active", "stage": "profile_applying"})
+ if status == "active" and operation["status"] not in {
+ "active",
+ "failed_retryable",
+ }:
+ return {"operations": []}
+ return {"operations": [operation]}
+
+ @app.get("/web/mpa/agent-operations/{operation_id}")
+ def fixture_get_mpa_operation(
+ operation_id: str, request: Request
+ ) -> dict[str, Any]:
+ active = active_mpa_scenario(request)
+ if active is None:
+ raise HTTPException(status_code=404, detail="scenario_not_found")
+ scenario, data = active
+ scenario_state = state.get(scenario)
+ if scenario_state["calls"].get("mpa-agent-create", 0) == 0:
+ raise HTTPException(status_code=404, detail="mpa_operation_not_found")
+ operation_complete = bool(
+ scenario_state["data"].get("operationComplete")
+ or scenario_state["calls"].get("mpa-operation-complete", 0) > 0
+ )
+ operation = _scenario_operation(
+ scenario,
+ data,
+ retried=scenario_state["calls"].get("mpa-operation-retry-complete", 0) > 0,
+ )
+ if scenario == "create_success" and not operation_complete:
+ operation.update({"status": "active", "stage": "profile_applying"})
+ if operation_id != operation["operationId"]:
+ raise HTTPException(status_code=404, detail="mpa_operation_not_found")
+ state.count(scenario, "mpa-operation-get")
+ return operation
+
+ @app.post("/web/mpa/agent-operations/{operation_id}/retry", status_code=202)
+ async def fixture_retry_mpa_operation(
+ operation_id: str, request: Request
+ ) -> dict[str, Any]:
+ active = active_mpa_scenario(request)
+ if active is None:
+ raise HTTPException(status_code=404, detail="scenario_not_found")
+ scenario, data = active
+ operation = _scenario_operation(scenario, data)
+ if operation_id != operation["operationId"]:
+ raise HTTPException(status_code=404, detail="mpa_operation_not_found")
+ payload = await request.json()
+ state.count(scenario, "mpa-operation-retry")
+ state.append_payload(scenario, "mpa-operation-retry", payload)
+ try:
+ await asyncio.to_thread(state.wait_if_blocked, scenario, "retry_complete")
+ except TimeoutError as error:
+ raise HTTPException(
+ status_code=504, detail="scenario_barrier_timeout"
+ ) from error
+ state.count(scenario, "mpa-operation-retry-complete")
+ return _scenario_operation(scenario, data, retried=True)
+
+ @app.get("/web/runtimes")
+ def fixture_runtimes(
+ request: Request,
+ agentCategory: str = "",
+ scope: str = "",
+ region: str = "",
+ ) -> dict[str, Any]:
+ active = active_mpa_scenario(request)
+ if active is None:
+ return {"runtimes": [], "nextToken": ""}
+ scenario, data = active
+ category = agentCategory.strip().lower()
+ if category and category != "mpa":
+ return {"runtimes": [], "nextToken": ""}
+ if scenario == "slow_scope_switch":
+ user = request.headers.get("X-VeADK-Local-User", "").strip()
+ if scope != "mine":
+ raise HTTPException(status_code=404, detail="resource_not_found")
+ if user == "alice" and region == "cn-beijing":
+ state.count(scenario, "runtime-list-old")
+ try:
+ state.wait_if_blocked(scenario, "old_scope_response")
+ except TimeoutError as error:
+ raise HTTPException(
+ status_code=504, detail="scenario_barrier_timeout"
+ ) from error
+ item = _runtime_item(
+ {
+ **data,
+ "runtimeId": "runtime-same-id",
+ "runtimeName": "MPA Scope Alice",
+ "region": "cn-beijing",
+ }
+ )
+ return {"runtimes": [item], "nextToken": ""}
+ if user == "bob" and region == "cn-shanghai":
+ state.count(scenario, "runtime-list-current")
+ item = _runtime_item(
+ {
+ **data,
+ "runtimeId": "runtime-same-id",
+ "runtimeName": "MPA Scope Bob",
+ "region": "cn-shanghai",
+ }
+ )
+ return {"runtimes": [item], "nextToken": ""}
+ state.count(scenario, "runtime-list-denied")
+ raise HTTPException(status_code=404, detail="resource_not_found")
+ state.count(scenario, "runtime-list")
+ return {"runtimes": [_runtime_item(data)], "nextToken": ""}
+
+ @app.get("/web/mpa/agents/{mpa_instance_id}/view")
+ def fixture_mpa_agent_view(
+ mpa_instance_id: str,
+ request: Request,
+ ) -> dict[str, Any]:
+ active = active_mpa_scenario(request)
+ if active is None:
+ raise HTTPException(status_code=404, detail="scenario_not_found")
+ scenario, data = active
+ if scenario == "slow_scope_switch":
+ user = request.headers.get("X-VeADK-Local-User", "").strip()
+ if user not in {"alice", "bob"}:
+ state.count(scenario, "mpa-view-denied")
+ raise HTTPException(status_code=404, detail="resource_not_found")
+ if mpa_instance_id != data["runtimeId"]:
+ raise HTTPException(status_code=404, detail="runtime_not_found")
+
+ runtime = _runtime_item(data)
+ profile = {
+ "operationId": "scenario-profile",
+ "status": "applied",
+ "profileRevision": data["latestProfileRevision"],
+ "runtimeRevision": str(data["latestProfileRevision"]),
+ "etag": f'"{data["latestProfileRevision"]}"',
+ }
+ binding_status = "bound"
+ bindings: list[dict[str, Any]] = []
+ safe_error = None
+ can_read = True
+ can_write = True
+ can_debug = True
+ active_operation = None
+ if scenario in _CREATION_FAILURES:
+ scenario_state = state.get(scenario)
+ recovered = (
+ scenario_state["calls"].get("mpa-operation-retry-complete", 0) > 0
+ )
+ if not recovered:
+ active_operation = _scenario_operation(scenario, data)
+ can_debug = False
+ if scenario == "runtime_missing":
+ binding_status = "runtime_missing"
+ runtime = None
+ profile = None
+ can_read = can_write = can_debug = False
+ safe_error = {"code": "runtime_missing", "message": "runtime_missing"}
+ elif scenario == "binding_ambiguous":
+ binding_status = "binding_ambiguous"
+ bindings = [
+ runtime,
+ {
+ **runtime,
+ "runtimeId": "runtime-mpa-s2-secondary",
+ "region": "cn-shanghai",
+ },
+ ]
+ runtime = None
+ profile = None
+ can_read = can_write = can_debug = False
+ safe_error = {"code": "binding_ambiguous", "message": "binding_ambiguous"}
+ elif scenario == "orphan_runtime":
+ binding_status = "orphan_runtime"
+ profile = None
+ can_debug = False
+ safe_error = {"code": "profile_not_found", "message": "profile_not_found"}
+ elif scenario == "old_runtime":
+ runtime = {**runtime, "currentVersion": 1}
+ can_write = can_debug = False
+
+ return {
+ "mpaInstanceId": mpa_instance_id,
+ "bindingStatus": binding_status,
+ "runtime": runtime,
+ "profile": profile,
+ "activeOperation": active_operation,
+ "bindings": bindings,
+ "capabilities": {
+ "canRead": can_read,
+ "canWrite": can_write,
+ "canDebug": can_debug,
+ },
+ "safeError": safe_error,
+ }
+
+ @app.get("/web/runtime-detail")
+ def fixture_runtime_detail(
+ request: Request,
+ runtimeId: str = "",
+ region: str = "",
+ ) -> dict[str, Any]:
+ data = active_mpa_data(request)
+ if data is None or runtimeId != data["runtimeId"]:
+ raise HTTPException(status_code=404, detail="runtime_not_found")
+ return {
+ "runtimeId": data["runtimeId"],
+ "name": data["runtimeName"],
+ "description": "Browser scenario MPA Runtime",
+ "status": "Ready",
+ "statusMessage": "",
+ "model": _profile_model_for(data["profileRevision"]),
+ "project": "mpa-s2-browser",
+ "region": region or data["region"],
+ "createdAt": "2026-09-15T00:00:00Z",
+ "updatedAt": "2026-09-15T00:00:00Z",
+ "currentVersion": data["latestProfileRevision"],
+ "resources": {},
+ "envs": [],
+ "memoryId": "",
+ "toolId": "",
+ "knowledgeId": "",
+ "mcpToolsetId": "",
+ "artifactUrl": "",
+ "artifactType": "",
+ "networkTypes": ["public"],
+ "endpoint": "http://127.0.0.1/scenario-runtime",
+ "authType": "custom_jwt",
+ }
+
+ @app.get("/web/runtime-update-capability")
+ def fixture_runtime_update_capability(
+ request: Request,
+ runtimeId: str = "",
+ region: str = "",
+ appName: str = "",
+ ) -> dict[str, Any]:
+ data = active_mpa_data(request)
+ if data is None or runtimeId != data["runtimeId"]:
+ raise HTTPException(status_code=404, detail="runtime_not_found")
+ return {
+ "canUpdate": True,
+ "reason": "",
+ "recoveryStatus": "complete",
+ "editMode": "source-preserving",
+ "recoverySource": "agent-info",
+ "warnings": [],
+ "etag": '"7"',
+ "runtime": {
+ "runtimeId": data["runtimeId"],
+ "name": data["runtimeName"],
+ "region": region or data["region"],
+ "currentVersion": data["latestProfileRevision"],
+ "envs": [],
+ "configuredEnvKeys": [],
+ "network": {"type": "public"},
+ },
+ "agent": _agent_info({**data, "appName": appName or data["appName"]}),
+ }
+
+ @app.get("/web/workspaces")
+ def fixture_workspaces(request: Request) -> dict[str, Any]:
+ if active_mpa_data(request) is None:
+ return {"items": []}
+ return {"items": []}
+
+ @app.get("/web/v3/environments")
+ def fixture_environments(request: Request) -> dict[str, Any]:
+ if active_mpa_data(request) is None:
+ return {"items": []}
+ return {"items": []}
+
+ @app.get("/web/runtime-tool-channel/{runtime_id}/capabilities")
+ def fixture_runtime_tool_capabilities(
+ runtime_id: str,
+ request: Request,
+ ) -> dict[str, Any]:
+ data = active_mpa_data(request)
+ if data is None or runtime_id != data["runtimeId"]:
+ raise HTTPException(status_code=404, detail="runtime_not_found")
+ return {
+ "enabled": False,
+ "supported": False,
+ "tools": [],
+ }
+
+ @app.get("/web/evaluation/statuses")
+ def fixture_evaluation_statuses(
+ request: Request,
+ runtimeId: str = "",
+ region: str = "",
+ appName: str = "",
+ userId: str = "",
+ ) -> dict[str, Any]:
+ data = active_mpa_data(request)
+ if data is None or runtimeId != data["runtimeId"]:
+ raise HTTPException(status_code=404, detail="runtime_not_found")
+ return {
+ "runtimeId": runtimeId,
+ "region": region or data["region"],
+ "appName": appName or data["appName"],
+ "userId": userId,
+ "items": [],
+ }
+
+ @app.post("/web/runtime-route-channel/{runtime_id}/connect")
+ def fixture_runtime_route_channel(
+ runtime_id: str,
+ request: Request,
+ ) -> dict[str, Any]:
+ active = active_mpa_scenario(request)
+ if active is None:
+ raise HTTPException(status_code=404, detail="runtime_not_found")
+ scenario, data = active
+ if data is None or runtime_id != data["runtimeId"]:
+ raise HTTPException(status_code=404, detail="runtime_not_found")
+ state.count(scenario, "route-channel-connect")
+ return {
+ "enabled": True,
+ "supported": True,
+ "connected": True,
+ "catalogRevision": "scenario",
+ }
+
+ @app.api_route(
+ "/web/runtime-proxy/{runtime_id}/{path:path}",
+ methods=["GET", "HEAD", "POST", "PATCH", "DELETE"],
+ )
+ async def fixture_runtime_proxy(runtime_id: str, path: str, request: Request):
+ active = active_mpa_scenario(request)
+ if active is None:
+ raise HTTPException(status_code=404, detail="runtime_not_found")
+ scenario, data = active
+ if data is None or runtime_id != data["runtimeId"]:
+ raise HTTPException(status_code=404, detail="runtime_not_found")
+ if request.method == "GET" and path == "list-apps":
+ state.count(scenario, "list-apps")
+ return [data["appName"]]
+ if request.method == "GET" and path == f"web/agent-info/{data['appName']}":
+ return _agent_info(data)
+ if request.method == "GET" and path == f"web/agent-draft/{data['appName']}":
+ return {
+ "draft": {
+ "name": data["runtimeName"],
+ "description": "Browser scenario MPA Agent",
+ "instruction": "Use the scenario fixture.",
+ "modelName": _profile_model_for(data["profileRevision"]),
+ "agentType": "llm",
+ }
+ }
+ profile_status_match = fullmatch(
+ rf"api/v1/agents/{data['runtimeId']}/profile-status",
+ path,
+ )
+ if request.method == "GET" and profile_status_match:
+ state.count(scenario, "runtime-profile-status")
+ return {
+ "operationId": "scenario-profile",
+ "status": "applied",
+ "profileRevision": data["latestProfileRevision"],
+ "runtimeRevision": str(data["latestProfileRevision"]),
+ "etag": f'"{data["latestProfileRevision"]}"',
+ }
+ if request.method == "GET" and path == "api/v1/sessions":
+ state.count(scenario, "runtime-session-list")
+ return [
+ {
+ "sessionId": data["sessionId"],
+ "id": data["sessionId"],
+ "appName": data["appName"],
+ "userId": "scenario-user",
+ "mpaInstanceId": data["runtimeId"],
+ "profileRevision": data["profileRevision"],
+ }
+ ]
+ if request.method == "POST" and path == "api/v1/sessions":
+ state.count(scenario, "runtime-session-create")
+ payload = await request.json()
+ if scenario == "cursor_expired":
+ state.append_payload(scenario, "session-create", payload)
+ return {
+ "sessionId": data["sessionId"],
+ "id": data["sessionId"],
+ "appName": data["appName"],
+ "userId": "scenario-user",
+ "mpaInstanceId": data["runtimeId"],
+ "profileRevision": payload.get(
+ "profileRevision", data["profileRevision"]
+ ),
+ }
+ session_detail_match = fullmatch(
+ rf"api/v1/sessions/{data['sessionId']}",
+ path,
+ )
+ if request.method == "GET" and session_detail_match:
+ state.count(scenario, "runtime-session-read")
+ return _adk_session(data)
+ session_events_match = fullmatch(
+ rf"api/v1/sessions/{data['sessionId']}/events",
+ path,
+ )
+ if request.method == "GET" and session_events_match:
+ state.count(scenario, "runtime-session-events")
+ return {"events": deepcopy(data.get("events", []))}
+ control_by_session_match = fullmatch(
+ rf"api/v1/a2a/tasks/by-session/{data['sessionId']}/control",
+ path,
+ )
+ if request.method == "GET" and control_by_session_match:
+ state.count(scenario, "runtime-control-status")
+ state.maybe_advance_pause(scenario)
+ return state.control(scenario)
+ control_by_task_match = fullmatch(
+ rf"api/v1/a2a/tasks/{data['turnControl']['taskId']}/control",
+ path,
+ )
+ if request.method == "GET" and control_by_task_match:
+ state.count(scenario, "runtime-control-status")
+ state.maybe_advance_pause(scenario)
+ return state.control(scenario)
+ control_action_match = fullmatch(
+ rf"api/v1/a2a/tasks/by-session/{data['sessionId']}/control/(pause|resume)",
+ path,
+ ) or fullmatch(
+ rf"api/v1/a2a/tasks/{data['turnControl']['taskId']}/control/(pause|resume)",
+ path,
+ )
+ if request.method == "POST" and control_action_match:
+ payload = await request.json()
+ idempotency_key = request.headers.get("Idempotency-Key", "").strip()
+ result, status_code = state.control_action(
+ scenario,
+ control_action_match.group(1),
+ idempotency_key=idempotency_key,
+ expected_generation=int(payload.get("expectedGeneration", -1)),
+ )
+ if status_code >= 400:
+ return JSONResponse({"detail": result}, status_code=status_code)
+ return result
+ continue_match = fullmatch(
+ rf"api/v1/a2a/tasks/{data['turnControl']['taskId']}/continue",
+ path,
+ )
+ if request.method == "POST" and continue_match:
+ payload = await request.json()
+ idempotency_key = request.headers.get("Idempotency-Key", "").strip()
+ result, status_code = state.continue_turn(
+ scenario,
+ idempotency_key=idempotency_key,
+ expected_generation=int(payload.get("expectedGeneration", -1)),
+ )
+ if status_code >= 400:
+ return JSONResponse({"detail": result}, status_code=status_code)
+ return result
+ sessions_match = fullmatch(
+ rf"apps/{data['appName']}/users/([^/]+)/sessions(?:/([^/]+))?",
+ path,
+ )
+ if sessions_match:
+ user_id = sessions_match.group(1)
+ requested_session_id = sessions_match.group(2)
+ if requested_session_id and requested_session_id != data["sessionId"]:
+ raise HTTPException(status_code=404, detail="session_not_found")
+ if request.method == "POST":
+ state.count(scenario, "session-create")
+ return _adk_session(data, user_id)
+ if request.method == "GET":
+ return (
+ _adk_session(data, user_id)
+ if requested_session_id
+ else [_adk_session(data, user_id)]
+ )
+ run_match = fullmatch(
+ rf"api/v1/sessions/{data['sessionId']}/run",
+ path,
+ )
+ if request.method == "POST" and run_match:
+ state.count(scenario, "runtime-run")
+ payload = await request.json()
+ if scenario == "cursor_expired":
+ state.append_payload(scenario, "run", payload)
+ return {
+ "sessionId": data["sessionId"],
+ "invocationId": f"{scenario}-invocation",
+ "turnId": f"{scenario}-turn",
+ "operationId": f"{scenario}-operation",
+ "executionConfigRevision": int(
+ payload.get("executionConfigVersion")
+ or data["executionConfig"]["revision"]
+ ),
+ }
+ sse_match = fullmatch(
+ rf"api/v1/sessions/{data['sessionId']}/sse",
+ path,
+ )
+ if request.method == "POST" and sse_match:
+ state.count(scenario, "runtime-sse")
+ payload = await request.json()
+ if scenario == "cursor_expired":
+ state.append_payload(scenario, "sse", payload)
+ last_event_id = str(payload.get("lastEventId") or "")
+ known_event_ids = {
+ str(event.get("id"))
+ for event in state.session_events()
+ if event.get("id")
+ }
+ if last_event_id and last_event_id not in known_event_ids:
+ return JSONResponse(
+ {
+ "detail": {
+ "code": "cursor_expired",
+ "message": "cursor_expired",
+ }
+ },
+ status_code=410,
+ )
+
+ async def body():
+ if (
+ scenario == "turn_lifecycle"
+ and payload.get("invocationId")
+ == "turn_lifecycle-continued-invocation"
+ ):
+ yield (
+ 'data: {"partial":false,"id":"event-after-continue",'
+ '"invocationId":"turn_lifecycle-continued-invocation",'
+ '"content":{"parts":[{"text":"Continued after safe point"}]},'
+ '"turnComplete":true}\n\n'
+ ).encode("utf-8")
+ return
+ events = (
+ state.session_events()
+ if scenario in {"cursor_expired", "turn_lifecycle"}
+ else []
+ )
+ last_event_id = str(payload.get("lastEventId") or "")
+ if not last_event_id:
+ for event in events:
+ yield f"data: {json.dumps(event)}\n\n".encode("utf-8")
+ event = {
+ "id": "event-after-refresh",
+ "invocationId": f"{scenario}-invocation",
+ "author": data["appName"],
+ "partial": False,
+ "content": {
+ "role": "model",
+ "parts": [{"text": "After refresh"}],
+ },
+ "turnComplete": True,
+ }
+ if scenario == "turn_lifecycle":
+ running_event = {
+ "id": "event-lifecycle-running",
+ "invocationId": "turn_lifecycle-invocation",
+ "author": data["appName"],
+ "partial": True,
+ "content": {
+ "role": "model",
+ "parts": [{"text": "Lifecycle task running"}],
+ },
+ "turnComplete": False,
+ }
+ yield f"data: {json.dumps(running_event)}\n\n".encode("utf-8")
+ try:
+ await asyncio.to_thread(
+ state.wait_if_blocked,
+ scenario,
+ "stream_complete",
+ )
+ except TimeoutError:
+ return
+ if scenario == "cursor_expired":
+ state.persist_session_event(event)
+ yield f"data: {json.dumps(event)}\n\n".encode("utf-8")
+
+ return StreamingResponse(body(), media_type="text/event-stream")
+ if request.method == "POST" and path == "run_sse":
+ state.count(scenario, "run-sse")
+
+ async def body():
+ event = {
+ "id": "scenario-event-1",
+ "author": data["appName"],
+ "partial": False,
+ "content": {
+ "role": "model",
+ "parts": [{"text": "Scenario response"}],
+ },
+ "turnComplete": True,
+ }
+ yield f"data: {json.dumps(event)}\\n\\n".encode("utf-8")
+
+ return StreamingResponse(body(), media_type="text/event-stream")
+ raise HTTPException(status_code=404, detail="scenario_runtime_path_not_found")
+
+ @app.get("/web/mpa/agents/{mpa_instance_id}/profile-status")
+ def fixture_profile_status(
+ mpa_instance_id: str,
+ request: Request,
+ response: Response,
+ ) -> dict[str, Any]:
+ data = active_mpa_data(request)
+ if data is None or mpa_instance_id != data["runtimeId"]:
+ raise HTTPException(status_code=404, detail="profile_status_not_found")
+ etag = f'"{data["latestProfileRevision"]}"'
+ response.headers["ETag"] = etag
+ return {
+ "operationId": "scenario-profile",
+ "status": "applied",
+ "profileRevision": data["latestProfileRevision"],
+ "runtimeRevision": str(data["latestProfileRevision"]),
+ "etag": etag,
+ }
+
+ @app.get("/web/mpa/sessions/{session_id}/execution-config")
+ def fixture_get_execution_config(
+ session_id: str,
+ request: Request,
+ response: Response,
+ ) -> dict[str, Any]:
+ data = active_mpa_data(request)
+ if data is None or session_id != data["sessionId"]:
+ raise HTTPException(status_code=404, detail="execution_config_not_found")
+ config = state.execution_config()
+ response.headers["ETag"] = config["etag"]
+ return config
+
+ @app.patch("/web/mpa/sessions/{session_id}/execution-config")
+ async def fixture_patch_execution_config(
+ session_id: str,
+ request: Request,
+ response: Response,
+ ) -> Any:
+ data = active_s2_data(request)
+ if data is None or session_id != data["sessionId"]:
+ raise HTTPException(status_code=404, detail="execution_config_not_found")
+ if_match = request.headers.get("If-Match", "").strip()
+ if not if_match:
+ raise HTTPException(
+ status_code=428,
+ detail={
+ "code": "precondition_required",
+ "message": "precondition_required",
+ },
+ )
+ payload = await request.json()
+ changes = payload.get("changes") if isinstance(payload, dict) else None
+ if not isinstance(changes, list):
+ raise HTTPException(status_code=400, detail="changes must be an array")
+ call = "config-patch"
+ if any(
+ isinstance(change, dict) and change.get("category") == "model"
+ for change in changes
+ ):
+ call = "config-patch-a"
+ elif any(
+ isinstance(change, dict) and change.get("category") == "mcpServers"
+ for change in changes
+ ):
+ call = "config-patch-b"
+ state.count("session_revision_6", call)
+ try:
+ state.wait_if_blocked("session_revision_6", call)
+ except TimeoutError as error:
+ raise HTTPException(
+ status_code=504, detail="scenario_barrier_timeout"
+ ) from error
+ config, updated = state.update_execution_config(
+ if_match=if_match,
+ changes=changes,
+ )
+ if not updated:
+ return current_state_response(config, 412, "execution_config_changed")
+ response.headers["ETag"] = config["etag"]
+ return config
+
+ @app.post("/web/mpa/sessions/{session_id}/profile-upgrade")
+ async def fixture_profile_upgrade(
+ session_id: str,
+ request: Request,
+ response: Response,
+ ) -> Any:
+ data = active_s2_data(request)
+ if data is None or session_id != data["sessionId"]:
+ raise HTTPException(status_code=404, detail="execution_config_not_found")
+ if_match = request.headers.get("If-Match", "").strip()
+ idempotency_key = request.headers.get("Idempotency-Key", "").strip()
+ if not idempotency_key:
+ raise HTTPException(
+ status_code=400,
+ detail={
+ "code": "idempotency_key_required",
+ "message": "idempotency_key_required",
+ },
+ )
+ if not if_match:
+ raise HTTPException(
+ status_code=428,
+ detail={
+ "code": "precondition_required",
+ "message": "precondition_required",
+ },
+ )
+ payload = await request.json()
+ target_revision = (
+ int(payload.get("targetProfileRevision", 0))
+ if isinstance(payload, dict)
+ else 0
+ )
+ state.count("session_revision_6", "profile-upgrade")
+ try:
+ state.wait_if_blocked("session_revision_6", "profile-upgrade")
+ except TimeoutError as error:
+ raise HTTPException(
+ status_code=504, detail="scenario_barrier_timeout"
+ ) from error
+ config, status_code, code = state.upgrade_profile(
+ if_match=if_match,
+ idempotency_key=idempotency_key,
+ target_profile_revision=target_revision,
+ )
+ if status_code == 412:
+ return current_state_response(config, status_code, code)
+ if status_code >= 400:
+ raise HTTPException(
+ status_code=status_code,
+ detail={"code": code, "message": code},
+ )
+ response.headers["ETag"] = config["etag"]
+ return config
+
+ @app.get("/__test/mpa/scenarios")
+ def list_scenarios(request: Request) -> dict[str, Any]:
+ authorize(request)
+ return {"scenarios": state.names()}
+
+ @app.put("/__test/mpa/scenarios/{scenario}")
+ def put_scenario(
+ scenario: str, payload: ScenarioRequest, request: Request
+ ) -> dict[str, Any]:
+ authorize(request)
+ return state.replace(scenario, payload.barriers)
+
+ @app.get("/__test/mpa/scenarios/{scenario}")
+ def get_scenario(scenario: str, request: Request) -> dict[str, Any]:
+ authorize(request)
+ try:
+ return state.get(scenario)
+ except KeyError as error:
+ raise HTTPException(status_code=404, detail="scenario_not_found") from error
+
+ @app.post("/__test/mpa/scenarios/{scenario}/calls/{call}")
+ def record_call(scenario: str, call: str, request: Request) -> dict[str, int]:
+ authorize(request)
+ try:
+ return {"count": state.count(scenario, call)}
+ except KeyError as error:
+ raise HTTPException(status_code=404, detail="scenario_not_found") from error
+
+ @app.post("/__test/mpa/scenarios/{scenario}/barriers/{barrier}/release")
+ def release_barrier(
+ scenario: str, barrier: str, request: Request
+ ) -> dict[str, bool]:
+ authorize(request)
+ try:
+ state.release(scenario, barrier)
+ except KeyError as error:
+ raise HTTPException(status_code=404, detail="barrier_not_found") from error
+ return {"released": True}
+
+ @app.post("/__test/mpa/scenarios/{scenario}/new-turn-required")
+ def seed_new_turn_required(scenario: str, request: Request) -> dict[str, Any]:
+ authorize(request)
+ try:
+ return state.seed_new_turn_required(scenario)
+ except KeyError as error:
+ raise HTTPException(status_code=404, detail="scenario_not_found") from error
+
+ return True
diff --git a/frontend/server/mpa_a2a.py b/frontend/server/mpa_a2a.py
new file mode 100644
index 000000000..fb8bf601f
--- /dev/null
+++ b/frontend/server/mpa_a2a.py
@@ -0,0 +1,51 @@
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Compatibility for MPA agent cards behind shared Runtime gateway paths."""
+
+import re
+from urllib.parse import urlsplit, urlunsplit
+
+
+def mpa_a2a_rpc_url(endpoint: str, advertised_url: str) -> str:
+ """Restore only the known default RPC path on a same-origin Runtime gateway."""
+ try:
+ base = urlsplit(endpoint)
+ card = urlsplit(advertised_url)
+ if (
+ base.scheme not in {"http", "https"}
+ or base.scheme != card.scheme
+ or not base.hostname
+ or base.hostname != card.hostname
+ or (base.port or (443 if base.scheme == "https" else 80))
+ != (card.port or (443 if card.scheme == "https" else 80))
+ or any(
+ (
+ base.username is not None,
+ card.username is not None,
+ base.query,
+ card.query,
+ base.fragment,
+ card.fragment,
+ )
+ )
+ or not re.fullmatch(r"/runtime/[a-z0-9-]+", base.path.rstrip("/"))
+ or card.path != "/a2a/jsonrpc"
+ ):
+ return advertised_url
+ return urlunsplit(
+ (base.scheme, base.netloc, base.path.rstrip("/") + card.path, "", "")
+ )
+ except ValueError:
+ return advertised_url
diff --git a/frontend/server/mpa_agent_info.py b/frontend/server/mpa_agent_info.py
new file mode 100644
index 000000000..47d1d04a1
--- /dev/null
+++ b/frontend/server/mpa_agent_info.py
@@ -0,0 +1,134 @@
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Read-only, allowlisted metadata for the Studio MPA information rail."""
+
+from typing import Any
+
+import httpx
+
+
+def _bound_spaces(card: dict[str, Any] | None, region: str) -> dict[str, Any]:
+ unavailable = {"skillSpaces": [], "skillSpacesStatus": "unsupported"}
+ capabilities = (card or {}).get("capabilities")
+ if not isinstance(capabilities, dict):
+ return unavailable
+ extensions = capabilities.get("extensions")
+ if not isinstance(extensions, list):
+ return unavailable
+ topology = next(
+ (
+ item.get("params")
+ for item in extensions
+ if isinstance(item, dict)
+ and item.get("uri") == "urn:veadk:mpa:resource-topology:v1"
+ ),
+ None,
+ )
+ if not isinstance(topology, dict):
+ return unavailable
+ nodes, edges = topology.get("nodes"), topology.get("edges")
+ if not isinstance(nodes, list) or not isinstance(edges, list):
+ return unavailable
+ agents = {
+ node.get("id")
+ for node in nodes
+ if isinstance(node, dict)
+ and node.get("kind") == "agent"
+ and isinstance(node.get("id"), str)
+ }
+ targets = {
+ edge.get("target")
+ for edge in edges
+ if isinstance(edge, dict)
+ and isinstance(edge.get("source"), str)
+ and edge.get("source") in agents
+ and edge.get("relation") == "mounts"
+ and isinstance(edge.get("target"), str)
+ }
+ ids = dict.fromkeys(
+ node["resourceId"].strip()
+ for node in nodes
+ if isinstance(node, dict)
+ and isinstance(node.get("id"), str)
+ and node.get("id") in targets
+ and node.get("kind") == "skill-space"
+ and node.get("status") == "configured"
+ and isinstance(node.get("resourceId"), str)
+ and node["resourceId"].strip()
+ )
+ return {
+ "skillSpacesStatus": "ready",
+ "skillSpaces": [{"id": space_id, "region": region} for space_id in ids],
+ }
+
+
+async def load_mpa_agent_info(
+ endpoint: str,
+ headers: dict[str, str],
+ card: dict[str, Any] | None,
+ region: str,
+ *,
+ runtime_api_key: str = "",
+) -> dict[str, Any]:
+ """Keep document failures independent from bound-space discovery."""
+ result: dict[str, Any] = {
+ "agentsMd": None,
+ "agentsMdStatus": "error",
+ **_bound_spaces(card, region),
+ }
+ legacy_fallback = False
+ try:
+ async with httpx.AsyncClient(timeout=httpx.Timeout(10, connect=4)) as client:
+ safe_headers = {
+ key: value
+ for key, value in headers.items()
+ if key.lower() != "x-mpa-studio-key"
+ }
+ key = runtime_api_key.strip()
+ if key.lower().startswith("bearer "):
+ key = key[7:].strip()
+ if key:
+ response = await client.get(
+ f"{endpoint.rstrip('/')}/api/v1/studio/agent-info",
+ headers={**safe_headers, "X-MPA-Studio-Key": key},
+ )
+ if response.status_code in (404, 405):
+ legacy_fallback = True
+ response = await client.get(
+ f"{endpoint.rstrip('/')}/api/v1/agents", headers=safe_headers
+ )
+ else:
+ response = await client.get(
+ f"{endpoint.rstrip('/')}/api/v1/agents", headers=safe_headers
+ )
+ if response.status_code in (401, 403):
+ result["agentsMdStatus"] = "unsupported" if legacy_fallback else "forbidden"
+ elif response.status_code in (404, 405, 501):
+ result["agentsMdStatus"] = "unsupported"
+ elif response.is_success:
+ payload = response.json()
+ if not isinstance(payload, dict) or "agentsMd" not in payload:
+ return result
+ document = payload["agentsMd"]
+ if document is not None and not isinstance(document, str):
+ return result
+ result.update(agentsMd=document, agentsMdStatus="ready")
+ for key in ("name", "description", "model"):
+ if isinstance(payload.get(key), str):
+ result[key] = payload[key]
+ except (httpx.HTTPError, ValueError):
+ # Never forward arbitrary upstream bodies or transport credentials.
+ pass
+ return result
diff --git a/frontend/server/mpa_creation.py b/frontend/server/mpa_creation.py
new file mode 100644
index 000000000..c03f7ed80
--- /dev/null
+++ b/frontend/server/mpa_creation.py
@@ -0,0 +1,222 @@
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Authorized Studio entry points for VeADK-owned MPA creation."""
+
+from __future__ import annotations
+
+import os
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+from pathlib import Path
+from typing import Any
+from uuid import NAMESPACE_URL, UUID, uuid5
+
+from fastapi import FastAPI, HTTPException, Request
+from pydantic import (
+ BaseModel,
+ ConfigDict,
+ Field,
+ ValidationError,
+ field_validator,
+ model_validator,
+)
+
+from veadk.integrations.mpa.managed.config import (
+ ConfigurationError,
+ load_studio_profile,
+ validate_creation_resources,
+ validate_image_reference,
+ validate_runtime_name,
+ validate_creation_tos,
+ with_creation_images,
+ with_creation_resources,
+ with_creation_tos,
+)
+from veadk.integrations.mpa.managed.credentials import load_volcengine_credentials
+from veadk.integrations.mpa.managed.tasks import CreationTasks, TaskError, owner_key
+
+
+def _studio_task_path() -> Path:
+ return Path(
+ os.getenv(
+ "VEADK_MPA_TASK_DB",
+ "/tmp/veadk-studio/mpa-creation.sqlite3",
+ )
+ )
+
+
+class CreationRequest(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ requestId: UUID
+ agentId: str = Field(default="", pattern=r"^[a-z0-9][a-z0-9_-]{0,63}$")
+ name: str = ""
+ description: str = Field(default="", max_length=512)
+ region: str = Field(pattern=r"^cn-[a-z]+$", max_length=32)
+ runtimeImage: str = Field(default="", max_length=1024)
+ workerImage: str = Field(default="", max_length=1024)
+ pgHost: str = Field(default="", max_length=255)
+ pgPort: str = Field(default="", max_length=5)
+ openvikingUrl: str = Field(default="", max_length=1024)
+ openvikingResourceId: str = Field(default="", max_length=128)
+ openvikingApiKey: str = Field(default="", max_length=512, repr=False)
+ tosAccessKey: str = Field(default="", max_length=256, repr=False)
+ tosSecretKey: str = Field(default="", max_length=256, repr=False)
+ tosBucket: str = Field(default="", max_length=256)
+
+ @field_validator("runtimeImage", "workerImage")
+ @classmethod
+ def validate_image(cls, value):
+ return validate_image_reference(value)
+
+ @field_validator("name")
+ @classmethod
+ def validate_name(cls, value: str) -> str:
+ return validate_runtime_name(value)
+
+ @model_validator(mode="after")
+ def validate_resources(self):
+ if not self.name and not self.agentId:
+ raise ValueError("Runtime name is required")
+ validate_creation_resources(self.model_dump())
+ validate_creation_tos(self.model_dump())
+ return self
+
+
+def mount_mpa_creation_routes(
+ app: FastAPI, *, owner, service: CreationTasks | None = None, supported=True
+):
+ tasks = service
+
+ def get_tasks():
+ nonlocal tasks
+ if tasks is None:
+ task_path = _studio_task_path()
+ tasks = CreationTasks(task_path)
+ return tasks
+
+ def profile(region):
+ if not supported:
+ raise ConfigurationError("MPA creation requires the Volcengine provider")
+ result = load_studio_profile(region=region)
+ try:
+ load_volcengine_credentials(result.managed.credential_file)
+ except ValueError:
+ raise ConfigurationError(
+ "Configure server deployment credentials"
+ ) from None
+ return None, result
+
+ @app.get("/web/mpa-creation/config")
+ async def inspect(request: Request, region: str):
+ owner(request)
+ try:
+ _, result = profile(region)
+ return result.summary()
+ except ConfigurationError as exc:
+ return {"configured": False, "region": region, "error": str(exc)}
+
+ @app.post("/web/mpa-creation/tasks", status_code=202)
+ async def create(request: Request):
+ studio_runtime_owner = owner(request)
+ identity = owner_key(studio_runtime_owner)
+ content = bytearray()
+ async for chunk in request.stream():
+ content.extend(chunk)
+ if len(content) > 8192:
+ raise HTTPException(413, "Creation request is too large")
+ try:
+ body = CreationRequest.model_validate_json(bytes(content))
+ except ValidationError:
+ raise HTTPException(422, "Invalid MPA creation request") from None
+ try:
+ path, config = profile(body.region)
+ payload = body.model_dump(mode="json")
+ if not payload["agentId"]:
+ payload["agentId"] = (
+ "mi-"
+ + uuid5(
+ NAMESPACE_URL, f"veadk:mpa:{identity}:{body.requestId}"
+ ).hex[:24]
+ )
+ if not payload["name"]:
+ # Keep the exact input shape of already-submitted legacy tasks.
+ payload.pop("name")
+ tos = {
+ field: payload.pop(field)
+ for field in ("tosAccessKey", "tosSecretKey", "tosBucket")
+ }
+ with_creation_resources(config, payload)
+ openviking_api_key = payload.pop("openvikingApiKey")
+ resources = validate_creation_resources(
+ {**payload, "openvikingApiKey": openviking_api_key}
+ )
+ images = config.image_defaults()
+ for field in ("runtimeImage", "workerImage"):
+ if payload[field]:
+ images[field] = payload[field]
+ else:
+ payload.pop(field)
+ for field in ("pgHost", "pgPort", "openvikingUrl", "openvikingResourceId"):
+ if resources[field]:
+ payload[field] = resources[field]
+ else:
+ payload.pop(field)
+ with_creation_tos(with_creation_images(config, images), tos)
+ return await get_tasks().start(
+ identity,
+ payload,
+ config_path=path,
+ timeout=config.managed.timeout_seconds,
+ images=images,
+ secrets={"openvikingApiKey": openviking_api_key},
+ studio_runtime_owner=studio_runtime_owner,
+ tos=tos,
+ )
+ except ConfigurationError as exc:
+ raise HTTPException(400, str(exc)) from None
+ except TaskError as exc:
+ raise HTTPException(409, str(exc)) from None
+
+ @app.get("/web/mpa-creation/tasks/{task_id}")
+ async def status(request: Request, task_id: str):
+ identity = owner_key(owner(request))
+ try:
+ return get_tasks().get(identity, task_id)
+ except TaskError:
+ raise HTTPException(404, "Creation task not found") from None
+
+ @app.post("/web/mpa-creation/tasks/{task_id}/cancel")
+ async def cancel(request: Request, task_id: str):
+ identity = owner_key(owner(request))
+ try:
+ return await get_tasks().cancel(identity, task_id)
+ except TaskError:
+ raise HTTPException(404, "Creation task not found") from None
+
+ async def shutdown():
+ if tasks is not None:
+ await tasks.close()
+
+ previous_lifespan = app.router.lifespan_context
+
+ @asynccontextmanager
+ async def lifespan(instance: FastAPI) -> AsyncIterator[dict[str, Any]]:
+ try:
+ async with previous_lifespan(instance) as state:
+ yield dict(state or {})
+ finally:
+ await shutdown()
+
+ app.router.lifespan_context = lifespan
diff --git a/frontend/server/mpa_cron.py b/frontend/server/mpa_cron.py
new file mode 100644
index 000000000..7e6a7eb2c
--- /dev/null
+++ b/frontend/server/mpa_cron.py
@@ -0,0 +1,196 @@
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""User-scoped task management through authorized MPA Runtime connections."""
+
+import asyncio
+from collections.abc import Callable
+from typing import Any
+
+import httpx
+from fastapi import FastAPI, HTTPException, Path, Query, Request
+
+
+async def task_request(
+ endpoint: str,
+ authorization: str,
+ user_id: str,
+ method: str,
+ suffix: str = "",
+ params: dict | None = None,
+ payload: dict | None = None,
+) -> dict[str, Any]:
+ if not user_id:
+ raise HTTPException(401, "mpa_identity_required")
+ try:
+ async with httpx.AsyncClient(timeout=30, follow_redirects=False) as client:
+ response = await client.request(
+ method,
+ endpoint.rstrip("/") + "/api/v1/esa-cron-tasks" + suffix,
+ headers={"Authorization": authorization, "x-user-id": user_id},
+ params=params,
+ json=payload,
+ )
+ if not response.is_success:
+ raise HTTPException(
+ response.status_code if response.is_error else 502,
+ "mpa_tasks_failed",
+ )
+ data = response.json()
+ if not isinstance(data, dict):
+ raise TypeError("Invalid task response")
+ return data
+ except (httpx.HTTPError, ValueError, TypeError) as error:
+ raise HTTPException(502, "mpa_upstream_failed") from error
+
+
+def mount_routes(
+ app: FastAPI,
+ *,
+ authorize: Callable,
+ connection: Callable,
+ authorization: Callable,
+ region_for: Callable,
+ user_for: Callable,
+) -> None:
+ async def send(
+ request, runtime_id, region, method, suffix="", params=None, payload=None
+ ):
+ region = region_for(region)
+ runtime = await asyncio.to_thread(authorize, request, runtime_id, region)
+ user_id = user_for(request)
+ endpoint, apikey, auth_type, _ = await asyncio.to_thread(
+ connection, runtime_id, region, runtime
+ )
+ return await task_request(
+ endpoint,
+ authorization(request, apikey, auth_type),
+ user_id,
+ method,
+ suffix,
+ params,
+ payload,
+ )
+
+ async def body(request: Request, allowed: set[str]):
+ raw = await request.body()
+ if len(raw) > 32768:
+ raise HTTPException(413, "mpa_payload_too_large")
+ try:
+ payload = await request.json()
+ except ValueError as error:
+ raise HTTPException(422, "mpa_invalid_payload") from error
+ if not isinstance(payload, dict) or set(payload) - allowed:
+ raise HTTPException(422, "mpa_invalid_payload")
+ return payload
+
+ fields = {
+ "name",
+ "agentId",
+ "prompt",
+ "enabled",
+ "schedule",
+ "delivery",
+ "jitterSeconds",
+ "timeoutSeconds",
+ }
+
+ @app.get("/web/mpa-cron/{runtime_id}")
+ async def get_tasks(
+ request: Request,
+ runtime_id: str,
+ region: str,
+ offset: int = Query(0, ge=0),
+ query: str = Query("", max_length=200),
+ ):
+ return await send(
+ request,
+ runtime_id,
+ region,
+ "GET",
+ params={
+ "includeDisabled": "true",
+ "limit": 20,
+ "offset": offset,
+ "query": query,
+ },
+ )
+
+ @app.post("/web/mpa-cron/{runtime_id}")
+ async def create_task(request: Request, runtime_id: str, region: str):
+ return await send(
+ request,
+ runtime_id,
+ region,
+ "POST",
+ payload=await body(request, fields | {"clientToken"}),
+ )
+
+ @app.post("/web/mpa-cron/{runtime_id}/{task_id}")
+ async def update_task(
+ request: Request,
+ runtime_id: str,
+ region: str,
+ task_id: str = Path(pattern=r"^[A-Za-z0-9_-]+$"),
+ ):
+ return await send(
+ request,
+ runtime_id,
+ region,
+ "POST",
+ "/" + task_id,
+ payload=await body(request, fields | {"expectedVersion"}),
+ )
+
+ @app.delete("/web/mpa-cron/{runtime_id}/{task_id}")
+ async def delete_task(
+ request: Request,
+ runtime_id: str,
+ region: str,
+ task_id: str = Path(pattern=r"^[A-Za-z0-9_-]+$"),
+ ):
+ return await send(request, runtime_id, region, "DELETE", "/" + task_id)
+
+ @app.post("/web/mpa-cron/{runtime_id}/{task_id}/run")
+ async def run_task(
+ request: Request,
+ runtime_id: str,
+ region: str,
+ task_id: str = Path(pattern=r"^[A-Za-z0-9_-]+$"),
+ ):
+ return await send(
+ request,
+ runtime_id,
+ region,
+ "POST",
+ "/" + task_id + "/run",
+ payload=await body(request, {"clientToken", "mode"}),
+ )
+
+ @app.get("/web/mpa-cron/{runtime_id}/{task_id}/runs")
+ async def get_runs(
+ request: Request,
+ runtime_id: str,
+ region: str,
+ task_id: str = Path(pattern=r"^[A-Za-z0-9_-]+$"),
+ offset: int = Query(0, ge=0),
+ ):
+ return await send(
+ request,
+ runtime_id,
+ region,
+ "GET",
+ "/" + task_id + "/runs",
+ params={"offset": offset, "limit": 20},
+ )
diff --git a/frontend/server/mpa_identity_callback.py b/frontend/server/mpa_identity_callback.py
new file mode 100644
index 000000000..b7abeb5c3
--- /dev/null
+++ b/frontend/server/mpa_identity_callback.py
@@ -0,0 +1,438 @@
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Server-side OAuth callback relay for AgentKit MPA runtimes."""
+
+from __future__ import annotations
+
+import base64
+import ipaddress
+import json
+import logging
+import re
+from collections.abc import Awaitable, Callable, Iterable
+from dataclasses import dataclass
+from typing import Any
+from urllib.parse import urlencode, urlsplit, urlunsplit
+
+import httpx
+from fastapi import FastAPI
+from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
+
+logger = logging.getLogger(__name__)
+
+MPA_CALLBACK_PATH = "/oauth/callback"
+MPA_RUNTIME_CALLBACK_PATH = "/identity/oauth/callback"
+MAX_STATE_LENGTH = 16 * 1024
+MAX_CALLBACK_RESPONSE_BYTES = 64 * 1024
+CALLBACK_TIMEOUT_SECONDS = 10.0
+
+_SECURITY_HEADERS = {
+ "Cache-Control": "private, no-store, max-age=0",
+ "Content-Security-Policy": (
+ "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; "
+ "form-action 'none'; frame-ancestors 'none'"
+ ),
+ "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
+ "Pragma": "no-cache",
+ "Referrer-Policy": "no-referrer",
+ "X-Content-Type-Options": "nosniff",
+ "X-Frame-Options": "DENY",
+}
+
+
+@dataclass(frozen=True)
+class MpaCallbackTarget:
+ instance_id: str
+
+
+@dataclass(frozen=True)
+class IdentityRelayState:
+ request_id: str
+ provider_id: str
+ target: MpaCallbackTarget
+
+
+@dataclass(frozen=True)
+class MpaRuntimeCredentials:
+ endpoint_origin: str
+ api_key: str
+
+
+@dataclass(frozen=True)
+class RuntimeCallbackResult:
+ http_status: int
+ payload: dict[str, Any]
+
+
+HostedCallbackResolver = Callable[[str], Awaitable[str]]
+RuntimeCredentialsResolver = Callable[
+ [MpaCallbackTarget], Awaitable[MpaRuntimeCredentials]
+]
+
+
+class MpaIdentityCallbackError(ValueError):
+ """A sanitized callback error that is safe to expose through logs."""
+
+
+def select_mpa_runtime(
+ candidates: Iterable[tuple[str, Any]],
+ target: MpaCallbackTarget,
+) -> tuple[str, Any]:
+ """Select exactly one Runtime by trusted control-plane environment metadata."""
+ matches: list[tuple[str, Any]] = []
+ for region, runtime in candidates:
+ envs = {
+ str(getattr(item, "key", "") or ""): str(getattr(item, "value", "") or "")
+ for item in (getattr(runtime, "envs", None) or [])
+ }
+ if envs.get("MPA_AGENT_ID", "").strip() == target.instance_id:
+ matches.append((region, runtime))
+ if len(matches) != 1:
+ reason = "not found" if not matches else "not unique"
+ raise MpaIdentityCallbackError(f"MPA Runtime {reason}")
+ return matches[0]
+
+
+def _decode_base64url_json(value: str) -> dict[str, Any] | None:
+ if not value or len(value) > MAX_STATE_LENGTH:
+ return None
+ try:
+ padding = "=" * (-len(value) % 4)
+ decoded = base64.urlsafe_b64decode(f"{value}{padding}")
+ payload = json.loads(decoded.decode("utf-8"))
+ except (ValueError, UnicodeDecodeError, json.JSONDecodeError):
+ return None
+ return payload if isinstance(payload, dict) else None
+
+
+def parse_identity_relay_state(value: str) -> IdentityRelayState | None:
+ """Decode the outer UserPool relay state and its strict MPA target."""
+ outer = _decode_base64url_json(value.strip())
+ if outer is None:
+ return None
+ request_id = outer.get("request_id")
+ provider_id = outer.get("provider_id")
+ request_state = outer.get("request_state")
+ if (
+ not isinstance(request_id, str)
+ or not request_id.strip()
+ or not isinstance(provider_id, str)
+ or not provider_id.strip()
+ or not isinstance(request_state, str)
+ or not request_state.strip()
+ ):
+ return None
+
+ target = parse_mpa_runtime_state(request_state)
+ if target is None:
+ return None
+ return IdentityRelayState(
+ request_id=request_id.strip(),
+ provider_id=provider_id.strip(),
+ target=target,
+ )
+
+
+def parse_mpa_runtime_state(value: str) -> MpaCallbackTarget | None:
+ """Decode the UserPool-issued callback state and its strict MPA target."""
+ payload = _decode_base64url_json(value.strip())
+ if payload is None:
+ return None
+ target = payload.get("target")
+ if (
+ not isinstance(target, str)
+ or not target.startswith("mi-")
+ or len(target) <= len("mi-")
+ ):
+ return None
+ return MpaCallbackTarget(target.strip())
+
+
+def _normalize_https_origin(value: str) -> str:
+ try:
+ parsed = urlsplit(value.strip())
+ hostname = (parsed.hostname or "").rstrip(".").lower()
+ port = parsed.port
+ except ValueError:
+ raise ValueError("invalid HTTPS origin") from None
+ try:
+ ipaddress.ip_address(hostname)
+ except ValueError:
+ is_ip = False
+ else:
+ is_ip = True
+ if (
+ parsed.scheme != "https"
+ or not hostname
+ or is_ip
+ or parsed.username
+ or parsed.password
+ or port
+ or len(hostname) > 253
+ or any(
+ re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label) is None
+ for label in hostname.split(".")
+ )
+ or "." not in hostname
+ ):
+ raise ValueError("invalid HTTPS origin")
+ return urlunsplit(("https", hostname, "", "", ""))
+
+
+def _normalize_runtime_endpoint(value: str) -> str:
+ parsed = urlsplit(value)
+ origin = _normalize_https_origin(value)
+ if parsed.path not in {"", "/"} or parsed.query or parsed.fragment:
+ raise MpaIdentityCallbackError("invalid Runtime endpoint")
+ return origin
+
+
+def build_user_pool_hosted_callback_url(
+ issuer: str,
+ connection_type: str,
+) -> str:
+ """Build the trusted UserPool relay endpoint from discovered pool metadata."""
+ origin = _normalize_https_origin(issuer)
+ callback_kind = {
+ "oauth": "generic_oauth",
+ "oidc": "generic_oidc",
+ }.get(connection_type.strip().lower())
+ if callback_kind is None:
+ raise ValueError("unsupported identity provider connection type")
+ return f"{origin}/login/{callback_kind}/callback"
+
+
+def _validate_hosted_callback_url(value: str) -> str:
+ parsed = urlsplit(value)
+ origin = _normalize_https_origin(value)
+ if (
+ parsed.path
+ not in {
+ "/login/generic_oauth/callback",
+ "/login/generic_oidc/callback",
+ }
+ or parsed.query
+ or parsed.fragment
+ ):
+ raise MpaIdentityCallbackError("invalid UserPool callback endpoint")
+ return f"{origin}{parsed.path}"
+
+
+def _user_pool_redirect_response(
+ hosted_callback_url: str,
+ code: str,
+ state: str,
+) -> RedirectResponse:
+ callback_url = _validate_hosted_callback_url(hosted_callback_url)
+ return RedirectResponse(
+ f"{callback_url}?{urlencode({'code': code, 'state': state})}",
+ status_code=302,
+ headers=_SECURITY_HEADERS,
+ )
+
+
+def _parse_runtime_payload(response: httpx.Response) -> dict[str, Any]:
+ content_length = response.headers.get("content-length")
+ if content_length:
+ try:
+ if int(content_length) > MAX_CALLBACK_RESPONSE_BYTES:
+ raise MpaIdentityCallbackError("Runtime callback response is too large")
+ except ValueError:
+ pass
+ if len(response.content) > MAX_CALLBACK_RESPONSE_BYTES:
+ raise MpaIdentityCallbackError("Runtime callback response is too large")
+ try:
+ payload = response.json()
+ except ValueError as error:
+ raise MpaIdentityCallbackError(
+ "Runtime returned invalid callback JSON"
+ ) from error
+ code = payload.get("code") if isinstance(payload, dict) else None
+ if (
+ not isinstance(payload, dict)
+ or not isinstance(code, int)
+ or isinstance(code, bool)
+ or "message" not in payload
+ or "error" not in payload
+ ):
+ raise MpaIdentityCallbackError("Runtime returned invalid callback payload")
+ return payload
+
+
+async def _call_runtime_callback(
+ client: httpx.AsyncClient,
+ credentials: MpaRuntimeCredentials,
+ code: str,
+ state: str,
+) -> RuntimeCallbackResult:
+ origin = _normalize_runtime_endpoint(credentials.endpoint_origin)
+ api_key = credentials.api_key.strip()
+ if not api_key:
+ raise MpaIdentityCallbackError("Runtime key authentication is unavailable")
+ response = await client.get(
+ f"{origin}{MPA_RUNTIME_CALLBACK_PATH}",
+ params={"code": code, "state": state},
+ headers={
+ "Accept": "application/json",
+ "Authorization": f"Bearer {api_key}",
+ },
+ follow_redirects=False,
+ )
+ if 300 <= response.status_code < 400:
+ raise MpaIdentityCallbackError("Runtime callback redirected unexpectedly")
+ return RuntimeCallbackResult(response.status_code, _parse_runtime_payload(response))
+
+
+def _result_page(title: str, heading: str, description: str) -> str:
+ return f"""
+
+
+
+
+ {title}
+
+
+ {heading}
{description}
+"""
+
+
+def _invalid_request() -> JSONResponse:
+ return JSONResponse(
+ {"code": 4002, "message": "", "error": "Invalid callback request"},
+ status_code=400,
+ headers=_SECURITY_HEADERS,
+ )
+
+
+def _internal_error() -> JSONResponse:
+ return JSONResponse(
+ {"code": 5000, "message": "", "error": "MPA authorization failed"},
+ status_code=502,
+ headers=_SECURITY_HEADERS,
+ )
+
+
+def _runtime_result_response(result: RuntimeCallbackResult) -> Response:
+ code = result.payload["code"]
+ if code == 0:
+ return HTMLResponse(
+ _result_page("授权成功", "授权成功", "您可以关闭本页面并返回会话。"),
+ status_code=200,
+ headers=_SECURITY_HEADERS,
+ )
+ if code == 4003:
+ return HTMLResponse(
+ _result_page(
+ "授权链接已失效",
+ "授权链接已失效",
+ "请返回会话重新触发授权。",
+ ),
+ status_code=result.http_status,
+ headers=_SECURITY_HEADERS,
+ )
+ return JSONResponse(
+ result.payload,
+ status_code=result.http_status,
+ headers=_SECURITY_HEADERS,
+ )
+
+
+def mount_mpa_identity_callback(
+ app: FastAPI,
+ *,
+ hosted_callback_resolver: HostedCallbackResolver,
+ runtime_credentials_resolver: RuntimeCredentialsResolver,
+ http_client: httpx.AsyncClient | None = None,
+) -> None:
+ """Mount the public browser callback for MPA Runtime authorization."""
+
+ @app.get(MPA_CALLBACK_PATH)
+ async def mpa_identity_callback(
+ code: str | None = None,
+ state: str | None = None,
+ error: str | None = None,
+ ) -> Response:
+ if error or not code or not state:
+ return _invalid_request()
+ relay_state = parse_identity_relay_state(state)
+ if relay_state is not None:
+ stage = "resolve_userpool_callback"
+ try:
+ hosted_callback_url = await hosted_callback_resolver(
+ relay_state.provider_id
+ )
+ return _user_pool_redirect_response(hosted_callback_url, code, state)
+ except Exception as callback_error: # noqa: BLE001 - sanitize route boundary
+ safe_error = (
+ str(callback_error)
+ if isinstance(callback_error, MpaIdentityCallbackError)
+ else type(callback_error).__name__
+ )
+ logger.warning(
+ "MPA identity callback failed stage=%s error=%s "
+ "error_type=%s request_id=%s target=%s",
+ stage,
+ safe_error,
+ type(callback_error).__name__,
+ relay_state.request_id,
+ relay_state.target.instance_id,
+ )
+ return _internal_error()
+
+ target = parse_mpa_runtime_state(state)
+ if target is None:
+ return _invalid_request()
+
+ owned_client = http_client is None
+ client = http_client or httpx.AsyncClient(
+ timeout=httpx.Timeout(CALLBACK_TIMEOUT_SECONDS),
+ follow_redirects=False,
+ )
+ stage = "resolve_runtime"
+ try:
+ credentials = await runtime_credentials_resolver(target)
+ stage = "runtime_callback"
+ result = await _call_runtime_callback(
+ client,
+ credentials,
+ code,
+ state,
+ )
+ response = _runtime_result_response(result)
+ except Exception as callback_error: # noqa: BLE001 - sanitize route boundary
+ safe_error = (
+ str(callback_error)
+ if isinstance(callback_error, MpaIdentityCallbackError)
+ else type(callback_error).__name__
+ )
+ logger.warning(
+ "MPA identity callback failed stage=%s error=%s "
+ "error_type=%s target=%s",
+ stage,
+ safe_error,
+ type(callback_error).__name__,
+ target.instance_id,
+ )
+ response = _internal_error()
+ finally:
+ if owned_client:
+ await client.aclose()
+
+ return response
diff --git a/frontend/server/runtime_logs.py b/frontend/server/runtime_logs.py
index ec3ce73dd..db53457b7 100644
--- a/frontend/server/runtime_logs.py
+++ b/frontend/server/runtime_logs.py
@@ -277,10 +277,11 @@ async def read_logs(
runtime_types.GetRuntimeInstanceLogsRequest(
RuntimeId=runtime_id,
InstanceName=instance_name,
- Limit=500,
+ Limit=1000,
),
)
- return self._sanitize(str(response.logs or ""))
+ sanitized = self._sanitize(str(response.logs or ""))
+ return "\n".join(sanitized.splitlines()[-1000:])
async def snapshot(
self,
diff --git a/frontend/server/skills/documents.py b/frontend/server/skills/documents.py
new file mode 100644
index 000000000..8a811958b
--- /dev/null
+++ b/frontend/server/skills/documents.py
@@ -0,0 +1,59 @@
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Edit only SKILL.md while preserving every other archive member."""
+
+import io
+import zipfile
+from pathlib import PurePosixPath
+
+from .archive import validate_skill_archive
+from .repository import SkillRepositoryError
+
+
+def replace_skill_document(content: bytes, document: str) -> bytes:
+ original = validate_skill_archive(content)
+ output = io.BytesIO()
+ with zipfile.ZipFile(io.BytesIO(content)) as source:
+ names = [
+ name
+ for name in source.namelist()
+ if PurePosixPath(name).parts[0] != "__MACOSX"
+ ]
+ path = next(
+ (name for name in names if PurePosixPath(name).as_posix() == "SKILL.md"),
+ None,
+ )
+ if path is None:
+ path = next(
+ name
+ for name in names
+ if len(PurePosixPath(name).parts) == 2
+ and PurePosixPath(name).name == "SKILL.md"
+ )
+ with zipfile.ZipFile(output, "w") as target:
+ for info in source.infolist():
+ target.writestr(
+ info,
+ document.encode("utf-8")
+ if info.filename == path
+ else source.read(info),
+ )
+ result = output.getvalue()
+ updated = validate_skill_archive(result)
+ if updated.name != original.name:
+ raise SkillRepositoryError(
+ "SKILL_VERSION_NAME_MISMATCH", "新版本必须保留原技能名称", status_code=422
+ )
+ return result
diff --git a/frontend/server/skills/version_routes.py b/frontend/server/skills/version_routes.py
index 87c67eadd..a50a8082b 100644
--- a/frontend/server/skills/version_routes.py
+++ b/frontend/server/skills/version_routes.py
@@ -20,11 +20,17 @@
from typing import Any
from fastapi import Query, Request
+from pydantic import BaseModel, Field
from .models import SkillIdentity
from .versions import SkillVersionRepository
+class SkillDocumentBody(BaseModel):
+ baseVersion: str = Field(min_length=1, max_length=64)
+ content: str = Field(min_length=1, max_length=262144)
+
+
def mount_skill_version_routes(
app: Any,
versions: SkillVersionRepository,
@@ -32,6 +38,40 @@ def mount_skill_version_routes(
invoke: Callable[[Callable[[], Any]], Awaitable[Any]],
read_archive: Callable[[Request], Awaitable[bytes]],
) -> None:
+ @app.get("/web/skill-management/spaces/{space_id}/skills/{skill_id}/document")
+ async def get_document(
+ request: Request,
+ space_id: str,
+ skill_id: str,
+ region: str = Query(..., min_length=1, max_length=64),
+ ) -> Any:
+ identity = identity_resolver(request)
+ return await invoke(
+ lambda: versions.document(
+ identity, region=region, space_id=space_id, skill_id=skill_id
+ )
+ )
+
+ @app.put("/web/skill-management/spaces/{space_id}/skills/{skill_id}/document")
+ async def put_document(
+ request: Request,
+ space_id: str,
+ skill_id: str,
+ body: SkillDocumentBody,
+ region: str = Query(..., min_length=1, max_length=64),
+ ) -> Any:
+ identity = identity_resolver(request)
+ return await invoke(
+ lambda: versions.save_document(
+ identity,
+ region=region,
+ space_id=space_id,
+ skill_id=skill_id,
+ base_version=body.baseVersion,
+ document=body.content,
+ )
+ )
+
@app.get("/web/skill-management/spaces/{space_id}/skills/{skill_id}/versions")
async def list_versions(
request: Request,
diff --git a/frontend/server/skills/versions.py b/frontend/server/skills/versions.py
index cfa8fc148..757894476 100644
--- a/frontend/server/skills/versions.py
+++ b/frontend/server/skills/versions.py
@@ -189,6 +189,7 @@ def upload(
space_id: str,
skill_id: str,
content: bytes,
+ expected_version: str | None = None,
) -> dict[str, Any]:
from agentkit.sdk.skills import types as sdk
from agentkit.toolkit.cli.cli_skills_workflow import (
@@ -220,6 +221,14 @@ def upload(
status_code=422,
)
previous = {item.version for item in self._versions(client, skill_id)}
+ if expected_version is not None and (
+ not previous or max(previous, key=version_order) != expected_version
+ ):
+ raise SkillRepositoryError(
+ "SKILL_DOCUMENT_CONFLICT",
+ "技能已有新版本,请重新打开后编辑。",
+ status_code=409,
+ )
config = GlobalConfigManager().load()
storage = resolve_skill_publish_storage(
region=region,
@@ -263,3 +272,66 @@ def upload(
"description": archive.description,
"skillSpaceId": space_id,
}
+
+ def document(
+ self, identity: SkillIdentity, *, region: str, space_id: str, skill_id: str
+ ) -> dict[str, Any]:
+ versions = self.list(
+ identity, region=region, space_id=space_id, skill_id=skill_id
+ )
+ if not versions["items"]:
+ raise SkillRepositoryError(
+ "SKILL_DOCUMENT_NOT_FOUND", "未找到技能版本", status_code=404
+ )
+ version = versions["items"][0]["version"]
+ content, _ = self._repository.skill_archive(
+ region=region,
+ space_id=space_id,
+ skill_id=skill_id,
+ version=version,
+ )
+ return {
+ "content": validate_skill_archive(content).skill_md,
+ "baseVersion": version,
+ "canUpdate": versions["canUpdate"],
+ }
+
+ def save_document(
+ self,
+ identity: SkillIdentity,
+ *,
+ region: str,
+ space_id: str,
+ skill_id: str,
+ base_version: str,
+ document: str,
+ ) -> dict[str, Any]:
+ from .documents import replace_skill_document
+
+ versions = self.list(
+ identity, region=region, space_id=space_id, skill_id=skill_id
+ )
+ if not versions["canUpdate"]:
+ raise SkillRepositoryError(
+ "SKILL_VERSION_UPDATE_FORBIDDEN", "无权修改此技能", status_code=403
+ )
+ if not versions["items"] or versions["items"][0]["version"] != base_version:
+ raise SkillRepositoryError(
+ "SKILL_DOCUMENT_CONFLICT",
+ "技能已有新版本,请重新打开后编辑。",
+ status_code=409,
+ )
+ content, _ = self._repository.skill_archive(
+ region=region,
+ space_id=space_id,
+ skill_id=skill_id,
+ version=base_version,
+ )
+ return self.upload(
+ identity,
+ region=region,
+ space_id=space_id,
+ skill_id=skill_id,
+ content=replace_skill_document(content, document),
+ expected_version=base_version,
+ )
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 1d5655d6f..921b6aa13 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -3,10 +3,12 @@ import { useDevelopmentRun } from "./create/useDevelopmentRun";
import { developmentRunStatus } from "./create/developmentPresentation";
import { DevelopmentTaskNotice, type DevelopmentTaskSnapshot } from "./create/DevelopmentTaskNotice";
import { UserManagement } from "./users/UserManagement";
+import { SandboxFileContext } from "./ui/SandboxFileLink";
import {
useCallback,
useEffect,
useLayoutEffect,
+ useMemo,
useRef,
useState,
type MouseEventHandler,
@@ -34,21 +36,29 @@ import {
downloadArtifact,
previewArtifact,
getAgentInfo,
+ getTurnControl,
getAutomaticEvaluationStatuses,
+ getMpaSessionExecutionConfig,
+ getMpaAgentOperation,
getSessionTrace,
getSession,
getStudioAccess,
getRuntimeStudioToolCapabilities,
getRuntimeMcpCredentials,
getRuntimes,
+ isMpaRuntimeApp,
+ isMpaA2aRuntimeApp,
listApps,
listEnvironments,
listWorkspaces,
listModelOptions,
+ listActiveMpaAgentOperations,
listSessions,
prepareSessionEnvironmentMounts,
runSseIncompleteResponseError,
runSSE,
+ controlTurn,
+ continueTurnSSE,
refreshAgentFeedbackCases,
submitIssueFeedback,
submitMessageFeedback,
@@ -66,16 +76,21 @@ import {
type FrontendInvocation,
type CloudRuntime,
type MessageFeedbackRating,
+ type MpaSessionExecutionConfig,
+ type MpaAgentOperation,
type SiteBranding,
type RuntimeStudioToolCapabilities,
type SessionEnvironmentMountSelection,
type StudioAccess,
type StudioEnvironment,
type StudioWorkspace,
+ type TurnControlState,
+ TurnControlConflictError,
type UiConfig,
type UiFeatures,
} from "./adk/client";
import type { RuntimeLogTarget } from "./adk/runtimeLogs";
+import type { SelectedSkill } from "./create/skills/types";
import {
addTokenUsage,
aggregateTokenUsage,
@@ -93,20 +108,22 @@ import {
createAssistantEventProjector,
eventsToTurns,
sessionTitle,
+ shouldShowEmptyAssistantResponse,
upsertProjectedAssistantTurn,
type Block,
type IntelligentDevelopmentReleaseRef,
type Turn,
type TurnActivityDetail,
} from "./blocks";
+import { reconcilePersistedTranscript } from "./transcriptReconcile";
import { i18n } from "./i18n";
-import { buildTranscriptRows } from "./transcriptRows";
+import { buildTranscriptRows, groupMpaTranscriptTurns } from "./transcriptRows";
import { Sidebar, type SidebarPage } from "./ui/Sidebar";
-import { AgentInfoPanel } from "./ui/AgentTopology";
+import { MpaAgentInfoRail } from "./ui/mpa-agent-info/MpaAgentInfoRail";
import type { SkillCenterWorkspaceLaunch } from "./ui/SkillCenter";
import { LibraryView, type LibraryTab } from "./ui/LibraryView";
import { AddAgentKitView } from "./ui/AddAgentKit";
-import { AgentWorkspace } from "./ui/AgentWorkspace";
+import { AgentWorkspace, type MpaProfileEditTarget } from "./ui/AgentWorkspace";
import {
MyAgents,
invalidateRuntimeAgentCache,
@@ -124,6 +141,8 @@ import { GitHubIntegration } from "./ui/GitHubIntegration";
import { GitLabIntegration } from "./ui/GitLabIntegration";
import { FeishuBotIntegration } from "./automations/feishu/FeishuBotIntegration";
import { CodingAgentsIntegration } from "./automations/coding-agents/CodingAgentsIntegration";
+import type { AutomationCategoryId } from "./automations/types";
+import { MpaChannelsAutomation } from "./automations/mpa-channels/MpaChannelsAutomation";
import { WebsiteIntegration } from "./automations/website-integration/WebsiteIntegration";
import { SearchView } from "./ui/Search";
import {
@@ -310,6 +329,14 @@ function issueFeedbackModuleForPage(page: string): IssueFeedbackModule {
return "other";
}
+function isOptionalStudioStorageUnavailable(cause: unknown): boolean {
+ const message = cause instanceof Error ? cause.message : String(cause ?? "");
+ return message.includes("HTTP 503") && (
+ message.includes("未配置持久化存储") ||
+ message.includes("storage is not configured")
+ );
+}
+
interface NewChatCapabilitiesState {
agentId?: string;
ready?: boolean;
@@ -358,6 +385,7 @@ async function loadHydratedSessions(
userId: string,
): Promise {
const list = await listSessions(appName, userId);
+ if (isMpaRuntimeApp(appName)) return list;
const results = await Promise.allSettled(
list.map((session) =>
session.events?.length
@@ -417,8 +445,6 @@ const DRAFT_AUTOSAVE_DELAY_MS = 600;
const AUTO_EVALUATION_RUNNING_POLL_MS = 1_000;
const AUTO_EVALUATION_RETRY_POLL_MS = 5_000;
const AUTO_EVALUATION_MIN_PENDING_POLL_MS = 500;
-const EMPTY_STRING_SET: Set = new Set();
-const EMPTY_STRING_ARR: string[] = [];
const ENVIRONMENT_STUDIO_TOOL_IDS = [
"list_envs",
"get_env_manifest",
@@ -426,6 +452,7 @@ const ENVIRONMENT_STUDIO_TOOL_IDS = [
"delegate_to_codex_sandbox",
] as const;
const SESSION_ENVIRONMENT_STORAGE_KEY = "veadk.sessionEnvironmentMounts.v1";
+const SESSION_SKILL_STORAGE_KEY = "veadk.sessionSkillMounts.v1";
interface StoredSessionEnvironmentState {
mounts: Record;
@@ -501,6 +528,43 @@ function persistSessionEnvironmentState(
}
}
+function loadStoredSessionSkills(): Record {
+ if (typeof localStorage === "undefined") return {};
+ try {
+ const raw = JSON.parse(localStorage.getItem(SESSION_SKILL_STORAGE_KEY) ?? "{}");
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
+ return Object.fromEntries(
+ Object.entries(raw as Record).slice(-200).map(([key, value]) => [
+ key,
+ Array.isArray(value)
+ ? value.slice(0, 20).filter((item): item is SelectedSkill => (
+ Boolean(item)
+ && typeof item === "object"
+ && !Array.isArray(item)
+ && (item as SelectedSkill).source === "skillspace"
+ && typeof (item as SelectedSkill).name === "string"
+ && typeof (item as SelectedSkill).folder === "string"
+ && typeof (item as SelectedSkill).skillSpaceId === "string"
+ && typeof (item as SelectedSkill).skillId === "string"
+ && typeof (item as SelectedSkill).version === "string"
+ ))
+ : [],
+ ]),
+ );
+ } catch {
+ return {};
+ }
+}
+
+function persistSessionSkills(skills: Record) {
+ if (typeof localStorage === "undefined") return;
+ try {
+ localStorage.setItem(SESSION_SKILL_STORAGE_KEY, JSON.stringify(skills));
+ } catch {
+ // Storage can be unavailable in private or quota-restricted browsers.
+ }
+}
+
function emptyInvocation(): FrontendInvocation {
return { skills: [] };
}
@@ -967,6 +1031,10 @@ function appText(key: string, options?: Record): string {
return i18n.t(key, { ns: "app", ...options });
}
+function uiText(key: string, options?: Record): string {
+ return i18n.t(key, { ns: "ui", ...options });
+}
+
function releaseAttachmentPreviews(items: Attachment[]) {
for (const item of items) {
if (item.previewUrl?.startsWith("blob:")) URL.revokeObjectURL(item.previewUrl);
@@ -1117,12 +1185,26 @@ function sessionUsageKey(app: string, session: string): string {
return `${app}\u0001${session}`;
}
+function lastCompletedEventId(turns: Turn[] | undefined): string | undefined {
+ const value = [...(turns ?? [])]
+ .reverse()
+ .find((turn) => turn.role === "assistant" && turn.meta?.eventId)
+ ?.meta?.eventId;
+ return value?.trim() || undefined;
+}
+
export default function App() {
const { t } = useTranslation("app");
const [apps, setApps] = useState([]);
- const [appName, setAppName] = useState("");
+ const [appName, setAppName] = useState(() =>
+ typeof localStorage !== "undefined" ? localStorage.getItem(LS.app) || "" : "",
+ );
const [sessions, setSessions] = useState([]);
- const [sessionId, setSessionId] = useState("");
+ const [sessionId, setSessionId] = useState(() =>
+ typeof localStorage !== "undefined"
+ ? localStorage.getItem(LS.session) || ""
+ : "",
+ );
const creatingSessionRef = useRef | null>(null);
const sessionRefreshRequestRef = useRef(0);
const agentSelectionPreparationRequestRef = useRef(0);
@@ -1455,17 +1537,21 @@ export default function App() {
const [studioToolCapabilities, setStudioToolCapabilities] =
useState(null);
const [studioToolsLoading, setStudioToolsLoading] = useState(false);
- const [studioToolsError, setStudioToolsError] = useState("");
+ const [selectedCronRuntime, setSelectedCronRuntime] = useState<{
+ runtimeId: string; name: string; region: string;
+ } | undefined>();
+ useEffect(() => setSelectedCronRuntime(undefined), [appName]);
const [draftStudioRuntime, setDraftStudioRuntime] = useState<{
appName: string;
runtimeId: string;
+ mpaInstanceId?: string;
name: string;
region: string;
} | null>(null);
- const [draftStudioToolIds, setDraftStudioToolIds] = useState([]);
- const [studioToolIdsBySession, setStudioToolIdsBySession] = useState<
- Record
- >({});
+ const [sessionSkillsBySession] = useState<
+ Record
+ >(() => loadStoredSessionSkills());
+ useEffect(() => persistSessionSkills(sessionSkillsBySession), [sessionSkillsBySession]);
const [sessionEnvironments, setSessionEnvironments] = useState([]);
const [sessionWorkspaces, setSessionWorkspaces] = useState([]);
const [sessionEnvironmentsLoading, setSessionEnvironmentsLoading] = useState(false);
@@ -1493,7 +1579,14 @@ export default function App() {
Record
>({});
const [agentInfo, setAgentInfo] = useState(null);
+ const [turnControlBySession, setTurnControlBySession] = useState>({});
+ const turnControlBySessionRef = useRef>({});
+ const [turnControlBusy, setTurnControlBusy] = useState(false);
+ const [selectedModelBySession, setSelectedModelBySession] = useState<
+ Record
+ >({});
const [agentInfoRefreshKey, setAgentInfoRefreshKey] = useState(0);
+ const agentInfoSelectionRef = useRef("");
const [capabilitiesLoading, setCapabilitiesLoading] = useState(false);
const removedAttachmentIdsRef = useRef>(new Set());
// Streaming state is PER SESSION so multiple sessions can stream at once
@@ -1838,20 +1931,6 @@ export default function App() {
const [uiConfigLoaded, setUiConfigLoaded] = useState(false);
const [localMode, setLocalMode] = useState(false);
const [loadingSession, setLoadingSession] = useState(false);
- // The executing sub-agent (ADK event.author) and everyone who emitted this
- // turn — PER SESSION, so each session's topology highlights its own stream.
- const [activeAgentBySession, setActiveAgentBySession] = useState<
- Record
- >({});
- const [seenAgentsBySession, setSeenAgentsBySession] = useState<
- Record>
- >({});
- // The current delegation chain (root → … → executing agent) per session,
- // built from event.actions.transfer_to_agent / end_of_agent.
- const [execPathBySession, setExecPathBySession] = useState<
- Record
- >({});
-
// Everything the view needs for the ACTIVE session, derived from the
// per-session maps above.
const busy = streamingSids.has(sessionId);
@@ -1985,9 +2064,6 @@ export default function App() {
setSandboxSession((current) => current?.intelligentDevelopment ? { ...current, busy } : current);
},
});
- const activeAgent = activeAgentBySession[sessionId] ?? "";
- const seenAgents = seenAgentsBySession[sessionId] ?? EMPTY_STRING_SET;
- const execPath = execPathBySession[sessionId] ?? EMPTY_STRING_ARR;
const rootCapabilityNode = agentInfo?.graph;
const rootAgentNames = [
agentInfo?.name,
@@ -2010,11 +2086,13 @@ export default function App() {
tools: [
...new Set([
...(rootCapabilityNode?.tools ?? agentInfo.tools),
- ...(sessionId
- ? (studioToolIdsBySession[
- studioToolSelectionKey(appName, userId, sessionId)
- ] ?? [])
- : draftStudioToolIds),
+ ...(sessionId && (
+ environmentMountsBySession[
+ studioToolSelectionKey(appName, userId, sessionId)
+ ] ?? []
+ ).length > 0
+ ? [...ENVIRONMENT_STUDIO_TOOL_IDS]
+ : []),
]),
],
skills: rootCapabilityNode?.skills ?? agentInfo.skills,
@@ -2067,41 +2145,6 @@ export default function App() {
}
}
- // Apply a stream event's control-flow signals to a session's live state:
- // author = who's executing now; transfer_to_agent pushes the delegation
- // chain; end_of_agent / escalate pops it. `author` always wins for highlight.
- const applyStreamSignals = (sid: string, ev: AdkEvent) => {
- const who = ev.author && ev.author !== "user" ? ev.author : undefined;
- if (who) {
- setActiveAgentBySession((m) => ({ ...m, [sid]: who }));
- setSeenAgentsBySession((m) => ({
- ...m,
- [sid]: new Set(m[sid] ?? []).add(who),
- }));
- // Seed the path with the entry (root) agent on the first event.
- setExecPathBySession((m) =>
- m[sid]?.length ? m : { ...m, [sid]: [who] },
- );
- }
- const transferTo =
- ev.actions?.transferToAgent ?? ev.actions?.transfer_to_agent;
- if (transferTo) {
- setExecPathBySession((m) => {
- const cur = m[sid] ?? [];
- return cur[cur.length - 1] === transferTo
- ? m
- : { ...m, [sid]: [...cur, transferTo] };
- });
- }
- const ended =
- ev.actions?.endOfAgent ?? ev.actions?.end_of_agent ?? ev.actions?.escalate;
- if (ended) {
- setExecPathBySession((m) => {
- const cur = m[sid] ?? [];
- return cur.length <= 1 ? m : { ...m, [sid]: cur.slice(0, -1) };
- });
- }
- };
const [createView, setCreateView] = useState(loadView);
const [deepseekDraft, setDeepseekDraft] = useState(createNativeDraft);
const [deploymentTasks, setDeploymentTasks] = useState<
@@ -2119,6 +2162,38 @@ export default function App() {
return next;
});
}, []);
+
+ const operationToDeploymentTask = useCallback((
+ operation: MpaAgentOperation,
+ ): DeploymentTaskUpdate => {
+ const succeeded = operation.status === "succeeded";
+ const failed = operation.status === "failed_retryable"
+ || operation.status === "failed_terminal";
+ const startedAt = Date.parse(operation.createdAt ?? operation.updatedAt ?? "");
+ return {
+ id: `mpa-operation:${operation.operationId}`,
+ agentName: operation.mpaInstanceId || operation.runtimeId || operation.operationId,
+ runtimeName: operation.mpaInstanceId || operation.runtimeId || operation.operationId,
+ runtimeId: operation.runtimeId,
+ mpaInstanceId: operation.mpaInstanceId,
+ region: operation.runtimeRegion || "cn-beijing",
+ startedAt: Number.isFinite(startedAt) ? startedAt : Date.now(),
+ status: succeeded ? "success" : failed ? "error" : "running",
+ phase: operation.stage,
+ label: failed
+ ? uiText("agentWorkspace.deploymentFailed")
+ : succeeded
+ ? uiText("agentWorkspace.deployStatus.success")
+ : uiText("agentWorkspace.deployStatus.running"),
+ message: operation.safeErrorCode || undefined,
+ pct: succeeded || failed ? 100 : undefined,
+ mpaProfile: true,
+ mpaSmoke: operation.operationKind === "create",
+ operationId: operation.operationId,
+ };
+ }, []);
+ const recoveredOperationIdsRef = useRef(new Set());
+
// Whether the server has cloud AK/SK. The agent-creation workbench needs
// them; assume present until the runtime-config check says otherwise (avoids
// flashing the notice in the common, configured case).
@@ -2169,7 +2244,7 @@ export default function App() {
const [feedbackCaseReturnKind, setFeedbackCaseReturnKind] =
useState<"good" | "bad">("good");
const [focusedWorkspaceAgentSection, setFocusedWorkspaceAgentSection] =
- useState<"basic" | "evaluations">("basic");
+ useState<"basic" | "sessionConfig" | "evaluations">("basic");
const [focusedWorkspaceCaseKind, setFocusedWorkspaceCaseKind] =
useState<"good" | "bad">("good");
const [feedbackTargetEventId, setFeedbackTargetEventId] = useState("");
@@ -2200,6 +2275,7 @@ export default function App() {
return current.filter((_, entryIndex) => entryIndex !== index);
});
}, []);
+ const [applicationsCategory, setApplicationsCategory] = useState("development");
const [applicationsView, setApplicationsView] =
useState<"catalog" | ApplicationId | null>(null);
const [cronJobsView, setCronJobsView] = useState(false);
@@ -2219,19 +2295,28 @@ export default function App() {
null,
);
const [libraryRuntimePermissions, setLibraryRuntimePermissions] = useState<
- Record
+ Record<
+ string,
+ {
+ canDelete: boolean;
+ agentCategory?: "general" | "mpa";
+ mpaInstanceId?: string;
+ }
+ >
>({});
const [hiddenRuntimeIds, setHiddenRuntimeIds] = useState>(
() => new Set(),
);
const [runtimeUpdateTarget, setRuntimeUpdateTarget] = useState<{
runtimeId: string;
+ mpaInstanceId?: string;
name: string;
region: string;
appName?: string;
currentVersion?: number | null;
etag?: string;
editMode?: "source-preserving" | "regenerate";
+ mpaProfileOnly?: boolean;
configuredMcpEnvKeys?: string[];
configuredRuntimeEnvKeys?: string[];
} | null>(null);
@@ -2239,6 +2324,43 @@ export default function App() {
defaultCloudRegion(cloudProvider),
);
const [focusedDeploymentTaskId, setFocusedDeploymentTaskId] = useState("");
+
+ useEffect(() => {
+ if (authStatus !== "authenticated") return;
+ const controller = new AbortController();
+
+ const recover = async (initial: MpaAgentOperation) => {
+ if (recoveredOperationIdsRef.current.has(initial.operationId)) return;
+ recoveredOperationIdsRef.current.add(initial.operationId);
+ let operation = initial;
+ try {
+ while (!controller.signal.aborted) {
+ operation = await getMpaAgentOperation(
+ operation.operationId,
+ controller.signal,
+ );
+ const task = operationToDeploymentTask(operation);
+ updateDeploymentTask(task);
+ if (operation.operationKind === "create") {
+ setFocusedDeploymentTaskId(task.id);
+ setFocusedWorkspaceAgentId("");
+ setManageAgents(true);
+ }
+ if (operation.status !== "active") return;
+ await new Promise((resolve) => window.setTimeout(resolve, 500));
+ }
+ } finally {
+ recoveredOperationIdsRef.current.delete(initial.operationId);
+ }
+ };
+
+ void listActiveMpaAgentOperations()
+ .then((operations) => Promise.all(operations.map(recover)))
+ .catch(() => {
+ // Recovery is best-effort; normal Runtime discovery remains available.
+ });
+ return () => controller.abort();
+ }, [authStatus, operationToDeploymentTask, updateDeploymentTask]);
const [focusedWorkspaceAgentId, setFocusedWorkspaceAgentId] = useState("");
const [agentDetailTarget, setAgentDetailTarget] =
useState(null);
@@ -2465,7 +2587,13 @@ export default function App() {
for (const agent of targets) {
try {
if (!agent.region) throw new Error(appText("errors.runtimeRegionMissingForDelete"));
- await deleteRuntime(agent.runtimeId, agent.region);
+ await deleteRuntime(agent.runtimeId, agent.region, {
+ agentCategory: agent.agentCategory,
+ mpaInstanceId:
+ agent.agentCategory === "mpa"
+ ? agent.mpaInstanceId ?? agent.runtimeId
+ : undefined,
+ });
removeRuntimeConnection(agent.runtimeId);
deletedRuntimeIds.add(agent.runtimeId);
deletedAgentIds.add(agent.id);
@@ -2581,7 +2709,11 @@ export default function App() {
Object.fromEntries(
runtimes.map((runtime) => [
runtime.runtimeId,
- { canDelete: runtime.canDelete },
+ {
+ canDelete: runtime.canDelete,
+ agentCategory: runtime.agentCategory,
+ mpaInstanceId: runtime.mpaInstanceId,
+ },
]),
),
);
@@ -2665,7 +2797,12 @@ export default function App() {
result.runtimeName,
result.region ?? fallbackRegion,
result.version,
- { waitForReady: true, agentName: result.agentName },
+ {
+ waitForReady: true,
+ agentName: result.agentName,
+ agentCategory: result.mpaOperation ? "mpa" : "general",
+ mpaInstanceId: result.mpaInstanceId,
+ },
);
setConnections(loadConnections());
setAgentInfoRefreshKey((key) => key + 1);
@@ -2693,6 +2830,11 @@ export default function App() {
const responseAnnotationContextsRef = useRef<
Map
>(new Map());
+ const transcriptTurns = useMemo(() => groupMpaTranscriptTurns(
+ turns,
+ isMpaA2aRuntimeApp(appName),
+ activeConversationBusy || presentingStream,
+ ), [turns, appName, connections, activeConversationBusy, presentingStream]);
const responseAnnotationRuntimeAvailable = connections.some(
(connection) =>
Boolean(connection.runtimeId && connection.region) &&
@@ -2700,7 +2842,7 @@ export default function App() {
);
useLayoutEffect(() => {
const contexts = new Map();
- turns.forEach((turn, index) => {
+ transcriptTurns.forEach((turn, index) => {
const feedbackEventId = turn.meta?.eventId ?? "";
const canRate = Boolean(
responseAnnotationRuntimeAvailable && feedbackEventId && turnText(turn),
@@ -2708,7 +2850,7 @@ export default function App() {
const turnIsStreaming = assistantTurnIsStreaming(
turn,
index,
- turns.length,
+ transcriptTurns.length,
activeConversationBusy,
presentingStream,
);
@@ -2720,7 +2862,7 @@ export default function App() {
!turnAwaitingAuth(turn)
),
turn,
- input: canRate ? previousUserTurnText(turns, index) : "",
+ input: canRate ? previousUserTurnText(transcriptTurns, index) : "",
});
});
responseAnnotationContextsRef.current = contexts;
@@ -2729,7 +2871,7 @@ export default function App() {
cloudProvider,
presentingStream,
responseAnnotationRuntimeAvailable,
- turns,
+ transcriptTurns,
]);
const openResponseAnnotation = useCallback(() => {
const selection = window.getSelection();
@@ -2875,7 +3017,10 @@ export default function App() {
setUserInfo(id.info);
setLocalMode(!!id.local);
setAuthStatus(id.status);
- if (id.status === "authenticated") {
+ if (
+ id.status === "authenticated" &&
+ !localStorage.getItem(LS.session)
+ ) {
restoredRef.current = true;
agentSelectionClearedRef.current = true;
localStorage.removeItem(LS.app);
@@ -3426,29 +3571,36 @@ export default function App() {
preparedSelection?.agentId === appName &&
preparedSelection.userId === userId
) {
+ agentInfoSelectionRef.current = appName;
setCapabilitiesLoading(false);
return;
}
let cancelled = false;
- setAgentInfo(null);
+ const controller = new AbortController();
+ if (agentInfoSelectionRef.current !== appName) setAgentInfo(null);
+ agentInfoSelectionRef.current = appName;
setInvocation(emptyInvocation());
if (authStatus !== "authenticated" || myAgents || agentDetailTarget || !appName) {
setCapabilitiesLoading(false);
return;
}
setCapabilitiesLoading(true);
- getAgentInfo(appName)
+ getAgentInfo(appName, controller.signal)
.then((info) => {
if (!cancelled) setAgentInfo(info);
})
.catch(() => {
- if (!cancelled) setAgentInfo(null);
+ if (!cancelled) setAgentInfo(current => current?.agentCategory === "mpa" ? {
+ ...current,
+ mpa: {agentsMd: null, agentsMdStatus: "error", skillSpaces: [], skillSpacesStatus: "error"},
+ } : null);
})
.finally(() => {
if (!cancelled) setCapabilitiesLoading(false);
});
return () => {
cancelled = true;
+ controller.abort();
};
}, [agentDetailTarget, appName, agentInfoRefreshKey, authStatus, myAgents]);
useEffect(() => {
@@ -3688,6 +3840,33 @@ export default function App() {
}
}
+ async function refreshSessionTranscript(
+ app: string,
+ sid: string,
+ expectedEventId: string,
+ ): Promise {
+ try {
+ const session = await getSession(app, userId, sid);
+ const nextTurns = eventsToTurns(session.events ?? [], session.state, {
+ mpaA2a: isMpaA2aRuntimeApp(app),
+ });
+ setTurnsBySession((current) => {
+ const existing = current[sid] ?? [];
+ const reconciled = reconcilePersistedTranscript(
+ existing,
+ nextTurns,
+ expectedEventId,
+ );
+ return reconciled === existing
+ ? current
+ : { ...current, [sid]: reconciled };
+ });
+ } catch {
+ // Live SSE remains the primary display path. This is only a best-effort
+ // reconciliation against the Runtime's persisted transcript after done.
+ }
+ }
+
function openSandboxLaunch(
kind: "codex" | SandboxAgentKind = "codex",
fromAgents = false,
@@ -4697,7 +4876,6 @@ export default function App() {
setInitializingSession(false);
setPendingTurns([]);
setInvocation(emptyInvocation());
- setDraftStudioToolIds([]);
discardDraftAttachments(attachments);
setAttachments([]);
if (abandonedSession) void abandonDraftSession(abandonedSession);
@@ -4860,7 +5038,7 @@ export default function App() {
async function pickSession(id: string) {
if (sandboxSession) exitSandboxSession();
- if (id === sessionId) return;
+ if (id === sessionId && turnsBySession[id] !== undefined) return;
viewSidRef.current = id;
setError("");
setInitializingSession(false);
@@ -4876,7 +5054,9 @@ export default function App() {
setLoadingSession(true);
try {
const s = await getSession(appName, userId, id);
- setTurnsFor(id, eventsToTurns(s.events ?? [], s.state));
+ setTurnsFor(id, eventsToTurns(s.events ?? [], s.state, {
+ mpaA2a: isMpaA2aRuntimeApp(appName),
+ }));
setTokenUsageBySession((current) => ({
...current,
[sessionUsageKey(appName, id)]: aggregateTokenUsage(s.events ?? []),
@@ -5065,24 +5245,201 @@ export default function App() {
);
}
+ const activeTurnControl = sessionId ? turnControlBySession[sessionId] ?? null : null;
+ const activeTurnIsControllable = Boolean(
+ activeTurnControl?.allowedActions.length
+ || ["running", "pausing", "paused", "resuming", "interrupting", "cancelling"]
+ .includes(activeTurnControl?.state ?? ""),
+ );
+
+ function commitFinishedAssistantTurns(
+ sid: string,
+ eventProjector: ReturnType,
+ ): { hasVisibleContent: boolean; eventId: string } {
+ let hasVisibleContent = false;
+ let eventId = "";
+ for (const unfinished of eventProjector.finish()) {
+ if (turnHasVisibleContent(unfinished)) {
+ hasVisibleContent = true;
+ eventId = unfinished.meta?.eventId ?? eventId;
+ }
+ setTurnsFor(sid, (turns) =>
+ upsertProjectedAssistantTurn(turns, unfinished),
+ );
+ }
+ return { hasVisibleContent, eventId };
+ }
+
+ async function streamTurnContinuation(sid: string, control: TurnControlState) {
+ const ctrl = new AbortController();
+ streamAbortsRef.current.set(sid, ctrl);
+ setStreaming(sid, true);
+ setTurnControlBySession((current) => {
+ if (!current[sid]) return current;
+ const next = { ...current };
+ delete next[sid];
+ return next;
+ });
+ const nextTurnControls = { ...turnControlBySessionRef.current };
+ delete nextTurnControls[sid];
+ turnControlBySessionRef.current = nextTurnControls;
+ startStreamPresentation(sid);
+ viewSidRef.current = sid;
+ const eventProjector = createAssistantEventProjector(
+ `${sid}-continue-${crypto.randomUUID()}`,
+ undefined,
+ { mpaA2a: isMpaA2aRuntimeApp(appName) },
+ );
+ let streamFailed = false;
+ try {
+ let finalEventId = "";
+ let hasCompletedReply = false;
+ for await (const event of continueTurnSSE({
+ appName,
+ sessionId: sid,
+ taskId: control.taskId,
+ expectedGeneration: control.generation,
+ idempotencyKey: `mpa-continue:${control.taskId}:${control.generation}`,
+ lastEventId: lastCompletedEventId(turnsBySession[sid]),
+ signal: ctrl.signal,
+ onRuntimeContext: (context) => {
+ setRuntimeLogTargetsBySession((current) => ({
+ ...current,
+ [`${appName}\n${sid}`]: context,
+ }));
+ },
+ })) {
+ if (ctrl.signal.aborted) break;
+ const errMsg = event.error ?? event.errorMessage ?? event.error_message;
+ if (typeof errMsg === "string" && errMsg) {
+ streamFailed = true;
+ if (viewSidRef.current === sid) setError(errMsg);
+ break;
+ }
+ addTokenUsageFor(appName, sid, event);
+ const projection = eventProjector.project(event);
+ if (projection.ignored) continue;
+ if (
+ projection.completed &&
+ turnHasVisibleContent(projection.turn)
+ ) {
+ hasCompletedReply = true;
+ finalEventId = projection.turn.meta?.eventId ?? finalEventId;
+ }
+ setTurnsFor(sid, (turns) =>
+ upsertProjectedAssistantTurn(turns, projection.turn),
+ );
+ }
+ const finalized = commitFinishedAssistantTurns(sid, eventProjector);
+ hasCompletedReply = hasCompletedReply || finalized.hasVisibleContent;
+ finalEventId = finalized.eventId || finalEventId;
+ if (!ctrl.signal.aborted && !streamFailed && !hasCompletedReply) {
+ streamFailed = true;
+ if (viewSidRef.current === sid) {
+ setError(runSseIncompleteResponseError());
+ }
+ }
+ void refreshSessions(appName);
+ if (!ctrl.signal.aborted && !streamFailed && finalEventId) {
+ void refreshSessionTranscript(appName, sid, finalEventId);
+ }
+ if (!ctrl.signal.aborted && !streamFailed && finalEventId) {
+ automaticEvaluationStatusRefreshRef.current();
+ }
+ } catch (e) {
+ streamFailed = true;
+ if (
+ (e as Error)?.name !== "AbortError" &&
+ !ctrl.signal.aborted &&
+ viewSidRef.current === sid
+ ) {
+ setError(e instanceof Error ? e.message : String(e));
+ }
+ } finally {
+ commitFinishedAssistantTurns(sid, eventProjector);
+ if (streamAbortsRef.current.get(sid) === ctrl) {
+ streamAbortsRef.current.delete(sid);
+ setStreaming(sid, false);
+ finishStreamPresentation(sid);
+ }
+ }
+ }
+
+ async function applyTurnControl(action: "pause" | "resume") {
+ if (!sessionId || !appName || !activeTurnControl) return;
+ setTurnControlBusy(true);
+ try {
+ const next = await controlTurn(
+ appName, sessionId, action, activeTurnControl.generation,
+ );
+ if (action === "resume" && next.resumeDisposition === "new_turn_required") {
+ streamAbortsRef.current.get(sessionId)?.abort();
+ await streamTurnContinuation(sessionId, next);
+ return;
+ }
+ setTurnControlBySession((current) => ({ ...current, [sessionId]: next }));
+ turnControlBySessionRef.current = {
+ ...turnControlBySessionRef.current,
+ [sessionId]: next,
+ };
+ } catch (cause) {
+ if (cause instanceof TurnControlConflictError) {
+ const next = cause.authoritativeState;
+ setTurnControlBySession((current) => ({ ...current, [sessionId]: next }));
+ turnControlBySessionRef.current = {
+ ...turnControlBySessionRef.current,
+ [sessionId]: next,
+ };
+ return;
+ }
+ setError(cause instanceof Error ? cause.message : String(cause));
+ } finally {
+ setTurnControlBusy(false);
+ }
+ }
+
async function send(
text: string,
atts: Attachment[] = [],
selectedInvocation: FrontendInvocation = emptyInvocation(),
messageSource: AgentMessageSource = "composer",
selectedPlatformTools?: readonly string[],
+ allowWhileBusy = false,
) {
// `busy` here = the CURRENT session is already streaming (can't double-send
// to it). Other sessions can stream concurrently.
if (
(!text.trim() && atts.length === 0) ||
- conversationBusy ||
+ (!allowWhileBusy && conversationBusy) ||
!appName ||
!userId
) return;
+ const selectableModels = agentInfo?.selectableModels ?? [];
+ const modelSelectionKey = sessionId || `new:${appName}`;
+ const selectedModel = selectedModelBySession[modelSelectionKey] || "";
+ const requestedModel = selectableModels.length > 1
+ ? selectableModels.includes(selectedModel)
+ ? selectedModel
+ : agentInfo?.model || selectableModels[0] || ""
+ : "";
+ const mountedSkillInvocation = {
+ ...selectedInvocation,
+ skills: [
+ ...selectedInvocation.skills,
+ ...(agentInfo?.agentCategory === "mpa" ? [] : selectedSessionSkills).map((skill) => ({
+ name: skill.name,
+ description: skill.description ?? "",
+ skillSpaceId: skill.skillSpaceId,
+ skillId: skill.skillId,
+ version: skill.version,
+ })),
+ ].filter((skill, index, values) => (
+ values.findIndex((candidate) => candidate.name === skill.name) === index
+ )),
+ };
setError("");
const createsSession = !sessionId;
- let platformTools = [...(selectedPlatformTools ?? selectedStudioToolIds)];
+ let platformTools = [...(selectedPlatformTools ?? environmentStudioToolIds)];
const environmentMounts = createsSession
? []
: environmentMountsBySession[
@@ -5104,8 +5461,8 @@ export default function App() {
: null;
const userBlocks: Turn["blocks"] = [];
- if (selectedInvocation.skills.length > 0 || selectedInvocation.targetAgent) {
- userBlocks.push({ kind: "invocation", value: selectedInvocation });
+ if (mountedSkillInvocation.skills.length > 0 || mountedSkillInvocation.targetAgent) {
+ userBlocks.push({ kind: "invocation", value: mountedSkillInvocation });
}
if (atts.length)
userBlocks.push({
@@ -5161,6 +5518,9 @@ export default function App() {
if (selectedTask) {
const requiredTools = NEW_CHAT_TASK_TOOLS[selectedTask];
const agentTools = new Set(agentInfo?.tools ?? []);
+ const availableStudioToolIds = new Set(
+ studioToolCapabilities?.tools.map((tool) => tool.id) ?? [],
+ );
const availableTools = new Set([
...agentTools,
...(studioToolRuntime ? availableStudioToolIds : []),
@@ -5201,15 +5561,15 @@ export default function App() {
createsSession ? optimisticTurns : [...current, ...optimisticTurns],
);
if (createsSession) {
- if (studioToolRuntime) {
- const key = studioToolSelectionKey(appName, userId, sid);
- setStudioToolIdsBySession((current) => ({
+ viewSidRef.current = sid;
+ setSessionId(sid);
+ if (requestedModel && createsSession) {
+ setSelectedModelBySession((current) => ({
...current,
- [key]: [...platformTools],
+ [sid]: requestedModel,
+ [modelSelectionKey]: "",
}));
}
- viewSidRef.current = sid;
- setSessionId(sid);
setPendingTurns([]);
setInitializingSession(false);
}
@@ -5218,20 +5578,29 @@ export default function App() {
const ctrl = new AbortController();
streamAbortsRef.current.set(sid, ctrl);
setStreaming(sid, true);
+ if (agentInfo?.turnLifecycleControl && currentRuntime) {
+ setTurnControlBySession((current) => {
+ if (!current[sid]) return current;
+ const next = { ...current };
+ delete next[sid];
+ return next;
+ });
+ const nextTurnControls = { ...turnControlBySessionRef.current };
+ delete nextTurnControls[sid];
+ turnControlBySessionRef.current = nextTurnControls;
+ }
startStreamPresentation(sid);
viewSidRef.current = sid;
- setActiveAgentBySession((m) => ({ ...m, [sid]: "" }));
- setSeenAgentsBySession((m) => ({ ...m, [sid]: new Set() }));
- setExecPathBySession((m) => ({ ...m, [sid]: [] }));
-
const eventProjector = createAssistantEventProjector(
`${sid}-${crypto.randomUUID()}`,
optimisticAssistantTurn,
+ { mpaA2a: isMpaA2aRuntimeApp(appName) },
);
let streamFailed = false;
let streamError: unknown = null;
try {
+ const mpaRunConfig = await resolveMpaRunConfig(sid, ctrl.signal);
let finalEventId = "";
let hasCompletedReply = false;
for await (const event of runSSE({
@@ -5239,8 +5608,12 @@ export default function App() {
userId,
sessionId: sid,
text,
+ modelId: requestedModel,
+ idempotencyKey: mpaRunConfig?.idempotencyKey,
+ executionConfigVersion: mpaRunConfig?.executionConfigVersion,
+ lastEventId: lastCompletedEventId(turnsBySession[sid]),
attachments: atts,
- invocation: selectedInvocation,
+ invocation: mountedSkillInvocation,
platformTools: studioToolRuntime ? platformTools : undefined,
environmentMounts: studioToolRuntime && environmentMounts.length > 0
? environmentMounts
@@ -5261,8 +5634,6 @@ export default function App() {
if (viewSidRef.current === sid) setError(errMsg);
break;
}
- // Live topology: author + transfer/end signals, keyed by session.
- applyStreamSignals(sid, event);
addTokenUsageFor(appName, sid, event);
const projection = eventProjector.project(event);
if (projection.ignored) continue;
@@ -5277,6 +5648,9 @@ export default function App() {
upsertProjectedAssistantTurn(turns, projection.turn),
);
}
+ const finalized = commitFinishedAssistantTurns(sid, eventProjector);
+ hasCompletedReply = hasCompletedReply || finalized.hasVisibleContent;
+ finalEventId = finalized.eventId || finalEventId;
if (!ctrl.signal.aborted && !streamFailed && !hasCompletedReply) {
streamFailed = true;
streamError = runSseIncompleteResponseError();
@@ -5285,6 +5659,9 @@ export default function App() {
}
}
void refreshSessions(appName);
+ if (!ctrl.signal.aborted && !streamFailed && finalEventId) {
+ void refreshSessionTranscript(appName, sid, finalEventId);
+ }
if (trackRuntimeMessage && ctrl.signal.aborted) {
messageOperation?.fail({
sessionId: String(sid),
@@ -5325,11 +5702,7 @@ export default function App() {
setError(e instanceof Error ? e.message : String(e));
}
} finally {
- for (const unfinished of eventProjector.finish()) {
- setTurnsFor(sid, (turns) =>
- upsertProjectedAssistantTurn(turns, unfinished),
- );
- }
+ commitFinishedAssistantTurns(sid, eventProjector);
if (
!ctrl.signal.aborted &&
streamFailed &&
@@ -5338,11 +5711,11 @@ export default function App() {
) {
setInput((current) => current.trim() ? current : text);
}
- if (streamAbortsRef.current.get(sid) === ctrl) streamAbortsRef.current.delete(sid);
- setStreaming(sid, false);
- finishStreamPresentation(sid);
- setActiveAgentBySession((m) => ({ ...m, [sid]: "" }));
- setExecPathBySession((m) => ({ ...m, [sid]: [] }));
+ if (streamAbortsRef.current.get(sid) === ctrl) {
+ streamAbortsRef.current.delete(sid);
+ setStreaming(sid, false);
+ finishStreamPresentation(sid);
+ }
}
}
@@ -5403,13 +5776,14 @@ export default function App() {
studioToolSelectionKey(appName, userId, sid)
] ?? [];
const resumedPlatformTools = environmentMounts.length > 0
- ? [...new Set([...selectedStudioToolIds, ...ENVIRONMENT_STUDIO_TOOL_IDS])]
- : selectedStudioToolIds;
+ ? [...ENVIRONMENT_STUDIO_TOOL_IDS]
+ : [];
const eventProjector = createAssistantEventProjector(
`${sid}-${crypto.randomUUID()}`,
lastTurn?.role === "assistant"
? { ...lastTurn, blocks: base }
: undefined,
+ { mpaA2a: isMpaA2aRuntimeApp(appName) },
);
try {
let finalEventId = "";
@@ -5422,6 +5796,7 @@ export default function App() {
functionResponses: [
{ id: block.callId, name: "adk_request_credential", response },
],
+ lastEventId: lastCompletedEventId(turnsBySession[sid]),
platformTools: studioToolRuntime ? resumedPlatformTools : undefined,
environmentMounts: studioToolRuntime && environmentMounts.length > 0
? environmentMounts
@@ -5441,7 +5816,6 @@ export default function App() {
if (viewSidRef.current === sid) setError(errMsg);
break;
}
- applyStreamSignals(sid, event);
addTokenUsageFor(appName, sid, event);
const projection = eventProjector.project(event);
if (projection.ignored) continue;
@@ -5456,6 +5830,9 @@ export default function App() {
upsertProjectedAssistantTurn(turns, projection.turn),
);
}
+ const finalized = commitFinishedAssistantTurns(sid, eventProjector);
+ hasCompletedReply = hasCompletedReply || finalized.hasVisibleContent;
+ finalEventId = finalized.eventId || finalEventId;
if (!ctrl.signal.aborted && !streamFailed && !hasCompletedReply) {
streamFailed = true;
if (viewSidRef.current === sid) {
@@ -5463,6 +5840,9 @@ export default function App() {
}
}
void refreshSessions(appName);
+ if (!ctrl.signal.aborted && !streamFailed && finalEventId) {
+ void refreshSessionTranscript(appName, sid, finalEventId);
+ }
if (!ctrl.signal.aborted && !streamFailed && finalEventId) {
automaticEvaluationStatusRefreshRef.current();
}
@@ -5476,16 +5856,10 @@ export default function App() {
setError(e instanceof Error ? e.message : String(e));
}
} finally {
- for (const unfinished of eventProjector.finish()) {
- setTurnsFor(sid, (turns) =>
- upsertProjectedAssistantTurn(turns, unfinished),
- );
- }
+ commitFinishedAssistantTurns(sid, eventProjector);
if (streamAbortsRef.current.get(sid) === ctrl) streamAbortsRef.current.delete(sid);
setStreaming(sid, false);
finishStreamPresentation(sid);
- setActiveAgentBySession((m) => ({ ...m, [sid]: "" }));
- setExecPathBySession((m) => ({ ...m, [sid]: [] }));
}
}
@@ -5506,6 +5880,69 @@ export default function App() {
region: currentConn.region,
}
: undefined;
+ const currentRuntimeId = currentRuntime?.runtimeId ?? "";
+ const currentRuntimeRegion = currentRuntime?.region ?? "";
+ useEffect(() => {
+ if (
+ !sessionId ||
+ !appName ||
+ !currentRuntimeId ||
+ !agentInfo?.turnLifecycleControl
+ ) return;
+ if (!busy && !turnControlBySessionRef.current[sessionId]) return;
+ let cancelled = false;
+ let controlUnavailable = false;
+ const refresh = () => {
+ if (controlUnavailable) return;
+ void getTurnControl(appName, sessionId)
+ .then((state) => {
+ if (!cancelled) {
+ controlUnavailable = false;
+ setTurnControlBySession((current) => (
+ current[sessionId]?.taskId === state.taskId
+ && current[sessionId]?.state === state.state
+ && current[sessionId]?.generation === state.generation
+ && current[sessionId]?.allowedActions.join("\0")
+ === state.allowedActions.join("\0")
+ ? current
+ : { ...current, [sessionId]: state }
+ ));
+ turnControlBySessionRef.current = {
+ ...turnControlBySessionRef.current,
+ [sessionId]: state,
+ };
+ }
+ })
+ .catch(() => {
+ if (!cancelled) {
+ const hadKnownControl = Boolean(turnControlBySessionRef.current[sessionId]);
+ setTurnControlBySession((current) => {
+ const next = { ...current };
+ delete next[sessionId];
+ return next;
+ });
+ const next = { ...turnControlBySessionRef.current };
+ delete next[sessionId];
+ turnControlBySessionRef.current = next;
+ if (!hadKnownControl) controlUnavailable = true;
+ }
+ });
+ };
+ refresh();
+ const timer = window.setInterval(() => {
+ const current = turnControlBySessionRef.current[sessionId];
+ if (current && ["completed", "failed", "rejected", "cancelled", "canceled", "interrupted", "orphaned"].includes(current.state)) return;
+ refresh();
+ }, 1000);
+ return () => { cancelled = true; window.clearInterval(timer); };
+ }, [
+ agentInfo?.turnLifecycleControl,
+ appName,
+ busy,
+ currentRuntimeId,
+ currentRuntimeRegion,
+ sessionId,
+ ]);
const selectedDraftStudioRuntime =
draftStudioRuntime?.appName === appName ? draftStudioRuntime : undefined;
// Local Agents execute through this Studio process, so use the synthetic
@@ -5524,7 +5961,7 @@ export default function App() {
useEffect(() => {
let cancelled = false;
setStudioToolCapabilities(null);
- setStudioToolsError("");
+ setSessionEnvironmentsError("");
if (
authStatus !== "authenticated" ||
!access ||
@@ -5545,7 +5982,7 @@ export default function App() {
})
.catch((cause) => {
if (cancelled) return;
- setStudioToolsError(
+ setSessionEnvironmentsError(
cause instanceof Error ? cause.message : appText("errors.localToolsLoadFailed"),
);
})
@@ -5571,11 +6008,23 @@ export default function App() {
setSessionEnvironmentsLoading(true);
setSessionEnvironmentsError("");
try {
- const [items, workspaces] = await Promise.all([
+ const environmentResults = await Promise.allSettled([
listEnvironments(controller.signal),
listWorkspaces(controller.signal),
]);
if (controller.signal.aborted) return;
+ const items = environmentResults[0].status === "fulfilled"
+ ? environmentResults[0].value
+ : [];
+ const workspaces = environmentResults[1].status === "fulfilled"
+ ? environmentResults[1].value
+ : [];
+ const environmentErrors = environmentResults.flatMap((result) =>
+ result.status === "rejected" &&
+ !isOptionalStudioStorageUnavailable(result.reason)
+ ? [result.reason instanceof Error ? result.reason.message : String(result.reason)]
+ : []
+ );
const availableEnvironments = items.filter((environment) =>
["aio-sandbox", "codex-sandbox"].includes(environment.baseEnvironment) &&
environment.latestVersion?.status === "available" &&
@@ -5593,6 +6042,7 @@ export default function App() {
// workspaces disappear from both the picker and existing Session mounts.
setSessionEnvironments(availableEnvironments);
setSessionWorkspaces(workspaces);
+ setSessionEnvironmentsError(environmentErrors.join("\n"));
setEnvironmentMountsBySession((current) => Object.fromEntries(
Object.entries(current).map(([key, selections]) => [
key,
@@ -5699,6 +6149,14 @@ export default function App() {
canDelete: entry.runtimeId
? libraryRuntimePermissions[entry.runtimeId]?.canDelete === true
: false,
+ agentCategory: entry.runtimeId
+ ? entry.agentCategory ??
+ libraryRuntimePermissions[entry.runtimeId]?.agentCategory
+ : entry.agentCategory,
+ mpaInstanceId: entry.runtimeId
+ ? entry.mpaInstanceId ??
+ libraryRuntimePermissions[entry.runtimeId]?.mpaInstanceId
+ : entry.mpaInstanceId,
}));
const orderedWorkspaceAgentEntries: AgentEntry[] = (() => {
if (workspaceAgentEntries.length === 0) return workspaceAgentEntries;
@@ -5718,51 +6176,24 @@ export default function App() {
const activeStudioToolSelectionKey = sessionId
? studioToolSelectionKey(appName, userId, sessionId)
: "";
- const storedStudioToolIds = sessionId
- ? (studioToolIdsBySession[activeStudioToolSelectionKey] ?? [])
- : draftStudioToolIds;
const allStudioToolIds = new Set(
studioToolCapabilities?.tools.map((tool) => tool.id) ?? [],
);
- const availableStudioToolIds = new Set(
- [...allStudioToolIds].filter((toolId) => !agentInfo?.tools.includes(toolId)),
- );
const selectedEnvironmentMounts = sessionId
? environmentMountsBySession[activeStudioToolSelectionKey] ?? []
: [];
- const selectedEnvironmentWorkspaceIds = sessionId
- ? environmentWorkspaceIdsBySession[activeStudioToolSelectionKey] ?? []
- : [];
const canMountSessionEnvironment = ENVIRONMENT_STUDIO_TOOL_IDS.every((toolId) =>
allStudioToolIds.has(toolId)
);
- const selectedStudioToolIds = [...new Set([
- ...storedStudioToolIds.filter((toolId) => availableStudioToolIds.has(toolId)),
- ...(selectedEnvironmentMounts.length > 0 && canMountSessionEnvironment
- ? [...ENVIRONMENT_STUDIO_TOOL_IDS]
- : []),
- ])];
- const visibleStudioTools = studioToolCapabilities?.tools.filter((tool) =>
- !ENVIRONMENT_STUDIO_TOOL_IDS.includes(
- tool.id as (typeof ENVIRONMENT_STUDIO_TOOL_IDS)[number],
- ) || selectedEnvironmentMounts.length > 0
- ) ?? [];
- const updateSelectedStudioToolIds = (selectedIds: string[]) => {
- const next = [...new Set([
- ...selectedIds,
- ...(selectedEnvironmentMounts.length > 0 ? [...ENVIRONMENT_STUDIO_TOOL_IDS] : []),
- ])].filter((toolId) =>
- availableStudioToolIds.has(toolId),
- );
- if (!sessionId) {
- setDraftStudioToolIds(next);
- return;
- }
- setStudioToolIdsBySession((current) => ({
- ...current,
- [activeStudioToolSelectionKey]: next,
- }));
- };
+ const environmentStudioToolIds = selectedEnvironmentMounts.length > 0 && canMountSessionEnvironment
+ ? [...ENVIRONMENT_STUDIO_TOOL_IDS]
+ : [];
+ const selectedSessionSkills = sessionId
+ ? sessionSkillsBySession[activeStudioToolSelectionKey] ?? []
+ : [];
+ const selectedEnvironmentWorkspaceIds = sessionId
+ ? environmentWorkspaceIdsBySession[activeStudioToolSelectionKey] ?? []
+ : [];
const updateSelectedEnvironments = async (
selections: SessionEnvironmentMountSelection[],
workspaceIds: string[] = [],
@@ -5799,26 +6230,8 @@ export default function App() {
...current,
[activeStudioToolSelectionKey]: workspaceIds,
}));
- setStudioToolIdsBySession((current) => {
- const selectedIds = current[activeStudioToolSelectionKey] ?? [];
- return {
- ...current,
- [activeStudioToolSelectionKey]: selections.length > 0
- ? [...new Set([...selectedIds, ...ENVIRONMENT_STUDIO_TOOL_IDS])]
- : selectedIds.filter((toolId) => !ENVIRONMENT_STUDIO_TOOL_IDS.includes(
- toolId as (typeof ENVIRONMENT_STUDIO_TOOL_IDS)[number],
- )),
- };
- });
};
- const studioToolsUnavailableReason = studioToolsError
- ? studioToolsError
- : studioToolCapabilities && !studioToolCapabilities.enabled
- ? t("errors.localBffToolsNotConfigured")
- : studioToolCapabilities && !studioToolCapabilities.supported
- ? t("errors.runtimeBffToolsDisabled")
- : "";
const sessionEnvironmentsUnavailableReason = sessionEnvironmentsError
|| (!studioToolsLoading && studioToolCapabilities && !canMountSessionEnvironment
? t("errors.sandboxToolsUnavailable")
@@ -5829,6 +6242,28 @@ export default function App() {
remoteAppId(currentConn.id, app) === appName
) ?? agentInfo?.appName ?? currentConn.apps[0] ?? currentConn.name
: "";
+ async function resolveMpaRunConfig(
+ sid: string,
+ signal?: AbortSignal,
+ ): Promise<{ idempotencyKey: string; executionConfigVersion: number } | null> {
+ if (
+ !isMpaRuntimeApp(appName) ||
+ !currentConn?.runtimeId ||
+ !currentRuntimeAppName
+ ) {
+ return null;
+ }
+ const config: MpaSessionExecutionConfig = await getMpaSessionExecutionConfig({
+ runtimeId: currentConn.runtimeId,
+ region: currentConn.region ?? defaultCloudRegion(cloudProvider),
+ sessionId: sid,
+ signal,
+ });
+ return {
+ idempotencyKey: `mpa-run:${sid}:${crypto.randomUUID()}`,
+ executionConfigVersion: config.revision,
+ };
+ }
const submitIssueFeedbackForTurn = async (feedback: {
issues: IssueFeedbackIssue[];
@@ -6108,6 +6543,7 @@ export default function App() {
setConnections(nextConnections);
setNewChatCapabilities(capabilities);
commitHydratedSessions(id, hydratedSessions);
+ agentInfoSelectionRef.current = id;
setAgentInfo(nextAgentInfo);
setCapabilitiesLoading(false);
setEvaluatingSids(new Set(
@@ -6115,6 +6551,7 @@ export default function App() {
.filter((status) => status.state === "running")
.map((status) => status.sessionId) ?? [],
));
+ startNewChat();
setAppName(id);
exitAgentDetailContext();
setFocusedDeploymentTaskId("");
@@ -6127,7 +6564,6 @@ export default function App() {
setIntelligentDeployment(null);
setWorkspaceView(false);
setEnvironmentView(false);
- startNewChat();
};
const openIntelligentDeploymentChat = async (agentId: string) => {
@@ -6138,7 +6574,10 @@ export default function App() {
await refreshCurrentAgentAndStartNewChat(id);
};
- const openAgentCreateFromMyAgents = (region: string) => {
+ const openAgentCreateFromMyAgents = (
+ region: string,
+ agentCategory: "general" | "mpa" = "general",
+ ) => {
if (!canCreateRuntimeAgents) {
setError(appText("errors.noCreateAgentPermission"));
return;
@@ -6147,9 +6586,24 @@ export default function App() {
setManageAgents(false);
setNewRuntimeRegion(region);
setImportedDraft(null);
- setCreateView(null);
- setAddMenuSurface("entry");
- setAddMenu(true);
+ setRuntimeUpdateTarget(null);
+ if (agentCategory === "mpa") {
+ setAddMenu(false);
+ setAddMenuSurface("entry");
+ setCustomCreateMode("custom");
+ setCustomCreationSurface("vulcan");
+ setEditingDraftId(`draft-${Date.now().toString(36)}`);
+ editingDraftBaselineRef.current = null;
+ setCreateView("custom");
+ } else {
+ setEditingDraftId("");
+ editingDraftBaselineRef.current = null;
+ setCreateView(null);
+ setAddMenuSurface("entry");
+ setAddMenu(true);
+ }
+ setFocusedDeploymentTaskId("");
+ setFocusedWorkspaceAgentId("");
setError("");
};
@@ -6169,6 +6623,10 @@ export default function App() {
agent.name,
agent.runtime.region,
agent.runtime.currentVersion,
+ {
+ agentCategory: agent.agentCategory ?? agent.runtime.agentCategory,
+ mpaInstanceId: agent.runtime.mpaInstanceId,
+ },
);
operation.succeed({
runtimeRegion: agent.runtime.region,
@@ -6190,6 +6648,10 @@ export default function App() {
} = {},
) => {
if (!agent.runtime) return;
+ // Task reads do not depend on the Runtime's chat/session protocol.
+ setSelectedCronRuntime({
+ runtimeId: agent.runtime.runtimeId, name: agent.name, region: agent.runtime.region,
+ });
try {
const agentId = await connectRuntimeForUser(
agent,
@@ -6215,6 +6677,36 @@ export default function App() {
setError("");
};
+ const editMpaProfile = (target: MpaProfileEditTarget) => {
+ if (!canManageAgents && !canCreateRuntimeAgents) {
+ setError(appText("errors.noManageAgentPermission"));
+ return;
+ }
+ exitAgentDetailContext();
+ setImportedDraft(target.draft);
+ setCustomCreateMode("custom");
+ setCustomCreationSurface("vulcan");
+ setEditingDraftId(`mpa-profile-${target.runtimeId}`);
+ editingDraftBaselineRef.current = null;
+ setFocusedDeploymentTaskId("");
+ setFocusedWorkspaceAgentId("");
+ setRuntimeUpdateTarget({
+ runtimeId: target.runtimeId,
+ mpaInstanceId: target.mpaInstanceId,
+ name: target.name,
+ region: target.region,
+ appName: target.appName,
+ currentVersion: target.currentVersion,
+ etag: target.runtimeRevision,
+ editMode: "regenerate",
+ mpaProfileOnly: true,
+ configuredMcpEnvKeys: configuredMcpEnvKeys(target.draft),
+ configuredRuntimeEnvKeys: [],
+ });
+ setCreateView("custom");
+ setError("");
+ };
+
const closeAgentDetailPage = () => {
exitAgentDetailContext();
setFocusedDeploymentTaskId("");
@@ -6249,8 +6741,6 @@ export default function App() {
const openMyAgentsPage = () => {
setPlatformFeedbackOrigin(null);
if (sandboxSession) exitSandboxSession();
- viewSidRef.current = "";
- setSessionId("");
setCreateView(null);
setSkillCenter(false);
setAddAgent(false);
@@ -6384,6 +6874,10 @@ export default function App() {
agent.label,
agent.region ?? defaultCloudRegion(cloudProvider),
agent.currentVersion,
+ {
+ agentCategory: agent.agentCategory,
+ mpaInstanceId: agent.mpaInstanceId,
+ },
);
operation.succeed({
runtimeRegion: agent.region,
@@ -6412,8 +6906,10 @@ export default function App() {
remote: true,
runtimeApp: detailConnection?.apps[0],
runtimeId: agentDetailTarget.runtime.runtimeId,
+ mpaInstanceId: agentDetailTarget.runtime.mpaInstanceId,
region: agentDetailTarget.runtime.region,
currentVersion: agentDetailTarget.runtime.currentVersion,
+ agentCategory: agentDetailTarget.agentCategory ?? agentDetailTarget.runtime.agentCategory,
canDelete: agentDetailTarget.runtime.canDelete,
}
: null;
@@ -6671,7 +7167,7 @@ export default function App() {
- {!sandboxSession && currentRuntime && sessionId && (
+ {!sandboxSession && currentRuntime && sessionId && !isMpaRuntimeApp(appName) && !isMpaA2aRuntimeApp(appName) && (
{
+ setSelectedModelBySession((current) => ({
+ ...current,
+ [sessionId || `new:${appName}`]: model,
+ }));
+ }}
+ turnControl={activeTurnIsControllable ? activeTurnControl : null}
+ turnControlBusy={turnControlBusy}
+ onTurnControl={(action) => void applyTurnControl(action)}
tokenUsage={activeTokenUsage}
systemTokenEstimate={systemTokenEstimate}
allowAttachments={!sandboxSession}
@@ -6917,10 +7430,13 @@ export default function App() {
cloudProvider,
),
isMine: runtime.isMine,
+ agentCategory: runtime.agentCategory,
runtime: {
runtimeId: runtime.runtimeId,
+ mpaInstanceId: runtime.mpaInstanceId,
region: runtime.region,
currentVersion: runtime.currentVersion,
+ agentCategory: runtime.agentCategory,
canDelete: runtime.canDelete,
},
},
@@ -6931,6 +7447,7 @@ export default function App() {
setDraftStudioRuntime({
appName: agentId,
runtimeId: runtime.runtimeId,
+ mpaInstanceId: runtime.mpaInstanceId,
name: runtime.name,
region: runtime.region,
});
@@ -7091,7 +7608,7 @@ export default function App() {
setCreateView("workspace");
} : undefined} />
) : cronJobsView ? (
-
+
) : applicationsView === "coding-agents" ? (
setApplicationsView("catalog")}
@@ -7100,6 +7617,12 @@ export default function App() {
setApplicationsView("catalog")}
/>
+ ) : applicationsView === "mpa-channels" ? (
+ setApplicationsView("catalog")}
+ />
) : applicationsView === "website-integration" ? (
setApplicationsView("catalog")}
@@ -7121,7 +7644,7 @@ export default function App() {
}}
/>
) : applicationsView === "catalog" ? (
-
+
) : sandboxAgentWorkspace ? (
void refreshAgentLibrary()}
@@ -7368,6 +7893,7 @@ export default function App() {
setFocusedWorkspaceAgentId("");
setRuntimeUpdateTarget({
runtimeId: capability.runtime.runtimeId,
+ mpaInstanceId: capability.runtime.mpaInstanceId,
name:
capability.runtime.name ||
runtimeAgent.name ||
@@ -7380,6 +7906,7 @@ export default function App() {
capability.editMode === "source-preserving"
? "source-preserving"
: "regenerate",
+ mpaProfileOnly: false,
configuredMcpEnvKeys: configuredMcpEnvKeys(editorDraft),
configuredRuntimeEnvKeys:
capability.runtime.configuredEnvKeys,
@@ -7387,6 +7914,7 @@ export default function App() {
setCreateView("custom");
setError("");
}}
+ onEditMpaProfile={editMpaProfile}
onEditDraft={(item) => {
exitAgentDetailContext();
setImportedDraft(item.draft);
@@ -7784,10 +8312,10 @@ export default function App() {
onWheel={onConversationWheel}
onTouchMove={onConversationTouchMove}
>
- {buildTranscriptRows(turns, rootCapabilityNode).map((row) => {
+ {buildTranscriptRows(transcriptTurns, rootCapabilityNode).map((row) => {
const renderTurn = (i: number) => {
- const turn = turns[i];
- const isLast = i === turns.length - 1;
+ const turn = transcriptTurns[i];
+ const isLast = i === transcriptTurns.length - 1;
if (turn.role === "system") {
return turn.activity ? (
- : ) : null
+ : ) : null
) : (
<>
- completeStreamPresentation(sessionId)
- : undefined
- }
- onAction={onAction}
- onAuth={onAuth}
- onArtifactDownload={(filename, version) =>
- downloadArtifact(appName, userId, sessionId, filename, version)
- }
- onArtifactPreview={(filename, version) =>
- previewArtifact(appName, userId, sessionId, filename, version)
- }
- onResolveDelivery={resolveIntelligentDelivery}
- onResolveDeliveryComparison={resolveIntelligentDeliveryComparison}
- onDownloadDelivery={downloadIntelligentDelivery}
- onDeployDelivery={setIntelligentDeployment}
- onBranchSelect={(branch) => {
- setInput(t("conversation.continueBranch", { branch: branch.label }));
- }}
- />
- {/* Finalized turn that produced no visible answer (e.g. only
- thinking + an empty A2UI surface) — show a fallback note. */}
- {!turnIsStreaming && !turnHasVisibleContent(turn) && (
+
+ completeStreamPresentation(sessionId)
+ : undefined
+ }
+ onAction={onAction}
+ onAuth={onAuth}
+ onArtifactDownload={(filename, version) =>
+ downloadArtifact(appName, userId, sessionId, filename, version)
+ }
+ onArtifactPreview={(filename, version) =>
+ previewArtifact(appName, userId, sessionId, filename, version)
+ }
+ onResolveDelivery={resolveIntelligentDelivery}
+ onResolveDeliveryComparison={resolveIntelligentDeliveryComparison}
+ onDownloadDelivery={downloadIntelligentDelivery}
+ onDeployDelivery={setIntelligentDeployment}
+ onBranchSelect={(branch) => {
+ setInput(t("conversation.continueBranch", { branch: branch.label }));
+ }}
+ />
+
+ {/* MPA A2A fragments share one request-level empty notice. */}
+ {shouldShowEmptyAssistantResponse(transcriptTurns, i, turnHasVisibleContent, {
+ mpaA2a: isMpaA2aRuntimeApp(appName),
+ turnIsStreaming,
+ requestIsStreaming: activeConversationBusy || presentingStream,
+ }) && (
{t("conversation.emptyResponse")}
)}
{/* Hide the actions/timestamp row while this turn is still
@@ -8027,7 +8560,7 @@ export default function App() {
title={t("feedback.reportIssue")}
onClick={() => setIssueFeedbackTarget({
turn,
- input: previousUserTurnText(turns, i),
+ input: previousUserTurnText(transcriptTurns, i),
})}
>
@@ -8082,25 +8615,15 @@ export default function App() {
return renderTurn(row.turnIndexes[0]);
})}
- {!sandboxSession && (
-
{
+ preparedAgentSelectionRef.current = null;
+ setAgentInfoRefreshKey(key => key + 1);
+ }}
loading={capabilitiesLoading}
- activeAgent={activeAgent}
- seenAgents={seenAgents}
- execPath={execPath}
- studioTools={visibleStudioTools}
- selectedStudioToolIds={selectedStudioToolIds}
- managedStudioToolIds={selectedEnvironmentMounts.length > 0
- ? ENVIRONMENT_STUDIO_TOOL_IDS
- : []}
- studioToolsLoading={studioToolsLoading}
- studioToolsDisabled={conversationBusy}
- studioToolsUnavailableReason={studioToolsUnavailableReason}
- onStudioToolsChange={
- studioToolRuntime ? updateSelectedStudioToolIds : undefined
- }
environments={sessionEnvironments}
workspaces={sessionWorkspaces}
selectedEnvironments={selectedEnvironmentMounts}
diff --git a/frontend/src/adk/channels.test.ts b/frontend/src/adk/channels.test.ts
new file mode 100644
index 000000000..06fe3a1b0
--- /dev/null
+++ b/frontend/src/adk/channels.test.ts
@@ -0,0 +1,15 @@
+import { afterEach, expect, it, vi } from "vitest";
+import { channelRequest } from "./client";
+
+afterEach(() => vi.unstubAllGlobals());
+
+it("routes channel CRUD through the Runtime proxy without browser credentials", async () => {
+ const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify({ success: true }), { status: 200, headers: { "Content-Type": "application/json" } }));
+ vi.stubGlobal("fetch", fetcher);
+ await channelRequest({ runtimeId: "runtime-test", region: "cn-beijing" }, "/chat-permissions?channel=feishu&chat_id=group", { method: "DELETE" });
+ const [url, init] = fetcher.mock.calls[0];
+ expect(String(url)).toContain("/web/runtime-proxy/runtime-test/api/v1/channels/chat-permissions");
+ expect(String(url)).toContain("_method=DELETE");
+ expect(init.method).toBe("POST");
+ expect(new Headers(init.headers).has("X-MPA-Channel-Key")).toBe(false);
+});
diff --git a/frontend/src/adk/client.ts b/frontend/src/adk/client.ts
index 3841b7ada..fd6e951f7 100644
--- a/frontend/src/adk/client.ts
+++ b/frontend/src/adk/client.ts
@@ -1,3 +1,4 @@
+import { fetchMpaCronTasks, manageMpaTask, fetchMpaRuns, type MpaRuntime } from "./mpaCronTasks";
// Thin client for the Google ADK API server (the same server `veadk frontend`
// launches). Uses relative URLs so it works same-origin in production and via
// the Vite dev proxy in development.
@@ -15,7 +16,7 @@ import {
runtimeContextFromResponse,
type RuntimeLogTarget,
} from "./runtimeLogs";
-import { parseSSE } from "./sse";
+import { parseSSE, SSE_EVENT_NAME } from "./sse";
import { normalizeRuntimeDescription } from "./runtimeDescription";
import {
DeploymentStatusUnconfirmedError,
@@ -104,6 +105,8 @@ export interface AdkEvent {
timestamp?: number;
usageMetadata?: AdkUsage;
usage_metadata?: AdkUsage;
+ customMetadata?: Record;
+ custom_metadata?: Record;
// Set when the model/run fails; /run_sse emits it as a `data: {"error": ...}`
// frame (also seen as errorMessage / error_message).
error?: string;
@@ -142,6 +145,8 @@ export interface TraceSpan {
export interface AdkSession {
id: string;
lastUpdateTime?: number;
+ title?: string;
+ status?: string;
events?: AdkEvent[];
state?: Record;
[k: string]: unknown;
@@ -377,8 +382,10 @@ export interface AdkEndpoint {
base?: string;
apiKey?: string;
runtimeId?: string;
+ mpaInstanceId?: string;
region?: string;
runtimeVersion?: number | null;
+ agentCategory?: "general" | "mpa";
retryProbe?: boolean;
}
@@ -397,7 +404,9 @@ interface RemoteApp {
base?: string;
apiKey?: string;
runtimeId?: string;
+ mpaInstanceId?: string;
region?: string;
+ agentCategory?: "general" | "mpa";
}
const remoteApps = new Map();
@@ -414,10 +423,23 @@ function resolve(appName: string): { app: string; ep: AdkEndpoint } {
if (!r) return { app: appName, ep: {} };
return {
app: r.app,
- ep: { base: r.base, apiKey: r.apiKey, runtimeId: r.runtimeId, region: r.region },
+ ep: {
+ base: r.base,
+ apiKey: r.apiKey,
+ runtimeId: r.runtimeId,
+ mpaInstanceId: r.mpaInstanceId,
+ region: r.region,
+ agentCategory: r.agentCategory,
+ },
};
}
+function isMpaEndpoint(app: string, ep: AdkEndpoint): boolean {
+ // Discovery selects the transport; product metadata must not override A2A.
+ return app !== "a2a-default" &&
+ Boolean(ep.runtimeId && (ep.agentCategory === "mpa" || ep.mpaInstanceId));
+}
+
/** fetch wrapper. Routing, in priority order:
* 1. `runtimeId` → same-origin `/web/runtime-proxy/{id}{path}` (server injects
* the apikey; apikey never reaches the browser).
@@ -519,6 +541,22 @@ function formatErrorDetail(detail: unknown): string {
return "";
}
+async function httpErrorCode(res: Response): Promise {
+ const text = await res.clone().text().catch(() => "");
+ if (!text) return "";
+ try {
+ const data = JSON.parse(text) as { detail?: unknown; error?: unknown };
+ const detail = data.detail ?? data.error;
+ if (typeof detail === "string") return detail;
+ if (detail && typeof detail === "object" && "code" in detail) {
+ return String((detail as { code?: unknown }).code ?? "");
+ }
+ } catch {
+ return "";
+ }
+ return "";
+}
+
export async function httpErrorMessage(
res: Response,
fallback: string,
@@ -551,6 +589,17 @@ export async function httpErrorMessage(
}
}
+async function mpaRuntimeSessionListError(res: Response): Promise {
+ const detail = await httpErrorMessage(
+ res,
+ adkT("client.mpaRuntimeSessionsLoadFailed"),
+ );
+ if (res.status === 401 && /X-Jwt-Token/i.test(detail)) {
+ return adkT("client.mpaRuntimeLegacyAuthUnsupported", { detail });
+ }
+ return detail;
+}
+
export async function listModelApiKeys(
signal?: AbortSignal,
refresh = false,
@@ -843,6 +892,59 @@ export async function createSession(
userId: string,
): Promise {
const { app, ep } = resolve(appName);
+ if (isMpaEndpoint(app, ep)) {
+ const mpaInstanceId = await resolveMpaRuntimeInstanceId(ep);
+ const statusRes = await apiFetch(
+ `/api/v1/agents/${encodeURIComponent(mpaInstanceId)}/profile-status`,
+ { cache: "no-store" },
+ ep,
+ );
+ const profileNotFound =
+ !statusRes.ok && (await httpErrorCode(statusRes)) === "profile_not_found";
+ if (!statusRes.ok && !profileNotFound) {
+ const fallback = adkT("client.createSessionFailedWithStatus", {
+ status: statusRes.status,
+ });
+ const detail = await httpErrorMessage(
+ statusRes,
+ adkT("client.createSessionFailed"),
+ );
+ throw new Error(
+ detail === fallback
+ ? fallback
+ : adkT("common.fallbackWithDetail", { fallback, detail }),
+ );
+ }
+ const profile = profileNotFound
+ ? {}
+ : ((await statusRes.json()) as Partial);
+ const profileRevision = Number(profile.profileRevision ?? 0);
+ const res = await apiFetch(
+ "/api/v1/sessions",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ mpaInstanceId,
+ profileRevision,
+ }),
+ },
+ ep,
+ );
+ if (!res.ok) {
+ const fallback = adkT("client.createSessionFailedWithStatus", {
+ status: res.status,
+ });
+ const detail = await httpErrorMessage(res, adkT("client.createSessionFailed"));
+ throw new Error(
+ detail === fallback
+ ? fallback
+ : adkT("common.fallbackWithDetail", { fallback, detail }),
+ );
+ }
+ const session = await res.json();
+ return String(session.sessionId ?? session.id ?? "");
+ }
const res = await apiFetch(
`/apps/${app}/users/${encodeURIComponent(userId)}/sessions`,
{ method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" },
@@ -857,13 +959,39 @@ export async function createSession(
return session.id;
}
+async function resolveMpaRuntimeInstanceId(ep: AdkEndpoint): Promise {
+ const existing = ep.mpaInstanceId?.trim();
+ if (existing) return existing;
+ const runtimeId = ep.runtimeId?.trim() ?? "";
+ if (!runtimeId) return "";
+ try {
+ const detail = await getRuntimeDetail(runtimeId, ep.region ?? "cn-beijing");
+ const fromDetail = detail.mpaInstanceId?.trim();
+ if (fromDetail) return fromDetail;
+ const fromEnv = detail.envs.find((item) => item.key === "MPA_AGENT_ID")
+ ?.value.trim();
+ if (fromEnv) return fromEnv;
+ } catch {
+ /* Preserve the legacy fallback for runtimes without visible metadata. */
+ }
+ return runtimeId;
+}
+
export async function listSessions(
appName: string,
userId: string,
): Promise {
const { app, ep } = resolve(appName);
+ if (isMpaEndpoint(app, ep)) {
+ const res = await apiFetch("/api/v1/sessions", { cache: "no-store" }, ep);
+ if (!res.ok) throw new Error(await mpaRuntimeSessionListError(res));
+ const payload = (await res.json()) as { sessions?: AdkSession[] } | AdkSession[];
+ return Array.isArray(payload) ? payload : payload.sessions ?? [];
+ }
const res = await apiFetch(`/apps/${app}/users/${encodeURIComponent(userId)}/sessions`, {}, ep);
- if (!res.ok) throw new Error(`list sessions failed: ${res.status}`);
+ if (!res.ok) {
+ throw new Error(await httpErrorMessage(res, adkT("client.listSessionsFailed")));
+ }
return res.json();
}
@@ -873,6 +1001,33 @@ export async function getSession(
sessionId: string,
): Promise {
const { app, ep } = resolve(appName);
+ if (isMpaEndpoint(app, ep)) {
+ const res = await apiFetch(
+ `/api/v1/sessions/${encodeURIComponent(sessionId)}`,
+ { cache: "no-store" },
+ ep,
+ );
+ if (!res.ok) {
+ const detail = await httpErrorMessage(res, adkT("client.getSessionFailed"));
+ throw new Error(adkT("client.getSessionFailedWithDetail", { status: res.status, detail }));
+ }
+ const session = (await res.json()) as AdkSession;
+ const eventsRes = await apiFetch(
+ `/api/v1/sessions/${encodeURIComponent(sessionId)}/events`,
+ { cache: "no-store" },
+ ep,
+ );
+ if (!eventsRes.ok) {
+ const detail = await httpErrorMessage(eventsRes, adkT("client.getSessionFailed"));
+ throw new Error(adkT("client.getSessionFailedWithDetail", {
+ status: eventsRes.status,
+ detail,
+ }));
+ }
+ const eventsPayload = (await eventsRes.json()) as { events?: AdkEvent[] };
+ session.events = Array.isArray(eventsPayload.events) ? eventsPayload.events : [];
+ return session;
+ }
const res = await apiFetch(
`/apps/${app}/users/${encodeURIComponent(userId)}/sessions/${encodeURIComponent(sessionId)}`,
{},
@@ -1214,6 +1369,15 @@ export async function deleteSession(
sessionId: string,
): Promise {
const { app, ep } = resolve(appName);
+ if (isMpaEndpoint(app, ep)) {
+ const res = await apiFetch(
+ `/api/v1/sessions/${encodeURIComponent(sessionId)}`,
+ { method: "DELETE" },
+ ep,
+ );
+ if (!res.ok && res.status !== 404) throw new Error(`delete session failed: ${res.status}`);
+ return;
+ }
const res = await apiFetch(
`/apps/${app}/users/${encodeURIComponent(userId)}/sessions/${sessionId}`,
{ method: "DELETE" },
@@ -1233,6 +1397,19 @@ function decodeArtifactData(value: string): Uint8Array {
return bytes;
}
+export async function fetchSessionFile(
+ appName: string,
+ sessionId: string,
+ path: string,
+ signal: AbortSignal,
+): Promise {
+ const { ep } = resolve(appName);
+ const url = `/api/v1/sessions/${encodeURIComponent(sessionId)}/files/download?path=${encodeURIComponent(path)}`;
+ const response = await apiFetch(url, { signal }, ep, TRANSFER_REQUEST_TIMEOUT_MS);
+ if (!response.ok) throw new Error(`SESSION_FILE_HTTP_${response.status}`);
+ return response.blob();
+}
+
export async function downloadArtifact(
appName: string,
userId: string,
@@ -1496,18 +1673,37 @@ export interface AgentTarget {
}
export interface FrontendInvocation {
- skills: AgentSkill[];
+ skills: SessionSkillSelection[];
targetAgent?: AgentTarget;
}
+export interface SessionSkillSelection extends AgentSkill {
+ skillSpaceId?: string;
+ skillId?: string;
+ version?: string;
+}
+
+export type MpaReadStatus = "ready" | "unsupported" | "forbidden" | "error";
+export interface MpaAgentMetadata {
+ agentsMd: string | null;
+ agentsMdStatus: MpaReadStatus;
+ skillSpacesStatus: MpaReadStatus;
+ skillSpaces: Array<{ id: string; region: string }>;
+}
+
/** Introspected metadata for an agent app, served locally or by Agent Server. */
export interface AgentInfo {
+ agentCategory?: "general" | "mpa";
+ mpa?: MpaAgentMetadata;
/** Real ADK app id used in runtime proxy paths; display names may differ. */
appName?: string;
name: string;
description: string;
type?: AgentNodeType;
model: string;
+ selectableModels?: string[];
+ turnLifecycleControl?: TurnLifecycleCapability;
+ resourceTopology?: ResourceTopology;
tools: string[];
skills: AgentSkill[];
/** False when an older Agent Server omits Skill introspection entirely. */
@@ -1527,8 +1723,9 @@ async function fetchAgentInfo(
app: string,
ep: AdkEndpoint,
loadDraft = true,
+ signal?: AbortSignal,
): Promise {
- const res = await apiFetch(`/web/agent-info/${app}`, {}, ep);
+ const res = await apiFetch(`/web/agent-info/${app}`, { signal }, ep);
if (!res.ok) throw new Error(`agent-info failed: ${res.status}`);
const info = (await res.json()) as Partial;
if (loadDraft && !info.draft) {
@@ -1544,10 +1741,15 @@ async function fetchAgentInfo(
}
return {
appName: app,
+ agentCategory: info.agentCategory,
+ mpa: info.mpa,
name: info.name ?? app,
description: info.description ?? "",
type: info.type,
model: info.model ?? "",
+ selectableModels: info.selectableModels ?? [],
+ turnLifecycleControl: info.turnLifecycleControl,
+ resourceTopology: info.resourceTopology,
tools: info.tools ?? [],
skillsPreviewSupported: Array.isArray(info.skills),
skills: info.skills ?? [],
@@ -1559,9 +1761,277 @@ async function fetchAgentInfo(
};
}
-export async function getAgentInfo(appName: string): Promise {
+function isAgentInfoNotFound(error: unknown): boolean {
+ return /agent-info failed:\s*404\b/i.test(String(error));
+}
+
+async function fetchAgentInfoWithA2aFallback(
+ app: string,
+ ep: AdkEndpoint,
+ loadDraft = true,
+ signal?: AbortSignal,
+): Promise {
+ try {
+ return await fetchAgentInfo(app, ep, loadDraft, signal);
+ } catch (error) {
+ if (app === "a2a-default" || !isAgentInfoNotFound(error)) throw error;
+ return fetchAgentInfo("a2a-default", ep, loadDraft, signal);
+ }
+}
+
+export type TurnControlAction = "pause" | "resume";
+
+export interface TurnLifecycleCapability {
+ actions: TurnControlAction[];
+ pauseMode: "cooperative-safe-point" | string;
+ processReplacementResume: boolean;
+}
+
+export interface ResourceTopology {
+ nodes: Array<{ id: string; kind: string; name: string; status: string; resourceId?: string }>;
+ edges: Array<{ source: string; target: string; relation: string }>;
+}
+
+export interface TurnControlState {
+ taskId: string;
+ state: string;
+ generation: number;
+ desiredState?: string | null;
+ safePoint?: string | null;
+ checkpoint?: Record | null;
+ allowedActions: TurnControlAction[];
+ idempotentReplay: boolean;
+ pausedAt?: string | null;
+ resumableUntil?: string | null;
+ resumeDisposition?: "same_turn" | "new_turn_required";
+ continuationPrompt?: string | null;
+}
+
+export interface TurnContinueResult {
+ taskId: string;
+ sessionId: string;
+ invocationId: string;
+ turnId: string;
+ operationId: string;
+ executionConfigRevision: number;
+ continuationOf: string;
+ idempotentReplay: boolean;
+}
+
+export interface ContinueTurnSseArgs {
+ appName: string;
+ sessionId: string;
+ taskId: string;
+ expectedGeneration: number;
+ idempotencyKey: string;
+ lastEventId?: string;
+ signal?: AbortSignal;
+ onRuntimeContext?: (context: RuntimeLogTarget) => void;
+}
+
+export class TurnControlConflictError extends Error {
+ constructor(readonly authoritativeState: TurnControlState) {
+ super("Turn state changed before the control request completed");
+ this.name = "TurnControlConflictError";
+ }
+}
+
+export async function getTurnControl(
+ appName: string,
+ sessionId: string,
+): Promise {
+ const { ep } = resolve(appName);
+ const res = await apiFetch(
+ `/api/v1/a2a/tasks/by-session/${encodeURIComponent(sessionId)}/control`,
+ { cache: "no-store" },
+ ep,
+ );
+ if (!res.ok) throw new Error(await httpErrorMessage(res, "turn control failed"));
+ return res.json();
+}
+
+export async function controlTurn(
+ appName: string,
+ sessionId: string,
+ action: TurnControlAction,
+ expectedGeneration: number,
+): Promise {
+ const { ep } = resolve(appName);
+ const res = await apiFetch(
+ `/api/v1/a2a/tasks/by-session/${encodeURIComponent(sessionId)}/control/${action}`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Idempotency-Key": crypto.randomUUID(),
+ },
+ body: JSON.stringify({ expectedGeneration }),
+ },
+ ep,
+ );
+ if (res.status === 409) {
+ const body = await res.json().catch(() => null) as { detail?: TurnControlState } | null;
+ if (body?.detail?.taskId) throw new TurnControlConflictError(body.detail);
+ }
+ if (!res.ok) throw new Error(await httpErrorMessage(res, "turn control failed"));
+ return res.json();
+}
+
+export async function continueTurn(
+ appName: string,
+ sessionId: string,
+ taskId: string,
+ expectedGeneration: number,
+ idempotencyKey: string,
+): Promise {
+ const { ep } = resolve(appName);
+ const res = await apiFetch(
+ `/api/v1/a2a/tasks/${encodeURIComponent(taskId)}/continue`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Idempotency-Key": idempotencyKey,
+ },
+ body: JSON.stringify({ expectedGeneration }),
+ },
+ ep,
+ );
+ if (res.status === 409) {
+ const body = await res.json().catch(() => null) as { detail?: TurnControlState } | null;
+ if (body?.detail?.taskId) throw new TurnControlConflictError(body.detail);
+ }
+ if (!res.ok) throw new Error(await httpErrorMessage(res, "turn continue failed"));
+ const result = await res.json() as TurnContinueResult;
+ if (result.sessionId !== sessionId) {
+ throw new Error("turn continue returned a different session");
+ }
+ return result;
+}
+
+export async function* continueTurnSSE({
+ appName,
+ sessionId,
+ taskId,
+ expectedGeneration,
+ idempotencyKey,
+ lastEventId,
+ signal,
+ onRuntimeContext,
+}: ContinueTurnSseArgs): AsyncGenerator {
+ const { ep } = resolve(appName);
+ const normalizedLastEventId = lastEventId?.trim() ?? "";
+ const firstEventDeadline = runSseFirstEventDeadline(signal);
+ let res: Response;
+ try {
+ const acceptedRes = await apiFetch(
+ `/api/v1/a2a/tasks/${encodeURIComponent(taskId)}/continue`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Idempotency-Key": idempotencyKey,
+ },
+ body: JSON.stringify({ expectedGeneration }),
+ signal: firstEventDeadline.signal,
+ },
+ ep,
+ 0,
+ );
+ const runtimeContext = runtimeContextFromResponse(
+ acceptedRes,
+ ep.runtimeId ?? "",
+ ep.region ?? "",
+ );
+ if (runtimeContext) onRuntimeContext?.(runtimeContext);
+ if (!acceptedRes.ok) {
+ firstEventDeadline.cleanup();
+ const detail = await httpErrorMessage(acceptedRes, "turn continue failed");
+ throw new Error(
+ formatRunSseError(adkT("client.runSseFailedWithDetail", {
+ status: acceptedRes.status,
+ detail,
+ })),
+ );
+ }
+ const accepted = (await acceptedRes.json()) as TurnContinueResult;
+ if (accepted.sessionId !== sessionId) {
+ firstEventDeadline.cleanup();
+ throw new Error(formatRunSseError("turn continue returned a different session"));
+ }
+ res = await apiFetch(
+ `/api/v1/sessions/${encodeURIComponent(sessionId)}/sse`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ invocationId: accepted.invocationId,
+ ...(normalizedLastEventId ? { lastEventId: normalizedLastEventId } : {}),
+ }),
+ signal: firstEventDeadline.signal,
+ },
+ ep,
+ 0,
+ );
+ } catch (error) {
+ firstEventDeadline.cleanup();
+ if (firstEventDeadline.timedOut()) throw new Error(runSseFirstEventTimeoutError());
+ if (signal?.aborted || (error as Error)?.name === "AbortError") throw error;
+ throw new Error(formatRunSseError(error));
+ }
+ const runtimeContext = runtimeContextFromResponse(
+ res,
+ ep.runtimeId ?? "",
+ ep.region ?? "",
+ );
+ if (runtimeContext) onRuntimeContext?.(runtimeContext);
+ if (!res.ok) {
+ firstEventDeadline.cleanup();
+ const detail = await httpErrorMessage(res, adkT("client.runSessionFailed"));
+ throw new Error(
+ formatRunSseError(adkT("client.runSseFailedWithDetail", {
+ status: res.status,
+ detail,
+ })),
+ );
+ }
+ let receivedEvent = false;
+ try {
+ for await (const evt of parseSSE(res)) {
+ if (
+ evt &&
+ typeof evt === "object" &&
+ (evt as Record)[SSE_EVENT_NAME] === "done"
+ ) {
+ firstEventDeadline.clearDeadline();
+ break;
+ }
+ receivedEvent = true;
+ firstEventDeadline.clearDeadline();
+ const event = { ...(evt as AdkEvent) };
+ delete (event as Record)[SSE_EVENT_NAME];
+ if (typeof event.error === "string") event.error = formatRunSseError(event.error);
+ if (typeof event.errorMessage === "string") {
+ event.errorMessage = formatRunSseError(event.errorMessage);
+ }
+ if (typeof event.error_message === "string") {
+ event.error_message = formatRunSseError(event.error_message);
+ }
+ yield event;
+ }
+ } catch (error) {
+ if (firstEventDeadline.timedOut()) throw new Error(runSseFirstEventTimeoutError());
+ if (signal?.aborted || (error as Error)?.name === "AbortError") throw error;
+ throw new Error(formatRunSseError(error));
+ } finally {
+ firstEventDeadline.cleanup();
+ }
+ if (!receivedEvent) throw new Error(runSseEmptyResponseError());
+}
+
+export async function getAgentInfo(appName: string, signal?: AbortSignal): Promise {
const { app, ep } = resolve(appName);
- return fetchAgentInfo(app, ep, false);
+ return fetchAgentInfoWithA2aFallback(app, ep, false, signal);
}
/** Read Agent metadata for a Runtime without connecting or persisting it. */
@@ -1585,7 +2055,7 @@ async function fetchRuntimeAgentInfo(
freshCached?.apps[0] ||
(await fetchRemoteApps("", "", ep))[0];
if (!app) throw new Error(adkT("client.noPreviewableAgent"));
- return fetchAgentInfo(app, ep);
+ return fetchAgentInfoWithA2aFallback(app, ep);
} catch (error) {
if (
error instanceof RuntimeAccessDeniedError ||
@@ -1662,6 +2132,18 @@ export function prefetchRuntimeAgentInfo(
void getRuntimeAgentInfo(runtimeId, region, knownApp).catch(() => {});
}
+export function isMpaRuntimeApp(appName: string): boolean {
+ const { app, ep } = resolve(appName);
+ return isMpaEndpoint(app, ep);
+}
+
+/** Product-scoped A2A presentation; general A2A apps must keep their defaults. */
+export function isMpaA2aRuntimeApp(appName: string): boolean {
+ const { app, ep } = resolve(appName);
+ return app === "a2a-default" &&
+ Boolean(ep.runtimeId && (ep.agentCategory === "mpa" || ep.mpaInstanceId));
+}
+
/** One web-search hit (Volcengine WebSearch WebItem, trimmed for the UI). */
export interface WebHit {
title: string;
@@ -1728,6 +2210,10 @@ export interface RunArgs {
userId: string;
sessionId: string;
text: string;
+ modelId?: string;
+ idempotencyKey?: string;
+ executionConfigVersion?: number;
+ lastEventId?: string;
attachments?: Attachment[];
invocation?: FrontendInvocation;
/** Complete set of local BFF tool IDs selected for this run. */
@@ -1814,6 +2300,10 @@ export async function* runSSE({
userId,
sessionId,
text,
+ modelId,
+ idempotencyKey,
+ executionConfigVersion,
+ lastEventId,
attachments = [],
invocation,
platformTools,
@@ -1866,37 +2356,139 @@ export async function* runSSE({
},
};
}
+ const normalizedIdempotencyKey = idempotencyKey?.trim() ?? "";
+ const normalizedLastEventId = lastEventId?.trim() ?? "";
+ const hasExecutionConfigVersion = typeof executionConfigVersion === "number";
+ const executionMetadata = hasExecutionConfigVersion || normalizedIdempotencyKey
+ ? {
+ veadkExecution: {
+ ...(hasExecutionConfigVersion ? { executionConfigVersion } : {}),
+ ...(normalizedIdempotencyKey
+ ? { idempotencyKey: normalizedIdempotencyKey }
+ : {}),
+ },
+ }
+ : undefined;
+ const customMetadata =
+ invocationMetadata || executionMetadata
+ ? {
+ ...(invocationMetadata ? { veadkInvocation: invocationMetadata } : {}),
+ ...(executionMetadata ?? {}),
+ }
+ : undefined;
+ const isMpa = isMpaEndpoint(app, ep);
+ if (isMpa && ep.runtimeId) {
+ const region = ep.region ? `?region=${encodeURIComponent(ep.region)}` : "";
+ try {
+ const prewarm = await studioFetch(
+ `/web/mpa/identity-prewarm/${encodeURIComponent(ep.runtimeId)}${region}`,
+ {
+ method: "POST",
+ headers: { "X-Requested-With": "XMLHttpRequest" },
+ signal,
+ },
+ 10_000,
+ );
+ if (!prewarm.ok) {
+ console.warn("[mpa] identity prewarm unavailable", prewarm.status);
+ }
+ } catch (error) {
+ if (signal?.aborted) throw error;
+ console.warn("[mpa] identity prewarm unavailable");
+ }
+ }
let res: Response;
const firstEventDeadline = runSseFirstEventDeadline(signal);
try {
- res = await apiFetch(
- "/run_sse",
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- app_name: app,
- user_id: userId,
- session_id: sessionId,
- new_message: { role: "user", parts },
- streaming: true,
- ...(platformTools !== undefined
- ? { platform_tools: [...platformTools] }
- : {}),
- ...(environmentMounts !== undefined
- ? { environment_mounts: [...environmentMounts] }
- : environmentMount
- ? { environment_mount: environmentMount }
+ if (isMpa) {
+ const runRes = await apiFetch(
+ `/api/v1/sessions/${encodeURIComponent(sessionId)}/run`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...(normalizedIdempotencyKey
+ ? { "Idempotency-Key": normalizedIdempotencyKey }
: {}),
- custom_metadata: invocationMetadata
- ? { veadkInvocation: invocationMetadata }
- : undefined,
- }),
- signal: firstEventDeadline.signal,
- },
- ep,
- 0,
- );
+ },
+ body: JSON.stringify({
+ content: text,
+ ...(hasExecutionConfigVersion ? { executionConfigVersion } : {}),
+ }),
+ signal: firstEventDeadline.signal,
+ },
+ ep,
+ 0,
+ );
+ const runtimeContext = runtimeContextFromResponse(
+ runRes,
+ ep.runtimeId ?? "",
+ ep.region ?? "",
+ );
+ if (runtimeContext) onRuntimeContext?.(runtimeContext);
+ if (!runRes.ok) {
+ firstEventDeadline.cleanup();
+ const detail = await httpErrorMessage(runRes, adkT("client.runSessionFailed"));
+ throw new Error(
+ formatRunSseError(adkT("client.runSseFailedWithDetail", {
+ status: runRes.status,
+ detail,
+ })),
+ );
+ }
+ const accepted = (await runRes.json()) as {
+ invocationId?: string;
+ };
+ res = await apiFetch(
+ `/api/v1/sessions/${encodeURIComponent(sessionId)}/sse`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ invocationId: accepted.invocationId,
+ ...(normalizedLastEventId ? { lastEventId: normalizedLastEventId } : {}),
+ }),
+ signal: firstEventDeadline.signal,
+ },
+ ep,
+ 0,
+ );
+ } else {
+ res = await apiFetch(
+ "/run_sse",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...(normalizedIdempotencyKey
+ ? { "Idempotency-Key": normalizedIdempotencyKey }
+ : {}),
+ },
+ body: JSON.stringify({
+ app_name: app,
+ user_id: userId,
+ session_id: sessionId,
+ new_message: { role: "user", parts },
+ streaming: true,
+ ...(normalizedLastEventId ? { lastEventId: normalizedLastEventId } : {}),
+ ...(hasExecutionConfigVersion ? { executionConfigVersion } : {}),
+ ...(modelId?.trim() ? { model_id: modelId.trim() } : {}),
+ ...(platformTools !== undefined
+ ? { platform_tools: [...platformTools] }
+ : {}),
+ ...(environmentMounts !== undefined
+ ? { environment_mounts: [...environmentMounts] }
+ : environmentMount
+ ? { environment_mount: environmentMount }
+ : {}),
+ custom_metadata: customMetadata,
+ }),
+ signal: firstEventDeadline.signal,
+ },
+ ep,
+ 0,
+ );
+ }
} catch (error) {
firstEventDeadline.cleanup();
if (firstEventDeadline.timedOut()) throw new Error(runSseFirstEventTimeoutError());
@@ -1919,9 +2511,18 @@ export async function* runSSE({
let receivedEvent = false;
try {
for await (const evt of parseSSE(res)) {
+ if (
+ evt &&
+ typeof evt === "object" &&
+ (evt as Record)[SSE_EVENT_NAME] === "done"
+ ) {
+ firstEventDeadline.clearDeadline();
+ break;
+ }
receivedEvent = true;
firstEventDeadline.clearDeadline();
- const event = evt as AdkEvent;
+ const event = { ...(evt as AdkEvent) };
+ delete (event as Record)[SSE_EVENT_NAME];
if (typeof event.error === "string") event.error = formatRunSseError(event.error);
if (typeof event.errorMessage === "string") {
event.errorMessage = formatRunSseError(event.errorMessage);
@@ -1947,6 +2548,7 @@ export interface DeployAgentkitResult {
agentName: string;
runtimeName: string;
runtimeId?: string;
+ mpaInstanceId?: string;
consoleUrl?: string;
region?: string;
version?: number | null;
@@ -1956,6 +2558,628 @@ export interface DeployAgentkitResult {
transport: string;
runtimeId?: string;
};
+ mpaOperation?: MpaAgentOperation;
+}
+
+export interface MpaProfilePayload {
+ name: string;
+ description: string;
+ system: string;
+ model: Record;
+ tools: Array>;
+ skills: Array>;
+ mcpServers: Array>;
+ multiagent?: Record;
+ metadata: Record;
+}
+
+export interface MpaAgentOperation {
+ operationId: string;
+ operationKind: "create" | "update";
+ ownerId: string;
+ targetKey: string;
+ stage: string;
+ status: "active" | "succeeded" | "failed_retryable" | "failed_terminal" | "cancelled";
+ mpaInstanceId?: string;
+ runtimeId?: string;
+ runtimeRegion?: string;
+ runtimeRevision?: string;
+ profileRevision?: number | null;
+ profileDigest?: string;
+ profileOperationId?: string;
+ safeErrorCode?: string;
+ retryCount?: number;
+ createdAt?: string;
+ updatedAt?: string;
+}
+
+export interface MpaProfileStatus {
+ operationId: string;
+ status: string;
+ profileRevision: number;
+ runtimeRevision: string;
+ etag: string;
+}
+
+export type MpaAgentBindingStatus =
+ | "runtime_missing"
+ | "bound"
+ | "binding_ambiguous"
+ | "orphan_runtime";
+
+export interface MpaAgentView {
+ mpaInstanceId: string;
+ bindingStatus: MpaAgentBindingStatus;
+ runtime: (Partial & {
+ runtimeId: string;
+ region: string;
+ readinessPhase?: string;
+ }) | null;
+ profile: MpaProfileStatus | null;
+ activeOperation: MpaAgentOperation | null;
+ bindings: Array & { runtimeId: string; region: string }>;
+ capabilities: {
+ canRead: boolean;
+ canWrite: boolean;
+ canDebug: boolean;
+ };
+ safeError?: { code: string; message: string } | null;
+}
+
+export interface MpaAgentDeletePreview {
+ mpaInstanceId: string;
+ bindingStatus: MpaAgentBindingStatus;
+ runtime: (Partial & { runtimeId: string; region: string }) | null;
+ profile: Partial | null;
+ activeOperation: MpaAgentOperation | null;
+ bindings: Array & { runtimeId: string; region: string }>;
+ canDelete: boolean;
+ blockers: string[];
+ sessionCounts: {
+ total: number;
+ active: number;
+ idle: number;
+ debug: number;
+ };
+ activeSessions: Array<{
+ sessionId: string;
+ status: string;
+ appName?: string;
+ }>;
+ cleanupPlan: Array<{
+ stage: string;
+ description: string;
+ count: number;
+ }>;
+}
+
+export interface MpaRuntimeConsoleRun {
+ invocationId: string;
+ userId: string;
+ status: string;
+ startedAt: string;
+ endedAt?: string | null;
+ durationMs?: number | null;
+ wallDurationMs?: number | null;
+ stepCount: number;
+ queueDurationMs?: number | null;
+ firstContentMs?: number | null;
+ firstModelContentMs?: number | null;
+ firstVisibleAnswerMs?: number | null;
+ modelRequestCount: number;
+ toolCount: number;
+ failedToolCount: number;
+ fileChangeCount: number;
+ fileChangeEventCount: number;
+ uniqueFileCount: number;
+ retryCount: number;
+ usage: Record;
+ slowestStep?: Record | null;
+ metricCoverage: Record;
+}
+
+export interface MpaRuntimeConsoleRunsResponse {
+ sessionId: string;
+ runs: MpaRuntimeConsoleRun[];
+}
+
+export interface MpaRuntimeTraceStep {
+ stepId: string;
+ parentStepId?: string | null;
+ sequence: number;
+ kind: string;
+ title: string;
+ status: string;
+ startedAt: string;
+ endedAt?: string | null;
+ durationMs?: number | null;
+ sourceEventId?: string | null;
+ category: string;
+ timing: Record;
+ correlation: Record;
+ details: Record;
+}
+
+export interface MpaRuntimeTraceResponse {
+ traceVersion: string;
+ sessionId: string;
+ invocationId: string;
+ correlation: Record;
+ run: Record;
+ coverage: Record;
+ steps: MpaRuntimeTraceStep[];
+}
+
+export type MpaExecutionConfigMode = "replace" | "clear" | "inherit";
+
+export interface MpaExecutionConfigChange {
+ category: string;
+ mode: MpaExecutionConfigMode;
+ value?: unknown;
+}
+
+export interface MpaSessionExecutionConfig {
+ appName: string;
+ sessionId: string;
+ revision: number;
+ etag: string;
+ mpaInstanceId: string;
+ profileRevision: number;
+ profileDefaultRevision: number;
+ overrides: Record;
+ effectiveRefs: Record;
+ invalidRefs: Array>;
+ updatedBy?: string;
+ createdAt?: string;
+ updatedAt?: string;
+}
+
+export class MpaExecutionConfigRequestError extends Error {
+ readonly status: number;
+ readonly code: string;
+ readonly currentState?: MpaSessionExecutionConfig;
+
+ constructor(
+ message: string,
+ {
+ status,
+ code,
+ currentState,
+ }: {
+ status: number;
+ code: string;
+ currentState?: MpaSessionExecutionConfig;
+ },
+ ) {
+ super(message);
+ this.name = "MpaExecutionConfigRequestError";
+ this.status = status;
+ this.code = code;
+ this.currentState = currentState;
+ }
+}
+
+async function mpaExecutionConfigError(
+ res: Response,
+): Promise {
+ const fallback = adkT("client.mpaExecutionConfigFailed");
+ const text = await res.text().catch(() => "");
+ if (!text) {
+ return new MpaExecutionConfigRequestError(
+ adkT("common.fallbackWithHttpStatus", { fallback, status: res.status }),
+ { status: res.status, code: "" },
+ );
+ }
+ try {
+ const data = JSON.parse(text) as {
+ detail?: unknown;
+ error?: unknown;
+ currentState?: MpaSessionExecutionConfig;
+ };
+ const envelope = (
+ data.error && typeof data.error === "object"
+ ? data.error
+ : data.detail && typeof data.detail === "object"
+ ? data.detail
+ : null
+ ) as { code?: unknown; currentState?: MpaSessionExecutionConfig } | null;
+ const detail = formatErrorDetail(data.detail ?? data.error);
+ const message = detail
+ ? adkT("client.errorWithDetailAndRawResponse", {
+ context: adkT("common.fallbackWithHttpStatus", {
+ fallback,
+ status: res.status,
+ }),
+ detail,
+ response: text,
+ })
+ : adkT("client.errorWithRawResponse", {
+ context: adkT("common.fallbackWithHttpStatus", {
+ fallback,
+ status: res.status,
+ }),
+ response: text,
+ });
+ return new MpaExecutionConfigRequestError(message, {
+ status: res.status,
+ code: String(envelope?.code ?? ""),
+ currentState: envelope?.currentState ?? data.currentState,
+ });
+ } catch {
+ return new MpaExecutionConfigRequestError(
+ adkT("client.errorWithRawResponse", {
+ context: adkT("common.fallbackWithHttpStatus", {
+ fallback,
+ status: res.status,
+ }),
+ response: text,
+ }),
+ { status: res.status, code: "" },
+ );
+ }
+}
+
+export function mpaProfileFromAgentDraft(draft: AgentDraft): MpaProfilePayload {
+ const selectedSkillProfiles = (draft.selectedSkills ?? []).map((skill) => ({
+ source: skill.source,
+ folder: skill.folder,
+ name: skill.name,
+ skillSpaceId: skill.skillSpaceId,
+ skillSpaceName: skill.skillSpaceName,
+ skillSpaceRegion: skill.skillSpaceRegion,
+ skillId: skill.skillId,
+ version: skill.version,
+ }));
+ const legacySkills = (draft.skills ?? []).map((name) => ({ name }));
+ const mcpServers = (draft.mcpTools ?? []).map((tool) => ({
+ name: tool.name,
+ transport: tool.transport,
+ url: tool.url,
+ }));
+ return {
+ name: draft.name?.trim() || "mpa-agent",
+ description: draft.description ?? "",
+ system: draft.instruction?.trim() || "You are a helpful assistant.",
+ model: {
+ id: draft.modelName || draft.model || "",
+ source: draft.modelSource ?? "ark",
+ provider: draft.modelProvider ?? "",
+ apiBase: draft.modelApiBase ?? "",
+ },
+ tools: [
+ ...(draft.builtinTools ?? []).map((id) => ({ id, source: "builtin" })),
+ ...(draft.tools ?? []).map((name) => ({ name, source: "legacy" })),
+ ...(draft.customTools ?? []).map((tool) => ({
+ name: tool.name,
+ description: tool.description,
+ source: "custom",
+ })),
+ ],
+ skills: [...selectedSkillProfiles, ...legacySkills],
+ mcpServers,
+ ...(draft.subAgents?.length
+ ? { multiagent: { type: draft.agentType ?? "llm", subAgents: draft.subAgents.map(mpaProfileFromAgentDraft) } }
+ : {}),
+ metadata: {
+ "veadk:agent-type": "mpa",
+ "veadk:source": "studio-agent-draft",
+ agentType: draft.agentType ?? "llm",
+ },
+ };
+}
+
+export async function startMpaAgentOperation(params: {
+ operationKind: "create" | "update";
+ runtimeId: string;
+ region: string;
+ mpaInstanceId: string;
+ sourceProfileId: string;
+ draft: AgentDraft;
+ targetKey?: string;
+ runtimeRevision?: string;
+ idempotencyKey: string;
+}): Promise {
+ const body = {
+ operationKind: params.operationKind,
+ runtimeId: params.runtimeId,
+ region: params.region,
+ mpaInstanceId: params.mpaInstanceId,
+ sourceProfileId: params.sourceProfileId,
+ targetKey: params.targetKey,
+ runtimeRevision: params.runtimeRevision,
+ profile: mpaProfileFromAgentDraft(params.draft),
+ };
+ const path =
+ params.operationKind === "update"
+ ? `/web/mpa/agents/${encodeURIComponent(params.mpaInstanceId)}`
+ : "/web/mpa/agents";
+ const res = await apiFetch(path, {
+ method: params.operationKind === "update" ? "PATCH" : "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Idempotency-Key": params.idempotencyKey,
+ },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ throw new Error(await httpErrorMessage(res, adkT("client.mpaOperationFailed")));
+ }
+ return (await res.json()) as MpaAgentOperation;
+}
+
+export async function applyMpaProfileAfterDeployment(params: {
+ draft: AgentDraft;
+ taskId: string;
+ agentName: string;
+ runtimeId: string;
+ region: string;
+ operationKind: "create" | "update";
+ mpaInstanceId?: string;
+ runtimeName?: string;
+ sourceDraftId?: string;
+ runtimeRevision?: string;
+}): Promise {
+ const normalizedRuntimeId = params.runtimeId.trim();
+ const normalizedMpaInstanceId =
+ params.mpaInstanceId?.trim() || normalizedRuntimeId;
+ const sourceProfileId = params.sourceDraftId?.trim()
+ ? `studio-draft:${params.sourceDraftId.trim()}`
+ : `studio-agent:${params.agentName}:${params.taskId}`;
+ return startMpaAgentOperation({
+ operationKind: params.operationKind,
+ runtimeId: normalizedRuntimeId,
+ region: params.region,
+ mpaInstanceId: normalizedMpaInstanceId,
+ sourceProfileId,
+ draft: params.draft,
+ targetKey:
+ params.operationKind === "update"
+ ? normalizedMpaInstanceId
+ : `runtime:${params.region}:${params.runtimeName?.trim() || normalizedMpaInstanceId}`,
+ runtimeRevision: params.runtimeRevision?.trim() || undefined,
+ idempotencyKey: `${params.taskId}:mpa-profile`,
+ });
+}
+
+export async function listActiveMpaAgentOperations(): Promise {
+ const res = await apiFetch("/web/mpa/agent-operations?status=active", {
+ cache: "no-store",
+ });
+ if (!res.ok) {
+ throw new Error(await httpErrorMessage(res, adkT("client.mpaOperationLoadFailed")));
+ }
+ const payload = (await res.json()) as { operations?: MpaAgentOperation[] };
+ return payload.operations ?? [];
+}
+
+export async function getMpaAgentOperation(
+ operationId: string,
+ signal?: AbortSignal,
+): Promise {
+ const res = await apiFetch(
+ `/web/mpa/agent-operations/${encodeURIComponent(operationId)}`,
+ { cache: "no-store", signal },
+ );
+ if (!res.ok) {
+ throw new Error(await httpErrorMessage(res, adkT("client.mpaOperationLoadFailed")));
+ }
+ return (await res.json()) as MpaAgentOperation;
+}
+
+export async function getMpaProfileStatus(params: {
+ runtimeId: string;
+ region: string;
+ mpaInstanceId: string;
+ signal?: AbortSignal;
+}): Promise {
+ const query = new URLSearchParams({
+ runtimeId: params.runtimeId,
+ region: params.region,
+ });
+ const res = await apiFetch(
+ `/web/mpa/agents/${encodeURIComponent(params.mpaInstanceId)}/profile-status?${query.toString()}`,
+ { cache: "no-store", signal: params.signal },
+ );
+ if (!res.ok) {
+ throw new Error(await httpErrorMessage(res, adkT("client.mpaOperationLoadFailed")));
+ }
+ const payload = (await res.json()) as Partial;
+ return {
+ operationId: String(payload.operationId ?? ""),
+ status: String(payload.status ?? ""),
+ profileRevision: Number(payload.profileRevision ?? 0),
+ runtimeRevision: String(payload.runtimeRevision ?? ""),
+ etag: String(payload.etag ?? res.headers.get("ETag") ?? ""),
+ };
+}
+
+export async function getMpaAgentView(params: {
+ mpaInstanceId: string;
+ runtimeId?: string;
+ region?: string;
+ signal?: AbortSignal;
+}): Promise {
+ const query = new URLSearchParams({ region: params.region ?? "all" });
+ if (params.runtimeId) query.set("runtimeId", params.runtimeId);
+ const res = await apiFetch(
+ `/web/mpa/agents/${encodeURIComponent(params.mpaInstanceId)}/view?${query.toString()}`,
+ { cache: "no-store", signal: params.signal },
+ );
+ if (!res.ok) {
+ throw new Error(await httpErrorMessage(res, adkT("client.mpaOperationLoadFailed")));
+ }
+ return (await res.json()) as MpaAgentView;
+}
+
+export async function getMpaAgentDeletePreview(params: {
+ mpaInstanceId: string;
+ runtimeId: string;
+ region: string;
+ signal?: AbortSignal;
+}): Promise {
+ const query = new URLSearchParams({
+ runtimeId: params.runtimeId,
+ region: params.region,
+ });
+ const res = await apiFetch(
+ `/web/mpa/agents/${encodeURIComponent(params.mpaInstanceId)}/delete-preview?${query.toString()}`,
+ { cache: "no-store", signal: params.signal },
+ );
+ if (!res.ok) {
+ throw new Error(await httpErrorMessage(res, adkT("client.mpaDeletePreviewFailed")));
+ }
+ return (await res.json()) as MpaAgentDeletePreview;
+}
+
+export async function getMpaRuntimeConsoleRuns(params: {
+ runtimeId: string;
+ region: string;
+ sessionId: string;
+ signal?: AbortSignal;
+}): Promise {
+ const ep: AdkEndpoint = {
+ runtimeId: params.runtimeId,
+ region: params.region,
+ agentCategory: "mpa",
+ };
+ const res = await apiFetch(
+ `/api/v1/runtime-console/admin/sessions/${encodeURIComponent(params.sessionId)}/runs`,
+ { cache: "no-store", signal: params.signal },
+ ep,
+ );
+ if (!res.ok) {
+ throw new Error(await httpErrorMessage(res, adkT("client.loadMpaDiagnosticsFailed")));
+ }
+ return (await res.json()) as MpaRuntimeConsoleRunsResponse;
+}
+
+export async function getMpaRuntimeConsoleTrace(params: {
+ runtimeId: string;
+ region: string;
+ sessionId: string;
+ invocationId: string;
+ signal?: AbortSignal;
+}): Promise {
+ const ep: AdkEndpoint = {
+ runtimeId: params.runtimeId,
+ region: params.region,
+ agentCategory: "mpa",
+ };
+ const query = new URLSearchParams({ sessionId: params.sessionId });
+ const res = await apiFetch(
+ `/api/v1/runtime-console/admin/runs/${encodeURIComponent(params.invocationId)}/trace?${query.toString()}`,
+ { cache: "no-store", signal: params.signal },
+ ep,
+ );
+ if (!res.ok) {
+ throw new Error(await httpErrorMessage(res, adkT("client.loadMpaDiagnosticsFailed")));
+ }
+ return (await res.json()) as MpaRuntimeTraceResponse;
+}
+
+export async function retryMpaAgentOperation(
+ operationId: string,
+ params: Omit[0], "idempotencyKey">,
+): Promise {
+ const res = await apiFetch(
+ `/web/mpa/agent-operations/${encodeURIComponent(operationId)}/retry`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ operationKind: params.operationKind,
+ runtimeId: params.runtimeId,
+ region: params.region,
+ mpaInstanceId: params.mpaInstanceId,
+ sourceProfileId: params.sourceProfileId,
+ targetKey: params.targetKey,
+ runtimeRevision: params.runtimeRevision,
+ profile: mpaProfileFromAgentDraft(params.draft),
+ }),
+ },
+ );
+ if (!res.ok) {
+ throw new Error(await httpErrorMessage(res, adkT("client.mpaOperationFailed")));
+ }
+ return (await res.json()) as MpaAgentOperation;
+}
+
+export async function getMpaSessionExecutionConfig(params: {
+ runtimeId: string;
+ region: string;
+ sessionId: string;
+ signal?: AbortSignal;
+}): Promise {
+ const query = new URLSearchParams({
+ runtimeId: params.runtimeId,
+ region: params.region,
+ });
+ const res = await apiFetch(
+ `/web/mpa/sessions/${encodeURIComponent(params.sessionId)}/execution-config?${query.toString()}`,
+ { cache: "no-store", signal: params.signal },
+ );
+ if (!res.ok) {
+ throw await mpaExecutionConfigError(res);
+ }
+ return (await res.json()) as MpaSessionExecutionConfig;
+}
+
+export async function patchMpaSessionExecutionConfig(params: {
+ runtimeId: string;
+ region: string;
+ sessionId: string;
+ etag: string;
+ changes: MpaExecutionConfigChange[];
+}): Promise {
+ const res = await apiFetch(
+ `/web/mpa/sessions/${encodeURIComponent(params.sessionId)}/execution-config`,
+ {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ "If-Match": params.etag,
+ },
+ body: JSON.stringify({
+ runtimeId: params.runtimeId,
+ region: params.region,
+ changes: params.changes,
+ }),
+ },
+ );
+ if (!res.ok) {
+ throw await mpaExecutionConfigError(res);
+ }
+ return (await res.json()) as MpaSessionExecutionConfig;
+}
+
+export async function upgradeMpaSessionProfile(params: {
+ runtimeId: string;
+ region: string;
+ sessionId: string;
+ etag: string;
+ idempotencyKey: string;
+ targetProfileRevision: number;
+}): Promise {
+ const res = await apiFetch(
+ `/web/mpa/sessions/${encodeURIComponent(params.sessionId)}/profile-upgrade`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "If-Match": params.etag,
+ "Idempotency-Key": params.idempotencyKey,
+ },
+ body: JSON.stringify({
+ runtimeId: params.runtimeId,
+ region: params.region,
+ targetProfileRevision: params.targetProfileRevision,
+ }),
+ },
+ );
+ if (!res.ok) {
+ throw await mpaExecutionConfigError(res);
+ }
+ return (await res.json()) as MpaSessionExecutionConfig;
}
export async function checkRuntimeNameAvailability(
@@ -3343,6 +4567,8 @@ export async function createGithubDeliveryCicdPipeline(params: {
runtimeName: string;
runtimeId: string;
region: string;
+ agentCategory?: "general" | "mpa";
+ mpaCompatibilityManifest?: Record;
cloudProvider: CloudProvider;
projectPath?: string;
volcengineAccessKey: string;
@@ -3361,6 +4587,8 @@ export async function createGithubDeliveryCicdPipeline(params: {
runtimeName: params.runtimeName,
runtimeId: params.runtimeId,
region: params.region,
+ agentCategory: params.agentCategory,
+ mpaCompatibilityManifest: params.mpaCompatibilityManifest,
cloudProvider: params.cloudProvider,
projectPath: params.projectPath ?? ".",
volcengineAccessKey: params.volcengineAccessKey,
@@ -3383,6 +4611,8 @@ export async function initializeGithubDeliveryMain(params: {
runtimeName: string;
runtimeId: string;
region: string;
+ agentCategory?: "general" | "mpa";
+ mpaCompatibilityManifest?: Record;
cloudProvider: CloudProvider;
projectPath?: string;
volcengineAccessKey: string;
@@ -3402,6 +4632,8 @@ export async function initializeGithubDeliveryMain(params: {
runtimeName: params.runtimeName,
runtimeId: params.runtimeId,
region: params.region,
+ agentCategory: params.agentCategory,
+ mpaCompatibilityManifest: params.mpaCompatibilityManifest,
cloudProvider: params.cloudProvider,
projectPath: params.projectPath ?? ".",
volcengineAccessKey: params.volcengineAccessKey,
@@ -3421,6 +4653,8 @@ export async function attachGithubDeliveryCicdToSourceSync(params: {
runtimeName: string;
runtimeId: string;
region: string;
+ agentCategory?: "general" | "mpa";
+ mpaCompatibilityManifest?: Record;
cloudProvider: CloudProvider;
projectPath?: string;
volcengineAccessKey: string;
@@ -3437,6 +4671,8 @@ export async function attachGithubDeliveryCicdToSourceSync(params: {
runtimeName: params.runtimeName,
runtimeId: params.runtimeId,
region: params.region,
+ agentCategory: params.agentCategory,
+ mpaCompatibilityManifest: params.mpaCompatibilityManifest,
cloudProvider: params.cloudProvider,
projectPath: params.projectPath ?? ".",
volcengineAccessKey: params.volcengineAccessKey,
@@ -3475,6 +4711,9 @@ export async function getGithubDeliveryVersions(
export async function createGithubDeliveryRollbackPr(params: {
runtimeId: string;
targetCommitSha: string;
+ region?: string;
+ agentCategory?: "general" | "mpa";
+ mpaCompatibilityManifest?: Record;
}): Promise {
const res = await apiFetch("/web/github-delivery/rollback-pr", {
method: "POST",
@@ -3503,6 +4742,8 @@ export async function bindGithubCicdRuntime(params: {
export async function syncGithubCicdRuntime(params: {
runtimeId: string;
project: { name: string; files: { path: string; content: string }[] };
+ agentCategory?: "general" | "mpa";
+ mpaCompatibilityManifest?: Record;
}): Promise {
const res = await apiFetch(
"/web/github-cicd/runtime-sync",
@@ -3512,6 +4753,8 @@ export async function syncGithubCicdRuntime(params: {
body: JSON.stringify({
runtimeId: params.runtimeId,
project: params.project,
+ agentCategory: params.agentCategory,
+ mpaCompatibilityManifest: params.mpaCompatibilityManifest,
}),
},
{},
@@ -3543,6 +4786,8 @@ export async function deployAgentkitProject(
runtimeId?: string;
runtimeName?: string;
appName?: string;
+ agentCategory?: "general" | "mpa";
+ mpaCompatibilityManifest?: Record;
editMode?: "source-preserving" | "regenerate";
draft?: AgentDraft;
updateEtag?: string;
@@ -3667,6 +4912,8 @@ export async function deployAgentkitProject(
runtimeId: opts?.runtimeId,
runtimeName: opts?.runtimeName,
appName: opts?.appName,
+ agentCategory: opts?.agentCategory,
+ mpaCompatibilityManifest: opts?.mpaCompatibilityManifest,
editMode: opts?.editMode,
draft: opts?.draft,
updateEtag: opts?.updateEtag,
@@ -3768,6 +5015,7 @@ export async function deployAgentkitProject(
agentName: deployedAgentName,
runtimeName: deployedRuntimeName,
runtimeId: final.runtimeId,
+ mpaInstanceId: final.mpaInstanceId?.trim() || undefined,
consoleUrl: final.consoleUrl,
region: final.region,
version: final.version,
@@ -4196,6 +5444,7 @@ export async function getAgentUsage({
export interface CloudRuntime {
name: string;
runtimeId: string;
+ mpaInstanceId?: string;
status: string;
region: string;
author: string;
@@ -4204,6 +5453,8 @@ export interface CloudRuntime {
memoryMb?: number | null;
createdAt?: string;
currentVersion?: number | null;
+ /** Product family used by the new-chat picker. */
+ agentCategory?: "general" | "mpa";
/** True when this runtime was deployed by the current user (veadk:author). */
isMine: boolean;
/** Server-authorized deletion capability for this managed Runtime. */
@@ -4417,6 +5668,7 @@ export class RuntimeListError extends Error {
* page continues pagination; the server derives ownership from identity. */
export async function getRuntimes(
opts: {
+ agentCategory?: "general" | "mpa";
nextToken?: string;
pageSize?: number;
region?: string;
@@ -4429,6 +5681,7 @@ export async function getRuntimes(
page_size: String(opts.pageSize ?? 30),
region: opts.region ?? "all",
});
+ if (opts.agentCategory) p.set("agentCategory", opts.agentCategory);
if (opts.nextToken) p.set("next_token", opts.nextToken);
const res = await apiFetch(`/web/runtimes?${p.toString()}`, {
signal: opts.signal,
@@ -4625,11 +5878,17 @@ export async function revealRuntimeApiKey(
export async function deleteRuntime(
runtimeId: string,
region: string,
+ opts: { agentCategory?: "general" | "mpa"; mpaInstanceId?: string } = {},
): Promise {
const res = await apiFetch("/web/delete-runtime", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ runtimeId, region }),
+ body: JSON.stringify({
+ runtimeId,
+ region,
+ agentCategory: opts.agentCategory,
+ mpaInstanceId: opts.mpaInstanceId,
+ }),
});
if (!res.ok) {
const t = await res.text().catch(() => "");
@@ -4663,6 +5922,7 @@ export interface RuntimeUpdateCapability {
etag: string;
runtime: {
runtimeId: string;
+ mpaInstanceId?: string;
name: string;
region: string;
currentVersion?: number | null;
@@ -4955,6 +6215,7 @@ async function runtimeUpdateCapabilityErrorMessage(res: Response): Promise {
+ const params = new URLSearchParams({ runtimeId, region, key });
+ const response = await apiFetch(`/web/runtime-env/copy?${params.toString()}`, { method: "POST" });
+ if (!response.ok) throw new Error(await httpErrorMessage(response, adkT("client.copySecretFailed")));
+ const payload = await response.json() as { value?: unknown };
+ if (typeof payload.value !== "string" || !payload.value) {
+ throw new Error(adkT("client.copySecretFailed"));
+ }
+ await navigator.clipboard.writeText(payload.value);
+}
+
export function getCachedRuntimeDetail(
runtimeId: string,
region = "cn-beijing",
@@ -5286,3 +6563,36 @@ export async function updateSandboxTool(kind: SandboxToolKind): Promise<{
if (typeof payload.updated !== "boolean") throw new Error(adkT("client.invalidSandboxUpdate"));
return { updated: payload.updated, state: sandboxImageState(payload.state) };
}
+
+
+export type ChannelScope = "group" | "group_sender" | "group_topic" | "group_topic_sender";
+export interface ChannelCapabilities { channels?: string[]; credentialBindingChannels?: string[]; serverSideBinding: boolean; bindingReady: boolean; bindingError?: string | null; }
+export interface FeishuBinding {
+ id: string;
+ status: "PENDING" | "SCANNED" | "AUTHORIZED" | "REGISTERING" | "BOUND" | "FAILED" | "EXPIRED";
+ loginUrl: string; qrCodeImage: string; expiresAt: string; pollAfterSeconds: number;
+ appId?: string | null; lastErrorCode?: string | null; lastErrorMessage?: string | null;
+}
+export interface ChannelDiagnostics {
+ configured: boolean; appId?: string | null; appName?: string | null;
+ gatewayConfigured: boolean; routeConfigured: boolean; missingConfiguration: string[];
+ deliveryHealth: string; lastInboundAt?: number | null; lastDeliveryAt?: number | null;
+}
+export interface ChannelPermission { channel: string; chatId: string; chatName?: string; groupSessionScope?: ChannelScope | null; }
+export class ChannelApiError extends Error {
+ constructor(public status: number) { super(`Channel request failed (${status})`); }
+}
+export async function channelRequest(ep: AdkEndpoint, path: string, init: RequestInit = {}): Promise {
+ const response = await apiFetch(`/api/v1/channels${path}`, {
+ ...init, headers: { "Content-Type": "application/json", ...init.headers },
+ }, ep);
+ // Do not surface arbitrary upstream error bodies: they may contain secrets.
+ if (!response.ok) throw new ChannelApiError(response.status);
+ return response.json() as Promise;
+}
+export function listMpaCronTasks(runtime: MpaRuntime, offset: number, query: string, signal?: AbortSignal) {
+ return fetchMpaCronTasks(apiFetch, runtime, offset, query, signal);
+}
+
+export function requestMpaTask(runtime: MpaRuntime, method: string, suffix: string, payload?: unknown, signal?: AbortSignal) { return manageMpaTask(apiFetch, runtime, method, suffix, payload, signal); }
+export function listMpaRuns(runtime: MpaRuntime, taskId: string, offset: number, signal?: AbortSignal) { return fetchMpaRuns(apiFetch, runtime, taskId, offset, signal); }
diff --git a/frontend/src/adk/connections.ts b/frontend/src/adk/connections.ts
index 08491d4b3..893162249 100644
--- a/frontend/src/adk/connections.ts
+++ b/frontend/src/adk/connections.ts
@@ -23,8 +23,10 @@ export interface RemoteConnection {
/** Preferred: an AgentKit runtime routed through the server-side proxy. When
* set, `base`/`apiKey` are unused and the apikey stays server-side. */
runtimeId?: string;
+ mpaInstanceId?: string;
region?: string;
currentVersion?: number | null;
+ agentCategory?: "general" | "mpa";
apps: string[];
/** Optional app ID -> friendly name mapping (e.g., "a_1" -> "a_1-4zkzsezc") */
appLabels?: Record;
@@ -39,10 +41,13 @@ export interface AgentEntry {
remote: boolean;
host?: string; // remote host, for display
runtimeId?: string;
+ mpaInstanceId?: string;
region?: string;
currentVersion?: number | null;
/** Server-authorized permission for Studio-managed Runtime deletion. */
canDelete?: boolean;
+ /** Product category supplied by the Runtime list; unknown entries hide MPA-only UI. */
+ agentCategory?: "general" | "mpa";
}
const STORAGE_KEY = "veadk_agentkit_connections";
@@ -53,6 +58,8 @@ interface ConnectRuntimeOptions {
waitForReady?: boolean;
/** Friendly ADK Agent label. Runtime resource names may include a suffix. */
agentName?: string;
+ agentCategory?: "general" | "mpa";
+ mpaInstanceId?: string;
}
export function loadConnections(): RemoteConnection[] {
@@ -95,7 +102,13 @@ export function registerConnections(conns: RemoteConnection[]): void {
registerRemoteApp(
remoteAppId(c.id, app),
c.runtimeId
- ? { app, runtimeId: c.runtimeId, region: c.region! }
+ ? {
+ app,
+ runtimeId: c.runtimeId,
+ mpaInstanceId: c.mpaInstanceId,
+ region: c.region!,
+ agentCategory: c.agentCategory,
+ }
: { app, base: c.base, apiKey: c.apiKey },
);
}
@@ -113,7 +126,12 @@ export function addRuntimeConnection(
apps: string[],
appLabels?: Record,
currentVersion?: number | null,
+ agentCategory?: "general" | "mpa",
+ mpaInstanceId?: string,
): RemoteConnection {
+ const list = loadConnections();
+ const existingIndex = list.findIndex((item) => item.runtimeId === runtimeId);
+ const previous = existingIndex === -1 ? undefined : list[existingIndex];
const conn: RemoteConnection = {
id: `rt_${runtimeId}`,
name: name || runtimeId,
@@ -122,9 +140,9 @@ export function addRuntimeConnection(
apps,
appLabels,
currentVersion,
+ agentCategory: agentCategory ?? previous?.agentCategory,
+ mpaInstanceId: mpaInstanceId?.trim() || previous?.mpaInstanceId,
};
- const list = loadConnections();
- const existingIndex = list.findIndex((item) => item.runtimeId === runtimeId);
if (existingIndex === -1) list.push(conn);
else list[existingIndex] = conn;
persist(list);
@@ -138,6 +156,8 @@ async function connectRuntimeOnce(
region: string,
currentVersion?: number | null,
agentName?: string,
+ agentCategory?: "general" | "mpa",
+ mpaInstanceId?: string,
): Promise {
let apps: string[] | null = null;
let resolvedRegion = region || "cn-beijing";
@@ -187,6 +207,8 @@ async function connectRuntimeOnce(
apps,
labels,
currentVersion,
+ agentCategory,
+ mpaInstanceId,
);
return remoteAppId(connection.id, apps[0]);
}
@@ -212,6 +234,8 @@ export async function connectRuntime(
region,
currentVersion,
options.agentName,
+ options.agentCategory,
+ options.mpaInstanceId,
);
} catch (error) {
const elapsedMs = Date.now() - startedAt;
@@ -293,6 +317,8 @@ export function buildAgentEntries(
runtimeId: c.runtimeId,
region: c.region,
currentVersion: c.currentVersion,
+ agentCategory: c.agentCategory,
+ mpaInstanceId: c.mpaInstanceId,
};
}),
);
diff --git a/frontend/src/adk/deploymentI18n.ts b/frontend/src/adk/deploymentI18n.ts
index 61388b2b0..0973d9ff4 100644
--- a/frontend/src/adk/deploymentI18n.ts
+++ b/frontend/src/adk/deploymentI18n.ts
@@ -27,6 +27,9 @@ const PHASE_KEYS: Record = {
publish: "client.deploymentProgress.publishing",
evaluation: "client.deploymentProgress.evaluating",
update: "client.deploymentProgress.updating",
+ profile_applying: "client.deploymentProgress.applyingMpaProfile",
+ smoke_running: "client.deploymentProgress.runningMpaSmoke",
+ runnable: "client.deploymentProgress.mpaReady",
complete: "client.deploymentProgress.completing",
github: "client.deploymentProgress.github",
};
diff --git a/frontend/src/adk/mpa-identity-prewarm.test.ts b/frontend/src/adk/mpa-identity-prewarm.test.ts
new file mode 100644
index 000000000..05f8978f2
--- /dev/null
+++ b/frontend/src/adk/mpa-identity-prewarm.test.ts
@@ -0,0 +1,107 @@
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { clearRemoteApps, registerRemoteApp, runSSE } from "./client";
+
+beforeEach(() => {
+ vi.stubGlobal("window", { location: { search: "", origin: "https://studio.example" } });
+ vi.stubGlobal("sessionStorage", { getItem: () => null });
+ vi.stubGlobal("localStorage", { getItem: () => null });
+});
+
+afterEach(() => {
+ clearRemoteApps();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
+
+const run = () => runSSE({
+ appName: "remote-mpa",
+ userId: "studio-user",
+ sessionId: "session-1",
+ text: "hello",
+});
+
+it("prewarms an MPA Runtime before sending a web-chat run", async () => {
+ registerRemoteApp("remote-mpa", {
+ app: "mpa-agent",
+ runtimeId: "runtime-1",
+ region: "cn-beijing",
+ agentCategory: "mpa",
+ });
+ const fetcher = vi.fn()
+ .mockResolvedValueOnce(new Response('{"ok":true}', { status: 200 }))
+ .mockResolvedValueOnce(new Response('{"invocationId":"inv-1"}', { status: 200 }))
+ .mockResolvedValueOnce(new Response('data: {"author":"agent"}\n\nevent: done\ndata: {}\n\n', { status: 200 }));
+ vi.stubGlobal("fetch", fetcher);
+
+ const events = [];
+ for await (const event of run()) events.push(event);
+
+ expect(events).toHaveLength(1);
+ expect(String(fetcher.mock.calls[0][0])).toContain("/web/mpa/identity-prewarm/runtime-1?region=cn-beijing");
+ expect(fetcher.mock.calls[0][1].method).toBe("POST");
+ expect(new Headers(fetcher.mock.calls[0][1].headers).get("X-Requested-With")).toBe("XMLHttpRequest");
+ expect(String(fetcher.mock.calls[1][0])).toContain("/web/runtime-proxy/runtime-1/api/v1/sessions/session-1/run");
+ expect(JSON.stringify(fetcher.mock.calls)).not.toContain("idToken");
+});
+
+it("keeps MPA chat available when identity prewarm fails", async () => {
+ registerRemoteApp("remote-mpa", {
+ app: "mpa-agent", runtimeId: "runtime-1", agentCategory: "mpa",
+ });
+ const fetcher = vi.fn()
+ .mockResolvedValueOnce(new Response(
+ '{"detail":"studio_oauth_session_required"}',
+ { status: 409, headers: { "Content-Type": "application/json" } },
+ ))
+ .mockResolvedValueOnce(new Response('{"invocationId":"inv-1"}', { status: 200 }))
+ .mockResolvedValueOnce(new Response('data: {"author":"agent"}\n\nevent: done\ndata: {}\n\n', { status: 200 }));
+ vi.stubGlobal("fetch", fetcher);
+ const warning = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+ const events = [];
+ for await (const event of run()) events.push(event);
+
+ expect(events).toHaveLength(1);
+ expect(fetcher).toHaveBeenCalledTimes(3);
+ expect(String(fetcher.mock.calls[1][0])).toContain("/run");
+ expect(warning).toHaveBeenCalledWith("[mpa] identity prewarm unavailable", 409);
+ warning.mockRestore();
+});
+
+it("keeps MPA chat available when the prewarm request has a network error", async () => {
+ registerRemoteApp("remote-mpa", {
+ app: "mpa-agent", runtimeId: "runtime-1", agentCategory: "mpa",
+ });
+ const fetcher = vi.fn()
+ .mockRejectedValueOnce(new Error("network unavailable"))
+ .mockResolvedValueOnce(new Response('{"invocationId":"inv-1"}', { status: 200 }))
+ .mockResolvedValueOnce(new Response('data: {"author":"agent"}\n\nevent: done\ndata: {}\n\n', { status: 200 }));
+ vi.stubGlobal("fetch", fetcher);
+ const warning = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+ const events = [];
+ for await (const event of run()) events.push(event);
+
+ expect(events).toHaveLength(1);
+ expect(fetcher).toHaveBeenCalledTimes(3);
+ expect(warning).toHaveBeenCalledWith("[mpa] identity prewarm unavailable");
+ warning.mockRestore();
+});
+
+it("does not start a run when chat is cancelled during prewarm", async () => {
+ registerRemoteApp("remote-mpa", {
+ app: "mpa-agent", runtimeId: "runtime-1", agentCategory: "mpa",
+ });
+ const controller = new AbortController();
+ const fetcher = vi.fn().mockImplementation(async () => {
+ controller.abort();
+ throw new DOMException("Aborted", "AbortError");
+ });
+ vi.stubGlobal("fetch", fetcher);
+
+ await expect(runSSE({
+ appName: "remote-mpa", userId: "studio-user", sessionId: "session-1",
+ text: "hello", signal: controller.signal,
+ }).next()).rejects.toThrow();
+ expect(fetcher).toHaveBeenCalledTimes(1);
+});
diff --git a/frontend/src/adk/mpaCreation.ts b/frontend/src/adk/mpaCreation.ts
new file mode 100644
index 000000000..7f19aee3e
--- /dev/null
+++ b/frontend/src/adk/mpaCreation.ts
@@ -0,0 +1,106 @@
+import { withAuth } from "./auth";
+import { withLocalUser } from "./identity";
+import { withLocaleHeaders } from "./i18n";
+import { requestSignal, DEFAULT_REQUEST_TIMEOUT_MS } from "./timeout";
+
+export interface MpaCreationInput {
+ requestId: string;
+ name?: string;
+ agentId?: string;
+ description: string;
+ region: string;
+ runtimeImage?: string;
+ workerImage?: string;
+ pgHost?: string;
+ pgPort?: string;
+ openvikingUrl?: string;
+ openvikingResourceId?: string;
+ tosAccessKey?: string;
+ tosSecretKey?: string;
+ tosBucket?: string;
+}
+export interface MpaCreationRequest extends MpaCreationInput {
+ openvikingApiKey?: string;
+}
+export interface MpaCreationTask
+ extends Omit<
+ MpaCreationInput,
+ "tosAccessKey" | "tosSecretKey" | "tosBucket"
+ > {
+ taskId: string;
+ state: "running" | "cancelling" | "succeeded" | "failed" | "cancelled";
+ stage: string;
+ error?: string;
+ images?: { runtimeImage?: string; workerImage?: string };
+ result?: {
+ runtime_id?: string;
+ skill_space_id?: string;
+ gateway_id?: string;
+ };
+}
+export interface MpaCreationConfig {
+ configured: boolean;
+ region: string;
+ error?: string;
+ runtimeImage?: string;
+ workerImage?: string;
+ pgHost?: string;
+ pgPort?: string;
+ postgresMode?: "auto";
+ postgresMigrationRequired?: boolean;
+ postgresLayout?: "split-workspaces";
+ adminWorkspaceName?: string;
+ adminDatabaseName?: string;
+}
+
+export class MpaCreationRequestError extends Error {
+ constructor(
+ readonly status: number,
+ message: string,
+ ) {
+ super(message);
+ this.name = "MpaCreationRequestError";
+ }
+}
+
+async function request(
+ path: string,
+ signal: AbortSignal,
+ body?: unknown,
+): Promise {
+ const response = await fetch(withAuth(`/web/mpa-creation/${path}`), {
+ method: body === undefined ? "GET" : "POST",
+ headers: withLocaleHeaders(
+ withLocalUser({ "Content-Type": "application/json" }),
+ ),
+ body: body === undefined ? undefined : JSON.stringify(body),
+ signal: requestSignal(signal, DEFAULT_REQUEST_TIMEOUT_MS),
+ });
+ if (!response.ok) {
+ const value = await response.json().catch(() => ({}));
+ throw new MpaCreationRequestError(
+ response.status,
+ typeof value.detail === "string"
+ ? value.detail
+ : `HTTP ${response.status}`,
+ );
+ }
+ return response.json() as Promise;
+}
+export const getMpaCreationConfig = (region: string, signal: AbortSignal) =>
+ request(
+ `config?region=${encodeURIComponent(region)}`,
+ signal,
+ );
+export const startMpaCreation = (
+ input: MpaCreationRequest,
+ signal: AbortSignal,
+) => request("tasks", signal, input);
+export const getMpaCreation = (id: string, signal: AbortSignal) =>
+ request(`tasks/${encodeURIComponent(id)}`, signal);
+export const cancelMpaCreation = (id: string, signal: AbortSignal) =>
+ request(
+ `tasks/${encodeURIComponent(id)}/cancel`,
+ signal,
+ {},
+ );
diff --git a/frontend/src/adk/mpaCreationImages.ts b/frontend/src/adk/mpaCreationImages.ts
new file mode 100644
index 000000000..54668e860
--- /dev/null
+++ b/frontend/src/adk/mpaCreationImages.ts
@@ -0,0 +1,10 @@
+export function validCreationImage(value: string = ""): boolean {
+ const image = value.trim();
+ return (
+ !image ||
+ (image.length <= 1024 &&
+ /^[A-Za-z0-9._:/@-]+$/.test(image) &&
+ !image.includes("//") &&
+ (!image.includes("@") || /^[^@]+@sha256:[a-fA-F0-9]{64}$/.test(image)))
+ );
+}
diff --git a/frontend/src/adk/mpaCreationResources.ts b/frontend/src/adk/mpaCreationResources.ts
new file mode 100644
index 000000000..5ff059313
--- /dev/null
+++ b/frontend/src/adk/mpaCreationResources.ts
@@ -0,0 +1,38 @@
+export function validPgTarget(host = "", port = ""): boolean {
+ const selectedHost = host.trim();
+ const selectedPort = port.trim();
+ return (
+ (!selectedHost && !selectedPort) ||
+ (selectedHost.length <= 255 &&
+ /^[A-Za-z0-9][A-Za-z0-9.-]*$/.test(selectedHost) &&
+ !selectedHost.includes("..") &&
+ /^[0-9]{1,5}$/.test(selectedPort) &&
+ Number(selectedPort) >= 1 &&
+ Number(selectedPort) <= 65535)
+ );
+}
+
+export function validOpenViking(url = "", resourceId = "", apiKey = ""): boolean {
+ const selectedUrl = url.trim();
+ const selectedId = resourceId.trim();
+ if (!selectedUrl && !selectedId && !apiKey) return true;
+ if (!selectedUrl || !selectedId || !apiKey.trim()) return false;
+ if (selectedUrl.length > 1024 || /\s/.test(selectedUrl)) return false;
+ const authority = selectedUrl.replace(/^https:\/\//, "").split("/", 1)[0];
+ if (authority.includes(":")) return false;
+ try {
+ const parsed = new URL(selectedUrl);
+ return (
+ parsed.protocol === "https:" &&
+ !!parsed.hostname &&
+ !parsed.username &&
+ !parsed.password &&
+ !parsed.port &&
+ !parsed.search &&
+ !parsed.hash &&
+ selectedId.length <= 128 && /^ov-[a-zA-Z0-9_-]+$/.test(selectedId)
+ );
+ } catch {
+ return false;
+ }
+}
diff --git a/frontend/src/adk/mpaCronTasks.ts b/frontend/src/adk/mpaCronTasks.ts
new file mode 100644
index 000000000..00dfb523f
--- /dev/null
+++ b/frontend/src/adk/mpaCronTasks.ts
@@ -0,0 +1,210 @@
+import type { AdkEndpoint } from "./client";
+
+export interface MpaRuntime {
+ runtimeId: string;
+ region: string;
+ name: string;
+}
+export interface MpaCronTask {
+ id: string;
+ name: string;
+ enabled: boolean;
+ schedule: Record & { type: string };
+ prompt?: string;
+ agentId?: string;
+ version?: number;
+ delivery?: Record;
+ jitterSeconds?: number;
+ timeoutSeconds?: number;
+ lastRunAt?: string | null;
+ runningAt?: string | null;
+ createdAt?: string;
+ nextRunAt?: string | null;
+ lastRunStatus?: string | null;
+}
+export interface MpaCronTaskPage {
+ overview?: { executionCount: number; successRate: number };
+ items: MpaCronTask[];
+ total: number;
+ hasMore: boolean;
+ nextOffset: number | null;
+}
+type Request = (
+ path: string,
+ init: RequestInit,
+ endpoint: AdkEndpoint,
+) => Promise;
+
+function validSchedule(schedule: Record): boolean {
+ if (schedule.timezone != null) {
+ if (typeof schedule.timezone !== "string") return false;
+ try {
+ new Intl.DateTimeFormat("en-US", { timeZone: schedule.timezone });
+ } catch {
+ return false;
+ }
+ }
+ if (
+ schedule.runAt != null &&
+ (typeof schedule.runAt !== "string" ||
+ !Number.isFinite(Date.parse(schedule.runAt)))
+ )
+ return false;
+ return [schedule.weekdays, schedule.monthDays].every(
+ (days) =>
+ days == null || (Array.isArray(days) && days.every(Number.isInteger)),
+ );
+}
+
+export async function fetchMpaCronTasks(
+ request: Request,
+ runtime: MpaRuntime,
+ offset: number,
+ query: string,
+ signal?: AbortSignal,
+): Promise {
+ const response = await request(
+ `/web/mpa-cron/${encodeURIComponent(runtime.runtimeId)}?region=${encodeURIComponent(runtime.region)}&offset=${offset}&query=${encodeURIComponent(query)}`,
+ { signal },
+ {},
+ );
+ // Do not expose arbitrary upstream bodies, which may contain credentials.
+ if (!response.ok) throw new Error(`MPA_HTTP_${response.status}`);
+ let data;
+ try {
+ data = await response.json();
+ } catch {
+ throw new Error("MPA_INVALID_RESPONSE");
+ }
+ if (
+ !data ||
+ !Array.isArray(data.items) ||
+ !Number.isInteger(data.total) ||
+ data.total < 0 ||
+ typeof data.hasMore !== "boolean" ||
+ (data.hasMore &&
+ (!Number.isInteger(data.nextOffset) || data.nextOffset <= offset)) ||
+ (data.overview != null &&
+ (!Number.isFinite(data.overview.executionCount) ||
+ !Number.isFinite(data.overview.successRate))) ||
+ !data.items.every(
+ (item: MpaCronTask) =>
+ item &&
+ typeof item.id === "string" &&
+ typeof item.name === "string" &&
+ typeof item.enabled === "boolean" &&
+ (item.prompt == null || typeof item.prompt === "string") &&
+ item.schedule &&
+ typeof item.schedule.type === "string" &&
+ validSchedule(item.schedule) &&
+ [item.agentId, item.lastRunAt, item.runningAt, item.createdAt].every(
+ (value) => value == null || typeof value === "string",
+ ) &&
+ (item.nextRunAt == null || typeof item.nextRunAt === "string") &&
+ (item.lastRunStatus == null || typeof item.lastRunStatus === "string"),
+ )
+ ) {
+ throw new Error("MPA_INVALID_RESPONSE");
+ }
+ return data;
+}
+
+export interface MpaRun {
+ id: string;
+ status: string;
+ startedAt?: string | null;
+ scheduledAt: string;
+ durationMs?: number | null;
+ errorMessage?: string | null;
+ sessionId?: string | null;
+}
+export interface MpaRunPage {
+ items: MpaRun[];
+ total: number;
+ hasMore: boolean;
+ nextOffset: number | null;
+}
+export type TaskFields = Pick & {
+ agentId?: string;
+ prompt: string;
+ delivery?: Record;
+ jitterSeconds?: number;
+ timeoutSeconds?: number;
+};
+export async function manageMpaTask(
+ request: Request,
+ runtime: MpaRuntime,
+ method: string,
+ suffix: string,
+ payload?: unknown,
+ signal?: AbortSignal,
+): Promise> {
+ const response = await request(
+ `/web/mpa-cron/${encodeURIComponent(runtime.runtimeId)}${suffix}?region=${encodeURIComponent(runtime.region)}`,
+ {
+ method,
+ signal,
+ ...(payload === undefined
+ ? {}
+ : {
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ }),
+ },
+ {},
+ );
+ if (!response.ok) throw new Error(`MPA_HTTP_${response.status}`);
+ let data;
+ try {
+ data = await response.json();
+ } catch {
+ throw new Error("MPA_INVALID_RESPONSE");
+ }
+ if (!data || typeof data !== "object" || Array.isArray(data))
+ throw new Error("MPA_INVALID_RESPONSE");
+ return data;
+}
+
+export async function fetchMpaRuns(
+ request: Request,
+ runtime: MpaRuntime,
+ taskId: string,
+ offset: number,
+ signal?: AbortSignal,
+): Promise {
+ const response = await request(
+ `/web/mpa-cron/${encodeURIComponent(runtime.runtimeId)}/${encodeURIComponent(taskId)}/runs?region=${encodeURIComponent(runtime.region)}&offset=${offset}`,
+ { signal },
+ {},
+ );
+ if (!response.ok) throw new Error(`MPA_HTTP_${response.status}`);
+ let data;
+ try {
+ data = await response.json();
+ } catch {
+ throw new Error("MPA_INVALID_RESPONSE");
+ }
+ if (
+ !data ||
+ !Array.isArray(data.items) ||
+ !Number.isInteger(data.total) ||
+ data.total < 0 ||
+ typeof data.hasMore !== "boolean" ||
+ (data.hasMore &&
+ (!Number.isInteger(data.nextOffset) || data.nextOffset <= offset)) ||
+ !data.items.every(
+ (run: MpaRun) =>
+ run &&
+ typeof run.id === "string" &&
+ typeof run.status === "string" &&
+ typeof run.scheduledAt === "string" &&
+ [run.startedAt, run.errorMessage, run.sessionId].every(
+ (value) => value == null || typeof value === "string",
+ ) &&
+ (run.durationMs == null ||
+ (Number.isFinite(run.durationMs) && run.durationMs >= 0)),
+ )
+ )
+ throw new Error("MPA_INVALID_RESPONSE");
+ return data;
+}
diff --git a/frontend/src/adk/skillDocuments.ts b/frontend/src/adk/skillDocuments.ts
new file mode 100644
index 000000000..4f8d9d1ee
--- /dev/null
+++ b/frontend/src/adk/skillDocuments.ts
@@ -0,0 +1,69 @@
+import { withAuth } from "./auth";
+import { withLocalUser } from "./identity";
+import { withLocaleHeaders } from "./i18n";
+import {
+ requestSignal,
+ DEFAULT_REQUEST_TIMEOUT_MS,
+ TRANSFER_REQUEST_TIMEOUT_MS,
+} from "./timeout";
+import { skillApiErrorFromResponse } from "./skills";
+
+export interface SkillDocumentTarget {
+ spaceId: string;
+ skillId: string;
+ region: string;
+ name: string;
+}
+export interface SkillDocument {
+ content: string;
+ baseVersion: string;
+ canUpdate: boolean;
+}
+async function request(
+ target: SkillDocumentTarget & { signal?: AbortSignal },
+ init: RequestInit,
+ timeout: number,
+): Promise {
+ const params = new URLSearchParams({ region: target.region });
+ const response = await fetch(
+ withAuth(
+ `/web/skill-management/spaces/${encodeURIComponent(target.spaceId)}/skills/${encodeURIComponent(target.skillId)}/document?${params}`,
+ ),
+ {
+ ...init,
+ headers: withLocaleHeaders(withLocalUser(init.headers)),
+ signal: requestSignal(target.signal, timeout),
+ },
+ );
+ if (!response.ok)
+ throw await skillApiErrorFromResponse(
+ response,
+ "SKILL_DOCUMENT_REQUEST_FAILED",
+ );
+ return response.json() as Promise;
+}
+export function getSkillDocument(
+ target: SkillDocumentTarget & { signal?: AbortSignal },
+): Promise {
+ return request(target, {}, DEFAULT_REQUEST_TIMEOUT_MS);
+}
+export function saveSkillDocument(
+ target: SkillDocumentTarget & {
+ content: string;
+ baseVersion: string;
+ signal?: AbortSignal;
+ },
+): Promise<{ version: string }> {
+ return request(
+ target,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ content: target.content,
+ baseVersion: target.baseVersion,
+ }),
+ },
+ TRANSFER_REQUEST_TIMEOUT_MS,
+ );
+}
diff --git a/frontend/src/adk/sse.ts b/frontend/src/adk/sse.ts
index 8b964ced4..af98fa4bf 100644
--- a/frontend/src/adk/sse.ts
+++ b/frontend/src/adk/sse.ts
@@ -1,7 +1,12 @@
// Minimal Server-Sent-Events parser for `fetch` response bodies.
//
// The ADK `/run_sse` endpoint emits `data: \n\n` frames. This async
-// generator yields each parsed JSON payload as it arrives.
+// generator yields each parsed JSON payload as it arrives. When an upstream
+// names the SSE event, the parsed payload includes a non-enumerable
+// `__sseEvent` property so callers that care about transport control frames
+// can react without changing normal event shape assertions.
+
+export const SSE_EVENT_NAME = "__sseEvent";
export async function* parseSSE(
response: Response,
@@ -19,14 +24,26 @@ export async function* parseSSE(
};
const parseFrame = (frame: string, final = false): unknown | undefined => {
- const data = frame
- .split(/\r?\n/)
+ const lines = frame.split(/\r?\n/);
+ const eventName = lines
+ .find((line) => line.startsWith("event:"))
+ ?.slice(6)
+ .trim();
+ const data = lines
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trimStart())
.join("\n");
if (!data || data === "[DONE]" || data === "ping") return undefined;
try {
- return JSON.parse(data);
+ const parsed = JSON.parse(data);
+ if (eventName && parsed && typeof parsed === "object") {
+ Object.defineProperty(parsed, SSE_EVENT_NAME, {
+ value: eventName,
+ enumerable: false,
+ configurable: true,
+ });
+ }
+ return parsed;
} catch {
const rawData = rawDataExcerpt(data);
if (final) {
diff --git a/frontend/src/adk/wecom-sdk.d.ts b/frontend/src/adk/wecom-sdk.d.ts
new file mode 100644
index 000000000..b0a50c9aa
--- /dev/null
+++ b/frontend/src/adk/wecom-sdk.d.ts
@@ -0,0 +1,3 @@
+declare module "@wecom/wecom-aibot-sdk/dist/wecom-aibot-sdk.esm.js" {
+ export { default, WecomAIBotSDK } from "@wecom/wecom-aibot-sdk";
+}
diff --git a/frontend/src/adk/wecomAuthorization.ts b/frontend/src/adk/wecomAuthorization.ts
new file mode 100644
index 000000000..9be608962
--- /dev/null
+++ b/frontend/src/adk/wecomAuthorization.ts
@@ -0,0 +1,77 @@
+import defaultSdk, {
+ WecomAIBotSDK,
+} from "@wecom/wecom-aibot-sdk/dist/wecom-aibot-sdk.esm.js";
+
+// The package creates a singleton listener on import; attempts own their instances.
+defaultSdk.destroy();
+
+export class WecomAuthorizationError extends Error {
+ constructor(public readonly code: string) {
+ super("WeCom authorization failed");
+ }
+}
+
+export function authorizeWecom(
+ signal: AbortSignal,
+): Promise<{ botId: string; secret: string }> {
+ if (signal.aborted)
+ return Promise.reject(new DOMException("Cancelled", "AbortError"));
+ const sdk = new WecomAIBotSDK();
+ return new Promise((resolve, reject) => {
+ let settled = false;
+ const cleanup = () => {
+ settled = true;
+ clearTimeout(timeout);
+ signal.removeEventListener("abort", abort);
+ sdk.destroy();
+ };
+ const fail = (error: unknown) => {
+ if (settled) return;
+ cleanup();
+ reject(error);
+ };
+ const abort = () => fail(new DOMException("Cancelled", "AbortError"));
+ const timeout = setTimeout(
+ () => fail(new WecomAuthorizationError("AUTH_TIMEOUT")),
+ 300_000,
+ );
+ signal.addEventListener("abort", abort, { once: true });
+ try {
+ // No await before opening: browsers require the original user activation.
+ sdk
+ .openBotInfoAuthWindow({
+ source: "mpa-agent",
+ state: crypto.randomUUID(),
+ debug: false,
+ })
+ .then(
+ (bot) => {
+ if (settled) return;
+ if (
+ typeof bot?.botid !== "string" ||
+ !bot.botid.trim() ||
+ typeof bot?.secret !== "string" ||
+ !bot.secret.trim()
+ ) {
+ fail(new WecomAuthorizationError("INVALID_RESULT"));
+ return;
+ }
+ cleanup();
+ resolve({ botId: bot.botid.trim(), secret: bot.secret.trim() });
+ },
+ (error: unknown) => {
+ const code =
+ error &&
+ typeof error === "object" &&
+ "code" in error &&
+ typeof error.code === "string"
+ ? error.code
+ : "FAILED";
+ fail(new WecomAuthorizationError(code));
+ },
+ );
+ } catch {
+ fail(new WecomAuthorizationError("FAILED"));
+ }
+ });
+}
diff --git a/frontend/src/automations/mpa-channels/MpaChannelsAutomation.css b/frontend/src/automations/mpa-channels/MpaChannelsAutomation.css
new file mode 100644
index 000000000..3952c19da
--- /dev/null
+++ b/frontend/src/automations/mpa-channels/MpaChannelsAutomation.css
@@ -0,0 +1,153 @@
+.mpa-channels-page {
+ flex: 1;
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ padding: 32px 32px 0;
+ background: hsl(var(--background));
+ color: hsl(var(--foreground));
+}
+.mpa-channels-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding-bottom: 24px;
+}
+.mpa-channels-header > div {
+ min-width: 0;
+}
+.mpa-channels-header h1 {
+ margin: 0;
+ font-size: 20px;
+ font-weight: 650;
+ line-height: 1.25;
+}
+.mpa-channels-header p {
+ margin: 6px 0 0;
+ font-size: 13px;
+ line-height: 1.5;
+ color: hsl(var(--muted-foreground));
+}
+.mpa-channels-icon {
+ width: 32px;
+ height: 32px;
+ flex: 0 0 32px;
+}
+.mpa-channels-back {
+ display: grid;
+ place-items: center;
+ flex: 0 0 32px;
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ border: 1px solid hsl(var(--border));
+ border-radius: 8px;
+ background: hsl(var(--panel));
+ color: hsl(var(--muted-foreground));
+ cursor: pointer;
+}
+.mpa-channels-back svg {
+ width: 16px;
+ height: 16px;
+}
+.mpa-channels-back:hover {
+ background: hsl(var(--secondary));
+ color: hsl(var(--foreground));
+}
+.mpa-channels-scroll {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ padding-bottom: 32px;
+ scrollbar-gutter: stable;
+}
+.mpa-channels-content {
+ display: grid;
+ gap: 24px;
+ max-width: 1040px;
+ min-width: 0;
+}
+.mpa-channels-picker {
+ display: grid;
+ gap: 12px;
+ min-width: 0;
+ padding: 20px 24px;
+ border: 1px solid hsl(var(--border));
+ border-radius: 12px;
+ background: hsl(var(--panel));
+}
+.mpa-channels-picker-heading,
+.mpa-channels-pagination {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+.mpa-channels-picker h2 {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+}
+.mpa-channels-picker p {
+ margin: 0;
+ font-size: 13px;
+ line-height: 1.5;
+ color: hsl(var(--muted-foreground));
+ overflow-wrap: anywhere;
+}
+.mpa-channels-action {
+ flex-shrink: 0;
+ min-height: 32px;
+ padding: 4px 10px;
+ border: 1px solid hsl(var(--border));
+ border-radius: 6px;
+ background: hsl(var(--background));
+ color: hsl(var(--foreground));
+ cursor: pointer;
+ font: inherit;
+ font-size: 12px;
+}
+.mpa-channels-action:hover:not(:disabled) {
+ background: hsl(var(--secondary));
+}
+.mpa-channels-action:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+.mpa-channels-error {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.mpa-channels-error p {
+ color: hsl(var(--destructive));
+}
+.mpa-channels-notice {
+ margin: 0;
+ padding: 24px;
+ color: hsl(var(--muted-foreground));
+ font-size: 13px;
+ line-height: 1.6;
+}
+.mpa-channels-back:focus-visible,
+.mpa-channels-action:focus-visible {
+ outline: 2px solid hsl(var(--ring));
+ outline-offset: 2px;
+}
+@media (max-width: 640px) {
+ .mpa-channels-page {
+ padding: 20px 16px 0;
+ }
+ .mpa-channels-header {
+ align-items: flex-start;
+ }
+ .mpa-channels-picker {
+ padding: 16px;
+ }
+ .mpa-channels-pagination {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+}
diff --git a/frontend/src/automations/mpa-channels/MpaChannelsAutomation.tsx b/frontend/src/automations/mpa-channels/MpaChannelsAutomation.tsx
new file mode 100644
index 000000000..5afbcd36f
--- /dev/null
+++ b/frontend/src/automations/mpa-channels/MpaChannelsAutomation.tsx
@@ -0,0 +1,268 @@
+import { useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ getRuntimes,
+ RuntimeListError,
+ type CloudRuntime,
+ type RuntimeScope,
+ type StudioRole,
+} from "../../adk/client";
+import { formatRelativeTimeLabel } from "../../ui/relativeTime";
+import { DeploymentSelect } from "../../ui/DeploymentSelect";
+import { RuntimeChannels } from "../../ui/RuntimeChannels";
+import { TextShimmer } from "../../ui/text-shimmer/TextShimmer";
+import { MpaChannelsIcon } from "./MpaChannelsIcon";
+import "../../ui/ProjectPreview.css";
+import "./MpaChannelsAutomation.css";
+
+interface MpaChannelsAutomationProps {
+ role: StudioRole;
+ runtimeScope: RuntimeScope;
+ onBack: () => void;
+}
+
+const runtimeKey = (runtime: CloudRuntime) =>
+ `${runtime.region}:${runtime.runtimeId}`;
+
+export function MpaChannelsAutomation({
+ role,
+ runtimeScope,
+ onBack,
+}: MpaChannelsAutomationProps) {
+ const { t } = useTranslation("automations");
+ const authorized = role === "admin" || role === "super_admin";
+ return (
+
+
+
+ {authorized ? (
+
+ ) : (
+
+ {t("mpaChannels.unauthorized")}
+
+ )}
+
+
+ );
+}
+
+function MpaChannelSelector({ runtimeScope }: { runtimeScope: RuntimeScope }) {
+ const { t, i18n } = useTranslation("automations");
+ const [runtimes, setRuntimes] = useState([]);
+ const [selectedKey, setSelectedKey] = useState("");
+ const [query, setQuery] = useState("");
+ const [nextToken, setNextToken] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [loaded, setLoaded] = useState(false);
+ const [error, setError] = useState<"unauthorized" | "loadFailed" | null>(
+ null,
+ );
+ const request = useRef(null);
+ const failedCursor = useRef("");
+
+ async function loadPage(cursor = "") {
+ request.current?.abort();
+ const controller = new AbortController();
+ request.current = controller;
+ setLoading(true);
+ setError(null);
+ failedCursor.current = cursor;
+ try {
+ const page = await getRuntimes({
+ agentCategory: "mpa",
+ scope: runtimeScope,
+ region: "all",
+ pageSize: 100,
+ nextToken: cursor || undefined,
+ signal: controller.signal,
+ });
+ if (controller.signal.aborted) return;
+ // Older filtered list responses may omit category metadata.
+ const items = page.runtimes.filter(
+ (runtime) =>
+ runtime.runtimeId &&
+ runtime.region &&
+ (runtime.agentCategory === "mpa" ||
+ runtime.agentCategory === undefined),
+ );
+ setRuntimes((current) => [
+ ...new Map(
+ (cursor ? [...current, ...items] : items).map((runtime) => [
+ runtimeKey(runtime),
+ runtime,
+ ]),
+ ).values(),
+ ]);
+ if (!cursor)
+ setSelectedKey((current) =>
+ items.some((runtime) => runtimeKey(runtime) === current)
+ ? current
+ : "",
+ );
+ setNextToken(page.nextToken);
+ setLoaded(true);
+ } catch (cause) {
+ if (controller.signal.aborted) return;
+ const denied =
+ cause instanceof RuntimeListError &&
+ (cause.status === 401 || cause.status === 403);
+ setError(denied ? "unauthorized" : "loadFailed");
+ if (denied) {
+ setRuntimes([]);
+ setSelectedKey("");
+ setNextToken("");
+ setLoaded(false);
+ failedCursor.current = "";
+ }
+ } finally {
+ if (!controller.signal.aborted) setLoading(false);
+ }
+ }
+
+ useEffect(() => {
+ void loadPage();
+ return () => request.current?.abort();
+ // The parent remounts the selector whenever role or scope changes.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const selected = runtimes.find(
+ (runtime) => runtimeKey(runtime) === selectedKey,
+ );
+ const keyword = query.trim().toLocaleLowerCase();
+ const options = runtimes
+ .filter((runtime) =>
+ `${runtime.name} ${runtime.runtimeId} ${runtime.region}`
+ .toLocaleLowerCase()
+ .includes(keyword),
+ )
+ .map((runtime) => {
+ const createdAt = formatRelativeTimeLabel(
+ runtime.createdAt,
+ Date.now(),
+ i18n.resolvedLanguage || i18n.language,
+ );
+ return {
+ value: runtimeKey(runtime),
+ label: runtime.name || runtime.runtimeId,
+ description:
+ runtime.description?.trim() || t("mpaChannels.noDescription"),
+ metadata: `${t("mpaChannels.optionMetadata", {
+ creator: runtime.author?.trim() || t("mpaChannels.unknownCreator"),
+ createdAt,
+ })}\n${runtime.region} · ${runtime.runtimeId}`,
+ };
+ });
+
+ return (
+
+
+
+
{t("mpaChannels.selectAgent")}
+
+
+
+ {selected && (
+
+ {selected.region} · {selected.runtimeId}
+
+ )}
+ {loading && (
+
+ {t("mpaChannels.loading")}
+
+ )}
+ {error && (
+
+
{t(`mpaChannels.${error}`)}
+
+
+ )}
+ {!loading && !error && loaded && !runtimes.length && !nextToken && (
+ {t("mpaChannels.empty")}
+ )}
+ {nextToken && (
+
+
{t("mpaChannels.moreAvailable")}
+
+
+ )}
+
+ {selected ? (
+
+ ) : loaded && runtimes.length > 0 ? (
+
+ {t("mpaChannels.chooseHint")}
+
+ ) : null}
+
+ );
+}
diff --git a/frontend/src/automations/mpa-channels/MpaChannelsIcon.tsx b/frontend/src/automations/mpa-channels/MpaChannelsIcon.tsx
new file mode 100644
index 000000000..4e8dba4bc
--- /dev/null
+++ b/frontend/src/automations/mpa-channels/MpaChannelsIcon.tsx
@@ -0,0 +1,22 @@
+import type { SVGProps } from "react";
+
+export function MpaChannelsIcon(props: SVGProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/automations/mpaChannels.ts b/frontend/src/automations/mpaChannels.ts
new file mode 100644
index 000000000..ea401afea
--- /dev/null
+++ b/frontend/src/automations/mpaChannels.ts
@@ -0,0 +1,10 @@
+import type { MpaChannelsAutomationDefinition } from "./types";
+
+export const mpaChannelsAutomation: MpaChannelsAutomationDefinition = {
+ id: "mpa-channels",
+ kind: "mpa-channels",
+ category: "channels",
+ icon: "mpa-channels",
+ name: "MPA agent messaging channels",
+ description: "Connect an existing MPA agent to Feishu, WeCom and DingTalk.",
+};
diff --git a/frontend/src/automations/registry.ts b/frontend/src/automations/registry.ts
index b9e0c4991..0bdd75b89 100644
--- a/frontend/src/automations/registry.ts
+++ b/frontend/src/automations/registry.ts
@@ -1,3 +1,4 @@
+import { mpaChannelsAutomation } from "./mpaChannels";
import { codingAgentsAutomation } from "./codingAgents";
import { feishuBotAutomation } from "./feishuBot";
import { gitLabMergeRequestReviewAutomation } from "./gitlabMergeRequestReview";
@@ -27,6 +28,7 @@ export const AUTOMATIONS: readonly AutomationDefinition[] = [
pullRequestReviewAutomation,
gitLabMergeRequestReviewAutomation,
feishuBotAutomation,
+ mpaChannelsAutomation,
websiteIntegrationAutomation,
];
diff --git a/frontend/src/automations/types.ts b/frontend/src/automations/types.ts
index d04080811..afdf58aba 100644
--- a/frontend/src/automations/types.ts
+++ b/frontend/src/automations/types.ts
@@ -11,11 +11,16 @@ export type AutomationId =
| "gitlab-review"
| "feishu"
| "coding-agents"
- | "website-integration";
+ | "website-integration"
+ | "mpa-channels";
export type GitHubAutomationId = Exclude<
AutomationId,
- "gitlab-review" | "feishu" | "coding-agents" | "website-integration"
+ | "gitlab-review"
+ | "feishu"
+ | "coding-agents"
+ | "website-integration"
+ | "mpa-channels"
>;
export type AutomationCategoryId = "development" | "channels";
@@ -52,7 +57,13 @@ export interface AutomationFieldDefinition {
export interface AutomationCardDefinition {
id: AutomationId;
category: AutomationCategoryId;
- icon: "github" | "gitlab" | "feishu" | "coding-agents" | "website-integration";
+ icon:
+ | "github"
+ | "gitlab"
+ | "feishu"
+ | "coding-agents"
+ | "website-integration"
+ | "mpa-channels";
name: string;
badge?: string;
badgeTone?: "default" | "success";
@@ -101,9 +112,15 @@ export interface WebsiteIntegrationAutomationDefinition extends AutomationCardDe
kind: "website-integration";
}
+export interface MpaChannelsAutomationDefinition extends AutomationCardDefinition {
+ id: "mpa-channels";
+ kind: "mpa-channels";
+}
+
export type AutomationDefinition =
| GitHubAutomationDefinition
| GitLabAutomationDefinition
| FeishuAutomationDefinition
| CodingAgentAutomationDefinition
- | WebsiteIntegrationAutomationDefinition;
+ | WebsiteIntegrationAutomationDefinition
+ | MpaChannelsAutomationDefinition;
diff --git a/frontend/src/blocks.ts b/frontend/src/blocks.ts
index 76b823ddb..72aab2846 100644
--- a/frontend/src/blocks.ts
+++ b/frontend/src/blocks.ts
@@ -18,6 +18,7 @@ import type {
MessageFeedbackState,
} from "./adk/client";
import { i18n } from "./i18n/runtime";
+import { addTokenUsage, EMPTY_SESSION_TOKEN_USAGE } from "./adk/tokenUsage";
import type { A2uiMessage } from "./a2ui/types";
import type { SandboxTokenUsage } from "./adk/sandbox";
import type { ProjectFile } from "./create/project";
@@ -116,7 +117,14 @@ export type Block = {
| { kind: "turn-summary"; value: DevelopmentTurnMetrics }
| { kind: "diff"; text: string; done: boolean }
| { kind: "progress"; text: string }
- | { kind: "thinking"; text: string; done: boolean }
+ | { kind: "activity-source"; label: string }
+ | {
+ kind: "thinking";
+ reasoningSegmentId?: string;
+ text: string;
+ done: boolean;
+ thoughtKind?: "reasoning" | "thought";
+ }
| { kind: "text"; text: string }
| {
kind: "tool";
@@ -127,6 +135,7 @@ export type Block = {
done: boolean;
status?: "running" | "completed" | "failed";
defaultOpen?: boolean;
+ source?: "codex-sandbox" | "studio" | "runtime";
codexActivity?: CodexSandboxActivity;
progressText?: string;
}
@@ -171,10 +180,15 @@ export interface Acc {
}
export interface TurnMeta {
+ a2aStatus?: string;
author?: string;
localId?: string;
streaming?: boolean;
tokens?: number;
+ /** MPA A2A request usage deltas keyed by source/event identity. */
+ mpaUsage?: Record;
+ /** Nonempty sandbox final, used only by the MPA A2A presentation view. */
+ mpaFinalAnswer?: string;
ts?: number; // epoch seconds
eventId?: string;
invocationId?: string;
@@ -206,6 +220,7 @@ export function emptyAcc(): Acc {
}
const MAX_PENDING_CODEX_PROGRESS = 64;
+const MAX_SEEN_EVENT_IDS = 2_048;
function applyCodexProgressToTool(
blocks: Block[],
@@ -221,7 +236,10 @@ function applyCodexProgressToTool(
if (block.kind !== "tool" || block.name !== progress.toolName) continue;
if (block.callId === progress.requestId) {
if (block.done) return "completed";
- block.codexActivity = applyCodexSandboxProgress(block.codexActivity, progress);
+ block.codexActivity = applyCodexSandboxProgress(
+ block.codexActivity,
+ progress,
+ );
block.status = progress.terminalStatus ?? "running";
if (progress.terminalStatus) block.done = true;
return "applied";
@@ -237,7 +255,10 @@ function applyCodexProgressToTool(
if (fallbackIndex >= 0) {
const block = blocks[fallbackIndex];
if (block.kind !== "tool") return "unmatched";
- block.codexActivity = applyCodexSandboxProgress(block.codexActivity, progress);
+ block.codexActivity = applyCodexSandboxProgress(
+ block.codexActivity,
+ progress,
+ );
block.status = progress.terminalStatus ?? "running";
if (progress.terminalStatus) block.done = true;
return "applied";
@@ -250,10 +271,13 @@ function codexResponseStatus(response: unknown): "completed" | "failed" {
return "completed";
}
const result = response as Record;
- const status = typeof result.status === "string" ? result.status.toLowerCase() : "";
+ const status =
+ typeof result.status === "string" ? result.status.toLowerCase() : "";
if (
- result.ok === false
- || ["error", "failed", "denied", "declined", "cancelled", "timeout"].includes(status)
+ result.ok === false ||
+ ["error", "failed", "denied", "declined", "cancelled", "timeout"].includes(
+ status,
+ )
) {
return "failed";
}
@@ -261,7 +285,8 @@ function codexResponseStatus(response: unknown): "completed" | "failed" {
}
function codexDirectAnswer(response: unknown): string {
- if (!response || typeof response !== "object" || Array.isArray(response)) return "";
+ if (!response || typeof response !== "object" || Array.isArray(response))
+ return "";
const result = response as Record;
if (result.ok !== true || typeof result.message !== "string") return "";
return result.message.trim();
@@ -282,6 +307,136 @@ export interface AssistantEventProjection {
const fnCall = (p: AdkPart) => p.functionCall ?? p.function_call;
const fnResp = (p: AdkPart) => p.functionResponse ?? p.function_response;
+function eventMetadata(ev: AdkEvent): Record {
+ const metadata = ev.customMetadata ?? ev.custom_metadata;
+ return metadata && typeof metadata === "object" && !Array.isArray(metadata)
+ ? (metadata as Record)
+ : {};
+}
+
+function nestedMetadata(
+ metadata: Record,
+ key: string,
+): Record {
+ const value = metadata[key];
+ return value && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : {};
+}
+
+function metadataString(metadata: Record, key: string): string {
+ const value = metadata[key];
+ return typeof value === "string" ? value : "";
+}
+
+function mpaMetadata(ev: AdkEvent): Record {
+ return nestedMetadata(eventMetadata(ev), "mpa");
+}
+
+function mpaEventType(ev: AdkEvent): string {
+ const metadata = eventMetadata(ev);
+ const mpa = mpaMetadata(ev);
+ return (
+ metadataString(metadata, "eventType") ||
+ metadataString(metadata, "event_type") ||
+ metadataString(mpa, "eventType") ||
+ metadataString(mpa, "event_type") ||
+ metadataString(mpa, "kind")
+ );
+}
+
+function isMpaSandboxEvent(ev: AdkEvent): boolean {
+ const metadata = eventMetadata(ev);
+ const mpa = mpaMetadata(ev);
+ return (
+ metadataString(metadata, "source") === "sandbox" ||
+ metadataString(mpa, "source") === "sandbox"
+ );
+}
+
+function isMpaUsageEvent(ev: AdkEvent): boolean {
+ const eventType = mpaEventType(ev);
+ return eventType === "usage.updated" || eventType === "usage";
+}
+
+function isFinalAlreadyEmittedSandboxResponse(ev: AdkEvent): boolean {
+ return (ev.content?.parts ?? []).some((part) => {
+ const response = fnResp(part);
+ const payload = response?.response;
+ return (
+ response?.name === "sandbox_task" &&
+ payload &&
+ typeof payload === "object" &&
+ !Array.isArray(payload) &&
+ (payload as Record).finalAlreadyEmitted === true
+ );
+ });
+}
+
+function completeSandboxTaskBlocks(blocks: Block[]) {
+ for (const block of blocks) {
+ if (block.kind !== "tool" || block.name !== "sandbox_task" || block.done) {
+ continue;
+ }
+ block.done = true;
+ block.status = "completed";
+ }
+}
+
+function toolNamesMatch(left: string, right: string): boolean {
+ if (left === right) return true;
+ const commandAliases = new Set([
+ "exec_command",
+ "commandExecution",
+ "command_execution",
+ "Run command",
+ ]);
+ return commandAliases.has(left) && commandAliases.has(right);
+}
+
+function toolResponseState(
+ response: unknown,
+ partial = false,
+): { done: boolean; status: "running" | "completed" | "failed" } {
+ const responseStatus =
+ response && typeof response === "object"
+ ? String((response as Record).status ?? "").toLowerCase()
+ : "";
+ const failed =
+ ["failed", "error", "cancelled", "denied", "timeout"].includes(
+ responseStatus,
+ ) || (response as Record | undefined)?.ok === false;
+ const running = ["running", "started", "working"].includes(
+ responseStatus,
+ );
+ return {
+ done: failed || !(partial || running),
+ status: failed ? "failed" : partial || running ? "running" : "completed",
+ };
+}
+
+export function flattenCodexActivityBlocks(blocks: Block[]): Block[] {
+ return blocks.flatMap((block) => {
+ if (
+ block.kind !== "tool" ||
+ block.name !== "delegate_to_codex_sandbox" ||
+ !block.codexActivity?.items.length
+ )
+ return [block];
+ return [
+ {
+ kind: "activity-source" as const,
+ label: block.codexActivity.title || "Codex Sandbox",
+ },
+ ...block.codexActivity.items.map(({ block: child }) =>
+ child.kind === "tool"
+ ? { ...child, source: "codex-sandbox" as const }
+ : child,
+ ),
+ ];
+ });
+}
+
function transferAgentName(args: unknown): string {
if (!args || typeof args !== "object") return "";
const record = args as Record;
@@ -301,18 +456,20 @@ export function attachmentsFromParts(parts: AdkPart[]): AttachmentView[] {
const files: AttachmentView[] = [];
for (const [index, p] of parts.entries()) {
const metadata = (p.partMetadata ?? p.part_metadata) as
- | Record
- | undefined;
- const transport = metadata?.veadkTransport as Record | undefined;
+ Record | undefined;
+ const transport = metadata?.veadkTransport as
+ Record | undefined;
if (transport?.hidden === true) continue;
const stored = metadata?.veadkMedia as Record | undefined;
if (typeof stored?.uri === "string") {
files.push({
id: String(stored.id ?? stored.uri),
- mimeType: typeof stored.mimeType === "string" ? stored.mimeType : undefined,
+ mimeType:
+ typeof stored.mimeType === "string" ? stored.mimeType : undefined,
uri: stored.uri,
name: typeof stored.name === "string" ? stored.name : undefined,
- sizeBytes: typeof stored.sizeBytes === "number" ? stored.sizeBytes : undefined,
+ sizeBytes:
+ typeof stored.sizeBytes === "number" ? stored.sizeBytes : undefined,
});
continue;
}
@@ -342,9 +499,9 @@ export function attachmentsFromParts(parts: AdkPart[]): AttachmentView[] {
function visiblePartText(part: AdkPart): string | undefined {
const metadata = (part.partMetadata ?? part.part_metadata) as
- | Record
- | undefined;
- const transport = metadata?.veadkTransport as Record | undefined;
+ Record | undefined;
+ const transport = metadata?.veadkTransport as
+ Record | undefined;
return transport?.hideText === true ? undefined : part.text;
}
@@ -357,7 +514,9 @@ const AGENT_NODE_TYPES = new Set([
]);
/** Restore slash-skill and @agent selections persisted in part metadata. */
-export function invocationFromParts(parts: AdkPart[]): FrontendInvocation | undefined {
+export function invocationFromParts(
+ parts: AdkPart[],
+): FrontendInvocation | undefined {
for (const part of parts) {
const raw = (part.partMetadata ?? part.part_metadata)?.veadkInvocation;
if (!raw || typeof raw !== "object") continue;
@@ -367,10 +526,15 @@ export function invocationFromParts(parts: AdkPart[]): FrontendInvocation | unde
if (!item || typeof item !== "object") return [];
const skill = item as Record;
return typeof skill.name === "string"
- ? [{
+ ? [
+ {
name: skill.name,
- description: typeof skill.description === "string" ? skill.description : "",
- }]
+ description:
+ typeof skill.description === "string"
+ ? skill.description
+ : "",
+ },
+ ]
: [];
})
: [];
@@ -388,9 +552,12 @@ export function invocationFromParts(parts: AdkPart[]): FrontendInvocation | unde
) {
targetAgent = {
name: target.name,
- description: typeof target.description === "string" ? target.description : "",
+ description:
+ typeof target.description === "string" ? target.description : "",
type: type as AgentNodeType,
- path: target.path.filter((item): item is string => typeof item === "string"),
+ path: target.path.filter(
+ (item): item is string => typeof item === "string",
+ ),
};
}
}
@@ -406,24 +573,55 @@ function appendAttachments(blocks: Block[], files: AttachmentView[]) {
else blocks.push({ kind: "attachment", files });
}
-function appendArtifacts(blocks: Block[], files: { filename: string; version: number }[]) {
+function appendArtifacts(
+ blocks: Block[],
+ files: { filename: string; version: number }[],
+) {
if (!files.length) return;
const last = blocks[blocks.length - 1];
if (last?.kind === "artifact") {
for (const file of files) {
- if (!last.files.some((item) =>
- item.filename === file.filename && item.version === file.version
- )) last.files.push(file);
+ if (
+ !last.files.some(
+ (item) =>
+ item.filename === file.filename && item.version === file.version,
+ )
+ )
+ last.files.push(file);
}
return;
}
blocks.push({ kind: "artifact", files });
}
-function appendText(blocks: Block[], kind: "thinking" | "text", text: string) {
+function appendText(
+ blocks: Block[],
+ kind: "thinking" | "text",
+ text: string,
+ thoughtKind?: "reasoning" | "thought",
+) {
const last = blocks[blocks.length - 1];
- if (last && last.kind === kind) last.text += text;
- else blocks.push(kind === "thinking" ? { kind, text, done: false } : { kind, text });
+ if (
+ last &&
+ last.kind === kind &&
+ (last.kind !== "thinking" || last.thoughtKind === thoughtKind)
+ )
+ last.text += text;
+ else
+ blocks.push(
+ kind === "thinking"
+ ? { kind, text, done: false, thoughtKind }
+ : { kind, text },
+ );
+}
+
+function thoughtKindOf(event: AdkEvent): "reasoning" | "thought" {
+ const metadata = event.customMetadata ?? event.custom_metadata;
+ if (metadata && typeof metadata === "object") {
+ const value = (metadata as Record).thoughtKind;
+ if (value === "reasoning" || value === "thought") return value;
+ }
+ return "reasoning";
}
function closeThinking(blocks: Block[]) {
@@ -431,10 +629,26 @@ function closeThinking(blocks: Block[]) {
}
/** Apply one ADK event to a turn accumulator, returning a new accumulator. */
-export function applyEvent(acc: Acc, ev: AdkEvent): Acc {
+export function applyEvent(acc: Acc, ev: AdkEvent, options: { mpaA2a?: boolean } = {}): Acc {
const blocks = acc.blocks.map((b) => ({ ...b }));
let liveStart = acc.liveStart;
let pendingCodexProgress = acc.pendingCodexProgress.slice();
+ if (isMpaUsageEvent(ev) && !options.mpaA2a) {
+ return { blocks, liveStart, pendingCodexProgress };
+ }
+ if (isFinalAlreadyEmittedSandboxResponse(ev)) {
+ // The Runtime has already streamed the sandbox answer and uses this
+ // wrapper response only as the parent tool's completion signal. Preserve
+ // those live answer deltas while closing the parent activity card. Keep
+ // the preview replaceable by a later persisted sandbox final event.
+ completeSandboxTaskBlocks(blocks);
+ closeThinking(blocks);
+ return {
+ blocks,
+ liveStart,
+ pendingCodexProgress,
+ };
+ }
const parts = ev.content?.parts ?? [];
const progressUpdates = parts.flatMap((part) => {
const progress = parseBranchCompareProgress(
@@ -453,14 +667,20 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc {
for (let index = blocks.length - 1; index >= 0; index -= 1) {
const block = blocks[index];
if (
- block.kind !== "tool"
- || block.done
- || block.name !== progress.toolName
- || (progress.requestId && block.callId && block.callId !== progress.requestId)
+ block.kind !== "tool" ||
+ block.done ||
+ block.name !== progress.toolName ||
+ (progress.requestId &&
+ block.callId &&
+ block.callId !== progress.requestId)
) {
continue;
}
- block.response = applyBranchCompareProgress(block.args, block.response, progress);
+ block.response = applyBranchCompareProgress(
+ block.args,
+ block.response,
+ progress,
+ );
block.status = "running";
break;
}
@@ -468,8 +688,9 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc {
for (const progress of codexProgressUpdates) {
const outcome = applyCodexProgressToTool(blocks, progress);
if (outcome === "unmatched") {
- pendingCodexProgress = [...pendingCodexProgress, progress]
- .slice(-MAX_PENDING_CODEX_PROGRESS);
+ pendingCodexProgress = [...pendingCodexProgress, progress].slice(
+ -MAX_PENDING_CODEX_PROGRESS,
+ );
}
}
return { blocks, liveStart, pendingCodexProgress };
@@ -480,22 +701,49 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc {
// Streaming delta: append into the live-preview region.
for (const p of parts) {
const text = visiblePartText(p);
+ const segmentId = options.mpaA2a && p.thought
+ ? metadataString(eventMetadata(ev), "reasoningSegmentId") : "";
+ if (segmentId && typeof text === "string" && text) {
+ const previous = blocks.find((block) =>
+ block.kind === "thinking" && block.reasoningSegmentId === segmentId);
+ if (previous?.kind === "thinking") previous.text += text;
+ else blocks.push({ kind: "thinking", text, done: false,
+ thoughtKind: thoughtKindOf(ev), reasoningSegmentId: segmentId });
+ continue;
+ }
if (typeof text === "string" && text)
- appendText(blocks, p.thought ? "thinking" : "text", text);
+ appendText(
+ blocks,
+ p.thought ? "thinking" : "text",
+ text,
+ p.thought ? thoughtKindOf(ev) : undefined,
+ );
}
return { blocks, liveStart, pendingCodexProgress };
}
// Consolidated / final event: drop the live preview and append authoritative
// content (merging consecutive same-kind text parts into one block).
+ // MPA finals can contain only the answer, without earlier outer/worker reasoning.
+ // Keep that reasoning unless this event explicitly supplies its replacement.
+ const preservedThinking = options.mpaA2a &&
+ !parts.some((part) => part.thought && visiblePartText(part))
+ ? blocks.slice(liveStart).filter((block) => block.kind === "thinking")
+ : [];
blocks.length = liveStart;
+ blocks.push(...preservedThinking);
for (const p of parts) {
const fc = fnCall(p);
const fr = fnResp(p);
const files = attachmentsFromParts([p]);
const text = visiblePartText(p);
if (typeof text === "string" && text) {
- appendText(blocks, p.thought ? "thinking" : "text", text);
+ appendText(
+ blocks,
+ p.thought ? "thinking" : "text",
+ text,
+ p.thought ? thoughtKindOf(ev) : undefined,
+ );
} else if (files.length) {
closeThinking(blocks);
appendAttachments(blocks, files);
@@ -508,13 +756,17 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc {
ev.actions?.transfer_to_agent ||
i18n.t("app:common.unknownAgent");
blocks.push({ kind: "agent-transfer", agentName, done: false });
+ } else if (fc.name === "sandbox_task" && isMpaSandboxEvent(ev)) {
+ continue;
} else if (fc.name === REQUEST_EUC) {
// MCP/tool OAuth: render a dedicated auth card instead of a tool row.
const args = (fc.args ?? {}) as Record;
const authConfig = args.authConfig ?? args.auth_config ?? args;
// functionCallId looks like "_adk_toolset_auth_McpToolset"; surface the
// toolset name so the card can say what is being authorized.
- const rawId = String(args.functionCallId ?? args.function_call_id ?? "");
+ const rawId = String(
+ args.functionCallId ?? args.function_call_id ?? "",
+ );
const label = rawId.replace(/^_adk_toolset_auth_/, "") || undefined;
blocks.push({
kind: "auth",
@@ -525,6 +777,23 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc {
done: false,
});
} else {
+ const existingTool = fc.id
+ ? [...blocks]
+ .reverse()
+ .find(
+ (block: Block) =>
+ block.kind === "tool" && block.callId === fc.id,
+ )
+ : undefined;
+ if (existingTool?.kind === "tool") {
+ existingTool.name = fc.name ?? existingTool.name;
+ existingTool.args = fc.args ?? existingTool.args;
+ if (existingTool.response === undefined) {
+ existingTool.done = false;
+ existingTool.status = "running";
+ }
+ continue;
+ }
const toolBlock: Extract = {
kind: "tool",
name: fc.name ?? "",
@@ -537,8 +806,8 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc {
const stillPending: CodexSandboxProgress[] = [];
for (const progress of pendingCodexProgress) {
if (
- progress.toolName === toolBlock.name
- && progress.requestId === toolBlock.callId
+ progress.toolName === toolBlock.name &&
+ progress.requestId === toolBlock.callId
) {
applyCodexProgressToTool(blocks, progress);
} else {
@@ -569,34 +838,64 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc {
}
}
}
+ let matchedTool = false;
for (let i = blocks.length - 1; i >= 0; i--) {
const b = blocks[i];
- const isCodexTool = b.kind === "tool" && b.name === "delegate_to_codex_sandbox";
+ const isCodexTool =
+ b.kind === "tool" && b.name === "delegate_to_codex_sandbox";
if (
- b.kind === "tool"
- && (!b.done || isCodexTool)
- && b.name === fr.name
- && (!fr.id || !b.callId || b.callId === fr.id)
+ b.kind === "tool" &&
+ (!b.done || isCodexTool || Boolean(fr.id && b.callId === fr.id)) &&
+ ((Boolean(fr.id) && Boolean(b.callId) && b.callId === fr.id) ||
+ ((!fr.id || !b.callId) && toolNamesMatch(b.name, fr.name ?? "")))
) {
const previousAnswer = isCodexTool
? codexDirectAnswer(b.response)
: "";
- b.done = true;
+ const responseState = toolResponseState(
+ fr.response,
+ ev.partial === true,
+ );
+ b.done = responseState.done;
b.response = fr.response;
+ b.status = responseState.status;
if (isCodexTool) {
b.codexActivity = hydrateCodexSandboxActivity(
b.codexActivity,
fr.response,
);
- b.status = codexResponseStatus(fr.response);
+ b.status =
+ responseState.status === "running"
+ ? "running"
+ : codexResponseStatus(fr.response);
const answer = codexDirectAnswer(fr.response);
if (answer && answer !== previousAnswer) {
appendText(blocks, "text", answer);
}
}
+ matchedTool = true;
break;
}
}
+ if (
+ !matchedTool &&
+ fr.name !== TRANSFER_AGENT_TOOL &&
+ fr.name !== REQUEST_EUC &&
+ fr.name !== A2UI_TOOL
+ ) {
+ const responseState = toolResponseState(
+ fr.response,
+ ev.partial === true,
+ );
+ blocks.push({
+ kind: "tool",
+ name: fr.name ?? "",
+ callId: fr.id,
+ response: fr.response,
+ done: responseState.done,
+ status: responseState.status,
+ });
+ }
if (fr.name === A2UI_TOOL) {
const msgs = (fr.response?.[VALIDATED_JSON_KEY] as A2uiMessage[]) ?? [];
if (msgs.length) {
@@ -611,9 +910,15 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc {
if (artifactDelta) {
appendArtifacts(
blocks,
- Object.entries(artifactDelta).map(([filename, version]) => ({ filename, version })),
+ Object.entries(artifactDelta).map(([filename, version]) => ({
+ filename,
+ version,
+ })),
);
}
+ if (isMpaSandboxEvent(ev) && mpaEventType(ev) === "invocation.completed") {
+ completeSandboxTaskBlocks(blocks);
+ }
closeThinking(blocks); // a consolidated thinking segment is complete
liveStart = blocks.length;
return { blocks, liveStart, pendingCodexProgress };
@@ -621,6 +926,7 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc {
function completesAssistantResponse(ev: AdkEvent, blocks: Block[]): boolean {
if (ev.partial === true) return false;
+ if (isFinalAlreadyEmittedSandboxResponse(ev)) return true;
const parts = ev.content?.parts ?? [];
const hasFinalAnswerPart = parts.some((part) => {
const text = visiblePartText(part);
@@ -631,26 +937,42 @@ function completesAssistantResponse(ev: AdkEvent, blocks: Block[]): boolean {
});
const hasA2ui = parts.some((part) => {
const response = fnResp(part);
- return response?.name === A2UI_TOOL &&
+ return (
+ response?.name === A2UI_TOOL &&
Array.isArray(response.response?.[VALIDATED_JSON_KEY]) &&
- response.response[VALIDATED_JSON_KEY].length > 0;
+ response.response[VALIDATED_JSON_KEY].length > 0
+ );
});
const artifactDelta = ev.actions?.artifactDelta ?? ev.actions?.artifact_delta;
- const hasArtifact = Boolean(artifactDelta && Object.keys(artifactDelta).length > 0);
+ const hasArtifact = Boolean(
+ artifactDelta && Object.keys(artifactDelta).length > 0,
+ );
const agentEnded = Boolean(
- ev.actions?.endOfAgent ?? ev.actions?.end_of_agent ?? ev.actions?.escalate
+ ev.actions?.endOfAgent ?? ev.actions?.end_of_agent ?? ev.actions?.escalate,
);
- const hasAnswerBlock = blocks.some((block) =>
+ const hasAnswerBlock = blocks.some(
+ (block) =>
block.kind === "text" ||
block.kind === "attachment" ||
block.kind === "artifact" ||
block.kind === "a2ui" ||
- block.kind === "delivery"
+ block.kind === "delivery",
+ );
+ return (
+ hasFinalAnswerPart ||
+ hasA2ui ||
+ hasArtifact ||
+ (agentEnded && hasAnswerBlock)
);
- return hasFinalAnswerPart || hasA2ui || hasArtifact || (agentEnded && hasAnswerBlock);
+}
+
+function a2aStatusOf(ev: AdkEvent): string | undefined {
+ const status = (ev.customMetadata ?? ev.custom_metadata)?.a2aStatus;
+ return typeof status === "string" ? status : undefined;
}
function eventAffectsAssistantTurn(ev: AdkEvent): boolean {
+ if (a2aStatusOf(ev)) return true;
const artifactDelta = ev.actions?.artifactDelta ?? ev.actions?.artifact_delta;
if (artifactDelta && Object.keys(artifactDelta).length > 0) return true;
return (ev.content?.parts ?? []).some((part) =>
@@ -658,11 +980,68 @@ function eventAffectsAssistantTurn(ev: AdkEvent): boolean {
visiblePartText(part) ||
attachmentsFromParts([part]).length > 0 ||
fnCall(part) ||
- fnResp(part)
- )
+ fnResp(part),
+ ),
);
}
+/** Reconcile an authoritative outer snapshot, never a sandbox/token delta. */
+function reconcileMpaA2aReasoningSnapshot(acc: Acc, ev: AdkEvent): Acc {
+ const metadata = eventMetadata(ev);
+ const parts = ev.content?.parts ?? [];
+ if (
+ ev.partial !== false ||
+ metadata.projectionSource !== "a2a-artifact" ||
+ isMpaSandboxEvent(ev) ||
+ !parts.length ||
+ ev.actions ||
+ !parts.every((part) => part.thought && typeof part.text === "string" &&
+ !fnCall(part) && !fnResp(part) && attachmentsFromParts([part]).length === 0)
+ ) return acc;
+ const previous = acc.blocks[acc.blocks.length - 1];
+ const snapshot = parts.map((part) => part.text).join("").trim();
+ if (
+ previous?.kind !== "thinking" ||
+ !previous.text.trim() ||
+ !snapshot.startsWith(previous.text.trim())
+ ) return acc;
+ // Re-open only this adjacent snapshot's preview range. Other activity is kept.
+ return { ...acc, liveStart: acc.blocks.length - 1 };
+}
+
+/** MPA A2A can split one user request into outer and sandbox assistant turns. */
+export function shouldShowEmptyAssistantResponse(
+ turns: Turn[],
+ index: number,
+ hasVisibleContent: (turn: Turn) => boolean,
+ options: {
+ mpaA2a: boolean;
+ turnIsStreaming: boolean;
+ requestIsStreaming: boolean;
+ },
+): boolean {
+ const turn = turns[index];
+ if (!turn || turn.role !== "assistant" || options.turnIsStreaming || hasVisibleContent(turn)) {
+ return false;
+ }
+ if (!options.mpaA2a) return true;
+ let start = index;
+ while (start > 0 && turns[start - 1].role !== "user") start -= 1;
+ let end = index + 1;
+ while (end < turns.length && turns[end].role !== "user") end += 1;
+ if (end === turns.length && options.requestIsStreaming) return false;
+ const fragments = turns.slice(start, end);
+ if (fragments.some(hasVisibleContent)) return false;
+ // Empty status placeholders are not rendered, so anchor the notice to the
+ // last renderable fragment rather than a trailing heartbeat-only turn.
+ return !turns.slice(index + 1, end).some((fragment) => fragment.blocks.length > 0);
+}
+
+function isUserEchoEvent(ev: AdkEvent): boolean {
+ if (ev.author !== "user" && ev.content?.role !== "user") return false;
+ return !(ev.content?.parts ?? []).some((part) => fnCall(part) || fnResp(part));
+}
+
/** Keep one mutable stream accumulator per active Agent response. Parallel
* Agents can interleave token events, so a single "current author" loses
* content and creates one turn per author switch. A completed response closes
@@ -671,10 +1050,31 @@ function eventAffectsAssistantTurn(ev: AdkEvent): boolean {
export function createAssistantEventProjector(
localIdPrefix = "adk-stream",
initialTurn?: Turn,
+ options: { mpaA2a?: boolean } = {},
) {
let sequence = 0;
const active = new Map();
+ const seenEventIds = new Set();
+ const eventIdOrder: string[] = [];
let seededKey: string | undefined;
+ let latestMpaTurn: Turn | undefined;
+ let mpaUsage = { ...initialTurn?.meta?.mpaUsage };
+
+ const recordMpaUsage = (ev: AdkEvent): boolean => {
+ if (!options.mpaA2a || !(ev.usageMetadata ?? ev.usage_metadata)) return false;
+ const count = addTokenUsage(EMPTY_SESSION_TOKEN_USAGE, ev).current.totalTokenCount;
+ if (!count) return false;
+ const metadata = eventMetadata(ev);
+ const key = JSON.stringify([
+ metadata.source ?? "outer",
+ metadata.requestId ?? "",
+ ev.invocationId ?? ev.invocation_id ?? "",
+ ev.id || JSON.stringify(ev.usageMetadata ?? ev.usage_metadata),
+ ]);
+ if (Object.prototype.hasOwnProperty.call(mpaUsage, key)) return false;
+ mpaUsage = { ...mpaUsage, [key]: count };
+ return true;
+ };
const keyFor = (author: string, invocationId: string) =>
`${invocationId}\u0000${author}`;
@@ -682,7 +1082,8 @@ export function createAssistantEventProjector(
if (initialTurn?.role === "assistant") {
const author = initialTurn.meta?.author ?? "";
const invocationId = initialTurn.meta?.invocationId ?? "";
- const localId = initialTurn.meta?.localId ?? `${localIdPrefix}-${sequence++}`;
+ const localId =
+ initialTurn.meta?.localId ?? `${localIdPrefix}-${sequence++}`;
const acc = emptyAcc();
acc.blocks = initialTurn.blocks;
acc.liveStart = initialTurn.blocks.length;
@@ -701,10 +1102,65 @@ export function createAssistantEventProjector(
return {
project(ev: AdkEvent): AssistantEventProjection {
- const author = ev.author && ev.author !== "user" ? ev.author : "";
+ if (isUserEchoEvent(ev)) {
+ return {
+ turn: { role: "assistant", blocks: [] },
+ completed: false,
+ ignored: true,
+ };
+ }
+ const usageChanged = recordMpaUsage(ev);
+ // Usage is metadata, including deltas arriving after the final answer.
+ // Update the existing fragment; never manufacture a new empty reply.
+ if (
+ options.mpaA2a && !eventAffectsAssistantTurn(ev) &&
+ !ev.content?.parts?.length
+ ) {
+ if (usageChanged && latestMpaTurn) {
+ latestMpaTurn = {
+ ...latestMpaTurn,
+ meta: { ...latestMpaTurn.meta, mpaUsage },
+ };
+ return { turn: latestMpaTurn, completed: false };
+ }
+ return { turn: { role: "assistant", blocks: [] }, completed: false, ignored: true };
+ }
+ if (isMpaUsageEvent(ev) && !options.mpaA2a) {
+ return {
+ turn: { role: "assistant", blocks: [] },
+ completed: false,
+ ignored: true,
+ };
+ }
+ if (ev.id && seenEventIds.has(ev.id)) {
+ return {
+ turn: { role: "assistant", blocks: [] },
+ completed: false,
+ ignored: true,
+ };
+ }
+ if (ev.id) {
+ seenEventIds.add(ev.id);
+ eventIdOrder.push(ev.id);
+ if (eventIdOrder.length > MAX_SEEN_EVENT_IDS) {
+ seenEventIds.delete(eventIdOrder.shift()!);
+ }
+ }
+ const rawAuthor = ev.author && ev.author !== "user" ? ev.author : "";
const invocationId = ev.invocationId ?? ev.invocation_id ?? "";
- const key = keyFor(author, invocationId);
+ let author = isMpaSandboxEvent(ev) ? "" : rawAuthor;
+ let key = keyFor(author, invocationId);
let state = active.get(key);
+ if (!state && isMpaSandboxEvent(ev) && invocationId) {
+ const existing = [...active.entries()].find(([candidate]) =>
+ candidate.startsWith(`${invocationId}\u0000`)
+ );
+ if (existing) {
+ key = existing[0];
+ state = existing[1];
+ author = state.meta.author ?? "";
+ }
+ }
if (!state && seededKey) {
const seeded = active.get(seededKey);
if (seeded && (!seeded.meta.author || seeded.meta.author === author)) {
@@ -713,6 +1169,18 @@ export function createAssistantEventProjector(
state = seeded;
}
}
+ // The MPA live stream can omit the persisted sandbox
+ // `invocation.completed` event and finish with only the wrapper response.
+ // Use that response to close the active parent tool. During history
+ // replay the completed sandbox event has already closed the turn, so the
+ // same wrapper response must remain ignored to avoid a duplicate card.
+ if (!state && isFinalAlreadyEmittedSandboxResponse(ev)) {
+ return {
+ turn: { role: "assistant", blocks: [] },
+ completed: false,
+ ignored: true,
+ };
+ }
if (!state && !eventAffectsAssistantTurn(ev)) {
return {
turn: { role: "assistant", blocks: [] },
@@ -725,11 +1193,18 @@ export function createAssistantEventProjector(
state = {
acc: emptyAcc(),
localId,
- meta: { author: author || undefined, invocationId: invocationId || undefined },
+ meta: {
+ author: author || undefined,
+ invocationId: invocationId || undefined,
+ },
};
}
- state.acc = applyEvent(state.acc, ev);
+ state.acc = applyEvent(
+ options.mpaA2a ? reconcileMpaA2aReasoningSnapshot(state.acc, ev) : state.acc,
+ ev,
+ options,
+ );
const usage = ev.usageMetadata ?? ev.usage_metadata;
const completed = completesAssistantResponse(ev, state.acc.blocks);
state.meta = {
@@ -737,7 +1212,15 @@ export function createAssistantEventProjector(
author: author || state.meta.author,
localId: state.localId,
streaming: !completed,
+ a2aStatus: a2aStatusOf(ev),
tokens: usage?.totalTokenCount || state.meta.tokens,
+ ...(options.mpaA2a ? {
+ mpaUsage,
+ mpaFinalAnswer: isMpaSandboxEvent(ev) && mpaEventType(ev) === "invocation.completed"
+ ? (ev.content?.parts ?? []).filter((part) => !part.thought)
+ .map((part) => visiblePartText(part) ?? "").join("").trim() || undefined
+ : state.meta.mpaFinalAnswer,
+ } : {}),
ts: ev.timestamp || state.meta.ts,
invocationId: invocationId || state.meta.invocationId,
eventId: completed && ev.id ? ev.id : state.meta.eventId,
@@ -747,20 +1230,32 @@ export function createAssistantEventProjector(
blocks: state.acc.blocks,
meta: state.meta,
};
- if (completed) {
+ const awaitsPersistedSandboxFinal =
+ completed && isFinalAlreadyEmittedSandboxResponse(ev);
+ if (completed && !awaitsPersistedSandboxFinal) {
active.delete(key);
seededKey = undefined;
} else {
+ // Keep the terminal wrapper state addressable until transport finish.
+ // Some stored/replayed streams can still deliver the authoritative
+ // sandbox invocation.completed event immediately after the wrapper.
active.set(key, state);
}
+ if (options.mpaA2a) latestMpaTurn = turn;
return { turn, completed };
},
finish(): Turn[] {
const turns = [...active.values()].map((state): Turn => ({
role: "assistant",
- blocks: state.acc.blocks,
- meta: { ...state.meta, streaming: false },
+ blocks: state.acc.blocks.map((block) =>
+ block.kind === "thinking" ? { ...block, done: true } : block
+ ),
+ meta: {
+ ...state.meta,
+ streaming: false,
+ ...(options.mpaA2a ? { mpaUsage } : {}),
+ },
}));
active.clear();
return turns;
@@ -768,7 +1263,10 @@ export function createAssistantEventProjector(
};
}
-export function upsertProjectedAssistantTurn(turns: Turn[], projected: Turn): Turn[] {
+export function upsertProjectedAssistantTurn(
+ turns: Turn[],
+ projected: Turn,
+): Turn[] {
const localId = projected.meta?.localId;
if (!localId) return [...turns, projected];
const index = turns.findIndex((turn) => turn.meta?.localId === localId);
@@ -782,9 +1280,13 @@ export function upsertProjectedAssistantTurn(turns: Turn[], projected: Turn): Tu
export function eventsToTurns(
events: AdkEvent[],
sessionState: Record = {},
+ options: { mpaA2a?: boolean } = {},
): Turn[] {
let turns: Turn[] = [];
- let projector = createAssistantEventProjector("adk-history");
+ let historySegment = 0;
+ const nextProjector = () =>
+ createAssistantEventProjector(`adk-history-${historySegment++}`, undefined, options);
+ let projector = nextProjector();
for (const ev of events) {
// Classify by author only: function-response events are authored by the
// agent but carry content.role === "user", so a role-based check would
@@ -799,7 +1301,10 @@ export function eventsToTurns(
if (turns[i].role !== "assistant") continue;
for (let j = turns[i].blocks.length - 1; j >= 0; j--) {
const b = turns[i].blocks[j];
- if (b.kind === "auth") { b.done = true; break; }
+ if (b.kind === "auth") {
+ b.done = true;
+ break;
+ }
}
break;
}
@@ -822,6 +1327,7 @@ export function eventsToTurns(
if (files.length) blocks.push({ kind: "attachment", files });
if (text) blocks.push({ kind: "text", text });
turns.push({ role: "user", blocks, meta: { ts: ev.timestamp } });
+ projector = nextProjector();
} else {
const projection = projector.project(ev);
if (!projection.ignored) {
diff --git a/frontend/src/components/ai-app/ConversationFlow/StudioConversation.tsx b/frontend/src/components/ai-app/ConversationFlow/StudioConversation.tsx
index 273713a5e..32657b31b 100644
--- a/frontend/src/components/ai-app/ConversationFlow/StudioConversation.tsx
+++ b/frontend/src/components/ai-app/ConversationFlow/StudioConversation.tsx
@@ -196,6 +196,11 @@ export function fromStudioTurns(turns: readonly Turn[], options: StudioConversat
switch (block.kind) {
case "turn-summary": return {id, type: "custom", content: };
case "diff": return { id, type: "custom", content: };
+ case "activity-source": return {
+ id,
+ type: "custom",
+ content: {block.label}
,
+ };
case "text": return { id, type: "markdown", text: block.text };
case "thinking": return { id, type: "reasoning", title: "思考过程", status: block.done ? "complete" : "running", content: block.text };
case "progress": return { id, type: "reasoning", title: block.text, status: turn.meta?.streaming ? "running" : "complete", content: block.text };
diff --git a/frontend/src/components/layouts/ModalLayout/ModalLayout.tsx b/frontend/src/components/layouts/ModalLayout/ModalLayout.tsx
index cabf51b45..9f34b117a 100644
--- a/frontend/src/components/layouts/ModalLayout/ModalLayout.tsx
+++ b/frontend/src/components/layouts/ModalLayout/ModalLayout.tsx
@@ -6,6 +6,8 @@ import "./ModalLayout.css";
export interface ModalLayoutProps extends Omit, "title"> {
title: ReactNode;
topGlow?: boolean;
+ /** Custom footer; null omits it while undefined preserves the default actions. */
+ footer?: ReactNode;
cancelLabel?: string;
confirmLabel?: string;
closeLabel?: string;
@@ -14,7 +16,7 @@ export interface ModalLayoutProps extends Omit, "
onConfirm?: () => void;
}
-export function ModalLayout({ title, topGlow = true, children, cancelLabel = "Cancel", confirmLabel = "Confirm", closeLabel = "Close", onClose, onCancel, onConfirm, className = "", ...props }: ModalLayoutProps) {
+export function ModalLayout({ title, topGlow = true, children, footer, cancelLabel = "Cancel", confirmLabel = "Confirm", closeLabel = "Close", onClose, onCancel, onConfirm, className = "", ...props }: ModalLayoutProps) {
const titleId = useId();
return
@@ -22,9 +24,11 @@ export function ModalLayout({ title, topGlow = true, children, cancelLabel = "Ca
} />
{children}
-
}
;
}
diff --git a/frontend/src/components/primitives/Button/Button.css b/frontend/src/components/primitives/Button/Button.css
index 5b7b65666..a3852722b 100644
--- a/frontend/src/components/primitives/Button/Button.css
+++ b/frontend/src/components/primitives/Button/Button.css
@@ -27,6 +27,11 @@
transition: background-color var(--studio-motion-duration-fast, 140ms) ease-out, color var(--studio-motion-duration-fast, 140ms) ease-out;
}
+.studio-button:disabled {
+ opacity: .45;
+ cursor: not-allowed;
+}
+
/* Figma: 632:252803 */
.studio-button--secondary {
background: #262626;
diff --git a/frontend/src/create/CustomCreate.tsx b/frontend/src/create/CustomCreate.tsx
index 8daf7fd11..0ba5ea928 100644
--- a/frontend/src/create/CustomCreate.tsx
+++ b/frontend/src/create/CustomCreate.tsx
@@ -153,10 +153,12 @@ import {
checkRuntimeNameAvailability,
deleteGeneratedAgentTestRun,
deployAgentkitProject,
+ applyMpaProfileAfterDeployment,
generateAgentDraftFromRequirement,
generateAgentProject,
listModelApiKeys,
listModelOptions,
+ retryMpaAgentOperation,
type ModelApiKeyOption,
runGeneratedAgentTestSSE,
type ModelOption,
@@ -3944,12 +3946,14 @@ interface CustomCreateProps extends CreateModeProps {
/** Existing Runtime target when editing an Agent from the library. */
deploymentTarget?: {
runtimeId: string;
+ mpaInstanceId?: string;
name: string;
region: string;
appName?: string;
currentVersion?: number | null;
etag?: string;
editMode?: "source-preserving" | "regenerate";
+ mpaProfileOnly?: boolean;
configuredMcpEnvKeys?: string[];
configuredRuntimeEnvKeys?: string[];
};
@@ -5115,6 +5119,7 @@ export function CustomCreate({
runtimeId: deploymentTarget?.runtimeId,
runtimeName: options?.runtimeName ?? deploymentRuntimeName,
appName: deploymentTarget?.appName,
+ agentCategory: "mpa",
editMode: deploymentTarget?.editMode,
draft:
deploymentTarget || mcpGatewayManaged ? codegenDraft(draft) : undefined,
@@ -5288,6 +5293,12 @@ export function CustomCreate({
pct: 0,
});
let activeTask: DeploymentTaskUpdate | null = null;
+ let latestPhase = "prepare";
+ let latestLabel = t("traditional.deployment.preparing");
+ let latestMessage = t("traditional.deployment.generatingConfiguration");
+ let latestRuntimeId = deploymentTarget?.runtimeId;
+ let latestRegion = deploymentTarget?.region ?? deployRegion;
+ let failedMpaRetry: (() => Promise) | undefined;
try {
if (!deploymentTarget) {
const availability = await checkRuntimeNameAvailability(
@@ -5304,17 +5315,17 @@ export function CustomCreate({
setProject(generated);
const taskId = crypto.randomUUID();
const startedAt = Date.now();
- let latestPhase = "prepare";
- let latestLabel = t("traditional.deployment.preparing");
- let latestMessage = t("traditional.deployment.generatingConfiguration");
const taskBase = {
id: taskId,
...(workspaceDraftId ? { draftId: workspaceDraftId } : {}),
agentName: deploymentDraft.name,
runtimeName: deploymentRuntimeName.trim(),
- region: deployRegion,
+ runtimeId: deploymentTarget?.runtimeId,
+ region: deploymentTarget?.region ?? deployRegion,
startedAt,
agentDraft: deploymentDraft,
+ mpaProfile: true,
+ mpaSmoke: !deploymentTarget,
};
const initialTask: DeploymentTaskUpdate = {
...taskBase,
@@ -5328,6 +5339,82 @@ export function CustomCreate({
onDeploymentTaskChange?.(initialTask);
onDeploymentStarted?.(initialTask);
+ if (deploymentTarget?.mpaProfileOnly) {
+ latestPhase = "profile_applying";
+ latestLabel = t("traditional.deployment.stages.applyMpaProfile");
+ latestMessage = t("traditional.deployment.applyingMpaProfile");
+ setNewWorkbenchDeployStage({
+ level: "info",
+ phase: latestPhase,
+ message: latestMessage,
+ pct: 0,
+ });
+ onDeploymentTaskChange?.({
+ ...taskBase,
+ status: "running",
+ phase: latestPhase,
+ label: latestLabel,
+ message: latestMessage,
+ pct: 0,
+ mpaProfile: true,
+ mpaSmoke: false,
+ });
+ const operationKind = deploymentTarget.etag ? "update" : "create";
+ const mpaOperation = await applyMpaProfileAfterDeployment({
+ draft: deploymentDraft,
+ taskId,
+ agentName: deploymentDraft.name,
+ runtimeId: deploymentTarget.runtimeId,
+ region: deploymentTarget.region,
+ operationKind,
+ mpaInstanceId: deploymentTarget.mpaInstanceId,
+ runtimeName: deploymentRuntimeName.trim(),
+ sourceDraftId: workspaceDraftId,
+ runtimeRevision: deploymentTarget.etag,
+ });
+ if (mpaOperation.status !== "succeeded") {
+ latestMessage =
+ mpaOperation.safeErrorCode ||
+ t("traditional.deployment.mpaOperationIncomplete");
+ setNewWorkbenchDeployStage({
+ level: "error",
+ phase: latestPhase,
+ message: latestMessage,
+ pct: 100,
+ });
+ throw new Error(latestMessage);
+ }
+ setNewWorkbenchDeploySucceeded(true);
+ setNewWorkbenchDeployStage({
+ level: "success",
+ phase: latestPhase,
+ message: t("traditional.deployment.mpaProfileApplied"),
+ pct: 100,
+ });
+ onDeploymentTaskChange?.({
+ ...taskBase,
+ status: "success",
+ phase: "complete",
+ label: t("traditional.deployment.complete"),
+ message: t("traditional.deployment.mpaProfileApplied"),
+ pct: 100,
+ mpaProfile: true,
+ mpaSmoke: false,
+ });
+ await onDeploymentComplete?.({
+ apikey: "",
+ url: "",
+ agentName: deploymentDraft.name,
+ runtimeName: deploymentRuntimeName.trim(),
+ runtimeId: deploymentTarget.runtimeId,
+ mpaInstanceId: deploymentTarget.mpaInstanceId,
+ region: deploymentTarget.region,
+ version: deploymentTarget.currentVersion,
+ mpaOperation,
+ });
+ return;
+ }
+
const envMap = new Map(
Object.entries(allEnvValues)
.map(([key, value]) => [key.trim(), value] as const)
@@ -5384,6 +5471,149 @@ export function CustomCreate({
envs: [...envMap].map(([key, value]) => ({ key, value })),
},
);
+ let completeResult = result;
+ const mpaRuntimeId = result.runtimeId || deploymentTarget?.runtimeId || "";
+ const mpaRegion = result.region || deploymentTarget?.region || deployRegion;
+ latestRuntimeId = mpaRuntimeId || latestRuntimeId;
+ latestRegion = mpaRegion;
+ if (mpaRuntimeId) {
+ latestPhase = "profile_applying";
+ latestLabel = t("traditional.deployment.stages.applyMpaProfile");
+ latestMessage = t("traditional.deployment.applyingMpaProfile");
+ setNewWorkbenchDeployStage({
+ level: "info",
+ phase: latestPhase,
+ message: latestMessage,
+ pct: 0,
+ });
+ onDeploymentTaskChange?.({
+ ...taskBase,
+ runtimeName: result.runtimeName || taskBase.runtimeName,
+ runtimeId: mpaRuntimeId,
+ region: mpaRegion,
+ status: "running",
+ phase: latestPhase,
+ label: latestLabel,
+ message: latestMessage,
+ pct: 0,
+ mpaProfile: true,
+ mpaSmoke: !deploymentTarget,
+ });
+ const mpaOperation = await applyMpaProfileAfterDeployment({
+ draft: deploymentDraft,
+ taskId,
+ agentName: deploymentDraft.name,
+ runtimeId: mpaRuntimeId,
+ region: mpaRegion,
+ operationKind: deploymentTarget ? "update" : "create",
+ mpaInstanceId:
+ deploymentTarget?.mpaInstanceId ?? result.mpaInstanceId,
+ runtimeName: deploymentRuntimeName.trim() || result.runtimeName,
+ sourceDraftId: workspaceDraftId,
+ runtimeRevision: deploymentTarget?.etag,
+ });
+ if (mpaOperation.status !== "succeeded") {
+ latestPhase =
+ mpaOperation.stage === "smoke_running"
+ ? "smoke_running"
+ : "profile_applying";
+ latestLabel =
+ latestPhase === "smoke_running"
+ ? t("traditional.deployment.stages.verifyMpaRuntime")
+ : t("traditional.deployment.stages.applyMpaProfile");
+ latestMessage =
+ mpaOperation.safeErrorCode ||
+ t("traditional.deployment.mpaOperationIncomplete");
+ setNewWorkbenchDeployStage({
+ level: "error",
+ phase: latestPhase,
+ message: latestMessage,
+ pct: 100,
+ });
+ if (mpaOperation.status === "failed_retryable") {
+ const retryParams = {
+ operationKind: deploymentTarget ? "update" as const : "create" as const,
+ runtimeId: mpaRuntimeId,
+ region: mpaRegion,
+ mpaInstanceId:
+ deploymentTarget?.mpaInstanceId ?? result.mpaInstanceId ?? mpaRuntimeId,
+ sourceProfileId: workspaceDraftId?.trim()
+ ? `studio-draft:${workspaceDraftId.trim()}`
+ : `studio-agent:${deploymentDraft.name}:${taskId}`,
+ draft: deploymentDraft,
+ targetKey: deploymentTarget
+ ? deploymentTarget.mpaInstanceId ?? mpaRuntimeId
+ : `runtime:${mpaRegion}:${deploymentRuntimeName.trim() || result.runtimeName}`,
+ runtimeRevision: deploymentTarget?.etag,
+ };
+ failedMpaRetry = async () => {
+ const retried = await retryMpaAgentOperation(
+ mpaOperation.operationId,
+ retryParams,
+ );
+ onDeploymentTaskChange?.({
+ ...taskBase,
+ runtimeId: mpaRuntimeId,
+ mpaInstanceId: retryParams.mpaInstanceId,
+ region: mpaRegion,
+ status: retried.status === "succeeded" ? "success" : "error",
+ phase: retried.stage,
+ label: retried.status === "succeeded"
+ ? t("traditional.deployment.complete")
+ : t("traditional.deployment.failed"),
+ message: retried.safeErrorCode || undefined,
+ pct: 100,
+ mpaProfile: true,
+ mpaSmoke: !deploymentTarget,
+ operationId: retried.operationId,
+ });
+ if (retried.status !== "succeeded") {
+ throw new Error(
+ retried.safeErrorCode || t("traditional.deployment.mpaOperationIncomplete"),
+ );
+ }
+ await onDeploymentComplete?.({ ...result, mpaOperation: retried });
+ };
+ }
+ throw new Error(latestMessage);
+ }
+ if (deploymentTarget) {
+ latestMessage = t("traditional.deployment.mpaProfileApplied");
+ setNewWorkbenchDeployStage({
+ level: "success",
+ phase: latestPhase,
+ message: latestMessage,
+ pct: 100,
+ });
+ } else {
+ latestPhase = "smoke_running";
+ latestLabel = t("traditional.deployment.stages.verifyMpaRuntime");
+ latestMessage = t("traditional.deployment.runningMpaSmoke");
+ onDeploymentTaskChange?.({
+ ...taskBase,
+ runtimeName: result.runtimeName || taskBase.runtimeName,
+ runtimeId: mpaRuntimeId,
+ region: mpaRegion,
+ status: "running",
+ phase: latestPhase,
+ label: latestLabel,
+ message: latestMessage,
+ pct: 100,
+ mpaProfile: true,
+ mpaSmoke: true,
+ });
+ latestPhase = "runnable";
+ latestLabel = t("traditional.deployment.stages.mpaReady");
+ latestMessage = t("traditional.deployment.mpaReady");
+ setNewWorkbenchDeployStage({
+ level: "success",
+ phase: latestPhase,
+ message: latestMessage,
+ pct: 100,
+ });
+ }
+ completeResult = { ...result, mpaOperation };
+ }
setNewWorkbenchDeploySucceeded(true);
setNewWorkbenchDeployStage({
level: "success",
@@ -5393,16 +5623,20 @@ export function CustomCreate({
});
onDeploymentTaskChange?.({
...taskBase,
- runtimeName: result.runtimeName || taskBase.runtimeName,
- runtimeId: result.runtimeId,
- region: result.region || deployRegion,
+ runtimeName: completeResult.runtimeName || taskBase.runtimeName,
+ runtimeId: completeResult.runtimeId || deploymentTarget?.runtimeId,
+ region: completeResult.region || deploymentTarget?.region || deployRegion,
status: "success",
- phase: "complete",
+ phase: completeResult.mpaOperation && !deploymentTarget ? "runnable" : "complete",
label: t("traditional.deployment.complete"),
- message: result.warnings?.join(";"),
+ message: completeResult.mpaOperation && !deploymentTarget
+ ? t("traditional.deployment.mpaReady")
+ : completeResult.warnings?.join(";"),
pct: 100,
+ mpaProfile: true,
+ mpaSmoke: Boolean(completeResult.mpaOperation && !deploymentTarget),
});
- await onDeploymentComplete?.(result);
+ await onDeploymentComplete?.(completeResult);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setNewWorkbenchDeployError(message);
@@ -5417,10 +5651,15 @@ export function CustomCreate({
startedAt: Date.now(),
}),
status: "error",
- phase: activeTask?.phase,
+ runtimeId: latestRuntimeId,
+ region: latestRegion,
+ phase: latestPhase,
label: t("traditional.deployment.failed"),
message,
- retry: () => deployFromNewWorkbench(deploymentOptions),
+ mpaProfile: true,
+ mpaSmoke: !deploymentTarget,
+ operationId: undefined,
+ retry: failedMpaRetry ?? (() => deployFromNewWorkbench(deploymentOptions)),
};
onDeploymentTaskChange?.(failedTask);
} finally {
@@ -5488,6 +5727,7 @@ export function CustomCreate({
}
onChange={(event) => setAiRequirement(event.target.value)}
onKeyDown={(event) => {
+ if (isImeCompositionEvent(event.nativeEvent)) return;
if (event.key === "Enter") {
event.preventDefault();
void handleGenerateDraft();
@@ -5539,6 +5779,7 @@ export function CustomCreate({
deployRegion={deployRegion}
runtimeName={deploymentRuntimeName}
isRuntimeUpdate={Boolean(deploymentTarget)}
+ profileOnly={deploymentTarget?.mpaProfileOnly}
deploying={newWorkbenchDeploying}
deployStage={newWorkbenchDeployStage}
deployError={newWorkbenchDeployError}
@@ -6659,7 +6900,9 @@ export function CustomCreate({
}
deploymentActionTargetId="cw-publish-primary-action"
deploymentRuntimeId={deploymentTarget?.runtimeId}
+ deploymentMpaInstanceId={deploymentTarget?.mpaInstanceId}
deploymentRuntimeName={deploymentRuntimeName}
+ deploymentRuntimeRevision={deploymentTarget?.etag}
deploymentRuntimeNameCustomized={
!!deploymentTarget ||
!!draft.deployment?.runtimeNameCustomized
diff --git a/frontend/src/create/NewAgentWorkbench.tsx b/frontend/src/create/NewAgentWorkbench.tsx
index e146f07a7..a1ce9d74c 100644
--- a/frontend/src/create/NewAgentWorkbench.tsx
+++ b/frontend/src/create/NewAgentWorkbench.tsx
@@ -65,6 +65,7 @@ export interface NewAgentWorkbenchProps {
deployRegion: string;
runtimeName: string;
isRuntimeUpdate?: boolean;
+ profileOnly?: boolean;
deploying: boolean;
deployStage: DeployStage | null;
deployError: string;
@@ -798,6 +799,7 @@ export function NewAgentWorkbench({
deployRegion,
runtimeName,
isRuntimeUpdate = false,
+ profileOnly = false,
deploying,
deployStage,
deployError,
@@ -889,7 +891,7 @@ export function NewAgentWorkbench({
],
);
const modelApiKeyMissing =
- (modelSource === "ark" && !draft.deployment?.modelApiKeyId?.trim()) ||
+ (!profileOnly && modelSource === "ark" && !draft.deployment?.modelApiKeyId?.trim()) ||
Boolean(missingCustomModelCredential);
const showModelApiKeyError =
modelApiKeyMissing && (showAgentErrors || !modelDataLoading);
@@ -1001,6 +1003,18 @@ export function NewAgentWorkbench({
if (step === "agent") {
setAgentValidationVisible(true);
if (agentHasErrors) return;
+ if (profileOnly) {
+ onDeploy({
+ authentication: { type: "api_key" },
+ sessionStorage,
+ sessionBackend,
+ minInstance: Number(minInstance) || 1,
+ maxInstance: Number(maxInstance) || 1,
+ createEvaluationSets: false,
+ resources: deployResources,
+ });
+ return;
+ }
setStep("environment");
return;
}
@@ -1281,7 +1295,7 @@ export function NewAgentWorkbench({
) : null}
- {step === "environment" ? (
+ {step === "environment" && !profileOnly ? (
) : null}
- {step === "deployment" ? (
+ {step === "deployment" && !profileOnly ? (
- {step === "deployment"
+ {profileOnly
+ ? deploySucceeded
+ ? t("workbench.actions.applyProfileAgain")
+ : t("workbench.actions.applyProfile")
+ : step === "deployment"
? deploySucceeded
? isRuntimeUpdate
? t("workbench.actions.updateAgain")
diff --git a/frontend/src/create/agentDraftStorage.ts b/frontend/src/create/agentDraftStorage.ts
index 22972deb2..28754a845 100644
--- a/frontend/src/create/agentDraftStorage.ts
+++ b/frontend/src/create/agentDraftStorage.ts
@@ -26,12 +26,14 @@ export interface WorkspaceAgentDraft {
creationMode?: WorkspaceAgentCreationMode;
deploymentTarget?: {
runtimeId: string;
+ mpaInstanceId?: string;
name: string;
region: string;
appName?: string;
currentVersion?: number | null;
etag?: string;
editMode?: "source-preserving" | "regenerate";
+ mpaProfileOnly?: boolean;
configuredMcpEnvKeys?: string[];
configuredRuntimeEnvKeys?: string[];
};
diff --git a/frontend/src/create/skills/skillspace.ts b/frontend/src/create/skills/skillspace.ts
index 936b6a3ff..531b5e488 100644
--- a/frontend/src/create/skills/skillspace.ts
+++ b/frontend/src/create/skills/skillspace.ts
@@ -69,12 +69,13 @@ export interface SkillSpacePageOptions {
page: number;
pageSize: number;
project?: string;
+ signal?: AbortSignal;
}
-async function jfetch(url: string): Promise {
+async function jfetch(url: string, signal?: AbortSignal): Promise {
const res = await fetch(withAuth(url), {
headers: withLocaleHeaders(withLocalUser({ accept: "application/json" })),
- signal: requestSignal(undefined, DEFAULT_REQUEST_TIMEOUT_MS),
+ signal: requestSignal(signal, DEFAULT_REQUEST_TIMEOUT_MS),
});
if (!res.ok) {
throw await skillApiErrorFromResponse(
@@ -123,6 +124,7 @@ export async function listSkillsInSpacePage(
if (options.project) params.set("project", options.project);
return jfetch>(
`/web/skill-spaces/${encodeURIComponent(spaceId)}/skills?${params.toString()}`,
+ options.signal,
);
}
diff --git a/frontend/src/cronjobs/CronJobs.tsx b/frontend/src/cronjobs/CronJobs.tsx
index 9f3283f94..d8bb9e442 100644
--- a/frontend/src/cronjobs/CronJobs.tsx
+++ b/frontend/src/cronjobs/CronJobs.tsx
@@ -1,3 +1,5 @@
+import { MpaCronTasks } from "./MpaCronTasks";
+import type { MpaRuntime } from "../adk/mpaCronTasks";
import {
useCallback,
useEffect,
@@ -77,6 +79,7 @@ import { CronJobFinalAnswer } from "./CronJobFinalAnswer";
import "./CronJobs.css";
interface CronJobsProps {
+ selectedRuntime?: MpaRuntime;
cloudProvider: CloudProvider;
}
@@ -560,7 +563,8 @@ function JobDetail({
);
}
-export function CronJobs({ cloudProvider }: CronJobsProps) {
+export function CronJobs({ cloudProvider, selectedRuntime }: CronJobsProps) {
+ const [source, setSource] = useState("studio");
useTranslation("cronjobs");
const [jobs, setJobs] = useState([]);
const [runtimes, setRuntimes] = useState([]);
@@ -744,6 +748,11 @@ export function CronJobs({ cloudProvider }: CronJobsProps) {
className="cronjobs-page-head"
title={cronText("page.title")}
/>
+
+
+
+ {source === "runtime" ? : <>
{loading && jobs.length === 0 ? : error ? {cronText("page.loadFailed")}{error} : 0} onCreate={() => setDrawerJob(null)} onSelect={(job) => setSelectedId(job.jobId)} />}
+ >}
{drawerJob !== undefined ? setDrawerJob(undefined)} onSubmit={submitDrawer} /> : null}
);
diff --git a/frontend/src/cronjobs/MpaCronTasks.css b/frontend/src/cronjobs/MpaCronTasks.css
new file mode 100644
index 000000000..77d73828c
--- /dev/null
+++ b/frontend/src/cronjobs/MpaCronTasks.css
@@ -0,0 +1,427 @@
+.mpa-cron {
+ min-width: 0;
+ padding-bottom: 24px;
+ font-size: 13px;
+ color: hsl(var(--foreground));
+}
+.mpa-cron-target {
+ display: flex;
+ gap: 8px 16px;
+ flex-wrap: wrap;
+ overflow-wrap: anywhere;
+ color: hsl(var(--muted-foreground));
+ font-size: 12px;
+ margin-bottom: 20px;
+}
+.mpa-cron-target strong {
+ color: hsl(var(--foreground));
+ font-weight: 500;
+}
+.mpa-cron-heading,
+.mpa-toolbar,
+.mpa-calendar-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+.mpa-cron-heading {
+ margin-bottom: 24px;
+}
+.mpa-segment {
+ display: flex;
+ padding: 3px;
+ background: hsl(var(--muted));
+ border-radius: 999px;
+}
+.mpa-segment button {
+ border: 0;
+ background: transparent;
+ color: hsl(var(--muted-foreground));
+ padding: 4px 16px;
+ border-radius: 999px;
+ min-height: 28px;
+ cursor: pointer;
+}
+.mpa-segment button[aria-pressed="true"] {
+ background: hsl(var(--panel));
+ color: hsl(var(--foreground));
+}
+.mpa-create {
+ border-radius: 999px !important;
+ gap: 8px;
+}
+.mpa-cron-search {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+.mpa-cron-search input {
+ width: 180px;
+ min-width: 0;
+ height: 34px;
+ border: 1px solid hsl(var(--border));
+ border-radius: 6px;
+ padding: 0 12px;
+ background: hsl(var(--panel));
+ color: inherit;
+}
+.mpa-cron-overview {
+ display: flex;
+ gap: 16px;
+ flex-wrap: wrap;
+ color: hsl(var(--muted-foreground));
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+ margin-left: auto;
+}
+.mpa-cron-overview strong {
+ color: hsl(var(--foreground));
+ font-weight: 500;
+}
+.mpa-cron-table {
+ overflow-x: auto;
+ border: 1px solid hsl(var(--border));
+ border-radius: 8px;
+ margin-top: 12px;
+}
+.mpa-cron table,
+.mpa-detail table {
+ width: 100%;
+ border-collapse: collapse;
+ text-align: left;
+ table-layout: fixed;
+ min-width: 850px;
+}
+.mpa-cron th,
+.mpa-cron td,
+.mpa-detail th,
+.mpa-detail td {
+ padding: 18px 20px;
+ border-bottom: 1px solid hsl(var(--border));
+ vertical-align: middle;
+ font-weight: 400;
+ overflow-wrap: anywhere;
+}
+.mpa-cron th {
+ font-weight: 500;
+ padding-top: 12px;
+ padding-bottom: 12px;
+}
+.mpa-cron tbody tr:last-child td {
+ border-bottom: 0;
+}
+.mpa-cron tbody tr:hover {
+ background: hsl(var(--muted) / 0.45);
+}
+.mpa-cron th:last-child {
+ width: 140px;
+}
+.mpa-cron th:nth-child(3) {
+ width: 24%;
+}
+.mpa-cron th:nth-child(4) {
+ width: 180px;
+}
+.mpa-cron th .deployment-select {
+ width: 100%;
+}
+.mpa-task-link {
+ border: 0;
+ padding: 0;
+ background: none;
+ color: hsl(var(--feature-link));
+ cursor: pointer;
+ text-align: left;
+ line-height: 1.6;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+.mpa-status {
+ display: inline-block;
+ border: 1px solid hsl(var(--border));
+ border-radius: 4px;
+ padding: 2px 8px;
+ white-space: nowrap;
+ color: hsl(var(--muted-foreground));
+ font-size: 12px;
+}
+.mpa-status-Succeeded {
+ color: hsl(150 52% 35%);
+ border-color: hsl(150 35% 75%);
+}
+.mpa-status-Failed,
+.mpa-status-TimedOut {
+ color: hsl(var(--destructive));
+ border-color: hsl(var(--destructive) / 0.3);
+}
+.mpa-status-Running {
+ color: hsl(var(--primary));
+}
+.mpa-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.mpa-icon-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 0;
+ background: none;
+ color: hsl(var(--muted-foreground));
+ width: 32px;
+ height: 32px;
+ border-radius: 6px;
+ cursor: pointer;
+}
+.mpa-icon-button:hover {
+ background: hsl(var(--muted));
+ color: hsl(var(--foreground));
+}
+.mpa-switch {
+ display: inline-flex;
+ align-items: center;
+ border: 0;
+ padding: 3px;
+ border-radius: 999px;
+ background: hsl(var(--muted));
+ width: 32px;
+ height: 20px;
+ cursor: pointer;
+ flex-shrink: 0;
+}
+.mpa-switch span {
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: hsl(var(--panel));
+ border: 1px solid hsl(var(--border));
+}
+.mpa-switch[aria-checked="true"] {
+ background: hsl(var(--primary));
+ justify-content: flex-end;
+}
+.mpa-cron button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+.mpa-cron :focus-visible {
+ outline: 2px solid hsl(var(--primary));
+ outline-offset: 2px;
+}
+.mpa-cron-pages {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-top: 16px;
+ font-variant-numeric: tabular-nums;
+}
+.mpa-cron-pages span {
+ margin-right: auto;
+ color: hsl(var(--muted-foreground));
+}
+.mpa-state {
+ padding: 40px 24px;
+ text-align: center;
+ color: hsl(var(--muted-foreground));
+}
+.mpa-cron-error {
+ color: hsl(var(--destructive));
+ line-height: 1.6;
+}
+.mpa-dialog {
+ width: min(680px, calc(100vw - 32px));
+}
+.mpa-editor fieldset {
+ border: 0;
+ padding: 0;
+ margin: 0;
+ display: grid;
+ gap: 16px;
+ min-width: 0;
+}
+.mpa-editor label {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ font-size: 13px;
+}
+.mpa-editor textarea {
+ height: auto;
+ resize: vertical;
+ min-height: 120px;
+}
+.mpa-fields {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+}
+.mpa-editor .mpa-checkbox {
+ flex-direction: row;
+ align-items: center;
+}
+.mpa-details {
+ display: grid;
+ grid-template-columns: 100px 1fr;
+ gap: 16px;
+ margin: 0;
+}
+.mpa-details dt {
+ color: hsl(var(--muted-foreground));
+}
+.mpa-details dd {
+ margin: 0;
+ overflow-wrap: anywhere;
+}
+.mpa-prompt {
+ white-space: pre-wrap;
+ max-height: 220px;
+ overflow: auto;
+}
+.mpa-history-title {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-top: 24px;
+}
+.mpa-history-title h3 {
+ font-size: 15px;
+ font-weight: 500;
+}
+.mpa-detail {
+ width: min(900px, calc(100vw - 32px));
+}
+.mpa-detail table {
+ min-width: 650px;
+}
+.mpa-calendar-bar {
+ margin: 20px 0 12px;
+ gap: 8px;
+}
+.mpa-calendar-bar strong {
+ margin-right: auto;
+ font-size: 17px;
+ font-weight: 500;
+}
+.mpa-calendar-bar span {
+ font-size: 12px;
+ color: hsl(var(--muted-foreground));
+}
+.mpa-calendar {
+ display: grid;
+ grid-template-columns: repeat(7, minmax(0, 1fr));
+ border: 1px solid hsl(var(--border));
+ border-radius: 8px;
+ overflow: hidden;
+}
+.mpa-calendar-weekday {
+ text-align: center;
+ padding: 12px;
+ color: hsl(var(--muted-foreground));
+ background: hsl(var(--muted) / 0.35);
+}
+.mpa-calendar-day {
+ min-height: 110px;
+ padding: 8px;
+ border-top: 1px solid hsl(var(--border));
+ border-right: 1px solid hsl(var(--border));
+ overflow: hidden;
+}
+.mpa-calendar-day:nth-child(7n) {
+ border-right: 0;
+}
+.mpa-calendar-day time {
+ font-variant-numeric: tabular-nums;
+ display: block;
+ margin-bottom: 8px;
+}
+.mpa-calendar-day.is-outside {
+ background: hsl(var(--muted) / 0.3);
+ color: hsl(var(--muted-foreground));
+}
+.mpa-calendar-task {
+ display: block;
+ width: 100%;
+ border: 0;
+ border-radius: 4px;
+ text-align: left;
+ padding: 4px 6px;
+ margin-top: 4px;
+ color: hsl(var(--primary));
+ background: hsl(var(--primary) / 0.07);
+ cursor: pointer;
+ font-size: 12px;
+}
+.mpa-calendar-task span {
+ display: block;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.mpa-cron > p {
+ line-height: 1.6;
+}
+@media (max-width: 760px) {
+ .mpa-toolbar {
+ align-items: stretch;
+ }
+ .mpa-cron-overview {
+ margin-left: 0;
+ }
+ .mpa-cron-search {
+ width: 100%;
+ }
+ .mpa-cron-search input {
+ flex: 1;
+ }
+ .mpa-fields {
+ grid-template-columns: 1fr;
+ }
+ .mpa-calendar-day {
+ min-height: 80px;
+ padding: 4px;
+ }
+ .mpa-calendar-task {
+ padding: 4px 2px;
+ }
+ .mpa-details {
+ grid-template-columns: 1fr;
+ gap: 8px;
+ }
+}
+.mpa-status-filter {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ background: none;
+ border: 0;
+ padding: 0;
+ color: inherit;
+ cursor: pointer;
+ white-space: nowrap;
+ min-height: 28px;
+}
+.mpa-status-filter .icon {
+ width: 14px;
+ height: 14px;
+ color: hsl(var(--muted-foreground));
+}
+.mpa-editor input[type="checkbox"] {
+ appearance: auto;
+ width: 16px;
+ height: 16px;
+ accent-color: hsl(var(--primary));
+}
+.mpa-dialog form {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ overflow: hidden;
+}
+.mpa-dialog .mpa-editor {
+ flex: 1;
+}
diff --git a/frontend/src/cronjobs/MpaCronTasks.tsx b/frontend/src/cronjobs/MpaCronTasks.tsx
new file mode 100644
index 000000000..2eefbc13e
--- /dev/null
+++ b/frontend/src/cronjobs/MpaCronTasks.tsx
@@ -0,0 +1,621 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Menu } from "@openai/apps-sdk-ui/components/Menu";
+import { listMpaCronTasks, requestMpaTask } from "../adk/client";
+import type {
+ MpaCronTask,
+ MpaCronTaskPage,
+ MpaRuntime,
+ TaskFields,
+} from "../adk/mpaCronTasks";
+import { TextShimmer } from "../ui/text-shimmer/TextShimmer";
+import { DialogShell } from "../ui/SandboxControls";
+import { MpaTaskEditor } from "./MpaTaskEditor";
+import { MpaTaskDetail, mpaErrorKey } from "./MpaTaskDetail";
+import { MpaIcon } from "./MpaTaskIcons";
+import {
+ calendarTimes,
+ formatTime,
+ monthDays,
+ runStatuses,
+ scheduleText,
+ taskStatus,
+} from "./mpaSchedule";
+import "../create/CustomCreate.css";
+import "../ui/ProjectPreview.css";
+import "./MpaCronTasks.css";
+
+export function MpaCronTasks({ runtime }: { runtime?: MpaRuntime }) {
+ const { t } = useTranslation("cronjobs");
+ return runtime ? (
+
+ ) : (
+ {t("mpa.selectRuntime")}
+ );
+}
+function RuntimeTasks({ runtime }: { runtime: MpaRuntime }) {
+ const { t, i18n } = useTranslation("cronjobs");
+ const label = (key: string) => t(`mpa.manage.${key}`);
+ const [draftQuery, setDraftQuery] = useState(""),
+ [query, setQuery] = useState(""),
+ [index, setIndex] = useState(0),
+ [refresh, setRefresh] = useState(0);
+ const [page, setPage] = useState(null),
+ [loading, setLoading] = useState(true),
+ [error, setError] = useState(""),
+ [notice, setNotice] = useState("");
+ const [view, setView] = useState("list"),
+ [status, setStatus] = useState("all"),
+ [month, setMonth] = useState(() => new Date());
+ const [editor, setEditor] = useState<{
+ task?: MpaCronTask;
+ copy: boolean;
+ } | null>(null),
+ [detail, setDetail] = useState(null),
+ [deleting, setDeleting] = useState(null);
+ const [busy, setBusy] = useState(false),
+ [writeError, setWriteError] = useState("");
+ const locked = useRef(false),
+ composing = useRef(false);
+ const lifetime = useRef(new AbortController());
+ const tokens = useRef(new Map());
+ useEffect(() => {
+ const controller = new AbortController();
+ lifetime.current = controller;
+ return () => controller.abort();
+ }, []);
+ useEffect(() => {
+ const controller = new AbortController();
+ setLoading(true);
+ setError("");
+ setPage(null);
+ void (async () => {
+ const first = await listMpaCronTasks(
+ runtime,
+ 0,
+ query,
+ controller.signal,
+ );
+ const items = [...first.items];
+ let current = first;
+ let offset = 0;
+ while (current.hasMore) {
+ if (controller.signal.aborted) return;
+ if (
+ current.nextOffset === null ||
+ current.nextOffset <= offset ||
+ items.length > 100000
+ )
+ throw new Error("MPA_INVALID_RESPONSE");
+ offset = current.nextOffset;
+ current = await listMpaCronTasks(
+ runtime,
+ offset,
+ query,
+ controller.signal,
+ );
+ items.push(...current.items);
+ }
+ if (!controller.signal.aborted)
+ setPage({
+ ...first,
+ items: [...new Map(items.map((task) => [task.id, task])).values()],
+ hasMore: false,
+ nextOffset: null,
+ });
+ })()
+ .catch((e) => {
+ if (!controller.signal.aborted) setError(mpaErrorKey(e));
+ })
+ .finally(() => {
+ if (!controller.signal.aborted) setLoading(false);
+ });
+ return () => controller.abort();
+ }, [runtime.runtimeId, runtime.region, query, refresh]);
+ const filtered = useMemo(
+ () =>
+ page?.items.filter(
+ (task) => status === "all" || taskStatus(task) === status,
+ ) || [],
+ [page, status],
+ );
+ const days = useMemo(() => monthDays(month), [month]);
+ const calendar = useMemo(
+ () =>
+ days.map((day) => ({
+ day,
+ tasks: filtered.flatMap((task) => {
+ const entry = calendarTimes(task, day);
+ return entry ? [{ task, ...entry }] : [];
+ }),
+ })),
+ [days, filtered],
+ );
+ const visible = filtered.slice(index, index + 10);
+ function token(key: string) {
+ let value = tokens.current.get(key);
+ if (!value) {
+ value = crypto.randomUUID();
+ tokens.current.set(key, value);
+ }
+ return value;
+ }
+ async function mutate(
+ kind: "create" | "edit" | "delete" | "run" | "toggle",
+ task?: MpaCronTask,
+ fields?: TaskFields,
+ ) {
+ if (locked.current) return;
+ locked.current = true;
+ setBusy(true);
+ setWriteError("");
+ setNotice("");
+ const operation = lifetime.current;
+ const key =
+ kind === "create" ? JSON.stringify(fields) : `${kind}:${task?.id}`;
+ try {
+ const suffix = task
+ ? `/${encodeURIComponent(task.id)}${kind === "run" ? "/run" : ""}`
+ : "";
+ if (
+ (kind === "edit" || kind === "toggle") &&
+ (!Number.isInteger(task?.version) || Number(task?.version) < 1)
+ )
+ throw new Error("MPA_INVALID_RESPONSE");
+ const payload =
+ kind === "delete"
+ ? undefined
+ : kind === "run"
+ ? { clientToken: token(key), mode: "Force" }
+ : kind === "create"
+ ? { ...fields, clientToken: token(key) }
+ : {
+ ...(kind === "toggle" ? { enabled: !task!.enabled } : fields),
+ expectedVersion: task!.version,
+ };
+ const result = await requestMpaTask(
+ runtime,
+ kind === "delete" ? "DELETE" : "POST",
+ suffix,
+ payload,
+ operation.signal,
+ );
+ if (
+ kind === "delete"
+ ? result.removed !== true
+ : kind === "run"
+ ? !result.run
+ : !result.task
+ )
+ throw new Error("MPA_INVALID_RESPONSE");
+ tokens.current.delete(key);
+ if (!operation.signal.aborted) {
+ setEditor(null);
+ setDeleting(null);
+ setDetail(null);
+ setNotice(label(kind === "run" ? "queued" : "saved"));
+ setIndex(0);
+ setRefresh((n) => n + 1);
+ }
+ } catch (e) {
+ if (!operation.signal.aborted) setWriteError(t(mpaErrorKey(e)));
+ } finally {
+ locked.current = false;
+ if (!operation.signal.aborted) setBusy(false);
+ }
+ }
+ function openEditor(task?: MpaCronTask, copy = false) {
+ setWriteError("");
+ setEditor({ task, copy });
+ }
+ return (
+
+
+ {runtime.name}
+
+ {runtime.runtimeId} · {runtime.region}
+
+
+
+
+ {["list", "calendar"].map((mode) => (
+
+ ))}
+
+
+
+
+
+ {page?.overview && (
+
+ {label("overview")}
+
+ {t("mpa.successRate")}{" "}
+ {Math.round(page.overview.successRate * 100)}%
+
+
+ {t("mpa.executionCount")}{" "}
+ {page.overview.executionCount}
+
+
+ )}
+
+ {error && (
+
+ {t(error)}
+
+ )}
+ {writeError && !editor && !deleting && (
+
+ {writeError}
+
+ )}
+ {notice && {notice}
}
+ {loading && (
+
+ {t("mpa.loading")}
+
+ )}
+ {!loading && !error && page && (
+ <>
+ {view === "list" ? (
+ <>
+
+
+
+
+ | {label("name")} |
+ {label("lastRun")} |
+ {label("schedule")} |
+
+
+ |
+ {label("agent")} |
+ {label("actions")} |
+
+
+
+ {visible.map((task) => (
+
+ |
+
+ |
+ {formatTime(task.lastRunAt, i18n.language)} |
+ {scheduleText(task, label, i18n.language)} |
+
+
+ {label(taskStatus(task))}
+
+ |
+ {task.agentId || "—"} |
+
+
+
+
+
+
+ |
+
+ ))}
+
+
+ {filtered.length === 0 && (
+
+ {t("mpa.empty")}
+
+ )}
+
+
+ >
+ ) : (
+ <>
+
+
+ {new Intl.DateTimeFormat(i18n.language, {
+ year: "numeric",
+ month: "long",
+ }).format(month)}
+
+
+ {label("calendarZone")}{" "}
+ {Intl.DateTimeFormat().resolvedOptions().timeZone}
+
+
+
+
+
+
+ {days.slice(0, 7).map((d) => (
+
+ {new Intl.DateTimeFormat(i18n.language, {
+ weekday: "short",
+ }).format(d)}
+
+ ))}
+ {calendar.map(({ day, tasks }) => (
+
+
+ {tasks.map(({ task, at, count, nextOnly }) => (
+
+ ))}
+
+ ))}
+
+ {label("calendarHint")}
+ >
+ )}
+ >
+ )}
+ {editor && (
+ setEditor(null)}
+ onSave={(fields) =>
+ void mutate(
+ editor.task && !editor.copy ? "edit" : "create",
+ editor.copy ? undefined : editor.task,
+ fields,
+ )
+ }
+ />
+ )}
+ {detail && (
+ setDetail(null)}
+ />
+ )}
+ {deleting && (
+ }
+ busy={busy}
+ onClose={() => setDeleting(null)}
+ >
+
+
{t("mpa.manage.deleteConfirm", { name: deleting.name })}
+ {writeError && (
+
+ {writeError}
+
+ )}
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/cronjobs/MpaTaskDetail.tsx b/frontend/src/cronjobs/MpaTaskDetail.tsx
new file mode 100644
index 000000000..af177bb03
--- /dev/null
+++ b/frontend/src/cronjobs/MpaTaskDetail.tsx
@@ -0,0 +1,147 @@
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { listMpaRuns } from "../adk/client";
+import type { MpaCronTask, MpaRunPage, MpaRuntime } from "../adk/mpaCronTasks";
+import { DialogShell } from "../ui/SandboxControls";
+import { TextShimmer } from "../ui/text-shimmer/TextShimmer";
+import { formatTime, scheduleText } from "./mpaSchedule";
+import { MpaIcon } from "./MpaTaskIcons";
+export function mpaErrorKey(error: unknown): string {
+ const message = error instanceof Error ? error.message : "";
+ return (
+ (
+ {
+ MPA_HTTP_401: "mpa.authRequired",
+ MPA_HTTP_403: "mpa.forbidden",
+ MPA_HTTP_404: "mpa.unsupported",
+ MPA_HTTP_409: "mpa.manage.conflict",
+ MPA_HTTP_422: "mpa.manage.invalid",
+ MPA_INVALID_RESPONSE: "mpa.invalidResponse",
+ } as Record
+ )[message] || "mpa.loadFailed"
+ );
+}
+export function MpaTaskDetail({
+ task,
+ runtime,
+ onClose,
+}: {
+ task: MpaCronTask;
+ runtime: MpaRuntime;
+ onClose: () => void;
+}) {
+ const { t, i18n } = useTranslation("cronjobs");
+ const label = (key: string) => t(`mpa.manage.${key}`);
+ const [offset, setOffset] = useState(0),
+ [refresh, setRefresh] = useState(0),
+ [page, setPage] = useState(null),
+ [error, setError] = useState(""),
+ [loading, setLoading] = useState(true);
+ useEffect(() => {
+ const controller = new AbortController();
+ setLoading(true);
+ setError("");
+ setPage(null);
+ void listMpaRuns(runtime, task.id, offset, controller.signal)
+ .then((data) => {
+ if (!controller.signal.aborted) setPage(data);
+ })
+ .catch((e) => {
+ if (!controller.signal.aborted) setError(mpaErrorKey(e));
+ })
+ .finally(() => {
+ if (!controller.signal.aborted) setLoading(false);
+ });
+ return () => controller.abort();
+ }, [runtime.runtimeId, runtime.region, task.id, offset, refresh]);
+ return (
+ }
+ className="mpa-dialog mpa-detail"
+ onClose={onClose}
+ >
+
+
+ - {label("agent")}
+ - {task.agentId || "—"}
+ - {label("schedule")}
+ - {scheduleText(task, label, i18n.language)}
+ - {t("mpa.columns.next")}
+ - {formatTime(task.nextRunAt, i18n.language)}
+ - {label("prompt")}
+ - {task.prompt}
+
+
+
{label("history")}
+
+
+ {error && (
+
+ {t(error)}
+
+ )}
+ {loading &&
{t("mpa.loading")}}
+ {page && (
+ <>
+
+
+
+
+ {["lastRun", "lastStatus", "duration", "result"].map(
+ (k) => (
+ | {label(k)} |
+ ),
+ )}
+
+
+
+ {page.items.map((run) => (
+
+ |
+ {formatTime(
+ run.startedAt || run.scheduledAt,
+ i18n.language,
+ )}
+ |
+ {label(run.status)} |
+
+ {run.durationMs == null ? "—" : `${run.durationMs} ms`}
+ |
+ {run.errorMessage || run.sessionId || "—"} |
+
+ ))}
+
+
+
+ {page.items.length === 0 &&
{label("noRuns")}
}
+
+ >
+ )}
+
+
+ );
+}
diff --git a/frontend/src/cronjobs/MpaTaskEditor.tsx b/frontend/src/cronjobs/MpaTaskEditor.tsx
new file mode 100644
index 000000000..321b5be84
--- /dev/null
+++ b/frontend/src/cronjobs/MpaTaskEditor.tsx
@@ -0,0 +1,346 @@
+import { useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { DialogShell } from "../ui/SandboxControls";
+import { DeploymentSelect } from "../ui/DeploymentSelect";
+import type { MpaCronTask, TaskFields } from "../adk/mpaCronTasks";
+import { scheduleTypes } from "./mpaSchedule";
+import { MpaIcon } from "./MpaTaskIcons";
+
+export function MpaTaskEditor({
+ task,
+ copy,
+ busy,
+ error,
+ onClose,
+ onSave,
+}: {
+ task?: MpaCronTask;
+ copy: boolean;
+ busy: boolean;
+ error: string;
+ onClose: () => void;
+ onSave: (fields: TaskFields) => void;
+}) {
+ const { t } = useTranslation("cronjobs");
+ const label = (key: string) => t(`mpa.manage.${key}`);
+ const initial = task?.schedule;
+ const [name, setName] = useState(
+ task ? task.name + (copy ? ` (${label("copy")})` : "") : "",
+ );
+ const [prompt, setPrompt] = useState(task?.prompt || "");
+ const [type, setType] = useState(initial?.type || "Daily");
+ const [zone, setZone] = useState(
+ String(initial?.timezone || "Asia/Shanghai"),
+ );
+ const [time, setTime] = useState(String(initial?.time || "09:00"));
+ const [date, setDate] = useState(() => {
+ if (!initial?.runAt) return "";
+ const d = new Date(String(initial.runAt));
+ return new Date(d.getTime() - d.getTimezoneOffset() * 60000)
+ .toISOString()
+ .slice(0, 16);
+ });
+ const [interval, setInterval] = useState(
+ String(initial?.intervalSeconds || 3600),
+ );
+ const [days, setDays] = useState(String(initial?.weekdays || "1,2,3,4,5"));
+ const [monthDays, setMonthDays] = useState(String(initial?.monthDays || "1"));
+ const [cron, setCron] = useState(
+ String(initial?.cronExpression || "0 9 * * *"),
+ );
+ const [enabled, setEnabled] = useState(task?.enabled ?? true);
+ const [channel, setChannel] = useState(
+ String(task?.delivery?.channel || "Web"),
+ );
+ const [target, setTarget] = useState(String(task?.delivery?.targetId || ""));
+ const [validation, setValidation] = useState("");
+ const composing = useRef(false);
+ const nameRef = useRef(null);
+ function save() {
+ if (busy || composing.current) return;
+ try {
+ new Intl.DateTimeFormat("en-US", { timeZone: zone });
+ } catch {
+ setValidation(label("timezoneInvalid"));
+ return;
+ }
+ const schedule: TaskFields["schedule"] = { type, timezone: zone };
+ if (type === "Once") {
+ const at = Date.parse(date);
+ if (!Number.isFinite(at) || at <= Date.now()) {
+ setValidation(label("futureTime"));
+ return;
+ }
+ schedule.runAt = new Date(at).toISOString();
+ } else if (type === "Interval") {
+ const seconds = Number(interval);
+ if (!Number.isInteger(seconds) || seconds < 30 || seconds > 31536000) {
+ setValidation(label("intervalInvalid"));
+ return;
+ }
+ schedule.intervalSeconds = seconds;
+ if (initial?.type === type && initial.anchorAt)
+ schedule.anchorAt = initial.anchorAt;
+ } else if (type === "Cron") {
+ if (cron.trim().split(/\s+/).length !== 5) {
+ setValidation(t("validation.cronFields"));
+ return;
+ }
+ schedule.cronExpression = cron.trim();
+ } else {
+ schedule.time = time;
+ if (type === "Weekly" || type === "Monthly") {
+ const value = (type === "Weekly" ? days : monthDays)
+ .split(",")
+ .map(Number);
+ const max = type === "Weekly" ? 7 : 31;
+ if (
+ !value.length ||
+ value.some((n) => !Number.isInteger(n) || n < 1 || n > max) ||
+ new Set(value).size !== value.length
+ ) {
+ setValidation(label("daysInvalid"));
+ return;
+ }
+ schedule[type === "Weekly" ? "weekdays" : "monthDays"] = value;
+ }
+ }
+ if (
+ !name.trim() ||
+ !prompt.trim() ||
+ (channel === "Feishu" && !target.trim())
+ ) {
+ setValidation(label("required"));
+ return;
+ }
+ const delivery =
+ task?.delivery?.channel === channel &&
+ String(task.delivery.targetId || "") === target
+ ? task.delivery
+ : {
+ channel,
+ targetId: channel === "Web" ? null : target.trim(),
+ receiveIdType: "chat_id",
+ bestEffort: true,
+ };
+ setValidation("");
+ onSave({
+ name: name.trim(),
+ ...(task?.agentId ? { agentId: task.agentId } : {}),
+ prompt: prompt.trim(),
+ enabled,
+ schedule,
+ delivery,
+ jitterSeconds: task?.jitterSeconds ?? 0,
+ timeoutSeconds: task?.timeoutSeconds ?? 3600,
+ });
+ }
+ return (
+ }
+ className="mpa-dialog"
+ initialFocusRef={nameRef}
+ busy={busy}
+ onClose={onClose}
+ >
+
+
+ );
+}
diff --git a/frontend/src/cronjobs/MpaTaskIcons.tsx b/frontend/src/cronjobs/MpaTaskIcons.tsx
new file mode 100644
index 000000000..2d37d3beb
--- /dev/null
+++ b/frontend/src/cronjobs/MpaTaskIcons.tsx
@@ -0,0 +1,31 @@
+export function MpaIcon({
+ kind,
+}: {
+ kind:
+ "plus" | "play" | "edit" | "more" | "detail" | "copy" | "delete" | "filter";
+}) {
+ const paths = {
+ filter: "M4 5h16l-6 7v7l-4-2v-5Z",
+ plus: "M12 5v14M5 12h14",
+ play: "M8 5l11 7-11 7Z",
+ edit: "m15 4 5 5-11 11H4v-5ZM13 6l5 5",
+ more: "M5 12h.01M12 12h.01M19 12h.01",
+ detail: "M7 3h10l3 3v15H4V3ZM8 9h8M8 13h8M8 17h5",
+ copy: "M9 8V3h12v13h-5M3 8h13v13H3Z",
+ delete: "M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7M14 10v7",
+ };
+ return (
+
+ );
+}
diff --git a/frontend/src/cronjobs/mpaSchedule.ts b/frontend/src/cronjobs/mpaSchedule.ts
new file mode 100644
index 000000000..00d60953a
--- /dev/null
+++ b/frontend/src/cronjobs/mpaSchedule.ts
@@ -0,0 +1,185 @@
+import type { MpaCronTask } from "../adk/mpaCronTasks";
+
+export const scheduleTypes = [
+ "Once",
+ "Interval",
+ "Daily",
+ "Weekly",
+ "Monthly",
+ "Cron",
+] as const;
+export const runStatuses = [
+ "Queued",
+ "Running",
+ "Succeeded",
+ "Failed",
+ "TimedOut",
+ "Cancelled",
+ "Skipped",
+] as const;
+export function taskStatus(task: MpaCronTask): string {
+ return task.runningAt ? "Running" : task.lastRunStatus || "Pending";
+}
+export function formatTime(
+ value: unknown,
+ locale: string,
+ zone?: string,
+): string {
+ if (typeof value !== "string" || !Number.isFinite(Date.parse(value)))
+ return "—";
+ return new Intl.DateTimeFormat(locale, {
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ timeZone: zone,
+ }).format(new Date(value));
+}
+export function scheduleText(
+ task: MpaCronTask,
+ label: (key: string) => string,
+ locale: string,
+): string {
+ const s = task.schedule;
+ const zone = typeof s.timezone === "string" ? s.timezone : "Asia/Shanghai";
+ const type = s.type;
+ const value =
+ type === "Once"
+ ? formatTime(s.runAt, locale, zone)
+ : type === "Interval"
+ ? `${s.intervalSeconds} ${label("seconds")}`
+ : type === "Cron"
+ ? String(s.cronExpression)
+ : `${s.time}${type === "Weekly" ? ` · ${String(s.weekdays)}` : type === "Monthly" ? ` · ${String(s.monthDays)}` : ""}`;
+ return `${label(type)} · ${value} (${zone})`;
+}
+export function monthDays(month: Date): Date[] {
+ const start = new Date(month.getFullYear(), month.getMonth(), 1);
+ start.setDate(start.getDate() - ((start.getDay() + 6) % 7));
+ return Array.from(
+ { length: 42 },
+ (_, i) =>
+ new Date(start.getFullYear(), start.getMonth(), start.getDate() + i),
+ );
+}
+const parts = (date: Date, zone: string) =>
+ Object.fromEntries(
+ new Intl.DateTimeFormat("en-US", {
+ timeZone: zone,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ hourCycle: "h23",
+ })
+ .formatToParts(date)
+ .map((p) => [p.type, p.value]),
+ );
+// Convert wall time using candidate offsets on both sides of a possible DST transition.
+function wallTimes(
+ y: number,
+ m: number,
+ d: number,
+ h: number,
+ min: number,
+ zone: string,
+): number[] {
+ const wall = Date.UTC(y, m - 1, d, h, min);
+ const candidates = new Set();
+ for (const delta of [-86400000, 0, 86400000]) {
+ const base = wall + delta;
+ const p = parts(new Date(base), zone);
+ const offset =
+ Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute) - base;
+ const at = wall - offset;
+ const actual = parts(new Date(at), zone);
+ if (
+ +actual.year === y &&
+ +actual.month === m &&
+ +actual.day === d &&
+ +actual.hour === h &&
+ +actual.minute === min
+ )
+ candidates.add(at);
+ }
+ return [...candidates];
+}
+export function calendarTimes(
+ task: MpaCronTask,
+ day: Date,
+): { at: number; count: number; nextOnly: boolean } | null {
+ const start = day.getTime();
+ const end = new Date(
+ day.getFullYear(),
+ day.getMonth(),
+ day.getDate() + 1,
+ ).getTime();
+ const s = task.schedule;
+ const zone = String(s.timezone || "Asia/Shanghai");
+ const created = Date.parse(task.createdAt || "") || -Infinity;
+ const within = (at: number) => at >= Math.max(start, created) && at < end;
+ if (s.type === "Once") {
+ const at = Date.parse(String(s.runAt));
+ return within(at) ? { at, count: 1, nextOnly: false } : null;
+ }
+ if (!task.enabled) return null;
+ if (s.type === "Interval") {
+ const anchor = Date.parse(String(s.anchorAt || task.nextRunAt));
+ const step = Number(s.intervalSeconds) * 1000;
+ if (!Number.isFinite(anchor) || !Number.isFinite(step) || step < 30000)
+ return null;
+ const at =
+ anchor +
+ Math.max(0, Math.ceil((Math.max(start, created) - anchor) / step)) * step;
+ return within(at)
+ ? { at, count: Math.ceil((end - at) / step), nextOnly: false }
+ : null;
+ }
+ let time = String(s.time);
+ let weekdays = s.weekdays as number[] | undefined;
+ let dates = s.monthDays as number[] | undefined;
+ if (s.type === "Cron") {
+ const f = String(s.cronExpression).trim().split(/\s+/);
+ if (
+ f.length !== 5 ||
+ !/^\d+$/.test(f[0]) ||
+ +f[0] > 59 ||
+ !/^\d+$/.test(f[1]) ||
+ +f[1] > 23 ||
+ f[3] !== "*" ||
+ !/^(\*|\d+(,\d+)*)$/.test(f[2]) ||
+ !/^(\*|\d+(,\d+)*)$/.test(f[4]) ||
+ (f[2] !== "*" && f[4] !== "*")
+ ) {
+ const at = Date.parse(task.nextRunAt || "");
+ return within(at) ? { at, count: 1, nextOnly: true } : null;
+ }
+ time = `${f[1]}:${f[0]}`;
+ dates = f[2] === "*" ? undefined : f[2].split(",").map(Number);
+ weekdays =
+ f[4] === "*"
+ ? undefined
+ : f[4].split(",").map((n) => (+n === 0 ? 7 : +n));
+ }
+ const [h, min] = time.split(":").map(Number);
+ if (!Number.isFinite(h) || !Number.isFinite(min)) return null;
+ const found: number[] = [];
+ for (let delta = -1; delta <= 1; delta++) {
+ const p = parts(new Date(start + delta * 86400000), zone);
+ const y = +p.year,
+ m = +p.month,
+ d = +p.day;
+ const weekday = new Date(Date.UTC(y, m - 1, d)).getUTCDay() || 7;
+ if (
+ (weekdays && !weekdays.includes(weekday)) ||
+ (dates && !dates.includes(d))
+ )
+ continue;
+ found.push(...wallTimes(y, m, d, h, min, zone).filter(within));
+ }
+ const unique = [...new Set(found)].sort((a, b) => a - b);
+ return unique.length
+ ? { at: unique[0], count: unique.length, nextOnly: false }
+ : null;
+}
diff --git a/frontend/src/i18n/resources/en-US/adk.json b/frontend/src/i18n/resources/en-US/adk.json
index c47fc09e6..003059cc0 100644
--- a/frontend/src/i18n/resources/en-US/adk.json
+++ b/frontend/src/i18n/resources/en-US/adk.json
@@ -42,7 +42,8 @@
"resourceCollectionExpiredHint": "Tip: This resource collection has expired. Send the task again so the system can collect the resources before creating the Agent.",
"networkConfigurationHint": "Tip: Try again later, and review the Runtime, model, or gateway logs.",
"modelQuotaHint": "Tip: The model has reached its TPM/RPM quota. Try again later or increase the model quota.",
- "rawResponseLabel": "Raw response: "
+ "rawResponseLabel": "Raw response: ",
+ "copySecretFailed": "Failed to copy the secret value"
},
"runtimeLogs": {
"httpStatus": "HTTP status: {{status}}",
@@ -480,11 +481,22 @@
"publishing": "Publishing the service",
"evaluating": "Creating evaluation sets",
"updating": "Updating the Runtime configuration",
+ "applyingMpaProfile": "Applying the Agent profile to MPA Runtime",
+ "runningMpaSmoke": "Verifying the MPA Runtime execution path",
+ "mpaReady": "MPA Runtime is ready",
"completing": "Finalizing deployment",
"github": "Configuring GitHub delivery",
"inProgress": "Deployment in progress"
},
"deploymentFailed": "Deployment failed",
+ "listSessionsFailed": "Failed to load sessions",
+ "mpaOperationFailed": "Failed to apply the MPA Runtime profile",
+ "mpaOperationLoadFailed": "Failed to load MPA Runtime operations",
+ "mpaRuntimeLegacyAuthUnsupported": "This MPA Runtime uses an older authentication adapter and cannot list Studio sessions. Update the Runtime image to the current MPA AgentKit build, then refresh this Agent.\n{{detail}}",
+ "mpaRuntimeSessionsLoadFailed": "Failed to load MPA Runtime sessions",
+ "mpaDeletePreviewFailed": "Failed to load MPA delete preview",
+ "loadMpaDiagnosticsFailed": "Failed to load MPA Runtime diagnostics",
+ "mpaExecutionConfigFailed": "Failed to update the MPA session configuration",
"deploymentDisconnected": "Deployment failed: connection interrupted",
"deploymentMissingAgentName": "Deployment failed: Agent name missing from response",
"deploymentMissingConnection": "Deployment failed: AgentKit connection information missing from response",
diff --git a/frontend/src/i18n/resources/en-US/automations.json b/frontend/src/i18n/resources/en-US/automations.json
index e6bc9d65d..0395fb9d4 100644
--- a/frontend/src/i18n/resources/en-US/automations.json
+++ b/frontend/src/i18n/resources/en-US/automations.json
@@ -88,6 +88,10 @@
"badge": "Beta",
"description": "Create a Feishu bot and connect its messages directly to AgentKit Runtime."
},
+ "mpa-channels": {
+ "name": "MPA agent messaging channels",
+ "description": "Connect an existing MPA agent to Feishu, WeCom and DingTalk."
+ },
"website-integration": {
"name": "Website integration",
"description": "Embed an AgentKit Runtime on your website as a floating chat window."
@@ -195,6 +199,24 @@
"noFiles": "No previewable files."
}
},
+ "mpaChannels": {
+ "noDescription": "No description",
+ "unknownCreator": "Unknown creator",
+ "optionMetadata": "{{creator}} | {{createdAt}}",
+ "selectAgent": "Select an MPA agent",
+ "chooseAgent": "Choose an MPA agent",
+ "search": "Search by name, Runtime ID or region",
+ "noMatches": "No matching MPA agents",
+ "loading": "Loading MPA agents…",
+ "empty": "No manageable MPA agents. Create or deploy an MPA agent first.",
+ "unauthorized": "Studio administrator access is required to manage MPA messaging channels. Contact an administrator.",
+ "loadFailed": "Could not load MPA agents. Please retry.",
+ "retry": "Retry",
+ "refresh": "Refresh list",
+ "loadMore": "Load more",
+ "moreAvailable": "More agents are available. Search matches loaded results; load more to continue.",
+ "chooseHint": "Select an MPA agent to configure Feishu, WeCom and DingTalk."
+ },
"feishu": {
"title": "Feishu bot",
"description": "Create a Feishu Agent powered by AgentKit Runtime",
diff --git a/frontend/src/i18n/resources/en-US/conversation.json b/frontend/src/i18n/resources/en-US/conversation.json
index 01068fec0..093c771e0 100644
--- a/frontend/src/i18n/resources/en-US/conversation.json
+++ b/frontend/src/i18n/resources/en-US/conversation.json
@@ -50,6 +50,7 @@
"connectingDescription": "Establishing a secure log stream through the Studio BFF.",
"emptyTitle": "No logs yet",
"emptyDescription": "Connected to the instance and waiting for new log output.",
+ "download": "Download logs",
"retention": "Logs refresh automatically. Only the latest {{count}} lines are kept."
},
"trace": {
@@ -102,11 +103,18 @@
"downloadFormat": "Download {{format}}"
},
"blocks": {
+ "a2a": {
+ "connecting": "Waiting for Runtime response…",
+ "submitted": "Task queued…",
+ "working": "Task running…"
+ },
"unsupportedComponent": "Unsupported component: {{component}}",
"sandboxIdentity": "Codex Sandbox execution identifiers",
"useSkill": "Use the {{name}} skill",
- "thinkingDone": "Finished thinking",
- "thinking": "Thinking",
+ "thinkingDone": "Finished working thought",
+ "thinking": "Working thought",
+ "reasoningDone": "Finished model reasoning",
+ "reasoning": "Model reasoning",
"justNow": "Just now",
"sourceUnavailable": "The generated source is temporarily unavailable. Please try again later.",
"downloadStarted": "Download started",
@@ -235,6 +243,79 @@
"webSearch": { "running": "Searching the web", "completed": "Web search complete", "failed": "Web search did not complete" },
"errorDetail": "Codex execution did not complete.",
"errorTitle": "Codex encountered an error"
+ },
+ "toolActivity": {
+ "status": {
+ "queued": "Queued",
+ "running": "Running",
+ "completed": "Completed",
+ "failed": "Failed"
+ },
+ "goal": {
+ "queued": "Waiting to create goal",
+ "running": "Creating goal",
+ "completed": "Created goal",
+ "failed": "Failed to create goal"
+ },
+ "command": {
+ "queued": "Waiting to run command",
+ "running": "Running command",
+ "completed": "Ran command",
+ "failed": "Command failed"
+ },
+ "read": {
+ "queued": "Waiting to read",
+ "running": "Reading",
+ "completed": "Read content",
+ "failed": "Read failed"
+ },
+ "search": {
+ "queued": "Waiting to search",
+ "running": "Searching",
+ "completed": "Search complete",
+ "failed": "Search failed"
+ },
+ "file-change": {
+ "queued": "Waiting to change files",
+ "running": "Changing files",
+ "completed": "Changed files",
+ "failed": "File change failed"
+ },
+ "mcp": {
+ "queued": "Waiting to call external tool",
+ "running": "Calling external tool",
+ "completed": "Called external tool",
+ "failed": "External tool call failed"
+ },
+ "authorization": {
+ "queued": "Waiting for authorization",
+ "running": "Waiting for authorization",
+ "completed": "Authorization complete",
+ "failed": "Authorization failed"
+ },
+ "generic": {
+ "queued": "Waiting to call tool",
+ "running": "Calling tool",
+ "completed": "Called tool",
+ "failed": "Tool call failed",
+ "named": {
+ "queued": "Waiting to call {{tool}}",
+ "running": "Calling {{tool}}",
+ "completed": "Called {{tool}}",
+ "failed": "{{tool}} call failed"
+ }
+ },
+ "exitCode": "exit {{code}}",
+ "omittedLines": "… {{count}} lines omitted …",
+ "omittedCharacters": "… {{count}} characters omitted …",
+ "rawData": "View raw data",
+ "callId": "Call ID",
+ "copyCallId": "Copy call ID",
+ "copy": "Copy",
+ "copied": "Copied",
+ "copyFailed": "Copy failed. Try again.",
+ "explored_one": "Explored 1 item",
+ "explored_other": "Explored {{count}} items"
}
},
"tokenUsage": {
@@ -259,6 +340,16 @@
"summaryTokens": "{{used}} used, {{remaining}} remaining, {{total}} total",
"overflow": "Context exceeded by {{count}} tokens",
"title": "Context usage",
+ "category": "Category",
+ "currentTurn": "Current Turn",
+ "sessionTotal": "Session total",
+ "details": {
+ "input": "Input",
+ "output": "Output",
+ "reasoning": "Reasoning",
+ "cache": "Cache hit",
+ "total": "Total"
+ },
"unknownModel": "The context window for this model is not available",
"unknownRuntime": "The current runtime did not provide model information"
},
@@ -275,8 +366,8 @@
"connecting": "Connecting…",
"connect": "Connect and add"
},
- "composer": { "placeholder": "Type a message…", "inputAria": "Message", "generating": "Generating", "send": "Send" },
+ "composer": { "placeholder": "Type a message…", "inputAria": "Message", "generating": "Generating", "send": "Send", "model": "Model" },
"invocation": { "ariaLabel": "Invocation context for this turn", "removeSkill": "Remove skill {{name}}", "removeAgent": "Remove agent {{name}}" },
"visualization": { "cardAria": "{{label}} chart", "viewAria": "{{label}} display mode", "preview": "Preview", "code": "Code", "invalidEcharts": "The ECharts configuration is not a valid, safe data object. Switch to Code to inspect it.", "renderFailed": "The chart cannot be rendered right now. Switch to Code to inspect it.", "echartsAria": "ECharts preview", "rendering": "Rendering chart…", "mermaidFailed": "The chart cannot be rendered right now. Switch to Code to inspect the Mermaid source.", "mermaidAria": "Mermaid preview" }
- ,"markdown": { "playVideo": "Play video: {{name}}", "enlargeImage": "Enlarge image preview: {{name}}", "image": "image", "enlargeVideo": "Enlarge video", "videoPreview": "Video preview", "downloadVideo": "Download video", "close": "Close" }
+ ,"markdown": { "downloadFile": "Download file", "downloading": "Downloading…", "downloadFailed": "Download failed. Check that the Sandbox and file are still available, then click to retry.", "playVideo": "Play video: {{name}}", "enlargeImage": "Enlarge image preview: {{name}}", "image": "image", "enlargeVideo": "Enlarge video", "videoPreview": "Video preview", "downloadVideo": "Download video", "close": "Close" }
}
diff --git a/frontend/src/i18n/resources/en-US/create.json b/frontend/src/i18n/resources/en-US/create.json
index fb1961007..2c8bd39b0 100644
--- a/frontend/src/i18n/resources/en-US/create.json
+++ b/frontend/src/i18n/resources/en-US/create.json
@@ -595,7 +595,9 @@
"actions": {
"updateAgain": "Update again",
"deployAgain": "Deploy again",
- "updateAndPublish": "Update and publish"
+ "updateAndPublish": "Update and publish",
+ "applyProfile": "Apply Profile",
+ "applyProfileAgain": "Apply Profile again"
}
},
"traditional": {
@@ -1157,6 +1159,11 @@
"generatingConfiguration": "Generating deployment configuration",
"runtimeNameExists": "This Runtime name already exists. Choose another name and try again.",
"preparing": "Preparing deployment",
+ "applyingMpaProfile": "Applying the Agent profile to MPA Runtime",
+ "mpaProfileApplied": "MPA Runtime profile applied",
+ "runningMpaSmoke": "Verifying the MPA Runtime execution path",
+ "mpaReady": "MPA Runtime is ready",
+ "mpaOperationIncomplete": "MPA Runtime setup did not complete. Retry this deployment task.",
"complete": "Deployment complete",
"failed": "Deployment failed",
"updateAndPublish": "Update and publish",
@@ -1164,6 +1171,9 @@
"build": "Build image",
"deploy": "Deploy Runtime",
"publish": "Publish service",
+ "applyMpaProfile": "Apply MPA profile",
+ "verifyMpaRuntime": "Verify MPA Runtime",
+ "mpaReady": "MPA Runtime ready",
"running": "Deploying"
}
},
diff --git a/frontend/src/i18n/resources/en-US/cronjobs.json b/frontend/src/i18n/resources/en-US/cronjobs.json
index 248030a91..4ff5864cb 100644
--- a/frontend/src/i18n/resources/en-US/cronjobs.json
+++ b/frontend/src/i18n/resources/en-US/cronjobs.json
@@ -1,4 +1,111 @@
{
+ "mpa": {
+ "source": "Task source",
+ "studio": "Studio scheduled tasks",
+ "title": "Runtime scheduled tasks",
+ "selectRuntime": "Select a cloud Runtime from the Agent list first.",
+ "scope": "Scheduled tasks for the current Studio user in this Runtime.",
+ "refresh": "Refresh",
+ "loading": "Loading Runtime tasks",
+ "empty": "No matching scheduled tasks in this Runtime.",
+ "authRequired": "Runtime rejected the request. Check gateway access and the Runtime authentication setting.",
+ "forbidden": "The current user cannot access this Runtime.",
+ "unsupported": "This Runtime does not provide the MPA scheduled tasks API.",
+ "invalidResponse": "The tasks API returned unrecognized data. Check the Runtime version.",
+ "loadFailed": "Could not load tasks. Check the network and Runtime status, then refresh.",
+ "pagination": "Runtime task pagination",
+ "total": "{{total}} tasks",
+ "previous": "Previous",
+ "next": "Next",
+ "columns": {
+ "name": "Task name",
+ "enabled": "Status",
+ "schedule": "Schedule",
+ "next": "Next run",
+ "last": "Latest result"
+ },
+ "schedule": {
+ "once": "Once",
+ "interval": "Interval",
+ "daily": "Daily",
+ "weekly": "Weekly",
+ "monthly": "Monthly",
+ "cron": "Cron"
+ },
+ "search": "Search tasks",
+ "executionCount": "Executions",
+ "successRate": "Success rate",
+ "prompt": "Task content",
+ "manage": {
+ "view": "Task view",
+ "list": "List",
+ "calendar": "Calendar",
+ "create": "Create scheduled task",
+ "edit": "Edit task",
+ "copy": "Copy",
+ "delete": "Delete",
+ "detail": "Details",
+ "run": "Run now",
+ "name": "Task",
+ "lastRun": "Last execution",
+ "schedule": "Schedule",
+ "lastStatus": "Last execution status",
+ "agent": "Executing Agent",
+ "actions": "Actions",
+ "all": "All statuses",
+ "Pending": "Not run yet",
+ "Queued": "Queued",
+ "Running": "Running",
+ "Succeeded": "Succeeded",
+ "Failed": "Failed",
+ "TimedOut": "Timed out",
+ "Cancelled": "Cancelled",
+ "Skipped": "Skipped",
+ "overview": "Execution statistics:",
+ "prompt": "Task content",
+ "timezone": "Time zone",
+ "localTime": "Execution time (device time zone)",
+ "time": "Time",
+ "Once": "Once",
+ "Interval": "Interval",
+ "Daily": "Daily",
+ "Weekly": "Weekly",
+ "Monthly": "Monthly",
+ "Cron": "Cron expression",
+ "seconds": "seconds",
+ "interval": "Interval in seconds",
+ "weekdays": "Weekdays (1 = Monday, 7 = Sunday; comma separated)",
+ "monthDays": "Days of month (1–31; comma separated)",
+ "delivery": "Delivery",
+ "Web": "Web",
+ "Feishu": "Feishu",
+ "target": "Feishu recipient ID",
+ "enabled": "Enable task",
+ "cancel": "Cancel",
+ "save": "Save",
+ "saving": "Saving",
+ "required": "Enter a task name, Agent ID, prompt and delivery target.",
+ "timezoneInvalid": "Enter a valid IANA time zone, such as Asia/Shanghai.",
+ "futureTime": "Choose a future execution time.",
+ "intervalInvalid": "Interval must be an integer between 30 and 31536000 seconds.",
+ "daysInvalid": "Enter valid, unique days.",
+ "history": "Execution history",
+ "duration": "Duration",
+ "result": "Result / session",
+ "noRuns": "No executions yet.",
+ "conflict": "The task changed or the request conflicts. Close the dialog, refresh and retry.",
+ "invalid": "Request validation failed. Check user identity, execution time, Agent ID and task settings.",
+ "queued": "Execution request submitted.",
+ "saved": "Task updated.",
+ "deleteConfirm": "Delete “{{name}}”? It will no longer be scheduled.",
+ "calendarZone": "Display time zone:",
+ "prevMonth": "Previous month",
+ "nextMonth": "Next month",
+ "today": "Today",
+ "nextOnly": "Next only",
+ "calendarHint": "The calendar shows expected executions. Complex Cron expressions show only the next execution returned by the server."
+ }
+ },
"actions": {
"backToList": "Back to scheduled tasks",
"cancel": "Cancel",
diff --git a/frontend/src/i18n/resources/en-US/newChat.json b/frontend/src/i18n/resources/en-US/newChat.json
index 47d1f71d6..d0136c11e 100644
--- a/frontend/src/i18n/resources/en-US/newChat.json
+++ b/frontend/src/i18n/resources/en-US/newChat.json
@@ -55,6 +55,7 @@
"types": {
"agent": "Agent",
"general": "General Agent",
+ "mpa": "MPA Agent",
"codex": "Codex Agent",
"deepseekHarness": "DeepSeek Harness",
"openclaw": "OpenClaw Agent",
diff --git a/frontend/src/i18n/resources/en-US/ui.json b/frontend/src/i18n/resources/en-US/ui.json
index 6cc716567..16d1552fc 100644
--- a/frontend/src/i18n/resources/en-US/ui.json
+++ b/frontend/src/i18n/resources/en-US/ui.json
@@ -124,11 +124,15 @@
"usageOverview": "Usage overview",
"sections": {
"basic": "Basic information",
+ "profileConfig": "Profile configuration",
+ "sessionConfig": "Session configuration",
"usage": "Usage overview",
+ "diagnostics": "Diagnostics",
"evaluations": "Evaluations",
"optimizations": "Optimizations",
"integrations": "Integrations",
- "versions": "Versions"
+ "versions": "Versions",
+ "channels": "Message channels"
},
"evaluationGroup": "Evaluation group",
"optimizations": "Optimization suggestions",
@@ -188,6 +192,79 @@
"deploymentConfig": "Deployment configuration",
"deploymentConfigDescription": "Review instance and runtime settings before updating the Runtime.",
"deploymentRegion": "Deployment region",
+ "mpaControlPlane": "MPA control plane",
+ "loadingMpaAgentView": "Loading MPA Runtime binding…",
+ "mpaRuntimeBound": "This MPA Agent is bound to one Runtime and ready for management.",
+ "mpaRuntimeMissing": "No visible MPA Runtime is bound to this Agent.",
+ "mpaRuntimeBindingAmbiguous": "Multiple MPA Runtimes match this Agent. Resolve the binding before making changes.",
+ "mpaRuntimeOrphan": "The Runtime exists, but it does not contain this MPA Profile.",
+ "mpaRuntimeLegacyAuthUnsupported": "This Runtime uses an older MPA image that still requires X-Jwt-Token. Update it to the current AgentKit-compatible image before managing Profile or Session settings.",
+ "profileConfig": "Profile configuration",
+ "profileConfigTitle": "Profile configuration",
+ "profileConfigDescription": "Review and apply the Agent-level MPA Profile. The Profile is the Runtime default configuration inherited by new Sessions.",
+ "profileStatus": "Profile status",
+ "profileApplied": "Applied",
+ "profileNotApplied": "Not applied",
+ "profileNotAppliedDescription": "This Runtime can run, but it has not been saved as a Studio-managed Profile. Applying it creates a Profile revision.",
+ "applyProfile": "Apply Profile",
+ "reapplyProfile": "Reapply Profile",
+ "editProfile": "Edit Profile",
+ "profileApplying": "Applying…",
+ "profileAppliedNotice": "Profile applied. Current revision is {{revision}}.",
+ "profileApplyIncomplete": "Profile apply did not finish. Check the current operation state and retry.",
+ "profileApplyFailed": "Failed to apply Profile.",
+ "profilePrompt": "System prompt",
+ "profileResources": "Resource configuration",
+ "profileResourceSummary": "{{tools}} tools · {{skills}} Skills · {{mcp}} MCP servers",
+ "sessionConfigTitle": "Session configuration",
+ "sessionConfigDescription": "Review and change execution choices for the current MPA Session. Changes apply to the next turn.",
+ "loadingSessionConfig": "Loading Session configuration…",
+ "sessionConfigNoAgent": "Select an MPA Agent to manage its Session configuration.",
+ "sessionConfigNonMpa": "Session configuration is only available for MPA Agents.",
+ "sessionConfigNoSession": "Open a chat with this MPA Agent before changing Session configuration.",
+ "sessionConfigDifferentRuntime": "Open the current chat for this MPA Agent before changing Session configuration.",
+ "sessionConfigSaved": "Session configuration updated. The next turn will use the new revision.",
+ "sessionConfigStale": "This Session was changed elsewhere. The latest configuration is shown; review it before saving again.",
+ "sessionConfigRevision": "Config revision",
+ "sessionProfileRevision": "Session Profile revision",
+ "latestProfileRevision": "Latest Profile revision",
+ "sessionProfileUpgradeAvailable": "A newer Profile revision is available: v{{current}} → v{{target}}.",
+ "upgradeSessionProfile": "Upgrade Session Profile",
+ "sessionProfileUpgrading": "Upgrading…",
+ "sessionProfileUpgraded": "Session Profile upgraded. The next turn will use the new revision.",
+ "sessionProfileUpgradeFailed": "Unable to upgrade the Session Profile.",
+ "sessionModelOverride": "Model override",
+ "modelId": "Model ID",
+ "inheritProfileDefault": "Inherit Profile default",
+ "sessionMcpServers": "MCP servers",
+ "mcpServerCount": "{{count}} selected",
+ "clearSessionMcp": "Clear MCP servers",
+ "mpaDiagnosticsTitle": "Runtime diagnostics",
+ "mpaDiagnosticsDescription": "Review recent Runtime Console runs and their Agent, Profile, Turn, task, and worker correlation.",
+ "loadingMpaDiagnostics": "Loading Runtime diagnostics…",
+ "noMpaDiagnostics": "No Runtime diagnostic runs for this Session.",
+ "latestRun": "Latest run",
+ "traceCorrelation": "Trace correlation",
+ "traceCorrelationDescription": "Stable identifiers returned by Runtime Console for this invocation.",
+ "traceSteps": "Trace steps",
+ "traceStepsDescription": "Recent bounded Runtime Console steps for the selected invocation.",
+ "mpaInstanceId": "MPA instance",
+ "turnId": "Turn ID",
+ "taskId": "Task ID",
+ "workerKind": "Worker kind",
+ "workerSession": "Worker Session",
+ "workerTurn": "Worker Turn",
+ "workerProfile": "Worker Profile",
+ "workerProfileVersion": "Worker Profile version",
+ "step": "Step",
+ "stepCount": "Steps",
+ "duration": "Duration",
+ "noTraceSteps": "No trace steps recorded for this invocation.",
+ "executionConfigMode": {
+ "replace": "Session override",
+ "clear": "Cleared for this Session",
+ "inherit": "Using Profile default"
+ },
"concurrency": "Concurrency",
"selectedOptimizations": "Selected optimizations",
"selectedOptimizationsDescription": "These optimizations will be applied to this update.",
@@ -328,6 +405,21 @@
"deleteDraftDescription": "Delete draft “{{name}}”?",
"deleteAgent": "Delete Agent",
"closeDeleteConfirmation": "Close delete confirmation",
+ "mpaDeletePreviewSummary": "Studio will delete visible idle MPA Runtime sessions first, then delete the AgentKit Runtime. Running work must finish or be stopped before deletion.",
+ "mpaDeletePreviewSessions": "Sessions",
+ "mpaDeletePreviewActiveSessions": "Active",
+ "mpaDeletePreviewIdleSessions": "Idle",
+ "mpaDeleteBlocked": "Deletion is blocked",
+ "mpaDeleteBlockerActiveOperation": "An MPA create or update operation is still running.",
+ "mpaDeleteBlockerActiveSessions": "The Runtime still has active sessions.",
+ "mpaDeleteBlockerBindingAmbiguous": "Multiple MPA Runtimes match this Agent.",
+ "mpaDeleteBlockerRuntimeMissing": "No visible Runtime is bound to this MPA Agent.",
+ "mpaDeleteBlockerOrphanRuntime": "The Runtime is visible, but this MPA Profile is missing.",
+ "mpaDeleteStage": {
+ "runtime_sessions": "Delete {{count}} visible idle Runtime sessions",
+ "runtime_diagnostics": "Remove diagnostics and mounted metadata owned by those sessions",
+ "agentkit_runtime": "Delete the AgentKit Runtime"
+ },
"draftDeletionWarning": "The draft will be removed from this browser.",
"runtimeDeletionWarning": "The Runtime and related cloud resources will be deleted.",
"noneSelected": "None selected",
@@ -343,6 +435,7 @@
"totalCount": "{{count}} total",
"deploymentProgress": "Deployment progress",
"returnToEdit": "Return to editing",
+ "retryOperation": "Retry operation",
"buildLog": "Build log",
"githubMountLog": "GitHub setup log",
"githubDeliveryMountLog": "GitHub continuous-delivery setup log",
@@ -393,6 +486,18 @@
"label": "Create evaluation sets",
"description": "Create Good Case and Bad Case evaluation sets automatically"
},
+ "profileApplying": {
+ "label": "Apply MPA profile",
+ "description": "Write the Studio Agent profile into the MPA Runtime"
+ },
+ "smokeRunning": {
+ "label": "Verify MPA Runtime",
+ "description": "Run the Runtime readiness smoke before exposing it as runnable"
+ },
+ "runnable": {
+ "label": "MPA Runtime ready",
+ "description": "The Runtime accepted the profile and passed execution readiness"
+ },
"github": {
"label": "Connect GitHub delivery",
"description": "Initialize the target branch and GitHub Actions workflow"
@@ -416,12 +521,16 @@
"cloudOnlyUpdate": "Only cloud Agents can be updated",
"deleteDeployedUnsupported": "Deleting deployed Agents is not supported",
"deleteDraftUnsupported": "Deleting drafts is not supported",
+ "loadMpaDeletePreview": "Unable to load MPA delete preview",
"loadAgentInfo": "Unable to load Agent information",
"loadApiKey": "Unable to load the API key",
"loadGithubVersions": "Unable to load GitHub versions",
"loadEvaluations": "Unable to load evaluation cases",
+ "loadMpaAgentView": "Unable to load MPA Agent status",
+ "loadMpaDiagnostics": "Unable to load Runtime diagnostics",
"loadOptimizations": "Unable to load optimization suggestions",
"loadRuntimeDetails": "Unable to load Runtime details",
+ "loadSessionConfig": "Unable to load Session configuration",
"loadUsage": "Unable to load usage data",
"noCreatePermission": "This account cannot create Agents",
"noManagePermission": "This account cannot manage this Agent",
@@ -439,6 +548,7 @@
"environmentCenter": {
"title": "Environments",
"loadFailed": "Failed to load environments. Check the storage configuration and try again.",
+ "storageUnavailable": "Persistent storage is not configured. Environment management is unavailable.",
"create": "New environment",
"configure": "Configure environment",
"details": "Environment details",
@@ -1087,6 +1197,10 @@
"githubMountFailed": "Failed to enable GitHub continuous delivery",
"githubMountFailedDetail": "Failed to enable GitHub continuous delivery: {{message}}",
"githubMountFailedHint": "Failed to enable GitHub continuous delivery. See the GitHub log for details.",
+ "applyingMpaProfile": "Applying the Agent profile to MPA Runtime",
+ "mpaProfileApplied": "MPA Runtime profile applied",
+ "runningMpaSmoke": "Verifying the MPA Runtime execution path",
+ "mpaReady": "MPA Runtime is ready",
"deploymentComplete": "Deployment complete",
"deployedNotConnected": "Deployed, not connected yet",
"cancelled": "Cancelled",
@@ -1132,7 +1246,10 @@
"createRuntime": "Create Runtime",
"publishService": "Publish service",
"updateInstances": "Update instance configuration",
- "createEvaluationSets": "Create evaluation sets"
+ "createEvaluationSets": "Create evaluation sets",
+ "applyMpaProfile": "Apply MPA profile",
+ "verifyMpaRuntime": "Verify MPA Runtime",
+ "mpaReady": "MPA Runtime ready"
},
"errors": {
"instanceRangeInteger": "Minimum instances must be an integer of 0 or more, and maximum instances must be an integer greater than 0.",
@@ -1149,6 +1266,8 @@
"runtimeNameExists": "This Runtime name already exists. Change it and try again.",
"deployedButGithubMountFailed": "Deployment succeeded, but enabling GitHub continuous delivery failed: {{message}}",
"deployedButGithubBindFailed": "Deployment succeeded, but binding GitHub failed: {{message}}",
+ "mpaRuntimeIdMissing": "Deployment succeeded, but the Runtime ID is missing, so the MPA profile cannot be applied.",
+ "mpaOperationIncomplete": "MPA Runtime setup did not complete. Retry this deployment task.",
"deploymentStatusUnconfirmed": "The connection was interrupted, so the final deployment status cannot be confirmed. The task may still be running in the cloud. Check the same task in AgentKit or CodePipeline and avoid a duplicate deployment.",
"failedAtStage": "{{action}} failed during {{stage}}: {{message}}",
"noAgentAtEndpoint": "Connected successfully, but no Agents were found at this endpoint (/list-apps is empty).",
@@ -1158,6 +1277,7 @@
},
"workspace": {
"title": "Workspaces",
+ "storageUnavailable": "Persistent storage is not configured. Workspace management is unavailable.",
"detail": "Workspace details",
"create": "New workspace",
"editorDescription": "Group frequently used environments. An environment can belong to multiple workspaces.",
@@ -1233,6 +1353,30 @@
}
},
"composer": {
+ "model": "Model",
+ "turnControls": "Turn controls",
+ "pause": "Pause",
+ "resume": "Resume",
+ "interrupt": "Interrupt",
+ "cancel": "Cancel",
+ "pauseTurn": "Pause the whole flow at a safe point",
+ "resumeTurn": "Resume the whole flow",
+ "interruptTurn": "Interrupt this flow and retain Session context",
+ "cancelTurn": "Cancel this flow permanently",
+ "turnState": {
+ "running": "Running",
+ "pausing": "Reaching safe point",
+ "paused": "Paused",
+ "resuming": "Resuming",
+ "interrupting": "Interrupting",
+ "cancelling": "Cancelling",
+ "interrupted": "Interrupted",
+ "completed": "Completed",
+ "failed": "Failed",
+ "cancelled": "Cancelled",
+ "orphaned": "Status pending",
+ "newTurnRequired": "Continue from checkpoint"
+ },
"tasks": {
"ppt": "Presentation",
"image": "Image generation",
@@ -1373,6 +1517,7 @@
"agent": "Agents",
"agentTypes": {
"general": "General Agents",
+ "mpa": "MPA Agents",
"codex": "Codex",
"deepseek-harness": "DeepSeek",
"openclaw": "OpenClaw",
@@ -1447,7 +1592,100 @@
"deleted": "Deleted",
"unknown": "Unknown status"
},
- "wakingHint": "Waking the agent. This may take some time."
+ "wakingHint": "Waking the agent. This may take some time.",
+ "mpaCreate": {
+ "name": "Name",
+ "namePlaceholder": "e.g. test-agent",
+ "nameHelp": "Used as the Runtime name. Use 4–64 letters, numbers, underscores or hyphens.",
+ "stepsLabel": "Creation steps",
+ "steps": {
+ "basics": "Basics",
+ "postgres": "PostgreSQL",
+ "openviking": "OpenViking",
+ "autoPostgres": "Database"
+ },
+ "next": "Next",
+ "previousStep": "Previous",
+ "pgDescription": "Choose an existing PostgreSQL instance. Creation prepares an isolated database for this agent on that instance.",
+ "pgSplitDescription": "Use the configured business PG Workspace. Agents share this Workspace, each with its own database. Shared resource records are stored separately in mpa_admin_workspace / mpa_admin_db, configured by the administrator.",
+ "pgConsole": "Create or inspect an AIDAP PG Workspace in Volcengine",
+ "pgHost": "PG host",
+ "pgPort": "PG port",
+ "pgInvalid": "Enter a valid PG host and port.",
+ "pgCredentials": "The deployment service provides the PG login and password; host and port must match its configuration.",
+ "openvikingDescription": "Optional. Enter all three fields to enable OpenViking, or leave all blank to omit OpenViking settings from the new agent.",
+ "openvikingConsole": "Inspect OpenViking resources in Volcengine",
+ "openvikingUrl": "OpenViking service URL",
+ "openvikingResourceId": "OpenViking resource ID",
+ "openvikingApiKey": "OpenViking API Key",
+ "openvikingInvalid": "Enter a valid HTTPS service URL, resource ID and API Key together, or leave all blank.",
+ "openvikingCredentials": "The API Key is used only for this creation and is not saved in browser drafts; re-enter it after a refresh.",
+ "tosTitle": "Session output storage",
+ "tosDescription": "Optional. Enter complete TOS credentials and a bucket to mount an isolated output directory for every Session. Leave all three blank to use the server defaults; TOS mounting is disabled if no defaults are configured.",
+ "tosAccessKey": "TOS Access Key",
+ "tosSecretKey": "TOS Secret Key",
+ "tosBucket": "TOS bucket",
+ "tosInvalid": "TOS Access Key, Secret Key, and bucket are all required together.",
+ "tosCredentials": "Credentials are used only for this creation and are not saved in browser recovery data or creation task records.",
+ "runtimeImage": "MPA image",
+ "workerImage": "Worker image",
+ "imageDefault": "Leave blank to use the configured image",
+ "imageInvalid": "Enter a valid container image reference without URLs, spaces or credentials.",
+ "title": "Create MPA agent",
+ "agentId": "Agent ID",
+ "description": "Description",
+ "region": "Deployment region",
+ "plan": "Creation plan",
+ "sharedResources": "Prepare or reuse the account VPC, subnets and APIG message gateway",
+ "agentResources": "Prepare an isolated database, Skill Space and worker, then deploy the agent",
+ "readiness": "Verify gateway and agent readiness before adding the agent to the list",
+ "checking": "Checking creation configuration…",
+ "configured": "Creation configuration is ready. Submission checks cloud resources and starts provisioning.",
+ "notConfigured": "Creation prerequisites are not configured. Ask an administrator to configure them, then retry.",
+ "loadFailed": "Could not check creation configuration. Please retry.",
+ "reload": "Check again",
+ "submit": "Start creation",
+ "retry": "Retry creation",
+ "newAgent": "Create another agent",
+ "newAgentDescription": "Create another agent opens an editable new request and replaces this recovery draft. It does not cancel the old task. If the old task is still running, wait for it to finish before submitting.",
+ "close": "Close",
+ "cancel": "Cancel creation",
+ "confirmCancel": "Confirm cancellation",
+ "cancelDescription": "Cancellation stops subsequent steps. Created databases, Skill Spaces, gateways and other registered resources are retained. Retry using the same agent ID.",
+ "background": "Creation continues after closing this window. Open it again to view progress.",
+ "pollFailed": "Progress is temporarily unavailable. Reconnecting; avoid creating another agent.",
+ "creationFailed": "This step failed. Check its resources, server configuration and permissions, then retry the same agent ID.",
+ "cancelFailed": "Cancellation failed. Check the current progress and retry.",
+ "cancelled": "Creation cancelled. Registered resources are retained for retry.",
+ "timeout": "Creation timed out. Registered resources are retained. Inspect them before retrying.",
+ "interrupted": "Creation was interrupted. Retry the same agent ID to recover registered resources.",
+ "skillSpace": "Skill Space",
+ "states": {
+ "running": "Creating",
+ "cancelling": "Cancelling",
+ "succeeded": "Created",
+ "failed": "Creation failed",
+ "cancelled": "Cancelled"
+ },
+ "stages": {
+ "queued": "Queued",
+ "checking": "Checking account and configuration",
+ "network": "Preparing VPC and subnets",
+ "gateway": "Preparing APIG and IM Gateway",
+ "database": "Preparing business database",
+ "skills": "Preparing Skill Space",
+ "worker": "Preparing worker",
+ "deploying": "Preparing database, skills and Runtime",
+ "verifying": "Waiting for agent readiness",
+ "admin_workspace": "Prepare management PG Workspace",
+ "business_workspace": "Prepare business PG Workspace",
+ "admin_database": "Initialize shared management database"
+ },
+ "back": "Keep creating",
+ "pgAutoDescription": "VeADK uses its deployment account to create or reuse two PG Workspaces. mpa_admin_workspace / mpa_admin_db stores shared resource records; the business Workspace hosts an independent database for each agent. No PG connection input is needed. Preparation starts after submission.",
+ "pgModeChanged": "This submitted task uses previous PG settings and cannot be retried in automatic mode. Ask an administrator to reconcile its resources and task before starting a new creation.",
+ "pgMigrationRequired": "An existing shared registry is configured. Migrate its resource records to mpa_admin_workspace / mpa_admin_db and coordinate the registry URL cutover for existing Runtimes, then recheck this configuration."
+ }
},
"skillCenter": {
"library": "Skill library",
@@ -1683,5 +1921,99 @@
"deleteBase": "Unable to delete the knowledge base",
"deleteDocument": "Unable to delete the knowledge item"
}
+ },
+ "channels": {
+ "title": "Feishu channel",
+ "refresh": "Refresh",
+ "loading": "Loading channel configuration…",
+ "unsupported": "This Runtime does not support channel management. Upgrade its MPA image.",
+ "unauthorized": "Channel management requires a Studio administrator and an authorized Runtime credential.",
+ "configurationError": "Channel management configuration is incomplete. Check the Runtime metadata and encryption key.",
+ "requestFailed": "Channel request failed. Check the Runtime connection and retry.",
+ "bound": "Bound: {{name}}",
+ "unbound": "No Feishu bot is bound.",
+ "guidance": "Private chats use separate conversations. In allowed groups, mention this bot to start a conversation.",
+ "keyRequired": "Configure a persistent CHANNEL_STATE_ENCRYPTION_KEY in the Runtime before binding.",
+ "gateway": "Message gateway",
+ "route": "Inbound route",
+ "delivery": "Reply delivery",
+ "configured": "Configured",
+ "missing": "Missing",
+ "delivery_not_observed": "Not observed",
+ "delivery_delivered": "Delivered",
+ "delivery_retrying": "Retrying",
+ "missingConfig": "Missing Runtime configuration: {{fields}}",
+ "bind": "Bind with QR code",
+ "newQr": "Generate a new QR code",
+ "unbind": "Unbind",
+ "confirmUnbind": "Disconnect this bot? It will stop receiving new messages.",
+ "qrAlt": "Bot authorization QR code",
+ "openQr": "Open authorization page",
+ "uncertain": "Registration was interrupted. Check the bot binding and Gateway before starting again.",
+ "bindingFailed": "Binding failed. Check Gateway configuration and retry.",
+ "chatId": "Allowed group ID",
+ "scope": "Group conversation scope",
+ "saveGroup": "Save group",
+ "noGroups": "No groups allowed yet. Private chats do not need this permission.",
+ "edit": "Edit",
+ "remove": "Remove",
+ "removeGroup": "Remove {{name}}",
+ "scope_group_sender": "Separate by sender",
+ "scope_group": "Shared by group",
+ "scope_group_topic_sender": "Separate by topic and sender",
+ "scope_group_topic": "Shared by topic",
+ "status_PENDING": "Waiting for scan",
+ "status_SCANNED": "Scanned; waiting for authorization",
+ "status_AUTHORIZED": "Authorized; preparing registration",
+ "status_REGISTERING": "Registering bot",
+ "status_BOUND": "Bot bound",
+ "status_FAILED": "Binding failed",
+ "status_EXPIRED": "QR code expired",
+ "workspaceTitle": "Message channels",
+ "workspaceDescription": "Connect messaging apps to this Agent and manage access.",
+ "provider_feishu": "Feishu",
+ "provider_wecom": "WeCom",
+ "provider_dingtalk": "DingTalk",
+ "comingSoon": "Not available yet",
+ "unavailableDescription": "Binding and message settings for {{name}} are not available yet.",
+ "runtimeRequired": "Deploy this Agent to a Runtime before configuring message channels.",
+ "bindingTitle": "Bot connection",
+ "connected": "Bound",
+ "notBound": "Not bound",
+ "gatewayDescription": "Gateway configuration for receiving channel messages.",
+ "routeDescription": "Routing configuration for delivering messages to this Agent.",
+ "deliveryDescription": "Observed reply delivery; configuration alone does not confirm delivery.",
+ "groupsTitle": "Group access",
+ "groupsDescription": "Allow specific groups and choose how conversations are shared.",
+ "providerBindingTitle": "Connect {{name}}",
+ "providerUnbound": "Connect a {{name}} bot to let users message this agent.",
+ "qrGuidance": "Authorize with {{name}}. The Runtime saves the bot credentials and configures the message gateway.",
+ "wecomGuidance": "Authorize with WeCom QR code, or enter a Bot ID and Secret to bind an existing bot.",
+ "wecomBotId": "Bot ID",
+ "wecomSecret": "Secret",
+ "bindCredentials": "Connect bot",
+ "wecomScan": "Bind with QR code",
+ "wecomAuthorizing": "Waiting for WeCom authorization…",
+ "cancelAuthorization": "Cancel authorization",
+ "wecomPopupBlocked": "Allow popups in your browser, then try authorizing again.",
+ "wecomAuthCancelled": "WeCom authorization was cancelled. You can scan again.",
+ "wecomAuthTimeout": "WeCom authorization timed out. Please scan again.",
+ "wecomInvalidResult": "Authorization did not return a Bot ID and Secret. Authorize again or enter them manually.",
+ "wecomAuthFailed": "WeCom authorization failed. Try again or enter credentials manually.",
+ "dingtalkClientId": "Client ID",
+ "dingtalkClientSecret": "Client Secret",
+ "manualUpgrade": "Upgrade this Runtime image to configure {{name}} manually. Quick setup is still available.",
+ "pairingTitle": "Message channel QR pairing",
+ "feishuPairingDescription": "If a bot is already connected, it will be replaced after successful Feishu authorization and binding. In the authorization flow, you can bind an existing bot or create a new one.",
+ "generatingQr": "Generating pairing QR code…",
+ "dingtalkGuidance": "Authorize with DingTalk QR code, or enter a Client ID and Client Secret to bind an existing bot.",
+ "wecomPairingDescription": "Pair again in the WeCom authorization window. The new bot replaces the current connection after binding succeeds. Cancelling authorization or a failed binding does not disconnect the existing bot.",
+ "configurationMethod": "Configuration method",
+ "quickSetup": "Quick setup",
+ "manualSetup": "Manual setup",
+ "feishuAppId": "App ID",
+ "feishuAppSecret": "App Secret",
+ "manualGuidance": "Enter the credentials of an existing {{name}} bot to connect it to this agent.",
+ "manualReplacement": "Successful binding replaces the current bot. Cancelling or a failed binding does not disconnect the existing bot."
}
}
diff --git a/frontend/src/i18n/resources/en-US/workspaceTools.json b/frontend/src/i18n/resources/en-US/workspaceTools.json
index ca35dd817..827176439 100644
--- a/frontend/src/i18n/resources/en-US/workspaceTools.json
+++ b/frontend/src/i18n/resources/en-US/workspaceTools.json
@@ -254,8 +254,13 @@
"deleteRuntime": "Delete this Runtime",
"loadingDetail": "Loading details…",
"agentStructure": "Agent structure",
- "secretHidden": "Sensitive value hidden. Select to reveal it.",
- "revealSecret": "Show the value of {{key}}",
+ "secretHidden": "Sensitive value is never shown. Copy it with an authorized request.",
+ "revealSecret": "Sensitive value for {{key}}",
+ "copySecret": "Copy the value of {{key}}",
+ "copy": "Copy",
+ "copied": "Copied",
+ "copyFailed": "Copy failed",
+ "notConfigured": "Not configured",
"fields": {
"model": "Model",
"description": "Description",
@@ -283,9 +288,10 @@
"itemCount_one": "{{count}} item",
"itemCount_other": "{{count}} items",
"info": "Agent information",
- "infoAndTopology": "Agent information and topology",
+ "infoAndTopology": "Agent information and settings",
"loadingInfo": "Loading Agent information…",
"unnamedAgent": "Unnamed Agent",
+ "agentsMd": "AGENTS.md",
"tools": "Tools",
"toolList": "Tool list",
"studioTool": "Studio Tool",
@@ -296,11 +302,16 @@
"addStudioToolHere": "Add Studio tools to this chat",
"skills": "Skills",
"skillList": "Skill list",
+ "addSkill": "Add Skill Space skills",
+ "addSkillHere": "Add Skill Space skills to this Session",
+ "skillMountNextTurn": "Mounted versions are frozen when the next Turn starts.",
"previewUnsupported": "Preview is not supported",
"sessionEnvironment": "Session environment",
"environment": "Environment",
"agentCanvas": "Agent canvas",
"topology": "Structure",
+ "nodeKinds": { "agent": "Agent", "sandbox": "Sandbox", "worker-session": "Worker Session", "skill-space": "Skill Space", "skill": "Skill", "studio-tool": "Studio Tool", "mcp": "MCP", "environment": "Environment", "knowledge": "Knowledge", "sub-agent": "Sub-agent" },
+ "nodeStatuses": { "configured": "Configured", "mounted": "Mounted", "running": "Running", "paused": "Paused" },
"viewCanvasFullscreen": "View Agent canvas in full screen",
"viewFullscreen": "View full screen",
"executionCanvas": "Agent execution canvas",
@@ -309,7 +320,28 @@
"close": "Close",
"capabilitiesSubtitle": "Capabilities and collaboration topology",
"closeInfo": "Close Agent information",
- "infoUnavailable": "Agent information is temporarily unavailable."
+ "infoUnavailable": "Agent information is temporarily unavailable.",
+ "refresh": "Refresh information",
+ "loadingSkills": "Loading bound skills…",
+ "sessionSkills": "Temporary session skills",
+ "noBoundSpace": "This agent has no bound Skill Space.",
+ "noBoundSkills": "The bound Skill Space has no skills.",
+ "loadFailed": "Could not load this information. Refresh to retry.",
+ "accessDenied": "You do not have permission to read this information.",
+ "documentUnsupported": "Upgrade the Runtime image to enable Studio to read AGENTS.md.",
+ "skillsUnsupported": "This Runtime does not provide Skill Space bindings.",
+ "skillsDegraded": "Skill information is incomplete. Refresh to retry.",
+ "documentEditHint": "Edit the latest version. Saving publishes a new version to this Skill Space and can affect other agents using it. Running sessions may need a cache refresh or a new session. Keep the frontmatter name unchanged.",
+ "documentLoading": "Loading SKILL.md…",
+ "documentWritable": "Editable",
+ "documentReadOnly": "Read only — this space or your permissions do not allow editing",
+ "documentLoadFailed": "Could not load SKILL.md. Refresh to retry.",
+ "documentSaveFailed": "Save was not confirmed. Your edits are preserved. Reopen to check the current version before retrying.",
+ "documentConflict": "A newer version exists. Copy your edits, then reopen the editor to avoid overwriting it.",
+ "discardDocument": "Discard unsaved changes?",
+ "keepEditing": "Keep editing",
+ "discardChanges": "Discard changes",
+ "saveDocument": "Save new version"
},
"sessionEnvironment": {
"mountFailed": "Could not mount the environment",
diff --git a/frontend/src/i18n/resources/zh-CN/adk.json b/frontend/src/i18n/resources/zh-CN/adk.json
index ea385df8e..358ffccb7 100644
--- a/frontend/src/i18n/resources/zh-CN/adk.json
+++ b/frontend/src/i18n/resources/zh-CN/adk.json
@@ -42,7 +42,8 @@
"resourceCollectionExpiredHint": "提示:本次资源清单已失效,请重新发送任务;系统会重新收集资源后再创建 Agent。",
"networkConfigurationHint": "提示:请稍后重试,查看 Runtime、模型或网关日志。",
"modelQuotaHint": "提示:模型当前触发了 TPM/RPM 配额限制,请稍后重试或提高模型配额。",
- "rawResponseLabel": "原始响应:"
+ "rawResponseLabel": "原始响应:",
+ "copySecretFailed": "复制敏感值失败"
},
"runtimeLogs": {
"httpStatus": "HTTP 状态码:{{status}}",
@@ -480,11 +481,22 @@
"publishing": "正在发布服务",
"evaluating": "正在创建评测集",
"updating": "正在更新 Runtime 配置",
+ "applyingMpaProfile": "正在将 Agent 配置应用到 MPA Runtime",
+ "runningMpaSmoke": "正在验证 MPA Runtime 执行链路",
+ "mpaReady": "MPA Runtime 已可运行",
"completing": "正在完成部署",
"github": "正在配置 GitHub 持续交付",
"inProgress": "正在部署"
},
"deploymentFailed": "部署失败",
+ "listSessionsFailed": "加载 Session 失败",
+ "mpaOperationFailed": "应用 MPA Runtime 配置失败",
+ "mpaOperationLoadFailed": "加载 MPA Runtime 操作失败",
+ "mpaRuntimeLegacyAuthUnsupported": "该 MPA Runtime 使用旧版鉴权适配,当前无法读取 Studio Session。请将 Runtime 镜像更新到最新 MPA AgentKit 版本后刷新此 Agent。\n{{detail}}",
+ "mpaRuntimeSessionsLoadFailed": "加载 MPA Runtime Session 失败",
+ "mpaDeletePreviewFailed": "加载 MPA 删除预览失败",
+ "loadMpaDiagnosticsFailed": "加载 MPA Runtime 诊断失败",
+ "mpaExecutionConfigFailed": "更新 MPA 会话配置失败",
"deploymentDisconnected": "部署失败:连接中断",
"deploymentMissingAgentName": "部署失败:返回缺少 Agent 名称",
"deploymentMissingConnection": "部署失败:返回缺少 AgentKit 连接信息",
diff --git a/frontend/src/i18n/resources/zh-CN/automations.json b/frontend/src/i18n/resources/zh-CN/automations.json
index 579294239..7253b5e62 100644
--- a/frontend/src/i18n/resources/zh-CN/automations.json
+++ b/frontend/src/i18n/resources/zh-CN/automations.json
@@ -88,6 +88,10 @@
"badge": "Beta",
"description": "创建飞书机器人,并将消息直接接入 AgentKit Runtime。"
},
+ "mpa-channels": {
+ "name": "MPA智能体消息渠道",
+ "description": "为已有 MPA 智能体绑定飞书、企业微信和钉钉。"
+ },
"website-integration": {
"name": "网站集成",
"description": "将 AgentKit Runtime 以悬浮聊天窗口嵌入网站。"
@@ -195,6 +199,24 @@
"noFiles": "没有可预览的文件。"
}
},
+ "mpaChannels": {
+ "noDescription": "暂无描述",
+ "unknownCreator": "未知创建者",
+ "optionMetadata": "{{creator}} | {{createdAt}}",
+ "selectAgent": "选择 MPA 智能体",
+ "chooseAgent": "请选择 MPA 智能体",
+ "search": "搜索名称、Runtime ID 或地域",
+ "noMatches": "没有匹配的 MPA 智能体",
+ "loading": "正在加载 MPA 智能体…",
+ "empty": "暂无可管理的 MPA 智能体,请先创建或部署一个 MPA 智能体。",
+ "unauthorized": "需要 Studio 管理员权限才能管理 MPA 消息渠道,请联系管理员。",
+ "loadFailed": "加载 MPA 智能体失败,请重试。",
+ "retry": "重试",
+ "refresh": "刷新列表",
+ "loadMore": "加载更多",
+ "moreAvailable": "还有更多智能体。搜索仅匹配已加载的结果,可继续加载。",
+ "chooseHint": "选择一个 MPA 智能体后,即可配置飞书、企业微信和钉钉。"
+ },
"feishu": {
"title": "飞书机器人",
"description": "创建一个由 AgentKit Runtime 驱动的飞书智能体",
diff --git a/frontend/src/i18n/resources/zh-CN/conversation.json b/frontend/src/i18n/resources/zh-CN/conversation.json
index 78bcf9ef2..010fc2a32 100644
--- a/frontend/src/i18n/resources/zh-CN/conversation.json
+++ b/frontend/src/i18n/resources/zh-CN/conversation.json
@@ -50,6 +50,7 @@
"connectingDescription": "正在通过 Studio BFF 建立安全日志流。",
"emptyTitle": "暂无日志",
"emptyDescription": "已连接实例,等待新的日志输出。",
+ "download": "下载日志",
"retention": "日志自动刷新,仅保留最近 {{count}} 行"
},
"trace": {
@@ -102,11 +103,18 @@
"downloadFormat": "下载 {{format}}"
},
"blocks": {
+ "a2a": {
+ "connecting": "正在等待 Runtime 响应…",
+ "submitted": "任务排队中…",
+ "working": "任务执行中…"
+ },
"unsupportedComponent": "不支持的组件:{{component}}",
"sandboxIdentity": "Codex Sandbox 执行标识",
"useSkill": "使用 {{name}} 技能",
- "thinkingDone": "已完成思考",
- "thinking": "思考中",
+ "thinkingDone": "已完成工作思考",
+ "thinking": "工作思考中",
+ "reasoningDone": "已完成模型推理",
+ "reasoning": "模型推理中",
"justNow": "刚刚",
"sourceUnavailable": "暂时无法读取生成的源码,请稍后重试。",
"downloadStarted": "已开始下载",
@@ -235,6 +243,79 @@
"webSearch": { "running": "正在进行网络搜索", "completed": "已完成网络搜索", "failed": "网络搜索未完成" },
"errorDetail": "Codex 执行未完成。",
"errorTitle": "Codex 执行遇到错误"
+ },
+ "toolActivity": {
+ "status": {
+ "queued": "等待执行",
+ "running": "正在执行",
+ "completed": "执行成功",
+ "failed": "执行失败"
+ },
+ "goal": {
+ "queued": "等待创建目标",
+ "running": "正在创建目标",
+ "completed": "已创建目标",
+ "failed": "创建目标失败"
+ },
+ "command": {
+ "queued": "等待运行命令",
+ "running": "正在运行命令",
+ "completed": "已运行命令",
+ "failed": "命令执行失败"
+ },
+ "read": {
+ "queued": "等待读取",
+ "running": "正在读取",
+ "completed": "已读取内容",
+ "failed": "读取失败"
+ },
+ "search": {
+ "queued": "等待搜索",
+ "running": "正在搜索",
+ "completed": "已完成搜索",
+ "failed": "搜索失败"
+ },
+ "file-change": {
+ "queued": "等待修改文件",
+ "running": "正在修改文件",
+ "completed": "已修改文件",
+ "failed": "文件修改失败"
+ },
+ "mcp": {
+ "queued": "等待调用外部工具",
+ "running": "正在调用外部工具",
+ "completed": "已调用外部工具",
+ "failed": "外部工具调用失败"
+ },
+ "authorization": {
+ "queued": "等待授权",
+ "running": "等待授权",
+ "completed": "授权已完成",
+ "failed": "授权失败"
+ },
+ "generic": {
+ "queued": "等待调用工具",
+ "running": "正在调用工具",
+ "completed": "已调用工具",
+ "failed": "工具调用失败",
+ "named": {
+ "queued": "等待调用 {{tool}}",
+ "running": "正在调用 {{tool}}",
+ "completed": "已调用 {{tool}}",
+ "failed": "{{tool}} 调用失败"
+ }
+ },
+ "exitCode": "退出码 {{code}}",
+ "omittedLines": "… 省略 {{count}} 行 …",
+ "omittedCharacters": "… 省略 {{count}} 个字符 …",
+ "rawData": "查看原始数据",
+ "callId": "调用 ID",
+ "copyCallId": "复制调用 ID",
+ "copy": "复制",
+ "copied": "已复制",
+ "copyFailed": "复制失败,请重试。",
+ "explored_one": "已探索 1 项",
+ "explored_other": "已探索 {{count}} 项"
}
},
"tokenUsage": {
@@ -259,6 +340,16 @@
"summaryTokens": "{{used}} 已用,剩余 {{remaining}},总计 {{total}}",
"overflow": "已超出上下文 {{count}} Token",
"title": "上下文用量",
+ "category": "类型",
+ "currentTurn": "当前轮",
+ "sessionTotal": "会话累计",
+ "details": {
+ "input": "输入",
+ "output": "输出",
+ "reasoning": "推理",
+ "cache": "缓存命中",
+ "total": "总计"
+ },
"unknownModel": "暂未收录该模型的上下文窗口",
"unknownRuntime": "当前 Runtime 未提供模型信息"
},
@@ -275,8 +366,8 @@
"connecting": "连接中…",
"connect": "连接并添加"
},
- "composer": { "placeholder": "输入消息…", "inputAria": "输入消息", "generating": "正在生成", "send": "发送" },
+ "composer": { "placeholder": "输入消息…", "inputAria": "输入消息", "generating": "正在生成", "send": "发送", "model": "模型" },
"invocation": { "ariaLabel": "本轮调用上下文", "removeSkill": "移除技能 {{name}}", "removeAgent": "移除 Agent {{name}}" },
"visualization": { "cardAria": "{{label}} 图表", "viewAria": "{{label}} 显示方式", "preview": "预览", "code": "代码", "invalidEcharts": "ECharts 配置不是有效且安全的数据对象,请切换到代码检查内容。", "renderFailed": "图表暂时无法渲染,请切换到代码检查内容。", "echartsAria": "ECharts 图表预览", "rendering": "正在渲染图表…", "mermaidFailed": "图表暂时无法渲染,请切换到代码查看 Mermaid 内容。", "mermaidAria": "Mermaid 图表预览" }
- ,"markdown": { "playVideo": "点击播放视频:{{name}}", "enlargeImage": "放大预览:{{name}}", "image": "图片", "enlargeVideo": "点击放大视频", "videoPreview": "视频预览", "downloadVideo": "下载视频", "close": "关闭" }
+ ,"markdown": { "downloadFile": "下载文件", "downloading": "下载中…", "downloadFailed": "下载失败。请确认 Sandbox 和文件仍可用,然后点击重试。", "playVideo": "点击播放视频:{{name}}", "enlargeImage": "放大预览:{{name}}", "image": "图片", "enlargeVideo": "点击放大视频", "videoPreview": "视频预览", "downloadVideo": "下载视频", "close": "关闭" }
}
diff --git a/frontend/src/i18n/resources/zh-CN/create.json b/frontend/src/i18n/resources/zh-CN/create.json
index 3b327945f..2a7a3b95b 100644
--- a/frontend/src/i18n/resources/zh-CN/create.json
+++ b/frontend/src/i18n/resources/zh-CN/create.json
@@ -595,7 +595,9 @@
"actions": {
"updateAgain": "再次更新",
"deployAgain": "重新部署",
- "updateAndPublish": "更新并发布"
+ "updateAndPublish": "更新并发布",
+ "applyProfile": "应用 Profile",
+ "applyProfileAgain": "再次应用 Profile"
}
},
"traditional": {
@@ -1157,6 +1159,11 @@
"generatingConfiguration": "正在生成部署配置",
"runtimeNameExists": "Runtime 名称已存在,请修改后重试。",
"preparing": "准备部署",
+ "applyingMpaProfile": "正在将 Agent 配置应用到 MPA Runtime",
+ "mpaProfileApplied": "MPA Runtime 配置已应用",
+ "runningMpaSmoke": "正在验证 MPA Runtime 执行链路",
+ "mpaReady": "MPA Runtime 已可运行",
+ "mpaOperationIncomplete": "MPA Runtime 初始化未完成,请重试此部署任务。",
"complete": "部署完成",
"failed": "部署失败",
"updateAndPublish": "更新并发布",
@@ -1164,6 +1171,9 @@
"build": "构建镜像",
"deploy": "部署 Runtime",
"publish": "发布服务",
+ "applyMpaProfile": "应用 MPA 配置",
+ "verifyMpaRuntime": "验证 MPA Runtime",
+ "mpaReady": "MPA Runtime 可运行",
"running": "部署中"
}
},
diff --git a/frontend/src/i18n/resources/zh-CN/cronjobs.json b/frontend/src/i18n/resources/zh-CN/cronjobs.json
index ba1623d96..71309e641 100644
--- a/frontend/src/i18n/resources/zh-CN/cronjobs.json
+++ b/frontend/src/i18n/resources/zh-CN/cronjobs.json
@@ -1,4 +1,111 @@
{
+ "mpa": {
+ "source": "任务来源",
+ "studio": "Studio 定时任务",
+ "title": "Runtime 定时任务",
+ "selectRuntime": "请先在 Agent 列表中选择一个云端 Runtime。",
+ "scope": "展示当前 Studio 用户在此 Runtime 中的定时任务。",
+ "refresh": "刷新",
+ "loading": "正在获取 Runtime 任务",
+ "empty": "此 Runtime 中没有符合条件的定时任务。",
+ "authRequired": "Runtime 拒绝了请求,请检查网关访问权限及 Runtime 的鉴权开关。",
+ "forbidden": "当前用户没有此 Runtime 的访问权限。",
+ "unsupported": "此 Runtime 未提供 mpa 定时任务接口。",
+ "invalidResponse": "任务接口返回了无法识别的数据,请检查 Runtime 版本。",
+ "loadFailed": "获取任务失败,请检查网络和 Runtime 状态后刷新。",
+ "pagination": "Runtime 任务分页",
+ "total": "共 {{total}} 条",
+ "previous": "上一页",
+ "next": "下一页",
+ "columns": {
+ "name": "任务名称",
+ "enabled": "状态",
+ "schedule": "执行计划",
+ "next": "下次执行",
+ "last": "最近结果"
+ },
+ "schedule": {
+ "once": "单次",
+ "interval": "间隔",
+ "daily": "每天",
+ "weekly": "每周",
+ "monthly": "每月",
+ "cron": "Cron"
+ },
+ "search": "搜索任务",
+ "executionCount": "执行次数",
+ "successRate": "成功率",
+ "prompt": "任务内容",
+ "manage": {
+ "view": "任务视图",
+ "list": "列表",
+ "calendar": "日历",
+ "create": "创建定时任务",
+ "edit": "编辑任务",
+ "copy": "复制",
+ "delete": "删除",
+ "detail": "详情",
+ "run": "立即执行",
+ "name": "任务",
+ "lastRun": "上次执行",
+ "schedule": "执行周期",
+ "lastStatus": "上次执行状态",
+ "agent": "执行 Agent",
+ "actions": "操作",
+ "all": "全部状态",
+ "Pending": "尚未执行",
+ "Queued": "已排队",
+ "Running": "执行中",
+ "Succeeded": "执行成功",
+ "Failed": "执行失败",
+ "TimedOut": "执行超时",
+ "Cancelled": "已取消",
+ "Skipped": "已跳过",
+ "overview": "执行统计:",
+ "prompt": "任务内容",
+ "timezone": "时区",
+ "localTime": "执行时间(当前设备时区)",
+ "time": "执行时间",
+ "Once": "单次",
+ "Interval": "间隔",
+ "Daily": "每天",
+ "Weekly": "每周",
+ "Monthly": "每月",
+ "Cron": "Cron 表达式",
+ "seconds": "秒",
+ "interval": "间隔秒数",
+ "weekdays": "星期(1 为周一,7 为周日,逗号分隔)",
+ "monthDays": "每月日期(1–31,逗号分隔)",
+ "delivery": "结果投递",
+ "Web": "Web",
+ "Feishu": "飞书",
+ "target": "飞书接收 ID",
+ "enabled": "启用任务",
+ "cancel": "取消",
+ "save": "保存",
+ "saving": "保存中",
+ "required": "请填写任务名称、Agent ID、任务内容和投递目标。",
+ "timezoneInvalid": "请输入有效的 IANA 时区,例如 Asia/Shanghai。",
+ "futureTime": "请选择未来的执行时间。",
+ "intervalInvalid": "间隔需要为 30–31536000 的整数秒。",
+ "daysInvalid": "请输入有效且不重复的日期或星期。",
+ "history": "执行记录",
+ "duration": "耗时",
+ "result": "结果 / 会话",
+ "noRuns": "暂无执行记录。",
+ "conflict": "任务已被修改或请求发生冲突,请关闭弹窗并刷新后重试。",
+ "invalid": "请求校验失败,请检查用户身份、执行时间、Agent ID 和任务配置。",
+ "queued": "已提交执行请求。",
+ "saved": "任务已更新。",
+ "deleteConfirm": "确定删除“{{name}}”?删除后将不再调度此任务。",
+ "calendarZone": "显示时区:",
+ "prevMonth": "上个月",
+ "nextMonth": "下个月",
+ "today": "今天",
+ "nextOnly": "仅下次",
+ "calendarHint": "日历展示预计执行安排;复杂 Cron 仅显示服务端给出的下一次执行时间。"
+ }
+ },
"actions": {
"backToList": "返回定时任务列表",
"cancel": "取消",
diff --git a/frontend/src/i18n/resources/zh-CN/newChat.json b/frontend/src/i18n/resources/zh-CN/newChat.json
index 00a30059c..a13ed8730 100644
--- a/frontend/src/i18n/resources/zh-CN/newChat.json
+++ b/frontend/src/i18n/resources/zh-CN/newChat.json
@@ -55,6 +55,7 @@
"types": {
"agent": "智能体",
"general": "通用智能体",
+ "mpa": "MPA 智能体",
"codex": "Codex 智能体",
"deepseekHarness": "DeepSeek Harness",
"openclaw": "OpenClaw 智能体",
diff --git a/frontend/src/i18n/resources/zh-CN/ui.json b/frontend/src/i18n/resources/zh-CN/ui.json
index f8bb47df0..8e06e6883 100644
--- a/frontend/src/i18n/resources/zh-CN/ui.json
+++ b/frontend/src/i18n/resources/zh-CN/ui.json
@@ -124,11 +124,15 @@
"usageOverview": "使用概览",
"sections": {
"basic": "基本信息",
+ "profileConfig": "Profile 配置",
+ "sessionConfig": "会话配置",
"usage": "使用概览",
+ "diagnostics": "诊断",
"evaluations": "评测",
"optimizations": "优化建议",
"integrations": "集成",
- "versions": "版本"
+ "versions": "版本",
+ "channels": "消息渠道"
},
"evaluationGroup": "评测组",
"optimizations": "优化建议",
@@ -188,6 +192,79 @@
"deploymentConfig": "部署配置",
"deploymentConfigDescription": "确认实例和运行配置后更新 Runtime。",
"deploymentRegion": "部署区域",
+ "mpaControlPlane": "MPA 控制面",
+ "loadingMpaAgentView": "正在读取 MPA Runtime 绑定…",
+ "mpaRuntimeBound": "该 MPA Agent 已绑定唯一 Runtime,可继续管理。",
+ "mpaRuntimeMissing": "当前 Agent 没有可见的 MPA Runtime 绑定。",
+ "mpaRuntimeBindingAmbiguous": "多个 MPA Runtime 匹配当前 Agent,请先修复绑定后再修改。",
+ "mpaRuntimeOrphan": "Runtime 存在,但其中没有当前 MPA Profile。",
+ "mpaRuntimeLegacyAuthUnsupported": "该 Runtime 使用旧版 MPA 镜像,仍要求 X-Jwt-Token。请先更新到当前 AgentKit 兼容镜像,再管理 Profile 或 Session 配置。",
+ "profileConfig": "Profile 配置",
+ "profileConfigTitle": "Profile 配置",
+ "profileConfigDescription": "查看并应用 Agent 级 MPA Profile。Profile 是 Runtime 的长期默认配置,新会话默认继承它。",
+ "profileStatus": "Profile 状态",
+ "profileApplied": "已应用",
+ "profileNotApplied": "未应用",
+ "profileNotAppliedDescription": "当前 Runtime 可运行,但尚未保存为 Studio 可管理的 Profile。应用后会生成 Profile revision。",
+ "applyProfile": "应用 Profile",
+ "reapplyProfile": "重新应用 Profile",
+ "editProfile": "编辑 Profile",
+ "profileApplying": "应用中…",
+ "profileAppliedNotice": "Profile 已应用,当前 revision 为 {{revision}}。",
+ "profileApplyIncomplete": "Profile 应用未完成,请查看当前操作状态后重试。",
+ "profileApplyFailed": "Profile 应用失败。",
+ "profilePrompt": "系统提示词",
+ "profileResources": "资源配置",
+ "profileResourceSummary": "{{tools}} 个工具 · {{skills}} 个 Skill · {{mcp}} 个 MCP 服务",
+ "sessionConfigTitle": "会话配置",
+ "sessionConfigDescription": "查看和调整当前 MPA Session 的执行配置。变更只对下一轮对话生效。",
+ "loadingSessionConfig": "正在加载会话配置…",
+ "sessionConfigNoAgent": "请选择一个 MPA Agent 后再管理会话配置。",
+ "sessionConfigNonMpa": "会话配置仅适用于 MPA Agent。",
+ "sessionConfigNoSession": "请先打开这个 MPA Agent 的对话,再修改会话配置。",
+ "sessionConfigDifferentRuntime": "请先切回这个 MPA Agent 的当前对话,再修改会话配置。",
+ "sessionConfigSaved": "会话配置已更新,下一轮对话将使用新 revision。",
+ "sessionConfigStale": "该 Session 已被其他位置修改,已展示最新配置,请确认后再保存。",
+ "sessionConfigRevision": "配置 revision",
+ "sessionProfileRevision": "Session Profile revision",
+ "latestProfileRevision": "最新 Profile revision",
+ "sessionProfileUpgradeAvailable": "检测到新的 Profile revision:v{{current}} → v{{target}}。",
+ "upgradeSessionProfile": "升级 Session Profile",
+ "sessionProfileUpgrading": "升级中…",
+ "sessionProfileUpgraded": "Session Profile 已升级,下一轮对话将使用新 revision。",
+ "sessionProfileUpgradeFailed": "Session Profile 升级失败。",
+ "sessionModelOverride": "模型覆盖",
+ "modelId": "模型 ID",
+ "inheritProfileDefault": "继承 Profile 默认值",
+ "sessionMcpServers": "MCP 服务",
+ "mcpServerCount": "已选择 {{count}} 个",
+ "clearSessionMcp": "清空 MCP 服务",
+ "mpaDiagnosticsTitle": "Runtime 诊断",
+ "mpaDiagnosticsDescription": "查看最近的 Runtime Console 运行记录,以及 Agent、Profile、Turn、task 和 worker 关联。",
+ "loadingMpaDiagnostics": "正在加载 Runtime 诊断…",
+ "noMpaDiagnostics": "当前 Session 暂无 Runtime 诊断运行记录。",
+ "latestRun": "最近运行",
+ "traceCorrelation": "Trace 关联",
+ "traceCorrelationDescription": "Runtime Console 为本次 invocation 返回的稳定标识。",
+ "traceSteps": "Trace 步骤",
+ "traceStepsDescription": "当前 invocation 最近的有界 Runtime Console 步骤。",
+ "mpaInstanceId": "MPA 实例",
+ "turnId": "Turn ID",
+ "taskId": "Task ID",
+ "workerKind": "Worker 类型",
+ "workerSession": "Worker Session",
+ "workerTurn": "Worker Turn",
+ "workerProfile": "Worker Profile",
+ "workerProfileVersion": "Worker Profile 版本",
+ "step": "步骤",
+ "stepCount": "步骤数",
+ "duration": "耗时",
+ "noTraceSteps": "本次 invocation 暂无 trace 步骤。",
+ "executionConfigMode": {
+ "replace": "Session 覆盖",
+ "clear": "本 Session 已清空",
+ "inherit": "使用 Profile 默认值"
+ },
"concurrency": "并发数",
"selectedOptimizations": "已选优化项",
"selectedOptimizationsDescription": "这些优化会应用到本次更新。",
@@ -328,6 +405,21 @@
"deleteDraftDescription": "确定删除草稿“{{name}}”吗?",
"deleteAgent": "删除 Agent",
"closeDeleteConfirmation": "关闭删除确认",
+ "mpaDeletePreviewSummary": "Studio 会先删除可见的空闲 MPA Runtime 会话,再删除 AgentKit Runtime。运行中的任务需要先结束或停止后才能删除。",
+ "mpaDeletePreviewSessions": "会话",
+ "mpaDeletePreviewActiveSessions": "运行中",
+ "mpaDeletePreviewIdleSessions": "空闲",
+ "mpaDeleteBlocked": "当前无法删除",
+ "mpaDeleteBlockerActiveOperation": "仍有 MPA 创建或更新操作在执行。",
+ "mpaDeleteBlockerActiveSessions": "Runtime 中仍有运行中的会话。",
+ "mpaDeleteBlockerBindingAmbiguous": "当前 Agent 匹配到多个 MPA Runtime。",
+ "mpaDeleteBlockerRuntimeMissing": "当前 MPA Agent 没有可见的 Runtime 绑定。",
+ "mpaDeleteBlockerOrphanRuntime": "Runtime 可见,但其中缺少当前 MPA Profile。",
+ "mpaDeleteStage": {
+ "runtime_sessions": "删除 {{count}} 个可见空闲 Runtime 会话",
+ "runtime_diagnostics": "清理这些会话关联的诊断数据和挂载元信息",
+ "agentkit_runtime": "删除 AgentKit Runtime"
+ },
"draftDeletionWarning": "草稿将从当前浏览器中删除。",
"runtimeDeletionWarning": "Runtime 和相关云端资源将被删除。",
"noneSelected": "尚未选择",
@@ -343,6 +435,7 @@
"totalCount": "共 {{count}} 条",
"deploymentProgress": "部署进度",
"returnToEdit": "返回编辑",
+ "retryOperation": "重试操作",
"buildLog": "构建日志",
"githubMountLog": "GitHub 挂载日志",
"githubDeliveryMountLog": "GitHub 持续交付挂载日志",
@@ -393,6 +486,18 @@
"label": "创建评测集",
"description": "自动创建 Good Case 和 Bad Case 评测集"
},
+ "profileApplying": {
+ "label": "应用 MPA 配置",
+ "description": "将 Studio Agent 配置写入 MPA Runtime"
+ },
+ "smokeRunning": {
+ "label": "验证 MPA Runtime",
+ "description": "在标记可运行前执行 Runtime 就绪验证"
+ },
+ "runnable": {
+ "label": "MPA Runtime 可运行",
+ "description": "Runtime 已接受配置并通过执行就绪验证"
+ },
"github": {
"label": "挂载 GitHub 持续交付",
"description": "初始化目标分支与 GitHub Actions workflow"
@@ -416,12 +521,16 @@
"cloudOnlyUpdate": "仅云端 Agent 支持更新",
"deleteDeployedUnsupported": "当前不支持删除已部署 Agent",
"deleteDraftUnsupported": "当前不支持删除草稿",
+ "loadMpaDeletePreview": "无法加载 MPA 删除预览",
"loadAgentInfo": "无法加载 Agent 信息",
"loadApiKey": "无法读取 API Key",
"loadGithubVersions": "无法加载 GitHub 版本",
"loadEvaluations": "无法加载评测案例",
+ "loadMpaAgentView": "无法加载 MPA Agent 状态",
+ "loadMpaDiagnostics": "无法加载 Runtime 诊断",
"loadOptimizations": "无法加载优化建议",
"loadRuntimeDetails": "无法加载 Runtime 详情",
+ "loadSessionConfig": "无法加载会话配置",
"loadUsage": "无法加载使用数据",
"noCreatePermission": "当前账号没有创建 Agent 的权限",
"noManagePermission": "当前账号没有管理此 Agent 的权限",
@@ -439,6 +548,7 @@
"environmentCenter": {
"title": "环境",
"loadFailed": "环境加载失败,请检查存储配置后重试。",
+ "storageUnavailable": "管理员未配置持久化存储,环境管理暂不可用。",
"create": "新建环境",
"configure": "配置环境",
"details": "环境详情",
@@ -1087,6 +1197,10 @@
"githubMountFailed": "挂载 GitHub 持续交付失败",
"githubMountFailedDetail": "GitHub 持续交付挂载失败:{{message}}",
"githubMountFailedHint": "挂载 GitHub 持续交付失败,详见 GitHub 日志。",
+ "applyingMpaProfile": "正在将 Agent 配置应用到 MPA Runtime",
+ "mpaProfileApplied": "MPA Runtime 配置已应用",
+ "runningMpaSmoke": "正在验证 MPA Runtime 执行链路",
+ "mpaReady": "MPA Runtime 已可运行",
"deploymentComplete": "部署完成",
"deployedNotConnected": "部署完成,暂未连接",
"cancelled": "已取消",
@@ -1132,7 +1246,10 @@
"createRuntime": "创建 Runtime",
"publishService": "发布服务",
"updateInstances": "更新实例配置",
- "createEvaluationSets": "创建评测集"
+ "createEvaluationSets": "创建评测集",
+ "applyMpaProfile": "应用 MPA 配置",
+ "verifyMpaRuntime": "验证 MPA Runtime",
+ "mpaReady": "MPA Runtime 可运行"
},
"errors": {
"instanceRangeInteger": "最小实例数必须为大于等于 0 的整数,最大实例数必须为大于 0 的整数。",
@@ -1149,6 +1266,8 @@
"runtimeNameExists": "Runtime 名称已存在,请修改后重试。",
"deployedButGithubMountFailed": "部署成功,但挂载 GitHub 持续交付失败:{{message}}",
"deployedButGithubBindFailed": "部署成功,但绑定 GitHub 失败:{{message}}",
+ "mpaRuntimeIdMissing": "部署成功,但返回缺少 Runtime ID,无法应用 MPA 配置。",
+ "mpaOperationIncomplete": "MPA Runtime 初始化未完成,请重试此部署任务。",
"deploymentStatusUnconfirmed": "连接已中断,当前无法确认部署最终状态。任务可能仍在云端运行,请到 AgentKit 或 Code Pipeline 查看同一任务,避免重复部署。",
"failedAtStage": "{{action}}失败({{stage}}阶段):{{message}}",
"noAgentAtEndpoint": "连接成功,但该地址未发现任何 Agent(/list-apps 为空)。",
@@ -1158,6 +1277,7 @@
},
"workspace": {
"title": "工作区",
+ "storageUnavailable": "管理员未配置持久化存储,工作区管理暂不可用。",
"detail": "工作区详情",
"create": "新建工作区",
"editorDescription": "将常用环境组合在一起;同一个环境可以加入多个工作区。",
@@ -1233,6 +1353,30 @@
}
},
"composer": {
+ "model": "模型",
+ "turnControls": "流程控制",
+ "pause": "暂停",
+ "resume": "恢复",
+ "interrupt": "中断",
+ "cancel": "取消",
+ "pauseTurn": "在安全点暂停完整流程",
+ "resumeTurn": "恢复完整流程",
+ "interruptTurn": "中断当前流程并保留会话上下文",
+ "cancelTurn": "取消当前流程且不可恢复",
+ "turnState": {
+ "running": "运行中",
+ "pausing": "正在到达安全点",
+ "paused": "已暂停",
+ "resuming": "正在恢复",
+ "interrupting": "正在中断",
+ "cancelling": "正在取消",
+ "interrupted": "已中断",
+ "completed": "已完成",
+ "failed": "失败",
+ "cancelled": "已取消",
+ "orphaned": "状态待确认",
+ "newTurnRequired": "需要从检查点继续"
+ },
"tasks": {
"ppt": "PPT",
"image": "图片生成",
@@ -1373,6 +1517,7 @@
"agent": "智能体",
"agentTypes": {
"general": "通用智能体",
+ "mpa": "MPA 智能体",
"codex": "Codex",
"deepseek-harness": "DeepSeek",
"openclaw": "OpenClaw",
@@ -1447,7 +1592,100 @@
"deleted": "已删除",
"unknown": "未知状态"
},
- "wakingHint": "正在唤醒智能体,可能需要一些时间。"
+ "wakingHint": "正在唤醒智能体,可能需要一些时间。",
+ "mpaCreate": {
+ "name": "名称",
+ "namePlaceholder": "例如 test-agent",
+ "nameHelp": "作为 Runtime 名称,支持 4–64 位字母、数字、下划线或连字符。",
+ "stepsLabel": "创建步骤",
+ "steps": {
+ "basics": "基本信息",
+ "postgres": "PostgreSQL",
+ "openviking": "OpenViking",
+ "autoPostgres": "数据库"
+ },
+ "next": "下一步",
+ "previousStep": "上一步",
+ "pgDescription": "选择已有的 PostgreSQL 实例。创建流程会在该实例中准备智能体的独立业务库。",
+ "pgSplitDescription": "使用已配置的业务 PG Workspace。多个智能体共用此 Workspace,每个智能体拥有独立业务库。共享资源记录单独存放在 mpa_admin_workspace / mpa_admin_db,由管理员配置。",
+ "pgConsole": "前往火山引擎 AIDAP 创建或查看 PG Workspace",
+ "pgHost": "PG 主机地址",
+ "pgPort": "PG 端口",
+ "pgInvalid": "请填写有效的 PG 主机地址和端口。",
+ "pgCredentials": "PG 连接账号和密码由部署服务配置;主机和端口须与服务端配置一致。",
+ "openvikingDescription": "可选。三项全部填写才启用 OpenViking;全部留空则不向新智能体注入 OpenViking 配置。",
+ "openvikingConsole": "前往火山引擎查看 OpenViking 资源",
+ "openvikingUrl": "OpenViking 服务地址",
+ "openvikingResourceId": "OpenViking 资源 ID",
+ "openvikingApiKey": "OpenViking API Key",
+ "openvikingInvalid": "请同时填写有效的 HTTPS 服务地址、资源 ID 和 API Key,或全部留空。",
+ "openvikingCredentials": "API Key 仅用于本次创建,不保存到浏览器草稿;刷新页面后需重新填写。",
+ "tosTitle": "会话输出存储",
+ "tosDescription": "可选。填写完整的 TOS 凭证和存储桶,为新智能体的每个会话挂载独立输出目录。三项均留空时使用服务端预设;服务端未配置时,不启用 TOS 挂载。",
+ "tosAccessKey": "TOS Access Key",
+ "tosSecretKey": "TOS Secret Key",
+ "tosBucket": "TOS 存储桶",
+ "tosInvalid": "TOS Access Key、Secret Key 和存储桶必须同时填写。",
+ "tosCredentials": "凭证仅用于本次创建,不会保存在浏览器恢复数据或创建任务记录中。",
+ "runtimeImage": "MPA 镜像",
+ "workerImage": "Worker 镜像",
+ "imageDefault": "留空使用配置文件中的默认镜像",
+ "imageInvalid": "请输入有效的镜像地址,不要包含网址、空格或登录凭据。",
+ "title": "创建 MPA 智能体",
+ "agentId": "智能体 ID",
+ "description": "描述",
+ "region": "部署地域",
+ "plan": "创建计划",
+ "sharedResources": "准备或复用账号共享 VPC、子网和 APIG 消息网关",
+ "agentResources": "准备独立业务库、技能空间和执行环境,部署智能体",
+ "readiness": "检查网关和智能体就绪状态,成功后加入列表",
+ "checking": "正在检查创建配置…",
+ "configured": "创建配置已就绪。提交后会检查云资源并开始准备。",
+ "notConfigured": "尚未配置创建所需的资源参数,请联系管理员配置后重试。",
+ "loadFailed": "检查创建配置失败,请重试。",
+ "reload": "重新检查",
+ "submit": "开始创建",
+ "retry": "重试创建",
+ "newAgent": "新建另一个智能体",
+ "newAgentDescription": "新建另一个智能体将开启可编辑的新请求,替换当前恢复草稿,但不会取消旧任务。旧任务仍在运行时,请等待其结束后再提交。",
+ "close": "关闭",
+ "cancel": "取消创建",
+ "confirmCancel": "确认取消",
+ "cancelDescription": "取消后会停止后续步骤。已创建的数据库、技能空间、网关和其他已登记资源会保留,可使用同一智能体 ID 重试。",
+ "background": "关闭窗口后创建会继续。再次打开可查看进度。",
+ "pollFailed": "暂时无法获取进度,正在重新连接。请勿重复创建其他智能体。",
+ "creationFailed": "当前步骤未完成,请检查对应资源、服务端配置及权限后,用同一智能体 ID 重试。",
+ "cancelFailed": "取消请求失败,请重新查询进度后重试。",
+ "cancelled": "创建已取消,已登记资源保留供重试。",
+ "timeout": "创建超时,已登记资源保留。请检查对应资源后重试。",
+ "interrupted": "创建任务中断。请用同一智能体 ID 重试以恢复已登记资源。",
+ "skillSpace": "技能空间",
+ "states": {
+ "running": "正在创建",
+ "cancelling": "正在取消",
+ "succeeded": "创建成功",
+ "failed": "创建失败",
+ "cancelled": "已取消"
+ },
+ "stages": {
+ "queued": "等待开始",
+ "checking": "检查账号与配置",
+ "network": "准备 VPC 和子网",
+ "gateway": "准备 APIG 和 IM Gateway",
+ "database": "准备业务数据库",
+ "skills": "准备技能空间",
+ "worker": "准备执行环境",
+ "deploying": "准备数据库、技能空间和 Runtime",
+ "verifying": "等待智能体就绪",
+ "admin_workspace": "准备管理 PG Workspace",
+ "business_workspace": "准备业务 PG Workspace",
+ "admin_database": "初始化共享管理库"
+ },
+ "back": "继续创建",
+ "pgAutoDescription": "使用 VeADK 部署账号自动创建或复用两个 PG Workspace:mpa_admin_workspace / mpa_admin_db 存放共享资源记录,业务 Workspace 供多个智能体共用,每个智能体拥有独立业务库。无需填写 PG 连接信息,提交创建后开始准备。",
+ "pgModeChanged": "此创建任务使用旧的 PG 配置,不能直接切换为自动模式重试。请联系管理员确认已有资源和任务后重新创建。",
+ "pgMigrationRequired": "检测到旧共享注册库。请先将共享资源记录迁移到 mpa_admin_workspace / mpa_admin_db,并完成现有 Runtime 的注册库地址切换;完成后点击“重新检查”。"
+ }
},
"skillCenter": {
"library": "技能库",
@@ -1683,5 +1921,99 @@
"deleteBase": "删除知识库失败",
"deleteDocument": "删除知识失败"
}
+ },
+ "channels": {
+ "title": "飞书渠道",
+ "refresh": "刷新",
+ "loading": "正在加载渠道配置…",
+ "unsupported": "此 Runtime 不支持渠道管理,请升级 MPA 镜像。",
+ "unauthorized": "渠道管理需要 Studio 管理员身份及有效的 Runtime 凭据。",
+ "configurationError": "渠道管理配置不完整,请检查 Runtime 元数据及加密密钥。",
+ "requestFailed": "渠道请求失败,请检查 Runtime 连接后重试。",
+ "bound": "已绑定:{{name}}",
+ "unbound": "尚未绑定飞书机器人。",
+ "guidance": "私聊使用独立会话。在已授权群内 @ 当前机器人即可对话。",
+ "keyRequired": "绑定前请在 Runtime 配置持久的 CHANNEL_STATE_ENCRYPTION_KEY。",
+ "gateway": "消息网关",
+ "route": "入站路由",
+ "delivery": "回复投递",
+ "configured": "已配置",
+ "missing": "未配置",
+ "delivery_not_observed": "尚未观测",
+ "delivery_delivered": "已投递",
+ "delivery_retrying": "重试中",
+ "missingConfig": "缺少 Runtime 配置:{{fields}}",
+ "bind": "扫码绑定",
+ "newQr": "重新生成二维码",
+ "unbind": "解除绑定",
+ "confirmUnbind": "确认解绑此机器人?解绑后将停止接收新消息。",
+ "qrAlt": "机器人绑定二维码",
+ "openQr": "打开授权页面",
+ "uncertain": "注册过程已中断。重新开始前,请检查机器人绑定与 Gateway 状态。",
+ "bindingFailed": "绑定失败,请检查 Gateway 配置后重试。",
+ "chatId": "允许的群 ID",
+ "scope": "群会话策略",
+ "saveGroup": "保存群权限",
+ "noGroups": "尚未授权任何群。私聊无需群权限。",
+ "edit": "编辑",
+ "remove": "移除",
+ "removeGroup": "移除 {{name}}",
+ "scope_group_sender": "按发言人隔离",
+ "scope_group": "全群共享",
+ "scope_group_topic_sender": "按话题和发言人隔离",
+ "scope_group_topic": "按话题共享",
+ "status_PENDING": "等待扫码",
+ "status_SCANNED": "已扫码,等待授权",
+ "status_AUTHORIZED": "已授权,准备注册",
+ "status_REGISTERING": "正在注册机器人",
+ "status_BOUND": "机器人已绑定",
+ "status_FAILED": "绑定失败",
+ "status_EXPIRED": "二维码已过期",
+ "workspaceTitle": "消息渠道",
+ "workspaceDescription": "连接消息平台,让用户直接与此智能体对话,并管理访问权限。",
+ "provider_feishu": "飞书",
+ "provider_wecom": "企业微信",
+ "provider_dingtalk": "钉钉",
+ "comingSoon": "暂未开放",
+ "unavailableDescription": "{{name}}的绑定与消息配置暂未开放。",
+ "runtimeRequired": "请先将此智能体部署到 Runtime,再配置消息渠道。",
+ "bindingTitle": "机器人绑定",
+ "connected": "已绑定",
+ "notBound": "未绑定",
+ "gatewayDescription": "用于接收渠道消息的网关配置。",
+ "routeDescription": "将渠道消息转发给当前智能体的路由配置。",
+ "deliveryDescription": "实际观测到的回复投递状态,完成配置不代表已成功投递。",
+ "groupsTitle": "群聊权限",
+ "groupsDescription": "添加允许访问的群,并设置群内对话的共享范围。",
+ "providerBindingTitle": "绑定{{name}}",
+ "providerUnbound": "绑定{{name}}机器人,让用户通过消息与此智能体对话。",
+ "qrGuidance": "使用{{name}}扫码授权,Runtime 将保存机器人凭据并配置消息网关。",
+ "wecomGuidance": "使用企业微信扫码授权,或手动填写 Bot ID 和 Secret 绑定已有机器人。",
+ "wecomBotId": "Bot ID",
+ "wecomSecret": "Secret",
+ "bindCredentials": "绑定机器人",
+ "wecomScan": "扫码绑定",
+ "wecomAuthorizing": "等待企业微信授权…",
+ "cancelAuthorization": "取消授权",
+ "wecomPopupBlocked": "授权窗口被浏览器拦截,请允许弹窗后重试。",
+ "wecomAuthCancelled": "已取消企业微信授权,可以重新扫码。",
+ "wecomAuthTimeout": "企业微信授权已超时,请重新扫码。",
+ "wecomInvalidResult": "授权结果缺少 Bot ID 或 Secret,请重新授权或手动填写。",
+ "wecomAuthFailed": "企业微信授权失败,请重试或手动填写凭据。",
+ "dingtalkClientId": "Client ID",
+ "dingtalkClientSecret": "Client Secret",
+ "manualUpgrade": "当前镜像尚不支持{{name}}手动配置,请升级后使用;仍可使用极速配置。",
+ "pairingTitle": "消息渠道扫码配对",
+ "feishuPairingDescription": "如果已有连接机器人,飞书扫码并绑定成功后原有机器人会被替换,可选绑定存量机器人或新建机器人。",
+ "generatingQr": "正在生成配对二维码…",
+ "dingtalkGuidance": "使用钉钉扫码授权,或手动填写 Client ID 和 Client Secret 绑定已有机器人。",
+ "wecomPairingDescription": "将在企业微信授权窗口中重新扫码配对。新机器人绑定成功后会替换当前连接;取消授权或绑定失败不会解绑原机器人。",
+ "configurationMethod": "配置方式",
+ "quickSetup": "极速配置",
+ "manualSetup": "手动配置",
+ "feishuAppId": "App ID",
+ "feishuAppSecret": "App Secret",
+ "manualGuidance": "填写已有{{name}}机器人的应用凭据,连接到当前智能体。",
+ "manualReplacement": "绑定成功后将替换当前机器人;取消或绑定失败不会解除现有绑定。"
}
}
diff --git a/frontend/src/i18n/resources/zh-CN/workspaceTools.json b/frontend/src/i18n/resources/zh-CN/workspaceTools.json
index d472dec7d..0586fa23b 100644
--- a/frontend/src/i18n/resources/zh-CN/workspaceTools.json
+++ b/frontend/src/i18n/resources/zh-CN/workspaceTools.json
@@ -254,8 +254,13 @@
"deleteRuntime": "删除该 Runtime",
"loadingDetail": "读取详情…",
"agentStructure": "Agent 结构",
- "secretHidden": "敏感值已隐藏,点击显示",
- "revealSecret": "显示 {{key}} 的值",
+ "secretHidden": "敏感值不展示,可通过授权请求直接复制",
+ "revealSecret": "{{key}} 的敏感值",
+ "copySecret": "复制 {{key}} 的值",
+ "copy": "复制",
+ "copied": "已复制",
+ "copyFailed": "复制失败",
+ "notConfigured": "未配置",
"fields": {
"model": "模型",
"description": "描述",
@@ -283,9 +288,10 @@
"itemCount_one": "{{count}} 项",
"itemCount_other": "{{count}} 项",
"info": "Agent 信息",
- "infoAndTopology": "Agent 信息与拓扑",
+ "infoAndTopology": "Agent 信息与设置",
"loadingInfo": "正在读取 Agent 信息…",
"unnamedAgent": "未命名 Agent",
+ "agentsMd": "AGENTS.md",
"tools": "工具",
"toolList": "工具列表",
"studioTool": "Studio Tool",
@@ -296,11 +302,16 @@
"addStudioToolHere": "在此对话中添加 Studio 工具",
"skills": "技能",
"skillList": "技能列表",
+ "addSkill": "添加技能空间技能",
+ "addSkillHere": "向此会话添加技能空间技能",
+ "skillMountNextTurn": "挂载的版本将在下一 Turn 开始时冻结。",
"previewUnsupported": "暂不支持预览",
"sessionEnvironment": "会话环境",
"environment": "环境",
"agentCanvas": "Agent 画布",
"topology": "结构拓扑",
+ "nodeKinds": { "agent": "Agent", "sandbox": "沙箱", "worker-session": "Worker 会话", "skill-space": "技能空间", "skill": "技能", "studio-tool": "Studio 工具", "mcp": "MCP", "environment": "环境", "knowledge": "知识库", "sub-agent": "子 Agent" },
+ "nodeStatuses": { "configured": "已配置", "mounted": "已挂载", "running": "运行中", "paused": "已暂停" },
"viewCanvasFullscreen": "全屏查看 Agent 画布",
"viewFullscreen": "全屏查看",
"executionCanvas": "Agent 执行画布",
@@ -309,7 +320,28 @@
"close": "关闭",
"capabilitiesSubtitle": "能力与协作拓扑",
"closeInfo": "关闭 Agent 信息",
- "infoUnavailable": "暂时无法读取 Agent 信息。"
+ "infoUnavailable": "暂时无法读取 Agent 信息。",
+ "refresh": "刷新信息",
+ "loadingSkills": "正在加载绑定技能…",
+ "sessionSkills": "本会话临时技能",
+ "noBoundSpace": "当前智能体未绑定技能空间。",
+ "noBoundSkills": "绑定的技能空间暂无技能。",
+ "loadFailed": "加载失败,请刷新重试。",
+ "accessDenied": "无权读取此信息。",
+ "documentUnsupported": "当前 Runtime 不支持 Studio 读取 AGENTS.md,请升级 Runtime 镜像。",
+ "skillsUnsupported": "当前 Runtime 未提供技能空间绑定信息。",
+ "skillsDegraded": "技能信息不完整,请刷新重试。",
+ "documentEditHint": "编辑最新版本。保存会发布新版本到此技能空间,可能影响使用它的其他智能体。运行中的会话可能需要等待缓存刷新或新建会话后生效。请保留 frontmatter 中的 name。",
+ "documentLoading": "正在加载 SKILL.md…",
+ "documentWritable": "可编辑",
+ "documentReadOnly": "只读:此空间或当前权限不允许编辑",
+ "documentLoadFailed": "无法加载 SKILL.md,请刷新重试。",
+ "documentSaveFailed": "未确认保存成功,已保留编辑内容。重试前请重新打开,检查当前版本。",
+ "documentConflict": "技能已有新版本。请复制当前修改,再重新打开编辑器,避免覆盖。",
+ "discardDocument": "放弃未保存的修改?",
+ "keepEditing": "继续编辑",
+ "discardChanges": "放弃修改",
+ "saveDocument": "保存为新版本"
},
"sessionEnvironment": {
"mountFailed": "挂载环境失败",
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 3b59bf3b3..2e2691909 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -2,6 +2,8 @@ import "@fontsource/inter/latin-400.css";
import "@fontsource/inter/latin-500.css";
import "@fontsource/inter/latin-600.css";
import "./styles.css";
+import "./components/tokens/theme.css";
+import "./components/tokens/component-themes.css";
import "./i18n";
import React from "react";
@@ -11,6 +13,10 @@ import { PhotoProvider } from "react-photo-view";
import App from "./App";
import "react-photo-view/dist/react-photo-view.css";
+// Keep the existing Studio light by default, including portalled components.
+// Respect an explicit theme supplied by the host page.
+document.documentElement.dataset.theme ??= "light";
+
const PRELOAD_RECOVERY_KEY = "veadk.preloadRecoveryAt";
// An open tab can still reference hashed lazy chunks from the previous release.
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 01c61f508..fc6c98450 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -1707,6 +1707,34 @@ body {
.md li > ul, .md li > ol { margin: 0.15em 0; }
.md a { color: hsl(var(--primary)); text-decoration: underline; text-underline-offset: 2px; }
.md a:hover { opacity: 0.8; }
+.md a.sandbox-file-download {
+ display: flex;
+ width: fit-content;
+ align-items: center;
+ gap: 10px;
+ max-width: 100%;
+ min-height: 44px;
+ box-sizing: border-box;
+ margin: 6px 0;
+ padding: 10px 18px;
+ border: 1px solid hsl(var(--primary));
+ border-radius: 10px;
+ background: hsl(var(--primary));
+ color: hsl(var(--primary-foreground));
+ font-size: 16px;
+ font-weight: 600;
+ line-height: 1.5;
+ text-decoration: none;
+ vertical-align: middle;
+ overflow-wrap: anywhere;
+}
+.md a.sandbox-file-download > span:last-child { min-width: 0; }
+.md a.sandbox-file-download:focus-visible {
+ outline: 2px solid hsl(var(--ring));
+ outline-offset: 3px;
+}
+.md a.sandbox-file-download[aria-disabled="true"] { opacity: 0.65; cursor: wait; }
+
.md blockquote {
margin: 0 0 0.7em;
padding: 0.1em 0.9em;
@@ -3046,6 +3074,7 @@ body {
0 8px 32px rgb(0 0 0 / 0.028),
0 24px 72px 8px rgb(0 0 0 / 0.02);
}
+.composer--new-chat .composer-box--has-model { padding-bottom: 56px; }
.composer-input-stack {
position: relative;
display: flex;
@@ -3168,11 +3197,7 @@ body {
opacity: 1;
transform: scale(1);
}
-.composer--new-chat .comp-send {
- position: absolute;
- right: 10px;
- bottom: 10px;
-}
+.composer--new-chat .comp-send { position: static; }
.composer--new-chat .comp-send .icon {
width: 20px;
height: 20px;
@@ -3455,7 +3480,81 @@ body {
height: 36px;
flex-shrink: 0;
}
-.composer--new-chat .composer-submit-actions { display: contents; }
+
+.composer-model-select {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ color: hsl(var(--muted-foreground));
+ font-size: 11px;
+}
+.composer-turn-status { display: inline-flex; min-width: 0; align-items: center; }
+.composer-turn-state { color: hsl(var(--muted-foreground)); font-size: 11px; }
+
+.composer-model-select .new-chat-compact-select {
+ width: min(260px, 30vw);
+ max-width: 260px;
+}
+
+.composer-model-select .new-chat-compact-select__trigger {
+ width: 100%;
+ max-width: none;
+ min-height: 36px;
+ border: 0;
+ border-radius: 7px;
+ background: transparent;
+ color: hsl(var(--muted-foreground));
+ padding: 0 8px;
+ font: inherit;
+ font-size: 13px;
+}
+
+.composer-model-select .new-chat-compact-select__menu {
+ right: 0;
+ left: auto;
+ width: min(360px, calc(100vw - 32px));
+}
+.composer--new-chat .composer-submit-actions {
+ position: absolute;
+ z-index: 5;
+ left: auto;
+ right: 10px;
+ bottom: 10px;
+ display: flex;
+ width: auto;
+ max-width: calc(100% - 300px);
+ min-width: 0;
+ pointer-events: none;
+}
+.composer--new-chat .composer-submit-actions > * { pointer-events: auto; }
+.composer--new-chat .composer-model-select {
+ min-width: 0;
+ margin-left: auto;
+}
+.composer--new-chat .composer-model-select .new-chat-compact-select__trigger {
+ width: min(260px, 30vw);
+ min-width: 0;
+}
+@media (max-width: 640px) {
+ .composer--new-chat .new-chat-agent-picker { bottom: 54px; }
+ .composer--new-chat .composer-box--has-model { padding-bottom: 96px; }
+ .composer--new-chat .composer-submit-actions {
+ left: 10px;
+ right: 10px;
+ width: calc(100% - 20px);
+ max-width: none;
+ justify-content: flex-end;
+ }
+ .composer--new-chat .composer-model-select {
+ margin-left: 0;
+ margin-right: auto;
+ }
+ .composer--new-chat .composer-model-select,
+ .composer--new-chat .composer-model-select .new-chat-compact-select {
+ width: min(210px, 52vw);
+ max-width: min(210px, 52vw);
+ }
+}
.comp-send {
flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
@@ -3699,6 +3798,11 @@ body {
text-overflow: ellipsis;
white-space: nowrap;
}
+.token-usage-detail { margin-top: 10px; border-top: 1px solid hsl(var(--border)); padding-top: 8px; }
+.token-usage-detail__head, .token-usage-detail__row { display: grid; grid-template-columns: minmax(84px, 1fr) auto auto; gap: 12px; align-items: baseline; }
+.token-usage-detail__head { color: hsl(var(--muted-foreground)); font-size: 10px; margin-bottom: 4px; }
+.token-usage-detail__row { font-size: 11px; padding: 2px 0; }
+.token-usage-detail__row strong { font-variant-numeric: tabular-nums; text-align: right; }
@media (prefers-reduced-motion: reduce) {
.token-usage-indicator,
@@ -5795,6 +5899,21 @@ a.search-result { text-decoration: none; color: inherit; }
font-size: 12px;
line-height: 1.5;
}
+.topo-refresh {
+ align-self: flex-start;
+ padding: 4px 8px;
+ border: 1px solid hsl(var(--border));
+ border-radius: 6px;
+ background: hsl(var(--background));
+ color: hsl(var(--foreground));
+ font: inherit;
+ font-size: 12px;
+ cursor: pointer;
+}
+.topo-refresh:hover:not(:disabled) { background: hsl(var(--muted)); }
+.topo-refresh:disabled { opacity: .5; cursor: default; }
+.topo-refresh:focus-visible { outline: 2px solid hsl(var(--primary)); outline-offset: 2px; }
+.topo-data-error { font-size: 12px; color: hsl(var(--muted-foreground)); overflow-wrap: anywhere; }
.topo-agent-card {
flex: 0 0 auto;
min-width: 0;
@@ -5908,7 +6027,6 @@ a.search-result { text-decoration: none; color: inherit; }
line-height: 1;
white-space: nowrap;
}
-.topo-remove-capability:disabled,
.topo-capability-add-slot:disabled {
cursor: not-allowed;
opacity: 0.45;
@@ -5934,8 +6052,18 @@ a.search-result { text-decoration: none; color: inherit; }
outline-offset: 3px;
border-radius: 5px;
}
-.topo-tools-scroll { max-height: 104px; }
+.topo-agents-md-scroll { max-height: 132px; }
.topo-skills-scroll { max-height: 152px; }
+.topo-agents-md-content {
+ margin: 0;
+ color: hsl(var(--foreground));
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
+ "Liberation Mono", "Courier New", monospace;
+ font-size: 11px;
+ line-height: 1.5;
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+}
.session-environment-select {
display: flex;
min-width: 0;
@@ -6236,41 +6364,6 @@ a.search-result { text-decoration: none; color: inherit; }
outline: 2px solid hsl(var(--ring) / 0.38);
outline-offset: 2px;
}
-.topo-custom-badge {
- display: inline-flex;
- align-items: center;
- height: 17px;
- padding: 0 5px;
- flex-shrink: 0;
- border-radius: 5px;
- background: hsl(var(--primary) / 0.1);
- color: hsl(var(--primary));
- font-size: 9.5px;
- font-weight: 650;
- line-height: 1;
-}
-.topo-remove-capability {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 20px;
- height: 20px;
- margin-left: auto;
- padding: 0;
- flex-shrink: 0;
- border: 0;
- border-radius: 6px;
- background: transparent;
- color: hsl(var(--muted-foreground));
- font-size: 15px;
- line-height: 1;
- cursor: pointer;
-}
-.topo-remove-capability svg { width: 14px; height: 14px; }
-.topo-remove-capability:hover:not(:disabled) {
- background: hsl(var(--destructive) / 0.1);
- color: hsl(var(--destructive));
-}
.topo-skill-list {
display: flex;
min-width: 0;
@@ -6317,119 +6410,6 @@ a.search-result { text-decoration: none; color: inherit; }
font-size: 11.5px;
line-height: 1.5;
}
-.topo-topology {
- min-height: 0;
-}
-.topo-canvas-heading {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- margin-bottom: 9px;
-}
-.topo-canvas-expand,
-.topo-canvas-dialog-header button {
- display: inline-flex;
- width: 30px;
- height: 30px;
- align-items: center;
- justify-content: center;
- padding: 0;
- border: 0;
- border-radius: 8px;
- background: transparent;
- color: hsl(var(--muted-foreground));
- cursor: pointer;
-}
-.topo-canvas-expand:hover,
-.topo-canvas-dialog-header button:hover {
- background: hsl(var(--muted));
- color: hsl(var(--foreground));
-}
-.topo-canvas-expand:focus-visible,
-.topo-canvas-dialog-header button:focus-visible {
- outline: 2px solid hsl(var(--ring) / 0.5);
- outline-offset: 2px;
-}
-.topo-canvas-expand svg,
-.topo-canvas-dialog-header button svg {
- width: 16px;
- height: 16px;
-}
-.topo-canvas-preview {
- position: relative;
- min-height: 120px;
- flex: 1;
- overflow: hidden;
- border: 1px solid hsl(var(--border));
- border-radius: 12px;
- background: hsl(var(--background));
-}
-.topo-canvas-preview .abc-root,
-.topo-canvas-dialog-body .abc-root {
- width: 100%;
- height: 100%;
- min-width: 0;
- flex: 1 1 auto;
- border: 0;
-}
-.topo-canvas-preview .abc-minimap { display: none; }
-.topo-canvas-dialog {
- position: fixed;
- z-index: 1200;
- inset: 0;
- display: flex;
- min-width: 0;
- min-height: 0;
- flex-direction: column;
- background: hsl(var(--background));
-}
-.topo-canvas-dialog-header {
- display: flex;
- min-height: 64px;
- align-items: center;
- justify-content: space-between;
- gap: 24px;
- padding: 0 24px;
- border-bottom: 1px solid hsl(var(--border));
-}
-.topo-canvas-dialog-header > div {
- display: flex;
- min-width: 0;
- align-items: baseline;
- gap: 10px;
-}
-.topo-canvas-dialog-header strong {
- font-size: 15px;
- font-weight: 600;
-}
-.topo-canvas-dialog-header span {
- overflow: hidden;
- color: hsl(var(--muted-foreground));
- font-size: 13px;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.topo-canvas-dialog-body {
- display: flex;
- min-width: 0;
- min-height: 0;
- flex: 1;
- padding: 16px;
-}
-.topo-canvas-dialog-body .abc-canvas {
- overflow: hidden;
- border: 1px solid hsl(var(--border));
- border-radius: 16px;
-}
-@media (max-width: 640px) {
- .topo-canvas-dialog-header { padding: 0 16px; }
- .topo-canvas-dialog-body { padding: 8px; }
-}
-/* Keep the fullscreen control visually quiet inside the information rail. */
-.topo-canvas-heading .topo-section-count {
- flex-shrink: 0;
-}
@media (min-width: 1280px) {
.agent-info-trigger { display: none; }
.topo:not(.is-drawer) .topo-module-scroll { max-height: none; }
@@ -6952,7 +6932,7 @@ a.search-result { text-decoration: none; color: inherit; }
padding: 24px 20px;
}
-.studio-confirm-body p {
+.studio-confirm-body > div {
margin: 0;
color: hsl(var(--foreground));
font-size: 14px;
diff --git a/frontend/src/transcriptReconcile.ts b/frontend/src/transcriptReconcile.ts
new file mode 100644
index 000000000..fc8e9612f
--- /dev/null
+++ b/frontend/src/transcriptReconcile.ts
@@ -0,0 +1,29 @@
+import type { Turn } from "./blocks";
+
+function latestAssistantTurn(turns: Turn[]): Turn | undefined {
+ for (let index = turns.length - 1; index >= 0; index -= 1) {
+ if (turns[index].role === "assistant") return turns[index];
+ }
+ return undefined;
+}
+
+/**
+ * Replace the live transcript only when persistence has caught up with the
+ * exact reply that triggered the refresh and no newer local reply has started.
+ */
+export function reconcilePersistedTranscript(
+ current: Turn[],
+ persisted: Turn[],
+ expectedEventId: string,
+): Turn[] {
+ if (!expectedEventId) return current;
+ const persistedHasExpectedReply = persisted.some(
+ (turn) =>
+ turn.role === "assistant" && turn.meta?.eventId === expectedEventId,
+ );
+ if (!persistedHasExpectedReply) return current;
+
+ const latestLocalReply = latestAssistantTurn(current);
+ if (latestLocalReply?.meta?.eventId !== expectedEventId) return current;
+ return persisted;
+}
diff --git a/frontend/src/transcriptRows.ts b/frontend/src/transcriptRows.ts
index 46e7d14f5..2415b3ade 100644
--- a/frontend/src/transcriptRows.ts
+++ b/frontend/src/transcriptRows.ts
@@ -65,3 +65,72 @@ export function buildTranscriptRows(
return rows.map(({ groupKey, ...row }) => row);
}
+
+/** Derive MPA response regions without changing persisted/projected turns. */
+export function groupMpaTranscriptTurns(
+ turns: Turn[],
+ enabled: boolean,
+ requestBusy: boolean,
+): Turn[] {
+ if (!enabled) return turns;
+ const result: Turn[] = [];
+ for (let index = 0; index < turns.length;) {
+ const first = turns[index];
+ if (first.role !== "assistant") {
+ result.push(first);
+ index += 1;
+ continue;
+ }
+ const fragments: Turn[] = [];
+ while (index < turns.length && turns[index].role === "assistant") {
+ fragments.push(turns[index++]);
+ }
+ const sandboxAnswers = new Set(fragments.flatMap((turn) =>
+ turn.meta?.mpaFinalAnswer ? [turn.meta.mpaFinalAnswer.trim()] : []
+ ));
+ const visibleFragments = fragments.map((turn) => {
+ const answerText = turn.blocks.filter((block) => block.kind === "text")
+ .map((block) => block.text).join("").trim();
+ // This is a reversible view filter: later extensions restore all text.
+ if (!turn.meta?.mpaFinalAnswer && answerText && sandboxAnswers.has(answerText)) {
+ return { ...turn, blocks: turn.blocks.filter((block) => block.kind !== "text") };
+ }
+ return turn;
+ });
+ const answer = [...visibleFragments].reverse().find((turn) =>
+ turn.blocks.some((block) => block.kind === "text" && block.text.trim())
+ ) ?? fragments[fragments.length - 1];
+ const usage: Record = {};
+ const legacyUsage = new Map();
+ const hasLedger = fragments.some((turn) => turn.meta?.mpaUsage);
+ for (const fragment of fragments) {
+ for (const [key, count] of Object.entries(fragment.meta?.mpaUsage ?? {})) {
+ usage[key] = Math.max(usage[key] ?? 0, count);
+ }
+ const tokens = fragment.meta?.tokens;
+ if (typeof tokens === "number" && Number.isFinite(tokens) && tokens >= 0) {
+ const key = fragment.meta?.invocationId || "unidentified";
+ legacyUsage.set(key, Math.max(legacyUsage.get(key) ?? 0, tokens));
+ }
+ }
+ const counts = hasLedger ? Object.values(usage) : [...legacyUsage.values()];
+ const timestamps = fragments.flatMap((turn) => turn.meta?.ts ? [turn.meta.ts] : []);
+ result.push({
+ role: "assistant",
+ blocks: visibleFragments.flatMap((turn) => turn.blocks),
+ meta: {
+ ...answer.meta,
+ localId: first.meta?.localId,
+ // Do not label the consolidated MPA response as one of its subagents.
+ author: undefined,
+ streaming: (index === turns.length && requestBusy) ||
+ fragments.some((turn) => turn.meta?.streaming === true),
+ a2aStatus: fragments[fragments.length - 1].meta?.a2aStatus,
+ ts: timestamps.length ? Math.max(...timestamps) : undefined,
+ tokens: counts.length ? counts.reduce((total, count) => total + count, 0) : undefined,
+ ...(hasLedger ? { mpaUsage: usage } : {}),
+ },
+ });
+ }
+ return result;
+}
diff --git a/frontend/src/ui/AgentSelector.tsx b/frontend/src/ui/AgentSelector.tsx
index 43b3d51ca..e87bc78d8 100644
--- a/frontend/src/ui/AgentSelector.tsx
+++ b/frontend/src/ui/AgentSelector.tsx
@@ -296,7 +296,10 @@ export function AgentSelector({
connectSource: "navbar_picker",
});
setConnecting(rt.runtimeId);
- connectRuntime(rt.runtimeId, rt.name, rt.region)
+ connectRuntime(rt.runtimeId, rt.name, rt.region, rt.currentVersion, {
+ agentCategory: rt.agentCategory,
+ mpaInstanceId: rt.mpaInstanceId,
+ })
.then(async (agentId) => {
await onSelect(agentId);
operation.succeed({
@@ -914,7 +917,7 @@ function RuntimeDetailContent({ runtime }: { runtime: SelectedRuntime }) {
{detail.envs.map((e) => (
{e.key}
- {e.value}
+ {e.sensitive ? "••••••••" : e.value}
))}
diff --git a/frontend/src/ui/AgentTopology.tsx b/frontend/src/ui/AgentTopology.tsx
index 57939d711..8c4bb94ce 100644
--- a/frontend/src/ui/AgentTopology.tsx
+++ b/frontend/src/ui/AgentTopology.tsx
@@ -1,84 +1,19 @@
-import { useEffect, useRef, useState, type RefObject } from "react";
-import type { TFunction } from "i18next";
+import { useEffect, useState, type RefObject } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
-import { Maximize2, X } from "lucide-react";
+import { X } from "lucide-react";
import type {
AgentInfo,
- AgentNode,
SessionEnvironmentMountSelection,
- StudioBffTool,
StudioEnvironment,
StudioWorkspace,
} from "../adk/client";
-import { AgentBuildCanvas } from "../create/AgentBuildCanvas";
-import {
- modelConfigurationFromRuntime,
- modelNameFromRuntime,
-} from "../create/runtimeModelName";
-import { emptyDraft, type AgentDraft } from "../create/types";
-import {
- studioToolLabel,
- StudioToolDialog,
-} from "./StudioToolDialog";
+import { SkillSpacePicker } from "../create/SkillSpacePicker";
+import type { SelectedSkill } from "../create/skills/types";
+import { modelNameFromRuntime } from "../create/runtimeModelName";
import { TextShimmer } from "./text-shimmer/TextShimmer";
import { SessionEnvironmentPicker } from "./SessionEnvironmentPicker";
-
-function totalNodes(node: AgentNode): number {
- return 1 + node.children.reduce((count, child) => count + totalNodes(child), 0);
-}
-
-function nodeId(node: AgentNode): string {
- return node.id || node.name;
-}
-
-/** Older generated runtimes exposed only Python variable names. Keep those
- * identifiers for event matching, but do not leak them into the UI. */
-function legacyDisplayName(node: AgentNode, isRoot: boolean, t: TFunction): string {
- const id = nodeId(node);
- if (node.id && node.name && node.name !== id) return node.name;
- if (isRoot && id === "agent") return t("agentTopology.mainAgent");
- const subAgent = /^agent_sub_(\d+)$/.exec(id);
- return subAgent
- ? t("agentTopology.subAgent", { index: subAgent[1] })
- : node.name || id;
-}
-
-function normalizeLegacyNames(
- node: AgentNode,
- t: TFunction,
- isRoot = true,
-): AgentNode {
- return {
- ...node,
- id: nodeId(node),
- name: legacyDisplayName(node, isRoot, t),
- children: node.children.map((child) => normalizeLegacyNames(child, t, false)),
- };
-}
-
-function graphNodeToCanvasDraft(node: AgentNode): AgentDraft {
- const fallback = emptyDraft();
- const runtimeModel = modelConfigurationFromRuntime(node.model);
- return {
- ...fallback,
- name: node.name,
- description: node.description,
- instruction: node.instruction || fallback.instruction,
- agentType: node.type,
- modelName: runtimeModel.modelName,
- modelProvider: runtimeModel.modelProvider,
- tools: node.tools ?? [],
- skills: (node.skills ?? []).map((skill) => skill.name),
- subAgents: node.children.map(graphNodeToCanvasDraft),
- };
-}
-
-function uniqueValues(values: string[]): string[] {
- return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
-}
-
-const INTERNAL_AGENT_TOOL_NAMES = new Set(["StudioExternalToolset"]);
+import type { BoundSkillState } from "./mpa-agent-info/MpaAgentInfoRail";
function uniqueSkills(skills: AgentInfo["skills"]): AgentInfo["skills"] {
return [
@@ -112,21 +47,15 @@ function ModuleTitle({ title, count }: ModuleTitleProps) {
);
}
-interface AgentInfoPanelProps {
- appName: string;
+export interface AgentInfoPanelProps {
+ boundSkills?: BoundSkillState;
+ onRefresh?: () => void;
+ onOpenSkill?: (index: number) => void;
info: AgentInfo | null;
loading: boolean;
- activeAgent: string;
- seenAgents: Set;
- execPath?: string[];
variant?: "rail" | "drawer";
- studioTools?: StudioBffTool[];
- selectedStudioToolIds?: readonly string[];
- managedStudioToolIds?: readonly string[];
- studioToolsLoading?: boolean;
- studioToolsDisabled?: boolean;
- studioToolsUnavailableReason?: string;
- onStudioToolsChange?: (selectedIds: string[]) => void;
+ selectedSessionSkills?: readonly SelectedSkill[];
+ onSessionSkillsChange?: (skills: SelectedSkill[]) => void;
environments?: StudioEnvironment[];
workspaces?: StudioWorkspace[];
selectedEnvironments?: readonly SessionEnvironmentMountSelection[];
@@ -145,17 +74,14 @@ interface AgentInfoPanelProps {
* right whitespace. The parent owns metadata loading so this display component
* never issues a duplicate `/web/agent-info` request. */
export function AgentInfoPanel({
- appName,
info,
loading,
+ boundSkills,
+ onRefresh,
+ onOpenSkill,
variant = "rail",
- studioTools = [],
- selectedStudioToolIds = [],
- managedStudioToolIds = [],
- studioToolsLoading = false,
- studioToolsDisabled = false,
- studioToolsUnavailableReason = "",
- onStudioToolsChange,
+ selectedSessionSkills = [],
+ onSessionSkillsChange,
environments = [],
workspaces = [],
selectedEnvironments = [],
@@ -167,26 +93,7 @@ export function AgentInfoPanel({
onEnvironmentsRefresh,
}: AgentInfoPanelProps) {
const { t } = useTranslation("workspaceTools");
- const [dialog, setDialog] = useState<"tool" | null>(null);
- const [canvasExpanded, setCanvasExpanded] = useState(false);
- const expandCanvasRef = useRef(null);
- const closeCanvas = () => {
- setCanvasExpanded(false);
- window.requestAnimationFrame(() => expandCanvasRef.current?.focus());
- };
- useEffect(() => {
- if (!canvasExpanded) return;
- const previousOverflow = document.body.style.overflow;
- const handleKeyDown = (event: KeyboardEvent) => {
- if (event.key === "Escape") closeCanvas();
- };
- document.body.style.overflow = "hidden";
- document.addEventListener("keydown", handleKeyDown);
- return () => {
- document.body.style.overflow = previousOverflow;
- document.removeEventListener("keydown", handleKeyDown);
- };
- }, [canvasExpanded]);
+ const [dialog, setDialog] = useState<"skill" | null>(null);
if (loading && !info) {
return (
)}
+ {onRefresh && (
+
+ )}
-
-
+
+
- {tools.length > 0 ? (
-
- {tools.map((tool) => (
-
-
-
- {tool.label}
- {tool.name}
-
- {tool.custom && {t("agentTopology.studioTool")}}
-
- {tool.custom && tool.removable && (
-
- )}
-
- ))}
+ {isMpa && documentStatus !== "ready" ? (
+
+ {t(`agentTopology.${documentStatus === "forbidden" ? "accessDenied" : documentStatus === "unsupported" ? "documentUnsupported" : "loadFailed"}`)}
+ ) : agentsInstruction.trim() ? (
+
{agentsInstruction}
) : (
{t("agentTopology.notConfigured")}
)}
- {canCustomize && (
-
-
-
- )}
- {!info.skillsPreviewSupported ? (
+ {isMpa && boundSkills?.loading && !skills.length ? (
+
{t("agentTopology.loadingSkills")}
+ ) : !isMpa && !info.skillsPreviewSupported ? (
{t("agentTopology.previewUnsupported")}
) : skills.length > 0 ? (
- {skills.map((skill) => (
+ {skills.map((skill, index) => (
- {skill.name}
+ {isMpa && onOpenSkill && boundSkills?.skills[index]?.skillId && !boundSkills.skills[index].lookupByName ? (
+
+ ) : {skill.name}}
{skill.description && (
@@ -370,10 +206,26 @@ export function AgentInfoPanel({
))}
- ) : (
-
{t("agentTopology.notConfigured")}
- )}
+ ) : !skillError ? (
+
{t(isMpa ? info.mpa?.skillSpaces.length ? "agentTopology.noBoundSkills" : "agentTopology.noBoundSpace" : "agentTopology.notConfigured")}
+ ) : null}
+ {skillError &&
{t(`agentTopology.${errorKey}`)}
}
+ {isMpa && boundSkills?.loading && skills.length > 0 &&
{t("agentTopology.loadingSkills")}}
+ {!isMpa && onSessionSkillsChange && (
+
+
+
+ )}
{(onEnvironmentsChange || selectedEnvironments.length > 0) && (
@@ -392,69 +244,41 @@ export function AgentInfoPanel({
/>
)}
-
-
-
-
-
-
-
- {renderCanvas(`conversation-canvas:${appName}`)}
-
-
- {dialog === "tool" && onStudioToolsChange && (
-
- !baseToolNames.has(tool.id) && !managedIds.has(tool.id)
- )}
- selectedIds={selectedStudioToolIds}
- loading={studioToolsLoading}
- disabled={studioToolsDisabled}
- unavailableReason={studioToolsUnavailableReason}
- onChange={onStudioToolsChange}
- onClose={() => setDialog(null)}
- />
- )}
-
- {canvasExpanded && createPortal(
-
-
-
- {t("agentTopology.executionCanvas")}
- {info.name}
-
+ {dialog === "skill" && onSessionSkillsChange && createPortal(
+
-
-
- {renderCanvas(`conversation-canvas-fullscreen:${appName}`)}
-
- ,
- document.body,
- )}
+ className="studio-tool-dialog-scrim"
+ aria-label={t("agentTopology.close")}
+ onClick={() => setDialog(null)}
+ />
+
+
,
+ document.body,
+ )}
+
>
);
}
@@ -476,19 +300,8 @@ function CloseIcon() {
}
export function AgentInfoDrawer({
- appName,
info,
loading,
- activeAgent,
- seenAgents,
- execPath,
- studioTools,
- selectedStudioToolIds,
- managedStudioToolIds,
- studioToolsLoading,
- studioToolsDisabled,
- studioToolsUnavailableReason,
- onStudioToolsChange,
environments,
workspaces,
selectedEnvironments,
@@ -501,19 +314,8 @@ export function AgentInfoDrawer({
onClose,
returnFocusRef,
}: {
- appName: string;
info: AgentInfo | null;
loading: boolean;
- activeAgent: string;
- seenAgents: Set;
- execPath: string[];
- studioTools?: StudioBffTool[];
- selectedStudioToolIds?: readonly string[];
- managedStudioToolIds?: readonly string[];
- studioToolsLoading?: boolean;
- studioToolsDisabled?: boolean;
- studioToolsUnavailableReason?: string;
- onStudioToolsChange?: (selectedIds: string[]) => void;
environments?: StudioEnvironment[];
workspaces?: StudioWorkspace[];
selectedEnvironments?: readonly SessionEnvironmentMountSelection[];
@@ -573,19 +375,8 @@ export function AgentInfoDrawer({
{info || loading ? (
div,
+.aw-session-config-card {
+ min-width: 0;
+ padding: 14px;
+ border: 1px solid hsl(var(--border));
+ border-radius: 12px;
+ background: hsl(var(--panel));
+}
+
+.aw-session-config-grid dt,
+.aw-session-config-field span {
+ color: hsl(var(--muted-foreground));
+ font-size: 11.5px;
+ font-weight: 550;
+}
+
+.aw-session-config-grid dd {
+ margin: 6px 0 0;
+ overflow-wrap: anywhere;
+ color: hsl(var(--foreground));
+ font-size: 13px;
+ font-weight: 650;
+}
+
+.aw-session-config-card {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(180px, 280px) auto;
+ align-items: end;
+ gap: 14px;
+}
+
+.aw-session-config-card h4,
+.aw-session-config-card p {
+ margin: 0;
+}
+
+.aw-session-config-card h4 {
+ color: hsl(var(--foreground));
+ font-size: 13.5px;
+ font-weight: 650;
+}
+
+.aw-session-config-card p,
+.aw-session-config-value small {
+ color: hsl(var(--muted-foreground));
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.aw-profile-config .aw-session-config-card {
+ grid-template-columns: minmax(0, 1fr);
+ align-items: start;
+}
+
+.aw-profile-config .aw-session-config-card p {
+ max-height: 180px;
+ overflow: auto;
+ white-space: pre-wrap;
+}
+
+.aw-profile-config-list {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ color: hsl(var(--muted-foreground));
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.aw-profile-config-list span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.aw-session-config-field {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.aw-session-config-field input {
+ width: 100%;
+ height: 34px;
+ box-sizing: border-box;
+ padding: 0 10px;
+ border: 1px solid hsl(var(--border));
+ border-radius: 7px;
+ background: hsl(var(--background));
+ color: hsl(var(--foreground));
+ font: inherit;
+ font-size: 12.5px;
+}
+
+.aw-session-config-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.aw-session-config-actions button:disabled,
+.aw-session-config-state button:disabled,
+.aw-session-config-upgrade button:disabled {
+ cursor: default;
+ opacity: 0.5;
+}
+
+.aw-session-config-value {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+
+.aw-session-config-value span {
+ color: hsl(var(--foreground));
+ font-size: 13px;
+ font-weight: 650;
+}
+
+.aw-session-config-value small {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.aw-session-config-state,
+.aw-session-config-notice,
+.aw-session-config-upgrade {
+ min-height: 52px;
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 14px;
+ border: 1px solid hsl(var(--border));
+ border-radius: 12px;
+ background: hsl(var(--secondary) / 0.16);
+ color: hsl(var(--muted-foreground));
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.aw-session-config-state {
+ justify-content: center;
+}
+
+.aw-session-config-state.is-error {
+ justify-content: space-between;
+ color: hsl(var(--destructive));
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+.aw-session-config-notice {
+ color: hsl(142 62% 30%);
+}
+
+.aw-session-config-upgrade {
+ justify-content: space-between;
+ background: hsl(var(--primary) / 0.07);
+ color: hsl(var(--foreground));
+}
+
.aw-basic-actions {
position: absolute;
z-index: 8;
@@ -3068,6 +3326,22 @@
grid-template-columns: minmax(0, 1fr);
}
+ .aw-session-config-grid,
+ .aw-session-config-card {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .aw-session-config-actions,
+ .aw-session-config-upgrade,
+ .aw-session-config-state.is-error {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .aw-session-config-actions {
+ justify-content: flex-start;
+ }
+
.aw-version-row {
grid-template-columns: minmax(0, 1fr);
align-items: start;
diff --git a/frontend/src/ui/AgentWorkspace.tsx b/frontend/src/ui/AgentWorkspace.tsx
index dbeac3644..5a2c8be13 100644
--- a/frontend/src/ui/AgentWorkspace.tsx
+++ b/frontend/src/ui/AgentWorkspace.tsx
@@ -35,14 +35,24 @@ import {
getAgentFeedbackCases,
getAgentOptimizations,
getGithubDeliveryVersions,
+ getMpaAgentDeletePreview,
+ getMpaAgentView,
+ getMpaProfileStatus,
+ getMpaRuntimeConsoleRuns,
+ getMpaRuntimeConsoleTrace,
+ getMpaSessionExecutionConfig,
getRuntimeAgentInfo,
getRuntimeDetail,
getRuntimeUpdateCapability,
+ mpaProfileFromAgentDraft,
+ patchMpaSessionExecutionConfig,
probeRuntimeA2a,
probeRuntimeApps,
prefetchRuntimeAgentInfo,
prefetchRuntimeDetail,
revealRuntimeApiKey,
+ startMpaAgentOperation,
+ upgradeMpaSessionProfile,
type AgentFeedbackCasesResponse,
type AgentFeedbackCase,
type AgentFeedbackSource,
@@ -55,6 +65,15 @@ import {
type AgentUsageResponse,
type GithubDeliveryVersionsResult,
type GithubDeliveryVersion,
+ MpaExecutionConfigRequestError,
+ type MpaAgentDeletePreview,
+ type MpaAgentView,
+ type MpaExecutionConfigMode,
+ type MpaProfilePayload,
+ type MpaProfileStatus,
+ type MpaRuntimeConsoleRun,
+ type MpaRuntimeTraceResponse,
+ type MpaSessionExecutionConfig,
type RuntimeA2aIntegration,
RuntimeProbeError,
type RuntimeDetail,
@@ -85,7 +104,7 @@ import { TextShimmer } from "./text-shimmer/TextShimmer";
import "./AgentWorkspace.css";
type WorkspaceView = "library" | "evaluation";
-type AgentSection = "basic" | "usage" | "evaluations" | "optimizations" | "integrations" | "versions";
+type AgentSection = "basic" | "profileConfig" | "sessionConfig" | "usage" | "diagnostics" | "evaluations" | "optimizations" | "integrations" | "versions";
type IntegrationProtocol = "api-server" | "a2a";
type EvaluationSection = "config" | "history";
type CaseKind = "good" | "bad";
@@ -94,27 +113,31 @@ type OptimizationModule = AgentOptimizationModule;
type AgentCase = AgentFeedbackCase & { tag?: string };
type OptimizationGroup = AgentOptimizationGroup;
+type AgentCategory = "general" | "mpa";
type DeleteConfirmTarget =
| {
kind: "selection";
title: string;
- description: string;
+ description: ReactNode;
confirmLabel: string;
+ canConfirm?: boolean;
agents: AgentEntry[];
drafts: WorkspaceAgentDraft[];
}
| {
kind: "agent";
title: string;
- description: string;
+ description: ReactNode;
confirmLabel: string;
+ canConfirm?: boolean;
agent: AgentEntry;
}
| {
kind: "draft";
title: string;
- description: string;
+ description: ReactNode;
confirmLabel: string;
+ canConfirm?: boolean;
draft: WorkspaceAgentDraft;
};
@@ -283,7 +306,10 @@ function evaluationText(value: string, t: TFunction): string {
const AGENT_SECTIONS: AgentSection[] = [
"basic",
+ "profileConfig",
+ "sessionConfig",
"usage",
+ "diagnostics",
"evaluations",
"optimizations",
"integrations",
@@ -324,6 +350,185 @@ interface RevealedApiKey {
value: string;
}
+interface SessionExecutionConfigState {
+ requestKey: string;
+ value: MpaSessionExecutionConfig;
+}
+
+interface MpaProfileStatusState {
+ requestKey: string;
+ value: MpaProfileStatus;
+}
+
+interface MpaProfileApplyState {
+ saving: boolean;
+ error: string;
+ notice: string;
+}
+
+interface MpaAgentViewState {
+ requestKey: string;
+ value: MpaAgentView;
+}
+
+interface MpaDiagnosticsState {
+ requestKey: string;
+ runs: MpaRuntimeConsoleRun[];
+ trace: MpaRuntimeTraceResponse | null;
+}
+
+export interface MpaProfileEditTarget {
+ draft: AgentDraft;
+ runtimeId: string;
+ mpaInstanceId: string;
+ name: string;
+ region: string;
+ appName?: string;
+ currentVersion?: number | null;
+ runtimeRevision?: string;
+}
+
+function recordId(value: unknown): string {
+ if (!value || typeof value !== "object") return "";
+ const id = (value as { id?: unknown }).id;
+ return typeof id === "string" ? id : "";
+}
+
+function effectiveModelId(config: MpaSessionExecutionConfig | null): string {
+ return recordId(config?.effectiveRefs?.model);
+}
+
+function executionOverrideMode(
+ config: MpaSessionExecutionConfig | null,
+ category: string,
+): MpaExecutionConfigMode | "" {
+ const value = config?.overrides?.[category];
+ if (!value || typeof value !== "object") return "";
+ const mode = (value as { mode?: unknown }).mode;
+ return mode === "replace" || mode === "clear" || mode === "inherit" ? mode : "";
+}
+
+function executionMcpIds(config: MpaSessionExecutionConfig | null): string[] {
+ const values = config?.effectiveRefs?.mcpServers;
+ if (!Array.isArray(values)) return [];
+ return values.map(recordId).filter(Boolean);
+}
+
+function mpaDeleteBlockerLabel(code: string, t: TFunction<"ui">): string {
+ switch (code) {
+ case "active_operation":
+ return t("agentWorkspace.mpaDeleteBlockerActiveOperation");
+ case "active_sessions":
+ return t("agentWorkspace.mpaDeleteBlockerActiveSessions");
+ case "binding_ambiguous":
+ return t("agentWorkspace.mpaDeleteBlockerBindingAmbiguous");
+ case "runtime_missing":
+ return t("agentWorkspace.mpaDeleteBlockerRuntimeMissing");
+ case "orphan_runtime":
+ return t("agentWorkspace.mpaDeleteBlockerOrphanRuntime");
+ default:
+ return code;
+ }
+}
+
+function mpaSafeErrorMessage(code: string | undefined, t: TFunction<"ui">): string {
+ switch (code) {
+ case "runtime_legacy_auth_unsupported":
+ return t("agentWorkspace.mpaRuntimeLegacyAuthUnsupported");
+ case "profile_not_found":
+ return t("agentWorkspace.mpaRuntimeOrphan");
+ case "binding_ambiguous":
+ return t("agentWorkspace.mpaRuntimeBindingAmbiguous");
+ case "runtime_missing":
+ return t("agentWorkspace.mpaRuntimeMissing");
+ default:
+ return code ?? "";
+ }
+}
+
+function MpaDeletePreviewDetails({
+ preview,
+ t,
+}: {
+ preview: MpaAgentDeletePreview;
+ t: TFunction<"ui">;
+}) {
+ const blockers = preview.blockers.map((code) => mpaDeleteBlockerLabel(code, t));
+ return (
+
+
{t("agentWorkspace.mpaDeletePreviewSummary")}
+
+
+
- {t("agentWorkspace.mpaDeletePreviewSessions")}
+ - {preview.sessionCounts.total}
+
+
+
- {t("agentWorkspace.mpaDeletePreviewActiveSessions")}
+ - {preview.sessionCounts.active}
+
+
+
- {t("agentWorkspace.mpaDeletePreviewIdleSessions")}
+ - {preview.sessionCounts.idle}
+
+
+ {blockers.length > 0 ? (
+
+
{t("agentWorkspace.mpaDeleteBlocked")}
+
+ {blockers.map((item) => (
+ - {item}
+ ))}
+
+
+ ) : (
+
+ {preview.cleanupPlan.map((stage) => (
+ -
+ {t(`agentWorkspace.mpaDeleteStage.${stage.stage}`, {
+ count: stage.count,
+ })}
+
+ ))}
+
+ )}
+
+ );
+}
+
+function executionConfigErrorMessage(error: unknown, fallback: string): string {
+ if (error instanceof MpaExecutionConfigRequestError && error.status === 412) {
+ return fallback;
+ }
+ return error instanceof Error ? error.message : String(error);
+}
+
+function mcpServerCount(config: MpaSessionExecutionConfig | null): number {
+ return executionMcpIds(config).length;
+}
+
+function formatMcpServerIds(
+ config: MpaSessionExecutionConfig | null,
+ t: TFunction,
+): string {
+ const ids = executionMcpIds(config);
+ return ids.length ? ids.join(", ") : t("agentWorkspace.none");
+}
+
+function isCategoryOverridden(
+ config: MpaSessionExecutionConfig | null,
+ category: string,
+): boolean {
+ return Boolean(config?.overrides?.[category]);
+}
+
+function profileUpgradeIdempotencyKey(
+ runtimeId: string,
+ sessionId: string,
+ targetProfileRevision: number,
+): string {
+ return `mpa-session-profile-upgrade:${runtimeId}:${sessionId}:${targetProfileRevision}`;
+}
+
function endpointPath(endpoint: string, path: string): string {
return endpoint ? `${endpoint.replace(/\/+$/, "")}${path}` : "";
}
@@ -578,6 +783,547 @@ function IntegrationPanel({
);
}
+function summarizeProfileList(
+ items: Array> | undefined,
+ fallback: string,
+): string {
+ const values = (items ?? []).flatMap((item) => {
+ const value = item.name ?? item.id ?? item.skillId ?? item.source;
+ return typeof value === "string" && value.trim() ? [value.trim()] : [];
+ });
+ return values.length ? values.join(", ") : fallback;
+}
+
+function canonicalJson(value: unknown): string {
+ if (Array.isArray(value)) {
+ return `[${value.map(canonicalJson).join(",")}]`;
+ }
+ if (value && typeof value === "object") {
+ const fields = Object.entries(value as Record)
+ .filter(([, fieldValue]) => fieldValue !== undefined)
+ .sort(([left], [right]) => left.localeCompare(right));
+ return `{${fields.map(([key, fieldValue]) =>
+ `${JSON.stringify(key)}:${canonicalJson(fieldValue)}`).join(",")}}`;
+ }
+ return JSON.stringify(value);
+}
+
+async function profileIdempotencyKey(
+ runtimeId: string,
+ operationKind: "create" | "update",
+ profile: MpaProfilePayload,
+): Promise {
+ const bytes = new TextEncoder().encode(canonicalJson(profile));
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
+ const hex = Array.from(new Uint8Array(digest), (byte) =>
+ byte.toString(16).padStart(2, "0")).join("");
+ return `mpa-profile:${runtimeId}:${operationKind}:${hex}`;
+}
+
+function MpaProfileConfigPanel({
+ profile,
+ profileStatus,
+ runtimeReady,
+ disabledReason,
+ saving,
+ error,
+ notice,
+ onApply,
+ onEditProfile,
+ onRefresh,
+}: {
+ profile: MpaProfilePayload;
+ profileStatus: MpaProfileStatus | null;
+ runtimeReady: boolean;
+ disabledReason: string;
+ saving: boolean;
+ error: string;
+ notice: string;
+ onApply: () => void;
+ onEditProfile: () => void;
+ onRefresh: () => void;
+}) {
+ const { t } = useTranslation("ui");
+ const applied = profileStatus?.status === "applied";
+ const canApply = runtimeReady && !saving;
+ return (
+
+
+
{t("agentWorkspace.profileConfigTitle")}
+
{t("agentWorkspace.profileConfigDescription")}
+
+
+ {disabledReason ? (
+ {disabledReason}
+ ) : (
+ <>
+ {error && (
+
+ {error}
+
+
+ )}
+ {notice && !error && (
+
+ {notice}
+
+ )}
+
+
+
+
+
+
+
+
+
- {t("agentWorkspace.profileStatus")}
+ - {applied ? t("agentWorkspace.profileApplied") : t("agentWorkspace.profileNotApplied")}
+
+
+
- {t("agentWorkspace.latestProfileRevision")}
+ - {profileStatus?.profileRevision ?? t("agentWorkspace.notAvailable")}
+
+
+
- {t("agentWorkspace.modelId")}
+ - {String(profile.model?.id || t("agentWorkspace.notAvailable"))}
+
+
+
- ETag
+ - {profileStatus?.etag || t("agentWorkspace.notAvailable")}
+
+
+
+ {!applied && (
+
+ {t("agentWorkspace.profileNotAppliedDescription")}
+
+
+ )}
+
+ {applied && (
+
+
+
+ )}
+
+
+
+
{t("agentWorkspace.profilePrompt")}
+
{profile.system || t("agentWorkspace.notAvailable")}
+
+
+
+
+
+
{t("agentWorkspace.profileResources")}
+
+ {t("agentWorkspace.profileResourceSummary", {
+ tools: profile.tools.length,
+ skills: profile.skills.length,
+ mcp: profile.mcpServers.length,
+ })}
+
+
+
+
+ {t("agentSelector.tools")}: {summarizeProfileList(profile.tools, t("agentWorkspace.notAvailable"))}
+
+
+ {t("agentSelector.skills")}: {summarizeProfileList(profile.skills, t("agentWorkspace.notAvailable"))}
+
+
+ MCP: {summarizeProfileList(profile.mcpServers, t("agentWorkspace.notAvailable"))}
+
+
+
+ >
+ )}
+
+ );
+}
+
+function SessionExecutionConfigPanel({
+ config,
+ profileStatus,
+ loadable,
+ disabledReason,
+ loading,
+ saving,
+ upgrading,
+ error,
+ notice,
+ modelValue,
+ modelDirty,
+ profileUpgradeAvailable,
+ onModelChange,
+ onSaveModel,
+ onInheritModel,
+ onClearMcp,
+ onInheritMcp,
+ onUpgradeProfile,
+ onRefresh,
+}: {
+ config: MpaSessionExecutionConfig | null;
+ profileStatus: MpaProfileStatus | null;
+ loadable: boolean;
+ disabledReason: string;
+ loading: boolean;
+ saving: boolean;
+ upgrading: boolean;
+ error: string;
+ notice: string;
+ modelValue: string;
+ modelDirty: boolean;
+ profileUpgradeAvailable: boolean;
+ onModelChange: (value: string) => void;
+ onSaveModel: () => void;
+ onInheritModel: () => void;
+ onClearMcp: () => void;
+ onInheritMcp: () => void;
+ onUpgradeProfile: () => void;
+ onRefresh: () => void;
+}) {
+ const { t } = useTranslation("ui");
+ const busy = loading || saving || upgrading;
+ const modelMode = executionOverrideMode(config, "model");
+ const mcpMode = executionOverrideMode(config, "mcpServers");
+ const currentModel = effectiveModelId(config);
+ const latestRevision = profileStatus?.status === "applied"
+ ? profileStatus.profileRevision
+ : null;
+ return (
+
+
+
{t("agentWorkspace.sessionConfigTitle")}
+
{t("agentWorkspace.sessionConfigDescription")}
+
+
+ {!loadable ? (
+ {disabledReason}
+ ) : loading && !config ? (
+
+
+ {t("agentWorkspace.loadingSessionConfig")}
+
+ ) : (
+ <>
+ {error && (
+
+ {error}
+
+
+ )}
+ {notice && !error && (
+
+ {notice}
+
+ )}
+ {config && (
+ <>
+
+
+
- {t("agentWorkspace.sessionConfigRevision")}
+ - {config.revision}
+
+
+
- {t("agentWorkspace.sessionProfileRevision")}
+ - {config.profileRevision}
+
+
+
- {t("agentWorkspace.latestProfileRevision")}
+ - {latestRevision ?? t("agentWorkspace.notAvailable")}
+
+
+
- ETag
+ - {config.etag || t("agentWorkspace.notAvailable")}
+
+
+
+ {profileUpgradeAvailable && latestRevision != null && (
+
+
+ {t("agentWorkspace.sessionProfileUpgradeAvailable", {
+ current: config.profileRevision,
+ target: latestRevision,
+ })}
+
+
+
+ )}
+
+
+
+
{t("agentWorkspace.sessionModelOverride")}
+
+ {modelMode
+ ? t(`agentWorkspace.executionConfigMode.${modelMode}`)
+ : t("agentWorkspace.executionConfigMode.inherit")}
+
+
+
+
+
+
+
+
+
+
+
+
{t("agentWorkspace.sessionMcpServers")}
+
+ {mcpMode
+ ? t(`agentWorkspace.executionConfigMode.${mcpMode}`)
+ : t("agentWorkspace.executionConfigMode.inherit")}
+
+
+
+
+ {t("agentWorkspace.mcpServerCount", {
+ count: mcpServerCount(config),
+ })}
+
+
+ {formatMcpServerIds(config, t)}
+
+
+
+
+
+
+
+ >
+ )}
+ >
+ )}
+
+ );
+}
+
+function correlationValue(
+ correlation: Record | undefined,
+ key: string,
+): string {
+ const value = correlation?.[key];
+ return value == null || value === "" ? "" : String(value);
+}
+
+function workerCorrelationValue(
+ correlation: Record | undefined,
+ key: string,
+): string {
+ const worker = correlation?.worker;
+ if (!worker || typeof worker !== "object") return "";
+ const value = (worker as Record)[key];
+ return value == null || value === "" ? "" : String(value);
+}
+
+function formatDurationMs(value: number | null | undefined, t: TFunction): string {
+ if (typeof value !== "number" || !Number.isFinite(value)) {
+ return t("agentWorkspace.notAvailable");
+ }
+ return `${Math.round(value)} ms`;
+}
+
+function MpaDiagnosticsPanel({
+ data,
+ loadable,
+ disabledReason,
+ loading,
+ error,
+ onRefresh,
+ locale,
+}: {
+ data: MpaDiagnosticsState | null;
+ loadable: boolean;
+ disabledReason: string;
+ loading: boolean;
+ error: string;
+ onRefresh: () => void;
+ locale: string;
+}) {
+ const { t } = useTranslation("ui");
+ const runs = data?.runs ?? [];
+ const trace = data?.trace ?? null;
+ const latestRun = runs[0];
+ const correlation = trace?.correlation ?? {};
+ const fields = [
+ ["mpaInstanceId", t("agentWorkspace.mpaInstanceId")],
+ ["profileRevision", t("agentWorkspace.sessionProfileRevision")],
+ ["executionConfigRevision", t("agentWorkspace.sessionConfigRevision")],
+ ["turnId", t("agentWorkspace.turnId")],
+ ["taskId", t("agentWorkspace.taskId")],
+ ["runtimeId", "Runtime ID"],
+ ["runtimeVersion", t("systemInfo.currentVersion")],
+ ] as const;
+ const workerFields = [
+ ["kind", t("agentWorkspace.workerKind")],
+ ["sessionId", t("agentWorkspace.workerSession")],
+ ["turnId", t("agentWorkspace.workerTurn")],
+ ["profileId", t("agentWorkspace.workerProfile")],
+ ["profileVersion", t("agentWorkspace.workerProfileVersion")],
+ ] as const;
+ return (
+
+
+
{t("agentWorkspace.mpaDiagnosticsTitle")}
+
{t("agentWorkspace.mpaDiagnosticsDescription")}
+
+ {!loadable ? (
+ {disabledReason}
+ ) : loading && !data ? (
+
+
+ {t("agentWorkspace.loadingMpaDiagnostics")}
+
+ ) : error ? (
+
+ {error}
+
+
+ ) : !latestRun ? (
+
+ {t("agentWorkspace.noMpaDiagnostics")}
+
+ ) : (
+ <>
+
+
+
- {t("agentWorkspace.latestRun")}
+ - {latestRun.invocationId}
+
+
+
- {t("agentSelector.status")}
+ - {latestRun.status}
+
+
+
- {t("agentWorkspace.lastUsed")}
+ - {formatAgentUsageTime(latestRun.startedAt, locale, t)}
+
+
+
- {t("agentWorkspace.stepCount")}
+ - {latestRun.stepCount}
+
+
+
+
+
{t("agentWorkspace.traceCorrelation")}
+
{t("agentWorkspace.traceCorrelationDescription")}
+
+
+ {fields.map(([key, label]) => (
+
+
- {label}
+ - {correlationValue(correlation, key) || t("agentWorkspace.notAvailable")}
+
+ ))}
+ {workerFields.map(([key, label]) => (
+
+
- {label}
+ - {workerCorrelationValue(correlation, key) || t("agentWorkspace.notAvailable")}
+
+ ))}
+
+
+
+
+
{t("agentWorkspace.traceSteps")}
+
{t("agentWorkspace.traceStepsDescription")}
+
+ {trace?.steps.length ? (
+
+
+ {t("agentWorkspace.traceSteps")}
+
+
+ | {t("agentWorkspace.step")} |
+ {t("agentSelector.status")} |
+ {t("agentWorkspace.duration")} |
+ {t("agentWorkspace.turnId")} |
+
+
+
+ {trace.steps.slice(0, 20).map((step) => (
+
+ | {step.title || step.kind}{step.kind} |
+ {step.status} |
+ {formatDurationMs(step.durationMs, t)} |
+ {correlationValue(step.correlation, "turnId") || t("agentWorkspace.notAvailable")} |
+
+ ))}
+
+
+
+ ) : (
+
+ {t("agentWorkspace.noTraceSteps")}
+
+ )}
+
+ >
+ )}
+
+ );
+}
+
function infoToDraft(
info: AgentInfo | null,
fallbackName: string,
@@ -729,6 +1475,13 @@ function deploymentSteps(task: DeploymentTaskUpdate, t: TFunction): Array<{
if (task.createEvaluationSets) {
steps.push({ phase: "evaluation", label: t("agentWorkspace.deploymentSteps.evaluation.label"), description: t("agentWorkspace.deploymentSteps.evaluation.description") });
}
+ if (task.mpaProfile) {
+ steps.push({ phase: "profile_applying", label: t("agentWorkspace.deploymentSteps.profileApplying.label"), description: t("agentWorkspace.deploymentSteps.profileApplying.description") });
+ if (task.mpaSmoke) {
+ steps.push({ phase: "smoke_running", label: t("agentWorkspace.deploymentSteps.smokeRunning.label"), description: t("agentWorkspace.deploymentSteps.smokeRunning.description") });
+ steps.push({ phase: "runnable", label: t("agentWorkspace.deploymentSteps.runnable.label"), description: t("agentWorkspace.deploymentSteps.runnable.description") });
+ }
+ }
if (task.githubDelivery) {
steps.push({ phase: "github", label: t("agentWorkspace.deploymentSteps.github.label"), description: t("agentWorkspace.deploymentSteps.github.description") });
}
@@ -745,6 +1498,9 @@ function deploymentStepIndex(task: DeploymentTaskUpdate, t: TFunction): number {
部署: "deploy",
发布: "publish",
创建评测集: "evaluation",
+ "应用 MPA Profile": "profile_applying",
+ "验证 MPA Runtime": "smoke_running",
+ "MPA Runtime 可运行": "runnable",
"挂载 GitHub 持续交付": "github",
部署完成: "complete",
} as Record)[task.label];
@@ -923,6 +1679,7 @@ function DeploymentProgressCard({
onReturnToEdit?: () => void;
}) {
const { t } = useTranslation("ui");
+ const [retrying, setRetrying] = useState(false);
const steps = deploymentSteps(task, t);
const currentIndex = deploymentStepIndex(task, t);
const progress = task.status === "success"
@@ -937,6 +1694,13 @@ function DeploymentProgressCard({
: task.status === "error"
? t("agentWorkspace.deployStatus.error")
: t("agentWorkspace.deployStatus.cancelled");
+ const retryOperation = () => {
+ if (retrying || !task.retry) return;
+ setRetrying(true);
+ void task.retry()
+ .catch(() => undefined)
+ .finally(() => setRetrying(false));
+ };
return (
{(task.status === "error" || task.status === "cancelled") && onReturnToEdit && (
+ {task.status === "error" && task.retry && (
+
+ )}
)}
+ {task.status === "error" && task.retry && !onReturnToEdit && (
+
+
+
+ )}
);
}
@@ -1054,6 +1844,8 @@ export interface AgentWorkspaceProps {
focusedAgentSection?: AgentSection;
focusedCaseKind?: CaseKind;
feedbackCasePreview?: AgentFeedbackCase | null;
+ currentSessionId?: string;
+ currentRuntimeId?: string;
detailOnly?: boolean;
onBack?: () => void;
onRetryAgents?: () => void;
@@ -1066,6 +1858,7 @@ export interface AgentWorkspaceProps {
onFeedbackCasesDeleted?: (items: AgentFeedbackCase[]) => void;
onCreateAgent: () => void;
onUpdateAgent: (capability: RuntimeUpdateCapability) => void;
+ onEditMpaProfile?: (target: MpaProfileEditTarget) => void;
onEditDraft?: (draft: WorkspaceAgentDraft) => void;
}
@@ -1087,6 +1880,8 @@ export function AgentWorkspace({
focusedAgentSection = "basic",
focusedCaseKind = "good",
feedbackCasePreview = null,
+ currentSessionId = "",
+ currentRuntimeId = "",
detailOnly = false,
onBack,
onRetryAgents,
@@ -1099,6 +1894,7 @@ export function AgentWorkspace({
onFeedbackCasesDeleted,
onCreateAgent,
onUpdateAgent,
+ onEditMpaProfile,
onEditDraft,
}: AgentWorkspaceProps) {
const { t, i18n } = useTranslation("ui");
@@ -1169,6 +1965,30 @@ export function AgentWorkspace({
const [agentUsageLoading, setAgentUsageLoading] = useState(false);
const [agentUsageError, setAgentUsageError] = useState("");
const [agentUsageReloadToken, setAgentUsageReloadToken] = useState(0);
+ const [sessionExecutionConfig, setSessionExecutionConfig] =
+ useState(null);
+ const [mpaProfileStatus, setMpaProfileStatus] =
+ useState(null);
+ const [mpaAgentView, setMpaAgentView] = useState(null);
+ const [mpaAgentViewLoading, setMpaAgentViewLoading] = useState(false);
+ const [mpaAgentViewError, setMpaAgentViewError] = useState("");
+ const [mpaDiagnostics, setMpaDiagnostics] =
+ useState(null);
+ const [mpaDiagnosticsLoading, setMpaDiagnosticsLoading] = useState(false);
+ const [mpaDiagnosticsError, setMpaDiagnosticsError] = useState("");
+ const [mpaDiagnosticsReloadToken, setMpaDiagnosticsReloadToken] = useState(0);
+ const [sessionConfigModelInput, setSessionConfigModelInput] = useState("");
+ const [sessionConfigLoading, setSessionConfigLoading] = useState(false);
+ const [sessionConfigSaving, setSessionConfigSaving] = useState(false);
+ const [sessionConfigUpgradeRunning, setSessionConfigUpgradeRunning] = useState(false);
+ const [sessionConfigError, setSessionConfigError] = useState("");
+ const [sessionConfigNotice, setSessionConfigNotice] = useState("");
+ const [sessionConfigReloadToken, setSessionConfigReloadToken] = useState(0);
+ const [mpaProfileApply, setMpaProfileApply] = useState({
+ saving: false,
+ error: "",
+ notice: "",
+ });
const [caseSelectionMode, setCaseSelectionMode] = useState(false);
const [selectedCaseIds, setSelectedCaseIds] = useState>(() => new Set());
const [deletingCases, setDeletingCases] = useState(false);
@@ -1181,6 +2001,7 @@ export function AgentWorkspace({
const updateCapabilityRequestRef = useRef(0);
const apiKeyRequestRef = useRef(0);
const agentUsageRequestRef = useRef(0);
+ const sessionExecutionConfigRequestRef = useRef(0);
const [evaluationGroups, setEvaluationGroups] = useState(DEFAULT_EVALUATION_GROUPS);
const [activeEvaluationGroupId, setActiveEvaluationGroupId] = useState("");
@@ -1284,9 +2105,25 @@ export function AgentWorkspace({
: null;
const selectedAgentAppName =
selectedAgentInfo?.appName || selectedAgent?.runtimeApp || selectedAgent?.app || "";
- const visibleAgentSectionIds = canViewUsage && selectedAgent?.runtimeId
- ? AGENT_SECTIONS
- : AGENT_SECTIONS.filter((item) => item !== "usage");
+ const selectedAgentCategory: AgentCategory =
+ selectedAgent?.agentCategory ?? "general";
+ const runtimeDetailForSelectedAgent = runtimeDetail?.runtimeId === selectedAgent?.runtimeId
+ ? runtimeDetail
+ : null;
+ const runtimeDetailMpaInstanceId =
+ runtimeDetailForSelectedAgent?.mpaInstanceId;
+ const selectedMpaInstanceId =
+ selectedAgent?.mpaInstanceId ??
+ runtimeDetailMpaInstanceId ??
+ selectedAgent?.runtimeId ??
+ "";
+ const visibleAgentSectionIds = AGENT_SECTIONS.filter((item) => {
+ if (item === "profileConfig") return selectedAgentCategory === "mpa";
+ if (item === "usage") return canViewUsage && selectedAgent?.runtimeId;
+ if (item === "sessionConfig") return selectedAgentCategory === "mpa";
+ if (item === "diagnostics") return selectedAgentCategory === "mpa" && selectedAgent?.runtimeId;
+ return true;
+ });
const visibleAgentSections = visibleAgentSectionIds.map((id) => ({
id,
label: t(`agentWorkspace.sections.${id}`),
@@ -1301,6 +2138,23 @@ export function AgentWorkspace({
? agentUsage.value
: null;
const integrationRequestKey = `${selectedAgent?.region ?? "cn-beijing"}:${selectedAgent?.runtimeId ?? ""}`;
+ const sessionConfigRequestKey = JSON.stringify([
+ selectedAgent?.runtimeId ?? "",
+ selectedMpaInstanceId,
+ selectedAgent?.region ?? "cn-beijing",
+ currentSessionId,
+ ]);
+ const mpaAgentViewRequestKey = JSON.stringify([
+ selectedAgent?.runtimeId ?? "",
+ selectedMpaInstanceId,
+ selectedAgent?.region ?? "cn-beijing",
+ ]);
+ const mpaProfileStatusRequestKey = mpaAgentViewRequestKey;
+ const mpaDiagnosticsRequestKey = JSON.stringify([
+ selectedAgent?.runtimeId ?? "",
+ selectedAgent?.region ?? "cn-beijing",
+ currentSessionId,
+ ]);
const selectedRevealedApiKey =
revealedApiKey?.requestKey === integrationRequestKey
? revealedApiKey.value
@@ -1309,6 +2163,54 @@ export function AgentWorkspace({
integrationProbe?.requestKey === integrationRequestKey
? integrationProbe
: null;
+ const selectedSessionExecutionConfig =
+ sessionExecutionConfig?.requestKey === sessionConfigRequestKey && currentSessionId
+ ? sessionExecutionConfig.value
+ : null;
+ const selectedMpaAgentView =
+ mpaAgentView?.requestKey === mpaAgentViewRequestKey
+ ? mpaAgentView.value
+ : null;
+ const selectedMpaProfileStatus =
+ mpaProfileStatus?.requestKey === mpaProfileStatusRequestKey
+ ? mpaProfileStatus.value
+ : selectedMpaAgentView?.profile ?? null;
+ const selectedMpaBindingStatus = selectedMpaAgentView?.bindingStatus ?? "";
+ const selectedMpaSafeError = mpaSafeErrorMessage(
+ selectedMpaAgentView?.safeError?.code,
+ t,
+ );
+ const mpaProfileCanWrite = selectedMpaAgentView?.capabilities.canWrite === true;
+ const selectedMpaDiagnostics =
+ mpaDiagnostics?.requestKey === mpaDiagnosticsRequestKey
+ ? mpaDiagnostics
+ : null;
+ const mpaProfileRuntimeReady = Boolean(
+ selectedAgent?.runtimeId &&
+ selectedAgentCategory === "mpa" &&
+ selectedMpaBindingStatus !== "runtime_missing" &&
+ selectedMpaBindingStatus !== "binding_ambiguous",
+ );
+ const sessionExecutionConfigLoadable = Boolean(
+ selectedAgent?.agentCategory === "mpa" &&
+ selectedAgent?.runtimeId &&
+ selectedAgent?.runtimeId === currentRuntimeId &&
+ selectedMpaBindingStatus === "bound" &&
+ currentSessionId,
+ );
+ const latestProfileRevision =
+ selectedMpaProfileStatus?.status === "applied"
+ ? selectedMpaProfileStatus.profileRevision
+ : null;
+ const profileUpgradeAvailable = Boolean(
+ sessionExecutionConfigLoadable &&
+ selectedSessionExecutionConfig &&
+ latestProfileRevision != null &&
+ latestProfileRevision > selectedSessionExecutionConfig.profileRevision,
+ );
+ const currentModelId = effectiveModelId(selectedSessionExecutionConfig);
+ const modelInputDirty =
+ sessionConfigModelInput.trim() !== currentModelId;
const apiIntegrationAvailable = Boolean(selectedIntegrationProbe?.apiApps?.length);
const a2aIntegrationAvailable = Boolean(selectedIntegrationProbe?.a2a);
const apiIntegrationAppName =
@@ -1352,6 +2254,7 @@ export function AgentWorkspace({
i18n.resolvedLanguage || i18n.language,
),
) ?? [];
+ const shouldLoadUpdateCapability = selectedAgentCategory !== "mpa";
useEffect(() => {
const requestId = updateCapabilityRequestRef.current + 1;
updateCapabilityRequestRef.current = requestId;
@@ -1360,7 +2263,7 @@ export function AgentWorkspace({
const runtimeId = selectedAgent?.runtimeId ?? "";
const region = selectedAgent?.region ?? "";
- if (!canUpdate || !runtimeId || !region) {
+ if (!shouldLoadUpdateCapability || !canUpdate || !runtimeId || !region) {
setUpdateCapabilityLoading(false);
return;
}
@@ -1443,6 +2346,7 @@ export function AgentWorkspace({
selectedAgent?.currentVersion,
selectedAgent?.region,
selectedAgent?.runtimeId,
+ shouldLoadUpdateCapability,
updateCapabilityRequestKey,
]);
const listedAgents = useMemo(() => {
@@ -1527,8 +2431,11 @@ export function AgentWorkspace({
(id) => publishedHarnessSidecar.componentOverrides[id],
)
: [];
+ const profileDraft = useMemo(() => mpaProfileFromAgentDraft(draft), [draft]);
const updateBlockedReason = selectedDraft
? canCreate ? "" : t("agentWorkspace.errors.noCreatePermission")
+ : selectedAgentCategory === "mpa"
+ ? ""
: !canUpdate
? t("agentWorkspace.errors.noManagePermission")
: !selectedAgent?.runtimeId
@@ -1551,6 +2458,61 @@ export function AgentWorkspace({
? ""
: t("agentWorkspace.errors.agentInfoMissing");
const updateReasonId = "aw-update-disabled-reason";
+ const profileConfigDisabledReason = !selectedAgent
+ ? t("agentWorkspace.sessionConfigNoAgent")
+ : selectedAgentCategory !== "mpa"
+ ? t("agentWorkspace.sessionConfigNonMpa")
+ : mpaAgentViewLoading
+ ? t("agentWorkspace.loadingMpaAgentView")
+ : mpaAgentViewError
+ ? mpaAgentViewError
+ : selectedMpaSafeError
+ ? selectedMpaSafeError
+ : selectedMpaBindingStatus === "runtime_missing"
+ ? t("agentWorkspace.mpaRuntimeMissing")
+ : selectedMpaBindingStatus === "binding_ambiguous"
+ ? t("agentWorkspace.mpaRuntimeBindingAmbiguous")
+ : selectedMpaBindingStatus && !mpaProfileRuntimeReady
+ ? t("agentWorkspace.mpaRuntimeMissing")
+ : !mpaProfileCanWrite
+ ? t("agentWorkspace.errors.noManagePermission")
+ : "";
+ const sessionConfigDisabledReason = !selectedAgent
+ ? t("agentWorkspace.sessionConfigNoAgent")
+ : selectedAgentCategory !== "mpa"
+ ? t("agentWorkspace.sessionConfigNonMpa")
+ : mpaAgentViewLoading
+ ? t("agentWorkspace.loadingMpaAgentView")
+ : mpaAgentViewError
+ ? mpaAgentViewError
+ : selectedMpaSafeError
+ ? selectedMpaSafeError
+ : selectedMpaBindingStatus === "runtime_missing"
+ ? t("agentWorkspace.mpaRuntimeMissing")
+ : selectedMpaBindingStatus === "binding_ambiguous"
+ ? t("agentWorkspace.mpaRuntimeBindingAmbiguous")
+ : selectedMpaBindingStatus === "orphan_runtime"
+ ? t("agentWorkspace.mpaRuntimeOrphan")
+ : !currentSessionId
+ ? t("agentWorkspace.sessionConfigNoSession")
+ : selectedAgent.runtimeId !== currentRuntimeId
+ ? t("agentWorkspace.sessionConfigDifferentRuntime")
+ : "";
+ const diagnosticsDisabledReason = !selectedAgent
+ ? t("agentWorkspace.sessionConfigNoAgent")
+ : selectedAgentCategory !== "mpa"
+ ? t("agentWorkspace.sessionConfigNonMpa")
+ : mpaAgentViewLoading
+ ? t("agentWorkspace.loadingMpaAgentView")
+ : mpaAgentViewError
+ ? mpaAgentViewError
+ : selectedMpaBindingStatus !== "bound"
+ ? sessionConfigDisabledReason
+ : !currentSessionId
+ ? t("agentWorkspace.sessionConfigNoSession")
+ : selectedAgent.runtimeId !== currentRuntimeId
+ ? t("agentWorkspace.sessionConfigDifferentRuntime")
+ : "";
const toolNames = useMemo(() => {
if (selectedAgentInfo) return selectedAgentInfo.tools;
const builtinNames = (draft.builtinTools ?? []).map(
@@ -1630,10 +2592,82 @@ export function AgentWorkspace({
const executionFlowKey = selectedAgentInfo
? `runtime:${selectedAgent?.runtimeId ?? selectedAgentInfo.name}:v${runtimeVersionKey}:${draftFlowKey}`
: `draft:${selectedPendingTask?.id ?? selectedDraft?.id ?? selectedAgent?.id ?? selectedName}:${draftFlowKey}`;
+ useEffect(() => {
+ let cancelled = false;
+ const runtimeId = selectedAgent?.runtimeId ?? "";
+ const mpaInstanceId = selectedMpaInstanceId || runtimeId;
+ const region = selectedAgent?.region ?? "cn-beijing";
+ const requestKey = mpaAgentViewRequestKey;
+ setMpaAgentViewError("");
+ if (selectedAgentCategory !== "mpa" || !runtimeId) {
+ setMpaAgentView(null);
+ setMpaAgentViewLoading(false);
+ return;
+ }
+ const controller = new AbortController();
+ setMpaAgentViewLoading(true);
+ void getMpaAgentView({
+ mpaInstanceId,
+ runtimeId,
+ region,
+ signal: controller.signal,
+ })
+ .then((value) => {
+ if (cancelled) return;
+ setMpaAgentView({ requestKey, value });
+ setMpaProfileStatus(
+ value.profile
+ ? { requestKey: mpaProfileStatusRequestKey, value: value.profile }
+ : null,
+ );
+ })
+ .catch((error: unknown) => {
+ if (cancelled || controller.signal.aborted) return;
+ setMpaAgentView(null);
+ setMpaAgentViewError(
+ error instanceof Error ? error.message : t("agentWorkspace.errors.loadMpaAgentView"),
+ );
+ })
+ .finally(() => {
+ if (!cancelled) setMpaAgentViewLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ controller.abort();
+ };
+ }, [
+ detailReloadToken,
+ mpaAgentViewRequestKey,
+ selectedMpaInstanceId,
+ selectedAgent?.region,
+ selectedAgent?.runtimeId,
+ selectedAgentCategory,
+ mpaProfileStatusRequestKey,
+ t,
+ ]);
+
useEffect(() => {
if (section === "usage" && !canViewUsage) setSection("basic");
}, [canViewUsage, section]);
+ useEffect(() => {
+ if (
+ section === "sessionConfig" &&
+ (!selectedAgent?.runtimeId || selectedAgentCategory !== "mpa")
+ ) {
+ setSection("basic");
+ }
+ }, [section, selectedAgent?.runtimeId, selectedAgentCategory]);
+
+ useEffect(() => {
+ if (
+ section === "diagnostics" &&
+ (!selectedAgent?.runtimeId || selectedAgentCategory !== "mpa")
+ ) {
+ setSection("basic");
+ }
+ }, [section, selectedAgent?.runtimeId, selectedAgentCategory]);
+
useEffect(() => {
if (!focusedDeploymentTaskId) return;
const focusedTask = deploymentTasks.find(
@@ -1848,6 +2882,77 @@ export function AgentWorkspace({
setIntegrationProtocol("api-server");
}, [integrationRequestKey, section]);
+ useEffect(() => {
+ const runtimeId = selectedAgent?.runtimeId ?? "";
+ const region = selectedAgent?.region ?? "cn-beijing";
+ const requestKey = mpaDiagnosticsRequestKey;
+ setMpaDiagnosticsError("");
+ if (
+ section !== "diagnostics" ||
+ selectedAgentCategory !== "mpa" ||
+ selectedMpaBindingStatus !== "bound" ||
+ !runtimeId ||
+ !currentSessionId ||
+ runtimeId !== currentRuntimeId
+ ) {
+ setMpaDiagnosticsLoading(false);
+ if (section !== "diagnostics") setMpaDiagnostics(null);
+ return;
+ }
+
+ const controller = new AbortController();
+ let cancelled = false;
+ setMpaDiagnosticsLoading(true);
+ void getMpaRuntimeConsoleRuns({
+ runtimeId,
+ region,
+ sessionId: currentSessionId,
+ signal: controller.signal,
+ })
+ .then(async (runsResult) => {
+ const firstRun = runsResult.runs[0];
+ const trace = firstRun
+ ? await getMpaRuntimeConsoleTrace({
+ runtimeId,
+ region,
+ sessionId: currentSessionId,
+ invocationId: firstRun.invocationId,
+ signal: controller.signal,
+ })
+ : null;
+ return { runs: runsResult.runs, trace };
+ })
+ .then((value) => {
+ if (cancelled) return;
+ setMpaDiagnostics({ requestKey, ...value });
+ })
+ .catch((error: unknown) => {
+ if (cancelled || controller.signal.aborted) return;
+ setMpaDiagnostics(null);
+ setMpaDiagnosticsError(
+ error instanceof Error ? error.message : t("agentWorkspace.errors.loadMpaDiagnostics"),
+ );
+ })
+ .finally(() => {
+ if (!cancelled) setMpaDiagnosticsLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ controller.abort();
+ };
+ }, [
+ currentRuntimeId,
+ currentSessionId,
+ mpaDiagnosticsReloadToken,
+ mpaDiagnosticsRequestKey,
+ section,
+ selectedAgent?.region,
+ selectedAgent?.runtimeId,
+ selectedAgentCategory,
+ selectedMpaBindingStatus,
+ t,
+ ]);
+
function clearRevealedApiKey() {
apiKeyRequestRef.current += 1;
setRevealedApiKey(null);
@@ -1925,6 +3030,92 @@ export function AgentWorkspace({
selectedAgent?.runtimeId,
]);
+ useEffect(() => {
+ const requestId = sessionExecutionConfigRequestRef.current + 1;
+ sessionExecutionConfigRequestRef.current = requestId;
+ setSessionConfigError("");
+ setSessionConfigNotice("");
+ setSessionConfigSaving(false);
+ setSessionConfigUpgradeRunning(false);
+ if (
+ section !== "sessionConfig" ||
+ !sessionExecutionConfigLoadable ||
+ !selectedAgent?.runtimeId
+ ) {
+ setSessionConfigLoading(false);
+ setSessionExecutionConfig(null);
+ setSessionConfigModelInput("");
+ return;
+ }
+
+ const runtimeId = selectedAgent.runtimeId;
+ const mpaInstanceId = selectedMpaInstanceId || runtimeId;
+ const region = selectedAgent.region ?? "cn-beijing";
+ const requestKey = sessionConfigRequestKey;
+ const controller = new AbortController();
+ setSessionConfigLoading(true);
+ void Promise.all([
+ getMpaSessionExecutionConfig({
+ runtimeId,
+ region,
+ sessionId: currentSessionId,
+ signal: controller.signal,
+ }),
+ getMpaProfileStatus({
+ runtimeId,
+ region,
+ mpaInstanceId,
+ signal: controller.signal,
+ }).catch(() => null),
+ ])
+ .then(([config, profileStatus]) => {
+ if (requestId !== sessionExecutionConfigRequestRef.current) return;
+ setSessionExecutionConfig({ requestKey, value: config });
+ setSessionConfigModelInput(effectiveModelId(config));
+ setMpaProfileStatus(
+ profileStatus
+ ? { requestKey: mpaProfileStatusRequestKey, value: profileStatus }
+ : null,
+ );
+ })
+ .catch((error: unknown) => {
+ if (
+ requestId !== sessionExecutionConfigRequestRef.current ||
+ controller.signal.aborted ||
+ (error as Error)?.name === "AbortError"
+ ) return;
+ setSessionExecutionConfig(null);
+ setSessionConfigModelInput("");
+ setSessionConfigError(
+ error instanceof Error
+ ? error.message
+ : t("agentWorkspace.errors.loadSessionConfig"),
+ );
+ })
+ .finally(() => {
+ if (
+ requestId === sessionExecutionConfigRequestRef.current &&
+ !controller.signal.aborted
+ ) {
+ setSessionConfigLoading(false);
+ }
+ });
+ return () => {
+ controller.abort();
+ };
+ }, [
+ currentSessionId,
+ sessionConfigReloadToken,
+ sessionConfigRequestKey,
+ sessionExecutionConfigLoadable,
+ section,
+ mpaProfileStatusRequestKey,
+ selectedMpaInstanceId,
+ selectedAgent?.region,
+ selectedAgent?.runtimeId,
+ t,
+ ]);
+
useEffect(() => {
let cancelled = false;
const runtimeId = selectedAgent?.runtimeId ?? "";
@@ -2069,6 +3260,8 @@ export function AgentWorkspace({
await createGithubDeliveryRollbackPr({
runtimeId,
targetCommitSha: commitSha,
+ region: selectedAgent?.region,
+ agentCategory: selectedAgentCategory,
});
const refreshed = await getGithubDeliveryVersions(runtimeId);
setGithubVersions(refreshed);
@@ -2081,6 +3274,211 @@ export function AgentWorkspace({
}
}
+ function applySessionConfigConflict(error: unknown, fallback: string): boolean {
+ if (!(error instanceof MpaExecutionConfigRequestError)) return false;
+ if (error.status !== 412 || !error.currentState) return false;
+ setSessionExecutionConfig({
+ requestKey: sessionConfigRequestKey,
+ value: error.currentState,
+ });
+ setSessionConfigModelInput(effectiveModelId(error.currentState));
+ setSessionConfigError(fallback);
+ return true;
+ }
+
+ async function saveSessionModelOverride(mode: MpaExecutionConfigMode = "replace") {
+ if (!selectedAgent?.runtimeId || !selectedSessionExecutionConfig) return;
+ const runtimeId = selectedAgent.runtimeId;
+ const region = selectedAgent.region ?? "cn-beijing";
+ const modelId = sessionConfigModelInput.trim();
+ const change = mode === "inherit"
+ ? { category: "model", mode }
+ : modelId
+ ? { category: "model", mode, value: { id: modelId } }
+ : { category: "model", mode: "inherit" as const };
+ setSessionConfigSaving(true);
+ setSessionConfigError("");
+ setSessionConfigNotice("");
+ try {
+ const next = await patchMpaSessionExecutionConfig({
+ runtimeId,
+ region,
+ sessionId: currentSessionId,
+ etag: selectedSessionExecutionConfig.etag,
+ changes: [change],
+ });
+ setSessionExecutionConfig({ requestKey: sessionConfigRequestKey, value: next });
+ setSessionConfigModelInput(effectiveModelId(next));
+ setSessionConfigNotice(t("agentWorkspace.sessionConfigSaved"));
+ } catch (error) {
+ if (!applySessionConfigConflict(
+ error,
+ t("agentWorkspace.sessionConfigStale"),
+ )) {
+ setSessionConfigError(
+ executionConfigErrorMessage(
+ error,
+ t("agentWorkspace.sessionConfigStale"),
+ ),
+ );
+ }
+ } finally {
+ setSessionConfigSaving(false);
+ }
+ }
+
+ async function updateSessionMcpMode(mode: Extract) {
+ if (!selectedAgent?.runtimeId || !selectedSessionExecutionConfig) return;
+ const runtimeId = selectedAgent.runtimeId;
+ const region = selectedAgent.region ?? "cn-beijing";
+ setSessionConfigSaving(true);
+ setSessionConfigError("");
+ setSessionConfigNotice("");
+ try {
+ const next = await patchMpaSessionExecutionConfig({
+ runtimeId,
+ region,
+ sessionId: currentSessionId,
+ etag: selectedSessionExecutionConfig.etag,
+ changes: [{ category: "mcpServers", mode }],
+ });
+ setSessionExecutionConfig({ requestKey: sessionConfigRequestKey, value: next });
+ setSessionConfigNotice(t("agentWorkspace.sessionConfigSaved"));
+ } catch (error) {
+ if (!applySessionConfigConflict(
+ error,
+ t("agentWorkspace.sessionConfigStale"),
+ )) {
+ setSessionConfigError(
+ executionConfigErrorMessage(
+ error,
+ t("agentWorkspace.sessionConfigStale"),
+ ),
+ );
+ }
+ } finally {
+ setSessionConfigSaving(false);
+ }
+ }
+
+ async function upgradeSessionProfileRevision() {
+ if (!selectedAgent?.runtimeId || !selectedSessionExecutionConfig || !latestProfileRevision) {
+ return;
+ }
+ const runtimeId = selectedAgent.runtimeId;
+ const region = selectedAgent.region ?? "cn-beijing";
+ setSessionConfigUpgradeRunning(true);
+ setSessionConfigError("");
+ setSessionConfigNotice("");
+ try {
+ const next = await upgradeMpaSessionProfile({
+ runtimeId,
+ region,
+ sessionId: currentSessionId,
+ etag: selectedSessionExecutionConfig.etag,
+ idempotencyKey: profileUpgradeIdempotencyKey(
+ runtimeId,
+ currentSessionId,
+ latestProfileRevision,
+ ),
+ targetProfileRevision: latestProfileRevision,
+ });
+ setSessionExecutionConfig({ requestKey: sessionConfigRequestKey, value: next });
+ setSessionConfigModelInput(effectiveModelId(next));
+ setSessionConfigNotice(t("agentWorkspace.sessionProfileUpgraded"));
+ } catch (error) {
+ if (!applySessionConfigConflict(
+ error,
+ t("agentWorkspace.sessionConfigStale"),
+ )) {
+ setSessionConfigError(
+ executionConfigErrorMessage(
+ error,
+ t("agentWorkspace.sessionProfileUpgradeFailed"),
+ ),
+ );
+ }
+ } finally {
+ setSessionConfigUpgradeRunning(false);
+ }
+ }
+
+ async function applyMpaProfileFromStudio() {
+ if (!selectedAgent?.runtimeId) return;
+ const runtimeId = selectedAgent.runtimeId;
+ const region = selectedAgent.region ?? "cn-beijing";
+ const mpaInstanceId = selectedMpaInstanceId || runtimeId;
+ const operationKind = selectedMpaProfileStatus?.status === "applied"
+ ? "update"
+ : "create";
+ const runtimeRevision = selectedMpaProfileStatus?.runtimeRevision ||
+ selectedMpaProfileStatus?.etag ||
+ "";
+ const idempotencyKey = await profileIdempotencyKey(
+ runtimeId,
+ operationKind,
+ profileDraft,
+ );
+ setMpaProfileApply({ saving: true, error: "", notice: "" });
+ try {
+ const operation = await startMpaAgentOperation({
+ operationKind,
+ runtimeId,
+ region,
+ mpaInstanceId,
+ sourceProfileId: `studio-runtime:${runtimeId}:${selectedAgentAppName || "a2a-default"}`,
+ targetKey: operationKind === "update"
+ ? mpaInstanceId
+ : `runtime:${region}:${runtimeId}`,
+ runtimeRevision: operationKind === "update" ? runtimeRevision : undefined,
+ draft,
+ idempotencyKey,
+ });
+ setMpaProfileApply({
+ saving: false,
+ error: operation.status === "succeeded"
+ ? ""
+ : operation.safeErrorCode || t("agentWorkspace.profileApplyIncomplete"),
+ notice: operation.status === "succeeded"
+ ? t("agentWorkspace.profileAppliedNotice", {
+ revision: operation.profileRevision ?? t("agentWorkspace.notAvailable"),
+ })
+ : "",
+ });
+ setDetailReloadToken((value) => value + 1);
+ setSessionConfigReloadToken((value) => value + 1);
+ } catch (error) {
+ setMpaProfileApply({
+ saving: false,
+ error: error instanceof Error
+ ? error.message
+ : t("agentWorkspace.profileApplyFailed"),
+ notice: "",
+ });
+ }
+ }
+
+ function editMpaProfileFromStudio() {
+ if (!selectedAgent?.runtimeId || selectedAgentCategory !== "mpa") return;
+ onEditMpaProfile?.({
+ draft,
+ runtimeId: selectedAgent.runtimeId,
+ mpaInstanceId: selectedMpaInstanceId || selectedAgent.runtimeId,
+ name: selectedName,
+ region: selectedAgent.region ?? "cn-beijing",
+ appName: selectedAgentAppName || "a2a-default",
+ currentVersion: selectedAgent.currentVersion ?? runtimeDetail?.currentVersion,
+ runtimeRevision:
+ selectedMpaProfileStatus?.runtimeRevision ||
+ selectedMpaProfileStatus?.etag ||
+ undefined,
+ });
+ }
+
+ useEffect(() => {
+ setMpaProfileApply({ saving: false, error: "", notice: "" });
+ }, [mpaAgentViewRequestKey]);
+
useEffect(() => {
const caseIds = new Set(feedbackCases.map((item) => item.id));
setSelectedCaseIds((current) => {
@@ -2373,6 +3771,10 @@ export function AgentWorkspace({
if (selectedDeleteCount === 0 || deletingAgents) return;
const runtimeCount = selectedDeletableAgents.length;
const draftCount = selectedDeletableDrafts.length;
+ if (runtimeCount === 1 && draftCount === 0) {
+ void deleteSingleAgent(selectedDeletableAgents[0]);
+ return;
+ }
setDeleteError("");
setDeleteConfirmTarget({
kind: "selection",
@@ -2437,14 +3839,36 @@ export function AgentWorkspace({
}
};
- const deleteSingleAgent = (agent: AgentEntry) => {
+ const deleteSingleAgent = async (agent: AgentEntry) => {
if (!onDeleteAgents || agent.canDelete !== true || deletingAgents) return;
setDeleteError("");
+ let preview: MpaAgentDeletePreview | null = null;
+ if (agent.agentCategory === "mpa") {
+ if (!agent.runtimeId || !agent.region) {
+ setDeleteError(t("agentWorkspace.errors.loadMpaDeletePreview"));
+ return;
+ }
+ try {
+ preview = await getMpaAgentDeletePreview({
+ mpaInstanceId: agent.mpaInstanceId ?? agent.runtimeId,
+ runtimeId: agent.runtimeId,
+ region: agent.region,
+ });
+ } catch (cause) {
+ setDeleteError(cause instanceof Error ? cause.message : String(cause));
+ return;
+ }
+ }
setDeleteConfirmTarget({
kind: "agent",
title: t("agentWorkspace.deleteAgentTitle"),
- description: t("agentWorkspace.deleteAgentDescription", { name: agent.label }),
+ description: preview ? (
+
+ ) : (
+ t("agentWorkspace.deleteAgentDescription", { name: agent.label })
+ ),
confirmLabel: t("agentWorkspace.deleteAgent"),
+ canConfirm: preview ? preview.canDelete : true,
agent,
});
};
@@ -2980,7 +4404,39 @@ export function AgentWorkspace({
)}
/>
)}
+ {selectedAgentCategory === "mpa" &&
+ (mpaAgentViewLoading ||
+ mpaAgentViewError ||
+ selectedMpaBindingStatus) && (
+
+ )}
{selectedAgent &&
+ selectedAgentCategory !== "mpa" &&
selectedUpdateCapability &&
!selectedUpdateCapability.canUpdate && (
)}
+ {section === "sessionConfig" && (
+
void saveSessionModelOverride()}
+ onInheritModel={() => void saveSessionModelOverride("inherit")}
+ onClearMcp={() => void updateSessionMcpMode("clear")}
+ onInheritMcp={() => void updateSessionMcpMode("inherit")}
+ onUpgradeProfile={() => void upgradeSessionProfileRevision()}
+ onRefresh={() => setSessionConfigReloadToken((value) => value + 1)}
+ />
+ )}
+ {section === "profileConfig" && (
+ void applyMpaProfileFromStudio()}
+ onEditProfile={editMpaProfileFromStudio}
+ onRefresh={() => setDetailReloadToken((value) => value + 1)}
+ />
+ )}
+ {section === "diagnostics" && (
+ setMpaDiagnosticsReloadToken((value) => value + 1)}
+ locale={i18n.resolvedLanguage ?? i18n.language}
+ />
+ )}
{section === "versions" && (
@@ -3762,16 +5266,22 @@ export function AgentWorkspace({
@@ -139,6 +147,8 @@ export function Applications({ onOpen }: ApplicationsProps) {
alt=""
aria-hidden="true"
/>
+ ) : application.icon === "mpa-channels" ? (
+
) : application.icon === "coding-agents" ? (
) : application.icon === "website-integration" ? (
diff --git a/frontend/src/ui/Blocks.tsx b/frontend/src/ui/Blocks.tsx
index e9ae9f188..e783caacd 100644
--- a/frontend/src/ui/Blocks.tsx
+++ b/frontend/src/ui/Blocks.tsx
@@ -11,7 +11,7 @@ import {
import { motion } from "motion/react";
import { Trans, useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
-import type { Block } from "../blocks";
+import { flattenCodexActivityBlocks, type Block } from "../blocks";
import { DevelopmentTurnSummary } from "../create/DevelopmentTurnSummary";
import { DevelopmentItemIcon } from "../create/DevelopmentItemIcon";
import { DevelopmentProcess } from "../create/DevelopmentProcess";
@@ -32,6 +32,15 @@ import { AgentKitLogoIcon } from "./icons/AgentKitLogoIcon";
import { DeliverySourceIcon } from "./icons/DeliverySourceIcon";
import { DeliveryVerifiedIcon } from "./icons/DeliveryVerifiedIcon";
import { CodeBrowserDialog } from "./CodeBrowserDialog";
+import {
+ ToolActivityCard,
+ ToolExplorationGroup,
+} from "./tool-activity/ToolActivityCard";
+import {
+ isSafeExploration,
+ presentToolActivity,
+ type ToolActivityInput,
+} from "./tool-activity/model";
const A2UI_TOOL = "send_a2ui_json_to_client";
const STREAM_FRAME_INTERVAL_MS = 28;
@@ -221,7 +230,11 @@ function CodexSandboxIdentity({
);
}
-function loadSkillLabel(name: string, args: unknown, t: TFunction): string | undefined {
+function loadSkillLabel(
+ name: string,
+ args: unknown,
+ t: TFunction,
+): string | undefined {
if (
name !== "load_skill" ||
args == null ||
@@ -238,12 +251,14 @@ function loadSkillLabel(name: string, args: unknown, t: TFunction): string | und
export function ThinkingBlock({
text,
done,
+ thoughtKind = "thought",
answerStarted = false,
streaming = false,
onStreamFrame,
}: {
text: string;
done: boolean;
+ thoughtKind?: "reasoning" | "thought";
answerStarted?: boolean;
streaming?: boolean;
onStreamFrame?: () => void;
@@ -296,10 +311,12 @@ export function ThinkingBlock({
/>
{done ? (
-
{t("blocks.thinkingDone")}
+
+ {t(thoughtKind === "reasoning" ? "blocks.reasoningDone" : "blocks.thinkingDone")}
+
) : (
- {t("blocks.thinking")}
+ {t(thoughtKind === "reasoning" ? "blocks.reasoning" : "blocks.thinking")}
)}
@@ -382,7 +399,9 @@ function DeliveryCard({
? t("blocks.justNow")
: Number.isNaN(validatedAt.getTime())
? value.validatedAt
- : validatedAt.toLocaleString(i18n.resolvedLanguage ?? i18n.language, { hour12: false });
+ : validatedAt.toLocaleString(i18n.resolvedLanguage ?? i18n.language, {
+ hour12: false,
+ });
useEffect(() => {
if (!downloadStatus) return;
@@ -463,7 +482,11 @@ function DeliveryCard({
<>