Compare commits

..

22 Commits

Author SHA1 Message Date
enyst e754edb798 Merge main into openhands/llm-extra-headers: resolve conflict in LLM to support both completion_kwargs and env-driven extra_headers (env only fills if not provided).\n\nCo-authored-by: openhands <openhands@all-hands.dev> 2025-11-16 01:07:36 +00:00
Hiep Le d6fab190bf feat(frontend): integrate with the API to create a sub-conversation for the planning agent (#11730) 2025-11-15 09:43:21 +07:00
Hiep Le 833aae1833 feat(backend): exclude sub-conversations when searching for conversations (#11733) 2025-11-15 00:21:27 +07:00
Tim O'Farrell 2841e35f24 Do not get live status updates when they are not required (#11727)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-14 07:55:43 -07:00
Tim O'Farrell 8115d82f96 feat: add created_at__gte filter to search_app_conversation_start_tasks (#11740)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-14 07:08:34 -07:00
Hiep Le 7263657937 feat(backend): include sub-conversation ids when fetching conversation details (#11734) 2025-11-14 11:34:30 +07:00
jpelletier1 34fcc50350 Update to include llms.txt (#11737) 2025-11-13 21:42:50 +00:00
jpelletier1 24a9758434 Adding an Agent Builder Skill/Microagent (#11720) 2025-11-13 16:10:00 -05:00
Tim O'Farrell f24d2a61e6 Fix for wrong column name (#11735) 2025-11-13 17:55:23 +00:00
Hiep Le e3d0380c2e feat(frontend): add support for the shift + tab shortcut to cycle through conversation modes (#11731) 2025-11-14 00:10:25 +07:00
Hiep Le 8c3f93ddc4 feat(frontend): set descriptive text for all options in the change agent button (#11732) 2025-11-14 00:10:15 +07:00
Hiep Le bc86796a67 feat(backend): enable sub-conversation creation using a different agent (#11715) 2025-11-13 23:06:44 +07:00
sp.wack d5b2d2ebc5 fix(frontend): Sync client PostHog opt-in status with server setting (#11728) 2025-11-13 13:22:05 +00:00
Rohit Malhotra b605c96796 Hotfix: rm max condenser size override (#11713) 2025-11-12 20:13:16 -05:00
sp.wack 8192184d3e chore(backend): Add better PostHog tracking (#11655)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-12 16:47:21 +00:00
Hiep Le 8e75f25108 feat(frontend): implement new task tracker interface (#11692) 2025-11-12 22:59:45 +07:00
Neha Prasad 73fe865c7e feat: queue chat messages during runtime connection (#11687)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-11-12 13:20:09 +00:00
Rohit Malhotra 95a44f4248 CLI release 1.0.7 (#11712) 2025-11-11 16:46:30 -05:00
Engel Nyst c4ff3d6483 Merge branch 'main' into openhands/llm-extra-headers 2025-10-15 08:23:21 +02:00
enyst bace2ef8a1 refactor(llm): remove partial_kwargs and factor extra headers parsing into helper
- Merge extra headers directly into kwargs across sync, async, streaming paths
- Add LLM._get_extra_headers() to de-duplicate env parsing
- Replace inline parsing in AsyncLLM and StreamingLLM
- Run pre-commit and unit tests

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-14 07:34:13 +00:00
enyst 4e7846928b refactor(llm): simplify extra_headers plumbing\n\n- Remove partial_kwargs/_partial_kwargs indirection\n- Pass extra_headers directly via kwargs to partial() calls\n\nCo-authored-by: openhands <openhands@all-hands.dev> 2025-10-14 07:02:25 +00:00
enyst 62dbb20846 feat(llm): support extra headers via env var LLM_EXTRA_HEADERS for LiteLLM completion and acompletion (async/streaming); add unit tests\n\nCo-authored-by: openhands <openhands@all-hands.dev> 2025-10-14 03:58:42 +00:00
61 changed files with 3042 additions and 346 deletions
+1 -22
View File
@@ -5,12 +5,8 @@ from experiments.constants import (
EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT,
)
from experiments.experiment_versions import (
handle_condenser_max_step_experiment,
handle_system_prompt_experiment,
)
from experiments.experiment_versions._004_condenser_max_step_experiment import (
handle_condenser_max_step_experiment__v1,
)
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.core.logger import openhands_logger as logger
@@ -31,10 +27,6 @@ class SaaSExperimentManager(ExperimentManager):
)
return agent
agent = handle_condenser_max_step_experiment__v1(
user_id, conversation_id, agent
)
if EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT:
agent = agent.model_copy(
update={'system_prompt_filename': 'system_prompt_long_horizon.j2'}
@@ -60,20 +52,7 @@ class SaaSExperimentManager(ExperimentManager):
"""
logger.debug(
'experiment_manager:run_conversation_variant_test:started',
extra={'user_id': user_id},
)
# Skip all experiment processing if the experiment manager is disabled
if not ENABLE_EXPERIMENT_MANAGER:
logger.info(
'experiment_manager:run_conversation_variant_test:skipped',
extra={'reason': 'experiment_manager_disabled'},
)
return conversation_settings
# Apply conversation-scoped experiments
conversation_settings = handle_condenser_max_step_experiment(
user_id, conversation_id, conversation_settings
extra={'user_id': user_id, 'conversation_id': conversation_id},
)
return conversation_settings
@@ -0,0 +1,41 @@
"""add parent_conversation_id to conversation_metadata
Revision ID: 081
Revises: 080
Create Date: 2025-11-06 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '081'
down_revision: Union[str, None] = '080'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.add_column(
'conversation_metadata',
sa.Column('parent_conversation_id', sa.String(), nullable=True),
)
op.create_index(
op.f('ix_conversation_metadata_parent_conversation_id'),
'conversation_metadata',
['parent_conversation_id'],
unique=False,
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_index(
op.f('ix_conversation_metadata_parent_conversation_id'),
table_name='conversation_metadata',
)
op.drop_column('conversation_metadata', 'parent_conversation_id')
+7
View File
@@ -30,6 +30,7 @@ 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')
@@ -362,6 +363,12 @@ 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}
)
+15
View File
@@ -28,6 +28,7 @@ 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')
@@ -457,6 +458,20 @@ 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
)
@@ -60,6 +60,7 @@ class SaasConversationStore(ConversationStore):
kwargs.pop('reasoning_tokens', None)
kwargs.pop('context_window', None)
kwargs.pop('per_turn_token', None)
kwargs.pop('parent_conversation_id', None)
return ConversationMetadata(**kwargs)
@@ -92,11 +92,8 @@ def test_unknown_variant_returns_original_agent_without_changes(monkeypatch):
assert getattr(result, 'condenser', None) is None
@patch('experiments.experiment_manager.handle_condenser_max_step_experiment__v1')
@patch('experiments.experiment_manager.ENABLE_EXPERIMENT_MANAGER', False)
def test_run_agent_variant_tests_v1_noop_when_manager_disabled(
mock_handle_condenser,
):
def test_run_agent_variant_tests_v1_noop_when_manager_disabled():
"""If ENABLE_EXPERIMENT_MANAGER is False, the method returns the exact same agent and does not call the handler."""
agent = make_agent()
conv_id = uuid4()
@@ -109,8 +106,6 @@ def test_run_agent_variant_tests_v1_noop_when_manager_disabled(
# Same object returned (no copy)
assert result is agent
# Handler should not have been called
mock_handle_condenser.assert_not_called()
@patch('experiments.experiment_manager.ENABLE_EXPERIMENT_MANAGER', True)
@@ -131,7 +126,3 @@ def test_run_agent_variant_tests_v1_calls_handler_and_sets_system_prompt(monkeyp
# Should be a different instance than the original (copied after handler runs)
assert result is not agent
assert result.system_prompt_filename == 'system_prompt_long_horizon.j2'
# The condenser returned by the handler must be preserved after the system-prompt override copy
assert isinstance(result.condenser, LLMSummarizingCondenser)
assert result.condenser.max_size == 80
@@ -8,10 +8,11 @@ vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"TASK_TRACKING_OBSERVATION$TASK_LIST": "Task List",
"TASK_TRACKING_OBSERVATION$TASK_ID": "ID",
"TASK_TRACKING_OBSERVATION$TASK_NOTES": "Notes",
"TASK_TRACKING_OBSERVATION$RESULT": "Result",
TASK_TRACKING_OBSERVATION$TASK_LIST: "Task List",
TASK_TRACKING_OBSERVATION$TASK_ID: "ID",
TASK_TRACKING_OBSERVATION$TASK_NOTES: "Notes",
TASK_TRACKING_OBSERVATION$RESULT: "Result",
COMMON$TASKS: "Tasks",
};
return translations[key] || key;
},
@@ -61,19 +62,26 @@ describe("TaskTrackingObservationContent", () => {
it("renders task list when command is 'plan' and tasks exist", () => {
render(<TaskTrackingObservationContent event={mockEvent} />);
expect(screen.getByText("Task List (3 items)")).toBeInTheDocument();
expect(screen.getByText("Tasks")).toBeInTheDocument();
expect(screen.getByText("Implement feature A")).toBeInTheDocument();
expect(screen.getByText("Fix bug B")).toBeInTheDocument();
expect(screen.getByText("Deploy to production")).toBeInTheDocument();
});
it("displays correct status icons and badges", () => {
render(<TaskTrackingObservationContent event={mockEvent} />);
const { container } = render(
<TaskTrackingObservationContent event={mockEvent} />,
);
// Check for status text (the icons are emojis)
expect(screen.getByText("todo")).toBeInTheDocument();
expect(screen.getByText("in progress")).toBeInTheDocument();
expect(screen.getByText("done")).toBeInTheDocument();
// Status is represented by icons, not text. Verify task items are rendered with their titles
// which indicates the status icons are present (status affects icon rendering)
expect(screen.getByText("Implement feature A")).toBeInTheDocument();
expect(screen.getByText("Fix bug B")).toBeInTheDocument();
expect(screen.getByText("Deploy to production")).toBeInTheDocument();
// Verify task items are present (they contain the status icons)
const taskItems = container.querySelectorAll('[data-name="item"]');
expect(taskItems).toHaveLength(3);
});
it("displays task IDs and notes", () => {
@@ -84,14 +92,9 @@ describe("TaskTrackingObservationContent", () => {
expect(screen.getByText("ID: task-3")).toBeInTheDocument();
expect(screen.getByText("Notes: This is a test task")).toBeInTheDocument();
expect(screen.getByText("Notes: Completed successfully")).toBeInTheDocument();
});
it("renders result section when content exists", () => {
render(<TaskTrackingObservationContent event={mockEvent} />);
expect(screen.getByText("Result")).toBeInTheDocument();
expect(screen.getByText("Task tracking operation completed successfully")).toBeInTheDocument();
expect(
screen.getByText("Notes: Completed successfully"),
).toBeInTheDocument();
});
it("does not render task list when command is not 'plan'", () => {
@@ -105,7 +108,7 @@ describe("TaskTrackingObservationContent", () => {
render(<TaskTrackingObservationContent event={eventWithoutPlan} />);
expect(screen.queryByText("Task List")).not.toBeInTheDocument();
expect(screen.queryByText("Tasks")).not.toBeInTheDocument();
});
it("does not render task list when task list is empty", () => {
@@ -119,17 +122,6 @@ describe("TaskTrackingObservationContent", () => {
render(<TaskTrackingObservationContent event={eventWithEmptyTasks} />);
expect(screen.queryByText("Task List")).not.toBeInTheDocument();
});
it("does not render result section when content is empty", () => {
const eventWithoutContent = {
...mockEvent,
content: "",
};
render(<TaskTrackingObservationContent event={eventWithoutContent} />);
expect(screen.queryByText("Result")).not.toBeInTheDocument();
expect(screen.queryByText("Tasks")).not.toBeInTheDocument();
});
});
@@ -60,6 +60,8 @@ class V1ConversationService {
selected_branch?: string,
conversationInstructions?: string,
trigger?: ConversationTrigger,
parent_conversation_id?: string,
agent_type?: "default" | "plan",
): Promise<V1AppConversationStartTask> {
const body: V1AppConversationStartRequest = {
selected_repository: selectedRepository,
@@ -67,6 +69,8 @@ class V1ConversationService {
selected_branch,
title: conversationInstructions,
trigger,
parent_conversation_id: parent_conversation_id || null,
agent_type,
};
// Add initial message if provided
@@ -111,11 +115,11 @@ class V1ConversationService {
* Search for start tasks (ongoing tasks that haven't completed yet)
* Use this to find tasks that were started but the user navigated away
*
* Note: Backend only supports filtering by limit. To filter by repository/trigger,
* Note: Backend supports filtering by limit and created_at__gte. To filter by repository/trigger,
* filter the results client-side after fetching.
*
* @param limit Maximum number of tasks to return (max 100)
* @returns Array of start tasks
* @returns Array of start tasks from the last 20 minutes
*/
static async searchStartTasks(
limit: number = 100,
@@ -123,6 +127,10 @@ class V1ConversationService {
const params = new URLSearchParams();
params.append("limit", limit.toString());
// Only get tasks from the last 20 minutes
const twentyMinutesAgo = new Date(Date.now() - 20 * 60 * 1000);
params.append("created_at__gte", twentyMinutesAgo.toISOString());
const { data } = await openHands.get<V1AppConversationStartTaskPage>(
`/api/v1/app-conversations/start-tasks/search?${params.toString()}`,
);
@@ -30,6 +30,8 @@ export interface V1AppConversationStartRequest {
title?: string | null;
trigger?: ConversationTrigger | null;
pr_number?: number[];
parent_conversation_id?: string | null;
agent_type?: "default" | "plan";
}
export type V1AppConversationStartTaskStatus =
+1
View File
@@ -77,6 +77,7 @@ export interface Conversation {
session_api_key: string | null;
pr_number?: number[] | null;
conversation_version?: "V0" | "V1";
sub_conversation_ids?: string[];
}
export interface ResultSet<T> {
@@ -1,4 +1,4 @@
import React, { useMemo, useEffect } from "react";
import React, { useMemo, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Typography } from "#/ui/typography";
import { I18nKey } from "#/i18n/declaration";
@@ -11,10 +11,12 @@ import { cn } from "#/utils/utils";
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";
export function ChangeAgentButton() {
const { t } = useTranslation();
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
const [contextMenuOpen, setContextMenuOpen] = useState<boolean>(false);
const conversationMode = useConversationStore(
(state) => state.conversationMode,
@@ -28,8 +30,14 @@ export function ChangeAgentButton() {
const { curAgentState } = useAgentState();
const { t } = useTranslation();
const isAgentRunning = curAgentState === AgentState.RUNNING;
const { data: conversation } = useActiveConversation();
const { mutate: createConversation, isPending: isCreatingConversation } =
useCreateConversation();
// Close context menu when agent starts running
useEffect(() => {
if (isAgentRunning && contextMenuOpen) {
@@ -37,6 +45,75 @@ export function ChangeAgentButton() {
}
}, [isAgentRunning, contextMenuOpen]);
const handlePlanClick = (
event: React.MouseEvent<HTMLButtonElement> | 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: () =>
displaySuccessToast(
t(I18nKey.PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED),
),
},
);
};
// Handle Shift + Tab keyboard shortcut to cycle through modes
useEffect(() => {
if (!shouldUsePlanningAgent || isAgentRunning) {
return undefined;
}
const handleKeyDown = (event: KeyboardEvent) => {
// Check for Shift + Tab combination
if (event.shiftKey && event.key === "Tab") {
// Prevent default tab navigation behavior
event.preventDefault();
event.stopPropagation();
// Cycle between modes: code -> plan -> code
const nextMode = conversationMode === "code" ? "plan" : "code";
if (nextMode === "plan") {
handlePlanClick(event);
} else {
setConversationMode(nextMode);
}
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [
shouldUsePlanningAgent,
isAgentRunning,
conversationMode,
setConversationMode,
]);
const handleButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
@@ -49,12 +126,6 @@ export function ChangeAgentButton() {
setConversationMode("code");
};
const handlePlanClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setConversationMode("plan");
};
const isExecutionAgent = conversationMode === "code";
const buttonLabel = useMemo(() => {
@@ -71,6 +142,8 @@ export function ChangeAgentButton() {
return <LessonPlanIcon width={18} height={18} color="#ffffff" />;
}, [isExecutionAgent]);
const isButtonDisabled = isAgentRunning || isCreatingConversation;
if (!shouldUsePlanningAgent) {
return null;
}
@@ -80,11 +153,11 @@ export function ChangeAgentButton() {
<button
type="button"
onClick={handleButtonClick}
disabled={isAgentRunning}
disabled={isButtonDisabled}
className={cn(
"flex items-center border border-[#4B505F] rounded-[100px] transition-opacity",
!isExecutionAgent && "border-[#597FF4] bg-[#4A67BD]",
isAgentRunning
isButtonDisabled
? "opacity-50 cursor-not-allowed"
: "cursor-pointer hover:opacity-80",
)}
@@ -5,19 +5,14 @@ import CodeTagIcon from "#/icons/code-tag.svg?react";
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
import { ContextMenu } from "#/ui/context-menu";
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
import { ContextMenuIconText } from "../context-menu/context-menu-icon-text";
import { ContextMenuIconTextWithDescription } from "../context-menu/context-menu-icon-text-with-description";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { cn } from "#/utils/utils";
import { CONTEXT_MENU_ICON_TEXT_CLASSNAME } from "#/utils/constants";
const contextMenuListItemClassName = cn(
"cursor-pointer p-0 h-auto hover:bg-transparent",
CONTEXT_MENU_ICON_TEXT_CLASSNAME,
);
const contextMenuIconTextClassName =
"gap-2 p-2 hover:bg-[#5C5D62] rounded h-[30px]";
interface ChangeAgentContextMenuProps {
onClose: () => void;
onCodeClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
@@ -52,17 +47,17 @@ export function ChangeAgentContextMenu({
testId="change-agent-context-menu"
position="top"
alignment="left"
className="min-h-fit min-w-[195px] mb-2"
className="min-h-fit mb-2 min-w-[195px] max-w-[195px] gap-0"
>
<ContextMenuListItem
testId="code-option"
onClick={handleCodeClick}
className={contextMenuListItemClassName}
>
<ContextMenuIconText
<ContextMenuIconTextWithDescription
icon={CodeTagIcon}
text={t(I18nKey.COMMON$CODE)}
className={contextMenuIconTextClassName}
title={t(I18nKey.COMMON$CODE)}
description={t(I18nKey.COMMON$CODE_AGENT_DESCRIPTION)}
/>
</ContextMenuListItem>
<ContextMenuListItem
@@ -70,10 +65,10 @@ export function ChangeAgentContextMenu({
onClick={handlePlanClick}
className={contextMenuListItemClassName}
>
<ContextMenuIconText
<ContextMenuIconTextWithDescription
icon={LessonPlanIcon}
text={t(I18nKey.COMMON$PLAN)}
className={contextMenuIconTextClassName}
title={t(I18nKey.COMMON$PLAN)}
description={t(I18nKey.COMMON$PLAN_AGENT_DESCRIPTION)}
/>
</ContextMenuListItem>
</ContextMenu>
@@ -1,11 +1,7 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { OpenHandsObservation } from "#/types/core/observations";
import { isTaskTrackingObservation } from "#/types/core/guards";
import { GenericEventMessage } from "../generic-event-message";
import { TaskTrackingObservationContent } from "../task-tracking-observation-content";
import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
import { getObservationResult } from "../event-content-helpers/get-observation-result";
interface TaskTrackingEventMessageProps {
event: OpenHandsObservation;
@@ -16,34 +12,13 @@ export function TaskTrackingEventMessage({
event,
shouldShowConfirmationButtons,
}: TaskTrackingEventMessageProps) {
const { t } = useTranslation();
if (!isTaskTrackingObservation(event)) {
return null;
}
const { command } = event.extras;
let title: React.ReactNode;
let initiallyExpanded = false;
// Determine title and expansion state based on command
if (command === "plan") {
title = t("OBSERVATION_MESSAGE$TASK_TRACKING_PLAN");
initiallyExpanded = true;
} else {
// command === "view"
title = t("OBSERVATION_MESSAGE$TASK_TRACKING_VIEW");
initiallyExpanded = false;
}
return (
<div>
<GenericEventMessage
title={title}
details={<TaskTrackingObservationContent event={event} />}
success={getObservationResult(event)}
initiallyExpanded={initiallyExpanded}
/>
<TaskTrackingObservationContent event={event} />
{shouldShowConfirmationButtons && <ConfirmationButtons />}
</div>
);
@@ -1,6 +1,5 @@
import { TaskTrackingObservation } from "#/types/core/observations";
import { TaskListSection } from "./task-tracking/task-list-section";
import { ResultSection } from "./task-tracking/result-section";
interface TaskTrackingObservationContentProps {
event: TaskTrackingObservation;
@@ -16,11 +15,6 @@ export function TaskTrackingObservationContent({
<div className="flex flex-col gap-4">
{/* Task List section - only show for 'plan' command */}
{shouldShowTaskList && <TaskListSection taskList={taskList} />}
{/* Result message - only show if there's meaningful content */}
{event.content && event.content.trim() && (
<ResultSection content={event.content} />
)}
</div>
);
}
@@ -1,21 +0,0 @@
import { useTranslation } from "react-i18next";
import { Typography } from "#/ui/typography";
interface ResultSectionProps {
content: string;
}
export function ResultSection({ content }: ResultSectionProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Typography.H3>{t("TASK_TRACKING_OBSERVATION$RESULT")}</Typography.H3>
</div>
<div className="p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 shadow-inner">
<pre className="whitespace-pre-wrap text-sm">{content.trim()}</pre>
</div>
</div>
);
}
@@ -1,7 +1,11 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import CircleIcon from "#/icons/u-circle.svg?react";
import CheckCircleIcon from "#/icons/u-check-circle.svg?react";
import LoadingIcon from "#/icons/loading.svg?react";
import { I18nKey } from "#/i18n/declaration";
import { cn } from "#/utils/utils";
import { Typography } from "#/ui/typography";
import { StatusIcon } from "./status-icon";
import { StatusBadge } from "./status-badge";
interface TaskItemProps {
task: {
@@ -10,33 +14,47 @@ interface TaskItemProps {
status: "todo" | "in_progress" | "done";
notes?: string;
};
index: number;
}
export function TaskItem({ task, index }: TaskItemProps) {
export function TaskItem({ task }: TaskItemProps) {
const { t } = useTranslation();
const icon = useMemo(() => {
switch (task.status) {
case "todo":
return <CircleIcon className="w-4 h-4 text-[#ffffff]" />;
case "in_progress":
return <LoadingIcon className="w-4 h-4 text-[#ffffff]" />;
case "done":
return <CheckCircleIcon className="w-4 h-4 text-[#A3A3A3]" />;
default:
return <CircleIcon className="w-4 h-4 text-[#ffffff]" />;
}
}, [task.status]);
const isDoneStatus = task.status === "done";
return (
<div className="border-l-2 border-gray-600 pl-3">
<div className="flex items-start gap-2">
<StatusIcon status={task.status} />
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Typography.Text className="text-sm text-gray-400">
{index + 1}.
</Typography.Text>
<StatusBadge status={task.status} />
</div>
<h4 className="font-medium text-white mb-1">{task.title}</h4>
<Typography.Text className="text-xs text-gray-400 mb-1">
{t("TASK_TRACKING_OBSERVATION$TASK_ID")}: {task.id}
</Typography.Text>
{task.notes && (
<Typography.Text className="text-sm text-gray-300 italic">
{t("TASK_TRACKING_OBSERVATION$TASK_NOTES")}: {task.notes}
</Typography.Text>
<div
className="flex gap-[14px] items-center px-4 py-2 w-full"
data-name="item"
>
<div className="shrink-0">{icon}</div>
<div className="flex flex-col items-start justify-center leading-[20px] text-nowrap whitespace-pre font-normal">
<Typography.Text
className={cn(
"text-[12px] text-white",
isDoneStatus && "text-[#A3A3A3]",
)}
</div>
>
{task.title}
</Typography.Text>
<Typography.Text className="text-[10px] text-[#A3A3A3] font-normal">
{t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_ID)}: {task.id}
</Typography.Text>
<Typography.Text className="text-[10px] text-[#A3A3A3]">
{t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_NOTES)}: {task.notes}
</Typography.Text>
</div>
</div>
);
@@ -1,5 +1,7 @@
import { useTranslation } from "react-i18next";
import { TaskItem } from "./task-item";
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
import { I18nKey } from "#/i18n/declaration";
import { Typography } from "#/ui/typography";
interface TaskListSectionProps {
@@ -15,19 +17,20 @@ export function TaskListSection({ taskList }: TaskListSectionProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Typography.H3>
{t("TASK_TRACKING_OBSERVATION$TASK_LIST")} ({taskList.length}{" "}
{taskList.length === 1 ? "item" : "items"})
</Typography.H3>
<div className="flex flex-col overflow-clip bg-[#25272d] border border-[#525252] rounded-[12px] w-full">
{/* Header Tabs */}
<div className="flex gap-1 items-center border-b border-[#525252] h-[41px] px-2 shrink-0">
<LessonPlanIcon className="shrink-0 w-4.5 h-4.5 text-[#9299aa]" />
<Typography.Text className="text-[11px] text-nowrap text-white tracking-[0.11px] font-medium leading-[16px] whitespace-pre">
{t(I18nKey.COMMON$TASKS)}
</Typography.Text>
</div>
<div className="p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 max-h-[400px] shadow-inner">
<div className="space-y-3">
{taskList.map((task, index) => (
<TaskItem key={task.id} task={task} index={index} />
))}
</div>
{/* Task Items */}
<div>
{taskList.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</div>
</div>
);
@@ -0,0 +1,39 @@
import React from "react";
import { ContextMenuIconText } from "./context-menu-icon-text";
import { Typography } from "#/ui/typography";
import { cn } from "#/utils/utils";
interface ContextMenuIconTextWithDescriptionProps {
icon: React.ComponentType<{ className?: string }>;
title: string;
description: string;
className?: string;
iconClassName?: string;
}
export function ContextMenuIconTextWithDescription({
icon,
title,
description,
className,
iconClassName,
}: ContextMenuIconTextWithDescriptionProps) {
return (
<div
className={cn(
"flex flex-col gap-1 justify-center hover:bg-[#5C5D62] rounded p-2",
className,
)}
>
<ContextMenuIconText
icon={icon}
text={title}
className="px-0"
iconClassName={iconClassName}
/>
<Typography.Text className="text-[#A3A3A3] text-[10px] font-normal whitespace-pre-wrap break-words">
{description}
</Typography.Text>
</div>
);
}
@@ -1,10 +1,13 @@
import { Trans } from "react-i18next";
import { OpenHandsEvent } from "#/types/v1/core";
import React from "react";
import { OpenHandsEvent, ObservationEvent } from "#/types/v1/core";
import { isActionEvent, isObservationEvent } from "#/types/v1/type-guards";
import { MonoComponent } from "../../../features/chat/mono-component";
import { PathComponent } from "../../../features/chat/path-component";
import { getActionContent } from "./get-action-content";
import { getObservationContent } from "./get-observation-content";
import { TaskTrackingObservationContent } from "../task-tracking/task-tracking-observation-content";
import { TaskTrackerObservation } from "#/types/v1/core/base/observation";
import i18n from "#/i18n";
const trimText = (text: string, maxLength: number): string => {
@@ -158,14 +161,24 @@ const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => {
export const getEventContent = (event: OpenHandsEvent) => {
let title: React.ReactNode = "";
let details: string = "";
let details: string | React.ReactNode = "";
if (isActionEvent(event)) {
title = getActionEventTitle(event);
details = getActionContent(event);
} else if (isObservationEvent(event)) {
title = getObservationEventTitle(event);
details = getObservationContent(event);
// For TaskTrackerObservation, use React component instead of markdown
if (event.observation.kind === "TaskTrackerObservation") {
details = (
<TaskTrackingObservationContent
event={event as ObservationEvent<TaskTrackerObservation>}
/>
);
} else {
details = getObservationContent(event);
}
}
return {
@@ -27,13 +27,16 @@ export function FinishEventMessage({
microagentPRUrl,
actions,
}: FinishEventMessageProps) {
const eventContent = getEventContent(event);
// For FinishAction, details is always a string (getActionContent returns string)
const message =
typeof eventContent.details === "string"
? eventContent.details
: String(eventContent.details);
return (
<>
<ChatMessage
type="agent"
message={getEventContent(event).details}
actions={actions}
/>
<ChatMessage type="agent" message={message} actions={actions} />
<MicroagentStatusWrapper
microagentStatus={microagentStatus}
microagentConversationId={microagentConversationId}
@@ -16,6 +16,13 @@ export function GenericEventMessageWrapper({
}: GenericEventMessageWrapperProps) {
const { title, details } = getEventContent(event);
if (
isObservationEvent(event) &&
event.observation.kind === "TaskTrackerObservation"
) {
return <div>{details}</div>;
}
return (
<div>
<GenericEventMessage
@@ -0,0 +1,56 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TaskItem as TaskItemType } from "#/types/v1/core/base/common";
import CircleIcon from "#/icons/u-circle.svg?react";
import CheckCircleIcon from "#/icons/u-check-circle.svg?react";
import LoadingIcon from "#/icons/loading.svg?react";
import { cn } from "#/utils/utils";
import { Typography } from "#/ui/typography";
import { I18nKey } from "#/i18n/declaration";
interface TaskItemProps {
task: TaskItemType;
}
export function TaskItem({ task }: TaskItemProps) {
const { t } = useTranslation();
const icon = useMemo(() => {
switch (task.status) {
case "todo":
return <CircleIcon className="w-4 h-4 text-[#ffffff]" />;
case "in_progress":
return (
<LoadingIcon className="w-4 h-4 text-[#ffffff]" strokeWidth={0.5} />
);
case "done":
return <CheckCircleIcon className="w-4 h-4 text-[#A3A3A3]" />;
default:
return <CircleIcon className="w-4 h-4 text-[#ffffff]" />;
}
}, [task.status]);
const isDoneStatus = task.status === "done";
return (
<div
className="flex gap-[14px] items-center px-4 py-2 w-full"
data-name="item"
>
<div className="shrink-0">{icon}</div>
<div className="flex flex-col items-start justify-center leading-[20px] text-nowrap whitespace-pre font-normal">
<Typography.Text
className={cn(
"text-[12px] text-white",
isDoneStatus && "text-[#A3A3A3]",
)}
>
{task.title}
</Typography.Text>
<Typography.Text className="text-[10px] text-[#A3A3A3]">
{t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_NOTES)}: {task.notes}
</Typography.Text>
</div>
</div>
);
}
@@ -0,0 +1,33 @@
import { useTranslation } from "react-i18next";
import { TaskItem } from "./task-item";
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
import { TaskItem as TaskItemType } from "#/types/v1/core/base/common";
import { I18nKey } from "#/i18n/declaration";
import { Typography } from "#/ui/typography";
interface TaskListSectionProps {
taskList: TaskItemType[];
}
export function TaskListSection({ taskList }: TaskListSectionProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col overflow-clip bg-[#25272d] border border-[#525252] rounded-[12px] w-full">
{/* Header Tabs */}
<div className="flex gap-1 items-center border-b border-[#525252] h-[41px] px-2 shrink-0">
<LessonPlanIcon className="shrink-0 w-4.5 h-4.5 text-[#9299aa]" />
<Typography.Text className="text-[11px] text-nowrap text-white tracking-[0.11px] font-medium leading-[16px] whitespace-pre">
{t(I18nKey.COMMON$TASKS)}
</Typography.Text>
</div>
{/* Task Items */}
<div>
{taskList.map((task, index) => (
<TaskItem key={`task-${index}`} task={task} />
))}
</div>
</div>
);
}
@@ -0,0 +1,23 @@
import React from "react";
import { ObservationEvent } from "#/types/v1/core";
import { TaskTrackerObservation } from "#/types/v1/core/base/observation";
import { TaskListSection } from "./task-list-section";
interface TaskTrackingObservationContentProps {
event: ObservationEvent<TaskTrackerObservation>;
}
export function TaskTrackingObservationContent({
event,
}: TaskTrackingObservationContentProps): React.ReactNode {
const { observation } = event;
const { command, task_list: taskList } = observation;
const shouldShowTaskList = command === "plan" && taskList.length > 0;
return (
<div className="flex flex-col gap-4">
{/* Task List section - only show for 'plan' command */}
{shouldShowTaskList && <TaskListSection taskList={taskList} />}
</div>
);
}
+37 -4
View File
@@ -142,6 +142,7 @@ export function WsClientProvider({
const { addEvent, clearEvents } = useEventStore();
const queryClient = useQueryClient();
const sioRef = React.useRef<Socket | null>(null);
const pendingEventsRef = React.useRef<Record<string, unknown>[]>([]);
const [webSocketStatus, setWebSocketStatus] =
React.useState<V0_WebSocketStatus>("DISCONNECTED");
const lastEventRef = React.useRef<Record<string, unknown> | null>(null);
@@ -151,17 +152,37 @@ export function WsClientProvider({
const { data: conversation, refetch: refetchConversation } =
useActiveConversation();
function send(event: Record<string, unknown>) {
if (!sioRef.current) {
EventLogger.error("WebSocket is not connected.");
function flushPendingEvents(socket: Socket | null = sioRef.current) {
if (!socket || pendingEventsRef.current.length === 0) {
return;
}
sioRef.current.emit("oh_user_action", event);
pendingEventsRef.current.forEach((queuedEvent) => {
socket.emit("oh_user_action", queuedEvent);
});
pendingEventsRef.current = [];
}
function send(event: Record<string, unknown>) {
const socket = sioRef.current;
if (!socket) {
EventLogger.error("WebSocket is not connected, queuing message...");
pendingEventsRef.current.push(event);
return;
}
if (pendingEventsRef.current.length > 0) {
flushPendingEvents(socket);
}
socket.emit("oh_user_action", event);
}
function handleConnect() {
setWebSocketStatus("CONNECTED");
removeErrorMessage();
flushPendingEvents();
}
function handleMessage(event: Record<string, unknown>) {
@@ -292,6 +313,7 @@ export function WsClientProvider({
clearEvents();
setWebSocketStatus("CONNECTING");
pendingEventsRef.current = [];
}, [conversationId]);
React.useEffect(() => {
@@ -301,6 +323,12 @@ export function WsClientProvider({
// Clear error messages when conversation is intentionally stopped
if (conversation && conversation.status === "STOPPED") {
const existingSocket = sioRef.current;
if (existingSocket) {
existingSocket.disconnect();
}
sioRef.current = null;
pendingEventsRef.current = [];
removeErrorMessage();
setWebSocketStatus("DISCONNECTED");
return () => undefined; // conversation intentionally stopped
@@ -320,6 +348,10 @@ export function WsClientProvider({
!conversation.runtime_status ||
conversation.runtime_status === "STATUS$STOPPED"
) {
if (sioRef.current) {
sioRef.current.disconnect();
}
sioRef.current = null;
return () => undefined; // conversation not ready for WebSocket connection
}
@@ -368,6 +400,7 @@ export function WsClientProvider({
sio.on("disconnect", handleDisconnect);
sioRef.current = sio;
flushPendingEvents(sio);
return () => {
sio.off("connect", handleConnect);
@@ -17,6 +17,8 @@ interface CreateConversationVariables {
suggestedTask?: SuggestedTask;
conversationInstructions?: string;
createMicroagent?: CreateMicroagent;
parentConversationId?: string;
agentType?: "default" | "plan";
}
// Response type that combines both V1 and legacy responses
@@ -44,6 +46,8 @@ export const useCreateConversation = () => {
suggestedTask,
conversationInstructions,
createMicroagent,
parentConversationId,
agentType,
} = variables;
const useV1 = USE_V1_CONVERSATION_API() && !createMicroagent;
@@ -57,6 +61,8 @@ export const useCreateConversation = () => {
repository?.branch,
conversationInstructions,
undefined, // trigger - will be set by backend
parentConversationId,
agentType,
);
// Return a special task ID that the frontend will recognize
@@ -0,0 +1,41 @@
import React from "react";
import { usePostHog } from "posthog-js/react";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import { useSettings } from "./query/use-settings";
/**
* Hook to sync PostHog opt-in/out state with backend setting on mount.
* This ensures that if the backend setting changes (e.g., via API or different client),
* the PostHog instance reflects the current user preference.
*/
export const useSyncPostHogConsent = () => {
const posthog = usePostHog();
const { data: settings } = useSettings();
const hasSyncedRef = React.useRef(false);
React.useEffect(() => {
// Only run once when both PostHog and settings are available
if (!posthog || settings === undefined || hasSyncedRef.current) {
return;
}
const backendConsent = settings.USER_CONSENTS_TO_ANALYTICS;
// Only sync if there's a backend preference set
if (backendConsent !== null) {
const posthogHasOptedIn = posthog.has_opted_in_capturing();
const posthogHasOptedOut = posthog.has_opted_out_capturing();
// Check if PostHog state is out of sync with backend
const needsSync =
(backendConsent === true && !posthogHasOptedIn) ||
(backendConsent === false && !posthogHasOptedOut);
if (needsSync) {
handleCaptureConsent(posthog, backendConsent);
}
hasSyncedRef.current = true;
}
}, [posthog, settings]);
};
+4
View File
@@ -937,10 +937,14 @@ export enum I18nKey {
AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION = "AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION",
COMMON$MORE_OPTIONS = "COMMON$MORE_OPTIONS",
COMMON$CREATE_A_PLAN = "COMMON$CREATE_A_PLAN",
COMMON$TASKS = "COMMON$TASKS",
COMMON$PLAN_MD = "COMMON$PLAN_MD",
COMMON$READ_MORE = "COMMON$READ_MORE",
COMMON$BUILD = "COMMON$BUILD",
COMMON$ASK = "COMMON$ASK",
COMMON$PLAN = "COMMON$PLAN",
COMMON$LET_S_WORK_ON_A_PLAN = "COMMON$LET_S_WORK_ON_A_PLAN",
COMMON$CODE_AGENT_DESCRIPTION = "COMMON$CODE_AGENT_DESCRIPTION",
COMMON$PLAN_AGENT_DESCRIPTION = "COMMON$PLAN_AGENT_DESCRIPTION",
PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED = "PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED",
}
+64
View File
@@ -14991,6 +14991,22 @@
"de": "Einen Plan erstellen",
"uk": "Створити план"
},
"COMMON$TASKS": {
"en": "Tasks",
"ja": "タスク",
"zh-CN": "任务",
"zh-TW": "任務",
"ko-KR": "작업",
"no": "Oppgaver",
"it": "Attività",
"pt": "Tarefas",
"es": "Tareas",
"ar": "مهام",
"fr": "Tâches",
"tr": "Görevler",
"de": "Aufgaben",
"uk": "Завдання"
},
"COMMON$PLAN_MD": {
"en": "Plan.md",
"ja": "Plan.md",
@@ -15086,5 +15102,53 @@
"tr": "Bir plan üzerinde çalışalım",
"de": "Lassen Sie uns an einem Plan arbeiten",
"uk": "Давайте розробимо план"
},
"COMMON$CODE_AGENT_DESCRIPTION": {
"en": "Write, edit, and debug with AI assistance in real time.",
"ja": "AIの支援をリアルタイムで受けながら、コードの作成、編集、デバッグを行いましょう。",
"zh-CN": "实时在 AI 协助下编写、编辑和调试。",
"zh-TW": "即時在 AI 協助下編寫、編輯和除錯。",
"ko-KR": "AI의 지원을 받아 실시간으로 작성, 편집 및 디버깅하세요.",
"no": "Skriv, rediger og feilsøk med AI-assistanse i sanntid.",
"it": "Scrivi, modifica e esegui il debug con assistenza AI in tempo reale.",
"pt": "Escreva, edite e depure com assistência de IA em tempo real.",
"es": "Escribe, edita y depura con ayuda de IA en tiempo real.",
"ar": "اكتب وعدّل وصحّح الأخطاء بمساعدة الذكاء الاصطناعي في الوقت الفعلي.",
"fr": "Rédigez, modifiez et déboguez avec laide de lIA en temps réel.",
"tr": "AI desteğiyle gerçek zamanlı olarak yazın, düzenleyin ve hata ayıklayın.",
"de": "Schreiben, bearbeiten und debuggen Sie mit KI-Unterstützung in Echtzeit.",
"uk": "Пишіть, редагуйте та налагоджуйте з підтримкою ШІ у реальному часі."
},
"COMMON$PLAN_AGENT_DESCRIPTION": {
"en": "Outline goals, structure tasks, and map your next steps.",
"ja": "目標を明確にし、タスクを構造化し、次のステップを計画しましょう。",
"zh-CN": "概述目标、结构化任务,并规划下一步。",
"zh-TW": "概述目標、結構化任務,並規劃下一步。",
"ko-KR": "목표를 개요하고, 작업을 구조화하며, 다음 단계를 구상하세요.",
"no": "Skisser mål, strukturer oppgaver og planlegg dine neste steg.",
"it": "Definisci gli obiettivi, struttura le attività e pianifica i prossimi passi.",
"pt": "Esboce objetivos, estruture tarefas e trace seus próximos passos.",
"es": "Define objetivos, estructura tareas y planifica tus próximos pasos.",
"ar": "حدد الأهداف، نظم المهام، وارسم خطواتك التالية.",
"fr": "Dressez des objectifs, structurez vos tâches et planifiez vos prochaines étapes.",
"tr": "Hedefleri belirtin, görevleri yapılandırın ve sonraki adımlarınızı belirleyin.",
"de": "Umreißen Sie Ziele, strukturieren Sie Aufgaben und planen Sie Ihre nächsten Schritte.",
"uk": "Окресліть цілі, структуруйте завдання та сплануйте наступні кроки."
},
"PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED": {
"en": "Planning agent initialized",
"ja": "プランニングエージェントが初期化されました",
"zh-CN": "规划代理已初始化",
"zh-TW": "規劃代理已初始化",
"ko-KR": "계획 에이전트가 초기화되었습니다",
"no": "Planleggingsagent er initialisert",
"it": "Agente di pianificazione inizializzato",
"pt": "Agente de planejamento inicializado",
"es": "Agente de planificación inicializado",
"ar": "تم تهيئة وكيل التخطيط",
"fr": "Agent de planification initialisé",
"tr": "Planlama ajanı başlatıldı",
"de": "Planungsagent wurde initialisiert",
"uk": "Агент планування ініціалізовано"
}
}
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="7" viewBox="0 0 16 7" fill="none">
<path d="M7.50684 0.25C9.24918 0.25 10.9332 0.87774 12.251 2.01758C13.5688 3.15746 14.4327 4.73379 14.6836 6.45801L14.7256 6.74316H13.2129L13.1777 6.53516C12.9499 5.19635 12.2554 3.98161 11.2178 3.10547C10.1799 2.22925 8.86511 1.74805 7.50684 1.74805C6.14866 1.74811 4.83466 2.22931 3.79688 3.10547C2.75913 3.98161 2.06476 5.19628 1.83691 6.53516L1.80078 6.74316H0.289063L0.331055 6.45801C0.581982 4.73389 1.44504 3.15745 2.7627 2.01758C4.08041 0.877757 5.76455 0.250069 7.50684 0.25Z" fill="currentColor" stroke="currentColor" stroke-width="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 652 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M14.72 8.79L10.43 13.09L8.78 11.44C8.69036 11.3353 8.58004 11.2503 8.45597 11.1903C8.33191 11.1303 8.19678 11.0965 8.05906 11.0912C7.92134 11.0859 7.78401 11.1091 7.65568 11.1594C7.52736 11.2096 7.41081 11.2859 7.31335 11.3833C7.2159 11.4808 7.13964 11.5974 7.08937 11.7257C7.03909 11.854 7.01589 11.9913 7.02121 12.1291C7.02653 12.2668 7.06026 12.4019 7.12028 12.526C7.1803 12.65 7.26532 12.7604 7.37 12.85L9.72 15.21C9.81344 15.3027 9.92426 15.376 10.0461 15.4258C10.1679 15.4755 10.2984 15.5008 10.43 15.5C10.6923 15.4989 10.9437 15.3947 11.13 15.21L16.13 10.21C16.2237 10.117 16.2981 10.0064 16.3489 9.88458C16.3997 9.76272 16.4258 9.63201 16.4258 9.5C16.4258 9.36799 16.3997 9.23728 16.3489 9.11542C16.2981 8.99356 16.2237 8.88296 16.13 8.79C15.9426 8.60375 15.6892 8.49921 15.425 8.49921C15.1608 8.49921 14.9074 8.60375 14.72 8.79ZM12 2C10.0222 2 8.08879 2.58649 6.4443 3.6853C4.79981 4.78412 3.51809 6.3459 2.76121 8.17317C2.00433 10.0004 1.8063 12.0111 2.19215 13.9509C2.578 15.8907 3.53041 17.6725 4.92894 19.0711C6.32746 20.4696 8.10929 21.422 10.0491 21.8079C11.9889 22.1937 13.9996 21.9957 15.8268 21.2388C17.6541 20.4819 19.2159 19.2002 20.3147 17.5557C21.4135 15.9112 22 13.9778 22 12C22 10.6868 21.7413 9.38642 21.2388 8.17317C20.7363 6.95991 19.9997 5.85752 19.0711 4.92893C18.1425 4.00035 17.0401 3.26375 15.8268 2.7612C14.6136 2.25866 13.3132 2 12 2ZM12 20C10.4178 20 8.87104 19.5308 7.55544 18.6518C6.23985 17.7727 5.21447 16.5233 4.60897 15.0615C4.00347 13.5997 3.84504 11.9911 4.15372 10.4393C4.4624 8.88743 5.22433 7.46197 6.34315 6.34315C7.46197 5.22433 8.88743 4.4624 10.4393 4.15372C11.9911 3.84504 13.5997 4.00346 15.0615 4.60896C16.5233 5.21447 17.7727 6.23984 18.6518 7.55544C19.5308 8.87103 20 10.4177 20 12C20 14.1217 19.1572 16.1566 17.6569 17.6569C16.1566 19.1571 14.1217 20 12 20Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M12 2C10.0222 2 8.08879 2.58649 6.4443 3.6853C4.79981 4.78412 3.51809 6.3459 2.76121 8.17317C2.00433 10.0004 1.8063 12.0111 2.19215 13.9509C2.578 15.8907 3.53041 17.6725 4.92894 19.0711C6.32746 20.4696 8.10929 21.422 10.0491 21.8079C11.9889 22.1937 13.9996 21.9957 15.8268 21.2388C17.6541 20.4819 19.2159 19.2002 20.3147 17.5557C21.4135 15.9112 22 13.9778 22 12C22 10.6868 21.7413 9.38642 21.2388 8.17317C20.7363 6.95991 19.9997 5.85752 19.0711 4.92893C18.1425 4.00035 17.0401 3.26375 15.8268 2.7612C14.6136 2.25866 13.3132 2 12 2ZM12 20C10.4178 20 8.87104 19.5308 7.55544 18.6518C6.23985 17.7727 5.21447 16.5233 4.60897 15.0615C4.00347 13.5997 3.84504 11.9911 4.15372 10.4393C4.4624 8.88743 5.22433 7.46197 6.34315 6.34315C7.46197 5.22433 8.88743 4.4624 10.4393 4.15372C11.9911 3.84504 13.5997 4.00346 15.0615 4.60896C16.5233 5.21447 17.7727 6.23984 18.6518 7.55544C19.5308 8.87103 20 10.4177 20 12C20 14.1217 19.1572 16.1566 17.6569 17.6569C16.1566 19.1571 14.1217 20 12 20Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+4
View File
@@ -25,6 +25,7 @@ import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
import { useAutoLogin } from "#/hooks/use-auto-login";
import { useAuthCallback } from "#/hooks/use-auth-callback";
import { useReoTracking } from "#/hooks/use-reo-tracking";
import { useSyncPostHogConsent } from "#/hooks/use-sync-posthog-consent";
import { LOCAL_STORAGE_KEYS } from "#/utils/local-storage";
import { EmailVerificationGuard } from "#/components/features/guards/email-verification-guard";
import { MaintenanceBanner } from "#/components/features/maintenance/maintenance-banner";
@@ -100,6 +101,9 @@ export default function MainApp() {
// Initialize Reo.dev tracking in SaaS mode
useReoTracking();
// Sync PostHog opt-in/out state with backend setting on mount
useSyncPostHogConsent();
React.useEffect(() => {
// Don't change language when on TOS page
if (!isOnTosPage && settings?.LANGUAGE) {
+39
View File
@@ -0,0 +1,39 @@
---
name: agent_sdk_builder
version: 1.0.0
author: openhands
agent: CodeActAgent
triggers:
- /agent-builder
inputs:
- name: INITIAL_PROMPT
description: "Initial SDK requirements"
---
# Agent Builder and Interviewer Role
You are an expert requirements gatherer and agent builder. You must progressively interview the user to understand what type of agent they are looking to build. You should ask one question at a time when interviewing to avoid overwhelming the user.
Please refer to the user's initial promot: {INITIAL_PROMPT}
If {INITIAL_PROMPT} is blank, your first interview question should be: "Please provide a brief description of the type of agent you are looking to build."
# Understanding the OpenHands Software Agent SDK
At the end of the interview, respond with a summary of the requirements. Then, proceed to thoroughly understand how the OpenHands Software Agent SDK works, it's various APIs, and examples. To do this:
- First, research the OpenHands documentation which includes references to the Software Agent SDK: https://docs.openhands.dev/llms.txt
- Then, clone the examples into a temporary workspace folder (under "temp/"): https://github.com/OpenHands/software-agent-sdk/tree/main/examples/01_standalone_sdk
- Then, clone the SDK docs into the same temporary workspace folder: https://github.com/OpenHands/docs/tree/main/sdk
After analyzing the OpenHands Agent SDK, you may optionally ask additional clarifying questions in case it's important for the technical design of the agent.
# Generating the SDK Plan
You can then proceed to build a technical implementation plan based on the user requirements and your understanding of how the OpenHands Agent SDK works.
- The plan should be stored in "plan/SDK_PLAN.md" from the root of the workspace.
- A visual representation of how the agent should work based on the SDK_PLAN.md. This should look like a flow diagram with nodes and edges. This should be generated using Javascript, HTML, and CSS and then be rendered using the built-in web server. Store this in the plan/ directory.
# Implementing the Plan
After the plan is generated, please ask the user if they are ready to generate the SDK implementation. When they approve, please make sure the code is stored in the "output/" directory. Make sure the code provides logging that a user can see in the terminal. Ideally, the SDK is a single python file.
Additional guidelines:
- Users can configure their LLM API Key using an environment variable named "LLM_API_KEY"
- Unless otherwise specified, default to this model: openhands/claude-sonnet-4-20250514. This is configurable through the LLM_BASE_MODEL environment variable.
@@ -26,6 +26,7 @@ class AppConversationInfoService(ABC):
sort_order: AppConversationSortOrder = AppConversationSortOrder.CREATED_AT_DESC,
page_id: str | None = None,
limit: int = 100,
include_sub_conversations: bool = False,
) -> AppConversationInfoPage:
"""Search for sandboxed conversations."""
@@ -16,6 +16,13 @@ from openhands.sdk.llm import MetricsSnapshot
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
class AgentType(Enum):
"""Agent type for conversation."""
DEFAULT = 'default'
PLAN = 'plan'
class AppConversationInfo(BaseModel):
"""Conversation info which does not contain status."""
@@ -34,6 +41,9 @@ class AppConversationInfo(BaseModel):
metrics: MetricsSnapshot | None = None
parent_conversation_id: OpenHandsUUID | None = None
sub_conversation_ids: list[OpenHandsUUID] = Field(default_factory=list)
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
@@ -98,6 +108,8 @@ class AppConversationStartRequest(BaseModel):
title: str | None = None
trigger: ConversationTrigger | None = None
pr_number: list[int] = Field(default_factory=list)
parent_conversation_id: OpenHandsUUID | None = None
agent_type: AgentType = Field(default=AgentType.DEFAULT)
class AppConversationStartTaskStatus(Enum):
@@ -99,6 +99,12 @@ async def search_app_conversations(
lte=100,
),
] = 100,
include_sub_conversations: Annotated[
bool,
Query(
title='If True, include sub-conversations in the results. If False (default), exclude all sub-conversations.'
),
] = False,
app_conversation_service: AppConversationService = (
app_conversation_service_dependency
),
@@ -114,6 +120,7 @@ async def search_app_conversations(
updated_at__lt=updated_at__lt,
page_id=page_id,
limit=limit,
include_sub_conversations=include_sub_conversations,
)
@@ -193,7 +200,8 @@ async def stream_app_conversation_start(
user_context: UserContext = user_context_dependency,
) -> list[AppConversationStartTask]:
"""Start an app conversation start task and stream updates from it.
Leaves the connection open until either the conversation starts or there was an error"""
Leaves the connection open until either the conversation starts or there was an error
"""
response = StreamingResponse(
_stream_app_conversation_start(request, user_context),
media_type='application/json',
@@ -207,6 +215,10 @@ async def search_app_conversation_start_tasks(
UUID | None,
Query(title='Filter by conversation ID equal to this value'),
] = None,
created_at__gte: Annotated[
datetime | None,
Query(title='Filter by created_at greater than or equal to this datetime'),
] = None,
sort_order: Annotated[
AppConversationStartTaskSortOrder,
Query(title='Sort order for the results'),
@@ -233,6 +245,7 @@ async def search_app_conversation_start_tasks(
return (
await app_conversation_start_task_service.search_app_conversation_start_tasks(
conversation_id__eq=conversation_id__eq,
created_at__gte=created_at__gte,
sort_order=sort_order,
page_id=page_id,
limit=limit,
@@ -246,6 +259,10 @@ async def count_app_conversation_start_tasks(
UUID | None,
Query(title='Filter by conversation ID equal to this value'),
] = None,
created_at__gte: Annotated[
datetime | None,
Query(title='Filter by created_at greater than or equal to this datetime'),
] = None,
app_conversation_start_task_service: AppConversationStartTaskService = (
app_conversation_start_task_service_dependency
),
@@ -253,6 +270,7 @@ async def count_app_conversation_start_tasks(
"""Count conversation start tasks matching the given filters."""
return await app_conversation_start_task_service.count_app_conversation_start_tasks(
conversation_id__eq=conversation_id__eq,
created_at__gte=created_at__gte,
)
@@ -30,6 +30,7 @@ class AppConversationService(ABC):
sort_order: AppConversationSortOrder = AppConversationSortOrder.CREATED_AT_DESC,
page_id: str | None = None,
limit: int = 100,
include_sub_conversations: bool = False,
) -> AppConversationPage:
"""Search for sandboxed conversations."""
@@ -1,5 +1,6 @@
import asyncio
from abc import ABC, abstractmethod
from datetime import datetime
from uuid import UUID
from openhands.app_server.app_conversation.app_conversation_models import (
@@ -18,6 +19,7 @@ class AppConversationStartTaskService(ABC):
async def search_app_conversation_start_tasks(
self,
conversation_id__eq: UUID | None = None,
created_at__gte: datetime | None = None,
sort_order: AppConversationStartTaskSortOrder = AppConversationStartTaskSortOrder.CREATED_AT_DESC,
page_id: str | None = None,
limit: int = 100,
@@ -28,6 +30,7 @@ class AppConversationStartTaskService(ABC):
async def count_app_conversation_start_tasks(
self,
conversation_id__eq: UUID | None = None,
created_at__gte: datetime | None = None,
) -> int:
"""Count conversation start tasks."""
@@ -21,6 +21,7 @@ from openhands.app_server.app_conversation.app_conversation_info_service import
AppConversationInfoService,
)
from openhands.app_server.app_conversation.app_conversation_models import (
AgentType,
AppConversation,
AppConversationInfo,
AppConversationPage,
@@ -70,6 +71,7 @@ from openhands.sdk.llm import LLM
from openhands.sdk.security.confirmation_policy import AlwaysConfirm
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
from openhands.tools.preset.default import get_default_agent
from openhands.tools.preset.planning import get_planning_agent
_conversation_info_type_adapter = TypeAdapter(list[ConversationInfo | None])
_logger = logging.getLogger(__name__)
@@ -103,6 +105,7 @@ class LiveStatusAppConversationService(GitAppConversationService):
sort_order: AppConversationSortOrder = AppConversationSortOrder.CREATED_AT_DESC,
page_id: str | None = None,
limit: int = 20,
include_sub_conversations: bool = False,
) -> AppConversationPage:
"""Search for sandboxed conversations."""
page = await self.app_conversation_info_service.search_app_conversation_info(
@@ -114,6 +117,7 @@ class LiveStatusAppConversationService(GitAppConversationService):
sort_order=sort_order,
page_id=page_id,
limit=limit,
include_sub_conversations=include_sub_conversations,
)
conversations: list[AppConversation] = await self._build_app_conversations(
page.items
@@ -168,6 +172,20 @@ class LiveStatusAppConversationService(GitAppConversationService):
) -> AsyncGenerator[AppConversationStartTask, None]:
# Create and yield the start task
user_id = await self.user_context.get_user_id()
# Validate and inherit from parent conversation if provided
if request.parent_conversation_id:
parent_info = (
await self.app_conversation_info_service.get_app_conversation_info(
request.parent_conversation_id
)
)
if parent_info is None:
raise ValueError(
f'Parent conversation not found: {request.parent_conversation_id}'
)
self._inherit_configuration_from_parent(request, parent_info)
task = AppConversationStartTask(
created_by_user_id=user_id,
request=request,
@@ -206,6 +224,8 @@ class LiveStatusAppConversationService(GitAppConversationService):
request.initial_message,
request.git_provider,
sandbox_spec.working_dir,
request.agent_type,
request.llm_model,
)
)
@@ -224,6 +244,7 @@ class LiveStatusAppConversationService(GitAppConversationService):
headers={'X-Session-API-Key': sandbox.session_api_key},
timeout=self.sandbox_startup_timeout,
)
response.raise_for_status()
info = ConversationInfo.model_validate(response.json())
@@ -241,6 +262,7 @@ class LiveStatusAppConversationService(GitAppConversationService):
git_provider=request.git_provider,
trigger=request.trigger,
pr_number=request.pr_number,
parent_conversation_id=request.parent_conversation_id,
)
await self.app_conversation_info_service.save_app_conversation_info(
app_conversation_info
@@ -452,11 +474,43 @@ class LiveStatusAppConversationService(GitAppConversationService):
)
return agent_server_url
def _inherit_configuration_from_parent(
self, request: AppConversationStartRequest, parent_info: AppConversationInfo
) -> None:
"""Inherit configuration from parent conversation if not explicitly provided.
This ensures sub-conversations automatically inherit:
- Sandbox ID (to share the same workspace/environment)
- Git parameters (repository, branch, provider)
- LLM model
Args:
request: The conversation start request to modify
parent_info: The parent conversation info to inherit from
"""
# Inherit sandbox_id from parent to share the same workspace/environment
if not request.sandbox_id:
request.sandbox_id = parent_info.sandbox_id
# Inherit git parameters from parent if not provided
if not request.selected_repository:
request.selected_repository = parent_info.selected_repository
if not request.selected_branch:
request.selected_branch = parent_info.selected_branch
if not request.git_provider:
request.git_provider = parent_info.git_provider
# Inherit LLM model from parent if not provided
if not request.llm_model and parent_info.llm_model:
request.llm_model = parent_info.llm_model
async def _build_start_conversation_request_for_user(
self,
initial_message: SendMessageRequest | None,
git_provider: ProviderType | None,
working_dir: str,
agent_type: AgentType = AgentType.DEFAULT,
llm_model: str | None = None,
) -> StartConversationRequest:
user = await self.user_context.get_user_info()
@@ -488,13 +542,19 @@ class LiveStatusAppConversationService(GitAppConversationService):
workspace = LocalWorkspace(working_dir=working_dir)
# Use provided llm_model if available, otherwise fall back to user's default
model = llm_model or user.llm_model
llm = LLM(
model=user.llm_model,
model=model,
base_url=user.llm_base_url,
api_key=user.llm_api_key,
usage_id='agent',
)
agent = get_default_agent(llm=llm)
# Select agent based on agent_type
if agent_type == AgentType.PLAN:
agent = get_planning_agent(llm=llm)
else:
agent = get_default_agent(llm=llm)
conversation_id = uuid4()
agent = ExperimentManagerImpl.run_agent_variant_tests__v1(
@@ -88,6 +88,7 @@ class StoredConversationMetadata(Base): # type: ignore
conversation_version = Column(String, nullable=False, default='V0', index=True)
sandbox_id = Column(String, nullable=True, index=True)
parent_conversation_id = Column(String, nullable=True, index=True)
@dataclass
@@ -110,10 +111,18 @@ class SQLAppConversationInfoService(AppConversationInfoService):
sort_order: AppConversationSortOrder = AppConversationSortOrder.CREATED_AT_DESC,
page_id: str | None = None,
limit: int = 100,
include_sub_conversations: bool = False,
) -> AppConversationInfoPage:
"""Search for sandboxed conversations without permission checks."""
query = await self._secure_select()
# Conditionally exclude sub-conversations based on the parameter
if not include_sub_conversations:
# Exclude sub-conversations (only include top-level conversations)
query = query.where(
StoredConversationMetadata.parent_conversation_id.is_(None)
)
query = self._apply_filters(
query=query,
title__contains=title__contains,
@@ -231,6 +240,26 @@ class SQLAppConversationInfoService(AppConversationInfoService):
query = query.where(*conditions)
return query
async def _get_sub_conversation_ids(
self, parent_conversation_id: UUID
) -> list[UUID]:
"""Get all sub-conversation IDs for a given parent conversation.
Args:
parent_conversation_id: The ID of the parent conversation
Returns:
List of sub-conversation IDs
"""
query = await self._secure_select()
query = query.where(
StoredConversationMetadata.parent_conversation_id
== str(parent_conversation_id)
)
result_set = await self.db_session.execute(query)
rows = result_set.scalars().all()
return [UUID(row.conversation_id) for row in rows]
async def get_app_conversation_info(
self, conversation_id: UUID
) -> AppConversationInfo | None:
@@ -241,7 +270,9 @@ class SQLAppConversationInfoService(AppConversationInfoService):
result_set = await self.db_session.execute(query)
result = result_set.scalar_one_or_none()
if result:
return self._to_info(result)
# Fetch sub-conversation IDs
sub_conversation_ids = await self._get_sub_conversation_ids(conversation_id)
return self._to_info(result, sub_conversation_ids=sub_conversation_ids)
return None
async def batch_get_app_conversation_info(
@@ -260,8 +291,13 @@ class SQLAppConversationInfoService(AppConversationInfoService):
results: list[AppConversationInfo | None] = []
for conversation_id in conversation_id_strs:
info = info_by_id.get(conversation_id)
sub_conversation_ids = await self._get_sub_conversation_ids(
UUID(conversation_id)
)
if info:
results.append(self._to_info(info))
results.append(
self._to_info(info, sub_conversation_ids=sub_conversation_ids)
)
else:
results.append(None)
@@ -277,7 +313,7 @@ class SQLAppConversationInfoService(AppConversationInfoService):
)
result = await self.db_session.execute(query)
existing = result.scalar_one_or_none()
assert existing is None or existing.created_by_user_id == user_id
assert existing is None or existing.user_id == user_id
metrics = info.metrics or MetricsSnapshot()
usage = metrics.accumulated_token_usage or TokenUsage()
@@ -307,6 +343,11 @@ class SQLAppConversationInfoService(AppConversationInfoService):
llm_model=info.llm_model,
conversation_version='V1',
sandbox_id=info.sandbox_id,
parent_conversation_id=(
str(info.parent_conversation_id)
if info.parent_conversation_id
else None
),
)
await self.db_session.merge(stored)
@@ -324,7 +365,11 @@ class SQLAppConversationInfoService(AppConversationInfoService):
)
return query
def _to_info(self, stored: StoredConversationMetadata) -> AppConversationInfo:
def _to_info(
self,
stored: StoredConversationMetadata,
sub_conversation_ids: list[UUID] | None = None,
) -> AppConversationInfo:
# V1 conversations should always have a sandbox_id
sandbox_id = stored.sandbox_id
assert sandbox_id is not None
@@ -364,6 +409,12 @@ class SQLAppConversationInfoService(AppConversationInfoService):
pr_number=stored.pr_number,
llm_model=stored.llm_model,
metrics=metrics,
parent_conversation_id=(
UUID(stored.parent_conversation_id)
if stored.parent_conversation_id
else None
),
sub_conversation_ids=sub_conversation_ids or [],
created_at=created_at,
updated_at=updated_at,
)
@@ -18,6 +18,7 @@ from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime
from typing import AsyncGenerator
from uuid import UUID
@@ -75,6 +76,7 @@ class SQLAppConversationStartTaskService(AppConversationStartTaskService):
async def search_app_conversation_start_tasks(
self,
conversation_id__eq: UUID | None = None,
created_at__gte: datetime | None = None,
sort_order: AppConversationStartTaskSortOrder = AppConversationStartTaskSortOrder.CREATED_AT_DESC,
page_id: str | None = None,
limit: int = 100,
@@ -95,6 +97,12 @@ class SQLAppConversationStartTaskService(AppConversationStartTaskService):
== conversation_id__eq
)
# Apply created_at__gte filter
if created_at__gte is not None:
query = query.where(
StoredAppConversationStartTask.created_at >= created_at__gte
)
# Add sort order
if sort_order == AppConversationStartTaskSortOrder.CREATED_AT:
query = query.order_by(StoredAppConversationStartTask.created_at)
@@ -139,6 +147,7 @@ class SQLAppConversationStartTaskService(AppConversationStartTaskService):
async def count_app_conversation_start_tasks(
self,
conversation_id__eq: UUID | None = None,
created_at__gte: datetime | None = None,
) -> int:
"""Count conversation start tasks."""
query = select(func.count(StoredAppConversationStartTask.id))
@@ -156,6 +165,12 @@ class SQLAppConversationStartTaskService(AppConversationStartTaskService):
== conversation_id__eq
)
# Apply created_at__gte filter
if created_at__gte is not None:
query = query.where(
StoredAppConversationStartTask.created_at >= created_at__gte
)
result = await self.session.execute(query)
count = result.scalar()
return count or 0
@@ -0,0 +1,41 @@
"""add parent_conversation_id to conversation_metadata
Revision ID: 003
Revises: 002
Create Date: 2025-11-06 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '003'
down_revision: Union[str, None] = '002'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.add_column(
'conversation_metadata',
sa.Column('parent_conversation_id', sa.String(), nullable=True),
)
op.create_index(
op.f('ix_conversation_metadata_parent_conversation_id'),
'conversation_metadata',
['parent_conversation_id'],
unique=False,
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_index(
op.f('ix_conversation_metadata_parent_conversation_id'),
table_name='conversation_metadata',
)
op.drop_column('conversation_metadata', 'parent_conversation_id')
@@ -318,7 +318,6 @@ class RemoteSandboxService(SandboxService):
created_at=utc_now(),
)
self.db_session.add(stored_sandbox)
await self.db_session.commit()
# Prepare environment variables
environment = await self._init_environment(sandbox_spec, sandbox_id)
@@ -407,7 +406,6 @@ class RemoteSandboxService(SandboxService):
if not stored_sandbox:
return False
await self.db_session.delete(stored_sandbox)
await self.db_session.commit()
runtime_data = await self._get_runtime(sandbox_id)
response = await self._send_runtime_api_request(
'POST',
@@ -11,7 +11,7 @@ from openhands.sdk.utils.models import DiscriminatedUnionMixin
# The version of the agent server to use for deployments.
# Typically this will be the same as the values from the pyproject.toml
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:f3c0c19-python'
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:4e2ecd8-python'
class SandboxSpecService(ABC):
+30
View File
@@ -42,6 +42,10 @@ 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,
@@ -709,6 +713,20 @@ 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()
@@ -887,6 +905,18 @@ 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
+4
View File
@@ -20,6 +20,9 @@ class AsyncLLM(LLM):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
# Apply extra headers from env if defined
_extra_headers = self._get_extra_headers()
self._async_completion = partial(
self._call_acompletion,
model=self.config.model,
@@ -35,6 +38,7 @@ class AsyncLLM(LLM):
top_p=self.config.top_p,
drop_params=self.config.drop_params,
seed=self.config.seed,
**({'extra_headers': _extra_headers} if _extra_headers is not None else {}),
)
async_completion_unwrapped = self._async_completion
+27 -3
View File
@@ -1,4 +1,5 @@
import copy
import json as _json
import os
import time
import warnings
@@ -58,6 +59,24 @@ class LLM(RetryMixin, DebugMixin):
config: an LLMConfig object specifying the configuration of the LLM.
"""
def _get_extra_headers(self) -> dict[str, Any] | None:
"""Read and validate extra headers from LLM_EXTRA_HEADERS env.
Returns a dict if valid JSON object, otherwise None.
"""
_extra_headers_env = os.getenv('LLM_EXTRA_HEADERS')
if not _extra_headers_env:
return None
try:
_extra_headers = _json.loads(_extra_headers_env)
if not isinstance(_extra_headers, dict):
logger.warning('LLM_EXTRA_HEADERS must be a JSON object; ignoring')
return None
return _extra_headers
except Exception as _e:
logger.warning(f'Failed parsing LLM_EXTRA_HEADERS: {_e}')
return None
def __init__(
self,
config: LLMConfig,
@@ -201,12 +220,17 @@ class LLM(RetryMixin, DebugMixin):
if self.config.completion_kwargs is not None:
kwargs.update(self.config.completion_kwargs)
# Apply extra headers from env if defined and not already provided via completion_kwargs
_extra_headers = self._get_extra_headers()
if _extra_headers is not None and 'extra_headers' not in kwargs:
kwargs['extra_headers'] = _extra_headers
self._completion = partial(
litellm_completion,
model=self.config.model,
api_key=self.config.api_key.get_secret_value()
if self.config.api_key
else None,
api_key=(
self.config.api_key.get_secret_value() if self.config.api_key else None
),
base_url=self.config.base_url,
api_version=self.config.api_version,
custom_llm_provider=self.config.custom_llm_provider,
+5 -1
View File
@@ -14,6 +14,9 @@ class StreamingLLM(AsyncLLM):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
# Apply extra headers from env if defined
_extra_headers = self._get_extra_headers()
self._async_streaming_completion = partial(
self._call_acompletion,
model=self.config.model,
@@ -28,7 +31,8 @@ class StreamingLLM(AsyncLLM):
temperature=self.config.temperature,
top_p=self.config.top_p,
drop_params=self.config.drop_params,
stream=True, # Ensure streaming is enabled
stream=True,
**({'extra_headers': _extra_headers} if _extra_headers is not None else {}),
)
async_streaming_completion_unwrapped = self._async_streaming_completion
@@ -28,3 +28,4 @@ class ConversationInfo:
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
pr_number: list[int] = field(default_factory=list)
conversation_version: str = 'V0'
sub_conversation_ids: list[str] = field(default_factory=list)
+10
View File
@@ -26,11 +26,13 @@ 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())
@@ -115,6 +117,14 @@ 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:
@@ -1,3 +1,4 @@
import asyncio
import base64
import itertools
import json
@@ -5,12 +6,14 @@ import os
import re
import uuid
from datetime import datetime, timedelta, timezone
from typing import Annotated
import base62
from fastapi import APIRouter, Depends, status
from fastapi import APIRouter, Depends, Query, Request, status
from fastapi.responses import JSONResponse
from jinja2 import Environment, FileSystemLoader
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from openhands.app_server.app_conversation.app_conversation_info_service import (
AppConversationInfoService,
@@ -24,9 +27,11 @@ from openhands.app_server.app_conversation.app_conversation_service import (
from openhands.app_server.config import (
depends_app_conversation_info_service,
depends_app_conversation_service,
depends_db_session,
depends_sandbox_service,
)
from openhands.app_server.sandbox.sandbox_service import SandboxService
from openhands.app_server.services.db_session_injector import set_db_session_keep_open
from openhands.core.config.llm_config import LLMConfig
from openhands.core.config.mcp_config import MCPConfig
from openhands.core.logger import openhands_logger as logger
@@ -99,6 +104,7 @@ app = APIRouter(prefix='/api', dependencies=get_dependencies())
app_conversation_service_dependency = depends_app_conversation_service()
app_conversation_info_service_dependency = depends_app_conversation_info_service()
sandbox_service_dependency = depends_sandbox_service()
db_session_dependency = depends_db_session()
def _filter_conversations_by_age(
@@ -304,6 +310,12 @@ async def search_conversations(
limit: int = 20,
selected_repository: str | None = None,
conversation_trigger: ConversationTrigger | None = None,
include_sub_conversations: Annotated[
bool,
Query(
title='If True, include sub-conversations in the results. If False (default), exclude all sub-conversations.'
),
] = False,
conversation_store: ConversationStore = Depends(get_conversation_store),
app_conversation_service: AppConversationService = app_conversation_service_dependency,
) -> ConversationInfoResultSet:
@@ -338,6 +350,7 @@ async def search_conversations(
limit=limit,
# Apply age filter at the service level if possible
created_at__gte=age_filter_date,
include_sub_conversations=include_sub_conversations,
)
# Convert V1 conversations to ConversationInfo format
@@ -467,16 +480,22 @@ async def get_conversation(
@app.delete('/conversations/{conversation_id}')
async def delete_conversation(
request: Request,
conversation_id: str = Depends(validate_conversation_id),
user_id: str | None = Depends(get_user_id),
app_conversation_service: AppConversationService = app_conversation_service_dependency,
app_conversation_info_service: AppConversationInfoService = app_conversation_info_service_dependency,
sandbox_service: SandboxService = sandbox_service_dependency,
db_session: AsyncSession = db_session_dependency,
) -> bool:
set_db_session_keep_open(request.state, True)
# Try V1 conversation first
v1_result = await _try_delete_v1_conversation(
conversation_id,
app_conversation_service,
app_conversation_info_service,
sandbox_service,
db_session,
)
if v1_result is not None:
return v1_result
@@ -488,23 +507,32 @@ async def delete_conversation(
async def _try_delete_v1_conversation(
conversation_id: str,
app_conversation_service: AppConversationService,
app_conversation_info_service: AppConversationInfoService,
sandbox_service: SandboxService,
db_session: AsyncSession,
) -> bool | None:
"""Try to delete a V1 conversation. Returns None if not a V1 conversation."""
result = None
try:
conversation_uuid = uuid.UUID(conversation_id)
# Check if it's a V1 conversation by trying to get it
app_conversation = await app_conversation_service.get_app_conversation(
conversation_uuid
app_conversation_info = (
await app_conversation_info_service.get_app_conversation_info(
conversation_uuid
)
)
if app_conversation:
if app_conversation_info:
# This is a V1 conversation, delete it using the app conversation service
# Pass the conversation ID for secure deletion
result = await app_conversation_service.delete_app_conversation(
app_conversation.id
app_conversation_info.id
)
# Delete the sandbox in the background
asyncio.create_task(
_delete_sandbox_and_close_connection(
sandbox_service, app_conversation_info.sandbox_id, db_session
)
)
await sandbox_service.delete_sandbox(app_conversation.sandbox_id)
except (ValueError, TypeError):
# Not a valid UUID, continue with V0 logic
pass
@@ -515,6 +543,16 @@ async def _try_delete_v1_conversation(
return result
async def _delete_sandbox_and_close_connection(
sandbox_service: SandboxService, sandbox_id: str, db_session: AsyncSession
):
try:
await sandbox_service.delete_sandbox(sandbox_id)
await db_session.commit()
finally:
await db_session.aclose()
async def _delete_v0_conversation(conversation_id: str, user_id: str | None) -> bool:
"""Delete a V0 conversation using the legacy logic."""
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
@@ -1157,6 +1195,7 @@ async def _fetch_v1_conversations_safe(
app_conversation_service: App conversation service for V1
v1_page_id: Page ID for V1 pagination
limit: Maximum number of results
include_sub_conversations: If True, include sub-conversations in results
Returns:
Tuple of (v1_conversations, v1_next_page_id)
@@ -1432,4 +1471,7 @@ def _to_conversation_info(app_conversation: AppConversation) -> ConversationInfo
created_at=app_conversation.created_at,
pr_number=app_conversation.pr_number,
conversation_version='V1',
sub_conversation_ids=[
sub_id.hex for sub_id in app_conversation.sub_conversation_ids
],
)
+270
View File
@@ -0,0 +1,270 @@
"""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),
},
)
@@ -435,7 +435,7 @@ class TestSandboxLifecycle:
9
) # max_num_sandboxes - 1
remote_sandbox_service.db_session.add.assert_called_once()
remote_sandbox_service.db_session.commit.assert_called_once()
remote_sandbox_service.db_session.commit.assert_not_called()
@pytest.mark.asyncio
async def test_start_sandbox_with_specific_spec(
@@ -627,7 +627,7 @@ class TestSandboxLifecycle:
# Verify
assert result is True
remote_sandbox_service.db_session.delete.assert_called_once_with(stored_sandbox)
remote_sandbox_service.db_session.commit.assert_called_once()
remote_sandbox_service.db_session.commit.assert_not_called()
remote_sandbox_service.httpx_client.request.assert_called_once_with(
'POST',
'https://api.example.com/stop',
@@ -623,3 +623,383 @@ class TestSQLAppConversationInfoService:
created_at__gte=start_time, created_at__lt=end_time
)
assert count == 2
@pytest.mark.asyncio
async def test_search_excludes_sub_conversations_by_default(
self,
service: SQLAppConversationInfoService,
):
"""Test that search excludes sub-conversations by default."""
# Create a parent conversation
parent_id = uuid4()
parent_info = AppConversationInfo(
id=parent_id,
created_by_user_id='test_user_123',
sandbox_id='sandbox_parent',
title='Parent Conversation',
created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc),
)
# Create sub-conversations
sub_info_1 = AppConversationInfo(
id=uuid4(),
created_by_user_id='test_user_123',
sandbox_id='sandbox_sub1',
title='Sub Conversation 1',
parent_conversation_id=parent_id,
created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc),
)
sub_info_2 = AppConversationInfo(
id=uuid4(),
created_by_user_id='test_user_123',
sandbox_id='sandbox_sub2',
title='Sub Conversation 2',
parent_conversation_id=parent_id,
created_at=datetime(2024, 1, 1, 14, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2024, 1, 1, 14, 30, 0, tzinfo=timezone.utc),
)
# Save all conversations
await service.save_app_conversation_info(parent_info)
await service.save_app_conversation_info(sub_info_1)
await service.save_app_conversation_info(sub_info_2)
# Search without include_sub_conversations (default False)
page = await service.search_app_conversation_info()
# Should only return the parent conversation
assert len(page.items) == 1
assert page.items[0].id == parent_id
assert page.items[0].title == 'Parent Conversation'
assert page.items[0].parent_conversation_id is None
@pytest.mark.asyncio
async def test_search_includes_sub_conversations_when_flag_true(
self,
service: SQLAppConversationInfoService,
):
"""Test that search includes sub-conversations when include_sub_conversations=True."""
# Create a parent conversation
parent_id = uuid4()
parent_info = AppConversationInfo(
id=parent_id,
created_by_user_id='test_user_123',
sandbox_id='sandbox_parent',
title='Parent Conversation',
created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc),
)
# Create sub-conversations
sub_info_1 = AppConversationInfo(
id=uuid4(),
created_by_user_id='test_user_123',
sandbox_id='sandbox_sub1',
title='Sub Conversation 1',
parent_conversation_id=parent_id,
created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc),
)
sub_info_2 = AppConversationInfo(
id=uuid4(),
created_by_user_id='test_user_123',
sandbox_id='sandbox_sub2',
title='Sub Conversation 2',
parent_conversation_id=parent_id,
created_at=datetime(2024, 1, 1, 14, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2024, 1, 1, 14, 30, 0, tzinfo=timezone.utc),
)
# Save all conversations
await service.save_app_conversation_info(parent_info)
await service.save_app_conversation_info(sub_info_1)
await service.save_app_conversation_info(sub_info_2)
# Search with include_sub_conversations=True
page = await service.search_app_conversation_info(
include_sub_conversations=True
)
# Should return all conversations (1 parent + 2 sub-conversations)
assert len(page.items) == 3
# Verify all conversations are present
conversation_ids = {item.id for item in page.items}
assert parent_id in conversation_ids
assert sub_info_1.id in conversation_ids
assert sub_info_2.id in conversation_ids
# Verify parent conversation has no parent_conversation_id
parent_item = next(item for item in page.items if item.id == parent_id)
assert parent_item.parent_conversation_id is None
# Verify sub-conversations have parent_conversation_id set
sub_item_1 = next(item for item in page.items if item.id == sub_info_1.id)
assert sub_item_1.parent_conversation_id == parent_id
sub_item_2 = next(item for item in page.items if item.id == sub_info_2.id)
assert sub_item_2.parent_conversation_id == parent_id
@pytest.mark.asyncio
async def test_search_sub_conversations_with_filters(
self,
service: SQLAppConversationInfoService,
):
"""Test that include_sub_conversations works correctly with other filters."""
# Create a parent conversation
parent_id = uuid4()
parent_info = AppConversationInfo(
id=parent_id,
created_by_user_id='test_user_123',
sandbox_id='sandbox_parent',
title='Parent Conversation',
created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc),
)
# Create sub-conversations with different titles
sub_info_1 = AppConversationInfo(
id=uuid4(),
created_by_user_id='test_user_123',
sandbox_id='sandbox_sub1',
title='Sub Conversation Alpha',
parent_conversation_id=parent_id,
created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc),
)
sub_info_2 = AppConversationInfo(
id=uuid4(),
created_by_user_id='test_user_123',
sandbox_id='sandbox_sub2',
title='Sub Conversation Beta',
parent_conversation_id=parent_id,
created_at=datetime(2024, 1, 1, 14, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2024, 1, 1, 14, 30, 0, tzinfo=timezone.utc),
)
# Save all conversations
await service.save_app_conversation_info(parent_info)
await service.save_app_conversation_info(sub_info_1)
await service.save_app_conversation_info(sub_info_2)
# Search with title filter and include_sub_conversations=False (default)
page = await service.search_app_conversation_info(title__contains='Alpha')
# Should only find parent if it matches, but parent doesn't have "Alpha"
# So should find nothing or only sub if we include them
assert len(page.items) == 0
# Search with title filter and include_sub_conversations=True
page = await service.search_app_conversation_info(
title__contains='Alpha', include_sub_conversations=True
)
# Should find the sub-conversation with "Alpha" in title
assert len(page.items) == 1
assert page.items[0].title == 'Sub Conversation Alpha'
assert page.items[0].parent_conversation_id == parent_id
# Search with title filter for "Parent" and include_sub_conversations=True
page = await service.search_app_conversation_info(
title__contains='Parent', include_sub_conversations=True
)
# Should find the parent conversation
assert len(page.items) == 1
assert page.items[0].title == 'Parent Conversation'
assert page.items[0].parent_conversation_id is None
@pytest.mark.asyncio
async def test_search_sub_conversations_with_date_filters(
self,
service: SQLAppConversationInfoService,
):
"""Test that include_sub_conversations works correctly with date filters."""
# Create a parent conversation
parent_id = uuid4()
parent_info = AppConversationInfo(
id=parent_id,
created_by_user_id='test_user_123',
sandbox_id='sandbox_parent',
title='Parent Conversation',
created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc),
)
# Create sub-conversations at different times
sub_info_1 = AppConversationInfo(
id=uuid4(),
created_by_user_id='test_user_123',
sandbox_id='sandbox_sub1',
title='Sub Conversation 1',
parent_conversation_id=parent_id,
created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc),
)
sub_info_2 = AppConversationInfo(
id=uuid4(),
created_by_user_id='test_user_123',
sandbox_id='sandbox_sub2',
title='Sub Conversation 2',
parent_conversation_id=parent_id,
created_at=datetime(2024, 1, 1, 14, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2024, 1, 1, 14, 30, 0, tzinfo=timezone.utc),
)
# Save all conversations
await service.save_app_conversation_info(parent_info)
await service.save_app_conversation_info(sub_info_1)
await service.save_app_conversation_info(sub_info_2)
# Search with date filter and include_sub_conversations=False (default)
cutoff_time = datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc)
page = await service.search_app_conversation_info(created_at__gte=cutoff_time)
# Should only return parent if it matches the filter, but parent is at 12:00
assert len(page.items) == 0
# Search with date filter and include_sub_conversations=True
page = await service.search_app_conversation_info(
created_at__gte=cutoff_time, include_sub_conversations=True
)
# Should find sub-conversations created after cutoff (sub_info_2 at 14:00)
assert len(page.items) == 1
assert page.items[0].id == sub_info_2.id
assert page.items[0].parent_conversation_id == parent_id
@pytest.mark.asyncio
async def test_search_multiple_parents_with_sub_conversations(
self,
service: SQLAppConversationInfoService,
):
"""Test search with multiple parent conversations and their sub-conversations."""
# Create first parent conversation
parent1_id = uuid4()
parent1_info = AppConversationInfo(
id=parent1_id,
created_by_user_id='test_user_123',
sandbox_id='sandbox_parent1',
title='Parent 1',
created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc),
)
# Create second parent conversation
parent2_id = uuid4()
parent2_info = AppConversationInfo(
id=parent2_id,
created_by_user_id='test_user_123',
sandbox_id='sandbox_parent2',
title='Parent 2',
created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc),
)
# Create sub-conversations for parent1
sub1_1 = AppConversationInfo(
id=uuid4(),
created_by_user_id='test_user_123',
sandbox_id='sandbox_sub1_1',
title='Sub 1-1',
parent_conversation_id=parent1_id,
created_at=datetime(2024, 1, 1, 14, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2024, 1, 1, 14, 30, 0, tzinfo=timezone.utc),
)
# Create sub-conversations for parent2
sub2_1 = AppConversationInfo(
id=uuid4(),
created_by_user_id='test_user_123',
sandbox_id='sandbox_sub2_1',
title='Sub 2-1',
parent_conversation_id=parent2_id,
created_at=datetime(2024, 1, 1, 15, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2024, 1, 1, 15, 30, 0, tzinfo=timezone.utc),
)
# Save all conversations
await service.save_app_conversation_info(parent1_info)
await service.save_app_conversation_info(parent2_info)
await service.save_app_conversation_info(sub1_1)
await service.save_app_conversation_info(sub2_1)
# Search without include_sub_conversations (default False)
page = await service.search_app_conversation_info()
# Should only return the 2 parent conversations
assert len(page.items) == 2
conversation_ids = {item.id for item in page.items}
assert parent1_id in conversation_ids
assert parent2_id in conversation_ids
assert sub1_1.id not in conversation_ids
assert sub2_1.id not in conversation_ids
# Search with include_sub_conversations=True
page = await service.search_app_conversation_info(
include_sub_conversations=True
)
# Should return all 4 conversations (2 parents + 2 sub-conversations)
assert len(page.items) == 4
conversation_ids = {item.id for item in page.items}
assert parent1_id in conversation_ids
assert parent2_id in conversation_ids
assert sub1_1.id in conversation_ids
assert sub2_1.id in conversation_ids
@pytest.mark.asyncio
async def test_search_sub_conversations_with_pagination(
self,
service: SQLAppConversationInfoService,
):
"""Test that include_sub_conversations works correctly with pagination."""
# Create a parent conversation
parent_id = uuid4()
parent_info = AppConversationInfo(
id=parent_id,
created_by_user_id='test_user_123',
sandbox_id='sandbox_parent',
title='Parent Conversation',
created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc),
)
# Create multiple sub-conversations
sub_conversations = []
for i in range(5):
sub_info = AppConversationInfo(
id=uuid4(),
created_by_user_id='test_user_123',
sandbox_id=f'sandbox_sub{i}',
title=f'Sub Conversation {i}',
parent_conversation_id=parent_id,
created_at=datetime(2024, 1, 1, 13 + i, 0, 0, tzinfo=timezone.utc),
updated_at=datetime(2024, 1, 1, 13 + i, 30, 0, tzinfo=timezone.utc),
)
sub_conversations.append(sub_info)
await service.save_app_conversation_info(sub_info)
# Save parent
await service.save_app_conversation_info(parent_info)
# Search with include_sub_conversations=True and pagination
page1 = await service.search_app_conversation_info(
include_sub_conversations=True, limit=3
)
# Should return 3 items (1 parent + 2 sub-conversations)
assert len(page1.items) == 3
assert page1.next_page_id is not None
# Get next page
page2 = await service.search_app_conversation_info(
include_sub_conversations=True, limit=3, page_id=page1.next_page_id
)
# Should return remaining items
assert len(page2.items) == 3
assert page2.next_page_id is None
# Verify all conversations are present across pages
all_ids = {item.id for item in page1.items} | {item.id for item in page2.items}
assert parent_id in all_ids
for sub_info in sub_conversations:
assert sub_info.id in all_ids
@@ -639,3 +639,145 @@ class TestSQLAppConversationStartTaskService:
user2_count = await user2_service.count_app_conversation_start_tasks()
assert user2_count == 1
async def test_search_app_conversation_start_tasks_with_created_at_gte_filter(
self,
service: SQLAppConversationStartTaskService,
sample_request: AppConversationStartRequest,
):
"""Test search with created_at__gte filter."""
from datetime import timedelta
from openhands.agent_server.models import utc_now
# Create tasks with different creation times
base_time = utc_now()
# Task 1: created 2 hours ago
task1 = AppConversationStartTask(
id=uuid4(),
created_by_user_id='user1',
status=AppConversationStartTaskStatus.WORKING,
request=sample_request,
)
task1.created_at = base_time - timedelta(hours=2)
await service.save_app_conversation_start_task(task1)
# Task 2: created 1 hour ago
task2 = AppConversationStartTask(
id=uuid4(),
created_by_user_id='user1',
status=AppConversationStartTaskStatus.READY,
request=sample_request,
)
task2.created_at = base_time - timedelta(hours=1)
await service.save_app_conversation_start_task(task2)
# Task 3: created 30 minutes ago
task3 = AppConversationStartTask(
id=uuid4(),
created_by_user_id='user1',
status=AppConversationStartTaskStatus.WORKING,
request=sample_request,
)
task3.created_at = base_time - timedelta(minutes=30)
await service.save_app_conversation_start_task(task3)
# Search for tasks created in the last 90 minutes
filter_time = base_time - timedelta(minutes=90)
result = await service.search_app_conversation_start_tasks(
created_at__gte=filter_time
)
# Should return task2 and task3 (created within last 90 minutes)
assert len(result.items) == 2
task_ids = [task.id for task in result.items]
assert task2.id in task_ids
assert task3.id in task_ids
assert task1.id not in task_ids
# Test count with the same filter
count = await service.count_app_conversation_start_tasks(
created_at__gte=filter_time
)
assert count == 2
# Search for tasks created in the last 45 minutes
filter_time_recent = base_time - timedelta(minutes=45)
result_recent = await service.search_app_conversation_start_tasks(
created_at__gte=filter_time_recent
)
# Should return only task3
assert len(result_recent.items) == 1
assert result_recent.items[0].id == task3.id
# Test count with recent filter
count_recent = await service.count_app_conversation_start_tasks(
created_at__gte=filter_time_recent
)
assert count_recent == 1
async def test_search_app_conversation_start_tasks_combined_filters(
self,
service: SQLAppConversationStartTaskService,
sample_request: AppConversationStartRequest,
):
"""Test search with both conversation_id and created_at__gte filters."""
from datetime import timedelta
from openhands.agent_server.models import utc_now
conversation_id1 = uuid4()
conversation_id2 = uuid4()
base_time = utc_now()
# Task 1: conversation_id1, created 2 hours ago
task1 = AppConversationStartTask(
id=uuid4(),
created_by_user_id='user1',
status=AppConversationStartTaskStatus.WORKING,
app_conversation_id=conversation_id1,
request=sample_request,
)
task1.created_at = base_time - timedelta(hours=2)
await service.save_app_conversation_start_task(task1)
# Task 2: conversation_id1, created 30 minutes ago
task2 = AppConversationStartTask(
id=uuid4(),
created_by_user_id='user1',
status=AppConversationStartTaskStatus.READY,
app_conversation_id=conversation_id1,
request=sample_request,
)
task2.created_at = base_time - timedelta(minutes=30)
await service.save_app_conversation_start_task(task2)
# Task 3: conversation_id2, created 30 minutes ago
task3 = AppConversationStartTask(
id=uuid4(),
created_by_user_id='user1',
status=AppConversationStartTaskStatus.WORKING,
app_conversation_id=conversation_id2,
request=sample_request,
)
task3.created_at = base_time - timedelta(minutes=30)
await service.save_app_conversation_start_task(task3)
# Search for tasks with conversation_id1 created in the last hour
filter_time = base_time - timedelta(hours=1)
result = await service.search_app_conversation_start_tasks(
conversation_id__eq=conversation_id1, created_at__gte=filter_time
)
# Should return only task2 (conversation_id1 and created within last hour)
assert len(result.items) == 1
assert result.items[0].id == task2.id
assert result.items[0].app_conversation_id == conversation_id1
# Test count with combined filters
count = await service.count_app_conversation_start_tasks(
conversation_id__eq=conversation_id1, created_at__gte=filter_time
)
assert count == 1
@@ -0,0 +1,242 @@
"""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
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()
+89
View File
@@ -0,0 +1,89 @@
import asyncio
import json
from unittest.mock import AsyncMock, patch
import pytest
from openhands.core.config import LLMConfig
from openhands.llm.async_llm import AsyncLLM
from openhands.llm.llm import LLM
from openhands.llm.streaming_llm import StreamingLLM
@pytest.fixture
def extra_headers_env(monkeypatch):
headers = {
'editor-version': 'vscode/1.85.1',
'Copilot-Integration-Id': 'vscode-chat',
}
monkeypatch.setenv('LLM_EXTRA_HEADERS', json.dumps(headers))
return headers
def test_llm_passes_extra_headers_to_litellm_completion(extra_headers_env):
cfg = LLMConfig(model='gpt-4o', api_key='test_key')
with patch('openhands.llm.llm.litellm_completion') as mock_completion:
def _side_effect(*args, **kwargs):
assert 'extra_headers' in kwargs
assert kwargs['extra_headers'] == extra_headers_env
# minimal response structure expected by wrapper
return {'id': 'resp-1', 'choices': [{'message': {'content': 'ok'}}]}
mock_completion.side_effect = _side_effect
llm = LLM(cfg, service_id='svc')
resp = llm.completion(messages=[{'role': 'user', 'content': 'hi'}])
assert resp['choices'][0]['message']['content'] == 'ok'
@pytest.mark.asyncio
async def test_async_llm_passes_extra_headers_to_litellm_acompletion(extra_headers_env):
cfg = LLMConfig(model='gpt-4o', api_key='test_key')
async def _async_side_effect(*args, **kwargs):
assert 'extra_headers' in kwargs
assert kwargs['extra_headers'] == extra_headers_env
return {'id': 'resp-2', 'choices': [{'message': {'content': 'ok'}}]}
with patch(
'openhands.llm.async_llm.litellm_acompletion',
new=AsyncMock(side_effect=_async_side_effect),
):
llm = AsyncLLM(cfg, service_id='svc')
resp = await llm.async_completion(
messages=[{'role': 'user', 'content': 'hi'}], stream=False
)
assert resp['choices'][0]['message']['content'] == 'ok'
@pytest.mark.asyncio
async def test_streaming_llm_passes_extra_headers_to_litellm_acompletion(
extra_headers_env,
):
cfg = LLMConfig(model='gpt-4o', api_key='test_key')
async def _gen():
for chunk in [
{'choices': [{'delta': {'content': 'hello'}}]},
{'choices': [{'delta': {'content': ' world'}}]},
]:
yield chunk
await asyncio.sleep(0)
async def _async_side_effect(*args, **kwargs):
assert 'extra_headers' in kwargs
assert kwargs['extra_headers'] == extra_headers_env
return _gen()
with patch(
'openhands.llm.async_llm.litellm_acompletion',
new=AsyncMock(side_effect=_async_side_effect),
):
llm = StreamingLLM(cfg, service_id='svc')
collected = []
async for chunk in llm.async_streaming_completion(
messages=[{'role': 'user', 'content': 'hi'}], stream=True
):
collected.append(chunk['choices'][0]['delta']['content'])
assert ''.join(collected) == 'hello world'
+254 -134
View File
@@ -911,10 +911,16 @@ async def test_delete_conversation():
# Create a mock app conversation service
mock_app_conversation_service = MagicMock()
mock_app_conversation_service.get_app_conversation = AsyncMock(
# Create a mock app conversation info service
mock_app_conversation_info_service = MagicMock()
mock_app_conversation_info_service.get_app_conversation_info = AsyncMock(
return_value=None
)
# Create a mock sandbox service
mock_sandbox_service = MagicMock()
# Mock the conversation manager
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
@@ -932,9 +938,12 @@ async def test_delete_conversation():
# Call delete_conversation
result = await delete_conversation(
request=MagicMock(),
conversation_id='some_conversation_id',
user_id='12345',
app_conversation_service=mock_app_conversation_service,
app_conversation_info_service=mock_app_conversation_info_service,
sandbox_service=mock_sandbox_service,
)
# Verify the result
@@ -972,42 +981,63 @@ async def test_delete_v1_conversation_success():
mock_service = MagicMock()
mock_service_dep.return_value = mock_service
# Mock the conversation exists
mock_app_conversation = AppConversation(
id=conversation_uuid,
created_by_user_id='test_user',
sandbox_id='test-sandbox-id',
title='Test V1 Conversation',
sandbox_status=SandboxStatus.RUNNING,
execution_status=ConversationExecutionStatus.RUNNING,
session_api_key='test-api-key',
selected_repository='test/repo',
selected_branch='main',
git_provider=ProviderType.GITHUB,
trigger=ConversationTrigger.GUI,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
mock_service.get_app_conversation = AsyncMock(
return_value=mock_app_conversation
)
mock_service.delete_app_conversation = AsyncMock(return_value=True)
# Mock the app conversation info service
with patch(
'openhands.server.routes.manage_conversations.app_conversation_info_service_dependency'
) as mock_info_service_dep:
mock_info_service = MagicMock()
mock_info_service_dep.return_value = mock_info_service
# Call delete_conversation with V1 conversation ID
result = await delete_conversation(
conversation_id=conversation_id,
user_id='test_user',
app_conversation_service=mock_service,
)
# Mock the sandbox service
with patch(
'openhands.server.routes.manage_conversations.sandbox_service_dependency'
) as mock_sandbox_service_dep:
mock_sandbox_service = MagicMock()
mock_sandbox_service_dep.return_value = mock_sandbox_service
# Verify the result
assert result is True
# Mock the conversation info exists
mock_app_conversation_info = AppConversation(
id=conversation_uuid,
created_by_user_id='test_user',
sandbox_id='test-sandbox-id',
title='Test V1 Conversation',
sandbox_status=SandboxStatus.RUNNING,
execution_status=ConversationExecutionStatus.RUNNING,
session_api_key='test-api-key',
selected_repository='test/repo',
selected_branch='main',
git_provider=ProviderType.GITHUB,
trigger=ConversationTrigger.GUI,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
mock_info_service.get_app_conversation_info = AsyncMock(
return_value=mock_app_conversation_info
)
mock_service.delete_app_conversation = AsyncMock(return_value=True)
# Verify that get_app_conversation was called
mock_service.get_app_conversation.assert_called_once_with(conversation_uuid)
# Call delete_conversation with V1 conversation ID
result = await delete_conversation(
request=MagicMock(),
conversation_id=conversation_id,
user_id='test_user',
app_conversation_service=mock_service,
app_conversation_info_service=mock_info_service,
sandbox_service=mock_sandbox_service,
)
# Verify that delete_app_conversation was called with the conversation ID
mock_service.delete_app_conversation.assert_called_once_with(conversation_uuid)
# Verify the result
assert result is True
# Verify that get_app_conversation_info was called
mock_info_service.get_app_conversation_info.assert_called_once_with(
conversation_uuid
)
# Verify that delete_app_conversation was called with the conversation ID
mock_service.delete_app_conversation.assert_called_once_with(
conversation_uuid
)
@pytest.mark.asyncio
@@ -1025,25 +1055,46 @@ async def test_delete_v1_conversation_not_found():
mock_service = MagicMock()
mock_service_dep.return_value = mock_service
# Mock the conversation doesn't exist
mock_service.get_app_conversation = AsyncMock(return_value=None)
mock_service.delete_app_conversation = AsyncMock(return_value=False)
# Mock the app conversation info service
with patch(
'openhands.server.routes.manage_conversations.app_conversation_info_service_dependency'
) as mock_info_service_dep:
mock_info_service = MagicMock()
mock_info_service_dep.return_value = mock_info_service
# Call delete_conversation with V1 conversation ID
result = await delete_conversation(
conversation_id=conversation_id,
user_id='test_user',
app_conversation_service=mock_service,
)
# Mock the sandbox service
with patch(
'openhands.server.routes.manage_conversations.sandbox_service_dependency'
) as mock_sandbox_service_dep:
mock_sandbox_service = MagicMock()
mock_sandbox_service_dep.return_value = mock_sandbox_service
# Verify the result
assert result is False
# Mock the conversation doesn't exist
mock_info_service.get_app_conversation_info = AsyncMock(
return_value=None
)
mock_service.delete_app_conversation = AsyncMock(return_value=False)
# Verify that get_app_conversation was called
mock_service.get_app_conversation.assert_called_once_with(conversation_uuid)
# Call delete_conversation with V1 conversation ID
result = await delete_conversation(
request=MagicMock(),
conversation_id=conversation_id,
user_id='test_user',
app_conversation_service=mock_service,
app_conversation_info_service=mock_info_service,
sandbox_service=mock_sandbox_service,
)
# Verify that delete_app_conversation was NOT called
mock_service.delete_app_conversation.assert_not_called()
# Verify the result
assert result is False
# Verify that get_app_conversation_info was called
mock_info_service.get_app_conversation_info.assert_called_once_with(
conversation_uuid
)
# Verify that delete_app_conversation was NOT called
mock_service.delete_app_conversation.assert_not_called()
@pytest.mark.asyncio
@@ -1091,19 +1142,40 @@ async def test_delete_v1_conversation_invalid_uuid():
mock_runtime_cls.delete = AsyncMock()
mock_get_runtime_cls.return_value = mock_runtime_cls
# Call delete_conversation
result = await delete_conversation(
conversation_id=conversation_id,
user_id='test_user',
app_conversation_service=mock_service,
)
# Mock the app conversation info service
with patch(
'openhands.server.routes.manage_conversations.app_conversation_info_service_dependency'
) as mock_info_service_dep:
mock_info_service = MagicMock()
mock_info_service_dep.return_value = mock_info_service
# Verify the result
assert result is True
# Mock the sandbox service
with patch(
'openhands.server.routes.manage_conversations.sandbox_service_dependency'
) as mock_sandbox_service_dep:
mock_sandbox_service = MagicMock()
mock_sandbox_service_dep.return_value = mock_sandbox_service
# Verify V0 logic was used
mock_store.delete_metadata.assert_called_once_with(conversation_id)
mock_runtime_cls.delete.assert_called_once_with(conversation_id)
# Call delete_conversation
result = await delete_conversation(
request=MagicMock(),
conversation_id=conversation_id,
user_id='test_user',
app_conversation_service=mock_service,
app_conversation_info_service=mock_info_service,
sandbox_service=mock_sandbox_service,
)
# Verify the result
assert result is True
# Verify V0 logic was used
mock_store.delete_metadata.assert_called_once_with(
conversation_id
)
mock_runtime_cls.delete.assert_called_once_with(
conversation_id
)
@pytest.mark.asyncio
@@ -1121,57 +1193,84 @@ async def test_delete_v1_conversation_service_error():
mock_service = MagicMock()
mock_service_dep.return_value = mock_service
# Mock service error
mock_service.get_app_conversation = AsyncMock(
side_effect=Exception('Service error')
)
# Mock V0 conversation logic as fallback
# Mock the app conversation info service
with patch(
'openhands.server.routes.manage_conversations.ConversationStoreImpl.get_instance'
) as mock_get_instance:
mock_store = MagicMock()
mock_store.get_metadata = AsyncMock(
return_value=ConversationMetadata(
conversation_id=conversation_id,
title='Test V0 Conversation',
created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'),
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'),
selected_repository='test/repo',
user_id='test_user',
)
)
mock_store.delete_metadata = AsyncMock()
mock_get_instance.return_value = mock_store
'openhands.server.routes.manage_conversations.app_conversation_info_service_dependency'
) as mock_info_service_dep:
mock_info_service = MagicMock()
mock_info_service_dep.return_value = mock_info_service
# Mock conversation manager
# Mock the sandbox service
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_manager:
mock_manager.is_agent_loop_running = AsyncMock(return_value=False)
mock_manager.get_connections = AsyncMock(return_value={})
'openhands.server.routes.manage_conversations.sandbox_service_dependency'
) as mock_sandbox_service_dep:
mock_sandbox_service = MagicMock()
mock_sandbox_service_dep.return_value = mock_sandbox_service
# Mock runtime
# Mock service error
mock_info_service.get_app_conversation_info = AsyncMock(
side_effect=Exception('Service error')
)
# Mock V0 conversation logic as fallback
with patch(
'openhands.server.routes.manage_conversations.get_runtime_cls'
) as mock_get_runtime_cls:
mock_runtime_cls = MagicMock()
mock_runtime_cls.delete = AsyncMock()
mock_get_runtime_cls.return_value = mock_runtime_cls
# Call delete_conversation
result = await delete_conversation(
conversation_id=conversation_id,
user_id='test_user',
app_conversation_service=mock_service,
'openhands.server.routes.manage_conversations.ConversationStoreImpl.get_instance'
) as mock_get_instance:
mock_store = MagicMock()
mock_store.get_metadata = AsyncMock(
return_value=ConversationMetadata(
conversation_id=conversation_id,
title='Test V0 Conversation',
created_at=datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
),
last_updated_at=datetime.fromisoformat(
'2025-01-01T00:01:00+00:00'
),
selected_repository='test/repo',
user_id='test_user',
)
)
mock_store.delete_metadata = AsyncMock()
mock_get_instance.return_value = mock_store
# Verify the result (should fallback to V0)
assert result is True
# Mock conversation manager
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_manager:
mock_manager.is_agent_loop_running = AsyncMock(
return_value=False
)
mock_manager.get_connections = AsyncMock(return_value={})
# Verify V0 logic was used
mock_store.delete_metadata.assert_called_once_with(conversation_id)
mock_runtime_cls.delete.assert_called_once_with(conversation_id)
# Mock runtime
with patch(
'openhands.server.routes.manage_conversations.get_runtime_cls'
) as mock_get_runtime_cls:
mock_runtime_cls = MagicMock()
mock_runtime_cls.delete = AsyncMock()
mock_get_runtime_cls.return_value = mock_runtime_cls
# Call delete_conversation
result = await delete_conversation(
request=MagicMock(),
conversation_id=conversation_id,
user_id='test_user',
app_conversation_service=mock_service,
app_conversation_info_service=mock_info_service,
sandbox_service=mock_sandbox_service,
)
# Verify the result (should fallback to V0)
assert result is True
# Verify V0 logic was used
mock_store.delete_metadata.assert_called_once_with(
conversation_id
)
mock_runtime_cls.delete.assert_called_once_with(
conversation_id
)
@pytest.mark.asyncio
@@ -1195,42 +1294,63 @@ async def test_delete_v1_conversation_with_agent_server():
mock_service = MagicMock()
mock_service_dep.return_value = mock_service
# Mock the conversation exists with running sandbox
mock_app_conversation = AppConversation(
id=conversation_uuid,
created_by_user_id='test_user',
sandbox_id='test-sandbox-id',
title='Test V1 Conversation',
sandbox_status=SandboxStatus.RUNNING,
execution_status=ConversationExecutionStatus.RUNNING,
session_api_key='test-api-key',
selected_repository='test/repo',
selected_branch='main',
git_provider=ProviderType.GITHUB,
trigger=ConversationTrigger.GUI,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
mock_service.get_app_conversation = AsyncMock(
return_value=mock_app_conversation
)
mock_service.delete_app_conversation = AsyncMock(return_value=True)
# Mock the app conversation info service
with patch(
'openhands.server.routes.manage_conversations.app_conversation_info_service_dependency'
) as mock_info_service_dep:
mock_info_service = MagicMock()
mock_info_service_dep.return_value = mock_info_service
# Call delete_conversation with V1 conversation ID
result = await delete_conversation(
conversation_id=conversation_id,
user_id='test_user',
app_conversation_service=mock_service,
)
# Mock the sandbox service
with patch(
'openhands.server.routes.manage_conversations.sandbox_service_dependency'
) as mock_sandbox_service_dep:
mock_sandbox_service = MagicMock()
mock_sandbox_service_dep.return_value = mock_sandbox_service
# Verify the result
assert result is True
# Mock the conversation exists with running sandbox
mock_app_conversation_info = AppConversation(
id=conversation_uuid,
created_by_user_id='test_user',
sandbox_id='test-sandbox-id',
title='Test V1 Conversation',
sandbox_status=SandboxStatus.RUNNING,
execution_status=ConversationExecutionStatus.RUNNING,
session_api_key='test-api-key',
selected_repository='test/repo',
selected_branch='main',
git_provider=ProviderType.GITHUB,
trigger=ConversationTrigger.GUI,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
mock_info_service.get_app_conversation_info = AsyncMock(
return_value=mock_app_conversation_info
)
mock_service.delete_app_conversation = AsyncMock(return_value=True)
# Verify that get_app_conversation was called
mock_service.get_app_conversation.assert_called_once_with(conversation_uuid)
# Call delete_conversation with V1 conversation ID
result = await delete_conversation(
request=MagicMock(),
conversation_id=conversation_id,
user_id='test_user',
app_conversation_service=mock_service,
app_conversation_info_service=mock_info_service,
sandbox_service=mock_sandbox_service,
)
# Verify that delete_app_conversation was called with the conversation ID
mock_service.delete_app_conversation.assert_called_once_with(conversation_uuid)
# Verify the result
assert result is True
# Verify that get_app_conversation_info was called
mock_info_service.get_app_conversation_info.assert_called_once_with(
conversation_uuid
)
# Verify that delete_app_conversation was called with the conversation ID
mock_service.delete_app_conversation.assert_called_once_with(
conversation_uuid
)
@pytest.mark.asyncio
@@ -11,10 +11,21 @@ from openhands.app_server.app_conversation.app_conversation_info_service import
AppConversationInfoService,
)
from openhands.app_server.app_conversation.app_conversation_models import (
AgentType,
AppConversationInfo,
AppConversationPage,
AppConversationStartRequest,
AppConversationStartTask,
AppConversationStartTaskStatus,
)
from openhands.app_server.app_conversation.app_conversation_service import (
AppConversationService,
)
from openhands.microagent.microagent import KnowledgeMicroagent, RepoMicroagent
from openhands.microagent.types import MicroagentMetadata, MicroagentType
from openhands.server.data_models.conversation_info_result_set import (
ConversationInfoResultSet,
)
from openhands.server.routes.conversation import (
AddMessageRequest,
add_message,
@@ -22,11 +33,15 @@ from openhands.server.routes.conversation import (
)
from openhands.server.routes.manage_conversations import (
UpdateConversationRequest,
search_conversations,
update_conversation,
)
from openhands.server.session.conversation import ServerConversation
from openhands.storage.conversation.conversation_store import ConversationStore
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
ConversationTrigger,
)
@pytest.mark.asyncio
@@ -1125,3 +1140,322 @@ async def test_add_message_empty_message():
call_args = mock_manager.send_event_to_conversation.call_args
message_data = call_args[0][1]
assert message_data['args']['content'] == ''
@pytest.mark.sub_conversation
@pytest.mark.asyncio
async def test_create_sub_conversation_with_planning_agent():
"""Test creating a sub-conversation from a parent conversation with planning agent."""
from uuid import uuid4
parent_conversation_id = uuid4()
user_id = 'test_user_456'
sandbox_id = 'test_sandbox_123'
# Create mock parent conversation info
parent_info = AppConversationInfo(
id=parent_conversation_id,
created_by_user_id=user_id,
sandbox_id=sandbox_id,
selected_repository='test/repo',
selected_branch='main',
git_provider=None,
title='Parent Conversation',
llm_model='anthropic/claude-3-5-sonnet-20241022',
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
# Create sub-conversation request with planning agent
sub_conversation_request = AppConversationStartRequest(
parent_conversation_id=parent_conversation_id,
agent_type=AgentType.PLAN,
initial_message=None,
)
# Create mock app conversation service
mock_app_conversation_service = MagicMock(spec=AppConversationService)
mock_app_conversation_info_service = MagicMock(spec=AppConversationInfoService)
# Mock the service to return parent info
mock_app_conversation_info_service.get_app_conversation_info = AsyncMock(
return_value=parent_info
)
# Mock the start_app_conversation method to return a task
async def mock_start_generator(request):
task = AppConversationStartTask(
id=uuid4(),
created_by_user_id=user_id,
status=AppConversationStartTaskStatus.READY,
app_conversation_id=uuid4(),
sandbox_id=sandbox_id,
agent_server_url='http://agent-server:8000',
request=request,
)
yield task
mock_app_conversation_service.start_app_conversation = mock_start_generator
# Test the service method directly
async for task in mock_app_conversation_service.start_app_conversation(
sub_conversation_request
):
# Verify the task was created with planning agent
assert task is not None
assert task.status == AppConversationStartTaskStatus.READY
assert task.request.agent_type == AgentType.PLAN
assert task.request.parent_conversation_id == parent_conversation_id
assert task.sandbox_id == sandbox_id
break
@pytest.mark.asyncio
async def test_search_conversations_include_sub_conversations_default_false():
"""Test that include_sub_conversations defaults to False when not provided."""
with patch('openhands.server.routes.manage_conversations.config') as mock_config:
mock_config.conversation_max_age_seconds = 864000 # 10 days
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_manager:
async def mock_get_running_agent_loops(*args, **kwargs):
return set()
async def mock_get_connections(*args, **kwargs):
return {}
async def get_agent_loop_info(*args, **kwargs):
return []
mock_manager.get_running_agent_loops = mock_get_running_agent_loops
mock_manager.get_connections = mock_get_connections
mock_manager.get_agent_loop_info = get_agent_loop_info
with patch(
'openhands.server.routes.manage_conversations.datetime'
) as mock_datetime:
mock_datetime.now.return_value = datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
)
mock_datetime.fromisoformat = datetime.fromisoformat
mock_datetime.timezone = timezone
# Mock the conversation store
mock_store = MagicMock()
mock_store.search = AsyncMock(
return_value=ConversationInfoResultSet(results=[])
)
# Create a mock app conversation service
mock_app_conversation_service = AsyncMock()
mock_app_conversation_service.search_app_conversations.return_value = (
AppConversationPage(items=[])
)
# Call search_conversations without include_sub_conversations parameter
await search_conversations(
page_id=None,
limit=20,
selected_repository=None,
conversation_trigger=None,
conversation_store=mock_store,
app_conversation_service=mock_app_conversation_service,
)
# Verify that search_app_conversations was called with include_sub_conversations=False (default)
mock_app_conversation_service.search_app_conversations.assert_called_once()
call_kwargs = (
mock_app_conversation_service.search_app_conversations.call_args[1]
)
assert call_kwargs.get('include_sub_conversations') is False
@pytest.mark.asyncio
async def test_search_conversations_include_sub_conversations_explicit_false():
"""Test that include_sub_conversations=False is properly passed through."""
with patch('openhands.server.routes.manage_conversations.config') as mock_config:
mock_config.conversation_max_age_seconds = 864000 # 10 days
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_manager:
async def mock_get_running_agent_loops(*args, **kwargs):
return set()
async def mock_get_connections(*args, **kwargs):
return {}
async def get_agent_loop_info(*args, **kwargs):
return []
mock_manager.get_running_agent_loops = mock_get_running_agent_loops
mock_manager.get_connections = mock_get_connections
mock_manager.get_agent_loop_info = get_agent_loop_info
with patch(
'openhands.server.routes.manage_conversations.datetime'
) as mock_datetime:
mock_datetime.now.return_value = datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
)
mock_datetime.fromisoformat = datetime.fromisoformat
mock_datetime.timezone = timezone
# Mock the conversation store
mock_store = MagicMock()
mock_store.search = AsyncMock(
return_value=ConversationInfoResultSet(results=[])
)
# Create a mock app conversation service
mock_app_conversation_service = AsyncMock()
mock_app_conversation_service.search_app_conversations.return_value = (
AppConversationPage(items=[])
)
# Call search_conversations with include_sub_conversations=False
await search_conversations(
page_id=None,
limit=20,
selected_repository=None,
conversation_trigger=None,
include_sub_conversations=False,
conversation_store=mock_store,
app_conversation_service=mock_app_conversation_service,
)
# Verify that search_app_conversations was called with include_sub_conversations=False
mock_app_conversation_service.search_app_conversations.assert_called_once()
call_kwargs = (
mock_app_conversation_service.search_app_conversations.call_args[1]
)
assert call_kwargs.get('include_sub_conversations') is False
@pytest.mark.asyncio
async def test_search_conversations_include_sub_conversations_explicit_true():
"""Test that include_sub_conversations=True is properly passed through."""
with patch('openhands.server.routes.manage_conversations.config') as mock_config:
mock_config.conversation_max_age_seconds = 864000 # 10 days
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_manager:
async def mock_get_running_agent_loops(*args, **kwargs):
return set()
async def mock_get_connections(*args, **kwargs):
return {}
async def get_agent_loop_info(*args, **kwargs):
return []
mock_manager.get_running_agent_loops = mock_get_running_agent_loops
mock_manager.get_connections = mock_get_connections
mock_manager.get_agent_loop_info = get_agent_loop_info
with patch(
'openhands.server.routes.manage_conversations.datetime'
) as mock_datetime:
mock_datetime.now.return_value = datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
)
mock_datetime.fromisoformat = datetime.fromisoformat
mock_datetime.timezone = timezone
# Mock the conversation store
mock_store = MagicMock()
mock_store.search = AsyncMock(
return_value=ConversationInfoResultSet(results=[])
)
# Create a mock app conversation service
mock_app_conversation_service = AsyncMock()
mock_app_conversation_service.search_app_conversations.return_value = (
AppConversationPage(items=[])
)
# Call search_conversations with include_sub_conversations=True
await search_conversations(
page_id=None,
limit=20,
selected_repository=None,
conversation_trigger=None,
include_sub_conversations=True,
conversation_store=mock_store,
app_conversation_service=mock_app_conversation_service,
)
# Verify that search_app_conversations was called with include_sub_conversations=True
mock_app_conversation_service.search_app_conversations.assert_called_once()
call_kwargs = (
mock_app_conversation_service.search_app_conversations.call_args[1]
)
assert call_kwargs.get('include_sub_conversations') is True
@pytest.mark.asyncio
async def test_search_conversations_include_sub_conversations_with_other_filters():
"""Test that include_sub_conversations works correctly with other filters."""
with patch('openhands.server.routes.manage_conversations.config') as mock_config:
mock_config.conversation_max_age_seconds = 864000 # 10 days
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_manager:
async def mock_get_running_agent_loops(*args, **kwargs):
return set()
async def mock_get_connections(*args, **kwargs):
return {}
async def get_agent_loop_info(*args, **kwargs):
return []
mock_manager.get_running_agent_loops = mock_get_running_agent_loops
mock_manager.get_connections = mock_get_connections
mock_manager.get_agent_loop_info = get_agent_loop_info
with patch(
'openhands.server.routes.manage_conversations.datetime'
) as mock_datetime:
mock_datetime.now.return_value = datetime.fromisoformat(
'2025-01-01T00:00:00+00:00'
)
mock_datetime.fromisoformat = datetime.fromisoformat
mock_datetime.timezone = timezone
# Mock the conversation store
mock_store = MagicMock()
mock_store.search = AsyncMock(
return_value=ConversationInfoResultSet(results=[])
)
# Create a mock app conversation service
mock_app_conversation_service = AsyncMock()
mock_app_conversation_service.search_app_conversations.return_value = (
AppConversationPage(items=[])
)
# Create a valid base64-encoded page_id for testing
import base64
page_id_data = json.dumps({'v0': None, 'v1': 'test_v1_page_id'})
encoded_page_id = base64.b64encode(page_id_data.encode()).decode()
# Call search_conversations with include_sub_conversations and other filters
await search_conversations(
page_id=encoded_page_id,
limit=50,
selected_repository='test/repo',
conversation_trigger=ConversationTrigger.GUI,
include_sub_conversations=True,
conversation_store=mock_store,
app_conversation_service=mock_app_conversation_service,
)
# Verify that search_app_conversations was called with all parameters including include_sub_conversations=True
mock_app_conversation_service.search_app_conversations.assert_called_once()
call_kwargs = (
mock_app_conversation_service.search_app_conversations.call_args[1]
)
assert call_kwargs.get('include_sub_conversations') is True
assert call_kwargs.get('page_id') == 'test_v1_page_id'
assert call_kwargs.get('limit') == 50
+356
View File
@@ -0,0 +1,356 @@
"""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',
)