From 0f15b8a71ed9e4640ca499ba75176b6116e84bce Mon Sep 17 00:00:00 2001 From: coso Date: Mon, 13 Apr 2026 02:50:53 +0800 Subject: [PATCH] release: v1.9.0 --- .github/workflows/harness-nightly.yml | 29 +- .github/workflows/quality.yml | 112 +- AGENTS.md | 109 +- RELEASE_NOTES.md | 16 +- docs/README.md | 3 + docs/aiprompts/README.md | 2 + docs/aiprompts/commands.md | 2 +- docs/aiprompts/governance.md | 12 + docs/aiprompts/harness-engine-governance.md | 271 ++++ docs/aiprompts/providers.md | 25 + docs/aiprompts/quality-workflow.md | 18 +- docs/develop/lobsterai-business-benchmark.md | 5 +- docs/exec-plans/README.md | 28 + docs/exec-plans/tech-debt-tracker.md | 15 + docs/prd/tools/README.md | 5 +- docs/prd/tools/inventory.md | 7 +- docs/test/README.md | 10 + docs/test/harness-evals.manifest.json | 7 + docs/test/harness-evals.md | 59 +- .../evidence-links.json | 125 ++ .../minimal-observability-gap/expected.json | 28 + .../minimal-observability-gap/grader.md | 37 + .../minimal-observability-gap/input.json | 174 +++ .../evidence-links.json | 100 +- .../minimal-pending-request/expected.json | 2 +- .../replay/minimal-pending-request/input.json | 100 +- eslint.config.js | 10 - package-lock.json | 4 +- package.json | 5 +- packages/lime-cli-npm/README.md | 2 +- packages/lime-cli-npm/package.json | 2 +- scripts/app-version.mjs | 19 +- scripts/browser-runtime-smoke.mjs | 19 +- scripts/check-generated-slop-report.mjs | 158 +++ scripts/harness-eval-history-record.mjs | 394 +++++- scripts/harness-eval-runner.mjs | 338 +++-- scripts/harness-eval-trend-report.mjs | 362 ++++- scripts/lib/generated-slop-report-core.mjs | 988 +++++++++++++- .../lib/generated-slop-report-core.test.ts | 1170 ++++++++++++++++- scripts/lib/harness-dashboard-core.mjs | 765 +++++++++++ scripts/lib/harness-dashboard-core.test.ts | 184 +++ .../lib/harness-eval-history-record.test.ts | 188 +++ .../lib/harness-eval-history-window.test.ts | 25 +- .../lib/harness-eval-repo-fixtures.test.ts | 155 +++ .../lib/harness-review-decision-evals.test.ts | 362 ++++- scripts/local-ci.mjs | 19 +- scripts/quality-task-planner.mjs | 71 +- scripts/quality-task-planner.test.ts | 33 + scripts/quality-task-selector.mjs | 1 + scripts/start-web-bridge-dev.mjs | 37 + scripts/verify-gui-smoke.mjs | 810 +++++++++++- src-tauri/Cargo.lock | 38 +- src-tauri/Cargo.toml | 4 +- .../crates/agent/src/aster_state_support.rs | 2 +- .../crates/agent/src/prompt/templates.rs | 5 +- src-tauri/crates/agent/src/protocol.rs | 2 + src-tauri/crates/agent/src/session_store.rs | 18 +- src-tauri/crates/agent/src/session_update.rs | 2 + .../crates/aster/src/agents/agent.rs | 5 + .../aster/src/agents/execute_commands.rs | 1 + .../crates/aster/src/agents/reply_parts.rs | 7 + .../crates/aster/src/providers/api_client.rs | 88 +- .../crates/aster/src/providers/base.rs | 11 + .../aster/src/providers/formats/anthropic.rs | 25 +- .../aster/src/providers/formats/openai.rs | 24 + .../src/providers/formats/openai_responses.rs | 16 + .../crates/aster/src/providers/litellm.rs | 79 ++ .../crates/aster/src/providers/openrouter.rs | 79 ++ .../aster/src/session/session_manager.rs | 39 +- .../crates/aster/src/session/store.rs | 2 + .../crates/aster/src/session_context.rs | 131 ++ src-tauri/crates/core/src/agent/types.rs | 9 + .../src/database/agent_session_repository.rs | 11 +- .../crates/core/src/database/dao/agent.rs | 97 +- .../core/src/database/dao/api_key_provider.rs | 14 + .../migration/general_chat_migration.rs | 12 +- src-tauri/crates/core/src/database/schema.rs | 10 + .../crates/core/src/models/provider_type.rs | 17 + src-tauri/crates/infra/src/telemetry/types.rs | 18 + .../providers/src/providers/claude_custom.rs | 311 ++++- .../crates/providers/src/providers/mod.rs | 2 +- .../providers/src/streaming/converter.rs | 228 +++- src-tauri/crates/server/src/handlers/api.rs | 322 ++++- .../server/src/handlers/provider_calls.rs | 30 +- .../crates/server/src/handlers/websocket.rs | 70 +- src-tauri/crates/server/src/lib.rs | 13 + .../services/src/api_key_provider_service.rs | 68 +- .../services/src/aster_session_store.rs | 57 +- .../crates/skills/src/lime_llm_provider.rs | 20 +- src-tauri/src/agent/README.md | 2 +- src-tauri/src/agent/mod.rs | 4 - src-tauri/src/agent/subagent_scheduler.rs | 156 --- src-tauri/src/agent_tools/catalog.rs | 14 +- src-tauri/src/agent_tools/inventory.rs | 4 +- src-tauri/src/app/runner.rs | 5 - src-tauri/src/commands/aster_agent_cmd/mod.rs | 17 +- .../commands/aster_agent_cmd/runtime_turn.rs | 324 ++++- .../src/commands/aster_agent_cmd/tests.rs | 122 +- .../commands/aster_agent_cmd/tool_runtime.rs | 8 +- .../tool_runtime/subagent_tools.rs | 442 +------ .../src/commands/memory_management_cmd.rs | 156 +-- src-tauri/src/commands/mod.rs | 1 - src-tauri/src/commands/subagent_cmd.rs | 108 -- src-tauri/src/commands/unified_memory_cmd.rs | 46 +- .../src/services/chat_history_service.rs | 86 +- .../runtime_analysis_handoff_service.rs | 32 +- .../services/runtime_evidence_pack_service.rs | 1114 ++++++++++++++-- .../services/runtime_replay_case_service.rs | 36 +- src-tauri/tauri.conf.headless.json | 2 +- src-tauri/tauri.conf.json | 2 +- ...deepseek_reasoner_output_schema_runtime.rs | 1 + .../agent/chat/AgentChatWorkspace.tsx | 615 ++++++--- .../chat/components/AgentRuntimeStrip.tsx | 39 +- .../AgentThreadMemoryPrefetchPreview.tsx | 14 +- .../AgentThreadReliabilityPanel.test.tsx | 10 +- .../AgentThreadReliabilityPanel.tsx | 26 +- .../components/AgentThreadTimeline.test.tsx | 5 +- .../chat/components/AgentThreadTimeline.tsx | 15 +- .../components/CanvasSessionOverviewPanel.tsx | 4 +- .../components/CanvasWorkbenchLayout.test.tsx | 16 +- .../chat/components/CanvasWorkbenchLayout.tsx | 8 +- .../chat/components/ChatSidebar.test.tsx | 171 ++- .../agent/chat/components/ChatSidebar.tsx | 127 +- .../agent/chat/components/EmptyState.test.tsx | 10 +- .../agent/chat/components/EmptyState.tsx | 4 +- .../EmptyStateComposerPanel.test.tsx | 12 +- .../GeneralWorkbenchSidebar.test.tsx | 82 +- .../components/GeneralWorkbenchSidebar.tsx | 4 +- .../GeneralWorkbenchSidebarShell.tsx | 38 +- .../GeneralWorkbenchWorkflowPanel.tsx | 393 ++++-- .../components/HarnessStatusPanel.test.tsx | 54 +- .../chat/components/HarnessStatusPanel.tsx | 163 +-- .../components/InputbarComposerSection.tsx | 15 +- .../InputbarPromptCacheNotice.test.tsx | 182 +++ .../components/InputbarPromptCacheNotice.tsx | 49 + .../Inputbar/components/InputbarTools.tsx | 4 +- .../InputbarWorkflowStatusPanel.tsx | 2 +- .../Inputbar/components/TeamSelectorPanel.tsx | 6 +- .../Inputbar/hooks/useInputbarToolState.ts | 6 +- .../chat/components/Inputbar/index.test.tsx | 45 +- .../chat/components/MessageList.test.tsx | 134 +- .../agent/chat/components/MessageList.tsx | 64 +- .../agent/chat/components/README.md | 4 + .../components/StreamingRenderer.test.tsx | 6 +- .../chat/components/StreamingRenderer.tsx | 16 +- .../components/TeamMemoryShadowCard.test.tsx | 14 +- .../chat/components/TeamMemoryShadowCard.tsx | 13 +- .../TeamWorkbenchSummaryPanel.test.tsx | 90 +- .../components/TeamWorkbenchSummaryPanel.tsx | 117 +- .../components/TeamWorkspaceBoard.test.tsx | 124 +- .../components/TeamWorkspaceDock.test.tsx | 83 +- .../chat/components/TeamWorkspaceDock.tsx | 372 ++++-- .../components/TokenUsageDisplay.test.tsx | 52 + .../chat/components/TokenUsageDisplay.tsx | 30 +- .../chat/components/ToolCallDisplay.test.tsx | 2 +- .../SelectedSessionInlineActivitySection.tsx | 4 +- ...ectedSessionInlineCollaborationSection.tsx | 8 +- .../SelectedSessionInlineDetail.tsx | 2 +- .../SelectedSessionInlineHeader.tsx | 4 +- .../TeamWorkspaceBoardHeader.tsx | 44 +- .../TeamWorkspaceCanvasLaneCard.tsx | 2 +- .../TeamWorkspaceCanvasStage.tsx | 6 +- .../TeamWorkspaceCanvasSurfaceCopy.test.tsx | 119 ++ .../TeamWorkspaceCanvasToolbar.tsx | 6 +- .../TeamWorkspaceEmptyShellState.tsx | 18 +- .../TeamWorkspaceFallbackDetailSection.tsx | 2 +- .../TeamWorkspaceTeamOperationsPanel.tsx | 185 ++- .../TeamWorkspaceTeamOverviewChrome.test.tsx | 187 +++ .../TeamWorkspaceTeamOverviewChrome.tsx | 162 ++- .../TeamWorkspaceTeamOverviewControls.tsx | 53 +- ...orkspaceBoardPresentationSelectors.test.ts | 78 ++ ...teamWorkspaceBoardPresentationSelectors.ts | 138 ++ .../teamWorkspaceBoardPropBuilders.test.ts | 386 ++++++ .../teamWorkspaceBoardPropBuilders.ts | 250 ++-- .../useTeamWorkspaceBoardActionUiState.ts | 85 ++ .../useTeamWorkspaceBoardActions.ts | 100 +- ...seTeamWorkspaceBoardCanvasRuntime.test.tsx | 335 +++++ .../useTeamWorkspaceBoardComposer.test.tsx | 385 ++++++ .../useTeamWorkspaceBoardComposer.ts | 1 - ...eTeamWorkspaceBoardOperationState.test.tsx | 195 +++ .../useTeamWorkspaceBoardOperationState.ts | 76 ++ .../useTeamWorkspaceBoardPresentation.tsx | 121 +- .../useTeamWorkspaceBoardRuntimeState.ts | 123 +- ...ceBoardSelectedSessionActionState.test.tsx | 144 ++ ...orkspaceBoardSelectedSessionActionState.ts | 73 + ...useTeamWorkspaceBoardSessionGraph.test.tsx | 288 ++++ .../useTeamWorkspaceBoardShellProps.test.tsx | 318 +++++ .../useTeamWorkspaceBoardShellProps.ts | 42 +- ...useGeneralWorkbenchSidebarOrchestration.ts | 7 +- .../useGeneralWorkbenchWorkflowPanelState.ts | 2 +- .../agent/chat/hooks/agentChatHistory.test.ts | 4 + .../agent/chat/hooks/agentChatHistory.ts | 10 +- .../hooks/agentStreamRuntimeHandler.test.ts | 2 + src/components/agent/chat/hooks/index.ts | 1 - .../chat/hooks/useAsterAgentChat.test.tsx | 2 +- .../chat/hooks/useCompatSubagentRuntime.ts | 14 - .../hooks/useRuntimeTeamFormation.test.tsx | 2 - .../chat/hooks/useRuntimeTeamFormation.ts | 2 - .../hooks/useTeamMemoryShadowSync.test.tsx | 3 + .../chat/hooks/useTeamMemoryShadowSync.ts | 2 +- src/components/agent/chat/index.test.tsx | 539 ++------ .../boardChromeSelectors.test.ts | 35 +- .../boardChromeSelectors.ts | 97 +- .../canvasLaneSelectors.test.ts | 6 +- .../canvasLaneSelectors.ts | 37 +- .../formationDisplaySelectors.test.ts | 14 +- .../formationDisplaySelectors.ts | 88 +- .../liveRuntimeProjector.test.ts | 33 + .../liveRuntimeProjector.ts | 6 +- .../selectedSessionDetailSelectors.test.ts | 2 +- .../teamOperationSelectors.test.ts | 4 + .../teamOperationSelectors.ts | 12 +- .../agent/chat/teamWorkspaceRuntime.ts | 6 +- src/components/agent/chat/types.ts | 4 +- .../agent/chat/utils/agentThreadGrouping.ts | 25 +- .../utils/compatQuestionnaireA2UI.test.ts | 287 ---- .../chat/utils/compatQuestionnaireA2UI.ts | 1072 --------------- .../chat/utils/compatSubagentRuntime.test.ts | 85 -- .../agent/chat/utils/compatSubagentRuntime.ts | 92 -- .../utils/contextualRecommendations.test.ts | 16 +- .../chat/utils/contextualRecommendations.ts | 11 +- .../chat/utils/entryRecommendedSolutions.ts | 6 +- .../chat/utils/generalAgentPrompt.test.ts | 12 +- .../agent/chat/utils/generalAgentPrompt.ts | 20 +- .../agent/chat/utils/harnessState.ts | 2 +- .../agent/chat/utils/messageArtifacts.ts | 16 + .../utils/stableProcessingExperience.test.ts | 4 +- .../chat/utils/stableProcessingExperience.ts | 2 +- .../agent/chat/utils/subagentTimeline.test.ts | 49 +- .../agent/chat/utils/subagentTimeline.ts | 169 +-- .../agent/chat/utils/teamMemoryDisplay.ts | 9 + .../agent/chat/utils/teamPresets.ts | 2 +- .../agent/chat/utils/teamSuggestion.ts | 6 +- .../agent/chat/utils/teamWorkspaceCanvas.ts | 12 +- .../agent/chat/utils/teamWorkspaceCopy.ts | 10 +- .../agent/chat/utils/teamWorkspaceDisplay.ts | 34 + .../chat/utils/teamWorkspaceSessions.test.ts | 22 +- .../agent/chat/utils/teamWorkspaceSessions.ts | 47 +- .../agent/chat/utils/toolDisplayInfo.test.ts | 2 +- .../agent/chat/utils/toolDisplayInfo.ts | 32 +- .../workspace/WorkspaceHarnessDialogs.tsx | 4 +- .../agent/chat/workspace/chatSurfaceProps.ts | 7 +- .../chat/workspace/inputbarPresentation.ts | 40 - .../chat/workspace/runtimeTeamPreview.ts | 186 --- .../agent/chat/workspace/submissionPreview.ts | 66 - .../workspace/teamWorkbenchPresentation.tsx | 344 ----- ...useGeneralWorkbenchSidebarPresentation.tsx | 101 -- .../useWorkspaceA2UIRuntime.test.tsx | 26 + .../chat/workspace/useWorkspaceA2UIRuntime.ts | 61 +- .../useWorkspaceA2UISubmitActions.ts | 34 +- .../useWorkspaceCanvasLayoutRuntime.test.tsx | 2 +- .../useWorkspaceCanvasLayoutRuntime.ts | 12 +- .../useWorkspaceCanvasPreviewPresentation.tsx | 631 --------- .../useWorkspaceCanvasScenePresentation.tsx | 133 -- .../useWorkspaceCanvasSceneRuntime.test.ts | 124 ++ .../useWorkspaceCanvasSceneRuntime.tsx | 1103 +++++++++++++++- .../useWorkspaceContextDetailActions.tsx | 61 - .../useWorkspaceContextHarnessRuntime.ts | 7 +- ...WorkspaceConversationScenePresentation.tsx | 100 -- ...eWorkspaceConversationSceneRuntime.test.ts | 146 +- .../useWorkspaceConversationSceneRuntime.tsx | 175 ++- .../useWorkspaceConversationSendRuntime.ts | 35 - ...WorkspaceConversationShellSceneRuntime.tsx | 160 --- .../useWorkspaceDisplayMessagesRuntime.ts | 144 -- ...eWorkspaceGeneralWorkbenchShellRuntime.tsx | 152 --- ...aceGeneralWorkbenchVersionStatusRuntime.ts | 69 - .../useWorkspaceInputbarPresentation.tsx | 169 --- .../useWorkspaceInputbarScenePresentation.tsx | 133 -- .../useWorkspaceInputbarSceneRuntime.tsx | 428 ++++-- ...kspaceRuntimeTeamDispatchPreviewRuntime.ts | 50 - .../useWorkspaceSendActions.test.tsx | 99 +- .../chat/workspace/useWorkspaceSendActions.ts | 94 +- .../useWorkspaceShellChromeRuntime.ts | 115 -- .../useWorkspaceShellSceneRuntime.tsx | 100 -- ...orkspaceTeamSessionControlRuntime.test.tsx | 211 +++ .../useWorkspaceTeamSessionControlRuntime.ts | 63 +- .../useWorkspaceTeamSessionRuntime.test.tsx | 132 ++ .../useWorkspaceTeamSessionRuntime.ts | 8 +- ...seWorkspaceTeamWorkbenchAutoOpenRuntime.ts | 34 - .../useWorkspaceWorkflowProgressSync.ts | 77 -- .../workspace/workspaceSendHelpers.test.ts | 152 +++ .../chat/workspace/workspaceSendHelpers.ts | 268 +++- .../input-kit/ModelSelector.test.tsx | 56 + src/components/input-kit/ModelSelector.tsx | 40 +- .../api-key/AddCustomProviderModal.tsx | 24 + .../AddCustomProviderModal.ui.test.tsx | 34 + .../api-key/ApiKeyProviderSection.test.ts | 1 + .../api-key/DeleteProviderDialog.test.ts | 2 + .../api-key/ProviderConfigForm.tsx | 22 + .../api-key/ProviderConfigForm.ui.test.tsx | 22 + .../api-key/ProviderList.test.ts | 2 + .../api-key/ProviderListItem.test.ts | 1 + .../api-key/ProviderListItem.tsx | 13 + .../api-key/ProviderListItem.ui.test.tsx | 105 ++ .../api-key/ProviderSetting.test.ts | 2 + .../provider-pool/api-key/ProviderSetting.tsx | 12 + .../api-key/ProviderSetting.ui.test.tsx | 17 + .../provider-pool/api-key/README.md | 25 + .../general/appearance/index.test.tsx | 2 +- .../settings-v2/system/about/index.test.tsx | 6 +- src/hooks/subAgentSummary.test.ts | 90 -- src/hooks/subAgentSummary.ts | 116 -- src/hooks/useConfiguredProviders.test.ts | 27 + src/hooks/useConfiguredProviders.ts | 18 +- src/hooks/useSubAgentScheduler.test.tsx | 199 --- src/hooks/useSubAgentScheduler.ts | 254 ---- src/lib/api/agentProtocol.ts | 1 + src/lib/api/subAgentScheduler.test.ts | 50 - src/lib/api/subAgentScheduler.ts | 95 -- src/lib/governance/legacySurfaceCatalog.json | 73 +- .../governance/legacySurfaceCatalog.test.ts | 84 +- .../model/providerPromptCacheSupport.test.ts | 69 + src/lib/model/providerPromptCacheSupport.ts | 72 + 313 files changed, 19875 insertions(+), 9864 deletions(-) create mode 100644 docs/aiprompts/harness-engine-governance.md create mode 100644 docs/exec-plans/README.md create mode 100644 docs/exec-plans/tech-debt-tracker.md create mode 100644 docs/test/harness-fixtures/replay/minimal-observability-gap/evidence-links.json create mode 100644 docs/test/harness-fixtures/replay/minimal-observability-gap/expected.json create mode 100644 docs/test/harness-fixtures/replay/minimal-observability-gap/grader.md create mode 100644 docs/test/harness-fixtures/replay/minimal-observability-gap/input.json create mode 100644 scripts/check-generated-slop-report.mjs create mode 100644 scripts/lib/harness-dashboard-core.mjs create mode 100644 scripts/lib/harness-dashboard-core.test.ts create mode 100644 scripts/lib/harness-eval-repo-fixtures.test.ts create mode 100644 scripts/quality-task-planner.test.ts delete mode 100644 src-tauri/src/agent/subagent_scheduler.rs delete mode 100644 src-tauri/src/commands/subagent_cmd.rs create mode 100644 src/components/agent/chat/components/Inputbar/components/InputbarPromptCacheNotice.test.tsx create mode 100644 src/components/agent/chat/components/Inputbar/components/InputbarPromptCacheNotice.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/TeamWorkspaceCanvasSurfaceCopy.test.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/TeamWorkspaceTeamOverviewChrome.test.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/teamWorkspaceBoardPresentationSelectors.test.ts create mode 100644 src/components/agent/chat/components/team-workspace-board/teamWorkspaceBoardPresentationSelectors.ts create mode 100644 src/components/agent/chat/components/team-workspace-board/teamWorkspaceBoardPropBuilders.test.ts create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardActionUiState.ts create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardCanvasRuntime.test.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardComposer.test.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardOperationState.test.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardOperationState.ts create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardSelectedSessionActionState.test.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardSelectedSessionActionState.ts create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardSessionGraph.test.tsx create mode 100644 src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardShellProps.test.tsx delete mode 100644 src/components/agent/chat/hooks/useCompatSubagentRuntime.ts delete mode 100644 src/components/agent/chat/utils/compatQuestionnaireA2UI.test.ts delete mode 100644 src/components/agent/chat/utils/compatQuestionnaireA2UI.ts delete mode 100644 src/components/agent/chat/utils/compatSubagentRuntime.test.ts delete mode 100644 src/components/agent/chat/utils/compatSubagentRuntime.ts create mode 100644 src/components/agent/chat/utils/teamMemoryDisplay.ts create mode 100644 src/components/agent/chat/utils/teamWorkspaceDisplay.ts delete mode 100644 src/components/agent/chat/workspace/inputbarPresentation.ts delete mode 100644 src/components/agent/chat/workspace/runtimeTeamPreview.ts delete mode 100644 src/components/agent/chat/workspace/submissionPreview.ts delete mode 100644 src/components/agent/chat/workspace/teamWorkbenchPresentation.tsx delete mode 100644 src/components/agent/chat/workspace/useGeneralWorkbenchSidebarPresentation.tsx delete mode 100644 src/components/agent/chat/workspace/useWorkspaceCanvasPreviewPresentation.tsx delete mode 100644 src/components/agent/chat/workspace/useWorkspaceCanvasScenePresentation.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.test.ts delete mode 100644 src/components/agent/chat/workspace/useWorkspaceContextDetailActions.tsx delete mode 100644 src/components/agent/chat/workspace/useWorkspaceConversationScenePresentation.tsx delete mode 100644 src/components/agent/chat/workspace/useWorkspaceConversationSendRuntime.ts delete mode 100644 src/components/agent/chat/workspace/useWorkspaceConversationShellSceneRuntime.tsx delete mode 100644 src/components/agent/chat/workspace/useWorkspaceDisplayMessagesRuntime.ts delete mode 100644 src/components/agent/chat/workspace/useWorkspaceGeneralWorkbenchShellRuntime.tsx delete mode 100644 src/components/agent/chat/workspace/useWorkspaceGeneralWorkbenchVersionStatusRuntime.ts delete mode 100644 src/components/agent/chat/workspace/useWorkspaceInputbarPresentation.tsx delete mode 100644 src/components/agent/chat/workspace/useWorkspaceInputbarScenePresentation.tsx delete mode 100644 src/components/agent/chat/workspace/useWorkspaceRuntimeTeamDispatchPreviewRuntime.ts delete mode 100644 src/components/agent/chat/workspace/useWorkspaceShellChromeRuntime.ts delete mode 100644 src/components/agent/chat/workspace/useWorkspaceShellSceneRuntime.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceTeamSessionControlRuntime.test.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceTeamSessionRuntime.test.tsx delete mode 100644 src/components/agent/chat/workspace/useWorkspaceTeamWorkbenchAutoOpenRuntime.ts delete mode 100644 src/components/agent/chat/workspace/useWorkspaceWorkflowProgressSync.ts create mode 100644 src/components/agent/chat/workspace/workspaceSendHelpers.test.ts create mode 100644 src/components/provider-pool/api-key/ProviderListItem.ui.test.tsx delete mode 100644 src/hooks/subAgentSummary.test.ts delete mode 100644 src/hooks/subAgentSummary.ts delete mode 100644 src/hooks/useSubAgentScheduler.test.tsx delete mode 100644 src/hooks/useSubAgentScheduler.ts delete mode 100644 src/lib/api/subAgentScheduler.test.ts delete mode 100644 src/lib/api/subAgentScheduler.ts create mode 100644 src/lib/model/providerPromptCacheSupport.test.ts create mode 100644 src/lib/model/providerPromptCacheSupport.ts diff --git a/.github/workflows/harness-nightly.yml b/.github/workflows/harness-nightly.yml index d034aaba1..ef403d372 100644 --- a/.github/workflows/harness-nightly.yml +++ b/.github/workflows/harness-nightly.yml @@ -31,27 +31,18 @@ jobs: restore-keys: | harness-nightly-history- - - name: Generate harness eval summary + - name: Generate harness eval artifacts run: | - node scripts/harness-eval-runner.mjs \ - --output-json "./artifacts/harness-eval-summary.json" \ - --output-markdown "./artifacts/harness-eval-summary.md" \ - --record-history-dir "./artifacts/history" \ - --history-retain 30 - - - name: Generate harness eval trend - run: | - node scripts/harness-eval-trend-report.mjs \ + node scripts/harness-eval-history-record.mjs \ --history-dir "./artifacts/history" \ - --output-json "./artifacts/harness-eval-trend.json" \ - --output-markdown "./artifacts/harness-eval-trend.md" - - - name: Generate harness cleanup report - run: | - node scripts/report-generated-slop.mjs \ - --trend-input "./artifacts/harness-eval-trend.json" \ - --output-json "./artifacts/harness-cleanup-report.json" \ - --output-markdown "./artifacts/harness-cleanup-report.md" + --retain 30 \ + --summary-json "./artifacts/harness-eval-summary.json" \ + --summary-markdown "./artifacts/harness-eval-summary.md" \ + --trend-json "./artifacts/harness-eval-trend.json" \ + --trend-markdown "./artifacts/harness-eval-trend.md" \ + --cleanup-json "./artifacts/harness-cleanup-report.json" \ + --cleanup-markdown "./artifacts/harness-cleanup-report.md" \ + --dashboard-html "./artifacts/harness-dashboard.html" - name: Upload harness eval artifact uses: actions/upload-artifact@v4 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 941641c4e..2fca36794 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -16,6 +16,7 @@ jobs: runs-on: ubuntu-latest outputs: bridge: ${{ steps.detect.outputs.bridge }} + bridge_reasons: ${{ steps.detect.outputs.bridge_reasons }} changed_count: ${{ steps.detect.outputs.changed_count }} docs: ${{ steps.detect.outputs.docs }} docs_only: ${{ steps.detect.outputs.docs_only }} @@ -194,30 +195,97 @@ jobs: - name: Summarize shell: bash run: | - echo "changed_count: ${{ needs.changed.outputs.changed_count }}" - echo "docs_only : ${{ needs.changed.outputs.docs_only }}" - echo "integrity : ${{ needs.integrity.result }}" - echo "frontend : ${{ needs.frontend.result }}" - echo "bridge : ${{ needs.bridge_contracts.result }}" - echo "gui_smoke : ${{ needs.gui_smoke.result }}" + CHANGED_COUNT='${{ needs.changed.outputs.changed_count }}' + DOCS_ONLY='${{ needs.changed.outputs.docs_only }}' + BRIDGE_REASONS='${{ needs.changed.outputs.bridge_reasons }}' + INTEGRITY_RESULT='${{ needs.integrity.result }}' + FRONTEND_RESULT='${{ needs.frontend.result }}' + BRIDGE_RESULT='${{ needs.bridge_contracts.result }}' + GUI_SMOKE_RESULT='${{ needs.gui_smoke.result }}' + FALLBACK='${{ needs.changed.outputs.fallback }}' + WORKFLOW='${{ needs.changed.outputs.workflow }}' + INTEGRITY_REQUIRED='${{ needs.changed.outputs.integrity }}' + FRONTEND_REQUIRED='${{ needs.changed.outputs.frontend }}' + BRIDGE_REQUIRED='${{ needs.changed.outputs.bridge }}' + GUI_SMOKE_REQUIRED='${{ needs.changed.outputs.gui_smoke }}' - if [[ '${{ needs.changed.outputs.docs_only }}' == 'true' ]]; then - echo 'Only docs/markdown changes detected -> quality checks not required.' + BRIDGE_REASONS_DISPLAY="${BRIDGE_REASONS//,/、}" + if [[ -z "${BRIDGE_REASONS_DISPLAY}" ]]; then + BRIDGE_REASONS_DISPLAY="无" + fi + + echo "changed_count: ${CHANGED_COUNT}" + echo "docs_only : ${DOCS_ONLY}" + echo "bridge_reasons: ${BRIDGE_REASONS}" + echo "integrity : ${INTEGRITY_RESULT}" + echo "frontend : ${FRONTEND_RESULT}" + echo "bridge : ${BRIDGE_RESULT}" + echo "gui_smoke : ${GUI_SMOKE_RESULT}" + + FINAL_STATUS="success" + FAILURE_REASON="" + + if [[ "${DOCS_ONLY}" == 'true' ]]; then + SUMMARY_NOTE="Only docs/markdown changes detected -> quality checks not required." + else + SUMMARY_NOTE="Quality checks evaluated against changed areas." + + if [[ "${INTEGRITY_REQUIRED}" == 'true' || "${FALLBACK}" == 'true' || "${WORKFLOW}" == 'true' ]]; then + if [[ "${INTEGRITY_RESULT}" != 'success' ]]; then + FINAL_STATUS="failed" + FAILURE_REASON="integrity failed" + fi + fi + + if [[ -z "${FAILURE_REASON}" && ("${FRONTEND_REQUIRED}" == 'true' || "${FALLBACK}" == 'true' || "${WORKFLOW}" == 'true') ]]; then + if [[ "${FRONTEND_RESULT}" != 'success' ]]; then + FINAL_STATUS="failed" + FAILURE_REASON="frontend failed" + fi + fi + + if [[ -z "${FAILURE_REASON}" && ("${BRIDGE_REQUIRED}" == 'true' || "${FALLBACK}" == 'true' || "${WORKFLOW}" == 'true') ]]; then + if [[ "${BRIDGE_RESULT}" != 'success' ]]; then + FINAL_STATUS="failed" + FAILURE_REASON="bridge/contracts failed" + fi + fi + + if [[ -z "${FAILURE_REASON}" && ("${GUI_SMOKE_REQUIRED}" == 'true' || "${FALLBACK}" == 'true' || "${WORKFLOW}" == 'true') ]]; then + if [[ "${GUI_SMOKE_RESULT}" != 'success' ]]; then + FINAL_STATUS="failed" + FAILURE_REASON="gui smoke failed" + fi + fi + fi + + { + echo "## Quality Summary" + echo + echo "| Item | Value |" + echo "| --- | --- |" + echo "| changed_count | ${CHANGED_COUNT} |" + echo "| docs_only | ${DOCS_ONLY} |" + echo "| bridge_reasons | ${BRIDGE_REASONS_DISPLAY} |" + echo "| integrity | ${INTEGRITY_RESULT} |" + echo "| frontend | ${FRONTEND_RESULT} |" + echo "| bridge_contracts | ${BRIDGE_RESULT} |" + echo "| gui_smoke | ${GUI_SMOKE_RESULT} |" + echo "| final_status | ${FINAL_STATUS} |" + echo + echo "${SUMMARY_NOTE}" + if [[ -n "${FAILURE_REASON}" ]]; then + echo + echo "Failure reason: ${FAILURE_REASON}" + fi + } >> "$GITHUB_STEP_SUMMARY" + + if [[ "${DOCS_ONLY}" == 'true' ]]; then + echo "${SUMMARY_NOTE}" exit 0 fi - if [[ '${{ needs.changed.outputs.integrity }}' == 'true' || '${{ needs.changed.outputs.fallback }}' == 'true' || '${{ needs.changed.outputs.workflow }}' == 'true' ]]; then - [[ '${{ needs.integrity.result }}' == 'success' ]] || { echo 'integrity failed'; exit 1; } - fi - - if [[ '${{ needs.changed.outputs.frontend }}' == 'true' || '${{ needs.changed.outputs.fallback }}' == 'true' || '${{ needs.changed.outputs.workflow }}' == 'true' ]]; then - [[ '${{ needs.frontend.result }}' == 'success' ]] || { echo 'frontend failed'; exit 1; } - fi - - if [[ '${{ needs.changed.outputs.bridge }}' == 'true' || '${{ needs.changed.outputs.fallback }}' == 'true' || '${{ needs.changed.outputs.workflow }}' == 'true' ]]; then - [[ '${{ needs.bridge_contracts.result }}' == 'success' ]] || { echo 'bridge/contracts failed'; exit 1; } - fi - - if [[ '${{ needs.changed.outputs.gui_smoke }}' == 'true' || '${{ needs.changed.outputs.fallback }}' == 'true' || '${{ needs.changed.outputs.workflow }}' == 'true' ]]; then - [[ '${{ needs.gui_smoke.result }}' == 'success' ]] || { echo 'gui smoke failed'; exit 1; } + if [[ -n "${FAILURE_REASON}" ]]; then + echo "${FAILURE_REASON}" + exit 1 fi diff --git a/AGENTS.md b/AGENTS.md index 83b75595b..92a2658ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,99 +1,78 @@ # AI Agent 指南 -本文件只用于 **开发 Lime 源码仓库本身**。 -根 `AGENTS.md` 只保留仓库级规则、高频入口和硬约束;长流程与模块说明统一下沉到 `docs/aiprompts/`。 +本文件只用于 **开发 Lime 源码仓库本身**。根 `AGENTS.md` 只保留仓库级约束、导航和统一入口;模块细节与长流程统一下沉到 `docs/`。 -## 仓库级原则 +## 原则 -1. **始终使用中文输出** - 回复、注释、文档统一使用中文 -2. **先读后写** - 修改文件前必须先读取现有内容 -3. **避免无关变更** - 不顺手重构、不扩大范围、不主动做 git 提交/分支操作 -4. **规则留根,流程下沉** - 根文件只放长期稳定的仓库规则;长说明写到 `docs/aiprompts/` +1. **代码仓库是唯一的记录系统** - 不在 repo 里的知识对智能体不存在;凡影响开发的讨论、决策、外部资料,都必须落成 repo 内的 versioned artifact +2. **本文件是地图,不是百科全书** - 保持约 `100` 行,只暴露本层信息和下一步导航 +3. **把品味编码为规则** - 优先用 linter、结构测试、CI 检查约束质量;可机械验证优先于散文指南 +4. **计划是一等工件** - 执行计划带进度日志,集中存放于 `docs/exec-plans/` +5. **持续垃圾回收** - 技术债按小额、持续方式偿还;差距追踪见 `docs/exec-plans/tech-debt-tracker.md` +6. **卡住时修环境,不是更用力** - 先补上下文、工具、约束,再继续实现;缺口也要写回 repo -## 跨平台硬约束 +## 基础约束 -1. **默认双平台** - 新增功能、脚本、文档默认同时考虑 macOS 与 Windows -2. **禁止硬编码平台路径** - 用户数据、日志、缓存、凭证等目录必须走系统 API 或统一封装 -3. **优先平台无关入口** - 优先复用 `npm`、`cargo`、Tauri 命令和仓库脚本,不新增只适用于 Bash/zsh 的流程 -4. **未验证的平台假设要显式说明** - 涉及文件系统、进程、终端、快捷键、窗口、托盘、权限时尤其如此 +1. **始终使用中文** - 回复、文档、代码注释默认使用中文;若文件已有其他注释语言,保持与现有代码库一致 +2. **先读后写** - 修改文件前先读现状和相邻边界 +3. **避免无关变更** - 不顺手重构、不扩大范围、不主动做 git 提交或分支操作 +4. **默认双平台** - 新增功能、脚本、路径处理默认同时考虑 macOS 与 Windows +5. **禁止硬编码平台路径** - 用户数据、日志、缓存、凭证等目录必须走系统 API 或统一封装 +6. **优先平台无关入口** - 优先复用 `npm`、`cargo`、Tauri 命令和仓库脚本,不新增只适用于 Bash/zsh 的流程 +7. **未验证的平台假设要显式说明** - 涉及文件系统、进程、终端、快捷键、窗口、托盘、权限时尤其如此 +8. **不要继续扩展 compat / deprecated 路径** - 新 API、新命令、新前端入口默认落在当前 `current` 主路径 ## 工程硬规则 -1. **默认先跑统一入口** - 功能改动发起 PR 前默认执行 `npm run verify:local` -2. **版本改动必须校验一致性** - 改 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf*.json` 时必须执行 `npm run verify:app-version` -3. **命令边界改动必须校验契约** - 改 `safeInvoke` / `invoke`、Tauri command、Bridge/mock 边界时必须执行 `npm run test:contracts` +1. **默认统一校验入口** - 提交前默认执行 `npm run verify:local` +2. **版本改动必须校验一致性** - 改 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf*.json` 时执行 `npm run verify:app-version` +3. **协议改动必须同步四侧** - `safeInvoke(...)` / `invoke(...)`、`tauri::generate_handler!`、`agentCommandCatalog`、`mockPriorityCommands` / `defaultMocks` 必须保持一致,并执行 `npm run test:contracts` 4. **Lime 是 GUI 桌面产品** - 不能只以 `lint`、`typecheck`、单测通过作为“可交付”判断 -5. **高风险 GUI 改动必须做最小冒烟** - 涉及 GUI 壳、DevBridge、Workspace、主页面路径时必须执行 `npm run verify:gui-smoke` -6. **不要继续扩展 compat / deprecated 路径** - 新 API、新命令、新前端入口默认落在当前 `current` 主路径 -7. **协议改动必须同步四侧** - `safeInvoke(...)` / `invoke(...)`、`tauri::generate_handler!`、`agentCommandCatalog`、`mockPriorityCommands` / `defaultMocks` 必须保持一致 -8. **协议改动必须同步文档** - 至少同步更新 `docs/aiprompts/commands.md`、`docs/aiprompts/playwright-e2e.md`、`docs/aiprompts/quality-workflow.md` -9. **命令运行时改动先补设计包** - 新增或调整 `@` / 产品型 `/` / 轻卡 / viewer / `ServiceSkill` 场景时,先读 `docs/aiprompts/command-runtime.md`,并先补完整方案包再进入实现 -10. **用户可见 UI 改动必须补稳定回归** - 优先补现有 `*.test.tsx` 的关键文案、状态、交互断言;若已有 snapshot 机制,沿用现有机制 -11. **配置结构改动要成组更新** - schema、校验器、消费者、文档必须同步演进 -12. **依赖改动要同步锁文件** - 提交 `package-lock.json`、`src-tauri/Cargo.lock` 等实际锁文件;本仓库不适用 Bazel 规则 -13. **Rust 测试先小后大** - 先跑受影响 crate / 模块 / 定向测试,再决定是否跑全量 `cargo test` -14. **控制 Rust 文件规模** - 新增模块尽量控制在 `500 LoC` 以内;文件接近 `800 LoC` 时,新功能优先拆新模块 -15. **不要继续放大历史大文件** - 现有超大文件属于历史包袱,但新增逻辑应优先拆边界,不继续堆叠 -16. **质量门禁保持单一主线** - `.github/workflows/quality.yml`、`scripts/quality-task-planner.mjs`、本地统一入口要保持一致 +5. **高风险 GUI 改动必须做最小冒烟** - 涉及 GUI 壳、DevBridge、Workspace、主路径时执行 `npm run verify:gui-smoke` +6. **用户可见 UI 改动必须补稳定回归** - 优先补现有 `*.test.tsx` 或 snapshot 断言 +7. **配置与依赖改动要成组更新** - schema、校验器、消费者、文档、锁文件保持同步 +8. **Rust 变更先小测后全量** - 先跑受影响 crate / 模块 / 定向测试;新增模块尽量控制在 `500 LoC` 内,文件接近 `800 LoC` 时优先拆新模块 +9. **Harness Engine 只认单一事实源** - handoff / evidence / replay / analysis / review / GUI 统一消费 `agent_runtime_export_evidence_pack`;`requestTelemetry` 需要按 `session/thread/turn` 真实关联导出,无匹配请求时输出空摘要,不再保留伪 `unlinked` -## 路线图主线护栏 +## 执行与路线图 -当用户明确要求“对齐路线图 / 按顺序完成目标 / 继续主线”时,额外遵守以下规则: +1. **主线任务先重述目标** - 用户要求“对齐路线图 / 继续主线”时,先说明当前主目标、阶段和下一刀 +2. **每一刀都要可追踪** - 改动要么回挂到 `docs/roadmap/`,要么登记到 `docs/exec-plans/` 或技术债追踪 +3. **清理不能替代交付** - 连续两轮主要在做治理减法后,下一轮优先回到未完成主线 +4. **长任务必须落计划** - 超过一轮的实现、迁移、清理,写入 `docs/exec-plans/` 并持续更新进度日志 -1. **先重述主目标** - 开始新一轮实现前,先用一句话重述当前路线图的主目标、当前阶段和下一刀 -2. **主线优先于清理** - 默认优先推进路线图中尚未完成的主链事项;零引用清理、README 同步、dead util 删除只能作为从属动作,不能替代主线 -3. **每一刀都要回挂路线图** - 任何改动都必须能明确回答“它对应路线图哪一节、缩短了哪条主链距离” -4. **连续清理后强制回看路线图** - 如果连续两轮工作主要是治理减法或 dead surface 清退,下一轮必须重新打开路线图并优先选择尚未完成的主链项 -5. **发现偏离要立即纠偏** - 如果当前改动无法直接服务 `Conversation Runtime 效率 / 前端瘦身 / Team 委派 / 协议收敛 / 状态统一` 这五条主线,应立即停止扩散并回到路线图 -6. **汇报必须带主线判断** - 阶段汇报时必须显式说明“这一步为什么仍在主目标上”,不能只汇报局部文件改动 - -## UI 规则 - -1. **改界面先读视觉规范** - 先看 `docs/aiprompts/design-language.md` -2. **中文排版优先** - 避免英文 tracking 过大、重复标题、拥挤统计卡文案 -3. **宽度按页面类型选** - 表单页窄、工作台页宽,不要整仓一个 `max-width` -4. **渐变只做氛围层** - 背景存在感必须弱于内容 -5. **主表面默认实体底色** - 不要默认用 `bg-white/80`、`backdrop-blur` 制造层级 - -## 常用文档入口 +## 文档导航 +- **文档中心**:`docs/README.md` +- **模块级工程导航**:`docs/aiprompts/README.md` - **架构概览**:`docs/aiprompts/overview.md` -- **工程质量**:`docs/aiprompts/quality-workflow.md` -- **命令运行时**:`docs/aiprompts/command-runtime.md` -- **治理收口**:`docs/aiprompts/governance.md` -- **技能标准**:`docs/aiprompts/skill-standard.md` -- **站点适配器标准**:`docs/aiprompts/site-adapter-standard.md` +- **工程质量 / 校验**:`docs/aiprompts/quality-workflow.md` - **UI 规范**:`docs/aiprompts/design-language.md` - **Tauri 命令边界**:`docs/aiprompts/commands.md` -- **凭证与路径**:`docs/aiprompts/credential-pool.md` +- **命令运行时**:`docs/aiprompts/command-runtime.md` +- **治理与收口**:`docs/aiprompts/governance.md` +- **Harness Engine 治理**:`docs/aiprompts/harness-engine-governance.md` - **Playwright / GUI 续测**:`docs/aiprompts/playwright-e2e.md` +- **计划与进度**:`docs/exec-plans/README.md` +- **技术债追踪**:`docs/exec-plans/tech-debt-tracker.md` +- **路线图**:`docs/roadmap/` - **Codex Skills 索引**:`.codex/skills/README.md` ## 高频命令 ```bash -# 本地统一校验 npm run verify:local npm run verify:local:full - -# GUI 最小冒烟 npm run verify:gui-smoke npm run bridge:health -- --timeout-ms 120000 - -# 契约与治理 npm run test:contracts npm run governance:legacy-report - -# GUI / headless 调试 npm run tauri:dev:headless - -# Rust 定向 / 全量测试 cd src-tauri && cargo test ``` -## 文档维护 +## 维护规则 -1. 改仓库级规则时,同时更新本文件与对应 `docs/aiprompts/` 文档 -2. 新增长期使用的工程脚本时,同时补 `package.json` 与对应文档入口 -3. 如果某段说明已经超过“规则/入口”的粒度,就迁出根 `AGENTS.md` -4. 如果某条流程已经高频复用到值得做成 skill,同步更新 `.codex/skills/README.md` +1. 改仓库级规则时,同时更新本文件和对应 `docs/` 入口 +2. 新增长期流程优先落到 `docs/`;高频复用后再沉淀为 `.codex/skills/` +3. 如果某条规则已经能被 linter、结构测试或 CI 机械约束,就把约束写进工具链,而不是继续往本文件堆说明 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index ebc5522b7..eb6fb711d 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,8 +1,8 @@ -## Lime v1.8.0 +## Lime v1.9.0 ### ✨ 主要更新 -- 本次 `v1.8.0` 已收口当前工作区全部改动,核心集中在 Agent 聊天工作台、General Workbench、Service Skill、Team Workspace、Artifact / Timeline 展示与输入发送主链 +- 本次 `v1.9.0` 已收口当前工作区全部改动,核心集中在 Agent 聊天工作台、General Workbench、Service Skill、Team Workspace、Artifact / Timeline 展示与输入发送主链 - `src/components/agent/**`、`src/components/workspace/**`、`src/lib/api/**`、`src/features/browser-runtime/**`、`src/components/settings-v2/**` 一批界面、运行时与回归测试已一并进入本次发布 - 浏览器运行时、现有会话桥接、工具展示、团队协作、项目选择、技能目录、工作台工具命令与内容同步相关边界已同步更新 - 工程文档 `docs/aiprompts/commands.md`、`playwright-e2e.md`、`quality-workflow.md` 已随当前实现一起更新 @@ -10,14 +10,14 @@ ### 🔗 依赖与版本同步 - `aster-core` / `aster-models` 已内置到 `src-tauri/crates/aster-rust/`,不再依赖外部仓库本地 override -- 应用与 CLI 发布版本提升到 `1.8.0` -- 应用版本入口已对齐到 `1.8.0`,覆盖 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` -- `packages/lime-cli-npm/package.json`、README 发布示例与本地 `package-lock.json` 已同步到 `1.8.0` +- 应用与 CLI 发布版本提升到 `1.9.0` +- 应用版本入口已对齐到 `1.9.0`,覆盖 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` +- `packages/lime-cli-npm/package.json`、README 发布示例与本地 `package-lock.json` 已同步到 `1.9.0` - `src-tauri/Cargo.lock` 已在本轮 Rust 校验后同步更新到当前 workspace 状态 ### ⚠️ 发布说明 -- 本次发布 tag 为 `v1.8.0` +- 本次发布 tag 为 `v1.9.0` - 本次发布以当前工作区完整改动为准,不复用旧 tag - 当前 release note 已按这次完整发布内容刷新 @@ -31,8 +31,8 @@ ### 📝 文档同步 -- 发布说明已更新为当前这次完整的 `v1.8.0` 内容,可直接作为 GitHub Release note 使用 +- 发布说明已更新为当前这次完整的 `v1.9.0` 内容,可直接作为 GitHub Release note 使用 --- -**完整变更**: `v1.7.0` -> `v1.8.0` +**完整变更**: `v1.8.0` -> `v1.9.0` diff --git a/docs/README.md b/docs/README.md index 6dafd46be..bbeb93b9b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ - `content/`:对外文档站正文(产品介绍、用户指南、进阶能力) - `aiprompts/`:模块级工程文档(前后端组件、服务、命令、数据层) +- `exec-plans/`:执行计划、进度日志、技术债追踪 - `tech/`:跨模块技术蓝图与专题工程文档(当前已包含 Harness Engineering 指导文档) - `bussniss/`:商务合作与代理运营方案 - `develop/`:开发流程与协作规范 @@ -21,6 +22,8 @@ - `iteration-notes/`:迭代备忘与下版本建议(暂不进入当前发布范围的问题) - `images/`:文档图片资源 - `TECH_SPEC.md`:技术规格文档 +- `exec-plans/README.md`:执行计划目录说明 +- `exec-plans/tech-debt-tracker.md`:技术债持续追踪表 - `develop/execution-tracker-technical-plan.md`:统一执行追踪(Execution Tracker)专项技术规划 - `develop/execution-tracker-deprecation-plan.md`:统一执行追踪旧路径退场计划(P0 收口) - `develop/execution-tracker-p0-acceptance-report.md`:统一执行追踪 P0 验收报告 diff --git a/docs/aiprompts/README.md b/docs/aiprompts/README.md index 9991ec1ef..40054e2d5 100644 --- a/docs/aiprompts/README.md +++ b/docs/aiprompts/README.md @@ -16,6 +16,7 @@ - `overview.md` - 项目架构总览与模块分层 - `governance.md` - 新旧并存治理、迁移收口、禁止回流 +- `harness-engine-governance.md` - Harness Engine 事实源、evidence pack、replay / analysis / review 治理规范 - `quality-workflow.md` - 本地校验、GUI smoke、契约检查、CI 门禁 - `command-runtime.md` - `@` / `/` / 轻卡 / viewer / 功能方案包实施手册 - `skill-standard.md` - 统一技能标准、skill / adapter / runtime binding 边界 @@ -62,6 +63,7 @@ - **做网页登录态访问 / 网页导出 / Markdown 落盘场景**:先读 `web-browser-scene-skill.md` - **改 Workspace / GUI 壳 / 主路径**:先读 `workspace.md`、`quality-workflow.md`、`playwright-e2e.md` - **做迁移 / 收口 / 去兼容层**:先读 `governance.md` +- **改 handoff / evidence pack / replay / review / HarnessStatusPanel**:先读 `harness-engine-governance.md`,再回看 `governance.md` - **改 Provider / 凭证加载 / Token 刷新**:先读 `providers.md`、`credential-pool.md` - **做跨仓库联动**:先读 `limecore-collaboration-entry.md` diff --git a/docs/aiprompts/commands.md b/docs/aiprompts/commands.md index b07a8d265..43e7852fc 100644 --- a/docs/aiprompts/commands.md +++ b/docs/aiprompts/commands.md @@ -510,7 +510,7 @@ npm run verify:local - **Agent / Codex 主命令**:继续收敛到 `agent_runtime_*` - **子代理运行时主链**:继续收敛到 `agent_runtime_spawn_subagent`;当前 request surface 使用 `name / teamName / runInBackground / mode / isolation / cwd` 等字段,其中 `teamName` 需要与 `name` 搭配并依附现有 Team 上下文,`cwd` 必须是绝对目录,并稳定投影到 child session 的 `working_dir` 与 Team 成员展示;当前 runtime 仍会明确拒绝非空 `mode / isolation` -- **Team runtime 工具主链**:当前协作工具面继续收敛到 `Agent / TeamCreate / TeamDelete / SendMessage / ListPeers`;`SubAgentTask` 仅保留兼容入口,不再作为新的多代理主路径 +- **Team runtime 工具主链**:当前协作工具面继续收敛到 `Agent / TeamCreate / TeamDelete / SendMessage / ListPeers`;不要把已删除的 `SubAgentTask` compat 工具重新接回新的多代理主路径 - **用户可见消息工具主链**:继续收敛到 `SendUserMessage`,用于把回复、进度同步、主动提醒和附件送到用户主可见消息面;不要再把这类能力拆到其它平行工具名或旁路协议里 - **会话状态回写主链**:继续收敛到 `agent_runtime_update_session`,用于名称、执行策略、session provider/model、`recent_access_mode`、`recent_preferences` 以及 `recent_team_selection` 的轻量持久化回写 - **会话权限主链**:`agent_runtime_submit_turn.turn_config.approval_policy / sandbox_policy` 是正式 turn context 权限协议;`getSession` 返回的 `execution_runtime.recent_access_mode` 负责承接会话最近一次 accessMode。当前端已命中同一 steady-state 权限时,不应继续依赖 `harness.access_mode` 作为唯一事实源 diff --git a/docs/aiprompts/governance.md b/docs/aiprompts/governance.md index 469ab8acb..ec9fa7fd3 100644 --- a/docs/aiprompts/governance.md +++ b/docs/aiprompts/governance.md @@ -244,6 +244,17 @@ npm run test:contracts 只要其中任意一个答案是否定的,就说明治理还没完成。 +### Harness Engine 主链 + +遇到 handoff、evidence pack、replay、review、外部诊断交接相关改动时,至少问这几个问题: + +- 运行时事实是不是继续收敛到 `agent_runtime_export_evidence_pack` +- replay / analysis / review / GUI 是否只是在消费 evidence pack,而不是重新拼装一套摘要 +- gap 是否只来自“当前线程真实适用但尚未导出”的信号,而不是历史硬编码模板 +- request telemetry 是否已经按 `session/thread/turn` 真实关联导出;如果当前线程没有匹配请求,是否导出空摘要而不是继续保留 `unlinked` + +只要其中任意一个答案是否定的,就说明 Harness Engine 还在继续长平行事实源。 + ### 记忆与旁路 遇到记忆系统治理时,至少同时看: @@ -302,6 +313,7 @@ npm run test:contracts ## 相关文档 - `docs/aiprompts/commands.md` +- `docs/aiprompts/harness-engine-governance.md` - `docs/aiprompts/quality-workflow.md` - `docs/aiprompts/project-heatmap.md` diff --git a/docs/aiprompts/harness-engine-governance.md b/docs/aiprompts/harness-engine-governance.md new file mode 100644 index 000000000..d592e4f89 --- /dev/null +++ b/docs/aiprompts/harness-engine-governance.md @@ -0,0 +1,271 @@ +# Lime Harness Engine 治理规范 + +## 这份文档回答什么 + +本文件定义 Lime 的 harness Engine 应该如何长期演进,主要回答: + +- 运行时诊断、evidence pack、replay case、analysis handoff、review template 分别谁负责什么 +- 什么才算 harness 的唯一事实源,什么只是消费层或展示层 +- 哪些证据可以导出,哪些只能在“适用时”导出,不能把不存在的信号硬写成全局缺口 +- 当 Lime 对齐 Claude Code 的治理方式时,哪些原则应该真正落到代码里 + +它是 **Harness Engine 的长期治理文档**,不是某次分析导出功能的临时设计说明。 + +## 对齐目标 + +这份规范参考 Claude Code 的治理方式,但 **不照搬实现细节**。对齐的是以下几条原则: + +1. **单一事实源** +2. **先采集,再解释** +3. **只有可关联的遥测,才算会话级证据** +4. **只有真正发生过的验证,才进入 evidence** +5. **消费层不重新拼装另一套运行时真相** + +一句话说,就是: + +**Harness Engine 不是“再做一层报表”,而是把运行时事实稳定导出,供 replay / analysis / review / UI 复用。** + +## 事实源声明 + +从现在开始,Harness Engine 相关能力默认收敛到以下主链: + +- 运行时事实导出:`agent_runtime_export_evidence_pack` +- 回放样本导出:`agent_runtime_export_replay_case` +- 外部诊断交接:`agent_runtime_export_analysis_handoff` +- 人工审核模板:`agent_runtime_export_review_decision_template`、`agent_runtime_save_review_decision` + +其中: + +- `evidence pack` 是 **运行时事实源** +- `replay / analysis / review / history-record summary` 是 **派生物** +- GUI 面板例如 `HarnessStatusPanel` 与 nightly dashboard 是 **展示层** + +允许的数据方向只有: + +`runtime thread/session` -> `evidence pack` -> `replay / analysis / review / summary` -> `trend / cleanup / dashboard` -> `UI` + +禁止反向或旁路: + +- `analysis` 自己再拼一套 observability summary +- `replay` 不复用 evidence pack,改为本地猜测缺口 +- `UI` 的显示状态反过来成为事实源 + +## 分类语言 + +Harness Engine 相关 surface 继续沿用仓库统一分类: + +- `current`:当前唯一主路径,后续功能只允许继续向这里收敛 +- `compat`:兼容层,只允许委托和适配 +- `deprecated`:已知要退出的旧路径,只允许迁移和下线 +- `dead`:已无入口或确认停用,优先删除 + +在 harness 语境里,常见判断如下: + +- `current` + - `agent_runtime_export_*` + - evidence pack 中的 `runtime.json / timeline.json / artifacts.json` +- `compat` + - 临时存在但只做委托的旧导出入口 + - 仍保留的旧 GUI 打开目录/复制命令适配层 +- `deprecated` + - 各消费方各自维护的 observability builder + - 无条件导出固定 known gaps 的旧逻辑 +- `dead` + - 已没有入口引用的旧 handoff / review / diagnostics 拼装路径 + +## 核心治理原则 + +### 1. 先导出事实,再导出判断 + +evidence pack 先负责导出: + +- 关联键 +- timeline +- warnings / failed tool / failed command +- 最近产物 +- 已真实发生的 verification 记录 + +其中 `observabilitySummary` 应优先承载紧凑的 verification outcome 摘要;更完整的明细仍可放在 `artifacts.json`,但下游不应为了得到“是否通过 / 是否失败 / 是否缺失”再单独重建第二套判断。 + +analysis brief、replay input、review template 只能在此基础上做解释,不能自己再回头拼装另一套事实。 + +### 2. 信号必须按适用性导出 + +Claude Code 风格最重要的一点,不是“信号越多越好”,而是“只导出当前回合真正成立的信号”。 + +因此 Lime 的 harness 必须遵守: + +- `requestTelemetry` + - RequestLog 必须携带 `session_id / thread_id / turn_id / pending_request_id / queued_turn_id / subagent_session_id` + - evidence pack 必须按这些关联键导出会话级 request telemetry 摘要 + - 如果当前线程没有匹配请求,导出空摘要即可,不能再伪造 `unlinked` +- `artifactValidator` + - 只有最近产物里存在 ArtifactDocument 或等价 artifact schema 时,才允许出现 +- `browserVerification` + - 只有线程里真实出现浏览器工具或浏览器命令时,才允许出现 +- `guiSmoke` + - 只有线程里真实执行了 `verify:gui-smoke` 时,才允许出现 + +不适用的信号不应进入 `known_gaps`,更不应被硬编码成所有线程的默认缺口。 + +### 3. gap 代表“已证明缺失”,不是“历史上想接” + +`known_gaps` 只能来自: + +- 已定义的信号覆盖表 +- 且状态不是 `exported` +- 且该信号对当前线程确实适用 + +禁止继续保留以下旧做法: + +- 所有线程都写 `artifactValidator/browserVerification/guiSmoke` +- 即使没有相关产物或相关命令,也输出空 verification 壳 +- 用“未来计划支持”冒充“当前已识别缺口” + +补充约束: + +- trend / cleanup report 必须区分 `current` 样本里的 gap 和 `degraded` 样本故意保留的 gap +- 只有 `current` 样本出现的 observability gap 才应被当成主线回归风险 +- `degraded` 样本的作用是保留诊断语义与 known gap 事实,不能反过来把 cleanup 优先级抬高 + +### 4. 关联键先于遥测摘要 + +Claude Code 的 tracing / event logging 之所以可用,前提是事件先有稳定的 session 级关联。 + +因此 Lime 必须把下面这些键视为 harness 级基础设施,而不是锦上添花: + +- `session_id` +- `thread_id` +- `turn_id` +- `pending_request_id` +- `queued_turn_id` +- `subagent_session_id` + +如果 RequestLog 还没稳定携带这些键,就不能把 request telemetry 当成会话级证据;但一旦键已补齐,后续 analysis / replay / review / UI 必须统一消费同一份 request telemetry 摘要,不能继续并存旧的 `unlinked` 语义。 + +### 5. 展示层不能成为新事实源 + +以下内容只能消费 evidence pack,不能反向定义事实: + +- `HarnessStatusPanel` +- 外部 AI copy prompt +- review-summary / analysis-brief 里的文案摘要 +- GUI 上看到的 warning / gap / status 卡片 + +展示层至少要如实透出: + +- `current / degraded` observability gap 角色 +- `observabilityVerificationOutcomes` 里的紧凑 outcome 焦点 +- cleanup 主事实源里已经存在的 `blocking_failure / advisory_failure / recovered` 摘要 + +对 cleanup / dashboard / nightly report 这条推荐动作链,字段语义也必须固定: + +- failure 焦点只允许走 `focusVerificationFailureOutcomes` +- recovered 焦点只允许走 `focusVerificationRecoveredOutcomes` +- 不允许继续把两类 outcome 混在一个 recommendation 字段里,再由展示层二次猜测 + +一旦 UI 文案和后端 evidence 冲突,以 evidence pack 和运行时导出为准。 + +### 6. 诊断链路必须复用下游制品 + +标准导出链路应保持为: + +1. `evidence pack` 先落盘 +2. `replay case` 复用 evidence pack +3. `analysis handoff` 复用 handoff bundle + evidence pack + replay case +4. `history-record` 复用 summary,并继续派生 `trend / cleanup / dashboard` +5. `review decision template` 再复用 analysis handoff + +任何一层如果又回去重新读取 thread 并拼第二套摘要,都视为治理倒退。 + +### 7. 旁路系统也必须服从同一事实源 + +Harness Engine 不只是导出文件目录。以下旁路也必须收敛: + +- diagnostics 摘要 +- export copy prompt +- review template +- GUI 状态卡 +- 后续 eval / trend / promote 使用的样本事实 + +如果主导出已经修了,旁路仍沿旧字段判断,就说明治理还没完成。 + +## 典型反模式 + +出现以下行为,视为 Harness Engine 治理倒退: + +- 在 `analysis / replay / UI` 各自维护一份 observability 拼装逻辑 +- 给所有线程硬塞 `artifactValidator`、`browserVerification`、`guiSmoke` +- verification 还没发生,就先导出空数组或空对象占位 +- RequestLog 还无法 join,会话摘要却写成“已有 request telemetry” +- 后端证据已经修正,前端或 handoff 文案仍沿旧 gap 模板输出 +- 为保留旧行为再新增一层 compat 包装,而不是删掉错误事实 + +## 变更顺序 + +涉及 Harness Engine 改动时,默认按这个顺序做: + +1. 先确认唯一事实源是不是 `agent_runtime_export_*` +2. 再确认改动属于 `current / compat / deprecated / dead` 哪一类 +3. 先修 evidence pack,再修 replay / analysis / review / UI +4. 先删错误默认值,再考虑是否需要新增字段 +5. 最后补定向测试与最小 GUI / 契约验证 + +如果说不清这次改动要收敛到哪个导出入口,就不要继续扩。 + +## 最低验证要求 + +Harness Engine 改动至少应覆盖: + +- 受影响 Rust 服务的定向测试 +- evidence pack 导出结果检查 +- replay / analysis 是否复用 evidence pack 的回归检查 +- 如果改动触及 cleanup report / dashboard 推荐动作契约,默认把 cleanup contract 校验纳入最小门槛: + +```bash +npm run harness:cleanup-report:check +``` + +如果要校验某个指定产物,再显式传入: + +```bash +node scripts/check-generated-slop-report.mjs --input "" +``` + +如同时触及命令边界,再补: + +```bash +npm run test:contracts +``` + +如同时触及 GUI 主路径或 `HarnessStatusPanel`,再补: + +```bash +npm run verify:gui-smoke +``` + +人工 spot check 至少看一次: + +- `.lime/harness/sessions//evidence/runtime.json` +- `.lime/harness/sessions//evidence/artifacts.json` +- `.lime/harness/sessions//replay/input.json` +- `.lime/harness/sessions//analysis/analysis-context.json` + +## 当前最值得继续优化的一刀 + +如果继续沿这条主线推进,优先级最高的是: + +**把已导出的 request telemetry 继续沉淀到 trend / replay / review 守卫里,确保后续任何新入口都只能复用同一份会话级摘要,而不是再次旁路读取或重建第二套真相。** + +这样才能继续逼近 Claude Code 那种“先 trace,再解释,而且所有解释都复用同一份 trace”的治理状态。 + +## 相关文档 + +- `docs/aiprompts/governance.md` +- `docs/aiprompts/commands.md` +- `docs/aiprompts/quality-workflow.md` +- `docs/aiprompts/playwright-e2e.md` + +## 一句话总结 + +**Harness Engine 的治理目标不是导出更多文件,而是让所有诊断、回放、审核和 GUI 都只复用同一份运行时事实。** diff --git a/docs/aiprompts/providers.md b/docs/aiprompts/providers.md index 5912b2fa9..ad84ef974 100644 --- a/docs/aiprompts/providers.md +++ b/docs/aiprompts/providers.md @@ -126,6 +126,31 @@ x-api-key: {api_key} anthropic-version: 2023-06-01 ``` +## Prompt Cache 能力边界 + +Lime 当前把 Prompt Cache 能力视为 **Provider 类型能力**,而不是“请求长得像哪家协议”: + +- `anthropic` / `claude` / `claude-oauth`:声明为 `automatic` +- `anthropic-compatible`:声明为 `explicit_only` +- 其它 Provider:默认 `not_applicable` + +这条事实源当前收敛在: + +- 前端:`src/lib/model/providerPromptCacheSupport.ts` +- 后端:Provider 类型与运行时能力判断链 + +需要特别注意: + +1. `anthropic-compatible` 只表示接入方兼容 Anthropic wire format,不等于上游已经实现 Anthropic Automatic Prompt Caching +2. Lime 不会因为某个自定义渠道“长得像 Anthropic”就默认把它当成官方 Anthropic 自动缓存能力 +3. 对 `anthropic-compatible` 渠道,Lime 只保留显式 `cache_control` 语义;如果上游没有实现 Automatic Prompt Cache,`cached_input_tokens` 为空不能直接归因到 Lime 没发字段 + +排查这类问题时,优先确认三件事: + +1. 当前 Provider 类型是不是 `anthropic-compatible` +2. 上游服务是否真的声明支持 Anthropic Automatic Prompt Caching +3. 响应 usage 中是否存在 `cache_creation_input_tokens` / `cache_read_input_tokens` / `cached_input_tokens` + ## 凭证管理策略 ### 方案 B: 独立副本策略 diff --git a/docs/aiprompts/quality-workflow.md b/docs/aiprompts/quality-workflow.md index 817f20d33..07547c3d4 100644 --- a/docs/aiprompts/quality-workflow.md +++ b/docs/aiprompts/quality-workflow.md @@ -82,7 +82,7 @@ 如果本轮涉及 `companion_*` 桌宠命令族,还要同步检查本地 companion `WebSocket` 入口、前端 `src/lib/api/companion.ts` 网关、Rust 注册、治理目录册以及浏览器模式 mock 返回形态;浏览器模式下这组命令默认也要保持可 mock,不要让桌宠接入把默认页面渲染链路卡死。 -如果本轮涉及 team runtime 工具面或主线程用户消息工具,还要同步检查 Rust catalog / inventory、runtime 注册、浏览器 fallback mock 与前端 tool display;`Agent / TeamCreate / TeamDelete / SendMessage / ListPeers` 必须保持同一组 current surface,`SendUserMessage` 也必须继续停留在 current 主线程工具面,`SubAgentTask` 只能继续停留在 compat 读取边界。 +如果本轮涉及 team runtime 工具面或主线程用户消息工具,还要同步检查 Rust catalog / inventory、runtime 注册、浏览器 fallback mock 与前端 tool display;`Agent / TeamCreate / TeamDelete / SendMessage / ListPeers` 必须保持同一组 current surface,`SendUserMessage` 也必须继续停留在 current 主线程工具面,不要把已删除的 `SubAgentTask` compat 工具重新接回 Rust catalog、runtime 注册、mock 或前端 tool display。 如果本轮涉及 MCP bridge runtime tool surface、inventory 或 ToolSearch,还要同步检查 Rust extension 注入、inventory 快照、浏览器 fallback mock 与 GUI 面板命名;当前唯一命名事实源是 `mcp____`,对应 extension surface key 为 `mcp__`,不要让 mock 或 UI 退回裸 `server__tool`。同时,Lime runtime 里的 `ToolSearch` 当前事实源必须是 `ToolSearchBridgeTool`;`aster-rust` 自带 `ToolSearchTool` 只能停留在 compat 存量,不允许再抢占当前 runtime surface。 如果本轮还需要对子工作区单独跑 Rust 定向测试,例如 `src-tauri/crates/aster-rust`,必须确认产物仍落在统一的 `src-tauri/target`,不要重新写回子目录自己的 `target/`;否则 `tauri dev` 会把构建产物当成源码变化,反复触发重编译。 @@ -183,6 +183,22 @@ npm run test:bridge npm run bridge:health -- --timeout-ms 120000 ``` +如果本轮改动落在 harness cleanup / dashboard 推荐动作契约,还应补: + +```bash +npm run harness:cleanup-report:check +``` + +这条校验已经进入 `npm run test:contracts` 默认门禁;如果你要点检某个指定产物,再显式执行: + +```bash +node scripts/check-generated-slop-report.mjs --input "" +``` + +同时,`scripts/report-generated-slop.mjs`、`scripts/check-generated-slop-report.mjs`、`scripts/harness-eval-history-record.mjs`、`scripts/harness-eval-trend-report.mjs`、`scripts/lib/generated-slop-report-core.mjs`、`scripts/lib/harness-dashboard-core.mjs` 这条 harness cleanup/report 主链,在 `verify:local` 的 smart 模式里默认也按 bridge/contracts 风险处理。 +本地 `verify:local` 输出里如果看到 `bridge 校验(harness cleanup contract)`,说明命中的就是这条 cleanup/report 契约门禁,而不是普通 DevBridge 变更。 +CI 里的 `.github/workflows/quality.yml` 结果摘要现在也会透出 `bridge_reasons`,并写入 `GITHUB_STEP_SUMMARY`,用于区分这次是 `harness_cleanup_contract`、`bridge_runtime`,还是 `workflow_full_suite` / `fallback_full_suite` 这类全量触发。 + 作用: - 检查前端命令调用与 Rust 注册表是否一致 diff --git a/docs/develop/lobsterai-business-benchmark.md b/docs/develop/lobsterai-business-benchmark.md index 5ab7b4f0d..1d67937c2 100644 --- a/docs/develop/lobsterai-business-benchmark.md +++ b/docs/develop/lobsterai-business-benchmark.md @@ -28,8 +28,8 @@ - `src-tauri/src/services/execution_tracker_service.rs` - `src-tauri/src/services/heartbeat_service/mod.rs` - `src-tauri/src/services/heartbeat_service/delivery.rs` -- `src-tauri/src/commands/aster_agent_cmd.rs` -- `src-tauri/src/commands/subagent_cmd.rs` +- `src-tauri/src/commands/aster_agent_cmd/mod.rs` +- `src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs` - `src-tauri/crates/websocket/src/handlers/rpc_handler.rs` - `src-tauri/crates/core/src/database/schema.rs` @@ -256,4 +256,3 @@ Lime 的底座(追踪、权限、沙箱、Provider 能力)已经具备,当 1. **远程触发协议与命令面**(入口) 2. **任务治理状态模型**(稳定性) 3. **任务健康与追踪看板**(运营) - diff --git a/docs/exec-plans/README.md b/docs/exec-plans/README.md new file mode 100644 index 000000000..46f9e080a --- /dev/null +++ b/docs/exec-plans/README.md @@ -0,0 +1,28 @@ +# `docs/exec-plans` + +本目录存放会影响开发执行的 versioned artifact:执行计划、进度日志、阻塞记录、迁移清单、技术债追踪。 + +## 放什么 + +- 多轮实现、迁移、治理任务的执行计划 +- 与计划绑定的阶段进度、阻塞项、决策记录 +- 需要持续小额偿还的技术债与退出条件 + +## 命名约定 + +- 专项计划:`-plan.md` +- 进度日志:`-progress.md` +- 常驻追踪:使用固定文件名,例如 `tech-debt-tracker.md` + +## 使用规则 + +1. 计划不是一次性文档,推进状态变化时要同步更新 +2. 会改变实现顺序、范围或回滚策略的决策,必须记录在这里或链接到这里 +3. 清理类工作如果不能直接回挂路线图,应登记到 `tech-debt-tracker.md` +4. 被替代的计划不要悬空,保留跳转说明或归档指针 + +## 关联入口 + +- 路线图主线:`docs/roadmap/` +- 技术债追踪:`docs/exec-plans/tech-debt-tracker.md` +- 模块级实施细节:`docs/aiprompts/README.md` diff --git a/docs/exec-plans/tech-debt-tracker.md b/docs/exec-plans/tech-debt-tracker.md new file mode 100644 index 000000000..401975df5 --- /dev/null +++ b/docs/exec-plans/tech-debt-tracker.md @@ -0,0 +1,15 @@ +# 技术债追踪 + +本文件记录需要持续、小额偿还的技术债,避免把问题堆到一次性大清理。 + +## 记录规则 + +1. 每条技术债都要写清具体代码面、影响和下一小步 +2. 能回挂路线图主线的,优先回挂路线图;不能回挂的,登记到这里 +3. 状态至少区分 `待处理`、`进行中`、`已完成`、`放弃` + +## 条目 + +| ID | 日期 | 区域 | 差距 / 债务 | 影响 | 下一小步 | 关联文档 | 状态 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| TBD-000 | 2026-04-12 | 待补 | 初始化占位,后续将零散技术债迁入本表 | 先让规则有稳定落点,避免口头追踪 | 首次登记真实条目时替换本行 | `docs/exec-plans/README.md` | 待处理 | diff --git a/docs/prd/tools/README.md b/docs/prd/tools/README.md index e03afc7e7..4d76dc842 100644 --- a/docs/prd/tools/README.md +++ b/docs/prd/tools/README.md @@ -77,7 +77,6 @@ Lime 实际已经具备这些能力: | current | `src-tauri/crates/mcp/src/manager.rs` | MCP tools runtime registry | | current | `src-tauri/src/commands/aster_agent_cmd.rs` | Aster runtime 注入、`ToolSearch`、inventory 命令 | | current | `src-tauri/src/agent_tools/inventory.rs` | runtime 工具库存快照 | -| compat | `SubAgentTask` | 兼容旧子代理工具名;current 协作工具面已收敛到 `Agent / SendMessage / TeamCreate / TeamDelete / ListPeers` | | compat | `workspace_allowed_tool_names(...)` | 当前保留为旧调用入口别名,实际委托默认授权目录 | | dead-candidate | `src-tauri/crates/agent/src/tool_permissions.rs` | 已退出 `lime-agent` 的 `lib.rs` 编译图,仅通过 `src-tauri/crates/agent/tests/legacy_permission_surfaces.rs` 测试夹具加载 | | dead-candidate | `src-tauri/crates/agent/src/shell_security.rs` | 已退出 `lime-agent` 的 `lib.rs` 编译图,仅通过 `src-tauri/crates/agent/tests/legacy_permission_surfaces.rs` 测试夹具加载 | @@ -237,8 +236,8 @@ Lime 实际已经具备这些能力: - **Team runtime current surface** `Agent` / `SendMessage` / `TeamCreate` / `TeamDelete` / `ListPeers` -- **Compat only** - `SubAgentTask` +- **Compat tool name** + 当前不再保留 `SubAgentTask`;team runtime 已收敛到 `Agent / SendMessage / TeamCreate / TeamDelete / ListPeers` - **说明** Core surface 现已按 current surface 收敛;精确数量与分类以 `src-tauri/src/agent_tools/catalog.rs` 为准。 diff --git a/docs/prd/tools/inventory.md b/docs/prd/tools/inventory.md index 14a5bd9ee..537010d54 100644 --- a/docs/prd/tools/inventory.md +++ b/docs/prd/tools/inventory.md @@ -45,9 +45,9 @@ - `TeamDelete` - `ListPeers` -### Compat only +### Compat tool name -- `SubAgentTask` +- 当前不再保留 `SubAgentTask` ### Core 总数 @@ -111,7 +111,7 @@ Core surface 当前默认 allow 的工具集已经收敛到 current surface, 结论: - 默认 allowlist 继续优先保留常驻、小而稳的工具面 -- `SubAgentTask` 已降为 compat 入口,不再作为 current 默认协作工具面 +- `SubAgentTask` 已从应用层删除,不再作为 current 默认协作工具面 - `read` / `write` / `edit` / `bash` / `WebFetch` 这类需要参数约束或更强执行控制的工具仍不应默认放开 这符合“常驻工具面小而稳”的原则。 @@ -218,7 +218,6 @@ const snapshot = await getAgentRuntimeToolInventory({ ## 5.2 compat -- `SubAgentTask` - `workspace_allowed_tool_names(...)` ## 5.3 deprecated diff --git a/docs/test/README.md b/docs/test/README.md index 6858c8b21..05c457330 100644 --- a/docs/test/README.md +++ b/docs/test/README.md @@ -134,6 +134,16 @@ npm run harness:eval:trend npm run harness:eval:history:record ``` +默认会把完整 artifact 套件写到 `./.lime/harness/reports/`: + +- `harness-eval-summary.json` +- `harness-eval-summary.md` +- `harness-eval-trend.json` +- `harness-eval-trend.md` +- `harness-cleanup-report.json` +- `harness-cleanup-report.md` +- `harness-dashboard.html` + ### 运行 Harness cleanup / slop 报告 ```bash diff --git a/docs/test/harness-evals.manifest.json b/docs/test/harness-evals.manifest.json index 7dd333d3f..0fb850264 100644 --- a/docs/test/harness-evals.manifest.json +++ b/docs/test/harness-evals.manifest.json @@ -47,6 +47,13 @@ "source": "repo_fixture", "caseDir": "docs/test/harness-fixtures/replay/minimal-pending-request", "tags": ["conversation-runtime", "replay", "handoff", "evidence"] + }, + { + "id": "fixture-minimal-observability-gap", + "title": "最小 observability gap Replay 样本", + "source": "repo_fixture", + "caseDir": "docs/test/harness-fixtures/replay/minimal-observability-gap", + "tags": ["conversation-runtime", "replay", "handoff", "evidence", "observability-gap"] } ] }, diff --git a/docs/test/harness-evals.md b/docs/test/harness-evals.md index 8ebdabdc0..63ff6f258 100644 --- a/docs/test/harness-evals.md +++ b/docs/test/harness-evals.md @@ -56,8 +56,12 @@ Lime 当前不直接把“真实模型重放平台”一次做完,而是先固 1. **仓库固定 Replay 样本** - 用于 CI / nightly 的稳定入口 - - 当前固定一条 fixture: + - 当前固定两条 fixture: - `fixture-minimal-pending-request` + - `fixture-minimal-observability-gap` + - 角色分工必须稳定: + - `fixture-minimal-pending-request` 是 `current` 样本,要求 observability fully exported,不能继续挂 `known_gap` + - `fixture-minimal-observability-gap` 是 `degraded` 样本,专门承载 `known_gap` 语义,避免把证据缺口混进 current 样本 - 目的不是替代真实会话,而是先验证 grader 合同、字段预算和摘要出口不会漂移 2. **仓库沉淀 Replay 样本** @@ -89,6 +93,7 @@ Lime 当前不直接把“真实模型重放平台”一次做完,而是先固 3. 校验 replay case 最小四件套与关键 JSON 字段 4. 输出统一 JSON / Markdown 摘要,并聚合 `suite tag / failure mode / review decision status / risk level` 分布 5. 继续聚合 `observabilitySignals`,把 `requestTelemetry / artifactValidator / browser/gui smoke` 的证据缺口也纳入 trend 与 cleanup +6. 继续聚合 `observabilityVerificationOutcomes`,直接复用 evidence pack 导出的紧凑 outcome,告诉治理层当前更像是 `artifactValidator issues / browser failure / gui smoke failed` 还是已通过 当前它**不直接执行真实模型重放**,而是先把“样本是否可评估、摘要是否可归档”工程化。 @@ -167,16 +172,34 @@ node scripts/harness-replay-promote.mjs \ 固定下来。 -当前 nightly 还会恢复并追加 `artifacts/history/*.json` 历史窗口,用于让 trend 不只停留在单次 seed。 +当前 nightly 会恢复并追加 `artifacts/history/*.json` 历史窗口,用于让 trend 不只停留在单次 seed。 -在当前仓库主链里,nightly 还会基于 trend + doc freshness + governance report 继续生成 `harness-cleanup-report.json/md`,让回归样本、人工审核状态和治理建议进入同一份 nightly artifact。 +从当前主线开始,nightly 不再手工串 `runner -> trend -> cleanup` 三段命令,而是统一走: -从 `2026-03-27` 起,这个历史窗口不再依赖 workflow 里的 `cp / ls / xargs` 拼接,而是直接由 `scripts/harness-eval-runner.mjs --record-history-dir` 负责写入和裁剪,保证本地与 nightly 走同一条跨平台主链。 +```bash +node scripts/harness-eval-history-record.mjs \ + --history-dir "./artifacts/history" \ + --summary-json "./artifacts/harness-eval-summary.json" \ + --summary-markdown "./artifacts/harness-eval-summary.md" \ + --trend-json "./artifacts/harness-eval-trend.json" \ + --trend-markdown "./artifacts/harness-eval-trend.md" \ + --cleanup-json "./artifacts/harness-cleanup-report.json" \ + --cleanup-markdown "./artifacts/harness-cleanup-report.md" \ + --dashboard-html "./artifacts/harness-dashboard.html" +``` + +这样本地与 nightly 复用同一条 `summary -> history -> trend -> cleanup -> dashboard` 主线,也让 `current / degraded` observability gap 角色在两侧保持同口径,并把 `browser/gui/artifact` 的 verification outcome 焦点直接带进 nightly HTML 仪表板,继续绑定到同一事实源。 + +从 `2026-03-27` 起,这个历史窗口不再依赖 workflow 里的 `cp / ls / xargs` 拼接;当前唯一 current 入口是 `scripts/harness-eval-history-record.mjs`,统一负责写入和裁剪 history window,保证本地与 nightly 走同一条跨平台主链。 同一天新增的 `scripts/harness-eval-history-record.mjs` 又把这条主链向前推了一步: -- 本地可以一次命令完成 `summary -> history -> trend -> cleanup` -- 本地默认把 history window 写到 `./.lime/harness/history`,并把 trend / cleanup 报表写到 `./.lime/harness/reports` +- 本地可以一次命令完成 `summary -> history -> trend -> cleanup -> dashboard` +- 本地默认把 history window 写到 `./.lime/harness/history`,并把 `summary / trend / cleanup / dashboard` 全部写到 `./.lime/harness/reports` +- 如显式传入 `--dashboard-html`,会覆盖默认 HTML 产物路径,但仍直接复用 in-memory 的 `summary / trend / cleanup` 对象生成 HTML,不再额外读取第二套事实 +- `history-record` 自身的 JSON / text 结果也应直接带出 cleanup 侧的 verification outcome 焦点,避免调用方为了知道“先修 artifact/browser/gui 哪层”还要再手工下钻 cleanup 报告 +- `cleanup` 当前还应继续导出稳定的 verification 摘要,例如 `failureCaseCount / recoveredCaseCount`,让 dashboard 与 nightly 值班出口可以直接判断是否需要立刻阻断主线 +- 对 failure outcome 还应继续区分 `blocking_failure / advisory_failure`;默认像 `guiSmoke:failed`、`browserVerification:failure` 这类直接验证失败应被归到 blocking,而 `artifactValidator:issues_present` 这类更偏证据治理的失败应停留在 advisory - `cleanup report` 在未显式传入 `--trend-input / --trend-history-dir` 时,会优先自动发现 `./.lime/harness/history`,再回退到 `./artifacts/history` - 修复后的趋势积累不再只属于 nightly,开发者本地也能拿到同口径的历史窗口 @@ -203,13 +226,22 @@ node scripts/harness-eval-runner.mjs \ --output-json "./tmp/harness-eval-summary.json" \ --output-markdown "./tmp/harness-eval-summary.md" -# 推荐的本地一体化入口:记录 history,并同时刷新 trend / cleanup +# 推荐的本地一体化入口:记录 history,并同时刷新 summary / trend / cleanup / dashboard npm run harness:eval:history:record -# 如需显式指定本地 history window -node scripts/harness-eval-runner.mjs \ - --record-history-dir "./.lime/harness/history" \ - --history-retain 30 +# 默认产物目录: +# ./.lime/harness/reports/harness-eval-summary.json +# ./.lime/harness/reports/harness-eval-summary.md +# ./.lime/harness/reports/harness-eval-trend.json +# ./.lime/harness/reports/harness-eval-trend.md +# ./.lime/harness/reports/harness-cleanup-report.json +# ./.lime/harness/reports/harness-cleanup-report.md +# ./.lime/harness/reports/harness-dashboard.html + +# 如需覆盖 dashboard 产物路径 +node scripts/harness-eval-history-record.mjs \ + --history-dir "./.lime/harness/history" \ + --dashboard-html "./tmp/harness-dashboard.html" # 从历史 summary 目录生成趋势报告 node scripts/harness-eval-trend-report.mjs \ @@ -229,7 +261,8 @@ Runner 摘要至少回答下面这些问题: - 哪些 case 属于什么 suite tag / failure mode - 哪些 case 默认需要人工复核 - 哪些 case 已经记录人工审核状态与风险等级 -- 哪些 case 还缺 `observabilitySummary` 或仍处于 `requestTelemetry:unlinked`、`artifactValidator:known_gap` 等证据缺口 +- 哪些 case 还缺 `observabilitySummary` 或仍处于 `requestTelemetry:known_gap`、`artifactValidator:known_gap` 等证据缺口 +- 哪些 case 已经导出 `observabilityVerificationOutcomes`,并能直接看出 `artifactValidator:issues_present`、`browserVerification:failure`、`guiSmoke:failed` 等 outcome - 工作区 replay 是否已经开始形成增量样本 如果摘要回答不了这些问题,就说明 runner 还不算进入 current 主链。 @@ -241,6 +274,8 @@ Trend 报告至少还要回答: - 哪些 failure mode / suite tag 在 latest 里增长或退化了 - 哪些人工审核状态 / 风险等级在 latest 里新增、减少或发生迁移 - 哪些 observability signal gap 在 latest 里增加、减少或仍然停留在 current 样本中 +- 哪些 verification outcome 在 latest 里新增、减少或迁移,足以直接指出先修 artifact/browser/gui 哪一层 +- 哪些 observability gap 属于 `current` 样本回归,哪些只是 `degraded` 样本刻意保留的诊断基线 - 当前只有 trend seed,还是已经开始形成真正的历史窗口 ## 与其他事实源的关系 diff --git a/docs/test/harness-fixtures/replay/minimal-observability-gap/evidence-links.json b/docs/test/harness-fixtures/replay/minimal-observability-gap/evidence-links.json new file mode 100644 index 000000000..6a31d6412 --- /dev/null +++ b/docs/test/harness-fixtures/replay/minimal-observability-gap/evidence-links.json @@ -0,0 +1,125 @@ +{ + "replayCaseVersion": "v1", + "exportedAt": "2026-03-27T14:10:00Z", + "handoffBundle": { + "relativeRoot": ".lime/harness/sessions/fixture-session-observability-gap/handoff", + "absoluteRoot": "/workspace/lime/.lime/harness/sessions/fixture-session-observability-gap/handoff", + "artifacts": [ + { + "kind": "plan", + "title": "执行计划", + "relativePath": ".lime/harness/sessions/fixture-session-observability-gap/handoff/plan.md" + }, + { + "kind": "handoff", + "title": "交接摘要", + "relativePath": ".lime/harness/sessions/fixture-session-observability-gap/handoff/handoff.md" + } + ] + }, + "evidencePack": { + "relativeRoot": ".lime/harness/sessions/fixture-session-observability-gap/evidence", + "absoluteRoot": "/workspace/lime/.lime/harness/sessions/fixture-session-observability-gap/evidence", + "knownGaps": [ + "当前环境未找到可读取的 request telemetry 日志目录,Evidence Pack 无法导出会话级请求遥测。", + "当前检测到 1 个 ArtifactDocument 产物,但 validator outcome 尚未回挂到当前 evidence pack。" + ], + "artifacts": [ + { + "kind": "summary", + "title": "证据摘要", + "relativePath": ".lime/harness/sessions/fixture-session-observability-gap/evidence/summary.md" + }, + { + "kind": "runtime", + "title": "运行时快照", + "relativePath": ".lime/harness/sessions/fixture-session-observability-gap/evidence/runtime.json" + } + ] + }, + "observabilitySummary": { + "schemaVersion": "v1", + "correlation": { + "correlationKeys": [ + "session_id", + "thread_id", + "turn_id", + "pending_request_id", + "queued_turn_id", + "subagent_session_id" + ], + "sessionId": "fixture-session-observability-gap", + "threadId": "fixture-thread-observability-gap", + "activeTurnId": "turn-gap-001", + "pendingRequestIds": [], + "queuedTurnIds": [], + "subagentSessionIds": [] + }, + "counts": { + "turnCount": 1, + "itemCount": 2, + "pendingRequestCount": 0, + "queuedTurnCount": 0, + "warningCount": 0, + "failedToolCallCount": 0, + "failedCommandCount": 0, + "subagentCount": 0, + "recentArtifactCount": 2 + }, + "latest": { + "warning": null, + "failedTool": null, + "failedCommand": null + }, + "requestTelemetry": { + "source": "lime_infra.telemetry.request_logs", + "searchedRoots": [], + "matchedRequestCount": 0, + "latestRequestAt": null, + "statusCounts": {}, + "providers": [], + "models": [], + "requests": [] + }, + "signalCoverage": [ + { + "signal": "correlation", + "status": "exported", + "source": "runtime thread identity", + "detail": "当前证据包已导出 session/thread/turn/pending request/subagent 关联键。" + }, + { + "signal": "timeline", + "status": "exported", + "source": "timeline.json", + "detail": "当前证据包已导出最近 turn 与 item 时间线。" + }, + { + "signal": "warnings", + "status": "exported", + "source": "thread.diagnostics", + "detail": "当前线程没有 diagnostics,但 warning 通道已保留在导出结构中。" + }, + { + "signal": "requestTelemetry", + "status": "known_gap", + "source": "lime_infra.telemetry.request_logs", + "detail": "当前环境未找到可读取的 request telemetry 日志目录,Evidence Pack 无法导出会话级请求遥测。" + }, + { + "signal": "artifactValidator", + "status": "known_gap", + "source": "artifact_document_validator", + "detail": "当前检测到 1 个 ArtifactDocument 产物,但 validator outcome 尚未回挂到当前 evidence pack。" + } + ], + "knownGaps": [ + "当前环境未找到可读取的 request telemetry 日志目录,Evidence Pack 无法导出会话级请求遥测。", + "当前检测到 1 个 ArtifactDocument 产物,但 validator outcome 尚未回挂到当前 evidence pack。" + ] + }, + "recentArtifacts": [ + "docs/tech/harness/implementation-blueprint.md", + ".lime/artifacts/thread-gap/report.artifact.json" + ] +} diff --git a/docs/test/harness-fixtures/replay/minimal-observability-gap/expected.json b/docs/test/harness-fixtures/replay/minimal-observability-gap/expected.json new file mode 100644 index 000000000..7910f241f --- /dev/null +++ b/docs/test/harness-fixtures/replay/minimal-observability-gap/expected.json @@ -0,0 +1,28 @@ +{ + "replayCaseVersion": "v1", + "exportedAt": "2026-03-27T14:10:00Z", + "sessionId": "fixture-session-observability-gap", + "threadId": "fixture-thread-observability-gap", + "goalSummary": "确认评估链会把 observability 证据缺口显式识别出来,而不是把证据不完整的会话误判为全量覆盖。", + "successCriteria": [ + "评分结果必须明确指出 requestTelemetry 与 artifactValidator 的 known gap。", + "若沿用 handoff bundle 与 evidence pack,结论必须引用至少一条证据来源。", + "不得把 evidence 缺口包装成 PASS 证据。" + ], + "blockingChecks": [ + "确认当前线程虽已完成,但 observability 缺口仍被保留下来。", + "确认 requestTelemetry 缺日志目录不会被误判为已导出空摘要。" + ], + "artifactChecks": [ + "确认 `.lime/harness/sessions/fixture-session-observability-gap/handoff/handoff.md` 仍与目标一致。", + "确认 evidence pack 中记录的 known gaps 被正确解读。" + ], + "nonGoals": [ + "不要要求与原始会话完全相同的工具调用顺序。", + "不要把措辞差异当作失败,除非它改变了 observability 风险判断。" + ], + "graderSuggestion": { + "preferredMode": "result_artifact_and_observability_gap_resolution", + "requiresHumanReview": false + } +} diff --git a/docs/test/harness-fixtures/replay/minimal-observability-gap/grader.md b/docs/test/harness-fixtures/replay/minimal-observability-gap/grader.md new file mode 100644 index 000000000..ab8b7ed8a --- /dev/null +++ b/docs/test/harness-fixtures/replay/minimal-observability-gap/grader.md @@ -0,0 +1,37 @@ +# Replay Case 评分说明 + +- 会话:`fixture-session-observability-gap` +- 线程:`fixture-thread-observability-gap` +- 导出时间:2026-03-27T14:10:00Z +- 目标摘要:确认评估链会把 observability 证据缺口显式识别出来,而不是把证据不完整的会话误判为全量覆盖。 + +## 建议读取顺序 + +1. 先读 `input.json`,理解当前任务、线程状态与 observability 缺口。 +2. 再读 `expected.json`,确认只评估结果与风险。 +3. 再读 `evidence-links.json`,跳转到 handoff 与 evidence 入口。 +4. 如需补证据,优先回看 evidence pack 中的 `knownGaps` 与 `observabilitySummary`。 + +## 评分原则 + +- 只评结果,不评路径。 +- 先证据后结论;缺口必须引用 evidence,而不是凭印象补齐。 +- 如果 evidence 已明确记录 `known_gap`,结论里必须解释它是否阻断判定,不能把缺口包装成 PASS 证据。 + +## 最小通过条件 + +- 结果必须指出 `requestTelemetry` 与 `artifactValidator` 的缺口状态。 +- 结果必须引用 handoff 或 evidence 中的至少一条证据。 +- 不得把 observability 缺口误判为“证据已完整”。 + +## 建议输出模板 + +```text +verdict: pass | fail | needs_review +reason: +- ... +evidence: +- ... +risks: +- ... +``` diff --git a/docs/test/harness-fixtures/replay/minimal-observability-gap/input.json b/docs/test/harness-fixtures/replay/minimal-observability-gap/input.json new file mode 100644 index 000000000..042bb4a92 --- /dev/null +++ b/docs/test/harness-fixtures/replay/minimal-observability-gap/input.json @@ -0,0 +1,174 @@ +{ + "replayCaseVersion": "v1", + "source": "lime.fixture.replay_case", + "exportedAt": "2026-03-27T14:10:00Z", + "session": { + "sessionId": "fixture-session-observability-gap", + "threadId": "fixture-thread-observability-gap", + "workspaceId": "fixture-workspace", + "workspaceRoot": "/workspace/lime", + "model": "fixture-model", + "executionStrategy": "agent_runtime" + }, + "task": { + "goalSummary": "确认评估链会把 observability 证据缺口显式识别出来,而不是把证据不完整的会话误判为全量覆盖。", + "latestPlan": "先导出 handoff bundle 与 evidence pack,再由 grader 校验 observability gap。", + "latestTurnSummary": "线程已完成,但 request telemetry 与 artifact validator 仍存在 known gap。", + "latestTurnPrompt": "请把这次 evidence 不完整的会话导出为 replay 样本。", + "latestTurnId": "turn-gap-001", + "latestTurnStatus": "completed", + "threadStatus": "completed", + "primaryBlockingSummary": "当前交付已完成,但 observability 证据仍不完整,评分时必须显式说明。" + }, + "classification": { + "sourceKind": "repo_fixture", + "suiteTags": [ + "conversation-runtime", + "replay", + "observability-gap", + "handoff", + "evidence" + ], + "failureModes": [ + "observability_gap" + ], + "primaryBlockingKind": "completed" + }, + "runtimeContext": { + "pendingRequests": [], + "queuedTurns": [], + "todoItems": [ + { + "content": "补 request telemetry 关联", + "status": "in_progress" + } + ], + "activeSubagents": [], + "recentArtifacts": [ + "docs/tech/harness/implementation-blueprint.md", + ".lime/artifacts/thread-gap/report.artifact.json" + ], + "recentTimeline": [ + { + "itemId": "timeline-gap-001", + "turnId": "turn-gap-001", + "payloadKind": "plan", + "status": "completed", + "summary": "产出 observability gap 导出计划", + "updatedAt": "2026-03-27T14:02:00Z" + }, + { + "itemId": "timeline-gap-002", + "turnId": "turn-gap-001", + "payloadKind": "file_artifact", + "status": "completed", + "summary": "导出 evidence pack 与 handoff", + "updatedAt": "2026-03-27T14:07:00Z" + } + ], + "lastOutcome": { + "thread_id": "fixture-thread-observability-gap", + "turn_id": "turn-gap-001", + "outcome_type": "success", + "summary": "handoff 与 evidence 已导出,但 observability known gap 待后续治理", + "primary_cause": "observability_gap", + "retryable": true + }, + "incidents": [] + }, + "observability": { + "schemaVersion": "v1", + "correlation": { + "correlationKeys": [ + "session_id", + "thread_id", + "turn_id", + "pending_request_id", + "queued_turn_id", + "subagent_session_id" + ], + "sessionId": "fixture-session-observability-gap", + "threadId": "fixture-thread-observability-gap", + "activeTurnId": "turn-gap-001", + "pendingRequestIds": [], + "queuedTurnIds": [], + "subagentSessionIds": [] + }, + "counts": { + "turnCount": 1, + "itemCount": 2, + "pendingRequestCount": 0, + "queuedTurnCount": 0, + "warningCount": 0, + "failedToolCallCount": 0, + "failedCommandCount": 0, + "subagentCount": 0, + "recentArtifactCount": 2 + }, + "latest": { + "warning": null, + "failedTool": null, + "failedCommand": null + }, + "requestTelemetry": { + "source": "lime_infra.telemetry.request_logs", + "searchedRoots": [], + "matchedRequestCount": 0, + "latestRequestAt": null, + "statusCounts": {}, + "providers": [], + "models": [], + "requests": [] + }, + "signalCoverage": [ + { + "signal": "correlation", + "status": "exported", + "source": "runtime thread identity", + "detail": "当前证据包已导出 session/thread/turn/pending request/subagent 关联键。" + }, + { + "signal": "timeline", + "status": "exported", + "source": "timeline.json", + "detail": "当前证据包已导出最近 turn 与 item 时间线。" + }, + { + "signal": "warnings", + "status": "exported", + "source": "thread.diagnostics", + "detail": "当前线程没有 diagnostics,但 warning 通道已保留在导出结构中。" + }, + { + "signal": "requestTelemetry", + "status": "known_gap", + "source": "lime_infra.telemetry.request_logs", + "detail": "当前环境未找到可读取的 request telemetry 日志目录,Evidence Pack 无法导出会话级请求遥测。" + }, + { + "signal": "artifactValidator", + "status": "known_gap", + "source": "artifact_document_validator", + "detail": "当前检测到 1 个 ArtifactDocument 产物,但 validator outcome 尚未回挂到当前 evidence pack。" + } + ], + "knownGaps": [ + "当前环境未找到可读取的 request telemetry 日志目录,Evidence Pack 无法导出会话级请求遥测。", + "当前检测到 1 个 ArtifactDocument 产物,但 validator outcome 尚未回挂到当前 evidence pack。" + ] + }, + "linkedArtifacts": { + "handoffBundle": { + "relativeRoot": ".lime/harness/sessions/fixture-session-observability-gap/handoff", + "artifactCount": 4 + }, + "evidencePack": { + "relativeRoot": ".lime/harness/sessions/fixture-session-observability-gap/evidence", + "artifactCount": 4, + "knownGaps": [ + "当前环境未找到可读取的 request telemetry 日志目录,Evidence Pack 无法导出会话级请求遥测。", + "当前检测到 1 个 ArtifactDocument 产物,但 validator outcome 尚未回挂到当前 evidence pack。" + ] + } + } +} diff --git a/docs/test/harness-fixtures/replay/minimal-pending-request/evidence-links.json b/docs/test/harness-fixtures/replay/minimal-pending-request/evidence-links.json index f97b83e5d..7e0d841c9 100644 --- a/docs/test/harness-fixtures/replay/minimal-pending-request/evidence-links.json +++ b/docs/test/harness-fixtures/replay/minimal-pending-request/evidence-links.json @@ -20,11 +20,39 @@ "evidencePack": { "relativeRoot": ".lime/harness/sessions/fixture-session-minimal-pending-request/evidence", "absoluteRoot": "/workspace/lime/.lime/harness/sessions/fixture-session-minimal-pending-request/evidence", - "knownGaps": [ - "当前 Lime 虽然已有全局 RequestLog / token / retry / duration 遥测,但 RequestLog 尚未携带 session/thread/turn 关联键,Evidence Pack 只能导出 request telemetry coverage gap,不能给出精确的会话级摘要。", - "当前 Evidence Pack 尚未纳入 ArtifactDocument validator outcome 与修复问题摘要。", - "当前 Evidence Pack 尚未纳入 GUI smoke / browser 验证结果。" - ], + "knownGaps": [], + "verification": { + "artifactValidatorIssues": [ + { + "path": ".lime/artifacts/thread-1/report.artifact.json", + "issues": [ + "title 缺失或为空,已使用兜底标题。" + ], + "repaired": true, + "fallbackUsed": false + } + ], + "browserEvidence": [ + { + "itemId": "browser-tool-fixture-001", + "turnId": "turn-fixture-001", + "toolName": "browser_snapshot", + "status": "completed", + "updatedAt": "2026-03-27T11:22:00Z" + } + ], + "guiSmoke": { + "itemId": "gui-smoke-fixture-001", + "turnId": "turn-fixture-001", + "status": "completed", + "command": "npm run verify:gui-smoke", + "cwd": "/workspace/lime", + "exitCode": 0, + "error": null, + "updatedAt": "2026-03-27T11:23:30Z", + "outputPreview": "GUI smoke finished successfully" + } + }, "artifacts": [ { "kind": "summary", @@ -65,13 +93,25 @@ "failedToolCallCount": 0, "failedCommandCount": 0, "subagentCount": 0, - "recentArtifactCount": 2 + "recentArtifactCount": 3 }, "latest": { "warning": null, "failedTool": null, "failedCommand": null }, + "requestTelemetry": { + "source": "lime_infra.telemetry.request_logs", + "searchedRoots": [ + "/workspace/lime/request_logs" + ], + "matchedRequestCount": 0, + "latestRequestAt": null, + "statusCounts": {}, + "providers": [], + "models": [], + "requests": [] + }, "signalCoverage": [ { "signal": "correlation", @@ -93,37 +133,57 @@ }, { "signal": "requestTelemetry", - "status": "unlinked", - "source": "lime_infra.telemetry", - "detail": "Lime 已有 workspace 级 request telemetry,但当前 RequestLog 还未携带 session/thread/turn 元数据。" + "status": "exported", + "source": "lime_infra.telemetry.request_logs", + "detail": "当前证据包已扫描 request telemetry 日志目录,但当前会话未匹配到 provider request 记录。" }, { "signal": "artifactValidator", - "status": "known_gap", + "status": "exported", "source": "artifact_document_validator", - "detail": "Artifact validator outcome 尚未回挂到当前 evidence pack。" + "detail": "当前证据包已为 1 个 ArtifactDocument 产物导出 validator outcome。" }, { "signal": "browserVerification", - "status": "known_gap", + "status": "exported", "source": "browser runtime", - "detail": "浏览器截图、DOM 快照和交互验证结果尚未接入 evidence pack。" + "detail": "当前证据包已导出 1 条浏览器验证线索。" }, { "signal": "guiSmoke", - "status": "known_gap", + "status": "exported", "source": "verify:gui-smoke", - "detail": "GUI smoke 结果尚未作为结构化证据回挂当前会话。" + "detail": "当前证据包已导出 GUI smoke 运行结果。" } ], - "knownGaps": [ - "当前 Lime 虽然已有全局 RequestLog / token / retry / duration 遥测,但 RequestLog 尚未携带 session/thread/turn 关联键,Evidence Pack 只能导出 request telemetry coverage gap,不能给出精确的会话级摘要。", - "当前 Evidence Pack 尚未纳入 ArtifactDocument validator outcome 与修复问题摘要。", - "当前 Evidence Pack 尚未纳入 GUI smoke / browser 验证结果。" - ] + "verificationSummary": { + "artifactValidator": { + "applicable": true, + "recordCount": 1, + "issueCount": 1, + "repairedCount": 1, + "fallbackUsedCount": 0 + }, + "browserVerification": { + "recordCount": 1, + "successCount": 1, + "failureCount": 0, + "unknownCount": 0, + "latestUpdatedAt": "2026-03-27T11:22:00Z" + }, + "guiSmoke": { + "status": "completed", + "exitCode": 0, + "passed": true, + "updatedAt": "2026-03-27T11:23:30Z", + "hasOutputPreview": true + } + }, + "knownGaps": [] }, "recentArtifacts": [ "docs/tech/harness/implementation-blueprint.md", + ".lime/artifacts/thread-1/report.artifact.json", ".lime/harness/sessions/fixture-session-minimal-pending-request/handoff/handoff.md" ] } diff --git a/docs/test/harness-fixtures/replay/minimal-pending-request/expected.json b/docs/test/harness-fixtures/replay/minimal-pending-request/expected.json index 0d1c52d2c..9aadab182 100644 --- a/docs/test/harness-fixtures/replay/minimal-pending-request/expected.json +++ b/docs/test/harness-fixtures/replay/minimal-pending-request/expected.json @@ -15,7 +15,7 @@ ], "artifactChecks": [ "确认 `.lime/harness/sessions/fixture-session-minimal-pending-request/handoff/handoff.md` 仍与目标一致。", - "确认 evidence pack 中记录的 known gaps 没有被当作 PASS 证据。" + "确认 evidence pack 中导出的 verification 证据与 pending request 风险被正确解读。" ], "nonGoals": [ "不要要求与原始会话完全相同的工具调用顺序。", diff --git a/docs/test/harness-fixtures/replay/minimal-pending-request/input.json b/docs/test/harness-fixtures/replay/minimal-pending-request/input.json index ca85d0bde..059dc6b91 100644 --- a/docs/test/harness-fixtures/replay/minimal-pending-request/input.json +++ b/docs/test/harness-fixtures/replay/minimal-pending-request/input.json @@ -56,6 +56,7 @@ "activeSubagents": [], "recentArtifacts": [ "docs/tech/harness/implementation-blueprint.md", + ".lime/artifacts/thread-1/report.artifact.json", ".lime/harness/sessions/fixture-session-minimal-pending-request/handoff/handoff.md" ], "recentTimeline": [ @@ -113,13 +114,25 @@ "failedToolCallCount": 0, "failedCommandCount": 0, "subagentCount": 0, - "recentArtifactCount": 2 + "recentArtifactCount": 3 }, "latest": { "warning": null, "failedTool": null, "failedCommand": null }, + "requestTelemetry": { + "source": "lime_infra.telemetry.request_logs", + "searchedRoots": [ + "/workspace/lime/request_logs" + ], + "matchedRequestCount": 0, + "latestRequestAt": null, + "statusCounts": {}, + "providers": [], + "models": [], + "requests": [] + }, "signalCoverage": [ { "signal": "correlation", @@ -141,34 +154,53 @@ }, { "signal": "requestTelemetry", - "status": "unlinked", - "source": "lime_infra.telemetry", - "detail": "Lime 已有 workspace 级 request telemetry,但当前 RequestLog 还未携带 session/thread/turn 元数据。" + "status": "exported", + "source": "lime_infra.telemetry.request_logs", + "detail": "当前证据包已扫描 request telemetry 日志目录,但当前会话未匹配到 provider request 记录。" }, { "signal": "artifactValidator", - "status": "known_gap", + "status": "exported", "source": "artifact_document_validator", - "detail": "Artifact validator outcome 尚未回挂到当前 evidence pack。" + "detail": "当前证据包已为 1 个 ArtifactDocument 产物导出 validator outcome。" }, { "signal": "browserVerification", - "status": "known_gap", + "status": "exported", "source": "browser runtime", - "detail": "浏览器截图、DOM 快照和交互验证结果尚未接入 evidence pack。" + "detail": "当前证据包已导出 1 条浏览器验证线索。" }, { "signal": "guiSmoke", - "status": "known_gap", + "status": "exported", "source": "verify:gui-smoke", - "detail": "GUI smoke 结果尚未作为结构化证据回挂当前会话。" + "detail": "当前证据包已导出 GUI smoke 运行结果。" } ], - "knownGaps": [ - "当前 Lime 虽然已有全局 RequestLog / token / retry / duration 遥测,但 RequestLog 尚未携带 session/thread/turn 关联键,Evidence Pack 只能导出 request telemetry coverage gap,不能给出精确的会话级摘要。", - "当前 Evidence Pack 尚未纳入 ArtifactDocument validator outcome 与修复问题摘要。", - "当前 Evidence Pack 尚未纳入 GUI smoke / browser 验证结果。" - ] + "verificationSummary": { + "artifactValidator": { + "applicable": true, + "recordCount": 1, + "issueCount": 1, + "repairedCount": 1, + "fallbackUsedCount": 0 + }, + "browserVerification": { + "recordCount": 1, + "successCount": 1, + "failureCount": 0, + "unknownCount": 0, + "latestUpdatedAt": "2026-03-27T11:22:00Z" + }, + "guiSmoke": { + "status": "completed", + "exitCode": 0, + "passed": true, + "updatedAt": "2026-03-27T11:23:30Z", + "hasOutputPreview": true + } + }, + "knownGaps": [] }, "linkedArtifacts": { "handoffBundle": { @@ -178,11 +210,39 @@ "evidencePack": { "relativeRoot": ".lime/harness/sessions/fixture-session-minimal-pending-request/evidence", "artifactCount": 4, - "knownGaps": [ - "当前 Lime 虽然已有全局 RequestLog / token / retry / duration 遥测,但 RequestLog 尚未携带 session/thread/turn 关联键,Evidence Pack 只能导出 request telemetry coverage gap,不能给出精确的会话级摘要。", - "当前 Evidence Pack 尚未纳入 ArtifactDocument validator outcome 与修复问题摘要。", - "当前 Evidence Pack 尚未纳入 GUI smoke / browser 验证结果。" - ] + "verification": { + "artifactValidatorIssues": [ + { + "path": ".lime/artifacts/thread-1/report.artifact.json", + "issues": [ + "title 缺失或为空,已使用兜底标题。" + ], + "repaired": true, + "fallbackUsed": false + } + ], + "browserEvidence": [ + { + "itemId": "browser-tool-fixture-001", + "turnId": "turn-fixture-001", + "toolName": "browser_snapshot", + "status": "completed", + "updatedAt": "2026-03-27T11:22:00Z" + } + ], + "guiSmoke": { + "itemId": "gui-smoke-fixture-001", + "turnId": "turn-fixture-001", + "status": "completed", + "command": "npm run verify:gui-smoke", + "cwd": "/workspace/lime", + "exitCode": 0, + "error": null, + "updatedAt": "2026-03-27T11:23:30Z", + "outputPreview": "GUI smoke finished successfully" + } + }, + "knownGaps": [] } } } diff --git a/eslint.config.js b/eslint.config.js index e164e9373..154a84cd7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -570,15 +570,6 @@ const posterMaterialGatewayCommandSelectors = [ "海报素材相关后端命令请统一通过 `src/lib/api/posterMaterials.ts` 暴露的网关函数调用,避免在 Hook / 组件中继续直接拼接命令名。", })); -const subAgentSchedulerCommandSelectors = [ - "execute_subagent_tasks", - "cancel_subagent_tasks", -].map((command) => ({ - selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, - message: - "SubAgent 调度相关后端命令请统一通过 `src/lib/api/subAgentScheduler.ts` 暴露的网关函数调用,避免在 Hook / 组件中继续直接拼接命令名。", -})); - const fileSystemCommandSelectors = [ "reveal_in_finder", "open_with_default_app", @@ -1084,7 +1075,6 @@ export default [ ...personaGatewayCommandSelectors, ...brandPersonaGatewayCommandSelectors, ...posterMaterialGatewayCommandSelectors, - ...subAgentSchedulerCommandSelectors, ...fileSystemCommandSelectors, ...pluginGatewayCommandSelectors, ...fileBrowserCommandSelectors, diff --git a/package-lock.json b/package-lock.json index fa538cf7c..d80d23aa4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lime", - "version": "1.8.0", + "version": "1.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lime", - "version": "1.8.0", + "version": "1.9.0", "dependencies": { "@babel/standalone": "^7.29.0", "@fabianlars/tauri-plugin-oauth": "^2", diff --git a/package.json b/package.json index 30ec03229..a3e7c22cb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "1.8.0", + "version": "1.9.0", "type": "module", "engines": { "node": ">=22.0.0" @@ -41,7 +41,7 @@ "test:watch": "node --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs", "test:frontend": "npm run lint && npm run typecheck && npm test", "test:bridge": "npm test -- src/lib/dev-bridge/safeInvoke.test.ts src/lib/tauri-mock/core.test.ts", - "test:contracts": "npm run check:agent-runtime-clients && node scripts/check-command-contracts.mjs && node scripts/check-harness-contracts.mjs", + "test:contracts": "npm run check:agent-runtime-clients && node scripts/check-command-contracts.mjs && node scripts/check-harness-contracts.mjs && npm run harness:cleanup-report:check", "test:rust": "cargo test --manifest-path \"src-tauri/Cargo.toml\"", "harness:analysis": "node scripts/harness-analysis-brief.mjs", "harness:eval": "node scripts/harness-eval-runner.mjs", @@ -52,6 +52,7 @@ "harness:eval:trend:json": "node scripts/harness-eval-trend-report.mjs --format json", "harness:cleanup-report": "node scripts/report-generated-slop.mjs", "harness:cleanup-report:json": "node scripts/report-generated-slop.mjs --format json", + "harness:cleanup-report:check": "node scripts/check-generated-slop-report.mjs --generate-current", "harness:doc-freshness": "node scripts/check-doc-freshness.mjs", "harness:doc-freshness:json": "node scripts/check-doc-freshness.mjs --json", "lint:rust": "cargo clippy --manifest-path \"src-tauri/Cargo.toml\"", diff --git a/packages/lime-cli-npm/README.md b/packages/lime-cli-npm/README.md index 6b28c14c7..c6201c3f5 100644 --- a/packages/lime-cli-npm/README.md +++ b/packages/lime-cli-npm/README.md @@ -112,7 +112,7 @@ npm run build:release -- \ ```bash npm run build:release -- \ --target-triple "aarch64-apple-darwin" \ - --version "1.8.0" \ + --version "1.9.0" \ --out-dir "./dist" ``` diff --git a/packages/lime-cli-npm/package.json b/packages/lime-cli-npm/package.json index 140bb0425..658991a2c 100644 --- a/packages/lime-cli-npm/package.json +++ b/packages/lime-cli-npm/package.json @@ -1,6 +1,6 @@ { "name": "@limecloud/lime-cli", - "version": "1.8.0", + "version": "1.9.0", "description": "Lime 官方任务 CLI", "bin": { "lime": "scripts/run.js" diff --git a/scripts/app-version.mjs b/scripts/app-version.mjs index 19ed07a50..374b8a716 100644 --- a/scripts/app-version.mjs +++ b/scripts/app-version.mjs @@ -32,6 +32,20 @@ function parseWorkspaceField(section, fieldName) { return pattern.test(section); } +function readPackageJsonVersion(repoRoot) { + const packageJsonPath = path.join(repoRoot, "package.json"); + if (!fs.existsSync(packageJsonPath)) { + return null; + } + + try { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + return typeof packageJson.version === "string" ? packageJson.version : null; + } catch { + return null; + } +} + export function readCargoVersions(cargoTomlPath) { const content = fs.readFileSync(cargoTomlPath, "utf8"); const workspacePackageSection = extractSection(content, "workspace.package"); @@ -50,8 +64,11 @@ export function readCargoVersions(cargoTomlPath) { export function readWorkspaceAppVersion(repoRoot = process.cwd()) { const cargoTomlPath = path.join(repoRoot, "src-tauri", "Cargo.toml"); + if (!fs.existsSync(cargoTomlPath)) { + return readPackageJsonVersion(repoRoot); + } const { workspaceVersion } = readCargoVersions(cargoTomlPath); - return workspaceVersion; + return workspaceVersion ?? readPackageJsonVersion(repoRoot); } const currentFilePath = fileURLToPath(import.meta.url); diff --git a/scripts/browser-runtime-smoke.mjs b/scripts/browser-runtime-smoke.mjs index 1610c8047..3b4d60141 100644 --- a/scripts/browser-runtime-smoke.mjs +++ b/scripts/browser-runtime-smoke.mjs @@ -13,9 +13,12 @@ const DEFAULTS = { streamMode: "both", }; -const INVOKE_TIMEOUT_MS = 60_000; -const INVOKE_RETRY_COUNT = 3; -const INVOKE_RETRY_DELAY_MS = 500; +const INVOKE_TIMEOUT_CEILING_MS = 180_000; +const INVOKE_RETRY_COUNT = 10; +const INVOKE_RETRY_DELAY_MS = 1_000; +const POST_HEALTH_SETTLE_MS = 3_000; +const POST_LAUNCH_SETTLE_MS = 1_500; +const READ_PAGE_TIMEOUT_MS = 45_000; function printHelp() { console.log(` @@ -123,13 +126,14 @@ function isTransientInvokeError(error) { } async function invoke(options, cmd, args) { + const invokeTimeoutMs = Math.min(options.timeoutMs, INVOKE_TIMEOUT_CEILING_MS); const requestInit = { method: "POST", headers: { "content-type": "application/json", }, body: JSON.stringify({ cmd, args }), - signal: AbortSignal.timeout(Math.min(options.timeoutMs, INVOKE_TIMEOUT_MS)), + signal: AbortSignal.timeout(invokeTimeoutMs), }; for (let attempt = 1; attempt <= INVOKE_RETRY_COUNT; attempt += 1) { @@ -151,7 +155,7 @@ async function invoke(options, cmd, args) { if (!isTransientInvokeError(error) || attempt >= INVOKE_RETRY_COUNT) { if (error?.name === "TimeoutError") { throw new Error( - `[smoke:browser-runtime] ${cmd} 超时,${Math.min(options.timeoutMs, INVOKE_TIMEOUT_MS)}ms 内未收到 DevBridge 响应`, + `[smoke:browser-runtime] ${cmd} 超时,${invokeTimeoutMs}ms 内未收到 DevBridge 响应`, ); } throw new Error(`[smoke:browser-runtime] ${cmd} 请求失败: ${detail}`); @@ -209,6 +213,7 @@ async function main() { const options = parseArgs(process.argv.slice(2)); await waitForHealth(options); + await sleep(POST_HEALTH_SETTLE_MS); const profileKey = `smoke-browser-runtime-${Date.now()}`; let sessionId = null; @@ -256,11 +261,13 @@ async function main() { "get_browser_session_state 未返回 target_id", ); + await sleep(POST_LAUNCH_SETTLE_MS); + const actionResult = await invoke(options, "browser_execute_action", { request: { profile_key: profileKey, action: "read_page", - timeout_ms: 20_000, + timeout_ms: Math.min(options.timeoutMs, READ_PAGE_TIMEOUT_MS), }, }); assert(actionResult?.success === true, "browser_execute_action(read_page) 未成功"); diff --git a/scripts/check-generated-slop-report.mjs b/scripts/check-generated-slop-report.mjs new file mode 100644 index 000000000..9b6f795a5 --- /dev/null +++ b/scripts/check-generated-slop-report.mjs @@ -0,0 +1,158 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; + +import { assertGeneratedSlopReportContract } from "./lib/generated-slop-report-core.mjs"; + +const GENERATED_SLOP_REPORT_SCRIPT = "scripts/report-generated-slop.mjs"; + +function parseArgs(argv) { + const result = { + format: "text", + generateCurrent: false, + help: false, + input: ".lime/harness/reports/harness-cleanup-report.json", + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === "--input" && argv[index + 1]) { + result.input = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--format" && argv[index + 1]) { + result.format = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--generate-current") { + result.generateCurrent = true; + continue; + } + + if (arg === "--help" || arg === "-h") { + result.help = true; + } + } + + return result; +} + +function printHelp() { + console.log(` +Lime Harness Cleanup Report Contract Check + +用法: + node scripts/check-generated-slop-report.mjs + node scripts/check-generated-slop-report.mjs --generate-current + node scripts/check-generated-slop-report.mjs --input ".lime/harness/reports/harness-cleanup-report.json" + node scripts/check-generated-slop-report.mjs --input "./tmp/harness-cleanup-report.json" --format json + +选项: + --input PATH cleanup report JSON 路径,默认 ".lime/harness/reports/harness-cleanup-report.json" + --generate-current 先生成当前 cleanup report,再校验其契约 + --format FMT 输出格式:text | json + -h, --help 显示帮助 +`); +} + +function resolveInputPath(inputPath) { + return path.resolve(process.cwd(), inputPath); +} + +function buildCurrentCleanupReport() { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "lime-cleanup-report-contract-"), + ); + const outputPath = path.join(tempRoot, "harness-cleanup-report.json"); + + try { + execFileSync( + process.execPath, + [ + path.resolve(process.cwd(), GENERATED_SLOP_REPORT_SCRIPT), + "--format", + "json", + "--output-json", + outputPath, + ], + { + cwd: process.cwd(), + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + return outputPath; + } catch (error) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + throw error; + } +} + +function buildSummary(report, resolvedPath) { + return { + status: "ok", + inputPath: resolvedPath, + recommendationCount: Array.isArray(report?.recommendations) + ? report.recommendations.length + : 0, + verificationFailureFocusCount: Array.isArray( + report?.focus?.observabilityVerificationOutcomes, + ) + ? report.focus.observabilityVerificationOutcomes.length + : 0, + currentRecoveredBaselineCount: Array.isArray( + report?.focus?.currentRecoveredObservabilityVerificationOutcomes, + ) + ? report.focus.currentRecoveredObservabilityVerificationOutcomes.length + : 0, + }; +} + +function renderSummary(summary, format) { + if (format === "json") { + return `${JSON.stringify(summary, null, 2)}\n`; + } + + return [ + "[harness-cleanup-contract] ok", + `[harness-cleanup-contract] input: ${summary.inputPath}`, + `[harness-cleanup-contract] recommendations: ${summary.recommendationCount}`, + `[harness-cleanup-contract] verification failure focus: ${summary.verificationFailureFocusCount}`, + `[harness-cleanup-contract] current recovered baseline: ${summary.currentRecoveredBaselineCount}`, + ].join("\n") + "\n"; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + + if (options.help) { + printHelp(); + return; + } + + const resolvedPath = options.generateCurrent + ? buildCurrentCleanupReport() + : resolveInputPath(options.input); + + try { + const report = JSON.parse(fs.readFileSync(resolvedPath, "utf8")); + const validatedReport = assertGeneratedSlopReportContract(report); + const summary = buildSummary(validatedReport, resolvedPath); + process.stdout.write(renderSummary(summary, options.format)); + } finally { + if (options.generateCurrent) { + fs.rmSync(path.dirname(resolvedPath), { recursive: true, force: true }); + } + } +} + +main(); diff --git a/scripts/harness-eval-history-record.mjs b/scripts/harness-eval-history-record.mjs index 6261da8f6..3eb1c08d3 100644 --- a/scripts/harness-eval-history-record.mjs +++ b/scripts/harness-eval-history-record.mjs @@ -6,21 +6,34 @@ import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; +import { renderHarnessDashboardHtml } from "./lib/harness-dashboard-core.mjs"; + const RUNNER_PATH = "scripts/harness-eval-runner.mjs"; const TREND_PATH = "scripts/harness-eval-trend-report.mjs"; const CLEANUP_PATH = "scripts/report-generated-slop.mjs"; +const RECOVERED_VERIFICATION_OUTCOMES = new Set([ + "repaired", + "success", + "passed", + "clean", +]); function parseArgs(argv) { const result = { cleanupJson: "", cleanupMarkdown: "", + dashboardHtml: "", + dashboardTitle: "Lime Harness Dashboard", format: "text", help: false, historyDir: "./.lime/harness/history", + manifest: "", outputJson: "", retain: 30, skipCleanup: false, skipTrend: false, + summaryJson: "", + summaryMarkdown: "", trendJson: "", trendMarkdown: "", workspaceRoot: process.cwd(), @@ -41,6 +54,12 @@ function parseArgs(argv) { continue; } + if (arg === "--manifest" && argv[index + 1]) { + result.manifest = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--retain" && argv[index + 1]) { result.retain = Number.parseInt(String(argv[index + 1]).trim(), 10); index += 1; @@ -59,6 +78,18 @@ function parseArgs(argv) { continue; } + if (arg === "--summary-json" && argv[index + 1]) { + result.summaryJson = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--summary-markdown" && argv[index + 1]) { + result.summaryMarkdown = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--trend-json" && argv[index + 1]) { result.trendJson = String(argv[index + 1]).trim(); index += 1; @@ -83,6 +114,18 @@ function parseArgs(argv) { continue; } + if (arg === "--dashboard-html" && argv[index + 1]) { + result.dashboardHtml = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--dashboard-title" && argv[index + 1]) { + result.dashboardTitle = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--skip-trend") { result.skipTrend = true; continue; @@ -113,11 +156,16 @@ Lime Harness Eval History Record 选项: --history-dir PATH summary 历史目录,默认 ./.lime/harness/history --workspace-root PATH 生成当前 summary 时使用的工作区根目录 + --manifest PATH 透传给 harness eval runner,覆盖默认 manifest --retain N 历史窗口保留数量,默认 30 - --trend-json PATH trend JSON 输出路径 - --trend-markdown PATH trend Markdown 输出路径 - --cleanup-json PATH cleanup JSON 输出路径 - --cleanup-markdown PATH cleanup Markdown 输出路径 + --summary-json PATH summary JSON 输出路径,默认写入 reports/harness-eval-summary.json + --summary-markdown PATH summary Markdown 输出路径,默认写入 reports/harness-eval-summary.md + --trend-json PATH trend JSON 输出路径,默认写入 reports/harness-eval-trend.json + --trend-markdown PATH trend Markdown 输出路径,默认写入 reports/harness-eval-trend.md + --cleanup-json PATH cleanup JSON 输出路径,默认写入 reports/harness-cleanup-report.json + --cleanup-markdown PATH cleanup Markdown 输出路径,默认写入 reports/harness-cleanup-report.md + --dashboard-html PATH dashboard HTML 输出路径,依赖 summary / trend / cleanup;默认写入 reports/harness-dashboard.html + --dashboard-title TEXT dashboard 页面标题 --skip-trend 只记录 summary,不生成 trend --skip-cleanup 只记录 summary / trend,不生成 cleanup --format FMT 标准输出格式:text | json @@ -134,8 +182,9 @@ function ensureParentDirectory(filePath) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); } -function readJsonFile(filePath) { - return JSON.parse(fs.readFileSync(filePath, "utf8")); +function writeTextFile(filePath, contents) { + ensureParentDirectory(filePath); + fs.writeFileSync(filePath, contents, "utf8"); } function timestampForFilename() { @@ -154,20 +203,32 @@ function collectHistoryFiles(historyDir) { .sort((left, right) => left.localeCompare(right)); } -function buildCurrentSummary(repoRoot, workspaceRoot) { +function runRunner(repoRoot, args) { const runnerPath = resolvePath(repoRoot, RUNNER_PATH); - const output = execFileSync( - process.execPath, - [runnerPath, "--format", "json", "--workspace-root", workspaceRoot], - { - cwd: repoRoot, - encoding: "utf8", - stdio: ["ignore", "pipe", "inherit"], - }, - ); + return execFileSync(process.execPath, [runnerPath, ...args], { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }); +} + +function buildCurrentSummary(repoRoot, workspaceRoot, manifestPath) { + const args = ["--format", "json", "--workspace-root", workspaceRoot]; + if (manifestPath) { + args.push("--manifest", manifestPath); + } + const output = runRunner(repoRoot, args); return JSON.parse(output); } +function buildCurrentSummaryMarkdown(repoRoot, workspaceRoot, manifestPath) { + const args = ["--format", "markdown", "--workspace-root", workspaceRoot]; + if (manifestPath) { + args.push("--manifest", manifestPath); + } + return runRunner(repoRoot, args); +} + function writeJsonFile(filePath, payload) { ensureParentDirectory(filePath); fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); @@ -214,13 +275,157 @@ function buildDefaultArtifactPaths(historyDir) { ? path.join(historyParent, "reports") : historyParent; return { + summaryJson: path.join(artifactsRoot, "harness-eval-summary.json"), + summaryMarkdown: path.join(artifactsRoot, "harness-eval-summary.md"), trendJson: path.join(artifactsRoot, "harness-eval-trend.json"), trendMarkdown: path.join(artifactsRoot, "harness-eval-trend.md"), cleanupJson: path.join(artifactsRoot, "harness-cleanup-report.json"), cleanupMarkdown: path.join(artifactsRoot, "harness-cleanup-report.md"), + dashboardHtml: path.join(artifactsRoot, "harness-dashboard.html"), }; } +function toVerificationFailureOutcomeFocus(cleanupReport) { + const currentEntries = Array.isArray( + cleanupReport?.focus?.currentObservabilityVerificationOutcomes, + ) + ? cleanupReport.focus.currentObservabilityVerificationOutcomes + : []; + const fallbackEntries = Array.isArray( + cleanupReport?.focus?.observabilityVerificationOutcomes, + ) + ? cleanupReport.focus.observabilityVerificationOutcomes + : []; + const entries = + currentEntries.length > 0 ? currentEntries : fallbackEntries; + + return entries + .map((entry) => { + const signal = typeof entry?.signal === "string" ? entry.signal.trim() : ""; + const outcome = + typeof entry?.outcome === "string" ? entry.outcome.trim() : ""; + return signal && outcome ? `${signal}:${outcome}` : ""; + }) + .filter(Boolean); +} + +function toCurrentRecoveredBaselineFocus(cleanupReport) { + const explicitRecoveredEntries = Array.isArray( + cleanupReport?.focus?.currentRecoveredObservabilityVerificationOutcomes, + ) + ? cleanupReport.focus.currentRecoveredObservabilityVerificationOutcomes + : []; + const currentEntries = Array.isArray( + cleanupReport?.focus?.currentObservabilityVerificationOutcomes, + ) + ? cleanupReport.focus.currentObservabilityVerificationOutcomes + : []; + const fallbackEntries = Array.isArray( + cleanupReport?.focus?.observabilityVerificationOutcomes, + ) + ? cleanupReport.focus.observabilityVerificationOutcomes + : []; + const entries = + explicitRecoveredEntries.length > 0 + ? explicitRecoveredEntries + : currentEntries.length > 0 + ? currentEntries + : fallbackEntries; + + return entries + .filter((entry) => + RECOVERED_VERIFICATION_OUTCOMES.has( + typeof entry?.outcome === "string" ? entry.outcome.trim() : "", + ), + ) + .map((entry) => { + const signal = typeof entry?.signal === "string" ? entry.signal.trim() : ""; + const outcome = + typeof entry?.outcome === "string" ? entry.outcome.trim() : ""; + return signal && outcome ? `${signal}:${outcome}` : ""; + }) + .filter(Boolean); +} + +function toVerificationOutcomeCounts(cleanupReport) { + const summary = + cleanupReport && + typeof cleanupReport === "object" && + cleanupReport.summary && + cleanupReport.summary.verificationOutcomes && + typeof cleanupReport.summary.verificationOutcomes === "object" + ? cleanupReport.summary.verificationOutcomes + : {}; + const currentSummary = + summary && + typeof summary.current === "object" && + !Array.isArray(summary.current) + ? summary.current + : {}; + const degradedSummary = + summary && + typeof summary.degraded === "object" && + !Array.isArray(summary.degraded) + ? summary.degraded + : {}; + + return { + failureCaseCount: + typeof summary.failureCaseCount === "number" && + Number.isFinite(summary.failureCaseCount) + ? summary.failureCaseCount + : 0, + blockingFailureCaseCount: + typeof currentSummary.blockingFailureCaseCount === "number" && + Number.isFinite(currentSummary.blockingFailureCaseCount) + ? currentSummary.blockingFailureCaseCount + : 0, + advisoryFailureCaseCount: + typeof currentSummary.advisoryFailureCaseCount === "number" && + Number.isFinite(currentSummary.advisoryFailureCaseCount) + ? currentSummary.advisoryFailureCaseCount + : 0, + recoveredCaseCount: + typeof summary.recoveredCaseCount === "number" && + Number.isFinite(summary.recoveredCaseCount) + ? summary.recoveredCaseCount + : 0, + currentRecoveredCaseCount: + typeof currentSummary.recoveredCaseCount === "number" && + Number.isFinite(currentSummary.recoveredCaseCount) + ? currentSummary.recoveredCaseCount + : 0, + degradedBlockingFailureCaseCount: + typeof degradedSummary.blockingFailureCaseCount === "number" && + Number.isFinite(degradedSummary.blockingFailureCaseCount) + ? degradedSummary.blockingFailureCaseCount + : 0, + }; +} + +function toTrendCurrentRecoveredBaselineFocus(trendReport) { + const entries = Array.isArray( + trendReport?.classificationDeltas?.currentRecoveredObservabilityVerificationOutcomes, + ) + ? trendReport.classificationDeltas.currentRecoveredObservabilityVerificationOutcomes + : []; + + return entries + .filter((entry) => { + const latestCaseCount = + typeof entry?.latest?.caseCount === "number" && + Number.isFinite(entry.latest.caseCount) + ? entry.latest.caseCount + : 0; + return latestCaseCount > 0; + }) + .map((entry) => + typeof entry?.name === "string" ? entry.name.trim() : "", + ) + .filter(Boolean) + .slice(0, 3); +} + function renderOutput(result, format) { if (format === "json") { return `${JSON.stringify(result, null, 2)}\n`; @@ -234,8 +439,31 @@ function renderOutput(result, format) { `[lime] trimmed files: ${result.trimmedPaths.length}`, ]; + if (result.summary) { + if (result.summary.outputJsonPath) { + lines.push(`[lime] summary json: ${result.summary.outputJsonPath}`); + } + if (result.summary.outputMarkdownPath) { + lines.push(`[lime] summary markdown: ${result.summary.outputMarkdownPath}`); + } + } + if (result.trend) { lines.push(`[lime] trend sample count: ${result.trend.sampleCount}`); + lines.push( + `[lime] trend current observability gap cases: ${result.trend.currentObservabilityGapCaseCount}`, + ); + lines.push( + `[lime] trend degraded observability gap cases: ${result.trend.degradedObservabilityGapCaseCount}`, + ); + lines.push( + `[lime] trend current recovered baseline cases: ${result.trend.currentRecoveredVerificationCaseCount}`, + ); + if (result.trend.currentRecoveredBaselineFocus.length > 0) { + lines.push( + `[lime] trend current recovered baseline: ${result.trend.currentRecoveredBaselineFocus.join(", ")}`, + ); + } if (result.trend.outputJsonPath) { lines.push(`[lime] trend json: ${result.trend.outputJsonPath}`); } @@ -245,11 +473,49 @@ function renderOutput(result, format) { lines.push( `[lime] cleanup trend samples: ${result.cleanup.trendSampleCount}`, ); + lines.push( + `[lime] cleanup current observability gap cases: ${result.cleanup.currentObservabilityGapCaseCount}`, + ); + lines.push( + `[lime] cleanup degraded observability gap cases: ${result.cleanup.degradedObservabilityGapCaseCount}`, + ); + if (result.cleanup.verificationFailureOutcomeFocus.length > 0) { + lines.push( + `[lime] cleanup verification failure outcomes: ${result.cleanup.verificationFailureOutcomeFocus.join(", ")}`, + ); + } + lines.push( + `[lime] cleanup verification failure cases: ${result.cleanup.verificationFailureCaseCount}`, + ); + lines.push( + `[lime] cleanup verification blocking failure cases: ${result.cleanup.verificationBlockingFailureCaseCount}`, + ); + lines.push( + `[lime] cleanup verification advisory failure cases: ${result.cleanup.verificationAdvisoryFailureCaseCount}`, + ); + lines.push( + `[lime] cleanup degraded blocking verification failure cases: ${result.cleanup.verificationDegradedBlockingFailureCaseCount}`, + ); + lines.push( + `[lime] cleanup verification recovered cases: ${result.cleanup.verificationRecoveredCaseCount}`, + ); + lines.push( + `[lime] cleanup current recovered baseline cases: ${result.cleanup.currentVerificationRecoveredCaseCount}`, + ); + if (result.cleanup.currentRecoveredBaselineFocus.length > 0) { + lines.push( + `[lime] cleanup current recovered baseline: ${result.cleanup.currentRecoveredBaselineFocus.join(", ")}`, + ); + } if (result.cleanup.outputJsonPath) { lines.push(`[lime] cleanup json: ${result.cleanup.outputJsonPath}`); } } + if (result.dashboard?.outputHtmlPath) { + lines.push(`[lime] dashboard html: ${result.dashboard.outputHtmlPath}`); + } + return `${lines.join("\n")}\n`; } @@ -260,11 +526,24 @@ function runHistoryRecordCli() { return; } + if (options.dashboardHtml && (options.skipTrend || options.skipCleanup)) { + throw new Error( + "生成 dashboard 需要同时启用 trend 与 cleanup,请移除 --skip-trend / --skip-cleanup。", + ); + } + const repoRoot = process.cwd(); const historyDir = resolvePath(repoRoot, options.historyDir); fs.mkdirSync(historyDir, { recursive: true }); - const summary = buildCurrentSummary(repoRoot, options.workspaceRoot); + const effectiveManifest = options.manifest + ? resolvePath(repoRoot, options.manifest) + : ""; + const summary = buildCurrentSummary( + repoRoot, + options.workspaceRoot, + effectiveManifest, + ); const summaryFilePath = writeUniqueHistorySummary(historyDir, summary); const trimmedPaths = trimHistoryFiles(historyDir, options.retain); const historyCount = collectHistoryFiles(historyDir).length; @@ -276,8 +555,33 @@ function runHistoryRecordCli() { recordedSummaryPath: summaryFilePath, historyCount, trimmedPaths, + summary: null, trend: null, cleanup: null, + dashboard: null, + }; + let trendReport = null; + let cleanupReport = null; + + const summaryJsonPath = resolvePath( + repoRoot, + options.summaryJson || defaults.summaryJson, + ); + const summaryMarkdownPath = resolvePath( + repoRoot, + options.summaryMarkdown || defaults.summaryMarkdown, + ); + + writeJsonFile(summaryJsonPath, summary); + const summaryMarkdown = buildCurrentSummaryMarkdown( + repoRoot, + options.workspaceRoot, + effectiveManifest, + ); + writeTextFile(summaryMarkdownPath, summaryMarkdown); + result.summary = { + outputJsonPath: summaryJsonPath, + outputMarkdownPath: summaryMarkdownPath, }; if (!options.skipTrend) { @@ -309,9 +613,18 @@ function runHistoryRecordCli() { stdio: ["ignore", "pipe", "inherit"], }, ); - const trendReport = JSON.parse(trendOutput); + trendReport = JSON.parse(trendOutput); + const trendCurrentRecoveredBaselineFocus = + toTrendCurrentRecoveredBaselineFocus(trendReport); result.trend = { sampleCount: trendReport.sampleCount, + currentObservabilityGapCaseCount: + trendReport.latest?.totals?.currentObservabilityGapCaseCount ?? 0, + degradedObservabilityGapCaseCount: + trendReport.latest?.totals?.degradedObservabilityGapCaseCount ?? 0, + currentRecoveredVerificationCaseCount: + trendReport.latest?.totals?.currentRecoveredVerificationCaseCount ?? 0, + currentRecoveredBaselineFocus: trendCurrentRecoveredBaselineFocus, outputJsonPath: trendJsonPath, outputMarkdownPath: trendMarkdownPath, }; @@ -346,14 +659,57 @@ function runHistoryRecordCli() { stdio: ["ignore", "pipe", "inherit"], }, ); - const cleanupReport = JSON.parse(cleanupOutput); + cleanupReport = JSON.parse(cleanupOutput); + const verificationFailureOutcomeFocus = + toVerificationFailureOutcomeFocus(cleanupReport); + const currentRecoveredBaselineFocus = + toCurrentRecoveredBaselineFocus(cleanupReport); + const verificationOutcomeCounts = + toVerificationOutcomeCounts(cleanupReport); result.cleanup = { trendSampleCount: cleanupReport.summary?.trend?.sampleCount ?? 0, + currentObservabilityGapCaseCount: + cleanupReport.summary?.trend?.latestCurrentObservabilityGapCaseCount ?? 0, + degradedObservabilityGapCaseCount: + cleanupReport.summary?.trend?.latestDegradedObservabilityGapCaseCount ?? 0, + verificationFailureOutcomeFocus, + verificationFailureCaseCount: + verificationOutcomeCounts.failureCaseCount, + verificationBlockingFailureCaseCount: + verificationOutcomeCounts.blockingFailureCaseCount, + verificationAdvisoryFailureCaseCount: + verificationOutcomeCounts.advisoryFailureCaseCount, + verificationDegradedBlockingFailureCaseCount: + verificationOutcomeCounts.degradedBlockingFailureCaseCount, + verificationRecoveredCaseCount: + verificationOutcomeCounts.recoveredCaseCount, + currentVerificationRecoveredCaseCount: + verificationOutcomeCounts.currentRecoveredCaseCount, + currentRecoveredBaselineFocus, outputJsonPath: cleanupJsonPath, outputMarkdownPath: cleanupMarkdownPath, }; } + const dashboardHtmlPath = + options.skipTrend || options.skipCleanup + ? "" + : resolvePath(repoRoot, options.dashboardHtml || defaults.dashboardHtml); + + if (dashboardHtmlPath) { + const dashboardHtml = renderHarnessDashboardHtml({ + summaryReport: summary, + trendReport, + cleanupReport, + title: options.dashboardTitle, + }); + writeTextFile(dashboardHtmlPath, dashboardHtml); + result.dashboard = { + outputHtmlPath: dashboardHtmlPath, + title: options.dashboardTitle, + }; + } + const rendered = renderOutput(result, options.format); if (options.outputJson) { const outputPath = resolvePath(repoRoot, options.outputJson); diff --git a/scripts/harness-eval-runner.mjs b/scripts/harness-eval-runner.mjs index fdd0aaccd..4b89cfb85 100644 --- a/scripts/harness-eval-runner.mjs +++ b/scripts/harness-eval-runner.mjs @@ -22,16 +22,15 @@ const REVIEW_DECISION_RISK_LEVEL_SET = new Set([ "high", "unknown", ]); +const OBSERVABILITY_GAP_SUITE_TAG = "observability-gap"; function parseArgs(argv) { const result = { format: "text", help: false, - historyRetain: 30, manifest: DEFAULT_MANIFEST_PATH, outputJson: "", outputMarkdown: "", - recordHistoryDir: "", strict: true, workspaceRoot: process.cwd(), }; @@ -69,18 +68,6 @@ function parseArgs(argv) { continue; } - if (arg === "--record-history-dir" && argv[index + 1]) { - result.recordHistoryDir = String(argv[index + 1]).trim(); - index += 1; - continue; - } - - if (arg === "--history-retain" && argv[index + 1]) { - result.historyRetain = Number.parseInt(String(argv[index + 1]), 10); - index += 1; - continue; - } - if (arg === "--no-strict") { result.strict = false; continue; @@ -108,7 +95,6 @@ Lime Harness Eval Runner node scripts/harness-eval-runner.mjs --format json node scripts/harness-eval-runner.mjs --workspace-root "/path/to/workspace" node scripts/harness-eval-runner.mjs --output-json "./tmp/harness-eval-summary.json" --output-markdown "./tmp/harness-eval-summary.md" - node scripts/harness-eval-runner.mjs --record-history-dir "./artifacts/history" 选项: --manifest PATH 指定 manifest,默认 docs/test/harness-evals.manifest.json @@ -116,8 +102,6 @@ Lime Harness Eval Runner --format FMT 控制标准输出格式:text | json | markdown --output-json PATH 将 JSON 摘要写入指定路径 --output-markdown PATH 将 Markdown 摘要写入指定路径 - --record-history-dir PATH 将当前 summary 追加写入历史目录,供 trend/nightly 复用 - --history-retain N 历史目录最多保留多少条 summary,默认 30 --strict 严格模式(默认),发现 invalid case 时返回非 0 --no-strict 非严格模式,只输出摘要,不因 invalid case 退出失败 -h, --help 显示帮助 @@ -136,56 +120,6 @@ function ensureParentDirectory(filePath) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); } -function ensureDirectory(dirPath) { - fs.mkdirSync(dirPath, { recursive: true }); -} - -function toHistoryTimestamp(generatedAt) { - const parsed = new Date(generatedAt); - const normalized = Number.isNaN(parsed.getTime()) - ? new Date().toISOString() - : parsed.toISOString(); - return normalized.replace(/[-:]/g, "").replace(/\.(\d{3})Z$/, "$1Z"); -} - -function trimHistoryDirectory(historyDir, retainCount) { - const normalizedRetainCount = - Number.isInteger(retainCount) && retainCount > 0 ? retainCount : 30; - const historyFiles = fs - .readdirSync(historyDir, { withFileTypes: true }) - .filter( - (entry) => - entry.isFile() && - entry.name.endsWith("-harness-eval-summary.json"), - ) - .map((entry) => path.join(historyDir, entry.name)) - .sort((left, right) => right.localeCompare(left)); - - for (const staleFile of historyFiles.slice(normalizedRetainCount)) { - fs.rmSync(staleFile, { force: true }); - } -} - -function recordSummaryHistory(repoRoot, summary, options) { - if (!options.recordHistoryDir) { - return ""; - } - - const historyDir = resolvePath(repoRoot, options.recordHistoryDir); - ensureDirectory(historyDir); - const historyFilePath = path.join( - historyDir, - `${toHistoryTimestamp(summary.generatedAt)}-harness-eval-summary.json`, - ); - fs.writeFileSync( - historyFilePath, - `${JSON.stringify(summary, null, 2)}\n`, - "utf8", - ); - trimHistoryDirectory(historyDir, options.historyRetain); - return historyFilePath; -} - function normalizeStringList(value) { if (!Array.isArray(value)) { return []; @@ -213,6 +147,10 @@ function normalizeEnumString(value, allowedValues, fallback = "") { return fallback; } +function isObject(value) { + return value != null && typeof value === "object" && !Array.isArray(value); +} + function createBreakdownEntry(name) { return { name, @@ -255,6 +193,25 @@ function aggregateCaseBreakdown(cases, selector) { }); } +function isDegradedObservabilityGapCase(entry) { + return ( + entry.observabilityGapCount > 0 && + normalizeStringList(entry.tags).includes(OBSERVABILITY_GAP_SUITE_TAG) + ); +} + +function isCurrentObservabilityGapCase(entry) { + return entry.observabilityGapCount > 0 && !isDegradedObservabilityGapCase(entry); +} + +function isDegradedObservabilityDiagnosticCase(entry) { + return normalizeStringList(entry?.tags).includes(OBSERVABILITY_GAP_SUITE_TAG); +} + +function isCurrentObservabilityDiagnosticCase(entry) { + return !isDegradedObservabilityDiagnosticCase(entry); +} + function getValueByPath(target, dottedPath) { return dottedPath .split(".") @@ -388,19 +345,39 @@ function loadReviewDecisionForCase(caseDir, inlineReviewDecision) { }; } -function normalizeObservabilityCoverage(inputPayload, evidencePayload) { - const inlineSummary = - inputPayload?.observability && - typeof inputPayload.observability === "object" && - !Array.isArray(inputPayload.observability) - ? inputPayload.observability - : evidencePayload?.observabilitySummary && - typeof evidencePayload.observabilitySummary === "object" && - !Array.isArray(evidencePayload.observabilitySummary) - ? evidencePayload.observabilitySummary - : null; +function resolveObservabilitySummary(inputPayload, evidencePayload) { + const inlineSummary = isObject(inputPayload?.observability) + ? inputPayload.observability + : null; + const evidenceSummary = isObject(evidencePayload?.observabilitySummary) + ? evidencePayload.observabilitySummary + : null; - if (!inlineSummary) { + if (!inlineSummary && !evidenceSummary) { + return null; + } + + return { + signalCoverage: Array.isArray(inlineSummary?.signalCoverage) + ? inlineSummary.signalCoverage + : Array.isArray(evidenceSummary?.signalCoverage) + ? evidenceSummary.signalCoverage + : [], + verificationSummary: isObject(inlineSummary?.verificationSummary) + ? inlineSummary.verificationSummary + : isObject(evidenceSummary?.verificationSummary) + ? evidenceSummary.verificationSummary + : null, + }; +} + +function normalizeObservabilityCoverage(inputPayload, evidencePayload) { + const observabilitySummary = resolveObservabilitySummary( + inputPayload, + evidencePayload, + ); + + if (!observabilitySummary) { return [ { signal: "observabilitySummary", @@ -409,8 +386,8 @@ function normalizeObservabilityCoverage(inputPayload, evidencePayload) { ]; } - const signalCoverage = Array.isArray(inlineSummary.signalCoverage) - ? inlineSummary.signalCoverage + const signalCoverage = Array.isArray(observabilitySummary.signalCoverage) + ? observabilitySummary.signalCoverage : []; if (signalCoverage.length === 0) { @@ -461,6 +438,129 @@ function normalizeObservabilityCoverage(inputPayload, evidencePayload) { return coverage; } +function pushUniqueObservabilityVerificationOutcome(outcomes, seen, signal, outcome) { + const normalizedSignal = normalizeOptionalString(signal); + const normalizedOutcome = normalizeOptionalString(outcome); + if (!normalizedSignal || !normalizedOutcome) { + return; + } + + const fingerprint = `${normalizedSignal}:${normalizedOutcome}`; + if (seen.has(fingerprint)) { + return; + } + + seen.add(fingerprint); + outcomes.push({ + signal: normalizedSignal, + outcome: normalizedOutcome, + }); +} + +function normalizeObservabilityVerificationSummary(inputPayload, evidencePayload) { + const observabilitySummary = resolveObservabilitySummary( + inputPayload, + evidencePayload, + ); + const verificationSummary = isObject(observabilitySummary?.verificationSummary) + ? observabilitySummary.verificationSummary + : null; + + if (!verificationSummary) { + return []; + } + + const outcomes = []; + const seen = new Set(); + + const artifactValidator = isObject(verificationSummary.artifactValidator) + ? verificationSummary.artifactValidator + : null; + if (artifactValidator?.applicable === true) { + const recordCount = Number(artifactValidator.recordCount) || 0; + const issueCount = Number(artifactValidator.issueCount) || 0; + const repairedCount = Number(artifactValidator.repairedCount) || 0; + const fallbackUsedCount = Number(artifactValidator.fallbackUsedCount) || 0; + + if (recordCount > 0 && issueCount === 0) { + pushUniqueObservabilityVerificationOutcome( + outcomes, + seen, + "artifactValidator", + "clean", + ); + } + if (issueCount > 0) { + pushUniqueObservabilityVerificationOutcome( + outcomes, + seen, + "artifactValidator", + "issues_present", + ); + } + if (repairedCount > 0) { + pushUniqueObservabilityVerificationOutcome( + outcomes, + seen, + "artifactValidator", + "repaired", + ); + } + if (fallbackUsedCount > 0) { + pushUniqueObservabilityVerificationOutcome( + outcomes, + seen, + "artifactValidator", + "fallback_used", + ); + } + } + + const browserVerification = isObject(verificationSummary.browserVerification) + ? verificationSummary.browserVerification + : null; + if (browserVerification) { + if ((Number(browserVerification.successCount) || 0) > 0) { + pushUniqueObservabilityVerificationOutcome( + outcomes, + seen, + "browserVerification", + "success", + ); + } + if ((Number(browserVerification.failureCount) || 0) > 0) { + pushUniqueObservabilityVerificationOutcome( + outcomes, + seen, + "browserVerification", + "failure", + ); + } + if ((Number(browserVerification.unknownCount) || 0) > 0) { + pushUniqueObservabilityVerificationOutcome( + outcomes, + seen, + "browserVerification", + "unknown", + ); + } + } + + const guiSmoke = isObject(verificationSummary.guiSmoke) + ? verificationSummary.guiSmoke + : null; + if (guiSmoke && typeof guiSmoke.passed === "boolean") { + pushUniqueObservabilityVerificationOutcome( + outcomes, + seen, + "guiSmoke", + guiSmoke.passed ? "passed" : "failed", + ); + } + + return outcomes; +} + function validateCaseDirectory(caseDir, caseConfig, defaults, context) { const requiredArtifacts = normalizeStringList( caseConfig.requiredArtifacts ?? defaults.requiredArtifacts, @@ -551,6 +651,12 @@ function validateCaseDirectory(caseDir, caseConfig, defaults, context) { const observabilityGapCount = observabilityCoverage.filter( (entry) => entry.status !== "exported", ).length; + const observabilityVerificationCoverage = + normalizeObservabilityVerificationSummary(inputPayload, evidencePayload); + const observabilityVerificationOutcomes = + observabilityVerificationCoverage.map( + (entry) => `${entry.signal}:${entry.outcome}`, + ); const pendingRequestCount = Array.isArray( inputPayload?.runtimeContext?.pendingRequests, @@ -606,6 +712,8 @@ function validateCaseDirectory(caseDir, caseConfig, defaults, context) { observabilityCoverage, observabilitySignals, observabilityGapCount, + observabilityVerificationCoverage, + observabilityVerificationOutcomes, preferredMode, status: issues.length === 0 ? "ready" : "invalid", issues, @@ -675,6 +783,8 @@ function expandSuiteCases(suiteConfig, defaults, repoRoot, workspaceRoot) { ], observabilitySignals: ["observabilitySummary:missing"], observabilityGapCount: 1, + observabilityVerificationCoverage: [], + observabilityVerificationOutcomes: [], preferredMode: "", status: "invalid", issues: [ @@ -727,6 +837,8 @@ function expandSuiteCases(suiteConfig, defaults, repoRoot, workspaceRoot) { observabilityCoverage: [{ signal: "observabilitySummary", status: "missing" }], observabilitySignals: ["observabilitySummary:missing"], observabilityGapCount: 1, + observabilityVerificationCoverage: [], + observabilityVerificationOutcomes: [], preferredMode: "", status: "invalid", issues: [`不支持的 case source: ${source || "(empty)"}`], @@ -773,6 +885,12 @@ function buildSummary(manifest, suites, options) { const observabilityGapCases = allCases.filter( (entry) => entry.observabilityGapCount > 0, ); + const currentObservabilityGapCases = observabilityGapCases.filter((entry) => + isCurrentObservabilityGapCase(entry), + ); + const degradedObservabilityGapCases = observabilityGapCases.filter((entry) => + isDegradedObservabilityGapCase(entry), + ); return { manifestVersion: String(manifest.manifestVersion ?? "unknown"), @@ -790,6 +908,8 @@ function buildSummary(manifest, suites, options) { pendingRequestCaseCount: pendingCases.length, reviewDecisionRecordedCount: recordedReviewDecisionCases.length, observabilityGapCaseCount: observabilityGapCases.length, + currentObservabilityGapCaseCount: currentObservabilityGapCases.length, + degradedObservabilityGapCaseCount: degradedObservabilityGapCases.length, }, breakdowns: { suiteTags: aggregateCaseBreakdown(allCases, (entry) => entry.tags), @@ -807,6 +927,18 @@ function buildSummary(manifest, suites, options) { allCases, (entry) => entry.observabilitySignals, ), + observabilityVerificationOutcomes: aggregateCaseBreakdown( + allCases, + (entry) => entry.observabilityVerificationOutcomes, + ), + currentObservabilityVerificationOutcomes: aggregateCaseBreakdown( + allCases.filter((entry) => isCurrentObservabilityDiagnosticCase(entry)), + (entry) => entry.observabilityVerificationOutcomes, + ), + degradedObservabilityVerificationOutcomes: aggregateCaseBreakdown( + allCases.filter((entry) => isDegradedObservabilityDiagnosticCase(entry)), + (entry) => entry.observabilityVerificationOutcomes, + ), }, suites, }; @@ -824,6 +956,8 @@ function renderText(summary) { `[harness-eval] needs-review cases : ${summary.totals.needsHumanReviewCount}`, `[harness-eval] recorded review decisions: ${summary.totals.reviewDecisionRecordedCount}`, `[harness-eval] observability-gap cases: ${summary.totals.observabilityGapCaseCount}`, + `[harness-eval] current observability-gap cases: ${summary.totals.currentObservabilityGapCaseCount}`, + `[harness-eval] degraded observability-gap cases: ${summary.totals.degradedObservabilityGapCaseCount}`, ]; const topFailureModes = summary.breakdowns.failureModes.slice(0, 5); @@ -869,6 +1003,17 @@ function renderText(summary) { } } + const topVerificationOutcomes = + summary.breakdowns.observabilityVerificationOutcomes.slice(0, 5); + if (topVerificationOutcomes.length > 0) { + lines.push("[harness-eval] observability verification outcomes:"); + for (const entry of topVerificationOutcomes) { + lines.push( + ` - ${entry.name}: case=${entry.caseCount}, ready=${entry.readyCount}, invalid=${entry.invalidCount}`, + ); + } + } + for (const suite of summary.suites) { lines.push( `[harness-eval] suite ${suite.id}: ready ${suite.stats.readyCount} / ${suite.stats.caseCount}`, @@ -893,6 +1038,11 @@ function renderText(summary) { ` observability: ${entry.observabilitySignals.join(", ")}`, ); } + if (entry.observabilityVerificationOutcomes.length > 0) { + lines.push( + ` verification: ${entry.observabilityVerificationOutcomes.join(", ")}`, + ); + } for (const issue of entry.issues) { lines.push(` * ${issue}`); } @@ -917,6 +1067,8 @@ function renderMarkdown(summary) { `- needs review case:${summary.totals.needsHumanReviewCount}`, `- 已记录人工审核:${summary.totals.reviewDecisionRecordedCount}`, `- observability gap case:${summary.totals.observabilityGapCaseCount}`, + `- current observability gap case:${summary.totals.currentObservabilityGapCaseCount}`, + `- degraded observability gap case:${summary.totals.degradedObservabilityGapCaseCount}`, "", ]; @@ -987,6 +1139,19 @@ function renderMarkdown(summary) { lines.push(""); } + if (summary.breakdowns.observabilityVerificationOutcomes.length > 0) { + lines.push("## Observability Verification Outcome 分布"); + lines.push(""); + lines.push("| Outcome | case | ready | invalid |"); + lines.push("| --- | --- | --- | --- |"); + for (const entry of summary.breakdowns.observabilityVerificationOutcomes) { + lines.push( + `| ${entry.name} | ${entry.caseCount} | ${entry.readyCount} | ${entry.invalidCount} |`, + ); + } + lines.push(""); + } + for (const suite of summary.suites) { lines.push(`## ${suite.title}`); lines.push(""); @@ -1025,6 +1190,11 @@ function renderMarkdown(summary) { `observability: ${entry.observabilitySignals.join(", ")}`, ); } + if (entry.observabilityVerificationOutcomes.length > 0) { + classificationText.push( + `verification: ${entry.observabilityVerificationOutcomes.join(", ")}`, + ); + } const reviewText = entry.reviewDecisionStatus ? [ entry.reviewDecisionStatus, @@ -1078,7 +1248,6 @@ function main() { const jsonOutput = `${JSON.stringify(summary, null, 2)}\n`; const markdownOutput = renderMarkdown(summary); const textOutput = renderText(summary); - const historyFilePath = recordSummaryHistory(repoRoot, summary, options); if (options.outputJson) { const outputPath = resolvePath(repoRoot, options.outputJson); @@ -1098,11 +1267,6 @@ function main() { process.stdout.write(markdownOutput); } else { process.stdout.write(textOutput); - if (historyFilePath) { - process.stdout.write( - `[harness-eval] history snapshot: ${historyFilePath}\n`, - ); - } } const exitCode = determineExitCode(summary, options); diff --git a/scripts/harness-eval-trend-report.mjs b/scripts/harness-eval-trend-report.mjs index c5951625b..ce26d406c 100644 --- a/scripts/harness-eval-trend-report.mjs +++ b/scripts/harness-eval-trend-report.mjs @@ -6,6 +6,20 @@ import path from "node:path"; import process from "node:process"; const RUNNER_PATH = "scripts/harness-eval-runner.mjs"; +const OBSERVABILITY_GAP_SUITE_TAG = "observability-gap"; +const OBSERVABILITY_FAILURE_OUTCOMES = new Set([ + "artifactValidator:issues_present", + "artifactValidator:fallback_used", + "browserVerification:failure", + "browserVerification:unknown", + "guiSmoke:failed", +]); +const RECOVERED_VERIFICATION_OUTCOMES = new Set([ + "artifactValidator:repaired", + "browserVerification:success", + "guiSmoke:passed", + "guiSmoke:clean", +]); function parseArgs(argv) { const result = { @@ -170,12 +184,113 @@ function normalizeNumber(value) { return typeof value === "number" && Number.isFinite(value) ? value : 0; } +function listAllCases(summary) { + const suites = Array.isArray(summary?.suites) ? summary.suites : []; + return suites.flatMap((suite) => (Array.isArray(suite?.cases) ? suite.cases : [])); +} + +function getBreakdownEntries(summary, key) { + return Array.isArray(summary?.breakdowns?.[key]) ? summary.breakdowns[key] : []; +} + +function getBreakdownCaseCount(summary, key, namesSet) { + return getBreakdownEntries(summary, key).reduce((total, entry) => { + const name = String(entry?.name ?? ""); + if (!namesSet.has(name)) { + return total; + } + return total + normalizeNumber(entry?.caseCount); + }, 0); +} + +function buildObservabilityGapTotals(summary) { + const rawTotals = + summary?.totals && typeof summary.totals === "object" ? summary.totals : {}; + let total = normalizeNumber(rawTotals.observabilityGapCaseCount); + let current = normalizeNumber(rawTotals.currentObservabilityGapCaseCount); + let degraded = normalizeNumber(rawTotals.degradedObservabilityGapCaseCount); + const allCases = listAllCases(summary); + + if (allCases.length === 0) { + if (current === 0 && degraded === 0 && total > 0) { + current = total; + } + return { + total: total || current + degraded, + current, + degraded, + }; + } + + let derivedCurrent = 0; + let derivedDegraded = 0; + for (const entry of allCases) { + if (normalizeNumber(entry?.observabilityGapCount) <= 0) { + continue; + } + const tags = Array.isArray(entry?.tags) ? entry.tags : []; + if (tags.includes(OBSERVABILITY_GAP_SUITE_TAG)) { + derivedDegraded += 1; + } else { + derivedCurrent += 1; + } + } + + const derivedTotal = derivedCurrent + derivedDegraded; + if (derivedTotal === 0) { + return { + total: total || current + degraded, + current, + degraded, + }; + } + + return { + total: derivedTotal, + current: derivedCurrent, + degraded: derivedDegraded, + }; +} + +function buildNormalizedTotals(summary) { + const rawTotals = + summary?.totals && typeof summary.totals === "object" ? summary.totals : {}; + const gapTotals = buildObservabilityGapTotals(summary); + const currentVerificationOutcomeEntries = getBreakdownEntries( + summary, + "currentObservabilityVerificationOutcomes", + ); + return { + suiteCount: normalizeNumber(rawTotals.suiteCount), + caseCount: normalizeNumber(rawTotals.caseCount), + readyCount: normalizeNumber(rawTotals.readyCount), + invalidCount: normalizeNumber(rawTotals.invalidCount), + pendingRequestCaseCount: normalizeNumber(rawTotals.pendingRequestCaseCount), + needsHumanReviewCount: normalizeNumber(rawTotals.needsHumanReviewCount), + reviewDecisionRecordedCount: normalizeNumber( + rawTotals.reviewDecisionRecordedCount, + ), + observabilityGapCaseCount: gapTotals.total, + currentObservabilityGapCaseCount: gapTotals.current, + degradedObservabilityGapCaseCount: gapTotals.degraded, + currentRecoveredVerificationCaseCount: + currentVerificationOutcomeEntries.length > 0 + ? getBreakdownCaseCount( + summary, + "currentObservabilityVerificationOutcomes", + RECOVERED_VERIFICATION_OUTCOMES, + ) + : normalizeNumber(rawTotals.currentRecoveredVerificationCaseCount), + }; +} + function computeReadyRate(summary) { - const caseCount = normalizeNumber(summary?.totals?.caseCount); + const totals = buildNormalizedTotals(summary); + const caseCount = totals.caseCount; if (caseCount <= 0) { return 0; } - return normalizeNumber(summary?.totals?.readyCount) / caseCount; + return totals.readyCount / caseCount; } function getSuiteMap(summary) { @@ -195,9 +310,7 @@ function getSuiteMap(summary) { } function getBreakdownMap(summary, key) { - const entries = Array.isArray(summary?.breakdowns?.[key]) - ? summary.breakdowns[key] - : []; + const entries = getBreakdownEntries(summary, key); return new Map( entries.map((entry) => [ String(entry.name ?? ""), @@ -309,6 +422,12 @@ function buildBreakdownDeltas(baseline, latest, key) { }); } +function buildFilteredBreakdownDeltas(baseline, latest, key, predicate) { + return buildBreakdownDeltas(baseline, latest, key).filter((entry) => + predicate(entry), + ); +} + function buildStatusSignals(baseline, latest, sampleCount) { const signals = []; @@ -318,15 +437,17 @@ function buildStatusSignals(baseline, latest, sampleCount) { } const readyRateDelta = computeReadyRate(latest) - computeReadyRate(baseline); - const invalidDelta = - normalizeNumber(latest?.totals?.invalidCount) - - normalizeNumber(baseline?.totals?.invalidCount); + const baselineTotals = buildNormalizedTotals(baseline); + const latestTotals = buildNormalizedTotals(latest); + const invalidDelta = latestTotals.invalidCount - baselineTotals.invalidCount; const pendingDelta = - normalizeNumber(latest?.totals?.pendingRequestCaseCount) - - normalizeNumber(baseline?.totals?.pendingRequestCaseCount); - const observabilityGapDelta = - normalizeNumber(latest?.totals?.observabilityGapCaseCount) - - normalizeNumber(baseline?.totals?.observabilityGapCaseCount); + latestTotals.pendingRequestCaseCount - baselineTotals.pendingRequestCaseCount; + const currentObservabilityGapDelta = + latestTotals.currentObservabilityGapCaseCount - + baselineTotals.currentObservabilityGapCaseCount; + const currentRecoveredVerificationDelta = + latestTotals.currentRecoveredVerificationCaseCount - + baselineTotals.currentRecoveredVerificationCaseCount; if (invalidDelta > 0) { signals.push(`invalid case 增加 ${invalidDelta},存在回归候选。`); @@ -344,9 +465,15 @@ function buildStatusSignals(baseline, latest, sampleCount) { ); } - if (observabilityGapDelta > 0) { + if (currentObservabilityGapDelta > 0) { signals.push( - `observability gap case 增加 ${observabilityGapDelta},需先补 evidence / analysis / replay 的证据覆盖。`, + `current observability gap case 增加 ${currentObservabilityGapDelta},说明主线样本开始带缺口,需先补 evidence / analysis / replay 的证据覆盖。`, + ); + } + + if (currentRecoveredVerificationDelta > 0) { + signals.push( + `current recovered verification case 增加 ${currentRecoveredVerificationDelta},说明主线路径正在累积正向守卫。`, ); } @@ -364,6 +491,43 @@ function buildStatusSignals(baseline, latest, sampleCount) { ); } + const verificationOutcomeDeltas = buildBreakdownDeltas( + baseline, + latest, + "observabilityVerificationOutcomes", + ); + const increasedVerificationFailure = verificationOutcomeDeltas.find( + (entry) => + OBSERVABILITY_FAILURE_OUTCOMES.has(entry.name) && + entry.delta.caseCount > 0, + ); + if (increasedVerificationFailure) { + signals.push( + `verification outcome \`${increasedVerificationFailure.name}\` 新增 ${increasedVerificationFailure.delta.caseCount} 个 case。`, + ); + } + + const currentRecoveredVerificationDeltas = buildFilteredBreakdownDeltas( + baseline, + latest, + "currentObservabilityVerificationOutcomes", + (entry) => RECOVERED_VERIFICATION_OUTCOMES.has(entry.name), + ); + for (const entry of currentRecoveredVerificationDeltas.filter( + (candidate) => candidate.delta.caseCount < 0, + )) { + signals.push( + `current recovered verification baseline \`${entry.name}\` 减少 ${Math.abs(entry.delta.caseCount)},说明正向守卫可能回退。`, + ); + } + for (const entry of currentRecoveredVerificationDeltas.filter( + (candidate) => candidate.delta.caseCount > 0, + )) { + signals.push( + `current recovered verification baseline \`${entry.name}\` 新增 ${entry.delta.caseCount},说明主线路径正在形成正向基线。`, + ); + } + if (signals.length === 0) { signals.push("当前没有检测到明显退化信号。"); } @@ -389,6 +553,8 @@ function buildTrendReport(samples, repoRoot) { const latestEntry = sortedSamples[sortedSamples.length - 1]; const baseline = baselineEntry.summary; const latest = latestEntry.summary; + const baselineTotals = buildNormalizedTotals(baseline); + const latestTotals = buildNormalizedTotals(latest); const readyRateDelta = computeReadyRate(latest) - computeReadyRate(baseline); return { @@ -399,45 +565,46 @@ function buildTrendReport(samples, repoRoot) { baseline: { generatedAt: baseline.generatedAt, sourcePath: baselineEntry.sourcePath, - totals: baseline.totals, + totals: baselineTotals, }, latest: { generatedAt: latest.generatedAt, sourcePath: latestEntry.sourcePath, - totals: latest.totals, + totals: latestTotals, }, delta: { - suiteCount: - normalizeNumber(latest?.totals?.suiteCount) - - normalizeNumber(baseline?.totals?.suiteCount), - caseCount: - normalizeNumber(latest?.totals?.caseCount) - - normalizeNumber(baseline?.totals?.caseCount), - readyCount: - normalizeNumber(latest?.totals?.readyCount) - - normalizeNumber(baseline?.totals?.readyCount), - invalidCount: - normalizeNumber(latest?.totals?.invalidCount) - - normalizeNumber(baseline?.totals?.invalidCount), + suiteCount: latestTotals.suiteCount - baselineTotals.suiteCount, + caseCount: latestTotals.caseCount - baselineTotals.caseCount, + readyCount: latestTotals.readyCount - baselineTotals.readyCount, + invalidCount: latestTotals.invalidCount - baselineTotals.invalidCount, pendingRequestCaseCount: - normalizeNumber(latest?.totals?.pendingRequestCaseCount) - - normalizeNumber(baseline?.totals?.pendingRequestCaseCount), + latestTotals.pendingRequestCaseCount - + baselineTotals.pendingRequestCaseCount, needsHumanReviewCount: - normalizeNumber(latest?.totals?.needsHumanReviewCount) - - normalizeNumber(baseline?.totals?.needsHumanReviewCount), + latestTotals.needsHumanReviewCount - + baselineTotals.needsHumanReviewCount, reviewDecisionRecordedCount: - normalizeNumber(latest?.totals?.reviewDecisionRecordedCount) - - normalizeNumber(baseline?.totals?.reviewDecisionRecordedCount), + latestTotals.reviewDecisionRecordedCount - + baselineTotals.reviewDecisionRecordedCount, observabilityGapCaseCount: - normalizeNumber(latest?.totals?.observabilityGapCaseCount) - - normalizeNumber(baseline?.totals?.observabilityGapCaseCount), + latestTotals.observabilityGapCaseCount - + baselineTotals.observabilityGapCaseCount, + currentObservabilityGapCaseCount: + latestTotals.currentObservabilityGapCaseCount - + baselineTotals.currentObservabilityGapCaseCount, + degradedObservabilityGapCaseCount: + latestTotals.degradedObservabilityGapCaseCount - + baselineTotals.degradedObservabilityGapCaseCount, + currentRecoveredVerificationCaseCount: + latestTotals.currentRecoveredVerificationCaseCount - + baselineTotals.currentRecoveredVerificationCaseCount, readyRate: readyRateDelta, }, signals: buildStatusSignals(baseline, latest, sortedSamples.length), samples: sortedSamples.map((entry) => ({ generatedAt: entry.summary.generatedAt, sourcePath: entry.sourcePath, - totals: entry.summary.totals, + totals: buildNormalizedTotals(entry.summary), })), suiteDeltas: buildSuiteDeltas(baseline, latest), classificationDeltas: { @@ -458,6 +625,28 @@ function buildTrendReport(samples, repoRoot) { latest, "observabilitySignals", ), + observabilityVerificationOutcomes: buildBreakdownDeltas( + baseline, + latest, + "observabilityVerificationOutcomes", + ), + currentRecoveredObservabilityVerificationOutcomes: + buildFilteredBreakdownDeltas( + baseline, + latest, + "currentObservabilityVerificationOutcomes", + (entry) => RECOVERED_VERIFICATION_OUTCOMES.has(entry.name), + ), + currentObservabilityVerificationOutcomes: buildBreakdownDeltas( + baseline, + latest, + "currentObservabilityVerificationOutcomes", + ), + degradedObservabilityVerificationOutcomes: buildBreakdownDeltas( + baseline, + latest, + "degradedObservabilityVerificationOutcomes", + ), }, }; } @@ -473,6 +662,12 @@ function renderText(report) { `[harness-eval-trend] delta pendingRequestCaseCount: ${report.delta.pendingRequestCaseCount}`, `[harness-eval-trend] delta reviewDecisionRecordedCount: ${report.delta.reviewDecisionRecordedCount}`, `[harness-eval-trend] delta observabilityGapCaseCount: ${report.delta.observabilityGapCaseCount}`, + `[harness-eval-trend] delta currentObservabilityGapCaseCount: ${report.delta.currentObservabilityGapCaseCount}`, + `[harness-eval-trend] delta degradedObservabilityGapCaseCount: ${report.delta.degradedObservabilityGapCaseCount}`, + `[harness-eval-trend] delta currentRecoveredVerificationCaseCount: ${report.delta.currentRecoveredVerificationCaseCount}`, + `[harness-eval-trend] latest currentObservabilityGapCaseCount: ${report.latest.totals.currentObservabilityGapCaseCount}`, + `[harness-eval-trend] latest degradedObservabilityGapCaseCount: ${report.latest.totals.degradedObservabilityGapCaseCount}`, + `[harness-eval-trend] latest currentRecoveredVerificationCaseCount: ${report.latest.totals.currentRecoveredVerificationCaseCount}`, `[harness-eval-trend] delta readyRate: ${(report.delta.readyRate * 100).toFixed(1)}%`, ]; @@ -515,6 +710,42 @@ function renderText(report) { } } + const topVerificationOutcomeDeltas = + report.classificationDeltas.observabilityVerificationOutcomes.slice(0, 5); + if (topVerificationOutcomeDeltas.length > 0) { + lines.push("[harness-eval-trend] observability verification outcome deltas:"); + for (const entry of topVerificationOutcomeDeltas) { + lines.push( + ` - ${entry.name}: delta_case=${entry.delta.caseCount}, latest_case=${entry.latest.caseCount}, latest_invalid=${entry.latest.invalidCount}`, + ); + } + } + + const topCurrentRecoveredVerificationDeltas = + report.classificationDeltas.currentRecoveredObservabilityVerificationOutcomes.slice( + 0, + 5, + ); + if (topCurrentRecoveredVerificationDeltas.length > 0) { + lines.push("[harness-eval-trend] current recovered verification baseline deltas:"); + for (const entry of topCurrentRecoveredVerificationDeltas) { + lines.push( + ` - ${entry.name}: baseline_case=${entry.baseline.caseCount}, latest_case=${entry.latest.caseCount}, delta_case=${entry.delta.caseCount}`, + ); + } + } + + lines.push("[harness-eval-trend] observability gap roles:"); + lines.push( + ` - total: baseline=${report.baseline.totals.observabilityGapCaseCount}, latest=${report.latest.totals.observabilityGapCaseCount}, delta=${report.delta.observabilityGapCaseCount}`, + ); + lines.push( + ` - current: baseline=${report.baseline.totals.currentObservabilityGapCaseCount}, latest=${report.latest.totals.currentObservabilityGapCaseCount}, delta=${report.delta.currentObservabilityGapCaseCount}`, + ); + lines.push( + ` - degraded: baseline=${report.baseline.totals.degradedObservabilityGapCaseCount}, latest=${report.latest.totals.degradedObservabilityGapCaseCount}, delta=${report.delta.degradedObservabilityGapCaseCount}`, + ); + return `${lines.join("\n")}\n`; } @@ -537,6 +768,9 @@ function renderMarkdown(report) { `- needs review case 变化:${report.delta.needsHumanReviewCount}`, `- 已记录人工审核变化:${report.delta.reviewDecisionRecordedCount}`, `- observability gap case 变化:${report.delta.observabilityGapCaseCount}`, + `- current observability gap case 变化:${report.delta.currentObservabilityGapCaseCount}`, + `- degraded observability gap case 变化:${report.delta.degradedObservabilityGapCaseCount}`, + `- current recovered verification case 变化:${report.delta.currentRecoveredVerificationCaseCount}`, `- ready rate 变化:${(report.delta.readyRate * 100).toFixed(1)}%`, "", "## 信号", @@ -616,14 +850,60 @@ function renderMarkdown(report) { } } + if (report.classificationDeltas.observabilityVerificationOutcomes.length > 0) { + lines.push(""); + lines.push("## Observability Verification Outcome 变化"); + lines.push(""); + lines.push("| Outcome | baseline case | latest case | delta case | delta invalid |"); + lines.push("| --- | --- | --- | --- | --- |"); + for (const entry of report.classificationDeltas.observabilityVerificationOutcomes) { + lines.push( + `| ${entry.name} | ${entry.baseline.caseCount} | ${entry.latest.caseCount} | ${entry.delta.caseCount} | ${entry.delta.invalidCount} |`, + ); + } + } + + if ( + report.classificationDeltas.currentRecoveredObservabilityVerificationOutcomes + .length > 0 + ) { + lines.push(""); + lines.push("## Current Recovered Baseline 变化"); + lines.push(""); + lines.push("| Outcome | baseline case | latest case | delta case |"); + lines.push("| --- | --- | --- | --- |"); + for (const entry of report.classificationDeltas.currentRecoveredObservabilityVerificationOutcomes) { + lines.push( + `| ${entry.name} | ${entry.baseline.caseCount} | ${entry.latest.caseCount} | ${entry.delta.caseCount} |`, + ); + } + } + + lines.push(""); + lines.push("## Observability Gap 角色变化"); + lines.push(""); + lines.push("| 角色 | baseline case | latest case | delta case |"); + lines.push("| --- | --- | --- | --- |"); + lines.push( + `| total | ${report.baseline.totals.observabilityGapCaseCount} | ${report.latest.totals.observabilityGapCaseCount} | ${report.delta.observabilityGapCaseCount} |`, + ); + lines.push( + `| current | ${report.baseline.totals.currentObservabilityGapCaseCount} | ${report.latest.totals.currentObservabilityGapCaseCount} | ${report.delta.currentObservabilityGapCaseCount} |`, + ); + lines.push( + `| degraded | ${report.baseline.totals.degradedObservabilityGapCaseCount} | ${report.latest.totals.degradedObservabilityGapCaseCount} | ${report.delta.degradedObservabilityGapCaseCount} |`, + ); + lines.push(""); lines.push("## 时间线样本"); lines.push(""); - lines.push("| 时间 | 来源 | case | ready | invalid | pending_request |"); - lines.push("| --- | --- | --- | --- | --- | --- |"); + lines.push( + "| 时间 | 来源 | case | ready | invalid | pending_request | current_gap | degraded_gap |", + ); + lines.push("| --- | --- | --- | --- | --- | --- | --- | --- |"); for (const sample of report.samples) { lines.push( - `| ${sample.generatedAt} | \`${sample.sourcePath}\` | ${sample.totals.caseCount} | ${sample.totals.readyCount} | ${sample.totals.invalidCount} | ${sample.totals.pendingRequestCaseCount} |`, + `| ${sample.generatedAt} | \`${sample.sourcePath}\` | ${sample.totals.caseCount} | ${sample.totals.readyCount} | ${sample.totals.invalidCount} | ${sample.totals.pendingRequestCaseCount} | ${sample.totals.currentObservabilityGapCaseCount} | ${sample.totals.degradedObservabilityGapCaseCount} |`, ); } diff --git a/scripts/lib/generated-slop-report-core.mjs b/scripts/lib/generated-slop-report-core.mjs index 9912ae1d5..6eef81079 100644 --- a/scripts/lib/generated-slop-report-core.mjs +++ b/scripts/lib/generated-slop-report-core.mjs @@ -371,6 +371,33 @@ function buildTrendSummary(trendReport) { const baselineTotals = isObject(trendReport?.baseline?.totals) ? trendReport.baseline.totals : {}; + const latestObservabilityGapCaseCount = normalizeNumber( + latestTotals.observabilityGapCaseCount, + ); + const baselineObservabilityGapCaseCount = normalizeNumber( + baselineTotals.observabilityGapCaseCount, + ); + const latestDegradedObservabilityGapCaseCount = normalizeNumber( + latestTotals.degradedObservabilityGapCaseCount, + ); + const baselineDegradedObservabilityGapCaseCount = normalizeNumber( + baselineTotals.degradedObservabilityGapCaseCount, + ); + const latestCurrentObservabilityGapCaseCount = + latestTotals.currentObservabilityGapCaseCount != null + ? normalizeNumber(latestTotals.currentObservabilityGapCaseCount) + : Math.max( + 0, + latestObservabilityGapCaseCount - latestDegradedObservabilityGapCaseCount, + ); + const baselineCurrentObservabilityGapCaseCount = + baselineTotals.currentObservabilityGapCaseCount != null + ? normalizeNumber(baselineTotals.currentObservabilityGapCaseCount) + : Math.max( + 0, + baselineObservabilityGapCaseCount - + baselineDegradedObservabilityGapCaseCount, + ); const latestNeedsReviewCount = normalizeNumber( latestTotals.needsHumanReviewCount, ); @@ -395,9 +422,19 @@ function buildTrendSummary(trendReport) { latestNeedsReviewCount - latestReviewDecisionRecordedCount, ), observabilityGapCaseDelta: normalizeNumber(delta.observabilityGapCaseCount), - latestObservabilityGapCaseCount: normalizeNumber( - latestTotals.observabilityGapCaseCount, - ), + latestObservabilityGapCaseCount, + currentObservabilityGapCaseDelta: + delta.currentObservabilityGapCaseCount != null + ? normalizeNumber(delta.currentObservabilityGapCaseCount) + : latestCurrentObservabilityGapCaseCount - + baselineCurrentObservabilityGapCaseCount, + latestCurrentObservabilityGapCaseCount, + degradedObservabilityGapCaseDelta: + delta.degradedObservabilityGapCaseCount != null + ? normalizeNumber(delta.degradedObservabilityGapCaseCount) + : latestDegradedObservabilityGapCaseCount - + baselineDegradedObservabilityGapCaseCount, + latestDegradedObservabilityGapCaseCount, readyRateDelta: normalizeNumber(delta.readyRate), signals: Array.isArray(trendReport?.signals) ? trendReport.signals : [], }; @@ -507,6 +544,277 @@ function buildObservabilityFocusEntries(entries, sampleCount) { }); } +const OBSERVABILITY_VERIFICATION_FAILURE_OUTCOMES = new Set([ + "issues_present", + "fallback_used", + "failure", + "unknown", + "failed", +]); + +const OBSERVABILITY_VERIFICATION_RECOVERED_OUTCOMES = new Set([ + "repaired", + "success", + "passed", + "clean", +]); + +function isObservabilityVerificationFailureOutcome(outcome) { + return OBSERVABILITY_VERIFICATION_FAILURE_OUTCOMES.has( + normalizeString(outcome), + ); +} + +function isObservabilityVerificationRecoveredOutcome(outcome) { + return OBSERVABILITY_VERIFICATION_RECOVERED_OUTCOMES.has( + normalizeString(outcome), + ); +} + +const BLOCKING_VERIFICATION_FAILURES = new Set([ + "browserVerification:failure", + "guiSmoke:failed", +]); + +function getObservabilityVerificationOutcomeRole(signal, outcome) { + const normalizedSignal = normalizeString(signal); + const normalizedOutcome = normalizeString(outcome); + const fingerprint = `${normalizedSignal}:${normalizedOutcome}`; + + if (BLOCKING_VERIFICATION_FAILURES.has(fingerprint)) { + return "blocking_failure"; + } + + if (isObservabilityVerificationFailureOutcome(normalizedOutcome)) { + return "advisory_failure"; + } + + if (isObservabilityVerificationRecoveredOutcome(normalizedOutcome)) { + return "recovered"; + } + + return "other"; +} + +function getObservabilityVerificationOutcomeWeight(outcome) { + switch (normalizeString(outcome)) { + case "failed": + return 140; + case "failure": + return 130; + case "unknown": + return 115; + case "fallback_used": + return 110; + case "issues_present": + return 100; + case "repaired": + return 70; + default: + return 0; + } +} + +function buildObservabilityVerificationFocusEntries(entries, sampleCount) { + const normalizedEntries = Array.isArray(entries) ? entries : []; + + return normalizedEntries + .map((entry) => { + const latest = isObject(entry?.latest) ? entry.latest : {}; + const delta = isObject(entry?.delta) ? entry.delta : {}; + const baseline = isObject(entry?.baseline) ? entry.baseline : {}; + const parsed = splitObservabilitySignalName(entry?.name); + const positiveDeltaCase = Math.max(0, normalizeNumber(delta.caseCount)); + const latestCase = normalizeNumber(latest.caseCount); + const weight = getObservabilityVerificationOutcomeWeight(parsed.status); + const score = positiveDeltaCase * 140 + latestCase * weight; + + let state = "stable"; + if (sampleCount < 2 && latestCase > 0 && weight > 0) { + state = "seed-risk"; + } else if (positiveDeltaCase > 0 && weight > 0) { + state = "regressing"; + } else if (latestCase > 0 && weight > 0) { + state = "present"; + } + + return { + name: normalizeString(entry?.name, "(unknown)"), + signal: parsed.signal || "(unknown)", + outcome: parsed.status || "unknown", + baseline: { + caseCount: normalizeNumber(baseline.caseCount), + readyCount: normalizeNumber(baseline.readyCount), + invalidCount: normalizeNumber(baseline.invalidCount), + pendingRequestCaseCount: normalizeNumber( + baseline.pendingRequestCaseCount, + ), + needsHumanReviewCount: normalizeNumber( + baseline.needsHumanReviewCount, + ), + }, + latest: { + caseCount: latestCase, + readyCount: normalizeNumber(latest.readyCount), + invalidCount: normalizeNumber(latest.invalidCount), + pendingRequestCaseCount: normalizeNumber( + latest.pendingRequestCaseCount, + ), + needsHumanReviewCount: normalizeNumber( + latest.needsHumanReviewCount, + ), + }, + delta: { + caseCount: normalizeNumber(delta.caseCount), + readyCount: normalizeNumber(delta.readyCount), + invalidCount: normalizeNumber(delta.invalidCount), + pendingRequestCaseCount: normalizeNumber(delta.pendingRequestCaseCount), + needsHumanReviewCount: normalizeNumber(delta.needsHumanReviewCount), + }, + state, + score, + }; + }) + .filter( + (entry) => + entry.score > 0 || + (entry.latest.caseCount > 0 && + isObservabilityVerificationFailureOutcome(entry.outcome)), + ) + .sort((left, right) => { + if (right.score !== left.score) { + return right.score - left.score; + } + return left.name.localeCompare(right.name); + }); +} + +function buildVerificationOutcomeEntriesFromDeltas(entries) { + const normalizedEntries = Array.isArray(entries) ? entries : []; + + return normalizedEntries + .map((entry) => { + const latest = isObject(entry?.latest) ? entry.latest : {}; + const delta = isObject(entry?.delta) ? entry.delta : {}; + const baseline = isObject(entry?.baseline) ? entry.baseline : {}; + const parsed = splitObservabilitySignalName(entry?.name); + const latestCase = normalizeNumber(latest.caseCount); + const deltaCase = normalizeNumber(delta.caseCount); + + let state = "stable"; + if (deltaCase > 0) { + state = "expanding"; + } else if (deltaCase < 0) { + state = "shrinking"; + } else if (latestCase > 0) { + state = "present"; + } + + return { + name: normalizeString(entry?.name, "(unknown)"), + signal: parsed.signal || "(unknown)", + outcome: parsed.status || "unknown", + baseline: { + caseCount: normalizeNumber(baseline.caseCount), + readyCount: normalizeNumber(baseline.readyCount), + invalidCount: normalizeNumber(baseline.invalidCount), + pendingRequestCaseCount: normalizeNumber( + baseline.pendingRequestCaseCount, + ), + needsHumanReviewCount: normalizeNumber( + baseline.needsHumanReviewCount, + ), + }, + latest: { + caseCount: latestCase, + readyCount: normalizeNumber(latest.readyCount), + invalidCount: normalizeNumber(latest.invalidCount), + pendingRequestCaseCount: normalizeNumber( + latest.pendingRequestCaseCount, + ), + needsHumanReviewCount: normalizeNumber( + latest.needsHumanReviewCount, + ), + }, + delta: { + caseCount: deltaCase, + readyCount: normalizeNumber(delta.readyCount), + invalidCount: normalizeNumber(delta.invalidCount), + pendingRequestCaseCount: normalizeNumber(delta.pendingRequestCaseCount), + needsHumanReviewCount: normalizeNumber(delta.needsHumanReviewCount), + }, + state, + score: latestCase * 10 + Math.abs(deltaCase) * 5, + }; + }) + .filter((entry) => entry.latest.caseCount > 0 || entry.delta.caseCount !== 0) + .sort((left, right) => { + if (right.latest.caseCount !== left.latest.caseCount) { + return right.latest.caseCount - left.latest.caseCount; + } + if (Math.abs(right.delta.caseCount) !== Math.abs(left.delta.caseCount)) { + return Math.abs(right.delta.caseCount) - Math.abs(left.delta.caseCount); + } + return left.name.localeCompare(right.name); + }); +} + +function buildVerificationOutcomeSummary(focusEntries) { + const entries = Array.isArray(focusEntries) ? focusEntries : []; + const blockingFailureEntries = entries.filter( + (entry) => + getObservabilityVerificationOutcomeRole(entry?.signal, entry?.outcome) === + "blocking_failure", + ); + const advisoryFailureEntries = entries.filter( + (entry) => + getObservabilityVerificationOutcomeRole(entry?.signal, entry?.outcome) === + "advisory_failure", + ); + const failureEntries = [...blockingFailureEntries, ...advisoryFailureEntries]; + const recoveredEntries = entries.filter( + (entry) => + getObservabilityVerificationOutcomeRole(entry?.signal, entry?.outcome) === + "recovered", + ); + + return { + focusCount: entries.length, + failureFocusCount: failureEntries.length, + recoveredFocusCount: recoveredEntries.length, + blockingFailureFocusCount: blockingFailureEntries.length, + advisoryFailureFocusCount: advisoryFailureEntries.length, + failureCaseCount: failureEntries.reduce( + (total, entry) => total + normalizeNumber(entry?.latest?.caseCount), + 0, + ), + blockingFailureCaseCount: blockingFailureEntries.reduce( + (total, entry) => total + normalizeNumber(entry?.latest?.caseCount), + 0, + ), + advisoryFailureCaseCount: advisoryFailureEntries.reduce( + (total, entry) => total + normalizeNumber(entry?.latest?.caseCount), + 0, + ), + recoveredCaseCount: recoveredEntries.reduce( + (total, entry) => total + normalizeNumber(entry?.latest?.caseCount), + 0, + ), + topFailureOutcomes: failureEntries + .slice(0, 3) + .map((entry) => `${entry.signal}:${entry.outcome}`), + topBlockingFailureOutcomes: blockingFailureEntries + .slice(0, 3) + .map((entry) => `${entry.signal}:${entry.outcome}`), + topAdvisoryFailureOutcomes: advisoryFailureEntries + .slice(0, 3) + .map((entry) => `${entry.signal}:${entry.outcome}`), + topRecoveredOutcomes: recoveredEntries + .slice(0, 3) + .map((entry) => `${entry.signal}:${entry.outcome}`), + }; +} + function buildDocFreshnessSummary(docFreshnessReport) { const summary = isObject(docFreshnessReport?.summary) ? docFreshnessReport.summary @@ -568,16 +876,324 @@ function maybePushRecommendation(recommendations, action) { if (recommendations.some((entry) => entry.id === action.id)) { return; } - recommendations.push(action); + const normalizedAction = { + ...action, + focusVerificationFailureOutcomes: dedupeNonEmptyStrings( + Array.isArray(action?.focusVerificationFailureOutcomes) + ? action.focusVerificationFailureOutcomes + : [], + ), + focusVerificationRecoveredOutcomes: dedupeNonEmptyStrings( + Array.isArray(action?.focusVerificationRecoveredOutcomes) + ? action.focusVerificationRecoveredOutcomes + : [], + ), + }; + delete normalizedAction.focusObservabilityVerificationOutcomes; + recommendations.push(normalizedAction); +} + +function dedupeNonEmptyStrings(values) { + const normalizedValues = Array.isArray(values) ? values : []; + return [...new Set(normalizedValues.map((value) => normalizeString(value)).filter(Boolean))]; +} + +function assertStringArrayField(value, label, recommendationId) { + if (!Array.isArray(value)) { + throw new Error( + `cleanup recommendation ${recommendationId} 的 ${label} 必须是字符串数组。`, + ); + } + + if ( + value.some( + (entry) => + typeof entry !== "string" || normalizeString(entry).length === 0, + ) + ) { + throw new Error( + `cleanup recommendation ${recommendationId} 的 ${label} 只能包含非空字符串。`, + ); + } +} + +export function assertGeneratedSlopReportContract(report) { + const recommendations = Array.isArray(report?.recommendations) + ? report.recommendations + : []; + + recommendations.forEach((action, index) => { + if (!isObject(action)) { + throw new Error(`cleanup recommendation #${index + 1} 必须是对象。`); + } + + const recommendationId = normalizeString(action?.id, `#${index + 1}`); + + if ( + Object.prototype.hasOwnProperty.call( + action, + "focusObservabilityVerificationOutcomes", + ) + ) { + throw new Error( + `cleanup recommendation ${recommendationId} 不允许继续使用 focusObservabilityVerificationOutcomes;请改用 focusVerificationFailureOutcomes / focusVerificationRecoveredOutcomes。`, + ); + } + + assertStringArrayField( + action?.focusFailureModes, + "focusFailureModes", + recommendationId, + ); + assertStringArrayField( + action?.focusSuiteTags, + "focusSuiteTags", + recommendationId, + ); + assertStringArrayField( + action?.focusReviewDecisionStatuses, + "focusReviewDecisionStatuses", + recommendationId, + ); + assertStringArrayField( + action?.focusReviewRiskLevels, + "focusReviewRiskLevels", + recommendationId, + ); + assertStringArrayField( + action?.focusVerificationFailureOutcomes, + "focusVerificationFailureOutcomes", + recommendationId, + ); + assertStringArrayField( + action?.focusVerificationRecoveredOutcomes, + "focusVerificationRecoveredOutcomes", + recommendationId, + ); + assertStringArrayField( + action?.focusSurfaceIds, + "focusSurfaceIds", + recommendationId, + ); + assertStringArrayField(action?.commands, "commands", recommendationId); + assertStringArrayField( + action?.backlogTools, + "backlogTools", + recommendationId, + ); + assertStringArrayField(action?.rationale, "rationale", recommendationId); + }); + + const verificationFailureFocus = Array.isArray( + report?.focus?.observabilityVerificationOutcomes, + ) + ? report.focus.observabilityVerificationOutcomes + : []; + + if ( + verificationFailureFocus.some( + (entry) => + getObservabilityVerificationOutcomeRole(entry?.signal, entry?.outcome) === + "recovered", + ) + ) { + throw new Error( + "cleanup report 的 focus.observabilityVerificationOutcomes 只允许 failure outcome,不允许 recovered outcome 混入。", + ); + } + + return report; +} + +function hasObservabilityVerificationOutcome(entries, signal, outcome) { + const normalizedEntries = Array.isArray(entries) ? entries : []; + const normalizedSignal = normalizeString(signal); + const normalizedOutcome = normalizeString(outcome); + + return normalizedEntries.some( + (entry) => + normalizeString(entry?.signal) === normalizedSignal && + normalizeString(entry?.outcome) === normalizedOutcome, + ); +} + +function buildCurrentBlockingVerificationFollowUp( + focusCurrentObservabilityVerificationOutcomes, +) { + const hasCurrentGuiSmokeFailure = hasObservabilityVerificationOutcome( + focusCurrentObservabilityVerificationOutcomes, + "guiSmoke", + "failed", + ); + const hasCurrentBrowserVerificationFailure = + hasObservabilityVerificationOutcome( + focusCurrentObservabilityVerificationOutcomes, + "browserVerification", + "failure", + ); + const commands = ["npm run harness:eval", "npm run harness:eval:trend"]; + const backlogTools = []; + const rationale = []; + + if (hasCurrentGuiSmokeFailure) { + commands.push("npm run verify:gui-smoke"); + rationale.push( + "current 样本已出现 guiSmoke:failed,先恢复 GUI 壳 / DevBridge / Workspace 主路径的最小可启动性。", + ); + backlogTools.push( + "优先收敛 GUI 壳 / DevBridge / Workspace 主路径,再复跑 `npm run verify:gui-smoke`。", + ); + } + + if (hasCurrentBrowserVerificationFailure) { + rationale.push( + "current 样本已出现 browserVerification:failure,应先回看 browser replay / verification 失败样本,把失败断言回挂到受影响主路径。", + ); + backlogTools.push( + "回看 browser replay / browser verification 失败样本,并把失败断言回挂到受影响主路径。", + ); + } + + if (backlogTools.length === 0) { + backlogTools.push("按受影响主路径追加 `npm run verify:gui-smoke` 或专项 smoke"); + } + + return { + commands: dedupeNonEmptyStrings(commands), + backlogTools: dedupeNonEmptyStrings(backlogTools), + rationale: dedupeNonEmptyStrings(rationale), + }; +} + +function buildCurrentAdvisoryVerificationFollowUp( + focusCurrentObservabilityVerificationOutcomes, +) { + const hasArtifactValidatorIssuesPresent = hasObservabilityVerificationOutcome( + focusCurrentObservabilityVerificationOutcomes, + "artifactValidator", + "issues_present", + ); + const hasArtifactValidatorFallbackUsed = hasObservabilityVerificationOutcome( + focusCurrentObservabilityVerificationOutcomes, + "artifactValidator", + "fallback_used", + ); + const hasBrowserVerificationUnknown = hasObservabilityVerificationOutcome( + focusCurrentObservabilityVerificationOutcomes, + "browserVerification", + "unknown", + ); + const rationale = []; + const backlogTools = []; + + if (hasArtifactValidatorIssuesPresent) { + rationale.push( + "current 样本已出现 artifactValidator:issues_present,应先回看 validator issue 明细,再收敛 artifact 导出字段。", + ); + backlogTools.push( + "回看 artifact validator issue 明细,并收敛 evidence pack / artifacts.json / analysis handoff 的 artifact 字段。", + ); + } + + if (hasArtifactValidatorFallbackUsed) { + rationale.push( + "current 样本已出现 artifactValidator:fallback_used,说明 artifact 主路径仍不稳定,不能继续依赖 fallback 充当事实。", + ); + backlogTools.push( + "补齐 artifact 主路径导出与修复链,减少 fallback_used 持续留在 current 样本。", + ); + } + + if (hasBrowserVerificationUnknown) { + rationale.push( + "current 样本已出现 browserVerification:unknown,需要先把浏览器验证结果收敛成明确 outcome,再继续扩大分析。", + ); + backlogTools.push( + "回看 browser verification 导出链,确保 evidence pack / replay / analysis handoff 写出明确 success 或 failure,而不是 unknown。", + ); + } + + if (backlogTools.length === 0) { + backlogTools.push( + "先对齐 current verification outcome 到 artifact/browser/gui 主路径,再继续补 observability 证据。", + ); + } + + return { + rationale: dedupeNonEmptyStrings(rationale), + backlogTools: dedupeNonEmptyStrings(backlogTools), + }; +} + +function buildCurrentRecoveredVerificationFollowUp( + focusCurrentRecoveredObservabilityVerificationOutcomes, +) { + const hasArtifactValidatorRepaired = hasObservabilityVerificationOutcome( + focusCurrentRecoveredObservabilityVerificationOutcomes, + "artifactValidator", + "repaired", + ); + const hasBrowserVerificationSuccess = hasObservabilityVerificationOutcome( + focusCurrentRecoveredObservabilityVerificationOutcomes, + "browserVerification", + "success", + ); + const hasGuiSmokePassed = hasObservabilityVerificationOutcome( + focusCurrentRecoveredObservabilityVerificationOutcomes, + "guiSmoke", + "passed", + ); + const commands = ["npm run harness:eval", "npm run harness:eval:trend"]; + const rationale = []; + const backlogTools = []; + + if (hasArtifactValidatorRepaired) { + rationale.push( + "current 样本已出现 artifactValidator:repaired,说明 artifact 修复链已经回到可复用的主路径。", + ); + backlogTools.push( + "在 evidence pack / analysis handoff 里同时保留 artifact issue 与 repaired outcome,避免只剩修复结论而丢失修复上下文。", + ); + } + + if (hasBrowserVerificationSuccess) { + rationale.push( + "current 样本已出现 browserVerification:success,可把浏览器验证成功样本固化成主路径正向基线。", + ); + backlogTools.push( + "把 browser verification 成功样本固定进 current replay 基线,后续 failure 或 unknown 直接对比这条正向路径。", + ); + } + + if (hasGuiSmokePassed) { + commands.push("npm run verify:gui-smoke"); + rationale.push( + "current 样本已出现 guiSmoke:passed,可继续把 GUI smoke 通过链路当成桌面主路径的正向守卫。", + ); + backlogTools.push( + "主路径变更时优先复跑 `npm run verify:gui-smoke`,确认 GUI 壳 / DevBridge / Workspace 不从 passed 回退。", + ); + } + + return { + commands: dedupeNonEmptyStrings(commands), + rationale: dedupeNonEmptyStrings(rationale), + backlogTools: dedupeNonEmptyStrings(backlogTools), + }; } function buildRecommendations({ trendSummary, + verificationOutcomeSummary, focusFailureModes, focusSuiteTags, focusReviewDecisionStatuses, focusReviewRiskLevels, focusObservabilitySignals, + focusVerificationFailureOutcomes, + focusCurrentObservabilityVerificationOutcomes, + focusCurrentRecoveredObservabilityVerificationOutcomes, + focusDegradedObservabilityVerificationOutcomes, docFreshnessSummary, docFreshnessFocus, governanceSummary, @@ -597,10 +1213,45 @@ function buildRecommendations({ const topObservabilitySignals = focusObservabilitySignals .slice(0, 3) .map((entry) => `${entry.signal} (${entry.status})`); + const topVerificationFailureOutcomes = focusVerificationFailureOutcomes + .slice(0, 3) + .map((entry) => `${entry.signal} (${entry.outcome})`); + const topCurrentVerificationFailureOutcomes = + focusCurrentObservabilityVerificationOutcomes + .slice(0, 3) + .map((entry) => `${entry.signal} (${entry.outcome})`); + const topCurrentRecoveredVerificationOutcomes = + focusCurrentRecoveredObservabilityVerificationOutcomes + .slice(0, 3) + .map((entry) => `${entry.signal} (${entry.outcome})`); + const topDegradedVerificationFailureOutcomes = + focusDegradedObservabilityVerificationOutcomes + .slice(0, 3) + .map((entry) => `${entry.signal} (${entry.outcome})`); + const topRecommendedVerificationFailureOutcomes = + topCurrentVerificationFailureOutcomes.length > 0 + ? topCurrentVerificationFailureOutcomes + : topVerificationFailureOutcomes; const topGovernanceSurfaceIds = governanceSurfaces .filter((entry) => entry.active && entry.classification !== "current") .slice(0, 3) .map((entry) => entry.id); + const currentVerificationSummary = + verificationOutcomeSummary?.current ?? buildVerificationOutcomeSummary([]); + const degradedVerificationSummary = + verificationOutcomeSummary?.degraded ?? buildVerificationOutcomeSummary([]); + const currentBlockingVerificationFollowUp = + buildCurrentBlockingVerificationFollowUp( + focusCurrentObservabilityVerificationOutcomes, + ); + const currentAdvisoryVerificationFollowUp = + buildCurrentAdvisoryVerificationFollowUp( + focusCurrentObservabilityVerificationOutcomes, + ); + const currentRecoveredVerificationFollowUp = + buildCurrentRecoveredVerificationFollowUp( + focusCurrentRecoveredObservabilityVerificationOutcomes, + ); if (governanceSummary.violationCount > 0) { maybePushRecommendation(recommendations, { @@ -617,6 +1268,8 @@ function buildRecommendations({ focusSuiteTags: topSuiteTags, focusReviewDecisionStatuses: topReviewDecisionStatuses, focusReviewRiskLevels: topReviewRiskLevels, + focusVerificationFailureOutcomes: + topRecommendedVerificationFailureOutcomes, focusSurfaceIds: topGovernanceSurfaceIds, }); } @@ -639,6 +1292,8 @@ function buildRecommendations({ focusSuiteTags: topSuiteTags, focusReviewDecisionStatuses: topReviewDecisionStatuses, focusReviewRiskLevels: topReviewRiskLevels, + focusVerificationFailureOutcomes: + topRecommendedVerificationFailureOutcomes, focusSurfaceIds: [], }); } @@ -647,23 +1302,37 @@ function buildRecommendations({ trendSummary.invalidDelta > 0 || trendSummary.pendingDelta > 0 || trendSummary.needsReviewDelta > 0 || + currentVerificationSummary.blockingFailureCaseCount > 0 || focusFailureModes.some((entry) => entry.state !== "stable"); if (hasTrendPressure) { maybePushRecommendation(recommendations, { id: "replay-and-smoke-follow-up", - priority: "P1", + priority: + currentVerificationSummary.blockingFailureCaseCount > 0 ? "P0" : "P1", title: "把高风险 failure mode 回挂到 replay / smoke 验证", rationale: [ `当前 failure mode 焦点:${topFailureModes.join("、") || "暂无"}。`, "先用 replay / eval 固化失败,再按受影响主路径补最小 smoke,而不是直接凭印象清理。", + topCurrentVerificationFailureOutcomes.length > 0 + ? `当前 current verification failure outcome 焦点:${topCurrentVerificationFailureOutcomes.join("、")}。` + : "当前没有额外的 verification failure outcome 焦点。", + ...currentBlockingVerificationFollowUp.rationale, + currentVerificationSummary.blockingFailureCaseCount > 0 + ? `其中 current blocking verification failure 共 ${currentVerificationSummary.blockingFailureCaseCount} 个 case:${currentVerificationSummary.topBlockingFailureOutcomes.join("、") || "暂无"}。` + : "当前没有额外的 blocking verification failure。", + degradedVerificationSummary.blockingFailureCaseCount > 0 + ? `另有 ${degradedVerificationSummary.blockingFailureCaseCount} 个 degraded blocking verification failure 样本作为诊断基线,不直接抬高主线优先级。` + : "当前没有额外的 degraded blocking verification baseline。", ], - commands: ["npm run harness:eval", "npm run harness:eval:trend"], - backlogTools: ["按受影响主路径追加 `npm run verify:gui-smoke` 或专项 smoke"], + commands: currentBlockingVerificationFollowUp.commands, + backlogTools: currentBlockingVerificationFollowUp.backlogTools, focusFailureModes: topFailureModes, focusSuiteTags: topSuiteTags, focusReviewDecisionStatuses: topReviewDecisionStatuses, focusReviewRiskLevels: topReviewRiskLevels, + focusVerificationFailureOutcomes: + topRecommendedVerificationFailureOutcomes, focusSurfaceIds: [], }); } @@ -714,16 +1383,16 @@ function buildRecommendations({ focusSuiteTags: topSuiteTags, focusReviewDecisionStatuses: topReviewDecisionStatuses, focusReviewRiskLevels: topReviewRiskLevels, + focusVerificationFailureOutcomes: + topRecommendedVerificationFailureOutcomes, focusSurfaceIds: [], }); } const hasObservabilityPressure = - trendSummary.latestObservabilityGapCaseCount > 0 || - trendSummary.observabilityGapCaseDelta > 0 || - focusObservabilitySignals.some( - (entry) => entry.latest.caseCount > 0 || entry.delta.caseCount > 0, - ); + trendSummary.latestCurrentObservabilityGapCaseCount > 0 || + trendSummary.currentObservabilityGapCaseDelta > 0 || + currentVerificationSummary.advisoryFailureCaseCount > 0; if (hasObservabilityPressure) { maybePushRecommendation(recommendations, { @@ -732,16 +1401,31 @@ function buildRecommendations({ topObservabilitySignals.some( (entry) => entry.includes("observabilitySummary (missing)") || - entry.includes("requestTelemetry (unlinked)"), - ) || trendSummary.observabilityGapCaseDelta > 0 + entry.includes("requestTelemetry (known_gap)"), + ) || + trendSummary.currentObservabilityGapCaseDelta > 0 || + currentVerificationSummary.advisoryFailureCaseCount > 0 ? "P1" : "P2", title: "先补 observability 证据覆盖,再扩大外部分析与回归", rationale: [ - trendSummary.latestObservabilityGapCaseCount > 0 - ? `当前仍有 ${trendSummary.latestObservabilityGapCaseCount} 个 case 带着 observability 证据缺口进入 replay/eval。` + trendSummary.latestCurrentObservabilityGapCaseCount > 0 + ? `当前仍有 ${trendSummary.latestCurrentObservabilityGapCaseCount} 个 current case 带着 observability 证据缺口进入 replay/eval。` : "当前 trend 已检测到 observability coverage 漂移,需先修证据而不是空谈根因分析。", + trendSummary.latestDegradedObservabilityGapCaseCount > 0 + ? `另有 ${trendSummary.latestDegradedObservabilityGapCaseCount} 个 degraded gap 样本作为诊断基线保留,它们不应直接被当成主线回归。` + : "当前没有额外保留的 degraded observability gap 样本。", `当前缺口焦点:${topObservabilitySignals.join("、") || "暂无"}。这些缺口会直接降低 analysis handoff、人工审核和 cleanup report 的判断质量。`, + topCurrentVerificationFailureOutcomes.length > 0 + ? `当前 current verification failure outcome 焦点:${topCurrentVerificationFailureOutcomes.join("、")}。可用它们直接定位先补 artifact/browser/gui 哪一层。` + : "当前没有额外的 verification failure outcome 焦点。", + ...currentAdvisoryVerificationFollowUp.rationale, + currentVerificationSummary.advisoryFailureCaseCount > 0 + ? `当前 current advisory verification failure 共 ${currentVerificationSummary.advisoryFailureCaseCount} 个 case:${currentVerificationSummary.topAdvisoryFailureOutcomes.join("、") || "暂无"}。` + : "当前没有额外的 advisory verification failure。", + topDegradedVerificationFailureOutcomes.length > 0 + ? `当前保留的 degraded verification baseline:${topDegradedVerificationFailureOutcomes.join("、")}。` + : "当前没有额外的 degraded verification baseline。", ], commands: [ "npm run harness:eval", @@ -750,11 +1434,19 @@ function buildRecommendations({ ], backlogTools: [ "优先补 request telemetry 关联键、artifact validator outcome、browser/gui smoke 结果到 evidence pack / analysis handoff / replay。", + ...(topCurrentVerificationFailureOutcomes.length > 0 + ? [ + `先对齐 current verification failure outcome:${topCurrentVerificationFailureOutcomes.join("、")}。`, + ] + : []), + ...currentAdvisoryVerificationFollowUp.backlogTools, ], focusFailureModes: topFailureModes, focusSuiteTags: topSuiteTags, focusReviewDecisionStatuses: topReviewDecisionStatuses, focusReviewRiskLevels: topReviewRiskLevels, + focusVerificationFailureOutcomes: + topRecommendedVerificationFailureOutcomes, focusSurfaceIds: [], }); } @@ -785,10 +1477,42 @@ function buildRecommendations({ focusSuiteTags: topSuiteTags, focusReviewDecisionStatuses: topReviewDecisionStatuses, focusReviewRiskLevels: topReviewRiskLevels, + focusVerificationFailureOutcomes: + topRecommendedVerificationFailureOutcomes, focusSurfaceIds: topGovernanceSurfaceIds, }); } + if (currentVerificationSummary.recoveredCaseCount > 0) { + maybePushRecommendation(recommendations, { + id: "recovered-baseline-follow-up", + priority: + currentVerificationSummary.blockingFailureCaseCount === 0 && + currentVerificationSummary.advisoryFailureCaseCount === 0 + ? "P2" + : "P3", + title: "把 recovered verification outcome 固化成 current 正向基线", + rationale: [ + topCurrentRecoveredVerificationOutcomes.length > 0 + ? `当前 current recovered outcome 焦点:${topCurrentRecoveredVerificationOutcomes.join("、")}。` + : `当前 current recovered outcome 共 ${currentVerificationSummary.recoveredCaseCount} 个 case。`, + ...currentRecoveredVerificationFollowUp.rationale, + "恢复成功的 outcome 不应只停留在统计卡里,还应继续回挂到 replay / smoke / evidence 主链,作为后续回退判断的正向对照。", + ], + commands: currentRecoveredVerificationFollowUp.commands, + backlogTools: currentRecoveredVerificationFollowUp.backlogTools, + focusFailureModes: topFailureModes, + focusSuiteTags: topSuiteTags, + focusReviewDecisionStatuses: topReviewDecisionStatuses, + focusReviewRiskLevels: topReviewRiskLevels, + focusVerificationRecoveredOutcomes: + topCurrentRecoveredVerificationOutcomes.length > 0 + ? topCurrentRecoveredVerificationOutcomes + : [], + focusSurfaceIds: [], + }); + } + const shouldReviewDocs = docFreshnessSummary.issueCount > 0 || trendSummary.isSeed || @@ -817,6 +1541,8 @@ function buildRecommendations({ focusSuiteTags: topSuiteTags, focusReviewDecisionStatuses: topReviewDecisionStatuses, focusReviewRiskLevels: topReviewRiskLevels, + focusVerificationFailureOutcomes: + topRecommendedVerificationFailureOutcomes, focusSurfaceIds: topDocPaths.length > 0 ? topDocPaths : topGovernanceSurfaceIds, }); @@ -864,6 +1590,117 @@ export function buildGeneratedSlopReport({ trendReport?.classificationDeltas?.observabilitySignals, trendSummary.sampleCount, ); + const rawObservabilityVerificationOutcomes = + buildObservabilityVerificationFocusEntries( + trendReport?.classificationDeltas?.observabilityVerificationOutcomes, + trendSummary.sampleCount, + ); + const explicitRecoveredObservabilityVerificationOutcomes = + buildVerificationOutcomeEntriesFromDeltas( + trendReport?.classificationDeltas?.observabilityVerificationOutcomes, + ).filter( + (entry) => + getObservabilityVerificationOutcomeRole(entry?.signal, entry?.outcome) === + "recovered", + ); + const focusVerificationFailureOutcomes = + rawObservabilityVerificationOutcomes.filter( + (entry) => + getObservabilityVerificationOutcomeRole(entry?.signal, entry?.outcome) !== + "recovered", + ); + const rawCurrentObservabilityVerificationOutcomes = + buildObservabilityVerificationFocusEntries( + trendReport?.classificationDeltas?.currentObservabilityVerificationOutcomes, + trendSummary.sampleCount, + ); + const explicitCurrentRecoveredObservabilityVerificationOutcomes = + buildVerificationOutcomeEntriesFromDeltas( + trendReport?.classificationDeltas?.currentRecoveredObservabilityVerificationOutcomes, + ); + const focusCurrentObservabilityVerificationOutcomes = + rawCurrentObservabilityVerificationOutcomes.filter( + (entry) => + getObservabilityVerificationOutcomeRole(entry?.signal, entry?.outcome) !== + "recovered", + ); + const focusCurrentRecoveredObservabilityVerificationOutcomes = + explicitCurrentRecoveredObservabilityVerificationOutcomes.length > 0 + ? explicitCurrentRecoveredObservabilityVerificationOutcomes + : rawCurrentObservabilityVerificationOutcomes.filter( + (entry) => + getObservabilityVerificationOutcomeRole( + entry?.signal, + entry?.outcome, + ) === "recovered", + ); + const rawDegradedObservabilityVerificationOutcomes = + buildObservabilityVerificationFocusEntries( + trendReport?.classificationDeltas?.degradedObservabilityVerificationOutcomes, + trendSummary.sampleCount, + ); + const focusDegradedObservabilityVerificationOutcomes = + rawDegradedObservabilityVerificationOutcomes.filter( + (entry) => + getObservabilityVerificationOutcomeRole(entry?.signal, entry?.outcome) !== + "recovered", + ); + const mergedVerificationFailureOutcomes = + focusVerificationFailureOutcomes.length > 0 + ? focusVerificationFailureOutcomes + : [ + ...focusCurrentObservabilityVerificationOutcomes, + ...focusDegradedObservabilityVerificationOutcomes, + ].sort((left, right) => { + if (right.score !== left.score) { + return right.score - left.score; + } + return left.name.localeCompare(right.name); + }); + const verificationFailureSummary = buildVerificationOutcomeSummary( + mergedVerificationFailureOutcomes, + ); + const recoveredVerificationSummary = buildVerificationOutcomeSummary( + explicitRecoveredObservabilityVerificationOutcomes.length > 0 + ? explicitRecoveredObservabilityVerificationOutcomes + : mergedVerificationFailureOutcomes.filter( + (entry) => + getObservabilityVerificationOutcomeRole( + entry?.signal, + entry?.outcome, + ) === "recovered", + ), + ); + const verificationOutcomeSummary = { + ...verificationFailureSummary, + recoveredFocusCount: recoveredVerificationSummary.recoveredFocusCount, + recoveredCaseCount: recoveredVerificationSummary.recoveredCaseCount, + topRecoveredOutcomes: recoveredVerificationSummary.topRecoveredOutcomes, + }; + const currentVerificationFailureSummary = buildVerificationOutcomeSummary( + focusCurrentObservabilityVerificationOutcomes, + ); + const currentRecoveredVerificationSummary = buildVerificationOutcomeSummary( + focusCurrentRecoveredObservabilityVerificationOutcomes, + ); + const currentVerificationOutcomeSummary = { + ...currentVerificationFailureSummary, + recoveredFocusCount: currentRecoveredVerificationSummary.recoveredFocusCount, + recoveredCaseCount: currentRecoveredVerificationSummary.recoveredCaseCount, + topRecoveredOutcomes: currentRecoveredVerificationSummary.topRecoveredOutcomes, + }; + const degradedVerificationOutcomeSummary = buildVerificationOutcomeSummary( + focusDegradedObservabilityVerificationOutcomes, + ); + const currentRecoveredVerificationOutcomes = + focusCurrentRecoveredObservabilityVerificationOutcomes + .slice(0, 3) + .map((entry) => `${entry.signal} (${entry.outcome})`); + const combinedVerificationOutcomeSummary = { + ...verificationOutcomeSummary, + current: currentVerificationOutcomeSummary, + degraded: degradedVerificationOutcomeSummary, + }; const governanceSurfaces = buildGovernanceSurfaceEntries(governanceReport); const governanceSummary = buildGovernanceSummary( governanceReport, @@ -871,18 +1708,23 @@ export function buildGeneratedSlopReport({ ); const recommendations = buildRecommendations({ trendSummary, + verificationOutcomeSummary: combinedVerificationOutcomeSummary, focusFailureModes, focusSuiteTags, focusReviewDecisionStatuses, focusReviewRiskLevels, focusObservabilitySignals, + focusVerificationFailureOutcomes: mergedVerificationFailureOutcomes, + focusCurrentObservabilityVerificationOutcomes, + focusCurrentRecoveredObservabilityVerificationOutcomes, + focusDegradedObservabilityVerificationOutcomes, docFreshnessSummary, docFreshnessFocus, governanceSummary, governanceSurfaces, }); - return { + const report = { reportVersion: "v1", generatedAt: new Date().toISOString(), repoRoot: normalizeString(repoRoot), @@ -902,6 +1744,7 @@ export function buildGeneratedSlopReport({ }, summary: { trend: trendSummary, + verificationOutcomes: combinedVerificationOutcomeSummary, docFreshness: docFreshnessSummary, governance: governanceSummary, }, @@ -922,9 +1765,36 @@ export function buildGeneratedSlopReport({ trendSummary.reviewDecisionBacklogCount > 0 ? `仍有 ${trendSummary.reviewDecisionBacklogCount} 个需要人工审核的 case 尚未记录最终 decision。` : "当前没有待补录的人工审核 backlog。", - trendSummary.latestObservabilityGapCaseCount > 0 - ? `当前仍有 ${trendSummary.latestObservabilityGapCaseCount} 个 case 缺 observability 证据。` - : "当前没有额外的 observability 证据缺口。", + trendSummary.latestCurrentObservabilityGapCaseCount > 0 + ? `当前仍有 ${trendSummary.latestCurrentObservabilityGapCaseCount} 个 current case 缺 observability 证据。` + : "当前 current 样本没有额外的 observability 证据缺口。", + trendSummary.latestDegradedObservabilityGapCaseCount > 0 + ? `当前保留 ${trendSummary.latestDegradedObservabilityGapCaseCount} 个 degraded observability gap 样本作为诊断基线。` + : "当前没有额外保留的 degraded observability gap 样本。", + focusVerificationFailureOutcomes.length > 0 + ? `当前 verification failure outcome 焦点:${focusVerificationFailureOutcomes + .slice(0, 3) + .map((entry) => `${entry.signal} (${entry.outcome})`) + .join("、")}。` + : "当前没有额外的 verification failure outcome 焦点。", + verificationOutcomeSummary.failureCaseCount > 0 + ? `当前 verification failure 聚焦 ${verificationOutcomeSummary.failureFocusCount} 类 outcome,共 ${verificationOutcomeSummary.failureCaseCount} 个 case。` + : "当前没有额外的 verification failure case。", + currentVerificationOutcomeSummary.blockingFailureCaseCount > 0 + ? `当前 current 样本里有 ${currentVerificationOutcomeSummary.blockingFailureCaseCount} 个 blocking verification failure。` + : "当前没有额外的 blocking verification failure。", + currentVerificationOutcomeSummary.advisoryFailureCaseCount > 0 + ? `当前 current 样本里有 ${currentVerificationOutcomeSummary.advisoryFailureCaseCount} 个 advisory verification failure。` + : "当前没有额外的 advisory verification failure。", + currentVerificationOutcomeSummary.recoveredCaseCount > 0 + ? `当前 current recovered verification baseline:${currentRecoveredVerificationOutcomes.join("、") || "暂无"}。` + : "当前没有额外的 current recovered verification baseline。", + degradedVerificationOutcomeSummary.blockingFailureCaseCount > 0 + ? `当前保留 ${degradedVerificationOutcomeSummary.blockingFailureCaseCount} 个 degraded blocking verification failure 样本作为诊断基线。` + : "当前没有额外的 degraded blocking verification baseline。", + verificationOutcomeSummary.recoveredCaseCount > 0 + ? `当前 verification recovered 聚焦 ${verificationOutcomeSummary.recoveredFocusCount} 类 outcome,共 ${verificationOutcomeSummary.recoveredCaseCount} 个 case。` + : "当前没有额外的 verification recovered case。", ], focus: { failureModes: focusFailureModes.slice(0, 5), @@ -932,6 +1802,14 @@ export function buildGeneratedSlopReport({ reviewDecisionStatuses: focusReviewDecisionStatuses.slice(0, 5), reviewRiskLevels: focusReviewRiskLevels.slice(0, 5), observabilitySignals: focusObservabilitySignals.slice(0, 5), + observabilityVerificationOutcomes: + mergedVerificationFailureOutcomes.slice(0, 5), + currentObservabilityVerificationOutcomes: + focusCurrentObservabilityVerificationOutcomes.slice(0, 5), + currentRecoveredObservabilityVerificationOutcomes: + focusCurrentRecoveredObservabilityVerificationOutcomes.slice(0, 5), + degradedObservabilityVerificationOutcomes: + focusDegradedObservabilityVerificationOutcomes.slice(0, 5), docFreshness: docFreshnessFocus, governanceSurfaces: governanceSurfaces .filter((entry) => entry.active && entry.classification !== "current") @@ -939,6 +1817,8 @@ export function buildGeneratedSlopReport({ }, recommendations, }; + + return assertGeneratedSlopReportContract(report); } export function renderGeneratedSlopText(report) { @@ -954,6 +1834,13 @@ export function renderGeneratedSlopText(report) { `[harness-cleanup] delta review_decision_recorded: ${report.summary.trend.reviewDecisionRecordedDelta}`, `[harness-cleanup] review backlog: ${report.summary.trend.reviewDecisionBacklogCount}`, `[harness-cleanup] observability gap cases: ${report.summary.trend.latestObservabilityGapCaseCount}`, + `[harness-cleanup] current observability gap cases: ${report.summary.trend.latestCurrentObservabilityGapCaseCount}`, + `[harness-cleanup] degraded observability gap cases: ${report.summary.trend.latestDegradedObservabilityGapCaseCount}`, + `[harness-cleanup] verification failure cases: ${report.summary.verificationOutcomes.failureCaseCount}`, + `[harness-cleanup] current verification blocking failure cases: ${report.summary.verificationOutcomes.current?.blockingFailureCaseCount ?? 0}`, + `[harness-cleanup] current verification advisory failure cases: ${report.summary.verificationOutcomes.current?.advisoryFailureCaseCount ?? 0}`, + `[harness-cleanup] degraded verification blocking failure cases: ${report.summary.verificationOutcomes.degraded?.blockingFailureCaseCount ?? 0}`, + `[harness-cleanup] verification recovered cases: ${report.summary.verificationOutcomes.recoveredCaseCount}`, `[harness-cleanup] doc freshness issues: ${report.summary.docFreshness.issueCount}`, `[harness-cleanup] active compat/deprecated/dead-candidate: ${report.summary.governance.activeByClassification.compat}/${report.summary.governance.activeByClassification.deprecated}/${report.summary.governance.activeByClassification["dead-candidate"]}`, `[harness-cleanup] governance violations: ${report.summary.governance.violationCount}`, @@ -1008,6 +1895,23 @@ export function renderGeneratedSlopText(report) { } } + if (report.focus.observabilityVerificationOutcomes.length > 0) { + lines.push("[harness-cleanup] top observability verification outcomes:"); + for (const entry of report.focus.observabilityVerificationOutcomes) { + lines.push( + ` - ${entry.signal} (${entry.outcome}): state=${entry.state}, latest_case=${entry.latest.caseCount}, delta_case=${entry.delta.caseCount}, score=${entry.score}`, + ); + } + } + + lines.push("[harness-cleanup] observability gap role summary:"); + lines.push( + ` - current: latest=${report.summary.trend.latestCurrentObservabilityGapCaseCount}, delta=${report.summary.trend.currentObservabilityGapCaseDelta}`, + ); + lines.push( + ` - degraded: latest=${report.summary.trend.latestDegradedObservabilityGapCaseCount}, delta=${report.summary.trend.degradedObservabilityGapCaseDelta}`, + ); + if (report.focus.docFreshness.issueKinds.length > 0) { lines.push("[harness-cleanup] doc freshness issues:"); for (const entry of report.focus.docFreshness.issueKinds) { @@ -1043,6 +1947,13 @@ export function renderGeneratedSlopMarkdown(report) { `- 已记录人工审核 delta:${report.summary.trend.reviewDecisionRecordedDelta}`, `- 待补录人工审核 backlog:${report.summary.trend.reviewDecisionBacklogCount}`, `- observability gap case:${report.summary.trend.latestObservabilityGapCaseCount}`, + `- current observability gap case:${report.summary.trend.latestCurrentObservabilityGapCaseCount}`, + `- degraded observability gap case:${report.summary.trend.latestDegradedObservabilityGapCaseCount}`, + `- verification failure case:${report.summary.verificationOutcomes.failureCaseCount}`, + `- current verification blocking failure case:${report.summary.verificationOutcomes.current?.blockingFailureCaseCount ?? 0}`, + `- current verification advisory failure case:${report.summary.verificationOutcomes.current?.advisoryFailureCaseCount ?? 0}`, + `- degraded verification blocking failure case:${report.summary.verificationOutcomes.degraded?.blockingFailureCaseCount ?? 0}`, + `- verification recovered case:${report.summary.verificationOutcomes.recoveredCaseCount}`, `- doc freshness 问题数:${report.summary.docFreshness.issueCount}`, `- governance 违规数:${report.summary.governance.violationCount}`, `- compat 活跃 surface:${report.summary.governance.activeByClassification.compat}`, @@ -1057,6 +1968,18 @@ export function renderGeneratedSlopMarkdown(report) { lines.push(`- ${signal}`); } + lines.push(""); + lines.push("## Observability Gap 角色"); + lines.push(""); + lines.push("| 角色 | latest case | delta case |"); + lines.push("| --- | --- | --- |"); + lines.push( + `| current | ${report.summary.trend.latestCurrentObservabilityGapCaseCount} | ${report.summary.trend.currentObservabilityGapCaseDelta} |`, + ); + lines.push( + `| degraded | ${report.summary.trend.latestDegradedObservabilityGapCaseCount} | ${report.summary.trend.degradedObservabilityGapCaseDelta} |`, + ); + if (report.focus.failureModes.length > 0) { lines.push(""); lines.push("## Failure Mode 焦点"); @@ -1143,6 +2066,21 @@ export function renderGeneratedSlopMarkdown(report) { } } + if (report.focus.observabilityVerificationOutcomes.length > 0) { + lines.push(""); + lines.push("## Observability Verification 焦点"); + lines.push(""); + lines.push( + "| Signal | Outcome | latest case | delta case | score |", + ); + lines.push("| --- | --- | --- | --- | --- |"); + for (const entry of report.focus.observabilityVerificationOutcomes) { + lines.push( + `| ${entry.signal} | ${entry.outcome} | ${entry.latest.caseCount} | ${entry.delta.caseCount} | ${entry.score} |`, + ); + } + } + if (report.recommendations.length > 0) { lines.push(""); lines.push("## 推荐动作"); @@ -1169,6 +2107,16 @@ export function renderGeneratedSlopMarkdown(report) { `- 关注风险等级:${action.focusReviewRiskLevels.join("、")}`, ); } + if (action.focusVerificationFailureOutcomes.length > 0) { + lines.push( + `- 关注 verification failure outcome:${action.focusVerificationFailureOutcomes.join("、")}`, + ); + } + if (action.focusVerificationRecoveredOutcomes.length > 0) { + lines.push( + `- 关注 verification recovered outcome:${action.focusVerificationRecoveredOutcomes.join("、")}`, + ); + } if (action.focusSurfaceIds.length > 0) { lines.push(`- 关注 surface:${action.focusSurfaceIds.join("、")}`); } diff --git a/scripts/lib/generated-slop-report-core.test.ts b/scripts/lib/generated-slop-report-core.test.ts index 3171a97e0..acfb74b4f 100644 --- a/scripts/lib/generated-slop-report-core.test.ts +++ b/scripts/lib/generated-slop-report-core.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { buildGeneratedSlopReport } from "./generated-slop-report-core.mjs"; +import { + assertGeneratedSlopReportContract, + buildGeneratedSlopReport, + renderGeneratedSlopMarkdown, +} from "./generated-slop-report-core.mjs"; describe("generated-slop-report-core", () => { it("应把 trend seed 与活跃 legacy surface 转成治理建议", () => { @@ -14,6 +18,8 @@ describe("generated-slop-report-core", () => { needsHumanReviewCount: 0, reviewDecisionRecordedCount: 0, observabilityGapCaseCount: 1, + currentObservabilityGapCaseCount: 1, + degradedObservabilityGapCaseCount: 0, readyRate: 0, }, signals: ["样本数不足 2,当前仅形成 trend seed,还不能判断长期退化。"], @@ -22,6 +28,8 @@ describe("generated-slop-report-core", () => { needsHumanReviewCount: 0, reviewDecisionRecordedCount: 0, observabilityGapCaseCount: 0, + currentObservabilityGapCaseCount: 0, + degradedObservabilityGapCaseCount: 0, }, }, latest: { @@ -29,6 +37,8 @@ describe("generated-slop-report-core", () => { needsHumanReviewCount: 1, reviewDecisionRecordedCount: 0, observabilityGapCaseCount: 1, + currentObservabilityGapCaseCount: 1, + degradedObservabilityGapCaseCount: 0, }, }, classificationDeltas: { @@ -137,30 +147,6 @@ describe("generated-slop-report-core", () => { }, ], observabilitySignals: [ - { - name: "requestTelemetry:unlinked", - baseline: { - caseCount: 0, - readyCount: 0, - invalidCount: 0, - pendingRequestCaseCount: 0, - needsHumanReviewCount: 0, - }, - latest: { - caseCount: 1, - readyCount: 1, - invalidCount: 0, - pendingRequestCaseCount: 0, - needsHumanReviewCount: 0, - }, - delta: { - caseCount: 1, - readyCount: 1, - invalidCount: 0, - pendingRequestCaseCount: 0, - needsHumanReviewCount: 0, - }, - }, { name: "artifactValidator:known_gap", baseline: { @@ -186,6 +172,107 @@ describe("generated-slop-report-core", () => { }, }, ], + observabilityVerificationOutcomes: [ + { + name: "browserVerification:failure", + baseline: { + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + latest: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + delta: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + }, + { + name: "artifactValidator:repaired", + baseline: { + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + latest: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + delta: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + }, + ], + currentObservabilityVerificationOutcomes: [ + { + name: "browserVerification:failure", + baseline: { + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + latest: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + delta: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + }, + { + name: "artifactValidator:repaired", + baseline: { + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + latest: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + delta: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + }, + ], + degradedObservabilityVerificationOutcomes: [], }, }, governanceReport: { @@ -196,14 +283,12 @@ describe("generated-slop-report-core", () => { }, importResults: [ { - id: "team-subagent-scheduler-hook", - classification: "compat", - description: "compat hook", - existingTargets: ["src/hooks/useSubAgentScheduler.ts"], - references: [ - "src/components/agent/chat/hooks/useCompatSubagentRuntime.ts", - ], - testReferences: ["src/hooks/useSubAgentScheduler.test.tsx"], + id: "settings-api-server-page-entry", + classification: "dead-candidate", + description: "deleted settings page entry", + existingTargets: ["src/components/api-server/ApiServerPage.tsx"], + references: [], + testReferences: [], violations: [], }, ], @@ -244,14 +329,58 @@ describe("generated-slop-report-core", () => { expect(report.summary.trend.isSeed).toBe(true); expect(report.summary.trend.reviewDecisionBacklogCount).toBe(1); expect(report.summary.trend.latestObservabilityGapCaseCount).toBe(1); + expect(report.summary.verificationOutcomes).toMatchObject({ + failureFocusCount: 1, + recoveredFocusCount: 1, + failureCaseCount: 1, + blockingFailureFocusCount: 1, + advisoryFailureFocusCount: 0, + blockingFailureCaseCount: 1, + advisoryFailureCaseCount: 0, + recoveredCaseCount: 1, + topFailureOutcomes: ["browserVerification:failure"], + topBlockingFailureOutcomes: ["browserVerification:failure"], + topAdvisoryFailureOutcomes: [], + topRecoveredOutcomes: ["artifactValidator:repaired"], + current: expect.objectContaining({ + blockingFailureCaseCount: 1, + advisoryFailureCaseCount: 0, + recoveredCaseCount: 1, + }), + degraded: expect.objectContaining({ + blockingFailureCaseCount: 0, + advisoryFailureCaseCount: 0, + }), + }); expect(report.summary.docFreshness.issueCount).toBe(0); expect(report.focus.failureModes[0].name).toBe("pending_request"); expect(report.focus.reviewDecisionStatuses[0].name).toBe("pending_review"); expect(report.focus.reviewRiskLevels[0].name).toBe("high"); - expect(report.focus.observabilitySignals[0]).toMatchObject({ - signal: "requestTelemetry", - status: "unlinked", - }); + expect(report.focus.observabilitySignals).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + signal: "artifactValidator", + status: "known_gap", + }), + ]), + ); + expect(report.focus.observabilityVerificationOutcomes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + signal: "browserVerification", + outcome: "failure", + }), + ]), + ); + expect(report.focus.currentObservabilityVerificationOutcomes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + signal: "browserVerification", + outcome: "failure", + }), + ]), + ); + expect(report.focus.degradedObservabilityVerificationOutcomes).toEqual([]); expect(report.focus.governanceSurfaces[0].id).toBe( "migration-setting-key-leak", ); @@ -261,6 +390,7 @@ describe("generated-slop-report-core", () => { "replay-and-smoke-follow-up", "review-decision-follow-up", "observability-evidence-follow-up", + "recovered-baseline-follow-up", "governance-cleanup-priority", "doc-freshness-review", ]), @@ -274,11 +404,946 @@ describe("generated-slop-report-core", () => { report.recommendations.find((entry) => entry.id === "doc-freshness-review") ?.commands, ).toContain("npm run harness:doc-freshness"); + expect( + report.recommendations.find( + (entry) => entry.id === "observability-evidence-follow-up", + )?.focusVerificationFailureOutcomes, + ).toContain("browserVerification (failure)"); + expect( + report.recommendations.find( + (entry) => entry.id === "observability-evidence-follow-up", + ), + ).not.toHaveProperty("focusObservabilityVerificationOutcomes"); + expect( + report.recommendations.find( + (entry) => entry.id === "replay-and-smoke-follow-up", + )?.priority, + ).toBe("P0"); + expect( + report.recommendations.find( + (entry) => entry.id === "replay-and-smoke-follow-up", + )?.commands, + ).not.toContain("npm run verify:gui-smoke"); + expect( + report.recommendations.find( + (entry) => entry.id === "replay-and-smoke-follow-up", + )?.backlogTools, + ).toContain( + "回看 browser replay / browser verification 失败样本,并把失败断言回挂到受影响主路径。", + ); + expect( + report.recommendations.find( + (entry) => entry.id === "replay-and-smoke-follow-up", + )?.rationale, + ).toContain( + "current 样本已出现 browserVerification:failure,应先回看 browser replay / verification 失败样本,把失败断言回挂到受影响主路径。", + ); expect( report.recommendations.find( (entry) => entry.id === "observability-evidence-follow-up", )?.priority, ).toBe("P1"); + expect(report.summary.trend.latestCurrentObservabilityGapCaseCount).toBe(1); + expect(report.summary.trend.latestDegradedObservabilityGapCaseCount).toBe(0); + expect(report.signals).toContain( + "当前 verification failure outcome 焦点:browserVerification (failure)。", + ); + expect(report.signals).toContain( + "当前 verification failure 聚焦 1 类 outcome,共 1 个 case。", + ); + expect(report.signals).toContain( + "当前 current 样本里有 1 个 blocking verification failure。", + ); + expect(report.signals).toContain( + "当前没有额外的 advisory verification failure。", + ); + expect(report.signals).toContain( + "当前 current recovered verification baseline:artifactValidator (repaired)。", + ); + expect(report.signals).toContain( + "当前 verification recovered 聚焦 1 类 outcome,共 1 个 case。", + ); + }); + + it("应把 current guiSmoke failed 回挂成显式 GUI smoke 动作", () => { + const report = buildGeneratedSlopReport({ + repoRoot: "/tmp/lime", + trendReport: { + sampleCount: 2, + delta: { + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + reviewDecisionRecordedCount: 0, + observabilityGapCaseCount: 0, + currentObservabilityGapCaseCount: 0, + degradedObservabilityGapCaseCount: 0, + readyRate: 0, + }, + signals: ["当前没有检测到明显退化信号。"], + baseline: { + totals: { + needsHumanReviewCount: 0, + reviewDecisionRecordedCount: 0, + observabilityGapCaseCount: 0, + currentObservabilityGapCaseCount: 0, + degradedObservabilityGapCaseCount: 0, + }, + }, + latest: { + totals: { + needsHumanReviewCount: 0, + reviewDecisionRecordedCount: 0, + observabilityGapCaseCount: 0, + currentObservabilityGapCaseCount: 0, + degradedObservabilityGapCaseCount: 0, + }, + }, + classificationDeltas: { + failureModes: [], + suiteTags: [], + reviewDecisionStatuses: [], + reviewRiskLevels: [], + observabilitySignals: [], + observabilityVerificationOutcomes: [ + { + name: "guiSmoke:failed", + baseline: { + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + latest: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + delta: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + }, + ], + currentObservabilityVerificationOutcomes: [ + { + name: "guiSmoke:failed", + baseline: { + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + latest: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + delta: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + }, + ], + degradedObservabilityVerificationOutcomes: [], + }, + }, + governanceReport: { + summary: { + zeroReferenceCandidates: [], + classificationDriftCandidates: [], + violations: [], + }, + importResults: [], + commandResults: [], + frontendTextResults: [], + rustTextResults: [], + rustTextCountResults: [], + }, + docFreshnessReport: { + summary: { + monitoredDocumentCount: 10, + existingDocumentCount: 10, + issueCount: 0, + missingDocumentCount: 0, + missingRequiredReferenceCount: 0, + brokenMarkdownLinkCount: 0, + brokenCodePathReferenceCount: 0, + deletedSurfaceReferenceCount: 0, + }, + issues: [], + }, + sources: { + trend: { kind: "input-json" }, + docFreshness: { kind: "input-json" }, + governance: { kind: "input-json" }, + }, + }); + + const recommendation = report.recommendations.find( + (entry) => entry.id === "replay-and-smoke-follow-up", + ); + + expect(recommendation?.priority).toBe("P0"); + expect(recommendation?.commands).toEqual( + expect.arrayContaining([ + "npm run harness:eval", + "npm run harness:eval:trend", + "npm run verify:gui-smoke", + ]), + ); + expect(recommendation?.backlogTools).toContain( + "优先收敛 GUI 壳 / DevBridge / Workspace 主路径,再复跑 `npm run verify:gui-smoke`。", + ); + expect(recommendation?.rationale).toContain( + "current 样本已出现 guiSmoke:failed,先恢复 GUI 壳 / DevBridge / Workspace 主路径的最小可启动性。", + ); + }); + + it("应把 current advisory verification outcome 回挂成具体补证据动作", () => { + const report = buildGeneratedSlopReport({ + repoRoot: "/tmp/lime", + trendReport: { + sampleCount: 2, + delta: { + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + reviewDecisionRecordedCount: 0, + observabilityGapCaseCount: 0, + currentObservabilityGapCaseCount: 0, + degradedObservabilityGapCaseCount: 0, + readyRate: 0, + }, + signals: ["当前没有检测到明显退化信号。"], + baseline: { + totals: { + needsHumanReviewCount: 0, + reviewDecisionRecordedCount: 0, + observabilityGapCaseCount: 0, + currentObservabilityGapCaseCount: 0, + degradedObservabilityGapCaseCount: 0, + }, + }, + latest: { + totals: { + needsHumanReviewCount: 0, + reviewDecisionRecordedCount: 0, + observabilityGapCaseCount: 0, + currentObservabilityGapCaseCount: 0, + degradedObservabilityGapCaseCount: 0, + }, + }, + classificationDeltas: { + failureModes: [], + suiteTags: [], + reviewDecisionStatuses: [], + reviewRiskLevels: [], + observabilitySignals: [], + observabilityVerificationOutcomes: [ + { + name: "artifactValidator:issues_present", + baseline: { + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + latest: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + delta: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + }, + { + name: "browserVerification:unknown", + baseline: { + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + latest: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + delta: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + }, + ], + currentObservabilityVerificationOutcomes: [ + { + name: "artifactValidator:issues_present", + baseline: { + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + latest: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + delta: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + }, + { + name: "browserVerification:unknown", + baseline: { + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + latest: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + delta: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + }, + ], + degradedObservabilityVerificationOutcomes: [], + }, + }, + governanceReport: { + summary: { + zeroReferenceCandidates: [], + classificationDriftCandidates: [], + violations: [], + }, + importResults: [], + commandResults: [], + frontendTextResults: [], + rustTextResults: [], + rustTextCountResults: [], + }, + docFreshnessReport: { + summary: { + monitoredDocumentCount: 10, + existingDocumentCount: 10, + issueCount: 0, + missingDocumentCount: 0, + missingRequiredReferenceCount: 0, + brokenMarkdownLinkCount: 0, + brokenCodePathReferenceCount: 0, + deletedSurfaceReferenceCount: 0, + }, + issues: [], + }, + sources: { + trend: { kind: "input-json" }, + docFreshness: { kind: "input-json" }, + governance: { kind: "input-json" }, + }, + }); + + const recommendation = report.recommendations.find( + (entry) => entry.id === "observability-evidence-follow-up", + ); + + expect( + report.recommendations.find( + (entry) => entry.id === "replay-and-smoke-follow-up", + ), + ).toBeUndefined(); + expect(recommendation?.priority).toBe("P1"); + expect(recommendation?.commands).toEqual( + expect.arrayContaining([ + "npm run harness:eval", + "npm run harness:eval:trend", + "npm run harness:cleanup-report", + ]), + ); + expect(recommendation?.commands).not.toContain("npm run verify:gui-smoke"); + expect(recommendation?.backlogTools).toContain( + "回看 artifact validator issue 明细,并收敛 evidence pack / artifacts.json / analysis handoff 的 artifact 字段。", + ); + expect(recommendation?.backlogTools).toContain( + "回看 browser verification 导出链,确保 evidence pack / replay / analysis handoff 写出明确 success 或 failure,而不是 unknown。", + ); + expect(recommendation?.rationale).toContain( + "current 样本已出现 artifactValidator:issues_present,应先回看 validator issue 明细,再收敛 artifact 导出字段。", + ); + expect(recommendation?.rationale).toContain( + "current 样本已出现 browserVerification:unknown,需要先把浏览器验证结果收敛成明确 outcome,再继续扩大分析。", + ); + }); + + it("应把 current recovered verification outcome 固化成正向基线建议", () => { + const report = buildGeneratedSlopReport({ + repoRoot: "/tmp/lime", + trendReport: { + sampleCount: 2, + delta: { + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + reviewDecisionRecordedCount: 0, + observabilityGapCaseCount: 0, + currentObservabilityGapCaseCount: 0, + degradedObservabilityGapCaseCount: 0, + readyRate: 0, + }, + signals: ["当前没有检测到明显退化信号。"], + baseline: { + totals: { + needsHumanReviewCount: 0, + reviewDecisionRecordedCount: 0, + observabilityGapCaseCount: 0, + currentObservabilityGapCaseCount: 0, + degradedObservabilityGapCaseCount: 0, + }, + }, + latest: { + totals: { + needsHumanReviewCount: 0, + reviewDecisionRecordedCount: 0, + observabilityGapCaseCount: 0, + currentObservabilityGapCaseCount: 0, + degradedObservabilityGapCaseCount: 0, + }, + }, + classificationDeltas: { + failureModes: [], + suiteTags: [], + reviewDecisionStatuses: [], + reviewRiskLevels: [], + observabilitySignals: [], + observabilityVerificationOutcomes: [ + { + name: "artifactValidator:repaired", + baseline: { + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + latest: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + delta: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + }, + { + name: "browserVerification:success", + baseline: { + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + latest: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + delta: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + }, + { + name: "guiSmoke:passed", + baseline: { + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + latest: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + delta: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + }, + ], + currentObservabilityVerificationOutcomes: [ + { + name: "artifactValidator:repaired", + baseline: { + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + latest: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + delta: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + }, + { + name: "browserVerification:success", + baseline: { + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + latest: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + delta: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + }, + { + name: "guiSmoke:passed", + baseline: { + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + latest: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + delta: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + }, + ], + currentRecoveredObservabilityVerificationOutcomes: [ + { + name: "artifactValidator:repaired", + baseline: { + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + latest: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + delta: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + }, + { + name: "browserVerification:success", + baseline: { + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + latest: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + delta: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + }, + { + name: "guiSmoke:passed", + baseline: { + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + latest: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + delta: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + }, + ], + degradedObservabilityVerificationOutcomes: [], + }, + }, + governanceReport: { + summary: { + zeroReferenceCandidates: [], + classificationDriftCandidates: [], + violations: [], + }, + importResults: [], + commandResults: [], + frontendTextResults: [], + rustTextResults: [], + rustTextCountResults: [], + }, + docFreshnessReport: { + summary: { + monitoredDocumentCount: 10, + existingDocumentCount: 10, + issueCount: 0, + missingDocumentCount: 0, + missingRequiredReferenceCount: 0, + brokenMarkdownLinkCount: 0, + brokenCodePathReferenceCount: 0, + deletedSurfaceReferenceCount: 0, + }, + issues: [], + }, + sources: { + trend: { kind: "input-json" }, + docFreshness: { kind: "input-json" }, + governance: { kind: "input-json" }, + }, + }); + + const recommendation = report.recommendations.find( + (entry) => entry.id === "recovered-baseline-follow-up", + ); + + expect(report.summary.verificationOutcomes.recoveredCaseCount).toBe(3); + expect(report.summary.verificationOutcomes.recoveredFocusCount).toBe(3); + expect(report.summary.verificationOutcomes.topRecoveredOutcomes).toEqual( + expect.arrayContaining([ + "artifactValidator:repaired", + "browserVerification:success", + "guiSmoke:passed", + ]), + ); + expect(report.summary.verificationOutcomes.current.recoveredCaseCount).toBe(3); + expect( + report.focus.currentRecoveredObservabilityVerificationOutcomes, + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + signal: "artifactValidator", + outcome: "repaired", + }), + expect.objectContaining({ + signal: "browserVerification", + outcome: "success", + }), + expect.objectContaining({ + signal: "guiSmoke", + outcome: "passed", + }), + ]), + ); + expect(recommendation?.priority).toBe("P2"); + expect(recommendation?.focusVerificationRecoveredOutcomes).toEqual( + expect.arrayContaining([ + "artifactValidator (repaired)", + "browserVerification (success)", + "guiSmoke (passed)", + ]), + ); + expect(recommendation).not.toHaveProperty( + "focusObservabilityVerificationOutcomes", + ); + expect(recommendation?.commands).toEqual( + expect.arrayContaining([ + "npm run harness:eval", + "npm run harness:eval:trend", + "npm run verify:gui-smoke", + ]), + ); + expect(recommendation?.backlogTools).toContain( + "把 browser verification 成功样本固定进 current replay 基线,后续 failure 或 unknown 直接对比这条正向路径。", + ); + expect(recommendation?.backlogTools).toContain( + "主路径变更时优先复跑 `npm run verify:gui-smoke`,确认 GUI 壳 / DevBridge / Workspace 不从 passed 回退。", + ); + expect(recommendation?.rationale).toContain( + "current 样本已出现 browserVerification:success,可把浏览器验证成功样本固化成主路径正向基线。", + ); + expect(recommendation?.rationale).toContain( + "current 样本已出现 guiSmoke:passed,可继续把 GUI smoke 通过链路当成桌面主路径的正向守卫。", + ); + expect(report.signals).toContain( + "当前 current recovered verification baseline:artifactValidator (repaired)、browserVerification (success)、guiSmoke (passed)。", + ); + expect(report.signals).toContain( + "当前 verification recovered 聚焦 3 类 outcome,共 3 个 case。", + ); + }); + + it("仅 degraded observability gap 不应触发主线补证据建议", () => { + const report = buildGeneratedSlopReport({ + repoRoot: "/tmp/lime", + trendReport: { + sampleCount: 2, + delta: { + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + reviewDecisionRecordedCount: 0, + observabilityGapCaseCount: 0, + currentObservabilityGapCaseCount: 0, + degradedObservabilityGapCaseCount: 0, + readyRate: 0, + }, + signals: ["当前没有检测到明显退化信号。"], + baseline: { + totals: { + needsHumanReviewCount: 0, + reviewDecisionRecordedCount: 0, + observabilityGapCaseCount: 1, + currentObservabilityGapCaseCount: 0, + degradedObservabilityGapCaseCount: 1, + }, + }, + latest: { + totals: { + needsHumanReviewCount: 0, + reviewDecisionRecordedCount: 0, + observabilityGapCaseCount: 1, + currentObservabilityGapCaseCount: 0, + degradedObservabilityGapCaseCount: 1, + }, + }, + classificationDeltas: { + failureModes: [], + suiteTags: [], + reviewDecisionStatuses: [], + reviewRiskLevels: [], + observabilitySignals: [ + { + name: "requestTelemetry:known_gap", + baseline: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + latest: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + delta: { + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + }, + ], + observabilityVerificationOutcomes: [], + currentObservabilityVerificationOutcomes: [], + degradedObservabilityVerificationOutcomes: [ + { + name: "guiSmoke:failed", + baseline: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + latest: { + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + delta: { + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + }, + ], + }, + }, + governanceReport: { + summary: { + zeroReferenceCandidates: [], + classificationDriftCandidates: [], + violations: [], + }, + importResults: [], + commandResults: [], + frontendTextResults: [], + rustTextResults: [], + rustTextCountResults: [], + }, + docFreshnessReport: { + summary: { + monitoredDocumentCount: 10, + existingDocumentCount: 10, + issueCount: 0, + missingDocumentCount: 0, + missingRequiredReferenceCount: 0, + brokenMarkdownLinkCount: 0, + brokenCodePathReferenceCount: 0, + deletedSurfaceReferenceCount: 0, + }, + issues: [], + }, + sources: { + trend: { kind: "input-json" }, + docFreshness: { kind: "input-json" }, + governance: { kind: "input-json" }, + }, + }); + + expect(report.summary.trend.latestObservabilityGapCaseCount).toBe(1); + expect(report.summary.trend.latestCurrentObservabilityGapCaseCount).toBe(0); + expect(report.summary.trend.latestDegradedObservabilityGapCaseCount).toBe(1); + expect(report.summary.verificationOutcomes.failureCaseCount).toBe(1); + expect(report.summary.verificationOutcomes.blockingFailureCaseCount).toBe(1); + expect(report.summary.verificationOutcomes.advisoryFailureCaseCount).toBe(0); + expect(report.summary.verificationOutcomes.recoveredCaseCount).toBe(0); + expect(report.summary.verificationOutcomes.degraded.blockingFailureCaseCount).toBe(1); + expect( + report.recommendations.find( + (entry) => entry.id === "observability-evidence-follow-up", + ), + ).toBeUndefined(); + expect( + report.recommendations.find( + (entry) => entry.id === "replay-and-smoke-follow-up", + ), + ).toBeUndefined(); + expect(report.signals).toContain( + "当前 current 样本没有额外的 observability 证据缺口。", + ); + expect(report.signals).toContain( + "当前保留 1 个 degraded blocking verification failure 样本作为诊断基线。", + ); + expect(report.signals).toContain( + "当前保留 1 个 degraded observability gap 样本作为诊断基线。", + ); + const markdown = renderGeneratedSlopMarkdown(report); + expect(markdown).toContain("## Observability Gap 角色"); + expect(markdown).toContain("| current | 0 | 0 |"); + expect(markdown).toContain("| degraded | 1 | 0 |"); }); it("应把 governance 违规提升为 P0 守卫动作", () => { @@ -315,6 +1380,7 @@ describe("generated-slop-report-core", () => { reviewDecisionStatuses: [], reviewRiskLevels: [], observabilitySignals: [], + observabilityVerificationOutcomes: [], }, }, governanceReport: { @@ -389,4 +1455,34 @@ describe("generated-slop-report-core", () => { ?.priority, ).toBe("P1"); }); + + it("应拒绝 recommendation 回流旧 verification outcome 字段", () => { + expect(() => + assertGeneratedSlopReportContract({ + recommendations: [ + { + id: "legacy-outcome-field", + title: "旧字段示例", + priority: "P1", + rationale: ["示例"], + commands: [], + backlogTools: [], + focusFailureModes: [], + focusSuiteTags: [], + focusReviewDecisionStatuses: [], + focusReviewRiskLevels: [], + focusVerificationFailureOutcomes: [], + focusVerificationRecoveredOutcomes: [], + focusSurfaceIds: [], + focusObservabilityVerificationOutcomes: [ + "browserVerification (failure)", + ], + }, + ], + focus: { + observabilityVerificationOutcomes: [], + }, + }), + ).toThrow(/focusObservabilityVerificationOutcomes/); + }); }); diff --git a/scripts/lib/harness-dashboard-core.mjs b/scripts/lib/harness-dashboard-core.mjs new file mode 100644 index 000000000..0142d3b33 --- /dev/null +++ b/scripts/lib/harness-dashboard-core.mjs @@ -0,0 +1,765 @@ +function normalizeNumber(value) { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function normalizeString(value, fallback = "") { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : fallback; +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function formatTimestamp(value) { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return normalizeString(value, "-"); + } + return parsed.toLocaleString("zh-CN", { + hour12: false, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +function renderStatCard(label, value, tone = "neutral") { + return ` +
+ ${escapeHtml(label)} + ${escapeHtml(String(value))} +
+ `; +} + +function renderSignalList(signals) { + const items = Array.isArray(signals) ? signals.filter(Boolean) : []; + if (items.length === 0) { + return `

当前没有额外信号。

`; + } + + return ` +
    + ${items.map((item) => `
  • ${escapeHtml(item)}
  • `).join("")} +
+ `; +} + +function renderRecommendationList(recommendations) { + const items = Array.isArray(recommendations) ? recommendations : []; + if (items.length === 0) { + return `

当前没有新的治理建议。

`; + } + + return items + .map( + (entry) => { + const focusVerificationFailureOutcomes = Array.isArray( + entry.focusVerificationFailureOutcomes, + ) + ? entry.focusVerificationFailureOutcomes + : []; + const focusVerificationRecoveredOutcomes = Array.isArray( + entry.focusVerificationRecoveredOutcomes, + ) + ? entry.focusVerificationRecoveredOutcomes + : []; + + return ` +
+
+ ${escapeHtml(normalizeString(entry.priority, "P?"))} +

${escapeHtml(normalizeString(entry.title, "未命名建议"))}

+
+

${escapeHtml( + Array.isArray(entry.rationale) ? entry.rationale.join(" ") : "", + )}

+ ${ + focusVerificationFailureOutcomes.length > 0 + ? ` +

+ 关注 failure outcome:${escapeHtml( + focusVerificationFailureOutcomes.join("、"), + )} +

+ ` + : "" + } + ${ + focusVerificationRecoveredOutcomes.length > 0 + ? ` +

+ 关注 recovered outcome:${escapeHtml( + focusVerificationRecoveredOutcomes.join("、"), + )} +

+ ` + : "" + } + ${ + Array.isArray(entry.backlogTools) && entry.backlogTools.length > 0 + ? ` +
+ 后续动作 +
    + ${entry.backlogTools + .map((item) => `
  • ${escapeHtml(item)}
  • `) + .join("")} +
+
+ ` + : "" + } + ${ + Array.isArray(entry.commands) && entry.commands.length > 0 + ? ` +
+ 推荐命令 +
+ ${entry.commands + .map((command) => `${escapeHtml(command)}`) + .join("")} +
+
+ ` + : "" + } +
+ `; + }, + ) + .join(""); +} + +function describeVerificationOutcome(entry) { + const signal = normalizeString(entry?.signal, "unknown"); + const outcome = normalizeString(entry?.outcome, "unknown"); + + if (signal === "artifactValidator" && outcome === "issues_present") { + return "当前 evidence 已记录 artifact 校验问题,优先回看 validator issue 明细。"; + } + if (signal === "artifactValidator" && outcome === "fallback_used") { + return "当前 artifact 导出仍触发 fallback,说明产物结构或修复链未完全稳定。"; + } + if (signal === "browserVerification" && outcome === "failure") { + return "浏览器验证已有明确失败结果,优先回挂到 replay 或 smoke 断言。"; + } + if (signal === "browserVerification" && outcome === "unknown") { + return "浏览器验证结果仍不明确,需要先补 outcome 再继续扩分析。"; + } + if (signal === "guiSmoke" && outcome === "failed") { + return "GUI smoke 已明确失败,应优先收敛到受影响主路径。"; + } + if (signal === "guiSmoke" && outcome === "passed") { + return "GUI smoke 已通过,可继续把注意力放回 gap 与其它失败面。"; + } + if (signal === "artifactValidator" && outcome === "repaired") { + return "artifact validator 已执行修复,可结合 issues/fallback 判断是否还需继续治理。"; + } + if (signal === "browserVerification" && outcome === "success") { + return "浏览器验证已有成功样本,可作为 current 主线路径的正向基线。"; + } + + return "当前 verification outcome 已进入 cleanup 主线,可直接据此定位先修哪层。"; +} + +const RECOVERED_VERIFICATION_OUTCOMES = new Set([ + "repaired", + "success", + "passed", + "clean", +]); + +function isRecoveredVerificationOutcome(entry) { + return RECOVERED_VERIFICATION_OUTCOMES.has( + normalizeString(entry?.outcome, "unknown"), + ); +} + +function renderFocusTable(title, entries, columns) { + const rows = Array.isArray(entries) ? entries : []; + if (rows.length === 0) { + return ` +
+
+

${escapeHtml(title)}

+
+

当前没有数据。

+
+ `; + } + + return ` +
+
+

${escapeHtml(title)}

+
+
+ + + + ${columns.map((column) => ``).join("")} + + + + ${rows + .map( + (entry) => ` + + ${columns + .map((column) => ``) + .join("")} + + `, + ) + .join("")} + +
${escapeHtml(column.label)}
${escapeHtml(String(column.render(entry)))}
+
+
+ `; +} + +export function renderHarnessDashboardHtml({ + summaryReport, + trendReport, + cleanupReport, + title = "Lime Harness Dashboard", +}) { + const summaryTotals = + summaryReport && typeof summaryReport === "object" && summaryReport.totals + ? summaryReport.totals + : {}; + const trendSummary = + cleanupReport && + typeof cleanupReport === "object" && + cleanupReport.summary && + cleanupReport.summary.trend + ? cleanupReport.summary.trend + : {}; + const governanceSummary = + cleanupReport && + typeof cleanupReport === "object" && + cleanupReport.summary && + cleanupReport.summary.governance + ? cleanupReport.summary.governance + : {}; + const verificationSummary = + cleanupReport && + typeof cleanupReport === "object" && + cleanupReport.summary && + cleanupReport.summary.verificationOutcomes + ? cleanupReport.summary.verificationOutcomes + : {}; + const currentVerificationSummary = + verificationSummary && + typeof verificationSummary.current === "object" && + !Array.isArray(verificationSummary.current) + ? verificationSummary.current + : {}; + const degradedVerificationSummary = + verificationSummary && + typeof verificationSummary.degraded === "object" && + !Array.isArray(verificationSummary.degraded) + ? verificationSummary.degraded + : {}; + const trendSignals = Array.isArray(trendReport?.signals) ? trendReport.signals : []; + const cleanupSignals = Array.isArray(cleanupReport?.signals) + ? cleanupReport.signals + : []; + const recommendations = Array.isArray(cleanupReport?.recommendations) + ? cleanupReport.recommendations + : []; + const sampleRows = Array.isArray(trendReport?.samples) ? trendReport.samples : []; + const currentVerificationFocusRows = Array.isArray( + cleanupReport?.focus?.currentObservabilityVerificationOutcomes, + ) + ? cleanupReport.focus.currentObservabilityVerificationOutcomes.map((entry) => ({ + ...entry, + role: "current", + })) + : []; + const degradedVerificationFocusRows = Array.isArray( + cleanupReport?.focus?.degradedObservabilityVerificationOutcomes, + ) + ? cleanupReport.focus.degradedObservabilityVerificationOutcomes.map( + (entry) => ({ + ...entry, + role: "degraded", + }), + ) + : []; + const fallbackVerificationFocusRows = Array.isArray( + cleanupReport?.focus?.observabilityVerificationOutcomes, + ) + ? cleanupReport.focus.observabilityVerificationOutcomes.map((entry) => ({ + ...entry, + role: "mixed", + })) + : []; + const explicitCurrentRecoveredVerificationRows = Array.isArray( + cleanupReport?.focus?.currentRecoveredObservabilityVerificationOutcomes, + ) + ? cleanupReport.focus.currentRecoveredObservabilityVerificationOutcomes.map( + (entry) => ({ + ...entry, + role: "current", + }), + ) + : []; + const verificationFocusRows = + currentVerificationFocusRows.length > 0 || + degradedVerificationFocusRows.length > 0 + ? [...currentVerificationFocusRows, ...degradedVerificationFocusRows] + : fallbackVerificationFocusRows; + const currentRecoveredVerificationRows = + explicitCurrentRecoveredVerificationRows.length > 0 + ? explicitCurrentRecoveredVerificationRows + : currentVerificationFocusRows.length > 0 + ? currentVerificationFocusRows.filter((entry) => + isRecoveredVerificationOutcome(entry), + ) + : fallbackVerificationFocusRows.filter((entry) => + isRecoveredVerificationOutcome(entry), + ); + const currentRecoveredVerificationSummary = currentRecoveredVerificationRows + .slice(0, 3) + .map( + (entry) => + `${normalizeString(entry?.signal, "-")} (${normalizeString(entry?.outcome, "-")})`, + ) + .join("、"); + + return ` + + + + + ${escapeHtml(title)} + + + +
+
+
+ Harness Engine Nightly Dashboard +

${escapeHtml(title)}

+

同一份 evidence-first 主线下的 summary、trend、cleanup 视图。当前面板会明确区分主线 current 缺口与故意保留的 degraded 诊断基线,避免治理优先级被误导。

+
+ ${renderStatCard("Ready Case", normalizeNumber(summaryTotals.readyCount), "success")} + ${renderStatCard("Invalid Case", normalizeNumber(summaryTotals.invalidCount), normalizeNumber(summaryTotals.invalidCount) > 0 ? "danger" : "neutral")} + ${renderStatCard("Current Gap", normalizeNumber(trendSummary.latestCurrentObservabilityGapCaseCount), normalizeNumber(trendSummary.latestCurrentObservabilityGapCaseCount) > 0 ? "danger" : "success")} + ${renderStatCard("Degraded Gap", normalizeNumber(trendSummary.latestDegradedObservabilityGapCaseCount), "warning")} + ${renderStatCard("Current Blocking", normalizeNumber(currentVerificationSummary.blockingFailureCaseCount), normalizeNumber(currentVerificationSummary.blockingFailureCaseCount) > 0 ? "danger" : "success")} + ${renderStatCard("Current Advisory", normalizeNumber(currentVerificationSummary.advisoryFailureCaseCount), normalizeNumber(currentVerificationSummary.advisoryFailureCaseCount) > 0 ? "warning" : "neutral")} + ${renderStatCard("Current Recovered", normalizeNumber(currentVerificationSummary.recoveredCaseCount), normalizeNumber(currentVerificationSummary.recoveredCaseCount) > 0 ? "success" : "neutral")} + ${renderStatCard("Degraded Blocking", normalizeNumber(degradedVerificationSummary.blockingFailureCaseCount), normalizeNumber(degradedVerificationSummary.blockingFailureCaseCount) > 0 ? "warning" : "neutral")} + ${renderStatCard("Recovered Outcomes", normalizeNumber(verificationSummary.recoveredCaseCount), normalizeNumber(verificationSummary.recoveredCaseCount) > 0 ? "success" : "neutral")} + ${renderStatCard("Trend Samples", normalizeNumber(trendSummary.sampleCount))} + ${renderStatCard("Governance Violations", normalizeNumber(governanceSummary.violationCount), normalizeNumber(governanceSummary.violationCount) > 0 ? "danger" : "neutral")} +
+
+
+
+ Summary 生成时间 + ${escapeHtml(formatTimestamp(summaryReport?.generatedAt))} +
+
+ Trend 生成时间 + ${escapeHtml(formatTimestamp(trendReport?.generatedAt))} +
+
+ Cleanup 生成时间 + ${escapeHtml(formatTimestamp(cleanupReport?.generatedAt))} +
+
+ 当前事实源 + summary - history - trend - cleanup +
+
+ 当前 Recovered 基线 + ${escapeHtml( + currentRecoveredVerificationSummary || + "当前没有额外的 recovered baseline", + )} +
+
+
+ +
+
+
+
+

Trend 信号

+

只把 current gap 当主线风险,degraded gap 仅作为诊断基线保留。

+
+
+ ${renderSignalList(trendSignals)} +
+
+
+
+

Cleanup 信号

+

nightly 治理建议与当前风险摘要。

+
+
+ ${renderSignalList(cleanupSignals)} +
+
+ + ${renderFocusTable("Observability Gap 角色", [ + { + role: "current", + latest: normalizeNumber(trendSummary.latestCurrentObservabilityGapCaseCount), + delta: normalizeNumber(trendSummary.currentObservabilityGapCaseDelta), + meaning: "主线样本里的证据缺口,必须优先治理。", + }, + { + role: "degraded", + latest: normalizeNumber(trendSummary.latestDegradedObservabilityGapCaseCount), + delta: normalizeNumber(trendSummary.degradedObservabilityGapCaseDelta), + meaning: "刻意保留的诊断基线,不应直接抬高主线优先级。", + }, + ], [ + { label: "角色", render: (entry) => entry.role }, + { label: "latest case", render: (entry) => entry.latest }, + { label: "delta case", render: (entry) => entry.delta }, + { label: "说明", render: (entry) => entry.meaning }, + ])} + + ${renderFocusTable("Verification Outcome 焦点", verificationFocusRows, [ + { label: "Role", render: (entry) => normalizeString(entry.role, "-") }, + { label: "Signal", render: (entry) => normalizeString(entry.signal, "-") }, + { label: "Outcome", render: (entry) => normalizeString(entry.outcome, "-") }, + { label: "State", render: (entry) => normalizeString(entry.state, "-") }, + { label: "Latest Case", render: (entry) => normalizeNumber(entry?.latest?.caseCount) }, + { label: "Delta Case", render: (entry) => normalizeNumber(entry?.delta?.caseCount) }, + { label: "说明", render: (entry) => describeVerificationOutcome(entry) }, + ])} + + ${renderFocusTable("Current Recovered Baseline", currentRecoveredVerificationRows, [ + { label: "Signal", render: (entry) => normalizeString(entry.signal, "-") }, + { label: "Outcome", render: (entry) => normalizeString(entry.outcome, "-") }, + { label: "Latest Case", render: (entry) => normalizeNumber(entry?.latest?.caseCount) }, + { label: "Delta Case", render: (entry) => normalizeNumber(entry?.delta?.caseCount) }, + { label: "说明", render: (entry) => describeVerificationOutcome(entry) }, + ])} + + ${renderFocusTable("历史窗口样本", sampleRows, [ + { label: "时间", render: (entry) => formatTimestamp(entry.generatedAt) }, + { label: "来源", render: (entry) => normalizeString(entry.sourcePath, "-") }, + { label: "case", render: (entry) => normalizeNumber(entry?.totals?.caseCount) }, + { label: "ready", render: (entry) => normalizeNumber(entry?.totals?.readyCount) }, + { label: "invalid", render: (entry) => normalizeNumber(entry?.totals?.invalidCount) }, + { label: "current gap", render: (entry) => normalizeNumber(entry?.totals?.currentObservabilityGapCaseCount) }, + { label: "degraded gap", render: (entry) => normalizeNumber(entry?.totals?.degradedObservabilityGapCaseCount) }, + ])} + +
+
+
+

Cleanup 建议

+

当前 nightly 建议按已有优先级排序展示,直接复用 cleanup 报告事实源。

+
+
+
+ ${renderRecommendationList(recommendations)} +
+
+
+ +`; +} diff --git a/scripts/lib/harness-dashboard-core.test.ts b/scripts/lib/harness-dashboard-core.test.ts new file mode 100644 index 000000000..bef117c66 --- /dev/null +++ b/scripts/lib/harness-dashboard-core.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "vitest"; + +import { renderHarnessDashboardHtml } from "./harness-dashboard-core.mjs"; + +describe("harness-dashboard-core", () => { + it("应把 summary、trend、cleanup 渲染成单一事实源 dashboard", () => { + const html = renderHarnessDashboardHtml({ + title: "Harness Engine Dashboard", + summaryReport: { + generatedAt: "2026-04-12T08:00:00.000Z", + totals: { + readyCount: 2, + invalidCount: 1, + }, + }, + trendReport: { + generatedAt: "2026-04-12T08:01:00.000Z", + signals: ["current gap 保持为 0。"], + samples: [ + { + generatedAt: "2026-04-12T08:00:00.000Z", + sourcePath: "/tmp/history/summary.json", + totals: { + caseCount: 2, + readyCount: 2, + invalidCount: 0, + currentObservabilityGapCaseCount: 0, + degradedObservabilityGapCaseCount: 1, + }, + }, + ], + }, + cleanupReport: { + generatedAt: "2026-04-12T08:02:00.000Z", + signals: ["当前没有新的治理风险。"], + recommendations: [ + { + priority: "P1", + title: "保持 current gap 为 0", + rationale: ["继续沿用 current/degraded 分层。"], + backlogTools: [ + "回看 browser replay / browser verification 失败样本,并把失败断言回挂到受影响主路径。", + ], + commands: ["npm run harness:eval:history:record"], + focusVerificationFailureOutcomes: [ + "browserVerification (failure)", + ], + focusVerificationRecoveredOutcomes: [ + "artifactValidator (repaired)", + "browserVerification (success)", + ], + }, + ], + focus: { + currentObservabilityVerificationOutcomes: [ + { + signal: "browserVerification", + outcome: "failure", + state: "regressing", + latest: { + caseCount: 1, + }, + delta: { + caseCount: 1, + }, + }, + ], + currentRecoveredObservabilityVerificationOutcomes: [ + { + signal: "artifactValidator", + outcome: "repaired", + state: "expanding", + latest: { + caseCount: 1, + }, + delta: { + caseCount: 1, + }, + }, + { + signal: "browserVerification", + outcome: "success", + state: "present", + latest: { + caseCount: 1, + }, + delta: { + caseCount: 0, + }, + }, + { + signal: "guiSmoke", + outcome: "passed", + state: "present", + latest: { + caseCount: 1, + }, + delta: { + caseCount: 0, + }, + }, + ], + degradedObservabilityVerificationOutcomes: [ + { + signal: "guiSmoke", + outcome: "failed", + state: "present", + latest: { + caseCount: 1, + }, + delta: { + caseCount: 0, + }, + }, + ], + }, + summary: { + trend: { + sampleCount: 1, + latestCurrentObservabilityGapCaseCount: 0, + latestDegradedObservabilityGapCaseCount: 1, + currentObservabilityGapCaseDelta: 0, + degradedObservabilityGapCaseDelta: 0, + }, + verificationOutcomes: { + blockingFailureCaseCount: 2, + advisoryFailureCaseCount: 0, + recoveredCaseCount: 3, + current: { + blockingFailureCaseCount: 1, + advisoryFailureCaseCount: 0, + recoveredCaseCount: 3, + }, + degraded: { + blockingFailureCaseCount: 1, + advisoryFailureCaseCount: 0, + }, + }, + governance: { + violationCount: 0, + }, + }, + }, + }); + + expect(html).toContain("Harness Engine Nightly Dashboard"); + expect(html).toContain("Harness Engine Dashboard"); + expect(html).toContain("Current Gap"); + expect(html).toContain("Degraded Gap"); + expect(html).toContain("Current Blocking"); + expect(html).toContain("Current Advisory"); + expect(html).toContain("Current Recovered"); + expect(html).toContain("Degraded Blocking"); + expect(html).toContain("Recovered Outcomes"); + expect(html).toContain("Cleanup 建议"); + expect(html).toContain("Observability Gap 角色"); + expect(html).toContain("Verification Outcome 焦点"); + expect(html).toContain("Current Recovered Baseline"); + expect(html).toContain("current"); + expect(html).toContain("degraded"); + expect(html).toContain("browserVerification"); + expect(html).toContain("failure"); + expect(html).toContain("success"); + expect(html).toContain("artifactValidator"); + expect(html).toContain("repaired"); + expect(html).toContain("guiSmoke"); + expect(html).toContain("failed"); + expect(html).toContain("artifact validator 已执行修复"); + expect(html).toContain("浏览器验证已有成功样本"); + expect(html).toContain("当前 Recovered 基线"); + expect(html).toContain( + "artifactValidator (repaired)、browserVerification (success)、guiSmoke (passed)", + ); + expect(html).toContain("关注 failure outcome"); + expect(html).toContain("关注 recovered outcome"); + expect(html).toContain("artifactValidator (repaired)"); + expect(html).toContain("后续动作"); + expect(html).toContain( + "回看 browser replay / browser verification 失败样本,并把失败断言回挂到受影响主路径。", + ); + expect(html).toContain("推荐命令"); + expect(html).toContain("summary - history - trend - cleanup"); + }); +}); diff --git a/scripts/lib/harness-eval-history-record.test.ts b/scripts/lib/harness-eval-history-record.test.ts index 880465bbd..8b9d0a191 100644 --- a/scripts/lib/harness-eval-history-record.test.ts +++ b/scripts/lib/harness-eval-history-record.test.ts @@ -59,10 +59,125 @@ afterEach(() => { }); describe("harness-eval-history-record", () => { + it("默认入口应产出完整 harness artifact 套件", () => { + const tempRoot = createTempRoot(); + const historyDir = path.join(tempRoot, ".lime", "harness", "history"); + const workspaceRoot = path.join(tempRoot, "workspace"); + + const result = runNodeScript("scripts/harness-eval-history-record.mjs", [ + "--format", + "json", + "--history-dir", + historyDir, + "--workspace-root", + workspaceRoot, + ]); + + const reportsRoot = path.join(tempRoot, ".lime", "harness", "reports"); + const summaryJsonPath = path.join(reportsRoot, "harness-eval-summary.json"); + const summaryMarkdownPath = path.join(reportsRoot, "harness-eval-summary.md"); + const trendJsonPath = path.join(reportsRoot, "harness-eval-trend.json"); + const trendMarkdownPath = path.join(reportsRoot, "harness-eval-trend.md"); + const cleanupJsonPath = path.join(reportsRoot, "harness-cleanup-report.json"); + const cleanupMarkdownPath = path.join(reportsRoot, "harness-cleanup-report.md"); + const dashboardHtmlPath = path.join(reportsRoot, "harness-dashboard.html"); + + expect(result.summary.outputJsonPath).toBe(summaryJsonPath); + expect(result.summary.outputMarkdownPath).toBe(summaryMarkdownPath); + expect(result.trend.outputJsonPath).toBe(trendJsonPath); + expect(result.trend.outputMarkdownPath).toBe(trendMarkdownPath); + expect(result.trend.currentRecoveredVerificationCaseCount).toBe(3); + expect(result.trend.currentRecoveredBaselineFocus).toEqual( + expect.arrayContaining([ + "artifactValidator:repaired", + "browserVerification:success", + "guiSmoke:passed", + ]), + ); + expect(result.cleanup.outputJsonPath).toBe(cleanupJsonPath); + expect(result.cleanup.outputMarkdownPath).toBe(cleanupMarkdownPath); + expect(result.cleanup.verificationFailureOutcomeFocus).toEqual( + expect.arrayContaining([ + "artifactValidator:issues_present", + ]), + ); + expect(result.cleanup.verificationFailureCaseCount).toBe(1); + expect(result.cleanup.verificationBlockingFailureCaseCount).toBe(0); + expect(result.cleanup.verificationAdvisoryFailureCaseCount).toBe(1); + expect(result.cleanup.verificationDegradedBlockingFailureCaseCount).toBe(0); + expect(result.cleanup.verificationRecoveredCaseCount).toBe(3); + expect(result.cleanup.currentVerificationRecoveredCaseCount).toBe(3); + expect(result.cleanup.currentRecoveredBaselineFocus).toEqual( + expect.arrayContaining([ + "artifactValidator:repaired", + "browserVerification:success", + "guiSmoke:passed", + ]), + ); + expect(result.dashboard.outputHtmlPath).toBe(dashboardHtmlPath); + + expect(fs.existsSync(summaryJsonPath)).toBe(true); + expect(fs.existsSync(summaryMarkdownPath)).toBe(true); + expect(fs.existsSync(trendJsonPath)).toBe(true); + expect(fs.existsSync(trendMarkdownPath)).toBe(true); + expect(fs.existsSync(cleanupJsonPath)).toBe(true); + expect(fs.existsSync(cleanupMarkdownPath)).toBe(true); + expect(fs.existsSync(dashboardHtmlPath)).toBe(true); + + const cleanupJson = JSON.parse(fs.readFileSync(cleanupJsonPath, "utf8")); + expect( + cleanupJson.recommendations.every( + (entry: Record) => + !Object.prototype.hasOwnProperty.call( + entry, + "focusObservabilityVerificationOutcomes", + ), + ), + ).toBe(true); + expect(() => + execFileSync( + process.execPath, + [ + path.join(repoRoot, "scripts/check-generated-slop-report.mjs"), + "--input", + cleanupJsonPath, + "--format", + "json", + ], + { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ), + ).not.toThrow(); + }, 30_000); + it("应记录本地 summary 历史,并生成非 seed 的 trend / cleanup", () => { const tempRoot = createTempRoot(); const historyDir = path.join(tempRoot, ".lime", "harness", "history"); const workspaceRoot = path.join(tempRoot, "workspace"); + const summaryJsonPath = path.join( + tempRoot, + ".lime", + "harness", + "reports", + "harness-eval-summary.json", + ); + const summaryMarkdownPath = path.join( + tempRoot, + ".lime", + "harness", + "reports", + "harness-eval-summary.md", + ); + const dashboardHtmlPath = path.join( + tempRoot, + ".lime", + "harness", + "reports", + "harness-dashboard.html", + ); const firstResult = runNodeScript("scripts/harness-eval-history-record.mjs", [ "--format", @@ -71,6 +186,12 @@ describe("harness-eval-history-record", () => { historyDir, "--workspace-root", workspaceRoot, + "--summary-json", + summaryJsonPath, + "--summary-markdown", + summaryMarkdownPath, + "--dashboard-html", + dashboardHtmlPath, "--output-json", path.join(tempRoot, "first.json"), ]); @@ -84,6 +205,12 @@ describe("harness-eval-history-record", () => { historyDir, "--workspace-root", workspaceRoot, + "--summary-json", + summaryJsonPath, + "--summary-markdown", + summaryMarkdownPath, + "--dashboard-html", + dashboardHtmlPath, "--output-json", path.join(tempRoot, "second.json"), ], @@ -91,11 +218,72 @@ describe("harness-eval-history-record", () => { expect(firstResult.historyCount).toBe(1); expect(firstResult.trend.sampleCount).toBe(1); + expect(firstResult.trend.currentObservabilityGapCaseCount).toBe(0); + expect(firstResult.trend.degradedObservabilityGapCaseCount).toBe(1); + expect(firstResult.trend.currentRecoveredVerificationCaseCount).toBe(3); + expect(firstResult.trend.currentRecoveredBaselineFocus).toEqual( + expect.arrayContaining([ + "artifactValidator:repaired", + "browserVerification:success", + "guiSmoke:passed", + ]), + ); + expect(firstResult.summary.outputJsonPath).toBe(summaryJsonPath); + expect(firstResult.summary.outputMarkdownPath).toBe(summaryMarkdownPath); + expect(firstResult.dashboard.outputHtmlPath).toBe(dashboardHtmlPath); expect(secondResult.historyCount).toBe(2); expect(secondResult.trend.sampleCount).toBe(2); + expect(secondResult.trend.currentObservabilityGapCaseCount).toBe(0); + expect(secondResult.trend.degradedObservabilityGapCaseCount).toBe(1); + expect(secondResult.trend.currentRecoveredVerificationCaseCount).toBe(3); + expect(secondResult.trend.currentRecoveredBaselineFocus).toEqual( + expect.arrayContaining([ + "artifactValidator:repaired", + "browserVerification:success", + "guiSmoke:passed", + ]), + ); expect(secondResult.cleanup.trendSampleCount).toBe(2); + expect(secondResult.cleanup.currentObservabilityGapCaseCount).toBe(0); + expect(secondResult.cleanup.degradedObservabilityGapCaseCount).toBe(1); + expect(secondResult.cleanup.verificationFailureOutcomeFocus).toEqual( + expect.arrayContaining([ + "artifactValidator:issues_present", + ]), + ); + expect(secondResult.cleanup.verificationFailureCaseCount).toBe(1); + expect(secondResult.cleanup.verificationBlockingFailureCaseCount).toBe(0); + expect(secondResult.cleanup.verificationAdvisoryFailureCaseCount).toBe(1); + expect(secondResult.cleanup.verificationDegradedBlockingFailureCaseCount).toBe(0); + expect(secondResult.cleanup.verificationRecoveredCaseCount).toBe(3); + expect(secondResult.cleanup.currentVerificationRecoveredCaseCount).toBe(3); + expect(secondResult.cleanup.currentRecoveredBaselineFocus).toEqual( + expect.arrayContaining([ + "artifactValidator:repaired", + "browserVerification:success", + "guiSmoke:passed", + ]), + ); expect(fs.existsSync(secondResult.recordedSummaryPath)).toBe(true); + expect(fs.existsSync(summaryJsonPath)).toBe(true); + expect(fs.existsSync(summaryMarkdownPath)).toBe(true); + expect(fs.existsSync(dashboardHtmlPath)).toBe(true); + const dashboardHtml = fs.readFileSync(dashboardHtmlPath, "utf8"); + expect(dashboardHtml).toContain("Harness Engine Nightly Dashboard"); + expect(dashboardHtml).toContain("Current Gap"); + expect(dashboardHtml).toContain("Degraded Gap"); + expect(dashboardHtml).toContain("Current Blocking"); + expect(dashboardHtml).toContain("Current Advisory"); + expect(dashboardHtml).toContain("Current Recovered"); + expect(dashboardHtml).toContain("Degraded Blocking"); + expect(dashboardHtml).toContain("Recovered Outcomes"); + expect(dashboardHtml).toContain("Cleanup 建议"); + expect(dashboardHtml).toContain("Observability Gap 角色"); + expect(dashboardHtml).toContain("Verification Outcome 焦点"); + expect(dashboardHtml).toContain("Current Recovered Baseline"); + expect(dashboardHtml).toContain("artifactValidator"); + expect(dashboardHtml).toContain("issues_present"); expect( fs.existsSync( path.join( diff --git a/scripts/lib/harness-eval-history-window.test.ts b/scripts/lib/harness-eval-history-window.test.ts index f377f90da..4cfb64fd4 100644 --- a/scripts/lib/harness-eval-history-window.test.ts +++ b/scripts/lib/harness-eval-history-window.test.ts @@ -159,7 +159,7 @@ afterEach(() => { }); describe("Harness eval history window", () => { - it("runner 应记录并裁剪历史窗口,trend 应复用该目录", () => { + it("history-record 应记录并裁剪历史窗口,trend 应复用该目录", () => { const tempRoot = createTempRoot(); const caseDir = createReplayFixture(tempRoot); const manifestPath = path.join(tempRoot, "manifest.json"); @@ -167,40 +167,37 @@ describe("Harness eval history window", () => { writeJson(manifestPath, createHarnessManifest(caseDir)); - runNodeScript("scripts/harness-eval-runner.mjs", [ + runNodeScript("scripts/harness-eval-history-record.mjs", [ "--format", "json", "--manifest", manifestPath, - "--record-history-dir", + "--history-dir", historyDir, - "--history-retain", + "--retain", "2", - "--no-strict", ]); sleepMs(10); - runNodeScript("scripts/harness-eval-runner.mjs", [ + runNodeScript("scripts/harness-eval-history-record.mjs", [ "--format", "json", "--manifest", manifestPath, - "--record-history-dir", + "--history-dir", historyDir, - "--history-retain", + "--retain", "2", - "--no-strict", ]); sleepMs(10); - runNodeScript("scripts/harness-eval-runner.mjs", [ + runNodeScript("scripts/harness-eval-history-record.mjs", [ "--format", "json", "--manifest", manifestPath, - "--record-history-dir", + "--history-dir", historyDir, - "--history-retain", + "--retain", "2", - "--no-strict", ]); const historyFiles = fs @@ -222,5 +219,5 @@ describe("Harness eval history window", () => { expect(report.signals).not.toContain( "样本数不足 2,当前仅形成 trend seed,还不能判断长期退化。", ); - }); + }, 30_000); }); diff --git a/scripts/lib/harness-eval-repo-fixtures.test.ts b/scripts/lib/harness-eval-repo-fixtures.test.ts new file mode 100644 index 000000000..8420f0b9c --- /dev/null +++ b/scripts/lib/harness-eval-repo-fixtures.test.ts @@ -0,0 +1,155 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { afterEach, describe, expect, it } from "vitest"; + +const repoRoot = process.cwd(); +const tempRoots: string[] = []; + +function createTempRoot() { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "lime-harness-repo-fixtures-"), + ); + tempRoots.push(tempRoot); + return tempRoot; +} + +function runHarnessEval(args: string[]) { + const output = execFileSync( + process.execPath, + [path.join(repoRoot, "scripts/harness-eval-runner.mjs"), ...args], + { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + return JSON.parse(output); +} + +afterEach(() => { + while (tempRoots.length > 0) { + const tempRoot = tempRoots.pop(); + if (tempRoot) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + } +}); + +describe("Harness repo fixtures", () => { + it("应固定 current 与 degraded observability 样本职责", () => { + const workspaceRoot = createTempRoot(); + const summary = runHarnessEval([ + "--format", + "json", + "--manifest", + "docs/test/harness-evals.manifest.json", + "--workspace-root", + workspaceRoot, + "--no-strict", + ]); + + const repoFixtureSuite = summary.suites.find( + (entry: { id: string }) => entry.id === "repo-fixtures", + ); + expect(repoFixtureSuite).toBeTruthy(); + expect(repoFixtureSuite.stats.caseCount).toBe(2); + expect(repoFixtureSuite.stats.readyCount).toBe(2); + expect(summary.totals.observabilityGapCaseCount).toBe(1); + expect(summary.totals.currentObservabilityGapCaseCount).toBe(0); + expect(summary.totals.degradedObservabilityGapCaseCount).toBe(1); + + const currentCase = repoFixtureSuite.cases.find( + (entry: { caseId: string }) => + entry.caseId === "fixture-minimal-pending-request", + ); + expect(currentCase).toBeTruthy(); + expect(currentCase.status).toBe("ready"); + expect(currentCase.observabilityGapCount).toBe(0); + expect(currentCase.observabilitySignals).toContain( + "requestTelemetry:exported", + ); + expect(currentCase.observabilitySignals).toContain( + "artifactValidator:exported", + ); + expect(currentCase.observabilitySignals).toContain( + "browserVerification:exported", + ); + expect(currentCase.observabilitySignals).toContain("guiSmoke:exported"); + expect(currentCase.observabilityVerificationOutcomes).toContain( + "artifactValidator:issues_present", + ); + expect(currentCase.observabilityVerificationOutcomes).toContain( + "artifactValidator:repaired", + ); + expect(currentCase.observabilityVerificationOutcomes).toContain( + "browserVerification:success", + ); + expect(currentCase.observabilityVerificationOutcomes).toContain( + "guiSmoke:passed", + ); + + const degradedCase = repoFixtureSuite.cases.find( + (entry: { caseId: string }) => + entry.caseId === "fixture-minimal-observability-gap", + ); + expect(degradedCase).toBeTruthy(); + expect(degradedCase.status).toBe("ready"); + expect(degradedCase.observabilityGapCount).toBe(2); + expect(degradedCase.observabilitySignals).toContain( + "requestTelemetry:known_gap", + ); + expect(degradedCase.observabilitySignals).toContain( + "artifactValidator:known_gap", + ); + + const gapCaseIds = repoFixtureSuite.cases + .filter( + (entry: { observabilityGapCount: number }) => + entry.observabilityGapCount > 0, + ) + .map((entry: { caseId: string }) => entry.caseId); + expect(gapCaseIds).toEqual(["fixture-minimal-observability-gap"]); + + const observabilityBreakdownNames = summary.breakdowns.observabilitySignals.map( + (entry: { name: string }) => entry.name, + ); + expect(observabilityBreakdownNames).toContain("requestTelemetry:exported"); + expect(observabilityBreakdownNames).toContain("requestTelemetry:known_gap"); + expect(observabilityBreakdownNames).toContain("artifactValidator:exported"); + expect(observabilityBreakdownNames).toContain( + "artifactValidator:known_gap", + ); + + const verificationOutcomeBreakdownNames = + summary.breakdowns.observabilityVerificationOutcomes.map( + (entry: { name: string }) => entry.name, + ); + expect(verificationOutcomeBreakdownNames).toContain( + "artifactValidator:issues_present", + ); + expect(verificationOutcomeBreakdownNames).toContain( + "artifactValidator:repaired", + ); + expect(verificationOutcomeBreakdownNames).toContain( + "browserVerification:success", + ); + expect(verificationOutcomeBreakdownNames).toContain("guiSmoke:passed"); + + const currentVerificationOutcomeBreakdownNames = + summary.breakdowns.currentObservabilityVerificationOutcomes.map( + (entry: { name: string }) => entry.name, + ); + expect(currentVerificationOutcomeBreakdownNames).toContain( + "artifactValidator:issues_present", + ); + expect(currentVerificationOutcomeBreakdownNames).toContain( + "browserVerification:success", + ); + + expect(summary.breakdowns.degradedObservabilityVerificationOutcomes).toEqual( + [], + ); + }); +}); diff --git a/scripts/lib/harness-review-decision-evals.test.ts b/scripts/lib/harness-review-decision-evals.test.ts index b52eed0ab..64f60ce0b 100644 --- a/scripts/lib/harness-review-decision-evals.test.ts +++ b/scripts/lib/harness-review-decision-evals.test.ts @@ -116,13 +116,36 @@ function createWorkspaceSessionArtifacts(tempRoot: string, sessionId: string) { }, { signal: "requestTelemetry", - status: "unlinked", + status: "exported", }, { signal: "artifactValidator", status: "known_gap", }, ], + verificationSummary: { + artifactValidator: { + applicable: true, + recordCount: 1, + issueCount: 1, + repairedCount: 1, + fallbackUsedCount: 0, + }, + browserVerification: { + recordCount: 1, + successCount: 1, + failureCount: 0, + unknownCount: 0, + latestUpdatedAt: "2026-03-27T11:22:00Z", + }, + guiSmoke: { + status: "completed", + exitCode: 0, + passed: true, + updatedAt: "2026-03-27T11:23:30Z", + hasOutputPreview: true, + }, + }, }, linkedArtifacts: { handoffBundle: { @@ -325,6 +348,8 @@ describe("Harness review decision / eval integration", () => { expect(summary.totals.reviewDecisionRecordedCount).toBe(1); expect(summary.totals.observabilityGapCaseCount).toBe(1); + expect(summary.totals.currentObservabilityGapCaseCount).toBe(1); + expect(summary.totals.degradedObservabilityGapCaseCount).toBe(0); expect(summary.breakdowns.reviewDecisionStatuses).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -343,21 +368,54 @@ describe("Harness review decision / eval integration", () => { ); expect(summary.breakdowns.observabilitySignals).toEqual( expect.arrayContaining([ - expect.objectContaining({ - name: "requestTelemetry:unlinked", - caseCount: 1, - }), expect.objectContaining({ name: "artifactValidator:known_gap", caseCount: 1, }), ]), ); + expect(summary.breakdowns.observabilityVerificationOutcomes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "artifactValidator:issues_present", + caseCount: 1, + }), + expect.objectContaining({ + name: "artifactValidator:repaired", + caseCount: 1, + }), + expect.objectContaining({ + name: "browserVerification:success", + caseCount: 1, + }), + expect.objectContaining({ + name: "guiSmoke:passed", + caseCount: 1, + }), + ]), + ); + expect(summary.breakdowns.currentObservabilityVerificationOutcomes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "browserVerification:success", + caseCount: 1, + }), + ]), + ); + expect(summary.breakdowns.degradedObservabilityVerificationOutcomes).toEqual( + [], + ); expect(summary.suites[0].cases[0]).toMatchObject({ reviewDecisionStatus: "accepted", reviewRiskLevel: "high", reviewHumanReviewer: "Lime Maintainer", - observabilityGapCount: 2, + observabilityGapCount: 1, + observabilityVerificationOutcomes: expect.arrayContaining([ + "artifactValidator:issues_present", + "artifactValidator:repaired", + "browserVerification:success", + "guiSmoke:passed", + ]), }); }); @@ -376,6 +434,8 @@ describe("Harness review decision / eval integration", () => { pendingRequestCaseCount: 0, needsHumanReviewCount: 1, observabilityGapCaseCount: 1, + currentObservabilityGapCaseCount: 1, + degradedObservabilityGapCaseCount: 0, }, breakdowns: { suiteTags: [], @@ -400,9 +460,10 @@ describe("Harness review decision / eval integration", () => { needsHumanReviewCount: 1, }, ], - observabilitySignals: [ + observabilitySignals: [], + observabilityVerificationOutcomes: [ { - name: "requestTelemetry:unlinked", + name: "browserVerification:unknown", caseCount: 1, readyCount: 1, invalidCount: 0, @@ -410,6 +471,17 @@ describe("Harness review decision / eval integration", () => { needsHumanReviewCount: 1, }, ], + currentObservabilityVerificationOutcomes: [ + { + name: "browserVerification:unknown", + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 1, + }, + ], + degradedObservabilityVerificationOutcomes: [], }, suites: [], }); @@ -424,6 +496,8 @@ describe("Harness review decision / eval integration", () => { pendingRequestCaseCount: 0, needsHumanReviewCount: 0, observabilityGapCaseCount: 1, + currentObservabilityGapCaseCount: 0, + degradedObservabilityGapCaseCount: 1, }, breakdowns: { suiteTags: [], @@ -458,6 +532,43 @@ describe("Harness review decision / eval integration", () => { needsHumanReviewCount: 0, }, ], + observabilityVerificationOutcomes: [ + { + name: "browserVerification:failure", + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + { + name: "guiSmoke:failed", + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + ], + currentObservabilityVerificationOutcomes: [], + degradedObservabilityVerificationOutcomes: [ + { + name: "browserVerification:failure", + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + { + name: "guiSmoke:failed", + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + ], }, suites: [], }); @@ -505,12 +616,6 @@ describe("Harness review decision / eval integration", () => { ); expect(report.classificationDeltas.observabilitySignals).toEqual( expect.arrayContaining([ - expect.objectContaining({ - name: "requestTelemetry:unlinked", - delta: expect.objectContaining({ - caseCount: -1, - }), - }), expect.objectContaining({ name: "artifactValidator:known_gap", delta: expect.objectContaining({ @@ -519,5 +624,234 @@ describe("Harness review decision / eval integration", () => { }), ]), ); + expect( + report.classificationDeltas.observabilitySignals.find( + (entry) => entry.name === "requestTelemetry:known_gap", + ), + ).toBeUndefined(); + expect(report.classificationDeltas.observabilityVerificationOutcomes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "browserVerification:failure", + delta: expect.objectContaining({ + caseCount: 1, + }), + }), + expect.objectContaining({ + name: "browserVerification:unknown", + delta: expect.objectContaining({ + caseCount: -1, + }), + }), + expect.objectContaining({ + name: "guiSmoke:failed", + delta: expect.objectContaining({ + caseCount: 1, + }), + }), + ]), + ); + expect( + report.classificationDeltas.currentObservabilityVerificationOutcomes, + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "browserVerification:unknown", + delta: expect.objectContaining({ + caseCount: -1, + }), + }), + ]), + ); + expect( + report.classificationDeltas.degradedObservabilityVerificationOutcomes, + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "browserVerification:failure", + delta: expect.objectContaining({ + caseCount: 1, + }), + }), + expect.objectContaining({ + name: "guiSmoke:failed", + delta: expect.objectContaining({ + caseCount: 1, + }), + }), + ]), + ); + expect(report.delta.currentObservabilityGapCaseCount).toBe(-1); + expect(report.delta.degradedObservabilityGapCaseCount).toBe(1); + expect(report.latest.totals.currentObservabilityGapCaseCount).toBe(0); + expect(report.latest.totals.degradedObservabilityGapCaseCount).toBe(1); + }); + + it("harness-eval-trend-report 应暴露 current recovered baseline 的新增趋势", () => { + const tempRoot = createTempRoot(); + const baselinePath = path.join(tempRoot, "baseline-recovered.json"); + const latestPath = path.join(tempRoot, "latest-recovered.json"); + + writeJson(baselinePath, { + generatedAt: "2026-03-28T10:00:00Z", + totals: { + suiteCount: 1, + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + observabilityGapCaseCount: 0, + currentObservabilityGapCaseCount: 0, + degradedObservabilityGapCaseCount: 0, + }, + breakdowns: { + suiteTags: [], + failureModes: [], + reviewDecisionStatuses: [], + reviewRiskLevels: [], + observabilitySignals: [], + observabilityVerificationOutcomes: [ + { + name: "browserVerification:success", + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + ], + currentObservabilityVerificationOutcomes: [ + { + name: "browserVerification:success", + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + ], + degradedObservabilityVerificationOutcomes: [], + }, + suites: [], + }); + + writeJson(latestPath, { + generatedAt: "2026-03-28T12:00:00Z", + totals: { + suiteCount: 1, + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + observabilityGapCaseCount: 0, + currentObservabilityGapCaseCount: 0, + degradedObservabilityGapCaseCount: 0, + }, + breakdowns: { + suiteTags: [], + failureModes: [], + reviewDecisionStatuses: [], + reviewRiskLevels: [], + observabilitySignals: [], + observabilityVerificationOutcomes: [ + { + name: "artifactValidator:repaired", + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + { + name: "browserVerification:success", + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + { + name: "guiSmoke:passed", + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + ], + currentObservabilityVerificationOutcomes: [ + { + name: "artifactValidator:repaired", + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + { + name: "browserVerification:success", + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + { + name: "guiSmoke:passed", + caseCount: 1, + readyCount: 1, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + ], + degradedObservabilityVerificationOutcomes: [], + }, + suites: [], + }); + + const report = runNodeScript("scripts/harness-eval-trend-report.mjs", [ + "--format", + "json", + "--input", + baselinePath, + "--input", + latestPath, + ]); + + expect(report.latest.totals.currentRecoveredVerificationCaseCount).toBe(3); + expect(report.delta.currentRecoveredVerificationCaseCount).toBe(2); + expect( + report.classificationDeltas.currentRecoveredObservabilityVerificationOutcomes, + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "artifactValidator:repaired", + delta: expect.objectContaining({ + caseCount: 1, + }), + }), + expect.objectContaining({ + name: "browserVerification:success", + delta: expect.objectContaining({ + caseCount: 0, + }), + }), + expect.objectContaining({ + name: "guiSmoke:passed", + delta: expect.objectContaining({ + caseCount: 1, + }), + }), + ]), + ); + expect(report.signals).toContain( + "current recovered verification baseline `artifactValidator:repaired` 新增 1,说明主线路径正在形成正向基线。", + ); + expect(report.signals).toContain( + "current recovered verification baseline `guiSmoke:passed` 新增 1,说明主线路径正在形成正向基线。", + ); }); }); diff --git a/scripts/local-ci.mjs b/scripts/local-ci.mjs index fde046293..107a37cbb 100644 --- a/scripts/local-ci.mjs +++ b/scripts/local-ci.mjs @@ -10,6 +10,14 @@ const rootDir = process.cwd(); const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; const cargoCommand = process.platform === "win32" ? "cargo.exe" : "cargo"; +const BRIDGE_REASON_LABELS = { + bridge_contracts: "bridge/contracts", + bridge_runtime: "DevBridge / mock / bridge runtime", + fallback_full_suite: "兜底全量", + full_suite: "full 模式", + harness_cleanup_contract: "harness cleanup contract", + workflow_full_suite: "workflow 全量", +}; function parseArgs(argv) { const result = { @@ -107,7 +115,16 @@ function printSummary(changedFiles, tasks) { console.log("[local-ci] - 前端校验"); } if (tasks.bridge) { - console.log("[local-ci] - bridge 校验"); + const bridgeReasonLabels = Array.isArray(tasks.bridgeReasons) + ? tasks.bridgeReasons + .map((reason) => BRIDGE_REASON_LABELS[reason] ?? reason) + .filter(Boolean) + : []; + console.log( + bridgeReasonLabels.length > 0 + ? `[local-ci] - bridge 校验(${bridgeReasonLabels.join(" / ")})` + : "[local-ci] - bridge 校验", + ); } if (tasks.guiSmoke) { console.log("[local-ci] - GUI 冒烟"); diff --git a/scripts/quality-task-planner.mjs b/scripts/quality-task-planner.mjs index 515828996..6d7d7849c 100644 --- a/scripts/quality-task-planner.mjs +++ b/scripts/quality-task-planner.mjs @@ -39,13 +39,28 @@ const FRONTEND_TOOLING_FILES = new Set([ const BRIDGE_FILES = new Set([ "vite.config.ts", "scripts/check-command-contracts.mjs", + "scripts/check-generated-slop-report.mjs", "scripts/check-dev-bridge-health.mjs", + "scripts/harness-eval-history-record.mjs", + "scripts/harness-eval-trend-report.mjs", + "scripts/report-generated-slop.mjs", "scripts/social-workbench-e2e-smoke.mjs", "scripts/chrome-bridge-e2e.mjs", "scripts/verify-gui-smoke.mjs", + "scripts/lib/generated-slop-report-core.mjs", + "scripts/lib/harness-dashboard-core.mjs", "docs/aiprompts/playwright-e2e.md", ]); +const HARNESS_CLEANUP_CONTRACT_FILES = new Set([ + "scripts/check-generated-slop-report.mjs", + "scripts/harness-eval-history-record.mjs", + "scripts/harness-eval-trend-report.mjs", + "scripts/report-generated-slop.mjs", + "scripts/lib/generated-slop-report-core.mjs", + "scripts/lib/harness-dashboard-core.mjs", +]); + const INTEGRITY_FILES = new Set([ "package.json", "src-tauri/Cargo.toml", @@ -249,6 +264,45 @@ function isBridgeChange(file) { ); } +function isHarnessCleanupContractChange(file) { + return HARNESS_CLEANUP_CONTRACT_FILES.has(file); +} + +function collectBridgeReasons(changedFiles, { full = false, fallback = false, workflow = false } = {}) { + if (full) { + return ["full_suite"]; + } + + if (fallback) { + return ["fallback_full_suite"]; + } + + if (workflow) { + return ["workflow_full_suite"]; + } + + const reasons = []; + + if (changedFiles.some(isHarnessCleanupContractChange)) { + reasons.push("harness_cleanup_contract"); + } + + if ( + changedFiles.some( + (file) => + isBridgeChange(file) && !isHarnessCleanupContractChange(file), + ) + ) { + reasons.push("bridge_runtime"); + } + + if (reasons.length === 0 && changedFiles.some(isBridgeChange)) { + reasons.push("bridge_contracts"); + } + + return reasons; +} + function isGuiSmokeChange(file) { return ( GUI_SMOKE_FILES.has(file) || @@ -272,6 +326,7 @@ function detectTasks(changedFiles, { full = false } = {}) { frontend: true, rust: true, bridge: true, + bridgeReasons: collectBridgeReasons([], { full: true }), guiSmoke: true, docs: true, docsOnly: false, @@ -286,6 +341,7 @@ function detectTasks(changedFiles, { full = false } = {}) { frontend: true, rust: true, bridge: true, + bridgeReasons: collectBridgeReasons([], { fallback: true }), guiSmoke: true, docs: true, docsOnly: false, @@ -301,6 +357,7 @@ function detectTasks(changedFiles, { full = false } = {}) { frontend: true, rust: true, bridge: true, + bridgeReasons: collectBridgeReasons([], { workflow: true }), guiSmoke: true, docs: true, docsOnly: false, @@ -315,6 +372,7 @@ function detectTasks(changedFiles, { full = false } = {}) { frontend: false, rust: false, bridge: false, + bridgeReasons: [], guiSmoke: false, docs: true, docsOnly: true, @@ -323,11 +381,14 @@ function detectTasks(changedFiles, { full = false } = {}) { }; } + const bridge = changedFiles.some(isBridgeChange); + return { integrity: changedFiles.some(isIntegrityChange), frontend: changedFiles.some(isFrontendChange), rust: changedFiles.some(isRustChange), - bridge: changedFiles.some(isBridgeChange), + bridge, + bridgeReasons: bridge ? collectBridgeReasons(changedFiles) : [], guiSmoke: changedFiles.some(isGuiSmokeChange), docs: changedFiles.some(isDocsChange), docsOnly: false, @@ -359,4 +420,10 @@ function planQualityTasks({ }; } -export { collectChangedFiles, detectTasks, planQualityTasks, resolveDiffBase }; +export { + collectBridgeReasons, + collectChangedFiles, + detectTasks, + planQualityTasks, + resolveDiffBase, +}; diff --git a/scripts/quality-task-planner.test.ts b/scripts/quality-task-planner.test.ts new file mode 100644 index 000000000..2793ed7cb --- /dev/null +++ b/scripts/quality-task-planner.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { detectTasks } from "./quality-task-planner.mjs"; + +describe("quality-task-planner", () => { + it("应把 harness cleanup/report 主链文件归到 bridge/contracts 风险", () => { + const tasks = detectTasks([ + "scripts/lib/generated-slop-report-core.mjs", + "scripts/check-generated-slop-report.mjs", + "scripts/harness-eval-history-record.mjs", + ]); + + expect(tasks.bridge).toBe(true); + expect(tasks.bridgeReasons).toContain("harness_cleanup_contract"); + expect(tasks.docsOnly).toBe(false); + }); + + it("应把 harness dashboard 渲染文件归到 bridge/contracts 风险", () => { + const tasks = detectTasks(["scripts/lib/harness-dashboard-core.mjs"]); + + expect(tasks.bridge).toBe(true); + expect(tasks.bridgeReasons).toContain("harness_cleanup_contract"); + expect(tasks.docsOnly).toBe(false); + }); + + it("应把 DevBridge 主链改动标记为 bridge runtime 风险", () => { + const tasks = detectTasks(["src/lib/dev-bridge/safeInvoke.ts"]); + + expect(tasks.bridge).toBe(true); + expect(tasks.bridgeReasons).toContain("bridge_runtime"); + expect(tasks.docsOnly).toBe(false); + }); +}); diff --git a/scripts/quality-task-selector.mjs b/scripts/quality-task-selector.mjs index 11c962621..b14424f7d 100644 --- a/scripts/quality-task-selector.mjs +++ b/scripts/quality-task-selector.mjs @@ -68,6 +68,7 @@ function printGithubFormat(result) { `frontend=${tasks.frontend}`, `rust=${tasks.rust}`, `bridge=${tasks.bridge}`, + `bridge_reasons=${Array.isArray(tasks.bridgeReasons) ? tasks.bridgeReasons.join(",") : ""}`, `gui_smoke=${tasks.guiSmoke}`, `docs=${tasks.docs}`, `docs_only=${tasks.docsOnly}`, diff --git a/scripts/start-web-bridge-dev.mjs b/scripts/start-web-bridge-dev.mjs index 7ab0e1915..e1876c68b 100755 --- a/scripts/start-web-bridge-dev.mjs +++ b/scripts/start-web-bridge-dev.mjs @@ -5,7 +5,11 @@ import process from 'node:process'; const DEV_URL = process.env.LIME_WEB_BRIDGE_URL?.trim() || 'http://127.0.0.1:1420/'; const DEV_URL_TIMEOUT_MS = 1_500; +const REUSE_ONLY_TIMEOUT_MS = 10_000; +const REUSE_ONLY_INTERVAL_MS = 500; const ROOT_MARKERS = ['Lime', '
']; +const REUSE_EXISTING_ONLY = + process.env.LIME_WEB_BRIDGE_REUSE_EXISTING_ONLY?.trim() === '1'; const env = { ...process.env }; delete env.TAURI_ENV_PLATFORM; @@ -39,6 +43,32 @@ async function probeExistingDevServer(url) { } } +async function waitForExistingDevServer(url) { + const startedAt = Date.now(); + let lastProbe = { reachable: false }; + + while (Date.now() - startedAt < REUSE_ONLY_TIMEOUT_MS) { + lastProbe = await probeExistingDevServer(url); + + if (lastProbe.reachable && lastProbe.isLimeDevShell) { + return lastProbe; + } + + if (lastProbe.reachable && !lastProbe.isLimeDevShell) { + const statusLabel = `${lastProbe.status} ${lastProbe.statusText}`.trim(); + throw new Error( + `[dev:web-bridge] ${DEV_URL} 已被其他服务占用,且返回内容不是 Lime dev shell(${statusLabel})。请先关闭占用进程后重试。`, + ); + } + + await new Promise((resolve) => setTimeout(resolve, REUSE_ONLY_INTERVAL_MS)); + } + + throw new Error( + `[dev:web-bridge] 要求复用已有 Lime dev server,但 ${url} 在 ${REUSE_ONLY_TIMEOUT_MS}ms 内未就绪。`, + ); +} + async function waitForExitSignal() { await new Promise((resolve) => { const handleExit = () => resolve(); @@ -64,6 +94,13 @@ function startVite() { } async function main() { + if (REUSE_EXISTING_ONLY) { + await waitForExistingDevServer(DEV_URL); + console.log(`[dev:web-bridge] 复用已存在的 Lime dev server: ${DEV_URL}`); + await waitForExitSignal(); + return; + } + const existingServer = await probeExistingDevServer(DEV_URL); if (existingServer.reachable) { diff --git a/scripts/verify-gui-smoke.mjs b/scripts/verify-gui-smoke.mjs index d4422e814..ed3ab574f 100644 --- a/scripts/verify-gui-smoke.mjs +++ b/scripts/verify-gui-smoke.mjs @@ -1,31 +1,108 @@ #!/usr/bin/env node import { spawn, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; -const DEFAULTS = { - appUrl: "http://127.0.0.1:1420/", - healthUrl: "http://127.0.0.1:3030/health", - timeoutMs: 600_000, - intervalMs: 1_000, - reuseRunning: false, - sampleProjectName: "Lime Smoke Workspace", -}; - const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootDir = path.resolve(__dirname, ".."); const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; const LIME_SKIP_STARTUP_WINDOW_REVEAL = "LIME_SKIP_STARTUP_WINDOW_REVEAL"; const LIME_DISABLE_SINGLE_INSTANCE = "LIME_DISABLE_SINGLE_INSTANCE"; +const LIME_WEB_BRIDGE_REUSE_EXISTING_ONLY = + "LIME_WEB_BRIDGE_REUSE_EXISTING_ONLY"; +const LIME_WEB_BRIDGE_URL = "LIME_WEB_BRIDGE_URL"; +const ROOT_MARKERS = ['Lime', '
']; +const SHARED_TAURI_TARGET_DIR = path.join(rootDir, "src-tauri", "target"); +const ISOLATED_GUI_SMOKE_TARGET_DIR = path.join( + os.tmpdir(), + "lime-gui-smoke-target", +); +const GUI_SMOKE_TEMP_CONFIG_BASENAME_PREFIX = "lime-gui-smoke-tauri-"; +const GUI_SMOKE_COLD_TIMEOUT_MS = 1_800_000; +const GUI_SMOKE_WARM_TIMEOUT_MS = 600_000; +const GUI_SMOKE_BRIDGE_HEARTBEAT_MS = 30_000; +const GUI_SMOKE_COMPILE_GRACE_MS = 900_000; +const GUI_SMOKE_MAX_COMPILE_GRACE_EXTENSIONS = 2; +const GUI_SMOKE_BOOT_GRACE_MS = 60_000; +const HEADLESS_TAURI_CONFIG_PATH = path.join( + rootDir, + "src-tauri", + "tauri.conf.headless.json", +); +const tauriCommand = + process.platform === "win32" + ? path.join(rootDir, "node_modules", ".bin", "tauri.cmd") + : path.join(rootDir, "node_modules", ".bin", "tauri"); const state = { child: null, cleanedUp: false, + tempConfigPath: null, }; +function resolveTargetBinaryPath(targetDir) { + const appBinaryName = process.platform === "win32" ? "lime.exe" : "lime"; + return path.join(targetDir, "debug", appBinaryName); +} + +function listTargetLockHolderCommands(targetDir) { + if (process.platform === "win32") { + return []; + } + + const lockPath = path.join(targetDir, "debug", ".cargo-lock"); + const pidOutput = runQuietCommand("lsof", ["-t", "--", lockPath]); + if (!pidOutput) { + return []; + } + + const pids = [ + ...new Set( + pidOutput + .split("\n") + .map((item) => item.trim()) + .filter(Boolean), + ), + ]; + + return pids + .map((pid) => runQuietCommand("ps", ["-p", pid, "-o", "command="])) + .filter(Boolean); +} + +function resolvePreferredCargoTargetDir() { + const sharedTargetLockHolders = listTargetLockHolderCommands( + SHARED_TAURI_TARGET_DIR, + ); + if (sharedTargetLockHolders.length === 0) { + return SHARED_TAURI_TARGET_DIR; + } + + return ISOLATED_GUI_SMOKE_TARGET_DIR; +} + +function resolveDefaultTimeoutMs(cargoTargetDir) { + const binaryPath = resolveTargetBinaryPath(cargoTargetDir); + return fs.existsSync(binaryPath) + ? GUI_SMOKE_WARM_TIMEOUT_MS + : GUI_SMOKE_COLD_TIMEOUT_MS; +} + +const DEFAULTS = { + appUrl: "http://127.0.0.1:1420/", + healthUrl: "http://127.0.0.1:3030/health", + cargoTargetDir: resolvePreferredCargoTargetDir(), + intervalMs: 1_000, + reuseRunning: false, + sampleProjectName: "Lime Smoke Workspace", +}; +DEFAULTS.timeoutMs = resolveDefaultTimeoutMs(DEFAULTS.cargoTargetDir); + function printHelp() { console.log(` Lime GUI 冒烟入口 @@ -41,9 +118,10 @@ Lime GUI 冒烟入口 选项: --app-url 前端地址,默认 http://127.0.0.1:1420/ --health-url DevBridge 健康检查地址,默认 http://127.0.0.1:3030/health - --timeout-ms 等待 headless / bridge / smoke 的超时,默认 600000 + --timeout-ms 等待 headless / bridge / smoke 的超时,默认冷启动 1800000 / 热启动 600000 --interval-ms 轮询间隔,默认 1000 --sample-project-name workspace 路径校验使用的示例项目名 + --cargo-target-dir 指定 Cargo target 目录;默认优先复用 src-tauri/target,无锁时共享,否则回退独立 GUI smoke target --reuse-running 复用已启动的 headless Tauri,不主动拉起 -h, --help 显示帮助 `); @@ -85,6 +163,12 @@ function parseArgs(argv) { continue; } + if (arg === "--cargo-target-dir" && argv[index + 1]) { + options.cargoTargetDir = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--reuse-running") { options.reuseRunning = true; continue; @@ -116,6 +200,10 @@ function parseArgs(argv) { throw new Error("--sample-project-name 不能为空"); } + if (!options.cargoTargetDir) { + throw new Error("--cargo-target-dir 不能为空"); + } + return options; } @@ -129,10 +217,22 @@ function assert(condition, message) { } } +function isLimeDevShell(html) { + return ROOT_MARKERS.some((marker) => html.includes(marker)); +} + function formatCommand(command, args) { return [command, ...args].join(" "); } +function trimTrailingSlash(value) { + return value.endsWith("/") ? value.slice(0, -1) : value; +} + +function buildNoopBeforeDevCommand() { + return `"${process.execPath}" -e "process.exit(0)"`; +} + function runCommand(command, args, label, timeoutMs) { console.log(`\n[verify:gui-smoke] > ${formatCommand(command, args)}`); const result = spawnSync(command, args, { @@ -160,66 +260,435 @@ function runCommand(command, args, label, timeoutMs) { } } -function startHeadlessTauri() { - console.log("[verify:gui-smoke] 启动 headless Tauri 环境..."); - state.child = spawn(npmCommand, ["run", "tauri:dev:headless"], { - cwd: rootDir, - stdio: "inherit", - env: { - ...process.env, - [LIME_SKIP_STARTUP_WINDOW_REVEAL]: "1", - [LIME_DISABLE_SINGLE_INSTANCE]: "1", - }, - detached: process.platform !== "win32", - }); -} +function createHeadlessTauriConfig(options, startupMode) { + const rawConfig = fs.readFileSync(HEADLESS_TAURI_CONFIG_PATH, "utf8"); + const config = JSON.parse(rawConfig); -async function stopHeadlessTauri() { - const child = state.child; - if (!child || state.cleanedUp) { - return; + config.build = { + ...(config.build || {}), + devUrl: trimTrailingSlash(options.appUrl), + }; + + if (startupMode.reuseExistingAppShell) { + config.build.beforeDevCommand = buildNoopBeforeDevCommand(); } - state.cleanedUp = true; - console.log("[verify:gui-smoke] 停止 headless Tauri 环境..."); + const tempConfigPath = path.join( + os.tmpdir(), + `lime-gui-smoke-tauri-${process.pid}.json`, + ); + fs.writeFileSync(tempConfigPath, JSON.stringify(config, null, 2)); + state.tempConfigPath = tempConfigPath; + return tempConfigPath; +} - if (typeof child.pid !== "number") { +function resolveUrlPort(url) { + try { + return new URL(url).port || (url.startsWith("https:") ? "443" : "80"); + } catch { + return ""; + } +} + +function runQuietCommand(command, args) { + try { + return spawnSync(command, args, { + cwd: rootDir, + stdio: ["ignore", "pipe", "pipe"], + env: process.env, + encoding: "utf8", + }).stdout.trim(); + } catch { + return ""; + } +} + +function listProcessTable() { + if (process.platform === "win32") { + return []; + } + + const output = runQuietCommand("ps", ["-axo", "pid=,ppid=,pgid=,command="]); + if (!output) { + return []; + } + + return output + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const match = line.match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/); + if (!match) { + return null; + } + + return { + pid: Number(match[1]), + ppid: Number(match[2]), + pgid: Number(match[3]), + command: match[4], + }; + }) + .filter(Boolean); +} + +function listProcessStats() { + if (process.platform === "win32") { + return []; + } + + const output = runQuietCommand("ps", [ + "-axo", + "pid=,ppid=,pgid=,%cpu=,%mem=,etime=,stat=,command=", + ]); + if (!output) { + return []; + } + + return output + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const match = line.match( + /^(\d+)\s+(\d+)\s+(\d+)\s+([\d.]+)\s+([\d.]+)\s+(\S+)\s+(\S+)\s+(.*)$/, + ); + if (!match) { + return null; + } + + return { + pid: Number(match[1]), + ppid: Number(match[2]), + pgid: Number(match[3]), + cpu: Number(match[4]), + mem: Number(match[5]), + etime: match[6], + stat: match[7], + command: match[8], + }; + }) + .filter(Boolean); +} + +function isGuiSmokeVerifierCommand(command) { + return ( + command.includes("scripts/verify-gui-smoke.mjs") || + command.includes("npm run verify:gui-smoke") + ); +} + +function isGuiSmokeTauriCommand(command) { + return ( + command.includes("tauri dev") && + command.includes(GUI_SMOKE_TEMP_CONFIG_BASENAME_PREFIX) + ); +} + +function summarizeGuiSmokeProcessCommand(command) { + if (command.includes("/bin/rustc")) { + return "rustc"; + } + + if (command.includes("cargo run --no-default-features")) { + return "cargo"; + } + + if (command.includes("tauri dev")) { + return "tauri"; + } + + if (command.includes("start-web-bridge-dev.mjs")) { + return "web-bridge"; + } + + if (command.includes("npm run dev:web-bridge")) { + return "npm:dev:web-bridge"; + } + + if (command.includes("npm exec vite") || command.includes("/bin/vite")) { + return "vite"; + } + + return command.split(/\s+/)[0]?.split("/").pop() || "process"; +} + +function resolveGuiSmokeProcessGroupId(startedByScript) { + if (startedByScript && typeof state.child?.pid === "number") { + return state.child.pid; + } + + const snapshot = inspectGuiSmokeTauriProcesses(); + const firstActive = snapshot.active[0]; + return firstActive ? firstActive.pgid || firstActive.pid : null; +} + +function listGuiSmokeGroupProcesses(startedByScript) { + const targetGroupId = resolveGuiSmokeProcessGroupId(startedByScript); + if (!Number.isInteger(targetGroupId) || targetGroupId < 1) { + return []; + } + + return listProcessStats().filter((item) => item.pgid === targetGroupId); +} + +function hasActiveGuiSmokeCompile(startedByScript) { + return listGuiSmokeGroupProcesses(startedByScript).some( + (item) => + item.command.includes("/bin/rustc") || + item.command.includes("cargo run --no-default-features"), + ); +} + +function describeGuiSmokeHeartbeat(startedByScript) { + const interestingProcesses = listGuiSmokeGroupProcesses(startedByScript) + .filter( + (item) => + item.command.includes("tauri dev") || + item.command.includes("cargo run --no-default-features") || + item.command.includes("/bin/rustc") || + item.command.includes("start-web-bridge-dev.mjs") || + item.command.includes("npm run dev:web-bridge") || + item.command.includes("npm exec vite") || + item.command.includes("/bin/vite"), + ) + .sort((left, right) => right.cpu - left.cpu || left.pid - right.pid) + .slice(0, 5); + + if (interestingProcesses.length === 0) { + return ""; + } + + return interestingProcesses + .map((item) => { + const cpu = Number.isFinite(item.cpu) ? item.cpu.toFixed(1) : "0.0"; + return `${summarizeGuiSmokeProcessCommand(item.command)} pid=${item.pid} etime=${item.etime} cpu=${cpu}% stat=${item.stat}`; + }) + .join(" | "); +} + +function inspectGuiSmokeTauriProcesses() { + const processTable = listProcessTable(); + const processByPid = new Map(processTable.map((item) => [item.pid, item])); + const active = []; + const stale = []; + + for (const item of processTable) { + if (!isGuiSmokeTauriCommand(item.command)) { + continue; + } + + if (item.pid === process.pid) { + continue; + } + + const parent = processByPid.get(item.ppid); + if (!parent || !isGuiSmokeVerifierCommand(parent.command)) { + stale.push(item); + continue; + } + + active.push(item); + } + + return { active, stale }; +} + +function isProcessAlive(pid) { + if (!Number.isInteger(pid) || pid < 1) { + return false; + } + + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function signalProcessTree(pid, signal) { + if (!Number.isInteger(pid) || pid < 1) { return; } if (process.platform === "win32") { - spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { + if (signal === "SIGKILL") { + spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { + stdio: "ignore", + }); + return; + } + + spawnSync("taskkill", ["/pid", String(pid), "/T"], { stdio: "ignore", }); return; } - try { - process.kill(-child.pid, "SIGTERM"); - } catch { - try { - child.kill("SIGTERM"); - } catch { - return; - } + const target = `-${pid}`; + spawnSync("kill", [`-${signal}`, target], { + stdio: "ignore", + }); +} + +async function stopProcessTree(pid, label) { + if (!Number.isInteger(pid) || pid < 1) { + return; } + console.log(`[verify:gui-smoke] 清理残留 ${label} 进程组: ${pid}`); + signalProcessTree(pid, "SIGTERM"); + for (let attempt = 0; attempt < 25; attempt += 1) { - if (child.exitCode !== null || child.signalCode) { + if (!isProcessAlive(pid)) { return; } await sleep(200); } - try { - process.kill(-child.pid, "SIGKILL"); - } catch { - try { - child.kill("SIGKILL"); - } catch { - // ignore + signalProcessTree(pid, "SIGKILL"); +} + +async function cleanupStaleGuiSmokeProcesses() { + const snapshot = inspectGuiSmokeTauriProcesses(); + if (snapshot.stale.length === 0) { + return 0; + } + + for (const item of snapshot.stale) { + const groupPid = item.pgid || item.pid; + await stopProcessTree(groupPid, `GUI smoke Tauri(PID=${item.pid})`); + } + + return snapshot.stale.length; +} + +function listListeningCommandsForPort(port) { + if (!port || process.platform === "win32") { + return []; + } + + const pidOutput = runQuietCommand("lsof", [ + "-nP", + `-iTCP:${port}`, + "-sTCP:LISTEN", + "-t", + ]); + if (!pidOutput) { + return []; + } + + const pids = [...new Set(pidOutput.split("\n").map((item) => item.trim()).filter(Boolean))]; + return pids + .map((pid) => runQuietCommand("ps", ["-p", pid, "-o", "command="])) + .filter(Boolean); +} + +function isLikelyLimeFrontendListener(command) { + return ( + command.includes(rootDir) && + (command.includes("vite") || + command.includes("npm run dev") || + command.includes("tauri dev")) + ); +} + +function startHeadlessTauri(options, startupMode) { + console.log("[verify:gui-smoke] 启动 headless Tauri 环境..."); + runCommand( + npmCommand, + ["run", "generate:agent-runtime-clients"], + "generate:agent-runtime-clients", + options.timeoutMs, + ); + runCommand( + npmCommand, + ["run", "generate:extension-site-adapters"], + "generate:extension-site-adapters", + options.timeoutMs, + ); + const tauriConfigPath = createHeadlessTauriConfig(options, startupMode); + + state.child = spawn( + tauriCommand, + ["dev", "--no-watch", "--config", tauriConfigPath], + { + cwd: rootDir, + stdio: "inherit", + env: { + ...process.env, + CARGO_TARGET_DIR: options.cargoTargetDir, + [LIME_SKIP_STARTUP_WINDOW_REVEAL]: "1", + [LIME_DISABLE_SINGLE_INSTANCE]: "1", + [LIME_WEB_BRIDGE_URL]: options.appUrl, + ...(startupMode.reuseExistingAppShell + ? { + [LIME_WEB_BRIDGE_REUSE_EXISTING_ONLY]: "1", + } + : {}), + }, + detached: process.platform !== "win32", + }, + ); +} + +async function stopHeadlessTauri() { + const child = state.child; + if (state.cleanedUp) { + return; + } + + state.cleanedUp = true; + if (child) { + console.log("[verify:gui-smoke] 停止 headless Tauri 环境..."); + } + + if (typeof child?.pid === "number") { + if (process.platform === "win32") { + spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { + stdio: "ignore", + }); + } else { + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + try { + child.kill("SIGTERM"); + } catch { + // ignore + } + } + + for (let attempt = 0; attempt < 25; attempt += 1) { + if (child.exitCode !== null || child.signalCode) { + break; + } + await sleep(200); + } + + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + try { + child.kill("SIGKILL"); + } catch { + // ignore + } + } } } + + try { + if (state.tempConfigPath) { + fs.unlinkSync(state.tempConfigPath); + } + } catch { + // ignore + } + + state.tempConfigPath = null; } async function waitForAppShell(options) { @@ -236,9 +705,7 @@ async function waitForAppShell(options) { } assert( - html.includes("Lime") || - html.includes('
') || - html.includes('
0 ? parts.join(" ") : "unknown"; +} + +async function waitForBridgeHealth(options, startedByScript) { + const startedAt = Date.now(); + let deadlineAt = startedAt + options.timeoutMs; + let compileGraceCount = 0; + let bootGraceUsed = false; + let lastError = null; + let lastHeartbeatAt = startedAt; + + console.log(`[bridge:health] 开始检查: ${options.healthUrl}`); + + while (true) { + if ( + startedByScript && + state.child && + (typeof state.child.exitCode === "number" || state.child.signalCode) + ) { + const exitDetail = describeChildExit(state.child); + const lastDetail = + lastError instanceof Error + ? `;最近一次健康检查错误: ${lastError.message}` + : ""; + throw new Error( + `[verify:gui-smoke] headless Tauri 在 DevBridge 就绪前提前退出(${exitDetail})${lastDetail}`, + ); + } + + try { + const response = await fetch(options.healthUrl, { + method: "GET", + signal: AbortSignal.timeout(Math.min(options.intervalMs, 1_500)), + }); + const text = await response.text(); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = null; + } + + const elapsed = Date.now() - startedAt; + const status = + payload && typeof payload === "object" ? payload.status : undefined; + console.log( + `[bridge:health] 就绪: ${options.healthUrl} (${elapsed}ms)${status ? ` status=${status}` : ""}`, + ); + return; + } catch (error) { + lastError = error; + const heartbeatAt = Date.now(); + if (heartbeatAt >= deadlineAt) { + const heartbeat = describeGuiSmokeHeartbeat(startedByScript); + if ( + compileGraceCount < GUI_SMOKE_MAX_COMPILE_GRACE_EXTENSIONS && + hasActiveGuiSmokeCompile(startedByScript) + ) { + compileGraceCount += 1; + deadlineAt = heartbeatAt + GUI_SMOKE_COMPILE_GRACE_MS; + console.log( + `[bridge:health] 检测到 GUI smoke 仍在编译,第 ${compileGraceCount} 次额外延长 ${GUI_SMOKE_COMPILE_GRACE_MS}ms 等待 DevBridge${heartbeat ? `;进程组: ${heartbeat}` : ""}`, + ); + continue; + } + + if ( + !bootGraceUsed && + startedByScript && + state.child && + state.child.exitCode === null && + !state.child.signalCode + ) { + bootGraceUsed = true; + deadlineAt = heartbeatAt + GUI_SMOKE_BOOT_GRACE_MS; + console.log( + `[bridge:health] 编译链已结束,额外延长 ${GUI_SMOKE_BOOT_GRACE_MS}ms 等待 headless Tauri 拉起 DevBridge${heartbeat ? `;进程组: ${heartbeat}` : ""}`, + ); + continue; + } + + break; + } + + if (heartbeatAt - lastHeartbeatAt >= GUI_SMOKE_BRIDGE_HEARTBEAT_MS) { + lastHeartbeatAt = heartbeatAt; + const detail = + error instanceof Error + ? error.message + : String(error || "unknown error"); + const heartbeat = describeGuiSmokeHeartbeat(startedByScript); + console.log( + `[bridge:health] 等待中: ${options.healthUrl} (${heartbeatAt - startedAt}ms);最近错误: ${detail}${heartbeat ? `;进程组: ${heartbeat}` : ""}`, + ); + } + await sleep(options.intervalMs); + } + } + + const detail = + lastError instanceof Error + ? lastError.message + : String(lastError || "unknown error"); + throw new Error( + `[bridge:health] 超时未就绪: ${options.healthUrl}。最后错误: ${detail}`, + ); +} + +async function probeAppShell(url, timeoutMs) { + try { + const response = await fetch(url, { + method: "GET", + signal: AbortSignal.timeout(timeoutMs), + }); + const html = await response.text(); + + return { + reachable: response.ok, + status: response.status, + statusText: response.statusText, + isLimeDevShell: response.ok && isLimeDevShell(html), + }; + } catch { + return { + reachable: false, + status: null, + statusText: "", + isLimeDevShell: false, + }; + } +} + async function resolveStartupMode(options) { if (options.reuseRunning) { return { shouldStart: false, reusedExisting: true, + reuseExistingAppShell: false, }; } - const existingAppShell = await isUrlReady(options.appUrl, 1_500); - if (!existingAppShell) { + const staleProcessCount = await cleanupStaleGuiSmokeProcesses(); + if (staleProcessCount > 0) { + console.log( + `[verify:gui-smoke] 已清理 ${staleProcessCount} 条残留 GUI smoke headless 链路。`, + ); + } + + const guiSmokeProcesses = inspectGuiSmokeTauriProcesses(); + const existingAppShell = await probeAppShell(options.appUrl, 1_500); + if (existingAppShell.reachable && !existingAppShell.isLimeDevShell) { + const statusLabel = `${existingAppShell.status || "unknown"} ${existingAppShell.statusText || ""}`.trim(); + throw new Error( + `[verify:gui-smoke] ${options.appUrl} 已被其他服务占用,且返回内容不是 Lime 前端壳(${statusLabel})。请先关闭占用进程后重试。`, + ); + } + + if (!existingAppShell.isLimeDevShell) { + const existingListeners = listListeningCommandsForPort( + resolveUrlPort(options.appUrl), + ); + const hasLimeFrontendListener = existingListeners.some( + isLikelyLimeFrontendListener, + ); + + if (guiSmokeProcesses.active.length > 0) { + const pidList = guiSmokeProcesses.active.map((item) => item.pid).join(", "); + console.log( + `[verify:gui-smoke] 检测到已有 GUI smoke headless 进程正在启动(PID: ${pidList});本次将直接复用现有链路并等待 DevBridge。`, + ); + return { + shouldStart: false, + reusedExisting: true, + reuseExistingAppShell: hasLimeFrontendListener, + }; + } + + if (hasLimeFrontendListener) { + console.log( + `[verify:gui-smoke] 检测到 ${options.appUrl} 已由当前仓库的前端启动链监听,但页面尚未完全就绪;将复用现有前端启动链,只拉起 headless Tauri 与 DevBridge。`, + ); + return { + shouldStart: true, + reusedExisting: false, + reuseExistingAppShell: true, + }; + } + + if (existingListeners.length > 0) { + throw new Error( + `[verify:gui-smoke] ${options.appUrl} 已被其他进程占用,且当前无法确认为 Lime 前端壳。请先关闭占用进程后重试。`, + ); + } + return { shouldStart: true, reusedExisting: false, + reuseExistingAppShell: false, }; } @@ -297,6 +966,19 @@ async function resolveStartupMode(options) { return { shouldStart: false, reusedExisting: true, + reuseExistingAppShell: true, + }; + } + + if (guiSmokeProcesses.active.length > 0) { + const pidList = guiSmokeProcesses.active.map((item) => item.pid).join(", "); + console.log( + `[verify:gui-smoke] 检测到已有 GUI smoke headless 进程正在启动(PID: ${pidList});本次将直接复用现有前端与 headless 链路。`, + ); + return { + shouldStart: false, + reusedExisting: true, + reuseExistingAppShell: true, }; } @@ -306,6 +988,7 @@ async function resolveStartupMode(options) { return { shouldStart: true, reusedExisting: false, + reuseExistingAppShell: true, }; } @@ -318,6 +1001,10 @@ async function main() { const startupMode = await resolveStartupMode(options); const startedByScript = startupMode.shouldStart; + console.log( + `[verify:gui-smoke] Cargo target: ${options.cargoTargetDir}`, + ); + const handleSignal = async (signal) => { try { await stopHeadlessTauri(); @@ -335,28 +1022,13 @@ async function main() { try { if (startedByScript) { - startHeadlessTauri(); + startHeadlessTauri(options, startupMode); await sleep(1_500); } else if (startupMode.reusedExisting) { console.log("[verify:gui-smoke] 复用已运行的 headless Tauri 环境。"); } - runCommand( - npmCommand, - [ - "run", - "bridge:health", - "--", - "--url", - options.healthUrl, - "--timeout-ms", - String(options.timeoutMs), - "--interval-ms", - String(options.intervalMs), - ], - "bridge:health", - options.timeoutMs + 5_000, - ); + await waitForBridgeHealth(options, startedByScript); await waitForAppShell(options); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b4fa7d487..9cfcf260d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5101,7 +5101,7 @@ dependencies = [ [[package]] name = "lime" -version = "1.8.0" +version = "1.9.0" dependencies = [ "anyhow", "arboard", @@ -5206,7 +5206,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "1.8.0" +version = "1.9.0" dependencies = [ "anyhow", "aster-core", @@ -5235,7 +5235,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "1.8.0" +version = "1.9.0" dependencies = [ "chrono", "futures", @@ -5252,7 +5252,7 @@ dependencies = [ [[package]] name = "lime-cli" -version = "1.8.0" +version = "1.9.0" dependencies = [ "clap", "lime-core", @@ -5264,7 +5264,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "1.8.0" +version = "1.9.0" dependencies = [ "async-trait", "lime-core", @@ -5280,7 +5280,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "1.8.0" +version = "1.9.0" dependencies = [ "aster-models", "async-trait", @@ -5320,7 +5320,7 @@ dependencies = [ [[package]] name = "lime-credential" -version = "1.8.0" +version = "1.9.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5355,7 +5355,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "1.8.0" +version = "1.9.0" dependencies = [ "aes", "axum 0.7.9", @@ -5385,7 +5385,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "1.8.0" +version = "1.9.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5405,7 +5405,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "1.8.0" +version = "1.9.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5421,7 +5421,7 @@ dependencies = [ [[package]] name = "lime-media-runtime" -version = "1.8.0" +version = "1.9.0" dependencies = [ "axum 0.7.9", "chrono", @@ -5452,7 +5452,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "1.8.0" +version = "1.9.0" dependencies = [ "async-trait", "lime-core", @@ -5471,7 +5471,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "1.8.0" +version = "1.9.0" dependencies = [ "anyhow", "async-stream", @@ -5526,7 +5526,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "1.8.0" +version = "1.9.0" dependencies = [ "aster-core", "async-stream", @@ -5571,7 +5571,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "1.8.0" +version = "1.9.0" dependencies = [ "axum 0.7.9", "futures", @@ -5586,7 +5586,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "1.8.0" +version = "1.9.0" dependencies = [ "anyhow", "aster-core", @@ -5628,7 +5628,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "1.8.0" +version = "1.9.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5646,7 +5646,7 @@ dependencies = [ [[package]] name = "lime-terminal" -version = "1.8.0" +version = "1.9.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -5673,7 +5673,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "1.8.0" +version = "1.9.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 34eee9fd5..b3c63b8ab 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -4,7 +4,7 @@ exclude = ["crates/aster", "crates/aster-models", "crates/aster-rust"] resolver = "2" [workspace.package] -version = "1.8.0" +version = "1.9.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" @@ -189,7 +189,7 @@ version = "2.4" [package] name = "lime" -version = "1.8.0" +version = "1.9.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/agent/src/aster_state_support.rs b/src-tauri/crates/agent/src/aster_state_support.rs index 2e0895995..c5f45bbd1 100644 --- a/src-tauri/crates/agent/src/aster_state_support.rs +++ b/src-tauri/crates/agent/src/aster_state_support.rs @@ -193,5 +193,5 @@ Lime 是一个 AI 代理服务应用,帮助用户: - 需要显式建立或清理 team 上下文时,优先使用 TeamCreate / TeamDelete;进入 team 后再通过 Agent / SendMessage / ListPeers 维持协作主路径 - 优先复用已有子代理上下文,通过 SendMessage 继续推进强相关任务,而不是反复创建新子代理 - 只有当主线程确实被结果阻塞时,才围绕已有 team workspace 状态等待结果,不要反复机械轮询 -- 旧的 SubAgentTask 仅视为兼容入口,不应作为新的 team runtime 主路径 +- 已删除的 `SubAgentTask` 工具名不应重新挂回 team runtime 主路径 "#; diff --git a/src-tauri/crates/agent/src/prompt/templates.rs b/src-tauri/crates/agent/src/prompt/templates.rs index 32c1ac52b..df29fb582 100644 --- a/src-tauri/crates/agent/src/prompt/templates.rs +++ b/src-tauri/crates/agent/src/prompt/templates.rs @@ -44,7 +44,6 @@ pub const TOOL_GUIDELINES: &str = r#"# 工具使用策略 ### 委派工具 - **Agent / TeamCreate / TeamDelete / SendMessage / ListPeers**: 当前 team runtime 主路径 -- **SubAgentTask**: 兼容入口,仅用于历史 prompt/schema 仍输出旧格式时兜底 ### 人在环工具 - **AskUserQuestion**: 向用户请求确认或补充信息 @@ -55,7 +54,7 @@ pub const TOOL_GUIDELINES: &str = r#"# 工具使用策略 2. **并行调用**:如果多个工具调用之间没有依赖关系,应该并行调用 3. **先读后改**:修改文件前必须先读取文件内容 4. **最小权限**:只执行必要的操作,避免不必要的文件修改 -5. **独立子问题再委派**:只有当任务需要隔离上下文、并行探索或分离执行时,才使用 team runtime 工具;优先 `Agent`,不要默认走 `SubAgentTask`"#; +5. **独立子问题再委派**:只有当任务需要隔离上下文、并行探索或分离执行时,才使用 team runtime 工具;优先 `Agent`,不要恢复旧工具名或额外平行入口"#; /// 代码编写指南 pub const CODING_GUIDELINES: &str = r#"# 代码编写指南 @@ -102,7 +101,7 @@ pub const TASK_MANAGEMENT: &str = r#"# 任务管理 不要批量完成多个任务后再标记,应该完成一个标记一个。 -如果某个子问题可以独立分析、规划或执行,并且不需要持续共享主对话上下文,可以使用 `Agent` 委派出去;`SubAgentTask` 只保留给兼容旧 schema 的场景。"#; +如果某个子问题可以独立分析、规划或执行,并且不需要持续共享主对话上下文,可以使用 `Agent` 委派出去;统一使用 `Agent / SendMessage / TeamCreate / TeamDelete / ListPeers`,不要恢复旧 schema 工具名。"#; /// Git 操作指南 pub const GIT_GUIDELINES: &str = r#"# Git 操作 diff --git a/src-tauri/crates/agent/src/protocol.rs b/src-tauri/crates/agent/src/protocol.rs index 7c6f50458..20dc8ae0a 100644 --- a/src-tauri/crates/agent/src/protocol.rs +++ b/src-tauri/crates/agent/src/protocol.rs @@ -42,6 +42,8 @@ pub struct AgentArtifactSignal { pub struct AgentTokenUsage { pub input_tokens: u32, pub output_tokens: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cached_input_tokens: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/crates/agent/src/session_store.rs b/src-tauri/crates/agent/src/session_store.rs index f332f0810..9508f4d83 100644 --- a/src-tauri/crates/agent/src/session_store.rs +++ b/src-tauri/crates/agent/src/session_store.rs @@ -997,6 +997,10 @@ fn resolve_runtime_usage_from_aster_session( Some(crate::protocol::AgentTokenUsage { input_tokens: input_tokens as u32, output_tokens: output_tokens as u32, + cached_input_tokens: session + .cached_input_tokens + .filter(|value| *value >= 0) + .map(|value| value as u32), }) } _ => None, @@ -1061,6 +1065,7 @@ pub async fn get_runtime_session_detail( session_id, usage.input_tokens, usage.output_tokens, + usage.cached_input_tokens, ) { tracing::warn!( @@ -1373,6 +1378,7 @@ fn convert_agent_message( .map(|usage| crate::protocol::AgentTokenUsage { input_tokens: usage.input_tokens, output_tokens: usage.output_tokens, + cached_input_tokens: usage.cached_input_tokens, }), }; @@ -1983,6 +1989,7 @@ mod tests { usage: Some(crate::protocol::AgentTokenUsage { input_tokens: 20_480, output_tokens: 10_240, + cached_input_tokens: Some(8_192), }), }]; let session = AsterSession { @@ -1997,11 +2004,12 @@ mod tests { assert!(applied.is_none()); assert_eq!( - messages[0] - .usage - .as_ref() - .map(|usage| (usage.input_tokens, usage.output_tokens)), - Some((20_480, 10_240)) + messages[0].usage.as_ref().map(|usage| ( + usage.input_tokens, + usage.output_tokens, + usage.cached_input_tokens, + )), + Some((20_480, 10_240, Some(8_192))) ); } diff --git a/src-tauri/crates/agent/src/session_update.rs b/src-tauri/crates/agent/src/session_update.rs index e303f0820..8beb51a16 100644 --- a/src-tauri/crates/agent/src/session_update.rs +++ b/src-tauri/crates/agent/src/session_update.rs @@ -7,6 +7,7 @@ use std::path::PathBuf; pub struct CompactionSessionMetricsUpdate { pub schedule_id: Option, pub current_window_tokens: i32, + pub cached_input_tokens: Option, pub accumulated_total_tokens: Option, pub accumulated_input_tokens: Option, pub accumulated_output_tokens: Option, @@ -57,6 +58,7 @@ pub async fn persist_compaction_session_metrics_update( .total_tokens(Some(update.current_window_tokens)) .input_tokens(Some(update.current_window_tokens)) .output_tokens(Some(0)) + .cached_input_tokens(update.cached_input_tokens) .accumulated_total_tokens(update.accumulated_total_tokens) .accumulated_input_tokens(update.accumulated_input_tokens) .accumulated_output_tokens(update.accumulated_output_tokens) diff --git a/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs b/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs index 7a1151f38..cfcdde095 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs @@ -4629,6 +4629,7 @@ mod tests { total_tokens: None, input_tokens: None, output_tokens: None, + cached_input_tokens: None, accumulated_total_tokens: None, accumulated_input_tokens: None, accumulated_output_tokens: None, @@ -4684,6 +4685,7 @@ mod tests { total_tokens: None, input_tokens: None, output_tokens: None, + cached_input_tokens: None, accumulated_total_tokens: None, accumulated_input_tokens: None, accumulated_output_tokens: None, @@ -4736,6 +4738,7 @@ mod tests { total_tokens: None, input_tokens: None, output_tokens: None, + cached_input_tokens: None, accumulated_total_tokens: None, accumulated_input_tokens: None, accumulated_output_tokens: None, @@ -4797,6 +4800,7 @@ mod tests { total_tokens: None, input_tokens: None, output_tokens: None, + cached_input_tokens: None, accumulated_total_tokens: None, accumulated_input_tokens: None, accumulated_output_tokens: None, @@ -4865,6 +4869,7 @@ mod tests { total_tokens: None, input_tokens: None, output_tokens: None, + cached_input_tokens: None, accumulated_total_tokens: None, accumulated_input_tokens: None, accumulated_output_tokens: None, diff --git a/src-tauri/crates/aster-rust/crates/aster/src/agents/execute_commands.rs b/src-tauri/crates/aster-rust/crates/aster/src/agents/execute_commands.rs index 51ab2e464..67565bd08 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/agents/execute_commands.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/agents/execute_commands.rs @@ -131,6 +131,7 @@ impl Agent { total_tokens: Some(0), input_tokens: Some(0), output_tokens: Some(0), + cached_input_tokens: Some(0), accumulated_total: None, accumulated_input: None, accumulated_output: None, diff --git a/src-tauri/crates/aster-rust/crates/aster/src/agents/reply_parts.rs b/src-tauri/crates/aster-rust/crates/aster/src/agents/reply_parts.rs index 4f1fc0339..29ae94bcf 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/agents/reply_parts.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/agents/reply_parts.rs @@ -438,6 +438,11 @@ impl Agent { usage.usage.output_tokens, ) }; + let current_cached_input = if is_compaction_usage { + Some(0) + } else { + usage.usage.cached_input_tokens + }; if let Some(store) = session_store { store @@ -448,6 +453,7 @@ impl Agent { total_tokens: current_total, input_tokens: current_input, output_tokens: current_output, + cached_input_tokens: current_cached_input, accumulated_total, accumulated_input, accumulated_output, @@ -460,6 +466,7 @@ impl Agent { .total_tokens(current_total) .input_tokens(current_input) .output_tokens(current_output) + .cached_input_tokens(current_cached_input) .accumulated_total_tokens(accumulated_total) .accumulated_input_tokens(accumulated_input) .accumulated_output_tokens(accumulated_output) diff --git a/src-tauri/crates/aster-rust/crates/aster/src/providers/api_client.rs b/src-tauri/crates/aster-rust/crates/aster/src/providers/api_client.rs index d5c1df478..b2969f22f 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/providers/api_client.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/providers/api_client.rs @@ -1,5 +1,5 @@ use crate::network::should_bypass_system_proxy_for_url; -use crate::session_context::SESSION_ID_HEADER; +use crate::session_context::current_request_correlation_context; use anyhow::Result; use async_trait::async_trait; use reqwest::{ @@ -379,8 +379,8 @@ impl<'a> ApiRequestBuilder<'a> { let mut request = request_builder(url, &self.client.client); request = request.headers(self.headers.clone()); - if let Some(session_id) = crate::session_context::current_session_id() { - request = request.header(SESSION_ID_HEADER, session_id); + for (header_name, header_value) in current_request_correlation_context().header_values() { + request = request.header(header_name, header_value); } request = match &self.client.auth { @@ -416,6 +416,10 @@ impl fmt::Debug for ApiClient { #[cfg(test)] mod tests { use super::*; + use crate::session_context::{ + PENDING_REQUEST_ID_HEADER, QUEUED_TURN_ID_HEADER, SESSION_ID_HEADER, + SUBAGENT_SESSION_ID_HEADER, THREAD_ID_HEADER, TURN_ID_HEADER, + }; #[test] fn should_bypass_proxy_for_loopback_host() { @@ -480,4 +484,82 @@ mod tests { assert!(!headers.contains_key(SESSION_ID_HEADER)); } + + #[tokio::test] + async fn test_request_correlation_headers_injection() { + let client = ApiClient::new( + "http://localhost:8080".to_string(), + AuthMethod::BearerToken("test-token".to_string()), + ) + .unwrap(); + let scope = crate::conversation::message::ActionRequiredScope { + session_id: Some("session-telemetry".to_string()), + thread_id: Some("thread-telemetry".to_string()), + turn_id: Some("turn-telemetry".to_string()), + }; + let turn_context = crate::session::TurnContextOverride { + metadata: std::collections::HashMap::from([ + ( + "pending_request_id".to_string(), + serde_json::json!("pending-telemetry"), + ), + ( + "queuedTurnId".to_string(), + serde_json::json!("queued-telemetry"), + ), + ( + "subagentSessionId".to_string(), + serde_json::json!("subagent-telemetry"), + ), + ]), + ..crate::session::TurnContextOverride::default() + }; + + crate::session_context::with_runtime_scope(scope, Some(turn_context), async { + let builder = client.request("/test"); + let request = builder + .send_request(|url, client| client.get(url)) + .await + .unwrap(); + let headers = request.build().unwrap().headers().clone(); + + assert_eq!( + headers.get(SESSION_ID_HEADER).unwrap().to_str().unwrap(), + "session-telemetry" + ); + assert_eq!( + headers.get(THREAD_ID_HEADER).unwrap().to_str().unwrap(), + "thread-telemetry" + ); + assert_eq!( + headers.get(TURN_ID_HEADER).unwrap().to_str().unwrap(), + "turn-telemetry" + ); + assert_eq!( + headers + .get(PENDING_REQUEST_ID_HEADER) + .unwrap() + .to_str() + .unwrap(), + "pending-telemetry" + ); + assert_eq!( + headers + .get(QUEUED_TURN_ID_HEADER) + .unwrap() + .to_str() + .unwrap(), + "queued-telemetry" + ); + assert_eq!( + headers + .get(SUBAGENT_SESSION_ID_HEADER) + .unwrap() + .to_str() + .unwrap(), + "subagent-telemetry" + ); + }) + .await; + } } diff --git a/src-tauri/crates/aster-rust/crates/aster/src/providers/base.rs b/src-tauri/crates/aster-rust/crates/aster/src/providers/base.rs index 986255ba0..1c3da8191 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/providers/base.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/providers/base.rs @@ -278,6 +278,7 @@ pub struct Usage { pub input_tokens: Option, pub output_tokens: Option, pub total_tokens: Option, + pub cached_input_tokens: Option, } fn sum_optionals(a: Option, b: Option) -> Option @@ -301,6 +302,10 @@ impl Add for Usage { sum_optionals(self.output_tokens, other.output_tokens), sum_optionals(self.total_tokens, other.total_tokens), ) + .with_cached_input_tokens(sum_optionals( + self.cached_input_tokens, + other.cached_input_tokens, + )) } } @@ -331,8 +336,14 @@ impl Usage { input_tokens, output_tokens, total_tokens: calculated_total, + cached_input_tokens: None, } } + + pub fn with_cached_input_tokens(mut self, cached_input_tokens: Option) -> Self { + self.cached_input_tokens = cached_input_tokens; + self + } } use async_trait::async_trait; diff --git a/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/anthropic.rs b/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/anthropic.rs index 421f966e8..72f378d44 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/anthropic.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/anthropic.rs @@ -330,7 +330,8 @@ pub fn get_usage(data: &Value) -> Result { Some(total_input_i32), Some(output_tokens_i32), Some(total_tokens_i32), - )) + ) + .with_cached_input_tokens(Some(cache_read_tokens.min(i32::MAX as u64) as i32))) } else if data.as_object().is_some() { // Check if the data itself is the usage object (for message_delta events that might have usage at top level) let input_tokens = data @@ -373,7 +374,8 @@ pub fn get_usage(data: &Value) -> Result { Some(total_input_i32), Some(output_tokens_i32), Some(total_tokens_i32), - )) + ) + .with_cached_input_tokens(Some(cache_read_tokens.min(i32::MAX as u64) as i32))) } else { tracing::debug!("🔍 Anthropic no token data found in object"); Ok(Usage::new(None, None, None)) @@ -655,11 +657,20 @@ where (None, Some(output)) => Some(output), (None, None) => None, }; + let merged_cached = existing_usage + .usage + .cached_input_tokens + .or(delta_usage.cached_input_tokens); - let merged_usage = crate::providers::base::Usage::new(merged_input, merged_output, merged_total); + let merged_usage = crate::providers::base::Usage::new( + merged_input, + merged_output, + merged_total, + ) + .with_cached_input_tokens(merged_cached); final_usage = Some(crate::providers::base::ProviderUsage::new(existing_usage.model.clone(), merged_usage)); - tracing::debug!("🔍 Anthropic MERGED usage: input_tokens={:?}, output_tokens={:?}, total_tokens={:?}", - merged_input, merged_output, merged_total); + tracing::debug!("🔍 Anthropic MERGED usage: input_tokens={:?}, output_tokens={:?}, total_tokens={:?}, cached_input_tokens={:?}", + merged_input, merged_output, merged_total, merged_cached); } else { // No existing usage, just use delta usage let model = event.data.get("model") @@ -749,6 +760,7 @@ mod tests { assert_eq!(usage.input_tokens, Some(24)); // 12 + 12 = 24 actual tokens assert_eq!(usage.output_tokens, Some(15)); assert_eq!(usage.total_tokens, Some(39)); // 24 + 15 + assert_eq!(usage.cached_input_tokens, Some(0)); Ok(()) } @@ -792,6 +804,7 @@ mod tests { assert_eq!(usage.input_tokens, Some(30)); // 15 + 15 = 30 actual tokens assert_eq!(usage.output_tokens, Some(20)); assert_eq!(usage.total_tokens, Some(50)); // 30 + 20 + assert_eq!(usage.cached_input_tokens, Some(0)); Ok(()) } @@ -866,6 +879,7 @@ mod tests { assert_eq!(usage.input_tokens, Some(10)); assert_eq!(usage.output_tokens, Some(45)); assert_eq!(usage.total_tokens, Some(55)); + assert_eq!(usage.cached_input_tokens, Some(0)); Ok(()) } @@ -1007,6 +1021,7 @@ mod tests { assert_eq!(usage.input_tokens, Some(15007)); assert_eq!(usage.output_tokens, Some(50)); assert_eq!(usage.total_tokens, Some(15057)); // 15007 + 50 + assert_eq!(usage.cached_input_tokens, Some(5000)); Ok(()) } diff --git a/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/openai.rs b/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/openai.rs index 1771201cb..ead39d1c1 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/openai.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/openai.rs @@ -404,7 +404,14 @@ pub fn get_usage(usage: &Value) -> Usage { _ => None, }); + let cached_input_tokens = usage + .get("prompt_tokens_details") + .and_then(|details| details.get("cached_tokens")) + .and_then(|v| v.as_i64()) + .map(|v| v as i32); + Usage::new(input_tokens, output_tokens, total_tokens) + .with_cached_input_tokens(cached_input_tokens) } /// Validates and fixes tool schemas to ensure they have proper parameter structure. @@ -1665,4 +1672,21 @@ data: [DONE] Ok(()) } + + #[test] + fn test_get_usage_should_preserve_cached_prompt_tokens() { + let usage = get_usage(&json!({ + "prompt_tokens": 1200, + "completion_tokens": 300, + "total_tokens": 1500, + "prompt_tokens_details": { + "cached_tokens": 900 + } + })); + + assert_eq!(usage.input_tokens, Some(1200)); + assert_eq!(usage.output_tokens, Some(300)); + assert_eq!(usage.total_tokens, Some(1500)); + assert_eq!(usage.cached_input_tokens, Some(900)); + } } diff --git a/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/openai_responses.rs b/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/openai_responses.rs index fde836527..89b9ac174 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/openai_responses.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/openai_responses.rs @@ -113,6 +113,8 @@ pub struct ResponseUsage { pub input_tokens: i32, pub output_tokens: i32, pub total_tokens: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_tokens_details: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -550,6 +552,13 @@ pub fn get_responses_usage(response: &ResponsesApiResponse) -> Usage { Some(u.output_tokens), Some(u.total_tokens), ) + .with_cached_input_tokens( + u.input_tokens_details + .as_ref() + .and_then(|details| details.get("cached_tokens")) + .and_then(|value| value.as_i64()) + .map(|value| value as i32), + ) }) } @@ -705,6 +714,13 @@ where Some(u.input_tokens), Some(u.output_tokens), Some(u.total_tokens), + ) + .with_cached_input_tokens( + u.input_tokens_details + .as_ref() + .and_then(|details| details.get("cached_tokens")) + .and_then(|value| value.as_i64()) + .map(|value| value as i32), ), ); final_usage = Some(ProviderUsage { diff --git a/src-tauri/crates/aster-rust/crates/aster/src/providers/litellm.rs b/src-tauri/crates/aster-rust/crates/aster/src/providers/litellm.rs index 6583ea4b7..0c818545f 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/providers/litellm.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/providers/litellm.rs @@ -287,6 +287,15 @@ pub fn update_request_for_cache_control(original_payload: &Value) -> Value { "text": content_str, "cache_control": { "type": "ephemeral" } }]); + } else if let Some(content_array) = content.as_array_mut() { + if let Some(last_content) = content_array.last_mut() { + if let Some(obj) = last_content.as_object_mut() { + obj.insert( + "cache_control".to_string(), + json!({ "type": "ephemeral" }), + ); + } + } } } user_count += 1; @@ -310,6 +319,12 @@ pub fn update_request_for_cache_control(original_payload: &Value) -> Value { "cache_control": { "type": "ephemeral" } }] }); + } else if let Some(content_array) = content.as_array_mut() { + if let Some(last_content) = content_array.last_mut() { + if let Some(obj) = last_content.as_object_mut() { + obj.insert("cache_control".to_string(), json!({ "type": "ephemeral" })); + } + } } } } @@ -332,6 +347,70 @@ pub fn update_request_for_cache_control(original_payload: &Value) -> Value { payload } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn update_request_for_cache_control_marks_array_content_blocks() { + let payload = json!({ + "messages": [ + { + "role": "system", + "content": [{"type": "text", "text": "system prompt"}] + }, + { + "role": "user", + "content": [{"type": "text", "text": "first question"}] + }, + { + "role": "assistant", + "content": "answer" + }, + { + "role": "user", + "content": [ + {"type": "text", "text": "second question"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}} + ] + } + ], + "tools": [ + { + "type": "function", + "function": {"name": "first_tool"} + }, + { + "type": "function", + "function": {"name": "second_tool"} + } + ] + }); + + let updated = update_request_for_cache_control(&payload); + let messages = updated["messages"] + .as_array() + .expect("messages should be array"); + + assert_eq!( + messages[0]["content"][0]["cache_control"]["type"], + "ephemeral" + ); + assert_eq!( + messages[1]["content"][0]["cache_control"]["type"], + "ephemeral" + ); + assert_eq!( + messages[3]["content"][1]["cache_control"]["type"], + "ephemeral" + ); + assert_eq!( + updated["tools"][1]["function"]["cache_control"]["type"], + "ephemeral" + ); + } +} + fn parse_custom_headers(headers_str: String) -> HashMap { let mut headers = HashMap::new(); for line in headers_str.lines() { diff --git a/src-tauri/crates/aster-rust/crates/aster/src/providers/openrouter.rs b/src-tauri/crates/aster-rust/crates/aster/src/providers/openrouter.rs index 3bfafd45a..903e14d1d 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/providers/openrouter.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/providers/openrouter.rs @@ -153,6 +153,15 @@ fn update_request_for_anthropic(original_payload: &Value) -> Value { "text": content_str, "cache_control": { "type": "ephemeral" } }]); + } else if let Some(content_array) = content.as_array_mut() { + if let Some(last_content) = content_array.last_mut() { + if let Some(obj) = last_content.as_object_mut() { + obj.insert( + "cache_control".to_string(), + json!({ "type": "ephemeral" }), + ); + } + } } } user_count += 1; @@ -177,6 +186,12 @@ fn update_request_for_anthropic(original_payload: &Value) -> Value { "cache_control": { "type": "ephemeral" } }] }); + } else if let Some(content_array) = content.as_array_mut() { + if let Some(last_content) = content_array.last_mut() { + if let Some(obj) = last_content.as_object_mut() { + obj.insert("cache_control".to_string(), json!({ "type": "ephemeral" })); + } + } } } } @@ -201,6 +216,70 @@ fn update_request_for_anthropic(original_payload: &Value) -> Value { payload } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn update_request_for_anthropic_marks_array_content_blocks() { + let payload = json!({ + "messages": [ + { + "role": "system", + "content": [{"type": "text", "text": "system prompt"}] + }, + { + "role": "user", + "content": [{"type": "text", "text": "first question"}] + }, + { + "role": "assistant", + "content": "answer" + }, + { + "role": "user", + "content": [ + {"type": "text", "text": "second question"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}} + ] + } + ], + "tools": [ + { + "type": "function", + "function": {"name": "first_tool"} + }, + { + "type": "function", + "function": {"name": "second_tool"} + } + ] + }); + + let updated = update_request_for_anthropic(&payload); + let messages = updated["messages"] + .as_array() + .expect("messages should be array"); + + assert_eq!( + messages[0]["content"][0]["cache_control"]["type"], + "ephemeral" + ); + assert_eq!( + messages[1]["content"][0]["cache_control"]["type"], + "ephemeral" + ); + assert_eq!( + messages[3]["content"][1]["cache_control"]["type"], + "ephemeral" + ); + assert_eq!( + updated["tools"][1]["function"]["cache_control"]["type"], + "ephemeral" + ); + } +} + async fn create_request_based_on_model( provider: &OpenRouterProvider, system: &str, diff --git a/src-tauri/crates/aster-rust/crates/aster/src/session/session_manager.rs b/src-tauri/crates/aster-rust/crates/aster/src/session/session_manager.rs index fbe147e84..3c1718977 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/session/session_manager.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/session/session_manager.rs @@ -27,7 +27,7 @@ use tokio::sync::OnceCell; use tracing::{info, warn}; use utoipa::ToSchema; -pub const CURRENT_SCHEMA_VERSION: i32 = 7; +pub const CURRENT_SCHEMA_VERSION: i32 = 8; pub const SESSIONS_FOLDER: &str = "sessions"; pub const DB_NAME: &str = "sessions.db"; const AUTO_SESSION_NAME_PLACEHOLDERS: &[&str] = &[ @@ -96,6 +96,7 @@ pub struct Session { pub total_tokens: Option, pub input_tokens: Option, pub output_tokens: Option, + pub cached_input_tokens: Option, pub accumulated_total_tokens: Option, pub accumulated_input_tokens: Option, pub accumulated_output_tokens: Option, @@ -118,6 +119,7 @@ pub struct SessionUpdateBuilder { total_tokens: Option>, input_tokens: Option>, output_tokens: Option>, + cached_input_tokens: Option>, accumulated_total_tokens: Option>, accumulated_input_tokens: Option>, accumulated_output_tokens: Option>, @@ -147,6 +149,7 @@ impl SessionUpdateBuilder { total_tokens: None, input_tokens: None, output_tokens: None, + cached_input_tokens: None, accumulated_total_tokens: None, accumulated_input_tokens: None, accumulated_output_tokens: None, @@ -206,6 +209,11 @@ impl SessionUpdateBuilder { self } + pub fn cached_input_tokens(mut self, tokens: Option) -> Self { + self.cached_input_tokens = Some(tokens); + self + } + pub fn accumulated_total_tokens(mut self, tokens: Option) -> Self { self.accumulated_total_tokens = Some(tokens); self @@ -478,6 +486,7 @@ impl SessionManager { total_tokens, input_tokens, output_tokens, + cached_input_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, @@ -503,6 +512,7 @@ impl SessionManager { if total_tokens.is_some() || input_tokens.is_some() || output_tokens.is_some() + || cached_input_tokens.is_some() || accumulated_total_tokens.is_some() || accumulated_input_tokens.is_some() || accumulated_output_tokens.is_some() @@ -516,6 +526,7 @@ impl SessionManager { total_tokens: total_tokens.flatten(), input_tokens: input_tokens.flatten(), output_tokens: output_tokens.flatten(), + cached_input_tokens: cached_input_tokens.flatten(), accumulated_total: accumulated_total_tokens.flatten(), accumulated_input: accumulated_input_tokens.flatten(), accumulated_output: accumulated_output_tokens.flatten(), @@ -583,6 +594,7 @@ impl Default for Session { total_tokens: None, input_tokens: None, output_tokens: None, + cached_input_tokens: None, accumulated_total_tokens: None, accumulated_input_tokens: None, accumulated_output_tokens: None, @@ -678,6 +690,7 @@ impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for Session { total_tokens: row.try_get("total_tokens")?, input_tokens: row.try_get("input_tokens")?, output_tokens: row.try_get("output_tokens")?, + cached_input_tokens: row.try_get("cached_input_tokens").ok().flatten(), accumulated_total_tokens: row.try_get("accumulated_total_tokens")?, accumulated_input_tokens: row.try_get("accumulated_input_tokens")?, accumulated_output_tokens: row.try_get("accumulated_output_tokens")?, @@ -770,6 +783,7 @@ impl SessionStorage { total_tokens INTEGER, input_tokens INTEGER, output_tokens INTEGER, + cached_input_tokens INTEGER, accumulated_total_tokens INTEGER, accumulated_input_tokens INTEGER, accumulated_output_tokens INTEGER, @@ -1006,11 +1020,11 @@ impl SessionStorage { r#" INSERT INTO sessions ( id, name, user_set_name, session_type, working_dir, created_at, updated_at, extension_data, - total_tokens, input_tokens, output_tokens, + total_tokens, input_tokens, output_tokens, cached_input_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, schedule_id, recipe_json, user_recipe_values_json, provider_name, model_config_json - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "#, ) .bind(&session.id) @@ -1024,6 +1038,7 @@ impl SessionStorage { .bind(session.total_tokens) .bind(session.input_tokens) .bind(session.output_tokens) + .bind(session.cached_input_tokens) .bind(session.accumulated_total_tokens) .bind(session.accumulated_input_tokens) .bind(session.accumulated_output_tokens) @@ -1319,6 +1334,15 @@ impl SessionStorage { .execute(&self.pool) .await?; } + 8 => { + sqlx::query( + r#" + ALTER TABLE sessions ADD COLUMN cached_input_tokens INTEGER + "#, + ) + .execute(&self.pool) + .await?; + } _ => { anyhow::bail!("Unknown migration version: {}", version); } @@ -1371,7 +1395,7 @@ impl SessionStorage { let mut session = sqlx::query_as::<_, Session>( r#" SELECT id, working_dir, name, description, user_set_name, session_type, created_at, updated_at, extension_data, - total_tokens, input_tokens, output_tokens, + total_tokens, input_tokens, output_tokens, cached_input_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, schedule_id, recipe_json, user_recipe_values_json, provider_name, model_config_json @@ -1426,6 +1450,7 @@ impl SessionStorage { add_update!(builder.total_tokens, "total_tokens"); add_update!(builder.input_tokens, "input_tokens"); add_update!(builder.output_tokens, "output_tokens"); + add_update!(builder.cached_input_tokens, "cached_input_tokens"); add_update!(builder.accumulated_total_tokens, "accumulated_total_tokens"); add_update!(builder.accumulated_input_tokens, "accumulated_input_tokens"); add_update!( @@ -1471,6 +1496,9 @@ impl SessionStorage { if let Some(ot) = builder.output_tokens { q = q.bind(ot); } + if let Some(cit) = builder.cached_input_tokens { + q = q.bind(cit); + } if let Some(att) = builder.accumulated_total_tokens { q = q.bind(att); } @@ -1614,7 +1642,7 @@ impl SessionStorage { let query = format!( r#" SELECT s.id, s.working_dir, s.name, s.description, s.user_set_name, s.session_type, s.created_at, s.updated_at, s.extension_data, - s.total_tokens, s.input_tokens, s.output_tokens, + s.total_tokens, s.input_tokens, s.output_tokens, s.cached_input_tokens, s.accumulated_total_tokens, s.accumulated_input_tokens, s.accumulated_output_tokens, s.schedule_id, s.recipe_json, s.user_recipe_values_json, s.provider_name, s.model_config_json, @@ -1714,6 +1742,7 @@ impl SessionStorage { .total_tokens(import.total_tokens) .input_tokens(import.input_tokens) .output_tokens(import.output_tokens) + .cached_input_tokens(import.cached_input_tokens) .accumulated_total_tokens(import.accumulated_total_tokens) .accumulated_input_tokens(import.accumulated_input_tokens) .accumulated_output_tokens(import.accumulated_output_tokens) diff --git a/src-tauri/crates/aster-rust/crates/aster/src/session/store.rs b/src-tauri/crates/aster-rust/crates/aster/src/session/store.rs index ee4fde7c8..09ab0f0e4 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/session/store.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/session/store.rs @@ -159,6 +159,7 @@ pub struct TokenStatsUpdate { pub total_tokens: Option, pub input_tokens: Option, pub output_tokens: Option, + pub cached_input_tokens: Option, pub accumulated_total: Option, pub accumulated_input: Option, pub accumulated_output: Option, @@ -189,6 +190,7 @@ impl SessionStore for NoopSessionStore { total_tokens: None, input_tokens: None, output_tokens: None, + cached_input_tokens: None, accumulated_total_tokens: None, accumulated_input_tokens: None, accumulated_output_tokens: None, diff --git a/src-tauri/crates/aster-rust/crates/aster/src/session_context.rs b/src-tauri/crates/aster-rust/crates/aster/src/session_context.rs index 1a26e06bb..55c69f150 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/session_context.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/session_context.rs @@ -1,9 +1,53 @@ use crate::conversation::message::ActionRequiredScope; use crate::session::TurnContextOverride; use futures::{Stream, StreamExt}; +use serde_json::Value; +use std::collections::HashMap; use tokio::task_local; pub const SESSION_ID_HEADER: &str = "aster-session-id"; +pub const THREAD_ID_HEADER: &str = "aster-thread-id"; +pub const TURN_ID_HEADER: &str = "aster-turn-id"; +pub const PENDING_REQUEST_ID_HEADER: &str = "aster-pending-request-id"; +pub const QUEUED_TURN_ID_HEADER: &str = "aster-queued-turn-id"; +pub const SUBAGENT_SESSION_ID_HEADER: &str = "aster-subagent-session-id"; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RequestCorrelationContext { + pub session_id: Option, + pub thread_id: Option, + pub turn_id: Option, + pub pending_request_id: Option, + pub queued_turn_id: Option, + pub subagent_session_id: Option, +} + +impl RequestCorrelationContext { + pub fn header_values(&self) -> Vec<(&'static str, String)> { + let mut headers = Vec::new(); + + if let Some(value) = self.session_id.clone() { + headers.push((SESSION_ID_HEADER, value)); + } + if let Some(value) = self.thread_id.clone() { + headers.push((THREAD_ID_HEADER, value)); + } + if let Some(value) = self.turn_id.clone() { + headers.push((TURN_ID_HEADER, value)); + } + if let Some(value) = self.pending_request_id.clone() { + headers.push((PENDING_REQUEST_ID_HEADER, value)); + } + if let Some(value) = self.queued_turn_id.clone() { + headers.push((QUEUED_TURN_ID_HEADER, value)); + } + if let Some(value) = self.subagent_session_id.clone() { + headers.push((SUBAGENT_SESSION_ID_HEADER, value)); + } + + headers + } +} task_local! { pub static SESSION_ID: Option; @@ -75,6 +119,59 @@ pub fn current_turn_context() -> Option { .flatten() } +pub fn current_request_correlation_context() -> RequestCorrelationContext { + let action_scope = current_action_scope(); + let turn_context = current_turn_context(); + let metadata = turn_context.as_ref().map(|context| &context.metadata); + + RequestCorrelationContext { + session_id: action_scope + .as_ref() + .and_then(|scope| scope.session_id.clone()) + .or_else(current_session_id), + thread_id: action_scope + .as_ref() + .and_then(|scope| scope.thread_id.clone()), + turn_id: action_scope + .as_ref() + .and_then(|scope| scope.turn_id.clone()), + pending_request_id: metadata.and_then(|value| { + find_metadata_string(value, &["pending_request_id", "pendingRequestId"]) + }), + queued_turn_id: metadata + .and_then(|value| find_metadata_string(value, &["queued_turn_id", "queuedTurnId"])), + subagent_session_id: metadata + .and_then(|value| { + find_metadata_string(value, &["subagent_session_id", "subagentSessionId"]) + }) + .or_else(|| metadata.and_then(find_subagent_session_id)), + } +} + +fn find_metadata_string(metadata: &HashMap, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| metadata.get(*key)) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn find_subagent_session_id(metadata: &HashMap) -> Option { + metadata + .get("subagent") + .and_then(Value::as_object) + .and_then(|subagent| { + subagent + .get("session_id") + .or_else(|| subagent.get("sessionId")) + }) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + pub fn scope_stream( scope: ActionRequiredScope, turn_context: Option, @@ -203,4 +300,38 @@ mod tests { }) .await; } + + #[tokio::test] + async fn test_request_correlation_context_reads_scope_and_turn_metadata() { + let scope = ActionRequiredScope { + session_id: Some("session-corr".to_string()), + thread_id: Some("thread-corr".to_string()), + turn_id: Some("turn-corr".to_string()), + }; + let mut metadata = HashMap::new(); + metadata.insert( + "pendingRequestId".to_string(), + serde_json::json!("pending-1"), + ); + metadata.insert("queued_turn_id".to_string(), serde_json::json!("queued-1")); + metadata.insert( + "subagent".to_string(), + serde_json::json!({ "sessionId": "subagent-1" }), + ); + let turn_context = TurnContextOverride { + metadata, + ..TurnContextOverride::default() + }; + + with_runtime_scope(scope, Some(turn_context), async { + let context = current_request_correlation_context(); + assert_eq!(context.session_id.as_deref(), Some("session-corr")); + assert_eq!(context.thread_id.as_deref(), Some("thread-corr")); + assert_eq!(context.turn_id.as_deref(), Some("turn-corr")); + assert_eq!(context.pending_request_id.as_deref(), Some("pending-1")); + assert_eq!(context.queued_turn_id.as_deref(), Some("queued-1")); + assert_eq!(context.subagent_session_id.as_deref(), Some("subagent-1")); + }) + .await; + } } diff --git a/src-tauri/crates/core/src/agent/types.rs b/src-tauri/crates/core/src/agent/types.rs index 031788a9b..bfee5ee64 100644 --- a/src-tauri/crates/core/src/agent/types.rs +++ b/src-tauri/crates/core/src/agent/types.rs @@ -446,6 +446,9 @@ pub struct TokenUsage { pub input_tokens: u32, /// 输出 token 数 pub output_tokens: u32, + /// 命中的缓存输入 token 数 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cached_input_tokens: Option, } impl TokenUsage { @@ -454,9 +457,15 @@ impl TokenUsage { Self { input_tokens, output_tokens, + cached_input_tokens: None, } } + pub fn with_cached_input_tokens(mut self, cached_input_tokens: Option) -> Self { + self.cached_input_tokens = cached_input_tokens; + self + } + /// 计算总 token 数 pub fn total(&self) -> u32 { self.input_tokens + self.output_tokens diff --git a/src-tauri/crates/core/src/database/agent_session_repository.rs b/src-tauri/crates/core/src/database/agent_session_repository.rs index 69c851aa2..3f075b14d 100644 --- a/src-tauri/crates/core/src/database/agent_session_repository.rs +++ b/src-tauri/crates/core/src/database/agent_session_repository.rs @@ -219,7 +219,14 @@ pub fn update_latest_assistant_message_usage( session_id: &str, input_tokens: u32, output_tokens: u32, + cached_input_tokens: Option, ) -> Result { - AgentDao::update_latest_assistant_message_usage(conn, session_id, input_tokens, output_tokens) - .map_err(|error| format!("更新最新 assistant 消息 usage 失败: {error}")) + AgentDao::update_latest_assistant_message_usage( + conn, + session_id, + input_tokens, + output_tokens, + cached_input_tokens, + ) + .map_err(|error| format!("更新最新 assistant 消息 usage 失败: {error}")) } diff --git a/src-tauri/crates/core/src/database/dao/agent.rs b/src-tauri/crates/core/src/database/dao/agent.rs index 2e10798f7..e83a1f5bf 100644 --- a/src-tauri/crates/core/src/database/dao/agent.rs +++ b/src-tauri/crates/core/src/database/dao/agent.rs @@ -881,6 +881,46 @@ impl AgentDao { Ok(result) } + pub fn list_message_text_rows_by_session_id( + conn: &Connection, + session_id: &str, + limit: usize, + ) -> Result, rusqlite::Error> { + let mut stmt = conn.prepare( + "SELECT session_id, role, content_json, timestamp + FROM agent_messages + WHERE session_id = ?1 + ORDER BY datetime(timestamp) DESC + LIMIT ?2", + )?; + let rows = stmt.query_map(params![session_id, limit as i64], |row| { + let content_json: String = row.get(2)?; + let timestamp: String = row.get(3)?; + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + parse_message_content(&content_json).as_text(), + parse_message_timestamp_to_millis(×tamp), + )) + })?; + + let mut result = Vec::new(); + for row in rows { + let (session_id, role, content, timestamp_ms) = row?; + let Some(timestamp_ms) = timestamp_ms else { + continue; + }; + result.push(AgentMessageTextRow { + session_id, + role, + content, + timestamp_ms, + }); + } + + Ok(result) + } + /// 更新会话的 updated_at 时间 pub fn update_session_time( conn: &Connection, @@ -926,9 +966,10 @@ impl AgentDao { tool_call_id, reasoning_content, input_tokens, - output_tokens + output_tokens, + cached_input_tokens ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", params![ session_id, message.role, @@ -939,6 +980,10 @@ impl AgentDao { message.reasoning_content.as_deref(), message.usage.as_ref().map(|usage| usage.input_tokens), message.usage.as_ref().map(|usage| usage.output_tokens), + message + .usage + .as_ref() + .and_then(|usage| usage.cached_input_tokens), ], )?; @@ -958,7 +1003,7 @@ impl AgentDao { ) -> Result, rusqlite::Error> { let mut stmt = conn.prepare( "SELECT role, content_json, timestamp, tool_calls_json, tool_call_id, reasoning_content, - input_tokens, output_tokens + input_tokens, output_tokens, cached_input_tokens FROM agent_messages WHERE session_id = ? ORDER BY id ASC", )?; @@ -971,6 +1016,7 @@ impl AgentDao { let reasoning_content: Option = row.get(5)?; let input_tokens: Option = row.get(6)?; let output_tokens: Option = row.get(7)?; + let cached_input_tokens: Option = row.get(8)?; // 解析 JSON - 支持多种格式 // 1. Aster 格式: [{"Text":"..."}, {"Text":"..."}] @@ -988,12 +1034,10 @@ impl AgentDao { tool_call_id, reasoning_content, usage: match (input_tokens, output_tokens) { - (Some(input_tokens), Some(output_tokens)) => { - Some(crate::agent::types::TokenUsage { - input_tokens, - output_tokens, - }) - } + (Some(input_tokens), Some(output_tokens)) => Some( + crate::agent::types::TokenUsage::new(input_tokens, output_tokens) + .with_cached_input_tokens(cached_input_tokens), + ), _ => None, }, }) @@ -1007,17 +1051,18 @@ impl AgentDao { session_id: &str, input_tokens: u32, output_tokens: u32, + cached_input_tokens: Option, ) -> Result { let rows = conn.execute( "UPDATE agent_messages - SET input_tokens = ?1, output_tokens = ?2 + SET input_tokens = ?1, output_tokens = ?2, cached_input_tokens = ?3 WHERE id = ( SELECT id FROM agent_messages - WHERE session_id = ?3 AND role = 'assistant' + WHERE session_id = ?4 AND role = 'assistant' ORDER BY id DESC LIMIT 1 )", - params![input_tokens, output_tokens, session_id], + params![input_tokens, output_tokens, cached_input_tokens, session_id], )?; Ok(rows > 0) @@ -1171,7 +1216,8 @@ mod tests { tool_call_id TEXT, reasoning_content TEXT, input_tokens INTEGER, - output_tokens INTEGER + output_tokens INTEGER, + cached_input_tokens INTEGER ); ", ) @@ -1513,7 +1559,10 @@ mod tests { tool_calls: None, tool_call_id: None, reasoning_content: Some("先分析参数,再继续请求".to_string()), - usage: Some(crate::agent::types::TokenUsage::new(1200, 300)), + usage: Some( + crate::agent::types::TokenUsage::new(1200, 300) + .with_cached_input_tokens(Some(900)), + ), }, ) .unwrap(); @@ -1526,7 +1575,9 @@ mod tests { ); assert_eq!( messages[0].usage, - Some(crate::agent::types::TokenUsage::new(1200, 300)) + Some( + crate::agent::types::TokenUsage::new(1200, 300).with_cached_input_tokens(Some(900)), + ) ); } @@ -1564,9 +1615,14 @@ mod tests { ) .unwrap(); - let updated = - AgentDao::update_latest_assistant_message_usage(&conn, "session-usage", 2048, 512) - .unwrap(); + let updated = AgentDao::update_latest_assistant_message_usage( + &conn, + "session-usage", + 2048, + 512, + Some(1536), + ) + .unwrap(); assert!(updated); let messages = AgentDao::get_messages(&conn, "session-usage").unwrap(); @@ -1574,7 +1630,10 @@ mod tests { assert_eq!(messages[1].usage, None); assert_eq!( messages[2].usage, - Some(crate::agent::types::TokenUsage::new(2048, 512)) + Some( + crate::agent::types::TokenUsage::new(2048, 512) + .with_cached_input_tokens(Some(1536)), + ) ); } } diff --git a/src-tauri/crates/core/src/database/dao/api_key_provider.rs b/src-tauri/crates/core/src/database/dao/api_key_provider.rs index 44d972c4b..4f8d7561a 100644 --- a/src-tauri/crates/core/src/database/dao/api_key_provider.rs +++ b/src-tauri/crates/core/src/database/dao/api_key_provider.rs @@ -152,6 +152,13 @@ impl ApiProviderType { ProviderProtocolFamily::Anthropic ) } + + /// 是否支持自动注入 Anthropic prompt cache 标记。 + /// + /// `AnthropicCompatible` 仅表示协议形状兼容,不代表上游实现了 Anthropic prompt caching。 + pub const fn supports_anthropic_prompt_cache(&self) -> bool { + matches!(self, ApiProviderType::Anthropic) + } } impl std::fmt::Display for ApiProviderType { @@ -200,6 +207,13 @@ mod tests { assert_eq!(spec.default_api_host, "https://api.openai.com"); } + #[test] + fn test_supports_anthropic_prompt_cache() { + assert!(ApiProviderType::Anthropic.supports_anthropic_prompt_cache()); + assert!(!ApiProviderType::AnthropicCompatible.supports_anthropic_prompt_cache()); + assert!(!ApiProviderType::Openai.supports_anthropic_prompt_cache()); + } + #[test] fn test_runtime_spec_contract_matrix() { let cases = [ diff --git a/src-tauri/crates/core/src/database/migration/general_chat_migration.rs b/src-tauri/crates/core/src/database/migration/general_chat_migration.rs index 1605d6bbe..5257e0479 100644 --- a/src-tauri/crates/core/src/database/migration/general_chat_migration.rs +++ b/src-tauri/crates/core/src/database/migration/general_chat_migration.rs @@ -386,7 +386,8 @@ mod tests { created_at TEXT NOT NULL, updated_at TEXT NOT NULL, working_dir TEXT, - execution_strategy TEXT NOT NULL DEFAULT 'react' + execution_strategy TEXT NOT NULL DEFAULT 'react', + cached_input_tokens INTEGER ); CREATE TABLE agent_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -398,7 +399,8 @@ mod tests { tool_call_id TEXT, reasoning_content TEXT, input_tokens INTEGER, - output_tokens INTEGER + output_tokens INTEGER, + cached_input_tokens INTEGER ); ", ) @@ -422,7 +424,8 @@ mod tests { created_at TEXT NOT NULL, updated_at TEXT NOT NULL, working_dir TEXT, - execution_strategy TEXT NOT NULL DEFAULT 'react' + execution_strategy TEXT NOT NULL DEFAULT 'react', + cached_input_tokens INTEGER ); CREATE TABLE agent_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -434,7 +437,8 @@ mod tests { tool_call_id TEXT, reasoning_content TEXT, input_tokens INTEGER, - output_tokens INTEGER + output_tokens INTEGER, + cached_input_tokens INTEGER ); ", ) diff --git a/src-tauri/crates/core/src/database/schema.rs b/src-tauri/crates/core/src/database/schema.rs index 124875b39..be042b113 100644 --- a/src-tauri/crates/core/src/database/schema.rs +++ b/src-tauri/crates/core/src/database/schema.rs @@ -538,6 +538,7 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { total_tokens INTEGER, input_tokens INTEGER, output_tokens INTEGER, + cached_input_tokens INTEGER, accumulated_total_tokens INTEGER, accumulated_input_tokens INTEGER, accumulated_output_tokens INTEGER, @@ -585,6 +586,10 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { "ALTER TABLE agent_sessions ADD COLUMN output_tokens INTEGER", [], ); + let _ = conn.execute( + "ALTER TABLE agent_sessions ADD COLUMN cached_input_tokens INTEGER", + [], + ); let _ = conn.execute( "ALTER TABLE agent_sessions ADD COLUMN accumulated_total_tokens INTEGER", [], @@ -626,6 +631,7 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { reasoning_content TEXT, input_tokens INTEGER, output_tokens INTEGER, + cached_input_tokens INTEGER, FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE )", [], @@ -643,6 +649,10 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { "ALTER TABLE agent_messages ADD COLUMN output_tokens INTEGER", [], ); + let _ = conn.execute( + "ALTER TABLE agent_messages ADD COLUMN cached_input_tokens INTEGER", + [], + ); // 创建 agent_messages 索引 conn.execute( diff --git a/src-tauri/crates/core/src/models/provider_type.rs b/src-tauri/crates/core/src/models/provider_type.rs index 422f0da58..cdc1f10ae 100644 --- a/src-tauri/crates/core/src/models/provider_type.rs +++ b/src-tauri/crates/core/src/models/provider_type.rs @@ -37,6 +37,15 @@ pub enum ProviderType { Ollama, } +impl ProviderType { + /// 是否支持自动注入 Anthropic prompt cache 标记。 + /// + /// `AnthropicCompatible` 只表示请求协议兼容,不能等同于上游具备 Anthropic prompt caching 能力。 + pub const fn supports_anthropic_prompt_cache(&self) -> bool { + matches!(self, Self::Claude | Self::ClaudeOAuth | Self::Anthropic) + } +} + impl std::fmt::Display for ProviderType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -226,4 +235,12 @@ mod tests { ProviderType::OpenAI ); } + + #[test] + fn test_supports_anthropic_prompt_cache() { + assert!(ProviderType::Claude.supports_anthropic_prompt_cache()); + assert!(ProviderType::Anthropic.supports_anthropic_prompt_cache()); + assert!(!ProviderType::AnthropicCompatible.supports_anthropic_prompt_cache()); + assert!(!ProviderType::OpenAI.supports_anthropic_prompt_cache()); + } } diff --git a/src-tauri/crates/infra/src/telemetry/types.rs b/src-tauri/crates/infra/src/telemetry/types.rs index fa7b5cf3a..480ec2808 100644 --- a/src-tauri/crates/infra/src/telemetry/types.rs +++ b/src-tauri/crates/infra/src/telemetry/types.rs @@ -41,6 +41,18 @@ impl std::fmt::Display for RequestStatus { pub struct RequestLog { /// 唯一请求 ID pub id: String, + /// Lime 会话 ID(如果有) + pub session_id: Option, + /// Lime 线程 ID(如果有) + pub thread_id: Option, + /// Lime 回合 ID(如果有) + pub turn_id: Option, + /// 关联的 pending request ID(如果有) + pub pending_request_id: Option, + /// 关联的 queued turn ID(如果有) + pub queued_turn_id: Option, + /// 关联的 subagent session ID(如果有) + pub subagent_session_id: Option, /// 请求时间戳 pub timestamp: DateTime, /// Provider 类型 @@ -74,6 +86,12 @@ impl RequestLog { pub fn new(id: String, provider: ProviderType, model: String, is_streaming: bool) -> Self { Self { id, + session_id: None, + thread_id: None, + turn_id: None, + pending_request_id: None, + queued_turn_id: None, + subagent_session_id: None, timestamp: Utc::now(), provider, model, diff --git a/src-tauri/crates/providers/src/providers/claude_custom.rs b/src-tauri/crates/providers/src/providers/claude_custom.rs index 8105d414b..76c288f5f 100644 --- a/src-tauri/crates/providers/src/providers/claude_custom.rs +++ b/src-tauri/crates/providers/src/providers/claude_custom.rs @@ -3,14 +3,25 @@ use lime_core::models::anthropic::AnthropicMessagesRequest; use lime_core::models::openai::{ChatCompletionRequest, ContentPart, MessageContent}; use reqwest::Client; use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; use std::error::Error; use std::time::Duration; +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum PromptCacheMode { + #[default] + Automatic, + ExplicitOnly, +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ClaudeCustomConfig { pub api_key: Option, pub base_url: Option, pub enabled: bool, + #[serde(default)] + pub prompt_cache_mode: PromptCacheMode, } pub struct ClaudeCustomProvider { @@ -46,17 +57,34 @@ impl Default for ClaudeCustomProvider { } impl ClaudeCustomProvider { + const CACHE_CONTROL_FIELD: &'static str = "cache_control"; + const CONTENT_FIELD: &'static str = "content"; + const ROLE_FIELD: &'static str = "role"; + const SYSTEM_FIELD: &'static str = "system"; + const TOOLS_FIELD: &'static str = "tools"; + const TYPE_FIELD: &'static str = "type"; + const USER_ROLE: &'static str = "user"; + pub fn new() -> Self { Self::default() } /// 使用 API key 和 base_url 创建 Provider pub fn with_config(api_key: String, base_url: Option) -> Self { + Self::with_prompt_cache_mode(api_key, base_url, PromptCacheMode::Automatic) + } + + pub fn with_prompt_cache_mode( + api_key: String, + base_url: Option, + prompt_cache_mode: PromptCacheMode, + ) -> Self { Self { config: ClaudeCustomConfig { api_key: Some(api_key), base_url, enabled: true, + prompt_cache_mode, }, client: create_http_client(), } @@ -213,6 +241,136 @@ impl ClaudeCustomProvider { } } + fn value_contains_cache_control(value: &Value) -> bool { + match value { + Value::Object(map) => { + map.contains_key(Self::CACHE_CONTROL_FIELD) + || map.values().any(Self::value_contains_cache_control) + } + Value::Array(items) => items.iter().any(Self::value_contains_cache_control), + _ => false, + } + } + + fn add_ephemeral_cache_control(block: &mut Value) { + if let Some(obj) = block.as_object_mut() { + obj.entry(Self::CACHE_CONTROL_FIELD.to_string()) + .or_insert_with(|| json!({ Self::TYPE_FIELD: "ephemeral" })); + } + } + + fn add_cache_control_to_content(content: &mut Value) { + match content { + Value::String(text) if !text.is_empty() => { + *content = json!([{ + Self::TYPE_FIELD: "text", + "text": text.clone(), + Self::CACHE_CONTROL_FIELD: { Self::TYPE_FIELD: "ephemeral" } + }]); + } + Value::Array(items) => { + if let Some(last) = items.last_mut() { + Self::add_ephemeral_cache_control(last); + } + } + _ => {} + } + } + + fn apply_prompt_cache_control(payload: &mut Value) { + if Self::value_contains_cache_control(payload) { + return; + } + + if let Some(messages) = payload + .as_object_mut() + .and_then(|obj| obj.get_mut("messages")) + .and_then(Value::as_array_mut) + { + let mut user_count = 0; + for message in messages.iter_mut().rev() { + if message.get(Self::ROLE_FIELD) == Some(&json!(Self::USER_ROLE)) { + if let Some(content) = message.get_mut(Self::CONTENT_FIELD) { + Self::add_cache_control_to_content(content); + } + user_count += 1; + if user_count >= 2 { + break; + } + } + } + } + + if let Some(system) = payload + .as_object_mut() + .and_then(|obj| obj.get_mut(Self::SYSTEM_FIELD)) + { + Self::add_cache_control_to_content(system); + } + + if let Some(tools) = payload + .as_object_mut() + .and_then(|obj| obj.get_mut(Self::TOOLS_FIELD)) + .and_then(Value::as_array_mut) + { + if let Some(last_tool) = tools.last_mut() { + Self::add_ephemeral_cache_control(last_tool); + } + } + } + + fn apply_prompt_cache_control_for_mode( + prompt_cache_mode: PromptCacheMode, + payload: &mut Value, + ) { + if prompt_cache_mode == PromptCacheMode::ExplicitOnly { + if Self::value_contains_cache_control(payload) { + tracing::debug!("[CLAUDE_API] 检测到显式 cache_control,保留上游标记"); + } else { + tracing::debug!( + "[CLAUDE_API] 当前 provider 未声明支持自动 prompt cache,跳过自动注入" + ); + } + return; + } + + Self::apply_prompt_cache_control(payload); + } + + fn maybe_apply_prompt_cache_control(&self, payload: &mut Value) { + Self::apply_prompt_cache_control_for_mode(self.config.prompt_cache_mode, payload); + } + + fn anthropic_prompt_tokens(response: &Value) -> u64 { + let usage = response + .get("usage") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + + usage + .get("input_tokens") + .and_then(Value::as_u64) + .unwrap_or(0) + + usage + .get("cache_creation_input_tokens") + .and_then(Value::as_u64) + .unwrap_or(0) + + usage + .get("cache_read_input_tokens") + .and_then(Value::as_u64) + .unwrap_or(0) + } + + fn anthropic_completion_tokens(response: &Value) -> u64 { + response + .get("usage") + .and_then(Value::as_object) + .and_then(|usage| usage.get("output_tokens")) + .and_then(Value::as_u64) + .unwrap_or(0) + } + /// 调用 Anthropic API(原生格式) pub async fn call_api( &self, @@ -234,13 +392,16 @@ impl ClaudeCustomProvider { request.stream ); + let mut payload = serde_json::to_value(request)?; + self.maybe_apply_prompt_cache_control(&mut payload); + let resp = self .client .post(&url) .header("x-api-key", api_key) .header("anthropic-version", "2023-06-01") .header("Content-Type", "application/json") - .json(request) + .json(&payload) .send() .await?; @@ -339,6 +500,7 @@ impl ClaudeCustomProvider { if let Some(tc) = Self::convert_openai_tool_choice_to_anthropic(&request.tool_choice) { anthropic_body["tool_choice"] = tc; } + self.maybe_apply_prompt_cache_control(&mut anthropic_body); let api_key = self .config @@ -398,8 +560,10 @@ impl ClaudeCustomProvider { .and_then(|arr| arr.first()) .and_then(|block| block["text"].as_str()) .unwrap_or(""); + let prompt_tokens = Self::anthropic_prompt_tokens(&anthropic_resp); + let completion_tokens = Self::anthropic_completion_tokens(&anthropic_resp); - Ok(serde_json::json!({ + Ok(json!({ "id": format!("chatcmpl-{}", uuid::Uuid::new_v4()), "object": "chat.completion", "created": std::time::SystemTime::now() @@ -416,9 +580,9 @@ impl ClaudeCustomProvider { "finish_reason": "stop" }], "usage": { - "prompt_tokens": anthropic_resp["usage"]["input_tokens"].as_u64().unwrap_or(0), - "completion_tokens": anthropic_resp["usage"]["output_tokens"].as_u64().unwrap_or(0), - "total_tokens": 0 + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": prompt_tokens + completion_tokens } })) } @@ -451,13 +615,16 @@ impl ClaudeCustomProvider { stream ); + let mut payload = request.clone(); + self.maybe_apply_prompt_cache_control(&mut payload); + let resp = self .client .post(&url) .header("x-api-key", api_key) .header("anthropic-version", "2023-06-01") .header("Content-Type", "application/json") - .json(request) + .json(&payload) .send() .await?; @@ -673,6 +840,7 @@ impl StreamingProvider for ClaudeCustomProvider { anthropic_body["tool_choice"] ); } + self.maybe_apply_prompt_cache_control(&mut anthropic_body); let url = self.build_url("messages"); @@ -891,4 +1059,135 @@ mod tests { assert_eq!(description.matches("[InputExamples]").count(), 1); assert_eq!(description.matches("[AllowedCallers]").count(), 1); } + + #[test] + fn test_apply_prompt_cache_control_marks_system_last_two_users_and_last_tool() { + let mut payload = json!({ + "model": "claude-sonnet-4", + "system": "You are a helpful assistant.", + "messages": [ + { + "role": "user", + "content": [{"type": "text", "text": "第一轮"}] + }, + { + "role": "assistant", + "content": [{"type": "text", "text": "收到"}] + }, + { + "role": "user", + "content": [{"type": "text", "text": "第二轮"}] + }, + { + "role": "user", + "content": "第三轮" + } + ], + "tools": [ + {"name": "tool_a", "input_schema": {"type": "object"}}, + {"name": "tool_b", "input_schema": {"type": "object"}} + ] + }); + + ClaudeCustomProvider::apply_prompt_cache_control(&mut payload); + + let system = payload["system"] + .as_array() + .expect("system should be array"); + assert_eq!(system[0]["cache_control"]["type"], "ephemeral"); + + let messages = payload["messages"] + .as_array() + .expect("messages should be array"); + assert!(messages[0]["content"][0].get("cache_control").is_none()); + assert_eq!( + messages[2]["content"][0]["cache_control"]["type"], + "ephemeral" + ); + assert_eq!( + messages[3]["content"][0]["cache_control"]["type"], + "ephemeral" + ); + + let tools = payload["tools"].as_array().expect("tools should be array"); + assert!(tools[0].get("cache_control").is_none()); + assert_eq!(tools[1]["cache_control"]["type"], "ephemeral"); + } + + #[test] + fn test_apply_prompt_cache_control_respects_existing_markers() { + let mut payload = json!({ + "model": "claude-sonnet-4", + "system": [{ + "type": "text", + "text": "preset", + "cache_control": {"type": "ephemeral"} + }], + "messages": [ + { + "role": "user", + "content": [{"type": "text", "text": "hello"}] + } + ], + "tools": [ + {"name": "tool_a", "input_schema": {"type": "object"}} + ] + }); + + ClaudeCustomProvider::apply_prompt_cache_control(&mut payload); + + assert!(payload["messages"][0]["content"][0] + .get("cache_control") + .is_none()); + assert!(payload["tools"][0].get("cache_control").is_none()); + } + + #[test] + fn test_apply_prompt_cache_control_for_explicit_only_skips_auto_injection() { + let mut payload = json!({ + "model": "glm-5.1", + "system": "You are a helpful assistant.", + "messages": [ + { + "role": "user", + "content": [{"type": "text", "text": "hello"}] + } + ], + "tools": [ + {"name": "tool_a", "input_schema": {"type": "object"}} + ] + }); + + ClaudeCustomProvider::apply_prompt_cache_control_for_mode( + PromptCacheMode::ExplicitOnly, + &mut payload, + ); + + assert!(payload["system"].is_string()); + assert!(payload["messages"][0]["content"][0] + .get("cache_control") + .is_none()); + assert!(payload["tools"][0].get("cache_control").is_none()); + } + + #[test] + fn test_anthropic_prompt_tokens_include_cache_usage() { + let response = json!({ + "usage": { + "input_tokens": 15, + "cache_creation_input_tokens": 120, + "cache_read_input_tokens": 80, + "output_tokens": 10 + } + }); + + assert_eq!( + ClaudeCustomProvider::anthropic_prompt_tokens(&response), + 215 + ); + assert_eq!( + ClaudeCustomProvider::anthropic_completion_tokens(&response), + 10 + ); + } } diff --git a/src-tauri/crates/providers/src/providers/mod.rs b/src-tauri/crates/providers/src/providers/mod.rs index e7f45ef01..328401ec3 100644 --- a/src-tauri/crates/providers/src/providers/mod.rs +++ b/src-tauri/crates/providers/src/providers/mod.rs @@ -24,7 +24,7 @@ pub use antigravity::AntigravityProvider; #[allow(unused_imports)] pub use antigravity::ANTIGRAVITY_MODELS_FALLBACK; #[allow(unused_imports)] -pub use claude_custom::ClaudeCustomProvider; +pub use claude_custom::{ClaudeCustomProvider, PromptCacheMode}; #[allow(unused_imports)] pub use claude_oauth::ClaudeOAuthProvider; #[allow(unused_imports)] diff --git a/src-tauri/crates/providers/src/streaming/converter.rs b/src-tauri/crates/providers/src/streaming/converter.rs index cb7d5d204..50795c48f 100644 --- a/src-tauri/crates/providers/src/streaming/converter.rs +++ b/src-tauri/crates/providers/src/streaming/converter.rs @@ -59,6 +59,50 @@ struct ToolCallAccumulator { index: u32, } +/// Anthropic 流式 usage 状态 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct AnthropicUsageState { + /// 输入 token + input_tokens: u64, + /// 输出 token + output_tokens: u64, + /// Cache read token + cache_read_input_tokens: u64, + /// Cache creation token + cache_creation_input_tokens: u64, +} + +impl AnthropicUsageState { + fn apply_usage_object(&mut self, usage: &serde_json::Value) { + if let Some(input_tokens) = usage.get("input_tokens").and_then(|v| v.as_u64()) { + self.input_tokens = input_tokens; + } + if let Some(output_tokens) = usage.get("output_tokens").and_then(|v| v.as_u64()) { + self.output_tokens = output_tokens; + } + if let Some(cache_read_input_tokens) = usage + .get("cache_read_input_tokens") + .and_then(|v| v.as_u64()) + { + self.cache_read_input_tokens = cache_read_input_tokens; + } + if let Some(cache_creation_input_tokens) = usage + .get("cache_creation_input_tokens") + .and_then(|v| v.as_u64()) + { + self.cache_creation_input_tokens = cache_creation_input_tokens; + } + } + + fn prompt_tokens(&self) -> u64 { + self.input_tokens + self.cache_read_input_tokens + self.cache_creation_input_tokens + } + + fn total_tokens(&self) -> u64 { + self.prompt_tokens() + self.output_tokens + } +} + /// 部分 JSON 累积器 /// /// 用于处理工具调用参数中的部分 JSON @@ -159,6 +203,12 @@ pub struct StreamConverter { message_started: bool, /// 累积的内容(用于重建完整响应) accumulated_content: String, + /// Anthropic 流式 usage 状态 + anthropic_usage: Option, + /// Anthropic stop_reason 映射后的 OpenAI finish_reason + anthropic_finish_reason: Option, + /// 是否已输出终止事件,避免 finish() 重复输出 + terminal_event_emitted: bool, } impl StreamConverter { @@ -181,6 +231,9 @@ impl StreamConverter { next_content_block_index: 0, message_started: false, accumulated_content: String::new(), + anthropic_usage: None, + anthropic_finish_reason: None, + terminal_event_emitted: false, } } @@ -217,6 +270,9 @@ impl StreamConverter { self.next_content_block_index = 0; self.message_started = false; self.accumulated_content.clear(); + self.anthropic_usage = None; + self.anthropic_finish_reason = None; + self.terminal_event_emitted = false; } /// 转换 chunk @@ -473,6 +529,7 @@ impl StreamConverter { for line in data.lines() { if let Some(json_str) = line.strip_prefix("data: ") { if json_str == "[DONE]" { + self.terminal_event_emitted = true; sse_events.push("data: [DONE]\n\n".to_string()); continue; } @@ -480,6 +537,12 @@ impl StreamConverter { if let Ok(event) = serde_json::from_str::(json_str) { if let Some(event_type) = event.get("type").and_then(|t| t.as_str()) { match event_type { + "message_start" => { + self.capture_anthropic_message_start_usage(&event); + } + "message_delta" => { + self.capture_anthropic_message_delta_state(&event); + } "content_block_delta" => { if let Some(delta) = event.get("delta") { if let Some(text) = delta.get("text").and_then(|t| t.as_str()) { @@ -550,8 +613,14 @@ impl StreamConverter { } } "message_stop" => { - sse_events.push(self.create_openai_finish_chunk("stop")); + let finish_reason = + self.anthropic_finish_reason.as_deref().unwrap_or("stop"); + sse_events.push(self.create_openai_finish_chunk( + finish_reason, + self.anthropic_openai_usage_json(), + )); sse_events.push("data: [DONE]\n\n".to_string()); + self.terminal_event_emitted = true; } _ => {} } @@ -573,6 +642,10 @@ impl StreamConverter { /// 生成结束事件 fn generate_end_events(&mut self) -> Vec { + if self.terminal_event_emitted { + return vec![]; + } + match self.target_format { StreamFormat::AnthropicSse => { vec![ @@ -587,7 +660,7 @@ impl StreamConverter { "tool_calls" }; vec![ - self.create_openai_finish_chunk(finish_reason), + self.create_openai_finish_chunk(finish_reason, None), "data: [DONE]\n\n".to_string(), ] } @@ -777,8 +850,12 @@ impl StreamConverter { format!("data: {chunk}\n\n") } - fn create_openai_finish_chunk(&self, finish_reason: &str) -> String { - let chunk = serde_json::json!({ + fn create_openai_finish_chunk( + &self, + finish_reason: &str, + usage: Option, + ) -> String { + let mut chunk = serde_json::json!({ "id": self.response_id, "object": "chat.completion.chunk", "created": self.get_created_timestamp(), @@ -789,14 +866,74 @@ impl StreamConverter { "finish_reason": finish_reason }] }); + if let Some(usage) = usage { + chunk["usage"] = usage; + } format!("data: {chunk}\n\n") } + + fn capture_anthropic_message_start_usage(&mut self, event: &serde_json::Value) { + let Some(usage) = event + .get("message") + .and_then(|message| message.get("usage")) + else { + return; + }; + + self.anthropic_usage + .get_or_insert_with(AnthropicUsageState::default) + .apply_usage_object(usage); + } + + fn capture_anthropic_message_delta_state(&mut self, event: &serde_json::Value) { + if let Some(usage) = event.get("usage") { + self.anthropic_usage + .get_or_insert_with(AnthropicUsageState::default) + .apply_usage_object(usage); + } + + if let Some(stop_reason) = event + .get("delta") + .and_then(|delta| delta.get("stop_reason")) + .and_then(|stop_reason| stop_reason.as_str()) + { + self.anthropic_finish_reason = Some(map_anthropic_stop_reason(stop_reason).to_string()); + } + } + + fn anthropic_openai_usage_json(&self) -> Option { + let usage = self.anthropic_usage.as_ref()?; + let prompt_tokens = usage.prompt_tokens(); + let completion_tokens = usage.output_tokens; + + let mut openai_usage = serde_json::json!({ + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": usage.total_tokens() + }); + + if usage.cache_read_input_tokens > 0 { + openai_usage["prompt_tokens_details"] = serde_json::json!({ + "cached_tokens": usage.cache_read_input_tokens + }); + } + + Some(openai_usage) + } } // ============================================================================ // 辅助函数 // ============================================================================ +fn map_anthropic_stop_reason(stop_reason: &str) -> &'static str { + match stop_reason { + "max_tokens" => "length", + "tool_use" => "tool_calls", + _ => "stop", + } +} + /// 从 SSE 事件列表中提取所有文本内容 pub fn extract_content_from_sse(events: &[String], format: StreamFormat) -> String { let mut content = String::new(); @@ -931,6 +1068,14 @@ pub fn extract_tool_calls_from_sse( mod tests { use super::*; + fn parse_openai_sse_payload(event: &str) -> serde_json::Value { + let json_str = event + .lines() + .find_map(|line| line.strip_prefix("data: ")) + .expect("expected data line"); + serde_json::from_str(json_str).expect("expected valid SSE json payload") + } + #[test] fn test_converter_new() { let converter = StreamConverter::new(StreamFormat::AwsEventStream, StreamFormat::OpenAiSse); @@ -1161,6 +1306,81 @@ mod tests { assert_eq!(content, "test"); } + #[test] + fn test_anthropic_to_openai_finish_chunk_includes_cache_aware_usage() { + let mut converter = StreamConverter::with_model( + StreamFormat::AnthropicSse, + StreamFormat::OpenAiSse, + "claude-3-7-sonnet", + ); + + let start_events = converter.convert( + br#"event: message_start +data: {"type":"message_start","message":{"id":"msg_test","type":"message","role":"assistant","model":"claude-3-7-sonnet","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":120,"output_tokens":0,"cache_read_input_tokens":80,"cache_creation_input_tokens":16}}} + +"#, + ); + assert!(start_events.is_empty()); + + let delta_events = converter.convert( + br#"event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":24}} + +"#, + ); + assert!(delta_events.is_empty()); + + let stop_events = converter.convert( + br#"event: message_stop +data: {"type":"message_stop"} + +"#, + ); + + assert_eq!(stop_events.len(), 2); + assert_eq!(stop_events[1], "data: [DONE]\n\n"); + + let finish_payload = parse_openai_sse_payload(&stop_events[0]); + assert_eq!(finish_payload["choices"][0]["finish_reason"], "stop"); + assert_eq!(finish_payload["usage"]["prompt_tokens"], 216); + assert_eq!(finish_payload["usage"]["completion_tokens"], 24); + assert_eq!(finish_payload["usage"]["total_tokens"], 240); + assert_eq!( + finish_payload["usage"]["prompt_tokens_details"]["cached_tokens"], + 80 + ); + + assert!(converter.finish().is_empty()); + } + + #[test] + fn test_anthropic_to_openai_tool_use_finish_reason_maps_to_tool_calls() { + let mut converter = StreamConverter::with_model( + StreamFormat::AnthropicSse, + StreamFormat::OpenAiSse, + "claude-3-7-sonnet", + ); + + let delta_events = converter.convert( + br#"event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":7}} + +"#, + ); + assert!(delta_events.is_empty()); + + let stop_events = converter.convert( + br#"event: message_stop +data: {"type":"message_stop"} + +"#, + ); + + let finish_payload = parse_openai_sse_payload(&stop_events[0]); + assert_eq!(finish_payload["choices"][0]["finish_reason"], "tool_calls"); + assert_eq!(finish_payload["usage"]["completion_tokens"], 7); + } + #[test] fn test_converter_state_transitions() { let mut converter = diff --git a/src-tauri/crates/server/src/handlers/api.rs b/src-tauri/crates/server/src/handlers/api.rs index 3a5a7f76e..bb1306354 100644 --- a/src-tauri/crates/server/src/handlers/api.rs +++ b/src-tauri/crates/server/src/handlers/api.rs @@ -35,6 +35,10 @@ use crate::middleware::request_dedup::{ use crate::middleware::response_cache::{CachedHttpResponse, ResponseCacheStore}; use crate::{record_request_telemetry, record_token_usage, AppState}; use aster::context::MODEL_CONTEXT_WINDOWS; +use aster::session_context::{ + PENDING_REQUEST_ID_HEADER, QUEUED_TURN_ID_HEADER, SESSION_ID_HEADER, + SUBAGENT_SESSION_ID_HEADER, THREAD_ID_HEADER, TURN_ID_HEADER, +}; use lime_core::errors::GatewayErrorCode; use lime_core::models::anthropic::AnthropicMessagesRequest; use lime_core::models::openai::{ChatCompletionRequest, ContentPart, MessageContent}; @@ -462,6 +466,28 @@ fn headers_to_string_map(headers: &axum::http::HeaderMap) -> HashMap Option<(u32, u32)>; + +struct UsageCapture<'a> { + state: &'a AppState, + ctx: &'a RequestContext, + parser: UsageParser, + fallback_input_tokens: Option, + fallback_output_tokens: Option, +} + +fn clamp_usage_tokens(value: u64) -> u32 { + value.min(u32::MAX as u64) as u32 +} + +fn extract_openai_usage_tokens(payload: &serde_json::Value) -> Option<(u32, u32)> { + let usage = payload.get("usage")?; + let prompt_tokens = usage.get("prompt_tokens")?.as_u64()?; + let completion_tokens = usage.get("completion_tokens")?.as_u64()?; + + Some(( + clamp_usage_tokens(prompt_tokens), + clamp_usage_tokens(completion_tokens), + )) +} + +fn anthropic_prompt_tokens(usage: &serde_json::Value) -> u64 { + usage + .get("input_tokens") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0) + + usage + .get("cache_creation_input_tokens") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0) + + usage + .get("cache_read_input_tokens") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0) +} + +fn extract_anthropic_usage_tokens(payload: &serde_json::Value) -> Option<(u32, u32)> { + let usage = payload.get("usage")?; + let prompt_tokens = anthropic_prompt_tokens(usage); + let completion_tokens = usage.get("output_tokens")?.as_u64()?; + + Some(( + clamp_usage_tokens(prompt_tokens), + clamp_usage_tokens(completion_tokens), + )) +} + +fn record_captured_token_usage( + capture: &UsageCapture<'_>, + response_status: StatusCode, + response_body: &[u8], +) { + if !response_status.is_success() { + return; + } + + let parsed_usage = serde_json::from_slice::(response_body) + .ok() + .and_then(|payload| (capture.parser)(&payload)); + + let usage = parsed_usage.or_else(|| { + match ( + capture.fallback_input_tokens, + capture.fallback_output_tokens, + ) { + (Some(input_tokens), Some(output_tokens)) => Some((input_tokens, output_tokens)), + _ => None, + } + }); + + if let Some((input_tokens, output_tokens)) = usage { + record_token_usage( + capture.state, + capture.ctx, + Some(input_tokens), + Some(output_tokens), + ); + } +} + async fn finalize_replayable_response( - mut response: Response, + response: Response, guard: &mut IdempotencyGuard, dedup_guard: &mut RequestDedupGuard, cache_guard: &mut ResponseCacheGuard, request_id: &str, +) -> Response { + finalize_replayable_response_with_usage_capture( + response, + guard, + dedup_guard, + cache_guard, + request_id, + None, + ) + .await +} + +async fn finalize_replayable_response_with_usage_capture( + mut response: Response, + guard: &mut IdempotencyGuard, + dedup_guard: &mut RequestDedupGuard, + cache_guard: &mut ResponseCacheGuard, + request_id: &str, + usage_capture: Option>, ) -> Response { set_request_id_header(&mut response, request_id); - if !guard.is_enabled() && !dedup_guard.is_enabled() && !cache_guard.is_enabled() { + if !guard.is_enabled() + && !dedup_guard.is_enabled() + && !cache_guard.is_enabled() + && usage_capture.is_none() + { return response; } @@ -671,6 +804,9 @@ async fn finalize_replayable_response( let (parts, body) = response.into_parts(); match to_bytes(body, REPLAY_CAPTURE_MAX_BYTES).await { Ok(bytes) => { + if let Some(capture) = usage_capture.as_ref() { + record_captured_token_usage(capture, parts.status, &bytes); + } let body_string = String::from_utf8_lossy(&bytes).to_string(); let headers_map = headers_to_string_map(&parts.headers); guard.complete(status, body_string.clone()); @@ -735,6 +871,66 @@ mod tests { })) } + #[test] + fn attach_request_correlation_metadata_should_copy_headers_into_context() { + let mut headers = HeaderMap::new(); + headers.insert( + SESSION_ID_HEADER, + header::HeaderValue::from_static("session-1"), + ); + headers.insert( + THREAD_ID_HEADER, + header::HeaderValue::from_static("thread-1"), + ); + headers.insert(TURN_ID_HEADER, header::HeaderValue::from_static("turn-1")); + headers.insert( + PENDING_REQUEST_ID_HEADER, + header::HeaderValue::from_static("pending-1"), + ); + headers.insert( + QUEUED_TURN_ID_HEADER, + header::HeaderValue::from_static("queued-1"), + ); + headers.insert( + SUBAGENT_SESSION_ID_HEADER, + header::HeaderValue::from_static("subagent-1"), + ); + + let mut ctx = RequestContext::new("gpt-5.4".to_string()); + attach_request_correlation_metadata(&mut ctx, &headers); + + assert_eq!( + ctx.get_metadata("session_id") + .and_then(serde_json::Value::as_str), + Some("session-1") + ); + assert_eq!( + ctx.get_metadata("thread_id") + .and_then(serde_json::Value::as_str), + Some("thread-1") + ); + assert_eq!( + ctx.get_metadata("turn_id") + .and_then(serde_json::Value::as_str), + Some("turn-1") + ); + assert_eq!( + ctx.get_metadata("pending_request_id") + .and_then(serde_json::Value::as_str), + Some("pending-1") + ); + assert_eq!( + ctx.get_metadata("queued_turn_id") + .and_then(serde_json::Value::as_str), + Some("queued-1") + ); + assert_eq!( + ctx.get_metadata("subagent_session_id") + .and_then(serde_json::Value::as_str), + Some("subagent-1") + ); + } + #[tokio::test] async fn finalize_idempotency_response_should_complete_for_non_5xx() { let store = create_store(); @@ -1087,6 +1283,60 @@ mod tests { .iter() .any(|reason| matches!(reason, CapabilityMismatchReason::ContextTooSmall { .. }))); } + + #[test] + fn extract_openai_usage_tokens_should_read_prompt_and_completion_tokens() { + let payload = serde_json::json!({ + "usage": { + "prompt_tokens": 321, + "completion_tokens": 123, + "total_tokens": 444 + } + }); + + assert_eq!(extract_openai_usage_tokens(&payload), Some((321, 123))); + } + + #[test] + fn extract_anthropic_usage_tokens_should_include_cache_tokens_in_prompt_total() { + let payload = serde_json::json!({ + "usage": { + "input_tokens": 300, + "cache_creation_input_tokens": 120, + "cache_read_input_tokens": 80, + "output_tokens": 45 + } + }); + + assert_eq!(extract_anthropic_usage_tokens(&payload), Some((500, 45))); + } + + #[test] + fn convert_anthropic_response_to_openai_should_preserve_cache_aware_usage() { + let payload = serde_json::json!({ + "id": "msg_123", + "content": [{ "type": "text", "text": "hello" }], + "stop_reason": "end_turn", + "usage": { + "input_tokens": 200, + "cache_creation_input_tokens": 50, + "cache_read_input_tokens": 75, + "output_tokens": 25 + } + }); + + let response = convert_anthropic_response_to_openai(&payload, "claude-sonnet-4"); + let parsed: serde_json::Value = + serde_json::from_str(&response).expect("response should be valid json"); + + assert_eq!(parsed["usage"]["prompt_tokens"], 325); + assert_eq!(parsed["usage"]["completion_tokens"], 25); + assert_eq!(parsed["usage"]["total_tokens"], 350); + assert_eq!( + parsed["usage"]["prompt_tokens_details"]["cached_tokens"], + 75 + ); + } } // ============================================================================ @@ -1827,6 +2077,7 @@ pub async fn chat_completions( // 创建请求上下文 let mut ctx = RequestContext::new(request.model.clone()).with_stream(request.stream); + attach_request_correlation_metadata(&mut ctx, &headers); eprintln!("[CHAT_COMPLETIONS] 请求ID: {}", ctx.request_id); // 幂等性检查(仅非流式) @@ -2126,15 +2377,32 @@ pub async fn chat_completions( }; record_request_telemetry(&state, &ctx, status, None); + let estimated_input_tokens = request + .messages + .iter() + .map(|message| match &message.content { + Some(content) => message_content_len(content), + None => 0, + } / 4) + .sum::() as u32; + let estimated_output_tokens = if is_success { 100 } else { 0 }; + // 如果成功且需要 Flow 捕获,提取响应体内容和响应头 // 注意:非流式响应需要读取 body,所以必须在这里处理 return attach_route_debug_headers( - finalize_replayable_response( + finalize_replayable_response_with_usage_capture( response, &mut idempotency_guard, &mut dedup_guard, &mut cache_guard, &ctx.request_id, + Some(UsageCapture { + state: &state, + ctx: &ctx, + parser: extract_openai_usage_tokens, + fallback_input_tokens: Some(estimated_input_tokens), + fallback_output_tokens: Some(estimated_output_tokens), + }), ) .await, &selected_provider, @@ -2553,6 +2821,7 @@ pub async fn anthropic_messages( // 创建请求上下文 let mut ctx = RequestContext::new(request.model.clone()).with_stream(request.stream); + attach_request_correlation_metadata(&mut ctx, &headers); // 幂等性检查(仅非流式) let idempotency_key = headers @@ -2869,7 +3138,6 @@ pub async fn anthropic_messages( }; record_request_telemetry(&state, &ctx, status, None); - // 估算 Token 使用量 let estimated_input_tokens = request .messages .iter() @@ -2888,25 +3156,23 @@ pub async fn anthropic_messages( .sum::() as u32; let estimated_output_tokens = if is_success { 100u32 } else { 0u32 }; - if is_success { - record_token_usage( - &state, - &ctx, - Some(estimated_input_tokens), - Some(estimated_output_tokens), - ); - } - // 完成 Flow 捕获并检查响应拦截 // **Validates: Requirements 2.1, 2.5** return attach_route_debug_headers( - finalize_replayable_response( + finalize_replayable_response_with_usage_capture( response, &mut idempotency_guard, &mut dedup_guard, &mut cache_guard, &ctx.request_id, + Some(UsageCapture { + state: &state, + ctx: &ctx, + parser: extract_anthropic_usage_tokens, + fallback_input_tokens: Some(estimated_input_tokens), + fallback_output_tokens: Some(estimated_output_tokens), + }), ) .await, &selected_provider, @@ -3506,12 +3772,30 @@ fn convert_anthropic_response_to_openai(anthropic_resp: &serde_json::Value, mode .and_then(|c| c["text"].as_str()) .unwrap_or(""); - let usage = serde_json::json!({ - "prompt_tokens": anthropic_resp["usage"]["input_tokens"].as_u64().unwrap_or(0), - "completion_tokens": anthropic_resp["usage"]["output_tokens"].as_u64().unwrap_or(0), - "total_tokens": anthropic_resp["usage"]["input_tokens"].as_u64().unwrap_or(0) - + anthropic_resp["usage"]["output_tokens"].as_u64().unwrap_or(0) + let usage_obj = anthropic_resp + .get("usage") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + let prompt_tokens = anthropic_prompt_tokens(&usage_obj); + let completion_tokens = usage_obj + .get("output_tokens") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let cache_read_tokens = usage_obj + .get("cache_read_input_tokens") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + + let mut usage = serde_json::json!({ + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": prompt_tokens + completion_tokens }); + if cache_read_tokens > 0 { + usage["prompt_tokens_details"] = serde_json::json!({ + "cached_tokens": cache_read_tokens + }); + } let openai_resp = serde_json::json!({ "id": anthropic_resp["id"].as_str().unwrap_or("chatcmpl-unknown"), diff --git a/src-tauri/crates/server/src/handlers/provider_calls.rs b/src-tauri/crates/server/src/handlers/provider_calls.rs index fdcc41b49..758581173 100644 --- a/src-tauri/crates/server/src/handlers/provider_calls.rs +++ b/src-tauri/crates/server/src/handlers/provider_calls.rs @@ -59,7 +59,7 @@ use lime_providers::converter::openai_to_antigravity::{ }; use lime_providers::providers::{ AntigravityProvider, ClaudeCustomProvider, CodexProvider, KiroProvider, OpenAICustomProvider, - VertexProvider, + PromptCacheMode, VertexProvider, }; use lime_providers::session::store_thought_signature; use lime_providers::stream::{PipelineConfig, StreamPipeline}; @@ -548,7 +548,16 @@ pub async fn call_provider_anthropic( CredentialData::ClaudeKey { api_key, base_url } => { // 打印 Claude 代理 URL 用于调试 let actual_base_url = base_url.as_deref().unwrap_or("https://api.anthropic.com"); - let claude = ClaudeCustomProvider::with_config(api_key.clone(), base_url.clone()); + let prompt_cache_mode = if credential.provider_type.supports_anthropic_prompt_cache() { + PromptCacheMode::Automatic + } else { + PromptCacheMode::ExplicitOnly + }; + let claude = ClaudeCustomProvider::with_prompt_cache_mode( + api_key.clone(), + base_url.clone(), + prompt_cache_mode, + ); let request_url = claude.get_base_url(); state.logs.write().await.add( "info", @@ -773,7 +782,11 @@ pub async fn call_provider_anthropic( // Anthropic API Key - 根据 base_url 决定调用方式 CredentialData::AnthropicKey { api_key, base_url } => { // 使用 Anthropic 原生格式调用(无论是否有自定义 base_url) - let claude = ClaudeCustomProvider::with_config(api_key.clone(), base_url.clone()); + let claude = ClaudeCustomProvider::with_prompt_cache_mode( + api_key.clone(), + base_url.clone(), + PromptCacheMode::Automatic, + ); let request_url = claude.get_base_url(); state.logs.write().await.add( "info", @@ -1667,7 +1680,16 @@ pub async fn call_provider_openai( &credential.uuid[..8], request.stream ); - let claude = ClaudeCustomProvider::with_config(api_key.clone(), base_url.clone()); + let prompt_cache_mode = if credential.provider_type.supports_anthropic_prompt_cache() { + PromptCacheMode::Automatic + } else { + PromptCacheMode::ExplicitOnly + }; + let claude = ClaudeCustomProvider::with_prompt_cache_mode( + api_key.clone(), + base_url.clone(), + prompt_cache_mode, + ); // 检查是否为流式请求 if request.stream { diff --git a/src-tauri/crates/server/src/handlers/websocket.rs b/src-tauri/crates/server/src/handlers/websocket.rs index f82aa8a1f..b79e5dd43 100644 --- a/src-tauri/crates/server/src/handlers/websocket.rs +++ b/src-tauri/crates/server/src/handlers/websocket.rs @@ -28,13 +28,27 @@ use lime_providers::converter::openai_to_antigravity::{ convert_antigravity_to_openai_response, convert_openai_to_antigravity_with_context, }; use lime_providers::providers::{ - AntigravityProvider, ClaudeCustomProvider, KiroProvider, OpenAICustomProvider, + AntigravityProvider, ClaudeCustomProvider, KiroProvider, OpenAICustomProvider, PromptCacheMode, }; use lime_server_utils::parse_cw_response; use lime_websocket::{ WsApiRequest, WsApiResponse, WsEndpoint, WsError, WsMessage as WsProtoMessage, }; +fn extract_openai_usage_pair(response: &serde_json::Value) -> (u64, u64) { + let usage = response.get("usage").and_then(serde_json::Value::as_object); + let prompt_tokens = usage + .and_then(|usage| usage.get("prompt_tokens")) + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let completion_tokens = usage + .and_then(|usage| usage.get("completion_tokens")) + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + + (prompt_tokens, completion_tokens) +} + /// WebSocket 查询参数 #[derive(Debug, Deserialize, Default)] pub struct WsQueryParams { @@ -657,7 +671,16 @@ pub async fn call_provider_openai_for_ws( actual_base_url, &credential.uuid[..8] ); - let provider = ClaudeCustomProvider::with_config(api_key.clone(), base_url.clone()); + let prompt_cache_mode = if credential.provider_type.supports_anthropic_prompt_cache() { + PromptCacheMode::Automatic + } else { + PromptCacheMode::ExplicitOnly + }; + let provider = ClaudeCustomProvider::with_prompt_cache_mode( + api_key.clone(), + base_url.clone(), + prompt_cache_mode, + ); match provider.call_openai_api(request).await { Ok(result) => { // 记录成功 @@ -798,7 +821,16 @@ pub async fn call_provider_anthropic_for_ws( actual_base_url, &credential.uuid[..8] ); - let provider = ClaudeCustomProvider::with_config(api_key.clone(), base_url.clone()); + let prompt_cache_mode = if credential.provider_type.supports_anthropic_prompt_cache() { + PromptCacheMode::Automatic + } else { + PromptCacheMode::ExplicitOnly + }; + let provider = ClaudeCustomProvider::with_prompt_cache_mode( + api_key.clone(), + base_url.clone(), + prompt_cache_mode, + ); let resp = match provider.call_api(request).await { Ok(r) => r, Err(e) => { @@ -838,6 +870,7 @@ pub async fn call_provider_anthropic_for_ws( // 转换为 OpenAI 格式并调用(健康状态更新在 call_provider_openai_for_ws 中处理) let openai_request = convert_anthropic_to_openai(request); let result = call_provider_openai_for_ws(state, credential, &openai_request).await?; + let (input_tokens, output_tokens) = extract_openai_usage_pair(&result); // 转换响应为 Anthropic 格式 Ok(serde_json::json!({ @@ -856,10 +889,37 @@ pub async fn call_provider_anthropic_for_ws( "model": request.model, "stop_reason": "end_turn", "usage": { - "input_tokens": 0, - "output_tokens": 0 + "input_tokens": input_tokens, + "output_tokens": output_tokens } })) } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_openai_usage_pair_should_read_prompt_and_completion_tokens() { + let response = serde_json::json!({ + "usage": { + "prompt_tokens": 420, + "completion_tokens": 69, + "total_tokens": 489 + } + }); + + assert_eq!(extract_openai_usage_pair(&response), (420, 69)); + } + + #[test] + fn extract_openai_usage_pair_should_default_to_zero_without_usage() { + let response = serde_json::json!({ + "id": "chatcmpl-test" + }); + + assert_eq!(extract_openai_usage_pair(&response), (0, 0)); + } +} diff --git a/src-tauri/crates/server/src/lib.rs b/src-tauri/crates/server/src/lib.rs index 648597bb6..80b87232f 100644 --- a/src-tauri/crates/server/src/lib.rs +++ b/src-tauri/crates/server/src/lib.rs @@ -69,6 +69,19 @@ pub fn record_request_telemetry( ctx.resolved_model.clone(), ctx.is_stream, ); + let metadata_string = |key: &str| { + ctx.get_metadata(key) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }; + log.session_id = metadata_string("session_id"); + log.thread_id = metadata_string("thread_id"); + log.turn_id = metadata_string("turn_id"); + log.pending_request_id = metadata_string("pending_request_id"); + log.queued_turn_id = metadata_string("queued_turn_id"); + log.subagent_session_id = metadata_string("subagent_session_id"); // 设置状态和持续时间 match status { diff --git a/src-tauri/crates/services/src/api_key_provider_service.rs b/src-tauri/crates/services/src/api_key_provider_service.rs index ba4993880..c2848f7ff 100644 --- a/src-tauri/crates/services/src/api_key_provider_service.rs +++ b/src-tauri/crates/services/src/api_key_provider_service.rs @@ -837,8 +837,14 @@ impl ApiKeyProviderService { } // Anthropic / AnthropicCompatible 统一走 /v1/messages provider_type if Self::uses_anthropic_protocol(provider_type) => { - self.test_anthropic_chat_once(&api_key, &provider.api_host, &test_model, &prompt) - .await + self.test_anthropic_chat_once( + &api_key, + &provider.api_host, + &test_model, + &prompt, + provider.provider_type.supports_anthropic_prompt_cache(), + ) + .await } // 其余默认 OpenAI 兼容 _ => { @@ -992,11 +998,20 @@ impl ApiKeyProviderService { api_host: &str, model: &str, prompt: &str, + enable_automatic_prompt_cache: bool, ) -> Result<(String, String), String> { - use lime_providers::providers::claude_custom::ClaudeCustomProvider; + use lime_providers::providers::claude_custom::{ClaudeCustomProvider, PromptCacheMode}; - let provider = - ClaudeCustomProvider::with_config(api_key.to_string(), Some(api_host.to_string())); + let prompt_cache_mode = if enable_automatic_prompt_cache { + PromptCacheMode::Automatic + } else { + PromptCacheMode::ExplicitOnly + }; + let provider = ClaudeCustomProvider::with_prompt_cache_mode( + api_key.to_string(), + Some(api_host.to_string()), + prompt_cache_mode, + ); let request = serde_json::json!({ "model": model, @@ -2155,7 +2170,11 @@ impl ApiKeyProviderService { // 对于其他客户端,需要检查凭证是否是 Claude Code 专用 // 通过发送测试请求来检查 if let Err(e) = self - .test_claude_key_compatibility(&api_key, &provider.api_host) + .test_claude_key_compatibility( + &api_key, + &provider.api_host, + provider.provider_type.supports_anthropic_prompt_cache(), + ) .await { if e.contains("CLAUDE_CODE_ONLY") { @@ -2395,7 +2414,12 @@ impl ApiKeyProviderService { .unwrap_or_else(|| "claude-3-haiku-20240307".to_string()); match self - .test_anthropic_connection(&api_key, &provider.api_host, &test_model) + .test_anthropic_connection( + &api_key, + &provider.api_host, + &test_model, + provider.provider_type.supports_anthropic_prompt_cache(), + ) .await { Ok(models) => Ok(models), @@ -2557,11 +2581,20 @@ impl ApiKeyProviderService { &self, api_key: &str, api_host: &str, + enable_automatic_prompt_cache: bool, ) -> Result<(), String> { - use lime_providers::providers::claude_custom::ClaudeCustomProvider; + use lime_providers::providers::claude_custom::{ClaudeCustomProvider, PromptCacheMode}; - let provider = - ClaudeCustomProvider::with_config(api_key.to_string(), Some(api_host.to_string())); + let prompt_cache_mode = if enable_automatic_prompt_cache { + PromptCacheMode::Automatic + } else { + PromptCacheMode::ExplicitOnly + }; + let provider = ClaudeCustomProvider::with_prompt_cache_mode( + api_key.to_string(), + Some(api_host.to_string()), + prompt_cache_mode, + ); // 发送一个最小的测试请求 let request = serde_json::json!({ @@ -2596,11 +2629,20 @@ impl ApiKeyProviderService { api_key: &str, api_host: &str, model: &str, + enable_automatic_prompt_cache: bool, ) -> Result, String> { - use lime_providers::providers::claude_custom::ClaudeCustomProvider; + use lime_providers::providers::claude_custom::{ClaudeCustomProvider, PromptCacheMode}; - let provider = - ClaudeCustomProvider::with_config(api_key.to_string(), Some(api_host.to_string())); + let prompt_cache_mode = if enable_automatic_prompt_cache { + PromptCacheMode::Automatic + } else { + PromptCacheMode::ExplicitOnly + }; + let provider = ClaudeCustomProvider::with_prompt_cache_mode( + api_key.to_string(), + Some(api_host.to_string()), + prompt_cache_mode, + ); // 发送一个简单的测试请求 let request = serde_json::json!({ diff --git a/src-tauri/crates/services/src/aster_session_store.rs b/src-tauri/crates/services/src/aster_session_store.rs index 2073d0cde..51836923e 100644 --- a/src-tauri/crates/services/src/aster_session_store.rs +++ b/src-tauri/crates/services/src/aster_session_store.rs @@ -264,6 +264,7 @@ impl SessionStore for LimeSessionStore { total_tokens: None, input_tokens: None, output_tokens: None, + cached_input_tokens: None, accumulated_total_tokens: None, accumulated_input_tokens: None, accumulated_output_tokens: None, @@ -306,7 +307,7 @@ impl SessionStore for LimeSessionStore { .prepare( "SELECT id, model, system_prompt, title, created_at, updated_at, working_dir, session_type, user_set_name, extension_data_json, - total_tokens, input_tokens, output_tokens, + total_tokens, input_tokens, output_tokens, cached_input_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, schedule_id, recipe_json, user_recipe_values_json, provider_name, model_config_json @@ -333,11 +334,12 @@ impl SessionStore for LimeSessionStore { row.get::<_, Option>(13)?, row.get::<_, Option>(14)?, row.get::<_, Option>(15)?, - row.get::<_, Option>(16)?, + row.get::<_, Option>(16)?, row.get::<_, Option>(17)?, row.get::<_, Option>(18)?, row.get::<_, Option>(19)?, row.get::<_, Option>(20)?, + row.get::<_, Option>(21)?, )) }) .map_err(|e| anyhow!("会话不存在: {e}"))?; @@ -356,6 +358,7 @@ impl SessionStore for LimeSessionStore { total_tokens, input_tokens, output_tokens, + cached_input_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, @@ -392,6 +395,7 @@ impl SessionStore for LimeSessionStore { total_tokens, input_tokens, output_tokens, + cached_input_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, @@ -549,7 +553,7 @@ impl SessionStore for LimeSessionStore { let mut stmt = conn.prepare( "SELECT id, model, system_prompt, title, created_at, updated_at, working_dir, session_type, user_set_name, extension_data_json, - total_tokens, input_tokens, output_tokens, + total_tokens, input_tokens, output_tokens, cached_input_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, schedule_id, recipe_json, user_recipe_values_json, provider_name, model_config_json @@ -570,14 +574,15 @@ impl SessionStore for LimeSessionStore { let total_tokens: Option = row.get(10)?; let input_tokens: Option = row.get(11)?; let output_tokens: Option = row.get(12)?; - let accumulated_total_tokens: Option = row.get(13)?; - let accumulated_input_tokens: Option = row.get(14)?; - let accumulated_output_tokens: Option = row.get(15)?; - let schedule_id: Option = row.get(16)?; - let recipe_json: Option = row.get(17)?; - let user_recipe_values_json: Option = row.get(18)?; - let provider_name: Option = row.get(19)?; - let model_config_json: Option = row.get(20)?; + let cached_input_tokens: Option = row.get(13)?; + let accumulated_total_tokens: Option = row.get(14)?; + let accumulated_input_tokens: Option = row.get(15)?; + let accumulated_output_tokens: Option = row.get(16)?; + let schedule_id: Option = row.get(17)?; + let recipe_json: Option = row.get(18)?; + let user_recipe_values_json: Option = row.get(19)?; + let provider_name: Option = row.get(20)?; + let model_config_json: Option = row.get(21)?; Ok(( id, @@ -592,6 +597,7 @@ impl SessionStore for LimeSessionStore { total_tokens, input_tokens, output_tokens, + cached_input_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, @@ -617,6 +623,7 @@ impl SessionStore for LimeSessionStore { total_tokens, input_tokens, output_tokens, + cached_input_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, @@ -649,6 +656,7 @@ impl SessionStore for LimeSessionStore { total_tokens, input_tokens, output_tokens, + cached_input_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, @@ -736,6 +744,7 @@ impl SessionStore for LimeSessionStore { total_tokens: session.total_tokens, input_tokens: session.input_tokens, output_tokens: session.output_tokens, + cached_input_tokens: session.cached_input_tokens, accumulated_total: session.accumulated_total_tokens, accumulated_input: session.accumulated_input_tokens, accumulated_output: session.accumulated_output_tokens, @@ -787,6 +796,7 @@ impl SessionStore for LimeSessionStore { total_tokens: original.total_tokens, input_tokens: original.input_tokens, output_tokens: original.output_tokens, + cached_input_tokens: original.cached_input_tokens, accumulated_total: original.accumulated_total_tokens, accumulated_input: original.accumulated_input_tokens, accumulated_output: original.accumulated_output_tokens, @@ -884,16 +894,18 @@ impl SessionStore for LimeSessionStore { total_tokens = COALESCE(?1, total_tokens), input_tokens = COALESCE(?2, input_tokens), output_tokens = COALESCE(?3, output_tokens), - accumulated_total_tokens = COALESCE(?4, accumulated_total_tokens), - accumulated_input_tokens = COALESCE(?5, accumulated_input_tokens), - accumulated_output_tokens = COALESCE(?6, accumulated_output_tokens), - schedule_id = COALESCE(?7, schedule_id), - updated_at = ?8 - WHERE id = ?9", + cached_input_tokens = COALESCE(?4, cached_input_tokens), + accumulated_total_tokens = COALESCE(?5, accumulated_total_tokens), + accumulated_input_tokens = COALESCE(?6, accumulated_input_tokens), + accumulated_output_tokens = COALESCE(?7, accumulated_output_tokens), + schedule_id = COALESCE(?8, schedule_id), + updated_at = ?9 + WHERE id = ?10", rusqlite::params![ stats.total_tokens, stats.input_tokens, stats.output_tokens, + stats.cached_input_tokens, stats.accumulated_total, stats.accumulated_input, stats.accumulated_output, @@ -913,6 +925,9 @@ impl SessionStore for LimeSessionStore { if let Some(output_tokens) = stats.output_tokens { session.output_tokens = Some(output_tokens); } + if let Some(cached_input_tokens) = stats.cached_input_tokens { + session.cached_input_tokens = Some(cached_input_tokens); + } if let Some(accumulated_total) = stats.accumulated_total { session.accumulated_total_tokens = Some(accumulated_total); } @@ -1350,6 +1365,7 @@ mod tests { total_tokens: Some(100), input_tokens: Some(60), output_tokens: Some(40), + cached_input_tokens: Some(24), accumulated_total: Some(300), accumulated_input: Some(180), accumulated_output: Some(120), @@ -1400,6 +1416,7 @@ mod tests { assert!(loaded.user_set_name); assert_eq!(loaded.session_type, SessionType::SubAgent); assert_eq!(loaded.total_tokens, Some(100)); + assert_eq!(loaded.cached_input_tokens, Some(24)); assert_eq!(loaded.accumulated_total_tokens, Some(300)); assert_eq!(loaded.schedule_id.as_deref(), Some("job-1")); assert_eq!(loaded.provider_name.as_deref(), Some("openai")); @@ -1614,6 +1631,7 @@ mod tests { total_tokens: Some(100), input_tokens: Some(60), output_tokens: Some(40), + cached_input_tokens: Some(24), accumulated_total: Some(300), accumulated_input: Some(180), accumulated_output: Some(120), @@ -1630,6 +1648,7 @@ mod tests { total_tokens: None, input_tokens: None, output_tokens: None, + cached_input_tokens: None, accumulated_total: None, accumulated_input: None, accumulated_output: None, @@ -1647,6 +1666,7 @@ mod tests { assert_eq!(loaded.total_tokens, Some(100)); assert_eq!(loaded.input_tokens, Some(60)); assert_eq!(loaded.output_tokens, Some(40)); + assert_eq!(loaded.cached_input_tokens, Some(24)); assert_eq!(loaded.accumulated_total_tokens, Some(300)); assert_eq!(loaded.accumulated_input_tokens, Some(180)); assert_eq!(loaded.accumulated_output_tokens, Some(120)); @@ -1672,6 +1692,7 @@ mod tests { total_tokens: Some(100), input_tokens: Some(60), output_tokens: Some(40), + cached_input_tokens: Some(24), accumulated_total: Some(300), accumulated_input: Some(180), accumulated_output: Some(120), @@ -1688,6 +1709,7 @@ mod tests { total_tokens: Some(0), input_tokens: Some(0), output_tokens: Some(0), + cached_input_tokens: Some(0), accumulated_total: None, accumulated_input: None, accumulated_output: None, @@ -1705,6 +1727,7 @@ mod tests { assert_eq!(loaded.total_tokens, Some(0)); assert_eq!(loaded.input_tokens, Some(0)); assert_eq!(loaded.output_tokens, Some(0)); + assert_eq!(loaded.cached_input_tokens, Some(0)); assert_eq!(loaded.accumulated_total_tokens, Some(300)); assert_eq!(loaded.accumulated_input_tokens, Some(180)); assert_eq!(loaded.accumulated_output_tokens, Some(120)); diff --git a/src-tauri/crates/skills/src/lime_llm_provider.rs b/src-tauri/crates/skills/src/lime_llm_provider.rs index 3ef1789e1..896644718 100644 --- a/src-tauri/crates/skills/src/lime_llm_provider.rs +++ b/src-tauri/crates/skills/src/lime_llm_provider.rs @@ -12,7 +12,7 @@ use lime_core::models::anthropic::AnthropicMessagesRequest; #[cfg(test)] use lime_core::models::provider_pool_model::PoolProviderType; use lime_core::models::provider_pool_model::{CredentialData, ProviderCredential}; -use lime_providers::providers::claude_custom::ClaudeCustomProvider; +use lime_providers::providers::claude_custom::{ClaudeCustomProvider, PromptCacheMode}; use lime_providers::providers::kiro::KiroProvider; use lime_providers::providers::openai_custom::OpenAICustomProvider; use lime_services::api_key_provider_service::ApiKeyProviderService; @@ -132,6 +132,11 @@ impl LimeLlmProvider { self.call_claude_api( api_key, base_url.as_deref(), + if credential.provider_type.supports_anthropic_prompt_cache() { + PromptCacheMode::Automatic + } else { + PromptCacheMode::ExplicitOnly + }, system_prompt, user_message, model, @@ -153,6 +158,11 @@ impl LimeLlmProvider { self.call_claude_api( api_key, base_url.as_deref(), + if credential.provider_type.supports_anthropic_prompt_cache() { + PromptCacheMode::Automatic + } else { + PromptCacheMode::ExplicitOnly + }, system_prompt, user_message, model, @@ -237,14 +247,18 @@ impl LimeLlmProvider { &self, api_key: &str, base_url: Option<&str>, + prompt_cache_mode: PromptCacheMode, system_prompt: &str, user_message: &str, model: &str, ) -> Result { use lime_core::models::anthropic::AnthropicMessage; - let claude = - ClaudeCustomProvider::with_config(api_key.to_string(), base_url.map(|s| s.to_string())); + let claude = ClaudeCustomProvider::with_prompt_cache_mode( + api_key.to_string(), + base_url.map(|s| s.to_string()), + prompt_cache_mode, + ); // 构建 Anthropic 请求 let request = AnthropicMessagesRequest { diff --git a/src-tauri/src/agent/README.md b/src-tauri/src/agent/README.md index 0e5e2a5b8..e4bb743df 100644 --- a/src-tauri/src/agent/README.md +++ b/src-tauri/src/agent/README.md @@ -12,6 +12,7 @@ AI Agent 集成模块,基于 aster-rust 框架实现。 - **凭证池桥接**:自动从 Lime 凭证池选择凭证配置 Aster Provider - **流式响应**:通过 Tauri 事件系统向前端推送流式内容 - **Skills 集成**:自动加载 Lime Skills 到 aster-rust,使 AI 能够自动调用 +- **多代理收敛**:旧 SubAgent scheduler Tauri 桥已删除,角色与 team runtime 纯逻辑统一收敛到 `crates/agent/src/subagent_scheduler.rs` ## 文件索引 @@ -24,7 +25,6 @@ AI Agent 集成模块,基于 aster-rust 框架实现。 | `event_converter.rs` | Aster 事件到 Tauri 事件转换 | | `credential_bridge.rs` | 重导出层(纯逻辑已迁移到 `crates/agent/src/credential_bridge.rs`) | | `mcp_bridge.rs` | MCP 服务桥接 | -| `subagent_scheduler.rs` | Tauri 事件桥接层(纯逻辑已迁移到 `crates/agent/src/subagent_scheduler.rs`) | ## Skills 集成 diff --git a/src-tauri/src/agent/mod.rs b/src-tauri/src/agent/mod.rs index 70b7b8efa..613dea843 100644 --- a/src-tauri/src/agent/mod.rs +++ b/src-tauri/src/agent/mod.rs @@ -7,7 +7,6 @@ mod aster_agent; pub mod aster_state; mod credential_bridge; pub mod runtime_queue_service; -mod subagent_scheduler; // 从 lime-agent crate re-export pub use lime_agent::event_converter; @@ -28,6 +27,3 @@ pub use lime_agent::{ QueuedTurnSnapshot, QueuedTurnTask, SubagentControlState, SubagentParentContext, SubagentRuntimeStatus, SubagentRuntimeStatusKind, }; -pub use subagent_scheduler::{ - LimeScheduler, LimeSubAgentExecutor, SubAgentProgressEvent, SubAgentRole, -}; diff --git a/src-tauri/src/agent/subagent_scheduler.rs b/src-tauri/src/agent/subagent_scheduler.rs deleted file mode 100644 index 390be0a31..000000000 --- a/src-tauri/src/agent/subagent_scheduler.rs +++ /dev/null @@ -1,156 +0,0 @@ -//! SubAgent 调度器集成(Tauri 桥接层) -//! -//! 纯逻辑已迁移到 `lime-agent` crate, -//! 本模块负责 Tauri 事件桥接。 - -use std::sync::Arc; - -use aster::agents::context::AgentContext; -use aster::agents::subagent_scheduler::{ - SchedulerConfig, SchedulerExecutionResult, SchedulerResult, SubAgentTask, -}; -use tauri::{AppHandle, Emitter}; - -use crate::database::DbConnection; - -pub use lime_agent::subagent_scheduler::{ - LimeSubAgentExecutor, SchedulerEventEmitter, SubAgentProgressEvent, SubAgentRole, -}; - -/// Lime SubAgent 调度器(Tauri 桥接) -pub struct LimeScheduler { - /// 内部纯逻辑调度器 - inner: lime_agent::subagent_scheduler::LimeScheduler, - /// Tauri AppHandle - app_handle: Option, - /// 调度事件归属的会话 ID - event_session_id: Option, -} - -impl LimeScheduler { - /// 创建新的调度器 - pub fn new(db: DbConnection) -> Self { - Self { - inner: lime_agent::subagent_scheduler::LimeScheduler::new(db), - app_handle: None, - event_session_id: None, - } - } - - /// 设置 Tauri AppHandle - pub fn with_app_handle(mut self, handle: AppHandle) -> Self { - self.app_handle = Some(handle); - self - } - - /// 绑定调度事件的会话 ID - pub fn with_event_session_id(mut self, session_id: impl Into) -> Self { - let normalized = session_id.into(); - self.event_session_id = (!normalized.trim().is_empty()).then_some(normalized); - self - } - - /// 设置默认角色 - pub fn with_default_role(mut self, role: SubAgentRole) -> Self { - self.inner = self.inner.with_default_role(role); - self - } - - /// 初始化调度器 - pub async fn init(&self, config: Option) { - let event_session_id = self.event_session_id.clone(); - let event_emitter = self.app_handle.clone().map(|handle| { - Arc::new(move |event: &serde_json::Value| { - let payload = enrich_scheduler_event_payload(event, event_session_id.as_deref()); - if let Err(err) = handle.emit("subagent-scheduler-event", payload) { - tracing::warn!("发送 Tauri 事件失败: {}", err); - } - }) as SchedulerEventEmitter - }); - - self.inner - .init_with_event_emitter(config, event_emitter) - .await; - } - - /// 执行任务 - pub async fn execute( - &self, - tasks: Vec, - parent_context: Option<&AgentContext>, - ) -> SchedulerResult { - self.inner.execute(tasks, parent_context).await - } - - /// 使用指定角色执行任务 - pub async fn execute_with_role( - &self, - tasks: Vec, - parent_context: Option<&AgentContext>, - role: SubAgentRole, - ) -> SchedulerResult { - self.inner - .execute_with_role(tasks, parent_context, role) - .await - } - - /// 取消执行 - pub async fn cancel(&self) { - self.inner.cancel().await; - } -} - -fn enrich_scheduler_event_payload( - event: &serde_json::Value, - session_id: Option<&str>, -) -> serde_json::Value { - let Some(session_id) = session_id.map(str::trim).filter(|value| !value.is_empty()) else { - return event.clone(); - }; - - match event { - serde_json::Value::Object(map) => { - let mut next = map.clone(); - next.insert( - "sessionId".to_string(), - serde_json::Value::String(session_id.to_string()), - ); - serde_json::Value::Object(next) - } - other => serde_json::json!({ - "type": "unknown", - "payload": other, - "sessionId": session_id, - }), - } -} - -#[cfg(test)] -mod tests { - use super::enrich_scheduler_event_payload; - - #[test] - fn should_append_session_id_for_object_event() { - let payload = serde_json::json!({ - "type": "started", - "totalTasks": 1, - }); - - let enriched = enrich_scheduler_event_payload(&payload, Some("session-a")); - - assert_eq!(enriched["type"], serde_json::json!("started")); - assert_eq!(enriched["sessionId"], serde_json::json!("session-a")); - } - - #[test] - fn should_keep_original_event_when_session_id_missing() { - let payload = serde_json::json!({ - "type": "completed", - "success": true, - }); - - let enriched = enrich_scheduler_event_payload(&payload, None); - - assert_eq!(enriched, payload); - } -} diff --git a/src-tauri/src/agent_tools/catalog.rs b/src-tauri/src/agent_tools/catalog.rs index fd58d4617..057e6f183 100644 --- a/src-tauri/src/agent_tools/catalog.rs +++ b/src-tauri/src/agent_tools/catalog.rs @@ -503,15 +503,6 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ permission_plane: ToolPermissionPlane::SessionAllowlist, workspace_default_allow: true, }, - ToolCatalogEntry { - name: "SubAgentTask", - profiles: CORE_PROFILES, - capabilities: DELEGATION_CAP, - lifecycle: ToolLifecycle::Compat, - source: ToolSourceKind::LimeInjected, - permission_plane: ToolPermissionPlane::SessionAllowlist, - workspace_default_allow: false, - }, ToolCatalogEntry { name: SOCIAL_IMAGE_TOOL_NAME, profiles: WORKBENCH_PROFILES, @@ -937,7 +928,6 @@ mod tests { assert!(names.contains(&"TeamDelete")); assert!(names.contains(&"Workflow")); assert!(names.contains(&"WebSearch")); - assert!(!names.contains(&"SubAgentTask")); assert!(!names.contains(&"Read")); assert!(!names.contains(&"Bash")); assert!(!names.contains(&SOCIAL_IMAGE_TOOL_NAME)); @@ -1059,7 +1049,7 @@ mod tests { .iter() .filter(|entry| entry.profiles.contains(&ToolSurfaceProfile::BrowserAssist)) .count(); - assert_eq!(core.len(), 41); + assert_eq!(core.len(), 40); assert_eq!( core.iter() .filter(|entry| entry.lifecycle == ToolLifecycle::Current) @@ -1070,7 +1060,7 @@ mod tests { core.iter() .filter(|entry| entry.lifecycle == ToolLifecycle::Compat) .count(), - 1 + 0 ); assert!(core .iter() diff --git a/src-tauri/src/agent_tools/inventory.rs b/src-tauri/src/agent_tools/inventory.rs index 5d4106561..33d824a23 100644 --- a/src-tauri/src/agent_tools/inventory.rs +++ b/src-tauri/src/agent_tools/inventory.rs @@ -845,9 +845,9 @@ mod tests { ], }); - assert_eq!(inventory.counts.catalog_total, 41); + assert_eq!(inventory.counts.catalog_total, 40); assert_eq!(inventory.counts.catalog_current_total, 40); - assert_eq!(inventory.counts.catalog_compat_total, 1); + assert_eq!(inventory.counts.catalog_compat_total, 0); assert_eq!(inventory.counts.registry_total, 4); assert_eq!(inventory.counts.registry_visible_total, 3); assert_eq!(inventory.counts.registry_catalog_unmapped_total, 1); diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 1db9707ff..de7137cfd 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -240,7 +240,6 @@ pub fn run() { .manage(recording_service) .manage(mcp_manager_state) .manage(automation_service_state) - .manage(commands::subagent_cmd::SubAgentSchedulerState::default()) .manage(commands::websocket_cmd::WsServiceState::default()) .manage(crate::services::companion_service::CompanionServiceState::default()) .manage(lime_gateway::telegram::TelegramGatewayState::default()) @@ -1535,10 +1534,6 @@ pub fn run() { commands::terminal_cmd::terminal_close, commands::terminal_cmd::terminal_list_sessions, commands::terminal_cmd::terminal_get_session, - // SubAgent commands - commands::subagent_cmd::init_subagent_scheduler, - commands::subagent_cmd::execute_subagent_tasks, - commands::subagent_cmd::cancel_subagent_tasks, // Connection commands commands::connection_cmd::connection_list, commands::connection_cmd::connection_add, diff --git a/src-tauri/src/commands/aster_agent_cmd/mod.rs b/src-tauri/src/commands/aster_agent_cmd/mod.rs index ed43a1972..5addc109f 100644 --- a/src-tauri/src/commands/aster_agent_cmd/mod.rs +++ b/src-tauri/src/commands/aster_agent_cmd/mod.rs @@ -16,7 +16,7 @@ use crate::agent::runtime_queue_service::{ }; use crate::agent::{ AsterAgentState, AsterAgentWrapper, QueuedTurnSnapshot, QueuedTurnTask, SessionDetail, - SessionInfo, SubAgentRole, + SessionInfo, }; use crate::agent_tools::catalog::{ browser_runtime_tool_prefix, build_mcp_extension_surface, workbench_tool_names, @@ -59,7 +59,6 @@ use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_ use crate::workspace::WorkspaceManager; use crate::LogState; use aster::agents::extension::ExtensionConfig; -use aster::agents::subagent_scheduler::SubAgentTask; use aster::agents::{Agent, AgentEvent}; use aster::chrome_mcp::get_chrome_mcp_tools; use aster::conversation::message::{ @@ -481,18 +480,16 @@ pub(crate) use summary_skill_launch::{ #[allow(unused_imports)] pub(crate) use tool_runtime::social_generate_cover_image_cmd; pub(crate) use tool_runtime::{apply_workspace_sandbox_permissions, ImageInput}; -#[cfg(test)] -#[allow(unused_imports)] -pub(crate) use tool_runtime::{ - build_subagent_task_definition, build_subagent_task_runtime_message, - collect_subagent_task_compat_warnings, extract_runtime_subagent_result_text, - parse_subagent_role, LimeBrowserMcpTool, SocialGenerateCoverImageTool, SubAgentTaskToolInput, - ToolSearchBridgeTool, -}; pub(crate) use tool_runtime::{ ensure_browser_mcp_tools_registered, ensure_creation_task_tools_registered, ensure_runtime_support_tools_registered, ensure_social_image_tool_registered, }; +#[cfg(test)] +#[allow(unused_imports)] +pub(crate) use tool_runtime::{ + extract_runtime_subagent_result_text, LimeBrowserMcpTool, SocialGenerateCoverImageTool, + ToolSearchBridgeTool, +}; pub(crate) use transcription_skill_launch::{ append_transcription_skill_launch_session_permissions, merge_system_prompt_with_transcription_skill_launch, diff --git a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs index c879ed32e..4caafa070 100644 --- a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs +++ b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs @@ -3,6 +3,7 @@ use super::*; use aster::session::TurnContextOverride; use lime_agent::AgentEvent as RuntimeAgentEvent; use lime_core::workspace::WorkspaceSettings; +use tauri::Manager; const ARTIFACT_DOCUMENT_REPAIRED_WARNING_CODE: &str = "artifact_document_repaired"; const ARTIFACT_DOCUMENT_FAILED_WARNING_CODE: &str = "artifact_document_failed"; @@ -13,6 +14,11 @@ const CONTEXT_COMPACTION_NOT_NEEDED_WARNING_CODE: &str = "context_compaction_not const TURN_MEMORY_PREFETCH_PROMPT_MARKER: &str = "【运行时记忆召回】"; const LIME_RUNTIME_METADATA_KEY: &str = "lime_runtime"; const LIME_RUNTIME_AUTO_COMPACT_KEY: &str = "auto_compact"; +const AUTO_RUNTIME_MEMORY_MIN_USER_CHARS: usize = 12; +const AUTO_RUNTIME_MEMORY_MIN_ASSISTANT_CHARS: usize = 48; +const AUTO_RUNTIME_MEMORY_MIN_TOTAL_CHARS: usize = 160; +const AUTO_RUNTIME_MEMORY_SESSION_MESSAGE_LIMIT: usize = 8; +const AUTO_RUNTIME_MEMORY_SESSION_MIN_MESSAGE_LENGTH: usize = 18; fn emit_runtime_events(app: &AppHandle, event_name: &str, events: Vec) { for event in events { @@ -47,6 +53,172 @@ fn merge_runtime_memory_prefetch_prompt( } } +fn normalize_runtime_memory_capture_text(input: &str) -> String { + input.split_whitespace().collect::>().join(" ") +} + +fn contains_runtime_memory_capture_signal(text: &str) -> bool { + [ + "记住", + "偏好", + "喜欢", + "不喜欢", + "习惯", + "以后", + "规则", + "流程", + "workflow", + "prefer", + "always", + "never", + "计划", + "待办", + "todo", + "下一步", + "错误", + "失败", + "报错", + "修复", + "fix", + "bug", + ] + .iter() + .any(|keyword| text.contains(keyword)) +} + +fn should_auto_capture_runtime_memory_turn(user_message: &str, assistant_output: &str) -> bool { + let normalized_user = normalize_runtime_memory_capture_text(user_message); + let normalized_assistant = normalize_runtime_memory_capture_text(assistant_output); + let total_chars = normalized_user.chars().count() + normalized_assistant.chars().count(); + + if normalized_user.chars().count() >= AUTO_RUNTIME_MEMORY_MIN_USER_CHARS + && normalized_assistant.chars().count() >= AUTO_RUNTIME_MEMORY_MIN_ASSISTANT_CHARS + && total_chars >= AUTO_RUNTIME_MEMORY_MIN_TOTAL_CHARS + { + return true; + } + + let signal_text = format!( + "{} {}", + normalized_user.to_lowercase(), + normalized_assistant.to_lowercase() + ); + contains_runtime_memory_capture_signal(signal_text.as_str()) +} + +fn spawn_runtime_memory_capture_task( + app: &AppHandle, + db: &DbConnection, + memory_config: lime_core::config::MemoryConfig, + session_id: &str, + user_message: &str, + assistant_output: &str, +) { + if !memory_config.enabled || !memory_config.auto.enabled { + return; + } + + if !should_auto_capture_runtime_memory_turn(user_message, assistant_output) { + return; + } + + let context_memory_service = app + .state::() + .inner() + .0 + .clone(); + let db = db.clone(); + let session_id = session_id.to_string(); + + // 自动沉淀走后台任务,避免延长主回合完成时间。 + tokio::spawn(async move { + let candidates = { + let conn = match db.lock() { + Ok(guard) => guard, + Err(error) => { + tracing::warn!( + "[AsterAgent] 后台自动记忆无法获取数据库锁: session_id={}, error={}", + session_id, + error + ); + return; + } + }; + + match crate::services::chat_history_service::load_session_memory_source_candidates( + &conn, + &session_id, + AUTO_RUNTIME_MEMORY_SESSION_MESSAGE_LIMIT, + AUTO_RUNTIME_MEMORY_SESSION_MIN_MESSAGE_LENGTH, + ) { + Ok(candidates) => candidates, + Err(error) => { + tracing::warn!( + "[AsterAgent] 后台自动记忆读取候选失败: session_id={}, error={}", + session_id, + error + ); + return; + } + } + }; + + if candidates.is_empty() { + return; + } + + match crate::commands::memory_management_cmd::analyze_memory_candidates( + context_memory_service.as_ref(), + &memory_config, + &candidates, + ) { + Ok(result) => { + if result.generated_entries > 0 { + tracing::info!( + "[AsterAgent] 已自动沉淀工作记忆: session_id={}, generated={}, dedup={}", + session_id, + result.generated_entries, + result.deduplicated_entries + ); + } + } + Err(error) => { + tracing::warn!( + "[AsterAgent] 后台自动沉淀工作记忆失败: session_id={}, error={}", + session_id, + error + ); + } + } + + match crate::commands::unified_memory_cmd::analyze_unified_memory_candidates( + &db, + &memory_config, + &candidates, + ) + .await + { + Ok(result) => { + if result.generated_entries > 0 { + tracing::info!( + "[AsterAgent] 已自动沉淀长期记忆: session_id={}, generated={}, dedup={}", + session_id, + result.generated_entries, + result.deduplicated_entries + ); + } + } + Err(error) => { + tracing::warn!( + "[AsterAgent] 后台自动沉淀长期记忆失败: session_id={}, error={}", + session_id, + error + ); + } + } + }); +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ProviderConfigApplyMode { Direct, @@ -1222,6 +1394,7 @@ async fn execute_aster_chat_request( .as_ref() .map(|config| config.provider_continuation_capability()) .unwrap_or(ProviderContinuationCapability::HistoryReplayOnly); + let runtime_memory_config = runtime_config.memory.clone(); let configured_provider_continuation_state = effective_provider_config .as_ref() .map(|config| config.provider_continuation_state()) @@ -1597,6 +1770,14 @@ async fn execute_aster_chat_request( request_metadata.as_ref(), execution.text_output.as_str(), ); + spawn_runtime_memory_capture_task( + &app, + db, + runtime_memory_config.clone(), + session_id, + &request.message, + execution.text_output.as_str(), + ); Ok(()) } Err(primary_error) @@ -1688,6 +1869,14 @@ async fn execute_aster_chat_request( request_metadata.as_ref(), execution.text_output.as_str(), ); + spawn_runtime_memory_capture_task( + &app, + db, + runtime_memory_config.clone(), + session_id, + &request.message, + execution.text_output.as_str(), + ); }) .map_err(|fallback_err| fallback_err.message) } @@ -1763,8 +1952,11 @@ async fn execute_aster_chat_request( if let Ok(events) = terminal_events { emit_runtime_events(app, &request.event_name, events); } - let usage = resolve_runtime_message_usage(session_id).await; - if let Some(ref usage) = usage { + let done_event = resolve_runtime_final_done_event(session_id).await; + if let RuntimeAgentEvent::FinalDone { + usage: Some(ref usage), + } = done_event + { if let Err(error) = persist_latest_assistant_message_usage(db, session_id, usage) { tracing::warn!( "[AsterAgent] 持久化消息 usage 失败(已降级继续): {}", @@ -1772,7 +1964,6 @@ async fn execute_aster_chat_request( ); } } - let done_event = RuntimeAgentEvent::FinalDone { usage }; if let Err(e) = app.emit(&request.event_name, &done_event) { tracing::error!("[AsterAgent] 发送完成事件失败: {}", e); } @@ -1854,6 +2045,10 @@ fn resolve_runtime_message_usage_from_session( Some(lime_agent::AgentTokenUsage { input_tokens: input_tokens as u32, output_tokens: output_tokens as u32, + cached_input_tokens: session + .cached_input_tokens + .filter(|value| *value >= 0) + .map(|value| value as u32), }) } _ => None, @@ -1867,6 +2062,12 @@ async fn resolve_runtime_message_usage(session_id: &str) -> Option RuntimeAgentEvent { + RuntimeAgentEvent::FinalDone { + usage: resolve_runtime_message_usage(session_id).await, + } +} + fn persist_latest_assistant_message_usage( db: &DbConnection, session_id: &str, @@ -1880,6 +2081,7 @@ fn persist_latest_assistant_message_usage( session_id, usage.input_tokens, usage.output_tokens, + usage.cached_input_tokens, )?; Ok(()) } @@ -1903,6 +2105,11 @@ fn build_compaction_session_metrics_update( let accumulated_input = accumulate(session.accumulated_input_tokens, usage.usage.input_tokens); let accumulated_output = accumulate(session.accumulated_output_tokens, usage.usage.output_tokens); + let cached_input_tokens = if usage.usage.output_tokens.is_some() { + usage.usage.cached_input_tokens + } else { + Some(0) + }; let current_window_tokens = usage .usage @@ -1913,6 +2120,7 @@ fn build_compaction_session_metrics_update( CompactionSessionMetricsUpdate { schedule_id, current_window_tokens, + cached_input_tokens, accumulated_total_tokens: accumulated_total, accumulated_input_tokens: accumulated_input, accumulated_output_tokens: accumulated_output, @@ -2228,7 +2436,7 @@ async fn compact_runtime_session_with_trigger( if let Ok(events) = terminal_events { emit_runtime_events(app, &event_name, events); } - let done_event = RuntimeAgentEvent::FinalDone { usage: None }; + let done_event = resolve_runtime_final_done_event(&session_id).await; if let Err(error) = app.emit(&event_name, &done_event) { tracing::error!("[AsterAgent] 发送压缩完成事件失败: {}", error); } @@ -2915,6 +3123,39 @@ mod tests { ); } + #[test] + fn should_auto_capture_runtime_memory_turn_for_long_turn_content() { + let user_message = "请记下这个团队的偏好:所有需求都先回到主线任务,再给出下一步明确行动。"; + let assistant_output = "好的,我会把这条协作规则当作后续回合的默认执行约束,并在继续实现前先说明当前主线、当前阶段以及下一刀要推进的内容,同时避免把工作扩散到无关页面或额外配置面。"; + + assert!(should_auto_capture_runtime_memory_turn( + user_message, + assistant_output + )); + } + + #[test] + fn should_auto_capture_runtime_memory_turn_for_memory_signal_keywords() { + let user_message = "记住:以后回复先给结论"; + let assistant_output = "收到,我以后会先给结论。"; + + assert!(should_auto_capture_runtime_memory_turn( + user_message, + assistant_output + )); + } + + #[test] + fn should_not_auto_capture_runtime_memory_turn_for_short_generic_turn() { + let user_message = "你好"; + let assistant_output = "收到"; + + assert!(!should_auto_capture_runtime_memory_turn( + user_message, + assistant_output + )); + } + #[test] fn service_skill_launch_stage_should_preserve_simple_user_message_and_force_site_run_first() { let user_message = "请帮我使用 GitHub 查一下 AI Agent 项目"; @@ -3240,6 +3481,7 @@ mod tests { .total_tokens(Some(90)) .input_tokens(Some(60)) .output_tokens(Some(30)) + .cached_input_tokens(Some(12)) .accumulated_total_tokens(Some(300)) .accumulated_input_tokens(Some(200)) .accumulated_output_tokens(Some(100)) @@ -3252,7 +3494,7 @@ mod tests { let usage = ProviderUsage::new( "gpt-4.1".to_string(), - Usage::new(Some(120), Some(45), Some(165)), + Usage::new(Some(120), Some(45), Some(165)).with_cached_input_tokens(Some(90)), ); update_compaction_session_metrics(&session_config, &usage) @@ -3267,6 +3509,7 @@ mod tests { assert_eq!(updated.total_tokens, Some(45)); assert_eq!(updated.input_tokens, Some(45)); assert_eq!(updated.output_tokens, Some(0)); + assert_eq!(updated.cached_input_tokens, Some(90)); assert_eq!(updated.accumulated_total_tokens, Some(465)); assert_eq!(updated.accumulated_input_tokens, Some(320)); assert_eq!(updated.accumulated_output_tokens, Some(145)); @@ -3294,6 +3537,7 @@ mod tests { .total_tokens(Some(180)) .input_tokens(Some(120)) .output_tokens(Some(60)) + .cached_input_tokens(Some(24)) .accumulated_total_tokens(Some(700)) .accumulated_input_tokens(Some(500)) .accumulated_output_tokens(Some(200)) @@ -3318,6 +3562,7 @@ mod tests { assert_eq!(updated.total_tokens, Some(0)); assert_eq!(updated.input_tokens, Some(0)); assert_eq!(updated.output_tokens, Some(0)); + assert_eq!(updated.cached_input_tokens, Some(0)); assert_eq!(updated.accumulated_total_tokens, Some(700)); assert_eq!(updated.accumulated_input_tokens, Some(500)); assert_eq!(updated.accumulated_output_tokens, Some(200)); @@ -3345,6 +3590,7 @@ mod tests { .total_tokens(Some(20)) .input_tokens(Some(10)) .output_tokens(Some(10)) + .cached_input_tokens(Some(6)) .accumulated_total_tokens(Some(200)) .accumulated_input_tokens(Some(120)) .accumulated_output_tokens(Some(80)) @@ -3355,7 +3601,7 @@ mod tests { let session_config = SessionConfigBuilder::new(&session.id).build(); let usage = ProviderUsage::new( "gpt-4.1".to_string(), - Usage::new(Some(30), Some(15), Some(45)), + Usage::new(Some(30), Some(15), Some(45)).with_cached_input_tokens(Some(18)), ); update_compaction_session_metrics(&session_config, &usage) @@ -3370,6 +3616,7 @@ mod tests { assert_eq!(updated.total_tokens, Some(15)); assert_eq!(updated.input_tokens, Some(15)); assert_eq!(updated.output_tokens, Some(0)); + assert_eq!(updated.cached_input_tokens, Some(18)); assert_eq!(updated.accumulated_total_tokens, Some(245)); assert_eq!(updated.accumulated_input_tokens, Some(150)); assert_eq!(updated.accumulated_output_tokens, Some(95)); @@ -3379,6 +3626,71 @@ mod tests { .expect("清理测试会话失败"); } + #[tokio::test] + async fn resolve_runtime_final_done_event_should_include_usage_from_session() { + ensure_runtime_turn_test_session_manager().await; + + let session = SessionManager::create_session( + PathBuf::from("."), + "final_done usage 测试".to_string(), + SessionType::User, + ) + .await + .expect("创建测试会话失败"); + + SessionManager::update_session(&session.id) + .input_tokens(Some(204)) + .output_tokens(Some(88)) + .cached_input_tokens(Some(160)) + .apply() + .await + .expect("写入 usage 失败"); + + let event = resolve_runtime_final_done_event(&session.id).await; + match event { + RuntimeAgentEvent::FinalDone { usage } => { + assert_eq!( + usage.map(|value| ( + value.input_tokens, + value.output_tokens, + value.cached_input_tokens, + )), + Some((204, 88, Some(160))) + ); + } + other => panic!("收到意外事件: {:?}", other), + } + + SessionManager::delete_session(&session.id) + .await + .expect("清理测试会话失败"); + } + + #[tokio::test] + async fn resolve_runtime_final_done_event_should_fall_back_to_none_without_session_usage() { + ensure_runtime_turn_test_session_manager().await; + + let session = SessionManager::create_session( + PathBuf::from("."), + "final_done 无 usage 测试".to_string(), + SessionType::User, + ) + .await + .expect("创建测试会话失败"); + + let event = resolve_runtime_final_done_event(&session.id).await; + match event { + RuntimeAgentEvent::FinalDone { usage } => { + assert!(usage.is_none(), "未写入 usage 时应返回 None"); + } + other => panic!("收到意外事件: {:?}", other), + } + + SessionManager::delete_session(&session.id) + .await + .expect("清理测试会话失败"); + } + #[tokio::test] async fn should_auto_compact_runtime_session_when_workspace_pref_enabled_and_context_threshold_exceeded( ) { diff --git a/src-tauri/src/commands/aster_agent_cmd/tests.rs b/src-tauri/src/commands/aster_agent_cmd/tests.rs index 758fe9a24..01833f29a 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tests.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tests.rs @@ -3851,8 +3851,8 @@ mod tests { fn test_merge_system_prompt_with_elicitation_context_appends_prompt() { let metadata = serde_json::json!({ "elicitation_context": { - "source": "legacy_questionnaire", - "mode": "compatibility_bridge", + "source": "runtime_action_required", + "mode": "runtime_metadata", "entries": [ { "label": "目标受众", @@ -3875,7 +3875,7 @@ mod tests { assert!(merged.contains(ELICITATION_CONTEXT_PROMPT_MARKER)); assert!(merged.contains("目标受众")); assert!(merged.contains("友好专业")); - assert!(merged.contains("legacy_questionnaire")); + assert!(merged.contains("runtime_action_required")); } #[test] @@ -6262,122 +6262,6 @@ mod tests { assert!(Arc::ptr_eq(&first, &second)); } - #[test] - fn test_parse_subagent_role_supports_aliases() { - assert_eq!( - parse_subagent_role(Some("explore")).unwrap(), - SubAgentRole::Explorer - ); - assert_eq!( - parse_subagent_role(Some("plan")).unwrap(), - SubAgentRole::Planner - ); - assert_eq!( - parse_subagent_role(Some("code")).unwrap(), - SubAgentRole::Executor - ); - assert_eq!(parse_subagent_role(None).unwrap(), SubAgentRole::Executor); - } - - #[test] - fn test_build_subagent_task_definition_uses_role_defaults() { - let input = SubAgentTaskToolInput { - prompt: "分析当前 harness 缺口".to_string(), - task_type: None, - description: None, - role: Some("explorer".to_string()), - timeout_secs: Some(45), - model: None, - return_summary: None, - allowed_tools: None, - denied_tools: None, - max_tokens: None, - }; - - let task = build_subagent_task_definition(&input, SubAgentRole::Explorer).unwrap(); - assert_eq!(task.task_type, "explore"); - assert_eq!(task.timeout.map(|value| value.as_secs()), Some(45)); - assert!(task.return_summary); - } - - #[test] - fn test_build_subagent_task_definition_applies_optional_fields() { - let input = SubAgentTaskToolInput { - prompt: "实现 harness 面板".to_string(), - task_type: Some("code".to_string()), - description: Some("实现前端面板".to_string()), - role: Some("executor".to_string()), - timeout_secs: Some(120), - model: Some("claude-sonnet-4-20250514".to_string()), - return_summary: Some(false), - allowed_tools: Some(vec!["read_file".to_string(), "write_file".to_string()]), - denied_tools: Some(vec!["execute_command".to_string()]), - max_tokens: Some(4096), - }; - - let task = build_subagent_task_definition(&input, SubAgentRole::Executor).unwrap(); - assert_eq!(task.task_type, "code"); - assert_eq!(task.description.as_deref(), Some("实现前端面板")); - assert_eq!(task.model.as_deref(), Some("claude-sonnet-4-20250514")); - assert!(!task.return_summary); - assert_eq!( - task.allowed_tools, - Some(vec!["read_file".to_string(), "write_file".to_string()]) - ); - assert_eq!(task.denied_tools, Some(vec!["execute_command".to_string()])); - assert_eq!(task.max_tokens, Some(4096)); - } - - #[test] - fn test_build_subagent_task_runtime_message_includes_soft_constraints() { - let input = SubAgentTaskToolInput { - prompt: "探索 team workspace 最佳实践".to_string(), - task_type: Some("explore".to_string()), - description: Some("探索 team workspace".to_string()), - role: Some("explorer".to_string()), - timeout_secs: None, - model: None, - return_summary: None, - allowed_tools: Some(vec!["read_file".to_string()]), - denied_tools: Some(vec!["write_file".to_string()]), - max_tokens: Some(1200), - }; - - let task = build_subagent_task_definition(&input, SubAgentRole::Explorer).unwrap(); - let message = build_subagent_task_runtime_message(&input, &task, SubAgentRole::Explorer); - - assert!(message.contains("任务标题:探索 team workspace")); - assert!(message.contains("子代理角色:explorer")); - assert!(message.contains("工具偏好:优先仅使用这些工具:read_file")); - assert!(message.contains("避免使用这些工具:write_file")); - assert!(message.contains("输出控制:请尽量将最终输出控制在 1200 tokens 内。")); - assert!(message.contains("不要再创建新的子代理")); - assert!(message.contains("任务说明:")); - assert!(message.contains("探索 team workspace 最佳实践")); - } - - #[test] - fn test_collect_subagent_task_compat_warnings_marks_soft_constraints() { - let input = SubAgentTaskToolInput { - prompt: "探索".to_string(), - task_type: None, - description: None, - role: None, - timeout_secs: None, - model: None, - return_summary: None, - allowed_tools: Some(vec!["read_file".to_string()]), - denied_tools: Some(vec!["write_file".to_string()]), - max_tokens: Some(512), - }; - - let warnings = collect_subagent_task_compat_warnings(&input); - assert_eq!(warnings.len(), 3); - assert!(warnings.iter().any(|item| item.contains("allowedTools"))); - assert!(warnings.iter().any(|item| item.contains("deniedTools"))); - assert!(warnings.iter().any(|item| item.contains("maxTokens"))); - } - #[test] fn test_subagent_counts_toward_team_limit_only_counts_active_states() { assert!(subagent_counts_toward_team_limit( diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs index 229e0a98e..f450c1f70 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs @@ -43,12 +43,8 @@ pub(crate) use social_tools::ensure_social_image_tool_registered; pub(crate) use social_tools::social_generate_cover_image_cmd; #[allow(unused_imports)] pub(crate) use social_tools::SocialGenerateCoverImageTool; -#[allow(unused_imports)] -pub(crate) use subagent_tools::{ - build_subagent_task_definition, build_subagent_task_runtime_message, - collect_subagent_task_compat_warnings, extract_runtime_subagent_result_text, - parse_subagent_role, SubAgentTaskToolInput, -}; +#[cfg(test)] +pub(crate) use subagent_tools::extract_runtime_subagent_result_text; use workspace_tools::WorkspaceSandboxedBashTool; #[cfg(test)] pub(crate) use workspace_tools::{ diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs index e3b806a81..c091e6bb2 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs @@ -1,204 +1,6 @@ use super::*; -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct SubAgentTaskToolInput { - pub(crate) prompt: String, - pub(crate) task_type: Option, - pub(crate) description: Option, - pub(crate) role: Option, - pub(crate) timeout_secs: Option, - pub(crate) model: Option, - pub(crate) return_summary: Option, - pub(crate) allowed_tools: Option>, - pub(crate) denied_tools: Option>, - pub(crate) max_tokens: Option, -} - -pub(crate) fn parse_subagent_role(raw: Option<&str>) -> Result { - let normalized = raw - .map(|value| value.trim().to_ascii_lowercase()) - .unwrap_or_else(|| "executor".to_string()); - - match normalized.as_str() { - "" | "executor" | "execute" | "code" => Ok(SubAgentRole::Executor), - "planner" | "plan" => Ok(SubAgentRole::Planner), - "explorer" | "explore" | "research" => Ok(SubAgentRole::Explorer), - _ => Err(ToolError::invalid_params(format!( - "未知 SubAgent 角色: {},支持 explorer/planner/executor", - normalized - ))), - } -} - -fn default_subagent_task_type(role: SubAgentRole) -> &'static str { - match role { - SubAgentRole::Explorer => "explore", - SubAgentRole::Planner => "plan", - SubAgentRole::Executor => "code", - } -} - -pub(crate) fn build_subagent_task_definition( - input: &SubAgentTaskToolInput, - role: SubAgentRole, -) -> Result { - let prompt = input.prompt.trim(); - if prompt.is_empty() { - return Err(ToolError::invalid_params( - "SubAgentTask.prompt 不能为空".to_string(), - )); - } - - let task_type = input - .task_type - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(default_subagent_task_type(role)); - - let mut task = SubAgentTask::new(uuid::Uuid::new_v4().to_string(), task_type, prompt); - - if let Some(description) = input - .description - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - task = task.with_description(description.to_string()); - } - - if let Some(timeout_secs) = input.timeout_secs.filter(|value| *value > 0) { - task = task.with_timeout(Duration::from_secs(timeout_secs)); - } - - if let Some(model) = input - .model - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - task = task.with_model(model.to_string()); - } - - if let Some(return_summary) = input.return_summary { - task = task.with_summary(return_summary); - } - - if let Some(allowed_tools) = input - .allowed_tools - .as_ref() - .filter(|items| !items.is_empty()) - { - task = task.with_allowed_tools(allowed_tools.clone()); - } - - if let Some(denied_tools) = input - .denied_tools - .as_ref() - .filter(|items| !items.is_empty()) - { - task = task.with_denied_tools(denied_tools.clone()); - } - - if let Some(max_tokens) = input.max_tokens.filter(|value| *value > 0) { - task = task.with_max_tokens(max_tokens); - } - - Ok(task) -} - -pub(crate) fn build_subagent_task_runtime_message( - input: &SubAgentTaskToolInput, - task: &SubAgentTask, - role: SubAgentRole, -) -> String { - let mut sections = Vec::new(); - - if let Some(description) = input - .description - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - sections.push(format!("任务标题:{description}")); - } - - sections.push(format!("子代理角色:{role}")); - - if let Some(task_type) = input - .task_type - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - sections.push(format!("任务类型:{task_type}")); - } - - if let Some(allowed_tools) = input - .allowed_tools - .as_ref() - .filter(|items| !items.is_empty()) - { - sections.push(format!( - "工具偏好:优先仅使用这些工具:{}", - allowed_tools.join(", ") - )); - } - - if let Some(denied_tools) = input - .denied_tools - .as_ref() - .filter(|items| !items.is_empty()) - { - sections.push(format!("避免使用这些工具:{}", denied_tools.join(", "))); - } - - if let Some(max_tokens) = input.max_tokens.filter(|value| *value > 0) { - sections.push(format!( - "输出控制:请尽量将最终输出控制在 {max_tokens} tokens 内。" - )); - } - - sections.push( - "协作约束:你不是唯一工作线程。请只处理当前明确分配的子任务,不要重复主线程或其他子代理的工作,不要再创建新的子代理。" - .to_string(), - ); - - sections.push("任务说明:".to_string()); - sections.push(task.prompt.clone()); - - sections.join("\n") -} - -pub(crate) fn collect_subagent_task_compat_warnings(input: &SubAgentTaskToolInput) -> Vec { - let mut warnings = Vec::new(); - - if input - .allowed_tools - .as_ref() - .is_some_and(|items| !items.is_empty()) - { - warnings - .push("allowedTools 已降级为对子代理的提示,不再由旧 scheduler 做硬限制".to_string()); - } - - if input - .denied_tools - .as_ref() - .is_some_and(|items| !items.is_empty()) - { - warnings - .push("deniedTools 已降级为对子代理的提示,不再由旧 scheduler 做硬限制".to_string()); - } - - if input.max_tokens.is_some_and(|value| value > 0) { - warnings.push("maxTokens 已降级为输出提示,当前 team runtime 不做强制截断".to_string()); - } - - warnings -} - +#[cfg(test)] fn extract_runtime_message_text(message: &AgentMessage) -> Option { let parts = message .content @@ -225,6 +27,7 @@ fn extract_runtime_message_text(message: &AgentMessage) -> Option { } } +#[cfg(test)] pub(crate) fn extract_runtime_subagent_result_text(detail: &SessionDetail) -> Option { detail .messages @@ -279,246 +82,6 @@ pub(crate) fn extract_runtime_subagent_result_text(detail: &SessionDetail) -> Op }) } -fn summarize_runtime_subagent_execution( - role: SubAgentRole, - status: &SubagentRuntimeStatus, - detail: Option<&SessionDetail>, -) -> String { - let result_text = detail - .and_then(extract_runtime_subagent_result_text) - .unwrap_or_else(|| "未返回摘要".to_string()); - - match status.kind { - SubagentRuntimeStatusKind::Completed => { - format!("子代理({role}) 已通过 team runtime 完成任务。\n\n{result_text}") - } - SubagentRuntimeStatusKind::Failed | SubagentRuntimeStatusKind::Aborted => { - format!("子代理({role}) 执行失败。\n\n{result_text}") - } - SubagentRuntimeStatusKind::Closed => { - format!("子代理({role}) 已关闭。\n\n{result_text}") - } - SubagentRuntimeStatusKind::NotFound => { - format!("子代理({role}) 未找到,无法获取结果。") - } - _ => format!( - "子代理({role}) 当前状态为 {:?}。\n\n{result_text}", - status.kind - ), - } -} - -#[derive(Debug, Clone)] -struct SubAgentTaskTool { - runtime: SubagentControlRuntime, -} - -impl SubAgentTaskTool { - fn new(runtime: SubagentControlRuntime) -> Self { - Self { runtime } - } -} - -#[async_trait] -impl Tool for SubAgentTaskTool { - fn name(&self) -> &str { - "SubAgentTask" - } - - fn description(&self) -> &str { - "兼容入口。仅用于兼容仍输出旧 SubAgentTask schema 的历史提示词或旧技能;内部会退化为串行 team runtime 调用,不适合作为新的多代理并发主路径。新实现优先直接使用 Agent / TeamCreate / TeamDelete / SendMessage / ListPeers。" - } - - fn input_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { - "prompt": { - "type": "string", - "description": "子代理要执行的任务说明" - }, - "taskType": { - "type": "string", - "description": "任务类型,例如 explore、plan、code、review" - }, - "description": { - "type": "string", - "description": "展示给用户的任务标题" - }, - "role": { - "type": "string", - "description": "子代理角色:explorer、planner、executor" - }, - "timeoutSecs": { - "type": "integer", - "minimum": 1, - "description": "单个子任务超时时间(秒)" - }, - "model": { - "type": "string", - "description": "可选模型名" - }, - "returnSummary": { - "type": "boolean", - "description": "是否优先返回摘要" - }, - "allowedTools": { - "type": "array", - "items": { "type": "string" }, - "description": "显式允许的工具列表" - }, - "deniedTools": { - "type": "array", - "items": { "type": "string" }, - "description": "显式拒绝的工具列表" - }, - "maxTokens": { - "type": "integer", - "minimum": 1, - "description": "子代理最大 token 限制" - } - }, - "required": ["prompt"], - "additionalProperties": false - }) - } - - fn options(&self) -> ToolOptions { - ToolOptions::new() - .with_max_retries(0) - .with_base_timeout(Duration::from_secs(900)) - .with_dynamic_timeout(false) - } - - async fn execute( - &self, - params: serde_json::Value, - context: &ToolContext, - ) -> Result { - let input: SubAgentTaskToolInput = serde_json::from_value(params) - .map_err(|err| ToolError::invalid_params(format!("SubAgentTask 参数无效: {err}")))?; - let role = parse_subagent_role(input.role.as_deref())?; - let task = build_subagent_task_definition(&input, role)?; - let task_id = task.id.clone(); - let parent_session_id = normalize_required_text(&context.session_id, "session_id") - .map_err(ToolError::invalid_params)?; - let compat_warnings = collect_subagent_task_compat_warnings(&input); - let response = agent_runtime_spawn_subagent_internal( - &self.runtime, - AgentRuntimeSpawnSubagentRequest { - parent_session_id, - message: build_subagent_task_runtime_message(&input, &task, role), - name: None, - team_name: None, - agent_type: Some(role.to_string()), - model: input.model.clone(), - run_in_background: false, - reasoning_effort: None, - fork_context: false, - blueprint_role_id: None, - blueprint_role_label: None, - profile_id: None, - profile_name: None, - role_key: None, - skill_ids: Vec::new(), - skill_directories: Vec::new(), - team_preset_id: None, - theme: None, - system_overlay: None, - output_contract: None, - mode: None, - isolation: None, - cwd: None, - }, - ) - .await - .map_err(|error| { - ToolError::execution_failed(format!( - "SubAgentTask 已切到 team runtime,但创建子代理失败: {error}" - )) - })?; - - let timeout_ms = input - .timeout_secs - .unwrap_or(900) - .saturating_mul(1000) - .min(i64::MAX as u64) as i64; - let wait_result = agent_runtime_wait_subagents_internal( - &self.runtime, - AgentRuntimeWaitSubagentsRequest { - ids: vec![response.agent_id.clone()], - timeout_ms: Some(timeout_ms), - }, - ) - .await - .map_err(|error| { - ToolError::execution_failed(format!( - "SubAgentTask 已创建子代理,但等待结果失败: {error}" - )) - })?; - - let detail = - AsterAgentWrapper::get_runtime_session_detail(&self.runtime.db, &response.agent_id) - .await - .ok(); - let status = wait_result - .status - .get(&response.agent_id) - .cloned() - .unwrap_or(SubagentRuntimeStatus { - session_id: response.agent_id.clone(), - kind: if wait_result.timed_out { - SubagentRuntimeStatusKind::Running - } else { - SubagentRuntimeStatusKind::NotFound - }, - latest_turn_id: None, - latest_turn_status: None, - queued_turn_count: 0, - team_phase: None, - team_parallel_budget: None, - team_active_count: None, - team_queued_count: None, - provider_concurrency_group: None, - provider_parallel_budget: None, - queue_reason: None, - retryable_overload: false, - closed: false, - }); - - let summary = if wait_result.timed_out { - format!( - "子代理({role}) 已创建,但在 {} 秒内未完成。可以继续通过 team workspace 跟踪: {}", - input.timeout_secs.unwrap_or(900), - response.agent_id - ) - } else { - summarize_runtime_subagent_execution(role, &status, detail.as_ref()) - }; - let metadata = serde_json::json!({ - "task_id": task_id, - "agent_id": response.agent_id, - "nickname": response.nickname, - "role": role.to_string(), - "status": status, - "timed_out": wait_result.timed_out, - "compat_mode": "SubAgentTask->Agent", - "compat_warnings": compat_warnings, - }); - - let success = !wait_result.timed_out && status.kind == SubagentRuntimeStatusKind::Completed; - let result = if success { - ToolResult::success(summary) - } else { - ToolResult::error(summary) - }; - - Ok(result - .with_metadata("subagent", metadata) - .with_metadata("role", serde_json::json!(role.to_string()))) - } -} - fn build_agent_control_tool_config( runtime: SubagentControlRuntime, ) -> aster::tools::AgentControlToolConfig { @@ -595,7 +158,6 @@ pub(super) fn register_subagent_runtime_tools( registry: &mut aster::tools::ToolRegistry, runtime: SubagentControlRuntime, ) { - registry.register(Box::new(SubAgentTaskTool::new(runtime.clone()))); aster::tools::register_agent_control_tools(registry, &build_agent_control_tool_config(runtime)); // 本地联调中的 Aster current surface 会在 Agent::list_tools() 里额外注入一份 `Agent`。 // 如果这里继续保留 registry 侧的同名 `Agent`,provider 在格式化 tools 时会直接报重名。 diff --git a/src-tauri/src/commands/memory_management_cmd.rs b/src-tauri/src/commands/memory_management_cmd.rs index 06ca118eb..5c4a1e5e7 100644 --- a/src-tauri/src/commands/memory_management_cmd.rs +++ b/src-tauri/src/commands/memory_management_cmd.rs @@ -26,7 +26,8 @@ use crate::services::runtime_agents_template_service::{ use aster::session::list_summaries; use chrono::{Local, NaiveDateTime, TimeZone}; use lime_core::app_paths; -use lime_services::context_memory_service::{MemoryEntry, MemoryFileType}; +use lime_core::config::MemoryConfig; +use lime_services::context_memory_service::{ContextMemoryService, MemoryEntry, MemoryFileType}; use rusqlite::Connection; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; @@ -330,82 +331,9 @@ async fn memory_runtime_request_analysis_impl( }); } - let max_generated_per_request = memory_config - .max_entries - .unwrap_or(MAX_GENERATED_PER_REQUEST as u32) - .clamp(1, MAX_GENERATED_PER_REQUEST_CAP as u32) - as usize; - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; let candidates = load_memory_candidates(&conn, from_timestamp, to_timestamp)?; - - if candidates.is_empty() { - return Ok(MemoryAnalysisResult { - analyzed_sessions: 0, - analyzed_messages: 0, - generated_entries: 0, - deduplicated_entries: 0, - }); - } - - let mut analyzed_sessions: HashSet = HashSet::new(); - let mut generated_entries = 0u32; - let mut deduplicated_entries = 0u32; - let mut generated_count_per_session: HashMap = HashMap::new(); - - for candidate in candidates.iter().take(MAX_SOURCE_MESSAGES) { - analyzed_sessions.insert(candidate.session_id.clone()); - - let counter = generated_count_per_session - .entry(candidate.session_id.clone()) - .or_insert(0); - if *counter >= MAX_GENERATED_PER_SESSION { - continue; - } - - let fingerprint = build_fingerprint(&candidate.content); - let (title, summary, file_type, category_tag) = build_memory_entry_fields(candidate); - let existing = memory_service - .0 - .get_session_memories(&candidate.session_id, Some(file_type))?; - - if is_duplicate_memory(&existing, &fingerprint, &summary) { - deduplicated_entries += 1; - continue; - } - - let entry = MemoryEntry { - id: uuid::Uuid::new_v4().to_string(), - session_id: candidate.session_id.clone(), - file_type, - title, - content: summary, - tags: vec![ - "auto_analysis".to_string(), - category_tag.to_string(), - fingerprint, - ], - priority: infer_priority(candidate), - created_at: candidate.created_at, - updated_at: candidate.created_at, - archived: false, - }; - - memory_service.0.save_memory_entry(&entry)?; - generated_entries += 1; - *counter += 1; - - if generated_entries as usize >= max_generated_per_request { - break; - } - } - - Ok(MemoryAnalysisResult { - analyzed_sessions: analyzed_sessions.len() as u32, - analyzed_messages: candidates.len() as u32, - generated_entries, - deduplicated_entries, - }) + analyze_memory_candidates(memory_service.0.as_ref(), &memory_config, &candidates) } /// 从历史对话中抽取 runtime / 上下文记忆条目 @@ -1620,6 +1548,84 @@ fn load_memory_candidates( ) } +pub(crate) fn analyze_memory_candidates( + memory_service: &ContextMemoryService, + memory_config: &MemoryConfig, + candidates: &[MemorySourceCandidate], +) -> Result { + if !memory_config.enabled || candidates.is_empty() { + return Ok(MemoryAnalysisResult { + analyzed_sessions: 0, + analyzed_messages: 0, + generated_entries: 0, + deduplicated_entries: 0, + }); + } + + let max_generated_per_request = memory_config + .max_entries + .unwrap_or(MAX_GENERATED_PER_REQUEST as u32) + .clamp(1, MAX_GENERATED_PER_REQUEST_CAP as u32) + as usize; + let mut analyzed_sessions: HashSet = HashSet::new(); + let mut generated_entries = 0u32; + let mut deduplicated_entries = 0u32; + let mut generated_count_per_session: HashMap = HashMap::new(); + + for candidate in candidates.iter().take(MAX_SOURCE_MESSAGES) { + analyzed_sessions.insert(candidate.session_id.clone()); + + let counter = generated_count_per_session + .entry(candidate.session_id.clone()) + .or_insert(0); + if *counter >= MAX_GENERATED_PER_SESSION { + continue; + } + + let fingerprint = build_fingerprint(&candidate.content); + let (title, summary, file_type, category_tag) = build_memory_entry_fields(candidate); + let existing = + memory_service.get_session_memories(&candidate.session_id, Some(file_type))?; + + if is_duplicate_memory(&existing, &fingerprint, &summary) { + deduplicated_entries += 1; + continue; + } + + let entry = MemoryEntry { + id: uuid::Uuid::new_v4().to_string(), + session_id: candidate.session_id.clone(), + file_type, + title, + content: summary, + tags: vec![ + "auto_analysis".to_string(), + category_tag.to_string(), + fingerprint, + ], + priority: infer_priority(candidate), + created_at: candidate.created_at, + updated_at: candidate.created_at, + archived: false, + }; + + memory_service.save_memory_entry(&entry)?; + generated_entries += 1; + *counter += 1; + + if generated_entries as usize >= max_generated_per_request { + break; + } + } + + Ok(MemoryAnalysisResult { + analyzed_sessions: analyzed_sessions.len() as u32, + analyzed_messages: candidates.len() as u32, + generated_entries, + deduplicated_entries, + }) +} + fn build_fingerprint(content: &str) -> String { let normalized = content.to_lowercase(); let compact = normalized diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 41b84485d..242f27f75 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -55,7 +55,6 @@ pub mod site_capability_cmd; pub mod skill_cmd; pub mod skill_error; pub mod skill_exec_cmd; -pub mod subagent_cmd; pub mod telegram_remote_cmd; pub mod telemetry_cmd; pub mod terminal_cmd; diff --git a/src-tauri/src/commands/subagent_cmd.rs b/src-tauri/src/commands/subagent_cmd.rs deleted file mode 100644 index 541dbe1fe..000000000 --- a/src-tauri/src/commands/subagent_cmd.rs +++ /dev/null @@ -1,108 +0,0 @@ -//! SubAgent 调度器命令 -//! -//! 提供 SubAgent 调度功能的 Tauri 命令接口 - -use std::sync::Arc; -use tauri::{AppHandle, State}; -use tokio::sync::RwLock; - -use aster::agents::context::AgentContext; -use aster::agents::subagent_scheduler::{SchedulerConfig, SchedulerExecutionResult, SubAgentTask}; - -use crate::agent::{LimeScheduler, SubAgentRole}; -use crate::database::DbConnection; - -/// SubAgent 调度器状态 -pub struct SubAgentSchedulerState { - #[allow(dead_code)] - scheduler: Arc>>, -} - -impl SubAgentSchedulerState { - pub fn new() -> Self { - Self { - scheduler: Arc::new(RwLock::new(None)), - } - } -} - -impl Default for SubAgentSchedulerState { - fn default() -> Self { - Self::new() - } -} - -/// 初始化 SubAgent 调度器 -#[allow(dead_code)] -#[tauri::command] -pub async fn init_subagent_scheduler( - app: AppHandle, - db: State<'_, DbConnection>, - state: State<'_, SubAgentSchedulerState>, - config: Option, - session_id: Option, -) -> Result<(), String> { - let mut scheduler = LimeScheduler::new(db.inner().clone()).with_app_handle(app); - if let Some(session_id) = session_id.filter(|value| !value.trim().is_empty()) { - scheduler = scheduler.with_event_session_id(session_id); - } - - scheduler.init(config).await; - - *state.scheduler.write().await = Some(scheduler); - - Ok(()) -} - -/// 执行 SubAgent 任务 -#[allow(dead_code)] -#[tauri::command] -pub async fn execute_subagent_tasks( - app: AppHandle, - db: State<'_, DbConnection>, - state: State<'_, SubAgentSchedulerState>, - tasks: Vec, - config: Option, - role: Option, - session_id: Option, -) -> Result { - let mut scheduler = LimeScheduler::new(db.inner().clone()).with_app_handle(app); - if let Some(session_id) = session_id.filter(|value| !value.trim().is_empty()) { - scheduler = scheduler.with_event_session_id(session_id); - } - scheduler.init(config.clone()).await; - *state.scheduler.write().await = Some(scheduler); - - let scheduler_guard = state.scheduler.read().await; - let scheduler = scheduler_guard - .as_ref() - .ok_or_else(|| "调度器初始化失败".to_string())?; - - // 创建父上下文 - let parent_context = AgentContext::new(); - - // 根据是否指定角色选择执行方式 - match role { - Some(role) => scheduler - .execute_with_role(tasks, Some(&parent_context), role) - .await - .map_err(|e| e.to_string()), - None => scheduler - .execute(tasks, Some(&parent_context)) - .await - .map_err(|e| e.to_string()), - } -} - -/// 取消 SubAgent 任务 -#[allow(dead_code)] -#[tauri::command] -pub async fn cancel_subagent_tasks(state: State<'_, SubAgentSchedulerState>) -> Result<(), String> { - let scheduler_guard = state.scheduler.read().await; - - if let Some(scheduler) = scheduler_guard.as_ref() { - scheduler.cancel().await; - } - - Ok(()) -} diff --git a/src-tauri/src/commands/unified_memory_cmd.rs b/src-tauri/src/commands/unified_memory_cmd.rs index b9748bf6c..5268bf236 100644 --- a/src-tauri/src/commands/unified_memory_cmd.rs +++ b/src-tauri/src/commands/unified_memory_cmd.rs @@ -5,6 +5,7 @@ use crate::config::GlobalConfigManagerState; use crate::database::DbConnection; use crate::services::chat_history_service::{load_memory_source_candidates, MemorySourceCandidate}; +use lime_core::config::MemoryConfig; use lime_memory::extractor::{self, ExtractionContext}; use lime_memory::gatekeeper::ChatMessage; use lime_memory::{MemoryCategory, MemoryMetadata, MemorySource, MemoryType, UnifiedMemory}; @@ -450,6 +451,19 @@ pub async fn unified_memory_analyze( } let memory_config = global_config.config().memory; + let candidates = { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + load_memory_candidates(&conn, from_timestamp, to_timestamp)? + }; + + analyze_unified_memory_candidates(&db, &memory_config, &candidates).await +} + +pub(crate) async fn analyze_unified_memory_candidates( + db: &DbConnection, + memory_config: &MemoryConfig, + candidates: &[MemorySourceCandidate], +) -> Result { if !memory_config.enabled { info!("[Unified Memory] 记忆功能已关闭,跳过分析"); return Ok(MemoryAnalysisResult { @@ -460,26 +474,20 @@ pub async fn unified_memory_analyze( }); } + if candidates.is_empty() { + return Ok(MemoryAnalysisResult { + analyzed_sessions: 0, + analyzed_messages: 0, + generated_entries: 0, + deduplicated_entries: 0, + }); + } + let max_generated_per_request = memory_config .max_entries .unwrap_or(MAX_GENERATED_PER_REQUEST as u32) .clamp(1, MAX_GENERATED_PER_REQUEST_CAP as u32) as usize; - - let candidates = { - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - load_memory_candidates(&conn, from_timestamp, to_timestamp)? - }; - - if candidates.is_empty() { - return Ok(MemoryAnalysisResult { - analyzed_sessions: 0, - analyzed_messages: 0, - generated_entries: 0, - deduplicated_entries: 0, - }); - } - let analyzed_sessions = candidates .iter() .map(|item| item.session_id.clone()) @@ -493,7 +501,7 @@ pub async fn unified_memory_analyze( let llm_attempted = llm_api_key.is_some(); if let Some(api_key) = llm_api_key { - match build_pending_from_llm(&db, &candidates, &api_key, max_generated_per_request).await { + match build_pending_from_llm(db, candidates, &api_key, max_generated_per_request).await { Ok((mut llm_pending, llm_dedup)) => { deduplicated_entries += llm_dedup; pending_memories.append(&mut llm_pending); @@ -506,7 +514,7 @@ pub async fn unified_memory_analyze( if !llm_attempted || pending_memories.is_empty() { let (mut fallback_pending, fallback_dedup) = - build_pending_from_rules(&db, &candidates, max_generated_per_request)?; + build_pending_from_rules(db, candidates, max_generated_per_request)?; deduplicated_entries += fallback_dedup; pending_memories.append(&mut fallback_pending); } @@ -540,7 +548,7 @@ pub async fn unified_memory_analyze( } fn build_pending_from_rules( - db: &State<'_, DbConnection>, + db: &DbConnection, candidates: &[MemorySourceCandidate], max_generated_per_request: usize, ) -> Result<(Vec, u32), String> { @@ -606,7 +614,7 @@ fn build_pending_from_rules( } async fn build_pending_from_llm( - db: &State<'_, DbConnection>, + db: &DbConnection, candidates: &[MemorySourceCandidate], api_key: &str, max_generated_per_request: usize, diff --git a/src-tauri/src/services/chat_history_service.rs b/src-tauri/src/services/chat_history_service.rs index 429e4482e..675c72c3a 100644 --- a/src-tauri/src/services/chat_history_service.rs +++ b/src-tauri/src/services/chat_history_service.rs @@ -48,6 +48,35 @@ pub fn load_memory_source_candidates( Ok(candidates) } +pub fn load_session_memory_source_candidates( + conn: &Connection, + session_id: &str, + limit: usize, + min_message_length: usize, +) -> Result, String> { + let rows = AgentDao::list_message_text_rows_by_session_id(conn, session_id, limit) + .map_err(|e| format!("读取 session agent_messages 失败: {e}"))?; + let mut candidates = Vec::new(); + let mut seen = HashSet::new(); + + for row in rows { + push_candidate( + &mut candidates, + &mut seen, + row.session_id, + row.role, + row.content, + row.timestamp_ms, + min_message_length, + ); + } + + candidates.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + candidates.truncate(limit); + + Ok(candidates) +} + fn load_unified_general_candidates( conn: &Connection, from_timestamp: Option, @@ -189,7 +218,7 @@ fn format_sqlite_datetime(timestamp_ms: i64) -> String { #[cfg(test)] mod tests { - use super::load_memory_source_candidates; + use super::{load_memory_source_candidates, load_session_memory_source_candidates}; use rusqlite::{params, Connection}; fn create_test_schema(conn: &Connection) { @@ -368,4 +397,59 @@ mod tests { assert!(session_ids.contains(&"agent-1")); assert!(!session_ids.contains(&"legacy-only")); } + + #[test] + fn load_session_memory_source_candidates_only_reads_target_session() { + let conn = Connection::open_in_memory().expect("open in memory db"); + create_test_schema(&conn); + + conn.execute( + "INSERT INTO agent_sessions (id, model, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)", + params![ + "agent-1", + "claude-sonnet-4", + "2026-03-12T10:00:00+08:00", + "2026-03-12T10:00:00+08:00" + ], + ) + .unwrap(); + conn.execute( + "INSERT INTO agent_sessions (id, model, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)", + params![ + "agent-2", + "claude-sonnet-4", + "2026-03-12T10:05:00+08:00", + "2026-03-12T10:05:00+08:00" + ], + ) + .unwrap(); + + conn.execute( + "INSERT INTO agent_messages (session_id, role, content_json, timestamp) VALUES (?1, ?2, ?3, ?4)", + params![ + "agent-1", + "user", + r#"[{"type":"text","text":"请记住我现在更偏好 pnpm 工作流"}]"#, + "2026-03-12T10:00:00+08:00" + ], + ) + .unwrap(); + conn.execute( + "INSERT INTO agent_messages (session_id, role, content_json, timestamp) VALUES (?1, ?2, ?3, ?4)", + params![ + "agent-2", + "assistant", + r#"[{"type":"text","text":"这条消息不该进入 agent-1 的自动抽取"}]"#, + "2026-03-12T10:05:00+08:00" + ], + ) + .unwrap(); + + let candidates = load_session_memory_source_candidates(&conn, "agent-1", 10, 1) + .expect("load session candidates"); + + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].session_id, "agent-1"); + assert!(candidates[0].content.contains("pnpm")); + } } diff --git a/src-tauri/src/services/runtime_analysis_handoff_service.rs b/src-tauri/src/services/runtime_analysis_handoff_service.rs index 4983beed6..a4f75ac4f 100644 --- a/src-tauri/src/services/runtime_analysis_handoff_service.rs +++ b/src-tauri/src/services/runtime_analysis_handoff_service.rs @@ -1156,11 +1156,39 @@ mod tests { } } + fn write_request_telemetry_fixture(root: &Path) { + let request_logs_dir = root.join("request_logs"); + fs::create_dir_all(&request_logs_dir).expect("create request logs dir"); + + let mut log = lime_infra::telemetry::RequestLog::new( + "req-analysis-1".to_string(), + lime_core::ProviderType::OpenAI, + "gpt-5.4".to_string(), + false, + ); + log.session_id = Some("session-1".to_string()); + log.thread_id = Some("thread-1".to_string()); + log.turn_id = Some("turn-1".to_string()); + log.pending_request_id = Some("req-1".to_string()); + log.queued_turn_id = Some("queued-1".to_string()); + log.mark_success(320, 200); + + fs::write( + request_logs_dir.join("requests_2026-03-27.jsonl"), + format!( + "{}\n", + serde_json::to_string(&log).expect("serialize request log") + ), + ) + .expect("write request log"); + } + #[test] fn should_export_runtime_analysis_handoff_to_workspace() { let temp_dir = TempDir::new().expect("temp dir"); let detail = build_detail(); let thread_read = build_thread_read(); + write_request_telemetry_fixture(temp_dir.path()); let result = export_runtime_analysis_handoff(&detail, &thread_read, temp_dir.path()) .expect("export"); @@ -1189,7 +1217,8 @@ mod tests { assert!(brief.contains("外部分析交接简报")); assert!(brief.contains("pending request:1")); assert!(brief.contains("证据关联与可观测覆盖")); - assert!(brief.contains("requestTelemetry (unlinked)")); + assert!(brief.contains("requestTelemetry")); + assert!(!brief.contains("requestTelemetry (unlinked)")); assert!(brief.contains("/workspace/lime")); let context = fs::read_to_string(context_path).expect("context"); @@ -1199,6 +1228,7 @@ mod tests { assert!(context.contains("\"observability\"")); assert!(context.contains("\"correlationKeys\"")); assert!(context.contains("\"gapSignals\"")); + assert!(context.contains("\"matchedRequestCount\": 1")); assert!(context.contains("/workspace/lime")); assert!(!context.contains(temp_dir.path().to_string_lossy().as_ref())); } diff --git a/src-tauri/src/services/runtime_evidence_pack_service.rs b/src-tauri/src/services/runtime_evidence_pack_service.rs index 2f0ec8dea..99b6b9b5d 100644 --- a/src-tauri/src/services/runtime_evidence_pack_service.rs +++ b/src-tauri/src/services/runtime_evidence_pack_service.rs @@ -5,13 +5,16 @@ use crate::agent::SessionDetail; use crate::commands::aster_agent_cmd::AgentRuntimeThreadReadModel; +use crate::services::artifact_document_validator::ARTIFACT_DOCUMENT_SCHEMA_VERSION; use chrono::Utc; use lime_core::database::dao::agent_timeline::AgentThreadItemPayload; +use lime_infra::telemetry::RequestLog; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::{json, Map, Value}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::fmt::Write as _; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; const SESSION_RELATIVE_ROOT: &str = ".lime/harness/sessions"; const EVIDENCE_DIR_NAME: &str = "evidence"; @@ -21,6 +24,8 @@ const TIMELINE_FILE_NAME: &str = "timeline.json"; const ARTIFACTS_FILE_NAME: &str = "artifacts.json"; const MAX_RECENT_ARTIFACTS: usize = 12; const MAX_PREVIEW_CHARS: usize = 200; +const MAX_BROWSER_EVIDENCE_ITEMS: usize = 6; +const MAX_REQUEST_TELEMETRY_ITEMS: usize = 12; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -62,6 +67,44 @@ pub struct RuntimeEvidencePackExportResult { pub artifacts: Vec, } +#[derive(Debug, Clone, PartialEq)] +struct RuntimeRecentArtifact { + path: String, + metadata: Option, +} + +#[derive(Debug, Clone, Default, PartialEq)] +struct RuntimeArtifactValidatorSummary { + applicable: bool, + records: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq)] +struct RuntimeEvidenceVerificationSummary { + artifact_validator: RuntimeArtifactValidatorSummary, + browser_evidence: Vec, + gui_smoke: Option, +} + +#[derive(Debug, Clone, Default)] +struct RuntimeRequestTelemetrySummary { + searched_roots: Vec, + matched_request_count: usize, + latest_request_at: Option, + status_counts: BTreeMap, + providers: Vec, + models: Vec, + requests: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RuntimeEvidenceSignalCoverageEntry { + signal: &'static str, + status: &'static str, + source: &'static str, + detail: String, +} + pub fn export_runtime_evidence_pack( detail: &SessionDetail, thread_read: &AgentRuntimeThreadReadModel, @@ -92,13 +135,29 @@ pub fn export_runtime_evidence_pack( ) })?; - let recent_artifacts = collect_recent_artifact_paths(detail); + let recent_artifacts = collect_recent_artifacts(detail); + let recent_artifact_paths = recent_artifacts + .iter() + .map(|artifact| artifact.path.clone()) + .collect::>(); let latest_turn_summary = collect_latest_turn_summary(detail); - let known_gaps = build_known_gaps(&recent_artifacts); + let request_telemetry = collect_request_telemetry(detail, workspace_root.as_path()); + let verification = + collect_runtime_verification(detail, workspace_root.as_path(), &recent_artifacts); + let signal_coverage = build_signal_coverage( + thread_read, + &recent_artifacts, + &request_telemetry, + &verification, + ); + let known_gaps = build_known_gaps(&recent_artifacts, &signal_coverage); let observability_summary = build_runtime_observability_summary_json( detail, thread_read, - &recent_artifacts, + &recent_artifact_paths, + &request_telemetry, + &verification, + &signal_coverage, &known_gaps, ); @@ -112,7 +171,7 @@ pub fn export_runtime_evidence_pack( build_summary_markdown( detail, thread_read, - &recent_artifacts, + &recent_artifact_paths, latest_turn_summary.as_deref(), &observability_summary, &known_gaps, @@ -129,7 +188,7 @@ pub fn export_runtime_evidence_pack( detail, thread_read, workspace_root.as_path(), - &recent_artifacts, + &recent_artifact_paths, &observability_summary, &known_gaps, exported_at.as_str(), @@ -152,8 +211,10 @@ pub fn export_runtime_evidence_pack( build_artifacts_json( detail, thread_read, - &recent_artifacts, + &recent_artifact_paths, &observability_summary, + &request_telemetry, + &verification, &known_gaps, exported_at.as_str(), )?, @@ -177,7 +238,7 @@ pub fn export_runtime_evidence_pack( item_count: detail.items.len(), pending_request_count: thread_read.pending_requests.len(), queued_turn_count: thread_read.queued_turns.len(), - recent_artifact_count: recent_artifacts.len(), + recent_artifact_count: recent_artifact_paths.len(), known_gaps, artifacts, }) @@ -448,20 +509,17 @@ fn build_artifacts_json( thread_read: &AgentRuntimeThreadReadModel, recent_artifacts: &[String], observability_summary: &Value, + request_telemetry: &RuntimeRequestTelemetrySummary, + verification: &RuntimeEvidenceVerificationSummary, known_gaps: &[String], exported_at: &str, ) -> Result { - let payload = json!({ + let mut payload = json!({ "schemaVersion": "v1", "exportedAt": exported_at, "recentArtifacts": recent_artifacts, "artifactCount": recent_artifacts.len(), "observabilitySummary": observability_summary, - "verification": { - "artifactValidatorIssues": [], - "browserEvidence": [], - "guiSmoke": null - }, "requests": { "pending": thread_read.pending_requests.iter().map(|item| { json!({ @@ -471,7 +529,7 @@ fn build_artifacts_json( "status": item.status }) }).collect::>(), - "knownGap": "provider request token / retry 摘要尚未接入当前 evidence pack" + "telemetry": build_request_telemetry_json(request_telemetry) }, "workspace": { "workspaceId": detail.workspace_id, @@ -480,21 +538,35 @@ fn build_artifacts_json( "knownGaps": known_gaps }); + if let Some(verification_payload) = build_verification_json(verification) { + payload + .as_object_mut() + .expect("artifacts payload must be object") + .insert( + "verification".to_string(), + Value::Object(verification_payload), + ); + } + serde_json::to_string_pretty(&payload) .map_err(|error| format!("序列化 artifacts.json 失败: {error}")) } -fn build_known_gaps(recent_artifacts: &[String]) -> Vec { - let mut gaps = vec![ - "当前 Lime 虽然已有全局 RequestLog / token / retry / duration 遥测,但 RequestLog 尚未携带 session/thread/turn 关联键,Evidence Pack 只能导出 request telemetry coverage gap,不能给出精确的会话级摘要。".to_string(), - "当前 Evidence Pack 尚未纳入 ArtifactDocument validator outcome 与修复问题摘要。".to_string(), - "当前 Evidence Pack 尚未纳入 GUI smoke / browser 验证结果。".to_string(), - ]; +fn build_known_gaps( + recent_artifacts: &[RuntimeRecentArtifact], + signal_coverage: &[RuntimeEvidenceSignalCoverageEntry], +) -> Vec { + let mut gaps = signal_coverage + .iter() + .filter(|entry| entry.status != "exported") + .map(|entry| entry.detail.clone()) + .collect::>(); if recent_artifacts.is_empty() { gaps.push("当前未检测到最近产物路径,Artifact 证据为空。".to_string()); } + gaps.dedup(); gaps } @@ -511,14 +583,15 @@ fn collect_latest_turn_summary(detail: &SessionDetail) -> Option { }) } -fn collect_recent_artifact_paths(detail: &SessionDetail) -> Vec { +fn collect_recent_artifacts(detail: &SessionDetail) -> Vec { let mut seen = std::collections::HashSet::new(); - let mut paths = Vec::new(); + let mut artifacts = Vec::new(); for item in detail.items.iter().rev() { - let Some(path) = (match &item.payload { - AgentThreadItemPayload::FileArtifact { path, .. } => { + let Some((path, metadata)) = (match &item.payload { + AgentThreadItemPayload::FileArtifact { path, metadata, .. } => { normalize_optional_text(Some(path.clone())) + .map(|normalized| (normalized, metadata.clone())) } _ => None, }) else { @@ -526,14 +599,179 @@ fn collect_recent_artifact_paths(detail: &SessionDetail) -> Vec { }; if seen.insert(path.clone()) { - paths.push(path); + artifacts.push(RuntimeRecentArtifact { path, metadata }); } - if paths.len() >= MAX_RECENT_ARTIFACTS { + if artifacts.len() >= MAX_RECENT_ARTIFACTS { break; } } - paths + artifacts +} + +fn collect_request_telemetry( + detail: &SessionDetail, + workspace_root: &Path, +) -> RuntimeRequestTelemetrySummary { + let turn_ids = detail + .turns + .iter() + .map(|turn| turn.id.clone()) + .collect::>(); + let mut matched_logs = Vec::new(); + let searched_roots = candidate_request_log_roots(workspace_root) + .into_iter() + .filter(|root| root.is_dir()) + .map(|root| { + for path in list_request_log_files(root.as_path()) { + if let Ok(raw) = fs::read_to_string(&path) { + for line in raw.lines() { + if line.trim().is_empty() { + continue; + } + let Ok(log) = serde_json::from_str::(line) else { + continue; + }; + if request_log_matches_session( + &log, + detail.id.as_str(), + detail.thread_id.as_str(), + &turn_ids, + ) { + matched_logs.push(log); + } + } + } + } + root.to_string_lossy().to_string() + }) + .collect::>(); + + matched_logs.sort_by(|left, right| { + right + .timestamp + .cmp(&left.timestamp) + .then_with(|| right.id.cmp(&left.id)) + }); + + let mut status_counts = BTreeMap::new(); + let mut providers = BTreeSet::new(); + let mut models = BTreeSet::new(); + for log in &matched_logs { + *status_counts.entry(log.status.to_string()).or_insert(0) += 1; + providers.insert(log.provider.to_string()); + models.insert(log.model.clone()); + } + + RuntimeRequestTelemetrySummary { + searched_roots, + matched_request_count: matched_logs.len(), + latest_request_at: matched_logs.first().map(|log| log.timestamp.to_rfc3339()), + status_counts, + providers: providers.into_iter().collect(), + models: models.into_iter().collect(), + requests: matched_logs + .into_iter() + .take(MAX_REQUEST_TELEMETRY_ITEMS) + .map(request_log_to_json) + .collect(), + } +} + +fn candidate_request_log_roots(workspace_root: &Path) -> Vec { + let workspace_roots = [ + workspace_root.join("request_logs"), + workspace_root.join(".lime/request_logs"), + ] + .into_iter() + .filter(|candidate| candidate.is_dir()) + .collect::>(); + + if !workspace_roots.is_empty() { + return workspace_roots; + } + + let mut roots = Vec::new(); + let mut seen = HashSet::new(); + + if let Ok(app_root) = lime_core::app_paths::resolve_request_logs_dir() { + let key = app_root.to_string_lossy().to_string(); + if seen.insert(key) { + roots.push(app_root); + } + } + + roots +} + +fn list_request_log_files(root: &Path) -> Vec { + let Ok(entries) = fs::read_dir(root) else { + return Vec::new(); + }; + + let mut files = entries + .filter_map(|entry| entry.ok().map(|value| value.path())) + .filter(|path| path.extension().is_some_and(|ext| ext == "jsonl")) + .collect::>(); + files.sort_by(|left, right| right.cmp(left)); + files +} + +fn request_log_matches_session( + log: &RequestLog, + session_id: &str, + thread_id: &str, + turn_ids: &HashSet, +) -> bool { + if log.session_id.as_deref() != Some(session_id) { + return false; + } + if log.thread_id.as_deref() != Some(thread_id) { + return false; + } + + match log.turn_id.as_deref() { + Some(turn_id) if !turn_ids.is_empty() => turn_ids.contains(turn_id), + _ => true, + } +} + +fn request_log_to_json(log: RequestLog) -> Value { + json!({ + "id": log.id, + "timestamp": log.timestamp.to_rfc3339(), + "provider": log.provider.to_string(), + "model": log.model, + "status": log.status.to_string(), + "durationMs": log.duration_ms, + "httpStatus": log.http_status, + "isStreaming": log.is_streaming, + "credentialId": log.credential_id, + "retryCount": log.retry_count, + "inputTokens": log.input_tokens, + "outputTokens": log.output_tokens, + "totalTokens": log.total_tokens, + "errorMessage": log.error_message, + "sessionId": log.session_id, + "threadId": log.thread_id, + "turnId": log.turn_id, + "pendingRequestId": log.pending_request_id, + "queuedTurnId": log.queued_turn_id, + "subagentSessionId": log.subagent_session_id + }) +} + +fn build_request_telemetry_json(summary: &RuntimeRequestTelemetrySummary) -> Value { + json!({ + "source": "lime_infra.telemetry.request_logs", + "searchedRoots": summary.searched_roots, + "matchedRequestCount": summary.matched_request_count, + "latestRequestAt": summary.latest_request_at, + "statusCounts": summary.status_counts, + "providers": summary.providers, + "models": summary.models, + "requests": summary.requests + }) } fn summarize_item_payload(payload: &AgentThreadItemPayload) -> (&'static str, Option) { @@ -565,14 +803,17 @@ fn truncate_text(value: &str) -> String { + "..." } -pub(crate) fn build_runtime_observability_summary_json( +fn build_runtime_observability_summary_json( detail: &SessionDetail, thread_read: &AgentRuntimeThreadReadModel, recent_artifacts: &[String], + request_telemetry: &RuntimeRequestTelemetrySummary, + verification: &RuntimeEvidenceVerificationSummary, + signal_coverage: &[RuntimeEvidenceSignalCoverageEntry], known_gaps: &[String], ) -> Value { let diagnostics = thread_read.diagnostics.as_ref(); - json!({ + let mut payload = json!({ "schemaVersion": "v1", "correlation": { "correlationKeys": [ @@ -606,56 +847,547 @@ pub(crate) fn build_runtime_observability_summary_json( "failedTool": latest_failed_tool_json(diagnostics), "failedCommand": latest_failed_command_json(diagnostics) }, - "signalCoverage": [ - json!({ - "signal": "correlation", - "status": "exported", - "source": "runtime thread identity", - "detail": "当前证据包已导出 session/thread/turn/pending request/subagent 关联键。" - }), - json!({ - "signal": "timeline", - "status": "exported", - "source": "timeline.json", - "detail": "当前证据包已导出最近 turn 与 item 时间线。" - }), - json!({ - "signal": "warnings", - "status": "exported", - "source": "thread.diagnostics", - "detail": if diagnostics.is_some() { - "当前证据包已导出 warning / failed tool / failed command 摘要。" - } else { - "当前线程没有 diagnostics,但 warning 通道已保留在导出结构中。" - } - }), - json!({ - "signal": "requestTelemetry", - "status": "unlinked", - "source": "lime_infra.telemetry", - "detail": "Lime 已有 workspace 级 request telemetry,但当前 RequestLog 还未携带 session/thread/turn 元数据。" - }), - json!({ - "signal": "artifactValidator", - "status": "known_gap", - "source": "artifact_document_validator", - "detail": "Artifact validator outcome 尚未回挂到当前 evidence pack。" - }), - json!({ - "signal": "browserVerification", - "status": "known_gap", - "source": "browser runtime", - "detail": "浏览器截图、DOM 快照和交互验证结果尚未接入 evidence pack。" - }), - json!({ - "signal": "guiSmoke", - "status": "known_gap", - "source": "verify:gui-smoke", - "detail": "GUI smoke 结果尚未作为结构化证据回挂当前会话。" - }) - ], + "requestTelemetry": build_request_telemetry_json(request_telemetry), + "signalCoverage": signal_coverage.iter().map(|entry| json!({ + "signal": entry.signal, + "status": entry.status, + "source": entry.source, + "detail": entry.detail + })).collect::>(), "knownGaps": known_gaps - }) + }); + + if let Some(verification_summary) = build_observability_verification_summary_json(verification) + { + payload + .as_object_mut() + .expect("observability summary must be object") + .insert("verificationSummary".to_string(), verification_summary); + } + + payload +} + +fn collect_runtime_verification( + detail: &SessionDetail, + workspace_root: &Path, + recent_artifacts: &[RuntimeRecentArtifact], +) -> RuntimeEvidenceVerificationSummary { + RuntimeEvidenceVerificationSummary { + artifact_validator: collect_artifact_validator_summary(workspace_root, recent_artifacts), + browser_evidence: collect_browser_evidence(detail), + gui_smoke: collect_gui_smoke_result(detail), + } +} + +fn collect_artifact_validator_summary( + workspace_root: &Path, + recent_artifacts: &[RuntimeRecentArtifact], +) -> RuntimeArtifactValidatorSummary { + let mut summary = RuntimeArtifactValidatorSummary::default(); + + for artifact in recent_artifacts { + let mut applicable = is_artifact_validator_applicable(artifact); + let mut candidates = Vec::new(); + + if let Some(metadata) = artifact.metadata.as_ref() { + candidates.push(metadata.clone()); + if let Some(document) = metadata.get("artifactDocument") { + candidates.push(document.clone()); + } + } + + if artifact.path.ends_with(".artifact.json") { + applicable = true; + let absolute_path = resolve_workspace_path(workspace_root, artifact.path.as_str()); + if let Ok(raw) = fs::read_to_string(&absolute_path) { + if let Ok(document) = serde_json::from_str::(raw.as_str()) { + candidates.push(document); + } + } + } + + summary.applicable |= applicable; + + for candidate in candidates { + if let Some(record) = + extract_artifact_validator_record(candidate, artifact.path.as_str()) + { + summary.records.push(record); + break; + } + } + } + + summary +} + +fn collect_browser_evidence(detail: &SessionDetail) -> Vec { + let mut evidence = Vec::new(); + + for item in detail.items.iter().rev() { + let record = match &item.payload { + AgentThreadItemPayload::ToolCall { + tool_name, + success, + error, + .. + } if is_browser_tool_name(tool_name.as_str()) => Some(json!({ + "kind": "tool_call", + "itemId": item.id, + "turnId": item.turn_id, + "toolName": tool_name, + "success": success, + "error": error, + "updatedAt": item.updated_at + })), + AgentThreadItemPayload::CommandExecution { + command, + exit_code, + error, + .. + } if is_browser_command(command.as_str()) => Some(json!({ + "kind": "command_execution", + "itemId": item.id, + "turnId": item.turn_id, + "command": command, + "exitCode": exit_code, + "error": error, + "updatedAt": item.updated_at + })), + _ => None, + }; + + if let Some(record) = record { + evidence.push(record); + } + if evidence.len() >= MAX_BROWSER_EVIDENCE_ITEMS { + break; + } + } + + evidence.reverse(); + evidence +} + +fn collect_gui_smoke_result(detail: &SessionDetail) -> Option { + detail + .items + .iter() + .rev() + .find_map(|item| match &item.payload { + AgentThreadItemPayload::CommandExecution { + command, + cwd, + aggregated_output, + exit_code, + error, + } if is_gui_smoke_command(command.as_str()) => Some(json!({ + "itemId": item.id, + "turnId": item.turn_id, + "status": item.status.as_str(), + "command": command, + "cwd": cwd, + "exitCode": exit_code, + "error": error, + "updatedAt": item.updated_at, + "outputPreview": aggregated_output + .as_ref() + .map(|value| truncate_text(value.as_str())) + })), + _ => None, + }) +} + +fn build_signal_coverage( + thread_read: &AgentRuntimeThreadReadModel, + recent_artifacts: &[RuntimeRecentArtifact], + request_telemetry: &RuntimeRequestTelemetrySummary, + verification: &RuntimeEvidenceVerificationSummary, +) -> Vec { + let diagnostics = thread_read.diagnostics.as_ref(); + let request_telemetry_entry = if request_telemetry.searched_roots.is_empty() { + RuntimeEvidenceSignalCoverageEntry { + signal: "requestTelemetry", + status: "known_gap", + source: "lime_infra.telemetry.request_logs", + detail: "当前环境未找到可读取的 request telemetry 日志目录,Evidence Pack 无法导出会话级请求遥测。".to_string(), + } + } else if request_telemetry.matched_request_count == 0 { + RuntimeEvidenceSignalCoverageEntry { + signal: "requestTelemetry", + status: "exported", + source: "lime_infra.telemetry.request_logs", + detail: "当前证据包已扫描 request telemetry 日志目录,但当前会话未匹配到 provider request 记录。".to_string(), + } + } else { + RuntimeEvidenceSignalCoverageEntry { + signal: "requestTelemetry", + status: "exported", + source: "lime_infra.telemetry.request_logs", + detail: format!( + "当前证据包已导出 {} 条按 session/thread/turn 关联的 request telemetry 记录。", + request_telemetry.matched_request_count + ), + } + }; + let mut coverage = vec![ + RuntimeEvidenceSignalCoverageEntry { + signal: "correlation", + status: "exported", + source: "runtime thread identity", + detail: "当前证据包已导出 session/thread/turn/pending request/subagent 关联键。" + .to_string(), + }, + RuntimeEvidenceSignalCoverageEntry { + signal: "timeline", + status: "exported", + source: "timeline.json", + detail: "当前证据包已导出最近 turn 与 item 时间线。".to_string(), + }, + RuntimeEvidenceSignalCoverageEntry { + signal: "warnings", + status: "exported", + source: "thread.diagnostics", + detail: if diagnostics.is_some() { + "当前证据包已导出 warning / failed tool / failed command 摘要。".to_string() + } else { + "当前线程没有 diagnostics,但 warning 通道已保留在导出结构中。".to_string() + }, + }, + request_telemetry_entry, + ]; + + if verification.artifact_validator.applicable { + coverage.push(RuntimeEvidenceSignalCoverageEntry { + signal: "artifactValidator", + status: if verification.artifact_validator.records.is_empty() { + "known_gap" + } else { + "exported" + }, + source: "artifact_document_validator", + detail: if verification.artifact_validator.records.is_empty() { + format!( + "当前检测到 {} 个 ArtifactDocument 产物,但 validator outcome 尚未回挂到当前 evidence pack。", + recent_artifacts + .iter() + .filter(|artifact| is_artifact_validator_applicable(artifact)) + .count() + ) + } else { + format!( + "当前证据包已为 {} 个 ArtifactDocument 产物导出 validator outcome。", + verification.artifact_validator.records.len() + ) + }, + }); + } + + if !verification.browser_evidence.is_empty() { + coverage.push(RuntimeEvidenceSignalCoverageEntry { + signal: "browserVerification", + status: "exported", + source: "browser runtime", + detail: format!( + "当前证据包已导出 {} 条浏览器验证线索。", + verification.browser_evidence.len() + ), + }); + } + + if verification.gui_smoke.is_some() { + coverage.push(RuntimeEvidenceSignalCoverageEntry { + signal: "guiSmoke", + status: "exported", + source: "verify:gui-smoke", + detail: "当前证据包已导出 GUI smoke 运行结果。".to_string(), + }); + } + + coverage +} + +fn build_verification_json( + verification: &RuntimeEvidenceVerificationSummary, +) -> Option> { + let mut payload = Map::new(); + + if verification.artifact_validator.applicable { + payload.insert( + "artifactValidatorIssues".to_string(), + Value::Array(verification.artifact_validator.records.clone()), + ); + } + + if !verification.browser_evidence.is_empty() { + payload.insert( + "browserEvidence".to_string(), + Value::Array(verification.browser_evidence.clone()), + ); + } + + if let Some(gui_smoke) = verification.gui_smoke.clone() { + payload.insert("guiSmoke".to_string(), gui_smoke); + } + + (!payload.is_empty()).then_some(payload) +} + +fn build_observability_verification_summary_json( + verification: &RuntimeEvidenceVerificationSummary, +) -> Option { + let mut payload = Map::new(); + + if verification.artifact_validator.applicable { + let issue_count = verification + .artifact_validator + .records + .iter() + .map(|record| { + record + .get("issues") + .and_then(Value::as_array) + .map(|issues| issues.len()) + .unwrap_or(0) + }) + .sum::(); + let repaired_count = verification + .artifact_validator + .records + .iter() + .filter(|record| { + record + .get("repaired") + .and_then(Value::as_bool) + .unwrap_or(false) + }) + .count(); + let fallback_used_count = verification + .artifact_validator + .records + .iter() + .filter(|record| { + record + .get("fallbackUsed") + .and_then(Value::as_bool) + .unwrap_or(false) + }) + .count(); + + payload.insert( + "artifactValidator".to_string(), + json!({ + "applicable": true, + "recordCount": verification.artifact_validator.records.len(), + "issueCount": issue_count, + "repairedCount": repaired_count, + "fallbackUsedCount": fallback_used_count + }), + ); + } + + if !verification.browser_evidence.is_empty() { + let mut success_count = 0usize; + let mut failure_count = 0usize; + let mut unknown_count = 0usize; + let mut latest_updated_at: Option = None; + + for record in &verification.browser_evidence { + if let Some(updated_at) = record.get("updatedAt").and_then(Value::as_str) { + if latest_updated_at + .as_ref() + .map(|current| updated_at > current.as_str()) + .unwrap_or(true) + { + latest_updated_at = Some(updated_at.to_string()); + } + } + + match browser_evidence_record_outcome(record) { + Some(true) => success_count += 1, + Some(false) => failure_count += 1, + None => unknown_count += 1, + } + } + + payload.insert( + "browserVerification".to_string(), + json!({ + "recordCount": verification.browser_evidence.len(), + "successCount": success_count, + "failureCount": failure_count, + "unknownCount": unknown_count, + "latestUpdatedAt": latest_updated_at + }), + ); + } + + if let Some(gui_smoke) = verification.gui_smoke.as_ref() { + let exit_code = gui_smoke.get("exitCode").and_then(Value::as_i64); + let has_error = gui_smoke + .get("error") + .and_then(Value::as_str) + .map(|value| !value.trim().is_empty()) + .unwrap_or(false); + let passed = exit_code == Some(0) && !has_error; + + payload.insert( + "guiSmoke".to_string(), + json!({ + "status": gui_smoke.get("status").cloned().unwrap_or(Value::Null), + "exitCode": exit_code, + "passed": passed, + "updatedAt": gui_smoke.get("updatedAt").cloned().unwrap_or(Value::Null), + "hasOutputPreview": gui_smoke.get("outputPreview").is_some() + }), + ); + } + + (!payload.is_empty()).then(|| Value::Object(payload)) +} + +fn browser_evidence_record_outcome(record: &Value) -> Option { + if let Some(success) = record.get("success").and_then(Value::as_bool) { + return Some(success); + } + + if let Some(exit_code) = record.get("exitCode").and_then(Value::as_i64) { + let has_error = record + .get("error") + .and_then(Value::as_str) + .map(|value| !value.trim().is_empty()) + .unwrap_or(false); + return Some(exit_code == 0 && !has_error); + } + + None +} + +fn is_artifact_validator_applicable(artifact: &RuntimeRecentArtifact) -> bool { + artifact.path.ends_with(".artifact.json") + || artifact + .metadata + .as_ref() + .map(value_looks_like_artifact_document) + .unwrap_or(false) +} + +fn value_looks_like_artifact_document(value: &Value) -> bool { + let Some(record) = value.as_object() else { + return false; + }; + + record + .get("schemaVersion") + .and_then(Value::as_str) + .map(str::trim) + == Some(ARTIFACT_DOCUMENT_SCHEMA_VERSION) + || record + .get("artifactSchema") + .and_then(Value::as_str) + .map(str::trim) + == Some(ARTIFACT_DOCUMENT_SCHEMA_VERSION) + || record.get("artifactDocument").is_some() + || record + .get("metadata") + .and_then(Value::as_object) + .and_then(|metadata| metadata.get("artifactSchema")) + .and_then(Value::as_str) + .map(str::trim) + == Some(ARTIFACT_DOCUMENT_SCHEMA_VERSION) +} + +fn extract_artifact_validator_record(candidate: Value, path: &str) -> Option { + let metadata = locate_artifact_validation_metadata(&candidate)?; + let issues = metadata + .get("artifactValidationIssues") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .map(ToString::to_string) + .collect::>() + }) + .unwrap_or_default(); + let repaired = metadata + .get("artifactValidationRepaired") + .and_then(Value::as_bool); + let fallback_used = metadata + .get("artifactFallbackUsed") + .and_then(Value::as_bool); + + if issues.is_empty() && repaired.is_none() && fallback_used.is_none() { + return None; + } + + Some(json!({ + "path": path, + "issues": issues, + "repaired": repaired, + "fallbackUsed": fallback_used + })) +} + +fn locate_artifact_validation_metadata(candidate: &Value) -> Option<&Map> { + let record = candidate.as_object()?; + if has_artifact_validation_fields(record) { + return Some(record); + } + + if let Some(metadata) = record.get("metadata").and_then(Value::as_object) { + if has_artifact_validation_fields(metadata) { + return Some(metadata); + } + } + + if let Some(document_metadata) = record + .get("artifactDocument") + .and_then(Value::as_object) + .and_then(|document| document.get("metadata")) + .and_then(Value::as_object) + { + if has_artifact_validation_fields(document_metadata) { + return Some(document_metadata); + } + } + + None +} + +fn has_artifact_validation_fields(record: &Map) -> bool { + record.contains_key("artifactValidationIssues") + || record.contains_key("artifactValidationRepaired") + || record.contains_key("artifactFallbackUsed") +} + +fn resolve_workspace_path(workspace_root: &Path, path: &str) -> PathBuf { + let candidate = PathBuf::from(path); + if candidate.is_absolute() { + candidate + } else { + workspace_root.join(path.replace('/', std::path::MAIN_SEPARATOR_STR)) + } +} + +fn is_browser_tool_name(tool_name: &str) -> bool { + let normalized = tool_name.trim().to_ascii_lowercase(); + normalized.contains("browser") + || normalized.contains("playwright") + || normalized.contains("chrome_devtools") + || normalized.contains("cdp") +} + +fn is_browser_command(command: &str) -> bool { + let normalized = command.trim().to_ascii_lowercase(); + (normalized.contains("browser") || normalized.contains("playwright")) + && !is_gui_smoke_command(command) +} + +fn is_gui_smoke_command(command: &str) -> bool { + let normalized = command.trim().to_ascii_lowercase(); + normalized.contains("verify:gui-smoke") || normalized.contains("verify-gui-smoke") } fn latest_warning_json( @@ -925,11 +1657,65 @@ mod tests { } } + fn write_request_telemetry_fixture(root: &Path) { + let request_logs_dir = root.join("request_logs"); + fs::create_dir_all(&request_logs_dir).expect("create request logs dir"); + + let mut log = RequestLog::new( + "req-log-1".to_string(), + lime_core::ProviderType::OpenAI, + "gpt-5.4".to_string(), + false, + ); + log.session_id = Some("session-1".to_string()); + log.thread_id = Some("thread-1".to_string()); + log.turn_id = Some("turn-1".to_string()); + log.pending_request_id = Some("req-1".to_string()); + log.queued_turn_id = Some("queued-1".to_string()); + log.mark_success(420, 200); + log.set_tokens(Some(128), Some(64)); + + fs::write( + request_logs_dir.join("requests_2026-03-27.jsonl"), + format!( + "{}\n", + serde_json::to_string(&log).expect("serialize request log") + ), + ) + .expect("write request log"); + } + + fn write_unmatched_request_telemetry_fixture(root: &Path) { + let request_logs_dir = root.join("request_logs"); + fs::create_dir_all(&request_logs_dir).expect("create request logs dir"); + + let mut log = RequestLog::new( + "req-log-unmatched".to_string(), + lime_core::ProviderType::Anthropic, + "claude-sonnet-4.5".to_string(), + false, + ); + log.session_id = Some("other-session".to_string()); + log.thread_id = Some("other-thread".to_string()); + log.turn_id = Some("other-turn".to_string()); + log.mark_success(180, 200); + + fs::write( + request_logs_dir.join("requests_2026-03-28.jsonl"), + format!( + "{}\n", + serde_json::to_string(&log).expect("serialize unmatched request log") + ), + ) + .expect("write unmatched request log"); + } + #[test] fn should_export_runtime_evidence_pack_to_workspace() { let temp_dir = TempDir::new().expect("temp dir"); let detail = build_detail(); let thread_read = build_thread_read(); + write_request_telemetry_fixture(temp_dir.path()); let result = export_runtime_evidence_pack(&detail, &thread_read, temp_dir.path()).expect("export"); @@ -944,7 +1730,7 @@ mod tests { assert_eq!(result.pending_request_count, 1); assert_eq!(result.queued_turn_count, 1); assert_eq!(result.recent_artifact_count, 1); - assert!(!result.known_gaps.is_empty()); + assert!(result.known_gaps.is_empty()); let summary_path = temp_dir .path() @@ -969,13 +1755,20 @@ mod tests { assert!(summary.contains("等待用户确认是否导出问题证据包")); assert!(summary.contains("证据关联与可观测覆盖")); assert!(summary.contains("requestTelemetry")); + assert!(!summary.contains("artifactValidator")); + assert!(!summary.contains("browserVerification")); + assert!(!summary.contains("guiSmoke")); let runtime = fs::read_to_string(runtime_path).expect("runtime"); assert!(runtime.contains("\"sessionId\": \"session-1\"")); assert!(runtime.contains("\"pendingRequestCount\": 1")); assert!(runtime.contains("\"observabilitySummary\"")); assert!(runtime.contains("\"requestTelemetry\"")); - assert!(runtime.contains("\"artifactValidator\"")); + assert!(runtime.contains("\"matchedRequestCount\": 1")); + assert!(!runtime.contains("\"verificationSummary\"")); + assert!(!runtime.contains("\"artifactValidator\"")); + assert!(!runtime.contains("\"browserVerification\"")); + assert!(!runtime.contains("\"guiSmoke\"")); let timeline = fs::read_to_string(timeline_path).expect("timeline"); assert!(timeline.contains("\"payloadKind\": \"plan\"")); @@ -983,6 +1776,163 @@ mod tests { let artifacts = fs::read_to_string(artifacts_path).expect("artifacts"); assert!(artifacts.contains("\"observabilitySummary\"")); + assert!(artifacts.contains("\"telemetry\"")); + assert!(artifacts.contains("\"matchedRequestCount\": 1")); + assert!(!artifacts.contains("\"verification\"")); + } + + #[test] + fn should_export_runtime_verification_when_signal_is_applicable() { + let temp_dir = TempDir::new().expect("temp dir"); + let mut detail = build_detail(); + let thread_read = build_thread_read(); + write_request_telemetry_fixture(temp_dir.path()); + let artifact_relative_path = ".lime/artifacts/thread-1/report.artifact.json"; + let artifact_absolute_path = temp_dir + .path() + .join(artifact_relative_path.replace('/', std::path::MAIN_SEPARATOR_STR)); + + fs::create_dir_all( + artifact_absolute_path + .parent() + .expect("artifact path should have parent"), + ) + .expect("create artifact dir"); + fs::write( + &artifact_absolute_path, + serde_json::to_string_pretty(&json!({ + "schemaVersion": ARTIFACT_DOCUMENT_SCHEMA_VERSION, + "title": "Harness Evidence", + "kind": "analysis", + "status": "ready", + "blocks": [ + { + "id": "block-1", + "type": "rich_text", + "content": "test" + } + ], + "metadata": { + "artifactValidationIssues": ["title 缺失或为空,已使用兜底标题。"], + "artifactValidationRepaired": true, + "artifactFallbackUsed": false + } + })) + .expect("serialize artifact document"), + ) + .expect("write artifact document"); + + if let AgentThreadItemPayload::FileArtifact { path, .. } = &mut detail.items[1].payload { + *path = artifact_relative_path.to_string(); + } + + detail.items.push(AgentThreadItem { + id: "browser-tool-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 4, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:00:40Z".to_string(), + completed_at: Some("2026-03-27T10:00:40Z".to_string()), + updated_at: "2026-03-27T10:00:40Z".to_string(), + payload: AgentThreadItemPayload::ToolCall { + tool_name: "browser_snapshot".to_string(), + arguments: None, + output: None, + success: Some(true), + error: None, + metadata: None, + }, + }); + detail.items.push(AgentThreadItem { + id: "gui-smoke-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 5, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:00:50Z".to_string(), + completed_at: Some("2026-03-27T10:00:50Z".to_string()), + updated_at: "2026-03-27T10:00:50Z".to_string(), + payload: AgentThreadItemPayload::CommandExecution { + command: "npm run verify:gui-smoke".to_string(), + cwd: temp_dir.path().to_string_lossy().to_string(), + aggregated_output: Some("GUI smoke finished successfully".to_string()), + exit_code: Some(0), + error: None, + }, + }); + + let result = + export_runtime_evidence_pack(&detail, &thread_read, temp_dir.path()).expect("export"); + + assert!(result + .known_gaps + .iter() + .all(|gap| !gap.contains("ArtifactDocument"))); + + let runtime_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/evidence/runtime.json"); + let artifacts_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/evidence/artifacts.json"); + + let runtime = fs::read_to_string(runtime_path).expect("runtime"); + assert!(runtime.contains("\"artifactValidator\"")); + assert!(runtime.contains("\"browserVerification\"")); + assert!(runtime.contains("\"guiSmoke\"")); + assert!(runtime.contains("\"status\": \"exported\"")); + assert!(runtime.contains("\"verificationSummary\"")); + assert!(runtime.contains("\"recordCount\": 1")); + assert!(runtime.contains("\"issueCount\": 1")); + assert!(runtime.contains("\"repairedCount\": 1")); + assert!(runtime.contains("\"successCount\": 1")); + assert!(runtime.contains("\"passed\": true")); + + let artifacts = fs::read_to_string(artifacts_path).expect("artifacts"); + assert!(artifacts.contains("\"verification\"")); + assert!(artifacts.contains("\"artifactValidatorIssues\"")); + assert!(artifacts.contains("\"browserEvidence\"")); assert!(artifacts.contains("\"guiSmoke\"")); + assert!(artifacts.contains("title 缺失或为空")); + } + + #[test] + fn should_export_empty_request_telemetry_summary_when_no_request_matches_current_thread() { + let temp_dir = TempDir::new().expect("temp dir"); + let detail = build_detail(); + let thread_read = build_thread_read(); + write_unmatched_request_telemetry_fixture(temp_dir.path()); + + let result = + export_runtime_evidence_pack(&detail, &thread_read, temp_dir.path()).expect("export"); + + assert!(result + .known_gaps + .iter() + .all(|gap| !gap.contains("request telemetry"))); + + let runtime_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/evidence/runtime.json"); + let artifacts_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/evidence/artifacts.json"); + + let runtime = fs::read_to_string(runtime_path).expect("runtime"); + assert!(runtime.contains("\"requestTelemetry\"")); + assert!(runtime.contains("\"searchedRoots\": [")); + assert!(runtime.contains("\"matchedRequestCount\": 0")); + assert!(runtime.contains("\"providers\": []")); + assert!(runtime.contains("\"models\": []")); + assert!(runtime.contains("\"requests\": []")); + assert!(runtime.contains("\"signal\": \"requestTelemetry\"")); + assert!(runtime.contains("\"status\": \"exported\"")); + assert!(runtime.contains("当前会话未匹配到 provider request 记录")); + assert!(!runtime.contains("\"verificationSummary\"")); + + let artifacts = fs::read_to_string(artifacts_path).expect("artifacts"); + assert!(artifacts.contains("\"telemetry\"")); + assert!(artifacts.contains("\"matchedRequestCount\": 0")); } } diff --git a/src-tauri/src/services/runtime_replay_case_service.rs b/src-tauri/src/services/runtime_replay_case_service.rs index dad7021c1..bdd90f91a 100644 --- a/src-tauri/src/services/runtime_replay_case_service.rs +++ b/src-tauri/src/services/runtime_replay_case_service.rs @@ -7,8 +7,7 @@ use crate::agent::SessionDetail; use crate::commands::aster_agent_cmd::AgentRuntimeThreadReadModel; use crate::services::runtime_evidence_pack_service::{ - build_runtime_observability_summary_json, export_runtime_evidence_pack, - RuntimeEvidencePackExportResult, + export_runtime_evidence_pack, RuntimeEvidencePackExportResult, }; use crate::services::runtime_handoff_artifact_service::{ export_runtime_handoff_bundle, RuntimeHandoffBundleExportResult, @@ -145,12 +144,7 @@ pub fn export_runtime_replay_case( let recent_artifacts = collect_recent_artifact_paths(detail); let pending_requests = collect_pending_request_inputs(detail, thread_read); let recent_timeline = collect_recent_timeline_items(detail); - let observability_summary = build_runtime_observability_summary_json( - detail, - thread_read, - &recent_artifacts, - &evidence_pack.known_gaps, - ); + let observability_summary = read_observability_summary_from_evidence_pack(&evidence_pack)?; let success_criteria = build_success_criteria( detail, thread_read, @@ -283,6 +277,29 @@ fn write_replay_file( }) } +fn read_observability_summary_from_evidence_pack( + evidence_pack: &RuntimeEvidencePackExportResult, +) -> Result { + let runtime_path = Path::new(&evidence_pack.pack_absolute_root).join("runtime.json"); + let raw = fs::read_to_string(&runtime_path).map_err(|error| { + format!( + "读取 evidence pack runtime.json 失败 {}: {error}", + runtime_path.display() + ) + })?; + let payload = serde_json::from_str::(raw.as_str()).map_err(|error| { + format!( + "解析 evidence pack runtime.json 失败 {}: {error}", + runtime_path.display() + ) + })?; + + Ok(payload + .pointer("/observabilitySummary") + .cloned() + .unwrap_or(Value::Null)) +} + #[allow(clippy::too_many_arguments)] fn build_input_json( detail: &SessionDetail, @@ -1261,6 +1278,7 @@ mod tests { assert!(input.contains("\"pending_request\"")); assert!(input.contains("\"observability\"")); assert!(input.contains("\"requestTelemetry\"")); + assert!(!input.contains("\"artifactValidator\"")); let expected = fs::read_to_string(expected_path).expect("expected"); assert!(expected.contains("不要要求与原始会话完全相同的工具调用顺序")); @@ -1274,6 +1292,6 @@ mod tests { assert!(links.contains("\"handoffBundle\"")); assert!(links.contains("\"evidencePack\"")); assert!(links.contains("\"observabilitySummary\"")); - assert!(links.contains("\"artifactValidator\"")); + assert!(!links.contains("\"artifactValidator\"")); } } diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index 56d19bf4d..7d237beda 100644 --- a/src-tauri/tauri.conf.headless.json +++ b/src-tauri/tauri.conf.headless.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.8.0", + "version": "1.9.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev:web-bridge", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 05bd6b850..e00d64472 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.8.0", + "version": "1.9.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src-tauri/tests/deepseek_reasoner_output_schema_runtime.rs b/src-tauri/tests/deepseek_reasoner_output_schema_runtime.rs index ae337d72b..f120fbcde 100644 --- a/src-tauri/tests/deepseek_reasoner_output_schema_runtime.rs +++ b/src-tauri/tests/deepseek_reasoner_output_schema_runtime.rs @@ -76,6 +76,7 @@ impl SessionStore for TestSessionStore { total_tokens: None, input_tokens: None, output_tokens: None, + cached_input_tokens: None, accumulated_total_tokens: None, accumulated_input_tokens: None, accumulated_output_tokens: None, diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index 05b8b0000..f9bd44231 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -17,12 +17,14 @@ import { import type { Dispatch, SetStateAction } from "react"; import { toast } from "sonner"; import { useAgentChatUnified } from "./hooks"; -import { type TaskStatusReason } from "./hooks/agentChatShared"; +import { + buildLiveTaskSnapshot, + type TaskStatusReason, +} from "./hooks/agentChatShared"; import { settleLiveArtifactAfterStreamStops, useArtifactDisplayState, } from "./hooks/useArtifactDisplayState"; -import { useCompatSubagentRuntime } from "./hooks/useCompatSubagentRuntime"; import type { TopicBranchStatus } from "./hooks/useTopicBranchBoard"; import { useSessionFiles } from "./hooks/useSessionFiles"; import { useContentSync } from "./hooks/useContentSync"; @@ -84,6 +86,7 @@ import { import type { Message, + MessageImage, MessagePreviewTarget, SiteSavedContentTarget, WriteArtifactContext, @@ -100,6 +103,7 @@ import { import { isHiddenInternalArtifactPath } from "./utils/internalArtifactVisibility"; import { buildHarnessRequestMetadata } from "./utils/harnessRequestMetadata"; import { deriveHarnessSessionState } from "./utils/harnessState"; +import { resolveWorkflowLayoutBottomSpacing } from "./utils/workflowLayout"; import { alignChatToolPreferencesWithExecutionStrategy, loadChatToolPreferences, @@ -110,10 +114,7 @@ import { createSessionRecentPreferencesFromChatToolPreferences, createSessionRecentTeamSelectionFromTeamDefinition, } from "./utils/sessionExecutionRuntime"; -import { - buildRealSubagentTimelineItems, - buildSyntheticSubagentTimelineItems, -} from "./utils/subagentTimeline"; +import { buildRealSubagentTimelineItems } from "./utils/subagentTimeline"; import { buildGeneralAgentSystemPrompt, resolveAgentChatMode, @@ -148,14 +149,11 @@ import { useWorkspaceHarnessInventoryRuntime } from "./workspace/useWorkspaceHar import { useWorkspaceCanvasWorkflowActions } from "./workspace/useWorkspaceCanvasWorkflowActions"; import { useWorkspaceCanvasSceneRuntime } from "./workspace/useWorkspaceCanvasSceneRuntime"; import { useWorkspaceCanvasMessageSyncRuntime } from "./workspace/useWorkspaceCanvasMessageSyncRuntime"; -import { useWorkspaceConversationShellSceneRuntime } from "./workspace/useWorkspaceConversationShellSceneRuntime"; -import { useWorkspaceDisplayMessagesRuntime } from "./workspace/useWorkspaceDisplayMessagesRuntime"; +import { useWorkspaceConversationSceneRuntime } from "./workspace/useWorkspaceConversationSceneRuntime"; import { useWorkspaceInputbarSceneRuntime } from "./workspace/useWorkspaceInputbarSceneRuntime"; import { useWorkspaceNavigationActions } from "./workspace/useWorkspaceNavigationActions"; -import { useWorkspaceShellChromeRuntime } from "./workspace/useWorkspaceShellChromeRuntime"; import { useWorkspaceWriteFileAction } from "./workspace/useWorkspaceWriteFileAction"; import { useWorkspaceArtifactPreviewActions } from "./workspace/useWorkspaceArtifactPreviewActions"; -import { useWorkspaceWorkflowProgressSync } from "./workspace/useWorkspaceWorkflowProgressSync"; import { useWorkspaceCanvasLayoutRuntime } from "./workspace/useWorkspaceCanvasLayoutRuntime"; import { useWorkspaceCanvasTaskFileSync } from "./workspace/useWorkspaceCanvasTaskFileSync"; import { useWorkspaceGeneralResourceSync } from "./workspace/useWorkspaceGeneralResourceSync"; @@ -169,26 +167,28 @@ import { buildImageSkillLaunchRequestMetadata } from "./workspace/imageSkillLaun import { useWorkspaceImageTaskPreviewRuntime } from "./workspace/useWorkspaceImageTaskPreviewRuntime"; import { useWorkspaceVideoTaskPreviewRuntime } from "./workspace/useWorkspaceVideoTaskPreviewRuntime"; import { useWorkspaceVideoTaskActionRuntime } from "./workspace/useWorkspaceVideoTaskActionRuntime"; -import { useWorkspaceRuntimeTeamDispatchPreviewRuntime } from "./workspace/useWorkspaceRuntimeTeamDispatchPreviewRuntime"; import { useWorkspaceSessionRestore } from "./workspace/useWorkspaceSessionRestore"; import { useWorkspaceResetRuntime } from "./workspace/useWorkspaceResetRuntime"; import { useWorkspaceSendActions } from "./workspace/useWorkspaceSendActions"; import { useWorkspaceTeamSessionControlRuntime } from "./workspace/useWorkspaceTeamSessionControlRuntime"; -import { useWorkspaceTeamWorkbenchAutoOpenRuntime } from "./workspace/useWorkspaceTeamWorkbenchAutoOpenRuntime"; import { useWorkspaceGeneralWorkbenchScaffoldRuntime } from "./workspace/useWorkspaceGeneralWorkbenchScaffoldRuntime"; -import { useWorkspaceGeneralWorkbenchVersionStatusRuntime } from "./workspace/useWorkspaceGeneralWorkbenchVersionStatusRuntime"; import { useWorkspaceTopicSwitch } from "./workspace/useWorkspaceTopicSwitch"; import { useWorkspaceA2UIRuntime } from "./workspace/useWorkspaceA2UIRuntime"; import { useWorkspaceSceneGateRuntime } from "./workspace/useWorkspaceSceneGateRuntime"; import { useWorkspaceAutoGuideRuntime } from "./workspace/useWorkspaceAutoGuideRuntime"; import { useWorkspaceGeneralWorkbenchSidebarRuntime } from "./workspace/useWorkspaceGeneralWorkbenchSidebarRuntime"; import { useWorkspaceGeneralWorkbenchRuntime } from "./workspace/useWorkspaceGeneralWorkbenchRuntime"; -import { useWorkspaceGeneralWorkbenchShellRuntime } from "./workspace/useWorkspaceGeneralWorkbenchShellRuntime"; -import { useWorkspaceContextDetailActions } from "./workspace/useWorkspaceContextDetailActions"; import { useWorkspaceTeamSessionRuntime } from "./workspace/useWorkspaceTeamSessionRuntime"; import { useWorkspaceGeneralWorkbenchDocumentPersistenceRuntime } from "./workspace/useWorkspaceGeneralWorkbenchDocumentPersistenceRuntime"; import { useWorkspaceServiceSkillEntryActions } from "./workspace/useWorkspaceServiceSkillEntryActions"; import { useWorkspaceArtifactViewModeControl } from "./workspace/useWorkspaceArtifactViewModeControl"; +import { WorkspaceGeneralWorkbenchSidebar } from "./workspace/WorkspaceGeneralWorkbenchSidebar"; +import { GeneralWorkbenchHarnessDialogSection } from "./workspace/WorkspaceHarnessDialogs"; +import { WorkspaceShellScene } from "./workspace/WorkspaceShellScene"; +import { + TEAM_PRIMARY_CHAT_PANEL_MIN_WIDTH, + TEAM_PRIMARY_CHAT_PANEL_WIDTH, +} from "./workspace/WorkspaceStyles"; import { hasNamedGeneralCanvasFilePreview } from "./workspace/generalCanvasPreviewState"; import { resolvePreferredServiceSkillResultFileTarget } from "./workspace/serviceSkillResultFileTarget"; import { @@ -1605,32 +1605,17 @@ export function AgentChatWorkspace({ syncSessionRecentPreferences, ]); - const hasRealTeamGraph = - childSubagentSessions.length > 0 || Boolean(subagentParentContext); const { clearRuntimeTeamState: clearPreparedRuntimeTeamState, prepareRuntimeTeamBeforeSend, } = useRuntimeTeamFormation({ projectId, - sessionId, selectedTeam, subagentEnabled: effectiveChatToolPreferences.subagent, - hasRealTeamGraph, }); - const { - runtimeTeamDispatchPreview, - runtimeTeamPreviewState, - clearRuntimeTeamDispatchPreview, - setRuntimeTeamDispatchPreview, - } = useWorkspaceRuntimeTeamDispatchPreviewRuntime({ - messagesLength: messages.length, - sessionId, - }); - const teamDispatchPreviewState = runtimeTeamPreviewState; const clearRuntimeTeamState = useCallback(() => { clearPreparedRuntimeTeamState(); - clearRuntimeTeamDispatchPreview(); - }, [clearPreparedRuntimeTeamState, clearRuntimeTeamDispatchPreview]); + }, [clearPreparedRuntimeTeamState]); const localImageWorkbenchSessionKeyRef = useRef( `__local_image_workbench__:${Date.now().toString(36)}:${Math.random() .toString(36) @@ -1746,17 +1731,13 @@ export function AgentChatWorkspace({ liveRuntimeBySessionId: teamSessionRuntime.liveRuntimeBySessionId, stopSending, }); - const { - teamWorkbenchAutoFocusToken, - dismissActiveTeamWorkbenchAutoOpen, - handleActivateTeamWorkbench, - } = useWorkspaceTeamWorkbenchAutoOpenRuntime({ - hasRealTeamGraph: teamSessionRuntime.hasRealTeamGraph, - layoutMode, - runtimeTeamRequestId: runtimeTeamDispatchPreview?.key ?? null, - sessionId, - setLayoutMode, - }); + const [teamWorkbenchAutoFocusToken, setTeamWorkbenchAutoFocusToken] = + useState(0); + const dismissActiveTeamWorkbenchAutoOpen = useCallback(() => {}, []); + const handleActivateTeamWorkbench = useCallback(() => { + setTeamWorkbenchAutoFocusToken((current) => current + 1); + setLayoutMode((current) => (current === "chat" ? "chat-canvas" : current)); + }, [setLayoutMode]); useEffect(() => { logAgentDebug( "AgentChatPage", @@ -2021,7 +2002,6 @@ export function AgentChatWorkspace({ siteSkillExecutionState?.profileKey, siteSkillExecutionState?.targetId, ]); - const compatSubagentRuntime = useCompatSubagentRuntime(sessionId); const realSubagentTimelineItems = useMemo( () => buildRealSubagentTimelineItems({ @@ -2031,25 +2011,9 @@ export function AgentChatWorkspace({ }), [childSubagentSessions, sessionId, turns], ); - const syntheticSubagentItems = useMemo( - () => - buildSyntheticSubagentTimelineItems({ - threadId: sessionId, - turnId: currentTurnId, - events: compatSubagentRuntime.events, - }), - [compatSubagentRuntime.events, currentTurnId, sessionId], - ); const effectiveThreadItems = useMemo( - () => - mergeThreadItems( - threadItems, - realSubagentTimelineItems, - realSubagentTimelineItems.length > 0 - ? undefined - : syntheticSubagentItems, - ), - [realSubagentTimelineItems, syntheticSubagentItems, threadItems], + () => mergeThreadItems(threadItems, realSubagentTimelineItems), + [realSubagentTimelineItems, threadItems], ); const harnessState = useMemo( () => @@ -2148,7 +2112,6 @@ export function AgentChatWorkspace({ isSending, projectMemory, harnessState, - compatSubagentRuntime, }); const { contextWorkspace, @@ -2259,7 +2222,6 @@ export function AgentChatWorkspace({ pendingA2UIForm, pendingA2UISource, pendingActionRequest, - pendingLegacyQuestionnaireA2UIForm, pendingPromotedA2UIActionRequest, resolvePendingA2UISubmit, } = useWorkspaceA2UIRuntime({ @@ -2315,9 +2277,51 @@ export function AgentChatWorkspace({ historyPageSize: GENERAL_WORKBENCH_HISTORY_PAGE_SIZE, }); - const { handleViewContextDetail } = useWorkspaceContextDetailActions({ - contextWorkspace, - }); + const handleViewContextDetail = useCallback( + (contextId: string) => { + const detail = contextWorkspace.getContextDetail(contextId); + if (!detail) { + toast.error("无法找到上下文详情"); + return; + } + + const sourceLabel = + detail.source === "material" + ? "素材库" + : detail.source === "content" + ? "历史内容" + : "搜索结果"; + + toast.info( +
+
+ {detail.name} +
+
+ 来源: {sourceLabel} · 约 {detail.estimatedTokens} tokens +
+
+ {detail.bodyText || detail.previewText} +
+
, + { duration: 10000 }, + ); + }, + [contextWorkspace], + ); const harnessRequestMetadata = useMemo( () => @@ -2384,13 +2388,50 @@ export function AgentChatWorkspace({ harnessPendingCount, }); - useWorkspaceGeneralWorkbenchVersionStatusRuntime({ - isThemeWorkbench, - themeWorkbenchRunState, + useEffect(() => { + if (!isThemeWorkbench || themeWorkbenchRunState !== "idle") { + return; + } + if (!canvasState || canvasState.type !== "document") { + return; + } + + const latestTerminal = themeWorkbenchBackendRunState?.latest_terminal ?? null; + + setDocumentVersionStatusMap((previous) => { + if (latestTerminal) { + const terminalVersionId = latestTerminal.run_id; + const terminalVersionExists = canvasState.versions.some( + (version) => version.id === terminalVersionId, + ); + if (terminalVersionExists) { + const terminalStatus: TopicBranchStatus = + latestTerminal.status === "success" ? "merged" : "candidate"; + if (previous[terminalVersionId] !== terminalStatus) { + return { + ...previous, + [terminalVersionId]: terminalStatus, + }; + } + } + } + + const currentVersionId = canvasState.currentVersionId; + if (!currentVersionId || previous[currentVersionId] !== "in_progress") { + return previous; + } + return { + ...previous, + [currentVersionId]: "pending", + }; + }); + }, [ canvasState, - latestTerminal: themeWorkbenchBackendRunState?.latest_terminal ?? null, + isThemeWorkbench, setDocumentVersionStatusMap, - }); + themeWorkbenchBackendRunState?.latest_terminal, + themeWorkbenchRunState, + ]); // 会话文件持久化 hook const { @@ -2622,7 +2663,8 @@ export function AgentChatWorkspace({ handleSendRef, webSearchPreferenceRef, isPreparingSend, - submissionPreview, + displayMessages, + teamDispatchPreviewState, } = useWorkspaceSendActions({ input, setInput, @@ -2640,6 +2682,7 @@ export function AgentChatWorkspace({ prepareActiveContextPrompt: contextWorkspace.prepareActiveContextPrompt, }, projectId, + sessionId, executionStrategy, accessMode, preferredTeamPresetId, @@ -2654,13 +2697,13 @@ export function AgentChatWorkspace({ browserAssistPreferredBackend: browserAssistRequestPreferredBackend, browserAssistAutoLaunch: browserAssistRequestAutoLaunch, workspaceRequestMetadataBase: initialRequestMetadata, - messagesCount: messages.length, + messages, + bootstrapDispatchPreviewMessages, sendMessage, resolveSendBoundary, finalizeAfterSendSuccess, rollbackAfterSendFailure, prepareRuntimeTeamBeforeSend, - setRuntimeTeamDispatchPreview, ensureBrowserAssistCanvas, handleAutoLaunchMatchedSiteSkill: workspaceServiceSkillEntryActions.handleAutoLaunchMatchedSiteSkill, @@ -2761,7 +2804,6 @@ export function AgentChatWorkspace({ }); const { handleInputbarA2UISubmit } = useWorkspaceA2UISubmitActions({ handlePermissionResponse, - pendingLegacyQuestionnaireA2UIForm, pendingPromotedA2UIActionRequest, resolvePendingA2UISubmit, sendMessage, @@ -2812,19 +2854,31 @@ export function AgentChatWorkspace({ console.log("[AgentChatPage] 执行技能命令:", command); handleSend([], false, false, command); }, [pendingSkillKey, isThemeWorkbench, consumePendingSkill, handleSend]); + useEffect(() => { + if (!sessionId) { + return; + } - const { displayMessages } = useWorkspaceDisplayMessagesRuntime({ - bootstrapDispatchPreviewMessages, + updateTopicSnapshot( + sessionId, + buildLiveTaskSnapshot({ + messages: displayMessages, + isSending, + pendingActionCount: pendingActions.length, + queuedTurnCount: queuedTurns.length, + workspaceError: Boolean(workspacePathMissing || workspaceHealthError), + }), + ); + }, [ + displayMessages, isSending, - messages, - pendingActionCount: pendingActions.length, - queuedTurnCount: queuedTurns.length, - runtimeTeamDispatchPreview, - submissionPreview, + pendingActions.length, + queuedTurns.length, sessionId, updateTopicSnapshot, - workspaceError: Boolean(workspacePathMissing || workspaceHealthError), - }); + workspaceHealthError, + workspacePathMissing, + ]); const latestAssistantMessageId = useMemo( () => [...displayMessages] @@ -2890,7 +2944,7 @@ export function AgentChatWorkspace({ shouldBootstrapCanvasOnEntry, canvasState, generalCanvasState, - showTeamWorkspaceBoard: teamSessionRuntime.showTeamWorkspaceBoard, + teamWorkspaceEnabled: teamSessionRuntime.teamWorkspaceEnabled, hasCurrentCanvasArtifact: Boolean(currentCanvasArtifact), currentCanvasArtifactType: currentCanvasArtifact?.type, hasBrowserAssistArtifact, @@ -3459,61 +3513,229 @@ export function AgentChatWorkspace({ setChatMessages, }); - const shellChromeRuntime = useWorkspaceShellChromeRuntime({ + const shellChromeRuntime = useMemo(() => { + const hasUnconsumedInitialDispatch = + !shouldUseCompactGeneralWorkbench && isBootstrapDispatchPending; + + const showChatLayout = + agentEntry === "claw" || + hasDisplayMessages || + hasPendingA2UIForm || + isThemeWorkbench || + hasUnconsumedInitialDispatch || + isSending || + queuedTurns.length > 0; + + const shouldHideGeneralWorkbenchInputForTheme = + shouldUseCompactGeneralWorkbench; + const shouldShowGeneralWorkbenchFloatingInputOverlay = + isThemeWorkbench && + showChatLayout && + !shouldHideGeneralWorkbenchInputForTheme; + const isWorkspaceCompactChrome = topBarChrome === "workspace-compact"; + const shouldRenderBrandedEmptyState = !showChatLayout; + const shouldRenderTopBar = !hideTopBar && !shouldRenderBrandedEmptyState; + const shouldRenderInlineA2UI = isSpecializedThemeMode; + + const shouldUseTeamPrimaryChatPanelWidth = + layoutMode === "chat-canvas" && + teamSessionRuntime.teamWorkspaceEnabled && + (teamSessionRuntime.hasRuntimeSessions || Boolean(teamDispatchPreviewState)); + + return { + showChatLayout, + isWorkspaceCompactChrome, + workflowLayoutBottomSpacing: resolveWorkflowLayoutBottomSpacing({ + contextWorkspaceEnabled: contextWorkspace.generalWorkbenchEnabled, + showFloatingInputOverlay: + shouldShowGeneralWorkbenchFloatingInputOverlay, + hasCanvasContent: layoutMode !== "chat", + workflowRunState: themeWorkbenchRunState, + gateStatus: currentGate.status, + }), + shouldHideGeneralWorkbenchInputForTheme, + shouldRenderTopBar, + layoutTransitionChatPanelWidth: shouldUseTeamPrimaryChatPanelWidth + ? TEAM_PRIMARY_CHAT_PANEL_WIDTH + : undefined, + layoutTransitionChatPanelMinWidth: shouldUseTeamPrimaryChatPanelWidth + ? TEAM_PRIMARY_CHAT_PANEL_MIN_WIDTH + : undefined, + shouldShowGeneralWorkbenchFloatingInputOverlay, + shouldRenderInlineA2UI, + }; + }, [ agentEntry, - contextWorkspaceEnabled: contextWorkspace.generalWorkbenchEnabled, + contextWorkspace.generalWorkbenchEnabled, + currentGate.status, hasDisplayMessages, hasPendingA2UIForm, hideTopBar, isBootstrapDispatchPending, - isSpecializedThemeMode, isSending, + isSpecializedThemeMode, isThemeWorkbench, layoutMode, - queuedTurnCount: queuedTurns.length, + queuedTurns.length, shouldUseCompactGeneralWorkbench, - showTeamWorkspaceBoard: teamSessionRuntime.showTeamWorkspaceBoard, - topBarChrome, - themeWorkbenchRunState, - currentGateStatus: currentGate.status, - hasRealTeamGraph: teamSessionRuntime.hasRealTeamGraph, teamDispatchPreviewState, - }); - const generalWorkbenchShellRuntime = useWorkspaceGeneralWorkbenchShellRuntime( - { - showChatPanel: effectiveShowChatPanel, - showSidebar, - hasPendingA2UIForm, - contextHarnessRuntime, - generalWorkbenchScaffoldRuntime, - generalWorkbenchSidebarRuntime, - harnessInventoryRuntime, - handleCreateVersionSnapshot, - handleSwitchBranchVersion, - handleSetBranchStatus, - handleAddImage, - handleImportDocument, - handleViewContextDetail, - messages, - harnessState, - compatSubagentRuntime, - childSubagentSessions, - selectedTeamLabel, - selectedTeamSummary, - selectedTeamRoles: selectedTeam?.roles, - teamMemorySnapshot: resolvedTeamMemoryShadowSnapshot, - handleOpenSubagentSession, - handleHarnessLoadFilePreview, - handleFileClick: handleWorkspaceFileClick, - }, + teamSessionRuntime.hasRuntimeSessions, + teamSessionRuntime.teamWorkspaceEnabled, + themeWorkbenchRunState, + topBarChrome, + ]); + const shouldShowGeneralWorkbenchSidebarForTheme = + !generalWorkbenchScaffoldRuntime.shouldUseCompactGeneralWorkbench; + const showGeneralWorkbenchSidebar = + effectiveShowChatPanel && + showSidebar && + !hasPendingA2UIForm && + isThemeWorkbench && + shouldShowGeneralWorkbenchSidebarForTheme && + (!generalWorkbenchScaffoldRuntime.enableGeneralWorkbenchPanelCollapse || + !generalWorkbenchScaffoldRuntime.generalWorkbenchSidebarCollapsed); + const showGeneralWorkbenchLeftExpandButton = + effectiveShowChatPanel && + showSidebar && + !hasPendingA2UIForm && + isThemeWorkbench && + shouldShowGeneralWorkbenchSidebarForTheme && + generalWorkbenchScaffoldRuntime.enableGeneralWorkbenchPanelCollapse && + generalWorkbenchScaffoldRuntime.generalWorkbenchSidebarCollapsed; + const handleDeleteGeneralWorkbenchVersion = useCallback( + () => undefined, + [], + ); + const handleCollapseGeneralWorkbenchSidebar = useCallback(() => { + generalWorkbenchScaffoldRuntime.setGeneralWorkbenchSidebarCollapsed(true); + }, [generalWorkbenchScaffoldRuntime]); + const handleExpandGeneralWorkbenchSidebar = useCallback(() => { + generalWorkbenchScaffoldRuntime.setGeneralWorkbenchSidebarCollapsed(false); + }, [generalWorkbenchScaffoldRuntime]); + const generalWorkbenchHarnessDialog = ( + + ); + const generalWorkbenchSidebarNode = ( + ); - useWorkspaceWorkflowProgressSync({ - enabled: isSpecializedThemeMode && hasMessages && steps.length > 0, + const workflowProgressEnabled = + isSpecializedThemeMode && hasMessages && steps.length > 0; + const workflowProgressSignature = useMemo(() => { + if (!workflowProgressEnabled) { + return "hidden"; + } + + const stepSignature = steps + .map((step) => `${step.id}:${step.status}:${step.title}`) + .join("|"); + return `${currentStepIndex}:${stepSignature}`; + }, [currentStepIndex, steps, workflowProgressEnabled]); + const lastWorkflowProgressSignatureRef = useRef(""); + + useEffect(() => { + if (!onWorkflowProgressChange) { + return; + } + + if ( + lastWorkflowProgressSignatureRef.current === workflowProgressSignature + ) { + return; + } + lastWorkflowProgressSignatureRef.current = workflowProgressSignature; + + if (!workflowProgressEnabled) { + onWorkflowProgressChange(null); + return; + } + + onWorkflowProgressChange({ + currentIndex: currentStepIndex, + steps: steps.map((step) => ({ + id: step.id, + title: step.title, + status: step.status, + })), + }); + }, [ currentStepIndex, - steps, onWorkflowProgressChange, - }); + steps, + workflowProgressEnabled, + workflowProgressSignature, + ]); + + useEffect(() => { + return () => { + onWorkflowProgressChange?.(null); + }; + }, [onWorkflowProgressChange]); const navigationActions = useWorkspaceNavigationActions({ applyProjectSelection, compactSession, @@ -3589,14 +3811,38 @@ export function AgentChatWorkspace({ selectedTeamSummary, teamDispatchPreviewState, teamMemorySnapshot: resolvedTeamMemoryShadowSnapshot, - teamSessionRuntime, - teamSessionControlRuntime, + currentSessionTitle: teamSessionRuntime.currentSessionTitle, + currentSessionRuntimeStatus: + teamSessionRuntime.currentSessionRuntimeStatus, + currentSessionLatestTurnStatus: + teamSessionRuntime.currentSessionLatestTurnStatus, + liveRuntimeBySessionId: teamSessionRuntime.liveRuntimeBySessionId, + liveActivityBySessionId: teamSessionRuntime.liveActivityBySessionId, + activityRefreshVersionBySessionId: + teamSessionRuntime.activityRefreshVersionBySessionId, + handleSendSubagentInput: + teamSessionControlRuntime.handleSendSubagentInput, + handleWaitSubagentSession: + teamSessionControlRuntime.handleWaitSubagentSession, + handleWaitActiveTeamSessions: + teamSessionControlRuntime.handleWaitActiveTeamSessions, + handleCloseCompletedTeamSessions: + teamSessionControlRuntime.handleCloseCompletedTeamSessions, + handleCloseSubagentSession: + teamSessionControlRuntime.handleCloseSubagentSession, + handleResumeSubagentSession: + teamSessionControlRuntime.handleResumeSubagentSession, + teamWaitSummary: teamSessionControlRuntime.teamWaitSummary, + teamControlSummary: teamSessionControlRuntime.teamControlSummary, + handleStopSending: teamSessionControlRuntime.handleStopSending, + teamWorkspaceEnabled: teamSessionRuntime.teamWorkspaceEnabled, handleOpenSubagentSession, handleReturnToParentSession, input, setInput, currentGate, - generalWorkbenchSidebarRuntime, + generalWorkbenchWorkflowSteps: + generalWorkbenchSidebarRuntime.generalWorkbenchWorkflowSteps, steps, workflowRunState: themeWorkbenchRunState, handleSend, @@ -3649,14 +3895,20 @@ export function AgentChatWorkspace({ handleRestartGeneralWorkbenchEntryPrompt, handleContinueGeneralWorkbenchEntryPrompt, generalWorkbenchEnabled: workspaceHarnessEnabled && chatMode === "general", - contextHarnessRuntime, + harnessPanelVisible: contextHarnessRuntime.harnessPanelVisible, + setHarnessPanelVisible: contextHarnessRuntime.setHarnessPanelVisible, harnessState, - compatSubagentRuntime, - harnessInventoryRuntime, + harnessEnvironment: contextHarnessRuntime.harnessEnvironment, + toolInventory: harnessInventoryRuntime.toolInventory, + toolInventoryLoading: harnessInventoryRuntime.toolInventoryLoading, + toolInventoryError: harnessInventoryRuntime.toolInventoryError, + refreshToolInventory: harnessInventoryRuntime.refreshToolInventory, mappedTheme, + activeRuntimeStatusTitle: contextHarnessRuntime.activeRuntimeStatusTitle, handleHarnessLoadFilePreview, handleFileClick: handleWorkspaceFileClick, - shellChromeRuntime, + showGeneralWorkbenchFloatingInputOverlay: + shellChromeRuntime.shouldShowGeneralWorkbenchFloatingInputOverlay, handleActivateTeamWorkbench, chatToolPreferences: effectiveChatToolPreferences, }); @@ -3712,40 +3964,53 @@ export function AgentChatWorkspace({ handleDocumentContentReviewRun, handleDocumentTextStylizeRun, preferContentReviewInRightRail, - teamSessionRuntime, - teamSessionControlRuntime, + teamWorkspaceEnabled: teamSessionRuntime.teamWorkspaceEnabled, + liveActivityBySessionId: teamSessionRuntime.liveActivityBySessionId, + teamWaitSummary: teamSessionControlRuntime.teamWaitSummary, + teamControlSummary: teamSessionControlRuntime.teamControlSummary, teamWorkbenchAutoFocusToken, teamDispatchPreviewState, teamMemorySnapshot: resolvedTeamMemoryShadowSnapshot, }); - const workspaceShellSceneRuntime = useWorkspaceConversationShellSceneRuntime({ + const handleSendFromEmptyState = useCallback( + ( + text: string, + sendExecutionStrategy?: "react" | "code_orchestrated" | "auto", + images?: MessageImage[], + ) => { + void handleSend( + images || [], + effectiveChatToolPreferences.webSearch, + effectiveChatToolPreferences.thinking, + text, + sendExecutionStrategy, + ); + }, + [ + effectiveChatToolPreferences.thinking, + effectiveChatToolPreferences.webSearch, + handleSend, + ], + ); + + const conversationSceneRuntime = useWorkspaceConversationSceneRuntime({ messageListEmptyStateVariant: agentEntry === "claw" ? "task-center" : "default", navbarContextVariant: agentEntry === "claw" ? "task-center" : "default", - sidebarContextVariant: agentEntry === "claw" ? "task-center" : "default", navigationActions, inputbarScene, canvasScene, + handleSendFromEmptyState, shellChromeRuntime, - generalWorkbenchShellRuntime, - contextHarnessRuntime, - teamSessionRuntime, - currentImageWorkbenchState, - project, - projectId, - projectMemory, - handleSend, - generalCanvasState, - showSidebar, - topics, - switchTopic, - handleResumeSidebarTask, - deleteTopic, - renameTopic, - childSubagentSessions, - subagentParentContext, - handleReturnToParentSession, + generalWorkbenchHarnessDialog, + teamWorkspaceEnabled: teamSessionRuntime.teamWorkspaceEnabled, + currentImageWorkbenchActive: currentImageWorkbenchState.active, + projectId: projectId ?? null, + projectRootPath: project?.rootPath || null, + projectCharacters: projectMemory?.characters || [], + generalCanvasContent: generalCanvasState.content, + handleToggleHarnessPanel: contextHarnessRuntime.handleToggleHarnessPanel, entryBannerVisible, entryBannerMessage, serviceSkillExecutionCard, @@ -3856,9 +4121,47 @@ export function AgentChatWorkspace({ timelineFocusRequestKey, }); + const shellPendingActions = pendingActions ?? []; + const shellQueuedTurns = queuedTurns ?? []; + const shellHandleBackHome = handleBackHome ?? (() => undefined); + const shellHandleOpenSubagentSession = + handleOpenSubagentSession ?? (() => undefined); + const shellDisplayMessages = displayMessages ?? []; + const shellIsSending = isSending ?? false; + return ( <> - {workspaceShellSceneRuntime.shellSceneNode} + = ({ activeTheme, toolPreferences, harnessState, - compatSubagentRuntime, childSubagentSessions = [], variant = "standalone", isSending = false, @@ -72,7 +69,11 @@ export const AgentRuntimeStrip: React.FC = ({ label: "联网搜索", enabled: toolPreferences.webSearch, }, - { key: "subagent", label: "多代理", enabled: toolPreferences.subagent }, + { + key: "subagent", + label: "任务拆分", + enabled: toolPreferences.subagent, + }, ], [toolPreferences], ); @@ -150,26 +151,14 @@ export const AgentRuntimeStrip: React.FC = ({ key: "team_sessions", label: completedTeamSessions > 0 - ? `协作会话 ${childSubagentSessions.length} · 已完成 ${completedTeamSessions}` - : `协作会话 ${childSubagentSessions.length}`, + ? `子任务 ${childSubagentSessions.length} · 已完成 ${completedTeamSessions}` + : `子任务 ${childSubagentSessions.length}`, tone: "outline", }); - } else if (compatSubagentRuntime.isRunning) { - const progressLabel = - compatSubagentRuntime.progress && - typeof compatSubagentRuntime.progress.completed === "number" && - typeof compatSubagentRuntime.progress.total === "number" - ? `协作成员处理中 ${compatSubagentRuntime.progress.completed}/${compatSubagentRuntime.progress.total}` - : "协作成员处理中"; - nextItems.push({ - key: "subagent_running", - label: progressLabel, - tone: "secondary", - }); } else if (harnessState.delegatedTasks.length > 0) { nextItems.push({ key: "delegated", - label: `最近协作 ${harnessState.delegatedTasks.length}`, + label: `最近子任务 ${harnessState.delegatedTasks.length}`, tone: "outline", }); } @@ -193,8 +182,6 @@ export const AgentRuntimeStrip: React.FC = ({ return nextItems; }, [ childSubagentSessions, - compatSubagentRuntime.isRunning, - compatSubagentRuntime.progress, executionRuntime, harnessState, isExecutionRuntimeActive, @@ -216,8 +203,8 @@ export const AgentRuntimeStrip: React.FC = ({ {toolPreferences.subagent ? ( {hasSelectedTeam - ? `协作 · ${selectedTeamLabel || `${selectedTeamRoleCount} 角色`}` - : "协作模式"} + ? `分工 · ${selectedTeamLabel || `${selectedTeamRoleCount} 角色`}` + : "分工模式"} ) : null}
@@ -238,14 +225,14 @@ export const AgentRuntimeStrip: React.FC = ({ {toolPreferences.subagent ? (
- 当前协作设置 + 当前任务分工 {" "} ·{" "} {selectedTeamSummary?.trim() || (hasSelectedTeam - ? `已配置 ${selectedTeamRoleCount} 个角色,系统会按需邀请协作成员。` - : "已开启协作模式,本次可选择或自定义协作方案。")} + ? `已配置 ${selectedTeamRoleCount} 个角色,系统会按需拆出子任务。` + : "已开启分工模式,本次可选择或自定义任务方案。")}
) : null} diff --git a/src/components/agent/chat/components/AgentThreadMemoryPrefetchPreview.tsx b/src/components/agent/chat/components/AgentThreadMemoryPrefetchPreview.tsx index 83c04cf43..0cbdeb720 100644 --- a/src/components/agent/chat/components/AgentThreadMemoryPrefetchPreview.tsx +++ b/src/components/agent/chat/components/AgentThreadMemoryPrefetchPreview.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from "react"; import { Badge } from "@/components/ui/badge"; import type { TurnMemoryPrefetchResult } from "@/lib/api/memoryRuntime"; import { cn } from "@/lib/utils"; +import { normalizeTeamMemoryDisplayText } from "../utils/teamMemoryDisplay"; type RuntimeMemoryPrefetchStatus = "idle" | "loading" | "ready" | "error"; @@ -152,7 +153,7 @@ export function AgentThreadMemoryPrefetchPreview({ )} > {status === "loading" - ? "正在按最新回合 prompt 预演 rules / working / durable / team / compaction 五层记忆命中。" + ? "正在按最新回合 prompt 预演规则 / 工作 / 持久 / 任务影子 / 压缩五层记忆命中。" : status === "error" ? error : "下面展示的是当前这轮真实会用到的记忆命中预演,不会改写会话,只帮助判断续接质量。"} @@ -175,7 +176,7 @@ export function AgentThreadMemoryPrefetchPreview({ {formatMemoryLayerStatusLabel("持久", result.durable_memories.length)} - {formatMemoryLayerStatusLabel("Team", result.team_memory_entries.length)} + {formatMemoryLayerStatusLabel("任务影子", result.team_memory_entries.length)} {formatMemoryLayerStatusLabel( @@ -256,7 +257,7 @@ export function AgentThreadMemoryPrefetchPreview({ ) : null} - + {result.team_memory_entries.length > 0 ? (
{result.team_memory_entries.slice(0, 3).map((entry) => ( @@ -275,13 +276,16 @@ export function AgentThreadMemoryPrefetchPreview({ ) : null}
- {truncateText(entry.content, 220)} + {truncateText( + normalizeTeamMemoryDisplayText(entry.content), + 220, + )}
))} {result.team_memory_entries.length > 3 ? (
- 另有 {result.team_memory_entries.length - 3} 条 Team shadow 未展开。 + 另有 {result.team_memory_entries.length - 3} 条任务影子未展开。
) : null} diff --git a/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx b/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx index a27d33e42..8fb95d593 100644 --- a/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx +++ b/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx @@ -613,7 +613,7 @@ describe("AgentThreadReliabilityPanel", () => { expect(container.textContent).toContain("规则 2"); expect(container.textContent).toContain("工作 已命中"); expect(container.textContent).toContain("持久 1"); - expect(container.textContent).toContain("Team 1"); + expect(container.textContent).toContain("任务影子 1"); expect(container.textContent).toContain("压缩 已命中"); expect(container.textContent).toContain("工作记忆摘录"); expect(container.textContent).toContain("继续整理风险点与来源摘要"); @@ -622,7 +622,7 @@ describe("AgentThreadReliabilityPanel", () => { expect(container.textContent).toContain("持久记忆命中"); expect(container.textContent).toContain("研究简报输出偏好"); expect(container.textContent).toContain("优先给风险与证据链"); - expect(container.textContent).toContain("Team Shadow 明细"); + expect(container.textContent).toContain("任务影子明细"); expect(container.textContent).toContain("team.selection"); expect(container.textContent).toContain("研究协作队"); expect(container.textContent).toContain("压缩续接摘要"); @@ -1032,7 +1032,7 @@ describe("AgentThreadReliabilityPanel", () => { expect.stringContaining("规则层:1 个来源"), ); expect(navigator.clipboard.writeText).toHaveBeenCalledWith( - expect.stringContaining("Team 层:1 条"), + expect.stringContaining("任务影子层:1 条"), ); expect(navigator.clipboard.writeText).toHaveBeenCalledWith( expect.stringContaining("规则来源:/workspace/AGENTS.md"), @@ -1041,7 +1041,7 @@ describe("AgentThreadReliabilityPanel", () => { expect.stringContaining("持久记忆详情:研究输出格式偏好|先给结论,再列风险"), ); expect(navigator.clipboard.writeText).toHaveBeenCalledWith( - expect.stringContaining("Team 影子详情:team.selection|研究协作队"), + expect.stringContaining("任务影子详情:team.selection|研究协作队"), ); expect(navigator.clipboard.writeText).toHaveBeenCalledWith( expect.stringContaining("压缩命中摘要:保留研究目标与最近来源摘要。"), @@ -1077,7 +1077,7 @@ describe("AgentThreadReliabilityPanel", () => { expect.stringContaining("浏览器工具执行失败"), ); expect(mockToast.success).toHaveBeenCalledWith("AI 诊断内容已复制"); - expect(container.textContent).toContain("compat 快速诊断"); + expect(container.textContent).toContain("线程级快速诊断"); expect(container.textContent).toContain("快速复制给 AI"); expect(container.textContent).toContain("复制原始 JSON(debug)"); expect(container.textContent).toContain("外部分析交接"); diff --git a/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx b/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx index 3d2e400a3..a383be26f 100644 --- a/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx +++ b/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx @@ -357,7 +357,7 @@ function buildMemoryPrefetchDiagnosticLines( `- 规则层:${prefetch.rules_source_paths.length} 个来源`, `- 工作层:${prefetch.working_memory_excerpt ? "已命中" : "未命中"}`, `- 持久层:${prefetch.durable_memories.length} 条`, - `- Team 层:${prefetch.team_memory_entries.length} 条`, + `- 任务影子层:${prefetch.team_memory_entries.length} 条`, `- 压缩层:${prefetch.latest_compaction ? "已命中" : "未命中"}`, ]; @@ -393,13 +393,13 @@ function buildMemoryPrefetchDiagnosticLines( } if (prefetch.team_memory_entries.length > 0) { sections.push( - `- Team 影子键:${prefetch.team_memory_entries + `- 任务影子键:${prefetch.team_memory_entries .slice(0, 3) .map((entry) => entry.key) .join("|")}`, ); sections.push( - `- Team 影子详情:${prefetch.team_memory_entries + `- 任务影子详情:${prefetch.team_memory_entries .slice(0, 3) .map( (entry) => @@ -453,7 +453,7 @@ function resolveMemoryPrefetchPreviewChangeLabel( case "durable": return `长期记忆 ${change.previous || "无"} -> ${change.current || "无"}`; case "team": - return `Team 影子 ${change.previous || "无"} -> ${change.current || "无"}`; + return `任务影子 ${change.previous || "无"} -> ${change.current || "无"}`; case "compaction": return `压缩摘要 ${change.previous || "无"} -> ${change.current || "无"}`; case "user_message": @@ -574,7 +574,7 @@ function buildReliabilityDiagnosticText(params: { `- 模型:${diagnosticRuntimeContext?.model || "未知"}`, `- 执行策略:${diagnosticRuntimeContext?.executionStrategy || "未知"}`, `- 主题:${diagnosticRuntimeContext?.activeTheme || "未知"}`, - `- 协作方案:${diagnosticRuntimeContext?.selectedTeamLabel || "未设置"}`, + `- 任务方案:${diagnosticRuntimeContext?.selectedTeamLabel || "未设置"}`, `- 工作区根目录:${diagnosticRuntimeContext?.workingDir || "未知"}`, "", "### 当前状态", @@ -735,7 +735,7 @@ function buildReliabilityDiagnosticText(params: { } if (memoryPrefetchComparison.diff.changed) { sections.push( - `- 层变化:规则 ${memoryPrefetchComparison.diff.layerChanges.rulesDelta >= 0 ? "+" : ""}${memoryPrefetchComparison.diff.layerChanges.rulesDelta}|工作 ${memoryPrefetchComparison.diff.layerChanges.workingChanged}|持久 ${memoryPrefetchComparison.diff.layerChanges.durableDelta >= 0 ? "+" : ""}${memoryPrefetchComparison.diff.layerChanges.durableDelta}|Team ${memoryPrefetchComparison.diff.layerChanges.teamDelta >= 0 ? "+" : ""}${memoryPrefetchComparison.diff.layerChanges.teamDelta}|压缩 ${memoryPrefetchComparison.diff.layerChanges.compactionChanged}`, + `- 层变化:规则 ${memoryPrefetchComparison.diff.layerChanges.rulesDelta >= 0 ? "+" : ""}${memoryPrefetchComparison.diff.layerChanges.rulesDelta}|工作 ${memoryPrefetchComparison.diff.layerChanges.workingChanged}|持久 ${memoryPrefetchComparison.diff.layerChanges.durableDelta >= 0 ? "+" : ""}${memoryPrefetchComparison.diff.layerChanges.durableDelta}|任务影子 ${memoryPrefetchComparison.diff.layerChanges.teamDelta >= 0 ? "+" : ""}${memoryPrefetchComparison.diff.layerChanges.teamDelta}|压缩 ${memoryPrefetchComparison.diff.layerChanges.compactionChanged}`, ); if (memoryPrefetchComparison.diff.previewChanges.length > 0) { sections.push( @@ -1238,7 +1238,7 @@ export const AgentThreadReliabilityPanel: React.FC< variant="outline" className="border-amber-200 bg-amber-50 text-amber-700" > - compat 快速诊断 + 线程级快速诊断
- 当前入口属于 `compat` 线程级快速诊断,只覆盖当前 thread 的运行信号。 - 正式交给外部模型分析时,请优先使用工作台“交接制品 → - 外部分析交接”的 `analysis-brief.md / analysis-context.json` - 主链;这里的“快速复制给 AI”只适合临时排障,“复制原始 - JSON(debug)”适合程序化分析、存档或二次处理。 + 当前入口只覆盖当前 thread 的运行信号。正式交给外部模型分析时, + 请优先使用工作台“交接制品 → 外部分析交接”的 + `analysis-brief.md / analysis-context.json` 主链;这里的“快速复制给 + AI”只适合临时排障,“复制原始 JSON(debug)”适合程序化分析、 + 存档或二次处理。
@@ -1683,7 +1683,7 @@ export const AgentThreadReliabilityPanel: React.FC< variant="outline" className="border-sky-200 bg-white text-sky-700" > - Team{" "} + 任务影子{" "} {memoryPrefetchComparison.diff.layerChanges.teamDelta > 0 ? "+" : ""} {memoryPrefetchComparison.diff.layerChanges.teamDelta} diff --git a/src/components/agent/chat/components/AgentThreadTimeline.test.tsx b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx index a389b6ba7..676df1426 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.test.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx @@ -990,7 +990,7 @@ describe("AgentThreadTimeline", () => { ).toBeNull(); }); - it("真实协作成员 item 应支持查看协作详情", () => { + it("真实子任务 item 应支持查看子任务详情", () => { const onOpenSubagentSession = vi.fn(); const items: AgentThreadItem[] = [ { @@ -1012,10 +1012,11 @@ describe("AgentThreadTimeline", () => { expect(container.textContent).toContain("图片任务 1"); expect(container.textContent).not.toContain("Image #1"); + expect(container.textContent).toContain("子任务:图片任务 1"); const button = Array.from( container.querySelectorAll("button"), - ).find((element) => element.textContent?.includes("查看协作详情")); + ).find((element) => element.textContent?.includes("查看子任务详情")); expect(button).toBeTruthy(); diff --git a/src/components/agent/chat/components/AgentThreadTimeline.tsx b/src/components/agent/chat/components/AgentThreadTimeline.tsx index 1893069da..b380b41f1 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.tsx @@ -831,13 +831,12 @@ function renderGroupItemDetails( if (item.type === "subagent_activity") { const subagentSessionId = item.session_id?.trim(); - const displayTitle = - resolveInternalImageTaskDisplayName(item.title) || "协作任务"; + const displayTitle = resolveInternalImageTaskDisplayName(item.title) || "子任务"; return ( {resolveSubagentStatusLabel(item.status_label, item.status)} @@ -862,7 +861,7 @@ function renderGroupItemDetails( variant="outline" onClick={() => onOpenSubagentSession(subagentSessionId)} > - 查看协作详情 + 查看子任务详情
) : null} @@ -1113,9 +1112,9 @@ function normalizeBlockPreviewLine( if ( kind === "subagent" && - !hasAnyPrefix(trimmed, ["分给协作成员", "协作成员"]) + !hasAnyPrefix(trimmed, ["分给子任务", "子任务", "分给协作成员", "协作成员"]) ) { - return `分给协作成员处理 ${trimmed}`; + return `分给子任务处理 ${trimmed}`; } return trimmed; @@ -1174,9 +1173,7 @@ function resolveBlockSummaryLines(block: AgentThreadOrderedBlock): string[] { } if (block.kind === "subagent") { - return [ - block.status === "completed" ? "协作成员处理完了" : "协作成员在处理", - ]; + return [block.status === "completed" ? "子任务已完成" : "子任务处理中"]; } if (block.kind === "other") { diff --git a/src/components/agent/chat/components/CanvasSessionOverviewPanel.tsx b/src/components/agent/chat/components/CanvasSessionOverviewPanel.tsx index 827f27cd0..75ca0177b 100644 --- a/src/components/agent/chat/components/CanvasSessionOverviewPanel.tsx +++ b/src/components/agent/chat/components/CanvasSessionOverviewPanel.tsx @@ -216,12 +216,12 @@ function buildActivityView(item: AgentThreadItem): SessionActivityView | null { case "subagent_activity": return { id: item.id, - title: item.title?.trim() || "子 Agent 活动", + title: item.title?.trim() || "子任务活动", summary: shortenText(item.summary, 100) || shortenText(item.role, 100) || shortenText(item.model, 100) || - "协作成员正在推进任务。", + "子任务正在推进当前任务。", timeLabel, statusLabel, tone, diff --git a/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx b/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx index 1ac49d81c..5a03a4bd3 100644 --- a/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx +++ b/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx @@ -1360,12 +1360,12 @@ describe("CanvasWorkbenchLayout", () => { renderPreview, teamView: { enabled: true, - title: "Team Workbench", - subtitle: "多成员实时协作", + title: "任务工作台", + subtitle: "任务进行时", badges: [ { key: "team-runtime", - label: "Team Workbench", + label: "任务工作台", tone: "accent", }, { @@ -1377,9 +1377,9 @@ describe("CanvasWorkbenchLayout", () => { summaryStats: [ { key: "team-status", - label: "协作状态", + label: "任务状态", value: "处理中", - detail: "2 位处理中,1 位排队中。", + detail: "2 项处理中,1 项排队中。", tone: "accent", }, ], @@ -1401,7 +1401,7 @@ describe("CanvasWorkbenchLayout", () => { ).not.toBeNull(); expect( container.querySelector( - 'button[aria-label="切换画布标签-Team Workbench"]', + 'button[aria-label="切换画布标签-任务工作台"]', ), ).not.toBeNull(); expect(renderPreview).toHaveBeenCalled(); @@ -1441,8 +1441,8 @@ describe("CanvasWorkbenchLayout", () => { renderPreview, teamView: { enabled: true, - title: "Team Workbench", - subtitle: "多成员实时协作", + title: "任务工作台", + subtitle: "任务进行时", autoFocusToken: 1, renderPreview: () =>
unused-team-preview
, renderPanel: () =>
team-panel
, diff --git a/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx b/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx index 458ff5d71..73e7d4398 100644 --- a/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx +++ b/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx @@ -1438,7 +1438,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ } return { kind: "team-workbench", - title: teamView.title || "Team Workbench", + title: teamView.title || "任务工作台", }; }, [teamView]); @@ -1580,7 +1580,9 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ { key: "team" as const, label: - teamView.tabLabel?.trim() || teamView.title?.trim() || "team", + teamView.tabLabel?.trim() || + teamView.title?.trim() || + "任务工作台", badge: teamView.tabBadge?.trim() || teamView.triggerState?.label?.trim() || @@ -2027,7 +2029,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
{teamView?.panelCopy?.emptyText || - "当前没有可展示的 Team Workbench。"} + "当前没有可展示的任务工作台。"}
); diff --git a/src/components/agent/chat/components/ChatSidebar.test.tsx b/src/components/agent/chat/components/ChatSidebar.test.tsx index 62e00c3ce..9a6994503 100644 --- a/src/components/agent/chat/components/ChatSidebar.test.tsx +++ b/src/components/agent/chat/components/ChatSidebar.test.tsx @@ -299,7 +299,7 @@ describe("ChatSidebar", () => { expect(container.textContent).toContain("更早记录"); }); - it("任务协作和任务列表应处于同一滚动区域", () => { + it("子任务和任务列表应处于同一滚动区域", () => { const container = renderSidebar({ childSubagentSessions: [ { @@ -325,14 +325,120 @@ describe("ChatSidebar", () => { expect(scrollArea).toBeTruthy(); expect(teamSection).toBeTruthy(); expect(scrollArea?.contains(teamSection)).toBe(true); - expect(scrollArea?.textContent).toContain("任务协作"); + expect(scrollArea?.textContent).toContain("子任务"); expect(scrollArea?.textContent).toContain( - "这里展示当前任务的协作成员、当前任务和任务节点。", + "这里优先展示正在处理的子任务,再回到当前任务和后续节点。", ); expect(scrollArea?.textContent).toContain("任务一"); }); - it("点击任务协作的任务入口应收起顶部区块并滚动到任务列表", () => { + it("父线程子任务应按状态优先级排序并前置当前焦点", () => { + const container = renderSidebar({ + childSubagentSessions: [ + { + id: "child-completed", + name: "已完成代理", + created_at: 1_742_288_400, + updated_at: 1_742_288_560, + session_type: "sub_agent", + task_summary: "已经输出收尾结果。", + role_hint: "writer", + runtime_status: "completed", + }, + { + id: "child-running", + name: "处理中代理", + created_at: 1_742_288_390, + updated_at: 1_742_288_500, + session_type: "sub_agent", + task_summary: "正在处理主线回归。", + role_hint: "executor", + runtime_status: "running", + }, + { + id: "child-queued", + name: "待开始代理", + created_at: 1_742_288_395, + updated_at: 1_742_288_540, + session_type: "sub_agent", + task_summary: "等待前序任务完成后接手。", + role_hint: "reviewer", + runtime_status: "queued", + }, + ], + }); + + const cards = Array.from( + container.querySelectorAll('[data-testid^="sidebar-subagent-session-"]'), + ); + + expect(cards.map((card) => card.getAttribute("data-testid"))).toEqual([ + "sidebar-subagent-session-child-running", + "sidebar-subagent-session-child-queued", + "sidebar-subagent-session-child-completed", + ]); + expect(cards[0]?.textContent).toContain("当前焦点"); + expect(cards[0]?.textContent).toContain("处理中代理"); + expect(cards[2]?.textContent).not.toContain("当前焦点"); + }); + + it("子线程并行子任务应按状态优先级排序并前置当前焦点", () => { + const container = renderSidebar({ + topics: [ + { + ...defaultTopics[0], + id: "child-1", + title: "当前子任务", + sourceSessionId: "child-1", + }, + ], + currentTopicId: "child-1", + subagentParentContext: { + parent_session_id: "parent-1", + parent_session_name: "主线程", + role_hint: "implementer", + task_summary: "对齐任务列表排序。", + created_from_turn_id: "turn-42", + sibling_subagent_sessions: [ + { + id: "child-completed", + name: "已完成代理", + created_at: 1_742_288_400, + updated_at: 1_742_288_560, + session_type: "sub_agent", + task_summary: "已经输出收尾结果。", + role_hint: "writer", + runtime_status: "completed", + }, + { + id: "child-running", + name: "处理中代理", + created_at: 1_742_288_390, + updated_at: 1_742_288_500, + session_type: "sub_agent", + task_summary: "正在处理主线回归。", + role_hint: "executor", + runtime_status: "running", + }, + ], + }, + }); + + const cards = Array.from( + container.querySelectorAll('[data-testid^="sidebar-subagent-session-"]'), + ); + + expect(cards.map((card) => card.getAttribute("data-testid"))).toEqual([ + "sidebar-subagent-session-child-running", + "sidebar-subagent-session-child-completed", + ]); + expect(cards[0]?.textContent).toContain("当前焦点"); + expect(container.textContent).toContain( + "当前线程来自主任务,可直接返回主任务并切换其他子任务;正在处理的任务会排在前面。", + ); + }); + + it("点击子任务入口应收起顶部区块并滚动到任务列表", () => { const container = renderSidebar({ childSubagentSessions: [ { @@ -373,7 +479,7 @@ describe("ChatSidebar", () => { block: "start", behavior: "smooth", }); - expect(container.textContent).toContain("已收起 · 1 个子代理 · 1 个处理中"); + expect(container.textContent).toContain("已收起 · 1 个子任务 · 1 个处理中"); expect(container.textContent).not.toContain("代码审查代理"); }); @@ -622,7 +728,7 @@ describe("ChatSidebar", () => { expect(onResumeTask).toHaveBeenCalledWith("topic-1", "user_action"); }); - it("父线程应在侧栏展示真实子代理并支持打开", () => { + it("父线程应在侧栏展示真实子任务并支持打开", () => { const onOpenSubagentSession = vi.fn(); const container = renderSidebar({ childSubagentSessions: [ @@ -650,9 +756,10 @@ describe("ChatSidebar", () => { onOpenSubagentSession, }); - expect(container.textContent).toContain("任务协作"); + expect(container.textContent).toContain("子任务"); expect(container.textContent).toContain("代码审查代理"); expect(container.textContent).toContain("文档校对代理"); + expect(container.textContent).toContain("子任务"); expect(container.textContent).toContain("处理中"); expect(container.textContent).toContain("已完成"); @@ -668,7 +775,7 @@ describe("ChatSidebar", () => { expect(onOpenSubagentSession).toHaveBeenCalledWith("child-1"); }); - it("父线程任务协作区域应支持折叠和展开", () => { + it("父线程子任务区域应支持折叠和展开", () => { const container = renderSidebar({ childSubagentSessions: [ { @@ -698,7 +805,7 @@ describe("ChatSidebar", () => { expect(container.textContent).toContain("文档校对代理"); const collapseButton = container.querySelector( - 'button[aria-label="收起任务协作"]', + 'button[aria-label="收起子任务"]', ) as HTMLButtonElement | null; expect(collapseButton).toBeTruthy(); @@ -707,13 +814,13 @@ describe("ChatSidebar", () => { }); expect(container.textContent).toContain( - "已收起 · 2 个子代理 · 1 个处理中 · 1 个已完成", + "已收起 · 2 个子任务 · 1 个处理中 · 1 个已完成", ); expect(container.textContent).not.toContain("代码审查代理"); expect(container.textContent).not.toContain("文档校对代理"); const expandButton = container.querySelector( - 'button[aria-label="展开任务协作"]', + 'button[aria-label="展开子任务"]', ) as HTMLButtonElement | null; expect(expandButton).toBeTruthy(); @@ -725,7 +832,7 @@ describe("ChatSidebar", () => { expect(container.textContent).toContain("文档校对代理"); }); - it("父线程任务协作在子代理较多时应默认收起,并支持展开更多子代理", () => { + it("父线程子任务较多时应默认收起,并支持展开更多子任务", () => { const container = renderSidebar({ childSubagentSessions: [ { @@ -772,7 +879,7 @@ describe("ChatSidebar", () => { }); expect(container.textContent).toContain( - "已收起 · 4 个子代理 · 2 个处理中 · 1 个稍后开始 · 1 个已完成", + "已收起 · 4 个子任务 · 2 个处理中 · 1 个稍后开始 · 1 个已完成", ); expect(container.textContent).not.toContain("代码审查代理"); expect(container.textContent).not.toContain("文档校对代理"); @@ -780,7 +887,7 @@ describe("ChatSidebar", () => { expect(container.textContent).not.toContain("回归验证代理"); const expandTeamButton = container.querySelector( - 'button[aria-label="展开任务协作"]', + 'button[aria-label="展开子任务"]', ) as HTMLButtonElement | null; expect(expandTeamButton).toBeTruthy(); @@ -791,14 +898,14 @@ describe("ChatSidebar", () => { }); expect(container.textContent).toContain("代码审查代理"); - expect(container.textContent).toContain("文档校对代理"); + expect(container.textContent).toContain("回归验证代理"); expect(container.textContent).toContain("数据整理代理"); - expect(container.textContent).not.toContain("回归验证代理"); - expect(container.textContent).toContain("展开剩余 1 个子代理"); + expect(container.textContent).not.toContain("文档校对代理"); + expect(container.textContent).toContain("展开剩余 1 个子任务"); const expandMoreButton = Array.from( container.querySelectorAll("button"), - ).find((element) => element.textContent?.includes("展开剩余 1 个子代理")); + ).find((element) => element.textContent?.includes("展开剩余 1 个子任务")); expect(expandMoreButton).toBeTruthy(); act(() => { @@ -808,11 +915,12 @@ describe("ChatSidebar", () => { }); expect(container.textContent).toContain("回归验证代理"); - expect(container.textContent).toContain("收起子代理列表"); + expect(container.textContent).toContain("文档校对代理"); + expect(container.textContent).toContain("收起子任务列表"); const collapseListButton = Array.from( container.querySelectorAll("button"), - ).find((element) => element.textContent?.includes("收起子代理列表")); + ).find((element) => element.textContent?.includes("收起子任务列表")); expect(collapseListButton).toBeTruthy(); act(() => { @@ -821,10 +929,11 @@ describe("ChatSidebar", () => { ); }); - expect(container.textContent).not.toContain("回归验证代理"); + expect(container.textContent).toContain("回归验证代理"); + expect(container.textContent).not.toContain("文档校对代理"); }); - it("子线程同级子代理较多时应默认收起任务协作", () => { + it("子线程并行子任务较多时应默认收起子任务", () => { const container = renderSidebar({ topics: [ { @@ -877,14 +986,14 @@ describe("ChatSidebar", () => { }); expect(container.textContent).toContain( - "已收起 · 3 个同级子代理 · 1 个处理中 · 1 个稍后开始 · 1 个已完成", + "已收起 · 3 个并行子任务 · 1 个处理中 · 1 个稍后开始 · 1 个已完成", ); expect(container.textContent).not.toContain("研究代理"); expect(container.textContent).not.toContain("验证代理"); expect(container.textContent).not.toContain("文档代理"); const expandButton = container.querySelector( - 'button[aria-label="展开任务协作"]', + 'button[aria-label="展开子任务"]', ) as HTMLButtonElement | null; expect(expandButton).toBeTruthy(); @@ -893,12 +1002,12 @@ describe("ChatSidebar", () => { }); expect(container.textContent).toContain("验证代理"); - expect(container.textContent).toContain("文档代理"); - expect(container.textContent).not.toContain("研究代理"); - expect(container.textContent).toContain("展开剩余 1 个同级子代理"); + expect(container.textContent).toContain("研究代理"); + expect(container.textContent).not.toContain("文档代理"); + expect(container.textContent).toContain("展开剩余 1 个并行子任务"); }); - it("子线程应展示父会话和同级子代理入口", () => { + it("子线程应展示父会话和并行子任务入口", () => { const onOpenSubagentSession = vi.fn(); const onReturnToParentSession = vi.fn(); const container = renderSidebar({ @@ -934,9 +1043,9 @@ describe("ChatSidebar", () => { onReturnToParentSession, }); - expect(container.textContent).toContain("任务协作"); + expect(container.textContent).toContain("子任务"); expect(container.textContent).toContain("主线程"); - expect(container.textContent).toContain("当前子代理"); + expect(container.textContent).toContain("当前子任务"); expect(container.textContent).toContain("实现 team sidebar"); expect(container.textContent).toContain("研究代理"); expect(container.textContent).toContain("稍后开始"); @@ -964,7 +1073,7 @@ describe("ChatSidebar", () => { expect(onOpenSubagentSession).toHaveBeenCalledWith("child-2"); }); - it("内部图片子代理标题应显示为用户文案", () => { + it("内部图片子任务标题应显示为用户文案", () => { const container = renderSidebar({ topics: [ { diff --git a/src/components/agent/chat/components/ChatSidebar.tsx b/src/components/agent/chat/components/ChatSidebar.tsx index d2219151b..831ed2dd7 100644 --- a/src/components/agent/chat/components/ChatSidebar.tsx +++ b/src/components/agent/chat/components/ChatSidebar.tsx @@ -43,7 +43,7 @@ const RECENT_TASK_WINDOW_MS = 1000 * 60 * 60 * 24 * 3; const OLDER_TASKS_INITIAL_COUNT = 8; const TEAM_SECTION_INITIAL_CHILD_COUNT = 3; const TEAM_SECTION_INITIAL_SIBLING_COUNT = 2; -const TEAM_SECTION_LABEL = "任务协作"; +const TEAM_SECTION_LABEL = "子任务"; const PINNED_TASK_IDS_STORAGE_KEY = "lime_task_sidebar_pinned_ids"; const STATUS_META: Record< @@ -462,16 +462,67 @@ const TEAM_STATUS_SUMMARY_ORDER: Array< NonNullable | "idle" > = ["running", "queued", "completed", "failed", "aborted", "closed", "idle"]; +const SUBAGENT_TASK_PRIORITY: Record< + NonNullable | "idle", + number +> = { + running: 0, + queued: 1, + failed: 2, + aborted: 2, + completed: 3, + closed: 4, + idle: 5, +}; + function resolveSubagentStatusMeta( status?: AsterSubagentSessionInfo["runtime_status"], ) { return SUBAGENT_STATUS_META[status ?? "idle"]; } +function sortSubagentSessionsByPriority( + sessions: AsterSubagentSessionInfo[], +): AsterSubagentSessionInfo[] { + return [...sessions].sort((left, right) => { + const leftPriority = + SUBAGENT_TASK_PRIORITY[left.runtime_status ?? "idle"] ?? + SUBAGENT_TASK_PRIORITY.idle; + const rightPriority = + SUBAGENT_TASK_PRIORITY[right.runtime_status ?? "idle"] ?? + SUBAGENT_TASK_PRIORITY.idle; + + if (leftPriority !== rightPriority) { + return leftPriority - rightPriority; + } + + if (left.updated_at !== right.updated_at) { + return right.updated_at - left.updated_at; + } + + if (left.created_at !== right.created_at) { + return right.created_at - left.created_at; + } + + return left.id.localeCompare(right.id); + }); +} + +function shouldMarkSubagentAsFocus( + session: AsterSubagentSessionInfo | undefined, +): boolean { + if (!session) { + return false; + } + + const status = session.runtime_status ?? "idle"; + return status !== "completed" && status !== "closed"; +} + function resolveSubagentSessionTypeLabel(value?: string) { switch (value) { case "sub_agent": - return "子代理"; + return "子任务"; case "fork": return "分支会话"; case "user": @@ -650,16 +701,13 @@ export const ChatSidebar: React.FC = ({ Record >({}); const sortedChildSubagentSessions = useMemo( - () => - [...childSubagentSessions].sort( - (left, right) => right.updated_at - left.updated_at, - ), + () => sortSubagentSessionsByPriority(childSubagentSessions), [childSubagentSessions], ); const siblingSubagentSessions = useMemo( () => - [...(subagentParentContext?.sibling_subagent_sessions ?? [])].sort( - (left, right) => right.updated_at - left.updated_at, + sortSubagentSessionsByPriority( + subagentParentContext?.sibling_subagent_sessions ?? [], ), [subagentParentContext?.sibling_subagent_sessions], ); @@ -710,8 +758,8 @@ export const ChatSidebar: React.FC = ({ buildCollapsedTeamSummary( teamSummarySessions, subagentParentContext - ? `${siblingSubagentSessions.length} 个同级子代理` - : `${sortedChildSubagentSessions.length} 个子代理`, + ? `${siblingSubagentSessions.length} 个并行子任务` + : `${sortedChildSubagentSessions.length} 个子任务`, ), [ siblingSubagentSessions, @@ -932,6 +980,7 @@ export const ChatSidebar: React.FC = ({ const renderSubagentSessionCard = ( session: AsterSubagentSessionInfo, options?: { + focusLabel?: string; highlightCurrent?: boolean; subtitle?: string; }, @@ -944,6 +993,7 @@ export const ChatSidebar: React.FC = ({ ) : null} {showAllSiblingSubagents && @@ -1302,7 +1362,7 @@ export const ChatSidebar: React.FC = ({ onClick={() => setShowAllSiblingSubagents(false)} className="w-full rounded-2xl border border-slate-200/80 bg-white/78 px-3 py-2 text-xs font-medium text-slate-500 transition hover:border-slate-300 hover:bg-white hover:text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-slate-300 dark:hover:bg-white/10 dark:hover:text-slate-100" > - 收起同级子代理列表 + 收起并行子任务列表 ) : null} @@ -1312,6 +1372,11 @@ export const ChatSidebar: React.FC = ({
{visibleChildSubagentSessions.map((session) => renderSubagentSessionCard(session, { + focusLabel: + session.id === sortedChildSubagentSessions[0]?.id && + shouldMarkSubagentAsFocus(session) + ? "当前焦点" + : undefined, highlightCurrent: session.id === currentTopicId, }), )} @@ -1321,7 +1386,7 @@ export const ChatSidebar: React.FC = ({ onClick={() => setShowAllChildSubagents(true)} className="w-full rounded-2xl border border-dashed border-slate-200/80 bg-white/70 px-3 py-2 text-xs font-medium text-slate-500 transition hover:border-slate-300 hover:bg-white hover:text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-slate-300 dark:hover:bg-white/10 dark:hover:text-slate-100" > - 展开剩余 {hiddenChildSubagentCount} 个子代理 + 展开剩余 {hiddenChildSubagentCount} 个子任务 ) : null} {showAllChildSubagents && @@ -1332,7 +1397,7 @@ export const ChatSidebar: React.FC = ({ onClick={() => setShowAllChildSubagents(false)} className="w-full rounded-2xl border border-slate-200/80 bg-white/78 px-3 py-2 text-xs font-medium text-slate-500 transition hover:border-slate-300 hover:bg-white hover:text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-slate-300 dark:hover:bg-white/10 dark:hover:text-slate-100" > - 收起子代理列表 + 收起子任务列表 ) : null}
diff --git a/src/components/agent/chat/components/EmptyState.test.tsx b/src/components/agent/chat/components/EmptyState.test.tsx index 66b323648..7aa887543 100644 --- a/src/components/agent/chat/components/EmptyState.test.tsx +++ b/src/components/agent/chat/components/EmptyState.test.tsx @@ -345,7 +345,7 @@ describe("EmptyState", () => { expect(container.textContent).not.toContain("快速启动"); expect(container.textContent).not.toContain("生成配图"); expect(container.textContent).not.toContain("浏览器任务起步"); - expect(container.textContent).not.toContain("Team 冒烟测试"); + expect(container.textContent).not.toContain("任务拆分冒烟测试"); expect(container.textContent).not.toContain("推荐方案"); const recommendationTitles = Array.from( @@ -500,11 +500,11 @@ describe("EmptyState", () => { await Promise.resolve(); }); - const expectedLabels = ["我的方法", "持续流程", "多代理", "浏览器接入"]; + const expectedLabels = ["我的方法", "持续流程", "任务拆分", "浏览器接入"]; const expectedAlts = [ "方法能力卡占位图", "持续流程能力卡占位图", - "多代理协作能力卡占位图", + "任务拆分能力卡占位图", "浏览器接入能力卡占位图", ]; @@ -975,7 +975,7 @@ describe("EmptyState", () => { container.querySelector('[data-testid="inputbar-plan-toggle"]'), ).toBeNull(); expect( - container.querySelector('button[title="多代理偏好已关闭"]'), + container.querySelector('button[title="任务拆分偏好已关闭"]'), ).toBeNull(); expect( container.querySelector('[data-testid="inputbar-access-mode-select"]'), @@ -1002,7 +1002,7 @@ describe("EmptyState", () => { ) as HTMLButtonElement | null; expect(planButton).toBeTruthy(); const subagentButton = container.querySelector( - 'button[title="多代理偏好已关闭"]', + 'button[title="任务拆分偏好已关闭"]', ) as HTMLButtonElement | null; expect(subagentButton).toBeTruthy(); const accessModeSelect = container.querySelector( diff --git a/src/components/agent/chat/components/EmptyState.tsx b/src/components/agent/chat/components/EmptyState.tsx index 11e767d4a..0451b3166 100644 --- a/src/components/agent/chat/components/EmptyState.tsx +++ b/src/components/agent/chat/components/EmptyState.tsx @@ -841,7 +841,7 @@ export const EmptyState: React.FC = ({ { key: "agent-teams", eyebrow: "支撑能力", - title: "多代理", + title: "任务拆分", value: subagentEnabled ? "当前任务支持分工推进" : "复杂任务可拆分并行推进", @@ -849,7 +849,7 @@ export const EmptyState: React.FC = ({ "当一个目标需要调研、方案和执行同时推进时,可以拆成多个分工后统一回收结论。", icon: , imageSrc: capabilityAgentTeamsPlaceholder, - imageAlt: "多代理协作能力卡占位图", + imageAlt: "任务拆分能力卡占位图", tone: "lime", }, ]; diff --git a/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx b/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx index 4109a7321..952b71570 100644 --- a/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx +++ b/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx @@ -458,7 +458,7 @@ describe("EmptyStateComposerPanel", () => { ), ).toBeNull(); expect( - container.querySelector('button[title="多代理偏好已关闭"]'), + container.querySelector('button[title="任务拆分偏好已关闭"]'), ).toBeNull(); expect( container.querySelector('[data-testid="empty-state-advanced-toggle"]'), @@ -510,7 +510,7 @@ describe("EmptyStateComposerPanel", () => { expect(enableButton).toBeNull(); expect( - container.querySelector('button[title="多代理偏好已关闭"]'), + container.querySelector('button[title="任务拆分偏好已关闭"]'), ).toBeNull(); expect( container.querySelector('[data-testid="empty-state-advanced-toggle"]'), @@ -537,7 +537,7 @@ describe("EmptyStateComposerPanel", () => { expandAdvancedControls(container); const enableButton = container.querySelector( - 'button[title="多代理偏好已关闭"]', + 'button[title="任务拆分偏好已关闭"]', ) as HTMLButtonElement | null; expect(enableButton).toBeTruthy(); @@ -574,7 +574,7 @@ describe("EmptyStateComposerPanel", () => { ).toBeTruthy(); const toggleButton = container.querySelector( - 'button[title="多代理偏好已开启"]', + 'button[title="任务拆分偏好已开启"]', ) as HTMLButtonElement | null; expect(toggleButton).toBeTruthy(); @@ -591,7 +591,7 @@ describe("EmptyStateComposerPanel", () => { container.querySelector('[data-testid="empty-state-team-selector"]'), ).toBeNull(); expect( - container.querySelector('button[title="多代理偏好已关闭"]'), + container.querySelector('button[title="任务拆分偏好已关闭"]'), ).toBeTruthy(); }); @@ -612,7 +612,7 @@ describe("EmptyStateComposerPanel", () => { ), ).toBeNull(); expect( - container.querySelector('button[title="多代理偏好已关闭"]'), + container.querySelector('button[title="任务拆分偏好已关闭"]'), ).toBeNull(); expect(enableButton).toBeTruthy(); expect(enableButton?.textContent).toContain("启用任务分工"); diff --git a/src/components/agent/chat/components/GeneralWorkbenchSidebar.test.tsx b/src/components/agent/chat/components/GeneralWorkbenchSidebar.test.tsx index 347aab7b1..a7fc6306b 100644 --- a/src/components/agent/chat/components/GeneralWorkbenchSidebar.test.tsx +++ b/src/components/agent/chat/components/GeneralWorkbenchSidebar.test.tsx @@ -177,7 +177,7 @@ function renderSidebar( } describe("GeneralWorkbenchSidebar", () => { - it("任务进行时面板应突出当前任务并按状态优先级排序", () => { + it("任务视图面板应突出当前任务并按状态优先级排序", () => { const { container } = renderSidebar({ workflowSteps: [ { id: "done", title: "完成提纲", status: "completed" }, @@ -188,7 +188,7 @@ describe("GeneralWorkbenchSidebar", () => { }); const workflowTab = container.querySelector( - 'button[aria-label="打开任务进行时面板"]', + 'button[aria-label="打开任务视图"]', ) as HTMLButtonElement | null; expect(workflowTab).toBeTruthy(); expect(workflowTab?.textContent).toContain("任务"); @@ -199,15 +199,23 @@ describe("GeneralWorkbenchSidebar", () => { } expect(container.textContent).toContain("任务工作台"); - expect(container.textContent).toContain("跟踪当前任务、后续节点与产物版本。"); - expect(container.textContent).toContain("任务进行时"); + expect(container.textContent).toContain("聚焦当前任务、后续节点与相关版本。"); + expect(container.textContent).toContain("任务视图"); expect(container.textContent).toContain("当前任务"); expect(container.textContent).toContain("撰写主稿"); expect(container.textContent).toContain("已完成 1/4"); + expect(container.textContent).toMatch(/相关分支|相关版本/); const stepNodes = Array.from( container.querySelectorAll('[data-testid="workflow-sidebar-step"]'), ); + const taskSection = container.querySelector( + '[data-testid="workflow-sidebar-task-section"]', + ) as HTMLElement | null; + const branchSection = container.querySelector( + '[data-testid="workflow-sidebar-branch-section"]', + ) as HTMLElement | null; + expect(stepNodes).toHaveLength(4); expect(stepNodes.map((node) => node.getAttribute("data-status"))).toEqual([ "active", @@ -215,6 +223,16 @@ describe("GeneralWorkbenchSidebar", () => { "pending", "completed", ]); + expect(taskSection).toBeTruthy(); + expect(branchSection).toBeTruthy(); + const taskSectionOrder = + taskSection && branchSection + ? taskSection.compareDocumentPosition(branchSection) & + Node.DOCUMENT_POSITION_FOLLOWING + : 0; + expect( + taskSectionOrder, + ).toBeTruthy(); }); it("传入折叠回调时应显示折叠按钮并可触发", () => { @@ -312,12 +330,22 @@ describe("GeneralWorkbenchSidebar", () => { it("应展示新的双 tab 与紧凑上下文列表结构", () => { const { container } = renderSidebar(); + const shell = container.querySelector( + '[data-testid="general-workbench-sidebar"]', + ) as HTMLElement | null; + const header = container.querySelector( + '[data-testid="general-workbench-sidebar-header"]', + ) as HTMLElement | null; + const tabs = container.querySelector( + '[data-testid="general-workbench-sidebar-tabs"]', + ) as HTMLElement | null; + expect(container.textContent).toContain("上下文管理"); expect( container.querySelector('button[aria-label="打开上下文管理"]'), ).toBeTruthy(); expect( - container.querySelector('button[aria-label="打开任务进行时面板"]'), + container.querySelector('button[aria-label="打开任务视图"]'), ).toBeTruthy(); expect( container.querySelector('button[aria-label="打开执行日志"]'), @@ -326,6 +354,11 @@ describe("GeneralWorkbenchSidebar", () => { expect(container.textContent).toContain("上下文列表"); expect(container.textContent).not.toContain("上下文概览"); expect(container.textContent).not.toContain("项目资料"); + expect(shell?.className).toContain("bg-slate-50"); + expect(shell?.className).not.toContain("linear-gradient"); + expect(header?.className).toContain("bg-white"); + expect(header?.className).not.toContain("backdrop-blur"); + expect(tabs?.className).toContain("bg-slate-100"); }); it("应展示新的搜索上下文输入区", () => { @@ -725,7 +758,7 @@ describe("GeneralWorkbenchSidebar", () => { }); const workflowTab = container.querySelector( - 'button[aria-label="打开任务进行时面板"]', + 'button[aria-label="打开任务视图"]', ) as HTMLButtonElement | null; if (workflowTab) { act(() => { @@ -734,7 +767,7 @@ describe("GeneralWorkbenchSidebar", () => { } const mergeButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent === "采纳到主稿", + (button) => button.textContent === "采纳", ); expect(mergeButton).toBeTruthy(); if (mergeButton) { @@ -745,7 +778,7 @@ describe("GeneralWorkbenchSidebar", () => { expect(onSetBranchStatus).toHaveBeenCalledWith("topic-a", "merged"); }); - it("版本模式应展示产物版本语义", () => { + it("版本模式应展示相关版本语义", () => { const onSetBranchStatus = vi.fn(); const { container } = renderSidebar({ branchMode: "version", @@ -753,7 +786,7 @@ describe("GeneralWorkbenchSidebar", () => { }); const workflowTab = container.querySelector( - 'button[aria-label="打开任务进行时面板"]', + 'button[aria-label="打开任务视图"]', ) as HTMLButtonElement | null; if (workflowTab) { act(() => { @@ -761,8 +794,8 @@ describe("GeneralWorkbenchSidebar", () => { }); } - expect(container.textContent).toContain("产物版本"); - expect(container.textContent).toContain("创建版本快照"); + expect(container.textContent).toContain("相关版本"); + expect(container.textContent).toContain("新增版本"); const setMainButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent === "设为主稿", @@ -780,7 +813,7 @@ describe("GeneralWorkbenchSidebar", () => { it("活动日志应展示后端闸门与运行标识", () => { const { container } = renderSidebar(); const workflowTab = container.querySelector( - 'button[aria-label="打开任务进行时面板"]', + 'button[aria-label="打开任务视图"]', ) as HTMLButtonElement | null; if (workflowTab) { act(() => { @@ -833,7 +866,7 @@ describe("GeneralWorkbenchSidebar", () => { }); const workflowTab = container.querySelector( - 'button[aria-label="打开任务进行时面板"]', + 'button[aria-label="打开任务视图"]', ) as HTMLButtonElement | null; if (workflowTab) { act(() => { @@ -866,7 +899,7 @@ describe("GeneralWorkbenchSidebar", () => { const onViewRunDetail = vi.fn(); const { container } = renderSidebar({ onViewRunDetail }); const workflowTab = container.querySelector( - 'button[aria-label="打开任务进行时面板"]', + 'button[aria-label="打开任务视图"]', ) as HTMLButtonElement | null; if (workflowTab) { act(() => { @@ -916,7 +949,7 @@ describe("GeneralWorkbenchSidebar", () => { }); const workflowTab = container.querySelector( - 'button[aria-label="打开任务进行时面板"]', + 'button[aria-label="打开任务视图"]', ) as HTMLButtonElement | null; if (workflowTab) { act(() => { @@ -958,7 +991,7 @@ describe("GeneralWorkbenchSidebar", () => { }); const workflowTab = container.querySelector( - 'button[aria-label="打开任务进行时面板"]', + 'button[aria-label="打开任务视图"]', ) as HTMLButtonElement | null; if (workflowTab) { act(() => { @@ -1033,7 +1066,7 @@ describe("GeneralWorkbenchSidebar", () => { }); const workflowTab = container.querySelector( - 'button[aria-label="打开任务进行时面板"]', + 'button[aria-label="打开任务视图"]', ) as HTMLButtonElement | null; if (workflowTab) { act(() => { @@ -1139,7 +1172,7 @@ describe("GeneralWorkbenchSidebar", () => { }); const workflowTab = container.querySelector( - 'button[aria-label="打开任务进行时面板"]', + 'button[aria-label="打开任务视图"]', ) as HTMLButtonElement | null; if (workflowTab) { act(() => { @@ -1222,7 +1255,7 @@ describe("GeneralWorkbenchSidebar", () => { }); const workflowTab = container.querySelector( - 'button[aria-label="打开任务进行时面板"]', + 'button[aria-label="打开任务视图"]', ) as HTMLButtonElement | null; if (workflowTab) { act(() => { @@ -1231,6 +1264,15 @@ describe("GeneralWorkbenchSidebar", () => { } expect(container.textContent).toContain("任务提交"); + const toggleCreationTasksButton = container.querySelector( + "button[aria-label='切换任务提交记录']", + ) as HTMLButtonElement | null; + expect(toggleCreationTasksButton).toBeTruthy(); + if (toggleCreationTasksButton) { + act(() => { + toggleCreationTasksButton.click(); + }); + } expect(container.textContent).toContain("配图生成"); expect(container.textContent).toContain("排版优化"); expect(container.textContent).toContain("本组 2 条"); @@ -1279,7 +1321,7 @@ describe("GeneralWorkbenchSidebar", () => { }); const workflowTab = container.querySelector( - 'button[aria-label="打开任务进行时面板"]', + 'button[aria-label="打开任务视图"]', ) as HTMLButtonElement | null; if (workflowTab) { act(() => { diff --git a/src/components/agent/chat/components/GeneralWorkbenchSidebar.tsx b/src/components/agent/chat/components/GeneralWorkbenchSidebar.tsx index fd8f3653a..f7568ea8e 100644 --- a/src/components/agent/chat/components/GeneralWorkbenchSidebar.tsx +++ b/src/components/agent/chat/components/GeneralWorkbenchSidebar.tsx @@ -27,7 +27,7 @@ function GeneralWorkbenchSidebarComponent({ }), ); const { - branchCount, + workflowCount, activeContextCount, visibleExecLogCount, contextPanelProps, @@ -43,7 +43,7 @@ function GeneralWorkbenchSidebarComponent({ activeTab={activeTab} isVersionMode={isVersionMode} activeContextCount={activeContextCount} - branchCount={branchCount} + workflowCount={workflowCount} visibleExecLogCount={visibleExecLogCount} onTabChange={setActiveTab} onRequestCollapse={onRequestCollapse} diff --git a/src/components/agent/chat/components/GeneralWorkbenchSidebarShell.tsx b/src/components/agent/chat/components/GeneralWorkbenchSidebarShell.tsx index 2e6976b94..2339e227c 100644 --- a/src/components/agent/chat/components/GeneralWorkbenchSidebarShell.tsx +++ b/src/components/agent/chat/components/GeneralWorkbenchSidebarShell.tsx @@ -8,7 +8,7 @@ interface GeneralWorkbenchSidebarShellProps { activeTab: GeneralWorkbenchSidebarTab; isVersionMode: boolean; activeContextCount: number; - branchCount: number; + workflowCount: number; visibleExecLogCount: number; onTabChange: (tab: GeneralWorkbenchSidebarTab) => void; onRequestCollapse?: () => void; @@ -18,13 +18,13 @@ interface GeneralWorkbenchSidebarShellProps { } const SIDEBAR_CONTAINER_CLASSNAME = - "relative flex h-full w-[290px] min-w-[290px] flex-col border-r border-slate-200/80 bg-[linear-gradient(180deg,rgba(248,250,252,0.96)_0%,rgba(241,245,249,0.84)_100%)]"; + "relative flex h-full w-[290px] min-w-[290px] flex-col border-r border-slate-200 bg-slate-50"; const SIDEBAR_COLLAPSE_HANDLE_CLASSNAME = - "absolute right-[-10px] top-1/2 z-[2] inline-flex h-[60px] w-4 -translate-y-1/2 items-center justify-center rounded-r-[10px] border border-l-0 border-slate-200/80 bg-white text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-900"; + "absolute right-[-10px] top-1/2 z-[2] inline-flex h-[60px] w-4 -translate-y-1/2 items-center justify-center rounded-r-[10px] border border-l-0 border-slate-200 bg-white text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-900"; const SIDEBAR_HEADER_CLASSNAME = - "border-b border-slate-200/80 bg-white/88 px-4 py-4 backdrop-blur-sm"; + "border-b border-slate-200 bg-white px-4 py-4"; const SIDEBAR_HEADER_META_ROW_CLASSNAME = "flex items-center justify-between gap-2.5"; @@ -33,7 +33,7 @@ const SIDEBAR_HEADER_ACTION_SLOT_CLASSNAME = "mt-[-2px] inline-flex shrink-0 items-center justify-center"; const SIDEBAR_EYEBROW_CLASSNAME = - "text-[10px] font-semibold uppercase tracking-[0.06em] text-slate-500"; + "text-[10px] font-semibold text-slate-500"; const SIDEBAR_TITLE_CLASSNAME = "mt-2.5 text-base font-semibold leading-6 text-slate-900"; @@ -42,7 +42,7 @@ const SIDEBAR_DESCRIPTION_CLASSNAME = "mt-1.5 text-[12px] leading-5 text-slate-500"; const SIDEBAR_TABS_CLASSNAME = - "mt-3 flex gap-1.5 rounded-[18px] border border-slate-200/80 bg-slate-50/80 p-1"; + "mt-3 flex gap-1.5 rounded-[18px] border border-slate-200 bg-slate-100 p-1"; const SIDEBAR_TAB_LABEL_CLASSNAME = "min-w-0 truncate"; @@ -56,7 +56,7 @@ function getSidebarTabButtonClassName(active: boolean) { "flex h-[38px] min-w-0 flex-1 items-center justify-center gap-1 rounded-xl border px-2 text-[11px] font-semibold leading-none transition-colors", active ? "border-slate-300 bg-white text-slate-900 shadow-sm shadow-slate-950/5" - : "border-transparent text-slate-500 hover:border-slate-200/90 hover:bg-white/80 hover:text-slate-900", + : "border-transparent text-slate-500 hover:border-slate-200 hover:bg-white hover:text-slate-900", ); } @@ -75,7 +75,7 @@ function resolveSidebarTitle( return "上下文管理"; } if (activeTab === "workflow") { - return "任务进行时"; + return "任务视图"; } return isVersionMode ? "版本记录" : "运行记录"; } @@ -89,8 +89,8 @@ function resolveSidebarDescription( } if (activeTab === "workflow") { return isVersionMode - ? "跟踪当前任务、后续节点与产物版本。" - : "跟踪当前任务、后续节点与分支进展。"; + ? "聚焦当前任务、后续节点与相关版本。" + : "聚焦当前任务、后续节点与相关分支。"; } return "查看技能调用、工具输出与运行记录。"; } @@ -99,7 +99,7 @@ export function GeneralWorkbenchSidebarShell({ activeTab, isVersionMode, activeContextCount, - branchCount, + workflowCount, visibleExecLogCount, onTabChange, onRequestCollapse, @@ -112,7 +112,10 @@ export function GeneralWorkbenchSidebarShell({ className={SIDEBAR_CONTAINER_CLASSNAME} data-testid="general-workbench-sidebar" > -
+
任务工作台
{headerActionSlot ? ( @@ -130,7 +133,10 @@ export function GeneralWorkbenchSidebarShell({
{resolveSidebarDescription(activeTab, isVersionMode)}
-
+
- - - - - {isVersionMode ? "创建版本快照" : "新建分支任务"} - - {onAddImage ? ( - - - 添加图片 - - ) : null} - {onImportDocument ? ( - - - 导入文稿 - - ) : null} - - - - -
+
- 任务进行时 + 任务视图 {remainingSteps}
-
-
-
+
+ {getStepIcon(currentWorkflowStep?.status ?? "active")} -
+
-
- 当前任务 -
-
- {currentWorkflowStep?.title || "当前流程已完成"} +
+ + 当前任务 + + + {currentWorkflowStep?.title || "当前流程已完成"} + + + {getWorkflowStatusLabel( + currentWorkflowStep?.status ?? "completed", + )} +
{workflowSummaryText}
-
- {workflowProgressLabel} +
+ + {workflowProgressLabel} + + + {remainingSteps > 0 + ? `剩余 ${remainingSteps} 项待处理` + : "当前流程已全部完成"} + + + + + + {Math.max(0, Math.min(100, Math.round(progressPercent)))}% +
- - {getWorkflowStatusLabel(currentWorkflowStep?.status ?? "completed")} -
-
- {remainingSteps > 0 - ? `剩余 ${remainingSteps} 项待处理` - : "当前流程已全部完成"} -
-
-
-
{sortedWorkflowSteps.map((step) => (
- {getStepIcon(step.status)} + + {getStepIcon(step.status)} +
{step.title}
@@ -610,50 +673,94 @@ function GeneralWorkbenchWorkflowPanelComponent({
-
+
- {isVersionMode ? "产物版本" : "篇内分支"} - - {branchItems.length} + {branchSectionTitle} + + + {branchItems.length} + + + + + + + + + {branchCreateLabel} + + {onAddImage ? ( + + + 添加图片 + + ) : null} + {onImportDocument ? ( + + + 导入文稿 + + ) : null} + +
{branchItems.length === 0 ? ( - - {isVersionMode - ? "暂无文稿版本,先生成或创建快照" - : "暂无分支任务"} - + {getEmptyBranchText(isVersionMode)} ) : ( - branchItems.map((item) => ( + sortedBranchItems.map((item) => ( - - onSwitchTopic(item.id)}> - {item.title} - - - {getBranchStatusText(item.status)} - - {!isVersionMode ? ( - onDeleteTopic(item.id)} - aria-label="删除分支" - > - - - ) : null} + +
+
+ onSwitchTopic(item.id)}> + {item.title} + + + {item.isCurrent + ? "当前焦点" + : getBranchStatusText(item.status)} + + {!isVersionMode ? ( + onDeleteTopic(item.id)} + aria-label="删除分支" + > + + + ) : null} +
+ {getBranchMetaText(item, isVersionMode)} +
onSetBranchStatus(item.id, "merged")} > - {isVersionMode ? "设为主稿" : "采纳到主稿"} + {branchPrimaryActionLabel} onSetBranchStatus(item.id, "pending")} > - {isVersionMode ? "标记待评审" : "标记待决策"} + {branchSecondaryActionLabel}
diff --git a/src/components/agent/chat/components/HarnessStatusPanel.test.tsx b/src/components/agent/chat/components/HarnessStatusPanel.test.tsx index a4b05aa78..78216fdbf 100644 --- a/src/components/agent/chat/components/HarnessStatusPanel.test.tsx +++ b/src/components/agent/chat/components/HarnessStatusPanel.test.tsx @@ -102,15 +102,6 @@ function renderPanel( root.render( { expect(document.body.textContent).toContain("429 rate limit"); }); - it("存在 selectedTeam 时应在工作台展示当前协作设置", () => { + it("存在 selectedTeam 时应在工作台展示当前任务分工", () => { renderPanel({ selectedTeamLabel: "前端联调团队", selectedTeamSummary: "分析、实现、验证三段式推进。", @@ -1335,8 +1326,8 @@ describe("HarnessStatusPanel", () => { ], }); - expect(document.body.textContent).toContain("协作设置"); - expect(document.body.textContent).toContain("当前协作设置"); + expect(document.body.textContent).toContain("任务分工"); + expect(document.body.textContent).toContain("当前任务分工"); expect(document.body.textContent).toContain("前端联调团队"); expect(document.body.textContent).toContain("分析、实现、验证三段式推进。"); expect(document.body.textContent).toContain("模板 code-explorer"); @@ -1344,7 +1335,7 @@ describe("HarnessStatusPanel", () => { expect(document.body.textContent).toContain("repo-exploration"); }); - it("存在真实 child session 时应优先展示协作会话摘要,并将旧 scheduler 降级为兼容轨迹", () => { + it("存在真实 child session 时应优先展示子任务摘要", () => { renderPanel({ childSubagentSessions: [ { @@ -1370,40 +1361,15 @@ describe("HarnessStatusPanel", () => { role_hint: "executor", }, ], - compatSubagentRuntime: { - isRunning: true, - progress: { - total: 2, - completed: 1, - failed: 0, - running: 1, - pending: 0, - skipped: 0, - cancelled: false, - currentTasks: ["legacy-task-1"], - percentage: 50, - }, - result: null, - error: null, - summary: "正在执行:legacy-task-1", - recentActivity: [ - { - id: "compat:1:started", - summary: "开始调度 2 个子任务", - }, - ], - hasSignals: true, - }, }); expect(document.body.textContent).toContain("任务进行中"); - expect(document.body.textContent).toContain("协作会话"); - expect(document.body.textContent).toContain("当前协作会话"); - expect(document.body.textContent).toContain("实时协作会话"); - expect(document.body.textContent).toContain("兼容模式"); - expect(document.body.textContent).toContain("旧链路"); - expect(document.body.textContent).not.toContain("兼容调度进度"); - expect(document.body.textContent).not.toContain("兼容调度轨迹"); + expect(document.body.textContent).toContain("子任务"); + expect(document.body.textContent).toContain("当前子任务"); + expect(document.body.textContent).toContain("实时子任务"); + expect(document.body.textContent).toContain("类型:子任务"); + expect(document.body.textContent).not.toContain("协作回退"); + expect(document.body.textContent).not.toContain("回退链路"); expect(document.body.textContent).toContain("研究代理"); expect(document.body.textContent).toContain("实现代理"); }); diff --git a/src/components/agent/chat/components/HarnessStatusPanel.tsx b/src/components/agent/chat/components/HarnessStatusPanel.tsx index 653bb7ffa..75a222a66 100644 --- a/src/components/agent/chat/components/HarnessStatusPanel.tsx +++ b/src/components/agent/chat/components/HarnessStatusPanel.tsx @@ -109,7 +109,6 @@ import { } from "../utils/workflowStepPresentation"; import { buildThreadReliabilityView } from "../utils/threadReliabilityView"; import { resolveTeamWorkspaceStableProcessingLabel } from "../utils/teamWorkspaceCopy"; -import type { CompatSubagentRuntimeDisplaySnapshot } from "../utils/compatSubagentRuntime"; import type { TeamRoleDefinition } from "../utils/teamDefinitions"; import type { TeamMemorySnapshot } from "@/lib/teamMemorySync"; import { AgentThreadReliabilityPanel } from "./AgentThreadReliabilityPanel"; @@ -135,7 +134,6 @@ export interface HarnessFilePreviewResult { interface HarnessStatusPanelProps { harnessState: HarnessSessionState; - compatSubagentRuntime: CompatSubagentRuntimeDisplaySnapshot; environment: HarnessEnvironmentSummary; layout?: "default" | "sidebar" | "dialog"; onLoadFilePreview?: (path: string) => Promise; @@ -340,12 +338,12 @@ function resolveSubagentRuntimeStatusVariant( function resolveSubagentSessionTypeLabel(value?: string): string { switch (value) { case "sub_agent": - return "协作成员"; + return "子任务"; case "fork": - return "分支协作"; + return "分支任务"; case "user": default: - return value?.trim() || "协作会话"; + return value?.trim() || "任务会话"; } } @@ -1535,115 +1533,6 @@ function SummaryCard({ ); } -function CompatSubagentFallbackCard({ - snapshot, - condensed = false, - onOpenUrl, -}: { - snapshot: CompatSubagentRuntimeDisplaySnapshot; - condensed?: boolean; - onOpenUrl: (url: string) => void | Promise; -}) { - if (!snapshot.hasSignals) { - return null; - } - - const statusVariant: ComponentProps["variant"] = snapshot.error - ? "destructive" - : snapshot.isRunning - ? "secondary" - : "outline"; - const statusLabel = snapshot.error - ? "异常" - : snapshot.isRunning - ? snapshot.progress - ? `${snapshot.progress.completed}/${snapshot.progress.total}` - : "处理中" - : snapshot.result - ? "已结束" - : `${snapshot.recentActivity.length} 条`; - const primarySummary = snapshot.progress - ? `进度 ${snapshot.progress.completed}/${snapshot.progress.total}${ - snapshot.progress.currentTasks.length > 0 - ? ` · 当前任务 ${snapshot.progress.currentTasks.join("、")}` - : "" - }` - : snapshot.summary || - snapshot.recentActivity[0]?.summary || - snapshot.error || - snapshot.result?.mergedSummary || - "检测到兼容调度信号"; - const visibleActivity = condensed - ? snapshot.recentActivity.slice(0, 1) - : snapshot.recentActivity.slice(0, 3); - - return ( -
-
-
-
- -
兼容模式
- 旧链路 -
-
- 仅用于承接旧调度信号,不作为当前协作视图的主事实源。 -
-
- {statusLabel} -
- -
- - - {visibleActivity.length > 0 && - visibleActivity[0]?.summary !== primarySummary ? ( -
-
- 最近兼容轨迹 -
-
- {visibleActivity.map((item) => ( - - ))} -
-
- ) : null} - - {!condensed && - snapshot.result?.mergedSummary && - snapshot.result.mergedSummary !== primarySummary ? ( -
-
- 兼容汇总 -
- -
- ) : null} - - {!condensed && snapshot.error && snapshot.error !== primarySummary ? ( -
- -
- ) : null} -
-
- ); -} - function InventoryStatCard({ title, value, @@ -1696,7 +1585,6 @@ function Section({ export function HarnessStatusPanel({ harnessState, - compatSubagentRuntime, environment, layout = "default", onLoadFilePreview, @@ -2064,7 +1952,6 @@ export function HarnessStatusPanel({ () => summarizeChildSubagentSessions(childSubagentSessions), [childSubagentSessions], ); - const hasCompatSchedulerSignals = compatSubagentRuntime.hasSignals; const hasSelectedTeamConfig = Boolean(selectedTeamLabel?.trim()) || Boolean(selectedTeamSummary?.trim()) || @@ -2229,7 +2116,7 @@ export function HarnessStatusPanel({ const sections: HarnessSectionNavItem[] = []; if (hasSelectedTeamConfig) { - sections.push({ key: "team_config", label: "协作设置" }); + sections.push({ key: "team_config", label: "任务分工" }); } if (runtimeTaskPresentation) { @@ -2264,10 +2151,9 @@ export function HarnessStatusPanel({ } if ( realTeamSummary.total > 0 || - harnessState.delegatedTasks.length > 0 || - hasCompatSchedulerSignals + harnessState.delegatedTasks.length > 0 ) { - sections.push({ key: "delegation", label: "协作成员" }); + sections.push({ key: "delegation", label: "子任务" }); } if (harnessState.latestContextTrace.length > 0) { sections.push({ key: "context", label: "上下文轨迹" }); @@ -2291,7 +2177,6 @@ export function HarnessStatusPanel({ harnessState.recentFileEvents.length, hasHandoffSection, hasSelectedTeamConfig, - hasCompatSchedulerSignals, realTeamSummary.total, runtimeTaskPresentation, threadReliabilityView.shouldRender, @@ -2337,7 +2222,7 @@ export function HarnessStatusPanel({ if (hasSelectedTeamConfig) { cards.push({ sectionKey: "team_config", - title: "协作设置", + title: "任务分工", value: selectedTeamLabel?.trim() || `${selectedTeamRoles?.length || 0} 个角色`, @@ -2345,7 +2230,7 @@ export function HarnessStatusPanel({ selectedTeamSummary?.trim() || ((selectedTeamRoles?.length || 0) > 0 ? `已配置 ${selectedTeamRoles?.length || 0} 个角色` - : "本次已启用协作设置"), + : "本次已启用任务分工"), icon: Workflow, }); } @@ -2364,7 +2249,7 @@ export function HarnessStatusPanel({ if (realTeamSummary.total > 0) { cards.push({ sectionKey: "delegation", - title: "协作成员", + title: "子任务", value: realTeamSummary.active > 0 ? `${realTeamSummary.active}/${realTeamSummary.total}` @@ -2696,11 +2581,6 @@ export function HarnessStatusPanel({ 任务进行中 - ) : compatSubagentRuntime.isRunning ? ( - - - 兼容调度中 - ) : null}

{description}

@@ -2811,7 +2691,7 @@ export function HarnessStatusPanel({ {hasSelectedTeamConfig ? (
0 ? `${selectedTeamRoles.length} 个角色` @@ -2823,7 +2703,7 @@ export function HarnessStatusPanel({
- {selectedTeamLabel || "当前已启用协作方案"} + {selectedTeamLabel || "当前已启用任务方案"}
{selectedTeamSummary ? (
@@ -2831,7 +2711,7 @@ export function HarnessStatusPanel({
) : (
- 本次会优先参考所选协作方案的角色分工,按需邀请协作成员一起处理。 + 本次会优先参考所选任务方案的角色分工,按需拆出子任务继续处理。
)}
@@ -3140,7 +3020,7 @@ export function HarnessStatusPanel({ hint={`待开始 ${handoffBundle.todo_pending} · 进行中 ${handoffBundle.todo_in_progress}`} /> @@ -5712,16 +5592,15 @@ export function HarnessStatusPanel({ ) : null} {realTeamSummary.total > 0 || - harnessState.delegatedTasks.length > 0 || - hasCompatSchedulerSignals ? ( + harnessState.delegatedTasks.length > 0 ? (
0 ? `处理中 ${realTeamSummary.active}` : realTeamSummary.total > 0 - ? `${realTeamSummary.total} 个协作` + ? `${realTeamSummary.total} 个子任务` : harnessState.delegatedTasks.length > 0 ? `${harnessState.delegatedTasks.length} 条` : undefined @@ -5733,7 +5612,7 @@ export function HarnessStatusPanel({
- 当前协作会话 + 当前子任务
{realTeamSummary.total} 个 @@ -5802,7 +5681,7 @@ export function HarnessStatusPanel({ {childSubagentSessions.length > 0 ? (
- 实时协作会话 + 实时子任务
{childSubagentSessions.map((session) => (
) : null} - - 0} - onOpenUrl={handleOpenExternalLink} - />
) : null} diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx index 828055670..33b898d73 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx @@ -14,6 +14,7 @@ import type { BuiltinInputCommand } from "../../../skill-selection/builtinComman import { TeamSelector } from "./TeamSelector"; import { InputbarWorkflowStatusPanel } from "./InputbarWorkflowStatusPanel"; import { InputbarModelExtra } from "./InputbarModelExtra"; +import { InputbarPromptCacheNotice } from "./InputbarPromptCacheNotice"; import { InputbarVisionCapabilityNotice } from "./InputbarVisionCapabilityNotice"; import { InputbarExecutionStrategySelect } from "./InputbarExecutionStrategySelect"; import { InputbarAccessModeSelect } from "./InputbarAccessModeSelect"; @@ -158,10 +159,14 @@ export const InputbarComposerSection: React.FC< currentPendingImages.length > 0 && Boolean(resolvedProviderType?.trim()) && Boolean(resolvedModel?.trim()); + const shouldShowPromptCacheNotice = Boolean(resolvedProviderType?.trim()); const resolvedTopExtra = - topExtra || shouldShowVisionNotice ? ( + topExtra || shouldShowPromptCacheNotice || shouldShowVisionNotice ? ( <> {topExtra} + {shouldShowPromptCacheNotice && resolvedProviderType ? ( + + ) : null} {shouldShowVisionNotice && resolvedProviderType && resolvedModel ? ( ) : null} - {!showAdvancedControls && shouldShowModelControls && !hasConfiguredModel ? ( + {!showAdvancedControls && + shouldShowModelControls && + !hasConfiguredModel ? ( - {showSkillSelector ? ( - - ) : null} + {showSkillSelector ? : null} {shouldShowTeamSelector ? ( ({ + useConfiguredProviders: (options: unknown) => + mockUseConfiguredProviders(options), + findConfiguredProviderBySelection: ( + providers: Array<{ key: string; providerId?: string }>, + selection?: string | null, + ) => { + const normalizedSelection = (selection || "").trim().toLowerCase(); + const keyMatch = + providers.find( + (provider) => provider.key.trim().toLowerCase() === normalizedSelection, + ) ?? null; + const providerIdMatch = + providers.find( + (provider) => + (provider.providerId || "").trim().toLowerCase() === + normalizedSelection, + ) ?? null; + + if (keyMatch && providerIdMatch && keyMatch !== providerIdMatch) { + if (!keyMatch.providerId && providerIdMatch.providerId) { + return providerIdMatch; + } + } + + return keyMatch ?? providerIdMatch ?? null; + }, + resolveConfiguredProviderPromptCacheSupportNotice: ( + providers: Array<{ key: string; providerId?: string; type?: string }>, + selection?: string | null, + ) => { + const normalizedSelection = (selection || "").trim().toLowerCase(); + const selectedProvider = + providers.find( + (provider) => provider.key.trim().toLowerCase() === normalizedSelection, + ) ?? + providers.find( + (provider) => + (provider.providerId || "").trim().toLowerCase() === + normalizedSelection, + ) ?? + null; + const normalizedConfiguredType = ( + selectedProvider?.type || "" + ).trim().toLowerCase(); + + if (normalizedConfiguredType === "anthropic-compatible") { + return { + label: "未声明自动缓存", + detail: + "当前 Provider 未声明支持自动 Prompt Cache;如需复用前缀,请使用显式 cache_control 标记。", + source: "configured_provider" as const, + }; + } + + if (normalizedSelection === "anthropic-compatible") { + return { + label: "未声明自动缓存", + detail: + "当前 Provider 未声明支持自动 Prompt Cache;当前提示基于 Provider 选择器回退判断,如需复用前缀,请使用显式 cache_control 标记。", + source: "selection_fallback" as const, + }; + } + + return null; + }, +})); + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + + mockUseConfiguredProviders.mockReturnValue({ + providers: [], + loading: false, + }); +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) break; + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.clearAllMocks(); +}); + +function renderNotice( + props?: Partial>, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + mountedRoots.push({ root, container }); + return container; +} + +describe("InputbarPromptCacheNotice", () => { + it("anthropic-compatible 自定义 Provider 应展示发送前提示", () => { + mockUseConfiguredProviders.mockReturnValue({ + providers: [ + { + key: "custom-provider-id", + label: "GLM Anthropic", + registryId: "custom-provider-id", + type: "anthropic-compatible", + providerId: "custom-provider-id", + }, + ], + loading: false, + }); + + const container = renderNotice(); + + expect( + container.querySelector('[data-testid="inputbar-prompt-cache-warning"]'), + ).not.toBeNull(); + expect(container.textContent).toContain("未声明支持自动 Prompt Cache"); + expect(container.textContent).toContain("cache_control"); + }); + + it("官方 Anthropic Provider 不应展示发送前提示", () => { + mockUseConfiguredProviders.mockReturnValue({ + providers: [ + { + key: "anthropic", + label: "Anthropic", + registryId: "anthropic", + type: "anthropic", + providerId: "anthropic", + }, + ], + loading: false, + }); + + const container = renderNotice({ + providerType: "anthropic", + }); + + expect( + container.querySelector('[data-testid="inputbar-prompt-cache-warning"]'), + ).toBeNull(); + }); + + it("未解析到真实 Provider 时,应允许按选择器回退展示提示", () => { + const container = renderNotice({ + providerType: "anthropic-compatible", + }); + + const warning = container.querySelector( + '[data-testid="inputbar-prompt-cache-warning"]', + ); + expect(warning).not.toBeNull(); + expect(warning?.getAttribute("title")).toContain("回退判断"); + }); +}); diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarPromptCacheNotice.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarPromptCacheNotice.tsx new file mode 100644 index 000000000..e347521ce --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/InputbarPromptCacheNotice.tsx @@ -0,0 +1,49 @@ +import React, { useMemo } from "react"; +import { AlertCircle } from "lucide-react"; +import { + resolveConfiguredProviderPromptCacheSupportNotice, + useConfiguredProviders, +} from "@/hooks/useConfiguredProviders"; + +interface InputbarPromptCacheNoticeProps { + providerType: string; +} + +export const InputbarPromptCacheNotice: React.FC< + InputbarPromptCacheNoticeProps +> = ({ providerType }) => { + const shouldInspectCapability = Boolean(providerType.trim()); + + const { providers, loading: providersLoading } = useConfiguredProviders({ + autoLoad: shouldInspectCapability, + }); + + const notice = useMemo(() => { + if (!shouldInspectCapability || providersLoading) { + return null; + } + + return resolveConfiguredProviderPromptCacheSupportNotice( + providers, + providerType, + ); + }, [providerType, providers, providersLoading, shouldInspectCapability]); + + if (!notice) { + return null; + } + + return ( +
+ + + 当前 Provider 未声明支持自动 Prompt Cache;如需复用前缀,请使用显式{" "} + cache_control 标记 + +
+ ); +}; diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarTools.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarTools.tsx index c9fc6da9a..0620c0500 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarTools.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarTools.tsx @@ -51,10 +51,10 @@ export const InputbarTools: React.FC = ({ onClick={() => onToolClick("subagent_mode")} className={activeTools["subagent_mode"] ? "active" : ""} aria-pressed={activeTools["subagent_mode"]} - title={`多代理偏好${activeTools["subagent_mode"] ? "已开启" : "已关闭"}`} + title={`任务拆分偏好${activeTools["subagent_mode"] ? "已开启" : "已关闭"}`} > - 多代理 + 任务拆分 ) : null} diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarWorkflowStatusPanel.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarWorkflowStatusPanel.tsx index 6ad5a5bce..7e413ec82 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarWorkflowStatusPanel.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarWorkflowStatusPanel.tsx @@ -366,7 +366,7 @@ export function InputbarWorkflowStatusPanel({ {renderToneIcon(summaryTone)} - 任务进行时 + 任务视图 {summaryTitle} diff --git a/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx index 008968c4a..4241cc615 100644 --- a/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx +++ b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx @@ -561,7 +561,7 @@ export const TeamSelectorPanel: React.FC = ({
{inspectorTeam.description || - "这套分工会作为主代理拆分子任务时的协作参考。"} + "这套分工会作为主代理拆分子任务时的分工参考。"}
@@ -963,7 +963,7 @@ export const TeamSelectorPanel: React.FC = ({ {selectedProfile && suggestedSkillIds.length > 0 ? ( <>推荐技能:{suggestedSkillIds.join("、")} ) : ( - "skillIds 会透传给运行时,用于约束子代理的技能集。" + "skillIds 会透传给运行时,用于约束子任务可用的技能集。" )}
@@ -1011,7 +1011,7 @@ export const TeamSelectorPanel: React.FC = ({ 任务分工配置
- 只在当前任务适合拆分推进时,为主代理提供分工结构参考。 + 只在当前任务适合拆分推进时,为当前任务提供任务分工结构参考。
{isProjectScopedCustomTeam diff --git a/src/components/agent/chat/components/Inputbar/hooks/useInputbarToolState.ts b/src/components/agent/chat/components/Inputbar/hooks/useInputbarToolState.ts index 331d1af4b..0521a28a7 100644 --- a/src/components/agent/chat/components/Inputbar/hooks/useInputbarToolState.ts +++ b/src/components/agent/chat/components/Inputbar/hooks/useInputbarToolState.ts @@ -90,7 +90,9 @@ export function useInputbarToolState({ thinking: thinkingEnabled, subagent: nextSubagent, }); - toast.info(`多代理${nextSubagent ? "偏好已开启" : "偏好已关闭"}`); + toast.info( + `任务拆分${nextSubagent ? "偏好已开启" : "偏好已关闭"}`, + ); break; } case "attach": @@ -124,7 +126,7 @@ export function useInputbarToolState({ thinking: thinkingEnabled, subagent: enabled, }); - toast.info(`多代理${enabled ? "偏好已开启" : "偏好已关闭"}`); + toast.info(`任务拆分${enabled ? "偏好已开启" : "偏好已关闭"}`); }, [subagentEnabled, thinkingEnabled, updateToolStates, webSearchEnabled], ); diff --git a/src/components/agent/chat/components/Inputbar/index.test.tsx b/src/components/agent/chat/components/Inputbar/index.test.tsx index ac822cc51..d981329e2 100644 --- a/src/components/agent/chat/components/Inputbar/index.test.tsx +++ b/src/components/agent/chat/components/Inputbar/index.test.tsx @@ -45,7 +45,7 @@ const mockInputbarCore = vi.fn( data-testid="toggle-subagent-mode" onClick={() => props.onToolClick?.("subagent_mode")} > - 切换多代理 + 切换任务拆分 {props.activeTools?.subagent_mode ? "on" : "off"} @@ -139,6 +139,12 @@ vi.mock("../ChatModelSelector", () => ({ ChatModelSelector: () =>
, })); +vi.mock("./components/InputbarPromptCacheNotice", () => ({ + InputbarPromptCacheNotice: (props: { providerType: string }) => ( +
{props.providerType}
+ ), +})); + vi.mock("@/lib/dev-bridge", () => ({ safeInvoke: vi.fn(async () => []), })); @@ -334,7 +340,7 @@ describe("Inputbar", () => { ], overlayAccessory: ( ), }); @@ -472,9 +478,7 @@ describe("Inputbar", () => { expect( container.querySelector('[data-testid="inputbar-plan-toggle"]'), ).toBeNull(); - expect( - container.querySelector('select[aria-label="权限模式"]'), - ).toBeNull(); + expect(container.querySelector('select[aria-label="权限模式"]')).toBeNull(); expandAdvancedControls(container); @@ -824,6 +828,25 @@ describe("Inputbar", () => { expect(latestCall.leftExtra).toBeDefined(); }); + it("已选择 Provider 时应将 prompt cache 提示组件挂到输入区顶部", async () => { + const { container } = renderInputbar({ + providerType: "custom-provider-id", + setProviderType: vi.fn(), + model: "glm-5.1", + setModel: vi.fn(), + }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect( + container.querySelector('[data-testid="inputbar-prompt-cache-warning"]'), + ).toBeTruthy(); + expect(container.textContent).toContain("custom-provider-id"); + }); + it("任务中心工作区应使用继续推进型输入提示", async () => { renderInputbar({ variant: "workspace", @@ -924,7 +947,7 @@ describe("Inputbar", () => { await Promise.resolve(); }); - expect(container.textContent).toContain("任务进行时"); + expect(container.textContent).toContain("任务视图"); expect(container.textContent).toContain("检索项目素材"); expect(container.textContent).toContain("任务队列"); expect(container.querySelector('[data-testid="inputbar-core"]')).toBeNull(); @@ -956,9 +979,9 @@ describe("Inputbar", () => { expect(container.textContent).toContain("检索项目素材"); - const collapseButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.getAttribute("aria-label") === "折叠任务队列", - ); + const collapseButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.getAttribute("aria-label") === "折叠任务队列"); expect(collapseButton).toBeTruthy(); act(() => { @@ -1028,7 +1051,7 @@ describe("Inputbar", () => { await Promise.resolve(); }); - expect(container.textContent).toContain("任务进行时"); + expect(container.textContent).toContain("任务视图"); expect(container.textContent).toContain("检索项目素材"); expect(container.querySelector('[data-testid="inputbar-core"]')).toBeNull(); }); @@ -1057,7 +1080,7 @@ describe("Inputbar", () => { expect( container.querySelector('[data-testid="inputbar-core"]'), ).toBeTruthy(); - expect(container.textContent).toContain("任务进行时"); + expect(container.textContent).toContain("任务视图"); expect(container.textContent).toContain("等待用户确认选题"); expect(container.textContent).not.toContain("正在生成中"); }); diff --git a/src/components/agent/chat/components/MessageList.test.tsx b/src/components/agent/chat/components/MessageList.test.tsx index 1efd19031..f348a9318 100644 --- a/src/components/agent/chat/components/MessageList.test.tsx +++ b/src/components/agent/chat/components/MessageList.test.tsx @@ -7,6 +7,77 @@ import type { Message } from "../types"; const IMAGE_WORKBENCH_FOCUS_EVENT = "lime:image-workbench-focus"; const VIDEO_WORKBENCH_TASK_ACTION_EVENT = "lime:video-workbench-task-action"; +type MockConfiguredProvider = { + key: string; + label?: string; + registryId?: string; + type?: string; + providerId?: string; +}; + +const mockUseConfiguredProviders = vi.fn((_options?: unknown) => ({ + providers: [] as MockConfiguredProvider[], + loading: false, +})); +const mockFindConfiguredProviderBySelection = vi.fn( + ( + _providers: MockConfiguredProvider[], + _selection?: string | null, + ): MockConfiguredProvider | null => null, +); +const mockTokenUsageDisplay = vi.fn( + ({ + promptCacheNotice, + }: { + promptCacheNotice?: { + label?: string; + } | null; + }) => ( +
+ {promptCacheNotice?.label || "token-usage-display"} +
+ ), +); + +vi.mock("@/hooks/useConfiguredProviders", () => ({ + useConfiguredProviders: (options?: unknown) => + mockUseConfiguredProviders(options), + findConfiguredProviderBySelection: ( + providers: MockConfiguredProvider[], + selection?: string | null, + ) => mockFindConfiguredProviderBySelection(providers, selection), + resolveConfiguredProviderPromptCacheSupportNotice: ( + providers: MockConfiguredProvider[], + selection?: string | null, + ) => { + const selectedProvider = + mockFindConfiguredProviderBySelection(providers, selection); + const normalizedConfiguredType = ( + selectedProvider?.type || "" + ).trim().toLowerCase(); + const normalizedSelection = (selection || "").trim().toLowerCase(); + + if (normalizedConfiguredType === "anthropic-compatible") { + return { + label: "未声明自动缓存", + detail: + "当前 Provider 未声明支持自动 Prompt Cache;如需复用前缀,请使用显式 cache_control 标记。", + source: "configured_provider" as const, + }; + } + + if (normalizedSelection === "anthropic-compatible") { + return { + label: "未声明自动缓存", + detail: + "当前 Provider 未声明支持自动 Prompt Cache;当前提示基于 Provider 选择器回退判断,如需复用前缀,请使用显式 cache_control 标记。", + source: "selection_fallback" as const, + }; + } + + return null; + }, +})); vi.mock("./MarkdownRenderer", () => ({ MarkdownRenderer: ({ content }: { content: string }) => ( @@ -83,7 +154,11 @@ vi.mock("./StreamingRenderer", () => ({ })); vi.mock("./TokenUsageDisplay", () => ({ - TokenUsageDisplay: () =>
, + TokenUsageDisplay: (props: { + promptCacheNotice?: { + label?: string; + } | null; + }) => mockTokenUsageDisplay(props), })); vi.mock("./AgentThreadTimeline", () => ({ @@ -121,6 +196,11 @@ afterEach(() => { mounted.container.remove(); } vi.clearAllMocks(); + mockUseConfiguredProviders.mockImplementation(() => ({ + providers: [], + loading: false, + })); + mockFindConfiguredProviderBySelection.mockImplementation(() => null); }); function render( @@ -235,6 +315,58 @@ describe("MessageList", () => { ); }); + it("anthropic-compatible 自定义 Provider 无缓存命中时应透传自动缓存提示", () => { + const now = new Date(); + const messages: Message[] = [ + { + id: "msg-assistant-usage", + role: "assistant", + content: "本轮已完成。", + timestamp: now, + usage: { + input_tokens: 1_500, + output_tokens: 500, + cached_input_tokens: 0, + }, + }, + ]; + + mockUseConfiguredProviders.mockImplementation(() => ({ + providers: [ + { + key: "custom-provider-id", + label: "GLM Anthropic", + registryId: "custom-provider-id", + type: "anthropic-compatible", + providerId: "custom-provider-id", + }, + ], + loading: false, + })); + mockFindConfiguredProviderBySelection.mockImplementation( + ( + providers: MockConfiguredProvider[], + selection?: string | null, + ): MockConfiguredProvider | null => + Array.isArray(providers) + ? (providers.find((provider) => provider.key === selection) ?? null) + : null, + ); + + const container = render(messages, { + providerType: "custom-provider-id", + }); + + expect(container.textContent).toContain("未声明自动缓存"); + expect(mockTokenUsageDisplay).toHaveBeenCalledWith( + expect.objectContaining({ + promptCacheNotice: expect.objectContaining({ + label: "未声明自动缓存", + }), + }), + ); + }); + it("图片任务消息卡应在聊天区渲染预览并支持展开图片画布", () => { const now = new Date(); const messages: Message[] = [ diff --git a/src/components/agent/chat/components/MessageList.tsx b/src/components/agent/chat/components/MessageList.tsx index 07627b95c..dc5f94996 100644 --- a/src/components/agent/chat/components/MessageList.tsx +++ b/src/components/agent/chat/components/MessageList.tsx @@ -18,6 +18,10 @@ import { import { Button } from "@/components/ui/button"; import { toast } from "sonner"; import type { Artifact } from "@/lib/artifact/types"; +import { + resolveConfiguredProviderPromptCacheSupportNotice, + useConfiguredProviders, +} from "@/hooks/useConfiguredProviders"; import { resolveArtifactProtocolFilePath } from "@/lib/artifact-protocol"; import { MessageListContainer, @@ -68,9 +72,7 @@ import { resolveSiteSavedContentTargetRelativePath, } from "../utils/siteToolResultSummary"; import logoImg from "/logo.png"; -import { - type ArtifactTimelineOpenTarget, -} from "../utils/artifactTimelineNavigation"; +import { type ArtifactTimelineOpenTarget } from "../utils/artifactTimelineNavigation"; interface MessageListProps { messages: Message[]; @@ -157,6 +159,8 @@ interface MessageListProps { timelineFocusRequestKey?: number; /** 当前由聊天区底部承载的待处理 A2UI 来源 */ activePendingA2UISource?: PendingA2UISource | null; + /** 当前会话的 provider 选择器 */ + providerType?: string; } function isDeferredTimelineItem(item: AgentThreadItem): boolean { @@ -403,6 +407,7 @@ const MessageListInner: React.FC = ({ focusedTimelineItemId = null, timelineFocusRequestKey = 0, activePendingA2UISource = null, + providerType, }) => { const scrollRef = useRef(null); const containerRef = useRef(null); @@ -412,6 +417,34 @@ const MessageListInner: React.FC = ({ const [isUserScrolling, setIsUserScrolling] = useState(false); const [shouldAutoScroll, setShouldAutoScroll] = useState(true); + const shouldInspectPromptCacheNotice = useMemo( + () => + Boolean( + providerType?.trim() && + messages.some( + (msg) => + msg.role === "assistant" && + !msg.isThinking && + msg.usage && + (msg.usage.cached_input_tokens ?? 0) <= 0, + ), + ), + [messages, providerType], + ); + const { providers } = useConfiguredProviders({ + autoLoad: shouldInspectPromptCacheNotice, + }); + const promptCacheNotice = useMemo( + () => + shouldInspectPromptCacheNotice + ? resolveConfiguredProviderPromptCacheSupportNotice( + providers, + providerType, + ) + : null, + [providerType, providers, shouldInspectPromptCacheNotice], + ); + const visibleMessages = useMemo( () => messages.filter((msg) => { @@ -581,7 +614,9 @@ const MessageListInner: React.FC = ({ : []; const trailingTimelineItems = timeline ? dedupeDeferredTimelineItems( - timelineConversationItems.filter((item) => isDeferredTimelineItem(item)), + timelineConversationItems.filter((item) => + isDeferredTimelineItem(item), + ), ).filter( (item) => item.type !== "file_artifact" || @@ -599,7 +634,8 @@ const MessageListInner: React.FC = ({ const hasTrailingArtifactTimelineItems = trailingTimelineItems.some( (item) => item.type === "file_artifact", ); - const timelineActionRequests = inlineProcessCoverage.actionRequestCounts.size + const timelineActionRequests = inlineProcessCoverage.actionRequestCounts + .size ? undefined : msg.actionRequests; const primaryActionRequests = @@ -643,12 +679,13 @@ const MessageListInner: React.FC = ({ : null; const shouldRenderMessageCanvasShortcut = Boolean( messageSavedSiteContentTarget && - onOpenSavedSiteContent && - !hasTrailingArtifactTimelineItems, + onOpenSavedSiteContent && + !hasTrailingArtifactTimelineItems, ); const messageCanvasShortcutTitle = messageSavedSiteContentTarget - ? resolveSiteSavedContentTargetDisplayName(messageSavedSiteContentTarget) || - "导出稿" + ? resolveSiteSavedContentTargetDisplayName( + messageSavedSiteContentTarget, + ) || "导出稿" : "文件"; const messageCanvasShortcutPath = messageSavedSiteContentTarget ? resolveSiteSavedContentTargetRelativePath(messageSavedSiteContentTarget) @@ -846,7 +883,14 @@ const MessageListInner: React.FC = ({ ) : null} {msg.role === "assistant" && !msg.isThinking && msg.usage && ( - + )} {msg.role === "assistant" && diff --git a/src/components/agent/chat/components/README.md b/src/components/agent/chat/components/README.md index c826de081..3a46930e9 100644 --- a/src/components/agent/chat/components/README.md +++ b/src/components/agent/chat/components/README.md @@ -34,15 +34,19 @@ Agent 聊天界面的 UI 组件集合。 | `team-workspace-board/teamWorkspaceCanvasControllerState.ts` | Team 画布控制纯状态工具,统一承载视口更新、lane 布局写回与置顶规则 | | `team-workspace-board/useTeamWorkspaceActivityPreviewSync.ts` | Team 过程预览同步 hook,统一承载请求去重、选中成员轮询与 stale preview 预取 | | `team-workspace-board/useTeamWorkspaceActivityPreviews.ts` | Team 过程预览 hook,统一承载成员最近过程的预取、轮询与 stale preview 预热 | +| `team-workspace-board/useTeamWorkspaceBoardActionUiState.ts` | Team Board action UI 状态 hook,统一承载选中成员输入草稿、pending 状态与派生文案输入值 | | `team-workspace-board/useTeamWorkspaceBoardActions.ts` | Team Board 操作控制 hook,统一承载成员发送/等待/关闭与 Team 级等待/收尾的 pending 状态 | | `team-workspace-board/useTeamWorkspaceBoardCanvasRuntime.ts` | Team Board 画布运行态组合 hook,统一承载 lane 拼装、stage hint 与自由画布控制接线 | | `team-workspace-board/useTeamWorkspaceBoardComposer.ts` | Team Board 高阶编排 hook,统一承载 runtime、activity、canvas、presentation 与 shell/empty-shell props 组合 | | `team-workspace-board/useTeamWorkspaceBoardFormationState.ts` | Team Board 分工展示前置 hook,统一承载 selected team 归一化与 runtime formation / plan display 组装 | +| `team-workspace-board/useTeamWorkspaceBoardOperationState.ts` | Team Board 运行态派生 hook,统一承载 team operation、wait summary 与 session control 组装 | | `team-workspace-board/useTeamWorkspaceBoardRuntimeState.ts` | Team Board 运行态组合 hook,统一承载 team 轨迹、焦点成员、会话控制与交互 action 组装 | +| `team-workspace-board/useTeamWorkspaceBoardSelectedSessionActionState.ts` | Team Board 当前选中成员 capability hook,统一承载 open/resume/send/wait/close 可用性派生 | | `team-workspace-board/useTeamWorkspaceBoardShellProps.ts` | Team Board 壳层 props 组合 hook,统一承载 empty shell / shell props 映射与当前选中成员 inline detail 装配 | | `team-workspace-board/useTeamWorkspaceBoardSessionGraph.ts` | Team Board 会话图组合 hook,统一承载 orchestrator/current child/visible/member canvas/rail 的派生组装 | | `team-workspace-board/useTeamWorkspaceBoardSelectedInlineDetail.tsx` | Team Board 当前选中成员详情组合 hook,统一承载 inline detail 条件渲染与 props 映射 | | `team-workspace-board/useTeamWorkspaceBoardPresentation.tsx` | Team Board 展示态 hook,统一承载 chrome 文案、详情展示与壳层样式拼装 | +| `team-workspace-board/teamWorkspaceBoardPresentationSelectors.ts` | Team Board 展示态 selector,统一承载细节可见性、成员画布文案与壳层 className 派生 | | `team-workspace-board/useTeamWorkspaceCanvasController.ts` | Team Board 画布控制 hook,统一承载自由画布的布局状态、持久化、lane 布局派生与交互接线 | | `team-workspace-board/useTeamWorkspaceCanvasInteractionHandlers.ts` | Team 画布交互 hook,统一承载拖拽、resize、平移、缩放、自动布局、fit 与选中行为 | | `team-workspace-board/useTeamWorkspaceCanvasKeyboardShortcuts.ts` | Team 画布键盘快捷键 hook,统一承载 Space 手型、缩放、适应与方向键平移 | diff --git a/src/components/agent/chat/components/StreamingRenderer.test.tsx b/src/components/agent/chat/components/StreamingRenderer.test.tsx index b3458cc3d..50977d1cc 100644 --- a/src/components/agent/chat/components/StreamingRenderer.test.tsx +++ b/src/components/agent/chat/components/StreamingRenderer.test.tsx @@ -356,7 +356,7 @@ describe("StreamingRenderer", () => { thinkingContent: "先检查滚动触发逻辑\n再确认输出展开时机", toolCalls: [ { - id: "tool-process-group-legacy", + id: "tool-process-group-fallback", name: "functions.exec_command", arguments: JSON.stringify({ cmd: "rg -n scrollKey src" }), status: "completed", @@ -431,7 +431,7 @@ describe("StreamingRenderer", () => { thinkingContent: "这段思考应由 timeline 承载", toolCalls: [ { - id: "tool-suppressed-legacy", + id: "tool-suppressed-fallback", name: "functions.exec_command", arguments: JSON.stringify({ cmd: "rg -n duplicate src" }), status: "completed", @@ -442,7 +442,7 @@ describe("StreamingRenderer", () => { ], actionRequests: [ { - requestId: "req-suppressed-legacy", + requestId: "req-suppressed-fallback", actionType: "tool_confirmation", status: "pending", prompt: "请确认是否继续", diff --git a/src/components/agent/chat/components/StreamingRenderer.tsx b/src/components/agent/chat/components/StreamingRenderer.tsx index 4f14dbdd2..ba2367de2 100644 --- a/src/components/agent/chat/components/StreamingRenderer.tsx +++ b/src/components/agent/chat/components/StreamingRenderer.tsx @@ -1602,25 +1602,25 @@ export const StreamingRenderer: React.FC = memo( ); } - // 回退模式:将思考、工具和确认收敛成同一条执行过程 - const legacyProcessEntries: StreamingProcessEntry[] = []; + // 非交错内容统一收敛到同一条执行过程,避免重复渲染多条过程流。 + const fallbackProcessEntries: StreamingProcessEntry[] = []; if (finalThinking) { - legacyProcessEntries.push({ + fallbackProcessEntries.push({ kind: "thinking", - id: "legacy-thinking", + id: "fallback-thinking", text: finalThinking, defaultExpanded: isStreaming, }); } for (const toolCall of suppressProcessFlow ? [] : toolCalls || []) { - legacyProcessEntries.push({ + fallbackProcessEntries.push({ kind: "tool", id: toolCall.id, toolCall, }); } for (const request of suppressProcessFlow ? [] : visibleActionRequests) { - legacyProcessEntries.push({ + fallbackProcessEntries.push({ kind: "action", id: request.requestId, actionRequired: request, @@ -1634,8 +1634,8 @@ export const StreamingRenderer: React.FC = memo( {shouldShowRuntimeStatus && runtimeStatus ? ( ) : null} - {legacyProcessEntries.length > 0 - ? renderProcessRun(legacyProcessEntries, "legacy-process") + {fallbackProcessEntries.length > 0 + ? renderProcessRun(fallbackProcessEntries, "fallback-process") : null} {/* 解析后的内容区域(包括 A2UI、write_file、普通文本) */} diff --git a/src/components/agent/chat/components/TeamMemoryShadowCard.test.tsx b/src/components/agent/chat/components/TeamMemoryShadowCard.test.tsx index 258714960..dbb05f5dc 100644 --- a/src/components/agent/chat/components/TeamMemoryShadowCard.test.tsx +++ b/src/components/agent/chat/components/TeamMemoryShadowCard.test.tsx @@ -55,7 +55,7 @@ describe("TeamMemoryShadowCard", () => { ).toBeNull(); }); - it("应展示 repo 作用域和关键 Team 记忆条目", () => { + it("应展示 repo 作用域和关键任务记忆条目", () => { const container = renderCard({ repoScope: "/workspace/lime", entries: { @@ -66,7 +66,7 @@ describe("TeamMemoryShadowCard", () => { }, "team.subagents": { key: "team.subagents", - content: "子代理:\n- 研究代理 [running] explorer · 梳理主线风险", + content: "子任务:\n- 研究代理 [running] explorer · 梳理主线风险", updatedAt: 300, }, "team.parent_context": { @@ -80,13 +80,15 @@ describe("TeamMemoryShadowCard", () => { expect( container.querySelector('[data-testid="team-memory-shadow-card"]'), ).not.toBeNull(); - expect(container.textContent).toContain("协作记忆影子"); + expect(container.textContent).toContain("任务记忆影子"); expect(container.textContent).toContain( - "当前仓库已缓存 3 条 Team 续接上下文", + "当前仓库已缓存 3 条任务续接上下文", ); expect(container.textContent).toContain("/workspace/lime"); - expect(container.textContent).toContain("当前 Team"); - expect(container.textContent).toContain("子代理概览"); + expect(container.textContent).toContain("当前任务方案"); + expect(container.textContent).toContain("任务方案:研究双人组"); + expect(container.textContent).toContain("子任务概览"); + expect(container.textContent).toContain("子任务:"); expect(container.textContent).toContain("父会话上下文"); expect(container.textContent).toContain( "研究代理 [running] explorer · 梳理主线风险", diff --git a/src/components/agent/chat/components/TeamMemoryShadowCard.tsx b/src/components/agent/chat/components/TeamMemoryShadowCard.tsx index 5c0a53e22..76c2f5080 100644 --- a/src/components/agent/chat/components/TeamMemoryShadowCard.tsx +++ b/src/components/agent/chat/components/TeamMemoryShadowCard.tsx @@ -1,6 +1,7 @@ import { BrainCircuit, FolderTree, Sparkles, Users } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import type { TeamMemoryEntry, TeamMemorySnapshot } from "@/lib/teamMemorySync"; +import { normalizeTeamMemoryDisplayText } from "../utils/teamMemoryDisplay"; interface TeamMemoryShadowCardProps { snapshot?: TeamMemorySnapshot | null; @@ -16,11 +17,11 @@ interface TeamMemoryDisplayEntry { const ENTRY_META: Record = { "team.selection": { - label: "当前 Team", + label: "当前任务方案", icon: Users, }, "team.subagents": { - label: "子代理概览", + label: "子任务概览", icon: Sparkles, }, "team.parent_context": { @@ -54,6 +55,7 @@ function toDisplayEntry(entry: TeamMemoryEntry): TeamMemoryDisplayEntry { icon: meta.icon, lines: entry.content .split("\n") + .map((line) => normalizeTeamMemoryDisplayText(line)) .map((line) => line.trim()) .filter(Boolean) .slice(0, 4), @@ -83,14 +85,13 @@ export function TeamMemoryShadowCard({
- 协作记忆影子 + 任务记忆影子
- 当前仓库已缓存 {entries.length} 条 Team 续接上下文 + 当前仓库已缓存 {entries.length} 条任务续接上下文

- 仅保存在当前仓库本地作用域,用来续接 Team - 选择、子代理概览和父会话上下文。 + 仅保存在当前仓库本地作用域,用来续接任务方案选择、子任务概览和父会话上下文。

diff --git a/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.test.tsx b/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.test.tsx index 7ae3faf7c..dae3161f0 100644 --- a/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.test.tsx +++ b/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.test.tsx @@ -72,10 +72,94 @@ describe("TeamWorkbenchSummaryPanel", () => { }, }); - expect(container.textContent).toContain("团队工作台"); - expect(container.textContent).toContain("协作记忆影子"); + expect(container.textContent).toContain("任务工作台"); + expect(container.textContent).toContain("任务记忆影子"); expect(container.textContent).toContain("/workspace/lime"); - expect(container.textContent).toContain("当前 Team"); + expect(container.textContent).toContain("当前任务方案"); expect(container.textContent).toContain("研究双人组"); }); + + it("任务分工已就绪时应展示当前任务视角", () => { + const container = renderPanel({ + teamDispatchPreviewState: { + requestId: "runtime-formed-1", + status: "formed", + label: "修复 Team", + summary: "分析、执行、验证三段式推进。", + members: [ + { + id: "task-1", + label: "分析", + summary: "收敛问题边界。", + roleKey: "explorer", + profileId: "code-explorer", + skillIds: ["repo-exploration"], + status: "planned", + }, + { + id: "task-2", + label: "执行", + summary: "落地修复并回传结果。", + roleKey: "executor", + profileId: "code-executor", + skillIds: ["bounded-implementation"], + status: "planned", + }, + ], + blueprint: { + label: "代码排障团队", + summary: "分析、执行、验证三段式推进。", + roles: [], + }, + updatedAt: Date.now(), + }, + }); + + expect(container.textContent).toContain("任务分工已准备好"); + expect(container.textContent).toContain("当前任务分工"); + expect(container.textContent).toContain( + "当前任务方案已就绪。任务拆出后,这里会从方案视图过渡到任务视图。", + ); + expect(container.textContent).toContain("参考方案:代码排障团队"); + }); + + it("最近动态应使用任务叙事", () => { + const container = renderPanel({ + childSubagentSessions: [ + { + id: "child-1", + name: "分析", + created_at: 1_710_000_000, + updated_at: 1_710_000_100, + session_type: "sub_agent", + runtime_status: "completed", + latest_turn_status: "completed", + task_summary: "已整理问题边界", + }, + { + id: "child-2", + name: "执行", + created_at: 1_710_000_010, + updated_at: 1_710_000_120, + session_type: "sub_agent", + runtime_status: "completed", + latest_turn_status: "completed", + task_summary: "已完成修复", + }, + ], + teamControlSummary: { + action: "close_completed", + requestedSessionIds: ["child-1", "child-2"], + cascadeSessionIds: [], + affectedSessionIds: ["child-1", "child-2"], + updatedAt: Date.now(), + }, + }); + + expect(container.textContent).toContain("最近动态"); + expect(container.textContent).toContain( + "最近一次收尾操作收起了 2 项已完成任务。", + ); + expect(container.textContent).toContain("任务轨道"); + }); }); diff --git a/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.tsx b/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.tsx index 96adaf92d..d9931e81b 100644 --- a/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.tsx +++ b/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.tsx @@ -13,11 +13,12 @@ import type { TeamWorkspaceWaitSummary, } from "../teamWorkspaceRuntime"; import { - resolveRuntimeFormationStatusMeta, resolveRuntimeMemberStatusMeta, summarizeTeamWorkspaceExecution, } from "../teamWorkspaceRuntime"; +import { buildRuntimeFormationDisplayState } from "../team-workspace-runtime/formationDisplaySelectors"; import type { TeamRoleDefinition } from "../utils/teamDefinitions"; +import { normalizeTeamWorkspaceDisplayValue } from "../utils/teamWorkspaceDisplay"; import { TeamMemoryShadowCard } from "./TeamMemoryShadowCard"; interface TeamWorkbenchSummaryPanelProps { @@ -46,19 +47,19 @@ function buildOperationSummary(params: { const affectedCount = params.teamControlSummary.affectedSessionIds.length; switch (params.teamControlSummary.action) { case "resume": - return `最近一次恢复操作影响 ${affectedCount} 个 agent。`; + return `最近一次继续操作影响 ${affectedCount} 项任务。`; case "close_completed": - return `最近一次收尾操作关闭了 ${affectedCount} 位已完成成员。`; + return `最近一次收尾操作收起了 ${affectedCount} 项已完成任务。`; case "close": default: - return `最近一次关闭操作影响 ${affectedCount} 个 agent。`; + return `最近一次暂停操作影响 ${affectedCount} 项任务。`; } } if (params.teamWaitSummary) { return params.teamWaitSummary.timedOut - ? `最近一次等待超时,仍有 ${params.teamWaitSummary.awaitedSessionIds.length} 位成员在推进。` - : "最近一次等待已收到成员结果。"; + ? `最近一次等待超时,仍有 ${params.teamWaitSummary.awaitedSessionIds.length} 项任务在推进。` + : "最近一次等待已收到任务结果。"; } return null; @@ -91,21 +92,13 @@ export function TeamWorkbenchSummaryPanel({ subagentParentContext, liveRuntimeBySessionId, }); - const hasRealTeamGraph = - childSubagentSessions.length > 0 || Boolean(subagentParentContext); - const runtimeFormationMeta = dispatchPreviewState - ? resolveRuntimeFormationStatusMeta(dispatchPreviewState.status) - : null; - const runtimeTeamLabel = - dispatchPreviewState?.label?.trim() || - dispatchPreviewState?.blueprint?.label?.trim() || - selectedTeamLabel?.trim() || - null; - const runtimeSummaryText = - dispatchPreviewState?.summary?.trim() || - dispatchPreviewState?.blueprint?.summary?.trim() || - selectedTeamSummary || - null; + const hasRuntimeSessions = executionSummary.totalSessionCount > 0; + const runtimeFormationDisplay = buildRuntimeFormationDisplayState({ + teamDispatchPreviewState: dispatchPreviewState, + fallbackLabel: selectedTeamLabel, + fallbackSummary: selectedTeamSummary, + }); + const runtimeTeamLabel = runtimeFormationDisplay.panelLabel; const operationSummary = buildOperationSummary({ teamWaitSummary, teamControlSummary, @@ -113,33 +106,37 @@ export function TeamWorkbenchSummaryPanel({ const latestActivity = Object.values(liveActivityBySessionId) .flat() .slice(0, 4); - const displayRoleCount = hasRealTeamGraph + const selectedRoleCount = (selectedTeamRoles ?? []).filter((role) => + role.label.trim(), + ).length; + const displayRoleCount = hasRuntimeSessions ? executionSummary.totalSessionCount - : (dispatchPreviewState?.members.length ?? 0); + : (dispatchPreviewState?.members.length ?? selectedRoleCount); const roleCards = dispatchPreviewState?.members.length ? dispatchPreviewState.members.map((member) => ({ id: member.id, - label: member.label, + label: normalizeTeamWorkspaceDisplayValue(member.label) || member.label, roleKey: member.roleKey, profileId: member.profileId, - summary: member.summary, + summary: + normalizeTeamWorkspaceDisplayValue(member.summary) || member.summary, skillIds: member.skillIds, statusMeta: resolveRuntimeMemberStatusMeta(member.status), })) : (selectedTeamRoles ?? []).map((role) => ({ id: role.id, - label: role.label, + label: normalizeTeamWorkspaceDisplayValue(role.label) || role.label, roleKey: role.roleKey, profileId: role.profileId, - summary: role.summary, + summary: normalizeTeamWorkspaceDisplayValue(role.summary) || role.summary, skillIds: role.skillIds ?? [], statusMeta: null, })); const summaryCards = [ { - label: "活跃 Agent", + label: "活跃任务", value: String(executionSummary.activeSessionCount), - hint: executionSummary.hasActiveRuntime ? "正在协作" : "尚未运行", + hint: executionSummary.hasActiveRuntime ? "任务进行中" : "尚未运行", }, { label: "处理中", @@ -152,21 +149,25 @@ export function TeamWorkbenchSummaryPanel({ hint: executionSummary.queuedSessionCount > 0 ? "按顺序继续" : "暂无", }, { - label: hasRealTeamGraph + label: hasRuntimeSessions ? "总会话" : dispatchPreviewState - ? "当前成员" - : "总会话", + ? "当前任务" + : selectedRoleCount > 0 + ? "计划分工" + : "总会话", value: String( - hasRealTeamGraph + hasRuntimeSessions ? executionSummary.totalSessionCount : displayRoleCount, ), - hint: hasRealTeamGraph - ? "已进入团队图谱" + hint: hasRuntimeSessions + ? "已进入任务轨道" : dispatchPreviewState - ? "当前编队" - : "等待创建", + ? "当前分工" + : selectedRoleCount > 0 + ? "已选方案" + : "等待创建", }, ]; @@ -175,55 +176,45 @@ export function TeamWorkbenchSummaryPanel({
- 团队工作台 + 任务工作台 {runtimeTeamLabel ? ( {runtimeTeamLabel} ) : null} - {runtimeFormationMeta ? ( + {runtimeFormationDisplay.panelStatusLabel ? ( - {runtimeFormationMeta.label} + {runtimeFormationDisplay.panelStatusLabel} ) : null}
{executionSummary.statusTitle || - runtimeFormationMeta?.title || - "团队已就绪,等待主代理开始编排"} + runtimeFormationDisplay.panelHeadline || + "任务工作台已就绪,等待主线程开始编排"}

- {dispatchPreviewState?.status === "failed" - ? dispatchPreviewState.errorMessage?.trim() || - "这次 Team 准备失败,可继续在当前对话中推进。" - : runtimeSummaryText || - "这里展示团队总览与运行密度;主对话只保留调度记录,角色执行正文在左侧 Team 画布查看。"} + {runtimeFormationDisplay.panelDescription}

- {!hasRealTeamGraph && dispatchPreviewState ? ( + {!hasRuntimeSessions && dispatchPreviewState ? (
- {dispatchPreviewState.status === "forming" - ? "模型正在依据当前任务准备 Team,真实成员加入后会自动切换到实时协作轨道。" - : dispatchPreviewState.status === "formed" - ? `已准备 ${dispatchPreviewState.members.length} 个成员,当前等待系统分派真实成员进入协作。` - : dispatchPreviewState.errorMessage?.trim() || - "暂未成功准备这次 Team。"} + {runtimeFormationDisplay.noticeText}
) : null} - {!hasRealTeamGraph && !dispatchPreviewState ? ( + {!hasRuntimeSessions && !dispatchPreviewState ? (
- 尚未出现真实团队成员。开始分派成员后,左侧 Team - 画布会自动切换为实时协作视图。 + 尚未接入任务。发送后这里会先展示分工,再过渡到任务视图。
) : null}
@@ -253,7 +244,7 @@ export function TeamWorkbenchSummaryPanel({
- {dispatchPreviewState ? "当前成员" : "角色分工"} + {dispatchPreviewState ? "当前任务分工" : "角色分工"}
{roleCards.map((role) => ( @@ -304,9 +295,9 @@ export function TeamWorkbenchSummaryPanel({
))}
- {dispatchPreviewState?.blueprint?.label ? ( + {runtimeFormationDisplay.referenceLabel ? (
- 参考蓝图 Team:{dispatchPreviewState.blueprint.label} + 参考方案:{runtimeFormationDisplay.referenceLabel}
) : null} @@ -359,7 +350,7 @@ export function TeamWorkbenchSummaryPanel({ 交互说明
- 左侧主画布负责展示成员轨道、实时过程与细节;右侧用于总览、角色结构与快速理解当前协作状态。 + 左侧主画布负责展示任务轨道、实时过程与细节;右侧用于总览、任务结构与快速理解当前进展状态。
diff --git a/src/components/agent/chat/components/TeamWorkspaceBoard.test.tsx b/src/components/agent/chat/components/TeamWorkspaceBoard.test.tsx index f31aede69..ec6c2c3bb 100644 --- a/src/components/agent/chat/components/TeamWorkspaceBoard.test.tsx +++ b/src/components/agent/chat/components/TeamWorkspaceBoard.test.tsx @@ -233,9 +233,10 @@ describe("TeamWorkspaceBoard", () => { childSubagentSessions: [], }); - expect(container.textContent).toContain("任务协作"); - expect(container.textContent).toContain("还没有成员接入"); - expect(container.textContent).toContain("查看任务进行时"); + expect(container.textContent).toContain("任务工作台"); + expect(container.textContent).toContain("还没有任务接手"); + expect(container.textContent).toContain("查看任务视图"); + expect(container.textContent).not.toContain("任务进行时"); expect(container.textContent).not.toContain("spawn_agent"); expect(container.textContent).not.toContain("Explorer 槽位"); expect(container.textContent).not.toContain("Executor 槽位"); @@ -256,8 +257,8 @@ describe("TeamWorkspaceBoard", () => { expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); - expect(container.textContent).toContain("需要时会自动接入协作成员"); - expect(container.textContent).toContain("邀请协作成员"); + expect(container.textContent).toContain("需要时会自动拆出任务"); + expect(container.textContent).toContain("拆出任务"); expect(container.textContent).toContain("收起细节"); }); @@ -287,7 +288,7 @@ describe("TeamWorkspaceBoard", () => { ], }); - expect(container.textContent).toContain("临时修复 Team"); + expect(container.textContent).toContain("临时修复任务方案"); expect(container.textContent).toContain("2 个计划分工"); const expandButton = container.querySelector( @@ -398,8 +399,10 @@ describe("TeamWorkspaceBoard", () => { ], }); - expect(container.textContent).toContain("任务协作"); - expect(container.textContent).toContain("2 位成员协作中"); + expect(container.textContent).toContain("任务工作台"); + expect(container.textContent).toContain( + "任务进行中 · 1 项处理中 / 1 项稍后开始", + ); expect(container.textContent).toContain("研究员"); expect(container.textContent).toContain("执行器"); expect(container.textContent).toContain("代码分析员"); @@ -417,13 +420,25 @@ describe("TeamWorkspaceBoard", () => { ?.getAttribute("data-expanded"), ).toBe("false"); expect( - container.querySelector('[data-testid="team-workspace-canvas-toolbar"]') + container.querySelector('[data-testid="team-workspace-compact-summary"]') ?.textContent, - ).toContain("焦点 研究员"); + ).toContain("当前焦点"); + expect( + container.querySelector('[data-testid="team-workspace-compact-summary"]') + ?.textContent, + ).toContain("当前焦点会优先落在正在处理的任务上。"); expect( container.querySelector('[data-testid="team-workspace-canvas-toolbar"]') ?.textContent, - ).toContain("缩放 100%"); + ).toContain("处理中"); + expect( + container.querySelector('[data-testid="team-workspace-canvas-toolbar"]') + ?.textContent, + ).not.toContain("当前焦点 研究员"); + expect( + container.querySelector('[data-testid="team-workspace-canvas-toolbar"]') + ?.textContent, + ).not.toContain("缩放 100%"); expect(container.textContent).toContain("最近进展"); expect(container.textContent).toContain( "回复:已完成竞品摘要与数据来源梳理。", @@ -438,7 +453,7 @@ describe("TeamWorkspaceBoard", () => { const executorLane = container.querySelector( '[data-testid="team-workspace-member-lane-child-2"]', ); - expect(researcherLane?.textContent).toContain("成员进展"); + expect(researcherLane?.textContent).toContain("任务进展"); expect(researcherLane?.textContent).toContain( "回复:已完成竞品摘要与数据来源梳理。", ); @@ -465,14 +480,14 @@ describe("TeamWorkspaceBoard", () => { expect(container.textContent).toContain("已对 3 个来源完成去重校验。"); const openButton = Array.from(container.querySelectorAll("button")).find( - (element) => element.textContent?.includes("打开对话"), + (element) => element.textContent?.includes("打开任务"), ); await clickElement(openButton ?? null); expect(onOpenSubagentSession).toHaveBeenCalledWith("child-1"); }); - it("真实成员存在时应优先按蓝图角色顺序排列泳道", async () => { + it("真实任务存在时应优先把处理中任务排到前面,并在同优先级下保持蓝图顺序", async () => { const container = await renderBoard({ childSubagentSessions: [ { @@ -512,15 +527,6 @@ describe("TeamWorkspaceBoard", () => { label: "修复 Team", summary: "分析、执行协同推进。", members: [ - { - id: "runtime-explorer", - label: "分析", - summary: "先定位问题。", - roleKey: "explorer", - profileId: "code-explorer", - skillIds: ["repo-exploration"], - status: "planned", - }, { id: "runtime-executor", label: "执行", @@ -530,6 +536,15 @@ describe("TeamWorkspaceBoard", () => { skillIds: ["bounded-implementation"], status: "planned", }, + { + id: "runtime-explorer", + label: "分析", + summary: "先定位问题。", + roleKey: "explorer", + profileId: "code-explorer", + skillIds: ["repo-exploration"], + status: "planned", + }, ], blueprint: null, updatedAt: Date.now(), @@ -654,13 +669,29 @@ describe("TeamWorkspaceBoard", () => { ), ).toBeFalsy(); expect( - container.querySelector('[data-testid="team-workspace-canvas-toolbar"]') + container.querySelector('[data-testid="team-workspace-compact-summary"]') ?.textContent, - ).toContain("焦点 实现代理"); + ).toContain("当前焦点"); + expect( + container.querySelector('[data-testid="team-workspace-compact-summary"]') + ?.textContent, + ).toContain("并行任务会在各自面板里持续更新进展和结果"); expect( container.querySelector('[data-testid="team-workspace-canvas-toolbar"]') ?.textContent, - ).toContain("缩放 100%"); + ).toContain("处理中"); + expect( + container.querySelector('[data-testid="team-workspace-canvas-toolbar"]') + ?.textContent, + ).toContain("当前任务"); + expect( + container.querySelector('[data-testid="team-workspace-canvas-toolbar"]') + ?.textContent, + ).not.toContain("当前焦点 实现代理"); + expect( + container.querySelector('[data-testid="team-workspace-canvas-toolbar"]') + ?.textContent, + ).not.toContain("缩放 100%"); expect(container.textContent).toContain("最近进展"); expect(container.textContent).toContain( "推理:先检查 team runtime 的控制面状态,再决定是否等待。", @@ -672,7 +703,7 @@ describe("TeamWorkspaceBoard", () => { const siblingLane = container.querySelector( '[data-testid="team-workspace-member-lane-child-sibling-1"]', ); - expect(currentLane?.textContent).toContain("成员进展"); + expect(currentLane?.textContent).toContain("任务进展"); expect(currentLane?.textContent).toContain( "推理:先检查 team runtime 的控制面状态,再决定是否等待。", ); @@ -682,7 +713,7 @@ describe("TeamWorkspaceBoard", () => { ); const returnButton = Array.from(container.querySelectorAll("button")).find( - (element) => element.textContent?.includes("返回主助手"), + (element) => element.textContent?.includes("返回主任务"), ); expect(returnButton).toBeTruthy(); @@ -761,8 +792,9 @@ describe("TeamWorkspaceBoard", () => { expect(embeddedShell?.className).toContain("pointer-events-auto"); expect(embeddedShell?.className).toContain("overflow-hidden"); expect(embeddedShell?.className).toContain("flex-col"); - expect(embeddedShell?.className).toContain("bg-transparent"); - expect(embeddedShell?.className).toContain("border-0"); + expect(embeddedShell?.className).toContain("rounded-[24px]"); + expect(embeddedShell?.className).toContain("border-slate-200"); + expect(embeddedShell?.className).toContain("bg-white"); expect(embeddedShell?.className).not.toContain("backdrop-blur"); expect(boardBody?.className).toContain("overflow-y-auto"); expect(railList?.getAttribute("data-layout-kind")).toBe("free-canvas"); @@ -911,7 +943,7 @@ describe("TeamWorkspaceBoard", () => { { id: "status-child-live-1-running", title: "状态切换", - detail: "收到 team 状态事件,已切换为运行中。", + detail: "收到任务状态事件,已切换为运行中。", statusLabel: "运行中", badgeClassName: "border border-sky-200 bg-sky-50 text-sky-700", }, @@ -1648,11 +1680,11 @@ describe("TeamWorkspaceBoard", () => { onWaitActiveTeamSessions, }); - expect(container.textContent).toContain("等待任一成员结果"); - expect(container.textContent).toContain("2 位处理中"); + expect(container.textContent).toContain("等待任一任务结果"); + expect(container.textContent).toContain("2 项处理中"); const waitAnyButton = Array.from(container.querySelectorAll("button")).find( - (element) => element.textContent?.includes("等待任一成员结果"), + (element) => element.textContent?.includes("等待任一任务结果"), ); expect(waitAnyButton).toBeTruthy(); @@ -1705,9 +1737,14 @@ describe("TeamWorkspaceBoard", () => { const operations = container.querySelector( '[data-testid="team-workspace-team-operations"]', ); + const operationList = container.querySelector( + '[data-testid="team-workspace-team-operations-list"]', + ); expect(operations).toBeTruthy(); - expect(operations?.className).toContain("overflow-x-auto"); + expect(operations?.textContent).toContain("任务进展"); + expect(operations?.textContent).toContain("最近 1 条"); + expect(operationList).toBeTruthy(); expect(operations?.textContent).toContain("收到结果"); expect(operations?.textContent).toContain("刚才等到 执行器 返回了新结果"); expect(operations?.textContent).toContain("当前状态为已完成"); @@ -1797,8 +1834,10 @@ describe("TeamWorkspaceBoard", () => { ); expect(operations).toBeTruthy(); + expect(operations?.textContent).toContain("任务进展"); + expect(operations?.textContent).toContain("最近 1 条"); expect(operations?.textContent).toContain("暂停处理"); - expect(operations?.textContent).toContain("刚才已暂停 2 位协作成员的处理"); + expect(operations?.textContent).toContain("刚才已暂停 2 项任务的处理"); }); it("点击 Team 轨迹项时应切换焦点到对应 agent", async () => { @@ -1907,12 +1946,12 @@ describe("TeamWorkspaceBoard", () => { onCloseCompletedTeamSessions, }); - expect(container.textContent).toContain("收起已完成成员"); - expect(container.textContent).toContain("2 位已完成"); + expect(container.textContent).toContain("收起已完成任务"); + expect(container.textContent).toContain("2 项已完成"); const closeCompletedButton = Array.from( container.querySelectorAll("button"), - ).find((element) => element.textContent?.includes("收起已完成成员")); + ).find((element) => element.textContent?.includes("收起已完成任务")); expect(closeCompletedButton).toBeTruthy(); await act(async () => { @@ -2040,6 +2079,9 @@ describe("TeamWorkspaceBoard", () => { expect(header).toBeTruthy(); expect(header?.className).toContain("sticky"); expect(header?.className).toContain("top-0"); + expect(header?.textContent).toContain("任务工作台"); + expect(header?.textContent).toContain("任务进行中"); + expect(header?.textContent).not.toContain("任务进行时"); }); it("本轮 Team 准备中时,应在空 shell 展示组建状态", async () => { @@ -2062,7 +2104,7 @@ describe("TeamWorkspaceBoard", () => { expect(container.textContent).toContain("正在准备任务分工"); expect(container.textContent).toContain("准备中"); - expect(container.textContent).toContain("任务方案 · 排障 Team"); + expect(container.textContent).toContain("任务方案 · 排障任务方案"); expect(container.textContent).toContain("参考方案 · 代码排障团队"); }); @@ -2142,7 +2184,7 @@ describe("TeamWorkspaceBoard", () => { expect(container.textContent).toContain("任务分工准备失败"); expect(container.textContent).toContain( - "Provider 认证失败,无法生成 Team。", + "Provider 认证失败,无法生成任务方案。", ); }); }); diff --git a/src/components/agent/chat/components/TeamWorkspaceDock.test.tsx b/src/components/agent/chat/components/TeamWorkspaceDock.test.tsx index caec4daea..29a7337ed 100644 --- a/src/components/agent/chat/components/TeamWorkspaceDock.test.tsx +++ b/src/components/agent/chat/components/TeamWorkspaceDock.test.tsx @@ -163,7 +163,7 @@ describe("TeamWorkspaceDock", () => { ], }); - expect(container.textContent).toContain("任务协作"); + expect(container.textContent).toContain("任务视图"); const toggleButton = container.querySelector( '[data-testid="team-workspace-dock-toggle"]', @@ -174,15 +174,18 @@ describe("TeamWorkspaceDock", () => { toggleButton?.click(); }); - expect(document.body.textContent).toContain("等待成员接入"); + expect(document.body.textContent).toContain("等待任务接手"); expect(document.body.textContent).toContain("任务方案"); expect(document.body.textContent).toContain("不遮挡画布"); expect(document.body.textContent).toContain("当前任务方案:前端联调团队"); expect(document.body.textContent).toContain("分析、实现、验证三段式推进。"); expect(document.body.textContent).not.toContain("查看详情"); - expect( - document.body.querySelector('[data-testid="team-workspace-empty-card"]'), - ).toBeTruthy(); + const emptyCard = document.body.querySelector( + '[data-testid="team-workspace-empty-card"]', + ); + expect(emptyCard).toBeTruthy(); + expect(emptyCard?.textContent).toContain("任务工作台"); + expect(emptyCard?.textContent).not.toContain("任务进行时"); expect( document.body.querySelector( '[data-testid="team-workspace-selected-team"]', @@ -260,7 +263,9 @@ describe("TeamWorkspaceDock", () => { expect( document.body.querySelector('[data-testid="team-workspace-dock-panel"]'), ).toBeTruthy(); - expect(container.textContent).toContain("收起协作面板"); + expect(container.textContent).toContain("任务视图"); + expect(container.textContent).toContain("1 项处理中"); + expect(container.textContent).not.toContain("收起任务视图"); expect(document.body.textContent).toContain("研究员"); }); @@ -270,7 +275,7 @@ describe("TeamWorkspaceDock", () => { requestId: "runtime-forming-1", status: "forming", label: "修复 Team", - summary: "正在根据任务准备协作成员。", + summary: "正在根据当前任务准备分工。", members: [], blueprint: { label: "代码排障团队", @@ -284,7 +289,9 @@ describe("TeamWorkspaceDock", () => { expect( document.body.querySelector('[data-testid="team-workspace-dock-panel"]'), ).toBeNull(); - expect(container.textContent).toContain("查看任务进行时 · 准备中"); + expect(container.textContent).toContain("任务视图"); + expect(container.textContent).toContain("准备中"); + expect(container.textContent).not.toContain("查看任务视图"); expect( container.querySelector('[data-testid="team-workspace-dock-signal"]'), ).toBeTruthy(); @@ -320,7 +327,9 @@ describe("TeamWorkspaceDock", () => { expect( document.body.querySelector('[data-testid="team-workspace-dock-panel"]'), ).toBeNull(); - expect(container.textContent).toContain("查看任务进行时 · 1"); + expect(container.textContent).toContain("任务视图"); + expect(container.textContent).toContain("1 项任务"); + expect(container.textContent).not.toContain("查看任务视图"); expect( container.querySelector('[data-testid="team-workspace-dock-signal"]'), ).toBeTruthy(); @@ -347,7 +356,9 @@ describe("TeamWorkspaceDock", () => { expect( document.body.querySelector('[data-testid="team-workspace-dock-panel"]'), ).toBeNull(); - expect(container.textContent).toContain("查看任务进行时 · 失败"); + expect(container.textContent).toContain("任务视图"); + expect(container.textContent).toContain("准备失败"); + expect(container.textContent).not.toContain("查看任务视图"); expect( container.querySelector('[data-testid="team-workspace-dock-signal"]'), ).toBeTruthy(); @@ -400,9 +411,9 @@ describe("TeamWorkspaceDock", () => { ); expect(toggleButton).toBeTruthy(); - expect(toggleButton?.textContent).toContain("修复 Team"); - expect(toggleButton?.textContent).toContain("1 位处理中"); - expect(toggleButton?.textContent).not.toContain("查看任务进行时"); + expect(toggleButton?.textContent).toContain("修复任务方案"); + expect(toggleButton?.textContent).toContain("1 项处理中"); + expect(toggleButton?.textContent).not.toContain("查看任务视图"); act(() => { toggleButton?.click(); @@ -433,8 +444,18 @@ describe("TeamWorkspaceDock", () => { const toggleButton = container.querySelector( '[data-testid="team-workspace-dock-toggle"]', ); + const summary = container.querySelector( + '[data-testid="team-workspace-dock-summary"]', + ); + const badges = container.querySelector( + '[data-testid="team-workspace-dock-badges"]', + ); - expect(toggleButton?.textContent).toContain("查看任务进行时 · 1"); + expect(toggleButton?.textContent).toContain("任务视图"); + expect(toggleButton?.textContent).toContain("1 项处理中"); + expect(toggleButton?.textContent).not.toContain("查看任务视图"); + expect(summary?.textContent).toContain("任务视图"); + expect(badges?.textContent).toContain("1 项处理中"); expect( container.querySelector('[data-testid="team-workspace-dock-signal"]'), ).toBeTruthy(); @@ -511,7 +532,9 @@ describe("TeamWorkspaceDock", () => { const toggleButton = container.querySelector( '[data-testid="team-workspace-dock-toggle"]', ); - expect(toggleButton?.textContent).toContain("收起协作面板"); + expect(toggleButton?.textContent).toContain("任务视图"); + expect(toggleButton?.textContent).toContain("1 项处理中"); + expect(toggleButton?.textContent).not.toContain("收起任务视图"); act(() => { toggleButton?.click(); @@ -550,7 +573,9 @@ describe("TeamWorkspaceDock", () => { expect( document.body.querySelector('[data-testid="team-workspace-dock-panel"]'), ).toBeNull(); - expect(toggleButton?.textContent).toContain("查看任务进行时 · 2"); + expect(toggleButton?.textContent).toContain("任务视图"); + expect(toggleButton?.textContent).toContain("2 项处理中"); + expect(toggleButton?.textContent).not.toContain("查看任务视图"); }); it("切换会话后,新的真实成员出现应再次自动展开", async () => { @@ -612,7 +637,9 @@ describe("TeamWorkspaceDock", () => { expect( document.body.querySelector('[data-testid="team-workspace-dock-panel"]'), ).toBeTruthy(); - expect(container.textContent).toContain("收起协作面板"); + expect(container.textContent).toContain("任务视图"); + expect(container.textContent).toContain("1 项处理中"); + expect(container.textContent).not.toContain("收起任务视图"); }); it("旧会话初次挂载时即使已有真实成员,也不应自动展开", async () => { @@ -636,7 +663,9 @@ describe("TeamWorkspaceDock", () => { expect( document.body.querySelector('[data-testid="team-workspace-dock-panel"]'), ).toBeNull(); - expect(container.textContent).toContain("查看任务进行时 · 1"); + expect(container.textContent).toContain("任务视图"); + expect(container.textContent).toContain("1 项处理中"); + expect(container.textContent).not.toContain("查看任务视图"); }); it("已有真实成员的 Dock 切到 inline 时,不应因为布局变化自动展开", async () => { @@ -679,7 +708,9 @@ describe("TeamWorkspaceDock", () => { expect( document.body.querySelector('[data-testid="team-workspace-dock-panel"]'), ).toBeNull(); - expect(container.textContent).toContain("查看任务进行时 · 1"); + expect(container.textContent).toContain("任务视图"); + expect(container.textContent).toContain("1 项处理中"); + expect(container.textContent).not.toContain("查看任务视图"); }); it("本轮任务方案已就绪时,应在空态 Dock 展示成员摘要", async () => { @@ -712,7 +743,17 @@ describe("TeamWorkspaceDock", () => { const toggleButton = container.querySelector( '[data-testid="team-workspace-dock-toggle"]', ); - expect(toggleButton?.textContent).toContain("查看任务进行时 · 1"); + const summary = container.querySelector( + '[data-testid="team-workspace-dock-summary"]', + ); + const badges = container.querySelector( + '[data-testid="team-workspace-dock-badges"]', + ); + expect(toggleButton?.textContent).toContain("任务视图"); + expect(toggleButton?.textContent).toContain("1 项任务"); + expect(toggleButton?.textContent).not.toContain("查看任务视图"); + expect(summary?.textContent).toContain("任务视图"); + expect(badges?.textContent).toContain("1 项任务"); act(() => { toggleButton?.click(); @@ -728,7 +769,7 @@ describe("TeamWorkspaceDock", () => { }); expect(document.body.textContent).toContain("任务分工已准备好"); - expect(document.body.textContent).toContain("修复 Team"); + expect(document.body.textContent).toContain("修复任务方案"); expect(document.body.textContent).toContain("分析"); }); }); diff --git a/src/components/agent/chat/components/TeamWorkspaceDock.tsx b/src/components/agent/chat/components/TeamWorkspaceDock.tsx index 7e9e9cc25..74469a2f6 100644 --- a/src/components/agent/chat/components/TeamWorkspaceDock.tsx +++ b/src/components/agent/chat/components/TeamWorkspaceDock.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { ChevronDown, ChevronUp, Workflow } from "lucide-react"; +import { ChevronDown, ChevronUp, ListTodo } from "lucide-react"; import styled, { css, keyframes } from "styled-components"; import type { AsterSubagentParentContext, @@ -14,17 +14,18 @@ import type { TeamWorkspaceWaitSummary, } from "../teamWorkspaceRuntime"; import { - resolveRuntimeFormationStatusMeta, resolveRuntimeMemberStatusMeta, + summarizeTeamWorkspaceExecution, } from "../teamWorkspaceRuntime"; +import { buildRuntimeFormationDisplayState } from "../team-workspace-runtime/formationDisplaySelectors"; import { TeamWorkspaceBoard } from "./TeamWorkspaceBoard"; import type { TeamRoleDefinition } from "../utils/teamDefinitions"; import { TEAM_WORKSPACE_PLAN_LABEL, - TEAM_WORKSPACE_REALTIME_BADGE_LABEL, TEAM_WORKSPACE_SURFACE_TITLE, TEAM_WORKSPACE_WAITING_HEADLINE, } from "../utils/teamWorkspaceCopy"; +import { normalizeTeamWorkspaceDisplayValue } from "../utils/teamWorkspaceDisplay"; const DockContainer = styled.div<{ $withBottomOverlay: boolean; @@ -155,24 +156,29 @@ const DockToggle = styled.button<{ }>` display: inline-flex; align-items: center; - gap: 8px; - min-height: ${({ $launcherOnly }) => ($launcherOnly ? "36px" : "42px")}; - padding: ${({ $launcherOnly }) => ($launcherOnly ? "6px 10px" : "8px 12px")}; + gap: ${({ $launcherOnly }) => ($launcherOnly ? "8px" : "10px")}; + min-height: ${({ $launcherOnly }) => ($launcherOnly ? "36px" : "48px")}; + padding: ${({ $launcherOnly }) => + $launcherOnly ? "6px 10px" : "8px 12px 8px 10px"}; border-radius: 999px; border: 1px solid ${({ $active, $expanded }) => $expanded - ? "rgba(14, 116, 144, 0.28)" + ? "rgba(14, 116, 144, 0.24)" : $active - ? "rgba(56, 189, 248, 0.32)" + ? "rgba(186, 230, 253, 0.96)" : "rgba(203, 213, 225, 0.9)"}; - background: ${({ $launcherOnly }) => - $launcherOnly ? "rgba(255, 255, 255, 0.98)" : "#ffffff"}; + background: ${({ $launcherOnly, $expanded }) => + $launcherOnly + ? "rgba(255, 255, 255, 0.98)" + : $expanded + ? "#ffffff" + : "#f8fafc"}; color: #0f172a; box-shadow: ${({ $launcherOnly }) => $launcherOnly ? "0 10px 24px -18px rgba(15, 23, 42, 0.18)" - : "0 16px 40px -28px rgba(15, 23, 42, 0.28)"}; + : "0 12px 28px -24px rgba(15, 23, 42, 0.22)"}; transition: transform 0.18s ease, box-shadow 0.18s ease, @@ -196,7 +202,7 @@ const DockToggle = styled.button<{ box-shadow: ${({ $launcherOnly }) => $launcherOnly ? "0 12px 28px -18px rgba(15, 23, 42, 0.22)" - : "0 18px 44px -28px rgba(15, 23, 42, 0.32)"}; + : "0 14px 30px -24px rgba(15, 23, 42, 0.24)"}; } `; @@ -206,20 +212,22 @@ const DockIconShell = styled.span<{ }>` position: relative; display: inline-flex; - height: ${({ $launcherOnly }) => ($launcherOnly ? "24px" : "28px")}; - width: ${({ $launcherOnly }) => ($launcherOnly ? "24px" : "28px")}; + height: ${({ $launcherOnly }) => ($launcherOnly ? "24px" : "26px")}; + width: ${({ $launcherOnly }) => ($launcherOnly ? "24px" : "26px")}; align-items: center; justify-content: center; border-radius: 999px; border: 1px solid rgba(226, 232, 240, 0.9); - background: #ffffff; + background: ${({ $launcherOnly }) => + $launcherOnly ? "#ffffff" : "rgba(255, 255, 255, 0.92)"}; color: #475569; ${({ $attention }) => $attention ? css` color: #0369a1; - border-color: rgba(125, 211, 252, 0.96); + border-color: rgba(125, 211, 252, 0.72); + background: #eff6ff; ` : ""} `; @@ -231,6 +239,31 @@ const DockTextStack = styled.span` min-width: 0; `; +const DockSummaryStack = styled.span` + display: inline-flex; + min-width: 0; + flex-direction: column; + align-items: flex-start; + gap: 3px; +`; + +const DockSummaryEyebrow = styled.span` + min-width: 0; + color: #64748b; + font-size: 10px; + font-weight: 600; + line-height: 1.2; + letter-spacing: 0.06em; +`; + +const DockBadgeRow = styled.span` + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + min-width: 0; +`; + const DockPrimaryText = styled.span<{ $launcherOnly: boolean }>` min-width: 0; overflow: hidden; @@ -248,27 +281,27 @@ const DockStatusBadge = styled.span<{ align-items: center; flex-shrink: 0; border-radius: 999px; - padding: 2px 8px; - font-size: 11px; - font-weight: 600; - line-height: 1.4; + padding: 1px 7px 1px 6px; + font-size: 10px; + font-weight: 500; + line-height: 1.35; border: 1px solid ${({ $tone }) => $tone === "success" - ? "rgba(167, 243, 208, 0.96)" + ? "rgba(167, 243, 208, 0.72)" : $tone === "error" - ? "rgba(254, 205, 211, 0.96)" + ? "rgba(254, 205, 211, 0.72)" : $tone === "active" - ? "rgba(186, 230, 253, 0.96)" - : "rgba(226, 232, 240, 0.96)"}; + ? "rgba(186, 230, 253, 0.72)" + : "rgba(226, 232, 240, 0.88)"}; background: ${({ $tone }) => $tone === "success" - ? "#ecfdf5" + ? "rgba(236, 253, 245, 0.86)" : $tone === "error" - ? "#fff1f2" + ? "rgba(255, 241, 242, 0.86)" : $tone === "active" - ? "#f0f9ff" - : "#f8fafc"}; + ? "rgba(240, 249, 255, 0.86)" + : "rgba(248, 250, 252, 0.92)"}; color: ${({ $tone }) => $tone === "success" ? "#047857" @@ -277,6 +310,23 @@ const DockStatusBadge = styled.span<{ : $tone === "active" ? "#0369a1" : "#64748b"}; + + &::before { + content: ""; + display: inline-flex; + width: 5px; + height: 5px; + margin-right: 6px; + border-radius: 999px; + background: ${({ $tone }) => + $tone === "success" + ? "#10b981" + : $tone === "error" + ? "#f43f5e" + : $tone === "active" + ? "#0ea5e9" + : "#94a3b8"}; + } `; const DockSignal = styled.span` @@ -509,48 +559,54 @@ export function TeamWorkspaceDock({ }: TeamWorkspaceDockProps) { const dispatchPreviewState = teamDispatchPreviewState; const launcherOnly = typeof onActivateWorkbench === "function"; - const hasRealTeamGraph = - childSubagentSessions.length > 0 || Boolean(subagentParentContext); + const executionSummary = summarizeTeamWorkspaceExecution({ + currentSessionId, + currentSessionRuntimeStatus, + currentSessionLatestTurnStatus, + currentSessionQueuedTurnCount, + childSubagentSessions, + subagentParentContext, + liveRuntimeBySessionId, + }); + const hasRuntimeSessions = executionSummary.totalSessionCount > 0; const hasRuntimeFormation = Boolean(dispatchPreviewState); - const runtimeFormationMeta = dispatchPreviewState - ? resolveRuntimeFormationStatusMeta(dispatchPreviewState.status) - : null; - const runtimeTeamLabel = - dispatchPreviewState?.label?.trim() || - dispatchPreviewState?.blueprint?.label?.trim() || - selectedTeamLabel?.trim() || - null; + const runtimeFormationDisplay = buildRuntimeFormationDisplayState({ + teamDispatchPreviewState: dispatchPreviewState, + fallbackLabel: selectedTeamLabel, + fallbackSummary: selectedTeamSummary, + }); + const runtimeTeamLabel = runtimeFormationDisplay.panelLabel; + const runtimeBlueprintSummary = normalizeTeamWorkspaceDisplayValue( + dispatchPreviewState?.blueprint?.summary, + ); + const normalizedSelectedTeamSummary = normalizeTeamWorkspaceDisplayValue( + selectedTeamSummary, + ); const runtimeTeamSummary = - dispatchPreviewState?.summary?.trim() || - dispatchPreviewState?.blueprint?.summary?.trim() || - selectedTeamSummary?.trim() || + normalizeTeamWorkspaceDisplayValue(dispatchPreviewState?.summary) || + runtimeBlueprintSummary || + normalizedSelectedTeamSummary || null; + const runtimeTaskCount = dispatchPreviewState?.members.length ?? 0; const [expanded, setExpanded] = useState(false); const [userDismissedAutoExpand, setUserDismissedAutoExpand] = useState(false); const hasInitializedRef = useRef(false); const previousSessionIdRef = useRef(currentSessionId ?? null); - const previousHasRealGraphRef = useRef(hasRealTeamGraph); + const previousHasRuntimeSessionsRef = useRef(hasRuntimeSessions); const toggleRef = useRef(null); const [inlinePanelLayout, setInlinePanelLayout] = useState(null); - const isCompact = !hasRealTeamGraph && !hasRuntimeFormation; + const isCompact = !hasRuntimeSessions && !hasRuntimeFormation; const showAttentionCue = - !expanded && - (hasRealTeamGraph || - dispatchPreviewState?.status === "forming" || - dispatchPreviewState?.status === "formed" || - dispatchPreviewState?.status === "failed"); - const dockCount = subagentParentContext - ? (subagentParentContext.sibling_subagent_sessions?.length ?? 0) + 1 - : childSubagentSessions.length; + !expanded && (hasRuntimeSessions || hasRuntimeFormation); const shouldPortalPanel = expanded; const [teamDetailExpanded, setTeamDetailExpanded] = useState(false); const hasSelectedTeamDetails = Boolean(runtimeTeamSummary) || - (dispatchPreviewState?.members.length ?? 0) > 0 || - Boolean(dispatchPreviewState?.blueprint?.summary?.trim()) || + runtimeTaskCount > 0 || + Boolean(runtimeBlueprintSummary) || (dispatchPreviewState?.blueprint?.roles.length ?? 0) > 0 || - Boolean(selectedTeamSummary?.trim()) || + Boolean(normalizedSelectedTeamSummary) || (selectedTeamRoles?.length ?? 0) > 0; const activeRuntimeSessionCount = childSubagentSessions.filter( (session) => @@ -575,14 +631,16 @@ export function TeamWorkspaceDock({ teamConcurrencySnapshot?.team_parallel_budget !== undefined ? `${teamConcurrencySnapshot.team_active_count ?? runningRuntimeSessionCount}/${teamConcurrencySnapshot.team_parallel_budget} 处理中` : runningRuntimeSessionCount > 0 - ? `${runningRuntimeSessionCount} 位处理中` + ? `${runningRuntimeSessionCount} 项处理中` : null; const teamQueueBadgeText = (teamConcurrencySnapshot?.team_queued_count ?? queuedRuntimeSessionCount) > 0 - ? `${teamConcurrencySnapshot?.team_queued_count ?? queuedRuntimeSessionCount} 位等待中` + ? `${teamConcurrencySnapshot?.team_queued_count ?? queuedRuntimeSessionCount} 项等待中` : null; - const teamQueueReason = teamConcurrencySnapshot?.queue_reason?.trim() || null; + const teamQueueReason = normalizeTeamWorkspaceDisplayValue( + teamConcurrencySnapshot?.queue_reason, + ); const launcherStatusMeta = useMemo<{ label: string; tone: "idle" | "active" | "success" | "error"; @@ -599,28 +657,31 @@ export function TeamWorkspaceDock({ return { label: "失败", tone: "error" }; } - if (hasRealTeamGraph) { + if (hasRuntimeSessions) { if (activeRuntimeSessionCount > 0) { return { label: - teamConcurrencyBadgeText || `${activeRuntimeSessionCount} 位处理中`, + teamConcurrencyBadgeText || `${activeRuntimeSessionCount} 项处理中`, tone: "active", }; } return { - label: `${dockCount} 名成员`, - tone: dockCount > 0 ? "success" : "idle", + label: `${executionSummary.totalSessionCount} 项任务`, + tone: executionSummary.totalSessionCount > 0 ? "success" : "idle", }; } if (dispatchPreviewState?.status === "formed") { - return dispatchPreviewState.members.length > 0 + return runtimeTaskCount > 0 ? { - label: `${dispatchPreviewState.members.length} 名成员`, + label: `${runtimeTaskCount} 项任务`, tone: "success", } - : { label: "已就绪", tone: "success" }; + : { + label: runtimeFormationDisplay.panelStatusLabel || "已就绪", + tone: "success", + }; } if (runtimeTeamLabel) { @@ -630,77 +691,121 @@ export function TeamWorkspaceDock({ return { label: "待开始", tone: "idle" }; }, [ activeRuntimeSessionCount, - dockCount, - hasRealTeamGraph, + executionSummary.totalSessionCount, + hasRuntimeSessions, launcherOnly, teamConcurrencyBadgeText, + runtimeFormationDisplay.panelStatusLabel, + runtimeTaskCount, runtimeTeamLabel, dispatchPreviewState, ]); - const toggleLabel = useMemo(() => { - if (expanded) { - return "收起协作面板"; - } - if (hasRealTeamGraph) { + const dockStatusBadges = useMemo< + Array<{ + label: string; + tone: "idle" | "active" | "success" | "error"; + }> + >(() => { + if (hasRuntimeSessions) { + const badges: Array<{ + label: string; + tone: "idle" | "active" | "success" | "error"; + }> = []; + if (teamConcurrencyBadgeText || teamQueueBadgeText) { - return ["查看任务进行时", teamConcurrencyBadgeText, teamQueueBadgeText] - .filter(Boolean) - .join(" · "); + if (teamConcurrencyBadgeText) { + badges.push({ label: teamConcurrencyBadgeText, tone: "active" }); + } + if (teamQueueBadgeText) { + badges.push({ label: teamQueueBadgeText, tone: "idle" }); + } + return badges; } - return `查看任务进行时 · ${dockCount}`; + + return [ + { + label: `${executionSummary.totalSessionCount} 项任务`, + tone: + executionSummary.totalSessionCount > 0 ? "success" : "idle", + }, + ]; } if (dispatchPreviewState?.status === "forming") { - return "查看任务进行时 · 准备中"; + return [ + { + label: runtimeFormationDisplay.panelStatusLabel || "准备中", + tone: "active", + }, + ]; } if (dispatchPreviewState?.status === "formed") { - return `查看任务进行时 · ${dispatchPreviewState.members.length}`; + return [ + { + label: + runtimeTaskCount > 0 + ? `${runtimeTaskCount} 项任务` + : runtimeFormationDisplay.panelStatusLabel || "已就绪", + tone: "success", + }, + ]; } if (dispatchPreviewState?.status === "failed") { - return "查看任务进行时 · 失败"; + return [ + { + label: runtimeFormationDisplay.panelStatusLabel || "失败", + tone: "error", + }, + ]; } - return TEAM_WORKSPACE_SURFACE_TITLE; + + return runtimeTeamLabel ? [{ label: "已准备", tone: "idle" }] : []; }, [ - dockCount, - expanded, - hasRealTeamGraph, + executionSummary.totalSessionCount, + hasRuntimeSessions, + runtimeTaskCount, dispatchPreviewState, + runtimeFormationDisplay.panelStatusLabel, teamConcurrencyBadgeText, teamQueueBadgeText, + runtimeTeamLabel, ]); - const launcherPrimaryLabel = runtimeTeamLabel || TEAM_WORKSPACE_SURFACE_TITLE; + const launcherPrimaryLabel = runtimeTeamLabel || "任务视图"; + const dockPrimaryLabel = "任务视图"; useEffect(() => { const normalizedSessionId = currentSessionId ?? null; if (!hasInitializedRef.current) { previousSessionIdRef.current = normalizedSessionId; - previousHasRealGraphRef.current = hasRealTeamGraph; + previousHasRuntimeSessionsRef.current = hasRuntimeSessions; hasInitializedRef.current = true; return; } if (previousSessionIdRef.current !== normalizedSessionId) { previousSessionIdRef.current = normalizedSessionId; - previousHasRealGraphRef.current = hasRealTeamGraph; + previousHasRuntimeSessionsRef.current = hasRuntimeSessions; setUserDismissedAutoExpand(false); setExpanded(false); return; } - const graphAppeared = !previousHasRealGraphRef.current && hasRealTeamGraph; - const graphCleared = previousHasRealGraphRef.current && !hasRealTeamGraph; + const runtimeSessionsAppeared = + !previousHasRuntimeSessionsRef.current && hasRuntimeSessions; + const runtimeSessionsCleared = + previousHasRuntimeSessionsRef.current && !hasRuntimeSessions; - if (graphCleared) { + if (runtimeSessionsCleared) { setUserDismissedAutoExpand(false); } - if (graphAppeared && !userDismissedAutoExpand) { + if (runtimeSessionsAppeared && !userDismissedAutoExpand) { setExpanded(true); } previousSessionIdRef.current = normalizedSessionId; - previousHasRealGraphRef.current = hasRealTeamGraph; - }, [currentSessionId, hasRealTeamGraph, userDismissedAutoExpand]); + previousHasRuntimeSessionsRef.current = hasRuntimeSessions; + }, [currentSessionId, hasRuntimeSessions, userDismissedAutoExpand]); const updateInlinePanelLayout = useCallback(() => { if ( @@ -750,11 +855,11 @@ export function TeamWorkspaceDock({ }; }, [shouldPortalPanel, updateInlinePanelLayout]); - if (!shellVisible && !hasRealTeamGraph) { + if (!shellVisible && !hasRuntimeSessions) { return null; } - const panelContent = hasRealTeamGraph ? ( + const panelContent = hasRuntimeSessions ? ( - - - {TEAM_WORKSPACE_SURFACE_TITLE}已启用 - - - {TEAM_WORKSPACE_REALTIME_BADGE_LABEL} - - {runtimeFormationMeta ? ( - {runtimeFormationMeta.label} + {TEAM_WORKSPACE_SURFACE_TITLE} + {runtimeFormationDisplay.panelStatusLabel ? ( + + {runtimeFormationDisplay.panelStatusLabel} + ) : null} - {runtimeFormationMeta?.title || TEAM_WORKSPACE_WAITING_HEADLINE} + {runtimeFormationDisplay.panelHeadline || + TEAM_WORKSPACE_WAITING_HEADLINE} - {dispatchPreviewState?.status === "forming" ? ( - "系统正在按当前任务准备分工,成员接入后会自动展开完整协作面板。" - ) : dispatchPreviewState?.status === "formed" ? ( - `已准备 ${dispatchPreviewState.members.length} 位协作成员,后续会自动接入并继续处理。` - ) : dispatchPreviewState?.status === "failed" ? ( - dispatchPreviewState.errorMessage?.trim() || - "这次任务分工准备失败,你仍然可以继续在当前对话中处理。" - ) : ( - <>需要时系统会自动安排任务分工,这里会切换成完整任务协作面板。 - )} + {dispatchPreviewState + ? runtimeFormationDisplay.noticeText + : runtimeFormationDisplay.panelDescription} {teamQueueReason ? ( {teamQueueReason} @@ -860,7 +955,10 @@ export function TeamWorkspaceDock({ return (
- {member.label} + + {normalizeTeamWorkspaceDisplayValue(member.label) || + member.label} + @@ -878,7 +976,8 @@ export function TeamWorkspaceDock({ ) : null}
- {member.summary} + {normalizeTeamWorkspaceDisplayValue(member.summary) || + member.summary} {member.skillIds.length > 0 ? (
@@ -898,20 +997,24 @@ export function TeamWorkspaceDock({ {!dispatchPreviewState ? selectedTeamRoles?.map((role) => ( - {role.label} + + {normalizeTeamWorkspaceDisplayValue(role.label) || + role.label} + - {role.summary} + {normalizeTeamWorkspaceDisplayValue(role.summary) || + role.summary} )) : null} - {dispatchPreviewState?.blueprint?.label ? ( + {runtimeFormationDisplay.referenceLabel ? ( - 参考方案 · {dispatchPreviewState.blueprint.label} + 参考方案 · {runtimeFormationDisplay.referenceLabel} - {dispatchPreviewState.blueprint.summary || + {runtimeBlueprintSummary || "当前任务分工参考了当前方案里的角色偏好。"} @@ -929,10 +1032,8 @@ export function TeamWorkspaceDock({ {runtimeTeamSummary ? ( {runtimeTeamSummary} ) : null} - {dispatchPreviewState?.members.length ? ( - - {dispatchPreviewState.members.length} 个成员 - + {runtimeTaskCount > 0 ? ( + {runtimeTaskCount} 项任务 ) : null} 不遮挡画布 @@ -974,12 +1075,12 @@ export function TeamWorkspaceDock({ aria-expanded={launcherOnly ? false : expanded} aria-label={ launcherOnly - ? `打开${TEAM_WORKSPACE_SURFACE_TITLE}` + ? "打开任务视图" : expanded - ? `收起${TEAM_WORKSPACE_SURFACE_TITLE}` - : `展开${TEAM_WORKSPACE_SURFACE_TITLE}` + ? "收起任务视图" + : "展开任务视图" } - $active={hasRealTeamGraph} + $active={hasRuntimeSessions} $expanded={expanded} $attention={showAttentionCue} $launcherOnly={launcherOnly} @@ -1004,7 +1105,7 @@ export function TeamWorkspaceDock({ $attention={showAttentionCue} $launcherOnly={launcherOnly} > - + {showAttentionCue ? (
diff --git a/src/components/agent/chat/components/team-workspace-board/SelectedSessionInlineDetail.tsx b/src/components/agent/chat/components/team-workspace-board/SelectedSessionInlineDetail.tsx index ce5d9ef95..e27ca07c8 100644 --- a/src/components/agent/chat/components/team-workspace-board/SelectedSessionInlineDetail.tsx +++ b/src/components/agent/chat/components/team-workspace-board/SelectedSessionInlineDetail.tsx @@ -100,7 +100,7 @@ export function SelectedSessionInlineDetail({
- 协作设置 + 任务分工
{detailDisplay.settingBadges.map((badge) => ( diff --git a/src/components/agent/chat/components/team-workspace-board/SelectedSessionInlineHeader.tsx b/src/components/agent/chat/components/team-workspace-board/SelectedSessionInlineHeader.tsx index d11da69c2..396fda370 100644 --- a/src/components/agent/chat/components/team-workspace-board/SelectedSessionInlineHeader.tsx +++ b/src/components/agent/chat/components/team-workspace-board/SelectedSessionInlineHeader.tsx @@ -51,7 +51,7 @@ export function SelectedSessionInlineHeader({ 当前查看 {selectedSession.isCurrent ? ( - 当前对话 + 当前任务 ) : null}
@@ -103,7 +103,7 @@ export function SelectedSessionInlineHeader({ variant="outline" onClick={() => void onOpenSelectedSession?.()} > - {isChildSession ? "切换会话" : "打开对话"} + {isChildSession ? "切换任务" : "打开任务"} ) : null}
diff --git a/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceBoardHeader.tsx b/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceBoardHeader.tsx index 065974d4d..3b1da33b0 100644 --- a/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceBoardHeader.tsx +++ b/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceBoardHeader.tsx @@ -1,19 +1,9 @@ -import { - Activity, - ArrowUpLeft, - ChevronDown, - ChevronUp, - Workflow, -} from "lucide-react"; +import { ArrowUpLeft, ChevronDown, ChevronUp } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import type { TeamWorkspaceBoardChromeDisplayState } from "../../team-workspace-runtime/boardChromeSelectors"; import type { TeamWorkspaceRuntimeStatus } from "../../teamWorkspaceRuntime"; -import { - TEAM_WORKSPACE_IDLE_STATUS_LABEL, - TEAM_WORKSPACE_REALTIME_BADGE_LABEL, - TEAM_WORKSPACE_SURFACE_TITLE, -} from "../../utils/teamWorkspaceCopy"; +import { TEAM_WORKSPACE_IDLE_STATUS_LABEL, TEAM_WORKSPACE_SURFACE_TITLE } from "../../utils/teamWorkspaceCopy"; interface TeamWorkspaceStatusMeta { badgeClassName: string; @@ -57,31 +47,25 @@ export function TeamWorkspaceBoardHeader({ return (
-
- - {TEAM_WORKSPACE_SURFACE_TITLE} - - - {TEAM_WORKSPACE_REALTIME_BADGE_LABEL} +
+ + {TEAM_WORKSPACE_SURFACE_TITLE} + {createdFromTurnId ? ( + + 承接自 {createdFromTurnId} + + ) : null}
-
+
{boardChromeDisplay.compactBoardHeadline} - {createdFromTurnId ? ( - - 来自之前的任务 {createdFromTurnId} - - ) : null} {!useCompactCanvasChrome && !isChildSession && totalTeamSessions > 0 ? ( - {totalTeamSessions} 位协作成员 + {totalTeamSessions} 项任务 ) : null}
@@ -136,7 +120,7 @@ export function TeamWorkspaceBoardHeader({ onClick={() => void onReturnToParentSession()} > - 返回主助手 + 返回主任务 ) : null}
diff --git a/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceCanvasLaneCard.tsx b/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceCanvasLaneCard.tsx index 31e0846dc..8c4b31bf1 100644 --- a/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceCanvasLaneCard.tsx +++ b/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceCanvasLaneCard.tsx @@ -193,7 +193,7 @@ export function TeamWorkspaceCanvasLaneCard({
- 成员进展 + 任务进展 {lane.kind === "session" ? "最近进展" : "等待接入"} diff --git a/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceCanvasStage.tsx b/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceCanvasStage.tsx index c91d4a422..b644e3402 100644 --- a/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceCanvasStage.tsx +++ b/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceCanvasStage.tsx @@ -94,9 +94,9 @@ export function TeamWorkspaceCanvasStage({ data-testid="team-workspace-canvas-shortcuts" > - 画布 + 任务视图 - 空白处拖拽 · Space 手型 · A 整理 · F 适应 + 拖拽调整任务布局 · Space 平移 · A 整理 · F 聚焦
{lanes.length > 0 ? (
- 暂无协作画布 + 暂无任务视图

{canvasStageHint} diff --git a/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceCanvasSurfaceCopy.test.tsx b/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceCanvasSurfaceCopy.test.tsx new file mode 100644 index 000000000..50b68fcd7 --- /dev/null +++ b/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceCanvasSurfaceCopy.test.tsx @@ -0,0 +1,119 @@ +import { act, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TeamWorkspaceCanvasStage } from "./TeamWorkspaceCanvasStage"; +import { TeamWorkspaceCanvasToolbar } from "./TeamWorkspaceCanvasToolbar"; +import { TeamWorkspaceCanvasViewButtons } from "./TeamWorkspaceTeamOverviewControls"; + +function renderIntoDocument(element: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(element); + }); + + return { + container, + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, + }; +} + +describe("TeamWorkspaceCanvasSurfaceCopy", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + }); + + it("应使用任务视图口径展示任务布局提示", () => { + const { container, unmount } = renderIntoDocument( + , + ); + + try { + expect(container.textContent).toContain("任务视图"); + expect(container.textContent).toContain("拖拽调整任务布局"); + expect(container.textContent).toContain("暂无任务视图"); + expect(container.textContent).not.toContain("暂无任务画布"); + } finally { + unmount(); + } + }); + + it("工具栏应使用任务视图与聚焦任务口径", () => { + const { container, unmount } = renderIntoDocument( + , + ); + + try { + expect(container.textContent).toContain("任务视图"); + expect(container.textContent).toContain("3 个任务面板"); + expect(container.textContent).toContain("聚焦任务"); + expect(container.textContent).not.toContain("自由画布"); + expect(container.textContent).not.toContain("适应视图"); + } finally { + unmount(); + } + }); + + it("紧凑控制条也应使用聚焦任务口径", () => { + const { container, unmount } = renderIntoDocument( + , + ); + + try { + expect(container.textContent).toContain("整理布局"); + expect(container.textContent).toContain("聚焦任务"); + expect(container.textContent).not.toContain("适应视图"); + } finally { + unmount(); + } + }); +}); diff --git a/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceCanvasToolbar.tsx b/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceCanvasToolbar.tsx index 68a694779..cc40d39a1 100644 --- a/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceCanvasToolbar.tsx +++ b/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceCanvasToolbar.tsx @@ -23,14 +23,14 @@ export function TeamWorkspaceCanvasToolbar({

- 自由画布 + 任务视图 缩放 {Math.round(zoom * 100)}% {laneCount > 0 ? ( - {laneCount} 个成员面板 + {laneCount} 个任务面板 ) : null}
@@ -64,7 +64,7 @@ export function TeamWorkspaceCanvasToolbar({ variant="outline" onClick={onFitCanvasView} > - 适应视图 + 聚焦任务
diff --git a/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceEmptyShellState.tsx b/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceEmptyShellState.tsx index d28db05f6..38fd2fd02 100644 --- a/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceEmptyShellState.tsx +++ b/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceEmptyShellState.tsx @@ -1,4 +1,4 @@ -import { Activity, ChevronDown, Workflow } from "lucide-react"; +import { ChevronDown } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import type { @@ -7,7 +7,6 @@ import type { } from "../../team-workspace-runtime/formationDisplaySelectors"; import { TEAM_WORKSPACE_IDLE_STATUS_LABEL, - TEAM_WORKSPACE_REALTIME_BADGE_LABEL, TEAM_WORKSPACE_SURFACE_TITLE, } from "../../utils/teamWorkspaceCopy"; @@ -43,19 +42,16 @@ export function TeamWorkspaceEmptyShellState({ >
-
- - {TEAM_WORKSPACE_SURFACE_TITLE} - - - {TEAM_WORKSPACE_REALTIME_BADGE_LABEL} +
+ + {TEAM_WORKSPACE_SURFACE_TITLE}
-
+
{hasRuntimeFormation ? runtimeFormationDisplay.panelHeadline - : "任务协作已就绪"} + : "任务面板已就绪"} {runtimeFormationDisplay.panelStatusLabel || @@ -85,7 +81,7 @@ export function TeamWorkspaceEmptyShellState({ data-testid="team-workspace-detail-toggle" > - 查看任务进行时 + 查看任务视图
diff --git a/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceFallbackDetailSection.tsx b/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceFallbackDetailSection.tsx index 475835cb0..0d1914d2e 100644 --- a/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceFallbackDetailSection.tsx +++ b/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceFallbackDetailSection.tsx @@ -69,7 +69,7 @@ export function TeamWorkspaceFallbackDetailSection({ ) : null}
- 推荐流程:邀请协作成员 → 查看结果 → 补充说明 + 推荐流程:拆出任务 → 查看结果 → 补充说明
diff --git a/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceTeamOperationsPanel.tsx b/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceTeamOperationsPanel.tsx index 632734da5..2e716bbd0 100644 --- a/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceTeamOperationsPanel.tsx +++ b/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceTeamOperationsPanel.tsx @@ -22,59 +22,92 @@ export function TeamWorkspaceTeamOperationsPanel({ return null; } + const heading = ( +
+ + 任务进展 + + 最近 {teamOperationEntries.length} 条 + +
+ ); + + const renderEntry = (entry: TeamOperationDisplayEntry, compact: boolean) => { + const content = ( + <> +
+ + {entry.title} + + + {formatOperationUpdatedAt(entry.updatedAt)} + +
+

+ {entry.detail} +

+ + ); + + if (entry.targetSessionId) { + return ( + + ); + } + + return ( +
+ {content} +
+ ); + }; + if (useCompactCanvasChrome) { return (
-
- - 协作动态 - - {teamOperationEntries.length} - + {heading} +
+ {teamOperationEntries.map((entry) => renderEntry(entry, true))}
- {teamOperationEntries.map((entry) => { - const content = ( -
- - {entry.title} - -
-
- {entry.detail} -
-
- {formatOperationUpdatedAt(entry.updatedAt)} -
-
-
- ); - - return entry.targetSessionId ? ( - - ) : ( -
- {content} -
- ); - })}
); } @@ -88,63 +121,9 @@ export function TeamWorkspaceTeamOperationsPanel({ )} data-testid="team-workspace-team-operations" > -
- - 协作动态 - - 最近 {teamOperationEntries.length} 条 - -
+ {heading}
- {teamOperationEntries.map((entry) => { - const content = ( - <> -
- - {entry.title} - - - {formatOperationUpdatedAt(entry.updatedAt)} - -
-

- {entry.detail} -

- - ); - - return entry.targetSessionId ? ( - - ) : ( -
- {content} -
- ); - })} + {teamOperationEntries.map((entry) => renderEntry(entry, false))}
); diff --git a/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceTeamOverviewChrome.test.tsx b/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceTeamOverviewChrome.test.tsx new file mode 100644 index 000000000..31b21b398 --- /dev/null +++ b/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceTeamOverviewChrome.test.tsx @@ -0,0 +1,187 @@ +import { act, type ComponentProps, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TeamWorkspaceTeamOverviewChrome } from "./TeamWorkspaceTeamOverviewChrome"; + +vi.mock("@/components/ui/button", () => ({ + Button: ({ + children, + className, + onClick, + type = "button", + ...props + }: { + children: ReactNode; + className?: string; + onClick?: () => void; + type?: "button" | "submit" | "reset"; + [key: string]: unknown; + }) => ( + + ), +})); + +const mountedRoots: Array<{ + root: ReturnType; + container: HTMLDivElement; +}> = []; + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + continue; + } + + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.clearAllMocks(); +}); + +function renderChrome( + props?: Partial>, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + const defaultProps: ComponentProps = + { + boardChromeDisplay: { + boardHeadline: "任务进行中 · 1 项处理中 / 1 项稍后开始", + boardHint: "这里只展示当前有哪些任务在处理、状态如何,以及最近更新到了哪里。", + compactBoardHeadline: "任务进行中 · 1 项处理中 / 1 项稍后开始", + compactToolbarChips: [ + { key: "focus", text: "当前焦点 研究员", tone: "summary" }, + { key: "status", text: "处理中", tone: "status", status: "running" }, + { key: "updated-at", text: "更新于 刚刚", tone: "muted" }, + { key: "current", text: "当前任务", tone: "muted" }, + { key: "waitable", text: "2 项处理中", tone: "muted" }, + ], + statusSummaryBadges: [], + }, + canCloseCompletedTeamSessions: true, + canWaitAnyActiveTeamSession: true, + completedCount: 1, + embedded: true, + formatUpdatedAt: () => "刚刚", + memberCanvasSubtitle: + "2 项任务已接入,当前焦点会优先落在正在处理的任务上。", + memberCanvasTitle: "任务视图", + onAutoArrangeCanvas: vi.fn(), + onCloseCompletedTeamSessions: vi.fn(), + onFitCanvasView: vi.fn(), + onSelectTeamOperationEntry: vi.fn(), + onWaitAnyActiveTeamSessions: vi.fn(), + onZoomIn: vi.fn(), + onZoomOut: vi.fn(), + pendingTeamAction: null, + resolveStatusMeta: () => ({ + badgeClassName: "border border-sky-200 bg-sky-50 text-sky-700", + }), + selectedSession: { + name: "研究员", + isCurrent: true, + updatedAt: 1710000000, + }, + teamOperationEntries: [], + useCompactCanvasChrome: true, + waitableCount: 2, + }; + + act(() => { + root.render( + , + ); + }); + + mountedRoots.push({ root, container }); + return container; +} + +describe("TeamWorkspaceTeamOverviewChrome", () => { + it("紧凑态应把当前焦点放进独立摘要块,并把操作放在下一行", () => { + const container = renderChrome(); + + const summary = container.querySelector( + '[data-testid="team-workspace-compact-summary"]', + ) as HTMLElement | null; + const toolbar = container.querySelector( + '[data-testid="team-workspace-canvas-toolbar"]', + ) as HTMLElement | null; + const controls = container.querySelector( + '[data-testid="team-workspace-compact-controls"]', + ) as HTMLElement | null; + + expect(summary?.textContent).toContain("任务视图"); + expect(summary?.textContent).toContain("研究员"); + expect(summary?.textContent).toContain("当前焦点"); + expect(summary?.textContent).toContain( + "当前焦点会优先落在正在处理的任务上。", + ); + expect(summary?.textContent).toContain("更新于 刚刚"); + expect(summary?.textContent).toContain("当前任务"); + + expect(toolbar?.textContent).toContain("处理中"); + expect(toolbar?.textContent).toContain("2 项处理中"); + expect(toolbar?.textContent).not.toContain("当前焦点 研究员"); + + expect(controls?.textContent).toContain("任务处理"); + expect(controls?.textContent).toContain("视图"); + expect(controls?.textContent).toContain("等待任一任务结果"); + expect(controls?.textContent).toContain("收起已完成任务"); + expect(controls?.textContent).toContain("聚焦任务"); + expect(controls?.textContent).toContain("整理布局"); + expect(controls?.textContent).not.toContain("缩小"); + expect(controls?.textContent).not.toContain("放大"); + }); + + it("紧凑态存在任务进展时,应优先把进展列表放在次级操作之前", () => { + const container = renderChrome({ + teamOperationEntries: [ + { + id: "wait-1", + title: "收到结果", + detail: "刚才等到 执行器 返回了新结果,当前状态为已完成。", + badgeClassName: "border border-emerald-200 bg-emerald-50 text-emerald-700", + updatedAt: 1710000000, + targetSessionId: "child-1", + }, + ], + }); + + const operations = container.querySelector( + '[data-testid="team-workspace-team-operations"]', + ) as HTMLElement | null; + const controls = container.querySelector( + '[data-testid="team-workspace-compact-controls"]', + ) as HTMLElement | null; + + expect(operations?.textContent).toContain("任务进展"); + expect(operations?.textContent).toContain("最近 1 条"); + expect(operations?.textContent).toContain("收到结果"); + expect(controls?.textContent).toContain("任务处理"); + expect(controls?.textContent).toContain("视图"); + const operationsOrder = + operations && controls + ? operations.compareDocumentPosition(controls) & + Node.DOCUMENT_POSITION_FOLLOWING + : 0; + expect( + operationsOrder, + ).toBeTruthy(); + }); +}); diff --git a/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceTeamOverviewChrome.tsx b/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceTeamOverviewChrome.tsx index 0924b8406..f773cb4a4 100644 --- a/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceTeamOverviewChrome.tsx +++ b/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceTeamOverviewChrome.tsx @@ -14,6 +14,7 @@ interface TeamWorkspaceStatusMeta { } interface TeamWorkspaceOverviewSelectedSession { + name?: string; isCurrent?: boolean; updatedAt?: number; } @@ -71,47 +72,124 @@ export function TeamWorkspaceTeamOverviewChrome({ "rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[11px] text-slate-500"; const compactCanvasMutedChipClassName = "rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-[11px] text-slate-500"; + const compactActionButtonClassName = + "h-8 rounded-full border-slate-200 bg-white px-3 text-xs text-slate-700 hover:bg-slate-100"; + const compactViewButtonClassName = + "h-8 rounded-full border-slate-200 bg-slate-50 px-3 text-xs text-slate-600 hover:bg-white"; + const compactSummaryStatusChips = boardChromeDisplay.compactToolbarChips.filter( + (chip) => chip.key !== "focus", + ); + const hasCompactTaskActions = + canWaitAnyActiveTeamSession || canCloseCompletedTeamSessions; return ( <> -
- {useCompactCanvasChrome ? ( - <> -
- {boardChromeDisplay.compactToolbarChips.map((chip) => ( - - ))} + {useCompactCanvasChrome ? ( +
+
+
+
+
+ {memberCanvasTitle} +
+
+
+ {selectedSession?.name?.trim() || "等待任务接手"} +
+ + {selectedSession ? "当前焦点" : "等待接手"} + +
+

+ {memberCanvasSubtitle} +

+
+ {selectedSession ? ( +
+ + + 更新于 {formatUpdatedAt(selectedSession.updatedAt)} + + {selectedSession.isCurrent ? ( + + 当前任务 + + ) : null} +
+ ) : null}
-
- + {compactSummaryStatusChips.length > 0 ? ( +
+ {compactSummaryStatusChips.map((chip) => ( + + ))} +
+ ) : null} +
+ +
+ {hasCompactTaskActions ? ( +
+ + 任务处理 + + +
+ ) : null} +
+ + 视图 +
- - ) : ( +
+
+ ) : ( +
<>
@@ -129,14 +207,14 @@ export function TeamWorkspaceTeamOverviewChrome({ {selectedSession.isCurrent ? ( - 当前对话 + 当前任务 ) : null}
) : null} - )} -
+
+ )} {!useCompactCanvasChrome && (canWaitAnyActiveTeamSession || canCloseCompletedTeamSessions || @@ -151,22 +229,24 @@ export function TeamWorkspaceTeamOverviewChrome({ /> {canWaitAnyActiveTeamSession ? ( - {waitableCount} 位成员正在处理中 + {waitableCount} 项任务正在处理中 ) : null} {canCloseCompletedTeamSessions ? ( - {completedCount} 位成员已完成 + {completedCount} 项任务已完成 ) : null}
) : null} - + {!useCompactCanvasChrome ? ( + + ) : null} ); } diff --git a/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceTeamOverviewControls.tsx b/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceTeamOverviewControls.tsx index 0b988b45c..8131b5c95 100644 --- a/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceTeamOverviewControls.tsx +++ b/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceTeamOverviewControls.tsx @@ -9,6 +9,7 @@ interface TeamWorkspaceStatusMeta { } interface TeamWorkspaceTeamActionButtonsProps { + buttonClassName?: string; canCloseCompletedTeamSessions: boolean; canWaitAnyActiveTeamSession: boolean; onCloseCompletedTeamSessions: () => void | Promise; @@ -17,6 +18,7 @@ interface TeamWorkspaceTeamActionButtonsProps { } export function TeamWorkspaceTeamActionButtons({ + buttonClassName, canCloseCompletedTeamSessions, canWaitAnyActiveTeamSession, onCloseCompletedTeamSessions, @@ -30,6 +32,7 @@ export function TeamWorkspaceTeamActionButtons({ type="button" size="sm" variant="outline" + className={buttonClassName} disabled={pendingTeamAction === "wait_any"} onClick={() => void onWaitAnyActiveTeamSessions()} data-testid="team-workspace-wait-active-button" @@ -39,7 +42,7 @@ export function TeamWorkspaceTeamActionButtons({ ) : null} {pendingTeamAction === "wait_any" ? "等待中..." - : "等待任一成员结果"} + : "等待任一任务结果"} ) : null} {canCloseCompletedTeamSessions ? ( @@ -47,6 +50,7 @@ export function TeamWorkspaceTeamActionButtons({ type="button" size="sm" variant="outline" + className={buttonClassName} disabled={pendingTeamAction === "close_completed"} onClick={() => void onCloseCompletedTeamSessions()} data-testid="team-workspace-close-completed-button" @@ -56,7 +60,7 @@ export function TeamWorkspaceTeamActionButtons({ ) : null} {pendingTeamAction === "close_completed" ? "收起中..." - : "收起已完成成员"} + : "收起已完成任务"} ) : null} @@ -64,6 +68,8 @@ export function TeamWorkspaceTeamActionButtons({ } interface TeamWorkspaceCanvasViewButtonsProps { + buttonClassName?: string; + includeZoomControls?: boolean; onAutoArrangeCanvas: () => void; onFitCanvasView: () => void; onZoomIn: () => void; @@ -71,6 +77,8 @@ interface TeamWorkspaceCanvasViewButtonsProps { } export function TeamWorkspaceCanvasViewButtons({ + buttonClassName, + includeZoomControls = true, onAutoArrangeCanvas, onFitCanvasView, onZoomIn, @@ -82,20 +90,43 @@ export function TeamWorkspaceCanvasViewButtons({ type="button" size="sm" variant="outline" + className={buttonClassName} + onClick={onFitCanvasView} + > + 聚焦任务 + + - - - + {includeZoomControls ? ( + <> + + + + ) : null} ); } diff --git a/src/components/agent/chat/components/team-workspace-board/teamWorkspaceBoardPresentationSelectors.test.ts b/src/components/agent/chat/components/team-workspace-board/teamWorkspaceBoardPresentationSelectors.test.ts new file mode 100644 index 000000000..02466e582 --- /dev/null +++ b/src/components/agent/chat/components/team-workspace-board/teamWorkspaceBoardPresentationSelectors.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + buildTeamWorkspaceBoardSurfaceClassNames, + resolveTeamWorkspaceBoardCopyState, +} from "./teamWorkspaceBoardPresentationSelectors"; + +describe("teamWorkspaceBoardPresentationSelectors", () => { + it("应为无真实成员画布的 schedule 状态返回稳定文案", () => { + expect( + resolveTeamWorkspaceBoardCopyState({ + detailExpanded: false, + dispatchPreviewStatus: "forming", + hasRuntimeSessions: false, + isChildSession: false, + isEmptyShellState: false, + shellExpanded: false, + visibleSessionsCount: 0, + }), + ).toMatchObject({ + detailToggleLabel: "查看细节", + detailVisible: false, + memberCanvasTitle: "任务视图", + }); + + expect( + resolveTeamWorkspaceBoardCopyState({ + detailExpanded: false, + dispatchPreviewStatus: "forming", + hasRuntimeSessions: false, + isChildSession: false, + isEmptyShellState: false, + shellExpanded: false, + visibleSessionsCount: 0, + }).memberCanvasSubtitle, + ).toContain("正在准备当前任务分工"); + expect( + resolveTeamWorkspaceBoardCopyState({ + detailExpanded: false, + dispatchPreviewStatus: "failed", + hasRuntimeSessions: false, + isChildSession: false, + isEmptyShellState: false, + shellExpanded: false, + visibleSessionsCount: 0, + }).memberCanvasSubtitle, + ).toContain("任务分工准备失败"); + }); + + it("应为真实任务画布与嵌入态返回紧凑壳层样式", () => { + const copyState = resolveTeamWorkspaceBoardCopyState({ + detailExpanded: false, + dispatchPreviewStatus: null, + hasRuntimeSessions: true, + isChildSession: false, + isEmptyShellState: false, + shellExpanded: false, + visibleSessionsCount: 3, + }); + const classNames = buildTeamWorkspaceBoardSurfaceClassNames({ + className: "custom-shell", + detailVisible: false, + embedded: true, + selectedSessionStatusCardClassName: "border-sky-200 bg-white", + selectedSessionVisible: true, + useCompactCanvasChrome: true, + }); + + expect(copyState.memberCanvasSubtitle).toContain("3 项任务已接入"); + expect(classNames.boardShellClassName).toContain("rounded-[24px]"); + expect(classNames.boardShellClassName).toContain("border-slate-200"); + expect(classNames.boardShellClassName).toContain("bg-white"); + expect(classNames.boardShellClassName).toContain("custom-shell"); + expect(classNames.boardHeaderClassName).toContain("sticky top-0"); + expect(classNames.canvasStageHeight).toBe("clamp(560px, 76vh, 980px)"); + expect(classNames.detailCardClassName).toContain("rounded-[20px]"); + expect(classNames.railCardClassName).toContain("space-y-3"); + }); +}); diff --git a/src/components/agent/chat/components/team-workspace-board/teamWorkspaceBoardPresentationSelectors.ts b/src/components/agent/chat/components/team-workspace-board/teamWorkspaceBoardPresentationSelectors.ts new file mode 100644 index 000000000..c44d8a969 --- /dev/null +++ b/src/components/agent/chat/components/team-workspace-board/teamWorkspaceBoardPresentationSelectors.ts @@ -0,0 +1,138 @@ +import { cn } from "@/lib/utils"; +import type { TeamWorkspaceRuntimeFormationStatus } from "../../teamWorkspaceRuntime"; +import { TEAM_WORKSPACE_CANVAS_STAGE_HEIGHT } from "../../utils/teamWorkspaceCanvas"; + +interface ResolveTeamWorkspaceBoardCopyStateParams { + detailExpanded: boolean; + dispatchPreviewStatus?: TeamWorkspaceRuntimeFormationStatus | null; + hasRuntimeSessions: boolean; + isChildSession: boolean; + isEmptyShellState: boolean; + shellExpanded: boolean; + visibleSessionsCount: number; +} + +interface TeamWorkspaceBoardCopyState { + detailToggleLabel: string; + detailVisible: boolean; + memberCanvasSubtitle: string; + memberCanvasTitle: string; +} + +interface BuildTeamWorkspaceBoardSurfaceClassNamesParams { + className?: string; + detailVisible: boolean; + embedded: boolean; + selectedSessionStatusCardClassName?: string | null; + selectedSessionVisible: boolean; + useCompactCanvasChrome: boolean; +} + +interface TeamWorkspaceBoardSurfaceClassNames { + boardBodyClassName: string; + boardHeaderClassName: string; + boardShellClassName: string; + canvasStageHeight: string; + detailCardClassName: string; + inlineDetailSectionClassName: string; + inlineTimelineEntryClassName: string; + inlineTimelineFeedClassName: string; + railCardClassName: string; +} + +export function resolveTeamWorkspaceBoardCopyState({ + detailExpanded, + dispatchPreviewStatus = null, + hasRuntimeSessions, + isChildSession, + isEmptyShellState, + shellExpanded, + visibleSessionsCount, +}: ResolveTeamWorkspaceBoardCopyStateParams): TeamWorkspaceBoardCopyState { + const detailVisible = + isEmptyShellState || !hasRuntimeSessions + ? detailExpanded || shellExpanded + : false; + + return { + detailToggleLabel: detailVisible ? "收起细节" : "查看细节", + detailVisible, + memberCanvasTitle: "任务视图", + memberCanvasSubtitle: hasRuntimeSessions + ? isChildSession + ? "并行任务会在各自面板里持续更新进展和结果,主对话只保留必要摘要。" + : `${visibleSessionsCount} 项任务已接入,当前焦点会优先落在正在处理的任务上。` + : dispatchPreviewStatus === "forming" + ? "正在准备当前任务分工,任务拆出后会在这里独立更新进展。" + : dispatchPreviewStatus === "formed" + ? "当前任务分工已经就绪,任务拆出后会在各自面板里开始处理。" + : dispatchPreviewStatus === "failed" + ? "这次任务分工准备失败,暂时无法生成任务面板。" + : "任务拆出后,这里会展开为独立的任务视图。", + }; +} + +export function buildTeamWorkspaceBoardSurfaceClassNames({ + className, + detailVisible, + embedded, + selectedSessionStatusCardClassName = null, + selectedSessionVisible, + useCompactCanvasChrome, +}: BuildTeamWorkspaceBoardSurfaceClassNamesParams): TeamWorkspaceBoardSurfaceClassNames { + return { + boardBodyClassName: embedded + ? cn( + "min-h-0 flex-1 overflow-y-auto overscroll-contain", + useCompactCanvasChrome + ? "space-y-2.5 p-3 sm:p-3.5" + : "space-y-3 p-3 sm:p-4", + ) + : cn(useCompactCanvasChrome ? "p-3 sm:p-3.5" : "p-3 sm:p-4"), + boardHeaderClassName: cn( + "flex flex-wrap items-start justify-between gap-3", + useCompactCanvasChrome ? "px-4 py-2.5 sm:px-4" : "px-4 py-3.5 sm:px-5", + embedded + ? cn( + "sticky top-0 z-20 border-b border-slate-200", + useCompactCanvasChrome ? "bg-white" : "bg-slate-50", + ) + : cn( + "border-b border-slate-200", + useCompactCanvasChrome ? "bg-white" : "bg-slate-50", + ), + ), + boardShellClassName: cn( + embedded + ? "pointer-events-auto flex h-full min-h-0 flex-1 flex-col overflow-hidden rounded-[24px] border border-slate-200 bg-white shadow-sm shadow-slate-950/5" + : "overflow-hidden rounded-[26px] border border-slate-200 bg-white shadow-[0_18px_64px_-42px_rgba(15,23,42,0.24)]", + embedded ? "mx-0 mt-0" : "mx-3 mt-2", + className, + ), + canvasStageHeight: + embedded && !detailVisible + ? "clamp(560px, 76vh, 980px)" + : TEAM_WORKSPACE_CANVAS_STAGE_HEIGHT, + detailCardClassName: cn( + embedded + ? "rounded-[20px] border border-slate-200 bg-white p-4" + : "rounded-[22px] border p-4 shadow-sm shadow-slate-950/5", + !embedded && + (selectedSessionVisible + ? selectedSessionStatusCardClassName + : "border-slate-200 bg-white"), + ), + inlineDetailSectionClassName: + "mt-3 rounded-[18px] border border-slate-200 bg-slate-50 p-3", + inlineTimelineEntryClassName: + "rounded-[14px] border border-slate-200 bg-white p-3", + inlineTimelineFeedClassName: + "mt-3 rounded-[16px] border border-slate-200 bg-white p-3", + railCardClassName: embedded + ? cn( + "pointer-events-auto", + useCompactCanvasChrome ? "space-y-3" : "space-y-4", + ) + : "rounded-[22px] border border-slate-200 bg-slate-50 p-3.5 shadow-sm shadow-slate-950/5", + }; +} diff --git a/src/components/agent/chat/components/team-workspace-board/teamWorkspaceBoardPropBuilders.test.ts b/src/components/agent/chat/components/team-workspace-board/teamWorkspaceBoardPropBuilders.test.ts new file mode 100644 index 000000000..e4a7b1ecb --- /dev/null +++ b/src/components/agent/chat/components/team-workspace-board/teamWorkspaceBoardPropBuilders.test.ts @@ -0,0 +1,386 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildTeamWorkspaceBoardCanvasSectionProps, + buildTeamWorkspaceBoardEmptyShellProps, + buildTeamWorkspaceBoardHeaderProps, + buildTeamWorkspaceBoardShellProps, +} from "./teamWorkspaceBoardPropBuilders"; + +describe("teamWorkspaceBoardPropBuilders", () => { + it("应按嵌入态规则组装 empty shell 与 shell props", () => { + const onExpand = vi.fn(); + const onToggleDetail = vi.fn(); + const onReturnToParentSession = vi.fn(); + const resolveStatusMeta = vi.fn().mockReturnValue({ + badgeClassName: "border-sky-200 bg-sky-50 text-sky-700", + }); + + const emptyShellProps = buildTeamWorkspaceBoardEmptyShellProps({ + className: "custom-shell", + embedded: true, + hasRuntimeFormation: true, + onExpand, + runtimeFormationDisplay: { + blueprintRoleCards: [], + emptyDetail: "系统正在准备分工。", + hint: "正在准备分工", + hasRuntimeFormation: true, + memberCards: [], + noticeText: "系统正在准备当前任务分工。", + panelDescription: "当前任务分工正在生成。", + panelHeadline: "任务分工准备中", + panelLabel: "编辑 Team", + panelStatusLabel: "准备中", + panelStatusBadgeClassName: "border-sky-200 bg-sky-50 text-sky-700", + panelTitle: "当前任务分工", + referenceLabel: null, + summaryBadges: [], + }, + selectedTeamPlanDisplay: { + hasSelectedTeamPlan: true, + label: "编辑 Team", + roleCards: [], + summary: "先拆三段,再并行推进。", + summaryBadges: [], + }, + }); + + const headerProps = buildTeamWorkspaceBoardHeaderProps({ + boardChromeDisplay: { + boardHeadline: "主任务总览", + boardHint: "主线程会持续汇总子任务进展。", + compactBoardHeadline: "任务进行中 · 3 项处理中", + compactToolbarChips: [], + statusSummaryBadges: [], + }, + createdFromTurnId: "turn-42", + detailToggleLabel: "查看细节", + detailVisible: false, + isChildSession: true, + isEmptyShellState: false, + onReturnToParentSession, + onToggleDetail, + resolveStatusMeta, + runtimeFormationStatusLabel: "准备中", + totalTeamSessions: 3, + useCompactCanvasChrome: true, + }); + + const shellProps = buildTeamWorkspaceBoardShellProps({ + boardBodyClassName: "board-body", + boardHeaderClassName: "board-header", + boardShellClassName: "board-shell", + canvasSectionProps: { + canvasStageProps: { + canvasBoundsHeight: 720, + canvasBoundsWidth: 1080, + canvasStageHeight: "720px", + canvasStageHint: "拖动查看任务进行时", + expandedSessionId: "child-1", + isCanvasPanModifierActive: false, + laneLayouts: {}, + lanes: [], + onCanvasWheel: vi.fn(), + onSelectLane: vi.fn(), + onStartCanvasLaneDrag: vi.fn(), + onStartCanvasLaneResize: vi.fn(), + onStartCanvasPan: vi.fn(), + selectedInlineDetail: null, + selectedSessionId: "child-1", + viewport: { x: 0, y: 0, zoom: 1 }, + viewportRef: { current: null }, + }, + canvasToolbarProps: { + laneCount: 0, + onAutoArrangeCanvas: vi.fn(), + onFitCanvasView: vi.fn(), + onResetCanvasView: vi.fn(), + onZoomIn: vi.fn(), + onZoomOut: vi.fn(), + zoom: 1, + }, + overviewChromeProps: { + boardChromeDisplay: { + boardHeadline: "主任务总览", + boardHint: "主线程会持续汇总子任务进展。", + compactBoardHeadline: "任务进行中 · 3 项处理中", + compactToolbarChips: [], + statusSummaryBadges: [], + }, + canCloseCompletedTeamSessions: false, + canWaitAnyActiveTeamSession: false, + completedCount: 0, + formatUpdatedAt: () => "刚刚", + memberCanvasSubtitle: "子任务会持续更新进展。", + memberCanvasTitle: "任务视图", + onAutoArrangeCanvas: vi.fn(), + onCloseCompletedTeamSessions: vi.fn(), + onFitCanvasView: vi.fn(), + onSelectTeamOperationEntry: vi.fn(), + onWaitAnyActiveTeamSessions: vi.fn(), + onZoomIn: vi.fn(), + onZoomOut: vi.fn(), + pendingTeamAction: null, + resolveStatusMeta, + selectedSession: null, + teamOperationEntries: [], + waitableCount: 0, + }, + fallbackDetailProps: null, + railCardClassName: "rail-card", + useCompactCanvasChrome: true, + }, + embedded: true, + headerProps, + }); + + expect(emptyShellProps).toMatchObject({ + className: "custom-shell", + embedded: true, + hasRuntimeFormation: true, + }); + expect(emptyShellProps.onExpand).toBe(onExpand); + expect(headerProps).toMatchObject({ + createdFromTurnId: "turn-42", + detailToggleLabel: "查看细节", + runtimeFormationStatusLabel: "准备中", + totalTeamSessions: 3, + }); + expect(headerProps.onToggleDetail).toBe(onToggleDetail); + expect(headerProps.onReturnToParentSession).toBe(onReturnToParentSession); + expect(shellProps).toMatchObject({ + boardBodyClassName: "board-body", + boardHeaderClassName: "board-header", + boardShellClassName: "board-shell", + embedded: true, + style: { maxHeight: "inherit" }, + }); + }); + + it("应在尚无 runtime sessions 时为 canvas section 透传 fallback detail 与 overview props", () => { + const onStartCanvasLaneDrag = vi.fn(); + const onStartCanvasLaneResize = vi.fn(); + const onSelectTeamOperationEntry = vi.fn(); + const resolveStatusMeta = vi.fn().mockReturnValue({ + badgeClassName: "border-amber-200 bg-amber-50 text-amber-700", + }); + + const props = buildTeamWorkspaceBoardCanvasSectionProps({ + boardChromeDisplay: { + boardHeadline: "任务进行中 · 1 项处理中 / 1 项稍后开始", + boardHint: "当前主任务会继续汇总进展。", + compactBoardHeadline: "任务进行中 · 1 项处理中 / 1 项稍后开始", + compactToolbarChips: [ + { + key: "running", + text: "运行中 1", + tone: "status", + status: "running", + }, + ], + statusSummaryBadges: [], + }, + canvasBoundsHeight: 680, + canvasBoundsWidth: 960, + canvasStageHeight: "680px", + canvasStageHint: "正在同步任务进行时", + canvasViewportRef: { current: null }, + canvasZoom: 1.2, + canCloseCompletedTeamSessions: true, + canWaitAnyActiveTeamSession: true, + completedCount: 1, + detailCardClassName: "detail-card", + detailVisible: true, + expandedSessionId: "child-2", + formatUpdatedAt: () => "刚刚", + hasRuntimeSessions: false, + isCanvasPanModifierActive: true, + laneLayouts: {}, + lanes: [ + { + persistKey: "lane-alpha", + } as never, + ], + memberCanvasSubtitle: "子任务会持续更新任务进展。", + memberCanvasTitle: "任务视图", + onAutoArrangeCanvas: vi.fn(), + onCanvasWheel: vi.fn(), + onCloseCompletedTeamSessions: vi.fn(), + onFitCanvasView: vi.fn(), + onResetCanvasView: vi.fn(), + onSelectCanvasLane: vi.fn(), + onSelectTeamOperationEntry, + onStartCanvasLaneDrag, + onStartCanvasLaneResize, + onStartCanvasPan: vi.fn(), + onWaitAnyActiveTeamSessions: vi.fn(), + onZoomIn: vi.fn(), + onZoomOut: vi.fn(), + pendingTeamAction: "wait_any", + railCardClassName: "rail-card", + resolveStatusMeta, + runtimeFormationDisplay: { + blueprintRoleCards: [], + emptyDetail: "系统正在准备分工。", + hint: "正在准备分工", + hasRuntimeFormation: true, + memberCards: [], + noticeText: "系统正在准备当前任务分工。", + panelDescription: "当前任务分工正在生成。", + panelHeadline: "任务分工准备中", + panelLabel: "资料整理 Team", + panelStatusLabel: "准备中", + panelStatusBadgeClassName: "border-sky-200 bg-sky-50 text-sky-700", + panelTitle: "当前任务分工", + referenceLabel: null, + summaryBadges: [], + }, + selectedInlineDetail: "inline-detail", + selectedSession: { + isCurrent: false, + updatedAt: 1710000000, + }, + selectedSessionId: "child-2", + selectedTeamPlanDisplay: { + hasSelectedTeamPlan: true, + label: "资料整理 Team", + roleCards: [], + summary: "先拆任务,再并行推进。", + summaryBadges: [], + }, + teamOperationEntries: [ + { + badgeClassName: "border-sky-200 bg-sky-50 text-sky-700", + detail: "资料整理返回了新结果", + id: "wait-1", + title: "收到结果", + updatedAt: 1710000000, + targetSessionId: "child-2", + }, + ], + useCompactCanvasChrome: true, + viewport: { x: 12, y: 24, zoom: 1.2 }, + waitableCount: 2, + }); + + expect(props.fallbackDetailProps).toMatchObject({ + detailCardClassName: "detail-card", + detailVisible: true, + }); + expect(props.overviewChromeProps).toMatchObject({ + canCloseCompletedTeamSessions: true, + canWaitAnyActiveTeamSession: true, + completedCount: 1, + memberCanvasTitle: "任务视图", + pendingTeamAction: "wait_any", + waitableCount: 2, + }); + expect(props.canvasToolbarProps.zoom).toBe(1.2); + expect(props.canvasStageProps.selectedInlineDetail).toBe("inline-detail"); + expect(props.canvasStageProps.viewport).toEqual({ x: 12, y: 24, zoom: 1.2 }); + + props.canvasStageProps.onStartCanvasLaneDrag?.( + { persistKey: "lane-alpha" } as never, + { clientX: 10, clientY: 20 } as never, + ); + props.canvasStageProps.onStartCanvasLaneResize?.( + { persistKey: "lane-alpha" } as never, + "right" as never, + { clientX: 40, clientY: 60 } as never, + ); + + expect(onStartCanvasLaneDrag).toHaveBeenCalledWith( + "lane-alpha", + expect.anything(), + ); + expect(onStartCanvasLaneResize).toHaveBeenCalledWith( + "lane-alpha", + "right", + expect.anything(), + ); + expect(props.overviewChromeProps.onSelectTeamOperationEntry).toBe( + onSelectTeamOperationEntry, + ); + expect(props.railCardClassName).toBe("rail-card"); + expect(props.useCompactCanvasChrome).toBe(true); + }); + + it("应在已有 runtime sessions 时关闭 fallback detail", () => { + const props = buildTeamWorkspaceBoardCanvasSectionProps({ + boardChromeDisplay: { + boardHeadline: "任务进行中", + boardHint: "正在持续汇总任务进展。", + compactBoardHeadline: "任务进行中", + compactToolbarChips: [], + statusSummaryBadges: [], + }, + canvasBoundsHeight: 680, + canvasBoundsWidth: 960, + canvasStageHeight: "680px", + canvasStageHint: "正在同步任务进行时", + canvasViewportRef: { current: null }, + canvasZoom: 1, + canCloseCompletedTeamSessions: false, + canWaitAnyActiveTeamSession: false, + completedCount: 0, + detailCardClassName: "detail-card", + detailVisible: false, + expandedSessionId: null, + formatUpdatedAt: () => "刚刚", + hasRuntimeSessions: true, + isCanvasPanModifierActive: false, + laneLayouts: {}, + lanes: [], + memberCanvasSubtitle: "已有任务接入。", + memberCanvasTitle: "任务视图", + onAutoArrangeCanvas: vi.fn(), + onCanvasWheel: vi.fn(), + onCloseCompletedTeamSessions: vi.fn(), + onFitCanvasView: vi.fn(), + onResetCanvasView: vi.fn(), + onSelectCanvasLane: vi.fn(), + onSelectTeamOperationEntry: vi.fn(), + onStartCanvasLaneDrag: vi.fn(), + onStartCanvasLaneResize: vi.fn(), + onStartCanvasPan: vi.fn(), + onWaitAnyActiveTeamSessions: vi.fn(), + onZoomIn: vi.fn(), + onZoomOut: vi.fn(), + pendingTeamAction: null, + railCardClassName: "rail-card", + resolveStatusMeta: vi.fn(), + runtimeFormationDisplay: { + blueprintRoleCards: [], + emptyDetail: "系统正在准备分工。", + hint: "正在准备分工", + hasRuntimeFormation: true, + memberCards: [], + noticeText: "系统正在准备当前任务分工。", + panelDescription: "当前任务分工正在生成。", + panelHeadline: "任务分工准备中", + panelLabel: "资料整理 Team", + panelStatusLabel: "准备中", + panelStatusBadgeClassName: "border-sky-200 bg-sky-50 text-sky-700", + panelTitle: "当前任务分工", + referenceLabel: null, + summaryBadges: [], + }, + selectedInlineDetail: null, + selectedSession: null, + selectedSessionId: null, + selectedTeamPlanDisplay: { + hasSelectedTeamPlan: true, + label: "资料整理 Team", + roleCards: [], + summary: "先拆任务,再并行推进。", + summaryBadges: [], + }, + teamOperationEntries: [], + useCompactCanvasChrome: true, + viewport: { x: 0, y: 0, zoom: 1 }, + waitableCount: 0, + }); + + expect(props.fallbackDetailProps).toBeNull(); + }); +}); diff --git a/src/components/agent/chat/components/team-workspace-board/teamWorkspaceBoardPropBuilders.ts b/src/components/agent/chat/components/team-workspace-board/teamWorkspaceBoardPropBuilders.ts index 7ee7f5500..09873c2f8 100644 --- a/src/components/agent/chat/components/team-workspace-board/teamWorkspaceBoardPropBuilders.ts +++ b/src/components/agent/chat/components/team-workspace-board/teamWorkspaceBoardPropBuilders.ts @@ -30,9 +30,30 @@ interface BuildTeamWorkspaceBoardEmptyShellPropsParams { interface BuildTeamWorkspaceBoardShellPropsParams { boardBodyClassName: TeamWorkspaceBoardShellProps["boardBodyClassName"]; - boardChromeDisplay: TeamWorkspaceBoardHeaderProps["boardChromeDisplay"]; boardHeaderClassName: TeamWorkspaceBoardShellProps["boardHeaderClassName"]; boardShellClassName: TeamWorkspaceBoardShellProps["boardShellClassName"]; + canvasSectionProps: TeamWorkspaceBoardShellProps["canvasSectionProps"]; + embedded: boolean; + headerProps: TeamWorkspaceBoardShellProps["headerProps"]; +} + +interface BuildTeamWorkspaceBoardHeaderPropsParams { + boardChromeDisplay: TeamWorkspaceBoardHeaderProps["boardChromeDisplay"]; + createdFromTurnId?: TeamWorkspaceBoardHeaderProps["createdFromTurnId"]; + detailToggleLabel: TeamWorkspaceBoardHeaderProps["detailToggleLabel"]; + detailVisible: TeamWorkspaceBoardHeaderProps["detailVisible"]; + isChildSession: TeamWorkspaceBoardHeaderProps["isChildSession"]; + isEmptyShellState: TeamWorkspaceBoardHeaderProps["isEmptyShellState"]; + onReturnToParentSession?: TeamWorkspaceBoardHeaderProps["onReturnToParentSession"]; + onToggleDetail: TeamWorkspaceBoardHeaderProps["onToggleDetail"]; + resolveStatusMeta: TeamWorkspaceBoardHeaderProps["resolveStatusMeta"]; + runtimeFormationStatusLabel?: TeamWorkspaceBoardHeaderProps["runtimeFormationStatusLabel"]; + totalTeamSessions: TeamWorkspaceBoardHeaderProps["totalTeamSessions"]; + useCompactCanvasChrome: TeamWorkspaceBoardHeaderProps["useCompactCanvasChrome"]; +} + +interface BuildTeamWorkspaceBoardCanvasSectionPropsParams { + boardChromeDisplay: TeamWorkspaceBoardOverviewChromeProps["boardChromeDisplay"]; canvasBoundsHeight: TeamWorkspaceBoardCanvasStageProps["canvasBoundsHeight"]; canvasBoundsWidth: TeamWorkspaceBoardCanvasStageProps["canvasBoundsWidth"]; canvasStageHeight: TeamWorkspaceBoardCanvasStageProps["canvasStageHeight"]; @@ -44,18 +65,13 @@ interface BuildTeamWorkspaceBoardShellPropsParams { canWaitAnyActiveTeamSession: TeamWorkspaceBoardOverviewChromeProps["canWaitAnyActiveTeamSession"]; completedCount: TeamWorkspaceBoardOverviewChromeProps["completedCount"]; - createdFromTurnId?: TeamWorkspaceBoardHeaderProps["createdFromTurnId"]; detailCardClassName: TeamWorkspaceBoardFallbackDetailProps["detailCardClassName"]; - detailToggleLabel: TeamWorkspaceBoardHeaderProps["detailToggleLabel"]; detailVisible: TeamWorkspaceBoardHeaderProps["detailVisible"]; - embedded: boolean; expandedSessionId?: TeamWorkspaceBoardCanvasStageProps["expandedSessionId"]; formatUpdatedAt: TeamWorkspaceBoardOverviewChromeProps["formatUpdatedAt"]; - hasRealTeamGraph: boolean; + hasRuntimeSessions: boolean; isCanvasPanModifierActive: TeamWorkspaceBoardCanvasStageProps["isCanvasPanModifierActive"]; - isChildSession: TeamWorkspaceBoardHeaderProps["isChildSession"]; - isEmptyShellState: TeamWorkspaceBoardHeaderProps["isEmptyShellState"]; laneLayouts: TeamWorkspaceBoardCanvasStageProps["laneLayouts"]; lanes: TeamWorkspaceBoardCanvasStageProps["lanes"]; memberCanvasSubtitle: TeamWorkspaceBoardOverviewChromeProps["memberCanvasSubtitle"]; @@ -66,7 +82,6 @@ interface BuildTeamWorkspaceBoardShellPropsParams { onCloseCompletedTeamSessions: TeamWorkspaceBoardOverviewChromeProps["onCloseCompletedTeamSessions"]; onFitCanvasView: TeamWorkspaceBoardCanvasToolbarProps["onFitCanvasView"]; - onReturnToParentSession?: TeamWorkspaceBoardHeaderProps["onReturnToParentSession"]; onResetCanvasView: TeamWorkspaceBoardCanvasToolbarProps["onResetCanvasView"]; onSelectCanvasLane: TeamWorkspaceBoardCanvasStageProps["onSelectLane"]; onSelectTeamOperationEntry: @@ -85,15 +100,13 @@ interface BuildTeamWorkspaceBoardShellPropsParams { >[2], ) => void; onStartCanvasPan: TeamWorkspaceBoardCanvasStageProps["onStartCanvasPan"]; - onToggleDetail: TeamWorkspaceBoardHeaderProps["onToggleDetail"]; onWaitAnyActiveTeamSessions: TeamWorkspaceBoardOverviewChromeProps["onWaitAnyActiveTeamSessions"]; onZoomIn: TeamWorkspaceBoardCanvasToolbarProps["onZoomIn"]; onZoomOut: TeamWorkspaceBoardCanvasToolbarProps["onZoomOut"]; pendingTeamAction: TeamWorkspaceBoardOverviewChromeProps["pendingTeamAction"]; railCardClassName: TeamWorkspaceBoardCanvasSectionProps["railCardClassName"]; - resolveStatusMeta: TeamWorkspaceBoardHeaderProps["resolveStatusMeta"]; - runtimeFormationStatusLabel?: TeamWorkspaceBoardHeaderProps["runtimeFormationStatusLabel"]; + resolveStatusMeta: TeamWorkspaceBoardOverviewChromeProps["resolveStatusMeta"]; runtimeFormationDisplay: TeamWorkspaceBoardFallbackDetailProps["runtimeFormationDisplay"]; selectedInlineDetail?: TeamWorkspaceBoardCanvasStageProps["selectedInlineDetail"]; @@ -103,8 +116,7 @@ interface BuildTeamWorkspaceBoardShellPropsParams { TeamWorkspaceBoardFallbackDetailProps["selectedTeamPlanDisplay"]; teamOperationEntries: TeamWorkspaceBoardOverviewChromeProps["teamOperationEntries"]; - totalTeamSessions: TeamWorkspaceBoardHeaderProps["totalTeamSessions"]; - useCompactCanvasChrome: TeamWorkspaceBoardHeaderProps["useCompactCanvasChrome"]; + useCompactCanvasChrome: TeamWorkspaceBoardCanvasSectionProps["useCompactCanvasChrome"]; viewport: TeamWorkspaceBoardCanvasStageProps["viewport"]; waitableCount: TeamWorkspaceBoardOverviewChromeProps["waitableCount"]; } @@ -127,31 +139,53 @@ export function buildTeamWorkspaceBoardEmptyShellProps({ }; } -export function buildTeamWorkspaceBoardShellProps({ - boardBodyClassName, +export function buildTeamWorkspaceBoardHeaderProps({ boardChromeDisplay, - boardHeaderClassName, - boardShellClassName, + createdFromTurnId, + detailToggleLabel, + detailVisible, + isChildSession, + isEmptyShellState, + onReturnToParentSession, + onToggleDetail, + resolveStatusMeta, + runtimeFormationStatusLabel, + totalTeamSessions, + useCompactCanvasChrome, +}: BuildTeamWorkspaceBoardHeaderPropsParams): TeamWorkspaceBoardHeaderProps { + return { + boardChromeDisplay, + createdFromTurnId, + detailToggleLabel, + detailVisible, + isChildSession, + isEmptyShellState, + onReturnToParentSession, + onToggleDetail, + resolveStatusMeta, + runtimeFormationStatusLabel, + totalTeamSessions, + useCompactCanvasChrome, + }; +} + +export function buildTeamWorkspaceBoardCanvasSectionProps({ + boardChromeDisplay, + canCloseCompletedTeamSessions, + canWaitAnyActiveTeamSession, + completedCount, + detailCardClassName, + detailVisible, + expandedSessionId, + formatUpdatedAt, + hasRuntimeSessions, canvasBoundsHeight, canvasBoundsWidth, canvasStageHeight, canvasStageHint, canvasViewportRef, canvasZoom, - canCloseCompletedTeamSessions, - canWaitAnyActiveTeamSession, - completedCount, - createdFromTurnId, - detailCardClassName, - detailToggleLabel, - detailVisible, - embedded, - expandedSessionId, - formatUpdatedAt, - hasRealTeamGraph, isCanvasPanModifierActive, - isChildSession, - isEmptyShellState, laneLayouts, lanes, memberCanvasSubtitle, @@ -160,114 +194,108 @@ export function buildTeamWorkspaceBoardShellProps({ onCanvasWheel, onCloseCompletedTeamSessions, onFitCanvasView, - onReturnToParentSession, onResetCanvasView, onSelectCanvasLane, onSelectTeamOperationEntry, onStartCanvasLaneDrag, onStartCanvasLaneResize, onStartCanvasPan, - onToggleDetail, onWaitAnyActiveTeamSessions, onZoomIn, onZoomOut, pendingTeamAction, railCardClassName, resolveStatusMeta, - runtimeFormationStatusLabel, runtimeFormationDisplay, selectedInlineDetail, selectedSession, selectedSessionId, selectedTeamPlanDisplay, teamOperationEntries, - totalTeamSessions, useCompactCanvasChrome, viewport, waitableCount, +}: BuildTeamWorkspaceBoardCanvasSectionPropsParams): TeamWorkspaceBoardCanvasSectionProps { + return { + canvasStageProps: { + canvasBoundsHeight, + canvasBoundsWidth, + canvasStageHeight, + canvasStageHint, + expandedSessionId, + isCanvasPanModifierActive, + laneLayouts, + lanes, + onCanvasWheel, + onSelectLane: onSelectCanvasLane, + onStartCanvasLaneDrag: (lane, event) => + onStartCanvasLaneDrag(lane.persistKey, event), + onStartCanvasLaneResize: (lane, direction, event) => + onStartCanvasLaneResize(lane.persistKey, direction, event), + onStartCanvasPan, + selectedInlineDetail, + selectedSessionId, + viewport, + viewportRef: canvasViewportRef, + }, + canvasToolbarProps: { + laneCount: lanes.length, + onAutoArrangeCanvas, + onFitCanvasView, + onResetCanvasView, + onZoomIn, + onZoomOut, + zoom: canvasZoom, + }, + fallbackDetailProps: hasRuntimeSessions + ? null + : { + detailCardClassName, + detailVisible, + runtimeFormationDisplay, + selectedTeamPlanDisplay, + }, + overviewChromeProps: { + boardChromeDisplay, + canCloseCompletedTeamSessions, + canWaitAnyActiveTeamSession, + completedCount, + formatUpdatedAt, + memberCanvasSubtitle, + memberCanvasTitle, + onAutoArrangeCanvas, + onCloseCompletedTeamSessions, + onFitCanvasView, + onSelectTeamOperationEntry, + onWaitAnyActiveTeamSessions, + onZoomIn, + onZoomOut, + pendingTeamAction, + resolveStatusMeta, + selectedSession, + teamOperationEntries, + waitableCount, + }, + railCardClassName, + useCompactCanvasChrome, + }; +} + +export function buildTeamWorkspaceBoardShellProps({ + boardBodyClassName, + boardHeaderClassName, + boardShellClassName, + canvasSectionProps, + embedded, + headerProps, }: BuildTeamWorkspaceBoardShellPropsParams): TeamWorkspaceBoardShellProps { return { boardBodyClassName, boardHeaderClassName, boardShellClassName, - canvasSectionProps: { - canvasStageProps: { - canvasBoundsHeight, - canvasBoundsWidth, - canvasStageHeight, - canvasStageHint, - expandedSessionId, - isCanvasPanModifierActive, - laneLayouts, - lanes, - onCanvasWheel, - onSelectLane: onSelectCanvasLane, - onStartCanvasLaneDrag: (lane, event) => - onStartCanvasLaneDrag(lane.persistKey, event), - onStartCanvasLaneResize: (lane, direction, event) => - onStartCanvasLaneResize(lane.persistKey, direction, event), - onStartCanvasPan, - selectedInlineDetail, - selectedSessionId, - viewport, - viewportRef: canvasViewportRef, - }, - canvasToolbarProps: { - laneCount: lanes.length, - onAutoArrangeCanvas, - onFitCanvasView, - onResetCanvasView, - onZoomIn, - onZoomOut, - zoom: canvasZoom, - }, - fallbackDetailProps: hasRealTeamGraph - ? null - : { - detailCardClassName, - detailVisible, - runtimeFormationDisplay, - selectedTeamPlanDisplay, - }, - overviewChromeProps: { - boardChromeDisplay, - canCloseCompletedTeamSessions, - canWaitAnyActiveTeamSession, - completedCount, - formatUpdatedAt, - memberCanvasSubtitle, - memberCanvasTitle, - onAutoArrangeCanvas, - onCloseCompletedTeamSessions, - onFitCanvasView, - onSelectTeamOperationEntry, - onWaitAnyActiveTeamSessions, - onZoomIn, - onZoomOut, - pendingTeamAction, - resolveStatusMeta, - selectedSession, - teamOperationEntries, - waitableCount, - }, - railCardClassName, - useCompactCanvasChrome, - }, + canvasSectionProps, embedded, - headerProps: { - boardChromeDisplay, - createdFromTurnId, - detailToggleLabel, - detailVisible, - isChildSession, - isEmptyShellState, - onReturnToParentSession, - onToggleDetail, - resolveStatusMeta, - runtimeFormationStatusLabel, - totalTeamSessions, - useCompactCanvasChrome, - }, + headerProps, style: embedded ? { maxHeight: "inherit" } : undefined, }; } diff --git a/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardActionUiState.ts b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardActionUiState.ts new file mode 100644 index 000000000..e69b9b2ae --- /dev/null +++ b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardActionUiState.ts @@ -0,0 +1,85 @@ +import { useCallback, useMemo, useState } from "react"; +import type { TeamSessionCard } from "../../utils/teamWorkspaceSessions"; + +export type TeamWorkspacePendingSessionAction = + | "close" + | "interrupt_send" + | "resume" + | "send" + | "wait"; + +export type TeamWorkspacePendingTeamAction = + | "close_completed" + | "wait_any"; + +interface UseTeamWorkspaceBoardActionUiStateParams { + selectedSession?: Pick | null; +} + +export function useTeamWorkspaceBoardActionUiState({ + selectedSession, +}: UseTeamWorkspaceBoardActionUiStateParams) { + const [pendingSessionAction, setPendingSessionAction] = useState<{ + sessionId: string; + action: TeamWorkspacePendingSessionAction; + } | null>(null); + const [pendingTeamAction, setPendingTeamAction] = + useState(null); + const [sessionInputDraftById, setSessionInputDraftById] = useState< + Record + >({}); + + const selectedActionPending = Boolean( + selectedSession && pendingSessionAction?.sessionId === selectedSession.id, + ); + const selectedSessionInputDraft = selectedSession + ? (sessionInputDraftById[selectedSession.id] ?? "") + : ""; + const selectedSessionInputMessage = useMemo( + () => selectedSessionInputDraft.trim(), + [selectedSessionInputDraft], + ); + + const handleSelectedSessionInputDraftChange = useCallback( + (value: string) => { + if (!selectedSession) { + return; + } + + setSessionInputDraftById((previous) => { + if (previous[selectedSession.id] === value) { + return previous; + } + return { + ...previous, + [selectedSession.id]: value, + }; + }); + }, + [selectedSession], + ); + + const clearSelectedSessionInputDraft = useCallback((sessionId: string) => { + setSessionInputDraftById((previous) => { + if (!previous[sessionId]) { + return previous; + } + return { + ...previous, + [sessionId]: "", + }; + }); + }, []); + + return { + clearSelectedSessionInputDraft, + handleSelectedSessionInputDraftChange, + pendingSessionAction, + pendingTeamAction, + selectedActionPending, + selectedSessionInputDraft, + selectedSessionInputMessage, + setPendingSessionAction, + setPendingTeamAction, + }; +} diff --git a/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardActions.ts b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardActions.ts index 16fbbab08..30c76b820 100644 --- a/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardActions.ts +++ b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardActions.ts @@ -1,16 +1,8 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback } from "react"; import type { TeamSessionCard } from "../../utils/teamWorkspaceSessions"; - -export type TeamWorkspacePendingSessionAction = - | "close" - | "interrupt_send" - | "resume" - | "send" - | "wait"; - -export type TeamWorkspacePendingTeamAction = - | "close_completed" - | "wait_any"; +import { + useTeamWorkspaceBoardActionUiState, +} from "./useTeamWorkspaceBoardActionUiState"; interface UseTeamWorkspaceBoardActionsParams { completedTeamSessionIds: string[]; @@ -47,26 +39,19 @@ export function useTeamWorkspaceBoardActions({ waitTimeoutMs = 30_000, waitableTeamSessionIds, }: UseTeamWorkspaceBoardActionsParams) { - const [pendingSessionAction, setPendingSessionAction] = useState<{ - sessionId: string; - action: TeamWorkspacePendingSessionAction; - } | null>(null); - const [pendingTeamAction, setPendingTeamAction] = - useState(null); - const [sessionInputDraftById, setSessionInputDraftById] = useState< - Record - >({}); - - const selectedActionPending = Boolean( - selectedSession && pendingSessionAction?.sessionId === selectedSession.id, - ); - const selectedSessionInputDraft = selectedSession - ? (sessionInputDraftById[selectedSession.id] ?? "") - : ""; - const selectedSessionInputMessage = useMemo( - () => selectedSessionInputDraft.trim(), - [selectedSessionInputDraft], - ); + const { + clearSelectedSessionInputDraft, + handleSelectedSessionInputDraftChange, + pendingSessionAction, + pendingTeamAction, + selectedActionPending, + selectedSessionInputDraft, + selectedSessionInputMessage, + setPendingSessionAction, + setPendingTeamAction, + } = useTeamWorkspaceBoardActionUiState({ + selectedSession, + }); const handleWaitAnyActiveTeamSessions = useCallback(async () => { if (!onWaitActiveTeamSessions || waitableTeamSessionIds.length <= 1) { @@ -79,7 +64,12 @@ export function useTeamWorkspaceBoardActions({ } finally { setPendingTeamAction(null); } - }, [onWaitActiveTeamSessions, waitTimeoutMs, waitableTeamSessionIds]); + }, [ + onWaitActiveTeamSessions, + setPendingTeamAction, + waitTimeoutMs, + waitableTeamSessionIds, + ]); const handleCloseCompletedTeamSessions = useCallback(async () => { if (!onCloseCompletedTeamSessions || completedTeamSessionIds.length === 0) { @@ -92,7 +82,11 @@ export function useTeamWorkspaceBoardActions({ } finally { setPendingTeamAction(null); } - }, [completedTeamSessionIds, onCloseCompletedTeamSessions]); + }, [ + completedTeamSessionIds, + onCloseCompletedTeamSessions, + setPendingTeamAction, + ]); const handleSelectedSessionAction = useCallback( async (action: "close" | "resume" | "wait") => { @@ -122,29 +116,11 @@ export function useTeamWorkspaceBoardActions({ onResumeSubagentSession, onWaitSubagentSession, selectedSession, + setPendingSessionAction, waitTimeoutMs, ], ); - const handleSelectedSessionInputDraftChange = useCallback( - (value: string) => { - if (!selectedSession) { - return; - } - - setSessionInputDraftById((previous) => { - if (previous[selectedSession.id] === value) { - return previous; - } - return { - ...previous, - [selectedSession.id]: value, - }; - }); - }, - [selectedSession], - ); - const handleSelectedSessionSendInput = useCallback( async (interrupt: boolean) => { if (!selectedSession || !selectedSessionInputMessage) { @@ -158,22 +134,20 @@ export function useTeamWorkspaceBoardActions({ await onSendSubagentInput?.(sessionId, selectedSessionInputMessage, { interrupt, }); - setSessionInputDraftById((previous) => { - if (!previous[sessionId]) { - return previous; - } - return { - ...previous, - [sessionId]: "", - }; - }); + clearSelectedSessionInputDraft(sessionId); } finally { setPendingSessionAction((current) => current?.sessionId === sessionId ? null : current, ); } }, - [onSendSubagentInput, selectedSession, selectedSessionInputMessage], + [ + clearSelectedSessionInputDraft, + onSendSubagentInput, + selectedSession, + selectedSessionInputMessage, + setPendingSessionAction, + ], ); return { diff --git a/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardCanvasRuntime.test.tsx b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardCanvasRuntime.test.tsx new file mode 100644 index 000000000..0012476a6 --- /dev/null +++ b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardCanvasRuntime.test.tsx @@ -0,0 +1,335 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TeamWorkspaceRuntimeFormationState } from "../../teamWorkspaceRuntime"; +import { useTeamWorkspaceBoardCanvasRuntime } from "./useTeamWorkspaceBoardCanvasRuntime"; + +type HookProps = Parameters[0]; + +const { mockUseTeamWorkspaceCanvasController } = vi.hoisted(() => ({ + mockUseTeamWorkspaceCanvasController: vi.fn(), +})); + +vi.mock("./useTeamWorkspaceCanvasController", () => ({ + useTeamWorkspaceCanvasController: mockUseTeamWorkspaceCanvasController, +})); + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; + +function createFormationState( + overrides?: Partial, +): TeamWorkspaceRuntimeFormationState { + return { + requestId: overrides?.requestId ?? "dispatch-1", + status: overrides?.status ?? "forming", + label: overrides?.label ?? null, + summary: overrides?.summary ?? null, + members: overrides?.members ?? [], + blueprint: overrides?.blueprint ?? null, + errorMessage: overrides?.errorMessage ?? null, + updatedAt: overrides?.updatedAt ?? 1, + }; +} + +function createControllerState( + overrides?: Partial< + ReturnType & + Record + >, +) { + return { + canvasBounds: { + height: 720, + width: 1080, + }, + canvasLaneLayouts: {}, + canvasViewportRef: { current: null }, + handleAutoArrangeCanvas: vi.fn(), + handleCanvasWheel: vi.fn(), + handleFitCanvasView: vi.fn(), + handleResetCanvasView: vi.fn(), + handleSelectCanvasLane: vi.fn(), + handleStartCanvasLaneDrag: vi.fn(), + handleStartCanvasLaneResize: vi.fn(), + handleStartCanvasPan: vi.fn(), + handleZoomIn: vi.fn(), + handleZoomOut: vi.fn(), + isCanvasPanModifierActive: false, + viewport: { x: 0, y: 0, zoom: 1.15 }, + zoom: 1.15, + ...overrides, + }; +} + +function renderHook(props?: Partial) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + let latestValue: ReturnType | null = + null; + + const defaultProps: HookProps = { + activityTimelineEntryLimit: 4, + canvasStorageScopeId: "scope-default", + canvasViewportFallbackHeight: 560, + embedded: false, + expandedSessionId: null, + focusSession: vi.fn(), + hasRealTeamGraph: false, + hasRuntimeFormation: false, + hasSelectedTeamPlan: false, + liveActivityBySessionId: {}, + memberCanvasSessions: [], + plannedRoles: [], + previewBySessionId: {}, + teamDispatchPreviewState: null, + }; + + function Probe(currentProps: HookProps) { + latestValue = useTeamWorkspaceBoardCanvasRuntime(currentProps); + return null; + } + + const render = async (nextProps?: Partial) => { + await act(async () => { + root.render(); + await Promise.resolve(); + }); + }; + + mountedRoots.push({ root, container }); + + return { + getValue: () => { + if (!latestValue) { + throw new Error("hook 尚未初始化"); + } + return latestValue; + }, + render, + }; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + mockUseTeamWorkspaceCanvasController.mockReturnValue(createControllerState()); +}); + +afterEach(async () => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + continue; + } + + await act(async () => { + mounted.root.unmount(); + await Promise.resolve(); + }); + mounted.container.remove(); + } + + vi.clearAllMocks(); +}); + +describe("useTeamWorkspaceBoardCanvasRuntime", () => { + it("应把真实成员任务进行时投影为 session lanes 并透传给 controller", async () => { + const focusSession = vi.fn(); + const harness = renderHook({ + canvasStorageScopeId: "scope-team-board", + canvasViewportFallbackHeight: 620, + expandedSessionId: "child-1", + focusSession, + hasRealTeamGraph: true, + hasRuntimeFormation: true, + hasSelectedTeamPlan: true, + liveActivityBySessionId: { + "child-1": [ + { + id: "live-1", + title: "工具 检查", + detail: "主线回归已补齐。", + statusLabel: "完成", + badgeClassName: + "border border-emerald-200 bg-emerald-50 text-emerald-700", + }, + ], + }, + memberCanvasSessions: [ + { + id: "child-1", + name: "执行代理", + runtimeStatus: "running", + taskSummary: "补齐 task schedule 主线回归。", + blueprintRoleId: "executor", + blueprintRoleLabel: "执行", + profileId: "code-executor", + profileName: "代码执行员", + roleKey: "executor", + teamPresetId: "code-triage-team", + latestTurnStatus: "running", + queuedTurnCount: 1, + teamParallelBudget: 2, + teamActiveCount: 1, + skills: [ + { + id: "repo-exploration", + name: "仓库探索", + }, + ], + }, + ], + plannedRoles: [ + { + id: "executor", + label: "执行", + summary: "负责落地主线改动。", + profileId: "code-executor", + roleKey: "executor", + }, + ], + previewBySessionId: { + "child-1": { + preview: "回复:旧内容", + entries: [ + { + id: "stored-1", + title: "回复", + detail: "历史里的旧进度。", + statusLabel: "消息", + badgeClassName: + "border border-slate-200 bg-slate-50 text-slate-600", + }, + ], + status: "ready", + }, + }, + teamDispatchPreviewState: createFormationState({ + requestId: "dispatch-runtime-1", + status: "formed", + members: [ + { + id: "executor", + label: "执行", + summary: "负责落地主线改动。", + profileId: "code-executor", + roleKey: "executor", + skillIds: [], + status: "running", + sessionId: "child-1", + }, + ], + }), + }); + + await harness.render(); + + const controllerArgs = + mockUseTeamWorkspaceCanvasController.mock.calls.at(-1)?.[0]; + + expect(controllerArgs).toMatchObject({ + canvasStorageScopeId: "scope-team-board", + canvasViewportFallbackHeight: 620, + embedded: false, + expandedSessionId: "child-1", + }); + expect(controllerArgs?.onSelectSession).toBe(focusSession); + expect(controllerArgs?.canvasLanes).toEqual(harness.getValue().canvasLanes); + expect(harness.getValue().canvasLanes).toEqual([ + expect.objectContaining({ + id: "child-1", + kind: "session", + persistKey: "session:child-1", + fallbackPersistKeys: ["runtime:executor", "planned:executor"], + title: "执行代理", + previewText: "工具 检查:主线回归已补齐。", + }), + ]); + expect(harness.getValue().canvasStageHint).toBe( + "当前任务会按状态持续刷新,焦点会优先落在正在处理的任务上;需要时可调整任务布局或缩放视图。", + ); + }); + + it("应在成员尚未接入时先把 runtime formation 投影为任务进行时预备 lane", async () => { + const harness = renderHook({ + hasRealTeamGraph: false, + hasRuntimeFormation: true, + hasSelectedTeamPlan: false, + plannedRoles: [ + { + id: "writer", + label: "写作", + summary: "把主线结果整理成说明。", + roleKey: "writer", + }, + ], + teamDispatchPreviewState: createFormationState({ + requestId: "dispatch-runtime-2", + status: "formed", + members: [ + { + id: "writer", + label: "写作", + summary: "负责整理主线结果。", + roleKey: "writer", + skillIds: [], + status: "running", + }, + ], + }), + }); + + await harness.render(); + + expect(harness.getValue().canvasLanes).toEqual([ + expect.objectContaining({ + id: "writer", + kind: "runtime", + persistKey: "runtime:writer", + fallbackPersistKeys: ["planned:writer"], + title: "写作", + previewText: "负责整理主线结果。", + }), + ]); + expect(harness.getValue().canvasStageHint).toBe( + "当前任务分工已经准备好,任务拆出后会依次开始处理。", + ); + }); + + it("应在仅有计划分工时生成 planned lane 并提示即将切换为任务进行时", async () => { + const harness = renderHook({ + hasRealTeamGraph: false, + hasRuntimeFormation: false, + hasSelectedTeamPlan: true, + plannedRoles: [ + { + id: "reviewer", + label: "复核", + summary: "确认任务进行时语义没有跑偏。", + roleKey: "reviewer", + }, + ], + }); + + await harness.render(); + + expect(harness.getValue().canvasLanes).toEqual([ + expect.objectContaining({ + id: "reviewer", + kind: "planned", + persistKey: "planned:reviewer", + title: "复核", + previewText: "确认任务进行时语义没有跑偏。", + }), + ]); + expect(harness.getValue().canvasStageHint).toBe( + "这里会先展示当前任务分工,任务拆出后会切换为独立的任务视图。", + ); + }); +}); diff --git a/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardComposer.test.tsx b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardComposer.test.tsx new file mode 100644 index 000000000..c41ff3655 --- /dev/null +++ b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardComposer.test.tsx @@ -0,0 +1,385 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useTeamWorkspaceBoardComposer } from "./useTeamWorkspaceBoardComposer"; + +type HookProps = Parameters[0]; + +const { + mockUseTeamWorkspaceActivityPreviews, + mockUseTeamWorkspaceBoardCanvasRuntime, + mockUseTeamWorkspaceBoardPresentation, + mockUseTeamWorkspaceBoardRuntimeState, + mockUseTeamWorkspaceBoardShellProps, +} = vi.hoisted(() => ({ + mockUseTeamWorkspaceActivityPreviews: vi.fn(), + mockUseTeamWorkspaceBoardCanvasRuntime: vi.fn(), + mockUseTeamWorkspaceBoardPresentation: vi.fn(), + mockUseTeamWorkspaceBoardRuntimeState: vi.fn(), + mockUseTeamWorkspaceBoardShellProps: vi.fn(), +})); + +vi.mock("./useTeamWorkspaceActivityPreviews", () => ({ + useTeamWorkspaceActivityPreviews: mockUseTeamWorkspaceActivityPreviews, +})); + +vi.mock("./useTeamWorkspaceBoardCanvasRuntime", () => ({ + useTeamWorkspaceBoardCanvasRuntime: mockUseTeamWorkspaceBoardCanvasRuntime, +})); + +vi.mock("./useTeamWorkspaceBoardPresentation", () => ({ + useTeamWorkspaceBoardPresentation: mockUseTeamWorkspaceBoardPresentation, +})); + +vi.mock("./useTeamWorkspaceBoardRuntimeState", () => ({ + useTeamWorkspaceBoardRuntimeState: mockUseTeamWorkspaceBoardRuntimeState, +})); + +vi.mock("./useTeamWorkspaceBoardShellProps", () => ({ + useTeamWorkspaceBoardShellProps: mockUseTeamWorkspaceBoardShellProps, +})); + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; + +function createFormationState() { + return { + hasRuntimeFormation: true, + hasSelectedTeamPlan: true, + plannedRoles: [{ id: "researcher", label: "资料整理" }], + runtimeFormationDisplay: { + panelStatusLabel: "准备中", + }, + }; +} + +function createSessionGraphState() { + return { + basePreviewableRailSessions: [{ id: "base-1" }], + baseRailSessions: [{ id: "base-1" }], + canvasStorageScopeId: "scope-1", + currentChildSession: { id: "child-current" }, + hasRealTeamGraph: true, + isChildSession: true, + isEmptyShellState: false, + memberCanvasSessions: [{ id: "child-1" }, { id: "child-2" }], + orchestratorSession: { id: "orchestrator-1" }, + railSessions: [{ id: "child-1" }, { id: "child-2" }], + siblingCount: 1, + totalTeamSessions: 2, + visibleSessions: [{ id: "child-1" }, { id: "child-2" }], + }; +} + +function renderHook(props?: Partial) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + let latestValue: ReturnType | null = + null; + + const defaultProps: HookProps = { + canvasViewportFallbackHeight: 560, + className: "board-shell", + currentSessionId: "child-2", + detailExpanded: true, + embedded: false, + formationState: createFormationState() as unknown as HookProps["formationState"], + onCloseCompletedTeamSessions: vi.fn(), + onCloseSubagentSession: vi.fn(), + onExpandEmptyShell: vi.fn(), + onOpenSubagentSession: vi.fn(), + onResumeSubagentSession: vi.fn(), + onReturnToParentSession: vi.fn(), + onSendSubagentInput: vi.fn(), + onToggleDetail: vi.fn(), + onWaitActiveTeamSessions: vi.fn(), + onWaitSubagentSession: vi.fn(), + sessionGraphState: createSessionGraphState() as unknown as HookProps["sessionGraphState"], + shellExpanded: true, + subagentParentContext: { + parent_session_name: "主线程", + } as HookProps["subagentParentContext"], + teamControlSummary: { + action: "close_completed", + affectedSessionIds: ["child-done"], + cascadeSessionIds: [], + requestedSessionIds: ["child-done"], + updatedAt: 1710000000, + }, + teamDispatchPreviewState: { + status: "running", + } as unknown as HookProps["teamDispatchPreviewState"], + teamWaitSummary: { + awaitedSessionIds: ["child-2"], + resolvedSessionId: "child-2", + resolvedStatus: "completed", + timedOut: false, + updatedAt: 1710000100, + }, + activityRefreshVersionBySessionId: { + "child-2": 3, + }, + liveActivityBySessionId: { + "child-2": [], + }, + }; + + function Probe(currentProps: HookProps) { + latestValue = useTeamWorkspaceBoardComposer(currentProps); + return null; + } + + const render = async (nextProps?: Partial) => { + await act(async () => { + root.render(); + await Promise.resolve(); + }); + }; + + mountedRoots.push({ root, container }); + + return { + getValue: () => { + if (!latestValue) { + throw new Error("hook 尚未初始化"); + } + return latestValue; + }, + render, + }; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + + mockUseTeamWorkspaceBoardRuntimeState.mockReturnValue({ + canCloseCompletedTeamSessions: true, + canWaitAnyActiveTeamSession: true, + completedTeamSessionIds: ["child-done-1", "child-done-2"], + expandedSessionId: "child-2", + focusSession: { id: "child-2" }, + selectedBaseSession: { id: "base-1" }, + selectedSession: { id: "child-2", runtimeStatus: "running" }, + statusSummary: { queued: 1, running: 1 }, + waitableTeamSessionIds: ["child-1"], + }); + mockUseTeamWorkspaceActivityPreviews.mockReturnValue({ + selectedSessionActivityState: { + entries: [], + previewState: null, + previewText: "资料整理刚更新进度", + shouldPoll: true, + supportsPreview: true, + }, + sessionActivityPreviewById: { + "child-2": { + previewText: "资料整理刚更新进度", + }, + }, + }); + mockUseTeamWorkspaceBoardCanvasRuntime.mockReturnValue({ + zoom: 1.25, + }); + mockUseTeamWorkspaceBoardPresentation.mockReturnValue({ + boardShellClassName: "board-shell", + }); + mockUseTeamWorkspaceBoardShellProps.mockReturnValue({ + emptyShellProps: { + embedded: false, + }, + shellProps: { + embedded: false, + }, + }); +}); + +afterEach(async () => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + continue; + } + + await act(async () => { + mounted.root.unmount(); + await Promise.resolve(); + }); + mounted.container.remove(); + } + + vi.clearAllMocks(); +}); + +describe("useTeamWorkspaceBoardComposer", () => { + it("应把 schedule 主线所需的 runtime、activity、canvas、presentation 与 shell 串成同一条编排链", async () => { + const harness = renderHook(); + + await harness.render(); + + expect(mockUseTeamWorkspaceBoardRuntimeState).toHaveBeenCalledWith( + expect.objectContaining({ + currentSessionId: "child-2", + isChildSession: true, + onCloseCompletedTeamSessions: expect.any(Function), + orchestratorSessionId: "orchestrator-1", + teamControlSummary: expect.objectContaining({ + action: "close_completed", + }), + teamWaitSummary: expect.objectContaining({ + resolvedSessionId: "child-2", + }), + visibleSessions: [{ id: "child-1" }, { id: "child-2" }], + waitTimeoutMs: 30_000, + }), + ); + + expect(mockUseTeamWorkspaceActivityPreviews).toHaveBeenCalledWith({ + activityRefreshVersionBySessionId: { + "child-2": 3, + }, + activityTimelineEntryLimit: 4, + basePreviewableRailSessions: [{ id: "base-1" }], + liveActivityBySessionId: { + "child-2": [], + }, + selectedBaseSession: { id: "base-1" }, + selectedSession: { id: "child-2", runtimeStatus: "running" }, + }); + + expect(mockUseTeamWorkspaceBoardCanvasRuntime).toHaveBeenCalledWith({ + activityTimelineEntryLimit: 4, + canvasStorageScopeId: "scope-1", + canvasViewportFallbackHeight: 560, + embedded: false, + expandedSessionId: "child-2", + focusSession: { id: "child-2" }, + hasRealTeamGraph: true, + hasRuntimeFormation: true, + hasSelectedTeamPlan: true, + liveActivityBySessionId: { + "child-2": [], + }, + memberCanvasSessions: [{ id: "child-1" }, { id: "child-2" }], + plannedRoles: [{ id: "researcher", label: "资料整理" }], + previewBySessionId: { + "child-2": { + previewText: "资料整理刚更新进度", + }, + }, + teamDispatchPreviewState: { + status: "running", + }, + }); + + expect(mockUseTeamWorkspaceBoardPresentation).toHaveBeenCalledWith({ + canCloseCompletedTeamSessions: true, + canWaitAnyActiveTeamSession: true, + className: "board-shell", + completedCount: 2, + detailExpanded: true, + dispatchPreviewStatus: "running", + embedded: false, + hasRuntimeFormation: true, + isChildSession: true, + isEmptyShellState: false, + parentSessionName: "主线程", + runtimeFormationDisplay: { + panelStatusLabel: "准备中", + }, + selectedSession: { id: "child-2", runtimeStatus: "running" }, + shellExpanded: true, + siblingCount: 1, + statusSummary: { queued: 1, running: 1 }, + totalTeamSessions: 2, + visibleSessionsCount: 2, + waitableCount: 1, + zoom: 1.25, + }); + + expect(mockUseTeamWorkspaceBoardShellProps).toHaveBeenCalledWith({ + className: "board-shell", + embedded: false, + canvasRuntimeState: { zoom: 1.25 }, + formationState: expect.objectContaining({ + hasRuntimeFormation: true, + }), + onExpandEmptyShell: expect.any(Function), + onOpenSubagentSession: expect.any(Function), + onReturnToParentSession: expect.any(Function), + onToggleDetail: expect.any(Function), + presentationState: { boardShellClassName: "board-shell" }, + runtimeState: expect.objectContaining({ + completedTeamSessionIds: ["child-done-1", "child-done-2"], + }), + selectedSessionActivityState: expect.objectContaining({ + previewText: "资料整理刚更新进度", + }), + sessionGraphState: expect.objectContaining({ + canvasStorageScopeId: "scope-1", + }), + subagentParentContext: { + parent_session_name: "主线程", + }, + }); + + expect(harness.getValue()).toEqual({ + emptyShellProps: { + embedded: false, + }, + shellProps: { + embedded: false, + }, + }); + }); + + it("应在可选输入缺省时回退到稳定默认值", async () => { + const harness = renderHook({ + activityRefreshVersionBySessionId: undefined, + currentSessionId: undefined, + liveActivityBySessionId: undefined, + subagentParentContext: undefined, + teamControlSummary: undefined, + teamDispatchPreviewState: undefined, + teamWaitSummary: undefined, + }); + + await harness.render(); + + expect(mockUseTeamWorkspaceBoardRuntimeState).toHaveBeenCalledWith( + expect.objectContaining({ + currentSessionId: undefined, + teamControlSummary: null, + teamWaitSummary: null, + }), + ); + expect(mockUseTeamWorkspaceActivityPreviews).toHaveBeenCalledWith( + expect.objectContaining({ + activityRefreshVersionBySessionId: {}, + liveActivityBySessionId: {}, + }), + ); + expect(mockUseTeamWorkspaceBoardCanvasRuntime).toHaveBeenCalledWith( + expect.objectContaining({ + liveActivityBySessionId: {}, + teamDispatchPreviewState: null, + }), + ); + expect(mockUseTeamWorkspaceBoardPresentation).toHaveBeenCalledWith( + expect.objectContaining({ + dispatchPreviewStatus: undefined, + parentSessionName: undefined, + }), + ); + expect(mockUseTeamWorkspaceBoardShellProps).toHaveBeenCalledWith( + expect.objectContaining({ + subagentParentContext: null, + }), + ); + }); +}); diff --git a/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardComposer.ts b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardComposer.ts index 57d2a87b9..42fd6b97b 100644 --- a/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardComposer.ts +++ b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardComposer.ts @@ -136,7 +136,6 @@ export function useTeamWorkspaceBoardComposer({ detailExpanded, dispatchPreviewStatus: teamDispatchPreviewState?.status, embedded, - hasRealTeamGraph: sessionGraphState.hasRealTeamGraph, hasRuntimeFormation: formationState.hasRuntimeFormation, isChildSession: sessionGraphState.isChildSession, isEmptyShellState: sessionGraphState.isEmptyShellState, diff --git a/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardOperationState.test.tsx b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardOperationState.test.tsx new file mode 100644 index 000000000..73c15e679 --- /dev/null +++ b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardOperationState.test.tsx @@ -0,0 +1,195 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { useTeamWorkspaceBoardOperationState } from "./useTeamWorkspaceBoardOperationState"; + +type HookProps = Parameters[0]; + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; + +function renderHook(props?: Partial) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + let latestValue: ReturnType | null = + null; + + const defaultProps: HookProps = { + currentChildSession: null, + currentSessionId: null, + isChildSession: false, + railSessions: [], + teamControlSummary: null, + teamWaitSummary: null, + visibleSessions: [], + }; + + function Probe(currentProps: HookProps) { + latestValue = useTeamWorkspaceBoardOperationState(currentProps); + return null; + } + + const render = async (nextProps?: Partial) => { + await act(async () => { + root.render(); + await Promise.resolve(); + }); + }; + + mountedRoots.push({ root, container }); + + return { + getValue: () => { + if (!latestValue) { + throw new Error("hook 尚未初始化"); + } + return latestValue; + }, + render, + }; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(async () => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + continue; + } + + await act(async () => { + mounted.root.unmount(); + await Promise.resolve(); + }); + mounted.container.remove(); + } +}); + +describe("useTeamWorkspaceBoardOperationState", () => { + it("应从当前可见成员与主轨道派生 schedule 状态", async () => { + const harness = renderHook({ + currentChildSession: { + id: "child-1", + name: "当前成员", + runtimeStatus: "running", + sessionType: "sub_agent", + }, + currentSessionId: "child-1", + isChildSession: true, + railSessions: [ + { + id: "child-1", + name: "当前成员", + runtimeStatus: "running", + sessionType: "sub_agent", + }, + { + id: "child-2", + name: "资料整理", + runtimeStatus: "queued", + sessionType: "sub_agent", + }, + { + id: "child-3", + name: "已完成成员", + runtimeStatus: "completed", + sessionType: "sub_agent", + }, + { + id: "user-main", + name: "主线程", + runtimeStatus: "running", + sessionType: "user", + }, + ], + visibleSessions: [ + { + id: "child-1", + name: "当前成员", + runtimeStatus: "running", + sessionType: "sub_agent", + }, + { + id: "child-2", + name: "资料整理", + runtimeStatus: "queued", + sessionType: "sub_agent", + }, + ], + }); + + await harness.render(); + + expect(harness.getValue().statusSummary).toEqual({ + queued: 1, + running: 1, + }); + expect(harness.getValue().waitableTeamSessionIds).toEqual([ + "child-1", + "child-2", + ]); + expect(harness.getValue().completedTeamSessionIds).toEqual(["child-3"]); + }); + + it("应只展示当前可见成员相关的等待动态", async () => { + const harness = renderHook({ + railSessions: [ + { + id: "child-1", + name: "资料整理", + runtimeStatus: "running", + sessionType: "sub_agent", + }, + ], + teamControlSummary: { + action: "close_completed", + affectedSessionIds: ["hidden-child"], + cascadeSessionIds: [], + requestedSessionIds: ["hidden-child"], + updatedAt: 100, + }, + teamWaitSummary: { + awaitedSessionIds: ["child-1"], + resolvedSessionId: "child-1", + resolvedStatus: "completed", + timedOut: false, + updatedAt: 200, + }, + visibleSessions: [ + { + id: "child-1", + name: "资料整理", + runtimeStatus: "running", + sessionType: "sub_agent", + }, + ], + }); + + await harness.render(); + + expect(harness.getValue().visibleTeamWaitSummary).toEqual({ + awaitedSessionIds: ["child-1"], + resolvedSessionId: "child-1", + resolvedStatus: "completed", + timedOut: false, + updatedAt: 200, + }); + expect(harness.getValue().teamOperationEntries).toHaveLength(1); + expect(harness.getValue().teamOperationEntries[0]).toMatchObject({ + targetSessionId: "child-1", + title: "收到结果", + }); + expect(harness.getValue().teamOperationEntries[0]?.detail).toContain( + "资料整理", + ); + }); +}); diff --git a/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardOperationState.ts b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardOperationState.ts new file mode 100644 index 000000000..336f3b4bf --- /dev/null +++ b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardOperationState.ts @@ -0,0 +1,76 @@ +import { useMemo } from "react"; +import type { + TeamWorkspaceControlSummary, + TeamWorkspaceWaitSummary, +} from "../../teamWorkspaceRuntime"; +import { + buildTeamWorkspaceSessionControlState, +} from "../../team-workspace-runtime/sessionStateSelectors"; +import { + buildVisibleTeamOperationState, + type TeamOperationDisplayEntry, +} from "../../team-workspace-runtime/teamOperationSelectors"; +import type { TeamSessionCard } from "../../utils/teamWorkspaceSessions"; + +interface UseTeamWorkspaceBoardOperationStateParams { + currentChildSession?: TeamSessionCard | null; + currentSessionId?: string | null; + isChildSession: boolean; + railSessions: TeamSessionCard[]; + teamControlSummary?: TeamWorkspaceControlSummary | null; + teamWaitSummary?: TeamWorkspaceWaitSummary | null; + visibleSessions: TeamSessionCard[]; +} + +interface TeamWorkspaceBoardOperationState { + completedTeamSessionIds: string[]; + statusSummary: Record; + teamOperationEntries: TeamOperationDisplayEntry[]; + visibleTeamWaitSummary: TeamWorkspaceWaitSummary | null; + waitableTeamSessionIds: string[]; +} + +export function useTeamWorkspaceBoardOperationState({ + currentChildSession = null, + currentSessionId, + isChildSession, + railSessions, + teamControlSummary = null, + teamWaitSummary = null, + visibleSessions, +}: UseTeamWorkspaceBoardOperationStateParams): TeamWorkspaceBoardOperationState { + const teamOperationState = useMemo( + () => + buildVisibleTeamOperationState({ + railSessions, + teamWaitSummary, + teamControlSummary, + }), + [railSessions, teamControlSummary, teamWaitSummary], + ); + const sessionControlState = useMemo( + () => + buildTeamWorkspaceSessionControlState({ + visibleSessions, + railSessions, + currentChildSession, + isChildSession, + currentSessionId, + }), + [ + currentChildSession, + currentSessionId, + isChildSession, + railSessions, + visibleSessions, + ], + ); + + return { + completedTeamSessionIds: sessionControlState.completedSessionIds, + statusSummary: sessionControlState.statusSummary, + teamOperationEntries: teamOperationState.entries, + visibleTeamWaitSummary: teamOperationState.visibleTeamWaitSummary, + waitableTeamSessionIds: sessionControlState.waitableSessionIds, + }; +} diff --git a/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardPresentation.tsx b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardPresentation.tsx index d1e4a15f3..36e9b6e49 100644 --- a/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardPresentation.tsx +++ b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardPresentation.tsx @@ -1,5 +1,4 @@ import { useMemo } from "react"; -import { cn } from "@/lib/utils"; import { buildTeamWorkspaceBoardChromeDisplayState } from "../../team-workspace-runtime/boardChromeSelectors"; import { buildSelectedSessionDetailDisplayState, @@ -12,7 +11,10 @@ import { resolveStatusMeta, type TeamSessionCard, } from "../../utils/teamWorkspaceSessions"; -import { TEAM_WORKSPACE_CANVAS_STAGE_HEIGHT } from "../../utils/teamWorkspaceCanvas"; +import { + buildTeamWorkspaceBoardSurfaceClassNames, + resolveTeamWorkspaceBoardCopyState, +} from "./teamWorkspaceBoardPresentationSelectors"; interface UseTeamWorkspaceBoardPresentationParams { canCloseCompletedTeamSessions: boolean; @@ -22,7 +24,6 @@ interface UseTeamWorkspaceBoardPresentationParams { detailExpanded: boolean; dispatchPreviewStatus?: TeamWorkspaceRuntimeFormationStatus | null; embedded: boolean; - hasRealTeamGraph: boolean; hasRuntimeFormation: boolean; isChildSession: boolean; isEmptyShellState: boolean; @@ -66,7 +67,6 @@ export function useTeamWorkspaceBoardPresentation({ detailExpanded, dispatchPreviewStatus = null, embedded, - hasRealTeamGraph, hasRuntimeFormation, isChildSession, isEmptyShellState, @@ -81,30 +81,27 @@ export function useTeamWorkspaceBoardPresentation({ zoom, runtimeFormationDisplay, }: UseTeamWorkspaceBoardPresentationParams): TeamWorkspaceBoardPresentationState { - const useCompactCanvasChrome = hasRealTeamGraph; - const detailVisible = - isEmptyShellState || !hasRealTeamGraph - ? detailExpanded || shellExpanded - : false; - const detailToggleLabel = detailVisible ? "收起细节" : "查看细节"; - const memberCanvasTitle = "协作进展画布"; - const memberCanvasSubtitle = hasRealTeamGraph - ? isChildSession - ? "当前协作成员会在各自面板里持续更新进展和结果,主对话只保留必要摘要。" - : `${visibleSessionsCount} 位协作成员已加入,每位成员都会在自己的面板里持续更新进展和结果。` - : dispatchPreviewStatus === "forming" - ? "正在准备当前任务分工,成员接入后会在这里独立更新进展。" - : dispatchPreviewStatus === "formed" - ? "当前任务分工已经就绪,成员接入后会在各自面板里开始处理。" - : dispatchPreviewStatus === "failed" - ? "这次任务分工准备失败,暂时无法生成成员面板。" - : "成员加入后,这里会展开为独立的任务进行时面板。"; + const hasRuntimeSessions = isChildSession || totalTeamSessions > 0; + const useCompactCanvasChrome = hasRuntimeSessions; + const { + detailToggleLabel, + detailVisible, + memberCanvasSubtitle, + memberCanvasTitle, + } = resolveTeamWorkspaceBoardCopyState({ + detailExpanded, + dispatchPreviewStatus, + hasRuntimeSessions, + isChildSession, + isEmptyShellState, + shellExpanded, + visibleSessionsCount, + }); const boardChromeDisplay = useMemo( () => buildTeamWorkspaceBoardChromeDisplayState({ - hasRealTeamGraph, - hasRuntimeFormation, + hasRuntimeSessions, runtimeFormationTitle: hasRuntimeFormation ? runtimeFormationDisplay.panelHeadline : null, @@ -125,7 +122,7 @@ export function useTeamWorkspaceBoardPresentation({ canCloseCompletedTeamSessions, canWaitAnyActiveTeamSession, completedCount, - hasRealTeamGraph, + hasRuntimeSessions, hasRuntimeFormation, isChildSession, parentSessionName, @@ -143,7 +140,7 @@ export function useTeamWorkspaceBoardPresentation({ const detailSummary = selectedSession?.taskSummary || buildFallbackSummary({ - hasRealTeamGraph, + hasRuntimeSessions, isChildSession, selectedSession, }); @@ -157,53 +154,24 @@ export function useTeamWorkspaceBoardPresentation({ [isChildSession, parentSessionName, selectedSession], ); const selectedStatusMeta = resolveStatusMeta(selectedSession?.runtimeStatus); - const boardShellClassName = cn( - embedded - ? "pointer-events-auto flex h-full min-h-0 flex-1 flex-col overflow-hidden rounded-none border-0 bg-transparent shadow-none" - : "overflow-hidden rounded-[26px] border border-slate-200 bg-white shadow-[0_18px_64px_-42px_rgba(15,23,42,0.24)]", - embedded ? "mx-0 mt-0" : "mx-3 mt-2", + const { + boardBodyClassName, + boardHeaderClassName, + boardShellClassName, + canvasStageHeight, + detailCardClassName, + inlineDetailSectionClassName, + inlineTimelineEntryClassName, + inlineTimelineFeedClassName, + railCardClassName, + } = buildTeamWorkspaceBoardSurfaceClassNames({ className, - ); - const boardHeaderClassName = cn( - "flex flex-wrap items-start justify-between gap-3", - useCompactCanvasChrome ? "px-4 py-2.5 sm:px-4" : "px-4 py-3.5 sm:px-5", - embedded - ? cn( - "sticky top-0 z-20 border-b border-slate-200", - useCompactCanvasChrome ? "bg-white" : "bg-slate-50", - ) - : cn( - "border-b border-slate-200", - useCompactCanvasChrome ? "bg-white" : "bg-slate-50", - ), - ); - const boardBodyClassName = embedded - ? cn( - "min-h-0 flex-1 overflow-y-auto overscroll-contain", - useCompactCanvasChrome - ? "space-y-2.5 p-3 sm:p-3.5" - : "space-y-3 p-3 sm:p-4", - ) - : cn(useCompactCanvasChrome ? "p-3 sm:p-3.5" : "p-3 sm:p-4"); - const canvasStageHeight = - embedded && !detailVisible - ? "clamp(560px, 76vh, 980px)" - : TEAM_WORKSPACE_CANVAS_STAGE_HEIGHT; - const railCardClassName = embedded - ? cn( - "pointer-events-auto", - useCompactCanvasChrome ? "space-y-3" : "space-y-4", - ) - : "rounded-[22px] border border-slate-200 bg-slate-50 p-3.5 shadow-sm shadow-slate-950/5"; - const detailCardClassName = cn( - embedded - ? "rounded-[20px] border border-slate-200 bg-white p-4" - : "rounded-[22px] border p-4 shadow-sm shadow-slate-950/5", - !embedded && - (selectedSession - ? selectedStatusMeta.cardClassName - : "border-slate-200 bg-white"), - ); + detailVisible, + embedded, + selectedSessionStatusCardClassName: selectedStatusMeta.cardClassName, + selectedSessionVisible: Boolean(selectedSession), + useCompactCanvasChrome, + }); return { boardBodyClassName, @@ -215,12 +183,9 @@ export function useTeamWorkspaceBoardPresentation({ detailSummary, detailToggleLabel, detailVisible, - inlineDetailSectionClassName: - "mt-3 rounded-[18px] border border-slate-200 bg-slate-50 p-3", - inlineTimelineEntryClassName: - "rounded-[14px] border border-slate-200 bg-white p-3", - inlineTimelineFeedClassName: - "mt-3 rounded-[16px] border border-slate-200 bg-white p-3", + inlineDetailSectionClassName, + inlineTimelineEntryClassName, + inlineTimelineFeedClassName, memberCanvasSubtitle, memberCanvasTitle, railCardClassName, diff --git a/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardRuntimeState.ts b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardRuntimeState.ts index 532151afc..2eab65b0a 100644 --- a/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardRuntimeState.ts +++ b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardRuntimeState.ts @@ -1,18 +1,13 @@ -import { useCallback, useMemo } from "react"; +import { useCallback } from "react"; import type { TeamWorkspaceControlSummary, TeamWorkspaceWaitSummary, } from "../../teamWorkspaceRuntime"; -import { - buildTeamWorkspaceSelectedSessionActionState, - buildTeamWorkspaceSessionControlState, -} from "../../team-workspace-runtime/sessionStateSelectors"; -import { - buildVisibleTeamOperationState, - type TeamOperationDisplayEntry, -} from "../../team-workspace-runtime/teamOperationSelectors"; +import type { TeamOperationDisplayEntry } from "../../team-workspace-runtime/teamOperationSelectors"; import type { TeamSessionCard } from "../../utils/teamWorkspaceSessions"; import { useTeamWorkspaceBoardActions } from "./useTeamWorkspaceBoardActions"; +import { useTeamWorkspaceBoardOperationState } from "./useTeamWorkspaceBoardOperationState"; +import { useTeamWorkspaceBoardSelectedSessionActionState } from "./useTeamWorkspaceBoardSelectedSessionActionState"; import { useTeamWorkspaceSessionFocus } from "./useTeamWorkspaceSessionFocus"; interface UseTeamWorkspaceBoardRuntimeStateParams { @@ -66,17 +61,21 @@ export function useTeamWorkspaceBoardRuntimeState({ visibleSessions, waitTimeoutMs = 30_000, }: UseTeamWorkspaceBoardRuntimeStateParams) { - const teamOperationState = useMemo( - () => - buildVisibleTeamOperationState({ - railSessions, - teamWaitSummary, - teamControlSummary, - }), - [railSessions, teamControlSummary, teamWaitSummary], - ); - const visibleTeamWaitSummary = teamOperationState.visibleTeamWaitSummary; - const teamOperationEntries = teamOperationState.entries; + const { + completedTeamSessionIds, + statusSummary, + teamOperationEntries, + visibleTeamWaitSummary, + waitableTeamSessionIds, + } = useTeamWorkspaceBoardOperationState({ + currentChildSession, + currentSessionId, + isChildSession, + railSessions, + teamControlSummary, + teamWaitSummary, + visibleSessions, + }); const { expandedSessionId, @@ -93,69 +92,27 @@ export function useTeamWorkspaceBoardRuntimeState({ visibleTeamWaitSummary, }); - const sessionControlState = useMemo( - () => - buildTeamWorkspaceSessionControlState({ - visibleSessions, - railSessions, - currentChildSession, - isChildSession, - currentSessionId, - }), - [ - currentChildSession, - currentSessionId, - isChildSession, - railSessions, - visibleSessions, - ], - ); - const statusSummary = sessionControlState.statusSummary; - const waitableTeamSessionIds = sessionControlState.waitableSessionIds; - const completedTeamSessionIds = sessionControlState.completedSessionIds; - - const sessionActionState = useMemo( - () => - buildTeamWorkspaceSelectedSessionActionState({ - completedTeamSessionIds, - currentSessionId, - hasCloseCompletedTeamSessionsHandler: Boolean( - onCloseCompletedTeamSessions, - ), - hasCloseSubagentSessionHandler: Boolean(onCloseSubagentSession), - hasOpenSubagentSessionHandler: Boolean(onOpenSubagentSession), - hasResumeSubagentSessionHandler: Boolean(onResumeSubagentSession), - hasSendSubagentInputHandler: Boolean(onSendSubagentInput), - hasWaitActiveTeamSessionsHandler: Boolean(onWaitActiveTeamSessions), - hasWaitSubagentSessionHandler: Boolean(onWaitSubagentSession), - selectedSession, - waitableTeamSessionIds, - }), - [ - completedTeamSessionIds, - currentSessionId, - onCloseCompletedTeamSessions, - onCloseSubagentSession, - onOpenSubagentSession, - onResumeSubagentSession, - onSendSubagentInput, - onWaitActiveTeamSessions, - onWaitSubagentSession, - selectedSession, - waitableTeamSessionIds, - ], - ); - const canCloseCompletedTeamSessions = - sessionActionState.canCloseCompletedTeamSessions; - const canOpenSelectedSession = sessionActionState.canOpenSelectedSession; - const canResumeSelectedSession = - sessionActionState.canResumeSelectedSession; - const canSendSelectedSessionInput = - sessionActionState.canSendSelectedSessionInput; - const canStopSelectedSession = sessionActionState.canStopSelectedSession; - const canWaitAnyActiveTeamSession = - sessionActionState.canWaitAnyActiveTeamSession; - const canWaitSelectedSession = sessionActionState.canWaitSelectedSession; + const { + canCloseCompletedTeamSessions, + canOpenSelectedSession, + canResumeSelectedSession, + canSendSelectedSessionInput, + canStopSelectedSession, + canWaitAnyActiveTeamSession, + canWaitSelectedSession, + } = useTeamWorkspaceBoardSelectedSessionActionState({ + completedTeamSessionIds, + currentSessionId, + onCloseCompletedTeamSessions, + onCloseSubagentSession, + onOpenSubagentSession, + onResumeSubagentSession, + onSendSubagentInput, + onWaitActiveTeamSessions, + onWaitSubagentSession, + selectedSession, + waitableTeamSessionIds, + }); const { handleCloseCompletedTeamSessions, diff --git a/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardSelectedSessionActionState.test.tsx b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardSelectedSessionActionState.test.tsx new file mode 100644 index 000000000..84792f0da --- /dev/null +++ b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardSelectedSessionActionState.test.tsx @@ -0,0 +1,144 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { useTeamWorkspaceBoardSelectedSessionActionState } from "./useTeamWorkspaceBoardSelectedSessionActionState"; + +type HookProps = Parameters[0]; + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; + +function renderHook(props?: Partial) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + let latestValue: + | ReturnType + | null = null; + + const defaultProps: HookProps = { + completedTeamSessionIds: [], + currentSessionId: "parent-1", + onCloseCompletedTeamSessions: undefined, + onCloseSubagentSession: undefined, + onOpenSubagentSession: undefined, + onResumeSubagentSession: undefined, + onSendSubagentInput: undefined, + onWaitActiveTeamSessions: undefined, + onWaitSubagentSession: undefined, + selectedSession: null, + waitableTeamSessionIds: [], + }; + + function Probe(currentProps: HookProps) { + latestValue = useTeamWorkspaceBoardSelectedSessionActionState(currentProps); + return null; + } + + const render = async (nextProps?: Partial) => { + await act(async () => { + root.render(); + await Promise.resolve(); + }); + }; + + mountedRoots.push({ root, container }); + + return { + getValue: () => { + if (!latestValue) { + throw new Error("hook 尚未初始化"); + } + return latestValue; + }, + render, + }; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(async () => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + continue; + } + + await act(async () => { + mounted.root.unmount(); + await Promise.resolve(); + }); + mounted.container.remove(); + } +}); + +describe("useTeamWorkspaceBoardSelectedSessionActionState", () => { + it("应为运行中成员开放当前可用操作", async () => { + const noop = async () => {}; + const harness = renderHook({ + completedTeamSessionIds: ["done-1"], + onCloseCompletedTeamSessions: noop, + onCloseSubagentSession: noop, + onOpenSubagentSession: noop, + onResumeSubagentSession: noop, + onSendSubagentInput: noop, + onWaitActiveTeamSessions: noop, + onWaitSubagentSession: noop, + selectedSession: { + id: "child-1", + name: "资料整理", + runtimeStatus: "running", + sessionType: "sub_agent", + }, + waitableTeamSessionIds: ["child-1", "child-2"], + }); + + await harness.render(); + + expect(harness.getValue()).toMatchObject({ + canCloseCompletedTeamSessions: true, + canOpenSelectedSession: true, + canResumeSelectedSession: false, + canSendSelectedSessionInput: true, + canStopSelectedSession: true, + canWaitAnyActiveTeamSession: true, + canWaitSelectedSession: true, + }); + }); + + it("应为已关闭成员只保留恢复能力", async () => { + const noop = async () => {}; + const harness = renderHook({ + onCloseSubagentSession: noop, + onOpenSubagentSession: noop, + onResumeSubagentSession: noop, + onSendSubagentInput: noop, + onWaitSubagentSession: noop, + selectedSession: { + id: "child-closed", + name: "封面定稿", + runtimeStatus: "closed", + sessionType: "sub_agent", + }, + waitableTeamSessionIds: ["child-2"], + }); + + await harness.render(); + + expect(harness.getValue()).toMatchObject({ + canOpenSelectedSession: true, + canResumeSelectedSession: true, + canSendSelectedSessionInput: false, + canStopSelectedSession: false, + canWaitAnyActiveTeamSession: false, + canWaitSelectedSession: false, + }); + }); +}); diff --git a/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardSelectedSessionActionState.ts b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardSelectedSessionActionState.ts new file mode 100644 index 000000000..8f08678c2 --- /dev/null +++ b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardSelectedSessionActionState.ts @@ -0,0 +1,73 @@ +import { useMemo } from "react"; +import { buildTeamWorkspaceSelectedSessionActionState } from "../../team-workspace-runtime/sessionStateSelectors"; +import type { TeamSessionCard } from "../../utils/teamWorkspaceSessions"; + +interface UseTeamWorkspaceBoardSelectedSessionActionStateParams { + completedTeamSessionIds: string[]; + currentSessionId?: string | null; + onCloseCompletedTeamSessions?: (sessionIds: string[]) => void | Promise; + onCloseSubagentSession?: (sessionId: string) => void | Promise; + onOpenSubagentSession?: (sessionId: string) => void | Promise; + onResumeSubagentSession?: (sessionId: string) => void | Promise; + onSendSubagentInput?: ( + sessionId: string, + message: string, + options?: { interrupt?: boolean }, + ) => void | Promise; + onWaitActiveTeamSessions?: ( + sessionIds: string[], + timeoutMs?: number, + ) => void | Promise; + onWaitSubagentSession?: ( + sessionId: string, + timeoutMs?: number, + ) => void | Promise; + selectedSession: TeamSessionCard | null; + waitableTeamSessionIds: string[]; +} + +export function useTeamWorkspaceBoardSelectedSessionActionState({ + completedTeamSessionIds, + currentSessionId, + onCloseCompletedTeamSessions, + onCloseSubagentSession, + onOpenSubagentSession, + onResumeSubagentSession, + onSendSubagentInput, + onWaitActiveTeamSessions, + onWaitSubagentSession, + selectedSession, + waitableTeamSessionIds, +}: UseTeamWorkspaceBoardSelectedSessionActionStateParams) { + return useMemo( + () => + buildTeamWorkspaceSelectedSessionActionState({ + completedTeamSessionIds, + currentSessionId, + hasCloseCompletedTeamSessionsHandler: Boolean( + onCloseCompletedTeamSessions, + ), + hasCloseSubagentSessionHandler: Boolean(onCloseSubagentSession), + hasOpenSubagentSessionHandler: Boolean(onOpenSubagentSession), + hasResumeSubagentSessionHandler: Boolean(onResumeSubagentSession), + hasSendSubagentInputHandler: Boolean(onSendSubagentInput), + hasWaitActiveTeamSessionsHandler: Boolean(onWaitActiveTeamSessions), + hasWaitSubagentSessionHandler: Boolean(onWaitSubagentSession), + selectedSession, + waitableTeamSessionIds, + }), + [ + completedTeamSessionIds, + currentSessionId, + onCloseCompletedTeamSessions, + onCloseSubagentSession, + onOpenSubagentSession, + onResumeSubagentSession, + onSendSubagentInput, + onWaitActiveTeamSessions, + onWaitSubagentSession, + selectedSession, + waitableTeamSessionIds, + ], + ); +} diff --git a/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardSessionGraph.test.tsx b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardSessionGraph.test.tsx new file mode 100644 index 000000000..463f8493a --- /dev/null +++ b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardSessionGraph.test.tsx @@ -0,0 +1,288 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { + AsterSubagentParentContext, + AsterSubagentSessionInfo, +} from "@/lib/api/agentRuntime"; +import type { + TeamWorkspaceLiveRuntimeState, + TeamWorkspaceRuntimeFormationState, +} from "../../teamWorkspaceRuntime"; +import { useTeamWorkspaceBoardSessionGraph } from "./useTeamWorkspaceBoardSessionGraph"; + +type HookProps = Parameters[0]; + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; + +function createSubagentSessionInfo( + overrides: Partial & + Pick, +): AsterSubagentSessionInfo { + return { + created_at: overrides.created_at ?? 1, + updated_at: overrides.updated_at ?? 2, + session_type: overrides.session_type ?? "sub_agent", + ...overrides, + }; +} + +function createFormationState( + overrides?: Partial, +): TeamWorkspaceRuntimeFormationState { + return { + requestId: overrides?.requestId ?? "dispatch-1", + status: overrides?.status ?? "formed", + label: overrides?.label ?? null, + summary: overrides?.summary ?? null, + members: overrides?.members ?? [], + blueprint: overrides?.blueprint ?? null, + errorMessage: overrides?.errorMessage ?? null, + updatedAt: overrides?.updatedAt ?? 1, + }; +} + +function renderHook(props?: Partial) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + let latestValue: ReturnType | null = + null; + + const defaultProps: HookProps = { + childSubagentSessions: [], + currentSessionId: "parent-1", + currentSessionLatestTurnStatus: "running", + currentSessionName: "主线程", + currentSessionQueuedTurnCount: 0, + currentSessionRuntimeStatus: "running", + liveRuntimeBySessionId: {}, + shellVisible: false, + subagentParentContext: null, + teamDispatchPreviewState: null, + }; + + function Probe(currentProps: HookProps) { + latestValue = useTeamWorkspaceBoardSessionGraph(currentProps); + return null; + } + + const render = async (nextProps?: Partial) => { + await act(async () => { + root.render(); + await Promise.resolve(); + }); + }; + + mountedRoots.push({ root, container }); + + return { + getValue: () => { + if (!latestValue) { + throw new Error("hook 尚未初始化"); + } + return latestValue; + }, + render, + }; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(async () => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + continue; + } + + await act(async () => { + mounted.root.unmount(); + await Promise.resolve(); + }); + mounted.container.remove(); + } +}); + +describe("useTeamWorkspaceBoardSessionGraph", () => { + it("应在子成员上下文里用 live runtime 覆盖当前任务进行时图谱", async () => { + const subagentParentContext = { + parent_session_id: "parent-1", + parent_session_name: "主线程", + task_summary: "拆解主线并推进。", + role_hint: "analysis", + blueprint_role_id: "role-analysis", + blueprint_role_label: "分析", + role_key: "analysis", + sibling_subagent_sessions: [ + createSubagentSessionInfo({ + id: "child-verifier", + name: "复核成员", + runtime_status: "queued", + latest_turn_status: "queued", + queued_turn_count: 2, + blueprint_role_id: "role-verify", + blueprint_role_label: "验证", + role_key: "verifier", + }), + createSubagentSessionInfo({ + id: "child-executor", + name: "执行成员", + runtime_status: "running", + latest_turn_status: "running", + queued_turn_count: 1, + blueprint_role_id: "role-executor", + blueprint_role_label: "执行", + role_key: "executor", + }), + ], + } as AsterSubagentParentContext; + const liveRuntimeBySessionId: Record< + string, + TeamWorkspaceLiveRuntimeState + > = { + "child-current": { + runtimeStatus: "running", + latestTurnStatus: "running", + queuedTurnCount: 3, + teamActiveCount: 1, + teamParallelBudget: 2, + baseFingerprint: "child-current:live", + }, + "child-executor": { + runtimeStatus: "completed", + latestTurnStatus: "completed", + queuedTurnCount: 0, + baseFingerprint: "child-executor:live", + }, + }; + const harness = renderHook({ + childSubagentSessions: [ + createSubagentSessionInfo({ + id: "ignored-root-child", + name: "不会进入当前子成员视图", + runtime_status: "running", + }), + ], + currentSessionId: "child-current", + currentSessionLatestTurnStatus: "queued", + currentSessionName: "分析成员", + currentSessionQueuedTurnCount: 1, + currentSessionRuntimeStatus: "queued", + liveRuntimeBySessionId, + subagentParentContext, + teamDispatchPreviewState: createFormationState({ + blueprint: { + label: "主线 Team", + summary: "分析、执行、验证并行推进。", + roles: [ + { + id: "role-analysis", + label: "分析", + summary: "收敛当前主线边界。", + roleKey: "analysis", + }, + { + id: "role-executor", + label: "执行", + summary: "落实当前改动。", + roleKey: "executor", + }, + { + id: "role-verify", + label: "验证", + summary: "确认任务进行时语义。", + roleKey: "verifier", + }, + ], + }, + }), + }); + + await harness.render(); + + expect(harness.getValue().isChildSession).toBe(true); + expect(harness.getValue().canvasStorageScopeId).toBe("child-current"); + expect(harness.getValue().hasRealTeamGraph).toBe(true); + expect(harness.getValue().isEmptyShellState).toBe(false); + expect(harness.getValue().siblingCount).toBe(2); + expect(harness.getValue().totalTeamSessions).toBe(3); + expect(harness.getValue().currentChildSession).toMatchObject({ + id: "child-current", + name: "分析成员", + runtimeStatus: "running", + latestTurnStatus: "running", + queuedTurnCount: 3, + teamActiveCount: 1, + teamParallelBudget: 2, + isCurrent: true, + }); + expect(harness.getValue().visibleSessions).toMatchObject([ + { + id: "child-verifier", + runtimeStatus: "queued", + latestTurnStatus: "queued", + queuedTurnCount: 2, + }, + { + id: "child-executor", + runtimeStatus: "completed", + latestTurnStatus: "completed", + queuedTurnCount: 0, + }, + ]); + expect( + harness.getValue().visibleSessions.map((session) => session.id), + ).toEqual(["child-verifier", "child-executor"]); + expect( + harness.getValue().visibleSessions.some( + (session) => session.id === "ignored-root-child", + ), + ).toBe(false); + expect( + harness.getValue().railSessions.map((session) => session.id), + ).toEqual(["child-current", "child-verifier", "child-executor"]); + expect( + harness.getValue().memberCanvasSessions.map((session) => session.id), + ).toEqual(["child-current", "child-verifier", "child-executor"]); + expect( + harness.getValue().basePreviewableRailSessions.map((session) => session.id), + ).toEqual(["child-current", "child-verifier", "child-executor"]); + }); + + it("应在空 shell 态下回退到 dispatch preview request id 作为 schedule 作用域", async () => { + const harness = renderHook({ + currentSessionId: " ", + currentSessionName: "主线程", + shellVisible: true, + teamDispatchPreviewState: createFormationState({ + requestId: "dispatch-preview-42", + status: "forming", + }), + }); + + await harness.render(); + + expect(harness.getValue().isChildSession).toBe(false); + expect(harness.getValue().hasRealTeamGraph).toBe(false); + expect(harness.getValue().isEmptyShellState).toBe(true); + expect(harness.getValue().canvasStorageScopeId).toBe( + "dispatch-preview-42", + ); + expect(harness.getValue().currentChildSession).toBeNull(); + expect(harness.getValue().visibleSessions).toEqual([]); + expect(harness.getValue().baseRailSessions).toEqual([]); + expect(harness.getValue().railSessions).toEqual([]); + expect(harness.getValue().memberCanvasSessions).toEqual([]); + expect(harness.getValue().basePreviewableRailSessions).toEqual([]); + expect(harness.getValue().totalTeamSessions).toBe(0); + }); +}); diff --git a/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardShellProps.test.tsx b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardShellProps.test.tsx new file mode 100644 index 000000000..f062b0f8d --- /dev/null +++ b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardShellProps.test.tsx @@ -0,0 +1,318 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useTeamWorkspaceBoardShellProps } from "./useTeamWorkspaceBoardShellProps"; + +type HookProps = Parameters[0]; + +const { mockUseTeamWorkspaceBoardSelectedInlineDetail } = vi.hoisted(() => ({ + mockUseTeamWorkspaceBoardSelectedInlineDetail: vi.fn(), +})); + +vi.mock("./useTeamWorkspaceBoardSelectedInlineDetail", () => ({ + useTeamWorkspaceBoardSelectedInlineDetail: + mockUseTeamWorkspaceBoardSelectedInlineDetail, +})); + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; +const inlineDetailNode = ( +
inline detail
+); + +function createRuntimeFormationDisplay() { + return { + blueprintRoleCards: [], + emptyDetail: "系统正在准备分工。", + hint: "正在准备分工", + hasRuntimeFormation: true, + memberCards: [], + noticeText: "系统正在准备当前任务分工。", + panelDescription: "当前任务分工正在生成。", + panelHeadline: "任务分工准备中", + panelLabel: "编辑 Team", + panelStatusLabel: "准备中", + panelStatusBadgeClassName: "border-sky-200 bg-sky-50 text-sky-700", + panelTitle: "当前任务分工", + referenceLabel: null, + summaryBadges: [], + }; +} + +function createSelectedTeamPlanDisplay() { + return { + hasSelectedTeamPlan: true, + label: "编辑 Team", + roleCards: [], + summary: "先拆三段,再并行推进。", + summaryBadges: [], + }; +} + +function renderHook(props?: Partial) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + let latestValue: ReturnType | null = + null; + + const defaultProps: HookProps = { + canvasRuntimeState: { + canvasBounds: { + height: 680, + width: 960, + }, + canvasLaneLayouts: {}, + canvasLanes: [{ persistKey: "lane-alpha" } as never], + canvasStageHint: "正在同步任务进行时", + canvasViewportRef: { current: null }, + handleAutoArrangeCanvas: vi.fn(), + handleCanvasWheel: vi.fn(), + handleFitCanvasView: vi.fn(), + handleResetCanvasView: vi.fn(), + handleSelectCanvasLane: vi.fn(), + handleStartCanvasLaneDrag: vi.fn(), + handleStartCanvasLaneResize: vi.fn(), + handleStartCanvasPan: vi.fn(), + handleZoomIn: vi.fn(), + handleZoomOut: vi.fn(), + isCanvasPanModifierActive: false, + viewport: { x: 12, y: 24, zoom: 1.1 }, + zoom: 1.1, + } as unknown as HookProps["canvasRuntimeState"], + className: "custom-shell", + embedded: true, + formationState: { + hasRuntimeFormation: true, + runtimeFormationDisplay: createRuntimeFormationDisplay(), + selectedTeamPlanDisplay: createSelectedTeamPlanDisplay(), + } as unknown as HookProps["formationState"], + onExpandEmptyShell: vi.fn(), + onOpenSubagentSession: vi.fn(), + onReturnToParentSession: vi.fn(), + onToggleDetail: vi.fn(), + presentationState: { + boardBodyClassName: "board-body", + boardChromeDisplay: { + boardHeadline: "主任务总览", + boardHint: "主线程会持续汇总子任务进展。", + compactBoardHeadline: "任务进行中 · 2 项处理中", + compactToolbarChips: [], + statusSummaryBadges: [], + }, + boardHeaderClassName: "board-header", + boardShellClassName: "board-shell", + canvasStageHeight: "680px", + detailCardClassName: "detail-card", + detailSummary: "子任务会持续更新任务进展。", + detailToggleLabel: "查看细节", + detailVisible: true, + inlineDetailSectionClassName: "inline-detail", + inlineTimelineEntryClassName: "inline-entry", + inlineTimelineFeedClassName: "inline-feed", + memberCanvasSubtitle: "子任务会持续更新任务进展。", + memberCanvasTitle: "任务视图", + railCardClassName: "rail-card", + selectedSessionDetailDisplay: { + summary: "资料整理正在推进。", + }, + useCompactCanvasChrome: true, + } as unknown as HookProps["presentationState"], + runtimeState: { + canCloseCompletedTeamSessions: true, + canOpenSelectedSession: true, + canResumeSelectedSession: false, + canSendSelectedSessionInput: true, + canStopSelectedSession: true, + canWaitAnyActiveTeamSession: true, + canWaitSelectedSession: true, + completedTeamSessionIds: ["child-done"], + expandedSessionId: "child-2", + handleCloseCompletedTeamSessions: vi.fn(), + handleSelectTeamOperationEntry: vi.fn(), + handleSelectedSessionAction: vi.fn(), + handleSelectedSessionInputDraftChange: vi.fn(), + handleSelectedSessionSendInput: vi.fn(), + handleWaitAnyActiveTeamSessions: vi.fn(), + pendingSessionAction: { + action: "interrupt_send", + sessionId: "child-2", + }, + pendingTeamAction: "wait_any", + selectedActionPending: true, + selectedSession: { + id: "child-2", + name: "资料整理", + runtimeStatus: "running", + sessionType: "sub_agent", + updatedAt: 1710000000, + }, + selectedSessionInputDraft: "继续推进下一步", + selectedSessionInputMessage: "继续推进下一步", + teamOperationEntries: [ + { + badgeClassName: "border-sky-200 bg-sky-50 text-sky-700", + detail: "资料整理返回了新结果", + id: "wait-1", + targetSessionId: "child-2", + title: "收到结果", + updatedAt: 1710000000, + }, + ], + waitableTeamSessionIds: ["child-1", "child-2"], + } as unknown as HookProps["runtimeState"], + selectedSessionActivityState: { + entries: [], + previewState: null, + previewText: "", + shouldPoll: false, + supportsPreview: false, + } as unknown as HookProps["selectedSessionActivityState"], + sessionGraphState: { + hasRealTeamGraph: true, + isChildSession: true, + isEmptyShellState: false, + totalTeamSessions: 2, + } as unknown as HookProps["sessionGraphState"], + subagentParentContext: { + created_from_turn_id: "turn-42", + parent_session_name: "主线程", + } as HookProps["subagentParentContext"], + }; + + function Probe(currentProps: HookProps) { + latestValue = useTeamWorkspaceBoardShellProps(currentProps); + return null; + } + + const render = async (nextProps?: Partial) => { + await act(async () => { + root.render(); + await Promise.resolve(); + }); + }; + + mountedRoots.push({ root, container }); + + return { + getValue: () => { + if (!latestValue) { + throw new Error("hook 尚未初始化"); + } + return latestValue; + }, + render, + }; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + mockUseTeamWorkspaceBoardSelectedInlineDetail.mockReturnValue(inlineDetailNode); +}); + +afterEach(async () => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + continue; + } + + await act(async () => { + mounted.root.unmount(); + await Promise.resolve(); + }); + mounted.container.remove(); + } + + vi.clearAllMocks(); +}); + +describe("useTeamWorkspaceBoardShellProps", () => { + it("应在已有 runtime sessions 时关闭 fallback detail 并组装完整壳层 props", async () => { + const harness = renderHook(); + + await harness.render(); + + expect( + mockUseTeamWorkspaceBoardSelectedInlineDetail, + ).toHaveBeenCalledWith( + expect.objectContaining({ + canOpenSelectedSession: true, + canWaitSelectedSession: true, + isChildSession: true, + selectedActionPending: true, + selectedSession: expect.objectContaining({ + id: "child-2", + }), + }), + ); + + expect(harness.getValue().emptyShellProps).toMatchObject({ + className: "custom-shell", + embedded: true, + hasRuntimeFormation: true, + }); + expect(harness.getValue().shellProps).toMatchObject({ + boardBodyClassName: "board-body", + boardHeaderClassName: "board-header", + boardShellClassName: "board-shell", + embedded: true, + style: { maxHeight: "inherit" }, + }); + expect(harness.getValue().shellProps.headerProps).toMatchObject({ + createdFromTurnId: "turn-42", + detailToggleLabel: "查看细节", + runtimeFormationStatusLabel: "准备中", + totalTeamSessions: 2, + }); + expect( + harness.getValue().shellProps.canvasSectionProps.canvasStageProps + .selectedSessionId, + ).toBe("child-2"); + expect( + harness.getValue().shellProps.canvasSectionProps.canvasStageProps + .selectedInlineDetail, + ).toBe(inlineDetailNode); + expect( + harness.getValue().shellProps.canvasSectionProps.overviewChromeProps, + ).toMatchObject({ + completedCount: 1, + pendingTeamAction: "wait_any", + waitableCount: 2, + }); + expect( + harness.getValue().shellProps.canvasSectionProps.fallbackDetailProps, + ).toBeNull(); + }); + + it("应在尚无 runtime sessions 时保留 fallback detail 并保留普通 shell 高度", async () => { + const harness = renderHook({ + embedded: false, + sessionGraphState: { + hasRealTeamGraph: false, + isChildSession: false, + isEmptyShellState: false, + totalTeamSessions: 0, + } as unknown as HookProps["sessionGraphState"], + subagentParentContext: null, + }); + + await harness.render(); + + expect( + harness.getValue().shellProps.canvasSectionProps.fallbackDetailProps, + ).toMatchObject({ + detailCardClassName: "detail-card", + detailVisible: true, + }); + expect(harness.getValue().shellProps.style).toBeUndefined(); + expect(harness.getValue().shellProps.headerProps.createdFromTurnId).toBe( + undefined, + ); + }); +}); diff --git a/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardShellProps.ts b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardShellProps.ts index f87690b5b..e7df71077 100644 --- a/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardShellProps.ts +++ b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardShellProps.ts @@ -5,7 +5,9 @@ import { resolveStatusMeta, } from "../../utils/teamWorkspaceSessions"; import { + buildTeamWorkspaceBoardCanvasSectionProps, buildTeamWorkspaceBoardEmptyShellProps, + buildTeamWorkspaceBoardHeaderProps, buildTeamWorkspaceBoardShellProps, } from "./teamWorkspaceBoardPropBuilders"; import { useTeamWorkspaceBoardSelectedInlineDetail } from "./useTeamWorkspaceBoardSelectedInlineDetail"; @@ -148,11 +150,11 @@ export function useTeamWorkspaceBoardShellProps({ zoom: canvasZoom, } = canvasRuntimeState; const { - hasRealTeamGraph, isChildSession, isEmptyShellState, totalTeamSessions, } = sessionGraphState; + const hasRuntimeSessions = isChildSession || totalTeamSessions > 0; const selectedInlineDetail = useTeamWorkspaceBoardSelectedInlineDetail({ canOpenSelectedSession, @@ -187,11 +189,22 @@ export function useTeamWorkspaceBoardShellProps({ runtimeFormationDisplay, selectedTeamPlanDisplay, }); - const shellProps = buildTeamWorkspaceBoardShellProps({ - boardBodyClassName, + const headerProps = buildTeamWorkspaceBoardHeaderProps({ + boardChromeDisplay, + createdFromTurnId: subagentParentContext?.created_from_turn_id, + detailToggleLabel, + detailVisible, + isChildSession, + isEmptyShellState, + onReturnToParentSession, + onToggleDetail, + resolveStatusMeta, + runtimeFormationStatusLabel: runtimeFormationDisplay.panelStatusLabel, + totalTeamSessions, + useCompactCanvasChrome, + }); + const canvasSectionProps = buildTeamWorkspaceBoardCanvasSectionProps({ boardChromeDisplay, - boardHeaderClassName, - boardShellClassName, canvasBoundsHeight: canvasBounds.height, canvasBoundsWidth: canvasBounds.width, canvasStageHeight, @@ -201,17 +214,12 @@ export function useTeamWorkspaceBoardShellProps({ canCloseCompletedTeamSessions, canWaitAnyActiveTeamSession, completedCount: completedTeamSessionIds.length, - createdFromTurnId: subagentParentContext?.created_from_turn_id, detailCardClassName, - detailToggleLabel, detailVisible, - embedded, expandedSessionId, formatUpdatedAt, - hasRealTeamGraph, + hasRuntimeSessions, isCanvasPanModifierActive, - isChildSession, - isEmptyShellState, laneLayouts: canvasLaneLayouts, lanes: canvasLanes, memberCanvasSubtitle, @@ -220,32 +228,36 @@ export function useTeamWorkspaceBoardShellProps({ onCanvasWheel: handleCanvasWheel, onCloseCompletedTeamSessions: handleCloseCompletedTeamSessions, onFitCanvasView: handleFitCanvasView, - onReturnToParentSession, onResetCanvasView: handleResetCanvasView, onSelectCanvasLane: handleSelectCanvasLane, onSelectTeamOperationEntry: handleSelectTeamOperationEntry, onStartCanvasLaneDrag: handleStartCanvasLaneDrag, onStartCanvasLaneResize: handleStartCanvasLaneResize, onStartCanvasPan: handleStartCanvasPan, - onToggleDetail, onWaitAnyActiveTeamSessions: handleWaitAnyActiveTeamSessions, onZoomIn: handleZoomIn, onZoomOut: handleZoomOut, pendingTeamAction, railCardClassName, resolveStatusMeta, - runtimeFormationStatusLabel: runtimeFormationDisplay.panelStatusLabel, runtimeFormationDisplay, selectedInlineDetail, selectedSession, selectedSessionId: selectedSession?.id ?? null, selectedTeamPlanDisplay, teamOperationEntries, - totalTeamSessions, useCompactCanvasChrome, viewport: canvasViewport, waitableCount: waitableTeamSessionIds.length, }); + const shellProps = buildTeamWorkspaceBoardShellProps({ + boardBodyClassName, + boardHeaderClassName, + boardShellClassName, + canvasSectionProps, + embedded, + headerProps, + }); return { emptyShellProps, diff --git a/src/components/agent/chat/components/useGeneralWorkbenchSidebarOrchestration.ts b/src/components/agent/chat/components/useGeneralWorkbenchSidebarOrchestration.ts index 001392c26..ab8911f4a 100644 --- a/src/components/agent/chat/components/useGeneralWorkbenchSidebarOrchestration.ts +++ b/src/components/agent/chat/components/useGeneralWorkbenchSidebarOrchestration.ts @@ -16,7 +16,7 @@ interface UseGeneralWorkbenchSidebarOrchestrationParams { } export interface GeneralWorkbenchSidebarOrchestration { - branchCount: number; + workflowCount: number; isVersionMode: boolean; activeContextCount: number; visibleExecLogCount: number; @@ -31,6 +31,9 @@ export function useGeneralWorkbenchSidebarOrchestration({ }: UseGeneralWorkbenchSidebarOrchestrationParams): GeneralWorkbenchSidebarOrchestration { const { isVersionMode, context, workflow, execLog } = input; const branchCount = workflow.branchItems.length; + const workflowCount = workflow.workflowSteps.filter( + (step) => step.status !== "completed" && step.status !== "skipped", + ).length; const runDetailSessionId = workflow.activeRunDetail?.session_id?.trim() || null; const { handleRevealArtifactInFinder, handleOpenArtifactWithDefaultApp } = @@ -76,7 +79,7 @@ export function useGeneralWorkbenchSidebarOrchestration({ }); return { - branchCount, + workflowCount, isVersionMode, activeContextCount: contextPanelState.activeContextItems.length, visibleExecLogCount: execLogState.visibleExecLogEntries.length, diff --git a/src/components/agent/chat/components/useGeneralWorkbenchWorkflowPanelState.ts b/src/components/agent/chat/components/useGeneralWorkbenchWorkflowPanelState.ts index b4be4fc7a..c13db412a 100644 --- a/src/components/agent/chat/components/useGeneralWorkbenchWorkflowPanelState.ts +++ b/src/components/agent/chat/components/useGeneralWorkbenchWorkflowPanelState.ts @@ -43,7 +43,7 @@ export function useGeneralWorkbenchWorkflowPanelState({ activeRunMetadata, }: UseGeneralWorkbenchWorkflowPanelStateParams): GeneralWorkbenchWorkflowPanelState { const [showActivityLogs, setShowActivityLogs] = useState(false); - const [showCreationTasks, setShowCreationTasks] = useState(true); + const [showCreationTasks, setShowCreationTasks] = useState(false); const completedSteps = useMemo( () => workflowSteps.filter((step) => step.status === "completed").length, diff --git a/src/components/agent/chat/hooks/agentChatHistory.test.ts b/src/components/agent/chat/hooks/agentChatHistory.test.ts index 36f9aa225..6e4e77d54 100644 --- a/src/components/agent/chat/hooks/agentChatHistory.test.ts +++ b/src/components/agent/chat/hooks/agentChatHistory.test.ts @@ -172,6 +172,7 @@ describe("agentChatHistory", () => { usage: { input_tokens: 12000, output_tokens: 19000, + cached_input_tokens: 4000, }, }, ], @@ -182,6 +183,7 @@ describe("agentChatHistory", () => { expect(messages[0]?.usage).toEqual({ input_tokens: 12000, output_tokens: 19000, + cached_input_tokens: 4000, }); }); @@ -433,6 +435,7 @@ describe("agentChatHistory", () => { usage: { input_tokens: 20480, output_tokens: 10240, + cached_input_tokens: 8192, }, }, ]; @@ -459,6 +462,7 @@ describe("agentChatHistory", () => { expect(mergedMessages[1]?.usage).toEqual({ input_tokens: 20480, output_tokens: 10240, + cached_input_tokens: 8192, }); }); diff --git a/src/components/agent/chat/hooks/agentChatHistory.ts b/src/components/agent/chat/hooks/agentChatHistory.ts index e2c7e106d..eac0846ce 100644 --- a/src/components/agent/chat/hooks/agentChatHistory.ts +++ b/src/components/agent/chat/hooks/agentChatHistory.ts @@ -74,6 +74,8 @@ const normalizeHistoryUsage = (usage: unknown): AgentTokenUsage | undefined => { const inputTokens = (usage as { input_tokens?: unknown }).input_tokens; const outputTokens = (usage as { output_tokens?: unknown }).output_tokens; + const cachedInputTokens = (usage as { cached_input_tokens?: unknown }) + .cached_input_tokens; if ( typeof inputTokens !== "number" || typeof outputTokens !== "number" || @@ -88,6 +90,12 @@ const normalizeHistoryUsage = (usage: unknown): AgentTokenUsage | undefined => { return { input_tokens: inputTokens, output_tokens: outputTokens, + cached_input_tokens: + typeof cachedInputTokens === "number" && + Number.isFinite(cachedInputTokens) && + cachedInputTokens >= 0 + ? cachedInputTokens + : undefined, }; }; @@ -896,7 +904,7 @@ const buildAssistantHydrationSignature = (message: Message): string => { const buildHistoryMessageSignature = (message: Message): string => { const usageSignature = message.usage - ? `${message.usage.input_tokens}:${message.usage.output_tokens}` + ? `${message.usage.input_tokens}:${message.usage.output_tokens}:${message.usage.cached_input_tokens ?? ""}` : ""; return [ message.role, diff --git a/src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts b/src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts index ae4f030d5..d8a9c87b6 100644 --- a/src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts +++ b/src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts @@ -48,6 +48,7 @@ describe("agentStreamRuntimeHandler", () => { usage: { input_tokens: 12_000, output_tokens: 19_000, + cached_input_tokens: 8_000, }, } as AgentEvent, requestState: { @@ -100,6 +101,7 @@ describe("agentStreamRuntimeHandler", () => { usage: { input_tokens: 12_000, output_tokens: 19_000, + cached_input_tokens: 8_000, }, }); }); diff --git a/src/components/agent/chat/hooks/index.ts b/src/components/agent/chat/hooks/index.ts index 365422faa..547c90195 100644 --- a/src/components/agent/chat/hooks/index.ts +++ b/src/components/agent/chat/hooks/index.ts @@ -39,6 +39,5 @@ export function useAgentChatUnified(options: UseAgentChatUnifiedOptions) { export { useAsterAgentChat } from "./useAsterAgentChat"; export { useRuntimeTeamFormation } from "./useRuntimeTeamFormation"; export { useTeamWorkspaceRuntime } from "./useTeamWorkspaceRuntime"; -// compat subagent 适配仅允许内部直连,不继续从统一 hooks 入口扩散。 export { useThemeContextWorkspace } from "./useThemeContextWorkspace"; export { useTopicBranchBoard } from "./useTopicBranchBoard"; diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx index d285d7cb1..987353025 100644 --- a/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx +++ b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx @@ -4565,7 +4565,7 @@ describe("useAsterAgentChat action_required 渲染链路", () => { stream.emit({ type: "tool_start", tool_id: "tool-meta-1", - tool_name: "SubAgentTask", + tool_name: "Agent", arguments: JSON.stringify({ prompt: "检查 harness 缺口", }), diff --git a/src/components/agent/chat/hooks/useCompatSubagentRuntime.ts b/src/components/agent/chat/hooks/useCompatSubagentRuntime.ts deleted file mode 100644 index c2f235989..000000000 --- a/src/components/agent/chat/hooks/useCompatSubagentRuntime.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useSubAgentScheduler } from "@/hooks/useSubAgentScheduler"; - -import { - buildCompatSubagentRuntimeSnapshot, - type CompatSubagentRuntimeSnapshot, -} from "../utils/compatSubagentRuntime"; - -export function useCompatSubagentRuntime( - sessionId?: string | null, -): CompatSubagentRuntimeSnapshot { - const compatSchedulerState = useSubAgentScheduler(sessionId); - - return buildCompatSubagentRuntimeSnapshot(compatSchedulerState); -} diff --git a/src/components/agent/chat/hooks/useRuntimeTeamFormation.test.tsx b/src/components/agent/chat/hooks/useRuntimeTeamFormation.test.tsx index 363cdaf99..321d5ccdd 100644 --- a/src/components/agent/chat/hooks/useRuntimeTeamFormation.test.tsx +++ b/src/components/agent/chat/hooks/useRuntimeTeamFormation.test.tsx @@ -37,10 +37,8 @@ function renderHook(props?: Partial) { const defaultProps: HookProps = { projectId: "project-1", - sessionId: "session-1", selectedTeam: createSelectedTeam(), subagentEnabled: true, - hasRealTeamGraph: false, createRequestId: () => "request-1", now: () => 1_710_000_000_000, }; diff --git a/src/components/agent/chat/hooks/useRuntimeTeamFormation.ts b/src/components/agent/chat/hooks/useRuntimeTeamFormation.ts index 7352481df..17d7ca631 100644 --- a/src/components/agent/chat/hooks/useRuntimeTeamFormation.ts +++ b/src/components/agent/chat/hooks/useRuntimeTeamFormation.ts @@ -14,10 +14,8 @@ interface PrepareRuntimeTeamBeforeSendParams { interface UseRuntimeTeamFormationOptions { projectId?: string | null; - sessionId?: string | null; selectedTeam?: TeamDefinition | null; subagentEnabled: boolean; - hasRealTeamGraph: boolean; createRequestId?: () => string; now?: () => number; } diff --git a/src/components/agent/chat/hooks/useTeamMemoryShadowSync.test.tsx b/src/components/agent/chat/hooks/useTeamMemoryShadowSync.test.tsx index 103e3b121..bbf19ea0e 100644 --- a/src/components/agent/chat/hooks/useTeamMemoryShadowSync.test.tsx +++ b/src/components/agent/chat/hooks/useTeamMemoryShadowSync.test.tsx @@ -152,6 +152,9 @@ describe("useTeamMemoryShadowSync", () => { "Team:研究双人组", ); expect(snapshot?.entries["team.selection"]?.content).toContain("角色:"); + expect(snapshot?.entries["team.subagents"]?.content).toContain( + "子任务:", + ); expect(snapshot?.entries["team.subagents"]?.content).toContain( "研究代理 [running] explorer · 梳理主线风险", ); diff --git a/src/components/agent/chat/hooks/useTeamMemoryShadowSync.ts b/src/components/agent/chat/hooks/useTeamMemoryShadowSync.ts index 1379030c4..7f0a65fea 100644 --- a/src/components/agent/chat/hooks/useTeamMemoryShadowSync.ts +++ b/src/components/agent/chat/hooks/useTeamMemoryShadowSync.ts @@ -91,7 +91,7 @@ function buildSubagentMemoryContent(params: { return [ params.sessionId ? `会话:${params.sessionId}` : null, - "子代理:", + "子任务:", ...sessions.map(summarizeSubagentSession), ] .filter((item): item is string => Boolean(item)) diff --git a/src/components/agent/chat/index.test.tsx b/src/components/agent/chat/index.test.tsx index 19d0288a2..8812f196d 100644 --- a/src/components/agent/chat/index.test.tsx +++ b/src/components/agent/chat/index.test.tsx @@ -23,7 +23,6 @@ const { mockUseThemeContextWorkspace, mockUseTopicBranchBoard, mockUseTeamWorkspaceRuntime, - mockUseCompatSubagentRuntime, mockUseSessionFiles, mockGetProject, mockGetDefaultProject, @@ -68,7 +67,6 @@ const { mockUseThemeContextWorkspace: vi.fn(), mockUseTopicBranchBoard: vi.fn(), mockUseTeamWorkspaceRuntime: vi.fn(), - mockUseCompatSubagentRuntime: vi.fn(), mockUseSessionFiles: vi.fn(), mockGetProject: vi.fn(), mockGetDefaultProject: vi.fn(), @@ -197,10 +195,6 @@ vi.mock("./hooks", () => ({ useTeamWorkspaceRuntime: mockUseTeamWorkspaceRuntime, })); -vi.mock("./hooks/useCompatSubagentRuntime", () => ({ - useCompatSubagentRuntime: mockUseCompatSubagentRuntime, -})); - vi.mock("@/hooks/useDeveloperFeatureFlags", () => ({ useDeveloperFeatureFlags: mockUseDeveloperFeatureFlags, })); @@ -476,7 +470,7 @@ vi.mock("./components/TeamWorkspaceDock", () => ({ onActivateWorkbench(); }} > - 打开任务协作 + 打开任务工作台 ) : null}
@@ -1237,15 +1231,6 @@ beforeEach(() => { liveActivityBySessionId: {}, activityRefreshVersionBySessionId: {}, }); - mockUseCompatSubagentRuntime.mockReturnValue({ - isRunning: false, - progress: null, - events: [], - result: null, - error: null, - recentActivity: [], - hasSignals: false, - }); mockUseSessionFiles.mockReturnValue({ saveFile: vi.fn(async () => undefined), files: [], @@ -1897,6 +1882,9 @@ describe("AgentChatPage 通用工作台", { timeout: 20_000 }, () => { expect(document.body.textContent).toContain("处理工作台"); expect(document.body.textContent).toContain("通用助手"); + expect(document.body.textContent).toContain( + "集中查看计划、待确认事项、任务进展、文件活动和处理结果。", + ); }); it("处理工作台开关关闭时不应显示入口,也不应触发工具库存读取", async () => { @@ -5883,8 +5871,8 @@ describe("AgentChatPage 服务技能 A2UI", () => { }); }); -describe("AgentChatPage legacy 问卷 A2UI", () => { - it("工作区编排出现待补充 A2UI 时应保持聊天主区域,不展示左侧工作台侧栏", async () => { +describe("AgentChatPage 当前 A2UI 事实源", () => { + it("历史问卷正文不应再提升为输入区 A2UI", async () => { mockUseThemeContextWorkspace.mockReturnValue( createMockThemeContextWorkspaceState({ enabled: true, @@ -5918,7 +5906,7 @@ describe("AgentChatPage legacy 问卷 A2UI", () => { }), ); - const container = renderPage({ + renderPage({ projectId: "project-theme-a2ui", contentId: "content-theme-a2ui", theme: "general", @@ -5926,222 +5914,48 @@ describe("AgentChatPage legacy 问卷 A2UI", () => { }); await flushEffects(10); - expect( - container - .querySelector('[data-testid="layout-transition"]') - ?.getAttribute("data-mode"), - ).toBe("chat"); - expect( - container.querySelector('[data-testid="general-workbench-sidebar"]'), - ).toBeNull(); - - const latestPendingPanelProps = - mockWorkspacePendingA2UIPanel.mock.calls.at(-1)?.[0] as - | { - pendingA2UIForm?: { - data?: Record; - } | null; - } - | undefined; - - expect(latestPendingPanelProps?.pendingA2UIForm?.data).toMatchObject({ - source: "legacy_questionnaire", - questionCount: 1, - }); - }); - - it("应将结构化问卷提升到输入区浮层,并按字段标签提交摘要", async () => { - mockUseAgentChatUnified.mockImplementation( - ({ workspaceId }: { workspaceId: string }) => { - observedWorkspaceIds.push(workspaceId); - return { - providerType: "kiro", - setProviderType: vi.fn(), - model: "mock-model", - setModel: vi.fn(), - executionStrategy: "auto", - setExecutionStrategy: vi.fn(), - messages: [ - { - id: "msg-legacy-user", - role: "user", - content: "帮我先梳理需求", - timestamp: new Date("2026-03-15T09:00:00.000Z"), - }, - { - id: "msg-legacy-assistant", - role: "assistant", - content: `为了继续推进,我需要你先补充以下信息: - -1. 目标与对象 -- 这次内容主要面向谁?(客户 / 上级 / 同事) -- 这次最想达成的目标是什么? - -2. 风格与限制 -- 语气偏好:正式严谨 / 友好专业 / 直接高效 -- 是否需要加入明确行动号召?`, - timestamp: new Date("2026-03-15T09:00:01.000Z"), - }, - ], - isSending: false, - sendMessage: sharedSendMessageMock, - stopSending: vi.fn(async () => undefined), - clearMessages: vi.fn(), - deleteMessage: vi.fn(), - editMessage: vi.fn(), - handlePermissionResponse: vi.fn(), - triggerAIGuide: sharedTriggerAIGuideMock, - topics: [ - { - id: "topic-a", - title: "话题 A", - updatedAt: Date.now(), - }, - ], - sessionId: "session-1", - switchTopic: sharedSwitchTopicMock, - deleteTopic: vi.fn(), - renameTopic: vi.fn(), - workspacePathMissing: false, - fixWorkspacePathAndRetry: vi.fn(), - dismissWorkspacePathError: vi.fn(), - }; - }, - ); - - renderPage({ - projectId: "project-legacy-a2ui", - theme: "general", - lockTheme: true, - }); - await flushEffects(10); - const latestMessageListProps = mockMessageList.mock.calls.at(-1)?.[0] as | { messages?: Array>; } | undefined; - expect(latestMessageListProps?.messages?.[1]?.content).toBe( - "已整理为补充信息表单,请在输入区完成填写。", + expect(latestMessageListProps?.messages?.[1]?.content).toContain( + "为了继续推进,我需要你先补充以下信息", + ); + expect(latestMessageListProps?.messages?.[1]?.content).toContain( + "这次内容主要面向谁?", ); const latestPendingPanelProps = mockWorkspacePendingA2UIPanel.mock.calls.at(-1)?.[0] as | { pendingA2UIForm?: { - data?: Record; - components?: Array>; + id?: string; + } | null; + a2uiSubmissionNotice?: { + title?: string; + summary?: string; } | null; - onA2UISubmit?: (formData: Record) => void; } | undefined; - - expect(latestPendingPanelProps?.pendingA2UIForm).toBeTruthy(); - expect(latestPendingPanelProps?.pendingA2UIForm?.data).toMatchObject({ - source: "legacy_questionnaire", - questionCount: 1, - governance: expect.objectContaining({ - originalQuestionCount: 4, - deferredQuestionCount: 3, - }), - }); - expect(latestPendingPanelProps?.pendingA2UIForm?.components || []).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - component: "ChoicePicker", - label: "这次内容主要面向谁?", - }), - ]), - ); - expect( - latestPendingPanelProps?.pendingA2UIForm?.components || [], - ).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - label: "这次最想达成的目标是什么?", - }), - expect.objectContaining({ - label: "语气偏好", - }), - expect.objectContaining({ - label: "是否需要加入明确行动号召?", - }), - ]), - ); - - const componentIdByLabel = Object.fromEntries( - (latestPendingPanelProps?.pendingA2UIForm?.components || []) - .filter( - (component) => - (component.component === "ChoicePicker" || - component.component === "TextField") && - typeof component.label === "string" && - typeof component.id === "string", - ) - .map((component) => [component.label, component.id]), - ); - - act(() => { - latestPendingPanelProps?.onA2UISubmit?.({ - [componentIdByLabel["这次内容主要面向谁?"]]: ["客户"], - }); - }); - - expect(sharedSendMessageMock).toHaveBeenCalledWith( - `我的选择: -- 这次内容主要面向谁?: 客户`, - [], - false, - false, - false, - undefined, - undefined, - undefined, - expect.objectContaining({ - requestMetadata: { - elicitation_context: expect.objectContaining({ - source: "legacy_questionnaire", - mode: "compatibility_bridge", - question_count: 1, - governance: expect.objectContaining({ - originalQuestionCount: 4, - deferredQuestionCount: 3, - }), - entries: expect.arrayContaining([ - expect.objectContaining({ - label: "这次内容主要面向谁?", - value: "客户", - summary: "客户", - }), - ]), - }), - }, - }), - ); + expect(latestPendingPanelProps?.pendingA2UIForm ?? null).toBeNull(); + expect(latestPendingPanelProps?.a2uiSubmissionNotice ?? null).toBeNull(); }); - it("ask/tool_calls 残留问卷正文也应提升为输入区 A2UI,而不是原样展示", async () => { - mockUseAgentChatUnified.mockImplementation( - ({ workspaceId }: { workspaceId: string }) => { - observedWorkspaceIds.push(workspaceId); - return { - providerType: "kiro", - setProviderType: vi.fn(), - model: "mock-model", - setModel: vi.fn(), - executionStrategy: "auto", - setExecutionStrategy: vi.fn(), - messages: [ - { - id: "msg-legacy-user", - role: "user", - content: "请先做网页研究简报", - timestamp: new Date("2026-03-15T09:00:00.000Z"), - }, - { - id: "msg-legacy-assistant-compat-ask", - role: "assistant", - content: `我注意到您想让我做“网页研究简报”,但您没有指定具体的研究主题。 + it("ask/tool_calls 残留问卷正文应原样显示且不生成 pending A2UI", async () => { + installMockAgentChatUnifiedState( + createMockAgentChatUnifiedState({ + messages: [ + { + id: "msg-legacy-user", + role: "user", + content: "请先做网页研究简报", + timestamp: new Date("2026-03-15T09:00:00.000Z"), + }, + { + id: "msg-legacy-assistant-compat-ask", + role: "assistant", + content: `我注意到您想让我做“网页研究简报”,但您没有指定具体的研究主题。 我注意到您想让我做“网页研究简报”,但您没有指定具体的研究主题。 @@ -6156,33 +5970,10 @@ askquestionarg_value>请提供您希望我研究的 - 其他您关心的主题 另外,请告诉我该研究的主要目的是什么?`, - timestamp: new Date("2026-03-15T09:00:01.000Z"), - }, - ], - isSending: false, - sendMessage: sharedSendMessageMock, - stopSending: vi.fn(async () => undefined), - clearMessages: vi.fn(), - deleteMessage: vi.fn(), - editMessage: vi.fn(), - handlePermissionResponse: vi.fn(), - triggerAIGuide: sharedTriggerAIGuideMock, - topics: [ - { - id: "topic-a", - title: "话题 A", - updatedAt: Date.now(), - }, - ], - sessionId: "session-1", - switchTopic: sharedSwitchTopicMock, - deleteTopic: vi.fn(), - renameTopic: vi.fn(), - workspacePathMissing: false, - fixWorkspacePathAndRetry: vi.fn(), - dismissWorkspacePathError: vi.fn(), - }; - }, + timestamp: new Date("2026-03-15T09:00:01.000Z"), + }, + ], + }), ); renderPage({ @@ -6197,185 +5988,38 @@ askquestionarg_value>请提供您希望我研究的 messages?: Array>; } | undefined; - expect(latestMessageListProps?.messages?.[1]?.content).toBe( - "已整理为补充信息表单,请在输入区完成填写。", + expect(latestMessageListProps?.messages?.[1]?.content).toContain( + "请提供您希望我研究的具体主题", + ); + expect(latestMessageListProps?.messages?.[1]?.content).toContain( + "", ); const latestPendingPanelProps = mockWorkspacePendingA2UIPanel.mock.calls.at(-1)?.[0] as | { pendingA2UIForm?: { - data?: Record; - components?: Array>; + id?: string; } | null; } | undefined; - - expect(latestPendingPanelProps?.pendingA2UIForm?.data).toMatchObject({ - source: "legacy_questionnaire", - sectionCount: 1, - questionCount: 1, - governance: expect.objectContaining({ - originalQuestionCount: 2, - deferredQuestionCount: 1, - }), - }); - expect( - (latestPendingPanelProps?.pendingA2UIForm?.components || []).some( - (component) => - component.component === "TextField" && - component.label === "请提供您希望我研究的具体主题", - ), - ).toBe(true); + expect(latestPendingPanelProps?.pendingA2UIForm ?? null).toBeNull(); }); - it("普通中文澄清问题也应提升为输入区 A2UI,而不是继续停留在正文里", async () => { - mockUseAgentChatUnified.mockImplementation( - ({ workspaceId }: { workspaceId: string }) => { - observedWorkspaceIds.push(workspaceId); - return { - providerType: "kiro", - setProviderType: vi.fn(), - model: "mock-model", - setModel: vi.fn(), - executionStrategy: "auto", - setExecutionStrategy: vi.fn(), - messages: [ - { - id: "msg-plain-legacy-user", - role: "user", - content: "围绕这个主题做研究", - timestamp: new Date("2026-03-15T09:00:00.000Z"), - }, - { - id: "msg-plain-legacy-assistant", - role: "assistant", - content: `我需要先明确一下:您希望我研究哪个主题?您的消息中提到了“围绕这个主题”,但没有具体说明主题内容。请告诉我: - -- 具体的研究主题(例如:某个产品、技术、公司、市场趋势、政策、事件等) -- 研究目的(例如:投资决策、技术选型、竞争分析、学习了解等) -- 是否有特定关注点(例如:风险、机会、对比、最新动态等) - -一旦您明确了主题,我会: -1. 使用联网搜索获取最新信息 -2. 整理关键来源和核心发现 -3. 识别风险点和待追踪问题 -4. 输出一版结构化的研究简报`, - timestamp: new Date("2026-03-15T09:00:01.000Z"), - }, - ], - isSending: false, - sendMessage: sharedSendMessageMock, - stopSending: vi.fn(async () => undefined), - clearMessages: vi.fn(), - deleteMessage: vi.fn(), - editMessage: vi.fn(), - handlePermissionResponse: vi.fn(), - triggerAIGuide: sharedTriggerAIGuideMock, - topics: [ - { - id: "topic-a", - title: "话题 A", - updatedAt: Date.now(), - }, - ], - sessionId: "session-1", - switchTopic: sharedSwitchTopicMock, - deleteTopic: vi.fn(), - renameTopic: vi.fn(), - workspacePathMissing: false, - fixWorkspacePathAndRetry: vi.fn(), - dismissWorkspacePathError: vi.fn(), - }; - }, - ); - - renderPage({ - projectId: "project-plain-legacy-a2ui", - theme: "general", - lockTheme: true, - }); - await flushEffects(10); - - const latestMessageListProps = mockMessageList.mock.calls.at(-1)?.[0] as - | { - messages?: Array>; - } - | undefined; - expect(latestMessageListProps?.messages?.[1]?.content).toBe( - "已整理为补充信息表单,请在输入区完成填写。", - ); - - const latestPendingPanelProps = - mockWorkspacePendingA2UIPanel.mock.calls.at(-1)?.[0] as - | { - pendingA2UIForm?: { - data?: Record; - components?: Array>; - } | null; - } - | undefined; - - expect(latestPendingPanelProps?.pendingA2UIForm?.data).toMatchObject({ - source: "legacy_questionnaire", - sectionCount: 1, - questionCount: 1, - governance: expect.objectContaining({ - originalQuestionCount: 3, - deferredQuestionCount: 2, - }), - }); - expect(latestPendingPanelProps?.pendingA2UIForm?.components || []).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - component: "TextField", - label: "具体的研究主题", - }), - ]), - ); - expect( - (latestPendingPanelProps?.pendingA2UIForm?.components || []).filter( - (component) => component.component === "TextField", - ), - ).toHaveLength(1); - expect( - latestPendingPanelProps?.pendingA2UIForm?.components || [], - ).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - component: "TextField", - label: "研究目的", - }), - expect.objectContaining({ - component: "TextField", - label: "是否有特定关注点", - }), - ]), - ); - }); - - it("问卷已提交后,消息区应折叠为简短确认而不是继续展示完整题面", async () => { - mockUseAgentChatUnified.mockImplementation( - ({ workspaceId }: { workspaceId: string }) => { - observedWorkspaceIds.push(workspaceId); - return { - providerType: "kiro", - setProviderType: vi.fn(), - model: "mock-model", - setModel: vi.fn(), - executionStrategy: "auto", - setExecutionStrategy: vi.fn(), - messages: [ - { - id: "msg-legacy-user", - role: "user", - content: "帮我先梳理需求", - timestamp: new Date("2026-03-15T09:00:00.000Z"), - }, - { - id: "msg-legacy-assistant", - role: "assistant", - content: `为了继续推进,我需要你先补充以下信息: + it("历史问卷已出现用户补充摘要时也不应折叠原正文", async () => { + installMockAgentChatUnifiedState( + createMockAgentChatUnifiedState({ + messages: [ + { + id: "msg-legacy-user", + role: "user", + content: "帮我先梳理需求", + timestamp: new Date("2026-03-15T09:00:00.000Z"), + }, + { + id: "msg-legacy-assistant", + role: "assistant", + content: `为了继续推进,我需要你先补充以下信息: 1. 目标与对象 - 这次内容主要面向谁?(客户 / 上级 / 同事) @@ -6384,41 +6028,18 @@ askquestionarg_value>请提供您希望我研究的 2. 风格与限制 - 语气偏好:正式严谨 / 友好专业 / 直接高效 - 是否需要加入明确行动号召?`, - timestamp: new Date("2026-03-15T09:00:01.000Z"), - }, - { - id: "msg-legacy-summary", - role: "user", - content: `我的选择: + timestamp: new Date("2026-03-15T09:00:01.000Z"), + }, + { + id: "msg-legacy-summary", + role: "user", + content: `我的选择: - 这次内容主要面向谁?: 客户 - 这次最想达成的目标是什么?: 帮助市场团队统一宣传口径`, - timestamp: new Date("2026-03-15T09:01:00.000Z"), - }, - ], - isSending: false, - sendMessage: sharedSendMessageMock, - stopSending: vi.fn(async () => undefined), - clearMessages: vi.fn(), - deleteMessage: vi.fn(), - editMessage: vi.fn(), - handlePermissionResponse: vi.fn(), - triggerAIGuide: sharedTriggerAIGuideMock, - topics: [ - { - id: "topic-a", - title: "话题 A", - updatedAt: Date.now(), - }, - ], - sessionId: "session-1", - switchTopic: sharedSwitchTopicMock, - deleteTopic: vi.fn(), - renameTopic: vi.fn(), - workspacePathMissing: false, - fixWorkspacePathAndRetry: vi.fn(), - dismissWorkspacePathError: vi.fn(), - }; - }, + timestamp: new Date("2026-03-15T09:01:00.000Z"), + }, + ], + }), ); renderPage({ @@ -6433,12 +6054,30 @@ askquestionarg_value>请提供您希望我研究的 messages?: Array>; } | undefined; - expect(latestMessageListProps?.messages?.[1]?.content).toBe( + expect(latestMessageListProps?.messages?.[1]?.content).toContain( + "为了继续推进,我需要你先补充以下信息", + ); + expect(latestMessageListProps?.messages?.[1]?.content).not.toBe( "补充信息表单已提交。", ); + + const latestPendingPanelProps = + mockWorkspacePendingA2UIPanel.mock.calls.at(-1)?.[0] as + | { + pendingA2UIForm?: { + id?: string; + } | null; + a2uiSubmissionNotice?: { + title?: string; + summary?: string; + } | null; + } + | undefined; + expect(latestPendingPanelProps?.pendingA2UIForm ?? null).toBeNull(); + expect(latestPendingPanelProps?.a2uiSubmissionNotice ?? null).toBeNull(); }); - it("真实 action_required 存在时,不应被 legacy 折叠逻辑覆盖", async () => { + it("真实 action_required 存在时,应优先提升当前 A2UI", async () => { mockUseAgentChatUnified.mockImplementation( ({ workspaceId }: { workspaceId: string }) => { observedWorkspaceIds.push(workspaceId); @@ -6543,7 +6182,7 @@ askquestionarg_value>请提供您希望我研究的 ); }); - it("真实 action_required 已提交后,输入区应显示补充信息确认提示而不是继续停留在表单态", async () => { + it("真实 action_required 已提交后,不应残留表单或旧兼容提示", async () => { mockUseAgentChatUnified.mockImplementation( ({ workspaceId }: { workspaceId: string }) => { observedWorkspaceIds.push(workspaceId); diff --git a/src/components/agent/chat/team-workspace-runtime/boardChromeSelectors.test.ts b/src/components/agent/chat/team-workspace-runtime/boardChromeSelectors.test.ts index e2a66ff71..77302a9a5 100644 --- a/src/components/agent/chat/team-workspace-runtime/boardChromeSelectors.test.ts +++ b/src/components/agent/chat/team-workspace-runtime/boardChromeSelectors.test.ts @@ -4,8 +4,7 @@ import { buildTeamWorkspaceBoardChromeDisplayState } from "./boardChromeSelector describe("boardChromeSelectors", () => { it("真实成员图应产出紧凑 chrome 标题与工具条 chips", () => { const state = buildTeamWorkspaceBoardChromeDisplayState({ - hasRealTeamGraph: true, - hasRuntimeFormation: false, + hasRuntimeSessions: true, isChildSession: false, totalTeamSessions: 2, siblingCount: 0, @@ -26,19 +25,20 @@ describe("boardChromeSelectors", () => { }, }); - expect(state.boardHeadline).toBe("2 位成员协作中"); + expect(state.boardHeadline).toBe("任务进行中 · 1 项处理中 / 1 项稍后开始"); expect(state.boardHint).toBe( - "这里只展示谁在帮你处理什么、处理到哪一步,以及接下来会给你什么结果。", + "这里只展示当前有哪些任务在处理、状态如何,以及最近更新到了哪里。", + ); + expect(state.compactBoardHeadline).toBe( + "任务进行中 · 1 项处理中 / 1 项稍后开始", ); - expect(state.compactBoardHeadline).toBe("2 位成员协作中"); expect(state.compactToolbarChips).toEqual([ - { key: "focus", text: "焦点 研究员", tone: "summary" }, + { key: "focus", text: "当前焦点 研究员", tone: "summary" }, { key: "status", text: "处理中", tone: "status", status: "running" }, { key: "updated-at", text: "更新于 刚刚", tone: "muted" }, - { key: "current", text: "当前对话", tone: "muted" }, - { key: "zoom", text: "缩放 100%", tone: "muted" }, - { key: "waitable", text: "2 位处理中", tone: "muted" }, - { key: "completed", text: "1 位已完成", tone: "muted" }, + { key: "current", text: "当前任务", tone: "muted" }, + { key: "waitable", text: "2 项处理中", tone: "muted" }, + { key: "completed", text: "1 项已完成", tone: "muted" }, ]); expect(state.statusSummaryBadges).toEqual([ { key: "running", text: "处理中 1", status: "running" }, @@ -48,10 +48,9 @@ describe("boardChromeSelectors", () => { it("forming 阶段应优先显示 runtime formation 的标题与提示", () => { const state = buildTeamWorkspaceBoardChromeDisplayState({ - hasRealTeamGraph: false, - hasRuntimeFormation: true, + hasRuntimeSessions: false, runtimeFormationTitle: "任务分工已准备好", - runtimeFormationHint: "当前任务的分工已经准备好,成员加入后会继续接手处理。", + runtimeFormationHint: "当前任务的分工已经准备好,任务拆出后会继续接手处理。", isChildSession: false, totalTeamSessions: 0, siblingCount: 0, @@ -65,18 +64,16 @@ describe("boardChromeSelectors", () => { }); expect(state.boardHeadline).toBe("任务分工已准备好"); - expect(state.boardHint).toBe("当前任务的分工已经准备好,成员加入后会继续接手处理。"); + expect(state.boardHint).toBe("当前任务的分工已经准备好,任务拆出后会继续接手处理。"); expect(state.compactBoardHeadline).toBe("任务分工已准备好"); expect(state.compactToolbarChips).toEqual([ - { key: "focus", text: "等待成员接入", tone: "summary" }, - { key: "zoom", text: "缩放 88%", tone: "muted" }, + { key: "focus", text: "等待任务接手", tone: "summary" }, ]); }); it("子线程应优先显示父会话标题与 sibling 提示", () => { const state = buildTeamWorkspaceBoardChromeDisplayState({ - hasRealTeamGraph: true, - hasRuntimeFormation: false, + hasRuntimeSessions: true, isChildSession: true, parentSessionName: "主线程总览", totalTeamSessions: 1, @@ -93,7 +90,7 @@ describe("boardChromeSelectors", () => { }); expect(state.boardHeadline).toBe("主线程总览"); - expect(state.boardHint).toBe("当前正与 2 位协作成员并行推进"); + expect(state.boardHint).toBe("当前任务正与 2 项并行子任务一起推进"); expect(state.compactBoardHeadline).toBe("主线程总览"); expect(state.statusSummaryBadges).toEqual([ { key: "completed", text: "已完成 1", status: "completed" }, diff --git a/src/components/agent/chat/team-workspace-runtime/boardChromeSelectors.ts b/src/components/agent/chat/team-workspace-runtime/boardChromeSelectors.ts index cab9b6a87..7105f34fa 100644 --- a/src/components/agent/chat/team-workspace-runtime/boardChromeSelectors.ts +++ b/src/components/agent/chat/team-workspace-runtime/boardChromeSelectors.ts @@ -41,50 +41,79 @@ function formatUpdatedAt(updatedAt?: number) { } function buildBoardHeadline(params: { - hasRealTeamGraph: boolean; + hasRuntimeSessions: boolean; isChildSession: boolean; parentSessionName?: string | null; + statusSummary: Record; totalTeamSessions: number; }) { const { - hasRealTeamGraph, + hasRuntimeSessions, isChildSession, parentSessionName, + statusSummary, totalTeamSessions, } = params; + const runningCount = statusSummary.running ?? 0; + const queuedCount = statusSummary.queued ?? 0; + const completedCount = statusSummary.completed ?? 0; + const retryCount = + (statusSummary.failed ?? 0) + (statusSummary.aborted ?? 0); if (isChildSession) { - return parentSessionName?.trim() || "主助手协作区"; + return parentSessionName?.trim() || "主任务总览"; } - if (!hasRealTeamGraph) { - return "需要时会自动接入协作成员"; + if (!hasRuntimeSessions) { + return "需要时会自动拆出任务"; + } + if (runningCount > 0) { + if (queuedCount > 0) { + return `任务进行中 · ${runningCount} 项处理中 / ${queuedCount} 项稍后开始`; + } + return totalTeamSessions > 1 + ? `任务进行中 · ${runningCount} 项处理中` + : "任务进行中"; + } + if (queuedCount > 0) { + return totalTeamSessions > 1 + ? `任务准备中 · ${queuedCount} 项稍后开始` + : "任务准备中"; + } + if (completedCount > 0 && completedCount === totalTeamSessions) { + return totalTeamSessions > 1 + ? `${completedCount} 项任务已完成` + : "任务已完成"; + } + if (retryCount > 0 && retryCount === totalTeamSessions) { + return totalTeamSessions > 1 + ? `${retryCount} 项任务需重试` + : "任务需重试"; } return totalTeamSessions > 0 - ? `${totalTeamSessions} 位成员协作中` + ? `${totalTeamSessions} 项任务已接入` : TEAM_WORKSPACE_SURFACE_TITLE; } function buildBoardHint(params: { - hasRealTeamGraph: boolean; + hasRuntimeSessions: boolean; isChildSession: boolean; siblingCount: number; }) { - const { hasRealTeamGraph, isChildSession, siblingCount } = params; + const { hasRuntimeSessions, isChildSession, siblingCount } = params; if (isChildSession) { return siblingCount > 0 - ? `当前正与 ${siblingCount} 位协作成员并行推进` - : "当前由你和这位协作成员一起推进"; + ? `当前任务正与 ${siblingCount} 项并行子任务一起推进` + : "当前正在处理这项子任务,结果会回流到主任务。"; } - if (!hasRealTeamGraph) { - return "系统会在需要时自动安排任务分工并接入协作成员,你只需要关注当前任务和结果。"; + if (!hasRuntimeSessions) { + return "系统会在需要时自动拆出任务、安排处理顺序,并把结果回流到当前任务。"; } - return "这里只展示谁在帮你处理什么、处理到哪一步,以及接下来会给你什么结果。"; + return "这里只展示当前有哪些任务在处理、状态如何,以及最近更新到了哪里。"; } export function buildTeamWorkspaceBoardChromeDisplayState(params: { - hasRealTeamGraph: boolean; - hasRuntimeFormation: boolean; + hasRuntimeSessions: boolean; runtimeFormationTitle?: string | null; runtimeFormationHint?: string | null; isChildSession: boolean; @@ -100,36 +129,29 @@ export function buildTeamWorkspaceBoardChromeDisplayState(params: { statusSummary: Record; }): TeamWorkspaceBoardChromeDisplayState { const boardHeadline = - !params.hasRealTeamGraph && params.runtimeFormationTitle + !params.hasRuntimeSessions && params.runtimeFormationTitle ? params.runtimeFormationTitle : buildBoardHeadline({ - hasRealTeamGraph: params.hasRealTeamGraph, + hasRuntimeSessions: params.hasRuntimeSessions, isChildSession: params.isChildSession, parentSessionName: params.parentSessionName, + statusSummary: params.statusSummary, totalTeamSessions: params.totalTeamSessions, }); const boardHint = - !params.hasRealTeamGraph && - params.hasRuntimeFormation && - params.runtimeFormationHint + !params.hasRuntimeSessions && params.runtimeFormationHint ? params.runtimeFormationHint : buildBoardHint({ - hasRealTeamGraph: params.hasRealTeamGraph, + hasRuntimeSessions: params.hasRuntimeSessions, isChildSession: params.isChildSession, siblingCount: params.siblingCount, }); - const compactBoardHeadline = - params.hasRealTeamGraph && - !params.isChildSession && - params.totalTeamSessions > 0 - ? `${params.totalTeamSessions} 位成员协作中` - : boardHeadline; const compactToolbarChips: TeamWorkspaceBoardChromeChip[] = [ { key: "focus", text: params.selectedSession - ? `焦点 ${params.selectedSession.name}` - : "等待成员接入", + ? `当前焦点 ${params.selectedSession.name}` + : "等待任务接手", tone: "summary", }, ...(params.selectedSession?.runtimeStatus @@ -157,21 +179,16 @@ export function buildTeamWorkspaceBoardChromeDisplayState(params: { ? [ { key: "current", - text: "当前对话", + text: "当前任务", tone: "muted" as const, }, ] : []), - { - key: "zoom", - text: `缩放 ${Math.round(params.zoom * 100)}%`, - tone: "muted", - }, ...(params.canWaitAnyActiveTeamSession ? [ { key: "waitable", - text: `${params.waitableCount} 位处理中`, + text: `${params.waitableCount} 项处理中`, tone: "muted" as const, }, ] @@ -180,7 +197,7 @@ export function buildTeamWorkspaceBoardChromeDisplayState(params: { ? [ { key: "completed", - text: `${params.completedCount} 位已完成`, + text: `${params.completedCount} 项已完成`, tone: "muted" as const, }, ] @@ -190,13 +207,15 @@ export function buildTeamWorkspaceBoardChromeDisplayState(params: { return { boardHeadline, boardHint, - compactBoardHeadline, + compactBoardHeadline: boardHeadline, compactToolbarChips, statusSummaryBadges: Object.entries(params.statusSummary) .filter(([, count]) => count > 0) .map(([status, count]) => { const normalizedStatus = - status === "idle" ? undefined : (status as TeamWorkspaceRuntimeStatus); + status === "idle" + ? undefined + : (status as TeamWorkspaceRuntimeStatus); return { key: status, diff --git a/src/components/agent/chat/team-workspace-runtime/canvasLaneSelectors.test.ts b/src/components/agent/chat/team-workspace-runtime/canvasLaneSelectors.test.ts index 3c5560751..2be7caa17 100644 --- a/src/components/agent/chat/team-workspace-runtime/canvasLaneSelectors.test.ts +++ b/src/components/agent/chat/team-workspace-runtime/canvasLaneSelectors.test.ts @@ -147,8 +147,8 @@ describe("canvasLaneSelectors", () => { title: "写作", badgeLabel: "正在处理", roleLabel: "撰写", - statusHint: "这位协作成员正在处理", - updatedAtLabel: "等待成员加入", + statusHint: "这项任务正在处理", + updatedAtLabel: "等待任务接手", previewText: "负责整理路线图结论。", }), ]); @@ -179,7 +179,7 @@ describe("canvasLaneSelectors", () => { title: "复核", badgeLabel: "待开始", roleLabel: "复核", - statusHint: "等待系统邀请协作成员加入", + statusHint: "等待系统把这项任务拆出来", updatedAtLabel: "计划分工", previewText: "补测试并确认风险。", }), diff --git a/src/components/agent/chat/team-workspace-runtime/canvasLaneSelectors.ts b/src/components/agent/chat/team-workspace-runtime/canvasLaneSelectors.ts index b2e06a7a7..3acc396b5 100644 --- a/src/components/agent/chat/team-workspace-runtime/canvasLaneSelectors.ts +++ b/src/components/agent/chat/team-workspace-runtime/canvasLaneSelectors.ts @@ -1,6 +1,7 @@ import type { AsterSubagentSkillInfo } from "@/lib/api/agentRuntime"; import { formatRelativeTime } from "@/lib/api/project"; import type { TeamRoleDefinition } from "../utils/teamDefinitions"; +import { normalizeTeamWorkspaceDisplayValue } from "../utils/teamWorkspaceDisplay"; import { getTeamPresetOption } from "../utils/teamPresets"; import { buildTeamWorkspaceSkillDisplayName, @@ -186,19 +187,22 @@ function buildSessionLaneEmptyState(params: { const { session, previewState } = params; if (previewState?.status === "error") { - return previewState.errorMessage?.trim() || "同步最新内容失败"; + return ( + normalizeTeamWorkspaceDisplayValue(previewState.errorMessage) || + "同步最新内容失败" + ); } if (previewState?.status === "loading") { - return "正在同步这位协作成员的最新内容..."; + return "正在同步这项任务的最新内容..."; } if (session?.runtimeStatus === "queued") { - return "这位协作成员已经收到任务,马上开始处理。"; + return "这项任务已经收到安排,马上开始处理。"; } if (session?.runtimeStatus === "running") { - return "这位协作成员正在处理,最新进展会持续刷新到这里。"; + return "这项任务正在处理,最新进展会持续刷新到这里。"; } if (session?.runtimeStatus === "completed") { @@ -212,7 +216,7 @@ function buildSessionLaneEmptyState(params: { return "这一步没有顺利完成,你可以在下方查看细节并决定是否继续。"; } - return "这位协作成员暂时还没有产出可展示的内容。"; + return "这项任务暂时还没有产出可展示的内容。"; } function buildCanvasLaneTitleSummary( @@ -221,9 +225,9 @@ function buildCanvasLaneTitleSummary( const memberMeta = resolveRuntimeMemberStatusMeta(member.status); const statusHint = member.status === "spawning" - ? "正在接入协作成员" + ? "正在接入这项任务" : member.status === "running" - ? "这位协作成员正在处理" + ? "这项任务正在处理" : member.status === "waiting" ? "等待继续补充说明" : member.status === "completed" @@ -231,8 +235,8 @@ function buildCanvasLaneTitleSummary( : member.status === "failed" ? "这一步需要重试" : member.sessionId - ? "已连接到真实成员" - : "等待成员接入"; + ? "已连接到真实任务" + : "等待任务接手"; return { badgeLabel: memberMeta.label, @@ -245,7 +249,7 @@ function buildCanvasLaneTitleSummary( : member.status === "waiting" ? "bg-amber-400" : "bg-sky-500", - summary: member.summary, + summary: normalizeTeamWorkspaceDisplayValue(member.summary) || member.summary, statusHint, }; } @@ -255,8 +259,8 @@ function buildPlannedRoleLaneSummary(role: TeamRoleDefinition) { badgeLabel: "待开始", badgeClassName: "border border-slate-200 bg-slate-50 text-slate-600", dotClassName: "bg-slate-300", - summary: role.summary, - statusHint: "等待系统邀请协作成员加入", + summary: normalizeTeamWorkspaceDisplayValue(role.summary) || role.summary, + statusHint: "等待系统把这项任务拆出来", }; } @@ -466,7 +470,7 @@ function buildSessionCanvasLane(params: { kind: "session", title: session.name, summary: - session.taskSummary || + normalizeTeamWorkspaceDisplayValue(session.taskSummary) || "暂时还没有任务摘要,打开详情后可查看完整上下文。", badgeLabel: meta.label, badgeClassName: meta.badgeClassName, @@ -537,9 +541,10 @@ export function buildTeamWorkspaceCanvasLanes(params: { resolveTeamWorkspaceRoleHintLabel(member.roleKey) || undefined, profileLabel: undefined, statusHint: laneSummary.statusHint, - updatedAtLabel: "等待成员加入", + updatedAtLabel: "等待任务接手", skillLabels: [], - previewText: member.summary, + previewText: + normalizeTeamWorkspaceDisplayValue(member.summary) || member.summary, previewEntries: [], }; }); @@ -563,7 +568,7 @@ export function buildTeamWorkspaceCanvasLanes(params: { statusHint: laneSummary.statusHint, updatedAtLabel: "计划分工", skillLabels: [], - previewText: role.summary, + previewText: normalizeTeamWorkspaceDisplayValue(role.summary) || role.summary, previewEntries: [], }; }); diff --git a/src/components/agent/chat/team-workspace-runtime/formationDisplaySelectors.test.ts b/src/components/agent/chat/team-workspace-runtime/formationDisplaySelectors.test.ts index 420cc2704..0a4ee9983 100644 --- a/src/components/agent/chat/team-workspace-runtime/formationDisplaySelectors.test.ts +++ b/src/components/agent/chat/team-workspace-runtime/formationDisplaySelectors.test.ts @@ -32,7 +32,7 @@ describe("formationDisplaySelectors", () => { ]); }); - it("runtime formation 已就绪时,应产出状态、成员与参考分工", () => { + it("runtime formation 已就绪时,应产出状态、任务与参考分工", () => { const state = buildRuntimeFormationDisplayState({ teamDispatchPreviewState: { requestId: "runtime-1", @@ -66,9 +66,9 @@ describe("formationDisplaySelectors", () => { expect(state.hasRuntimeFormation).toBe(true); expect(state.hint).toContain("当前任务的分工已经准备好"); expect(state.summaryBadges.map((badge) => badge.text)).toEqual([ - "任务方案 · 修复 Team", + "任务方案 · 修复任务方案", "已就绪", - "1 位当前成员", + "1 项当前任务", "参考方案 · 代码排障团队", ]); expect(state.panelHeadline).toBe("任务分工已准备好"); @@ -81,6 +81,8 @@ describe("formationDisplaySelectors", () => { summary: "先定位问题与影响面。", }); expect(state.noticeText).toContain("当前任务方案已就绪"); + expect(state.noticeText).toContain("任务拆出后"); + expect(state.noticeText).toContain("任务视图"); }); it("runtime formation 失败时,应优先使用失败原因", () => { @@ -97,8 +99,8 @@ describe("formationDisplaySelectors", () => { }, }); - expect(state.panelDescription).toBe("Provider 认证失败,无法生成 Team。"); - expect(state.emptyDetail).toBe("Provider 认证失败,无法生成 Team。"); - expect(state.noticeText).toBe("Provider 认证失败,无法生成 Team。"); + expect(state.panelDescription).toBe("Provider 认证失败,无法生成任务方案。"); + expect(state.emptyDetail).toBe("Provider 认证失败,无法生成任务方案。"); + expect(state.noticeText).toBe("Provider 认证失败,无法生成任务方案。"); }); }); diff --git a/src/components/agent/chat/team-workspace-runtime/formationDisplaySelectors.ts b/src/components/agent/chat/team-workspace-runtime/formationDisplaySelectors.ts index 77823762d..95ef45a50 100644 --- a/src/components/agent/chat/team-workspace-runtime/formationDisplaySelectors.ts +++ b/src/components/agent/chat/team-workspace-runtime/formationDisplaySelectors.ts @@ -1,4 +1,5 @@ import type { TeamRoleDefinition } from "../utils/teamDefinitions"; +import { normalizeTeamWorkspaceDisplayValue } from "../utils/teamWorkspaceDisplay"; import { TEAM_WORKSPACE_IDLE_STATUS_LABEL, TEAM_WORKSPACE_PLAN_LABEL, @@ -60,31 +61,32 @@ export function buildRuntimeFormationHint( ) { switch (teamDispatchPreviewState?.status) { case "forming": - return "系统正在准备当前任务的分工,成员接入后会自动开始处理。"; + return "系统正在准备当前任务的分工,任务拆出后会自动开始处理。"; case "formed": - return "当前任务的分工已经准备好,成员加入后会继续接手处理。"; + return "当前任务的分工已经准备好,任务拆出后会继续接手处理。"; case "failed": - return "当前任务的分工准备失败,但你仍然可以继续在当前对话里推进。"; + return "当前任务的分工准备失败,但你仍然可以继续在当前任务里推进。"; default: - return "需要时这里会自动展开成任务协作面板。"; + return "需要时这里会自动展开成任务面板。"; } } export function buildRuntimeFormationEmptyDetail( teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null, ) { + const errorMessage = normalizeTeamWorkspaceDisplayValue( + teamDispatchPreviewState?.errorMessage, + ); + switch (teamDispatchPreviewState?.status) { case "forming": - return "系统正在根据当前任务准备分工。完成后,这里会先展示当前成员卡片,再接入真实处理进展。"; + return "系统正在根据当前任务准备分工。完成后,这里会先展示任务卡片,再接入真实处理进展。"; case "formed": - return "当前任务方案已经准备好。画布会先展示当前分工,等成员真正开始处理后,再自动切换为实时进展。"; + return "当前任务方案已经准备好。画布会先展示当前分工,等任务真正开始处理后,再自动切换为任务视图。"; case "failed": - return ( - teamDispatchPreviewState.errorMessage?.trim() || - "当前任务分工准备失败,暂时无法展示当前成员。" - ); + return errorMessage || "当前任务分工准备失败,暂时无法展示当前任务。"; default: - return `${TEAM_WORKSPACE_IDLE_STATUS_LABEL}。系统开始分工后,详情区会切换为成员摘要视图。`; + return `${TEAM_WORKSPACE_IDLE_STATUS_LABEL}。系统开始分工后,详情区会切换为任务摘要视图。`; } } @@ -93,13 +95,19 @@ export function buildSelectedTeamPlanDisplayState(params: { selectedTeamSummary?: string | null; selectedTeamRoles?: TeamRoleDefinition[] | null; }): TeamWorkspaceSelectedTeamPlanDisplayState { - const label = params.selectedTeamLabel?.trim() || null; - const summary = params.selectedTeamSummary?.trim() || null; + const label = normalizeTeamWorkspaceDisplayValue(params.selectedTeamLabel); + const summary = normalizeTeamWorkspaceDisplayValue(params.selectedTeamSummary); const roleCards = (params.selectedTeamRoles ?? []) - .filter((role) => role.label.trim()) .map((role) => ({ id: role.id, - label: role.label, + label: normalizeTeamWorkspaceDisplayValue(role.label), + summary: + normalizeTeamWorkspaceDisplayValue(role.summary) || role.summary.trim(), + })) + .filter((role) => Boolean(role.label)) + .map((role) => ({ + id: role.id, + label: role.label || "", summary: role.summary, })); @@ -141,41 +149,41 @@ export function buildRuntimeFormationDisplayState(params: { }): TeamWorkspaceRuntimeFormationDisplayState { const state = params.teamDispatchPreviewState ?? null; const meta = state ? resolveRuntimeFormationStatusMeta(state.status) : null; - const label = - state?.label?.trim() || - state?.blueprint?.label?.trim() || - params.fallbackLabel?.trim() || - null; - const summary = - state?.summary?.trim() || - state?.blueprint?.summary?.trim() || - params.fallbackSummary?.trim() || - null; + const label = normalizeTeamWorkspaceDisplayValue( + state?.label || state?.blueprint?.label || params.fallbackLabel, + ); + const summary = normalizeTeamWorkspaceDisplayValue( + state?.summary || state?.blueprint?.summary || params.fallbackSummary, + ); + const errorMessage = normalizeTeamWorkspaceDisplayValue(state?.errorMessage); + const referenceLabel = normalizeTeamWorkspaceDisplayValue( + state?.blueprint?.label, + ); const memberCards = (state?.members ?? []).map((member) => { const memberMeta = resolveRuntimeMemberStatusMeta(member.status); return { id: member.id, - label: member.label, - summary: member.summary, + label: normalizeTeamWorkspaceDisplayValue(member.label) || member.label, + summary: + normalizeTeamWorkspaceDisplayValue(member.summary) || member.summary, badgeLabel: memberMeta.label, badgeClassName: memberMeta.badgeClassName, }; }); const blueprintRoleCards = (state?.blueprint?.roles ?? []).map((role) => ({ id: role.id, - label: role.label, - summary: role.summary, + label: normalizeTeamWorkspaceDisplayValue(role.label) || role.label, + summary: normalizeTeamWorkspaceDisplayValue(role.summary) || role.summary, })); const noticeText = state?.status === "forming" - ? "系统正在准备当前任务分工,完成后会先展示成员卡片,后续再切换为独立的实时进展面板。" + ? "系统正在准备当前任务分工,完成后会先展示任务卡片,后续再切换为独立的任务视图。" : state?.status === "formed" - ? "当前任务方案已就绪。系统开始分工后,这里会从方案视图过渡到实时协作画布。" + ? "当前任务方案已就绪。任务拆出后,这里会从方案视图过渡到任务视图。" : state?.status === "failed" - ? state.errorMessage?.trim() || - "当前任务分工准备失败,暂时还没有成员接入。" - : `${TEAM_WORKSPACE_IDLE_STATUS_LABEL}。系统开始分工后,这里会生成独立的成员进展画布。`; + ? errorMessage || "当前任务分工准备失败,暂时还没有任务接手。" + : `${TEAM_WORKSPACE_IDLE_STATUS_LABEL}。系统开始分工后,这里会生成独立的任务视图。`; return { hasRuntimeFormation: Boolean(state), @@ -206,17 +214,17 @@ export function buildRuntimeFormationDisplayState(params: { ? [ { key: "runtime-member-count", - text: `${memberCards.length} 位当前成员`, + text: `${memberCards.length} 项当前任务`, className: "rounded-full border border-slate-200 bg-white px-2.5 py-1", }, ] : []), - ...(state?.blueprint?.label + ...(referenceLabel ? [ { key: "runtime-blueprint-label", - text: `参考方案 · ${state.blueprint.label}`, + text: `参考方案 · ${referenceLabel}`, className: "rounded-full border border-slate-200 bg-white px-2.5 py-1", }, @@ -230,9 +238,9 @@ export function buildRuntimeFormationDisplayState(params: { panelHeadline: meta?.title || TEAM_WORKSPACE_WAITING_HEADLINE, panelDescription: state?.status === "failed" - ? state.errorMessage?.trim() || "当前任务分工准备失败,暂时无法展示更多内容。" - : summary || "这里会先展示当前任务方案,成员接入后再切换成实时进展。", - referenceLabel: state?.blueprint?.label?.trim() || null, + ? errorMessage || "当前任务分工准备失败,暂时无法展示更多内容。" + : summary || "这里会先展示当前任务方案,任务拆出后再切换成任务视图。", + referenceLabel, memberCards, blueprintRoleCards, }; diff --git a/src/components/agent/chat/team-workspace-runtime/liveRuntimeProjector.test.ts b/src/components/agent/chat/team-workspace-runtime/liveRuntimeProjector.test.ts index 4340efe0a..6177f8bab 100644 --- a/src/components/agent/chat/team-workspace-runtime/liveRuntimeProjector.test.ts +++ b/src/components/agent/chat/team-workspace-runtime/liveRuntimeProjector.test.ts @@ -121,4 +121,37 @@ describe("liveRuntimeProjector", () => { retryableOverload: true, }); }); + + it("queue 与 turn 生命周期提示应使用子任务口径", () => { + const queuedProjection = projectRuntimeStreamEvent({ + sessionId: "child-1", + session: createSessionSnapshot(), + event: { + type: "queue_added", + } as AgentEvent, + }); + + const startedProjection = projectRuntimeStreamEvent({ + sessionId: "child-1", + session: createSessionSnapshot(), + event: { + type: "queue_started", + } as AgentEvent, + }); + + const turnProjection = projectRuntimeStreamEvent({ + sessionId: "child-1", + session: createSessionSnapshot({ + runtimeStatus: "running", + latestTurnStatus: "running", + }), + event: { + type: "turn_started", + } as AgentEvent, + }); + + expect(queuedProjection?.entry?.detail).toContain("这项子任务会在前一项完成后继续处理"); + expect(startedProjection?.entry?.detail).toContain("这项子任务已经开始处理当前任务"); + expect(turnProjection?.entry?.detail).toContain("这项子任务正在推进当前内容"); + }); }); diff --git a/src/components/agent/chat/team-workspace-runtime/liveRuntimeProjector.ts b/src/components/agent/chat/team-workspace-runtime/liveRuntimeProjector.ts index f5e439c24..932db2194 100644 --- a/src/components/agent/chat/team-workspace-runtime/liveRuntimeProjector.ts +++ b/src/components/agent/chat/team-workspace-runtime/liveRuntimeProjector.ts @@ -584,7 +584,7 @@ export function projectRuntimeStreamEvent(params: { sessionId, key: "queue", title: "稍后开始", - detail: "新的说明已经收到,这位协作成员会在前一项完成后继续处理。", + detail: "新的说明已经收到,这项子任务会在前一项完成后继续处理。", statusLabel: resolveTeamWorkspaceDisplayRuntimeStatusLabel("queued"), badgeClassName: QUEUED_BADGE_CLASS_NAME, }), @@ -600,7 +600,7 @@ export function projectRuntimeStreamEvent(params: { sessionId, key: "queue", title: "开始处理", - detail: "这位协作成员已经开始处理当前任务。", + detail: "这项子任务已经开始处理当前任务。", statusLabel: resolveTeamWorkspaceDisplayRuntimeStatusLabel("running"), badgeClassName: IN_PROGRESS_BADGE_CLASS_NAME, }), @@ -646,7 +646,7 @@ export function projectRuntimeStreamEvent(params: { sessionId, key: "turn", title: "继续处理", - detail: "这位协作成员正在推进当前内容。", + detail: "这项子任务正在推进当前内容。", statusLabel: resolveTeamWorkspaceDisplayRuntimeStatusLabel("running"), badgeClassName: IN_PROGRESS_BADGE_CLASS_NAME, }), diff --git a/src/components/agent/chat/team-workspace-runtime/selectedSessionDetailSelectors.test.ts b/src/components/agent/chat/team-workspace-runtime/selectedSessionDetailSelectors.test.ts index 0f6481567..da0b2aa75 100644 --- a/src/components/agent/chat/team-workspace-runtime/selectedSessionDetailSelectors.test.ts +++ b/src/components/agent/chat/team-workspace-runtime/selectedSessionDetailSelectors.test.ts @@ -46,7 +46,7 @@ describe("selectedSessionDetailSelectors", () => { expect(state.queueReason).toBe("等待上游并发窗口。"); expect(state.metadata).toEqual([ "分工 执行", - "协作成员", + "子任务", "服务 OpenAI", "模型 gpt-5.4", "来源 spawn_agent", diff --git a/src/components/agent/chat/team-workspace-runtime/teamOperationSelectors.test.ts b/src/components/agent/chat/team-workspace-runtime/teamOperationSelectors.test.ts index 3423a04f1..7dcc66d56 100644 --- a/src/components/agent/chat/team-workspace-runtime/teamOperationSelectors.test.ts +++ b/src/components/agent/chat/team-workspace-runtime/teamOperationSelectors.test.ts @@ -80,6 +80,10 @@ describe("teamOperationSelectors", () => { "control-resume-1710000200000", "wait-1710000100000", ]); + expect(entries[0]?.detail).toBe("刚才已继续 研究员 的处理。"); + expect(entries[1]?.detail).toBe( + "刚才等待结果时超时了,还有 1 项任务仍在处理中。", + ); expect(formatOperationUpdatedAt()).toBe("刚刚"); }); diff --git a/src/components/agent/chat/team-workspace-runtime/teamOperationSelectors.ts b/src/components/agent/chat/team-workspace-runtime/teamOperationSelectors.ts index 0f29d8158..938595fef 100644 --- a/src/components/agent/chat/team-workspace-runtime/teamOperationSelectors.ts +++ b/src/components/agent/chat/team-workspace-runtime/teamOperationSelectors.ts @@ -55,7 +55,7 @@ function buildTeamWaitSummaryDisplay(params: { }) { if (params.summary.timedOut) { return { - text: `刚才等待结果时超时了,还有 ${params.summary.awaitedSessionIds.length} 位协作成员仍在处理中。`, + text: `刚才等待结果时超时了,还有 ${params.summary.awaitedSessionIds.length} 项任务仍在处理中。`, badgeClassName: "border border-amber-200 bg-amber-50 text-amber-700", }; } @@ -63,7 +63,7 @@ function buildTeamWaitSummaryDisplay(params: { const resolvedName = params.summary.resolvedSessionId ? (params.sessionNameById.get(params.summary.resolvedSessionId) ?? params.summary.resolvedSessionId) - : "成员"; + : "这项任务"; const normalizedStatus = params.summary.resolvedStatus ? normalizeTeamWorkspaceRuntimeStatus(params.summary.resolvedStatus) : undefined; @@ -82,20 +82,20 @@ function buildTeamControlSummaryDisplay(params: { const firstAffectedId = params.summary.affectedSessionIds[0]; const firstAffectedName = firstAffectedId ? (params.sessionNameById.get(firstAffectedId) ?? firstAffectedId) - : "成员"; + : "这项任务"; switch (params.summary.action) { case "resume": return { text: affectedCount > 1 - ? `刚才已继续 ${affectedCount} 位协作成员的处理。` + ? `刚才已继续 ${affectedCount} 项任务的处理。` : `刚才已继续 ${firstAffectedName} 的处理。`, badgeClassName: "border border-sky-200 bg-sky-50 text-sky-700", }; case "close_completed": return { - text: `刚才已收起 ${affectedCount} 位已完成成员。`, + text: `刚才已收起 ${affectedCount} 项已完成任务。`, badgeClassName: "border border-slate-200 bg-slate-100 text-slate-700", }; case "close": @@ -103,7 +103,7 @@ function buildTeamControlSummaryDisplay(params: { return { text: affectedCount > 1 - ? `刚才已暂停 ${affectedCount} 位协作成员的处理。` + ? `刚才已暂停 ${affectedCount} 项任务的处理。` : `刚才已暂停 ${firstAffectedName} 的处理。`, badgeClassName: "border border-slate-200 bg-slate-100 text-slate-700", }; diff --git a/src/components/agent/chat/teamWorkspaceRuntime.ts b/src/components/agent/chat/teamWorkspaceRuntime.ts index 92dce4a51..f2eea6f96 100644 --- a/src/components/agent/chat/teamWorkspaceRuntime.ts +++ b/src/components/agent/chat/teamWorkspaceRuntime.ts @@ -367,7 +367,7 @@ function resolveItemActivityDescriptor(item: AgentThreadItem): { }; case "subagent_activity": return { - title: "协作进展", + title: "任务进展", detail: normalizeActivityText( item.summary || item.title || item.status_label, ), @@ -418,7 +418,7 @@ function resolveExecutionSummaryStatusTitle(params: { if (runningSessionCount > 0) { if (queuedSessionCount > 0) { return totalSessionCount > 1 - ? `任务进行中 · ${runningSessionCount} 位处理中 / ${queuedSessionCount} 位稍后开始` + ? `任务进行中 · ${runningSessionCount} 项处理中 / ${queuedSessionCount} 项稍后开始` : "任务进行中"; } return totalSessionCount > 1 @@ -569,7 +569,7 @@ export function buildStatusEventActivityEntry( return { id: `status-${sessionId}-${normalizedStatus}-${Date.now()}`, title: "状态切换", - detail: `收到 team 状态事件,已切换为${statusMeta.label}。`, + detail: `收到任务状态事件,已切换为${statusMeta.label}。`, statusLabel: statusMeta.label, badgeClassName: statusMeta.badgeClassName, }; diff --git a/src/components/agent/chat/types.ts b/src/components/agent/chat/types.ts index 98d8c0fb6..fcd81653d 100644 --- a/src/components/agent/chat/types.ts +++ b/src/components/agent/chat/types.ts @@ -141,7 +141,7 @@ export interface SiteSavedContentTarget { export type PendingA2UISource = | { - kind: "assistant_message" | "legacy_message"; + kind: "assistant_message"; messageId: string; } | { @@ -171,7 +171,7 @@ export interface ActionRequiredScope { export interface ActionRequestGovernanceMeta { strategy: "single_turn_single_question"; - source: "runtime_action_required" | "legacy_questionnaire"; + source: "runtime_action_required"; originalQuestionCount?: number; originalFieldCount?: number; originalSectionCount?: number; diff --git a/src/components/agent/chat/utils/agentThreadGrouping.ts b/src/components/agent/chat/utils/agentThreadGrouping.ts index 260afc802..253347e50 100644 --- a/src/components/agent/chat/utils/agentThreadGrouping.ts +++ b/src/components/agent/chat/utils/agentThreadGrouping.ts @@ -372,7 +372,7 @@ function resolveGroupTitle( case "artifact": return "文件和产物"; case "subagent": - return "协作成员"; + return "子任务"; default: return "执行过程"; } @@ -569,15 +569,22 @@ function summarizeCollaborationItem(item: AgentThreadItem): string | null { "taskType", "role", "agent_type", - ]) || resolveToolDisplayLabel(item.tool_name), - "分给协作成员处理 ", - ["分给协作成员", "协作中 ", "邀请 ", "已邀请 "], + ]) || "子任务", + "分给子任务处理 ", + [ + "分给子任务", + "子任务处理中 ", + "分给协作成员", + "协作中 ", + "邀请 ", + "已邀请 ", + ], ); } if (normalized === "sendmessage") { return prefixAction( - readString(args, ["id", "agent_id", "message"]) || "目标协作成员", + readString(args, ["id", "agent_id", "message"]) || "目标子任务", "补充说明 ", ["补充说明 ", "已补充说明 ", "发送给 "], ); @@ -631,9 +638,9 @@ function summarizeSubagentItem(item: AgentThreadItem): string | null { resolveInternalImageTaskDisplayName(item.title) || item.summary || item.status_label || - "协作任务", - "分给协作成员处理 ", - ["分给协作成员", "协作成员"], + "子任务", + "分给子任务处理 ", + ["分给子任务", "子任务", "分给协作成员", "协作成员"], ); } @@ -880,7 +887,7 @@ export function buildAgentThreadDisplayModel( : current.kind === "artifact" ? "查看产物" : current.kind === "subagent" - ? "查看协作详情" + ? "查看子任务详情" : "查看执行过程", defaultExpanded: shouldDefaultExpand(current.kind, status), startedAt, diff --git a/src/components/agent/chat/utils/compatQuestionnaireA2UI.test.ts b/src/components/agent/chat/utils/compatQuestionnaireA2UI.test.ts deleted file mode 100644 index 2ba0fec1f..000000000 --- a/src/components/agent/chat/utils/compatQuestionnaireA2UI.test.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { - A2UIComponent, - ChoicePickerComponent, - TextFieldComponent, -} from "@/lib/workspace/a2ui"; - -import { - buildCompatQuestionnaireSubmissionPayload, - buildCompatQuestionnaireA2UI, - formatCompatQuestionnaireSubmission, -} from "./compatQuestionnaireA2UI"; - -const LEGACY_QUESTIONNAIRE = `为了继续推进,我需要你先补充以下信息: - -1. 目标与对象 -- 这次内容主要面向谁?(客户 / 上级 / 同事) -- 这次最想达成的目标是什么? - -2. 风格与限制 -- 语气偏好:正式严谨 / 友好专业 / 直接高效 -- 是否需要加入明确行动号召?`; - -const COMPAT_ASK_USER_QUESTIONNAIRE = `我注意到您想让我做“网页研究简报”,但您没有指定具体的研究主题。 - -我注意到您想让我做“网页研究简报”,但您没有指定具体的研究主题。 - -在我开始之前,需要先明确几个问题: - -askquestionarg_value>请提供您希望我研究的具体主题。这可以是: - -- 一个行业或领域(如“生成式 AI 在医疗领域的应用”) -- 一个产品或服务(如“Claude API vs 竞品对比”) -- 一个公司或组织(如“某公司的最新动态”) -- 一个技术或概念(如“WebAssembly 的新进展”) -- 其他您关心的主题 - -另外,请告诉我该研究的主要目的是什么?`; - -const PLAIN_ASK_USER_QUESTIONNAIRE = `我需要先明确一下:您希望我研究哪个主题?您的消息中提到了“围绕这个主题”,但没有具体说明主题内容。请告诉我: - -- 具体的研究主题(例如:某个产品、技术、公司、市场趋势、政策、事件等) -- 研究目的(例如:投资决策、技术选型、竞争分析、学习了解等) -- 是否有特定关注点(例如:风险、机会、对比、最新动态等) - -一旦您明确了主题,我会: -1. 使用联网搜索获取最新信息 -2. 整理关键来源和核心发现 -3. 识别风险点和待追踪问题 -4. 输出一版结构化的研究简报`; - -const MARKDOWN_PLAIN_ASK_USER_QUESTIONNAIRE = `# 继续推进前,请先补充信息 - -请告诉我: - -- **具体的研究主题**(例如:**A2UI 工作流** / **Chrome 连接器**) -- **研究目的**(例如:**技术选型** / **方案对比**) -- **是否有特定关注点**(例如:**风险** / **迁移成本**) - -明确后我会继续整理。`; - -type LegacyQuestionFieldComponent = ( - | ChoicePickerComponent - | TextFieldComponent -) & { - label: string; -}; - -function isLegacyQuestionFieldComponent( - component: A2UIComponent, -): component is LegacyQuestionFieldComponent { - return ( - (component.component === "ChoicePicker" || - component.component === "TextField") && - typeof component.label === "string" - ); -} - -describe("compatQuestionnaireA2UI", () => { - it("应将结构化问卷正文转换为输入区可渲染的 A2UI 表单", () => { - const response = buildCompatQuestionnaireA2UI(LEGACY_QUESTIONNAIRE); - - expect(response).not.toBeNull(); - expect(response?.submitAction?.label).toBe("确认并继续"); - expect(response?.data).toMatchObject({ - source: "legacy_questionnaire", - sectionCount: 1, - questionCount: 1, - governance: { - originalSectionCount: 2, - originalQuestionCount: 4, - deferredQuestionCount: 3, - }, - }); - expect( - response?.components.some((component) => component.component === "Card"), - ).toBe(true); - expect( - response?.components.some( - (component) => - component.component === "ChoicePicker" && - "label" in component && - component.label === "这次内容主要面向谁?", - ), - ).toBe(true); - expect(response?.components).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - label: "这次最想达成的目标是什么?", - }), - expect.objectContaining({ - label: "语气偏好", - }), - ]), - ); - }); - - it("普通回复或内联 a2ui 不应被误识别为 legacy 表单", () => { - expect( - buildCompatQuestionnaireA2UI("你可以先给我一个主题,我再继续展开。"), - ).toBeNull(); - expect( - buildCompatQuestionnaireA2UI('{"type":"form"}'), - ).toBeNull(); - }); - - it("应兼容 ask/tool_calls 残留问卷正文并转换为可渲染的 A2UI 表单", () => { - const response = buildCompatQuestionnaireA2UI( - COMPAT_ASK_USER_QUESTIONNAIRE, - ); - - expect(response).not.toBeNull(); - expect(response?.data).toMatchObject({ - source: "legacy_questionnaire", - sectionCount: 1, - questionCount: 1, - governance: { - originalQuestionCount: 2, - deferredQuestionCount: 1, - }, - }); - - const fieldComponents = response?.components.filter( - isLegacyQuestionFieldComponent, - ); - expect(fieldComponents).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - component: "TextField", - label: "请提供您希望我研究的具体主题", - helperText: expect.stringContaining("生成式 AI 在医疗领域的应用"), - }), - ]), - ); - expect(fieldComponents).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - label: "请告诉我该研究的主要目的是什么?", - }), - ]), - ); - }); - - it("应将普通中文澄清问题提升为输入区可渲染的 A2UI 表单", () => { - const response = buildCompatQuestionnaireA2UI(PLAIN_ASK_USER_QUESTIONNAIRE); - - expect(response).not.toBeNull(); - expect(response?.data).toMatchObject({ - source: "legacy_questionnaire", - sectionCount: 1, - questionCount: 1, - governance: { - originalQuestionCount: 3, - deferredQuestionCount: 2, - }, - }); - - const fieldComponents = response?.components.filter( - isLegacyQuestionFieldComponent, - ); - expect(fieldComponents).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - component: "TextField", - label: "具体的研究主题", - helperText: "例如:某个产品、技术、公司、市场趋势、政策、事件等", - }), - ]), - ); - expect(fieldComponents).toHaveLength(1); - }); - - it("应清洗字段中的 markdown 语法,并保留说明文本的段落结构", () => { - const response = buildCompatQuestionnaireA2UI( - MARKDOWN_PLAIN_ASK_USER_QUESTIONNAIRE, - ); - - expect(response).not.toBeNull(); - - const introTextComponent = response?.components.find( - (component) => - component.component === "Text" && - typeof component.id === "string" && - component.id.includes("_intro"), - ); - - expect(introTextComponent).toMatchObject({ - component: "Text", - text: "# 继续推进前,请先补充信息\n\n请告诉我:", - }); - - const fieldComponents = response?.components.filter( - isLegacyQuestionFieldComponent, - ); - expect(fieldComponents).toEqual([ - expect.objectContaining({ - component: "TextField", - label: "具体的研究主题", - helperText: "例如:A2UI 工作流 / Chrome 连接器", - }), - ]); - }); - - it("应将 legacy 表单回答格式化为带字段标签的用户摘要", () => { - const response = buildCompatQuestionnaireA2UI(LEGACY_QUESTIONNAIRE); - if (!response) { - throw new Error("预期应生成 legacy questionnaire A2UI"); - } - - const componentsByLabel = Object.fromEntries( - response.components - .filter(isLegacyQuestionFieldComponent) - .map((component) => [component.label, component.id]), - ); - - const submission = formatCompatQuestionnaireSubmission(response, { - [componentsByLabel["这次内容主要面向谁?"]]: ["客户"], - }); - - expect(submission).toBe(`我的选择: -- 这次内容主要面向谁?: 客户`); - }); - - it("应生成可传给运行时的结构化 elicitation_context 元数据", () => { - const response = buildCompatQuestionnaireA2UI(LEGACY_QUESTIONNAIRE); - if (!response) { - throw new Error("预期应生成 legacy questionnaire A2UI"); - } - - const componentsByLabel = Object.fromEntries( - response.components - .filter(isLegacyQuestionFieldComponent) - .map((component) => [component.label, component.id]), - ); - - const payload = buildCompatQuestionnaireSubmissionPayload(response, { - [componentsByLabel["这次内容主要面向谁?"]]: ["客户"], - }); - - expect(payload).toMatchObject({ - userData: { - "这次内容主要面向谁?": "客户", - }, - requestMetadata: { - elicitation_context: { - source: "legacy_questionnaire", - mode: "compatibility_bridge", - section_count: 1, - question_count: 1, - governance: { - originalSectionCount: 2, - originalQuestionCount: 4, - deferredQuestionCount: 3, - }, - entries: expect.arrayContaining([ - expect.objectContaining({ - label: "这次内容主要面向谁?", - value: "客户", - summary: "客户", - }), - ]), - }, - }, - }); - }); -}); diff --git a/src/components/agent/chat/utils/compatQuestionnaireA2UI.ts b/src/components/agent/chat/utils/compatQuestionnaireA2UI.ts deleted file mode 100644 index 57719d6b9..000000000 --- a/src/components/agent/chat/utils/compatQuestionnaireA2UI.ts +++ /dev/null @@ -1,1072 +0,0 @@ -import type { - A2UIComponent, - A2UIFormData, - A2UIResponse, -} from "@/lib/workspace/a2ui"; -import type { ActionRequestGovernanceMeta } from "../types"; - -export interface LegacyQuestionnaireSubmissionEntry { - fieldId: string; - label: string; - value: string | string[] | number | boolean; - summary: string; -} - -export interface LegacyQuestionnaireSubmissionPayload { - formattedMessage: string; - entries: LegacyQuestionnaireSubmissionEntry[]; - userData: Record; - requestMetadata: { - elicitation_context: { - source: "legacy_questionnaire"; - mode: "compatibility_bridge"; - form_id: string; - section_count?: number; - question_count?: number; - entries: LegacyQuestionnaireSubmissionEntry[]; - governance?: ActionRequestGovernanceMeta; - }; - }; -} - -interface LegacyQuestionnaireSection { - title: string; - questions: LegacyQuestionnaireQuestion[]; -} - -interface LegacyQuestionnaireQuestion { - id: string; - label: string; - options?: string[]; - multiSelect?: boolean; - helperText?: string; -} - -interface BuiltQuestionComponentResult { - component: A2UIComponent; - isCompact: boolean; -} - -interface GovernedLegacyQuestionnaire { - questionnaire: { - introText: string; - sections: LegacyQuestionnaireSection[]; - questionCount: number; - }; - governance?: ActionRequestGovernanceMeta; -} - -const INLINE_A2UI_REGEX = /|```\s*a2ui\b/i; -const SECTION_REGEX = /^\s*\d+[.、)]\s*(.+?)\s*$/; -const BULLET_REGEX = /^\s*[-*•●]\s+(.+?)\s*$/; -const OPTION_SPLIT_REGEX = /\s*(?:\/|/|\||||,|,|、)\s*/; -const COMPAT_ASK_MARKER_REGEX = - /(?:ask\s*)?\s*question\s*<\/arg_key>\s*(?:\s*arg_value>?|)?/i; -const COMPAT_ASK_END_REGEX = /<\/(?:arg_value|tool_calls)>/i; -const COMPAT_ASK_TAG_REGEX = /<\/?(?:tool_calls|arg_key|arg_value)[^>]*>/gi; -const COMPAT_ASK_FOLLOWUP_PREFIX_REGEX = /^(?:另外|此外|同时|并且)[,,\s]*/; -const PLAIN_ASK_INTENT_REGEX = - /(?:需要先明确|需要先确认|需要补充|请告诉我|请提供|补充以下信息|明确几个问题|在我开始之前|为了继续推进|麻烦补充|说明一下|提供一下)/; -const PLAIN_ASK_FOLLOWUP_REGEX = - /^(?:一旦|明确后|确认后|收到后|然后|接下来|之后|后续)\b/; -const PLAIN_ASK_INLINE_FOLLOWUP_REGEX = - /\s*(?=(?:一旦|明确后|确认后|收到后|然后|接下来|之后|后续).*(?:我会|我们会|开始|继续|整理|输出))/; -const LONG_TEXT_QUESTION_REGEX = - /(?:说明|描述|展开|补充|介绍|背景|现状|原因|为什么|建议|判断|分析|误区|问题|挑战|计划|预期|担忧|详细)/; - -function governLegacyQuestionnaire(questionnaire: { - introText: string; - sections: LegacyQuestionnaireSection[]; - questionCount: number; -}): GovernedLegacyQuestionnaire { - if (questionnaire.questionCount <= 1) { - return { - questionnaire, - }; - } - - const firstSectionIndex = questionnaire.sections.findIndex( - (section) => section.questions.length > 0, - ); - if (firstSectionIndex < 0) { - return { - questionnaire, - }; - } - - const firstSection = questionnaire.sections[firstSectionIndex]; - - return { - questionnaire: { - introText: questionnaire.introText, - sections: [ - { - title: firstSection.title, - questions: [firstSection.questions[0]], - }, - ], - questionCount: 1, - }, - governance: { - strategy: "single_turn_single_question", - source: "legacy_questionnaire", - originalSectionCount: questionnaire.sections.length, - originalQuestionCount: questionnaire.questionCount, - retainedSectionIndex: firstSectionIndex, - retainedQuestionIndex: 0, - deferredQuestionCount: questionnaire.questionCount - 1, - }, - }; -} - -function normalizeLine(value: string): string { - return value.replace(/\s+/g, " ").trim(); -} - -function stripMarkdownSyntax(value: string): string { - return value - .replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1") - .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") - .replace(/`{1,3}([^`]+)`{1,3}/g, "$1") - .replace(/^\s{0,3}#{1,6}\s+/gm, "") - .replace(/^\s*>\s?/gm, "") - .replace(/\*\*(.+?)\*\*/g, "$1") - .replace(/__(.+?)__/g, "$1") - .replace(/\*(.+?)\*/g, "$1") - .replace(/_(.+?)_/g, "$1") - .replace(/~~(.+?)~~/g, "$1"); -} - -function normalizePlainTextSurface(value: string): string { - return normalizeLine(stripMarkdownSyntax(value)); -} - -function joinMarkdownBlocks(lines: string[]): string { - return uniqueNormalizedLines(lines).join("\n\n"); -} - -function buildStableHash(value: string): string { - let hash = 0; - for (let i = 0; i < value.length; i += 1) { - hash = (hash * 31 + value.charCodeAt(i)) >>> 0; - } - return hash.toString(16); -} - -function uniqueOptions(options: string[]): string[] { - const seen = new Set(); - return options.filter((option) => { - const normalized = normalizePlainTextSurface(option); - if (!normalized || seen.has(normalized)) { - return false; - } - seen.add(normalized); - return true; - }); -} - -function splitOptions(value: string): string[] { - return uniqueOptions( - value - .split(OPTION_SPLIT_REGEX) - .map((item) => normalizePlainTextSurface(item)) - .filter(Boolean), - ); -} - -function stripTrailingPunctuation(value: string): string { - return value.replace(/[::;;,,。\s]+$/g, "").trim(); -} - -function uniqueNormalizedLines(lines: string[]): string[] { - const deduped: string[] = []; - - for (const rawLine of lines) { - const normalized = normalizeLine(rawLine); - if (!normalized || deduped[deduped.length - 1] === normalized) { - continue; - } - deduped.push(normalized); - } - - return deduped; -} - -function buildCompatAskHelperText(hints: string[]): string | undefined { - const normalizedHints = uniqueOptions(hints); - if (normalizedHints.length === 0) { - return undefined; - } - - return `例如:${normalizedHints.slice(0, 5).join(";")}`; -} - -function normalizeCompatAskQuestionLabel(value: string): string { - return stripTrailingPunctuation( - normalizePlainTextSurface(value).replace( - COMPAT_ASK_FOLLOWUP_PREFIX_REGEX, - "", - ), - ); -} - -function parseExampleHelperText(value: string): string | undefined { - const normalized = normalizePlainTextSurface(value); - if (!normalized) { - return undefined; - } - - if (/^(?:例如|比如|如)[::]/.test(normalized)) { - return normalized; - } - - if (/^(?:例如|比如|如)\b/.test(normalized)) { - return normalized.replace(/^(例如|比如|如)\s*/, "$1:"); - } - - if (/(?:例如|比如|如)/.test(normalized)) { - return normalized; - } - - return undefined; -} - -function buildPlainAskQuestion( - rawQuestion: string, -): LegacyQuestionnaireQuestion | null { - const normalizedQuestion = normalizeLine(rawQuestion); - if (!normalizedQuestion) { - return null; - } - - const parentheticalMatch = normalizedQuestion.match( - /^(.*?)(?:(([^)]+))|\(([^)]+)\))\s*$/, - ); - if (parentheticalMatch) { - const label = stripTrailingPunctuation( - normalizePlainTextSurface(parentheticalMatch[1]), - ); - const helperBlock = parentheticalMatch[2] || parentheticalMatch[3] || ""; - const helperText = parseExampleHelperText(helperBlock); - - if (label && helperText) { - return { - id: "", - label, - helperText, - }; - } - - const options = splitOptions(helperBlock); - if (label && options.length >= 2) { - return { - id: "", - label, - options, - multiSelect: /多选/.test(normalizedQuestion), - }; - } - } - - const colonMatch = normalizedQuestion.match(/^(.*?)[::]\s*(.+)$/); - if (colonMatch) { - const label = stripTrailingPunctuation( - normalizePlainTextSurface(colonMatch[1]), - ); - const helperText = parseExampleHelperText(colonMatch[2]); - if (label && helperText) { - return { - id: "", - label, - helperText, - }; - } - - const options = splitOptions(colonMatch[2]); - if (label && options.length >= 2) { - return { - id: "", - label, - options, - multiSelect: /多选/.test(normalizedQuestion), - }; - } - } - - if ( - /^是否/.test(normalizedQuestion) && - !/(?:例如|比如|如)/.test(normalizedQuestion) - ) { - return { - id: "", - label: stripTrailingPunctuation( - normalizePlainTextSurface(normalizedQuestion), - ), - options: ["是", "否"], - multiSelect: false, - }; - } - - return { - id: "", - label: - stripTrailingPunctuation(normalizePlainTextSurface(normalizedQuestion)) || - normalizePlainTextSurface(normalizedQuestion), - }; -} - -function parsePlainAskQuestionnaire(content: string): { - introText: string; - sections: LegacyQuestionnaireSection[]; - questionCount: number; -} | null { - if (COMPAT_ASK_MARKER_REGEX.test(content)) { - return null; - } - - if (!PLAIN_ASK_INTENT_REGEX.test(content)) { - return null; - } - - const lines = content.replace(/\r\n/g, "\n").split("\n"); - const introLines: string[] = []; - const questions: LegacyQuestionnaireQuestion[] = []; - const seenLabels = new Set(); - - for (const rawLine of lines) { - let line = normalizeLine(rawLine); - if (!line) { - continue; - } - - const inlineFollowupMatch = line.match(PLAIN_ASK_INLINE_FOLLOWUP_REGEX); - const shouldBreakAfterLine = Boolean( - inlineFollowupMatch && typeof inlineFollowupMatch.index === "number", - ); - if ( - shouldBreakAfterLine && - typeof inlineFollowupMatch?.index === "number" - ) { - line = line.slice(0, inlineFollowupMatch.index).trim(); - } - - if (questions.length > 0 && PLAIN_ASK_FOLLOWUP_REGEX.test(line)) { - break; - } - - const bulletMatch = rawLine.match(BULLET_REGEX); - if (bulletMatch) { - const parsedQuestion = buildPlainAskQuestion(bulletMatch[1]); - if (parsedQuestion?.label) { - const questionKey = parsedQuestion.label.toLowerCase(); - if (!seenLabels.has(questionKey)) { - seenLabels.add(questionKey); - questions.push({ - ...parsedQuestion, - id: `plain_1_${questions.length + 1}`, - }); - } - } - - if (shouldBreakAfterLine) { - break; - } - continue; - } - - if (questions.length === 0) { - introLines.push(line); - continue; - } - - if (shouldBreakAfterLine) { - break; - } - } - - if (questions.length < 2) { - return null; - } - - return { - introText: joinMarkdownBlocks(introLines), - sections: [ - { - title: "需要补充的信息", - questions, - }, - ], - questionCount: questions.length, - }; -} - -function parseLegacyFallbackQuestionnaire(content: string): { - introText: string; - sections: LegacyQuestionnaireSection[]; - questionCount: number; -} | null { - return ( - parseCompatAskQuestionnaire(content) || parsePlainAskQuestionnaire(content) - ); -} - -function parseCompatAskQuestionnaire(content: string): { - introText: string; - sections: LegacyQuestionnaireSection[]; - questionCount: number; -} | null { - const normalizedContent = content.replace(/\r\n/g, "\n"); - const markerMatch = normalizedContent.match(COMPAT_ASK_MARKER_REGEX); - if (!markerMatch || typeof markerMatch.index !== "number") { - return null; - } - - const bodyStart = markerMatch.index + markerMatch[0].length; - const rawBody = normalizedContent.slice(bodyStart); - const endMatch = rawBody.match(COMPAT_ASK_END_REGEX); - const body = ( - typeof endMatch?.index === "number" - ? rawBody.slice(0, endMatch.index) - : rawBody - ) - .replace(COMPAT_ASK_TAG_REGEX, " ") - .trim(); - - if (!body) { - return null; - } - - const introText = uniqueNormalizedLines( - normalizedContent - .slice(0, markerMatch.index) - .replace(COMPAT_ASK_TAG_REGEX, " ") - .split("\n"), - ).join("\n\n"); - - const paragraphs = body - .split(/\n\s*\n/) - .map((paragraph) => - paragraph - .split("\n") - .map((line) => normalizeLine(line.replace(COMPAT_ASK_TAG_REGEX, " "))) - .filter(Boolean), - ) - .filter((lines) => lines.length > 0); - - if (paragraphs.length === 0) { - return null; - } - - const hintLines: string[] = []; - const promptParagraphs: string[] = []; - - for (const lines of paragraphs) { - const bulletLines = lines - .map((line) => line.match(BULLET_REGEX)) - .filter((match): match is RegExpMatchArray => Boolean(match)) - .map((match) => normalizeLine(match[1])); - - if (bulletLines.length > 0) { - hintLines.push(...bulletLines); - } - - const nonBulletLines = lines.filter((line) => !BULLET_REGEX.test(line)); - if (nonBulletLines.length > 0) { - promptParagraphs.push(nonBulletLines.join(" ")); - } - } - - const questions: LegacyQuestionnaireQuestion[] = []; - const seenLabels = new Set(); - let hintHelperText = buildCompatAskHelperText(hintLines); - - const pushQuestion = (label: string) => { - const normalizedLabel = normalizeCompatAskQuestionLabel(label); - if (!normalizedLabel) { - return; - } - - const questionKey = normalizedLabel.toLowerCase(); - if (seenLabels.has(questionKey)) { - return; - } - - seenLabels.add(questionKey); - questions.push({ - id: `compat_1_${questions.length + 1}`, - label: normalizedLabel, - helperText: hintHelperText, - }); - hintHelperText = undefined; - }; - - for (const paragraph of promptParagraphs) { - const normalizedParagraph = normalizeLine(paragraph); - if (!normalizedParagraph) { - continue; - } - - if (/这可以是[::]?/.test(normalizedParagraph)) { - pushQuestion( - normalizedParagraph.split(/(?:。|\.)?\s*这可以是[::]?/)[0] || - normalizedParagraph, - ); - continue; - } - - const sentenceCandidates = normalizedParagraph - .split(/(?<=[??])\s*/) - .map((sentence) => normalizeCompatAskQuestionLabel(sentence)) - .filter(Boolean); - const candidates = - sentenceCandidates.length > 0 - ? sentenceCandidates - : [normalizeCompatAskQuestionLabel(normalizedParagraph)].filter( - Boolean, - ); - - for (const candidate of candidates) { - if ( - !/[??]$/.test(candidate) && - !/^(?:请|需要|麻烦|告诉我|说明|描述|补充|提供)/.test(candidate) - ) { - continue; - } - pushQuestion(candidate); - } - } - - if (questions.length === 0) { - return null; - } - - return { - introText, - sections: [ - { - title: "需要补充的信息", - questions, - }, - ], - questionCount: questions.length, - }; -} - -function extractQuestionOptions(rawQuestion: string): { - label: string; - options?: string[]; - multiSelect: boolean; -} { - const normalizedQuestion = normalizeLine(rawQuestion); - const multiSelect = /多选/.test(normalizedQuestion); - const parentheticalMatch = normalizedQuestion.match( - /^(.*?)(?:(([^)]+))|\(([^)]+)\))\s*$/, - ); - - if (parentheticalMatch) { - const optionBlock = parentheticalMatch[2] || parentheticalMatch[3] || ""; - const options = splitOptions(optionBlock); - if (options.length >= 2) { - return { - label: stripTrailingPunctuation( - normalizePlainTextSurface(parentheticalMatch[1]), - ), - options, - multiSelect, - }; - } - } - - const colonMatch = normalizedQuestion.match(/^(.*?)[::]\s*(.+)$/); - if (colonMatch) { - const options = splitOptions(colonMatch[2]); - if (options.length >= 2) { - return { - label: stripTrailingPunctuation( - normalizePlainTextSurface(colonMatch[1]), - ), - options, - multiSelect, - }; - } - } - - if (/^是否/.test(normalizedQuestion)) { - return { - label: stripTrailingPunctuation( - normalizePlainTextSurface(normalizedQuestion), - ), - options: ["是", "否"], - multiSelect: false, - }; - } - - return { - label: - stripTrailingPunctuation(normalizePlainTextSurface(normalizedQuestion)) || - normalizePlainTextSurface(normalizedQuestion), - multiSelect, - }; -} - -function parseLegacyQuestionnaire(content: string): { - introText: string; - sections: LegacyQuestionnaireSection[]; - questionCount: number; -} | null { - if (!content.trim() || INLINE_A2UI_REGEX.test(content)) { - return null; - } - - const lines = content.replace(/\r\n/g, "\n").split("\n"); - const introLines: string[] = []; - const sections: LegacyQuestionnaireSection[] = []; - let currentSection: LegacyQuestionnaireSection | null = null; - let questionCount = 0; - - for (const rawLine of lines) { - const line = normalizeLine(rawLine); - if (!line) { - continue; - } - - const sectionMatch = rawLine.match(SECTION_REGEX); - if (sectionMatch) { - currentSection = { - title: - stripTrailingPunctuation( - normalizePlainTextSurface(sectionMatch[1]), - ) || normalizePlainTextSurface(sectionMatch[1]), - questions: [], - }; - sections.push(currentSection); - continue; - } - - const questionMatch = rawLine.match(BULLET_REGEX); - if (questionMatch) { - if (!currentSection) { - return parseLegacyFallbackQuestionnaire(content); - } - - const parsedQuestion = extractQuestionOptions(questionMatch[1]); - if (!parsedQuestion.label) { - return parseLegacyFallbackQuestionnaire(content); - } - - currentSection.questions.push({ - id: `${sections.length}_${currentSection.questions.length + 1}`, - label: parsedQuestion.label, - options: parsedQuestion.options, - multiSelect: parsedQuestion.multiSelect, - }); - questionCount += 1; - continue; - } - - if (!currentSection) { - introLines.push(line); - continue; - } - - if (currentSection.questions.length === 0) { - continue; - } - - return parseLegacyFallbackQuestionnaire(content); - } - - if (sections.length < 2 || questionCount < 3) { - return parseLegacyFallbackQuestionnaire(content); - } - - if (sections.some((section) => section.questions.length === 0)) { - return parseLegacyFallbackQuestionnaire(content); - } - - return { - introText: joinMarkdownBlocks(introLines), - sections, - questionCount, - }; -} - -function shouldUseLongTextField( - question: LegacyQuestionnaireQuestion, -): boolean { - if (question.options && question.options.length >= 2) { - return false; - } - - return LONG_TEXT_QUESTION_REGEX.test(question.label); -} - -function isCompactQuestion(question: LegacyQuestionnaireQuestion): boolean { - return !shouldUseLongTextField(question); -} - -function buildQuestionComponent(params: { - hash: string; - sectionIndex: number; - questionIndex: number; - question: LegacyQuestionnaireQuestion; -}): BuiltQuestionComponentResult { - const { hash, sectionIndex, questionIndex, question } = params; - const componentId = `legacy_${hash}_${sectionIndex + 1}_${questionIndex + 1}`; - const compact = isCompactQuestion(question); - const safeLabel = - stripTrailingPunctuation(normalizePlainTextSurface(question.label)) || - normalizePlainTextSurface(question.label); - const safeHelperText = question.helperText - ? normalizePlainTextSurface(question.helperText) - : undefined; - const safeOptions = uniqueOptions(question.options || []); - - if (safeOptions.length >= 2) { - return { - component: { - id: componentId, - component: "ChoicePicker", - label: safeLabel, - value: [], - weight: 1, - variant: question.multiSelect - ? "multipleSelection" - : "mutuallyExclusive", - layout: "wrap", - options: safeOptions.map((option) => ({ - label: option, - value: option, - })), - }, - isCompact: true, - }; - } - - return { - component: { - id: componentId, - component: "TextField", - label: safeLabel, - value: "", - weight: compact ? 1 : undefined, - variant: compact ? "shortText" : "longText", - placeholder: "请输入你的回答", - helperText: safeHelperText, - }, - isCompact: compact, - }; -} - -function summarizeFormValue(value: unknown): string | null { - if (typeof value === "string") { - return value.trim() || null; - } - - if (typeof value === "number") { - return String(value); - } - - if (typeof value === "boolean") { - return value ? "是" : null; - } - - if (Array.isArray(value)) { - const items = value - .map((item) => (typeof item === "string" ? item.trim() : "")) - .filter(Boolean); - return items.length > 0 ? items.join("、") : null; - } - - return null; -} - -function normalizeStructuredValue( - value: unknown, -): string | string[] | number | boolean | null { - if (typeof value === "string") { - const normalized = value.trim(); - return normalized || null; - } - - if (typeof value === "number" || typeof value === "boolean") { - return value; - } - - if (Array.isArray(value)) { - const normalized = value - .map((item) => (typeof item === "string" ? item.trim() : "")) - .filter(Boolean); - if (normalized.length === 0) { - return null; - } - return normalized.length === 1 ? normalized[0] : normalized; - } - - return null; -} - -function isFieldComponent(component: A2UIComponent): component is Extract< - A2UIComponent, - { - component: - | "TextField" - | "ChoicePicker" - | "CheckBox" - | "Slider" - | "DateTimeInput"; - } -> { - return ( - component.component === "TextField" || - component.component === "ChoicePicker" || - component.component === "CheckBox" || - component.component === "Slider" || - component.component === "DateTimeInput" - ); -} - -function resolveComponentLabel(component: A2UIComponent): string | null { - if (!isFieldComponent(component)) { - return null; - } - - if (!("label" in component) || typeof component.label !== "string") { - return null; - } - - return component.label.trim() || null; -} - -export function buildCompatQuestionnaireA2UI( - content: string, -): A2UIResponse | null { - const parsedQuestionnaire = parseLegacyQuestionnaire(content); - if (!parsedQuestionnaire) { - return null; - } - const { questionnaire, governance } = - governLegacyQuestionnaire(parsedQuestionnaire); - - const hash = buildStableHash(content); - const components: A2UIComponent[] = []; - const childIds: string[] = []; - const titleId = `legacy_${hash}_title`; - - components.push({ - id: titleId, - component: "Text", - text: "补充信息", - variant: "h3", - }); - childIds.push(titleId); - - if (questionnaire.introText) { - const introId = `legacy_${hash}_intro`; - components.push({ - id: introId, - component: "Text", - text: questionnaire.introText, - variant: "body", - }); - childIds.push(introId); - } - - questionnaire.sections.forEach((section, sectionIndex) => { - const sectionChildIds: string[] = []; - const pendingCompactIds: string[] = []; - const sectionBaseId = `legacy_${hash}_section_${sectionIndex + 1}`; - const sectionTitleId = `${sectionBaseId}_title`; - - const flushPendingCompactRow = () => { - if (pendingCompactIds.length === 0) { - return; - } - - if (pendingCompactIds.length === 1) { - sectionChildIds.push(pendingCompactIds[0]); - pendingCompactIds.length = 0; - return; - } - - const rowId = `${sectionBaseId}_row_${sectionChildIds.length + 1}`; - components.push({ - id: rowId, - component: "Row", - children: [...pendingCompactIds], - gap: 12, - align: "stretch", - wrap: true, - minChildWidth: 220, - }); - sectionChildIds.push(rowId); - pendingCompactIds.length = 0; - }; - - components.push({ - id: sectionTitleId, - component: "Text", - text: section.title, - variant: "h4", - }); - sectionChildIds.push(sectionTitleId); - - section.questions.forEach((question, questionIndex) => { - const questionResult = buildQuestionComponent({ - hash, - sectionIndex, - questionIndex, - question, - }); - components.push(questionResult.component); - - if (questionResult.isCompact) { - pendingCompactIds.push(questionResult.component.id); - if (pendingCompactIds.length >= 2) { - flushPendingCompactRow(); - } - return; - } - - flushPendingCompactRow(); - sectionChildIds.push(questionResult.component.id); - }); - - flushPendingCompactRow(); - - const sectionContentId = `${sectionBaseId}_content`; - const sectionCardId = `${sectionBaseId}_card`; - - components.push({ - id: sectionContentId, - component: "Column", - children: sectionChildIds, - gap: 10, - align: "stretch", - }); - components.push({ - id: sectionCardId, - component: "Card", - child: sectionContentId, - }); - childIds.push(sectionCardId); - }); - - const rootId = `legacy_${hash}_root`; - components.push({ - id: rootId, - component: "Column", - children: childIds, - gap: 14, - align: "stretch", - }); - - return { - id: `legacy-questionnaire-${hash}`, - root: rootId, - data: { - source: "legacy_questionnaire", - sectionCount: questionnaire.sections.length, - questionCount: questionnaire.questionCount, - ...(governance - ? { - governance, - } - : {}), - }, - components, - submitAction: { - label: "确认并继续", - action: { - name: "submit", - }, - }, - }; -} - -export function formatCompatQuestionnaireSubmission( - response: A2UIResponse, - formData: A2UIFormData, -): string | null { - return ( - buildCompatQuestionnaireSubmissionPayload(response, formData) - ?.formattedMessage || null - ); -} - -export function buildCompatQuestionnaireSubmissionPayload( - response: A2UIResponse, - formData: A2UIFormData, -): LegacyQuestionnaireSubmissionPayload | null { - const entries = response.components - .filter(isFieldComponent) - .map((component) => { - const label = resolveComponentLabel(component); - if (!label) { - return null; - } - - const structuredValue = normalizeStructuredValue(formData[component.id]); - if (structuredValue === null) { - return null; - } - - const summary = summarizeFormValue(structuredValue); - if (!summary) { - return null; - } - - return { - fieldId: component.id, - label, - value: structuredValue, - summary, - }; - }) - .filter((entry): entry is LegacyQuestionnaireSubmissionEntry => - Boolean(entry), - ); - - if (entries.length === 0) { - return null; - } - - const lines = entries.map((entry) => `- ${entry.label}: ${entry.summary}`); - const userData = entries.reduce< - Record - >((accumulator, entry) => { - accumulator[entry.label] = entry.value; - return accumulator; - }, {}); - const data = - response.data && typeof response.data === "object" - ? response.data - : undefined; - const governance = - data?.governance && typeof data.governance === "object" - ? (data.governance as ActionRequestGovernanceMeta) - : undefined; - - return { - formattedMessage: `我的选择:\n${lines.join("\n")}`, - entries, - userData, - requestMetadata: { - elicitation_context: { - source: "legacy_questionnaire", - mode: "compatibility_bridge", - form_id: response.id, - section_count: - typeof data?.sectionCount === "number" - ? data.sectionCount - : undefined, - question_count: - typeof data?.questionCount === "number" - ? data.questionCount - : undefined, - entries, - governance, - }, - }, - }; -} diff --git a/src/components/agent/chat/utils/compatSubagentRuntime.test.ts b/src/components/agent/chat/utils/compatSubagentRuntime.test.ts deleted file mode 100644 index 116ef63ba..000000000 --- a/src/components/agent/chat/utils/compatSubagentRuntime.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - buildCompatSubagentRuntimeSnapshot, - summarizeCompatSubagentEvent, -} from "./compatSubagentRuntime"; - -describe("compatSubagentRuntime", () => { - it("应将 compat scheduler 状态投影为最近活动摘要", () => { - const snapshot = buildCompatSubagentRuntimeSnapshot({ - isRunning: true, - progress: { - total: 3, - completed: 1, - failed: 0, - running: 1, - pending: 1, - skipped: 0, - cancelled: false, - currentTasks: ["task-b"], - percentage: 33, - }, - events: [ - { type: "started", totalTasks: 3 }, - { type: "taskStarted", taskId: "task-a", taskType: "research" }, - { - type: "progress", - progress: { - total: 3, - completed: 1, - failed: 0, - running: 1, - pending: 1, - skipped: 0, - cancelled: false, - currentTasks: ["task-b"], - percentage: 33, - }, - }, - { type: "taskCompleted", taskId: "task-a", durationMs: 1200 }, - { type: "completed", success: true, durationMs: 4200 }, - ], - result: { - success: true, - results: [], - totalDurationMs: 4200, - successfulCount: 1, - failedCount: 0, - skippedCount: 0, - mergedSummary: "已完成汇总", - totalTokenUsage: { - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - }, - }, - error: null, - summary: "已完成 1 项子任务", - }); - - expect(snapshot.hasSignals).toBe(true); - expect(snapshot.summary).toBe("已完成 1 项子任务"); - expect(snapshot.recentActivity).toHaveLength(4); - expect(snapshot.recentActivity[0]?.summary).toBe("调度完成,耗时 4 秒"); - expect(snapshot.recentActivity[3]?.summary).toBe("任务 task-a 开始执行"); - }); - - it("应为队列拒绝和超时事件生成可读摘要", () => { - expect( - summarizeCompatSubagentEvent({ - type: "queueRejected", - requested: 5, - limit: 3, - }), - ).toBe("队列已拒绝:请求 5,上限 3"); - - expect( - summarizeCompatSubagentEvent({ - type: "taskTimedOut", - taskId: "task-c", - timeoutMs: 1500, - }), - ).toBe("任务 task-c 超时:1500 ms"); - }); -}); diff --git a/src/components/agent/chat/utils/compatSubagentRuntime.ts b/src/components/agent/chat/utils/compatSubagentRuntime.ts deleted file mode 100644 index 78fd78e4d..000000000 --- a/src/components/agent/chat/utils/compatSubagentRuntime.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { - SchedulerEvent, - SchedulerExecutionResult, - SchedulerProgress, -} from "@/lib/api/subAgentScheduler"; - -export type CompatSubagentEvent = SchedulerEvent; -export type CompatSubagentProgress = SchedulerProgress; - -export interface CompatSubagentRuntimeState { - isRunning: boolean; - progress: CompatSubagentProgress | null; - events: CompatSubagentEvent[]; - result: SchedulerExecutionResult | null; - error: string | null; - summary: string | null; -} - -export interface CompatSubagentRuntimeActivity { - id: string; - summary: string; -} - -export interface CompatSubagentRuntimeStatus { - isRunning: boolean; - progress: CompatSubagentProgress | null; -} - -export interface CompatSubagentRuntimeDisplaySnapshot extends CompatSubagentRuntimeStatus { - error: string | null; - result: SchedulerExecutionResult | null; - summary: string | null; - recentActivity: CompatSubagentRuntimeActivity[]; - hasSignals: boolean; -} - -export interface CompatSubagentRuntimeSnapshot - extends CompatSubagentRuntimeState, CompatSubagentRuntimeDisplaySnapshot {} - -export function summarizeCompatSubagentEvent( - event: CompatSubagentEvent, -): string { - switch (event.type) { - case "started": - return `开始调度 ${event.totalTasks} 个子任务`; - case "queueRejected": - return `队列已拒绝:请求 ${event.requested},上限 ${event.limit}`; - case "taskStarted": - return `任务 ${event.taskId} 开始执行`; - case "taskCompleted": - return `任务 ${event.taskId} 已完成`; - case "taskTimedOut": - return `任务 ${event.taskId} 超时:${event.timeoutMs} ms`; - case "taskFailed": - return `任务 ${event.taskId} 失败:${event.error}`; - case "taskRetry": - return `任务 ${event.taskId} 重试第 ${event.retryCount} 次`; - case "taskSkipped": - return `任务 ${event.taskId} 已跳过:${event.reason}`; - case "progress": - return `进度 ${event.progress.completed}/${event.progress.total}`; - case "completed": - return `调度完成,耗时 ${Math.round(event.durationMs / 1000)} 秒`; - case "cancelled": - return "调度已取消"; - default: - return (event as { type: string }).type; - } -} - -export function buildCompatSubagentRuntimeSnapshot( - state: CompatSubagentRuntimeState, -): CompatSubagentRuntimeSnapshot { - const recentActivity = state.events - .slice(-4) - .reverse() - .map((event, index, collection) => ({ - id: `compat:${collection.length - index}:${event.type}`, - summary: summarizeCompatSubagentEvent(event), - })); - - return { - ...state, - summary: state.summary, - recentActivity, - hasSignals: - state.isRunning || - state.events.length > 0 || - Boolean(state.error) || - Boolean(state.result), - }; -} diff --git a/src/components/agent/chat/utils/contextualRecommendations.test.ts b/src/components/agent/chat/utils/contextualRecommendations.test.ts index 75edd35b6..95f4eafeb 100644 --- a/src/components/agent/chat/utils/contextualRecommendations.test.ts +++ b/src/components/agent/chat/utils/contextualRecommendations.test.ts @@ -60,9 +60,11 @@ describe("getContextualRecommendations", () => { }); expect(recommendations.length).toBeGreaterThan(0); - expect(recommendations.some(([label]) => label.includes("Team"))).toBe( - true, - ); + expect( + recommendations.some( + ([label]) => label.includes("任务拆分") || label.includes("父子线程"), + ), + ).toBe(true); }); it("开启多代理偏好后应优先返回 team runtime 测试提示词", () => { @@ -80,7 +82,7 @@ describe("getContextualRecommendations", () => { expect( recommendations.some( ([label, prompt]) => - label.includes("Team") && prompt.includes("team runtime"), + label.includes("任务拆分") && prompt.includes("任务拆分方式"), ), ).toBe(true); }); @@ -100,7 +102,7 @@ describe("getContextualRecommendations", () => { expect( recommendations.some( ([label, prompt]) => - label.includes("Team") && prompt.includes("多代理"), + label.includes("任务拆分") && prompt.includes("任务拆分偏好"), ), ).toBe(true); }); @@ -150,8 +152,8 @@ describe("getContextualRecommendations", () => { it("应识别 team runtime 类推荐", () => { expect( isTeamRuntimeRecommendation( - "Team 冒烟测试", - "请按 team runtime 方式做一次冒烟测试:创建 explorer 与 executor 两个子代理并行处理。", + "任务拆分冒烟测试", + "请按任务拆分方式做一次冒烟测试:创建 explorer 与 executor 两个子任务并行处理。", ), ).toBe(true); }); diff --git a/src/components/agent/chat/utils/contextualRecommendations.ts b/src/components/agent/chat/utils/contextualRecommendations.ts index b264947e3..d6bf8805e 100644 --- a/src/components/agent/chat/utils/contextualRecommendations.ts +++ b/src/components/agent/chat/utils/contextualRecommendations.ts @@ -41,9 +41,12 @@ export function isTeamRuntimeRecommendation( return ( normalizedLabel.includes("team") || + shortLabel.includes("任务拆分") || shortLabel.includes("多代理") || shortLabel.includes("父子线程") || normalizedPrompt.includes("team runtime") || + fullPrompt.includes("任务工作台") || + fullPrompt.includes("任务拆分") || fullPrompt.includes("父子线程联调") || fullPrompt.includes("多代理") || (fullPrompt.includes("子代理") && @@ -70,16 +73,16 @@ function buildTeamRecommendations( const subject = normalizeSubject(context.input); const teamSetupHint = context.subagentEnabled ? "" - : "如果当前多代理偏好未开启,请先开启输入框工具条里的“多代理”开关,再继续执行。"; + : "如果当前任务拆分偏好未开启,请先开启输入框工具条里的“任务拆分”开关,再继续执行。"; return [ [ - "Team 冒烟测试", - `请按 team runtime 方式处理“${subject}”:先在主线程拆成两个子任务,再创建 explorer 与 executor 两个子代理并行推进;至少等待一个子代理结束,必要时继续使用 SendMessage 追加说明,最后回到主线程汇总每个 agent 的状态、结论和下一步。${teamSetupHint}`, + "任务拆分冒烟测试", + `请按任务拆分方式处理“${subject}”:先在主线程拆成两个子任务,再创建 explorer 与 executor 两个子任务并行推进;至少等待一个子任务结束,必要时继续使用 SendMessage 追加说明,最后回到主线程汇总每项任务的状态、结论和下一步。${teamSetupHint}`, ], [ "父子线程联调", - `请围绕“${subject}”做一次父子线程联调:主线程只负责分派、等待和汇总;子代理 A 负责整理事实与风险,子代理 B 负责给出落地步骤与验收标准;最终输出 team workspace 视角的协作总结。${teamSetupHint}`, + `请围绕“${subject}”做一次父子线程联调:主线程只负责分派、等待和汇总;子任务 A 负责整理事实与风险,子任务 B 负责给出落地步骤与验收标准;最终输出任务工作台视角的任务总结。${teamSetupHint}`, ], ]; } diff --git a/src/components/agent/chat/utils/entryRecommendedSolutions.ts b/src/components/agent/chat/utils/entryRecommendedSolutions.ts index dcd4d6cc7..31ca37297 100644 --- a/src/components/agent/chat/utils/entryRecommendedSolutions.ts +++ b/src/components/agent/chat/utils/entryRecommendedSolutions.ts @@ -94,13 +94,13 @@ const ENTRY_RECOMMENDED_SOLUTIONS: EntryRecommendedSolutionDefinition[] = [ }, { id: "team-breakdown", - title: "多代理拆任务", + title: "任务拆分", summary: - "适合需要并行调研、方案拆解或多角色协作的任务,进入后默认启用 team runtime 偏好。", + "适合需要并行调研、方案拆解或多角色分工的任务,进入后默认启用任务拆分偏好。", outputHint: "任务拆解 + 分工执行", categoryLabel: "多步骤执行", prompt: - "请把这个任务按多代理方式拆解:先定义目标和约束,再拆成并行子任务,明确每个子代理的职责、产出和回收方式。", + "请把这个任务按任务拆分方式拆解:先定义目标和约束,再拆成并行子任务,明确每项子任务的职责、产出和回收方式。", shouldEnableTeamMode: true, }, ]; diff --git a/src/components/agent/chat/utils/generalAgentPrompt.test.ts b/src/components/agent/chat/utils/generalAgentPrompt.test.ts index eb6a173bb..3dae74d17 100644 --- a/src/components/agent/chat/utils/generalAgentPrompt.test.ts +++ b/src/components/agent/chat/utils/generalAgentPrompt.test.ts @@ -36,12 +36,13 @@ describe("generalAgentPrompt", () => { expect(prompt).toContain("当前能力开关"); expect(prompt).toContain("执行车道"); expect(prompt).toContain("计划执行:已开启"); - expect(prompt).toContain("多代理:已开启"); + expect(prompt).toContain("任务拆分:已开启"); expect(prompt).toContain("统一使用 WebSearch"); expect(prompt).toContain("不要混用 search/search_query/ToolSearch"); expect(prompt).toContain("1 个当前最关键的问题"); expect(prompt).toContain("合理假设补齐"); expect(prompt).toContain("每轮最多只保留 1 个最关键问题"); + expect(prompt).not.toContain("多代理"); }); it("通用主题 Prompt 应回落到统一的主题说明", () => { @@ -53,13 +54,14 @@ describe("generalAgentPrompt", () => { expect(prompt).toContain("3-4 组 WebSearch 扩搜"); }); - it("通用主题 Prompt 仍应强调执行升级与团队协作边界", () => { + it("通用主题 Prompt 仍应强调执行升级与任务分工边界", () => { const prompt = buildGeneralAgentSystemPrompt("general"); expect(prompt).toContain("计划执行"); - expect(prompt).toContain("多代理"); - expect(prompt).toContain("如果进入计划执行或 Team 协作"); - expect(prompt).toContain("主对话负责解释分工"); + expect(prompt).toContain("任务拆分"); + expect(prompt).toContain("如果进入计划执行或任务分工"); + expect(prompt).toContain("任务分工模式下"); + expect(prompt).not.toContain("Team 协作"); }); it("工作区编排场景应注入 harness 上下文,并兼容旧 general workbench alias", () => { diff --git a/src/components/agent/chat/utils/generalAgentPrompt.ts b/src/components/agent/chat/utils/generalAgentPrompt.ts index 239b05ce7..b691f7a19 100644 --- a/src/components/agent/chat/utils/generalAgentPrompt.ts +++ b/src/components/agent/chat/utils/generalAgentPrompt.ts @@ -81,7 +81,7 @@ export function buildGeneralAgentSystemPrompt( `- 联网搜索:${describeEnabledState(toolPreferences?.webSearch)}`, `- 深度思考:${describeEnabledState(toolPreferences?.thinking)}`, `- 计划执行:${describeEnabledState(toolPreferences?.task)}`, - `- 多代理:${describeEnabledState(toolPreferences?.subagent)}`, + `- 任务拆分:${describeEnabledState(toolPreferences?.subagent)}`, ].join("\n"); const harnessLines = harness && isGeneralWorkbenchSessionMode(harness.sessionMode) @@ -94,7 +94,7 @@ export function buildGeneralAgentSystemPrompt( ] .filter(Boolean) .join("\n") - : "- 当前会话处于默认对话模式,可直接回答,也可在必要时升级到工具、任务或子代理。"; + : "- 当前会话处于默认对话模式,可直接回答,也可在必要时升级到工具、计划执行或子任务分工。"; const browserAssistLines = harness?.browserAssistEnabled ? [ "- 当前通用对话已启用 Browser Assist。只要任务涉及打开网站、点击、表单、登录、搜索、验证码、多因素认证或其他网页交互,必须优先使用 Lime Browser Assist 浏览器工具。", @@ -127,7 +127,7 @@ export function buildGeneralAgentSystemPrompt( - 不要把自己限制为编程助手;除非用户明确要求,否则不要默认进入“写代码 / 建文件 / 做研究报告 / 落盘到工作区”的模式。 - 当用户希望你帮他推进一件事时,能直接完成就直接完成;只有在关键信息缺失且会显著影响结果时,才提出 1 个当前最关键的问题。 - 如果剩余缺口可以通过合理假设补齐,就直接带着假设继续推进,并明确告诉用户你当前采用了什么假设,而不是先把所有问题一次性抛给用户。 -- 每次响应都先判断这是一条“直接回答”请求,还是需要升级到搜索、思考、任务、子代理或工作区操作;不要一上来就走重链路。 +- 每次响应都先判断这是一条“直接回答”请求,还是需要升级到搜索、思考、计划执行、任务拆分或工作区操作;不要一上来就走重链路。 当前能力开关: ${toolPreferenceLines} @@ -137,14 +137,14 @@ ${toolPreferenceLines} - 联网检索:适用于用户明确要求搜索,或问题涉及最新、实时、价格、政策、规则、版本、新闻、日期敏感信息。统一使用 WebSearch 作为检索入口;需要打开具体页面时再使用 WebFetch。 - 深度思考:适用于复杂推理、强约束规划、多方案取舍、高风险判断。 - 计划执行:适用于需要先整理步骤、分阶段推进、长链路处理或显式进入 Plan 模式的任务。 -- 多代理:适用于任务天然可拆分、需要并行探索,或主线程上下文会显著过载。 +- 任务拆分:适用于任务天然可拆分、需要并行探索,或主线程上下文会显著过载。 工具使用原则: 1. 不要为了显得像 agent 而强行调用工具;直接回答更合适时,就直接回答。 2. 只有在以下场景才主动联网核实:用户明确要求搜索;问题涉及今天、最新、价格、政策、法律、版本、新闻、实时数据;或者高风险信息需要校验。联网时统一使用 WebSearch,不要混用 search/search_query/ToolSearch 之类别名。 3. 深度思考默认只用于复杂推理、多方案比较、严格规划或高风险判断;简单问答、轻量改写、普通说明不要默认进入长链路推理。 4. 只有当任务需要先整理执行计划、分阶段推进、长链路处理,或用户明确要求 Plan 模式时,再升级为计划执行;否则优先在当前对话里直接完成。 -5. 只有当问题天然可拆分、需要并行探索/规划/执行、或主线程上下文会显著过载时,再使用 subagent;否则优先单 agent 完成。 +5. 只有当问题天然可拆分、需要并行探索/规划/执行、或主线程上下文会显著过载时,再进入任务拆分;否则优先单 agent 完成。 6. 用户明确要求读取、修改、创建、保存项目或工作区内容时,再使用文件或工作区能力;否则默认以对话结果为主,不主动落盘。 7. 如果用户明确要求检索,或问题涉及最新、实时、价格、政策、法律、版本、新闻、日期敏感信息,先核实再答;仅仅开启联网搜索能力不等于必须联网。 8. 新闻、最新动态、某日综述、热点盘点类请求,不要只做一次浅搜;至少围绕原始 query、中文日期/主题 query、英文等价 query、headlines/roundup query 做 3-4 组 WebSearch 扩搜,再按主题聚类总结。 @@ -156,12 +156,12 @@ ${toolPreferenceLines} 14. 如果要给出计划,默认同时给优先级、阶段划分、约束、风险和下一步动作,而不是抽象口号。 行为协议: -- 先判断应该走哪条车道:直接回答 / 联网检索 / 深度思考 / 计划执行 / 多代理。 +- 先判断应该走哪条车道:直接回答 / 联网检索 / 深度思考 / 计划执行 / 任务拆分。 - 如果用户当前只是想要答案、改写、总结、比较或建议,默认停留在“直接回答”车道。 -- 如果进入计划执行或 subagent,先用一句自然语言说明为什么要升级执行方式,再继续推进。 -- 如果进入计划执行或 Team 协作,不要只播报状态;要先告诉用户你会如何分工、接下来会发生什么、你会带着什么结果回来。 -- 如果当前偏好未开启计划执行或 subagent,只有在用户明确要求或问题明显需要时才升级,不要滥用。 -- Team 模式下,主对话负责解释分工、同步关键进展、提醒待确认事项,不要把用户晾在一句简短状态上。 +- 如果进入计划执行或任务拆分,先用一句自然语言说明为什么要升级执行方式,再继续推进。 +- 如果进入计划执行或任务分工,不要只播报状态;要先告诉用户你会如何分工、接下来会发生什么、你会带着什么结果回来。 +- 如果当前偏好未开启计划执行或任务拆分,只有在用户明确要求或问题明显需要时才升级,不要滥用。 +- 任务分工模式下,主对话负责解释分工、同步关键进展、提醒待确认事项,不要把用户晾在一句简短状态上。 - 当任务明显是多步骤、多阶段、需要先对齐执行路线时,先输出一个 \`...\` 计划块,再继续执行或解释。 - 计划块内容应简洁、可执行、按顺序展开,适合直接在 UI 中单独展示;如果已经能推进,就在计划块后继续推进,不要只给计划就停住。 diff --git a/src/components/agent/chat/utils/harnessState.ts b/src/components/agent/chat/utils/harnessState.ts index a8d4a1e39..92d391925 100644 --- a/src/components/agent/chat/utils/harnessState.ts +++ b/src/components/agent/chat/utils/harnessState.ts @@ -1332,7 +1332,7 @@ function deriveHarnessSessionStateFromItems( activity.delegation += 1; delegatedTasks.push({ id: item.id, - title: item.title || "子代理任务", + title: item.title || "子任务", status: item.status === "failed" ? "failed" diff --git a/src/components/agent/chat/utils/messageArtifacts.ts b/src/components/agent/chat/utils/messageArtifacts.ts index 6536ce3a9..47e20ded3 100644 --- a/src/components/agent/chat/utils/messageArtifacts.ts +++ b/src/components/agent/chat/utils/messageArtifacts.ts @@ -83,6 +83,12 @@ function fileNameFromPath(path: string): string { return segments[segments.length - 1] || normalized; } +function countPathSegments(path: string): number { + return normalizePath(path) + .split("/") + .filter((segment) => segment.length > 0 && segment !== ".").length; +} + function doesArtifactPathReferenceMatch( candidatePath?: string | null, targetPath?: string | null, @@ -98,6 +104,16 @@ function doesArtifactPathReferenceMatch( return true; } + if ( + countPathSegments(normalizedCandidate) > 1 && + countPathSegments(normalizedTarget) > 1 + ) { + return areArtifactProtocolPathsEquivalent( + normalizedCandidate, + normalizedTarget, + ); + } + if (!shouldAllowBareFileNameFallback(normalizedTarget)) { return false; } diff --git a/src/components/agent/chat/utils/stableProcessingExperience.test.ts b/src/components/agent/chat/utils/stableProcessingExperience.test.ts index 087ff9954..b616abad1 100644 --- a/src/components/agent/chat/utils/stableProcessingExperience.test.ts +++ b/src/components/agent/chat/utils/stableProcessingExperience.test.ts @@ -38,8 +38,8 @@ describe("stableProcessingExperience", () => { ).toBe(false); }); - it("Team 文案应强调成员依次开始", () => { - expect(getStableProcessingDescription("team")).toContain("协作成员"); + it("Team 文案应强调子任务依次开始", () => { + expect(getStableProcessingDescription("team")).toContain("子任务"); expect(getStableProcessingDescription("request")).toContain("同类请求"); }); }); diff --git a/src/components/agent/chat/utils/stableProcessingExperience.ts b/src/components/agent/chat/utils/stableProcessingExperience.ts index aab6baae2..2151dcc3b 100644 --- a/src/components/agent/chat/utils/stableProcessingExperience.ts +++ b/src/components/agent/chat/utils/stableProcessingExperience.ts @@ -45,7 +45,7 @@ export function getStableProcessingDescription( scope: StableProcessingScope = "request", ): string { if (scope === "team") { - return "当前模型会让协作成员依次开始,整体节奏更稳。"; + return "当前模型会让子任务依次开始,整体节奏更稳。"; } return "当前模型会在高峰时依次开始同类请求,优先保证稳定完成。"; diff --git a/src/components/agent/chat/utils/subagentTimeline.test.ts b/src/components/agent/chat/utils/subagentTimeline.test.ts index 25cbfce2d..db3cc67f1 100644 --- a/src/components/agent/chat/utils/subagentTimeline.test.ts +++ b/src/components/agent/chat/utils/subagentTimeline.test.ts @@ -1,55 +1,8 @@ import { describe, expect, it } from "vitest"; -import { - buildRealSubagentTimelineItems, - buildSyntheticSubagentTimelineItems, -} from "./subagentTimeline"; +import { buildRealSubagentTimelineItems } from "./subagentTimeline"; describe("subagentTimeline", () => { - it("应将调度事件映射为子代理 timeline items", () => { - const items = buildSyntheticSubagentTimelineItems({ - threadId: "thread-1", - turnId: "turn-1", - baseTime: new Date("2026-03-13T10:00:00Z"), - events: [ - { type: "started", totalTasks: 2 }, - { type: "taskStarted", taskId: "task-a", taskType: "research" }, - { - type: "progress", - progress: { - total: 2, - completed: 0, - failed: 0, - running: 1, - pending: 1, - skipped: 0, - cancelled: false, - currentTasks: ["task-a"], - percentage: 50, - }, - }, - { type: "taskCompleted", taskId: "task-a", durationMs: 1200 }, - { type: "completed", success: true, durationMs: 1800 }, - ], - }); - - expect(items.some((item) => item.id.includes(":run"))).toBe(true); - expect(items.some((item) => item.id.includes("task-a"))).toBe(true); - expect(items.find((item) => item.id.includes("task-a"))?.status).toBe( - "completed", - ); - }); - - it("缺少 thread 或 turn 时应返回空数组", () => { - expect( - buildSyntheticSubagentTimelineItems({ - threadId: null, - turnId: "turn-1", - events: [{ type: "started", totalTasks: 1 }], - }), - ).toEqual([]); - }); - it("应将真实 child session 投影为可归属到父 turn 的 timeline items", () => { const items = buildRealSubagentTimelineItems({ threadId: "thread-1", diff --git a/src/components/agent/chat/utils/subagentTimeline.ts b/src/components/agent/chat/utils/subagentTimeline.ts index 16d3e3a0e..4a162110a 100644 --- a/src/components/agent/chat/utils/subagentTimeline.ts +++ b/src/components/agent/chat/utils/subagentTimeline.ts @@ -1,6 +1,5 @@ import type { AgentThreadItem, AgentThreadTurn } from "../types"; import type { AsterSubagentSessionInfo } from "@/lib/api/agentRuntime"; -import type { CompatSubagentEvent } from "./compatSubagentRuntime"; import { sortThreadItems } from "./threadTimelineView"; type SyntheticSubagentItem = Extract< @@ -8,53 +7,12 @@ type SyntheticSubagentItem = Extract< { type: "subagent_activity" } >; -interface BuildSubagentTimelineItemsOptions { - threadId?: string | null; - turnId?: string | null; - events: CompatSubagentEvent[]; - baseTime?: Date; -} - interface BuildRealSubagentTimelineItemsOptions { threadId?: string | null; turns: AgentThreadTurn[]; childSessions: AsterSubagentSessionInfo[]; } -function createTimestamp(baseTime: number, index: number): string { - return new Date(baseTime + index).toISOString(); -} - -function resolveRunSummary(event: CompatSubagentEvent): string | undefined { - switch (event.type) { - case "started": - return `准备调度 ${event.totalTasks} 个子任务`; - case "progress": - return `进度 ${event.progress.completed}/${event.progress.total},处理中 ${event.progress.running}`; - case "completed": - return event.success - ? `子代理协作完成,耗时 ${Math.round(event.durationMs / 1000)} 秒` - : `子代理协作结束,耗时 ${Math.round(event.durationMs / 1000)} 秒`; - case "cancelled": - return "子代理协作已取消"; - default: - return undefined; - } -} - -function resolveTaskTitle( - event: Extract, -): string { - if ( - "taskType" in event && - typeof event.taskType === "string" && - event.taskType.trim() - ) { - return `${event.taskId} · ${event.taskType.trim()}`; - } - return event.taskId; -} - function resolveTimestampMs(value?: string | null): number | null { if (!value) { return null; @@ -189,7 +147,7 @@ export function buildRealSubagentTimelineItems({ status_label: resolveChildSubagentStatusLabel( childSession.runtime_status, ), - title: childSession.name || "子代理会话", + title: childSession.name || "子任务", summary: childSession.task_summary, role: childSession.role_hint, model: childSession.model, @@ -203,128 +161,3 @@ export function buildRealSubagentTimelineItems({ return sortThreadItems(items); } - -export function buildSyntheticSubagentTimelineItems({ - threadId, - turnId, - events, - baseTime, -}: BuildSubagentTimelineItemsOptions): AgentThreadItem[] { - const resolvedThreadId = threadId?.trim(); - const resolvedTurnId = turnId?.trim(); - if (!resolvedThreadId || !resolvedTurnId || events.length === 0) { - return []; - } - - const items = new Map(); - const startTimes = new Map(); - const timestampBase = (baseTime ?? new Date()).getTime(); - - const upsertItem = ( - id: string, - sequence: number, - timestamp: string, - item: Omit< - SyntheticSubagentItem, - "id" | "thread_id" | "turn_id" | "sequence" | "started_at" - >, - ) => { - const previous = items.get(id); - const startedAt = previous?.started_at || startTimes.get(id) || timestamp; - startTimes.set(id, startedAt); - items.set(id, { - ...previous, - ...item, - id, - thread_id: resolvedThreadId, - turn_id: resolvedTurnId, - sequence: previous?.sequence ?? sequence, - started_at: startedAt, - } as AgentThreadItem); - }; - - events.forEach((event, index) => { - const sequence = 10_000 + index; - const timestamp = createTimestamp(timestampBase, index); - const runItemId = `synthetic:subagent:${resolvedTurnId}:run`; - - switch (event.type) { - case "started": - case "progress": - case "completed": - case "cancelled": { - upsertItem(runItemId, sequence, timestamp, { - status: - event.type === "completed" || event.type === "cancelled" - ? "completed" - : "in_progress", - completed_at: - event.type === "completed" || event.type === "cancelled" - ? timestamp - : undefined, - updated_at: timestamp, - type: "subagent_activity", - status_label: - event.type === "started" - ? "dispatching" - : event.type === "progress" - ? "running" - : event.type === "cancelled" - ? "cancelled" - : "completed", - title: "子代理协作", - summary: resolveRunSummary(event), - }); - break; - } - - case "taskStarted": - case "taskRetry": - case "taskCompleted": - case "taskFailed": - case "taskSkipped": { - const itemId = `synthetic:subagent:${resolvedTurnId}:${event.taskId}`; - upsertItem(itemId, sequence, timestamp, { - status: - event.type === "taskFailed" - ? "failed" - : event.type === "taskCompleted" || event.type === "taskSkipped" - ? "completed" - : "in_progress", - completed_at: - event.type === "taskCompleted" || - event.type === "taskFailed" || - event.type === "taskSkipped" - ? timestamp - : undefined, - updated_at: timestamp, - type: "subagent_activity", - status_label: - event.type === "taskStarted" - ? "running" - : event.type === "taskRetry" - ? "retrying" - : event.type === "taskCompleted" - ? "completed" - : event.type === "taskSkipped" - ? "skipped" - : "failed", - title: resolveTaskTitle(event), - summary: - event.type === "taskStarted" - ? "子代理开始执行" - : event.type === "taskRetry" - ? `重试第 ${event.retryCount} 次` - : event.type === "taskCompleted" - ? `已完成,耗时 ${Math.round(event.durationMs / 1000)} 秒` - : event.type === "taskSkipped" - ? `已跳过:${event.reason}` - : event.error, - }); - break; - } - } - }); - - return sortThreadItems(Array.from(items.values())); -} diff --git a/src/components/agent/chat/utils/teamMemoryDisplay.ts b/src/components/agent/chat/utils/teamMemoryDisplay.ts new file mode 100644 index 000000000..63c00067a --- /dev/null +++ b/src/components/agent/chat/utils/teamMemoryDisplay.ts @@ -0,0 +1,9 @@ +export function normalizeTeamMemoryDisplayText(value?: string | null): string { + if (typeof value !== "string") { + return ""; + } + + return value + .replace(/^Team[::]\s*/gm, "任务方案:") + .replace(/^子代理[::]\s*/gm, "子任务:"); +} diff --git a/src/components/agent/chat/utils/teamPresets.ts b/src/components/agent/chat/utils/teamPresets.ts index d0e4a11cf..d015f0ea8 100644 --- a/src/components/agent/chat/utils/teamPresets.ts +++ b/src/components/agent/chat/utils/teamPresets.ts @@ -39,7 +39,7 @@ export const BUILTIN_TEAM_SKILL_OPTIONS: BuiltinTeamSkillOption[] = [ { id: "bounded-implementation", label: "边界实现", - description: "强调只改授权范围,避免和其他子代理冲突。", + description: "强调只改授权范围,避免和其他子任务冲突。", }, { id: "verification-report", diff --git a/src/components/agent/chat/utils/teamSuggestion.ts b/src/components/agent/chat/utils/teamSuggestion.ts index 87145631a..cc2c22334 100644 --- a/src/components/agent/chat/utils/teamSuggestion.ts +++ b/src/components/agent/chat/utils/teamSuggestion.ts @@ -237,7 +237,7 @@ export function getTeamSuggestion({ if (explicitTeamIntent) { score += 0.52; reasons.push( - "你已经显式提到 Team、多代理或父子线程,这类任务应走协作运行时。", + "你已经显式提到任务拆分或父子线程,这类任务应走任务运行时。", ); } @@ -248,7 +248,7 @@ export function getTeamSuggestion({ if (parallelSignals > 0) { score += parallelSignals > 1 ? 0.22 : 0.16; reasons.push( - "描述里出现并行拆分或多角色分工信号,适合让主线程编排多个子代理。", + "描述里出现并行拆分或多角色分工信号,适合让主线程编排多个子任务。", ); } @@ -256,7 +256,7 @@ export function getTeamSuggestion({ if (multiStageCount >= 3) { score += 0.22; reasons.push( - "任务同时包含分析、执行、验证等多个阶段,拆成团队协作更容易收敛。", + "任务同时包含分析、执行、验证等多个阶段,拆成任务分工更容易收敛。", ); } else if (multiStageCount === 2) { score += 0.12; diff --git a/src/components/agent/chat/utils/teamWorkspaceCanvas.ts b/src/components/agent/chat/utils/teamWorkspaceCanvas.ts index 3b0706663..0445a3532 100644 --- a/src/components/agent/chat/utils/teamWorkspaceCanvas.ts +++ b/src/components/agent/chat/utils/teamWorkspaceCanvas.ts @@ -164,29 +164,29 @@ export function buildCanvasStageHint(params: { } = params; if (hasRealTeamGraph) { - return "拖动画布空白处可平移,滚轮配合 Ctrl/Cmd 可缩放,拖动成员卡片可调整布局。"; + return "当前任务会按状态持续刷新,焦点会优先落在正在处理的任务上;需要时可调整任务布局或缩放视图。"; } if (teamDispatchPreviewState?.status === "forming") { - return "当前任务分工正在准备中,成员加入后会接手这些位置。"; + return "当前任务分工正在准备中,任务拆出后会接手这些位置。"; } if (teamDispatchPreviewState?.status === "formed") { - return "当前任务分工已经准备好,成员加入后会自动接手这些位置。"; + return "当前任务分工已经准备好,任务拆出后会依次开始处理。"; } if (teamDispatchPreviewState?.status === "failed") { return ( teamDispatchPreviewState.errorMessage?.trim() || - "当前任务分工准备失败,暂时无法生成成员画布。" + "当前任务分工准备失败,暂时无法生成任务视图。" ); } if (hasRuntimeFormation || hasSelectedTeamPlan) { - return "当前画布会先展示计划分工,成员加入后会切换为独立的任务进行时面板。"; + return "这里会先展示当前任务分工,任务拆出后会切换为独立的任务视图。"; } - return "成员加入后,这里会展开成可拖拽、可缩放的任务进行时画布。"; + return "任务拆出后,这里会切换成独立的任务视图。"; } export function resolveCanvasLaneBounds( diff --git a/src/components/agent/chat/utils/teamWorkspaceCopy.ts b/src/components/agent/chat/utils/teamWorkspaceCopy.ts index f1b10529b..1de792ee1 100644 --- a/src/components/agent/chat/utils/teamWorkspaceCopy.ts +++ b/src/components/agent/chat/utils/teamWorkspaceCopy.ts @@ -25,14 +25,14 @@ export type TeamWorkspaceDisplayMemberStatus = | "completed" | "failed"; -export const TEAM_WORKSPACE_SURFACE_TITLE = "任务协作"; +export const TEAM_WORKSPACE_SURFACE_TITLE = "任务工作台"; export const TEAM_WORKSPACE_REALTIME_BADGE_LABEL = "任务进行时"; -export const TEAM_WORKSPACE_MEMBER_NOUN = "协作成员"; +export const TEAM_WORKSPACE_MEMBER_NOUN = "子任务"; export const TEAM_WORKSPACE_PLAN_LABEL = "任务方案"; -export const TEAM_WORKSPACE_MAIN_ASSISTANT_LABEL = "主助手"; +export const TEAM_WORKSPACE_MAIN_ASSISTANT_LABEL = "主任务"; export const TEAM_WORKSPACE_STABLE_PROCESSING_LABEL = STABLE_PROCESSING_LABEL; -export const TEAM_WORKSPACE_WAITING_HEADLINE = "等待成员接入"; -export const TEAM_WORKSPACE_IDLE_STATUS_LABEL = "还没有成员接入"; +export const TEAM_WORKSPACE_WAITING_HEADLINE = "等待任务接手"; +export const TEAM_WORKSPACE_IDLE_STATUS_LABEL = "还没有任务接手"; const RUNTIME_STATUS_LABELS = { idle: "待开始", diff --git a/src/components/agent/chat/utils/teamWorkspaceDisplay.ts b/src/components/agent/chat/utils/teamWorkspaceDisplay.ts new file mode 100644 index 000000000..6feb317c4 --- /dev/null +++ b/src/components/agent/chat/utils/teamWorkspaceDisplay.ts @@ -0,0 +1,34 @@ +const TEAM_WORKSPACE_DISPLAY_REPLACEMENTS: Array = [ + [/\bteam\s*状态事件\b/gi, "任务状态事件"], + [/\bTeam\b/g, "任务方案"], + [/\bteam\b/g, "任务方案"], + [/子代理/g, "子任务"], + [/多代理/g, "任务拆分"], +]; + +function normalizeInsertedChineseSpacing(value: string): string { + return value + .replace(/([\u4e00-\u9fff])\s+(任务方案|任务|子任务|任务拆分)/g, "$1$2") + .replace(/(任务方案|任务|子任务|任务拆分)\s+([\u4e00-\u9fff])/g, "$1$2"); +} + +export function normalizeTeamWorkspaceDisplayText(value?: string | null): string { + if (typeof value !== "string") { + return ""; + } + + const replaced = TEAM_WORKSPACE_DISPLAY_REPLACEMENTS.reduce( + (current, [pattern, replacement]) => + current.replace(pattern, replacement), + value, + ); + + return normalizeInsertedChineseSpacing(replaced); +} + +export function normalizeTeamWorkspaceDisplayValue( + value?: string | null, +): string | null { + const normalized = normalizeTeamWorkspaceDisplayText(value).trim(); + return normalized || null; +} diff --git a/src/components/agent/chat/utils/teamWorkspaceSessions.test.ts b/src/components/agent/chat/utils/teamWorkspaceSessions.test.ts index 9addb44f7..96d912d18 100644 --- a/src/components/agent/chat/utils/teamWorkspaceSessions.test.ts +++ b/src/components/agent/chat/utils/teamWorkspaceSessions.test.ts @@ -51,7 +51,7 @@ describe("teamWorkspaceSessions", () => { profile_id: "profile-research", profile_name: "研究员", parent_session_id: "parent-session", - parent_session_name: "主助手", + parent_session_name: "主任务", role_key: "researcher", team_preset_id: "preset-1", theme: "general", @@ -159,21 +159,23 @@ describe("teamWorkspaceSessions", () => { ).toEqual(["session-plan", "session-exec"]); }); - it("应构建 rail sessions、member canvas sessions 与默认选中项", () => { + it("应按任务状态优先级构建 task schedule 顺序与默认选中项", () => { const orchestrator = createSessionCard({ id: "session-main", - name: "主助手", + name: "主任务", sessionType: "user", }); const planner = createSessionCard({ id: "session-plan", name: "规划成员", blueprintRoleLabel: "规划", + runtimeStatus: "completed", }); const executor = createSessionCard({ id: "session-exec", name: "执行成员", roleKey: "executor", + runtimeStatus: "running", }); const formationState = { requestId: "dispatch-1", @@ -213,8 +215,8 @@ describe("teamWorkspaceSessions", () => { }); expect(memberCanvasSessions.map((session) => session.id)).toEqual([ - "session-plan", "session-exec", + "session-plan", ]); expect(railSessions.map((session) => session.id)).toEqual([ "session-main", @@ -230,7 +232,7 @@ describe("teamWorkspaceSessions", () => { memberCanvasSessions, orchestratorSessionId: "session-main", }), - ).toBe("session-plan"); + ).toBe("session-exec"); }); it("应在展开成员失效时自动回收 expanded session id", () => { @@ -247,22 +249,22 @@ describe("teamWorkspaceSessions", () => { it("应返回对应场景的 fallback 文案", () => { expect( buildFallbackSummary({ - hasRealTeamGraph: false, + hasRuntimeSessions: false, isChildSession: false, }), - ).toContain("还没有成员接入"); + ).toContain("还没有任务接入"); expect( buildFallbackSummary({ - hasRealTeamGraph: true, + hasRuntimeSessions: true, isChildSession: false, selectedSession: createSessionCard({ id: "orchestrator", - name: "主助手", + name: "主任务", sessionType: "user", }), }), - ).toContain("主助手会负责整理需求"); + ).toContain("主任务会负责整理需求、安排任务顺序"); }); it("应暴露稳定的状态与时间展示 helper", () => { diff --git a/src/components/agent/chat/utils/teamWorkspaceSessions.ts b/src/components/agent/chat/utils/teamWorkspaceSessions.ts index 36a978462..b20170612 100644 --- a/src/components/agent/chat/utils/teamWorkspaceSessions.ts +++ b/src/components/agent/chat/utils/teamWorkspaceSessions.ts @@ -48,6 +48,16 @@ export interface TeamSessionCard { isCurrent?: boolean; } +const TEAM_WORKSPACE_TASK_SCHEDULE_PRIORITY: Record = { + running: 0, + queued: 1, + failed: 2, + aborted: 2, + completed: 3, + closed: 4, + idle: 5, +}; + const STATUS_META = { idle: { label: resolveTeamWorkspaceDisplayRuntimeStatusLabel(undefined), @@ -126,7 +136,7 @@ export function buildCurrentChildSession( return { id: currentSessionId, - name: currentSessionName?.trim() || "当前成员", + name: currentSessionName?.trim() || "当前任务", runtimeStatus: currentSessionRuntimeStatus, taskSummary: subagentParentContext.task_summary, roleHint: subagentParentContext.role_hint, @@ -163,7 +173,7 @@ export function buildOrchestratorSession( name: currentSessionName?.trim() || TEAM_WORKSPACE_MAIN_ASSISTANT_LABEL, runtimeStatus: currentSessionRuntimeStatus, taskSummary: - "当前主助手会负责拆分需求、邀请协作成员加入,并把各部分结果汇总到同一份内容里。", + "当前主任务会负责拆解任务、安排处理顺序,并把各部分结果汇总到同一份内容里。", roleHint: "orchestrator", sessionType: "user", isCurrent: true, @@ -369,13 +379,28 @@ export function buildTeamWorkspaceMemberCanvasSessions(params: { visibleSessions, teamDispatchPreviewState, } = params; - - return orderSessionsByRuntimeRoles( + const roleOrderedSessions = orderSessionsByRuntimeRoles( isChildSession ? dedupeSessions([currentChildSession, ...visibleSessions]) : visibleSessions, teamDispatchPreviewState, ); + + return roleOrderedSessions + .map((session, index) => ({ + session, + index, + priority: + TEAM_WORKSPACE_TASK_SCHEDULE_PRIORITY[session.runtimeStatus ?? "idle"] ?? + TEAM_WORKSPACE_TASK_SCHEDULE_PRIORITY.idle, + })) + .sort((left, right) => { + if (left.priority !== right.priority) { + return left.priority - right.priority; + } + return left.index - right.index; + }) + .map((entry) => entry.session); } export function buildTeamWorkspaceRailSessions(params: { @@ -457,20 +482,20 @@ export function resolveExpandedTeamWorkspaceSessionId( } export function buildFallbackSummary(params: { - hasRealTeamGraph: boolean; + hasRuntimeSessions: boolean; isChildSession: boolean; selectedSession?: TeamSessionCard | null; }) { - const { hasRealTeamGraph, isChildSession, selectedSession } = params; + const { hasRuntimeSessions, isChildSession, selectedSession } = params; - if (!hasRealTeamGraph) { - return "还没有成员接入。需要时系统会自动补充分工,并在这里展示最新进展。"; + if (!hasRuntimeSessions) { + return "还没有任务接入。需要时系统会自动补充分工,并在这里展示最新进展。"; } if (selectedSession?.sessionType === "user") { - return "主助手会负责整理需求、分配分工,并把各部分结果汇总到当前内容里。"; + return "主任务会负责整理需求、安排任务顺序,并把各部分结果汇总到当前内容里。"; } if (isChildSession) { - return "这位协作成员正在处理主助手分配的内容,你可以在这里切换查看其他成员的进展。"; + return "这项任务正在处理主任务分配的内容,你可以在这里切换查看其他并行任务的进展。"; } - return "选中一位协作成员后,这里会展示它正在帮你做什么,以及目前进展到哪一步。"; + return "选中一项任务后,这里会展示它正在推进什么,以及目前进展到哪一步。"; } diff --git a/src/components/agent/chat/utils/toolDisplayInfo.test.ts b/src/components/agent/chat/utils/toolDisplayInfo.test.ts index 056c33b47..52a2c98ff 100644 --- a/src/components/agent/chat/utils/toolDisplayInfo.test.ts +++ b/src/components/agent/chat/utils/toolDisplayInfo.test.ts @@ -63,7 +63,7 @@ describe("toolDisplayInfo", () => { expect(resolveToolDisplayLabel("BriefTool")).toBe("用户消息"); expect(resolveToolDisplayLabel("FileReadTool")).toBe("文件读取"); expect(resolveToolDisplayLabel("lime_search_web_images")).toBe("联网搜图"); - expect(resolveToolDisplayLabel("AgentTool")).toBe("邀请协作成员"); + expect(resolveToolDisplayLabel("AgentTool")).toBe("创建子任务"); expect(resolveToolDisplayLabel("SendMessageTool")).toBe("补充说明"); expect(resolveToolDisplayLabel("TeamCreateTool")).toBe("创建团队"); expect(resolveToolDisplayLabel("TeamDeleteTool")).toBe("删除团队"); diff --git a/src/components/agent/chat/utils/toolDisplayInfo.ts b/src/components/agent/chat/utils/toolDisplayInfo.ts index 670ae946c..e6ec538e0 100644 --- a/src/components/agent/chat/utils/toolDisplayInfo.ts +++ b/src/components/agent/chat/utils/toolDisplayInfo.ts @@ -129,9 +129,9 @@ const TOOL_STATUS_ACTIONS = { running: "创建任务中", }, subagent: { - failed: "协作失败", - completed: "协作完成", - running: "协作中", + failed: "子任务失败", + completed: "子任务完成", + running: "子任务处理中", }, vision: { failed: "分析失败", @@ -806,10 +806,10 @@ const EXACT_TOOL_CONFIGS = new Map([ "agent", { family: "subagent", - label: "邀请协作成员", - verb: "邀请", + label: "创建子任务", + verb: "创建", icon: Globe, - groupTitle: "协作", + groupTitle: "子任务", actionKey: "subagent", }, ], @@ -820,7 +820,7 @@ const EXACT_TOOL_CONFIGS = new Map([ label: "补充说明", verb: "发送", icon: Globe, - groupTitle: "协作", + groupTitle: "子任务", actionKey: "subagent", }, ], @@ -860,10 +860,10 @@ const EXACT_TOOL_CONFIGS = new Map([ "listpeers", { family: "list", - label: "协作成员", + label: "子任务", verb: "查看", icon: Globe, - groupTitle: "协作", + groupTitle: "子任务", actionKey: "list", }, ], @@ -874,7 +874,7 @@ const EXACT_TOOL_CONFIGS = new Map([ label: "查看任务进展", verb: "查看", icon: Globe, - groupTitle: "协作", + groupTitle: "子任务", actionKey: "subagent", }, ], @@ -885,7 +885,7 @@ const EXACT_TOOL_CONFIGS = new Map([ label: "继续处理", verb: "继续", icon: Globe, - groupTitle: "协作", + groupTitle: "子任务", actionKey: "subagent", }, ], @@ -896,7 +896,7 @@ const EXACT_TOOL_CONFIGS = new Map([ label: "暂停处理", verb: "暂停", icon: Globe, - groupTitle: "协作", + groupTitle: "子任务", actionKey: "subagent", }, ], @@ -1590,7 +1590,7 @@ export const resolveToolPrimarySubject = ( if (normalizedName === "sendmessage") { return ( resolveToolArgumentPreview(args, ["message", "id", "agent_id"]) || - "目标协作成员" + "目标子任务" ); } @@ -2033,10 +2033,10 @@ export const buildToolGroupHeadline = (toolCalls: ToolCallState[]): string => { if (info.family === "subagent") { return failed - ? `协作失败 ${toolCalls.length} 项` + ? `子任务失败 ${toolCalls.length} 项` : running - ? `协作进行中 ${toolCalls.length} 项` - : `已完成 ${toolCalls.length} 项协作操作`; + ? `子任务处理中 ${toolCalls.length} 项` + : `已完成 ${toolCalls.length} 项子任务操作`; } if (info.family === "task") { diff --git a/src/components/agent/chat/workspace/WorkspaceHarnessDialogs.tsx b/src/components/agent/chat/workspace/WorkspaceHarnessDialogs.tsx index f9ef18761..21edc9d64 100644 --- a/src/components/agent/chat/workspace/WorkspaceHarnessDialogs.tsx +++ b/src/components/agent/chat/workspace/WorkspaceHarnessDialogs.tsx @@ -8,7 +8,6 @@ import type { TeamMemorySnapshot } from "@/lib/teamMemorySync"; type HarnessPanelBaseProps = Pick< ComponentProps, | "harnessState" - | "compatSubagentRuntime" | "environment" | "childSubagentSessions" | "selectedTeamLabel" @@ -124,7 +123,7 @@ export function GeneralWorkbenchDialogSection({ layout: "dialog", teamMemorySnapshot, title: "处理工作台", - description: "集中查看计划、待确认事项、协作成员、文件活动和处理结果。", + description: "集中查看计划、待确认事项、任务进展、文件活动和处理结果。", toggleLabel: "工作台详情", leadContent: (
@@ -133,7 +132,6 @@ export function GeneralWorkbenchDialogSection({ toolPreferences={toolPreferences} harnessState={panelBaseProps.harnessState} childSubagentSessions={panelBaseProps.childSubagentSessions} - compatSubagentRuntime={panelBaseProps.compatSubagentRuntime} variant="embedded" isSending={isSending} executionRuntime={executionRuntime} diff --git a/src/components/agent/chat/workspace/chatSurfaceProps.ts b/src/components/agent/chat/workspace/chatSurfaceProps.ts index be8f77937..bb96bae6e 100644 --- a/src/components/agent/chat/workspace/chatSurfaceProps.ts +++ b/src/components/agent/chat/workspace/chatSurfaceProps.ts @@ -3,8 +3,13 @@ import { StepProgress } from "@/lib/workspace/workbenchUi"; import { ChatNavbar } from "../components/ChatNavbar"; import { ChatSidebar } from "../components/ChatSidebar"; import { EmptyState } from "../components/EmptyState"; +import { TeamWorkspaceBoard } from "../components/TeamWorkspaceBoard"; import { TeamWorkspaceDock } from "../components/TeamWorkspaceDock"; -import type { TeamWorkbenchSurfaceProps } from "./teamWorkbenchPresentation"; + +export type TeamWorkbenchSurfaceProps = Omit< + ComponentProps, + "className" | "embedded" | "defaultShellExpanded" +>; type ChatToolPreferences = { webSearch: boolean; diff --git a/src/components/agent/chat/workspace/inputbarPresentation.ts b/src/components/agent/chat/workspace/inputbarPresentation.ts deleted file mode 100644 index d61c804ab..000000000 --- a/src/components/agent/chat/workspace/inputbarPresentation.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { ComponentProps } from "react"; -import { Inputbar } from "../components/Inputbar"; -import { TeamWorkspaceDock } from "../components/TeamWorkspaceDock"; -import type { TeamWorkbenchSurfaceProps } from "./teamWorkbenchPresentation"; - -interface BuildFloatingTeamWorkspaceDockPropsParams { - enabled: boolean; - layoutMode: "chat" | "chat-canvas"; - showFloatingInputOverlay: boolean; - onActivateWorkbench: NonNullable< - ComponentProps["onActivateWorkbench"] - >; - surfaceProps: TeamWorkbenchSurfaceProps; -} - -export function buildFloatingTeamWorkspaceDockProps({ - enabled, - layoutMode, - showFloatingInputOverlay, - onActivateWorkbench, - surfaceProps, -}: BuildFloatingTeamWorkspaceDockPropsParams): ComponentProps< - typeof TeamWorkspaceDock -> | null { - if (!enabled || !showFloatingInputOverlay || layoutMode !== "chat") { - return null; - } - - return { - placement: "inline", - onActivateWorkbench, - ...surfaceProps, - }; -} - -export function buildWorkspaceInputbarProps( - params: Omit, "overlayAccessory">, -): Omit, "overlayAccessory"> { - return params; -} diff --git a/src/components/agent/chat/workspace/runtimeTeamPreview.ts b/src/components/agent/chat/workspace/runtimeTeamPreview.ts deleted file mode 100644 index e4025269c..000000000 --- a/src/components/agent/chat/workspace/runtimeTeamPreview.ts +++ /dev/null @@ -1,186 +0,0 @@ -import type { AssistantDraftState } from "../hooks/agentChatShared"; -import type { TeamWorkspaceRuntimeFormationState } from "../teamWorkspaceRuntime"; -import type { AgentRuntimeStatus, Message, MessageImage } from "../types"; - -export interface RuntimeTeamDispatchPreviewSnapshot { - key: string; - prompt: string; - images: MessageImage[]; - baseMessageCount: number; - status: "forming" | "formed" | "failed"; - formationState?: TeamWorkspaceRuntimeFormationState | null; - failureMessage?: string | null; -} - -export function resolveRuntimeTeamDispatchPreviewState( - snapshot: RuntimeTeamDispatchPreviewSnapshot | null | undefined, -): TeamWorkspaceRuntimeFormationState | null { - const formationState = snapshot?.formationState ?? null; - if (!snapshot || !formationState) { - return null; - } - - const normalizedFailureMessage = snapshot.failureMessage?.trim() || null; - if ( - snapshot.status === formationState.status && - !(snapshot.status === "failed" && normalizedFailureMessage) - ) { - return formationState; - } - - return { - ...formationState, - status: snapshot.status, - errorMessage: - snapshot.status === "failed" - ? normalizedFailureMessage || - formationState.errorMessage?.trim() || - null - : null, - }; -} - -function buildRuntimeTeamMemberPlanLines( - state: TeamWorkspaceRuntimeFormationState, -): string[] { - const members = state.members.slice(0, 3); - const lines = members.map((member, index) => { - const label = member.label.trim() || `成员 ${index + 1}`; - const summary = member.summary.trim() || "负责分担当前任务中的一部分工作。"; - return `${index + 1}. ${label}:${summary}`; - }); - - if (state.members.length > members.length) { - lines.push( - `另外还有 ${state.members.length - members.length} 位成员会继续配合处理。`, - ); - } - - return lines; -} - -function buildRuntimeTeamAssistantDraft( - state: TeamWorkspaceRuntimeFormationState | null | undefined, -): AssistantDraftState | undefined { - if (!state || state.status !== "formed") { - return undefined; - } - - const teamLabel = - state.label?.trim() || state.blueprint?.label?.trim() || "当前任务方案"; - const summary = - state.summary?.trim() || state.blueprint?.summary?.trim() || ""; - const planLines = buildRuntimeTeamMemberPlanLines(state); - const contentSections = [ - `我已经为这项任务准备了「${teamLabel}」。`, - summary ? `会先按“${summary}”来推进。` : null, - planLines.length > 0 ? `分工如下:\n${planLines.join("\n")}` : null, - "接下来我会让他们分别处理,再把关键进展、风险和需要你确认的事项汇总给你。", - ].filter(Boolean); - - const initialRuntimeStatus: AgentRuntimeStatus = { - phase: "routing", - title: "任务分工已准备好", - detail: - summary || "已整理好当前任务的分工,接下来会分别展开处理并同步结果。", - checkpoints: [ - `当前方案:${teamLabel}`, - `已安排 ${Math.max(state.members.length, 1)} 位协作成员`, - "主对话会持续同步关键进展", - ], - }; - - const waitingRuntimeStatus: AgentRuntimeStatus = { - phase: "routing", - title: "成员开始接手任务", - detail: - summary || "分工已经确认,协作成员会按各自职责继续处理并回传关键结果。", - checkpoints: [ - `当前方案:${teamLabel}`, - planLines[0] || "成员会分别接手自己的部分", - "主对话会持续同步关键进展", - ], - }; - - return { - content: contentSections.join("\n\n"), - initialRuntimeStatus, - waitingRuntimeStatus, - }; -} - -export function buildRuntimeTeamDispatchPreviewMessages( - snapshot: RuntimeTeamDispatchPreviewSnapshot, -): Message[] { - const normalizedPrompt = snapshot.prompt.trim(); - const timestamp = new Date(); - const formedAssistantDraft = - snapshot.status === "formed" - ? buildRuntimeTeamAssistantDraft(snapshot.formationState) - : undefined; - const formedTeamLabel = - snapshot.formationState?.label?.trim() || - snapshot.formationState?.blueprint?.label?.trim() || - "当前任务方案"; - const formedSummary = - snapshot.formationState?.summary?.trim() || - snapshot.formationState?.blueprint?.summary?.trim() || - ""; - const assistantRuntimeStatus = - snapshot.status === "failed" - ? { - phase: "failed" as const, - title: "任务分工准备失败", - detail: - snapshot.failureMessage?.trim() || - "这次任务分工准备失败,已回退到普通对话发送。", - } - : snapshot.status === "formed" - ? formedAssistantDraft?.initialRuntimeStatus || { - phase: "routing" as const, - title: "任务分工已准备好", - detail: - formedSummary || - "已整理好当前任务的分工,接下来会分别展开处理并同步结果。", - checkpoints: [ - `当前方案:${formedTeamLabel}`, - "协作成员会按分工开始接手", - "主对话会持续同步关键进展", - ], - } - : { - phase: "routing" as const, - title: "正在准备任务分工", - detail: - "系统正在根据当前任务安排分工,会先接入合适的成员,再把关键进展持续汇总回主对话。", - checkpoints: [ - "确认当前任务目标", - "安排任务分工", - "等待成员接手处理", - ], - }; - - return [ - { - id: `runtime-team-dispatch:${snapshot.key}:user`, - role: "user", - content: normalizedPrompt, - images: snapshot.images.length > 0 ? snapshot.images : undefined, - timestamp, - }, - { - id: `runtime-team-dispatch:${snapshot.key}:assistant`, - role: "assistant", - content: - snapshot.status === "failed" - ? "这次任务分工准备失败,已回退到普通执行。" - : snapshot.status === "formed" - ? formedAssistantDraft?.content || - `我已经为这项任务准备了「${formedTeamLabel}」。\n\n接下来我会让他们分别处理,再把关键进展和结果汇总给你。` - : "我会先安排任务分工,再把关键进展和结果汇总给你。", - timestamp: new Date(timestamp.getTime() + 1), - isThinking: snapshot.status === "forming", - runtimeStatus: assistantRuntimeStatus, - }, - ]; -} diff --git a/src/components/agent/chat/workspace/submissionPreview.ts b/src/components/agent/chat/workspace/submissionPreview.ts deleted file mode 100644 index 41e64e2c3..000000000 --- a/src/components/agent/chat/workspace/submissionPreview.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime"; -import type { Message, MessageImage } from "../types"; -import { - buildWaitingAgentRuntimeStatus, - formatAgentRuntimeStatusSummary, -} from "../utils/agentRuntimeStatus"; - -export interface SubmissionPreviewSnapshot { - key: string; - prompt: string; - images: MessageImage[]; - createdAt: number; - runtimeStatus: NonNullable; -} - -interface CreateSubmissionPreviewSnapshotOptions { - key: string; - prompt: string; - images: MessageImage[]; - executionStrategy: AsterExecutionStrategy; - webSearch?: boolean; - thinking?: boolean; -} - -export function createSubmissionPreviewSnapshot( - options: CreateSubmissionPreviewSnapshotOptions, -): SubmissionPreviewSnapshot { - const { key, prompt, images, executionStrategy, webSearch, thinking } = - options; - - return { - key, - prompt, - images, - createdAt: Date.now(), - runtimeStatus: buildWaitingAgentRuntimeStatus({ - executionStrategy, - webSearch, - thinking, - }), - }; -} - -export function buildSubmissionPreviewMessages( - snapshot: SubmissionPreviewSnapshot, -): Message[] { - const timestamp = new Date(snapshot.createdAt); - - return [ - { - id: `submission-preview:${snapshot.key}:user`, - role: "user", - content: snapshot.prompt, - images: snapshot.images.length > 0 ? snapshot.images : undefined, - timestamp, - }, - { - id: `submission-preview:${snapshot.key}:assistant`, - role: "assistant", - content: formatAgentRuntimeStatusSummary(snapshot.runtimeStatus), - timestamp: new Date(timestamp.getTime() + 1), - isThinking: true, - runtimeStatus: snapshot.runtimeStatus, - }, - ]; -} diff --git a/src/components/agent/chat/workspace/teamWorkbenchPresentation.tsx b/src/components/agent/chat/workspace/teamWorkbenchPresentation.tsx deleted file mode 100644 index 49e0b67a6..000000000 --- a/src/components/agent/chat/workspace/teamWorkbenchPresentation.tsx +++ /dev/null @@ -1,344 +0,0 @@ -import { - useCallback, - useMemo, - type ComponentProps, - type ReactNode, -} from "react"; -import type { - CanvasWorkbenchHeaderBadge, - CanvasWorkbenchSummaryStat, - CanvasWorkbenchTeamView, -} from "../components/CanvasWorkbenchLayout"; -import { TeamWorkbenchSummaryPanel } from "../components/TeamWorkbenchSummaryPanel"; -import { TeamWorkspaceBoard } from "../components/TeamWorkspaceBoard"; -import { - summarizeTeamWorkspaceExecution, - type TeamWorkspaceActivityEntry, - type TeamWorkspaceControlSummary, - type TeamWorkspaceLiveRuntimeState, - type TeamWorkspaceRuntimeFormationState, - type TeamWorkspaceWaitSummary, -} from "../teamWorkspaceRuntime"; -import { wrapPreviewWithWorkbenchTrigger } from "./workbenchPreviewHelpers"; -import type { TeamMemorySnapshot } from "@/lib/teamMemorySync"; - -export type TeamWorkbenchSurfaceProps = Omit< - ComponentProps, - "className" | "embedded" | "defaultShellExpanded" ->; - -interface UseTeamWorkbenchPresentationParams { - enabled: boolean; - surfaceProps: TeamWorkbenchSurfaceProps; - hasRealTeamGraph: boolean; - autoFocusToken?: string | number | null; - teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null; - liveActivityBySessionId?: Record; - teamWaitSummary?: TeamWorkspaceWaitSummary | null; - teamControlSummary?: TeamWorkspaceControlSummary | null; - teamMemorySnapshot?: TeamMemorySnapshot | null; -} - -function resolveTeamWorkbenchTriggerState(params: { - enabled: boolean; - hasRealTeamGraph: boolean; - teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null; - liveActivityBySessionId?: Record; - teamWaitSummary?: TeamWorkspaceWaitSummary | null; - teamControlSummary?: TeamWorkspaceControlSummary | null; - executionSummary: ReturnType; -}): CanvasWorkbenchTeamView["triggerState"] { - const dispatchPreviewState = params.teamDispatchPreviewState; - if (!params.enabled) { - return null; - } - - if (dispatchPreviewState?.status === "failed") { - return { tone: "error", label: "失败" }; - } - - if (dispatchPreviewState?.status === "forming") { - return { tone: "active", label: "组建中" }; - } - - if (params.executionSummary.runningSessionCount > 0) { - return { - tone: "active", - label: - params.executionSummary.runningSessionCount > 1 - ? `${params.executionSummary.runningSessionCount} 处理中` - : "处理中", - }; - } - - if (params.executionSummary.queuedSessionCount > 0) { - return { - tone: "active", - label: - params.executionSummary.queuedSessionCount > 1 - ? `${params.executionSummary.queuedSessionCount} 稍后开始` - : "稍后开始", - }; - } - - if (dispatchPreviewState?.status === "formed" && !params.hasRealTeamGraph) { - return { tone: "active", label: "已就绪" }; - } - - if ( - Object.values(params.liveActivityBySessionId ?? {}).some( - (entries) => (entries?.length ?? 0) > 0, - ) || - Boolean(params.teamWaitSummary) || - Boolean(params.teamControlSummary) - ) { - return { tone: "active", label: "有更新" }; - } - - return { tone: "idle", label: null }; -} - -export function useTeamWorkbenchPresentation({ - enabled, - surfaceProps, - hasRealTeamGraph, - autoFocusToken, - teamDispatchPreviewState = null, - liveActivityBySessionId = {}, - teamWaitSummary = null, - teamControlSummary = null, - teamMemorySnapshot = null, -}: UseTeamWorkbenchPresentationParams) { - const dispatchPreviewState = teamDispatchPreviewState; - const boardProps = useMemo>( - () => ({ - ...surfaceProps, - embedded: true, - defaultShellExpanded: true, - }), - [surfaceProps], - ); - - const renderTeamWorkbenchPreview = useCallback( - (stackedWorkbenchTrigger?: ReactNode) => - wrapPreviewWithWorkbenchTrigger( -
-
- -
-
, - stackedWorkbenchTrigger, - ), - [boardProps], - ); - - const summaryPanelProps = useMemo< - ComponentProps - >( - () => ({ - currentSessionId: surfaceProps.currentSessionId, - currentSessionRuntimeStatus: surfaceProps.currentSessionRuntimeStatus, - currentSessionLatestTurnStatus: - surfaceProps.currentSessionLatestTurnStatus, - currentSessionQueuedTurnCount: surfaceProps.currentSessionQueuedTurnCount, - childSubagentSessions: surfaceProps.childSubagentSessions, - subagentParentContext: surfaceProps.subagentParentContext, - liveRuntimeBySessionId: surfaceProps.liveRuntimeBySessionId, - liveActivityBySessionId, - teamWaitSummary, - teamControlSummary, - selectedTeamLabel: surfaceProps.selectedTeamLabel, - selectedTeamSummary: surfaceProps.selectedTeamSummary, - selectedTeamRoles: surfaceProps.selectedTeamRoles, - teamDispatchPreviewState: surfaceProps.teamDispatchPreviewState, - teamMemorySnapshot, - }), - [ - liveActivityBySessionId, - surfaceProps, - teamControlSummary, - teamMemorySnapshot, - teamWaitSummary, - ], - ); - - const teamWorkbenchSummaryPanel = useMemo( - () => , - [summaryPanelProps], - ); - - const executionSummary = useMemo( - () => - summarizeTeamWorkspaceExecution({ - currentSessionId: surfaceProps.currentSessionId, - currentSessionRuntimeStatus: surfaceProps.currentSessionRuntimeStatus, - currentSessionLatestTurnStatus: - surfaceProps.currentSessionLatestTurnStatus, - currentSessionQueuedTurnCount: - surfaceProps.currentSessionQueuedTurnCount, - childSubagentSessions: surfaceProps.childSubagentSessions, - subagentParentContext: surfaceProps.subagentParentContext, - liveRuntimeBySessionId: surfaceProps.liveRuntimeBySessionId as - | Record - | undefined, - }), - [surfaceProps], - ); - - const triggerState = useMemo( - () => - resolveTeamWorkbenchTriggerState({ - enabled, - hasRealTeamGraph, - teamDispatchPreviewState, - liveActivityBySessionId, - teamWaitSummary, - teamControlSummary, - executionSummary, - }), - [ - enabled, - executionSummary, - hasRealTeamGraph, - liveActivityBySessionId, - teamDispatchPreviewState, - teamControlSummary, - teamWaitSummary, - ], - ); - - const headerBadges = useMemo(() => { - const badges: CanvasWorkbenchHeaderBadge[] = [ - { - key: "team-runtime", - label: "Team Workbench", - tone: "accent", - }, - ]; - - if (triggerState?.label?.trim()) { - badges.push({ - key: "team-trigger-state", - label: triggerState.label.trim(), - tone: triggerState.tone === "active" ? "accent" : "default", - }); - } - - if (teamWaitSummary?.awaitedSessionIds?.length) { - badges.push({ - key: "team-awaiting", - label: `等待 ${teamWaitSummary.awaitedSessionIds.length}`, - tone: "default", - }); - } - - return badges; - }, [teamWaitSummary?.awaitedSessionIds, triggerState]); - - const summaryStats = useMemo(() => { - const leadStatus = - triggerState?.label?.trim() || executionSummary.statusTitle || "待机"; - const leadDetail = - executionSummary.statusTitle || "当前没有活跃的协作执行。"; - - const stats: CanvasWorkbenchSummaryStat[] = [ - { - key: "team-status", - label: "协作状态", - value: leadStatus, - detail: leadDetail, - tone: triggerState?.tone === "active" ? "accent" : "default", - }, - { - key: "team-members", - label: "活跃成员", - value: - executionSummary.totalSessionCount > 0 - ? `${executionSummary.activeSessionCount}/${executionSummary.totalSessionCount}` - : "0", - detail: - executionSummary.totalSessionCount > 0 - ? `${executionSummary.runningSessionCount} 位处理中,${executionSummary.queuedSessionCount} 位排队中。` - : "当前还没有可展示的协作成员。", - tone: executionSummary.activeSessionCount > 0 ? "accent" : "default", - }, - ]; - - if (teamWaitSummary?.awaitedSessionIds?.length) { - stats.push({ - key: "team-awaiting", - label: "等待确认", - value: `${teamWaitSummary.awaitedSessionIds.length} 项`, - detail: teamWaitSummary.timedOut - ? "等待结果超时,建议重新检查成员状态。" - : "正在等待成员完成或返回结果。", - tone: "default", - }); - } else if (teamControlSummary?.affectedSessionIds?.length) { - stats.push({ - key: "team-control", - label: "最近控制", - value: - teamControlSummary.action === "resume" - ? "恢复执行" - : teamControlSummary.action === "close_completed" - ? "清理已完成" - : "关闭协作", - detail: `影响 ${teamControlSummary.affectedSessionIds.length} 个会话。`, - tone: "default", - }); - } - - return stats; - }, [executionSummary, teamControlSummary, teamWaitSummary, triggerState]); - - const teamWorkbenchView = useMemo(() => { - if (!enabled) { - return null; - } - - return { - enabled: true, - title: - dispatchPreviewState?.label?.trim() || - dispatchPreviewState?.blueprint?.label?.trim() || - surfaceProps.selectedTeamLabel || - "团队工作台", - tabLabel: surfaceProps.selectedTeamLabel?.trim() || undefined, - tabBadge: triggerState?.label?.trim() || undefined, - tabBadgeTone: - triggerState?.tone === "error" - ? "rose" - : triggerState?.tone === "active" - ? "sky" - : "slate", - subtitle: "主对话保留调度记录,画布按角色分别展示执行过程与结果。", - autoFocusToken, - preferFixedPanel: true, - triggerState, - badges: headerBadges, - summaryStats, - panelCopy: { - emptyText: "当前没有可展示的 Team Workbench。", - }, - renderPreview: (options?: { stackedWorkbenchTrigger?: ReactNode }) => - renderTeamWorkbenchPreview(options?.stackedWorkbenchTrigger), - renderPanel: () => teamWorkbenchSummaryPanel, - }; - }, [ - autoFocusToken, - enabled, - renderTeamWorkbenchPreview, - dispatchPreviewState, - headerBadges, - summaryStats, - surfaceProps.selectedTeamLabel, - teamWorkbenchSummaryPanel, - triggerState, - ]); - - return { - renderTeamWorkbenchPreview, - teamWorkbenchView, - }; -} diff --git a/src/components/agent/chat/workspace/useGeneralWorkbenchSidebarPresentation.tsx b/src/components/agent/chat/workspace/useGeneralWorkbenchSidebarPresentation.tsx deleted file mode 100644 index 44066be2d..000000000 --- a/src/components/agent/chat/workspace/useGeneralWorkbenchSidebarPresentation.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { useCallback, type ComponentProps, type ReactNode } from "react"; -import { WorkspaceGeneralWorkbenchSidebar } from "./WorkspaceGeneralWorkbenchSidebar"; -import { GeneralWorkbenchHarnessDialogSection } from "./WorkspaceHarnessDialogs"; - -type WorkspaceGeneralWorkbenchSidebarProps = ComponentProps< - typeof WorkspaceGeneralWorkbenchSidebar ->; - -type GeneralWorkbenchSidebarWorkflowParams = Omit< - WorkspaceGeneralWorkbenchSidebarProps["workflow"], - "onDeleteTopic" ->; - -interface UseGeneralWorkbenchSidebarPresentationParams { - showChatPanel: boolean; - showSidebar: boolean; - hasPendingA2UIForm: boolean; - isThemeWorkbench: boolean; - shouldUseCompactGeneralWorkbench: boolean; - enablePanelCollapse: boolean; - sidebarCollapsed: boolean; - onSidebarCollapsedChange: (collapsed: boolean) => void; - sidebar: Omit< - WorkspaceGeneralWorkbenchSidebarProps, - | "visible" - | "isThemeWorkbench" - | "enablePanelCollapse" - | "onRequestCollapse" - | "workflow" - > & { - workflow: GeneralWorkbenchSidebarWorkflowParams; - }; - harnessDialog: ComponentProps; -} - -interface GeneralWorkbenchSidebarPresentationResult { - generalWorkbenchHarnessDialog: ReactNode; - generalWorkbenchSidebarNode: ReactNode; - showGeneralWorkbenchLeftExpandButton: boolean; - onExpandGeneralWorkbenchSidebar: () => void; -} - -export function useGeneralWorkbenchSidebarPresentation({ - showChatPanel, - showSidebar, - hasPendingA2UIForm, - isThemeWorkbench, - shouldUseCompactGeneralWorkbench, - enablePanelCollapse, - sidebarCollapsed, - onSidebarCollapsedChange, - sidebar, - harnessDialog, -}: UseGeneralWorkbenchSidebarPresentationParams): GeneralWorkbenchSidebarPresentationResult { - const shouldShowGeneralWorkbenchSidebarForTheme = - !shouldUseCompactGeneralWorkbench; - const showGeneralWorkbenchSidebar = - showChatPanel && - showSidebar && - !hasPendingA2UIForm && - isThemeWorkbench && - shouldShowGeneralWorkbenchSidebarForTheme && - (!enablePanelCollapse || !sidebarCollapsed); - const showGeneralWorkbenchLeftExpandButton = - showChatPanel && - showSidebar && - !hasPendingA2UIForm && - isThemeWorkbench && - shouldShowGeneralWorkbenchSidebarForTheme && - enablePanelCollapse && - sidebarCollapsed; - - const handleGeneralWorkbenchDeleteTopic = useCallback(() => undefined, []); - const handleGeneralWorkbenchSidebarCollapse = useCallback(() => { - onSidebarCollapsedChange(true); - }, [onSidebarCollapsedChange]); - const handleExpandGeneralWorkbenchSidebar = useCallback(() => { - onSidebarCollapsedChange(false); - }, [onSidebarCollapsedChange]); - - return { - generalWorkbenchHarnessDialog: ( - - ), - generalWorkbenchSidebarNode: ( - - ), - showGeneralWorkbenchLeftExpandButton, - onExpandGeneralWorkbenchSidebar: handleExpandGeneralWorkbenchSidebar, - }; -} diff --git a/src/components/agent/chat/workspace/useWorkspaceA2UIRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceA2UIRuntime.test.tsx index 1dea27e29..279affc0a 100644 --- a/src/components/agent/chat/workspace/useWorkspaceA2UIRuntime.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceA2UIRuntime.test.tsx @@ -123,6 +123,32 @@ describe("useWorkspaceA2UIRuntime", () => { expect(getValue().a2uiSubmissionNotice).toBeNull(); }); + it("历史问卷正文不应再生成 pending A2UI 表单", async () => { + const legacyQuestionnaireMessage = createAssistantMessage( + "assistant-legacy-questionnaire", + `为了继续推进,我需要你先补充以下信息: + +1. 目标与对象 +- 这次内容主要面向谁?(客户 / 上级 / 同事) +- 这次最想达成的目标是什么? + +2. 风格与限制 +- 语气偏好:正式严谨 / 友好专业 / 直接高效 +- 是否需要加入明确行动号召?`, + ); + const { render, getValue } = renderHook({ + messages: [legacyQuestionnaireMessage], + }); + + await render({ messages: [legacyQuestionnaireMessage] }); + + expect(getValue().pendingA2UIForm).toBeNull(); + expect(getValue().pendingA2UISource).toBeNull(); + expect(getValue().pendingActionRequest).toBeNull(); + expect(getValue().pendingPromotedA2UIActionRequest).toBeNull(); + expect(getValue().a2uiSubmissionNotice).toBeNull(); + }); + it("内联 A2UI 在消息内容短暂变成不完整 JSON 时应保留最后一份有效表单", async () => { const validA2UIMessage = createAssistantMessage( "assistant-a2ui", diff --git a/src/components/agent/chat/workspace/useWorkspaceA2UIRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceA2UIRuntime.ts index c349a774d..c8040daad 100644 --- a/src/components/agent/chat/workspace/useWorkspaceA2UIRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceA2UIRuntime.ts @@ -5,7 +5,6 @@ import { buildActionRequestA2UI, isActionRequestA2UICompatible, } from "../utils/actionRequestA2UI"; -import { buildCompatQuestionnaireA2UI } from "../utils/compatQuestionnaireA2UI"; import { buildProgressiveA2UIStepForm, hasMeaningfulProgressiveA2UIAnswers, @@ -23,7 +22,7 @@ type PendingA2UIResolution = | { form: A2UIResponse; source: PendingA2UISource & { - kind: "assistant_message" | "legacy_message"; + kind: "assistant_message"; }; } | { @@ -98,7 +97,6 @@ export function useWorkspaceA2UIRuntime({ pendingA2UIForm: A2UIResponse | null; pendingA2UISource: PendingA2UISource | null; pendingActionRequest: ActionRequired | null; - pendingLegacyQuestionnaireA2UIForm: A2UIResponse | null; pendingPromotedA2UIActionRequest: ActionRequired | null; resolvePendingA2UISubmit: ( formData: A2UIFormData, @@ -186,46 +184,6 @@ export function useWorkspaceA2UIRuntime({ return null; }, [messages, pendingMessageA2UIForm]); - const pendingLegacyQuestionnaireA2UI = - useMemo(() => { - if (pendingMessageA2UIForm || pendingActionRequest) { - return null; - } - - for (let i = messages.length - 1; i >= 0; i -= 1) { - const message = messages[i]; - - if (message.role === "user") { - return null; - } - - if (message.role !== "assistant") { - continue; - } - - if ((message.actionRequests || []).length > 0) { - return null; - } - - const form = buildCompatQuestionnaireA2UI(message.content || ""); - if (!form) { - return null; - } - - return { - form, - source: { - kind: "legacy_message", - messageId: message.id, - }, - }; - } - - return null; - }, [messages, pendingActionRequest, pendingMessageA2UIForm]); - const pendingLegacyQuestionnaireA2UIForm = - pendingLegacyQuestionnaireA2UI?.form ?? null; - const resolvedPendingA2UI = useMemo(() => { if (pendingMessageA2UI) { return pendingMessageA2UI; @@ -246,12 +204,8 @@ export function useWorkspaceA2UIRuntime({ }; } - return pendingLegacyQuestionnaireA2UI; - }, [ - pendingLegacyQuestionnaireA2UI, - pendingMessageA2UI, - pendingPromotedA2UIActionRequest, - ]); + return null; + }, [pendingMessageA2UI, pendingPromotedA2UIActionRequest]); const hasRecentA2UISubmission = useMemo(() => { if (resolvedPendingA2UI) { @@ -277,13 +231,7 @@ export function useWorkspaceA2UIRuntime({ if (message.role !== "user") { continue; } - - const content = message.content.trim(); - if (!content.startsWith("我的选择:")) { - return false; - } - - return true; + return false; } return false; @@ -451,7 +399,6 @@ export function useWorkspaceA2UIRuntime({ pendingA2UIForm, pendingA2UISource, pendingActionRequest, - pendingLegacyQuestionnaireA2UIForm, pendingPromotedA2UIActionRequest, resolvePendingA2UISubmit, }; diff --git a/src/components/agent/chat/workspace/useWorkspaceA2UISubmitActions.ts b/src/components/agent/chat/workspace/useWorkspaceA2UISubmitActions.ts index bc4c52e57..f14acd89a 100644 --- a/src/components/agent/chat/workspace/useWorkspaceA2UISubmitActions.ts +++ b/src/components/agent/chat/workspace/useWorkspaceA2UISubmitActions.ts @@ -1,15 +1,13 @@ import { useCallback } from "react"; import { toast } from "sonner"; -import type { A2UIFormData, A2UIResponse } from "@/lib/workspace/a2ui"; +import type { A2UIFormData } from "@/lib/workspace/a2ui"; import type { ConfirmResponse } from "../types"; import type { SendMessageFn } from "../hooks/agentChatShared"; import type { ActionRequired } from "../types"; import { buildActionRequestSubmissionPayload } from "../utils/actionRequestA2UI"; -import { buildCompatQuestionnaireSubmissionPayload } from "../utils/compatQuestionnaireA2UI"; interface UseWorkspaceA2UISubmitActionsParams { handlePermissionResponse: (response: ConfirmResponse) => Promise; - pendingLegacyQuestionnaireA2UIForm: A2UIResponse | null; pendingPromotedA2UIActionRequest: ActionRequired | null; resolvePendingA2UISubmit: (formData: A2UIFormData) => { status: "advance" | "empty" | "submit"; @@ -20,7 +18,6 @@ interface UseWorkspaceA2UISubmitActionsParams { export function useWorkspaceA2UISubmitActions({ handlePermissionResponse, - pendingLegacyQuestionnaireA2UIForm, pendingPromotedA2UIActionRequest, resolvePendingA2UISubmit, sendMessage, @@ -73,42 +70,13 @@ export function useWorkspaceA2UISubmitActions({ return; } - if (pendingLegacyQuestionnaireA2UIForm) { - const submissionPayload = buildCompatQuestionnaireSubmissionPayload( - pendingLegacyQuestionnaireA2UIForm, - effectiveFormData, - ); - - if (!submissionPayload) { - toast.info("请至少补充一项信息后再继续"); - return; - } - - void sendMessage( - submissionPayload.formattedMessage, - [], - false, - false, - false, - undefined, - undefined, - undefined, - { - requestMetadata: submissionPayload.requestMetadata, - }, - ); - return; - } - void handleA2UISubmit(effectiveFormData, ""); }, [ handleA2UISubmit, handlePermissionResponse, - pendingLegacyQuestionnaireA2UIForm, pendingPromotedA2UIActionRequest, resolvePendingA2UISubmit, - sendMessage, ], ); diff --git a/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.test.tsx index 9cc6f3459..b19c937a5 100644 --- a/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.test.tsx @@ -38,7 +38,7 @@ function renderHook(props?: Partial) { content: "", isEditing: false, }, - showTeamWorkspaceBoard: false, + teamWorkspaceEnabled: false, hasCurrentCanvasArtifact: false, currentCanvasArtifactType: null, hasBrowserAssistArtifact: false, diff --git a/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.ts index 44bb97e6c..5dc5b33b8 100644 --- a/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.ts @@ -40,7 +40,7 @@ interface UseWorkspaceCanvasLayoutRuntimeParams { shouldBootstrapCanvasOnEntry: boolean; canvasState: CanvasStateUnion | null; generalCanvasState: GeneralCanvasState; - showTeamWorkspaceBoard: boolean; + teamWorkspaceEnabled: boolean; hasCurrentCanvasArtifact: boolean; currentCanvasArtifactType?: string | null; hasBrowserAssistArtifact: boolean; @@ -76,7 +76,7 @@ export function useWorkspaceCanvasLayoutRuntime({ shouldBootstrapCanvasOnEntry, canvasState, generalCanvasState, - showTeamWorkspaceBoard, + teamWorkspaceEnabled, hasCurrentCanvasArtifact, currentCanvasArtifactType, hasBrowserAssistArtifact, @@ -266,7 +266,7 @@ export function useWorkspaceCanvasLayoutRuntime({ activeTheme !== "general" || !showChatPanel || layoutMode === "chat" || - showTeamWorkspaceBoard || + teamWorkspaceEnabled || hasCurrentCanvasArtifact || currentImageWorkbenchActive ) { @@ -287,7 +287,7 @@ export function useWorkspaceCanvasLayoutRuntime({ layoutMode, setLayoutMode, showChatPanel, - showTeamWorkspaceBoard, + teamWorkspaceEnabled, ]); useEffect(() => { @@ -343,7 +343,7 @@ export function useWorkspaceCanvasLayoutRuntime({ const handleToggleCanvas = useCallback(() => { if (activeTheme === "general" && !isThemeWorkbench) { const shouldManageStandaloneGeneralCanvas = - !showTeamWorkspaceBoard && + !teamWorkspaceEnabled && !hasCurrentCanvasArtifact && !currentImageWorkbenchActive; @@ -397,7 +397,7 @@ export function useWorkspaceCanvasLayoutRuntime({ setCanvasState, setGeneralCanvasState, setLayoutMode, - showTeamWorkspaceBoard, + teamWorkspaceEnabled, suppressGeneralCanvasArtifactAutoOpen, ]); diff --git a/src/components/agent/chat/workspace/useWorkspaceCanvasPreviewPresentation.tsx b/src/components/agent/chat/workspace/useWorkspaceCanvasPreviewPresentation.tsx deleted file mode 100644 index 52b427bd6..000000000 --- a/src/components/agent/chat/workspace/useWorkspaceCanvasPreviewPresentation.tsx +++ /dev/null @@ -1,631 +0,0 @@ -import { - useCallback, - useMemo, - type ComponentProps, - type ReactNode, -} from "react"; -import { toast } from "sonner"; -import { ArtifactCanvasOverlay, ArtifactToolbar } from "@/components/artifact"; -import { CanvasFactory } from "@/lib/workspace/workbenchCanvas"; -import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas"; -import type { ThemeType } from "@/lib/workspace/workbenchContract"; -import { - CanvasPanel as GeneralCanvasPanel, - type CanvasState as GeneralCanvasState, -} from "@/components/general-chat/bridge"; -import { - openPathWithDefaultApp, - revealPathInFinder, -} from "@/lib/api/fileSystem"; -import type { Artifact } from "@/lib/artifact/types"; -import { ImageTaskViewer } from "../components/ImageTaskViewer"; -import type { - CanvasWorkbenchDefaultPreview, - CanvasWorkbenchPreviewTarget, - CanvasWorkbenchTeamView, -} from "../components/CanvasWorkbenchLayout"; -import type { TaskFile } from "../components/TaskFiles"; -import type { AgentThreadItem } from "../types"; -import { - ArtifactWorkbenchPreview, - WorkspaceLiveCanvasPreview, -} from "./workbenchPreview"; -import { wrapPreviewWithWorkbenchTrigger } from "./workbenchPreviewHelpers"; -import { buildCanvasWorkbenchDefaultPreview } from "./canvasWorkbenchDefaultPreview"; -import { resolveAbsoluteWorkspacePath } from "./workspacePath"; -import { buildGeneralCanvasStateFromWorkspaceFile } from "./workspaceFilePreview"; -import { - useTeamWorkbenchPresentation, - type TeamWorkbenchSurfaceProps, -} from "./teamWorkbenchPresentation"; -import { hasRenderableGeneralCanvasPreview } from "./generalCanvasPreviewState"; -import type { TeamMemorySnapshot } from "@/lib/teamMemorySync"; -import type { - TeamWorkspaceActivityEntry, - TeamWorkspaceControlSummary, - TeamWorkspaceRuntimeFormationState, - TeamWorkspaceWaitSummary, -} from "../teamWorkspaceRuntime"; - -type ArtifactPreviewBaseProps = Omit< - ComponentProps, - "artifact" | "stackedWorkbenchTrigger" | "onArtifactDocumentControllerChange" ->; -type ImageWorkbenchCanvasProps = ComponentProps; -type GeneralCanvasPanelProps = Omit< - ComponentProps, - "toolbarActions" ->; -type RenderArtifactWorkbenchPreviewOptions = { - stackedWorkbenchTrigger?: ReactNode; - onArtifactDocumentControllerChange?: ComponentProps< - typeof ArtifactWorkbenchPreview - >["onArtifactDocumentControllerChange"]; -}; - -interface WorkspaceCanvasDefaultPreviewParams { - workspaceRoot: string | null; - canvasRenderTheme: ThemeType; - generalCanvasState: GeneralCanvasState; - resolvedCanvasState: CanvasStateUnion | null; - activeCanvasTaskFile: TaskFile | null; -} - -interface WorkspaceCanvasPreviewArtifactParams { - currentCanvasArtifact: Artifact | null; - displayedCanvasArtifact: Artifact | null; - artifactOverlay: - | ComponentProps["overlay"] - | null; - showPreviousVersionBadge: boolean; - artifactViewMode: ComponentProps["viewMode"]; - onArtifactViewModeChange: NonNullable< - ComponentProps["onViewModeChange"] - >; - artifactPreviewSize: ComponentProps["previewSize"]; - onArtifactPreviewSizeChange: NonNullable< - ComponentProps["onPreviewSizeChange"] - >; - onSaveArtifactDocument?: ComponentProps< - typeof ArtifactWorkbenchPreview - >["onSaveArtifactDocument"]; - onArtifactBlockRewriteRun?: ComponentProps< - typeof ArtifactWorkbenchPreview - >["onArtifactBlockRewriteRun"]; - threadItems?: AgentThreadItem[]; - focusedBlockId?: string | null; - blockFocusRequestKey?: number; - onJumpToTimelineItem?: (itemId: string) => void; - onCloseCanvas: () => void; - renderToolbarActions?: ComponentProps< - typeof ArtifactWorkbenchPreview - >["renderToolbarActions"]; -} - -interface WorkspaceCanvasPreviewImageWorkbenchParams { - active: boolean; - tasks: ImageWorkbenchCanvasProps["tasks"]; - outputs: ImageWorkbenchCanvasProps["outputs"]; - selectedOutputId: ImageWorkbenchCanvasProps["selectedOutputId"]; - viewport: ImageWorkbenchCanvasProps["viewport"]; - preferenceSummary: ImageWorkbenchCanvasProps["preferenceSummary"]; - preferenceWarning: ImageWorkbenchCanvasProps["preferenceWarning"]; - availableProviders: ImageWorkbenchCanvasProps["availableProviders"]; - selectedProviderId: ImageWorkbenchCanvasProps["selectedProviderId"]; - onProviderChange: ImageWorkbenchCanvasProps["onProviderChange"]; - availableModels: ImageWorkbenchCanvasProps["availableModels"]; - selectedModelId: ImageWorkbenchCanvasProps["selectedModelId"]; - onModelChange: ImageWorkbenchCanvasProps["onModelChange"]; - selectedSize: ImageWorkbenchCanvasProps["selectedSize"]; - onSizeChange: ImageWorkbenchCanvasProps["onSizeChange"]; - generating: ImageWorkbenchCanvasProps["generating"]; - savingToResource: ImageWorkbenchCanvasProps["savingToResource"]; - onStopGeneration: ImageWorkbenchCanvasProps["onStopGeneration"]; - onViewportChange: ImageWorkbenchCanvasProps["onViewportChange"]; - onSelectOutput: ImageWorkbenchCanvasProps["onSelectOutput"]; - onSaveSelectedToLibrary: ImageWorkbenchCanvasProps["onSaveSelectedToLibrary"]; - applySelectedOutputLabel: ImageWorkbenchCanvasProps["applySelectedOutputLabel"]; - onApplySelectedOutput?: ImageWorkbenchCanvasProps["onApplySelectedOutput"]; - onSeedFollowUpCommand: ImageWorkbenchCanvasProps["onSeedFollowUpCommand"]; - onOpenImage: ImageWorkbenchCanvasProps["onOpenImage"]; -} - -interface WorkspaceCanvasPreviewGeneralCanvasParams { - state: GeneralCanvasState; - onCloseCanvas: () => void; - onContentChange: (content: string) => void; -} - -interface WorkspaceCanvasPreviewLoadingParams { - isInitialContentLoading: boolean; - initialContentLoadError?: string | null; - shouldShowCanvasLoadingState: boolean; -} - -interface WorkspaceCanvasPreviewFactoryParams { - canvasRenderTheme: ThemeType; - resolvedCanvasState: CanvasStateUnion | null; - onStateChange: ComponentProps["onStateChange"]; - onBackHome: NonNullable["onBackHome"]>; - onCloseCanvas: NonNullable["onClose"]>; - isStreaming: ComponentProps["isStreaming"]; - onSelectionTextChange: ComponentProps< - typeof CanvasFactory - >["onSelectionTextChange"]; - projectId: string | null; - contentId: string | null; - autoImageTopic?: string; - autoContinueProviderType: ComponentProps< - typeof CanvasFactory - >["autoContinueProviderType"]; - onAutoContinueProviderTypeChange: ComponentProps< - typeof CanvasFactory - >["onAutoContinueProviderTypeChange"]; - autoContinueModel: ComponentProps["autoContinueModel"]; - onAutoContinueModelChange: ComponentProps< - typeof CanvasFactory - >["onAutoContinueModelChange"]; - autoContinueThinkingEnabled: boolean; - onAutoContinueThinkingEnabledChange: NonNullable< - ComponentProps["onAutoContinueThinkingEnabledChange"] - >; - onAutoContinueRun: NonNullable< - ComponentProps["onAutoContinueRun"] - >; - onAddImage: ComponentProps["onAddImage"]; - onImportDocument: ComponentProps["onImportDocument"]; - onContentReviewRun: NonNullable< - ComponentProps["onContentReviewRun"] - >; - onTextStylizeRun: NonNullable< - ComponentProps["onTextStylizeRun"] - >; - preferContentReviewInRightRail: boolean; -} - -interface WorkspaceCanvasPreviewTeamWorkbenchParams { - enabled: boolean; - surfaceProps: TeamWorkbenchSurfaceProps; - hasRealTeamGraph: boolean; - autoFocusToken?: string | number | null; - teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null; - liveActivityBySessionId?: Record; - teamWaitSummary?: TeamWorkspaceWaitSummary | null; - teamControlSummary?: TeamWorkspaceControlSummary | null; - teamMemorySnapshot?: TeamMemorySnapshot | null; -} - -interface UseWorkspaceCanvasPreviewPresentationParams { - defaultPreview: WorkspaceCanvasDefaultPreviewParams; - artifactPreview: WorkspaceCanvasPreviewArtifactParams; - imageWorkbench: WorkspaceCanvasPreviewImageWorkbenchParams; - generalCanvas: WorkspaceCanvasPreviewGeneralCanvasParams; - loading: WorkspaceCanvasPreviewLoadingParams; - canvasFactory: WorkspaceCanvasPreviewFactoryParams; - teamWorkbench: WorkspaceCanvasPreviewTeamWorkbenchParams; -} - -interface WorkspaceCanvasPreviewPresentationResult { - canvasWorkbenchDefaultPreview: CanvasWorkbenchDefaultPreview | null; - handleOpenCanvasWorkbenchPath: (path: string) => Promise; - handleRevealCanvasWorkbenchPath: (path: string) => Promise; - liveCanvasPreview: ReactNode; - hasLiveCanvasPreviewContent: boolean; - teamWorkbenchView: CanvasWorkbenchTeamView | null; - renderCanvasWorkbenchPreview: ( - target: CanvasWorkbenchPreviewTarget, - options?: { stackedWorkbenchTrigger?: ReactNode }, - ) => ReactNode; -} - -export function useWorkspaceCanvasPreviewPresentation({ - defaultPreview, - artifactPreview, - imageWorkbench, - generalCanvas, - loading, - canvasFactory, - teamWorkbench, -}: UseWorkspaceCanvasPreviewPresentationParams): WorkspaceCanvasPreviewPresentationResult { - const canvasWorkbenchDefaultPreview = useMemo( - () => - buildCanvasWorkbenchDefaultPreview({ - workspaceRoot: defaultPreview.workspaceRoot, - canvasRenderTheme: defaultPreview.canvasRenderTheme, - generalCanvasState: defaultPreview.generalCanvasState, - resolvedCanvasState: defaultPreview.resolvedCanvasState, - activeCanvasTaskFile: defaultPreview.activeCanvasTaskFile, - }), - [ - defaultPreview.activeCanvasTaskFile, - defaultPreview.canvasRenderTheme, - defaultPreview.generalCanvasState, - defaultPreview.resolvedCanvasState, - defaultPreview.workspaceRoot, - ], - ); - - const handleOpenCanvasWorkbenchPath = useCallback(async (path: string) => { - try { - await openPathWithDefaultApp(path); - } catch (error) { - toast.error( - `打开文件失败: ${error instanceof Error ? error.message : String(error)}`, - ); - } - }, []); - - const handleRevealCanvasWorkbenchPath = useCallback(async (path: string) => { - try { - await revealPathInFinder(path); - } catch (error) { - toast.error( - `定位文件失败: ${error instanceof Error ? error.message : String(error)}`, - ); - } - }, []); - - const artifactWorkbenchPreviewBaseProps = useMemo( - () => ({ - currentCanvasArtifact: artifactPreview.currentCanvasArtifact, - displayedCanvasArtifact: artifactPreview.displayedCanvasArtifact, - artifactOverlay: artifactPreview.artifactOverlay, - showPreviousVersionBadge: artifactPreview.showPreviousVersionBadge, - artifactViewMode: artifactPreview.artifactViewMode, - onArtifactViewModeChange: artifactPreview.onArtifactViewModeChange, - artifactPreviewSize: artifactPreview.artifactPreviewSize, - onArtifactPreviewSizeChange: artifactPreview.onArtifactPreviewSizeChange, - onSaveArtifactDocument: artifactPreview.onSaveArtifactDocument, - onArtifactBlockRewriteRun: artifactPreview.onArtifactBlockRewriteRun, - threadItems: artifactPreview.threadItems, - focusedBlockId: artifactPreview.focusedBlockId, - blockFocusRequestKey: artifactPreview.blockFocusRequestKey, - onJumpToTimelineItem: artifactPreview.onJumpToTimelineItem, - onCloseCanvas: artifactPreview.onCloseCanvas, - renderToolbarActions: artifactPreview.renderToolbarActions, - }), - [ - artifactPreview.artifactOverlay, - artifactPreview.artifactPreviewSize, - artifactPreview.artifactViewMode, - artifactPreview.blockFocusRequestKey, - artifactPreview.currentCanvasArtifact, - artifactPreview.displayedCanvasArtifact, - artifactPreview.focusedBlockId, - artifactPreview.onArtifactPreviewSizeChange, - artifactPreview.onSaveArtifactDocument, - artifactPreview.onArtifactBlockRewriteRun, - artifactPreview.onArtifactViewModeChange, - artifactPreview.onJumpToTimelineItem, - artifactPreview.onCloseCanvas, - artifactPreview.renderToolbarActions, - artifactPreview.showPreviousVersionBadge, - artifactPreview.threadItems, - ], - ); - - const imageWorkbenchCanvasProps = useMemo( - () => ({ - tasks: imageWorkbench.tasks, - outputs: imageWorkbench.outputs, - selectedOutputId: imageWorkbench.selectedOutputId, - viewport: imageWorkbench.viewport, - preferenceSummary: imageWorkbench.preferenceSummary, - preferenceWarning: imageWorkbench.preferenceWarning, - availableProviders: imageWorkbench.availableProviders, - selectedProviderId: imageWorkbench.selectedProviderId, - onProviderChange: imageWorkbench.onProviderChange, - availableModels: imageWorkbench.availableModels, - selectedModelId: imageWorkbench.selectedModelId, - onModelChange: imageWorkbench.onModelChange, - selectedSize: imageWorkbench.selectedSize, - onSizeChange: imageWorkbench.onSizeChange, - generating: imageWorkbench.generating, - savingToResource: imageWorkbench.savingToResource, - onStopGeneration: imageWorkbench.onStopGeneration, - onViewportChange: imageWorkbench.onViewportChange, - onSelectOutput: imageWorkbench.onSelectOutput, - onSaveSelectedToLibrary: imageWorkbench.onSaveSelectedToLibrary, - applySelectedOutputLabel: imageWorkbench.applySelectedOutputLabel, - onApplySelectedOutput: imageWorkbench.onApplySelectedOutput, - onSeedFollowUpCommand: imageWorkbench.onSeedFollowUpCommand, - onOpenImage: imageWorkbench.onOpenImage, - }), - [ - imageWorkbench.applySelectedOutputLabel, - imageWorkbench.availableModels, - imageWorkbench.availableProviders, - imageWorkbench.generating, - imageWorkbench.onApplySelectedOutput, - imageWorkbench.onModelChange, - imageWorkbench.onOpenImage, - imageWorkbench.onProviderChange, - imageWorkbench.onSaveSelectedToLibrary, - imageWorkbench.onSeedFollowUpCommand, - imageWorkbench.onSelectOutput, - imageWorkbench.onSizeChange, - imageWorkbench.onStopGeneration, - imageWorkbench.onViewportChange, - imageWorkbench.outputs, - imageWorkbench.preferenceSummary, - imageWorkbench.preferenceWarning, - imageWorkbench.savingToResource, - imageWorkbench.selectedModelId, - imageWorkbench.selectedOutputId, - imageWorkbench.selectedProviderId, - imageWorkbench.selectedSize, - imageWorkbench.tasks, - imageWorkbench.viewport, - ], - ); - - const generalCanvasPanelProps = useMemo( - () => ({ - state: generalCanvas.state, - baseFilePath: resolveAbsoluteWorkspacePath( - defaultPreview.workspaceRoot, - generalCanvas.state.filename, - ), - onClose: generalCanvas.onCloseCanvas, - onContentChange: generalCanvas.onContentChange, - }), - [ - defaultPreview.workspaceRoot, - generalCanvas.onCloseCanvas, - generalCanvas.onContentChange, - generalCanvas.state, - ], - ); - - const canvasLoadingLabel = useMemo( - () => - loading.isInitialContentLoading - ? "正在加载文稿内容..." - : loading.initialContentLoadError || "正在准备文稿画布...", - [loading.initialContentLoadError, loading.isInitialContentLoading], - ); - - const canvasFactoryProps = useMemo | null>( - () => - canvasFactory.resolvedCanvasState - ? { - theme: canvasFactory.canvasRenderTheme, - state: canvasFactory.resolvedCanvasState, - onStateChange: canvasFactory.onStateChange, - onBackHome: canvasFactory.onBackHome, - onClose: canvasFactory.onCloseCanvas, - isStreaming: canvasFactory.isStreaming, - onSelectionTextChange: canvasFactory.onSelectionTextChange, - projectId: canvasFactory.projectId, - contentId: canvasFactory.contentId, - autoImageTopic: canvasFactory.autoImageTopic, - autoContinueProviderType: canvasFactory.autoContinueProviderType, - onAutoContinueProviderTypeChange: - canvasFactory.onAutoContinueProviderTypeChange, - autoContinueModel: canvasFactory.autoContinueModel, - onAutoContinueModelChange: canvasFactory.onAutoContinueModelChange, - autoContinueThinkingEnabled: - canvasFactory.autoContinueThinkingEnabled, - onAutoContinueThinkingEnabledChange: - canvasFactory.onAutoContinueThinkingEnabledChange, - onAutoContinueRun: canvasFactory.onAutoContinueRun, - onAddImage: canvasFactory.onAddImage, - onImportDocument: canvasFactory.onImportDocument, - onContentReviewRun: canvasFactory.onContentReviewRun, - onTextStylizeRun: canvasFactory.onTextStylizeRun, - documentContentReviewPlacement: - canvasFactory.preferContentReviewInRightRail - ? ("external-rail" as const) - : ("inline" as const), - } - : null, - [ - canvasFactory.autoContinueModel, - canvasFactory.autoContinueProviderType, - canvasFactory.autoContinueThinkingEnabled, - canvasFactory.autoImageTopic, - canvasFactory.canvasRenderTheme, - canvasFactory.contentId, - canvasFactory.isStreaming, - canvasFactory.onAddImage, - canvasFactory.onAutoContinueModelChange, - canvasFactory.onAutoContinueProviderTypeChange, - canvasFactory.onAutoContinueRun, - canvasFactory.onAutoContinueThinkingEnabledChange, - canvasFactory.onBackHome, - canvasFactory.onCloseCanvas, - canvasFactory.onContentReviewRun, - canvasFactory.onImportDocument, - canvasFactory.onSelectionTextChange, - canvasFactory.onStateChange, - canvasFactory.onTextStylizeRun, - canvasFactory.preferContentReviewInRightRail, - canvasFactory.projectId, - canvasFactory.resolvedCanvasState, - ], - ); - - const renderArtifactWorkbenchPreview = useCallback( - (artifact: Artifact, options?: RenderArtifactWorkbenchPreviewOptions) => ( - - ), - [artifactWorkbenchPreviewBaseProps], - ); - - const hasLiveCanvasPreviewContent = useMemo(() => { - if (imageWorkbench.active) { - return true; - } - - if (defaultPreview.canvasRenderTheme === "general") { - return Boolean( - (artifactPreview.currentCanvasArtifact && - artifactPreview.displayedCanvasArtifact) || - hasRenderableGeneralCanvasPreview(defaultPreview.generalCanvasState), - ); - } - - return Boolean( - loading.shouldShowCanvasLoadingState || - defaultPreview.resolvedCanvasState, - ); - }, [ - artifactPreview.currentCanvasArtifact, - artifactPreview.displayedCanvasArtifact, - defaultPreview.canvasRenderTheme, - defaultPreview.generalCanvasState, - defaultPreview.resolvedCanvasState, - imageWorkbench.active, - loading.shouldShowCanvasLoadingState, - ]); - - const renderLiveCanvasPreview = useCallback( - (stackedWorkbenchTrigger?: ReactNode) => - hasLiveCanvasPreviewContent ? ( - - ) : null, - [ - artifactPreview.currentCanvasArtifact, - artifactPreview.displayedCanvasArtifact, - artifactPreview.onCloseCanvas, - canvasFactoryProps, - canvasLoadingLabel, - defaultPreview.canvasRenderTheme, - generalCanvasPanelProps, - hasLiveCanvasPreviewContent, - imageWorkbench.active, - imageWorkbenchCanvasProps, - loading.shouldShowCanvasLoadingState, - renderArtifactWorkbenchPreview, - ], - ); - - const renderGeneralCanvasPreviewTarget = useCallback( - ( - target: Extract, - stackedWorkbenchTrigger?: ReactNode, - ) => - ( - - ), - [ - artifactPreview.onCloseCanvas, - defaultPreview.workspaceRoot, - generalCanvas.onContentChange, - ], - ); - - const { renderTeamWorkbenchPreview, teamWorkbenchView } = - useTeamWorkbenchPresentation({ - enabled: teamWorkbench.enabled, - surfaceProps: teamWorkbench.surfaceProps, - hasRealTeamGraph: teamWorkbench.hasRealTeamGraph, - autoFocusToken: teamWorkbench.autoFocusToken, - teamDispatchPreviewState: teamWorkbench.teamDispatchPreviewState, - liveActivityBySessionId: teamWorkbench.liveActivityBySessionId, - teamWaitSummary: teamWorkbench.teamWaitSummary, - teamControlSummary: teamWorkbench.teamControlSummary, - }); - - const renderCanvasWorkbenchPreview = useCallback( - ( - target: CanvasWorkbenchPreviewTarget, - options?: { - stackedWorkbenchTrigger?: ReactNode; - }, - ) => { - const stackedWorkbenchTrigger = options?.stackedWorkbenchTrigger; - const renderWorkbenchStatePreview = ( - kind: "loading" | "unsupported" | "empty", - text: string, - ) => - wrapPreviewWithWorkbenchTrigger( -
- {text} -
, - stackedWorkbenchTrigger, - ); - - switch (target.kind) { - case "default-canvas": - return renderGeneralCanvasPreviewTarget( - target, - stackedWorkbenchTrigger, - ); - case "artifact": - case "synthetic-artifact": - return renderArtifactWorkbenchPreview(target.artifact, { - stackedWorkbenchTrigger, - }); - case "loading": - return renderWorkbenchStatePreview("loading", "正在准备预览..."); - case "unsupported": - return renderWorkbenchStatePreview("unsupported", target.reason); - case "empty": - return renderWorkbenchStatePreview("empty", "暂无可预览内容"); - case "team-workbench": - return renderTeamWorkbenchPreview(stackedWorkbenchTrigger); - default: - return null; - } - }, - [ - renderArtifactWorkbenchPreview, - renderGeneralCanvasPreviewTarget, - renderTeamWorkbenchPreview, - ], - ); - - return { - canvasWorkbenchDefaultPreview, - handleOpenCanvasWorkbenchPath, - handleRevealCanvasWorkbenchPath, - liveCanvasPreview: renderLiveCanvasPreview(), - hasLiveCanvasPreviewContent, - teamWorkbenchView, - renderCanvasWorkbenchPreview, - }; -} diff --git a/src/components/agent/chat/workspace/useWorkspaceCanvasScenePresentation.tsx b/src/components/agent/chat/workspace/useWorkspaceCanvasScenePresentation.tsx deleted file mode 100644 index 7d3c07e8d..000000000 --- a/src/components/agent/chat/workspace/useWorkspaceCanvasScenePresentation.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { useMemo } from "react"; -import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas"; -import type { ThemeType } from "@/lib/workspace/workbenchContract"; -import type { TaskFile } from "../components/TaskFiles"; -import { useWorkspaceCanvasPreviewPresentation } from "./useWorkspaceCanvasPreviewPresentation"; - -type WorkspaceCanvasPreviewPresentationParams = Parameters< - typeof useWorkspaceCanvasPreviewPresentation ->[0]; - -interface CanvasPreviewProviderSummary { - id: string; - name: string; -} - -interface UseWorkspaceCanvasScenePresentationParams { - shouldBootstrapCanvasOnEntry: boolean; - normalizedEntryTheme: ThemeType; - mappedTheme: ThemeType; - canvasState: CanvasStateUnion | null; - resolvedCanvasState: CanvasStateUnion | null; - isInitialContentLoading: boolean; - initialContentLoadError?: string | null; - imageWorkbenchProviders: CanvasPreviewProviderSummary[]; - activeCanvasTaskFile: TaskFile | null; - canvasPreviewPresentation: { - defaultPreview: Omit< - WorkspaceCanvasPreviewPresentationParams["defaultPreview"], - "canvasRenderTheme" | "resolvedCanvasState" | "activeCanvasTaskFile" - >; - artifactPreview: WorkspaceCanvasPreviewPresentationParams["artifactPreview"]; - imageWorkbench: Omit< - WorkspaceCanvasPreviewPresentationParams["imageWorkbench"], - "availableProviders" - >; - generalCanvas: WorkspaceCanvasPreviewPresentationParams["generalCanvas"]; - loading: Omit< - WorkspaceCanvasPreviewPresentationParams["loading"], - "shouldShowCanvasLoadingState" - >; - canvasFactory: Omit< - WorkspaceCanvasPreviewPresentationParams["canvasFactory"], - "canvasRenderTheme" | "resolvedCanvasState" - >; - teamWorkbench: WorkspaceCanvasPreviewPresentationParams["teamWorkbench"]; - }; -} - -interface WorkspaceCanvasScenePresentationResult extends ReturnType< - typeof useWorkspaceCanvasPreviewPresentation -> { - canvasRenderTheme: ThemeType; - shouldShowCanvasLoadingState: boolean; -} - -export function useWorkspaceCanvasScenePresentation({ - shouldBootstrapCanvasOnEntry, - normalizedEntryTheme, - mappedTheme, - canvasState, - resolvedCanvasState, - isInitialContentLoading, - initialContentLoadError, - imageWorkbenchProviders, - activeCanvasTaskFile, - canvasPreviewPresentation, -}: UseWorkspaceCanvasScenePresentationParams): WorkspaceCanvasScenePresentationResult { - const canvasRenderTheme = useMemo( - () => - (shouldBootstrapCanvasOnEntry - ? normalizedEntryTheme - : mappedTheme) as ThemeType, - [mappedTheme, normalizedEntryTheme, shouldBootstrapCanvasOnEntry], - ); - - const shouldShowCanvasLoadingState = useMemo( - () => - (!canvasState && - (shouldBootstrapCanvasOnEntry || - isInitialContentLoading || - Boolean(initialContentLoadError))) || - (resolvedCanvasState?.type === "document" && - !resolvedCanvasState.content.trim() && - (isInitialContentLoading || Boolean(initialContentLoadError))), - [ - canvasState, - initialContentLoadError, - isInitialContentLoading, - resolvedCanvasState, - shouldBootstrapCanvasOnEntry, - ], - ); - - const canvasPreviewImageWorkbenchProviders = useMemo( - () => - imageWorkbenchProviders.map((provider) => ({ - id: provider.id, - name: provider.name, - })), - [imageWorkbenchProviders], - ); - - const previewPresentation = useWorkspaceCanvasPreviewPresentation({ - defaultPreview: { - ...canvasPreviewPresentation.defaultPreview, - canvasRenderTheme, - resolvedCanvasState, - activeCanvasTaskFile, - }, - artifactPreview: canvasPreviewPresentation.artifactPreview, - imageWorkbench: { - ...canvasPreviewPresentation.imageWorkbench, - availableProviders: canvasPreviewImageWorkbenchProviders, - }, - generalCanvas: canvasPreviewPresentation.generalCanvas, - loading: { - ...canvasPreviewPresentation.loading, - shouldShowCanvasLoadingState, - }, - canvasFactory: { - ...canvasPreviewPresentation.canvasFactory, - canvasRenderTheme, - resolvedCanvasState, - }, - teamWorkbench: canvasPreviewPresentation.teamWorkbench, - }); - - return { - canvasRenderTheme, - shouldShowCanvasLoadingState, - ...previewPresentation, - }; -} diff --git a/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.test.ts b/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.test.ts new file mode 100644 index 000000000..24dfbd094 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import type { TeamWorkbenchSurfaceProps } from "./chatSurfaceProps"; +import { buildCanvasTeamWorkbenchView } from "./useWorkspaceCanvasSceneRuntime"; + +function createSurfaceProps( + overrides: Partial = {}, +): TeamWorkbenchSurfaceProps { + return { + currentSessionQueuedTurnCount: 0, + childSubagentSessions: [], + selectedTeamRoles: [], + liveRuntimeBySessionId: {}, + ...overrides, + }; +} + +function buildView( + overrides: Partial[0]> = {}, +) { + return buildCanvasTeamWorkbenchView({ + enabled: true, + surfaceProps: createSurfaceProps(), + liveActivityBySessionId: {}, + teamWaitSummary: null, + teamControlSummary: null, + renderTeamWorkbenchPreview: () => null, + renderTeamWorkbenchPanel: () => null, + ...overrides, + }); +} + +describe("buildCanvasTeamWorkbenchView", () => { + it("应为 workbench 摘要统计产出任务叙事", () => { + const view = buildView({ + surfaceProps: createSurfaceProps({ + childSubagentSessions: [ + { + id: "task-1", + name: "分析", + created_at: 1_710_000_000, + updated_at: 1_710_000_100, + session_type: "sub_agent", + runtime_status: "running", + latest_turn_status: "running", + task_summary: "收敛问题边界", + role_hint: "explorer", + }, + { + id: "task-2", + name: "执行", + created_at: 1_710_000_010, + updated_at: 1_710_000_120, + session_type: "sub_agent", + runtime_status: "running", + latest_turn_status: "running", + task_summary: "落地修复", + role_hint: "executor", + }, + { + id: "task-3", + name: "复核", + created_at: 1_710_000_020, + updated_at: 1_710_000_140, + session_type: "sub_agent", + runtime_status: "queued", + latest_turn_status: "queued", + task_summary: "等待接手", + role_hint: "reviewer", + }, + ], + }), + }); + + expect(view).not.toBeNull(); + if (!view) { + throw new Error("teamWorkbenchView 不应为空"); + } + + expect(view.subtitle).toBe( + "主对话保留调度记录,画布按任务分别展示执行过程与结果。", + ); + expect(view.summaryStats?.[0]).toMatchObject({ + key: "team-status", + label: "任务状态", + detail: "任务进行中 · 2 项处理中 / 1 项稍后开始", + }); + expect(view.summaryStats?.[1]).toMatchObject({ + key: "team-members", + label: "活跃任务", + value: "3/3", + detail: "2 项处理中,1 项排队中。", + }); + }); + + it("等待摘要应提示检查任务状态", () => { + const view = buildView({ + teamWaitSummary: { + awaitedSessionIds: ["task-1", "task-2"], + timedOut: true, + updatedAt: 1_710_000_100_000, + }, + }); + + expect(view).not.toBeNull(); + if (!view) { + throw new Error("teamWorkbenchView 不应为空"); + } + + expect(view.summaryStats?.[0]).toMatchObject({ + label: "任务状态", + detail: "当前没有活跃的任务执行。", + }); + expect(view.summaryStats?.[1]).toMatchObject({ + label: "活跃任务", + detail: "当前还没有可展示的任务。", + }); + expect(view.summaryStats?.[2]).toMatchObject({ + label: "等待确认", + value: "2 项", + detail: "等待结果超时,建议重新检查任务状态。", + }); + expect(view.panelCopy?.emptyText).toBe("当前没有可展示的任务工作台。"); + }); +}); diff --git a/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.tsx index 6e376dcb4..6c99a682c 100644 --- a/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.tsx @@ -1,32 +1,1088 @@ -import type { Dispatch, SetStateAction } from "react"; +import { + useCallback, + useMemo, + type ComponentProps, + type Dispatch, + type ReactNode, + type SetStateAction, +} from "react"; +import { toast } from "sonner"; +import { ArtifactCanvasOverlay, ArtifactToolbar } from "@/components/artifact"; import { useImageGen } from "@/components/image-gen/useImageGen"; +import { CanvasFactory } from "@/lib/workspace/workbenchCanvas"; +import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas"; +import type { ThemeType } from "@/lib/workspace/workbenchContract"; +import { + CanvasPanel as GeneralCanvasPanel, + type CanvasState as GeneralCanvasState, +} from "@/components/general-chat/bridge"; +import { + openPathWithDefaultApp, + revealPathInFinder, +} from "@/lib/api/fileSystem"; +import type { Artifact } from "@/lib/artifact/types"; import type { ArtifactDisplayState } from "../hooks/useArtifactDisplayState"; +import { ImageTaskViewer } from "../components/ImageTaskViewer"; +import { TeamWorkbenchSummaryPanel } from "../components/TeamWorkbenchSummaryPanel"; +import { TeamWorkspaceBoard } from "../components/TeamWorkspaceBoard"; +import type { + CanvasWorkbenchHeaderBadge, + CanvasWorkbenchDefaultPreview, + CanvasWorkbenchPreviewTarget, + CanvasWorkbenchSummaryStat, + CanvasWorkbenchTeamView, +} from "../components/CanvasWorkbenchLayout"; +import type { TaskFile } from "../components/TaskFiles"; +import { + ArtifactWorkbenchPreview, + WorkspaceLiveCanvasPreview, +} from "./workbenchPreview"; +import { wrapPreviewWithWorkbenchTrigger } from "./workbenchPreviewHelpers"; +import { buildCanvasWorkbenchDefaultPreview } from "./canvasWorkbenchDefaultPreview"; +import { resolveAbsoluteWorkspacePath } from "./workspacePath"; +import { buildGeneralCanvasStateFromWorkspaceFile } from "./workspaceFilePreview"; import type { SessionImageWorkbenchState } from "./imageWorkbenchHelpers"; -import { useWorkspaceCanvasScenePresentation } from "./useWorkspaceCanvasScenePresentation"; +import type { TeamWorkbenchSurfaceProps } from "./chatSurfaceProps"; +import { hasRenderableGeneralCanvasPreview } from "./generalCanvasPreviewState"; import { useWorkspaceInputbarSceneRuntime } from "./useWorkspaceInputbarSceneRuntime"; import { useWorkspaceImageWorkbenchActionRuntime } from "./useWorkspaceImageWorkbenchActionRuntime"; -import { useWorkspaceTeamSessionControlRuntime } from "./useWorkspaceTeamSessionControlRuntime"; -import { useWorkspaceTeamSessionRuntime } from "./useWorkspaceTeamSessionRuntime"; import type { AgentThreadItem } from "../types"; +import type { TeamMemorySnapshot } from "@/lib/teamMemorySync"; +import { summarizeTeamWorkspaceExecution } from "../teamWorkspaceRuntime"; +import type { + TeamWorkspaceActivityEntry, + TeamWorkspaceControlSummary, + TeamWorkspaceRuntimeFormationState, + TeamWorkspaceWaitSummary, +} from "../teamWorkspaceRuntime"; -type CanvasScenePresentationParams = Parameters< - typeof useWorkspaceCanvasScenePresentation ->[0]; +type ArtifactPreviewBaseProps = Omit< + ComponentProps, + "artifact" | "stackedWorkbenchTrigger" | "onArtifactDocumentControllerChange" +>; +type ImageWorkbenchCanvasProps = ComponentProps; +type GeneralCanvasPanelProps = Omit< + ComponentProps, + "toolbarActions" +>; +type RenderArtifactWorkbenchPreviewOptions = { + stackedWorkbenchTrigger?: ReactNode; + onArtifactDocumentControllerChange?: ComponentProps< + typeof ArtifactWorkbenchPreview + >["onArtifactDocumentControllerChange"]; +}; + +interface WorkspaceCanvasDefaultPreviewParams { + workspaceRoot: string | null; + canvasRenderTheme: ThemeType; + generalCanvasState: GeneralCanvasState; + resolvedCanvasState: CanvasStateUnion | null; + activeCanvasTaskFile: TaskFile | null; +} + +interface WorkspaceCanvasPreviewArtifactParams { + currentCanvasArtifact: Artifact | null; + displayedCanvasArtifact: Artifact | null; + artifactOverlay: + | ComponentProps["overlay"] + | null; + showPreviousVersionBadge: boolean; + artifactViewMode: ComponentProps["viewMode"]; + onArtifactViewModeChange: NonNullable< + ComponentProps["onViewModeChange"] + >; + artifactPreviewSize: ComponentProps["previewSize"]; + onArtifactPreviewSizeChange: NonNullable< + ComponentProps["onPreviewSizeChange"] + >; + onSaveArtifactDocument?: ComponentProps< + typeof ArtifactWorkbenchPreview + >["onSaveArtifactDocument"]; + onArtifactBlockRewriteRun?: ComponentProps< + typeof ArtifactWorkbenchPreview + >["onArtifactBlockRewriteRun"]; + threadItems?: AgentThreadItem[]; + focusedBlockId?: string | null; + blockFocusRequestKey?: number; + onJumpToTimelineItem?: (itemId: string) => void; + onCloseCanvas: () => void; + renderToolbarActions?: ComponentProps< + typeof ArtifactWorkbenchPreview + >["renderToolbarActions"]; +} + +interface WorkspaceCanvasPreviewImageWorkbenchParams { + active: boolean; + tasks: ImageWorkbenchCanvasProps["tasks"]; + outputs: ImageWorkbenchCanvasProps["outputs"]; + selectedOutputId: ImageWorkbenchCanvasProps["selectedOutputId"]; + viewport: ImageWorkbenchCanvasProps["viewport"]; + preferenceSummary: ImageWorkbenchCanvasProps["preferenceSummary"]; + preferenceWarning: ImageWorkbenchCanvasProps["preferenceWarning"]; + availableProviders: ImageWorkbenchCanvasProps["availableProviders"]; + selectedProviderId: ImageWorkbenchCanvasProps["selectedProviderId"]; + onProviderChange: ImageWorkbenchCanvasProps["onProviderChange"]; + availableModels: ImageWorkbenchCanvasProps["availableModels"]; + selectedModelId: ImageWorkbenchCanvasProps["selectedModelId"]; + onModelChange: ImageWorkbenchCanvasProps["onModelChange"]; + selectedSize: ImageWorkbenchCanvasProps["selectedSize"]; + onSizeChange: ImageWorkbenchCanvasProps["onSizeChange"]; + generating: ImageWorkbenchCanvasProps["generating"]; + savingToResource: ImageWorkbenchCanvasProps["savingToResource"]; + onStopGeneration: ImageWorkbenchCanvasProps["onStopGeneration"]; + onViewportChange: ImageWorkbenchCanvasProps["onViewportChange"]; + onSelectOutput: ImageWorkbenchCanvasProps["onSelectOutput"]; + onSaveSelectedToLibrary: ImageWorkbenchCanvasProps["onSaveSelectedToLibrary"]; + applySelectedOutputLabel: ImageWorkbenchCanvasProps["applySelectedOutputLabel"]; + onApplySelectedOutput?: ImageWorkbenchCanvasProps["onApplySelectedOutput"]; + onSeedFollowUpCommand: ImageWorkbenchCanvasProps["onSeedFollowUpCommand"]; + onOpenImage: ImageWorkbenchCanvasProps["onOpenImage"]; +} + +interface WorkspaceCanvasPreviewGeneralCanvasParams { + state: GeneralCanvasState; + onCloseCanvas: () => void; + onContentChange: (content: string) => void; +} + +interface WorkspaceCanvasPreviewLoadingParams { + isInitialContentLoading: boolean; + initialContentLoadError?: string | null; + shouldShowCanvasLoadingState: boolean; +} + +interface WorkspaceCanvasPreviewFactoryParams { + canvasRenderTheme: ThemeType; + resolvedCanvasState: CanvasStateUnion | null; + onStateChange: ComponentProps["onStateChange"]; + onBackHome: NonNullable["onBackHome"]>; + onCloseCanvas: NonNullable["onClose"]>; + isStreaming: ComponentProps["isStreaming"]; + onSelectionTextChange: ComponentProps< + typeof CanvasFactory + >["onSelectionTextChange"]; + projectId: string | null; + contentId: string | null; + autoImageTopic?: string; + autoContinueProviderType: ComponentProps< + typeof CanvasFactory + >["autoContinueProviderType"]; + onAutoContinueProviderTypeChange: ComponentProps< + typeof CanvasFactory + >["onAutoContinueProviderTypeChange"]; + autoContinueModel: ComponentProps["autoContinueModel"]; + onAutoContinueModelChange: ComponentProps< + typeof CanvasFactory + >["onAutoContinueModelChange"]; + autoContinueThinkingEnabled: boolean; + onAutoContinueThinkingEnabledChange: NonNullable< + ComponentProps["onAutoContinueThinkingEnabledChange"] + >; + onAutoContinueRun: NonNullable< + ComponentProps["onAutoContinueRun"] + >; + onAddImage: ComponentProps["onAddImage"]; + onImportDocument: ComponentProps["onImportDocument"]; + onContentReviewRun: NonNullable< + ComponentProps["onContentReviewRun"] + >; + onTextStylizeRun: NonNullable< + ComponentProps["onTextStylizeRun"] + >; + preferContentReviewInRightRail: boolean; +} + +interface WorkspaceCanvasPreviewTeamWorkbenchParams { + enabled: boolean; + surfaceProps: TeamWorkbenchSurfaceProps; + autoFocusToken?: string | number | null; + teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null; + liveActivityBySessionId?: Record; + teamWaitSummary?: TeamWorkspaceWaitSummary | null; + teamControlSummary?: TeamWorkspaceControlSummary | null; + teamMemorySnapshot?: TeamMemorySnapshot | null; +} + +interface UseWorkspaceCanvasPreviewRuntimeParams { + defaultPreview: WorkspaceCanvasDefaultPreviewParams; + artifactPreview: WorkspaceCanvasPreviewArtifactParams; + imageWorkbench: WorkspaceCanvasPreviewImageWorkbenchParams; + generalCanvas: WorkspaceCanvasPreviewGeneralCanvasParams; + loading: WorkspaceCanvasPreviewLoadingParams; + canvasFactory: WorkspaceCanvasPreviewFactoryParams; + teamWorkbench: WorkspaceCanvasPreviewTeamWorkbenchParams; +} + +interface WorkspaceCanvasPreviewRuntimeResult { + canvasWorkbenchDefaultPreview: CanvasWorkbenchDefaultPreview | null; + handleOpenCanvasWorkbenchPath: (path: string) => Promise; + handleRevealCanvasWorkbenchPath: (path: string) => Promise; + liveCanvasPreview: ReactNode; + hasLiveCanvasPreviewContent: boolean; + teamWorkbenchView: CanvasWorkbenchTeamView | null; + renderCanvasWorkbenchPreview: ( + target: CanvasWorkbenchPreviewTarget, + options?: { stackedWorkbenchTrigger?: ReactNode }, + ) => ReactNode; +} +interface UseWorkspaceCanvasScenePresentationRuntimeParams { + shouldBootstrapCanvasOnEntry: boolean; + normalizedEntryTheme: ThemeType; + mappedTheme: ThemeType; + canvasState: CanvasStateUnion | null; + resolvedCanvasState: CanvasStateUnion | null; + isInitialContentLoading: boolean; + initialContentLoadError?: string | null; + imageWorkbenchProviders: { + id: string; + name: string; + }[]; + activeCanvasTaskFile: TaskFile | null; + canvasPreviewPresentation: { + defaultPreview: Omit< + UseWorkspaceCanvasPreviewRuntimeParams["defaultPreview"], + "canvasRenderTheme" | "resolvedCanvasState" | "activeCanvasTaskFile" + >; + artifactPreview: UseWorkspaceCanvasPreviewRuntimeParams["artifactPreview"]; + imageWorkbench: Omit< + UseWorkspaceCanvasPreviewRuntimeParams["imageWorkbench"], + "availableProviders" + >; + generalCanvas: UseWorkspaceCanvasPreviewRuntimeParams["generalCanvas"]; + loading: Omit< + UseWorkspaceCanvasPreviewRuntimeParams["loading"], + "shouldShowCanvasLoadingState" + >; + canvasFactory: Omit< + UseWorkspaceCanvasPreviewRuntimeParams["canvasFactory"], + "canvasRenderTheme" | "resolvedCanvasState" + >; + teamWorkbench: UseWorkspaceCanvasPreviewRuntimeParams["teamWorkbench"]; + }; +} +interface WorkspaceCanvasScenePresentationRuntimeResult extends + WorkspaceCanvasPreviewRuntimeResult { + canvasRenderTheme: ThemeType; + shouldShowCanvasLoadingState: boolean; +} +type CanvasScenePresentationParams = + UseWorkspaceCanvasScenePresentationRuntimeParams; type CanvasPreviewPresentationParams = CanvasScenePresentationParams["canvasPreviewPresentation"]; type ArtifactPreviewParams = CanvasPreviewPresentationParams["artifactPreview"]; type ImageWorkbenchParams = CanvasPreviewPresentationParams["imageWorkbench"]; type CanvasFactoryParams = CanvasPreviewPresentationParams["canvasFactory"]; type TeamWorkbenchParams = CanvasPreviewPresentationParams["teamWorkbench"]; -type InputbarScene = ReturnType; +type InputbarScene = Pick< + ReturnType, + "activeCanvasTaskFile" | "teamWorkbenchSurfaceProps" +>; type ImageWorkbenchGenerationRuntime = ReturnType; type ImageWorkbenchActionRuntime = ReturnType< typeof useWorkspaceImageWorkbenchActionRuntime >; -type TeamSessionRuntime = ReturnType; -type TeamSessionControlRuntime = ReturnType< - typeof useWorkspaceTeamSessionControlRuntime ->; + +interface ResolveTeamWorkbenchTriggerStateParams { + enabled: boolean; + teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null; + liveActivityBySessionId?: Record; + teamWaitSummary?: TeamWorkspaceWaitSummary | null; + teamControlSummary?: TeamWorkspaceControlSummary | null; + executionSummary: ReturnType; +} + +interface BuildCanvasTeamWorkbenchViewParams { + enabled: boolean; + surfaceProps: TeamWorkbenchSurfaceProps; + autoFocusToken?: string | number | null; + teamDispatchPreviewState?: TeamWorkspaceRuntimeFormationState | null; + liveActivityBySessionId?: Record; + teamWaitSummary?: TeamWorkspaceWaitSummary | null; + teamControlSummary?: TeamWorkspaceControlSummary | null; + renderTeamWorkbenchPreview: ( + stackedWorkbenchTrigger?: ReactNode, + ) => ReactNode; + renderTeamWorkbenchPanel: () => ReactNode; +} + +function resolveTeamWorkbenchTriggerState({ + enabled, + teamDispatchPreviewState, + liveActivityBySessionId = {}, + teamWaitSummary = null, + teamControlSummary = null, + executionSummary, +}: ResolveTeamWorkbenchTriggerStateParams): CanvasWorkbenchTeamView["triggerState"] { + if (!enabled) { + return null; + } + + if (teamDispatchPreviewState?.status === "failed") { + return { tone: "error", label: "失败" }; + } + + if (teamDispatchPreviewState?.status === "forming") { + return { tone: "active", label: "组建中" }; + } + + if (executionSummary.runningSessionCount > 0) { + return { + tone: "active", + label: + executionSummary.runningSessionCount > 1 + ? `${executionSummary.runningSessionCount} 处理中` + : "处理中", + }; + } + + if (executionSummary.queuedSessionCount > 0) { + return { + tone: "active", + label: + executionSummary.queuedSessionCount > 1 + ? `${executionSummary.queuedSessionCount} 稍后开始` + : "稍后开始", + }; + } + + if ( + teamDispatchPreviewState?.status === "formed" && + executionSummary.totalSessionCount === 0 + ) { + return { tone: "active", label: "已就绪" }; + } + + if ( + Object.values(liveActivityBySessionId).some( + (entries) => (entries?.length ?? 0) > 0, + ) || + Boolean(teamWaitSummary) || + Boolean(teamControlSummary) + ) { + return { tone: "active", label: "有更新" }; + } + + return { tone: "idle", label: null }; +} + +export function buildCanvasTeamWorkbenchView({ + enabled, + surfaceProps, + autoFocusToken, + teamDispatchPreviewState = null, + liveActivityBySessionId = {}, + teamWaitSummary = null, + teamControlSummary = null, + renderTeamWorkbenchPreview, + renderTeamWorkbenchPanel, +}: BuildCanvasTeamWorkbenchViewParams): CanvasWorkbenchTeamView | null { + if (!enabled) { + return null; + } + + const dispatchPreviewState = + teamDispatchPreviewState ?? surfaceProps.teamDispatchPreviewState ?? null; + const executionSummary = summarizeTeamWorkspaceExecution({ + currentSessionId: surfaceProps.currentSessionId, + currentSessionRuntimeStatus: surfaceProps.currentSessionRuntimeStatus, + currentSessionLatestTurnStatus: + surfaceProps.currentSessionLatestTurnStatus, + currentSessionQueuedTurnCount: surfaceProps.currentSessionQueuedTurnCount, + childSubagentSessions: surfaceProps.childSubagentSessions, + subagentParentContext: surfaceProps.subagentParentContext, + liveRuntimeBySessionId: surfaceProps.liveRuntimeBySessionId, + }); + const triggerState = resolveTeamWorkbenchTriggerState({ + enabled, + teamDispatchPreviewState: dispatchPreviewState, + liveActivityBySessionId, + teamWaitSummary, + teamControlSummary, + executionSummary, + }); + const headerBadges: CanvasWorkbenchHeaderBadge[] = [ + { + key: "team-runtime", + label: "任务工作台", + tone: "accent", + }, + ]; + + if (triggerState?.label?.trim()) { + headerBadges.push({ + key: "team-trigger-state", + label: triggerState.label.trim(), + tone: triggerState.tone === "active" ? "accent" : "default", + }); + } + + if (teamWaitSummary?.awaitedSessionIds?.length) { + headerBadges.push({ + key: "team-awaiting", + label: `等待 ${teamWaitSummary.awaitedSessionIds.length}`, + tone: "default", + }); + } + + const leadStatus = + triggerState?.label?.trim() || executionSummary.statusTitle || "待机"; + const leadDetail = + executionSummary.statusTitle || "当前没有活跃的任务执行。"; + const summaryStats: CanvasWorkbenchSummaryStat[] = [ + { + key: "team-status", + label: "任务状态", + value: leadStatus, + detail: leadDetail, + tone: triggerState?.tone === "active" ? "accent" : "default", + }, + { + key: "team-members", + label: "活跃任务", + value: + executionSummary.totalSessionCount > 0 + ? `${executionSummary.activeSessionCount}/${executionSummary.totalSessionCount}` + : "0", + detail: + executionSummary.totalSessionCount > 0 + ? `${executionSummary.runningSessionCount} 项处理中,${executionSummary.queuedSessionCount} 项排队中。` + : "当前还没有可展示的任务。", + tone: executionSummary.activeSessionCount > 0 ? "accent" : "default", + }, + ]; + + if (teamWaitSummary?.awaitedSessionIds?.length) { + summaryStats.push({ + key: "team-awaiting", + label: "等待确认", + value: `${teamWaitSummary.awaitedSessionIds.length} 项`, + detail: teamWaitSummary.timedOut + ? "等待结果超时,建议重新检查任务状态。" + : "正在等待任务完成或返回结果。", + tone: "default", + }); + } else if (teamControlSummary?.affectedSessionIds?.length) { + summaryStats.push({ + key: "team-control", + label: "最近控制", + value: + teamControlSummary.action === "resume" + ? "继续处理" + : teamControlSummary.action === "close_completed" + ? "收起已完成" + : "暂停处理", + detail: `影响 ${teamControlSummary.affectedSessionIds.length} 个会话。`, + tone: "default", + }); + } + + return { + enabled: true, + title: + dispatchPreviewState?.label?.trim() || + dispatchPreviewState?.blueprint?.label?.trim() || + surfaceProps.selectedTeamLabel || + "团队工作台", + tabLabel: surfaceProps.selectedTeamLabel?.trim() || undefined, + tabBadge: triggerState?.label?.trim() || undefined, + tabBadgeTone: + triggerState?.tone === "error" + ? "rose" + : triggerState?.tone === "active" + ? "sky" + : "slate", + subtitle: "主对话保留调度记录,画布按任务分别展示执行过程与结果。", + autoFocusToken, + preferFixedPanel: true, + triggerState, + badges: headerBadges, + summaryStats, + panelCopy: { + emptyText: "当前没有可展示的任务工作台。", + }, + renderPreview: (options?: { stackedWorkbenchTrigger?: ReactNode }) => + renderTeamWorkbenchPreview(options?.stackedWorkbenchTrigger), + renderPanel: renderTeamWorkbenchPanel, + }; +} + +function useWorkspaceCanvasPreviewRuntime({ + defaultPreview, + artifactPreview, + imageWorkbench, + generalCanvas, + loading, + canvasFactory, + teamWorkbench, +}: UseWorkspaceCanvasPreviewRuntimeParams): WorkspaceCanvasPreviewRuntimeResult { + const canvasWorkbenchDefaultPreview = useMemo( + () => + buildCanvasWorkbenchDefaultPreview({ + workspaceRoot: defaultPreview.workspaceRoot, + canvasRenderTheme: defaultPreview.canvasRenderTheme, + generalCanvasState: defaultPreview.generalCanvasState, + resolvedCanvasState: defaultPreview.resolvedCanvasState, + activeCanvasTaskFile: defaultPreview.activeCanvasTaskFile, + }), + [ + defaultPreview.activeCanvasTaskFile, + defaultPreview.canvasRenderTheme, + defaultPreview.generalCanvasState, + defaultPreview.resolvedCanvasState, + defaultPreview.workspaceRoot, + ], + ); + + const handleOpenCanvasWorkbenchPath = useCallback(async (path: string) => { + try { + await openPathWithDefaultApp(path); + } catch (error) { + toast.error( + `打开文件失败: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }, []); + + const handleRevealCanvasWorkbenchPath = useCallback(async (path: string) => { + try { + await revealPathInFinder(path); + } catch (error) { + toast.error( + `定位文件失败: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }, []); + + const artifactWorkbenchPreviewBaseProps = useMemo( + () => ({ + currentCanvasArtifact: artifactPreview.currentCanvasArtifact, + displayedCanvasArtifact: artifactPreview.displayedCanvasArtifact, + artifactOverlay: artifactPreview.artifactOverlay, + showPreviousVersionBadge: artifactPreview.showPreviousVersionBadge, + artifactViewMode: artifactPreview.artifactViewMode, + onArtifactViewModeChange: artifactPreview.onArtifactViewModeChange, + artifactPreviewSize: artifactPreview.artifactPreviewSize, + onArtifactPreviewSizeChange: artifactPreview.onArtifactPreviewSizeChange, + onSaveArtifactDocument: artifactPreview.onSaveArtifactDocument, + onArtifactBlockRewriteRun: artifactPreview.onArtifactBlockRewriteRun, + threadItems: artifactPreview.threadItems, + focusedBlockId: artifactPreview.focusedBlockId, + blockFocusRequestKey: artifactPreview.blockFocusRequestKey, + onJumpToTimelineItem: artifactPreview.onJumpToTimelineItem, + onCloseCanvas: artifactPreview.onCloseCanvas, + renderToolbarActions: artifactPreview.renderToolbarActions, + }), + [ + artifactPreview.artifactOverlay, + artifactPreview.artifactPreviewSize, + artifactPreview.artifactViewMode, + artifactPreview.blockFocusRequestKey, + artifactPreview.currentCanvasArtifact, + artifactPreview.displayedCanvasArtifact, + artifactPreview.focusedBlockId, + artifactPreview.onArtifactBlockRewriteRun, + artifactPreview.onArtifactPreviewSizeChange, + artifactPreview.onArtifactViewModeChange, + artifactPreview.onCloseCanvas, + artifactPreview.onJumpToTimelineItem, + artifactPreview.onSaveArtifactDocument, + artifactPreview.renderToolbarActions, + artifactPreview.showPreviousVersionBadge, + artifactPreview.threadItems, + ], + ); + + const imageWorkbenchCanvasProps = useMemo( + () => ({ + tasks: imageWorkbench.tasks, + outputs: imageWorkbench.outputs, + selectedOutputId: imageWorkbench.selectedOutputId, + viewport: imageWorkbench.viewport, + preferenceSummary: imageWorkbench.preferenceSummary, + preferenceWarning: imageWorkbench.preferenceWarning, + availableProviders: imageWorkbench.availableProviders, + selectedProviderId: imageWorkbench.selectedProviderId, + onProviderChange: imageWorkbench.onProviderChange, + availableModels: imageWorkbench.availableModels, + selectedModelId: imageWorkbench.selectedModelId, + onModelChange: imageWorkbench.onModelChange, + selectedSize: imageWorkbench.selectedSize, + onSizeChange: imageWorkbench.onSizeChange, + generating: imageWorkbench.generating, + savingToResource: imageWorkbench.savingToResource, + onStopGeneration: imageWorkbench.onStopGeneration, + onViewportChange: imageWorkbench.onViewportChange, + onSelectOutput: imageWorkbench.onSelectOutput, + onSaveSelectedToLibrary: imageWorkbench.onSaveSelectedToLibrary, + applySelectedOutputLabel: imageWorkbench.applySelectedOutputLabel, + onApplySelectedOutput: imageWorkbench.onApplySelectedOutput, + onSeedFollowUpCommand: imageWorkbench.onSeedFollowUpCommand, + onOpenImage: imageWorkbench.onOpenImage, + }), + [ + imageWorkbench.applySelectedOutputLabel, + imageWorkbench.availableModels, + imageWorkbench.availableProviders, + imageWorkbench.generating, + imageWorkbench.onApplySelectedOutput, + imageWorkbench.onModelChange, + imageWorkbench.onOpenImage, + imageWorkbench.onProviderChange, + imageWorkbench.onSaveSelectedToLibrary, + imageWorkbench.onSeedFollowUpCommand, + imageWorkbench.onSelectOutput, + imageWorkbench.onSizeChange, + imageWorkbench.onStopGeneration, + imageWorkbench.onViewportChange, + imageWorkbench.outputs, + imageWorkbench.preferenceSummary, + imageWorkbench.preferenceWarning, + imageWorkbench.savingToResource, + imageWorkbench.selectedModelId, + imageWorkbench.selectedOutputId, + imageWorkbench.selectedProviderId, + imageWorkbench.selectedSize, + imageWorkbench.tasks, + imageWorkbench.viewport, + ], + ); + + const generalCanvasPanelProps = useMemo( + () => ({ + state: generalCanvas.state, + baseFilePath: resolveAbsoluteWorkspacePath( + defaultPreview.workspaceRoot, + generalCanvas.state.filename, + ), + onClose: generalCanvas.onCloseCanvas, + onContentChange: generalCanvas.onContentChange, + }), + [ + defaultPreview.workspaceRoot, + generalCanvas.onCloseCanvas, + generalCanvas.onContentChange, + generalCanvas.state, + ], + ); + + const canvasLoadingLabel = useMemo( + () => + loading.isInitialContentLoading + ? "正在加载文稿内容..." + : loading.initialContentLoadError || "正在准备文稿画布...", + [loading.initialContentLoadError, loading.isInitialContentLoading], + ); + + const canvasFactoryProps = useMemo | null>( + () => + canvasFactory.resolvedCanvasState + ? { + theme: canvasFactory.canvasRenderTheme, + state: canvasFactory.resolvedCanvasState, + onStateChange: canvasFactory.onStateChange, + onBackHome: canvasFactory.onBackHome, + onClose: canvasFactory.onCloseCanvas, + isStreaming: canvasFactory.isStreaming, + onSelectionTextChange: canvasFactory.onSelectionTextChange, + projectId: canvasFactory.projectId, + contentId: canvasFactory.contentId, + autoImageTopic: canvasFactory.autoImageTopic, + autoContinueProviderType: canvasFactory.autoContinueProviderType, + onAutoContinueProviderTypeChange: + canvasFactory.onAutoContinueProviderTypeChange, + autoContinueModel: canvasFactory.autoContinueModel, + onAutoContinueModelChange: canvasFactory.onAutoContinueModelChange, + autoContinueThinkingEnabled: + canvasFactory.autoContinueThinkingEnabled, + onAutoContinueThinkingEnabledChange: + canvasFactory.onAutoContinueThinkingEnabledChange, + onAutoContinueRun: canvasFactory.onAutoContinueRun, + onAddImage: canvasFactory.onAddImage, + onImportDocument: canvasFactory.onImportDocument, + onContentReviewRun: canvasFactory.onContentReviewRun, + onTextStylizeRun: canvasFactory.onTextStylizeRun, + documentContentReviewPlacement: + canvasFactory.preferContentReviewInRightRail + ? ("external-rail" as const) + : ("inline" as const), + } + : null, + [ + canvasFactory.autoContinueModel, + canvasFactory.autoContinueProviderType, + canvasFactory.autoContinueThinkingEnabled, + canvasFactory.autoImageTopic, + canvasFactory.canvasRenderTheme, + canvasFactory.contentId, + canvasFactory.isStreaming, + canvasFactory.onAddImage, + canvasFactory.onAutoContinueModelChange, + canvasFactory.onAutoContinueProviderTypeChange, + canvasFactory.onAutoContinueRun, + canvasFactory.onAutoContinueThinkingEnabledChange, + canvasFactory.onBackHome, + canvasFactory.onCloseCanvas, + canvasFactory.onContentReviewRun, + canvasFactory.onImportDocument, + canvasFactory.onSelectionTextChange, + canvasFactory.onStateChange, + canvasFactory.onTextStylizeRun, + canvasFactory.preferContentReviewInRightRail, + canvasFactory.projectId, + canvasFactory.resolvedCanvasState, + ], + ); + + const renderArtifactWorkbenchPreview = useCallback( + (artifact: Artifact, options?: RenderArtifactWorkbenchPreviewOptions) => ( + + ), + [artifactWorkbenchPreviewBaseProps], + ); + + const hasLiveCanvasPreviewContent = useMemo(() => { + if (imageWorkbench.active) { + return true; + } + + if (defaultPreview.canvasRenderTheme === "general") { + return Boolean( + (artifactPreview.currentCanvasArtifact && + artifactPreview.displayedCanvasArtifact) || + hasRenderableGeneralCanvasPreview(defaultPreview.generalCanvasState), + ); + } + + return Boolean( + loading.shouldShowCanvasLoadingState || + defaultPreview.resolvedCanvasState, + ); + }, [ + artifactPreview.currentCanvasArtifact, + artifactPreview.displayedCanvasArtifact, + defaultPreview.canvasRenderTheme, + defaultPreview.generalCanvasState, + defaultPreview.resolvedCanvasState, + imageWorkbench.active, + loading.shouldShowCanvasLoadingState, + ]); + + const renderLiveCanvasPreview = useCallback( + (stackedWorkbenchTrigger?: ReactNode) => + hasLiveCanvasPreviewContent ? ( + + ) : null, + [ + artifactPreview.currentCanvasArtifact, + artifactPreview.displayedCanvasArtifact, + artifactPreview.onCloseCanvas, + canvasFactoryProps, + canvasLoadingLabel, + defaultPreview.canvasRenderTheme, + generalCanvasPanelProps, + hasLiveCanvasPreviewContent, + imageWorkbench.active, + imageWorkbenchCanvasProps, + loading.shouldShowCanvasLoadingState, + renderArtifactWorkbenchPreview, + ], + ); + + const renderGeneralCanvasPreviewTarget = useCallback( + ( + target: Extract, + stackedWorkbenchTrigger?: ReactNode, + ) => ( + + ), + [ + artifactPreview.onCloseCanvas, + defaultPreview.workspaceRoot, + generalCanvas.onContentChange, + ], + ); + + const teamWorkbenchBoardProps = useMemo< + ComponentProps + >( + () => ({ + ...teamWorkbench.surfaceProps, + embedded: true, + defaultShellExpanded: true, + }), + [teamWorkbench.surfaceProps], + ); + + const renderTeamWorkbenchPreview = useCallback( + (stackedWorkbenchTrigger?: ReactNode) => + wrapPreviewWithWorkbenchTrigger( +
+
+ +
+
, + stackedWorkbenchTrigger, + ), + [teamWorkbenchBoardProps], + ); + + const teamWorkbenchSummaryPanelProps = useMemo< + ComponentProps + >( + () => ({ + currentSessionId: teamWorkbench.surfaceProps.currentSessionId, + currentSessionRuntimeStatus: + teamWorkbench.surfaceProps.currentSessionRuntimeStatus, + currentSessionLatestTurnStatus: + teamWorkbench.surfaceProps.currentSessionLatestTurnStatus, + currentSessionQueuedTurnCount: + teamWorkbench.surfaceProps.currentSessionQueuedTurnCount, + childSubagentSessions: teamWorkbench.surfaceProps.childSubagentSessions, + subagentParentContext: teamWorkbench.surfaceProps.subagentParentContext, + liveRuntimeBySessionId: teamWorkbench.surfaceProps.liveRuntimeBySessionId, + liveActivityBySessionId: teamWorkbench.liveActivityBySessionId, + teamWaitSummary: teamWorkbench.teamWaitSummary, + teamControlSummary: teamWorkbench.teamControlSummary, + selectedTeamLabel: teamWorkbench.surfaceProps.selectedTeamLabel, + selectedTeamSummary: teamWorkbench.surfaceProps.selectedTeamSummary, + selectedTeamRoles: teamWorkbench.surfaceProps.selectedTeamRoles, + teamDispatchPreviewState: + teamWorkbench.teamDispatchPreviewState ?? + teamWorkbench.surfaceProps.teamDispatchPreviewState, + teamMemorySnapshot: teamWorkbench.teamMemorySnapshot, + }), + [ + teamWorkbench.liveActivityBySessionId, + teamWorkbench.surfaceProps.childSubagentSessions, + teamWorkbench.surfaceProps.currentSessionId, + teamWorkbench.surfaceProps.currentSessionLatestTurnStatus, + teamWorkbench.surfaceProps.currentSessionQueuedTurnCount, + teamWorkbench.surfaceProps.currentSessionRuntimeStatus, + teamWorkbench.surfaceProps.liveRuntimeBySessionId, + teamWorkbench.surfaceProps.selectedTeamLabel, + teamWorkbench.surfaceProps.selectedTeamRoles, + teamWorkbench.surfaceProps.selectedTeamSummary, + teamWorkbench.surfaceProps.subagentParentContext, + teamWorkbench.surfaceProps.teamDispatchPreviewState, + teamWorkbench.teamControlSummary, + teamWorkbench.teamDispatchPreviewState, + teamWorkbench.teamMemorySnapshot, + teamWorkbench.teamWaitSummary, + ], + ); + + const teamWorkbenchSummaryPanel = useMemo( + () => , + [teamWorkbenchSummaryPanelProps], + ); + + const teamWorkbenchView = useMemo( + () => + buildCanvasTeamWorkbenchView({ + enabled: teamWorkbench.enabled, + surfaceProps: teamWorkbench.surfaceProps, + autoFocusToken: teamWorkbench.autoFocusToken, + teamDispatchPreviewState: teamWorkbench.teamDispatchPreviewState, + liveActivityBySessionId: teamWorkbench.liveActivityBySessionId, + teamWaitSummary: teamWorkbench.teamWaitSummary, + teamControlSummary: teamWorkbench.teamControlSummary, + renderTeamWorkbenchPreview, + renderTeamWorkbenchPanel: () => teamWorkbenchSummaryPanel, + }), + [ + renderTeamWorkbenchPreview, + teamWorkbench.autoFocusToken, + teamWorkbench.enabled, + teamWorkbench.liveActivityBySessionId, + teamWorkbench.surfaceProps, + teamWorkbench.teamControlSummary, + teamWorkbench.teamDispatchPreviewState, + teamWorkbench.teamWaitSummary, + teamWorkbenchSummaryPanel, + ], + ); + + const renderCanvasWorkbenchPreview = useCallback( + ( + target: CanvasWorkbenchPreviewTarget, + options?: { + stackedWorkbenchTrigger?: ReactNode; + }, + ) => { + const stackedWorkbenchTrigger = options?.stackedWorkbenchTrigger; + const renderWorkbenchStatePreview = ( + kind: "loading" | "unsupported" | "empty", + text: string, + ) => + wrapPreviewWithWorkbenchTrigger( +
+ {text} +
, + stackedWorkbenchTrigger, + ); + + switch (target.kind) { + case "default-canvas": + return renderGeneralCanvasPreviewTarget( + target, + stackedWorkbenchTrigger, + ); + case "artifact": + case "synthetic-artifact": + return renderArtifactWorkbenchPreview(target.artifact, { + stackedWorkbenchTrigger, + }); + case "loading": + return renderWorkbenchStatePreview("loading", "正在准备预览..."); + case "unsupported": + return renderWorkbenchStatePreview("unsupported", target.reason); + case "empty": + return renderWorkbenchStatePreview("empty", "暂无可预览内容"); + case "team-workbench": + return renderTeamWorkbenchPreview(stackedWorkbenchTrigger); + default: + return null; + } + }, + [ + renderArtifactWorkbenchPreview, + renderGeneralCanvasPreviewTarget, + renderTeamWorkbenchPreview, + ], + ); + + return { + canvasWorkbenchDefaultPreview, + handleOpenCanvasWorkbenchPath, + handleRevealCanvasWorkbenchPath, + liveCanvasPreview: renderLiveCanvasPreview(), + hasLiveCanvasPreviewContent, + teamWorkbenchView, + renderCanvasWorkbenchPreview, + }; +} + +function useWorkspaceCanvasScenePresentationRuntime({ + shouldBootstrapCanvasOnEntry, + normalizedEntryTheme, + mappedTheme, + canvasState, + resolvedCanvasState, + isInitialContentLoading, + initialContentLoadError, + imageWorkbenchProviders, + activeCanvasTaskFile, + canvasPreviewPresentation, +}: UseWorkspaceCanvasScenePresentationRuntimeParams): WorkspaceCanvasScenePresentationRuntimeResult { + const canvasRenderTheme = useMemo( + () => + (shouldBootstrapCanvasOnEntry + ? normalizedEntryTheme + : mappedTheme) as ThemeType, + [mappedTheme, normalizedEntryTheme, shouldBootstrapCanvasOnEntry], + ); + + const shouldShowCanvasLoadingState = useMemo( + () => + (!canvasState && + (shouldBootstrapCanvasOnEntry || + isInitialContentLoading || + Boolean(initialContentLoadError))) || + (resolvedCanvasState?.type === "document" && + !resolvedCanvasState.content.trim() && + (isInitialContentLoading || Boolean(initialContentLoadError))), + [ + canvasState, + initialContentLoadError, + isInitialContentLoading, + resolvedCanvasState, + shouldBootstrapCanvasOnEntry, + ], + ); + + const canvasPreviewImageWorkbenchProviders = useMemo( + () => + imageWorkbenchProviders.map((provider) => ({ + id: provider.id, + name: provider.name, + })), + [imageWorkbenchProviders], + ); + + const previewPresentation = useWorkspaceCanvasPreviewRuntime({ + defaultPreview: { + ...canvasPreviewPresentation.defaultPreview, + canvasRenderTheme, + resolvedCanvasState, + activeCanvasTaskFile, + }, + artifactPreview: canvasPreviewPresentation.artifactPreview, + imageWorkbench: { + ...canvasPreviewPresentation.imageWorkbench, + availableProviders: canvasPreviewImageWorkbenchProviders, + }, + generalCanvas: canvasPreviewPresentation.generalCanvas, + loading: { + ...canvasPreviewPresentation.loading, + shouldShowCanvasLoadingState, + }, + canvasFactory: { + ...canvasPreviewPresentation.canvasFactory, + canvasRenderTheme, + resolvedCanvasState, + }, + teamWorkbench: canvasPreviewPresentation.teamWorkbench, + }); + + return { + canvasRenderTheme, + shouldShowCanvasLoadingState, + ...previewPresentation, + }; +} interface UseWorkspaceCanvasSceneRuntimeParams { shouldBootstrapCanvasOnEntry: CanvasScenePresentationParams["shouldBootstrapCanvasOnEntry"]; @@ -86,8 +1142,10 @@ interface UseWorkspaceCanvasSceneRuntimeParams { handleDocumentContentReviewRun: CanvasFactoryParams["onContentReviewRun"]; handleDocumentTextStylizeRun: CanvasFactoryParams["onTextStylizeRun"]; preferContentReviewInRightRail: CanvasFactoryParams["preferContentReviewInRightRail"]; - teamSessionRuntime: TeamSessionRuntime; - teamSessionControlRuntime: TeamSessionControlRuntime; + teamWorkspaceEnabled: TeamWorkbenchParams["enabled"]; + liveActivityBySessionId: NonNullable; + teamWaitSummary: TeamWorkbenchParams["teamWaitSummary"]; + teamControlSummary: TeamWorkbenchParams["teamControlSummary"]; teamWorkbenchAutoFocusToken: TeamWorkbenchParams["autoFocusToken"]; teamDispatchPreviewState: TeamWorkbenchParams["teamDispatchPreviewState"]; teamMemorySnapshot: TeamWorkbenchParams["teamMemorySnapshot"]; @@ -144,8 +1202,10 @@ export function useWorkspaceCanvasSceneRuntime({ handleDocumentContentReviewRun, handleDocumentTextStylizeRun, preferContentReviewInRightRail, - teamSessionRuntime, - teamSessionControlRuntime, + teamWorkspaceEnabled, + liveActivityBySessionId, + teamWaitSummary, + teamControlSummary, teamWorkbenchAutoFocusToken, teamDispatchPreviewState, teamMemorySnapshot, @@ -157,7 +1217,7 @@ export function useWorkspaceCanvasSceneRuntime({ task.status === "running", ); - return useWorkspaceCanvasScenePresentation({ + return useWorkspaceCanvasScenePresentationRuntime({ shouldBootstrapCanvasOnEntry, normalizedEntryTheme, mappedTheme, @@ -264,14 +1324,13 @@ export function useWorkspaceCanvasSceneRuntime({ preferContentReviewInRightRail, }, teamWorkbench: { - enabled: teamSessionRuntime.showTeamWorkspaceBoard, + enabled: teamWorkspaceEnabled, surfaceProps: inputbarScene.teamWorkbenchSurfaceProps, - hasRealTeamGraph: teamSessionRuntime.hasRealTeamGraph, autoFocusToken: teamWorkbenchAutoFocusToken, teamDispatchPreviewState, - liveActivityBySessionId: teamSessionRuntime.liveActivityBySessionId, - teamWaitSummary: teamSessionControlRuntime.teamWaitSummary, - teamControlSummary: teamSessionControlRuntime.teamControlSummary, + liveActivityBySessionId, + teamWaitSummary, + teamControlSummary, teamMemorySnapshot, }, }, diff --git a/src/components/agent/chat/workspace/useWorkspaceContextDetailActions.tsx b/src/components/agent/chat/workspace/useWorkspaceContextDetailActions.tsx deleted file mode 100644 index d527a1ebe..000000000 --- a/src/components/agent/chat/workspace/useWorkspaceContextDetailActions.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { useCallback } from "react"; -import { toast } from "sonner"; -import type { ThemeContextWorkspaceState } from "../hooks/useThemeContextWorkspace"; - -interface UseWorkspaceContextDetailActionsParams { - contextWorkspace: ThemeContextWorkspaceState; -} - -export function useWorkspaceContextDetailActions({ - contextWorkspace, -}: UseWorkspaceContextDetailActionsParams) { - const handleViewContextDetail = useCallback( - (contextId: string) => { - const detail = contextWorkspace.getContextDetail(contextId); - if (!detail) { - toast.error("无法找到上下文详情"); - return; - } - - const sourceLabel = - detail.source === "material" - ? "素材库" - : detail.source === "content" - ? "历史内容" - : "搜索结果"; - - toast.info( -
-
- {detail.name} -
-
- 来源: {sourceLabel} · 约 {detail.estimatedTokens} tokens -
-
- {detail.bodyText || detail.previewText} -
-
, - { duration: 10000 }, - ); - }, - [contextWorkspace], - ); - - return { - handleViewContextDetail, - }; -} diff --git a/src/components/agent/chat/workspace/useWorkspaceContextHarnessRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceContextHarnessRuntime.ts index ccc60f817..35a4ba2ea 100644 --- a/src/components/agent/chat/workspace/useWorkspaceContextHarnessRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceContextHarnessRuntime.ts @@ -25,9 +25,6 @@ interface UseWorkspaceContextHarnessRuntimeParams { pendingApprovals: unknown[]; hasSignals: boolean; }; - compatSubagentRuntime: { - isRunning: boolean; - }; } export function useWorkspaceContextHarnessRuntime({ @@ -40,7 +37,6 @@ export function useWorkspaceContextHarnessRuntime({ isSending, projectMemory, harnessState, - compatSubagentRuntime, }: UseWorkspaceContextHarnessRuntimeParams) { const [harnessPanelVisible, setHarnessPanelVisible] = useState(() => enabled ? loadPersistedBoolean(HARNESS_PANEL_VISIBILITY_KEY, false) : false, @@ -80,8 +76,7 @@ export function useWorkspaceContextHarnessRuntime({ (harnessPanelVisible || isSending || harnessPendingCount > 0 || - harnessState.hasSignals || - compatSubagentRuntime.isRunning); + harnessState.hasSignals); const showHarnessToggle = shouldAlwaysShowHarnessToggle || hasHarnessActivity; const harnessAttentionLevel: "idle" | "active" | "warning" = harnessPendingCount > 0 diff --git a/src/components/agent/chat/workspace/useWorkspaceConversationScenePresentation.tsx b/src/components/agent/chat/workspace/useWorkspaceConversationScenePresentation.tsx deleted file mode 100644 index 57c160aa0..000000000 --- a/src/components/agent/chat/workspace/useWorkspaceConversationScenePresentation.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import type { ComponentProps, ReactNode } from "react"; -import { StepProgress } from "@/lib/workspace/workbenchUi"; -import { WorkspaceConversationScene } from "./WorkspaceConversationScene"; -import { MessageList } from "../components/MessageList"; -import { TeamWorkspaceDock } from "../components/TeamWorkspaceDock"; -import { - buildStepProgressProps, - buildTeamWorkspaceDockProps, -} from "./chatSurfaceProps"; -import type { TeamWorkbenchSurfaceProps } from "./teamWorkbenchPresentation"; - -type WorkspaceConversationSceneProps = ComponentProps< - typeof WorkspaceConversationScene ->; -type CanvasWorkbenchLayoutProps = NonNullable< - WorkspaceConversationSceneProps["canvasWorkbenchLayoutProps"] ->; - -interface UseWorkspaceConversationScenePresentationParams { - scene: Omit< - WorkspaceConversationSceneProps, - | "workspaceAlertVisible" - | "projectId" - | "canvasWorkbenchLayoutProps" - | "stepProgressProps" - | "teamWorkspaceDockProps" - | "messageListProps" - > & { - projectId: string | null | undefined; - }; - stepProgress: { - hidden: boolean; - isSpecializedThemeMode: boolean; - hasMessages: boolean; - steps: ComponentProps["steps"]; - currentIndex: ComponentProps["currentIndex"]; - onStepClick: NonNullable< - ComponentProps["onStepClick"] - >; - }; - messageList: ComponentProps; - teamWorkspaceDock: { - enabled: boolean; - shouldShowFloatingInputOverlay: boolean; - layoutMode: "chat" | "chat-canvas"; - onActivateWorkbench: NonNullable< - ComponentProps["onActivateWorkbench"] - >; - withBottomOverlay: boolean; - surfaceProps: TeamWorkbenchSurfaceProps; - }; - workspaceAlert: { - workspacePathMissing: boolean; - workspaceHealthError: boolean; - }; - canvasWorkbenchLayout: Omit< - CanvasWorkbenchLayoutProps, - "workspaceUnavailable" - >; -} - -interface WorkspaceConversationScenePresentationResult { - workspaceAlertVisible: boolean; - mainAreaNode: ReactNode; -} - -export function useWorkspaceConversationScenePresentation({ - scene, - stepProgress, - messageList, - teamWorkspaceDock, - workspaceAlert, - canvasWorkbenchLayout, -}: UseWorkspaceConversationScenePresentationParams): WorkspaceConversationScenePresentationResult { - const stepProgressProps = buildStepProgressProps(stepProgress); - const teamWorkspaceDockProps = buildTeamWorkspaceDockProps(teamWorkspaceDock); - const workspaceAlertVisible = Boolean( - workspaceAlert.workspacePathMissing || workspaceAlert.workspaceHealthError, - ); - - const canvasWorkbenchLayoutProps: CanvasWorkbenchLayoutProps = { - ...canvasWorkbenchLayout, - workspaceUnavailable: workspaceAlertVisible, - }; - - return { - workspaceAlertVisible, - mainAreaNode: ( - - ), - }; -} diff --git a/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.test.ts b/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.test.ts index 0bdad3885..da01edbe3 100644 --- a/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.test.ts +++ b/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.test.ts @@ -1,13 +1,12 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useWorkspaceConversationSceneRuntime } from "./useWorkspaceConversationSceneRuntime"; -const { mockPresentation } = vi.hoisted(() => ({ - mockPresentation: vi.fn((params) => params), -})); +type HookProps = Parameters[0]; -vi.mock("./useWorkspaceConversationScenePresentation", () => ({ - useWorkspaceConversationScenePresentation: mockPresentation, -})); +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; function createBaseParams(overrides: Record = {}) { const noop = vi.fn(); @@ -19,28 +18,39 @@ function createBaseParams(overrides: Record = {}) { handleWorkspaceAlertSelectDirectory: noop, handleDismissWorkspaceAlert: noop, handleManageProviders: noop, + handleProjectChange: noop, + handleOpenAppearanceSettings: noop, + handleBackToResources: noop, + handleCompactContext: noop, }, inputbarScene: { inputbarNode: null, + generalWorkbenchDialog: undefined, teamWorkbenchSurfaceProps: {}, - activeCanvasTaskFile: null, }, canvasScene: { + hasLiveCanvasPreviewContent: false, + liveCanvasPreview: null, + shouldShowCanvasLoadingState: false, + teamWorkbenchView: null, canvasWorkbenchDefaultPreview: null, handleOpenCanvasWorkbenchPath: noop, handleRevealCanvasWorkbenchPath: noop, renderCanvasWorkbenchPreview: noop, }, - conversationSendRuntime: { - handleSendFromEmptyState: noop, - }, + handleSendFromEmptyState: noop, shellChromeRuntime: { showChatLayout: true, isWorkspaceCompactChrome: false, workflowLayoutBottomSpacing: { messageViewportBottomPadding: "0px", + shellBottomInset: "0px", }, shouldHideGeneralWorkbenchInputForTheme: false, + shouldRenderTopBar: true, + layoutTransitionChatPanelWidth: undefined, + layoutTransitionChatPanelMinWidth: undefined, + shouldShowGeneralWorkbenchFloatingInputOverlay: false, shouldRenderInlineA2UI: false, }, generalWorkbenchHarnessDialog: undefined, @@ -144,7 +154,7 @@ function createBaseParams(overrides: Record = {}) { shouldCollapseCodeBlocks: false, shouldCollapseCodeBlockInChat: noop, handleCodeBlockClick: noop, - showTeamWorkspaceBoard: false, + teamWorkspaceEnabled: false, layoutMode: "chat-canvas", handleActivateTeamWorkbench: noop, isThemeWorkbench: false, @@ -162,19 +172,72 @@ function createBaseParams(overrides: Record = {}) { } as any; } -describe("useWorkspaceConversationSceneRuntime", () => { - beforeEach(() => { - mockPresentation.mockClear(); - }); +function renderHook(initialProps: HookProps) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + let latestValue: ReturnType | null = + null; + function Probe(currentProps: HookProps) { + latestValue = useWorkspaceConversationSceneRuntime(currentProps); + return null; + } + + const render = (nextProps: HookProps) => { + act(() => { + root.render(React.createElement(Probe, nextProps)); + }); + }; + + render(initialProps); + mountedRoots.push({ root, container }); + + return { + getValue: () => { + if (!latestValue) { + throw new Error("hook 尚未初始化"); + } + return latestValue; + }, + render, + }; +} + +function getRenderedSceneProps(params: ReturnType) { + const { getValue } = renderHook(params); + return (getValue().mainAreaNode as any).props; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + break; + } + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.restoreAllMocks(); +}); + +describe("useWorkspaceConversationSceneRuntime", () => { it("通用 Claw 双栏场景应继续同步 stacked/split 布局状态", () => { const params = createBaseParams(); const setCanvasWorkbenchLayoutMode = params.setCanvasWorkbenchLayoutMode; - useWorkspaceConversationSceneRuntime(params); - - const presentationParams = mockPresentation.mock.calls.at(-1)?.[0]; - expect(presentationParams?.canvasWorkbenchLayout?.onLayoutModeChange).toBe( + const sceneProps = getRenderedSceneProps(params); + expect(sceneProps.canvasWorkbenchLayoutProps.onLayoutModeChange).toBe( setCanvasWorkbenchLayoutMode, ); }); @@ -187,12 +250,8 @@ describe("useWorkspaceConversationSceneRuntime", () => { layoutMode: "canvas", }); - useWorkspaceConversationSceneRuntime(params); - - const presentationParams = mockPresentation.mock.calls.at(-1)?.[0]; - expect( - presentationParams?.canvasWorkbenchLayout?.onLayoutModeChange, - ).toBeUndefined(); + const sceneProps = getRenderedSceneProps(params); + expect(sceneProps.canvasWorkbenchLayoutProps.onLayoutModeChange).toBeUndefined(); }); it("任务中心场景应继续向页面层透传顶栏上下文变体", () => { @@ -200,10 +259,8 @@ describe("useWorkspaceConversationSceneRuntime", () => { navbarContextVariant: "task-center", }); - useWorkspaceConversationSceneRuntime(params); - - const presentationParams = mockPresentation.mock.calls.at(-1)?.[0]; - expect(presentationParams?.scene.navbarContextVariant).toBe("task-center"); + const sceneProps = getRenderedSceneProps(params); + expect(sceneProps.navbarContextVariant).toBe("task-center"); }); it("存在处理工作台入口时应透传顶栏按钮文案", () => { @@ -212,10 +269,8 @@ describe("useWorkspaceConversationSceneRuntime", () => { harnessToggleLabel: "工作台", }); - useWorkspaceConversationSceneRuntime(params); - - const presentationParams = mockPresentation.mock.calls.at(-1)?.[0]; - expect(presentationParams?.scene.harnessToggleLabel).toBe("工作台"); + const sceneProps = getRenderedSceneProps(params); + expect(sceneProps.harnessToggleLabel).toBe("工作台"); }); it("首页空态应继续透传 service skills 与选择回调", () => { @@ -231,13 +286,9 @@ describe("useWorkspaceConversationSceneRuntime", () => { onSelectServiceSkill, }); - useWorkspaceConversationSceneRuntime(params); - - const presentationParams = mockPresentation.mock.calls.at(-1)?.[0]; - expect(presentationParams?.scene.serviceSkills).toBe(serviceSkills); - expect(presentationParams?.scene.onSelectServiceSkill).toBe( - onSelectServiceSkill, - ); + const sceneProps = getRenderedSceneProps(params); + expect(sceneProps.serviceSkills).toBe(serviceSkills); + expect(sceneProps.onSelectServiceSkill).toBe(onSelectServiceSkill); }); it("应向画布壳透传解耦后的 sessionView 过程面板", () => { @@ -291,10 +342,8 @@ describe("useWorkspaceConversationSceneRuntime", () => { focusedTimelineItemId: "item-1", }); - useWorkspaceConversationSceneRuntime(params); - - const presentationParams = mockPresentation.mock.calls.at(-1)?.[0]; - const sessionView = presentationParams?.canvasWorkbenchLayout?.sessionView; + const sceneProps = getRenderedSceneProps(params); + const sessionView = sceneProps.canvasWorkbenchLayoutProps.sessionView; expect(sessionView?.title).toBe("Session · Main"); expect(sessionView?.tabLabel).toBe("Session · Main"); @@ -349,11 +398,8 @@ describe("useWorkspaceConversationSceneRuntime", () => { ], }); - useWorkspaceConversationSceneRuntime(params); - - const presentationParams = mockPresentation.mock.calls.at(-1)?.[0]; - const workspaceView = - presentationParams?.canvasWorkbenchLayout?.workspaceView; + const sceneProps = getRenderedSceneProps(params); + const workspaceView = sceneProps.canvasWorkbenchLayoutProps.workspaceView; expect(workspaceView?.title).toBe("项目工作区文件"); expect(workspaceView?.tabLabel).toBe("文件"); diff --git a/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx index 602a9ef80..8cc1cf953 100644 --- a/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx @@ -1,11 +1,16 @@ -import type { Dispatch, SetStateAction } from "react"; -import { useWorkspaceConversationScenePresentation } from "./useWorkspaceConversationScenePresentation"; -import { useWorkspaceConversationSendRuntime } from "./useWorkspaceConversationSendRuntime"; +import type { + ComponentProps, + Dispatch, + ReactNode, + SetStateAction, +} from "react"; +import { StepProgress } from "@/lib/workspace/workbenchUi"; import { useWorkspaceNavigationActions } from "./useWorkspaceNavigationActions"; import { useWorkspaceInputbarSceneRuntime } from "./useWorkspaceInputbarSceneRuntime"; import { useWorkspaceCanvasSceneRuntime } from "./useWorkspaceCanvasSceneRuntime"; -import { useWorkspaceShellChromeRuntime } from "./useWorkspaceShellChromeRuntime"; import { CanvasSessionOverviewPanel } from "../components/CanvasSessionOverviewPanel"; +import { MessageList } from "../components/MessageList"; +import { TeamWorkspaceDock } from "../components/TeamWorkspaceDock"; import type { CanvasWorkbenchHeaderView, CanvasWorkbenchSessionView, @@ -13,7 +18,7 @@ import type { } from "../components/CanvasWorkbenchLayout"; import type { ChatToolPreferences } from "../utils/chatToolPreferences"; import type { CreationMode } from "../components/types"; -import type { WriteArtifactContext } from "../types"; +import type { MessageImage, WriteArtifactContext } from "../types"; import type { PendingA2UISource } from "../types"; import type { LayoutMode, ThemeType } from "@/lib/workspace/workbenchContract"; import type { Artifact } from "@/lib/artifact/types"; @@ -21,17 +26,140 @@ import type { Character } from "@/lib/api/memory"; import type { TaskFile } from "../components/TaskFiles"; import type { WorkspacePathMissingState } from "../hooks/agentChatShared"; import type { ArtifactTimelineOpenTarget } from "../utils/artifactTimelineNavigation"; +import { + buildStepProgressProps, + buildTeamWorkspaceDockProps, + type TeamWorkbenchSurfaceProps, +} from "./chatSurfaceProps"; +import { WorkspaceConversationScene } from "./WorkspaceConversationScene"; -type NavigationActions = ReturnType; -type InputbarScene = ReturnType; -type CanvasScene = ReturnType; -type ConversationSendRuntime = ReturnType< - typeof useWorkspaceConversationSendRuntime +type InputbarScene = Pick< + ReturnType, + "inputbarNode" | "generalWorkbenchDialog" | "teamWorkbenchSurfaceProps" >; -type ShellChromeRuntime = ReturnType; -type ConversationScenePresentationParams = Parameters< - typeof useWorkspaceConversationScenePresentation ->[0]; +type CanvasScene = Pick< + ReturnType, + | "hasLiveCanvasPreviewContent" + | "liveCanvasPreview" + | "shouldShowCanvasLoadingState" + | "teamWorkbenchView" + | "canvasWorkbenchDefaultPreview" + | "handleOpenCanvasWorkbenchPath" + | "handleRevealCanvasWorkbenchPath" + | "renderCanvasWorkbenchPreview" +>; +type WorkspaceConversationSceneProps = ComponentProps< + typeof WorkspaceConversationScene +>; +type CanvasWorkbenchLayoutProps = NonNullable< + WorkspaceConversationSceneProps["canvasWorkbenchLayoutProps"] +>; +interface ConversationScenePresentationParams { + scene: Omit< + WorkspaceConversationSceneProps, + | "workspaceAlertVisible" + | "projectId" + | "canvasWorkbenchLayoutProps" + | "stepProgressProps" + | "teamWorkspaceDockProps" + | "messageListProps" + > & { + projectId: string | null | undefined; + }; + stepProgress: { + hidden: boolean; + isSpecializedThemeMode: boolean; + hasMessages: boolean; + steps: ComponentProps["steps"]; + currentIndex: ComponentProps["currentIndex"]; + onStepClick: NonNullable< + ComponentProps["onStepClick"] + >; + }; + messageList: ComponentProps; + teamWorkspaceDock: { + enabled: boolean; + shouldShowFloatingInputOverlay: boolean; + layoutMode: "chat" | "chat-canvas"; + onActivateWorkbench: NonNullable< + ComponentProps["onActivateWorkbench"] + >; + withBottomOverlay: boolean; + surfaceProps: TeamWorkbenchSurfaceProps; + }; + workspaceAlert: { + workspacePathMissing: boolean; + workspaceHealthError: boolean; + }; + canvasWorkbenchLayout: Omit< + CanvasWorkbenchLayoutProps, + "workspaceUnavailable" + >; +} +interface WorkspaceConversationScenePresentationResult { + workspaceAlertVisible: boolean; + mainAreaNode: ReactNode; +} +type NavigationActions = Pick< + ReturnType, + | "handleDismissEntryBanner" + | "handleWorkspaceAlertSelectDirectory" + | "handleDismissWorkspaceAlert" + | "handleManageProviders" + | "handleProjectChange" + | "handleOpenAppearanceSettings" + | "handleBackToResources" + | "handleCompactContext" +>; +interface ShellChromeRuntime { + showChatLayout: boolean; + isWorkspaceCompactChrome: boolean; + workflowLayoutBottomSpacing: { + shellBottomInset: string; + messageViewportBottomPadding: string; + }; + shouldHideGeneralWorkbenchInputForTheme: boolean; + shouldRenderTopBar: boolean; + layoutTransitionChatPanelWidth?: string; + layoutTransitionChatPanelMinWidth?: string; + shouldShowGeneralWorkbenchFloatingInputOverlay: boolean; + shouldRenderInlineA2UI: boolean; +} + +function renderWorkspaceConversationScene({ + scene, + stepProgress, + messageList, + teamWorkspaceDock, + workspaceAlert, + canvasWorkbenchLayout, +}: ConversationScenePresentationParams): WorkspaceConversationScenePresentationResult { + const stepProgressProps = buildStepProgressProps(stepProgress); + const teamWorkspaceDockProps = buildTeamWorkspaceDockProps(teamWorkspaceDock); + const workspaceAlertVisible = Boolean( + workspaceAlert.workspacePathMissing || workspaceAlert.workspaceHealthError, + ); + + const canvasWorkbenchLayoutProps: CanvasWorkbenchLayoutProps = { + ...canvasWorkbenchLayout, + workspaceUnavailable: workspaceAlertVisible, + }; + + return { + workspaceAlertVisible, + mainAreaNode: ( + + ), + }; +} function shortenSessionText(value?: string | null, maxLength = 120): string { const normalized = (value || "").trim().replace(/\s+/g, " "); @@ -80,7 +208,11 @@ interface UseWorkspaceConversationSceneRuntimeParams { navigationActions: NavigationActions; inputbarScene: InputbarScene; canvasScene: CanvasScene; - conversationSendRuntime: ConversationSendRuntime; + handleSendFromEmptyState: ( + text: string, + sendExecutionStrategy?: "react" | "code_orchestrated" | "auto", + images?: MessageImage[], + ) => void; shellChromeRuntime: ShellChromeRuntime; generalWorkbenchHarnessDialog: ConversationScenePresentationParams["scene"]["generalWorkbenchHarnessDialog"]; entryBannerVisible: ConversationScenePresentationParams["scene"]["entryBannerVisible"]; @@ -189,7 +321,7 @@ interface UseWorkspaceConversationSceneRuntimeParams { shouldCollapseCodeBlocks: ConversationScenePresentationParams["messageList"]["collapseCodeBlocks"]; shouldCollapseCodeBlockInChat: ConversationScenePresentationParams["messageList"]["shouldCollapseCodeBlock"]; handleCodeBlockClick: ConversationScenePresentationParams["messageList"]["onCodeBlockClick"]; - showTeamWorkspaceBoard: ConversationScenePresentationParams["teamWorkspaceDock"]["enabled"]; + teamWorkspaceEnabled: ConversationScenePresentationParams["teamWorkspaceDock"]["enabled"]; layoutMode: LayoutMode; handleActivateTeamWorkbench: ConversationScenePresentationParams["teamWorkspaceDock"]["onActivateWorkbench"]; isThemeWorkbench: boolean; @@ -211,7 +343,7 @@ export function useWorkspaceConversationSceneRuntime({ navigationActions, inputbarScene, canvasScene, - conversationSendRuntime, + handleSendFromEmptyState, shellChromeRuntime, generalWorkbenchHarnessDialog, entryBannerVisible, @@ -314,7 +446,7 @@ export function useWorkspaceConversationSceneRuntime({ shouldCollapseCodeBlocks, shouldCollapseCodeBlockInChat, handleCodeBlockClick, - showTeamWorkspaceBoard, + teamWorkspaceEnabled, layoutMode, handleActivateTeamWorkbench, isThemeWorkbench, @@ -552,7 +684,7 @@ export function useWorkspaceConversationSceneRuntime({ }, }; - return useWorkspaceConversationScenePresentation({ + return renderWorkspaceConversationScene({ scene: { entryBannerVisible, entryBannerMessage, @@ -572,7 +704,7 @@ export function useWorkspaceConversationSceneRuntime({ inputbarNode: inputbarScene.inputbarNode, input, setInput, - onSendMessage: conversationSendRuntime.handleSendFromEmptyState, + onSendMessage: handleSendFromEmptyState, emptyStateIsLoading: isPreparingSend || isSending, emptyStateDisabled: isPreparingSend || isSending, providerType, @@ -670,6 +802,7 @@ export function useWorkspaceConversationSceneRuntime({ messageList: { messages: displayMessages, emptyStateVariant: messageListEmptyStateVariant, + providerType, turns, threadItems: effectiveThreadItems, currentTurnId, @@ -707,7 +840,7 @@ export function useWorkspaceConversationSceneRuntime({ timelineFocusRequestKey, }, teamWorkspaceDock: { - enabled: showTeamWorkspaceBoard, + enabled: teamWorkspaceEnabled, shouldShowFloatingInputOverlay: shellChromeRuntime.shouldShowGeneralWorkbenchFloatingInputOverlay, layoutMode: teamWorkspaceDockLayoutMode, diff --git a/src/components/agent/chat/workspace/useWorkspaceConversationSendRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceConversationSendRuntime.ts deleted file mode 100644 index daf123716..000000000 --- a/src/components/agent/chat/workspace/useWorkspaceConversationSendRuntime.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useCallback } from "react"; -import type { MessageImage } from "../types"; -import type { ChatToolPreferences } from "../utils/chatToolPreferences"; -import type { WorkspaceHandleSend } from "./useWorkspaceSendActions"; - -interface UseWorkspaceConversationSendRuntimeParams { - chatToolPreferences: ChatToolPreferences; - handleSend: WorkspaceHandleSend; -} - -export function useWorkspaceConversationSendRuntime({ - chatToolPreferences, - handleSend, -}: UseWorkspaceConversationSendRuntimeParams) { - const handleSendFromEmptyState = useCallback( - ( - text: string, - sendExecutionStrategy?: "react" | "code_orchestrated" | "auto", - images?: MessageImage[], - ) => { - void handleSend( - images || [], - chatToolPreferences.webSearch, - chatToolPreferences.thinking, - text, - sendExecutionStrategy, - ); - }, - [chatToolPreferences.thinking, chatToolPreferences.webSearch, handleSend], - ); - - return { - handleSendFromEmptyState, - }; -} diff --git a/src/components/agent/chat/workspace/useWorkspaceConversationShellSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceConversationShellSceneRuntime.tsx deleted file mode 100644 index 278922235..000000000 --- a/src/components/agent/chat/workspace/useWorkspaceConversationShellSceneRuntime.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { useWorkspaceCanvasSceneRuntime } from "./useWorkspaceCanvasSceneRuntime"; -import { useWorkspaceContextHarnessRuntime } from "./useWorkspaceContextHarnessRuntime"; -import { useWorkspaceConversationSceneRuntime } from "./useWorkspaceConversationSceneRuntime"; -import { useWorkspaceConversationSendRuntime } from "./useWorkspaceConversationSendRuntime"; -import { useWorkspaceInputbarSceneRuntime } from "./useWorkspaceInputbarSceneRuntime"; -import { useWorkspaceNavigationActions } from "./useWorkspaceNavigationActions"; -import { useWorkspaceShellChromeRuntime } from "./useWorkspaceShellChromeRuntime"; -import { useWorkspaceShellSceneRuntime } from "./useWorkspaceShellSceneRuntime"; -import { useWorkspaceTeamSessionRuntime } from "./useWorkspaceTeamSessionRuntime"; -import { useWorkspaceGeneralWorkbenchShellRuntime } from "./useWorkspaceGeneralWorkbenchShellRuntime"; - -type NavigationActions = ReturnType; -type InputbarScene = ReturnType; -type CanvasScene = ReturnType; -type ShellChromeRuntime = ReturnType; -type GeneralWorkbenchShellRuntime = ReturnType< - typeof useWorkspaceGeneralWorkbenchShellRuntime ->; -type ContextHarnessRuntime = ReturnType< - typeof useWorkspaceContextHarnessRuntime ->; -type TeamSessionRuntime = ReturnType; -type ConversationSendRuntimeParams = Parameters< - typeof useWorkspaceConversationSendRuntime ->[0]; -type ConversationSceneRuntimeParams = Parameters< - typeof useWorkspaceConversationSceneRuntime ->[0]; -type ShellSceneRuntimeParams = Parameters< - typeof useWorkspaceShellSceneRuntime ->[0]; -type ConversationSceneBridgeParams = Omit< - ConversationSceneRuntimeParams, - | "navigationActions" - | "inputbarScene" - | "canvasScene" - | "conversationSendRuntime" - | "shellChromeRuntime" - | "generalWorkbenchHarnessDialog" - | "generalCanvasContent" - | "projectId" - | "projectCharacters" - | "handleToggleHarnessPanel" - | "showTeamWorkspaceBoard" - | "currentImageWorkbenchActive" - | "projectRootPath" ->; - -interface UseWorkspaceConversationShellSceneRuntimeParams extends ConversationSceneBridgeParams { - navigationActions: NavigationActions; - inputbarScene: InputbarScene; - canvasScene: CanvasScene; - shellChromeRuntime: ShellChromeRuntime; - generalWorkbenchShellRuntime: GeneralWorkbenchShellRuntime; - contextHarnessRuntime: Pick< - ContextHarnessRuntime, - "handleToggleHarnessPanel" - >; - teamSessionRuntime: Pick; - currentImageWorkbenchState: { active: boolean }; - project: { rootPath?: string | null } | null; - projectId: string | null | undefined; - sidebarContextVariant?: "default" | "task-center"; - generalCanvasState: { content: string }; - projectMemory: { - characters?: ConversationSceneRuntimeParams["projectCharacters"]; - } | null; - handleSend: ConversationSendRuntimeParams["handleSend"]; - showSidebar: ShellSceneRuntimeParams["showSidebar"]; - topics: ShellSceneRuntimeParams["topics"]; - switchTopic: ShellSceneRuntimeParams["switchTopic"]; - handleResumeSidebarTask: ShellSceneRuntimeParams["handleResumeSidebarTask"]; - deleteTopic: ShellSceneRuntimeParams["deleteTopic"]; - renameTopic: ShellSceneRuntimeParams["renameTopic"]; - childSubagentSessions: ShellSceneRuntimeParams["childSubagentSessions"]; - subagentParentContext: ShellSceneRuntimeParams["subagentParentContext"]; - handleReturnToParentSession: ShellSceneRuntimeParams["handleReturnToParentSession"]; -} - -export function useWorkspaceConversationShellSceneRuntime({ - navigationActions, - inputbarScene, - canvasScene, - shellChromeRuntime, - generalWorkbenchShellRuntime, - contextHarnessRuntime, - teamSessionRuntime, - currentImageWorkbenchState, - project, - projectId, - sidebarContextVariant = "default", - projectMemory, - handleSend, - showSidebar, - topics, - switchTopic, - handleResumeSidebarTask, - deleteTopic, - renameTopic, - childSubagentSessions, - subagentParentContext, - handleReturnToParentSession, - ...conversationScene -}: UseWorkspaceConversationShellSceneRuntimeParams) { - const conversationSendRuntime = useWorkspaceConversationSendRuntime({ - chatToolPreferences: conversationScene.chatToolPreferences, - handleSend, - }); - - const conversationSceneRuntime = useWorkspaceConversationSceneRuntime({ - ...conversationScene, - navigationActions, - inputbarScene, - canvasScene, - conversationSendRuntime, - shellChromeRuntime, - generalWorkbenchHarnessDialog: - generalWorkbenchShellRuntime.generalWorkbenchHarnessDialog, - generalCanvasContent: conversationScene.generalCanvasState.content, - projectId: projectId ?? null, - projectCharacters: projectMemory?.characters || [], - handleToggleHarnessPanel: contextHarnessRuntime.handleToggleHarnessPanel, - showTeamWorkspaceBoard: teamSessionRuntime.showTeamWorkspaceBoard, - currentImageWorkbenchActive: currentImageWorkbenchState.active, - projectRootPath: project?.rootPath || null, - }); - - const pendingActions = conversationScene.pendingActions ?? []; - const queuedTurns = conversationScene.queuedTurns ?? []; - const handleBackHome = conversationScene.handleBackHome ?? (() => undefined); - const handleOpenSubagentSession = - conversationScene.handleOpenSubagentSession ?? (() => undefined); - const displayMessages = conversationScene.displayMessages ?? []; - const isSending = conversationScene.isSending ?? false; - - return useWorkspaceShellSceneRuntime({ - compactChrome: shellChromeRuntime.isWorkspaceCompactChrome, - isThemeWorkbench: conversationScene.isThemeWorkbench, - showChatPanel: conversationScene.showChatPanel, - showSidebar, - sidebarContextVariant, - generalWorkbenchShellRuntime, - conversationSceneRuntime, - sessionId: conversationScene.sessionId, - topics, - handleBackHome, - switchTopic, - handleResumeSidebarTask, - deleteTopic, - renameTopic, - displayMessages, - isSending, - pendingActionCount: pendingActions.length, - queuedTurnCount: queuedTurns.length, - childSubagentSessions, - subagentParentContext, - handleOpenSubagentSession, - handleReturnToParentSession, - }); -} diff --git a/src/components/agent/chat/workspace/useWorkspaceDisplayMessagesRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceDisplayMessagesRuntime.ts deleted file mode 100644 index 5d7b5c8d5..000000000 --- a/src/components/agent/chat/workspace/useWorkspaceDisplayMessagesRuntime.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { useEffect, useMemo } from "react"; -import { buildLiveTaskSnapshot } from "../hooks/agentChatShared"; -import { buildCompatQuestionnaireA2UI } from "../utils/compatQuestionnaireA2UI"; -import { buildRuntimeTeamDispatchPreviewMessages } from "./runtimeTeamPreview"; -import type { RuntimeTeamDispatchPreviewSnapshot } from "./runtimeTeamPreview"; -import { - buildSubmissionPreviewMessages, - type SubmissionPreviewSnapshot, -} from "./submissionPreview"; -import type { Message } from "../types"; - -interface UseWorkspaceDisplayMessagesRuntimeParams { - bootstrapDispatchPreviewMessages: Message[]; - isSending: boolean; - messages: Message[]; - pendingActionCount: number; - queuedTurnCount: number; - runtimeTeamDispatchPreview: RuntimeTeamDispatchPreviewSnapshot | null; - submissionPreview: SubmissionPreviewSnapshot | null; - sessionId?: string | null; - updateTopicSnapshot: ( - sessionId: string, - snapshot: ReturnType, - ) => void; - workspaceError: boolean; -} - -function isLegacyQuestionnaireSummaryMessage(message?: Message): boolean { - return ( - message?.role === "user" && message.content.trim().startsWith("我的选择:") - ); -} - -function collapseLegacyQuestionnaireMessages(messages: Message[]): Message[] { - let mutated = false; - const collapsedMessages = messages.map((message, index) => { - if (message.role !== "assistant") { - return message; - } - - if ((message.actionRequests || []).length > 0) { - return message; - } - - const legacyForm = buildCompatQuestionnaireA2UI(message.content || ""); - if (!legacyForm) { - return message; - } - - const nextMessage = messages[index + 1]; - const isPendingQuestionnaire = index === messages.length - 1; - const hasSubmittedSummary = - isLegacyQuestionnaireSummaryMessage(nextMessage); - - if (!isPendingQuestionnaire && !hasSubmittedSummary) { - return message; - } - - mutated = true; - return { - ...message, - content: hasSubmittedSummary - ? "补充信息表单已提交。" - : "已整理为补充信息表单,请在输入区完成填写。", - }; - }); - - return mutated ? collapsedMessages : messages; -} - -export function useWorkspaceDisplayMessagesRuntime({ - bootstrapDispatchPreviewMessages, - isSending, - messages, - pendingActionCount, - queuedTurnCount, - runtimeTeamDispatchPreview, - submissionPreview, - sessionId, - updateTopicSnapshot, - workspaceError, -}: UseWorkspaceDisplayMessagesRuntimeParams) { - const displayMessages = useMemo(() => { - const collapsedMessages = collapseLegacyQuestionnaireMessages(messages); - const runtimeTeamDispatchPreviewMessages = runtimeTeamDispatchPreview - ? buildRuntimeTeamDispatchPreviewMessages(runtimeTeamDispatchPreview) - : []; - const submissionPreviewMessages = - collapsedMessages.length === 0 && submissionPreview - ? buildSubmissionPreviewMessages(submissionPreview) - : []; - - if (runtimeTeamDispatchPreviewMessages.length > 0) { - return [...collapsedMessages, ...runtimeTeamDispatchPreviewMessages]; - } - - if (submissionPreviewMessages.length > 0) { - return submissionPreviewMessages; - } - - if ( - collapsedMessages.length === 0 && - bootstrapDispatchPreviewMessages.length > 0 - ) { - return bootstrapDispatchPreviewMessages; - } - - return collapsedMessages; - }, [ - bootstrapDispatchPreviewMessages, - messages, - runtimeTeamDispatchPreview, - submissionPreview, - ]); - - useEffect(() => { - if (!sessionId) { - return; - } - - updateTopicSnapshot( - sessionId, - buildLiveTaskSnapshot({ - messages: displayMessages, - isSending, - pendingActionCount, - queuedTurnCount, - workspaceError, - }), - ); - }, [ - displayMessages, - isSending, - pendingActionCount, - queuedTurnCount, - sessionId, - updateTopicSnapshot, - workspaceError, - ]); - - return { - displayMessages, - }; -} diff --git a/src/components/agent/chat/workspace/useWorkspaceGeneralWorkbenchShellRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceGeneralWorkbenchShellRuntime.tsx deleted file mode 100644 index 5163e3527..000000000 --- a/src/components/agent/chat/workspace/useWorkspaceGeneralWorkbenchShellRuntime.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { useWorkspaceContextHarnessRuntime } from "./useWorkspaceContextHarnessRuntime"; -import { useWorkspaceHarnessInventoryRuntime } from "./useWorkspaceHarnessInventoryRuntime"; -import { useWorkspaceGeneralWorkbenchScaffoldRuntime } from "./useWorkspaceGeneralWorkbenchScaffoldRuntime"; -import { useWorkspaceGeneralWorkbenchSidebarRuntime } from "./useWorkspaceGeneralWorkbenchSidebarRuntime"; -import { useGeneralWorkbenchSidebarPresentation } from "./useGeneralWorkbenchSidebarPresentation"; - -type GeneralWorkbenchSidebarPresentationParams = Parameters< - typeof useGeneralWorkbenchSidebarPresentation ->[0]; -type GeneralWorkbenchSidebarHistory = NonNullable< - GeneralWorkbenchSidebarPresentationParams["sidebar"]["history"] ->; -type ContextHarnessRuntime = ReturnType< - typeof useWorkspaceContextHarnessRuntime ->; -type HarnessInventoryRuntime = ReturnType< - typeof useWorkspaceHarnessInventoryRuntime ->; -type GeneralWorkbenchScaffoldRuntime = ReturnType< - typeof useWorkspaceGeneralWorkbenchScaffoldRuntime ->; -type GeneralWorkbenchSidebarRuntime = ReturnType< - typeof useWorkspaceGeneralWorkbenchSidebarRuntime ->; - -interface UseWorkspaceGeneralWorkbenchShellRuntimeParams { - showChatPanel: GeneralWorkbenchSidebarPresentationParams["showChatPanel"]; - showSidebar: GeneralWorkbenchSidebarPresentationParams["showSidebar"]; - hasPendingA2UIForm: GeneralWorkbenchSidebarPresentationParams["hasPendingA2UIForm"]; - contextHarnessRuntime: ContextHarnessRuntime; - generalWorkbenchScaffoldRuntime: GeneralWorkbenchScaffoldRuntime; - generalWorkbenchSidebarRuntime: GeneralWorkbenchSidebarRuntime; - harnessInventoryRuntime: HarnessInventoryRuntime; - handleCreateVersionSnapshot: GeneralWorkbenchSidebarPresentationParams["sidebar"]["workflow"]["onCreateVersionSnapshot"]; - handleSwitchBranchVersion: GeneralWorkbenchSidebarPresentationParams["sidebar"]["workflow"]["onSwitchBranchVersion"]; - handleSetBranchStatus: GeneralWorkbenchSidebarPresentationParams["sidebar"]["workflow"]["onSetBranchStatus"]; - handleAddImage: GeneralWorkbenchSidebarPresentationParams["sidebar"]["workflow"]["onAddImage"]; - handleImportDocument: GeneralWorkbenchSidebarPresentationParams["sidebar"]["workflow"]["onImportDocument"]; - handleViewContextDetail: GeneralWorkbenchSidebarPresentationParams["sidebar"]["onViewContextDetail"]; - messages: GeneralWorkbenchSidebarHistory["messages"]; - harnessState: GeneralWorkbenchSidebarPresentationParams["harnessDialog"]["harnessState"]; - compatSubagentRuntime: GeneralWorkbenchSidebarPresentationParams["harnessDialog"]["compatSubagentRuntime"]; - childSubagentSessions: GeneralWorkbenchSidebarPresentationParams["harnessDialog"]["childSubagentSessions"]; - selectedTeamLabel: GeneralWorkbenchSidebarPresentationParams["harnessDialog"]["selectedTeamLabel"]; - selectedTeamSummary: GeneralWorkbenchSidebarPresentationParams["harnessDialog"]["selectedTeamSummary"]; - selectedTeamRoles: GeneralWorkbenchSidebarPresentationParams["harnessDialog"]["selectedTeamRoles"]; - teamMemorySnapshot: GeneralWorkbenchSidebarPresentationParams["harnessDialog"]["teamMemorySnapshot"]; - handleOpenSubagentSession: GeneralWorkbenchSidebarPresentationParams["harnessDialog"]["onOpenSubagentSession"]; - handleHarnessLoadFilePreview: GeneralWorkbenchSidebarPresentationParams["harnessDialog"]["onLoadFilePreview"]; - handleFileClick: GeneralWorkbenchSidebarPresentationParams["harnessDialog"]["onOpenFile"]; -} - -export function useWorkspaceGeneralWorkbenchShellRuntime({ - showChatPanel, - showSidebar, - hasPendingA2UIForm, - contextHarnessRuntime, - generalWorkbenchScaffoldRuntime, - generalWorkbenchSidebarRuntime, - harnessInventoryRuntime, - handleCreateVersionSnapshot, - handleSwitchBranchVersion, - handleSetBranchStatus, - handleAddImage, - handleImportDocument, - handleViewContextDetail, - messages, - harnessState, - compatSubagentRuntime, - childSubagentSessions, - selectedTeamLabel, - selectedTeamSummary, - selectedTeamRoles, - teamMemorySnapshot, - handleOpenSubagentSession, - handleHarnessLoadFilePreview, - handleFileClick, -}: UseWorkspaceGeneralWorkbenchShellRuntimeParams) { - return useGeneralWorkbenchSidebarPresentation({ - showChatPanel, - showSidebar, - hasPendingA2UIForm, - isThemeWorkbench: contextHarnessRuntime.isThemeWorkbench, - shouldUseCompactGeneralWorkbench: - generalWorkbenchScaffoldRuntime.shouldUseCompactGeneralWorkbench, - enablePanelCollapse: - generalWorkbenchScaffoldRuntime.enableGeneralWorkbenchPanelCollapse, - sidebarCollapsed: - generalWorkbenchScaffoldRuntime.generalWorkbenchSidebarCollapsed, - onSidebarCollapsedChange: - generalWorkbenchScaffoldRuntime.setGeneralWorkbenchSidebarCollapsed, - sidebar: { - generalWorkbenchHarnessSummary: - harnessInventoryRuntime.generalWorkbenchHarnessSummary, - harnessPanelVisible: contextHarnessRuntime.harnessPanelVisible, - onToggleHarnessPanel: contextHarnessRuntime.handleToggleHarnessPanel, - workflow: { - branchItems: generalWorkbenchScaffoldRuntime.branchItems, - onCreateVersionSnapshot: handleCreateVersionSnapshot, - onSwitchBranchVersion: handleSwitchBranchVersion, - onSetBranchStatus: handleSetBranchStatus, - workflowSteps: - generalWorkbenchSidebarRuntime.generalWorkbenchWorkflowSteps, - onAddImage: handleAddImage, - onImportDocument: handleImportDocument, - activityLogs: - generalWorkbenchSidebarRuntime.generalWorkbenchActivityLogs, - creationTaskEvents: - generalWorkbenchScaffoldRuntime.generalWorkbenchCreationTaskEvents, - onViewRunDetail: - generalWorkbenchSidebarRuntime.handleViewGeneralWorkbenchRunDetail, - activeRunDetail: - generalWorkbenchSidebarRuntime.selectedGeneralWorkbenchRunDetail, - activeRunDetailLoading: - generalWorkbenchSidebarRuntime.generalWorkbenchRunDetailLoading, - }, - contextWorkspace: contextHarnessRuntime.contextWorkspace, - onViewContextDetail: handleViewContextDetail, - history: { - hasMore: generalWorkbenchSidebarRuntime.generalWorkbenchHistoryHasMore, - loading: generalWorkbenchSidebarRuntime.generalWorkbenchHistoryLoading, - onLoadMore: - generalWorkbenchSidebarRuntime.handleLoadMoreGeneralWorkbenchHistory, - skillDetailMap: - generalWorkbenchSidebarRuntime.generalWorkbenchSkillDetailMap, - messages, - }, - }, - harnessDialog: { - enabled: - contextHarnessRuntime.workbenchEnabled && - contextHarnessRuntime.isThemeWorkbench, - open: contextHarnessRuntime.harnessPanelVisible, - onOpenChange: contextHarnessRuntime.setHarnessPanelVisible, - harnessState, - compatSubagentRuntime, - environment: contextHarnessRuntime.harnessEnvironment, - childSubagentSessions, - selectedTeamLabel, - selectedTeamSummary, - selectedTeamRoles, - teamMemorySnapshot, - toolInventory: harnessInventoryRuntime.toolInventory, - toolInventoryLoading: harnessInventoryRuntime.toolInventoryLoading, - toolInventoryError: harnessInventoryRuntime.toolInventoryError, - onRefreshToolInventory: harnessInventoryRuntime.refreshToolInventory, - onOpenSubagentSession: handleOpenSubagentSession, - onLoadFilePreview: handleHarnessLoadFilePreview, - onOpenFile: handleFileClick, - }, - }); -} diff --git a/src/components/agent/chat/workspace/useWorkspaceGeneralWorkbenchVersionStatusRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceGeneralWorkbenchVersionStatusRuntime.ts deleted file mode 100644 index b4411c3ea..000000000 --- a/src/components/agent/chat/workspace/useWorkspaceGeneralWorkbenchVersionStatusRuntime.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { useEffect, type Dispatch, type SetStateAction } from "react"; -import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas"; -import type { TopicBranchStatus } from "../hooks/useTopicBranchBoard"; - -interface GeneralWorkbenchLatestTerminalSummary { - run_id: string; - status?: string | null; -} - -interface UseWorkspaceGeneralWorkbenchVersionStatusRuntimeParams { - isThemeWorkbench: boolean; - themeWorkbenchRunState: "idle" | "auto_running" | "await_user_decision"; - canvasState: CanvasStateUnion | null; - latestTerminal: GeneralWorkbenchLatestTerminalSummary | null; - setDocumentVersionStatusMap: Dispatch< - SetStateAction> - >; -} - -export function useWorkspaceGeneralWorkbenchVersionStatusRuntime({ - isThemeWorkbench, - themeWorkbenchRunState, - canvasState, - latestTerminal, - setDocumentVersionStatusMap, -}: UseWorkspaceGeneralWorkbenchVersionStatusRuntimeParams) { - useEffect(() => { - if (!isThemeWorkbench || themeWorkbenchRunState !== "idle") { - return; - } - if (!canvasState || canvasState.type !== "document") { - return; - } - - setDocumentVersionStatusMap((previous) => { - if (latestTerminal) { - const terminalVersionId = latestTerminal.run_id; - const terminalVersionExists = canvasState.versions.some( - (version) => version.id === terminalVersionId, - ); - if (terminalVersionExists) { - const terminalStatus: TopicBranchStatus = - latestTerminal.status === "success" ? "merged" : "candidate"; - if (previous[terminalVersionId] !== terminalStatus) { - return { - ...previous, - [terminalVersionId]: terminalStatus, - }; - } - } - } - - const currentVersionId = canvasState.currentVersionId; - if (!currentVersionId || previous[currentVersionId] !== "in_progress") { - return previous; - } - return { - ...previous, - [currentVersionId]: "pending", - }; - }); - }, [ - canvasState, - isThemeWorkbench, - latestTerminal, - setDocumentVersionStatusMap, - themeWorkbenchRunState, - ]); -} diff --git a/src/components/agent/chat/workspace/useWorkspaceInputbarPresentation.tsx b/src/components/agent/chat/workspace/useWorkspaceInputbarPresentation.tsx deleted file mode 100644 index 4dac09e29..000000000 --- a/src/components/agent/chat/workspace/useWorkspaceInputbarPresentation.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { useMemo, type ComponentProps, type ReactNode } from "react"; -import { GeneralWorkbenchEntryPromptAccessory } from "../components/GeneralWorkbenchEntryPromptAccessory"; -import { GeneralWorkbenchDialogSection } from "./WorkspaceHarnessDialogs"; -import { WorkspaceInputbar } from "./WorkspaceInputbar"; -import { - buildFloatingTeamWorkspaceDockProps, - buildWorkspaceInputbarProps, -} from "./inputbarPresentation"; -import type { TeamWorkbenchSurfaceProps } from "./teamWorkbenchPresentation"; - -type WorkspaceInputbarBuilderParams = Parameters< - typeof buildWorkspaceInputbarProps ->[0]; -type FloatingTeamWorkspaceDockParams = Omit< - Parameters[0], - "surfaceProps" ->; - -interface WorkspaceTeamWorkbenchSurfaceParams { - shellVisible: TeamWorkbenchSurfaceProps["shellVisible"]; - currentSessionId: TeamWorkbenchSurfaceProps["currentSessionId"]; - currentSessionName: TeamWorkbenchSurfaceProps["currentSessionName"]; - currentSessionRuntimeStatus: TeamWorkbenchSurfaceProps["currentSessionRuntimeStatus"]; - currentSessionLatestTurnStatus: TeamWorkbenchSurfaceProps["currentSessionLatestTurnStatus"]; - currentSessionQueuedTurnCount: TeamWorkbenchSurfaceProps["currentSessionQueuedTurnCount"]; - childSubagentSessions: TeamWorkbenchSurfaceProps["childSubagentSessions"]; - subagentParentContext: TeamWorkbenchSurfaceProps["subagentParentContext"]; - liveRuntimeBySessionId: TeamWorkbenchSurfaceProps["liveRuntimeBySessionId"]; - liveActivityBySessionId: TeamWorkbenchSurfaceProps["liveActivityBySessionId"]; - activityRefreshVersionBySessionId: TeamWorkbenchSurfaceProps["activityRefreshVersionBySessionId"]; - onSendSubagentInput: TeamWorkbenchSurfaceProps["onSendSubagentInput"]; - onWaitSubagentSession: TeamWorkbenchSurfaceProps["onWaitSubagentSession"]; - onWaitActiveTeamSessions: TeamWorkbenchSurfaceProps["onWaitActiveTeamSessions"]; - onCloseCompletedTeamSessions: TeamWorkbenchSurfaceProps["onCloseCompletedTeamSessions"]; - onCloseSubagentSession: TeamWorkbenchSurfaceProps["onCloseSubagentSession"]; - onResumeSubagentSession: TeamWorkbenchSurfaceProps["onResumeSubagentSession"]; - onOpenSubagentSession: TeamWorkbenchSurfaceProps["onOpenSubagentSession"]; - onReturnToParentSession: TeamWorkbenchSurfaceProps["onReturnToParentSession"]; - teamWaitSummary: TeamWorkbenchSurfaceProps["teamWaitSummary"]; - teamControlSummary: TeamWorkbenchSurfaceProps["teamControlSummary"]; - selectedTeamLabel: TeamWorkbenchSurfaceProps["selectedTeamLabel"]; - selectedTeamSummary: TeamWorkbenchSurfaceProps["selectedTeamSummary"]; - selectedTeamRoles: TeamWorkbenchSurfaceProps["selectedTeamRoles"]; - teamDispatchPreviewState: TeamWorkbenchSurfaceProps["teamDispatchPreviewState"]; -} - -interface UseWorkspaceInputbarPresentationParams { - teamWorkbench: WorkspaceTeamWorkbenchSurfaceParams; - inputbar: WorkspaceInputbarBuilderParams; - floatingTeamWorkspaceDock: FloatingTeamWorkspaceDockParams; - generalWorkbenchEntryPrompt: - | ComponentProps["prompt"] - | null; - onRestartGeneralWorkbenchEntryPrompt: () => void; - onContinueGeneralWorkbenchEntryPrompt: () => Promise | void; - generalWorkbenchDialog: ComponentProps; -} - -interface WorkspaceInputbarPresentationResult { - teamWorkbenchSurfaceProps: TeamWorkbenchSurfaceProps; - inputbarNode: ReactNode; - generalWorkbenchDialog: ReactNode; -} - -export function useWorkspaceInputbarPresentation({ - teamWorkbench, - inputbar, - floatingTeamWorkspaceDock, - generalWorkbenchEntryPrompt, - onRestartGeneralWorkbenchEntryPrompt, - onContinueGeneralWorkbenchEntryPrompt, - generalWorkbenchDialog, -}: UseWorkspaceInputbarPresentationParams): WorkspaceInputbarPresentationResult { - const teamWorkbenchSurfaceProps = useMemo( - () => ({ - shellVisible: teamWorkbench.shellVisible, - currentSessionId: teamWorkbench.currentSessionId, - currentSessionName: teamWorkbench.currentSessionName, - currentSessionRuntimeStatus: teamWorkbench.currentSessionRuntimeStatus, - currentSessionLatestTurnStatus: - teamWorkbench.currentSessionLatestTurnStatus, - currentSessionQueuedTurnCount: - teamWorkbench.currentSessionQueuedTurnCount, - childSubagentSessions: teamWorkbench.childSubagentSessions, - subagentParentContext: teamWorkbench.subagentParentContext, - liveRuntimeBySessionId: teamWorkbench.liveRuntimeBySessionId, - liveActivityBySessionId: teamWorkbench.liveActivityBySessionId, - activityRefreshVersionBySessionId: - teamWorkbench.activityRefreshVersionBySessionId, - onSendSubagentInput: teamWorkbench.onSendSubagentInput, - onWaitSubagentSession: teamWorkbench.onWaitSubagentSession, - onWaitActiveTeamSessions: teamWorkbench.onWaitActiveTeamSessions, - onCloseCompletedTeamSessions: teamWorkbench.onCloseCompletedTeamSessions, - onCloseSubagentSession: teamWorkbench.onCloseSubagentSession, - onResumeSubagentSession: teamWorkbench.onResumeSubagentSession, - onOpenSubagentSession: teamWorkbench.onOpenSubagentSession, - onReturnToParentSession: teamWorkbench.onReturnToParentSession, - teamWaitSummary: teamWorkbench.teamWaitSummary, - teamControlSummary: teamWorkbench.teamControlSummary, - selectedTeamLabel: teamWorkbench.selectedTeamLabel, - selectedTeamSummary: teamWorkbench.selectedTeamSummary, - selectedTeamRoles: teamWorkbench.selectedTeamRoles, - teamDispatchPreviewState: teamWorkbench.teamDispatchPreviewState, - }), - [ - teamWorkbench.activityRefreshVersionBySessionId, - teamWorkbench.childSubagentSessions, - teamWorkbench.currentSessionId, - teamWorkbench.currentSessionLatestTurnStatus, - teamWorkbench.currentSessionName, - teamWorkbench.currentSessionQueuedTurnCount, - teamWorkbench.currentSessionRuntimeStatus, - teamWorkbench.liveActivityBySessionId, - teamWorkbench.liveRuntimeBySessionId, - teamWorkbench.onCloseCompletedTeamSessions, - teamWorkbench.onCloseSubagentSession, - teamWorkbench.onOpenSubagentSession, - teamWorkbench.onResumeSubagentSession, - teamWorkbench.onReturnToParentSession, - teamWorkbench.onSendSubagentInput, - teamWorkbench.onWaitActiveTeamSessions, - teamWorkbench.onWaitSubagentSession, - teamWorkbench.teamDispatchPreviewState, - teamWorkbench.selectedTeamLabel, - teamWorkbench.selectedTeamRoles, - teamWorkbench.selectedTeamSummary, - teamWorkbench.shellVisible, - teamWorkbench.subagentParentContext, - teamWorkbench.teamControlSummary, - teamWorkbench.teamWaitSummary, - ], - ); - - const generalWorkbenchEntryPromptAccessory = useMemo( - () => - generalWorkbenchEntryPrompt ? ( - - ) : null, - [ - generalWorkbenchEntryPrompt, - onContinueGeneralWorkbenchEntryPrompt, - onRestartGeneralWorkbenchEntryPrompt, - ], - ); - - const workspaceInputbarProps = buildWorkspaceInputbarProps(inputbar); - const floatingTeamWorkspaceDockProps = buildFloatingTeamWorkspaceDockProps({ - ...floatingTeamWorkspaceDock, - surfaceProps: teamWorkbenchSurfaceProps, - }); - - return { - teamWorkbenchSurfaceProps, - inputbarNode: ( - - ), - generalWorkbenchDialog: ( - - ), - }; -} diff --git a/src/components/agent/chat/workspace/useWorkspaceInputbarScenePresentation.tsx b/src/components/agent/chat/workspace/useWorkspaceInputbarScenePresentation.tsx deleted file mode 100644 index f822ab565..000000000 --- a/src/components/agent/chat/workspace/useWorkspaceInputbarScenePresentation.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { - useCallback, - useMemo, - type Dispatch, - type SetStateAction, -} from "react"; -import type { Character } from "@/lib/api/memory"; -import type { TaskFile } from "../components/TaskFiles"; -import { resolveCanvasTaskFileTarget } from "../utils/taskFileCanvasSync"; -import { isRenderableTaskFile } from "./generalWorkbenchHelpers"; -import { useWorkspaceInputbarPresentation } from "./useWorkspaceInputbarPresentation"; - -type WorkspaceInputbarPresentationParams = Parameters< - typeof useWorkspaceInputbarPresentation ->[0]; - -interface UseWorkspaceInputbarScenePresentationParams { - setMentionedCharacters: Dispatch>; - taskFiles: TaskFile[]; - taskFilesExpanded: boolean; - setTaskFilesExpanded: Dispatch>; - selectedFileId?: string; - isThemeWorkbench: boolean; - inputbarPresentation: { - teamWorkbench: WorkspaceInputbarPresentationParams["teamWorkbench"]; - inputbar: Omit< - WorkspaceInputbarPresentationParams["inputbar"], - | "taskFiles" - | "selectedFileId" - | "taskFilesExpanded" - | "onToggleTaskFiles" - | "onSelectCharacter" - >; - floatingTeamWorkspaceDock: WorkspaceInputbarPresentationParams["floatingTeamWorkspaceDock"]; - generalWorkbenchEntryPrompt: WorkspaceInputbarPresentationParams["generalWorkbenchEntryPrompt"]; - onRestartGeneralWorkbenchEntryPrompt: WorkspaceInputbarPresentationParams["onRestartGeneralWorkbenchEntryPrompt"]; - onContinueGeneralWorkbenchEntryPrompt: WorkspaceInputbarPresentationParams["onContinueGeneralWorkbenchEntryPrompt"]; - generalWorkbenchDialog: WorkspaceInputbarPresentationParams["generalWorkbenchDialog"]; - }; -} - -interface WorkspaceInputbarScenePresentationResult { - visibleTaskFiles: TaskFile[]; - visibleSelectedFileId?: string; - activeCanvasTaskFile: TaskFile | null; - teamWorkbenchSurfaceProps: ReturnType< - typeof useWorkspaceInputbarPresentation - >["teamWorkbenchSurfaceProps"]; - inputbarNode: ReturnType< - typeof useWorkspaceInputbarPresentation - >["inputbarNode"]; - generalWorkbenchDialog: ReturnType< - typeof useWorkspaceInputbarPresentation - >["generalWorkbenchDialog"]; -} - -export function useWorkspaceInputbarScenePresentation({ - setMentionedCharacters, - taskFiles, - taskFilesExpanded, - setTaskFilesExpanded, - selectedFileId, - isThemeWorkbench, - inputbarPresentation, -}: UseWorkspaceInputbarScenePresentationParams): WorkspaceInputbarScenePresentationResult { - const handleSelectCharacter = useCallback( - (character: Character) => { - setMentionedCharacters((previous) => { - if (previous.find((item) => item.id === character.id)) { - return previous; - } - return [...previous, character]; - }); - }, - [setMentionedCharacters], - ); - - const handleToggleTaskFiles = useCallback(() => { - setTaskFilesExpanded((previous) => !previous); - }, [setTaskFilesExpanded]); - - const visibleTaskFiles = useMemo( - () => - taskFiles.filter((file) => isRenderableTaskFile(file, isThemeWorkbench)), - [isThemeWorkbench, taskFiles], - ); - - const visibleSelectedFileId = useMemo(() => { - if (!selectedFileId) { - return undefined; - } - return visibleTaskFiles.some((file) => file.id === selectedFileId) - ? selectedFileId - : undefined; - }, [selectedFileId, visibleTaskFiles]); - - const activeCanvasTaskFile = useMemo( - () => - resolveCanvasTaskFileTarget(visibleTaskFiles, visibleSelectedFileId) - .targetFile, - [visibleSelectedFileId, visibleTaskFiles], - ); - - const { teamWorkbenchSurfaceProps, inputbarNode, generalWorkbenchDialog } = - useWorkspaceInputbarPresentation({ - teamWorkbench: inputbarPresentation.teamWorkbench, - inputbar: { - ...inputbarPresentation.inputbar, - taskFiles: visibleTaskFiles, - selectedFileId: visibleSelectedFileId, - taskFilesExpanded, - onToggleTaskFiles: handleToggleTaskFiles, - onSelectCharacter: handleSelectCharacter, - }, - floatingTeamWorkspaceDock: inputbarPresentation.floatingTeamWorkspaceDock, - generalWorkbenchEntryPrompt: - inputbarPresentation.generalWorkbenchEntryPrompt, - onRestartGeneralWorkbenchEntryPrompt: - inputbarPresentation.onRestartGeneralWorkbenchEntryPrompt, - onContinueGeneralWorkbenchEntryPrompt: - inputbarPresentation.onContinueGeneralWorkbenchEntryPrompt, - generalWorkbenchDialog: inputbarPresentation.generalWorkbenchDialog, - }); - - return { - visibleTaskFiles, - visibleSelectedFileId, - activeCanvasTaskFile, - teamWorkbenchSurfaceProps, - inputbarNode, - generalWorkbenchDialog, - }; -} diff --git a/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx index ea28ec853..b2ed57a20 100644 --- a/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx @@ -1,46 +1,286 @@ -import type { Dispatch, SetStateAction } from "react"; -import { useCallback } from "react"; -import { useWorkspaceContextHarnessRuntime } from "./useWorkspaceContextHarnessRuntime"; -import { useWorkspaceHarnessInventoryRuntime } from "./useWorkspaceHarnessInventoryRuntime"; -import { useWorkspaceInputbarScenePresentation } from "./useWorkspaceInputbarScenePresentation"; +import { + useCallback, + useMemo, + type ComponentProps, + type Dispatch, + type ReactNode, + type SetStateAction, +} from "react"; +import type { Character } from "@/lib/api/memory"; +import { GeneralWorkbenchEntryPromptAccessory } from "../components/GeneralWorkbenchEntryPromptAccessory"; +import { Inputbar } from "../components/Inputbar"; +import { TeamWorkspaceDock } from "../components/TeamWorkspaceDock"; import { useWorkspaceNavigationActions } from "./useWorkspaceNavigationActions"; -import { useWorkspaceShellChromeRuntime } from "./useWorkspaceShellChromeRuntime"; -import { useWorkspaceTeamSessionControlRuntime } from "./useWorkspaceTeamSessionControlRuntime"; -import { useWorkspaceTeamSessionRuntime } from "./useWorkspaceTeamSessionRuntime"; -import { useWorkspaceGeneralWorkbenchSidebarRuntime } from "./useWorkspaceGeneralWorkbenchSidebarRuntime"; import type { Message } from "../types"; import type { LayoutMode } from "@/lib/workspace/workbenchContract"; +import type { TaskFile } from "../components/TaskFiles"; import { DEFAULT_CHAT_TOOL_PREFERENCES, type ChatToolPreferences, } from "../utils/chatToolPreferences"; +import { resolveCanvasTaskFileTarget } from "../utils/taskFileCanvasSync"; +import { isRenderableTaskFile } from "./generalWorkbenchHelpers"; +import { + GeneralWorkbenchDialogSection, +} from "./WorkspaceHarnessDialogs"; +import { WorkspaceInputbar } from "./WorkspaceInputbar"; +import type { TeamWorkbenchSurfaceProps } from "./chatSurfaceProps"; -type InputbarScenePresentationParams = Parameters< - typeof useWorkspaceInputbarScenePresentation ->[0]; +type WorkspaceInputbarBuilderParams = Omit< + ComponentProps, + "overlayAccessory" +>; +interface WorkspaceTeamWorkbenchSurfaceParams { + shellVisible: TeamWorkbenchSurfaceProps["shellVisible"]; + currentSessionId: TeamWorkbenchSurfaceProps["currentSessionId"]; + currentSessionName: TeamWorkbenchSurfaceProps["currentSessionName"]; + currentSessionRuntimeStatus: TeamWorkbenchSurfaceProps["currentSessionRuntimeStatus"]; + currentSessionLatestTurnStatus: TeamWorkbenchSurfaceProps["currentSessionLatestTurnStatus"]; + currentSessionQueuedTurnCount: TeamWorkbenchSurfaceProps["currentSessionQueuedTurnCount"]; + childSubagentSessions: TeamWorkbenchSurfaceProps["childSubagentSessions"]; + subagentParentContext: TeamWorkbenchSurfaceProps["subagentParentContext"]; + liveRuntimeBySessionId: TeamWorkbenchSurfaceProps["liveRuntimeBySessionId"]; + liveActivityBySessionId: TeamWorkbenchSurfaceProps["liveActivityBySessionId"]; + activityRefreshVersionBySessionId: TeamWorkbenchSurfaceProps["activityRefreshVersionBySessionId"]; + onSendSubagentInput: TeamWorkbenchSurfaceProps["onSendSubagentInput"]; + onWaitSubagentSession: TeamWorkbenchSurfaceProps["onWaitSubagentSession"]; + onWaitActiveTeamSessions: TeamWorkbenchSurfaceProps["onWaitActiveTeamSessions"]; + onCloseCompletedTeamSessions: TeamWorkbenchSurfaceProps["onCloseCompletedTeamSessions"]; + onCloseSubagentSession: TeamWorkbenchSurfaceProps["onCloseSubagentSession"]; + onResumeSubagentSession: TeamWorkbenchSurfaceProps["onResumeSubagentSession"]; + onOpenSubagentSession: TeamWorkbenchSurfaceProps["onOpenSubagentSession"]; + onReturnToParentSession: TeamWorkbenchSurfaceProps["onReturnToParentSession"]; + teamWaitSummary: TeamWorkbenchSurfaceProps["teamWaitSummary"]; + teamControlSummary: TeamWorkbenchSurfaceProps["teamControlSummary"]; + selectedTeamLabel: TeamWorkbenchSurfaceProps["selectedTeamLabel"]; + selectedTeamSummary: TeamWorkbenchSurfaceProps["selectedTeamSummary"]; + selectedTeamRoles: TeamWorkbenchSurfaceProps["selectedTeamRoles"]; + teamDispatchPreviewState: TeamWorkbenchSurfaceProps["teamDispatchPreviewState"]; +} +interface FloatingTeamWorkspaceDockParams { + enabled: boolean; + layoutMode: "chat" | "chat-canvas"; + showFloatingInputOverlay: boolean; + onActivateWorkbench: NonNullable< + ComponentProps["onActivateWorkbench"] + >; +} + +interface UseWorkspaceInputbarScenePresentationRuntimeParams { + setMentionedCharacters: Dispatch>; + taskFiles: TaskFile[]; + taskFilesExpanded: boolean; + setTaskFilesExpanded: Dispatch>; + selectedFileId?: string; + isThemeWorkbench: boolean; + inputbarPresentation: { + teamWorkbench: WorkspaceTeamWorkbenchSurfaceParams; + inputbar: Omit< + WorkspaceInputbarBuilderParams, + | "taskFiles" + | "selectedFileId" + | "taskFilesExpanded" + | "onToggleTaskFiles" + | "onSelectCharacter" + >; + floatingTeamWorkspaceDock: FloatingTeamWorkspaceDockParams; + generalWorkbenchEntryPrompt: + | ComponentProps["prompt"] + | null; + onRestartGeneralWorkbenchEntryPrompt: () => void; + onContinueGeneralWorkbenchEntryPrompt: () => Promise | void; + generalWorkbenchDialog: ComponentProps; + }; +} +interface WorkspaceInputbarScenePresentationRuntimeResult { + visibleTaskFiles: TaskFile[]; + visibleSelectedFileId?: string; + activeCanvasTaskFile: TaskFile | null; + teamWorkbenchSurfaceProps: TeamWorkbenchSurfaceProps; + inputbarNode: ReactNode; + generalWorkbenchDialog: ReactNode; +} +type InputbarScenePresentationParams = + UseWorkspaceInputbarScenePresentationRuntimeParams; type InputbarPresentationParams = InputbarScenePresentationParams["inputbarPresentation"]; type TeamWorkbenchParams = InputbarPresentationParams["teamWorkbench"]; type InputbarParams = InputbarPresentationParams["inputbar"]; -type FloatingTeamWorkspaceDockParams = - InputbarPresentationParams["floatingTeamWorkspaceDock"]; type GeneralWorkbenchDialogParams = InputbarPresentationParams["generalWorkbenchDialog"]; type NavigationActions = ReturnType; -type ContextHarnessRuntime = ReturnType< - typeof useWorkspaceContextHarnessRuntime ->; -type HarnessInventoryRuntime = ReturnType< - typeof useWorkspaceHarnessInventoryRuntime ->; -type ShellChromeRuntime = ReturnType; -type TeamSessionRuntime = ReturnType; -type TeamSessionControlRuntime = ReturnType< - typeof useWorkspaceTeamSessionControlRuntime ->; -type GeneralWorkbenchSidebarRuntime = ReturnType< - typeof useWorkspaceGeneralWorkbenchSidebarRuntime ->; + +function useWorkspaceInputbarScenePresentationRuntime({ + setMentionedCharacters, + taskFiles, + taskFilesExpanded, + setTaskFilesExpanded, + selectedFileId, + isThemeWorkbench, + inputbarPresentation, +}: UseWorkspaceInputbarScenePresentationRuntimeParams): WorkspaceInputbarScenePresentationRuntimeResult { + const handleSelectCharacter = useCallback( + (character: Character) => { + setMentionedCharacters((previous) => { + if (previous.find((item) => item.id === character.id)) { + return previous; + } + return [...previous, character]; + }); + }, + [setMentionedCharacters], + ); + + const handleToggleTaskFiles = useCallback(() => { + setTaskFilesExpanded((previous) => !previous); + }, [setTaskFilesExpanded]); + + const visibleTaskFiles = useMemo( + () => + taskFiles.filter((file) => isRenderableTaskFile(file, isThemeWorkbench)), + [isThemeWorkbench, taskFiles], + ); + + const visibleSelectedFileId = useMemo(() => { + if (!selectedFileId) { + return undefined; + } + return visibleTaskFiles.some((file) => file.id === selectedFileId) + ? selectedFileId + : undefined; + }, [selectedFileId, visibleTaskFiles]); + + const activeCanvasTaskFile = useMemo( + () => + resolveCanvasTaskFileTarget(visibleTaskFiles, visibleSelectedFileId) + .targetFile, + [visibleSelectedFileId, visibleTaskFiles], + ); + + const teamWorkbenchSurfaceProps = useMemo( + () => ({ + shellVisible: inputbarPresentation.teamWorkbench.shellVisible, + currentSessionId: inputbarPresentation.teamWorkbench.currentSessionId, + currentSessionName: inputbarPresentation.teamWorkbench.currentSessionName, + currentSessionRuntimeStatus: + inputbarPresentation.teamWorkbench.currentSessionRuntimeStatus, + currentSessionLatestTurnStatus: + inputbarPresentation.teamWorkbench.currentSessionLatestTurnStatus, + currentSessionQueuedTurnCount: + inputbarPresentation.teamWorkbench.currentSessionQueuedTurnCount, + childSubagentSessions: + inputbarPresentation.teamWorkbench.childSubagentSessions, + subagentParentContext: + inputbarPresentation.teamWorkbench.subagentParentContext, + liveRuntimeBySessionId: + inputbarPresentation.teamWorkbench.liveRuntimeBySessionId, + liveActivityBySessionId: + inputbarPresentation.teamWorkbench.liveActivityBySessionId, + activityRefreshVersionBySessionId: + inputbarPresentation.teamWorkbench.activityRefreshVersionBySessionId, + onSendSubagentInput: + inputbarPresentation.teamWorkbench.onSendSubagentInput, + onWaitSubagentSession: + inputbarPresentation.teamWorkbench.onWaitSubagentSession, + onWaitActiveTeamSessions: + inputbarPresentation.teamWorkbench.onWaitActiveTeamSessions, + onCloseCompletedTeamSessions: + inputbarPresentation.teamWorkbench.onCloseCompletedTeamSessions, + onCloseSubagentSession: + inputbarPresentation.teamWorkbench.onCloseSubagentSession, + onResumeSubagentSession: + inputbarPresentation.teamWorkbench.onResumeSubagentSession, + onOpenSubagentSession: + inputbarPresentation.teamWorkbench.onOpenSubagentSession, + onReturnToParentSession: + inputbarPresentation.teamWorkbench.onReturnToParentSession, + teamWaitSummary: inputbarPresentation.teamWorkbench.teamWaitSummary, + teamControlSummary: inputbarPresentation.teamWorkbench.teamControlSummary, + selectedTeamLabel: inputbarPresentation.teamWorkbench.selectedTeamLabel, + selectedTeamSummary: + inputbarPresentation.teamWorkbench.selectedTeamSummary, + selectedTeamRoles: inputbarPresentation.teamWorkbench.selectedTeamRoles, + teamDispatchPreviewState: + inputbarPresentation.teamWorkbench.teamDispatchPreviewState, + }), + [inputbarPresentation.teamWorkbench], + ); + + const generalWorkbenchEntryPromptAccessory = useMemo( + () => + inputbarPresentation.generalWorkbenchEntryPrompt ? ( + + ) : null, + [ + inputbarPresentation.generalWorkbenchEntryPrompt, + inputbarPresentation.onContinueGeneralWorkbenchEntryPrompt, + inputbarPresentation.onRestartGeneralWorkbenchEntryPrompt, + ], + ); + + const floatingTeamWorkspaceDockProps = useMemo< + ComponentProps | null + >( + () => + !inputbarPresentation.floatingTeamWorkspaceDock.enabled || + !inputbarPresentation.floatingTeamWorkspaceDock.showFloatingInputOverlay || + inputbarPresentation.floatingTeamWorkspaceDock.layoutMode !== "chat" + ? null + : { + placement: "inline", + onActivateWorkbench: + inputbarPresentation.floatingTeamWorkspaceDock + .onActivateWorkbench, + ...teamWorkbenchSurfaceProps, + }, + [inputbarPresentation.floatingTeamWorkspaceDock, teamWorkbenchSurfaceProps], + ); + + const workspaceInputbarProps = useMemo( + () => ({ + ...inputbarPresentation.inputbar, + taskFiles: visibleTaskFiles, + selectedFileId: visibleSelectedFileId, + taskFilesExpanded, + onToggleTaskFiles: handleToggleTaskFiles, + onSelectCharacter: handleSelectCharacter, + }), + [ + handleSelectCharacter, + handleToggleTaskFiles, + inputbarPresentation.inputbar, + taskFilesExpanded, + visibleSelectedFileId, + visibleTaskFiles, + ], + ); + + const inputbarNode = ( + + ); + const generalWorkbenchDialog = ( + + ); + + return { + visibleTaskFiles, + visibleSelectedFileId, + activeCanvasTaskFile, + teamWorkbenchSurfaceProps, + inputbarNode, + generalWorkbenchDialog, + }; +} interface UseWorkspaceInputbarSceneRuntimeParams { contextVariant?: "default" | "task-center"; @@ -57,14 +297,28 @@ interface UseWorkspaceInputbarSceneRuntimeParams { selectedTeamSummary: TeamWorkbenchParams["selectedTeamSummary"]; teamDispatchPreviewState: TeamWorkbenchParams["teamDispatchPreviewState"]; teamMemorySnapshot: GeneralWorkbenchDialogParams["teamMemorySnapshot"]; - teamSessionRuntime: TeamSessionRuntime; - teamSessionControlRuntime: TeamSessionControlRuntime; + currentSessionTitle: TeamWorkbenchParams["currentSessionName"]; + currentSessionRuntimeStatus: TeamWorkbenchParams["currentSessionRuntimeStatus"]; + currentSessionLatestTurnStatus: TeamWorkbenchParams["currentSessionLatestTurnStatus"]; + liveRuntimeBySessionId: TeamWorkbenchParams["liveRuntimeBySessionId"]; + liveActivityBySessionId: TeamWorkbenchParams["liveActivityBySessionId"]; + activityRefreshVersionBySessionId: TeamWorkbenchParams["activityRefreshVersionBySessionId"]; + handleSendSubagentInput: TeamWorkbenchParams["onSendSubagentInput"]; + handleWaitSubagentSession: TeamWorkbenchParams["onWaitSubagentSession"]; + handleWaitActiveTeamSessions: TeamWorkbenchParams["onWaitActiveTeamSessions"]; + handleCloseCompletedTeamSessions: TeamWorkbenchParams["onCloseCompletedTeamSessions"]; + handleCloseSubagentSession: TeamWorkbenchParams["onCloseSubagentSession"]; + handleResumeSubagentSession: TeamWorkbenchParams["onResumeSubagentSession"]; + teamWaitSummary: TeamWorkbenchParams["teamWaitSummary"]; + teamControlSummary: TeamWorkbenchParams["teamControlSummary"]; + handleStopSending: InputbarParams["onStop"]; + teamWorkspaceEnabled: FloatingTeamWorkspaceDockParams["enabled"]; handleOpenSubagentSession: TeamWorkbenchParams["onOpenSubagentSession"]; handleReturnToParentSession: TeamWorkbenchParams["onReturnToParentSession"]; input: InputbarParams["input"]; setInput: InputbarParams["setInput"]; currentGate: InputbarParams["workflowGate"]; - generalWorkbenchSidebarRuntime: GeneralWorkbenchSidebarRuntime; + generalWorkbenchWorkflowSteps: InputbarParams["workflowSteps"]; steps: InputbarParams["workflowSteps"]; workflowRunState: InputbarParams["workflowRunState"]; handleSend: InputbarParams["onSend"]; @@ -122,14 +376,19 @@ interface UseWorkspaceInputbarSceneRuntimeParams { handleRestartGeneralWorkbenchEntryPrompt: InputbarPresentationParams["onRestartGeneralWorkbenchEntryPrompt"]; handleContinueGeneralWorkbenchEntryPrompt: InputbarPresentationParams["onContinueGeneralWorkbenchEntryPrompt"]; generalWorkbenchEnabled: boolean; - contextHarnessRuntime: ContextHarnessRuntime; + harnessPanelVisible: GeneralWorkbenchDialogParams["open"]; + setHarnessPanelVisible: GeneralWorkbenchDialogParams["onOpenChange"]; harnessState: GeneralWorkbenchDialogParams["harnessState"]; - compatSubagentRuntime: GeneralWorkbenchDialogParams["compatSubagentRuntime"]; - harnessInventoryRuntime: HarnessInventoryRuntime; + harnessEnvironment: GeneralWorkbenchDialogParams["environment"]; + toolInventory: GeneralWorkbenchDialogParams["toolInventory"]; + toolInventoryLoading: GeneralWorkbenchDialogParams["toolInventoryLoading"]; + toolInventoryError: GeneralWorkbenchDialogParams["toolInventoryError"]; + refreshToolInventory: GeneralWorkbenchDialogParams["onRefreshToolInventory"]; mappedTheme: GeneralWorkbenchDialogParams["activeTheme"]; + activeRuntimeStatusTitle: GeneralWorkbenchDialogParams["runtimeStatusTitle"]; handleHarnessLoadFilePreview: GeneralWorkbenchDialogParams["onLoadFilePreview"]; handleFileClick: GeneralWorkbenchDialogParams["onOpenFile"]; - shellChromeRuntime: ShellChromeRuntime; + showGeneralWorkbenchFloatingInputOverlay: FloatingTeamWorkspaceDockParams["showFloatingInputOverlay"]; handleActivateTeamWorkbench: FloatingTeamWorkspaceDockParams["onActivateWorkbench"]; chatToolPreferences?: ChatToolPreferences; } @@ -149,14 +408,28 @@ export function useWorkspaceInputbarSceneRuntime({ selectedTeamSummary, teamDispatchPreviewState, teamMemorySnapshot, - teamSessionRuntime, - teamSessionControlRuntime, + currentSessionTitle, + currentSessionRuntimeStatus, + currentSessionLatestTurnStatus, + liveRuntimeBySessionId, + liveActivityBySessionId, + activityRefreshVersionBySessionId, + handleSendSubagentInput, + handleWaitSubagentSession, + handleWaitActiveTeamSessions, + handleCloseCompletedTeamSessions, + handleCloseSubagentSession, + handleResumeSubagentSession, + teamWaitSummary, + teamControlSummary, + handleStopSending, + teamWorkspaceEnabled, handleOpenSubagentSession, handleReturnToParentSession, input, setInput, currentGate, - generalWorkbenchSidebarRuntime, + generalWorkbenchWorkflowSteps, steps, workflowRunState, handleSend, @@ -208,14 +481,19 @@ export function useWorkspaceInputbarSceneRuntime({ handleRestartGeneralWorkbenchEntryPrompt, handleContinueGeneralWorkbenchEntryPrompt, generalWorkbenchEnabled, - contextHarnessRuntime, + harnessPanelVisible, + setHarnessPanelVisible, harnessState, - compatSubagentRuntime, - harnessInventoryRuntime, + harnessEnvironment, + toolInventory, + toolInventoryLoading, + toolInventoryError, + refreshToolInventory, mappedTheme, + activeRuntimeStatusTitle, handleHarnessLoadFilePreview, handleFileClick, - shellChromeRuntime, + showGeneralWorkbenchFloatingInputOverlay, handleActivateTeamWorkbench, chatToolPreferences, }: UseWorkspaceInputbarSceneRuntimeParams) { @@ -243,7 +521,7 @@ export function useWorkspaceInputbarSceneRuntime({ resolvedTurns[resolvedTurns.length - 1]?.prompt_text?.trim() || ""; - return useWorkspaceInputbarScenePresentation({ + return useWorkspaceInputbarScenePresentationRuntime({ setMentionedCharacters, taskFiles, taskFilesExpanded, @@ -254,33 +532,25 @@ export function useWorkspaceInputbarSceneRuntime({ teamWorkbench: { shellVisible: resolvedChatToolPreferences.subagent, currentSessionId: sessionId, - currentSessionName: teamSessionRuntime.currentSessionTitle, - currentSessionRuntimeStatus: - teamSessionRuntime.currentSessionRuntimeStatus, - currentSessionLatestTurnStatus: - teamSessionRuntime.currentSessionLatestTurnStatus, + currentSessionName: currentSessionTitle, + currentSessionRuntimeStatus, + currentSessionLatestTurnStatus, currentSessionQueuedTurnCount: resolvedQueuedTurns.length, childSubagentSessions, subagentParentContext, - liveRuntimeBySessionId: teamSessionRuntime.liveRuntimeBySessionId, - liveActivityBySessionId: teamSessionRuntime.liveActivityBySessionId, - activityRefreshVersionBySessionId: - teamSessionRuntime.activityRefreshVersionBySessionId, - onSendSubagentInput: teamSessionControlRuntime.handleSendSubagentInput, - onWaitSubagentSession: - teamSessionControlRuntime.handleWaitSubagentSession, - onWaitActiveTeamSessions: - teamSessionControlRuntime.handleWaitActiveTeamSessions, - onCloseCompletedTeamSessions: - teamSessionControlRuntime.handleCloseCompletedTeamSessions, - onCloseSubagentSession: - teamSessionControlRuntime.handleCloseSubagentSession, - onResumeSubagentSession: - teamSessionControlRuntime.handleResumeSubagentSession, + liveRuntimeBySessionId, + liveActivityBySessionId, + activityRefreshVersionBySessionId, + onSendSubagentInput: handleSendSubagentInput, + onWaitSubagentSession: handleWaitSubagentSession, + onWaitActiveTeamSessions: handleWaitActiveTeamSessions, + onCloseCompletedTeamSessions: handleCloseCompletedTeamSessions, + onCloseSubagentSession: handleCloseSubagentSession, + onResumeSubagentSession: handleResumeSubagentSession, onOpenSubagentSession: handleOpenSubagentSession, onReturnToParentSession: handleReturnToParentSession, - teamWaitSummary: teamSessionControlRuntime.teamWaitSummary, - teamControlSummary: teamSessionControlRuntime.teamControlSummary, + teamWaitSummary, + teamControlSummary, selectedTeamLabel, selectedTeamSummary, selectedTeamRoles: selectedTeam?.roles, @@ -293,11 +563,11 @@ export function useWorkspaceInputbarSceneRuntime({ variant: isThemeWorkbench ? "workspace" : "default", workflowGate: isThemeWorkbench ? currentGate : null, workflowSteps: isThemeWorkbench - ? generalWorkbenchSidebarRuntime.generalWorkbenchWorkflowSteps + ? generalWorkbenchWorkflowSteps : steps, workflowRunState, onSend: handleSend, - onStop: teamSessionControlRuntime.handleStopSending, + onStop: handleStopSending, isLoading: isSending || resolvedQueuedTurns.length > 0, providerType, setProviderType, @@ -338,10 +608,9 @@ export function useWorkspaceInputbarSceneRuntime({ onRemoveQueuedTurn: removeQueuedTurn, }, floatingTeamWorkspaceDock: { - enabled: teamSessionRuntime.showTeamWorkspaceBoard, + enabled: teamWorkspaceEnabled, layoutMode: dockLayoutMode, - showFloatingInputOverlay: - shellChromeRuntime.shouldShowGeneralWorkbenchFloatingInputOverlay, + showFloatingInputOverlay: showGeneralWorkbenchFloatingInputOverlay, onActivateWorkbench: handleActivateTeamWorkbench, }, generalWorkbenchEntryPrompt, @@ -351,11 +620,10 @@ export function useWorkspaceInputbarSceneRuntime({ handleContinueGeneralWorkbenchEntryPrompt, generalWorkbenchDialog: { enabled: generalWorkbenchEnabled && !isThemeWorkbench, - open: contextHarnessRuntime.harnessPanelVisible, - onOpenChange: contextHarnessRuntime.setHarnessPanelVisible, + open: harnessPanelVisible, + onOpenChange: setHarnessPanelVisible, harnessState, - compatSubagentRuntime, - environment: contextHarnessRuntime.harnessEnvironment, + environment: harnessEnvironment, childSubagentSessions, selectedTeamLabel, selectedTeamSummary, @@ -370,7 +638,7 @@ export function useWorkspaceInputbarSceneRuntime({ messages, queuedTurns: resolvedQueuedTurns, canInterrupt: isSending, - onInterruptCurrentTurn: teamSessionControlRuntime.handleStopSending, + onInterruptCurrentTurn: handleStopSending, onResumeThread: resumeThread, onReplayPendingRequest: latestAssistantMessageId && replayPendingAction @@ -398,16 +666,16 @@ export function useWorkspaceInputbarSceneRuntime({ activeTheme: activeTheme || null, selectedTeamLabel: selectedTeamLabel || null, }, - toolInventory: harnessInventoryRuntime.toolInventory, - toolInventoryLoading: harnessInventoryRuntime.toolInventoryLoading, - toolInventoryError: harnessInventoryRuntime.toolInventoryError, - onRefreshToolInventory: harnessInventoryRuntime.refreshToolInventory, + toolInventory, + toolInventoryLoading, + toolInventoryError, + onRefreshToolInventory: refreshToolInventory, activeTheme: mappedTheme, toolPreferences: resolvedChatToolPreferences, isSending, executionRuntime: sessionExecutionRuntime, isExecutionRuntimeActive: Boolean(activeExecutionRuntime), - runtimeStatusTitle: contextHarnessRuntime.activeRuntimeStatusTitle, + runtimeStatusTitle: activeRuntimeStatusTitle, selectedTeamRoleCount: selectedTeam?.roles.length || 0, onOpenSubagentSession: handleOpenSubagentSession, onLoadFilePreview: handleHarnessLoadFilePreview, diff --git a/src/components/agent/chat/workspace/useWorkspaceRuntimeTeamDispatchPreviewRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceRuntimeTeamDispatchPreviewRuntime.ts deleted file mode 100644 index 28a27af71..000000000 --- a/src/components/agent/chat/workspace/useWorkspaceRuntimeTeamDispatchPreviewRuntime.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { - resolveRuntimeTeamDispatchPreviewState, - type RuntimeTeamDispatchPreviewSnapshot, -} from "./runtimeTeamPreview"; - -interface UseWorkspaceRuntimeTeamDispatchPreviewRuntimeParams { - messagesLength: number; - sessionId?: string | null; -} - -export function useWorkspaceRuntimeTeamDispatchPreviewRuntime({ - messagesLength, - sessionId, -}: UseWorkspaceRuntimeTeamDispatchPreviewRuntimeParams) { - const [runtimeTeamDispatchPreview, setRuntimeTeamDispatchPreview] = - useState(null); - const clearRuntimeTeamDispatchPreview = useCallback(() => { - setRuntimeTeamDispatchPreview(null); - }, []); - const runtimeTeamPreviewState = useMemo( - () => resolveRuntimeTeamDispatchPreviewState(runtimeTeamDispatchPreview), - [runtimeTeamDispatchPreview], - ); - - useEffect(() => { - clearRuntimeTeamDispatchPreview(); - }, [clearRuntimeTeamDispatchPreview, sessionId]); - - useEffect(() => { - if (!runtimeTeamDispatchPreview) { - return; - } - - if (messagesLength > runtimeTeamDispatchPreview.baseMessageCount) { - clearRuntimeTeamDispatchPreview(); - } - }, [ - clearRuntimeTeamDispatchPreview, - messagesLength, - runtimeTeamDispatchPreview, - ]); - - return { - runtimeTeamDispatchPreview, - runtimeTeamPreviewState, - clearRuntimeTeamDispatchPreview, - setRuntimeTeamDispatchPreview, - }; -} diff --git a/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx b/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx index 3e2959123..d942698fd 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { TeamMemorySnapshot } from "@/lib/teamMemorySync"; import type { ServiceSkillHomeItem } from "../service-skills/types"; +import type { Message } from "../types"; import { listMentionEntryUsage, recordMentionEntryUsage, @@ -67,7 +68,6 @@ const mockRollbackAfterSendFailure = vi.fn(); const mockSetInput = vi.fn(); const mockSetMentionedCharacters = vi.fn(); const mockSetChatToolPreferences = vi.fn(); -const mockSetRuntimeTeamDispatchPreview = vi.fn(); const mockEnsureBrowserAssistCanvas = vi.fn(async () => true); const mockHandleAutoLaunchMatchedSiteSkill = vi.fn(async () => undefined); const mockOpenRuntimeSceneGate = vi.fn(async () => undefined); @@ -307,6 +307,15 @@ function createTeamMemoryShadowSnapshot(): TeamMemorySnapshot { }; } +function createExistingMessages(count: number): Message[] { + return Array.from({ length: count }, (_, index) => ({ + id: `message-${index + 1}`, + role: index % 2 === 0 ? "user" : "assistant", + content: `历史消息 ${index + 1}`, + timestamp: new Date(1_710_000_000_000 + index), + })); +} + function mountHook(initialProps?: Partial): HookHarness { const container = document.createElement("div"); document.body.appendChild(container); @@ -335,6 +344,7 @@ function mountHook(initialProps?: Partial): HookHarness { prepareActiveContextPrompt: async () => "", }, projectId: "project-1", + sessionId: "session-1", executionStrategy: "react", accessMode: "current", preferredTeamPresetId: null, @@ -345,7 +355,8 @@ function mountHook(initialProps?: Partial): HookHarness { themeWorkbenchActiveQueueTitle: undefined, contentId: null, workspaceRequestMetadataBase: undefined, - messagesCount: 0, + messages: [], + bootstrapDispatchPreviewMessages: [], sendMessage: mockSendMessage, resolveSendBoundary: (({ sourceText }) => ({ sourceText, @@ -359,8 +370,6 @@ function mountHook(initialProps?: Partial): HookHarness { mockRollbackAfterSendFailure as HookProps["rollbackAfterSendFailure"], prepareRuntimeTeamBeforeSend: mockPrepareRuntimeTeamBeforeSend as HookProps["prepareRuntimeTeamBeforeSend"], - setRuntimeTeamDispatchPreview: - mockSetRuntimeTeamDispatchPreview as HookProps["setRuntimeTeamDispatchPreview"], ensureBrowserAssistCanvas: mockEnsureBrowserAssistCanvas as HookProps["ensureBrowserAssistCanvas"], handleAutoLaunchMatchedSiteSkill: @@ -463,6 +472,21 @@ describe("useWorkspaceSendActions", () => { } }); + it("无真实消息时应透传 bootstrap 预览消息", () => { + const bootstrapPreviewMessages = createExistingMessages(2); + const harness = mountHook({ + bootstrapDispatchPreviewMessages: bootstrapPreviewMessages, + }); + + try { + expect(harness.getValue().displayMessages).toEqual( + bootstrapPreviewMessages, + ); + } finally { + harness.unmount(); + } + }); + it("发送前如果准备出本地 team,应写入短生命周期 dispatch preview", async () => { mockPrepareRuntimeTeamBeforeSend.mockResolvedValueOnce( createPreparedRuntimeTeamState(), @@ -475,7 +499,7 @@ describe("useWorkspaceSendActions", () => { task: false, subagent: true, }, - messagesCount: 3, + messages: createExistingMessages(3), }); try { @@ -484,23 +508,22 @@ describe("useWorkspaceSendActions", () => { expect(started).toBe(true); }); - expect(mockSetRuntimeTeamDispatchPreview).toHaveBeenNthCalledWith( - 1, - null, - ); - expect(mockSetRuntimeTeamDispatchPreview).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - key: "runtime-team-preview-1", - prompt: "请拆解这个复杂需求,并安排多人协作推进", - baseMessageCount: 3, - status: "formed", - formationState: expect.objectContaining({ - requestId: "runtime-team-preview-1", - label: "研究协作组", - }), + expect(harness.getValue().teamDispatchPreviewState).toMatchObject({ + requestId: "runtime-team-preview-1", + status: "formed", + label: "研究协作组", + }); + expect(harness.getValue().displayMessages).toHaveLength(5); + expect(harness.getValue().displayMessages[3]).toMatchObject({ + role: "user", + content: "请拆解这个复杂需求,并安排多人协作推进", + }); + expect(harness.getValue().displayMessages[4]).toMatchObject({ + role: "assistant", + runtimeStatus: expect.objectContaining({ + title: "任务分工已准备好", }), - ); + }); } finally { harness.unmount(); } @@ -521,8 +544,13 @@ describe("useWorkspaceSendActions", () => { await Promise.resolve(); }); - expect(harness.getValue().submissionPreview).toMatchObject({ - prompt: "帮我找一下今天的新闻", + expect(harness.getValue().displayMessages).toHaveLength(2); + expect(harness.getValue().displayMessages[0]).toMatchObject({ + role: "user", + content: "帮我找一下今天的新闻", + }); + expect(harness.getValue().displayMessages[1]).toMatchObject({ + role: "assistant", runtimeStatus: expect.objectContaining({ title: "正在启动处理流程", }), @@ -533,7 +561,7 @@ describe("useWorkspaceSendActions", () => { await sendPromise; }); - expect(harness.getValue().submissionPreview).toBeNull(); + expect(harness.getValue().displayMessages).toEqual([]); } finally { harness.unmount(); } @@ -560,8 +588,13 @@ describe("useWorkspaceSendActions", () => { await Promise.resolve(); }); - expect(harness.getValue().submissionPreview).toMatchObject({ - prompt: "帮我整理一下今天的重要新闻", + expect(harness.getValue().displayMessages).toHaveLength(2); + expect(harness.getValue().displayMessages[0]).toMatchObject({ + role: "user", + content: "帮我整理一下今天的重要新闻", + }); + expect(harness.getValue().displayMessages[1]).toMatchObject({ + role: "assistant", runtimeStatus: expect.objectContaining({ title: "正在启动处理流程", }), @@ -577,7 +610,7 @@ describe("useWorkspaceSendActions", () => { await sendPromise; }); - expect(harness.getValue().submissionPreview).toBeNull(); + expect(harness.getValue().displayMessages).toEqual([]); } finally { harness.unmount(); } @@ -593,7 +626,6 @@ describe("useWorkspaceSendActions", () => { mockSendMessage.mockImplementationOnce(async () => deferredSend.promise); const harness = mountHook({ input: "帮我整理一下今天的重要新闻", - messagesCount: 0, contextWorkspace: { enabled: true, activeContextPrompt: "", @@ -917,8 +949,13 @@ describe("useWorkspaceSendActions", () => { await Promise.resolve(); }); - expect(harness.getValue().submissionPreview).toMatchObject({ - prompt: "@配图 生成 一张春日咖啡馆插画", + expect(harness.getValue().displayMessages).toHaveLength(2); + expect(harness.getValue().displayMessages[0]).toMatchObject({ + role: "user", + content: "@配图 生成 一张春日咖啡馆插画", + }); + expect(harness.getValue().displayMessages[1]).toMatchObject({ + role: "assistant", runtimeStatus: expect.objectContaining({ title: "正在启动处理流程", }), @@ -949,7 +986,7 @@ describe("useWorkspaceSendActions", () => { await sendPromise; }); - expect(harness.getValue().submissionPreview).toBeNull(); + expect(harness.getValue().displayMessages).toEqual([]); } finally { harness.unmount(); } diff --git a/src/components/agent/chat/workspace/useWorkspaceSendActions.ts b/src/components/agent/chat/workspace/useWorkspaceSendActions.ts index b97bf4df1..6098eec31 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSendActions.ts +++ b/src/components/agent/chat/workspace/useWorkspaceSendActions.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import type { Dispatch, SetStateAction } from "react"; import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime"; @@ -65,12 +65,17 @@ import type { HandleSendOptions } from "../hooks/handleSendTypes"; import type { GeneralWorkbenchSendBoundaryState } from "../hooks/useGeneralWorkbenchSendBoundary"; import type { UseRuntimeTeamFormationResult } from "../hooks/useRuntimeTeamFormation"; import type { SendMessageFn } from "../hooks/agentChatShared"; -import type { MessageImage } from "../types"; +import type { Message, MessageImage } from "../types"; import type { TeamDefinition } from "../utils/teamDefinitions"; -import type { RuntimeTeamDispatchPreviewSnapshot } from "./runtimeTeamPreview"; import type { AgentAccessMode } from "../hooks/agentChatStorage"; import { buildRuntimeTeamDispatchPreview, + buildRuntimeTeamDispatchPreviewMessages, + buildSubmissionPreviewMessages, + resolveRuntimeTeamDispatchPreviewState, + type RuntimeTeamDispatchPreviewSnapshot, + createSubmissionPreviewSnapshot, + type SubmissionPreviewSnapshot, buildWorkspaceRequestMetadata, buildWorkspaceSendText, hasModelSkillLaunchRequestMetadata, @@ -79,10 +84,6 @@ import { type ContextWorkspaceSummary, type EnsureBrowserAssistCanvasOptions, } from "./workspaceSendHelpers"; -import { - createSubmissionPreviewSnapshot, - type SubmissionPreviewSnapshot, -} from "./submissionPreview"; import type { Character } from "@/lib/api/memory"; import type { TeamMemorySnapshot } from "@/lib/teamMemorySync"; import type { ThemeType } from "@/lib/workspace/workbenchContract"; @@ -2302,6 +2303,7 @@ interface UseWorkspaceSendActionsParams { isThemeWorkbench: boolean; contextWorkspace: ContextWorkspaceSummary; projectId?: string | null; + sessionId?: string | null; executionStrategy: ExecutionStrategy; accessMode?: AgentAccessMode; preferredTeamPresetId?: string | null; @@ -2320,7 +2322,8 @@ interface UseWorkspaceSendActionsParams { | null; browserAssistAutoLaunch?: boolean | null; workspaceRequestMetadataBase?: Record; - messagesCount: number; + messages: Message[]; + bootstrapDispatchPreviewMessages: Message[]; sendMessage: SendMessageFn; resolveSendBoundary: (input: { sourceText: string; @@ -2333,9 +2336,6 @@ interface UseWorkspaceSendActionsParams { boundary: GeneralWorkbenchSendBoundaryState, ) => void; prepareRuntimeTeamBeforeSend: UseRuntimeTeamFormationResult["prepareRuntimeTeamBeforeSend"]; - setRuntimeTeamDispatchPreview: Dispatch< - SetStateAction - >; ensureBrowserAssistCanvas: ( target: string, options?: EnsureBrowserAssistCanvasOptions, @@ -2414,6 +2414,7 @@ export function useWorkspaceSendActions({ isThemeWorkbench, contextWorkspace, projectId, + sessionId, executionStrategy, accessMode, preferredTeamPresetId, @@ -2428,23 +2429,87 @@ export function useWorkspaceSendActions({ browserAssistPreferredBackend, browserAssistAutoLaunch, workspaceRequestMetadataBase, - messagesCount, + messages, + bootstrapDispatchPreviewMessages, sendMessage, resolveSendBoundary, finalizeAfterSendSuccess, rollbackAfterSendFailure, prepareRuntimeTeamBeforeSend: _prepareRuntimeTeamBeforeSend, - setRuntimeTeamDispatchPreview, ensureBrowserAssistCanvas, handleAutoLaunchMatchedSiteSkill, openRuntimeSceneGate, ensureSessionForCommandMetadata, resolveImageWorkbenchSkillRequest, }: UseWorkspaceSendActionsParams) { + const messagesCount = messages.length; + const [runtimeTeamDispatchPreview, setRuntimeTeamDispatchPreview] = useState< + RuntimeTeamDispatchPreviewSnapshot | null + >(null); const [submissionPreview, setSubmissionPreview] = useState(null); const [isPreparingSend, setIsPreparingSend] = useState(false); const isPreparingSendRef = useRef(false); + const clearRuntimeTeamDispatchPreview = useCallback(() => { + setRuntimeTeamDispatchPreview(null); + }, []); + const teamDispatchPreviewState = useMemo( + () => resolveRuntimeTeamDispatchPreviewState(runtimeTeamDispatchPreview), + [runtimeTeamDispatchPreview], + ); + const runtimeTeamDispatchPreviewMessages = useMemo( + () => + runtimeTeamDispatchPreview + ? buildRuntimeTeamDispatchPreviewMessages(runtimeTeamDispatchPreview) + : [], + [runtimeTeamDispatchPreview], + ); + const submissionPreviewMessages = useMemo( + () => + messagesCount === 0 && submissionPreview + ? buildSubmissionPreviewMessages(submissionPreview) + : [], + [messagesCount, submissionPreview], + ); + const displayMessages = useMemo(() => { + if (runtimeTeamDispatchPreviewMessages.length > 0) { + return [...messages, ...runtimeTeamDispatchPreviewMessages]; + } + + if (submissionPreviewMessages.length > 0) { + return submissionPreviewMessages; + } + + if (messagesCount === 0 && bootstrapDispatchPreviewMessages.length > 0) { + return bootstrapDispatchPreviewMessages; + } + + return messages; + }, [ + bootstrapDispatchPreviewMessages, + messages, + messagesCount, + runtimeTeamDispatchPreviewMessages, + submissionPreviewMessages, + ]); + + useEffect(() => { + clearRuntimeTeamDispatchPreview(); + }, [clearRuntimeTeamDispatchPreview, sessionId]); + + useEffect(() => { + if (!runtimeTeamDispatchPreview) { + return; + } + + if (messagesCount > runtimeTeamDispatchPreview.baseMessageCount) { + clearRuntimeTeamDispatchPreview(); + } + }, [ + clearRuntimeTeamDispatchPreview, + messagesCount, + runtimeTeamDispatchPreview, + ]); const resolveSendExecutionPlan = useCallback( async ( @@ -4507,6 +4572,7 @@ export function useWorkspaceSendActions({ handleSendRef, webSearchPreferenceRef, isPreparingSend, - submissionPreview, + displayMessages, + teamDispatchPreviewState, }; } diff --git a/src/components/agent/chat/workspace/useWorkspaceShellChromeRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceShellChromeRuntime.ts deleted file mode 100644 index 2b03f1009..000000000 --- a/src/components/agent/chat/workspace/useWorkspaceShellChromeRuntime.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { useMemo } from "react"; -import { resolveWorkflowLayoutBottomSpacing } from "../utils/workflowLayout"; -import { - TEAM_PRIMARY_CHAT_PANEL_MIN_WIDTH, - TEAM_PRIMARY_CHAT_PANEL_WIDTH, -} from "./WorkspaceStyles"; - -interface UseWorkspaceShellChromeRuntimeParams { - agentEntry?: "new-task" | "claw"; - contextWorkspaceEnabled: boolean; - hasDisplayMessages: boolean; - hasPendingA2UIForm: boolean; - hideTopBar: boolean; - isBootstrapDispatchPending: boolean; - isSpecializedThemeMode: boolean; - isSending: boolean; - isThemeWorkbench: boolean; - layoutMode: string; - queuedTurnCount: number; - shouldUseCompactGeneralWorkbench: boolean; - showTeamWorkspaceBoard: boolean; - topBarChrome: "full" | "workspace-compact"; - themeWorkbenchRunState: "idle" | "auto_running" | "await_user_decision"; - currentGateStatus: "running" | "waiting" | "idle"; - hasRealTeamGraph: boolean; - teamDispatchPreviewState: unknown; -} - -export function useWorkspaceShellChromeRuntime({ - agentEntry, - contextWorkspaceEnabled, - hasDisplayMessages, - hasPendingA2UIForm, - hideTopBar, - isBootstrapDispatchPending, - isSpecializedThemeMode, - isSending, - isThemeWorkbench, - layoutMode, - queuedTurnCount, - shouldUseCompactGeneralWorkbench, - showTeamWorkspaceBoard, - topBarChrome, - themeWorkbenchRunState, - currentGateStatus, - hasRealTeamGraph, - teamDispatchPreviewState, -}: UseWorkspaceShellChromeRuntimeParams) { - const hasUnconsumedInitialDispatch = - !shouldUseCompactGeneralWorkbench && isBootstrapDispatchPending; - - const showChatLayout = - agentEntry === "claw" || - hasDisplayMessages || - hasPendingA2UIForm || - isThemeWorkbench || - hasUnconsumedInitialDispatch || - isSending || - queuedTurnCount > 0; - - const shouldHideGeneralWorkbenchInputForTheme = - shouldUseCompactGeneralWorkbench; - const shouldShowGeneralWorkbenchFloatingInputOverlay = - isThemeWorkbench && - showChatLayout && - !shouldHideGeneralWorkbenchInputForTheme; - - const isWorkspaceCompactChrome = topBarChrome === "workspace-compact"; - const shouldRenderBrandedEmptyState = !showChatLayout; - const shouldRenderTopBar = !hideTopBar && !shouldRenderBrandedEmptyState; - const shouldRenderInlineA2UI = isSpecializedThemeMode; - - const shouldUseTeamPrimaryChatPanelWidth = - layoutMode === "chat-canvas" && - showTeamWorkspaceBoard && - (hasRealTeamGraph || Boolean(teamDispatchPreviewState)); - const layoutTransitionChatPanelWidth = shouldUseTeamPrimaryChatPanelWidth - ? TEAM_PRIMARY_CHAT_PANEL_WIDTH - : undefined; - const layoutTransitionChatPanelMinWidth = shouldUseTeamPrimaryChatPanelWidth - ? TEAM_PRIMARY_CHAT_PANEL_MIN_WIDTH - : undefined; - - const workflowLayoutBottomSpacing = useMemo( - () => - resolveWorkflowLayoutBottomSpacing({ - contextWorkspaceEnabled, - showFloatingInputOverlay: - shouldShowGeneralWorkbenchFloatingInputOverlay, - hasCanvasContent: layoutMode !== "chat", - workflowRunState: themeWorkbenchRunState, - gateStatus: currentGateStatus, - }), - [ - contextWorkspaceEnabled, - currentGateStatus, - layoutMode, - shouldShowGeneralWorkbenchFloatingInputOverlay, - themeWorkbenchRunState, - ], - ); - - return { - hasUnconsumedInitialDispatch, - isWorkspaceCompactChrome, - layoutTransitionChatPanelMinWidth, - layoutTransitionChatPanelWidth, - shouldHideGeneralWorkbenchInputForTheme, - shouldRenderInlineA2UI, - shouldRenderTopBar, - shouldShowGeneralWorkbenchFloatingInputOverlay, - showChatLayout, - workflowLayoutBottomSpacing, - }; -} diff --git a/src/components/agent/chat/workspace/useWorkspaceShellSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceShellSceneRuntime.tsx deleted file mode 100644 index f91dba7ac..000000000 --- a/src/components/agent/chat/workspace/useWorkspaceShellSceneRuntime.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import type { ComponentProps } from "react"; -import { WorkspaceShellScene } from "./WorkspaceShellScene"; -import { useWorkspaceConversationSceneRuntime } from "./useWorkspaceConversationSceneRuntime"; -import { useWorkspaceGeneralWorkbenchShellRuntime } from "./useWorkspaceGeneralWorkbenchShellRuntime"; - -type GeneralWorkbenchShellRuntime = ReturnType< - typeof useWorkspaceGeneralWorkbenchShellRuntime ->; -type ConversationSceneRuntime = ReturnType< - typeof useWorkspaceConversationSceneRuntime ->; -type WorkspaceShellSceneProps = ComponentProps; - -interface UseWorkspaceShellSceneRuntimeParams { - compactChrome: boolean; - isThemeWorkbench: boolean; - showChatPanel: boolean; - showSidebar: boolean; - sidebarContextVariant?: WorkspaceShellSceneProps["sidebarContextVariant"]; - generalWorkbenchShellRuntime: GeneralWorkbenchShellRuntime; - conversationSceneRuntime: ConversationSceneRuntime; - sessionId?: WorkspaceShellSceneProps["currentTopicId"]; - topics: WorkspaceShellSceneProps["topics"]; - handleBackHome: WorkspaceShellSceneProps["onNewChat"]; - switchTopic: WorkspaceShellSceneProps["onSwitchTopic"]; - handleResumeSidebarTask: WorkspaceShellSceneProps["onResumeTask"]; - deleteTopic: WorkspaceShellSceneProps["onDeleteTopic"]; - renameTopic: WorkspaceShellSceneProps["onRenameTopic"]; - displayMessages: WorkspaceShellSceneProps["currentMessages"]; - isSending: WorkspaceShellSceneProps["isSending"]; - pendingActionCount: number; - queuedTurnCount: number; - childSubagentSessions: WorkspaceShellSceneProps["childSubagentSessions"]; - subagentParentContext: WorkspaceShellSceneProps["subagentParentContext"]; - handleOpenSubagentSession: WorkspaceShellSceneProps["onOpenSubagentSession"]; - handleReturnToParentSession: WorkspaceShellSceneProps["onReturnToParentSession"]; -} - -export function useWorkspaceShellSceneRuntime({ - compactChrome, - isThemeWorkbench, - showChatPanel, - showSidebar, - sidebarContextVariant = "default", - generalWorkbenchShellRuntime, - conversationSceneRuntime, - sessionId, - topics, - handleBackHome, - switchTopic, - handleResumeSidebarTask, - deleteTopic, - renameTopic, - displayMessages, - isSending, - pendingActionCount, - queuedTurnCount, - childSubagentSessions, - subagentParentContext, - handleOpenSubagentSession, - handleReturnToParentSession, -}: UseWorkspaceShellSceneRuntimeParams) { - return { - shellSceneNode: ( - - ), - }; -} diff --git a/src/components/agent/chat/workspace/useWorkspaceTeamSessionControlRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceTeamSessionControlRuntime.test.tsx new file mode 100644 index 000000000..34989e258 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceTeamSessionControlRuntime.test.tsx @@ -0,0 +1,211 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useWorkspaceTeamSessionControlRuntime } from "./useWorkspaceTeamSessionControlRuntime"; + +const { toast } = vi.hoisted(() => ({ + toast: { + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + }, +})); + +const { + closeAgentRuntimeSubagent, + resumeAgentRuntimeSubagent, + sendAgentRuntimeSubagentInput, + waitAgentRuntimeSubagents, +} = vi.hoisted(() => ({ + closeAgentRuntimeSubagent: vi.fn(), + resumeAgentRuntimeSubagent: vi.fn(), + sendAgentRuntimeSubagentInput: vi.fn(), + waitAgentRuntimeSubagents: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast, +})); + +vi.mock("@/lib/api/agentRuntime", () => ({ + closeAgentRuntimeSubagent, + resumeAgentRuntimeSubagent, + sendAgentRuntimeSubagentInput, + waitAgentRuntimeSubagents, +})); + +type HookProps = Parameters[0]; + +const mountedRoots: Array<{ container: HTMLDivElement; root: Root }> = []; + +function renderHook(props?: Partial) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + let latestValue: ReturnType< + typeof useWorkspaceTeamSessionControlRuntime + > | null = null; + + const defaultProps: HookProps = { + childSubagentSessions: [], + liveRuntimeBySessionId: {}, + stopSending: vi.fn().mockResolvedValue(undefined), + }; + + function Probe(currentProps: HookProps) { + latestValue = useWorkspaceTeamSessionControlRuntime(currentProps); + return null; + } + + const render = async (nextProps?: Partial) => { + await act(async () => { + root.render(); + await Promise.resolve(); + await Promise.resolve(); + }); + }; + + mountedRoots.push({ container, root }); + + return { + render, + getValue: () => { + if (!latestValue) { + throw new Error("hook 尚未初始化"); + } + return latestValue; + }, + }; +} + +describe("useWorkspaceTeamSessionControlRuntime", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + + toast.error.mockReset(); + toast.info.mockReset(); + toast.success.mockReset(); + closeAgentRuntimeSubagent.mockReset(); + resumeAgentRuntimeSubagent.mockReset(); + sendAgentRuntimeSubagentInput.mockReset(); + waitAgentRuntimeSubagents.mockReset(); + }); + + afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + break; + } + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + }); + + it("等待任一活跃任务超时时应提示任务状态", async () => { + waitAgentRuntimeSubagents.mockResolvedValue({ + timed_out: true, + status: {}, + }); + const { render, getValue } = renderHook(); + + await render(); + + await act(async () => { + await getValue().handleWaitActiveTeamSessions(["task-1", "task-2"]); + }); + + expect(getValue().teamWaitSummary).toMatchObject({ + awaitedSessionIds: ["task-1", "task-2"], + timedOut: true, + }); + expect(toast.info).toHaveBeenCalledWith( + "等待超时,团队内活跃任务仍未进入最终状态", + ); + }); + + it("收起已完成任务后应提示任务语义的成功消息", async () => { + closeAgentRuntimeSubagent + .mockResolvedValueOnce({ + changed_session_ids: ["task-1"], + cascade_session_ids: [], + }) + .mockResolvedValueOnce({ + changed_session_ids: ["task-2"], + cascade_session_ids: [], + }); + const { render, getValue } = renderHook(); + + await render(); + + await act(async () => { + await getValue().handleCloseCompletedTeamSessions(["task-1", "task-2"]); + }); + + expect(getValue().teamControlSummary).toMatchObject({ + action: "close_completed", + requestedSessionIds: ["task-1", "task-2"], + affectedSessionIds: ["task-1", "task-2"], + }); + expect(toast.success).toHaveBeenCalledWith("已级联收起 2 项任务"); + }); + + it("停止发送后批量暂停任务时应提示任务语义", async () => { + const stopSending = vi.fn().mockResolvedValue(undefined); + closeAgentRuntimeSubagent + .mockResolvedValueOnce({ + changed_session_ids: ["task-1"], + cascade_session_ids: [], + }) + .mockResolvedValueOnce({ + changed_session_ids: ["task-2"], + cascade_session_ids: [], + }); + const { render, getValue } = renderHook({ + stopSending, + childSubagentSessions: [ + { + id: "task-1", + name: "分析", + created_at: 1_710_000_000, + updated_at: 1_710_000_100, + session_type: "sub_agent", + runtime_status: "running", + latest_turn_status: "running", + task_summary: "收敛问题边界", + }, + { + id: "task-2", + name: "执行", + created_at: 1_710_000_010, + updated_at: 1_710_000_120, + session_type: "sub_agent", + runtime_status: "queued", + latest_turn_status: "queued", + task_summary: "等待接手", + }, + ], + }); + + await render(); + + await act(async () => { + await getValue().handleStopSending(); + }); + + expect(stopSending).toHaveBeenCalledTimes(1); + expect(getValue().teamControlSummary).toMatchObject({ + action: "close", + requestedSessionIds: ["task-1", "task-2"], + affectedSessionIds: ["task-1", "task-2"], + }); + expect(toast.success).toHaveBeenCalledWith("已暂停 2 项任务的处理"); + }); +}); diff --git a/src/components/agent/chat/workspace/useWorkspaceTeamSessionControlRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceTeamSessionControlRuntime.ts index 97df3bfb1..fd1107331 100644 --- a/src/components/agent/chat/workspace/useWorkspaceTeamSessionControlRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceTeamSessionControlRuntime.ts @@ -74,19 +74,16 @@ export function useWorkspaceTeamSessionControlRuntime({ } if (summary.affectedSessionIds.length > 1) { - toast.success( - `已级联关闭 ${summary.affectedSessionIds.length} 位协作成员`, - ); + toast.success(`已级联停止 ${summary.affectedSessionIds.length} 项任务`); } else if (summary.affectedSessionIds.length === 1) { - toast.success("协作成员已关闭"); + toast.success("这项任务已停止"); } else { toast.info( - `当前成员状态为${resolveTeamWorkspaceRuntimeStatusLabel(response.previous_status.kind)},未发生新的关闭变更`, + `当前任务状态为${resolveTeamWorkspaceRuntimeStatusLabel(response.previous_status.kind)},未发生新的停止变更`, ); } } catch (error) { - const message = - error instanceof Error ? error.message : "关闭协作成员失败"; + const message = error instanceof Error ? error.message : "停止任务失败"; toast.error(message); throw error; } @@ -111,19 +108,16 @@ export function useWorkspaceTeamSessionControlRuntime({ } if (summary.affectedSessionIds.length > 1) { - toast.success( - `已级联恢复 ${summary.affectedSessionIds.length} 位协作成员`, - ); + toast.success(`已级联恢复 ${summary.affectedSessionIds.length} 项任务`); } else if (summary.affectedSessionIds.length === 1) { - toast.success("协作成员已恢复"); + toast.success("这项任务已恢复"); } else { toast.info( - `当前成员状态为${resolveTeamWorkspaceRuntimeStatusLabel(response.status.kind)},未发生新的恢复变更`, + `当前任务状态为${resolveTeamWorkspaceRuntimeStatusLabel(response.status.kind)},未发生新的恢复变更`, ); } } catch (error) { - const message = - error instanceof Error ? error.message : "恢复协作成员失败"; + const message = error instanceof Error ? error.message : "恢复任务失败"; toast.error(message); throw error; } @@ -139,16 +133,16 @@ export function useWorkspaceTeamSessionControlRuntime({ timeout_ms: timeoutMs, }); if (response.timed_out) { - toast.info("等待超时,该成员仍未进入最终状态"); + toast.info("等待超时,这项任务仍未进入最终状态"); return; } const status = response.status[subagentSessionId]; toast.success( - `该成员已进入${resolveTeamWorkspaceRuntimeStatusLabel(status?.kind)}状态`, + `这项任务已进入${resolveTeamWorkspaceRuntimeStatusLabel(status?.kind)}状态`, ); } catch (error) { - const message = error instanceof Error ? error.message : "等待成员失败"; + const message = error instanceof Error ? error.message : "等待任务失败"; toast.error(message); throw error; } @@ -162,7 +156,7 @@ export function useWorkspaceTeamSessionControlRuntime({ normalizeUniqueSessionIds(subagentSessionIds); if (normalizedSessionIds.length === 0) { - const error = new Error("没有可等待的活跃成员"); + const error = new Error("没有可等待的活跃任务"); toast.error(error.message); throw error; } @@ -178,7 +172,7 @@ export function useWorkspaceTeamSessionControlRuntime({ timedOut: true, updatedAt: Date.now(), }); - toast.info("等待超时,团队内活跃成员仍未进入最终状态"); + toast.info("等待超时,团队内活跃任务仍未进入最终状态"); return; } @@ -198,11 +192,10 @@ export function useWorkspaceTeamSessionControlRuntime({ updatedAt: Date.now(), }); toast.success( - `团队成员已进入${resolveTeamWorkspaceRuntimeStatusLabel(resolvedStatus)}状态`, + `团队任务已进入${resolveTeamWorkspaceRuntimeStatusLabel(resolvedStatus)}状态`, ); } catch (error) { - const message = - error instanceof Error ? error.message : "等待团队成员失败"; + const message = error instanceof Error ? error.message : "等待团队任务失败"; toast.error(message); throw error; } @@ -216,7 +209,7 @@ export function useWorkspaceTeamSessionControlRuntime({ normalizeUniqueSessionIds(subagentSessionIds); if (normalizedSessionIds.length === 0) { - const error = new Error("没有可关闭的已完成成员"); + const error = new Error("没有可关闭的已完成任务"); toast.error(error.message); throw error; } @@ -263,8 +256,8 @@ export function useWorkspaceTeamSessionControlRuntime({ if (succeededCount > 0) { toast.success( affectedSessionIds.length > 0 - ? `已级联关闭 ${affectedSessionIds.length} 个会话` - : `已关闭 ${succeededCount} 位已完成成员`, + ? `已级联收起 ${affectedSessionIds.length} 项任务` + : `已收起 ${succeededCount} 项已完成任务`, ); } @@ -273,7 +266,7 @@ export function useWorkspaceTeamSessionControlRuntime({ const message = firstFailure instanceof Error ? firstFailure.message - : "部分已完成成员关闭失败"; + : "部分已完成任务收起失败"; toast.error(message); if (succeededCount === 0) { throw firstFailure instanceof Error @@ -293,7 +286,7 @@ export function useWorkspaceTeamSessionControlRuntime({ ) => { const normalizedMessage = message.trim(); if (!normalizedMessage) { - const error = new Error("请输入要发送给成员的内容"); + const error = new Error("请输入要发送给这项任务的内容"); toast.error(error.message); throw error; } @@ -307,11 +300,11 @@ export function useWorkspaceTeamSessionControlRuntime({ toast.success( options?.interrupt === true ? "已中断当前执行并发送新说明" - : "已向成员发送补充说明", + : "已向这项任务发送补充说明", ); } catch (error) { const messageText = - error instanceof Error ? error.message : "发送成员说明失败"; + error instanceof Error ? error.message : "发送任务说明失败"; toast.error(messageText); throw error; } @@ -381,21 +374,19 @@ export function useWorkspaceTeamSessionControlRuntime({ toast.success( affectedSessionIds.length > 1 - ? `已暂停 ${affectedSessionIds.length} 位协作成员的处理` + ? `已暂停 ${affectedSessionIds.length} 项任务的处理` : affectedSessionIds.length === 1 - ? "已暂停协作成员处理" + ? "已暂停这项任务的处理" : activeTeamSessionIds.length > 1 - ? `已向 ${activeTeamSessionIds.length} 位协作成员发送暂停请求` - : "已向协作成员发送暂停请求", + ? `已向 ${activeTeamSessionIds.length} 项任务发送暂停请求` + : "已向这项任务发送暂停请求", ); } if (failedResults.length > 0) { const firstFailure = failedResults[0]?.reason; const message = - firstFailure instanceof Error - ? firstFailure.message - : "停止协作成员失败"; + firstFailure instanceof Error ? firstFailure.message : "停止任务失败"; toast.error(message); if (successfulResponses.length === 0) { throw firstFailure instanceof Error ? firstFailure : new Error(message); diff --git a/src/components/agent/chat/workspace/useWorkspaceTeamSessionRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceTeamSessionRuntime.test.tsx new file mode 100644 index 000000000..29465fda6 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceTeamSessionRuntime.test.tsx @@ -0,0 +1,132 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useWorkspaceTeamSessionRuntime } from "./useWorkspaceTeamSessionRuntime"; + +const { mockUseTeamWorkspaceRuntime } = vi.hoisted(() => ({ + mockUseTeamWorkspaceRuntime: vi.fn(), +})); + +vi.mock("../hooks", () => ({ + useTeamWorkspaceRuntime: mockUseTeamWorkspaceRuntime, +})); + +type HookProps = Parameters[0]; + +const mountedRoots: Array<{ container: HTMLDivElement; root: Root }> = []; + +function renderHook(props?: Partial) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + let latestValue: ReturnType | null = + null; + + const defaultProps: HookProps = { + sessionId: "session-1", + topics: [ + { + id: "session-1", + title: "主任务", + }, + ], + turns: [{ status: "completed" }], + queuedTurnCount: 0, + isSending: false, + subagentEnabled: false, + childSubagentSessions: [], + subagentParentContext: null, + }; + + function Probe(currentProps: HookProps) { + latestValue = useWorkspaceTeamSessionRuntime(currentProps); + return null; + } + + const render = async (nextProps?: Partial) => { + await act(async () => { + root.render(); + await Promise.resolve(); + }); + }; + + mountedRoots.push({ container, root }); + + return { + render, + getValue: () => { + if (!latestValue) { + throw new Error("hook 尚未初始化"); + } + return latestValue; + }, + }; +} + +describe("useWorkspaceTeamSessionRuntime", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + + mockUseTeamWorkspaceRuntime.mockReset(); + mockUseTeamWorkspaceRuntime.mockReturnValue({ + liveRuntimeBySessionId: {}, + liveActivityBySessionId: {}, + activityRefreshVersionBySessionId: {}, + }); + }); + + afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + break; + } + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + }); + + it("存在运行态任务时应暴露 runtime sessions 语义并打开 team workspace", async () => { + const harness = renderHook({ + childSubagentSessions: [ + { + id: "task-1", + name: "资料整理", + created_at: 1_710_000_000, + updated_at: 1_710_000_010, + session_type: "sub_agent", + runtime_status: "running", + latest_turn_status: "running", + }, + ], + }); + + await harness.render(); + + expect(harness.getValue()).toMatchObject({ + currentSessionTitle: "主任务", + hasRuntimeSessions: true, + teamWorkspaceEnabled: true, + }); + }); + + it("只有 subagent 开关开启时也应展示 workspace,但不应伪造 runtime sessions", async () => { + const harness = renderHook({ + subagentEnabled: true, + }); + + await harness.render(); + + expect(harness.getValue()).toMatchObject({ + hasRuntimeSessions: false, + teamWorkspaceEnabled: true, + }); + }); +}); diff --git a/src/components/agent/chat/workspace/useWorkspaceTeamSessionRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceTeamSessionRuntime.ts index 7bff85ec5..c76172f57 100644 --- a/src/components/agent/chat/workspace/useWorkspaceTeamSessionRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceTeamSessionRuntime.ts @@ -40,9 +40,9 @@ export function useWorkspaceTeamSessionRuntime({ [sessionId, topics], ); - const hasRealTeamGraph = + const hasRuntimeSessions = childSubagentSessions.length > 0 || Boolean(subagentParentContext); - const showTeamWorkspaceBoard = subagentEnabled || hasRealTeamGraph; + const teamWorkspaceEnabled = subagentEnabled || hasRuntimeSessions; const currentSessionRuntimeStatus = useMemo( () => @@ -69,8 +69,8 @@ export function useWorkspaceTeamSessionRuntime({ return { currentSessionTitle, - hasRealTeamGraph, - showTeamWorkspaceBoard, + hasRuntimeSessions, + teamWorkspaceEnabled, currentSessionRuntimeStatus, currentSessionLatestTurnStatus, ...liveTeamWorkspaceRuntime, diff --git a/src/components/agent/chat/workspace/useWorkspaceTeamWorkbenchAutoOpenRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceTeamWorkbenchAutoOpenRuntime.ts deleted file mode 100644 index 7d079d1ca..000000000 --- a/src/components/agent/chat/workspace/useWorkspaceTeamWorkbenchAutoOpenRuntime.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - useCallback, - useState, - type Dispatch, - type SetStateAction, -} from "react"; -import type { LayoutMode } from "@/lib/workspace/workbenchContract"; - -interface UseWorkspaceTeamWorkbenchAutoOpenRuntimeParams { - hasRealTeamGraph: boolean; - layoutMode: LayoutMode; - runtimeTeamRequestId: string | null; - sessionId?: string | null; - setLayoutMode: Dispatch>; -} - -export function useWorkspaceTeamWorkbenchAutoOpenRuntime({ - setLayoutMode, -}: UseWorkspaceTeamWorkbenchAutoOpenRuntimeParams) { - const [teamWorkbenchAutoFocusToken, setTeamWorkbenchAutoFocusToken] = - useState(0); - const dismissActiveTeamWorkbenchAutoOpen = useCallback(() => {}, []); - - const handleActivateTeamWorkbench = useCallback(() => { - setTeamWorkbenchAutoFocusToken((current) => current + 1); - setLayoutMode((current) => (current === "chat" ? "chat-canvas" : current)); - }, [setLayoutMode]); - - return { - teamWorkbenchAutoFocusToken, - dismissActiveTeamWorkbenchAutoOpen, - handleActivateTeamWorkbench, - }; -} diff --git a/src/components/agent/chat/workspace/useWorkspaceWorkflowProgressSync.ts b/src/components/agent/chat/workspace/useWorkspaceWorkflowProgressSync.ts deleted file mode 100644 index 88613d4d5..000000000 --- a/src/components/agent/chat/workspace/useWorkspaceWorkflowProgressSync.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { useEffect, useMemo, useRef } from "react"; -import type { StepStatus } from "@/lib/workspace/workbenchContract"; -import type { WorkflowProgressSnapshot } from "../agentChatWorkspaceContract"; - -interface WorkflowProgressStep { - id: string; - title: string; - status: StepStatus; -} - -interface UseWorkspaceWorkflowProgressSyncParams { - enabled: boolean; - currentStepIndex: number; - steps: WorkflowProgressStep[]; - onWorkflowProgressChange?: ( - snapshot: WorkflowProgressSnapshot | null, - ) => void; -} - -export function useWorkspaceWorkflowProgressSync({ - enabled, - currentStepIndex, - steps, - onWorkflowProgressChange, -}: UseWorkspaceWorkflowProgressSyncParams) { - const workflowProgressSignature = useMemo(() => { - if (!enabled) { - return "hidden"; - } - - const stepSignature = steps - .map((step) => `${step.id}:${step.status}:${step.title}`) - .join("|"); - return `${currentStepIndex}:${stepSignature}`; - }, [currentStepIndex, enabled, steps]); - - const lastWorkflowProgressSignatureRef = useRef(""); - - useEffect(() => { - if (!onWorkflowProgressChange) { - return; - } - - if ( - lastWorkflowProgressSignatureRef.current === workflowProgressSignature - ) { - return; - } - lastWorkflowProgressSignatureRef.current = workflowProgressSignature; - - if (!enabled) { - onWorkflowProgressChange(null); - return; - } - - onWorkflowProgressChange({ - currentIndex: currentStepIndex, - steps: steps.map((step) => ({ - id: step.id, - title: step.title, - status: step.status, - })), - }); - }, [ - currentStepIndex, - enabled, - onWorkflowProgressChange, - steps, - workflowProgressSignature, - ]); - - useEffect(() => { - return () => { - onWorkflowProgressChange?.(null); - }; - }, [onWorkflowProgressChange]); -} diff --git a/src/components/agent/chat/workspace/workspaceSendHelpers.test.ts b/src/components/agent/chat/workspace/workspaceSendHelpers.test.ts new file mode 100644 index 000000000..9fce91ea7 --- /dev/null +++ b/src/components/agent/chat/workspace/workspaceSendHelpers.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildRuntimeTeamDispatchPreviewMessages, + buildSubmissionPreviewMessages, + createSubmissionPreviewSnapshot, + resolveRuntimeTeamDispatchPreviewState, +} from "./workspaceSendHelpers"; + +describe("workspaceSendHelpers runtime team preview", () => { + it("应在失败预览中覆盖 formationState 的错误信息", () => { + const state = resolveRuntimeTeamDispatchPreviewState({ + key: "runtime-team-failed", + prompt: "请继续处理", + images: [], + baseMessageCount: 0, + status: "failed", + failureMessage: "Provider 认证失败", + formationState: { + requestId: "runtime-1", + status: "forming", + label: "修复 Team", + summary: "分析、执行、验证三段式推进。", + members: [], + blueprint: null, + updatedAt: Date.now(), + }, + }); + + expect(state).toMatchObject({ + status: "failed", + errorMessage: "Provider 认证失败", + }); + }); + + it("formed 预览消息应使用任务叙事", () => { + const messages = buildRuntimeTeamDispatchPreviewMessages({ + key: "runtime-team-formed", + prompt: "请拆成分析、执行、验证三个并行步骤继续推进", + images: [], + baseMessageCount: 0, + status: "formed", + formationState: { + requestId: "runtime-formed-1", + status: "formed", + label: "修复 Team", + summary: "分析、执行、验证三段式推进。", + members: [ + { + id: "task-1", + label: "分析", + summary: "收敛问题边界。", + roleKey: "explorer", + profileId: "code-explorer", + skillIds: ["repo-exploration"], + status: "planned", + }, + { + id: "task-2", + label: "执行", + summary: "完成修复并汇报结果。", + roleKey: "executor", + profileId: "code-executor", + skillIds: ["bounded-implementation"], + status: "planned", + }, + ], + blueprint: { + label: "代码排障团队", + summary: "分析、执行、验证三段式推进。", + roles: [], + }, + updatedAt: Date.now(), + }, + }); + + expect(messages).toHaveLength(2); + expect(messages[1]?.content).toContain("分工如下"); + expect(messages[1]?.content).toContain("这些任务会分别展开处理"); + expect(messages[1]?.runtimeStatus).toMatchObject({ + title: "任务分工已准备好", + detail: "分析、执行、验证三段式推进。", + checkpoints: [ + "当前方案:修复任务方案", + "已安排 2 项任务", + "主对话会持续同步关键进展", + ], + }); + }); + + it("forming 预览消息应提示等待任务接手", () => { + const messages = buildRuntimeTeamDispatchPreviewMessages({ + key: "runtime-team-forming", + prompt: "请先拆任务再继续", + images: [], + baseMessageCount: 0, + status: "forming", + formationState: { + requestId: "runtime-forming-1", + status: "forming", + label: "排障 Team", + summary: "分析、执行两段式推进。", + members: [], + blueprint: null, + updatedAt: Date.now(), + }, + }); + + expect(messages[1]?.isThinking).toBe(true); + expect(messages[1]?.runtimeStatus).toMatchObject({ + title: "正在准备任务分工", + detail: + "系统正在根据当前任务安排分工,会先拆出合适的任务,再把关键进展持续汇总回主对话。", + checkpoints: ["确认当前任务目标", "安排任务分工", "等待任务接手处理"], + }); + }); + + it("提交预览应生成等待态快照并映射成双消息预览", () => { + vi.spyOn(Date, "now").mockReturnValue(1_710_000_000_000); + + const snapshot = createSubmissionPreviewSnapshot({ + key: "submission-preview-1", + prompt: "继续处理当前任务", + images: [], + executionStrategy: "react", + webSearch: true, + thinking: false, + }); + + expect(snapshot).toMatchObject({ + key: "submission-preview-1", + prompt: "继续处理当前任务", + createdAt: 1_710_000_000_000, + }); + expect(snapshot.runtimeStatus).not.toBeNull(); + + const messages = buildSubmissionPreviewMessages(snapshot); + expect(messages).toHaveLength(2); + expect(messages[0]).toMatchObject({ + id: "submission-preview:submission-preview-1:user", + role: "user", + content: "继续处理当前任务", + }); + expect(messages[1]).toMatchObject({ + id: "submission-preview:submission-preview-1:assistant", + role: "assistant", + isThinking: true, + runtimeStatus: snapshot.runtimeStatus, + }); + + vi.restoreAllMocks(); + }); +}); diff --git a/src/components/agent/chat/workspace/workspaceSendHelpers.ts b/src/components/agent/chat/workspace/workspaceSendHelpers.ts index da589e640..4c23e840a 100644 --- a/src/components/agent/chat/workspace/workspaceSendHelpers.ts +++ b/src/components/agent/chat/workspace/workspaceSendHelpers.ts @@ -3,9 +3,16 @@ import { buildHarnessRequestMetadata, extractExistingHarnessMetadata, } from "../utils/harnessRequestMetadata"; +import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime"; +import type { AssistantDraftState } from "../hooks/agentChatShared"; import type { HandleSendOptions } from "../hooks/handleSendTypes"; import type { ChatToolPreferences } from "../utils/chatToolPreferences"; -import type { BrowserTaskRequirement, MessageImage } from "../types"; +import type { + AgentRuntimeStatus, + BrowserTaskRequirement, + Message, + MessageImage, +} from "../types"; import type { Character } from "@/lib/api/memory"; import { buildTeamMemoryShadowRequestMetadata, @@ -18,9 +25,14 @@ import type { TeamDefinitionSource, TeamRoleDefinition, } from "../utils/teamDefinitions"; -import type { RuntimeTeamDispatchPreviewSnapshot } from "./runtimeTeamPreview"; import type { UseRuntimeTeamFormationResult } from "../hooks/useRuntimeTeamFormation"; import type { AgentAccessMode } from "../hooks/agentChatStorage"; +import type { TeamWorkspaceRuntimeFormationState } from "../teamWorkspaceRuntime"; +import { + buildWaitingAgentRuntimeStatus, + formatAgentRuntimeStatusSummary, +} from "../utils/agentRuntimeStatus"; +import { normalizeTeamWorkspaceDisplayValue } from "../utils/teamWorkspaceDisplay"; const GENERAL_BROWSER_ASSIST_PROFILE_KEY = "general_browser_assist"; @@ -30,6 +42,24 @@ type PreparedRuntimeTeamState = NonNullable< > >; +export interface RuntimeTeamDispatchPreviewSnapshot { + key: string; + prompt: string; + images: MessageImage[]; + baseMessageCount: number; + status: "forming" | "formed" | "failed"; + formationState?: TeamWorkspaceRuntimeFormationState | null; + failureMessage?: string | null; +} + +export interface SubmissionPreviewSnapshot { + key: string; + prompt: string; + images: MessageImage[]; + createdAt: number; + runtimeStatus: NonNullable; +} + export interface ContextWorkspaceSummary { enabled: boolean; activeContextPrompt: string; @@ -571,3 +601,237 @@ export function buildRuntimeTeamDispatchPreview( failureMessage: preparedRuntimeTeamState.errorMessage?.trim() || null, }; } + +export function resolveRuntimeTeamDispatchPreviewState( + snapshot: RuntimeTeamDispatchPreviewSnapshot | null | undefined, +): TeamWorkspaceRuntimeFormationState | null { + const formationState = snapshot?.formationState ?? null; + if (!snapshot || !formationState) { + return null; + } + + const normalizedFailureMessage = snapshot.failureMessage?.trim() || null; + if ( + snapshot.status === formationState.status && + !(snapshot.status === "failed" && normalizedFailureMessage) + ) { + return formationState; + } + + return { + ...formationState, + status: snapshot.status, + errorMessage: + snapshot.status === "failed" + ? normalizedFailureMessage || + formationState.errorMessage?.trim() || + null + : null, + }; +} + +function buildRuntimeTeamMemberPlanLines( + state: TeamWorkspaceRuntimeFormationState, +): string[] { + const members = state.members.slice(0, 3); + const lines = members.map((member, index) => { + const label = + normalizeTeamWorkspaceDisplayValue(member.label) || `任务 ${index + 1}`; + const summary = + normalizeTeamWorkspaceDisplayValue(member.summary) || + "负责推进当前任务中的一部分工作。"; + return `${index + 1}. ${label}:${summary}`; + }); + + if (state.members.length > members.length) { + lines.push( + `另外还有 ${state.members.length - members.length} 项任务会继续接手处理。`, + ); + } + + return lines; +} + +function buildRuntimeTeamAssistantDraft( + state: TeamWorkspaceRuntimeFormationState | null | undefined, +): AssistantDraftState | undefined { + if (!state || state.status !== "formed") { + return undefined; + } + + const teamLabel = + normalizeTeamWorkspaceDisplayValue(state.label || state.blueprint?.label) || + "当前任务方案"; + const summary = + normalizeTeamWorkspaceDisplayValue( + state.summary || state.blueprint?.summary, + ) || ""; + const planLines = buildRuntimeTeamMemberPlanLines(state); + const contentSections = [ + `我已经为这项任务准备了「${teamLabel}」。`, + summary ? `会先按“${summary}”来推进。` : null, + planLines.length > 0 ? `分工如下:\n${planLines.join("\n")}` : null, + "接下来这些任务会分别展开处理,再把关键进展、风险和需要你确认的事项汇总给你。", + ].filter(Boolean); + + const initialRuntimeStatus: AgentRuntimeStatus = { + phase: "routing", + title: "任务分工已准备好", + detail: + summary || "已整理好当前任务的分工,接下来会分别展开处理并同步结果。", + checkpoints: [ + `当前方案:${teamLabel}`, + `已安排 ${Math.max(state.members.length, 1)} 项任务`, + "主对话会持续同步关键进展", + ], + }; + + const waitingRuntimeStatus: AgentRuntimeStatus = { + phase: "routing", + title: "任务开始接手", + detail: + summary || "分工已经确认,这些任务会按各自职责继续处理并回传关键结果。", + checkpoints: [ + `当前方案:${teamLabel}`, + planLines[0] || "这些任务会分别接手自己的部分", + "主对话会持续同步关键进展", + ], + }; + + return { + content: contentSections.join("\n\n"), + initialRuntimeStatus, + waitingRuntimeStatus, + }; +} + +export function buildRuntimeTeamDispatchPreviewMessages( + snapshot: RuntimeTeamDispatchPreviewSnapshot, +): Message[] { + const normalizedPrompt = snapshot.prompt.trim(); + const timestamp = new Date(); + const formedAssistantDraft = + snapshot.status === "formed" + ? buildRuntimeTeamAssistantDraft(snapshot.formationState) + : undefined; + const formedTeamLabel = + normalizeTeamWorkspaceDisplayValue( + snapshot.formationState?.label || snapshot.formationState?.blueprint?.label, + ) || + "当前任务方案"; + const formedSummary = + normalizeTeamWorkspaceDisplayValue( + snapshot.formationState?.summary || + snapshot.formationState?.blueprint?.summary, + ) || + ""; + const assistantRuntimeStatus = + snapshot.status === "failed" + ? { + phase: "failed" as const, + title: "任务分工准备失败", + detail: + normalizeTeamWorkspaceDisplayValue(snapshot.failureMessage) || + "这次任务分工准备失败,已回退到普通对话发送。", + } + : snapshot.status === "formed" + ? formedAssistantDraft?.initialRuntimeStatus || { + phase: "routing" as const, + title: "任务分工已准备好", + detail: + formedSummary || + "已整理好当前任务的分工,接下来会分别展开处理并同步结果。", + checkpoints: [ + `当前方案:${formedTeamLabel}`, + "这些任务会按分工开始接手", + "主对话会持续同步关键进展", + ], + } + : { + phase: "routing" as const, + title: "正在准备任务分工", + detail: + "系统正在根据当前任务安排分工,会先拆出合适的任务,再把关键进展持续汇总回主对话。", + checkpoints: [ + "确认当前任务目标", + "安排任务分工", + "等待任务接手处理", + ], + }; + + return [ + { + id: `runtime-team-dispatch:${snapshot.key}:user`, + role: "user", + content: normalizedPrompt, + images: snapshot.images.length > 0 ? snapshot.images : undefined, + timestamp, + }, + { + id: `runtime-team-dispatch:${snapshot.key}:assistant`, + role: "assistant", + content: + snapshot.status === "failed" + ? "这次任务分工准备失败,已回退到普通执行。" + : snapshot.status === "formed" + ? formedAssistantDraft?.content || + `我已经为这项任务准备了「${formedTeamLabel}」。\n\n接下来这些任务会分别展开处理,再把关键进展和结果汇总给你。` + : "我会先安排任务分工,再把关键进展和结果汇总给你。", + timestamp: new Date(timestamp.getTime() + 1), + isThinking: snapshot.status === "forming", + runtimeStatus: assistantRuntimeStatus, + }, + ]; +} + +interface CreateSubmissionPreviewSnapshotOptions { + key: string; + prompt: string; + images: MessageImage[]; + executionStrategy: AsterExecutionStrategy; + webSearch?: boolean; + thinking?: boolean; +} + +export function createSubmissionPreviewSnapshot( + options: CreateSubmissionPreviewSnapshotOptions, +): SubmissionPreviewSnapshot { + const { key, prompt, images, executionStrategy, webSearch, thinking } = + options; + + return { + key, + prompt, + images, + createdAt: Date.now(), + runtimeStatus: buildWaitingAgentRuntimeStatus({ + executionStrategy, + webSearch, + thinking, + }), + }; +} + +export function buildSubmissionPreviewMessages( + snapshot: SubmissionPreviewSnapshot, +): Message[] { + const timestamp = new Date(snapshot.createdAt); + + return [ + { + id: `submission-preview:${snapshot.key}:user`, + role: "user", + content: snapshot.prompt, + images: snapshot.images.length > 0 ? snapshot.images : undefined, + timestamp, + }, + { + id: `submission-preview:${snapshot.key}:assistant`, + role: "assistant", + content: formatAgentRuntimeStatusSummary(snapshot.runtimeStatus), + timestamp: new Date(timestamp.getTime() + 1), + isThinking: true, + runtimeStatus: snapshot.runtimeStatus, + }, + ]; +} diff --git a/src/components/input-kit/ModelSelector.test.tsx b/src/components/input-kit/ModelSelector.test.tsx index 4b20f6ec9..517033cef 100644 --- a/src/components/input-kit/ModelSelector.test.tsx +++ b/src/components/input-kit/ModelSelector.test.tsx @@ -311,6 +311,62 @@ describe("ModelSelector", () => { expect(pageText).toContain("无多模态"); }); + it("anthropic-compatible Provider 应在选择器中展示显式缓存提示", () => { + mockUseConfiguredProviders.mockReturnValue({ + providers: [ + { + key: "custom-anthropic-compatible", + label: "GLM Anthropic", + registryId: "custom-anthropic-compatible", + fallbackRegistryId: "anthropic", + type: "anthropic-compatible", + providerId: "custom-anthropic-compatible", + apiHost: "https://open.bigmodel.cn/api/anthropic", + }, + ], + loading: false, + }); + mockUseProviderModels.mockReturnValue({ + modelIds: ["glm-5.1"], + models: [ + { + id: "glm-5.1", + capabilities: { + vision: true, + tools: true, + streaming: true, + json_mode: true, + function_calling: true, + reasoning: true, + }, + }, + ], + loading: false, + error: null, + }); + + const { container } = renderModelSelector({ + providerType: "custom-anthropic-compatible", + model: "glm-5.1", + }); + + const trigger = container.querySelector( + 'button[role="combobox"]', + ) as HTMLButtonElement | null; + if (!trigger) { + throw new Error("未找到模型选择触发器"); + } + + act(() => { + trigger.click(); + }); + + const pageText = document.body.textContent || ""; + expect(pageText).toContain("显式缓存"); + expect(pageText).toContain("未声明自动 Prompt Cache"); + expect(pageText).toContain("cache_control"); + }); + it("无 Provider 引导关闭后应隐藏,并在重新挂载时保持关闭状态", () => { mockUseConfiguredProviders.mockReturnValue({ providers: [], diff --git a/src/components/input-kit/ModelSelector.tsx b/src/components/input-kit/ModelSelector.tsx index f5285f0ce..c006e56a6 100644 --- a/src/components/input-kit/ModelSelector.tsx +++ b/src/components/input-kit/ModelSelector.tsx @@ -25,6 +25,10 @@ import { useProviderModels } from "@/hooks/useProviderModels"; import { filterModelsByTheme } from "@/components/agent/chat/utils/modelThemePolicy"; import { getProviderModelCompatibilityIssue } from "@/components/agent/chat/utils/providerModelCompatibility"; import { getProviderLabel } from "@/lib/constants/providerMappings"; +import { + getProviderPromptCacheMode, + resolvePromptCacheSupportNotice, +} from "@/lib/model/providerPromptCacheSupport"; import { ModelCapabilityBadges } from "@/components/model/ModelCapabilityBadges"; import { resolveOemCloudRuntimeContext } from "@/lib/api/oemCloudRuntime"; import { resolveOemLimeHubProviderName } from "@/lib/oemLimeHubProvider"; @@ -82,9 +86,8 @@ export const ModelSelector: React.FC = ({ return false; } return ( - window.localStorage.getItem( - NO_PROVIDER_GUIDE_DISMISSED_STORAGE_KEY, - ) === "1" + window.localStorage.getItem(NO_PROVIDER_GUIDE_DISMISSED_STORAGE_KEY) === + "1" ); }, ); @@ -199,6 +202,14 @@ export const ModelSelector: React.FC = ({ .map((item) => item.id), [modelOptions], ); + const selectedPromptCacheNotice = useMemo( + () => + resolvePromptCacheSupportNotice({ + providerType, + configuredProviderType: selectedProvider?.type, + }), + [providerType, selectedProvider?.type], + ); const incompatibleModelCount = useMemo( () => modelOptions.filter((item) => item.compatibilityIssue).length, @@ -315,10 +326,7 @@ export const ModelSelector: React.FC = ({ const handleDismissNoProviderGuide = () => { if (typeof window !== "undefined") { - window.localStorage.setItem( - NO_PROVIDER_GUIDE_DISMISSED_STORAGE_KEY, - "1", - ); + window.localStorage.setItem(NO_PROVIDER_GUIDE_DISMISSED_STORAGE_KEY, "1"); } setNoProviderGuideDismissed(true); }; @@ -454,6 +462,12 @@ export const ModelSelector: React.FC = ({ 当前按 {activeThemeLabel} 组织候选模型
) : null} + {selectedPromptCacheNotice ? ( +
+ 当前 Provider 未声明自动 Prompt Cache,请使用显式 cache_control + 标记 +
+ ) : null}
@@ -469,6 +483,9 @@ export const ModelSelector: React.FC = ({ ) : ( configuredProviders.map((provider) => { const isSelected = selectedProvider?.key === provider.key; + const providerPromptCacheMode = getProviderPromptCacheMode( + provider.type, + ); return (