From 6d8cca43a8cb47509abcb1b739f3de8a4eacb18f Mon Sep 17 00:00:00 2001 From: Marco Dalalba Date: Sun, 30 Nov 2025 21:32:34 -0300 Subject: [PATCH 01/18] fix: add Azure GPT-5 family to stop words unsupported patterns (#11842) --- openhands/llm/model_features.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openhands/llm/model_features.py b/openhands/llm/model_features.py index a9857ffaca..f592f0bb98 100644 --- a/openhands/llm/model_features.py +++ b/openhands/llm/model_features.py @@ -132,6 +132,8 @@ SUPPORTS_STOP_WORDS_FALSE_PATTERNS: list[str] = [ 'grok-code-fast-1', # DeepSeek R1 family 'deepseek-r1-0528*', + # Azure GPT-5 family + 'azure/gpt-5*', ] From 991f1a242c8dd58eb4a805750bb619592cf92219 Mon Sep 17 00:00:00 2001 From: adshrc Date: Mon, 1 Dec 2025 12:09:33 +0100 Subject: [PATCH 02/18] feat(llm): added Claude Opus 4.5 model and corresponding test (#11841) --- frontend/src/utils/verified-models.ts | 1 + openhands/llm/llm.py | 8 +++++--- tests/unit/llm/test_llm.py | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/frontend/src/utils/verified-models.ts b/frontend/src/utils/verified-models.ts index 12453c6c86..5b2e19e9ef 100644 --- a/frontend/src/utils/verified-models.ts +++ b/frontend/src/utils/verified-models.ts @@ -59,6 +59,7 @@ export const VERIFIED_ANTHROPIC_MODELS = [ "claude-haiku-4-5-20251001", "claude-opus-4-20250514", "claude-opus-4-1-20250805", + "claude-opus-4-5-20251101", ]; // LiteLLM does not return the compatible Mistral models with the provider, so we list them here to set them ourselves diff --git a/openhands/llm/llm.py b/openhands/llm/llm.py index b94ed3bc2b..150fa54925 100644 --- a/openhands/llm/llm.py +++ b/openhands/llm/llm.py @@ -188,12 +188,14 @@ class LLM(RetryMixin, DebugMixin): if 'claude-opus-4-1' in self.config.model.lower(): kwargs['thinking'] = {'type': 'disabled'} - # Anthropic constraint: Opus 4.1 and Sonnet 4 models cannot accept both temperature and top_p + # Anthropic constraint: Opus 4.1, Opus 4.5, and Sonnet 4 models cannot accept both temperature and top_p # Prefer temperature (drop top_p) if both are specified. _model_lower = self.config.model.lower() - # Apply to Opus 4.1 and Sonnet 4 models to avoid API errors + # Apply to Opus 4.1, Opus 4.5, and Sonnet 4 models to avoid API errors if ( - ('claude-opus-4-1' in _model_lower) or ('claude-sonnet-4' in _model_lower) + ('claude-opus-4-1' in _model_lower) + or ('claude-opus-4-5' in _model_lower) + or ('claude-sonnet-4' in _model_lower) ) and ('temperature' in kwargs and 'top_p' in kwargs): kwargs.pop('top_p', None) diff --git a/tests/unit/llm/test_llm.py b/tests/unit/llm/test_llm.py index dfdb4e05b4..b04425e631 100644 --- a/tests/unit/llm/test_llm.py +++ b/tests/unit/llm/test_llm.py @@ -1255,6 +1255,25 @@ def test_opus_41_keeps_temperature_top_p(mock_completion): assert 'top_p' not in call_kwargs +@patch('openhands.llm.llm.litellm_completion') +def test_opus_45_keeps_temperature_drops_top_p(mock_completion): + mock_completion.return_value = { + 'choices': [{'message': {'content': 'ok'}}], + } + config = LLMConfig( + model='anthropic/claude-opus-4-5-20251101', + api_key='k', + temperature=0.7, + top_p=0.9, + ) + llm = LLM(config, service_id='svc') + llm.completion(messages=[{'role': 'user', 'content': 'hi'}]) + call_kwargs = mock_completion.call_args[1] + assert call_kwargs.get('temperature') == 0.7 + # Anthropic rejects both temperature and top_p together on Opus 4.5; we keep temperature and drop top_p + assert 'top_p' not in call_kwargs + + @patch('openhands.llm.llm.litellm_completion') def test_sonnet_4_keeps_temperature_drops_top_p(mock_completion): mock_completion.return_value = { From 27590497d5311c96b1c957a407bd78a4b7deeb39 Mon Sep 17 00:00:00 2001 From: Ray Myers Date: Mon, 1 Dec 2025 07:03:44 -0600 Subject: [PATCH 03/18] chore: update posthog-js from 1.290.0 to 1.298.1 (#11830) Co-authored-by: openhands --- frontend/package-lock.json | 16 ++++++++-------- frontend/package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 18ba27b40c..2da833345a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -39,7 +39,7 @@ "jose": "^6.1.0", "lucide-react": "^0.544.0", "monaco-editor": "^0.53.0", - "posthog-js": "^1.290.0", + "posthog-js": "^1.298.1", "react": "^19.1.1", "react-dom": "^19.1.1", "react-highlight": "^0.15.0", @@ -3910,9 +3910,9 @@ "license": "MIT" }, "node_modules/@posthog/core": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.5.2.tgz", - "integrity": "sha512-iedUP3EnOPPxTA2VaIrsrd29lSZnUV+ZrMnvY56timRVeZAXoYCkmjfIs3KBAsF8OUT5h1GXLSkoQdrV0r31OQ==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.6.0.tgz", + "integrity": "sha512-Tbh8UACwbb7jFdDC7wwXHtfNzO+4wKh3VbyMHmp2UBe6w1jliJixexTJNfkqdGZm+ht3M10mcKvGGPnoZ2zLBg==", "license": "MIT", "dependencies": { "cross-spawn": "^7.0.6" @@ -14711,12 +14711,12 @@ "license": "MIT" }, "node_modules/posthog-js": { - "version": "1.290.0", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.290.0.tgz", - "integrity": "sha512-zavBwZkf+3JeiSDVE7ZDXBfzva/iOljicdhdJH+cZoqp0LsxjKxjnNhGOd3KpAhw0wqdwjhd7Lp1aJuI7DXyaw==", + "version": "1.298.1", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.298.1.tgz", + "integrity": "sha512-MynFhC2HO6sg5moUfpkd0s6RzAqcqFX75kjIi4Xrj2Gl0/YQWYvFUgvv8FCpWPKPs2mdvNWYhs+oqJg0BVVHPw==", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@posthog/core": "1.5.2", + "@posthog/core": "1.6.0", "core-js": "^3.38.1", "fflate": "^0.4.8", "preact": "^10.19.3", diff --git a/frontend/package.json b/frontend/package.json index 8925a5d391..8e1998a304 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,7 +38,7 @@ "jose": "^6.1.0", "lucide-react": "^0.544.0", "monaco-editor": "^0.53.0", - "posthog-js": "^1.290.0", + "posthog-js": "^1.298.1", "react": "^19.1.1", "react-dom": "^19.1.1", "react-highlight": "^0.15.0", From e7e49c9110b2ecfc5a11036d97da9b5dcda620d0 Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Mon, 1 Dec 2025 20:13:54 +0700 Subject: [PATCH 04/18] fix(frontend): AppConversationStartTask timezone display in ui (#11847) --- .../conversation-card-footer.tsx | 2 +- .../start-task-card-footer.tsx | 2 +- .../recent-conversation.tsx | 14 ++++--- frontend/src/utils/format-time-delta.ts | 37 +++++++++++++++++-- 4 files changed, 43 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-footer.tsx b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-footer.tsx index fb77b58242..f44a2f8141 100644 --- a/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-footer.tsx +++ b/frontend/src/components/features/conversation-panel/conversation-card/conversation-card-footer.tsx @@ -39,7 +39,7 @@ export function ConversationCardFooter({ {(createdAt ?? lastUpdatedAt) && (

)} diff --git a/frontend/src/components/features/conversation-panel/start-task-card/start-task-card-footer.tsx b/frontend/src/components/features/conversation-panel/start-task-card/start-task-card-footer.tsx index a2f9bb4036..c5339183d5 100644 --- a/frontend/src/components/features/conversation-panel/start-task-card/start-task-card-footer.tsx +++ b/frontend/src/components/features/conversation-panel/start-task-card/start-task-card-footer.tsx @@ -31,7 +31,7 @@ export function StartTaskCardFooter({ {createdAt && (

)} diff --git a/frontend/src/components/features/home/recent-conversations/recent-conversation.tsx b/frontend/src/components/features/home/recent-conversations/recent-conversation.tsx index 7fcabe2f1f..d86bac55bf 100644 --- a/frontend/src/components/features/home/recent-conversations/recent-conversation.tsx +++ b/frontend/src/components/features/home/recent-conversations/recent-conversation.tsx @@ -67,12 +67,14 @@ export function RecentConversation({ conversation }: RecentConversationProps) { ) : null} - - {formatTimeDelta( - new Date(conversation.created_at || conversation.last_updated_at), - )}{" "} - {t(I18nKey.CONVERSATION$AGO)} - + {(conversation.created_at || conversation.last_updated_at) && ( + + {formatTimeDelta( + conversation.created_at || conversation.last_updated_at, + )}{" "} + {t(I18nKey.CONVERSATION$AGO)} + + )} diff --git a/frontend/src/utils/format-time-delta.ts b/frontend/src/utils/format-time-delta.ts index 8f2425a234..6785d9c845 100644 --- a/frontend/src/utils/format-time-delta.ts +++ b/frontend/src/utils/format-time-delta.ts @@ -1,16 +1,45 @@ +/** + * Parses a date string as UTC if it doesn't have a timezone indicator. + * This fixes the issue where ISO strings without timezone info are interpreted as local time. + * @param dateString ISO 8601 date string + * @returns Date object parsed as UTC + * + * @example + * parseDateAsUTC("2025-12-01T11:53:37.273886"); // Parsed as UTC + * parseDateAsUTC("2025-12-01T11:53:37.273886Z"); // Already has timezone, parsed correctly + * parseDateAsUTC("2025-12-01T11:53:37+00:00"); // Already has timezone, parsed correctly + */ +const parseDateAsUTC = (dateString: string): Date => { + // Check if the string already has a timezone indicator + // Look for 'Z' (UTC), '+' (positive offset), or '-' after the time part (negative offset) + const hasTimezone = + dateString.includes("Z") || dateString.match(/[+-]\d{2}:\d{2}$/) !== null; + + if (hasTimezone) { + // Already has timezone info, parse normally + return new Date(dateString); + } + + // No timezone indicator - append 'Z' to force UTC parsing + return new Date(`${dateString}Z`); +}; + /** * Formats a date into a compact string representing the time delta between the given date and the current date. - * @param date The date to format + * @param date The date to format (Date object or ISO 8601 string) * @returns A compact string representing the time delta between the given date and the current date * * @example * // now is 2024-01-01T00:00:00Z * formatTimeDelta(new Date("2023-12-31T23:59:59Z")); // "1s" - * formatTimeDelta(new Date("2022-01-01T00:00:00Z")); // "2y" + * formatTimeDelta("2023-12-31T23:59:59Z"); // "1s" + * formatTimeDelta("2025-12-01T11:53:37.273886"); // Parsed as UTC automatically */ -export const formatTimeDelta = (date: Date) => { +export const formatTimeDelta = (date: Date | string) => { + // Parse string dates as UTC if needed, or use Date object directly + const dateObj = typeof date === "string" ? parseDateAsUTC(date) : date; const now = new Date(); - const delta = now.getTime() - date.getTime(); + const delta = now.getTime() - dateObj.getTime(); const seconds = Math.floor(delta / 1000); const minutes = Math.floor(seconds / 60); From d9731b68509ea953ec66e86a52536d5dbd34fbbe Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Mon, 1 Dec 2025 20:42:44 +0700 Subject: [PATCH 05/18] feat(frontend): show plan content in the planning tab (#11807) --- .../v1-conversation-service.api.ts | 19 ++++ .../components/features/markdown/headings.tsx | 2 +- .../conversation-websocket-context.tsx | 82 ++++++++++++++++- .../mutation/use-read-conversation-file.ts | 17 ++++ frontend/src/routes/planner-tab.tsx | 2 +- frontend/src/state/conversation-store.ts | 91 ++----------------- frontend/src/types/v1/core/base/base.ts | 3 +- .../src/types/v1/core/base/observation.ts | 35 ++++++- frontend/src/types/v1/type-guards.ts | 10 ++ 9 files changed, 171 insertions(+), 90 deletions(-) create mode 100644 frontend/src/hooks/mutation/use-read-conversation-file.ts 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 5ca7daf09a..bd37fa8180 100644 --- a/frontend/src/api/conversation-service/v1-conversation-service.api.ts +++ b/frontend/src/api/conversation-service/v1-conversation-service.api.ts @@ -296,6 +296,25 @@ class V1ConversationService { const { data } = await openHands.get<{ runtime_id: string }>(url); return data; } + + /** + * Read a file from a specific conversation's sandbox workspace + * @param conversationId The conversation ID + * @param filePath Path to the file to read within the sandbox workspace (defaults to /workspace/project/PLAN.md) + * @returns The content of the file or an empty string if the file doesn't exist + */ + static async readConversationFile( + conversationId: string, + filePath: string = "/workspace/project/PLAN.md", + ): Promise { + const params = new URLSearchParams(); + params.append("file_path", filePath); + + const { data } = await openHands.get( + `/api/v1/app-conversations/${conversationId}/file?${params.toString()}`, + ); + return data; + } } export default V1ConversationService; diff --git a/frontend/src/components/features/markdown/headings.tsx b/frontend/src/components/features/markdown/headings.tsx index 2e12fc7db4..3098a4514a 100644 --- a/frontend/src/components/features/markdown/headings.tsx +++ b/frontend/src/components/features/markdown/headings.tsx @@ -8,7 +8,7 @@ export function h1({ React.HTMLAttributes & ExtraProps) { return ( -

+

{children}

); diff --git a/frontend/src/contexts/conversation-websocket-context.tsx b/frontend/src/contexts/conversation-websocket-context.tsx index b0b0119f75..ae115c41a9 100644 --- a/frontend/src/contexts/conversation-websocket-context.tsx +++ b/frontend/src/contexts/conversation-websocket-context.tsx @@ -26,6 +26,7 @@ import { isExecuteBashActionEvent, isExecuteBashObservationEvent, isConversationErrorEvent, + isPlanningFileEditorObservationEvent, } from "#/types/v1/type-guards"; import { ConversationStateUpdateEventStats } from "#/types/v1/core/events/conversation-state-event"; import { handleActionEventCacheInvalidation } from "#/utils/cache-utils"; @@ -38,6 +39,7 @@ import EventService from "#/api/event-service/event-service.api"; import { useConversationStore } from "#/state/conversation-store"; import { isBudgetOrCreditError } from "#/utils/error-handler"; import { useTracking } from "#/hooks/use-tracking"; +import { useReadConversationFile } from "#/hooks/mutation/use-read-conversation-file"; import useMetricsStore from "#/stores/metrics-store"; // eslint-disable-next-line @typescript-eslint/naming-convention @@ -102,12 +104,22 @@ export function ConversationWebSocketProvider({ number | null >(null); - const { conversationMode } = useConversationStore(); + const { conversationMode, setPlanContent } = useConversationStore(); + + // Hook for reading conversation file + const { mutate: readConversationFile } = useReadConversationFile(); // Separate received event count tracking per connection const receivedEventCountRefMain = useRef(0); const receivedEventCountRefPlanning = useRef(0); + // Track the latest PlanningFileEditorObservation event during history replay + // We'll only call the API once after history loading completes + const latestPlanningFileEventRef = useRef<{ + path: string; + conversationId: string; + } | null>(null); + // Helper function to update metrics from stats event const updateMetricsFromStats = useCallback( (event: ConversationStateUpdateEventStats) => { @@ -235,11 +247,40 @@ export function ConversationWebSocketProvider({ receivedEventCountRefPlanning, ]); + // Call API once after history loading completes if we tracked any PlanningFileEditorObservation events + useEffect(() => { + if (!isLoadingHistoryPlanning && latestPlanningFileEventRef.current) { + const { path, conversationId: currentPlanningConversationId } = + latestPlanningFileEventRef.current; + + readConversationFile( + { + conversationId: currentPlanningConversationId, + filePath: path, + }, + { + onSuccess: (fileContent) => { + setPlanContent(fileContent); + }, + onError: (error) => { + // eslint-disable-next-line no-console + console.warn("Failed to read conversation file:", error); + }, + }, + ); + + // Clear the ref after calling the API + latestPlanningFileEventRef.current = null; + } + }, [isLoadingHistoryPlanning, readConversationFile, setPlanContent]); + useEffect(() => { hasConnectedRefMain.current = false; setIsLoadingHistoryPlanning(!!subConversationIds?.length); setExpectedEventCountPlanning(null); receivedEventCountRefPlanning.current = 0; + // Reset the tracked event ref when sub-conversations change + latestPlanningFileEventRef.current = null; }, [subConversationIds]); // Merged loading history state - true if either connection is still loading @@ -254,6 +295,8 @@ export function ConversationWebSocketProvider({ setIsLoadingHistoryMain(true); setExpectedEventCountMain(null); receivedEventCountRefMain.current = 0; + // Reset the tracked event ref when conversation changes + latestPlanningFileEventRef.current = null; }, [conversationId]); // Separate message handlers for each connection @@ -438,6 +481,41 @@ export function ConversationWebSocketProvider({ .join("\n"); appendOutput(textContent); } + + // Handle PlanningFileEditorObservation events - read and update plan content + if (isPlanningFileEditorObservationEvent(event)) { + const planningAgentConversation = subConversations?.[0]; + const planningConversationId = planningAgentConversation?.id; + + if (planningConversationId && event.observation.path) { + // During history replay, track the latest event but don't call API + // After history loading completes, we'll call the API once with the latest event + if (isLoadingHistoryPlanning) { + latestPlanningFileEventRef.current = { + path: event.observation.path, + conversationId: planningConversationId, + }; + } else { + // History loading is complete - this is a new real-time event + // Call the API immediately for real-time updates + readConversationFile( + { + conversationId: planningConversationId, + filePath: event.observation.path, + }, + { + onSuccess: (fileContent) => { + setPlanContent(fileContent); + }, + onError: (error) => { + // eslint-disable-next-line no-console + console.warn("Failed to read conversation file:", error); + }, + }, + ); + } + } + } } } catch (error) { // eslint-disable-next-line no-console @@ -455,6 +533,8 @@ export function ConversationWebSocketProvider({ setExecutionStatus, appendInput, appendOutput, + readConversationFile, + setPlanContent, updateMetricsFromStats, ], ); diff --git a/frontend/src/hooks/mutation/use-read-conversation-file.ts b/frontend/src/hooks/mutation/use-read-conversation-file.ts new file mode 100644 index 0000000000..5dd8c51eb9 --- /dev/null +++ b/frontend/src/hooks/mutation/use-read-conversation-file.ts @@ -0,0 +1,17 @@ +import { useMutation } from "@tanstack/react-query"; +import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api"; + +interface UseReadConversationFileVariables { + conversationId: string; + filePath?: string; +} + +export const useReadConversationFile = () => + useMutation({ + mutationKey: ["read-conversation-file"], + mutationFn: async ({ + conversationId, + filePath, + }: UseReadConversationFileVariables): Promise => + V1ConversationService.readConversationFile(conversationId, filePath), + }); diff --git a/frontend/src/routes/planner-tab.tsx b/frontend/src/routes/planner-tab.tsx index a3002c6651..4fb46f9939 100644 --- a/frontend/src/routes/planner-tab.tsx +++ b/frontend/src/routes/planner-tab.tsx @@ -23,7 +23,7 @@ function PlannerTab() { const { planContent, setConversationMode } = useConversationStore(); - if (planContent) { + if (planContent !== null && planContent !== undefined) { return (
void; setConversationMode: (conversationMode: ConversationMode) => void; setSubConversationTaskId: (taskId: string | null) => void; + setPlanContent: (planContent: string | null) => void; } type ConversationStore = ConversationState & ConversationActions; @@ -81,91 +82,7 @@ export const useConversationStore = create()( submittedMessage: null, shouldHideSuggestions: false, hasRightPanelToggled: true, - planContent: ` -# Improve Developer Onboarding and Examples - -## Overview - -Based on the analysis of Browser-Use's current documentation and examples, this plan addresses gaps in developer onboarding by creating a progressive learning path, troubleshooting resources, and practical examples that address real-world scenarios (like the LM Studio/local LLM integration issues encountered). - -## Current State Analysis - -**Strengths:** - -- Good quickstart documentation in \`docs/quickstart.mdx\` -- Extensive examples across multiple categories (60+ example files) -- Well-structured docs with multiple LLM provider examples -- Active community support via Discord - -**Gaps Identified:** - -- No progressive tutorial series that builds complexity gradually -- Limited troubleshooting documentation for common issues -- Sparse comments in example files explaining what's happening -- Local LLM setup (Ollama/LM Studio) not prominently featured -- No "first 10 minutes" success path -- Missing visual/conceptual architecture guides for beginners -- Error messages don't always point to solutions - -## Proposed Improvements - -### 1. Create Interactive Tutorial Series (\`examples/tutorials/\`) - -**New folder structure:** - -\`\`\` -examples/tutorials/ -├── README.md # Tutorial overview and prerequisites -├── 00_hello_world.py # Absolute minimal example -├── 01_your_first_search.py # Basic search with detailed comments -├── 02_understanding_actions.py # How actions work -├── 03_data_extraction_basics.py # Extract data step-by-step -├── 04_error_handling.py # Common errors and solutions -├── 05_custom_tools_intro.py # First custom tool -├── 06_local_llm_setup.py # Ollama/LM Studio complete guide -└── 07_debugging_tips.py # Debugging strategies -\`\`\` - -**Key Features:** - -- Each file 50–80 lines max -- Extensive inline comments explaining every concept -- Clear learning objectives at the top of each file -- "What you'll learn" and "Prerequisites" sections -- Common pitfalls highlighted -- Expected output shown in comments - -### 2. Troubleshooting Guide (\`docs/troubleshooting.mdx\`) - -**Sections:** - -- Installation issues (Chromium, dependencies, virtual environments) -- LLM provider connection errors (API keys, timeouts, rate limits) -- Local LLM setup (Ollama vs LM Studio, model compatibility) -- Browser automation issues (element not found, timeout errors) -- Common error messages with solutions -- Performance optimization tips -- When to ask for help (Discord/GitHub) - -**Format:** - -**Error: "LLM call timed out after 60 seconds"** - -**What it means:** -The model took too long to respond - -**Common causes:** - -1. Model is too slow for the task -2. LM Studio/Ollama not responding properly -3. Complex page overwhelming the model - -**Solutions:** - -- Use flash_mode for faster execution -- Try a faster model (Gemini Flash, GPT-4 Turbo Mini) -- Simplify the task -- Check model server logs`, + planContent: null, conversationMode: "code", subConversationTaskId: null, @@ -304,6 +221,7 @@ The model took too long to respond shouldHideSuggestions: false, conversationMode: "code", subConversationTaskId: null, + planContent: null, }, false, "resetConversationState", @@ -317,6 +235,9 @@ The model took too long to respond setSubConversationTaskId: (subConversationTaskId) => set({ subConversationTaskId }, false, "setSubConversationTaskId"), + + setPlanContent: (planContent) => + set({ planContent }, false, "setPlanContent"), }), { name: "conversation-store", diff --git a/frontend/src/types/v1/core/base/base.ts b/frontend/src/types/v1/core/base/base.ts index 30c39cec89..7704f1105d 100644 --- a/frontend/src/types/v1/core/base/base.ts +++ b/frontend/src/types/v1/core/base/base.ts @@ -6,7 +6,8 @@ type EventType = | "Terminal" | "FileEditor" | "StrReplaceEditor" - | "TaskTracker"; + | "TaskTracker" + | "PlanningFileEditor"; type ActionOnlyType = | "BrowserNavigate" diff --git a/frontend/src/types/v1/core/base/observation.ts b/frontend/src/types/v1/core/base/observation.ts index 0625c9d336..062d7ddf6e 100644 --- a/frontend/src/types/v1/core/base/observation.ts +++ b/frontend/src/types/v1/core/base/observation.ts @@ -190,6 +190,38 @@ export interface TaskTrackerObservation task_list: TaskItem[]; } +export interface PlanningFileEditorObservation + extends ObservationBase<"PlanningFileEditorObservation"> { + /** + * Content returned from the tool as a list of TextContent/ImageContent objects. + */ + content: Array; + /** + * Whether the call resulted in an error. + */ + is_error: boolean; + /** + * The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`. + */ + command: "view" | "create" | "str_replace" | "insert" | "undo_edit"; + /** + * The file path that was edited. + */ + path: string | null; + /** + * Indicates if the file previously existed. If not, it was created. + */ + prev_exist: boolean; + /** + * The content of the file before the edit. + */ + old_content: string | null; + /** + * The content of the file after the edit. + */ + new_content: string | null; +} + export type Observation = | MCPToolObservation | FinishObservation @@ -199,4 +231,5 @@ export type Observation = | TerminalObservation | FileEditorObservation | StrReplaceEditorObservation - | TaskTrackerObservation; + | TaskTrackerObservation + | PlanningFileEditorObservation; diff --git a/frontend/src/types/v1/type-guards.ts b/frontend/src/types/v1/type-guards.ts index e73a5e0e74..306661e854 100644 --- a/frontend/src/types/v1/type-guards.ts +++ b/frontend/src/types/v1/type-guards.ts @@ -5,6 +5,7 @@ import { ExecuteBashAction, TerminalAction, ExecuteBashObservation, + PlanningFileEditorObservation, TerminalObservation, } from "./core"; import { AgentErrorEvent } from "./core/events/observation-event"; @@ -116,6 +117,15 @@ export const isExecuteBashObservationEvent = ( (event.observation.kind === "ExecuteBashObservation" || event.observation.kind === "TerminalObservation"); +/** + * Type guard function to check if an observation event is a PlanningFileEditorObservation + */ +export const isPlanningFileEditorObservationEvent = ( + event: OpenHandsEvent, +): event is ObservationEvent => + isObservationEvent(event) && + event.observation.kind === "PlanningFileEditorObservation"; + /** * Type guard function to check if an event is a system prompt event */ From 96f13b15e702805805f4230ba0fca8f585ead5ac Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Mon, 1 Dec 2025 17:58:03 +0400 Subject: [PATCH 06/18] Revert "chore(backend): Add better PostHog tracking" (#11749) --- enterprise/server/routes/auth.py | 7 - enterprise/server/routes/billing.py | 15 - openhands/controller/agent_controller.py | 30 -- openhands/server/routes/git.py | 10 - openhands/utils/posthog_tracker.py | 270 ------------- .../test_agent_controller_posthog.py | 243 ------------ tests/unit/utils/test_posthog_tracker.py | 356 ------------------ 7 files changed, 931 deletions(-) delete mode 100644 openhands/utils/posthog_tracker.py delete mode 100644 tests/unit/controller/test_agent_controller_posthog.py delete mode 100644 tests/unit/utils/test_posthog_tracker.py diff --git a/enterprise/server/routes/auth.py b/enterprise/server/routes/auth.py index c9e92d54f7..ba7aadb883 100644 --- a/enterprise/server/routes/auth.py +++ b/enterprise/server/routes/auth.py @@ -31,7 +31,6 @@ from openhands.server.services.conversation_service import create_provider_token from openhands.server.shared import config from openhands.server.user_auth import get_access_token from openhands.server.user_auth.user_auth import get_user_auth -from openhands.utils.posthog_tracker import track_user_signup_completed with warnings.catch_warnings(): warnings.simplefilter('ignore') @@ -370,12 +369,6 @@ async def accept_tos(request: Request): logger.info(f'User {user_id} accepted TOS') - # Track user signup completion in PostHog - track_user_signup_completed( - user_id=user_id, - signup_timestamp=user_settings.accepted_tos.isoformat(), - ) - response = JSONResponse( status_code=status.HTTP_200_OK, content={'redirect_url': redirect_url} ) diff --git a/enterprise/server/routes/billing.py b/enterprise/server/routes/billing.py index f1c0c5376b..5a8b59e2d7 100644 --- a/enterprise/server/routes/billing.py +++ b/enterprise/server/routes/billing.py @@ -28,7 +28,6 @@ from storage.subscription_access import SubscriptionAccess from openhands.server.user_auth import get_user_id from openhands.utils.http_session import httpx_verify_option -from openhands.utils.posthog_tracker import track_credits_purchased stripe.api_key = STRIPE_API_KEY billing_router = APIRouter(prefix='/api/billing') @@ -458,20 +457,6 @@ async def success_callback(session_id: str, request: Request): ) session.commit() - # Track credits purchased in PostHog - try: - track_credits_purchased( - user_id=billing_session.user_id, - amount_usd=amount_subtotal / 100, # Convert cents to dollars - credits_added=add_credits, - stripe_session_id=session_id, - ) - except Exception as e: - logger.warning( - f'Failed to track credits purchase: {e}', - extra={'user_id': billing_session.user_id, 'error': str(e)}, - ) - return RedirectResponse( f'{request.base_url}settings/billing?checkout=success', status_code=302 ) diff --git a/openhands/controller/agent_controller.py b/openhands/controller/agent_controller.py index 3f2ad87674..958e5cb348 100644 --- a/openhands/controller/agent_controller.py +++ b/openhands/controller/agent_controller.py @@ -42,10 +42,6 @@ from openhands.core.exceptions import ( from openhands.core.logger import LOG_ALL_EVENTS from openhands.core.logger import openhands_logger as logger from openhands.core.schema import AgentState -from openhands.utils.posthog_tracker import ( - track_agent_task_completed, - track_credit_limit_reached, -) from openhands.events import ( EventSource, EventStream, @@ -713,20 +709,6 @@ class AgentController: EventSource.ENVIRONMENT, ) - # Track agent task completion in PostHog - if new_state == AgentState.FINISHED: - try: - # Get app_mode from environment, default to 'oss' - app_mode = os.environ.get('APP_MODE', 'oss') - track_agent_task_completed( - conversation_id=self.id, - user_id=self.user_id, - app_mode=app_mode, - ) - except Exception as e: - # Don't let tracking errors interrupt the agent - self.log('warning', f'Failed to track agent completion: {e}') - # Save state whenever agent state changes to ensure we don't lose state # in case of crashes or unexpected circumstances self.save_state() @@ -905,18 +887,6 @@ class AgentController: self.state_tracker.run_control_flags() except Exception as e: logger.warning('Control flag limits hit') - # Track credit limit reached if it's a budget exception - if 'budget' in str(e).lower() and self.state.budget_flag: - try: - track_credit_limit_reached( - conversation_id=self.id, - user_id=self.user_id, - current_budget=self.state.budget_flag.current_value, - max_budget=self.state.budget_flag.max_value, - ) - except Exception as track_error: - # Don't let tracking errors interrupt the agent - self.log('warning', f'Failed to track credit limit: {track_error}') await self._react_to_exception(e) return diff --git a/openhands/server/routes/git.py b/openhands/server/routes/git.py index 1401bb0dcd..a6807a2e2a 100644 --- a/openhands/server/routes/git.py +++ b/openhands/server/routes/git.py @@ -26,13 +26,11 @@ from openhands.microagent.types import ( ) from openhands.server.dependencies import get_dependencies from openhands.server.shared import server_config -from openhands.server.types import AppMode from openhands.server.user_auth import ( get_access_token, get_provider_tokens, get_user_id, ) -from openhands.utils.posthog_tracker import alias_user_identities app = APIRouter(prefix='/api/user', dependencies=get_dependencies()) @@ -119,14 +117,6 @@ async def get_user( try: user: User = await client.get_user() - - # Alias git provider login with Keycloak user ID in PostHog (SaaS mode only) - if user_id and user.login and server_config.app_mode == AppMode.SAAS: - alias_user_identities( - keycloak_user_id=user_id, - git_login=user.login, - ) - return user except UnknownException as e: diff --git a/openhands/utils/posthog_tracker.py b/openhands/utils/posthog_tracker.py deleted file mode 100644 index c0859eddc7..0000000000 --- a/openhands/utils/posthog_tracker.py +++ /dev/null @@ -1,270 +0,0 @@ -"""PostHog tracking utilities for OpenHands events.""" - -import os - -from openhands.core.logger import openhands_logger as logger - -# Lazy import posthog to avoid import errors in environments where it's not installed -posthog = None - - -def _init_posthog(): - """Initialize PostHog client lazily.""" - global posthog - if posthog is None: - try: - import posthog as ph - - posthog = ph - posthog.api_key = os.environ.get( - 'POSTHOG_CLIENT_KEY', 'phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA' - ) - posthog.host = os.environ.get('POSTHOG_HOST', 'https://us.i.posthog.com') - except ImportError: - logger.warning( - 'PostHog not installed. Analytics tracking will be disabled.' - ) - posthog = None - - -def track_agent_task_completed( - conversation_id: str, - user_id: str | None = None, - app_mode: str | None = None, -) -> None: - """Track when an agent completes a task. - - Args: - conversation_id: The ID of the conversation/session - user_id: The ID of the user (optional, may be None for unauthenticated users) - app_mode: The application mode (saas/oss), optional - """ - _init_posthog() - - if posthog is None: - return - - # Use conversation_id as distinct_id if user_id is not available - # This ensures we can track completions even for anonymous users - distinct_id = user_id if user_id else f'conversation_{conversation_id}' - - try: - posthog.capture( - distinct_id=distinct_id, - event='agent_task_completed', - properties={ - 'conversation_id': conversation_id, - 'user_id': user_id, - 'app_mode': app_mode or 'unknown', - }, - ) - logger.debug( - 'posthog_track', - extra={ - 'event': 'agent_task_completed', - 'conversation_id': conversation_id, - 'user_id': user_id, - }, - ) - except Exception as e: - logger.warning( - f'Failed to track agent_task_completed to PostHog: {e}', - extra={ - 'conversation_id': conversation_id, - 'error': str(e), - }, - ) - - -def track_user_signup_completed( - user_id: str, - signup_timestamp: str, -) -> None: - """Track when a user completes signup by accepting TOS. - - Args: - user_id: The ID of the user (Keycloak user ID) - signup_timestamp: ISO format timestamp of when TOS was accepted - """ - _init_posthog() - - if posthog is None: - return - - try: - posthog.capture( - distinct_id=user_id, - event='user_signup_completed', - properties={ - 'user_id': user_id, - 'signup_timestamp': signup_timestamp, - }, - ) - logger.debug( - 'posthog_track', - extra={ - 'event': 'user_signup_completed', - 'user_id': user_id, - }, - ) - except Exception as e: - logger.warning( - f'Failed to track user_signup_completed to PostHog: {e}', - extra={ - 'user_id': user_id, - 'error': str(e), - }, - ) - - -def track_credit_limit_reached( - conversation_id: str, - user_id: str | None = None, - current_budget: float = 0.0, - max_budget: float = 0.0, -) -> None: - """Track when a user reaches their credit limit during a conversation. - - Args: - conversation_id: The ID of the conversation/session - user_id: The ID of the user (optional, may be None for unauthenticated users) - current_budget: The current budget spent - max_budget: The maximum budget allowed - """ - _init_posthog() - - if posthog is None: - return - - distinct_id = user_id if user_id else f'conversation_{conversation_id}' - - try: - posthog.capture( - distinct_id=distinct_id, - event='credit_limit_reached', - properties={ - 'conversation_id': conversation_id, - 'user_id': user_id, - 'current_budget': current_budget, - 'max_budget': max_budget, - }, - ) - logger.debug( - 'posthog_track', - extra={ - 'event': 'credit_limit_reached', - 'conversation_id': conversation_id, - 'user_id': user_id, - 'current_budget': current_budget, - 'max_budget': max_budget, - }, - ) - except Exception as e: - logger.warning( - f'Failed to track credit_limit_reached to PostHog: {e}', - extra={ - 'conversation_id': conversation_id, - 'error': str(e), - }, - ) - - -def track_credits_purchased( - user_id: str, - amount_usd: float, - credits_added: float, - stripe_session_id: str, -) -> None: - """Track when a user successfully purchases credits. - - Args: - user_id: The ID of the user (Keycloak user ID) - amount_usd: The amount paid in USD (cents converted to dollars) - credits_added: The number of credits added to the user's account - stripe_session_id: The Stripe checkout session ID - """ - _init_posthog() - - if posthog is None: - return - - try: - posthog.capture( - distinct_id=user_id, - event='credits_purchased', - properties={ - 'user_id': user_id, - 'amount_usd': amount_usd, - 'credits_added': credits_added, - 'stripe_session_id': stripe_session_id, - }, - ) - logger.debug( - 'posthog_track', - extra={ - 'event': 'credits_purchased', - 'user_id': user_id, - 'amount_usd': amount_usd, - 'credits_added': credits_added, - }, - ) - except Exception as e: - logger.warning( - f'Failed to track credits_purchased to PostHog: {e}', - extra={ - 'user_id': user_id, - 'error': str(e), - }, - ) - - -def alias_user_identities( - keycloak_user_id: str, - git_login: str, -) -> None: - """Alias a user's Keycloak ID with their git provider login for unified tracking. - - This allows PostHog to link events tracked from the frontend (using git provider login) - with events tracked from the backend (using Keycloak user ID). - - PostHog Python alias syntax: alias(previous_id, distinct_id) - - previous_id: The old/previous distinct ID that will be merged - - distinct_id: The new/canonical distinct ID to merge into - - For our use case: - - Git provider login is the previous_id (first used in frontend, before backend auth) - - Keycloak user ID is the distinct_id (canonical backend ID) - - Result: All events with git login will be merged into Keycloak user ID - - Args: - keycloak_user_id: The Keycloak user ID (canonical distinct_id) - git_login: The git provider username (GitHub/GitLab/Bitbucket) to merge - - Reference: - https://github.com/PostHog/posthog-python/blob/master/posthog/client.py - """ - _init_posthog() - - if posthog is None: - return - - try: - # Merge git provider login into Keycloak user ID - # posthog.alias(previous_id, distinct_id) - official Python SDK signature - posthog.alias(git_login, keycloak_user_id) - logger.debug( - 'posthog_alias', - extra={ - 'previous_id': git_login, - 'distinct_id': keycloak_user_id, - }, - ) - except Exception as e: - logger.warning( - f'Failed to alias user identities in PostHog: {e}', - extra={ - 'keycloak_user_id': keycloak_user_id, - 'git_login': git_login, - 'error': str(e), - }, - ) diff --git a/tests/unit/controller/test_agent_controller_posthog.py b/tests/unit/controller/test_agent_controller_posthog.py deleted file mode 100644 index 630c18e3aa..0000000000 --- a/tests/unit/controller/test_agent_controller_posthog.py +++ /dev/null @@ -1,243 +0,0 @@ -"""Integration tests for PostHog tracking in AgentController.""" - -import asyncio -from unittest.mock import MagicMock, patch - -import pytest - -from openhands.controller.agent import Agent -from openhands.controller.agent_controller import AgentController -from openhands.core.config import OpenHandsConfig -from openhands.core.config.agent_config import AgentConfig -from openhands.core.config.llm_config import LLMConfig -from openhands.core.schema import AgentState -from openhands.events import EventSource, EventStream -from openhands.events.action.message import SystemMessageAction -from openhands.llm.llm_registry import LLMRegistry -from openhands.server.services.conversation_stats import ConversationStats -from openhands.storage.memory import InMemoryFileStore - - -@pytest.fixture(scope='function') -def event_loop(): - """Create event loop for async tests.""" - loop = asyncio.get_event_loop_policy().new_event_loop() - yield loop - loop.close() - - -@pytest.fixture -def mock_agent_with_stats(): - """Create a mock agent with properly connected LLM registry and conversation stats.""" - import uuid - - # Create LLM registry - config = OpenHandsConfig() - llm_registry = LLMRegistry(config=config) - - # Create conversation stats - file_store = InMemoryFileStore({}) - conversation_id = f'test-conversation-{uuid.uuid4()}' - conversation_stats = ConversationStats( - file_store=file_store, conversation_id=conversation_id, user_id='test-user' - ) - - # Connect registry to stats - llm_registry.subscribe(conversation_stats.register_llm) - - # Create mock agent - agent = MagicMock(spec=Agent) - agent_config = MagicMock(spec=AgentConfig) - llm_config = LLMConfig( - model='gpt-4o', - api_key='test_key', - num_retries=2, - retry_min_wait=1, - retry_max_wait=2, - ) - agent_config.disabled_microagents = [] - agent_config.enable_mcp = True - agent_config.enable_stuck_detection = True - llm_registry.service_to_llm.clear() - mock_llm = llm_registry.get_llm('agent_llm', llm_config) - agent.llm = mock_llm - agent.name = 'test-agent' - agent.sandbox_plugins = [] - agent.config = agent_config - agent.llm_registry = llm_registry - agent.prompt_manager = MagicMock() - - # Add a proper system message mock - system_message = SystemMessageAction( - content='Test system message', tools=['test_tool'] - ) - system_message._source = EventSource.AGENT - system_message._id = -1 # Set invalid ID to avoid the ID check - agent.get_system_message.return_value = system_message - - return agent, conversation_stats, llm_registry - - -@pytest.fixture -def mock_event_stream(): - """Create a mock event stream.""" - mock = MagicMock( - spec=EventStream, - event_stream=EventStream(sid='test', file_store=InMemoryFileStore({})), - ) - mock.get_latest_event_id.return_value = 0 - return mock - - -@pytest.mark.asyncio -async def test_agent_finish_triggers_posthog_tracking( - mock_agent_with_stats, mock_event_stream -): - """Test that setting agent state to FINISHED triggers PostHog tracking.""" - mock_agent, conversation_stats, llm_registry = mock_agent_with_stats - - controller = AgentController( - agent=mock_agent, - event_stream=mock_event_stream, - conversation_stats=conversation_stats, - iteration_delta=10, - sid='test-conversation-123', - user_id='test-user-456', - confirmation_mode=False, - headless_mode=True, - ) - - with ( - patch('openhands.utils.posthog_tracker.posthog') as mock_posthog, - patch('os.environ.get') as mock_env_get, - ): - # Setup mocks - mock_posthog.capture = MagicMock() - mock_env_get.return_value = 'saas' - - # Initialize posthog in the tracker module - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - - # Set agent state to FINISHED - await controller.set_agent_state_to(AgentState.FINISHED) - - # Verify PostHog tracking was called - mock_posthog.capture.assert_called_once() - call_args = mock_posthog.capture.call_args - - assert call_args[1]['distinct_id'] == 'test-user-456' - assert call_args[1]['event'] == 'agent_task_completed' - assert 'conversation_id' in call_args[1]['properties'] - assert call_args[1]['properties']['user_id'] == 'test-user-456' - assert call_args[1]['properties']['app_mode'] == 'saas' - - await controller.close() - - -@pytest.mark.asyncio -async def test_agent_finish_without_user_id(mock_agent_with_stats, mock_event_stream): - """Test tracking when user_id is None.""" - mock_agent, conversation_stats, llm_registry = mock_agent_with_stats - - controller = AgentController( - agent=mock_agent, - event_stream=mock_event_stream, - conversation_stats=conversation_stats, - iteration_delta=10, - sid='test-conversation-789', - user_id=None, - confirmation_mode=False, - headless_mode=True, - ) - - with ( - patch('openhands.utils.posthog_tracker.posthog') as mock_posthog, - patch('os.environ.get') as mock_env_get, - ): - mock_posthog.capture = MagicMock() - mock_env_get.return_value = 'oss' - - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - - await controller.set_agent_state_to(AgentState.FINISHED) - - mock_posthog.capture.assert_called_once() - call_args = mock_posthog.capture.call_args - - # When user_id is None, distinct_id should be conversation_id - assert call_args[1]['distinct_id'].startswith('conversation_') - assert call_args[1]['properties']['user_id'] is None - - await controller.close() - - -@pytest.mark.asyncio -async def test_other_states_dont_trigger_tracking( - mock_agent_with_stats, mock_event_stream -): - """Test that non-FINISHED states don't trigger tracking.""" - mock_agent, conversation_stats, llm_registry = mock_agent_with_stats - - controller = AgentController( - agent=mock_agent, - event_stream=mock_event_stream, - conversation_stats=conversation_stats, - iteration_delta=10, - sid='test-conversation-999', - confirmation_mode=False, - headless_mode=True, - ) - - with patch('openhands.utils.posthog_tracker.posthog') as mock_posthog: - mock_posthog.capture = MagicMock() - - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - - # Try different states - await controller.set_agent_state_to(AgentState.RUNNING) - await controller.set_agent_state_to(AgentState.PAUSED) - await controller.set_agent_state_to(AgentState.STOPPED) - - # PostHog should not be called for non-FINISHED states - mock_posthog.capture.assert_not_called() - - await controller.close() - - -@pytest.mark.asyncio -async def test_tracking_error_doesnt_break_agent( - mock_agent_with_stats, mock_event_stream -): - """Test that tracking errors don't interrupt agent operation.""" - mock_agent, conversation_stats, llm_registry = mock_agent_with_stats - - controller = AgentController( - agent=mock_agent, - event_stream=mock_event_stream, - conversation_stats=conversation_stats, - iteration_delta=10, - sid='test-conversation-error', - confirmation_mode=False, - headless_mode=True, - ) - - with patch('openhands.utils.posthog_tracker.posthog') as mock_posthog: - mock_posthog.capture = MagicMock(side_effect=Exception('PostHog error')) - - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - - # Should not raise an exception - await controller.set_agent_state_to(AgentState.FINISHED) - - # Agent state should still be FINISHED despite tracking error - assert controller.state.agent_state == AgentState.FINISHED - - await controller.close() diff --git a/tests/unit/utils/test_posthog_tracker.py b/tests/unit/utils/test_posthog_tracker.py deleted file mode 100644 index cec0eff0cc..0000000000 --- a/tests/unit/utils/test_posthog_tracker.py +++ /dev/null @@ -1,356 +0,0 @@ -"""Unit tests for PostHog tracking utilities.""" - -from unittest.mock import MagicMock, patch - -import pytest - -from openhands.utils.posthog_tracker import ( - alias_user_identities, - track_agent_task_completed, - track_credit_limit_reached, - track_credits_purchased, - track_user_signup_completed, -) - - -@pytest.fixture -def mock_posthog(): - """Mock the posthog module.""" - with patch('openhands.utils.posthog_tracker.posthog') as mock_ph: - mock_ph.capture = MagicMock() - yield mock_ph - - -def test_track_agent_task_completed_with_user_id(mock_posthog): - """Test tracking agent task completion with user ID.""" - # Initialize posthog manually in the test - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - - track_agent_task_completed( - conversation_id='test-conversation-123', - user_id='user-456', - app_mode='saas', - ) - - mock_posthog.capture.assert_called_once_with( - distinct_id='user-456', - event='agent_task_completed', - properties={ - 'conversation_id': 'test-conversation-123', - 'user_id': 'user-456', - 'app_mode': 'saas', - }, - ) - - -def test_track_agent_task_completed_without_user_id(mock_posthog): - """Test tracking agent task completion without user ID (anonymous).""" - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - - track_agent_task_completed( - conversation_id='test-conversation-789', - user_id=None, - app_mode='oss', - ) - - mock_posthog.capture.assert_called_once_with( - distinct_id='conversation_test-conversation-789', - event='agent_task_completed', - properties={ - 'conversation_id': 'test-conversation-789', - 'user_id': None, - 'app_mode': 'oss', - }, - ) - - -def test_track_agent_task_completed_default_app_mode(mock_posthog): - """Test tracking with default app_mode.""" - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - - track_agent_task_completed( - conversation_id='test-conversation-999', - user_id='user-111', - ) - - mock_posthog.capture.assert_called_once_with( - distinct_id='user-111', - event='agent_task_completed', - properties={ - 'conversation_id': 'test-conversation-999', - 'user_id': 'user-111', - 'app_mode': 'unknown', - }, - ) - - -def test_track_agent_task_completed_handles_errors(mock_posthog): - """Test that tracking errors are handled gracefully.""" - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - mock_posthog.capture.side_effect = Exception('PostHog API error') - - # Should not raise an exception - track_agent_task_completed( - conversation_id='test-conversation-error', - user_id='user-error', - app_mode='saas', - ) - - -def test_track_agent_task_completed_when_posthog_not_installed(): - """Test tracking when posthog is not installed.""" - import openhands.utils.posthog_tracker as tracker - - # Simulate posthog not being installed - tracker.posthog = None - - # Should not raise an exception - track_agent_task_completed( - conversation_id='test-conversation-no-ph', - user_id='user-no-ph', - app_mode='oss', - ) - - -def test_track_user_signup_completed(mock_posthog): - """Test tracking user signup completion.""" - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - - track_user_signup_completed( - user_id='test-user-123', - signup_timestamp='2025-01-15T10:30:00Z', - ) - - mock_posthog.capture.assert_called_once_with( - distinct_id='test-user-123', - event='user_signup_completed', - properties={ - 'user_id': 'test-user-123', - 'signup_timestamp': '2025-01-15T10:30:00Z', - }, - ) - - -def test_track_user_signup_completed_handles_errors(mock_posthog): - """Test that user signup tracking errors are handled gracefully.""" - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - mock_posthog.capture.side_effect = Exception('PostHog API error') - - # Should not raise an exception - track_user_signup_completed( - user_id='test-user-error', - signup_timestamp='2025-01-15T12:00:00Z', - ) - - -def test_track_user_signup_completed_when_posthog_not_installed(): - """Test user signup tracking when posthog is not installed.""" - import openhands.utils.posthog_tracker as tracker - - # Simulate posthog not being installed - tracker.posthog = None - - # Should not raise an exception - track_user_signup_completed( - user_id='test-user-no-ph', - signup_timestamp='2025-01-15T13:00:00Z', - ) - - -def test_track_credit_limit_reached_with_user_id(mock_posthog): - """Test tracking credit limit reached with user ID.""" - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - - track_credit_limit_reached( - conversation_id='test-conversation-456', - user_id='user-789', - current_budget=10.50, - max_budget=10.00, - ) - - mock_posthog.capture.assert_called_once_with( - distinct_id='user-789', - event='credit_limit_reached', - properties={ - 'conversation_id': 'test-conversation-456', - 'user_id': 'user-789', - 'current_budget': 10.50, - 'max_budget': 10.00, - }, - ) - - -def test_track_credit_limit_reached_without_user_id(mock_posthog): - """Test tracking credit limit reached without user ID (anonymous).""" - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - - track_credit_limit_reached( - conversation_id='test-conversation-999', - user_id=None, - current_budget=5.25, - max_budget=5.00, - ) - - mock_posthog.capture.assert_called_once_with( - distinct_id='conversation_test-conversation-999', - event='credit_limit_reached', - properties={ - 'conversation_id': 'test-conversation-999', - 'user_id': None, - 'current_budget': 5.25, - 'max_budget': 5.00, - }, - ) - - -def test_track_credit_limit_reached_handles_errors(mock_posthog): - """Test that credit limit tracking errors are handled gracefully.""" - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - mock_posthog.capture.side_effect = Exception('PostHog API error') - - # Should not raise an exception - track_credit_limit_reached( - conversation_id='test-conversation-error', - user_id='user-error', - current_budget=15.00, - max_budget=10.00, - ) - - -def test_track_credit_limit_reached_when_posthog_not_installed(): - """Test credit limit tracking when posthog is not installed.""" - import openhands.utils.posthog_tracker as tracker - - # Simulate posthog not being installed - tracker.posthog = None - - # Should not raise an exception - track_credit_limit_reached( - conversation_id='test-conversation-no-ph', - user_id='user-no-ph', - current_budget=8.00, - max_budget=5.00, - ) - - -def test_track_credits_purchased(mock_posthog): - """Test tracking credits purchased.""" - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - - track_credits_purchased( - user_id='test-user-999', - amount_usd=50.00, - credits_added=50.00, - stripe_session_id='cs_test_abc123', - ) - - mock_posthog.capture.assert_called_once_with( - distinct_id='test-user-999', - event='credits_purchased', - properties={ - 'user_id': 'test-user-999', - 'amount_usd': 50.00, - 'credits_added': 50.00, - 'stripe_session_id': 'cs_test_abc123', - }, - ) - - -def test_track_credits_purchased_handles_errors(mock_posthog): - """Test that credits purchased tracking errors are handled gracefully.""" - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - mock_posthog.capture.side_effect = Exception('PostHog API error') - - # Should not raise an exception - track_credits_purchased( - user_id='test-user-error', - amount_usd=100.00, - credits_added=100.00, - stripe_session_id='cs_test_error', - ) - - -def test_track_credits_purchased_when_posthog_not_installed(): - """Test credits purchased tracking when posthog is not installed.""" - import openhands.utils.posthog_tracker as tracker - - # Simulate posthog not being installed - tracker.posthog = None - - # Should not raise an exception - track_credits_purchased( - user_id='test-user-no-ph', - amount_usd=25.00, - credits_added=25.00, - stripe_session_id='cs_test_no_ph', - ) - - -def test_alias_user_identities(mock_posthog): - """Test aliasing user identities. - - Verifies that posthog.alias(previous_id, distinct_id) is called correctly - where git_login is the previous_id and keycloak_user_id is the distinct_id. - """ - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - mock_posthog.alias = MagicMock() - - alias_user_identities( - keycloak_user_id='keycloak-123', - git_login='git-user', - ) - - # Verify: posthog.alias(previous_id='git-user', distinct_id='keycloak-123') - mock_posthog.alias.assert_called_once_with('git-user', 'keycloak-123') - - -def test_alias_user_identities_handles_errors(mock_posthog): - """Test that aliasing errors are handled gracefully.""" - import openhands.utils.posthog_tracker as tracker - - tracker.posthog = mock_posthog - mock_posthog.alias = MagicMock(side_effect=Exception('PostHog API error')) - - # Should not raise an exception - alias_user_identities( - keycloak_user_id='keycloak-error', - git_login='git-error', - ) - - -def test_alias_user_identities_when_posthog_not_installed(): - """Test aliasing when posthog is not installed.""" - import openhands.utils.posthog_tracker as tracker - - # Simulate posthog not being installed - tracker.posthog = None - - # Should not raise an exception - alias_user_identities( - keycloak_user_id='keycloak-no-ph', - git_login='git-no-ph', - ) From 6c821ab73e6ae077f3cf3f94aa3e6cad9ac78b6b Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Mon, 1 Dec 2025 21:29:18 +0700 Subject: [PATCH 07/18] fix(frontend): the content of the FinishObservation event is not being rendered correctly. (#11846) --- .../components/features/chat/chat-message.tsx | 21 +---- .../features/chat/error-message.tsx | 19 +---- .../features/chat/expandable-message.tsx | 19 +---- .../features/chat/generic-event-message.tsx | 17 +--- .../features/markdown/markdown-renderer.tsx | 80 +++++++++++++++++++ ...ent-management-view-microagent-content.tsx | 21 +---- .../get-observation-content.ts | 17 +++- .../generic-event-message-wrapper.tsx | 17 ++-- frontend/src/routes/planner-tab.tsx | 35 +------- .../src/types/v1/core/base/observation.ts | 8 +- 10 files changed, 128 insertions(+), 126 deletions(-) create mode 100644 frontend/src/components/features/markdown/markdown-renderer.tsx diff --git a/frontend/src/components/features/chat/chat-message.tsx b/frontend/src/components/features/chat/chat-message.tsx index a3dc934475..6f2f388682 100644 --- a/frontend/src/components/features/chat/chat-message.tsx +++ b/frontend/src/components/features/chat/chat-message.tsx @@ -1,15 +1,9 @@ import React from "react"; -import Markdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import remarkBreaks from "remark-breaks"; -import { code } from "../markdown/code"; import { cn } from "#/utils/utils"; -import { ul, ol } from "../markdown/list"; import { CopyToClipboardButton } from "#/components/shared/buttons/copy-to-clipboard-button"; -import { anchor } from "../markdown/anchor"; import { OpenHandsSourceType } from "#/types/core/base"; -import { paragraph } from "../markdown/paragraph"; import { TooltipButton } from "#/components/shared/buttons/tooltip-button"; +import { MarkdownRenderer } from "../markdown/markdown-renderer"; interface ChatMessageProps { type: OpenHandsSourceType; @@ -116,18 +110,7 @@ export function ChatMessage({ wordBreak: "break-word", }} > - - {message} - + {message}
{children} diff --git a/frontend/src/components/features/chat/error-message.tsx b/frontend/src/components/features/chat/error-message.tsx index 8de367a9a2..da40b3786e 100644 --- a/frontend/src/components/features/chat/error-message.tsx +++ b/frontend/src/components/features/chat/error-message.tsx @@ -1,13 +1,9 @@ import React from "react"; -import Markdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import remarkBreaks from "remark-breaks"; import { useTranslation } from "react-i18next"; -import { code } from "../markdown/code"; -import { ol, ul } from "../markdown/list"; import ArrowDown from "#/icons/angle-down-solid.svg?react"; import ArrowUp from "#/icons/angle-up-solid.svg?react"; import i18n from "#/i18n"; +import { MarkdownRenderer } from "../markdown/markdown-renderer"; interface ErrorMessageProps { errorId?: string; @@ -40,18 +36,7 @@ export function ErrorMessage({ errorId, defaultMessage }: ErrorMessageProps) { - {showDetails && ( - - {defaultMessage} - - )} + {showDetails && {defaultMessage}} ); } diff --git a/frontend/src/components/features/chat/expandable-message.tsx b/frontend/src/components/features/chat/expandable-message.tsx index 918eafd6b8..cf9ae550d2 100644 --- a/frontend/src/components/features/chat/expandable-message.tsx +++ b/frontend/src/components/features/chat/expandable-message.tsx @@ -1,9 +1,6 @@ import { useEffect, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; -import Markdown from "react-markdown"; import { Link } from "react-router"; -import remarkGfm from "remark-gfm"; -import remarkBreaks from "remark-breaks"; import { useConfig } from "#/hooks/query/use-config"; import { I18nKey } from "#/i18n/declaration"; import ArrowDown from "#/icons/angle-down-solid.svg?react"; @@ -13,9 +10,7 @@ import XCircle from "#/icons/x-circle-solid.svg?react"; import { OpenHandsAction } from "#/types/core/actions"; import { OpenHandsObservation } from "#/types/core/observations"; import { cn } from "#/utils/utils"; -import { code } from "../markdown/code"; -import { ol, ul } from "../markdown/list"; -import { paragraph } from "../markdown/paragraph"; +import { MarkdownRenderer } from "../markdown/markdown-renderer"; import { MonoComponent } from "./mono-component"; import { PathComponent } from "./path-component"; @@ -192,17 +187,7 @@ export function ExpandableMessage({ {showDetails && (
- - {details} - + {details}
)} diff --git a/frontend/src/components/features/chat/generic-event-message.tsx b/frontend/src/components/features/chat/generic-event-message.tsx index e5124b69fe..ff2ab633b1 100644 --- a/frontend/src/components/features/chat/generic-event-message.tsx +++ b/frontend/src/components/features/chat/generic-event-message.tsx @@ -1,13 +1,9 @@ import React from "react"; -import Markdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import remarkBreaks from "remark-breaks"; -import { code } from "../markdown/code"; -import { ol, ul } from "../markdown/list"; import ArrowDown from "#/icons/angle-down-solid.svg?react"; import ArrowUp from "#/icons/angle-up-solid.svg?react"; import { SuccessIndicator } from "./success-indicator"; import { ObservationResultStatus } from "./event-content-helpers/get-observation-result"; +import { MarkdownRenderer } from "../markdown/markdown-renderer"; interface GenericEventMessageProps { title: React.ReactNode; @@ -49,16 +45,7 @@ export function GenericEventMessage({ {showDetails && (typeof details === "string" ? ( - - {details} - + {details} ) : ( details ))} diff --git a/frontend/src/components/features/markdown/markdown-renderer.tsx b/frontend/src/components/features/markdown/markdown-renderer.tsx new file mode 100644 index 0000000000..0cb55498d6 --- /dev/null +++ b/frontend/src/components/features/markdown/markdown-renderer.tsx @@ -0,0 +1,80 @@ +import Markdown, { Components } from "react-markdown"; +import remarkGfm from "remark-gfm"; +import remarkBreaks from "remark-breaks"; +import { code } from "./code"; +import { ul, ol } from "./list"; +import { paragraph } from "./paragraph"; +import { anchor } from "./anchor"; +import { h1, h2, h3, h4, h5, h6 } from "./headings"; + +interface MarkdownRendererProps { + /** + * The markdown content to render. Can be passed as children (string) or content prop. + */ + children?: string; + content?: string; + /** + * Additional or override components for markdown elements. + * Default components (code, ul, ol) are always included unless overridden. + */ + components?: Partial; + /** + * Whether to include standard components (anchor, paragraph). + * Defaults to false. + */ + includeStandard?: boolean; + /** + * Whether to include heading components (h1-h6). + * Defaults to false. + */ + includeHeadings?: boolean; +} + +/** + * A reusable Markdown renderer component that provides consistent + * markdown rendering across the application. + * + * By default, includes: + * - code, ul, ol components + * - remarkGfm and remarkBreaks plugins + * + * Can be extended with: + * - includeStandard: adds anchor and paragraph components + * - includeHeadings: adds h1-h6 heading components + * - components prop: allows custom overrides or additional components + */ +export function MarkdownRenderer({ + children, + content, + components: customComponents, + includeStandard = false, + includeHeadings = false, +}: MarkdownRendererProps) { + // Build the components object with defaults and optional additions + const components: Components = { + code, + ul, + ol, + ...(includeStandard && { + a: anchor, + p: paragraph, + }), + ...(includeHeadings && { + h1, + h2, + h3, + h4, + h5, + h6, + }), + ...customComponents, // Custom components override defaults + }; + + const markdownContent = content ?? children ?? ""; + + return ( + + {markdownContent} + + ); +} diff --git a/frontend/src/components/features/microagent-management/microagent-management-view-microagent-content.tsx b/frontend/src/components/features/microagent-management/microagent-management-view-microagent-content.tsx index dc5b5fecaa..2994946731 100644 --- a/frontend/src/components/features/microagent-management/microagent-management-view-microagent-content.tsx +++ b/frontend/src/components/features/microagent-management/microagent-management-view-microagent-content.tsx @@ -1,16 +1,10 @@ import { useTranslation } from "react-i18next"; import { Spinner } from "@heroui/react"; -import Markdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import remarkBreaks from "remark-breaks"; -import { code } from "../markdown/code"; -import { ul, ol } from "../markdown/list"; -import { paragraph } from "../markdown/paragraph"; -import { anchor } from "../markdown/anchor"; import { useMicroagentManagementStore } from "#/state/microagent-management-store"; import { useRepositoryMicroagentContent } from "#/hooks/query/use-repository-microagent-content"; import { I18nKey } from "#/i18n/declaration"; import { extractRepositoryInfo } from "#/utils/utils"; +import { MarkdownRenderer } from "../markdown/markdown-renderer"; export function MicroagentManagementViewMicroagentContent() { const { t } = useTranslation(); @@ -49,18 +43,9 @@ export function MicroagentManagementViewMicroagentContent() { )} {microagentData && !isLoading && !error && ( - + {microagentData.content} - + )} ); diff --git a/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts b/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts index a227e99cfc..35d01b0655 100644 --- a/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts +++ b/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts @@ -184,7 +184,22 @@ const getFinishObservationContent = ( event: ObservationEvent, ): string => { const { observation } = event; - return observation.message || ""; + + // Extract text content from the observation + const textContent = observation.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); + + let content = ""; + + if (observation.is_error) { + content += `**Error:**\n${textContent}`; + } else { + content += textContent; + } + + return content; }; export const getObservationContent = (event: ObservationEvent): string => { diff --git a/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx b/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx index 94f35aec66..95c2652549 100644 --- a/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx +++ b/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx @@ -9,6 +9,7 @@ import { } from "../event-content-helpers/create-skill-ready-event"; import { V1ConfirmationButtons } from "#/components/shared/buttons/v1-confirmation-buttons"; import { ObservationResultStatus } from "../../../features/chat/event-content-helpers/get-observation-result"; +import { MarkdownRenderer } from "#/components/features/markdown/markdown-renderer"; interface GenericEventMessageWrapperProps { event: OpenHandsEvent | SkillReadyEvent; @@ -23,11 +24,17 @@ export function GenericEventMessageWrapper({ // SkillReadyEvent is not an observation event, so skip the observation checks if (!isSkillReadyEvent(event)) { - if ( - isObservationEvent(event) && - event.observation.kind === "TaskTrackerObservation" - ) { - return
{details}
; + if (isObservationEvent(event)) { + if (event.observation.kind === "TaskTrackerObservation") { + return
{details}
; + } + if (event.observation.kind === "FinishObservation") { + return ( + + {details as string} + + ); + } } } diff --git a/frontend/src/routes/planner-tab.tsx b/frontend/src/routes/planner-tab.tsx index 4fb46f9939..989e85596e 100644 --- a/frontend/src/routes/planner-tab.tsx +++ b/frontend/src/routes/planner-tab.tsx @@ -1,22 +1,8 @@ import { useTranslation } from "react-i18next"; -import Markdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import remarkBreaks from "remark-breaks"; import { I18nKey } from "#/i18n/declaration"; import LessonPlanIcon from "#/icons/lesson-plan.svg?react"; import { useConversationStore } from "#/state/conversation-store"; -import { code } from "#/components/features/markdown/code"; -import { ul, ol } from "#/components/features/markdown/list"; -import { paragraph } from "#/components/features/markdown/paragraph"; -import { anchor } from "#/components/features/markdown/anchor"; -import { - h1, - h2, - h3, - h4, - h5, - h6, -} from "#/components/features/markdown/headings"; +import { MarkdownRenderer } from "#/components/features/markdown/markdown-renderer"; function PlannerTab() { const { t } = useTranslation(); @@ -26,24 +12,9 @@ function PlannerTab() { if (planContent !== null && planContent !== undefined) { return (
- + {planContent} - +
); } diff --git a/frontend/src/types/v1/core/base/observation.ts b/frontend/src/types/v1/core/base/observation.ts index 062d7ddf6e..7e510888f0 100644 --- a/frontend/src/types/v1/core/base/observation.ts +++ b/frontend/src/types/v1/core/base/observation.ts @@ -25,9 +25,13 @@ export interface MCPToolObservation export interface FinishObservation extends ObservationBase<"FinishObservation"> { /** - * Final message sent to the user + * Content returned from the finish action as a list of TextContent/ImageContent objects. */ - message: string; + content: Array; + /** + * Whether the finish action resulted in an error + */ + is_error: boolean; } export interface ThinkObservation extends ObservationBase<"ThinkObservation"> { From 6c2862ae082100990c2533f07461a7649199936f Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Mon, 1 Dec 2025 11:08:00 -0500 Subject: [PATCH 08/18] feat(frontend): add handler for 'create a plan' button click (#11806) --- .../features/chat/change-agent-button.tsx | 55 ++------------ frontend/src/hooks/use-handle-plan-click.ts | 71 +++++++++++++++++++ frontend/src/routes/planner-tab.tsx | 6 +- 3 files changed, 81 insertions(+), 51 deletions(-) create mode 100644 frontend/src/hooks/use-handle-plan-click.ts diff --git a/frontend/src/components/features/chat/change-agent-button.tsx b/frontend/src/components/features/chat/change-agent-button.tsx index 6257587963..68a0bd2699 100644 --- a/frontend/src/components/features/chat/change-agent-button.tsx +++ b/frontend/src/components/features/chat/change-agent-button.tsx @@ -12,20 +12,15 @@ import { USE_PLANNING_AGENT } from "#/utils/feature-flags"; import { useAgentState } from "#/hooks/use-agent-state"; import { AgentState } from "#/types/agent-state"; import { useActiveConversation } from "#/hooks/query/use-active-conversation"; -import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; -import { displaySuccessToast } from "#/utils/custom-toast-handlers"; import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status"; import { useSubConversationTaskPolling } from "#/hooks/query/use-sub-conversation-task-polling"; +import { useHandlePlanClick } from "#/hooks/use-handle-plan-click"; export function ChangeAgentButton() { const [contextMenuOpen, setContextMenuOpen] = useState(false); - const { - conversationMode, - setConversationMode, - setSubConversationTaskId, - subConversationTaskId, - } = useConversationStore(); + const { conversationMode, setConversationMode, subConversationTaskId } = + useConversationStore(); const webSocketStatus = useUnifiedWebSocketStatus(); @@ -40,8 +35,6 @@ export function ChangeAgentButton() { const isAgentRunning = curAgentState === AgentState.RUNNING; const { data: conversation } = useActiveConversation(); - const { mutate: createConversation, isPending: isCreatingConversation } = - useCreateConversation(); // Poll sub-conversation task and invalidate parent conversation when ready useSubConversationTaskPolling( @@ -49,6 +42,9 @@ export function ChangeAgentButton() { conversation?.conversation_id || null, ); + // Get handlePlanClick and isCreatingConversation from custom hook + const { handlePlanClick, isCreatingConversation } = useHandlePlanClick(); + // Close context menu when agent starts running useEffect(() => { if ((isAgentRunning || !isWebSocketConnected) && contextMenuOpen) { @@ -56,45 +52,6 @@ export function ChangeAgentButton() { } }, [isAgentRunning, contextMenuOpen, isWebSocketConnected]); - const handlePlanClick = ( - event: React.MouseEvent | KeyboardEvent, - ) => { - event.preventDefault(); - event.stopPropagation(); - - // Set conversation mode to "plan" immediately - setConversationMode("plan"); - - // Check if sub_conversation_ids is not empty - if ( - (conversation?.sub_conversation_ids && - conversation.sub_conversation_ids.length > 0) || - !conversation?.conversation_id - ) { - // Do nothing if both conditions are true - return; - } - - // Create a new sub-conversation if we have a current conversation ID - createConversation( - { - parentConversationId: conversation.conversation_id, - agentType: "plan", - }, - { - onSuccess: (data) => { - displaySuccessToast( - t(I18nKey.PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED), - ); - // Track the task ID to poll for sub-conversation creation - if (data.v1_task_id) { - setSubConversationTaskId(data.v1_task_id); - } - }, - }, - ); - }; - const isButtonDisabled = isAgentRunning || isCreatingConversation || diff --git a/frontend/src/hooks/use-handle-plan-click.ts b/frontend/src/hooks/use-handle-plan-click.ts new file mode 100644 index 0000000000..9734bab8da --- /dev/null +++ b/frontend/src/hooks/use-handle-plan-click.ts @@ -0,0 +1,71 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { useConversationStore } from "#/state/conversation-store"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; +import { displaySuccessToast } from "#/utils/custom-toast-handlers"; + +/** + * Custom hook that encapsulates the logic for handling plan creation. + * Returns a function that can be called to create a plan conversation and + * the pending state of the conversation creation. + * + * @returns An object containing handlePlanClick function and isCreatingConversation boolean + */ +export const useHandlePlanClick = () => { + const { t } = useTranslation(); + const { setConversationMode, setSubConversationTaskId } = + useConversationStore(); + const { data: conversation } = useActiveConversation(); + const { mutate: createConversation, isPending: isCreatingConversation } = + useCreateConversation(); + + const handlePlanClick = useCallback( + (event?: React.MouseEvent | KeyboardEvent) => { + event?.preventDefault(); + event?.stopPropagation(); + + // Set conversation mode to "plan" immediately + setConversationMode("plan"); + + // Check if sub_conversation_ids is not empty + if ( + (conversation?.sub_conversation_ids && + conversation.sub_conversation_ids.length > 0) || + !conversation?.conversation_id + ) { + // Do nothing if both conditions are true + return; + } + + // Create a new sub-conversation if we have a current conversation ID + createConversation( + { + parentConversationId: conversation.conversation_id, + agentType: "plan", + }, + { + onSuccess: (data) => { + displaySuccessToast( + t(I18nKey.PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED), + ); + // Track the task ID to poll for sub-conversation creation + if (data.v1_task_id) { + setSubConversationTaskId(data.v1_task_id); + } + }, + }, + ); + }, + [ + conversation, + createConversation, + setConversationMode, + setSubConversationTaskId, + t, + ], + ); + + return { handlePlanClick, isCreatingConversation }; +}; diff --git a/frontend/src/routes/planner-tab.tsx b/frontend/src/routes/planner-tab.tsx index 989e85596e..fee7c9efc8 100644 --- a/frontend/src/routes/planner-tab.tsx +++ b/frontend/src/routes/planner-tab.tsx @@ -3,11 +3,13 @@ import { I18nKey } from "#/i18n/declaration"; import LessonPlanIcon from "#/icons/lesson-plan.svg?react"; import { useConversationStore } from "#/state/conversation-store"; import { MarkdownRenderer } from "#/components/features/markdown/markdown-renderer"; +import { useHandlePlanClick } from "#/hooks/use-handle-plan-click"; function PlannerTab() { const { t } = useTranslation(); - const { planContent, setConversationMode } = useConversationStore(); + const { planContent } = useConversationStore(); + const { handlePlanClick } = useHandlePlanClick(); if (planContent !== null && planContent !== undefined) { return ( @@ -27,7 +29,7 @@ function PlannerTab() {