mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e754edb798 | |||
| d6fab190bf | |||
| 833aae1833 | |||
| 2841e35f24 | |||
| 8115d82f96 | |||
| 7263657937 | |||
| 34fcc50350 | |||
| 24a9758434 | |||
| f24d2a61e6 | |||
| e3d0380c2e | |||
| 8c3f93ddc4 | |||
| bc86796a67 | |||
| d5b2d2ebc5 | |||
| b605c96796 | |||
| 8192184d3e | |||
| 8e75f25108 | |||
| 73fe865c7e | |||
| 95a44f4248 | |||
| c4ff3d6483 | |||
| bace2ef8a1 | |||
| 4e7846928b | |||
| 62dbb20846 |
@@ -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')
|
||||
@@ -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}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
+23
-31
@@ -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 =
|
||||
|
||||
@@ -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
-26
@@ -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>
|
||||
);
|
||||
|
||||
+39
@@ -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}
|
||||
|
||||
+7
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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 l’aide de l’IA 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": "Агент планування ініціалізовано"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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) {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -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'
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
Reference in New Issue
Block a user