From b06b9eedacbbd95098a70fa5aaa9595462aa1e94 Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Mon, 16 Feb 2026 23:57:32 +0100 Subject: [PATCH] fix: wire suggested task prompts for V1 (#12787) Co-authored-by: openhands --- .../mutation/use-create-conversation.test.tsx | 104 ++++++++++++++++++ .../v1-conversation-service.api.ts | 7 +- .../v1-conversation-service.types.ts | 4 +- .../hooks/mutation/use-create-conversation.ts | 3 +- .../app_conversation_models.py | 3 +- .../live_status_app_conversation_service.py | 32 ++++++ ...st_live_status_app_conversation_service.py | 60 +++++++++- 7 files changed, 207 insertions(+), 6 deletions(-) create mode 100644 frontend/__tests__/hooks/mutation/use-create-conversation.test.tsx diff --git a/frontend/__tests__/hooks/mutation/use-create-conversation.test.tsx b/frontend/__tests__/hooks/mutation/use-create-conversation.test.tsx new file mode 100644 index 0000000000..ba1a2ee498 --- /dev/null +++ b/frontend/__tests__/hooks/mutation/use-create-conversation.test.tsx @@ -0,0 +1,104 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, it, vi } from "vitest"; +import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api"; +import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; +import { SuggestedTask } from "#/utils/types"; + +vi.mock("#/hooks/query/use-settings", async () => { + const actual = await vi.importActual( + "#/hooks/query/use-settings", + ); + return { + ...actual, + useSettings: vi.fn().mockReturnValue({ + data: { + v1_enabled: true, + }, + isLoading: false, + }), + }; +}); + +vi.mock("#/hooks/use-tracking", () => ({ + useTracking: () => ({ + trackConversationCreated: vi.fn(), + }), +})); + +describe("useCreateConversation", () => { + it("passes suggested tasks to the V1 create conversation API", async () => { + const createConversationSpy = vi + .spyOn(V1ConversationService, "createConversation") + .mockResolvedValue({ + id: "task-id", + created_by_user_id: null, + status: "READY", + detail: null, + app_conversation_id: null, + sandbox_id: null, + agent_server_url: "http://agent-server.local", + request: { + sandbox_id: null, + initial_message: { + role: "user", + content: [{ type: "text", text: "Please address the comments" }], + }, + processors: [], + llm_model: null, + selected_repository: null, + selected_branch: null, + git_provider: "github", + suggested_task: null, + title: null, + trigger: null, + pr_number: [], + parent_conversation_id: null, + agent_type: "default", + }, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + + const { result } = renderHook(() => useCreateConversation(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + const suggestedTask: SuggestedTask = { + git_provider: "github", + issue_number: 42, + repo: "owner/repo", + title: "Resolve comments", + task_type: "UNRESOLVED_COMMENTS", + }; + + await result.current.mutateAsync({ + query: "Please address the comments", + repository: { + name: "owner/repo", + gitProvider: "github", + branch: "main", + }, + conversationInstructions: "Focus on review comments", + suggestedTask, + }); + + await waitFor(() => { + expect(createConversationSpy).toHaveBeenCalledWith( + "owner/repo", + "github", + "Please address the comments", + "main", + "Focus on review comments", + suggestedTask, + undefined, + undefined, + undefined, + ); + }); + }); +}); diff --git a/frontend/src/api/conversation-service/v1-conversation-service.api.ts b/frontend/src/api/conversation-service/v1-conversation-service.api.ts index 46c08c9ad8..f6aaf91219 100644 --- a/frontend/src/api/conversation-service/v1-conversation-service.api.ts +++ b/frontend/src/api/conversation-service/v1-conversation-service.api.ts @@ -2,6 +2,7 @@ import axios from "axios"; import { openHands } from "../open-hands-axios"; import { ConversationTrigger, GetVSCodeUrlResponse } from "../open-hands.types"; import { Provider } from "#/types/settings"; +import { SuggestedTask } from "#/utils/types"; import { buildHttpBaseUrl } from "#/utils/websocket-url"; import { buildSessionHeaders } from "#/utils/utils"; import type { @@ -61,6 +62,7 @@ class V1ConversationService { initialUserMsg?: string, selected_branch?: string, conversationInstructions?: string, + suggestedTask?: SuggestedTask, trigger?: ConversationTrigger, parent_conversation_id?: string, agent_type?: "default" | "plan", @@ -69,14 +71,15 @@ class V1ConversationService { selected_repository: selectedRepository, git_provider, selected_branch, + suggested_task: suggestedTask, title: conversationInstructions, trigger, parent_conversation_id: parent_conversation_id || null, agent_type, }; - // Add initial message if provided - if (initialUserMsg) { + // suggested_task implies the backend will construct the initial_message + if (!suggestedTask && initialUserMsg) { body.initial_message = { role: "user", content: [ diff --git a/frontend/src/api/conversation-service/v1-conversation-service.types.ts b/frontend/src/api/conversation-service/v1-conversation-service.types.ts index bf37b8ef2d..fb59623372 100644 --- a/frontend/src/api/conversation-service/v1-conversation-service.types.ts +++ b/frontend/src/api/conversation-service/v1-conversation-service.types.ts @@ -1,6 +1,7 @@ import { ConversationTrigger } from "../open-hands.types"; -import { Provider } from "#/types/settings"; import { V1SandboxStatus } from "../sandbox-service/sandbox-service.types"; +import { Provider } from "#/types/settings"; +import { SuggestedTask } from "#/utils/types"; // V1 Metrics Types export interface V1TokenUsage { @@ -47,6 +48,7 @@ export interface V1AppConversationStartRequest { selected_repository?: string | null; selected_branch?: string | null; git_provider?: Provider | null; + suggested_task?: SuggestedTask | null; title?: string | null; trigger?: ConversationTrigger | null; pr_number?: number[]; diff --git a/frontend/src/hooks/mutation/use-create-conversation.ts b/frontend/src/hooks/mutation/use-create-conversation.ts index 85e8dd880c..db5a700d8d 100644 --- a/frontend/src/hooks/mutation/use-create-conversation.ts +++ b/frontend/src/hooks/mutation/use-create-conversation.ts @@ -61,7 +61,8 @@ export const useCreateConversation = () => { query, repository?.branch, conversationInstructions, - undefined, // trigger - will be set by backend + suggestedTask, + undefined, // trigger - set by backend when applicable parentConversationId, agentType, ); diff --git a/openhands/app_server/app_conversation/app_conversation_models.py b/openhands/app_server/app_conversation/app_conversation_models.py index 22b261c269..83c3842c5d 100644 --- a/openhands/app_server/app_conversation/app_conversation_models.py +++ b/openhands/app_server/app_conversation/app_conversation_models.py @@ -11,7 +11,7 @@ from openhands.app_server.event_callback.event_callback_models import ( EventCallbackProcessor, ) from openhands.app_server.sandbox.sandbox_models import SandboxStatus -from openhands.integrations.service_types import ProviderType +from openhands.integrations.service_types import ProviderType, SuggestedTask from openhands.sdk.conversation.state import ConversationExecutionStatus from openhands.sdk.llm import MetricsSnapshot from openhands.sdk.plugin import PluginSource @@ -150,6 +150,7 @@ class AppConversationStartRequest(OpenHandsModel): selected_repository: str | None = None selected_branch: str | None = None git_provider: ProviderType | None = None + suggested_task: SuggestedTask | None = None title: str | None = None trigger: ConversationTrigger | None = None pr_number: list[int] = Field(default_factory=list) diff --git a/openhands/app_server/app_conversation/live_status_app_conversation_service.py b/openhands/app_server/app_conversation/live_status_app_conversation_service.py index 0772aae509..885c8dfcbf 100644 --- a/openhands/app_server/app_conversation/live_status_app_conversation_service.py +++ b/openhands/app_server/app_conversation/live_status_app_conversation_service.py @@ -18,6 +18,7 @@ from openhands.agent_server.models import ( ConversationInfo, SendMessageRequest, StartConversationRequest, + TextContent, ) from openhands.app_server.app_conversation.app_conversation_info_service import ( AppConversationInfoService, @@ -78,6 +79,7 @@ from openhands.app_server.utils.llm_metadata import ( ) from openhands.experiments.experiment_manager import ExperimentManagerImpl from openhands.integrations.provider import ProviderType +from openhands.integrations.service_types import SuggestedTask from openhands.sdk import Agent, AgentContext, LocalWorkspace from openhands.sdk.llm import LLM from openhands.sdk.plugin import PluginSource @@ -85,6 +87,7 @@ from openhands.sdk.secret import LookupSecret, SecretValue, StaticSecret from openhands.sdk.utils.paging import page_iterator from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace from openhands.server.types import AppMode +from openhands.storage.data_models.conversation_metadata import ConversationTrigger from openhands.tools.preset.default import ( get_default_tools, ) @@ -209,6 +212,8 @@ class LiveStatusAppConversationService(AppConversationServiceBase): ) self._inherit_configuration_from_parent(request, parent_info) + self._apply_suggested_task(request) + task = AppConversationStartTask( created_by_user_id=user_id, request=request, @@ -569,6 +574,33 @@ class LiveStatusAppConversationService(AppConversationServiceBase): if not request.llm_model and parent_info.llm_model: request.llm_model = parent_info.llm_model + def _apply_suggested_task(self, request: AppConversationStartRequest) -> None: + """Apply suggested task defaults to the start request.""" + suggested_task: SuggestedTask | None = request.suggested_task + if not suggested_task: + return + + if request.initial_message is not None: + raise ValueError( + 'initial_message cannot be provided when suggested_task is present' + ) + + prompt = suggested_task.get_prompt_for_task() + if not prompt: + raise ValueError( + f'Suggested task returned empty prompt for task type {suggested_task.task_type}' + ) + request.initial_message = SendMessageRequest( + role='user', + content=[TextContent(text=prompt)], + ) + request.trigger = ConversationTrigger.SUGGESTED_TASK + + if not request.selected_repository: + request.selected_repository = suggested_task.repo + if not request.git_provider: + request.git_provider = suggested_task.git_provider + def _compute_plan_path( self, working_dir: str, diff --git a/tests/unit/app_server/test_live_status_app_conversation_service.py b/tests/unit/app_server/test_live_status_app_conversation_service.py index b56f2ec9eb..1157d8ef01 100644 --- a/tests/unit/app_server/test_live_status_app_conversation_service.py +++ b/tests/unit/app_server/test_live_status_app_conversation_service.py @@ -14,6 +14,7 @@ from pydantic import SecretStr from openhands.agent_server.models import ( SendMessageRequest, StartConversationRequest, + TextContent, ) from openhands.app_server.app_conversation.app_conversation_models import ( AgentType, @@ -32,12 +33,14 @@ from openhands.app_server.sandbox.sandbox_models import ( from openhands.app_server.sandbox.sandbox_spec_models import SandboxSpecInfo from openhands.app_server.user.user_context import UserContext from openhands.integrations.provider import ProviderToken, ProviderType +from openhands.integrations.service_types import SuggestedTask, TaskType from openhands.sdk import Agent, Event from openhands.sdk.llm import LLM from openhands.sdk.secret import LookupSecret, StaticSecret from openhands.sdk.workspace import LocalWorkspace from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace from openhands.server.types import AppMode +from openhands.storage.data_models.conversation_metadata import ConversationTrigger # Env var used by openhands SDK LLM to skip context-window validation (e.g. for gpt-4 in tests) _ALLOW_SHORT_CONTEXT_WINDOWS = 'ALLOW_SHORT_CONTEXT_WINDOWS' @@ -112,7 +115,62 @@ class TestLiveStatusAppConversationService: self.mock_sandbox.id = uuid4() self.mock_sandbox.status = SandboxStatus.RUNNING - @pytest.mark.asyncio + def test_apply_suggested_task_sets_prompt_and_trigger(self): + """Test suggested task prompts populate initial message and trigger.""" + suggested_task = SuggestedTask( + git_provider=ProviderType.GITHUB, + task_type=TaskType.UNRESOLVED_COMMENTS, + repo='owner/repo', + issue_number=42, + title='Handle review comments', + ) + request = AppConversationStartRequest(suggested_task=suggested_task) + + self.service._apply_suggested_task(request) + + assert request.initial_message is not None + assert ( + request.initial_message.content[0].text + == suggested_task.get_prompt_for_task() + ) + assert request.trigger == ConversationTrigger.SUGGESTED_TASK + assert request.selected_repository == suggested_task.repo + assert request.git_provider == suggested_task.git_provider + + def test_apply_suggested_task_raises_if_initial_message_present(self): + suggested_task = SuggestedTask( + repo='foo/bar', + git_provider=ProviderType.GITHUB, + title='Some title', + task_type=TaskType.OPEN_ISSUE, + issue_number=123, + ) + + request = AppConversationStartRequest( + suggested_task=suggested_task, + initial_message=SendMessageRequest( + role='user', + content=[TextContent(text='User provided message')], + ), + ) + + with pytest.raises(ValueError, match='initial_message cannot be provided'): + self.service._apply_suggested_task(request) + + def test_apply_suggested_task_raises_if_prompt_empty(self): + suggested_task = SuggestedTask( + repo='foo/bar', + git_provider=ProviderType.GITHUB, + title='Some title', + task_type=TaskType.OPEN_ISSUE, + issue_number=123, + ) + request = AppConversationStartRequest(suggested_task=suggested_task) + + with patch.object(SuggestedTask, 'get_prompt_for_task', return_value=''): + with pytest.raises(ValueError, match='empty prompt'): + self.service._apply_suggested_task(request) + async def test_setup_secrets_for_git_providers_no_provider_tokens(self): """Test _setup_secrets_for_git_providers with no provider tokens.""" # Arrange