Merge branch 'main' into migrate-org-db-litellm-from-deploy
@@ -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')
|
||||
@@ -82,6 +82,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)
|
||||
|
||||
|
||||
233
frontend/__tests__/posthog-tracking.test.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
afterEach,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { screen, waitFor, render, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createMockAgentErrorEvent } from "#/mocks/mock-ws-helpers";
|
||||
import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context";
|
||||
import { conversationWebSocketTestSetup } from "./helpers/msw-websocket-setup";
|
||||
import { ConnectionStatusComponent } from "./helpers/websocket-test-components";
|
||||
|
||||
// Mock the tracking function
|
||||
const mockTrackCreditLimitReached = vi.fn();
|
||||
|
||||
// Mock useTracking hook
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackCreditLimitReached: mockTrackCreditLimitReached,
|
||||
trackLoginButtonClick: vi.fn(),
|
||||
trackConversationCreated: vi.fn(),
|
||||
trackPushButtonClick: vi.fn(),
|
||||
trackPullButtonClick: vi.fn(),
|
||||
trackCreatePrButtonClick: vi.fn(),
|
||||
trackGitProviderConnected: vi.fn(),
|
||||
trackUserSignupCompleted: vi.fn(),
|
||||
trackCreditsPurchased: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useActiveConversation hook
|
||||
vi.mock("#/hooks/query/use-active-conversation", () => ({
|
||||
useActiveConversation: () => ({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
// MSW WebSocket mock setup
|
||||
const { wsLink, server: mswServer } = conversationWebSocketTestSetup();
|
||||
|
||||
beforeAll(() => {
|
||||
// The global MSW server from vitest.setup.ts is already running
|
||||
// We just need to start our WebSocket-specific server
|
||||
mswServer.listen({ onUnhandledRequest: "bypass" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clear all mocks before each test
|
||||
mockTrackCreditLimitReached.mockClear();
|
||||
mswServer.resetHandlers();
|
||||
// Clean up any React components
|
||||
cleanup();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Close the WebSocket MSW server
|
||||
mswServer.close();
|
||||
|
||||
// Give time for any pending WebSocket connections to close. This is very important to prevent serious memory leaks
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function to render components with all necessary providers
|
||||
function renderWithProviders(
|
||||
children: React.ReactNode,
|
||||
conversationId = "test-conversation-123",
|
||||
conversationUrl = "http://localhost:3000/api/conversations/test-conversation-123",
|
||||
) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConversationWebSocketProvider
|
||||
conversationId={conversationId}
|
||||
conversationUrl={conversationUrl}
|
||||
sessionApiKey={null}
|
||||
>
|
||||
{children}
|
||||
</ConversationWebSocketProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("PostHog Analytics Tracking", () => {
|
||||
describe("Credit Limit Tracking", () => {
|
||||
it("should track credit_limit_reached when AgentErrorEvent contains budget error", async () => {
|
||||
// Create a mock AgentErrorEvent with budget-related error message
|
||||
const mockBudgetErrorEvent = createMockAgentErrorEvent({
|
||||
error: "ExceededBudget: Task exceeded maximum budget of $10.00",
|
||||
});
|
||||
|
||||
// Set up MSW to send the budget error event when connection is established
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send the mock budget error event after connection
|
||||
client.send(JSON.stringify(mockBudgetErrorEvent));
|
||||
}),
|
||||
);
|
||||
|
||||
// Render with all providers
|
||||
renderWithProviders(<ConnectionStatusComponent />);
|
||||
|
||||
// Wait for connection to be established
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
// Wait for the tracking event to be captured
|
||||
await waitFor(() => {
|
||||
expect(mockTrackCreditLimitReached).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
conversationId: "test-conversation-123",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should track credit_limit_reached when AgentErrorEvent contains 'credit' keyword", async () => {
|
||||
// Create error with "credit" keyword (case-insensitive)
|
||||
const mockCreditErrorEvent = createMockAgentErrorEvent({
|
||||
error: "Insufficient CREDIT to complete this operation",
|
||||
});
|
||||
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
client.send(JSON.stringify(mockCreditErrorEvent));
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithProviders(<ConnectionStatusComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTrackCreditLimitReached).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
conversationId: "test-conversation-123",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should NOT track credit_limit_reached for non-budget errors", async () => {
|
||||
// Create a regular error without budget/credit keywords
|
||||
const mockRegularErrorEvent = createMockAgentErrorEvent({
|
||||
error: "Failed to execute command: Permission denied",
|
||||
});
|
||||
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
client.send(JSON.stringify(mockRegularErrorEvent));
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithProviders(<ConnectionStatusComponent />);
|
||||
|
||||
// Wait for connection and error to be processed
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
// Verify that credit_limit_reached was NOT tracked
|
||||
expect(mockTrackCreditLimitReached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should only track credit_limit_reached once per error event", async () => {
|
||||
const mockBudgetErrorEvent = createMockAgentErrorEvent({
|
||||
error: "Budget exceeded: $10.00 limit reached",
|
||||
});
|
||||
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send the same error event twice
|
||||
client.send(JSON.stringify(mockBudgetErrorEvent));
|
||||
client.send(
|
||||
JSON.stringify({ ...mockBudgetErrorEvent, id: "different-id" }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithProviders(<ConnectionStatusComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTrackCreditLimitReached).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// Both calls should be for credit_limit_reached (once per event)
|
||||
expect(mockTrackCreditLimitReached).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
conversationId: "test-conversation-123",
|
||||
}),
|
||||
);
|
||||
expect(mockTrackCreditLimitReached).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
conversationId: "test-conversation-123",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 803 B After Width: | Height: | Size: 848 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1,32 +1,7 @@
|
||||
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1365 1365" width="1365" height="1365">
|
||||
<title>safari-pinned-tab-svg</title>
|
||||
<defs>
|
||||
<clipPath clipPathUnits="userSpaceOnUse" id="cp1">
|
||||
<path d="m655.2 313.1v822.23h-622.69v-822.23z"/>
|
||||
</clipPath>
|
||||
<clipPath clipPathUnits="userSpaceOnUse" id="cp2">
|
||||
<path d="m1308.84 304v828.5h-617.24v-828.5z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<style>
|
||||
.s0 { fill: none }
|
||||
.s1 { fill: #000000 }
|
||||
</style>
|
||||
<g id="surface1">
|
||||
<path class="s0" d="m1258.6 499.2c-40.8-26.1-68 13.8-64.7 68.1l-0.3 0.4c0.1-56.6-7.3-119.2-31.7-169.8-8.7-17.9-26.2-47.4-61-33.5-15.2 6.1-29 24.4-21.9 71.7 0 0 8 49.7 6.5 112.2v0.8c-9.9-172.2-47.3-224.7-100.7-221.3-17.1 3.1-40.5 10.8-32.6 63.8 0 0 8.5 55.2 11.3 99.2l0.2 2.2h-0.2c-25.2-94.9-59-96.2-83.5-92.5-22.3 3.3-46.6 27.3-34.3 74.7 38.6 148.4 31 327.2 28.2 352.9-7.9-17.6-10.3-31.4-21.3-50.7-43.9-77.1-64.8-82.8-90.4-84-25.5-1.1-53 15.2-51.2 46.3 1.9 31.1 17.1 36.3 38.7 79.6 16.9 33.8 21.7 78 55.7 158.4 28.1 66.5 101.6 139.5 235.6 130.8 108.5-3.7 270.6-43.2 242.4-302.5-7-45-1.7-82.7 1.9-121.4 5.8-60 14.1-159.4-26.7-185.5z"/>
|
||||
<path class="s0" d="m580.9 695.3c-25.7 1.7-46.4 7.7-89 85.7-10.6 19.4-12.7 33.3-20.3 50.9-3.3-25.5-14-204.2 21.9-353.4 11.4-47.5-13.3-71-35.6-73.9-24.6-3.3-58.5-1.3-81.9 94.6h-0.3l0.4-2.8c1.9-44 9.5-99.4 9.5-99.4 6.9-53.1-16.6-60.3-33.7-63.2-53.4-2.3-89.7 50.4-96.7 221.1h-0.2c-2.4-61.8 4.6-111 4.6-111 6.3-47.5-7.9-65.5-23.2-71.3-35-13.3-52 16.6-60.3 34.7-23.6 51-29.9 113.7-28.7 170.3l-0.4-0.4c2.4-54.2-25.6-93.7-65.9-66.9-40.3 26.9-30.1 126.2-23.4 186 4.5 38.6 10.4 76.2 4.1 121.4-23.5 259.7 139.3 296.1 247.8 297.8 134.1 6.1 206.3-68.3 233.3-135.4 32.5-80.9 36.6-125.3 52.8-159.3 20.8-43.8 36-49.2 37.3-80.3 1.3-31.1-26.5-46.9-52-45.3z"/>
|
||||
<g id="Clip-Path" clip-path="url(#cp1)">
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s1" d="m634.4 695.7c11.9 12 17.8 27.9 17.1 45.7-1 24.6-9.5 37.8-19.4 53.2-5.8 9-12.3 19.2-19.8 34.9-6.6 13.8-11.2 30.4-17 51.5-7.6 27.3-17 61.2-35.3 106.7-14.1 35.1-71.4 146.1-231.3 147.6q-9.8 0.1-20-0.3c-93.6-1.5-164.2-28.5-209.3-80.6-46.9-54-65.8-134.2-56.4-238.4l0.1-0.9c5.2-37.6 1.5-69.5-2.6-103.3-0.5-4.4-1-8.7-1.5-13.1-9.4-82.5-15.4-173.1 31.8-204.6 27.9-18.5 49.3-11.1 59.6-4.9 1 0.6 1.9 1.2 2.9 1.9 4.5-32.3 12.6-63.9 25.6-92.2 11.2-24.2 37.1-62.2 83.8-44.5 7.6 3 17.3 8.8 24.8 20.2 6.7-14.7 14.5-26.6 23.5-35.8 16.6-17.2 37.4-25.4 61.6-24.3l2.1 0.2c39.2 6.5 55.9 35 49.4 84.9 0 0 0 0.3 0 0.6 20-17 40.4-16.9 56.1-14.8 17.2 2.2 32.8 12.1 42.8 27.2 8.6 13 17.1 35.8 8.7 70.7-22.3 92.9-26.1 198.5-25.2 268.8 36.3-61.5 59.9-74.1 93.2-76.2 20.8-1.3 41.3 6 54.8 19.8zm-316.9-329.4c-35.1 36.2-49.7 148.5-43.4 333.7 19.5-3 39.5-5.2 59.6-6.5 4.7-91.2 13.3-153.7 23.7-197q0-0.5 0-0.9c2-44.4 9.3-98.9 9.7-101.2 4.7-36.3-5.7-39.2-17-41.1-13.2-0.3-23.5 3.8-32.5 13zm-124.6 49.5c-50 108.3-16.2 277.2-8.5 303.8 16.1-4.8 33.8-9.2 52.4-13-2.1-57.7-2.3-108.1-0.6-151.9-2.3-62.3 4.4-111.4 4.7-113.5 1.8-13 4.2-44.5-11.1-50.3-10.9-4.1-22.8-5.7-36.9 24.8zm407.9 357.3c8.9-13.8 12.6-19.5 13.2-33.3 0.2-6.7-1.6-12-5.9-16.3-5.9-6.1-16-9.4-26.1-8.8-17.3 1.2-33.6 2.2-73.8 75.9-5.6 10.4-8.5 18.9-11.8 28.6-2.1 6.3-4.4 13.2-7.7 20.8-0.1 0.3-0.2 0.6-0.4 0.8-1.4 3.3-3 6.8-4.8 10.4-17.6 34.2-46.2 53.4-76.5 51.6-10.4-0.7-18.2-9.9-17.6-20.6 0.6-10.7 9.6-18.7 19.8-18.2 18 1.1 33.1-15.3 41.2-31.1 0.7-1.4 1.3-2.8 2-4.2-4-41.1-11.8-210.7 22.9-354.8 3.9-16.5 2.8-30.1-3.3-39.3-5.6-8.4-13.4-10.3-16.5-10.7-11.2-1.5-19.3-0.9-27.8 6.4-20.5 17.8-46.8 77.8-56.4 261.8 5.8 0 11.6 0 17.3 0.2 10.3 0.3 18.5 9.2 18.2 19.9-0.3 10.7-8.8 19.1-19.2 18.9-95.8-2.8-204.4 22.8-250.2 47.9-2.8 1.5-5.7 2.3-8.6 2.3-6.8 0.1-13.4-3.7-16.8-10.3-4.8-9.5-1.4-21.2 7.8-26.3 8.1-4.4 17.9-8.9 28.9-13.1-6.7-22.4-18.6-82-20.1-150.6-0.3-1.5-0.5-3-0.4-4.6 1.2-29.4-7.6-48.4-16.4-53.6-5.2-3.1-12.1-1.8-20.6 3.9-31.6 21-19.4 127.4-14.9 167.4 0.5 4.3 1 8.6 1.5 12.9 4.1 34.7 8.4 70.6 2.6 113-8.3 92.7 7.5 162.9 47 208.5 38.4 44.2 98 66.3 182.4 67.6 151.5 7.1 203.3-92.6 215.7-123.3 17.4-43.4 26.5-76.2 33.8-102.5 6.4-22.9 11.4-41 19.5-58 8.5-17.9 16.1-29.7 22.2-39.2z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Clip-Path" clip-path="url(#cp2)">
|
||||
<g>
|
||||
<path fill-rule="evenodd" class="s1" d="m1301.9 802.9l0.1 0.9c11.3 104-6.3 184.6-52.2 239.5-44.1 52.8-114.2 81.3-208.3 84.5q-10.2 0.7-19.9 0.8c-159.5 1.6-218.8-108.4-233.5-143.2-19.1-45.1-29.1-78.9-37.2-106-6.2-21-11.1-37.5-18-51.2-7.7-15.5-14.4-25.6-20.4-34.5-10.1-15.1-18.8-28.2-20.3-52.8-1.1-17.7 4.5-33.6 16.2-45.9 13.3-14 33.8-21.8 54.5-20.9 33.3 1.5 57.1 13.7 94.5 74.5-0.3-70.4-6-175.9-30-268.4-9-34.8-0.9-57.7 7.5-70.8 9.7-15.3 25.1-25.5 42.2-28.1 15.7-2.3 36.2-2.9 56.4 13.8 0-0.2 0-0.4 0-0.4-7.4-49.9 8.8-78.8 47.9-86l2.1-0.3c24.1-1.6 45 6.3 62 23.2 9.1 9 17.1 20.7 24.1 35.3 7.4-11.6 16.9-17.6 24.5-20.6 46.4-18.7 72.9 18.9 84.5 42.9 13.5 28 22.1 59.5 27.3 91.6 0.9-0.6 1.8-1.3 2.8-1.9 10.2-6.3 31.5-14.3 59.7 3.8 47.8 30.6 43.4 121.3 35.5 203.9-0.4 4.4-0.8 8.7-1.3 13.1-3.4 33.9-6.6 65.9-0.8 103.3zm-197.6-256.5c2.5 43.7 3.2 94.2 2.1 151.9 18.7 3.5 36.4 7.5 52.7 12 7.2-26.7 37.9-196.3-14-303.7-14.6-30.1-26.5-28.4-37.4-24.1-15.1 6.1-12.2 37.5-10.2 50.7 0.4 1.9 8 50.9 6.8 113.2zm-117.5-199.2c-11.4 2.2-21.6 5.2-16.3 41.6 0.4 2.1 8.8 56.4 11.5 100.8q0 0.5 0 0.9c11.2 43.2 21 105.4 27.2 196.6 20.2 0.9 40.2 2.7 59.7 5.3 3-185.3-13.5-297.2-49.3-332.8-9.1-9.1-19.5-13-32.7-12.4zm278.5 348.5c0.5-4.3 0.9-8.6 1.3-13 3.9-40.1 14.1-146.6-17.9-167-8.6-5.5-15.5-6.7-20.6-3.5-8.7 5.4-17.2 24.5-15.4 53.9 0.1 1.5 0 3.1-0.3 4.6-0.4 68.5-11.2 128.4-17.5 150.9 11.1 4.1 20.9 8.3 29.1 12.6 9.3 4.9 13 16.6 8.3 26.2-3.3 6.7-9.8 10.6-16.6 10.6-2.9 0-5.9-0.6-8.6-2.1-46.2-24.3-155.4-47.7-251-43.2-10.5 0.3-19.2-7.7-19.6-18.5-0.5-10.7 7.5-19.8 17.9-20.2 5.6-0.3 11.4-0.5 17.2-0.6-12.8-183.8-40.2-243.3-61-260.7-8.6-7.1-16.8-7.6-27.9-5.9-3.2 0.5-10.9 2.5-16.4 11.1-5.9 9.3-6.8 22.9-2.5 39.3 37.3 143.5 32.4 313.2 29.2 354.3 0.7 1.4 1.3 2.7 2.1 4.2 8.4 15.6 23.7 31.7 41.7 30.3 10.3-0.7 19.3 7.2 20.1 17.9 0.8 10.6-6.9 20-17.2 20.8-30.3 2.5-59.3-16.3-77.4-50.1-2-3.6-3.6-7-5.1-10.3-0.1-0.2-0.2-0.5-0.3-0.7-3.5-7.5-5.9-14.5-8.2-20.8-3.5-9.6-6.4-18-12.3-28.3-41.6-72.9-57.9-73.7-75.1-74.5-10.2-0.5-20.2 3.1-26.1 9.3-4.1 4.3-5.9 9.7-5.5 16.4 0.8 13.8 4.6 19.4 13.7 33.1 6.3 9.4 14.1 21 23 38.7 8.3 16.9 13.7 34.8 20.5 57.7 7.7 26.2 17.4 58.7 35.6 101.8 12.9 30.5 66.7 129.1 217.3 119.2 84.9-2.9 144.2-26.2 181.7-71.1 38.7-46.3 53.2-116.8 43.3-209.4-6.6-42.3-3-78.2 0.5-113z"/>
|
||||
</g>
|
||||
</g>
|
||||
<path class="s1" d="m739.8 434.2c-3 0-6.1-0.6-9-2-10.5-5-15.2-18-10.3-28.9 15.7-35.6 38.8-68.4 66.6-94.9 8.5-8.1 21.9-7.6 29.7 1.3 7.9 8.9 7.4 22.7-1.2 30.8-23.7 22.7-43.4 50.6-56.8 81-3.6 7.9-11.1 12.6-19 12.7z"/>
|
||||
<path class="s1" d="m668.8 421.6c-10.9 0.1-20.3-8.5-21.2-20-4-51.4-4.2-103.5-0.4-154.8 0.9-12 11.1-21 22.6-20.1 11.6 0.9 20.3 11.3 19.4 23.3-3.6 49.1-3.4 98.9 0.4 148 1 12-7.7 22.5-19.3 23.5-0.5 0-1 0-1.5 0z"/>
|
||||
<path class="s1" d="m596.2 435.1c-9.4 0.1-18.2-6.5-20.6-16.4-8.9-36.3-25.9-70.8-48.9-99.7-7.4-9.3-6.1-23 2.8-30.7 9-7.6 22.3-6.3 29.7 3 26.9 33.8 46.7 74.2 57.2 116.7 2.9 11.6-4 23.5-15.2 26.5-1.8 0.4-3.4 0.6-5.1 0.7z"/>
|
||||
</g>
|
||||
<svg width="1365" height="1365" viewBox="0 -24 148 148" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M71.7542 16.863V2.97414C71.7542 1.82355 72.6872 0.890503 73.8378 0.890503C74.9884 0.890503 75.9214 1.82355 75.9214 2.97414V16.863C75.9214 18.0136 74.9884 18.9466 73.8378 18.9466C72.6872 18.9466 71.7542 18.0136 71.7542 16.863Z" fill="black"/>
|
||||
<path d="M82.5272 18.9329L89.4716 6.90477C90.0469 5.90832 91.3215 5.5668 92.3179 6.1421C93.3144 6.7174 93.6559 7.99197 93.0806 8.98841L86.1362 21.0165C85.5609 22.0129 84.2863 22.3545 83.2899 21.7792C82.2934 21.2039 81.9519 19.9293 82.5272 18.9329Z" fill="black"/>
|
||||
<path d="M65.1481 18.9329L58.2037 6.90477C57.6284 5.90832 56.3538 5.5668 55.3574 6.1421C54.3609 6.7174 54.0194 7.99197 54.5947 8.98841L61.5391 21.0165C62.1144 22.0129 63.389 22.3545 64.3854 21.7792C65.3819 21.2039 65.7234 19.9293 65.1481 18.9329Z" fill="black"/>
|
||||
<path d="M140.606 62.0292C140.606 58.409 141.583 47.6748 141.89 44.1323C142.097 41.7374 141.809 40.4247 141.424 39.7542C141.141 39.2626 140.699 38.915 139.634 38.8436C138.865 38.7921 138.027 39.0114 137.401 39.5761C136.814 40.1052 136.159 41.1682 136.159 43.3176L136.155 43.4388L135.198 59.758C135.164 60.3451 134.883 60.8911 134.424 61.2599C133.966 61.6284 133.374 61.7859 132.793 61.6941L122.764 60.1068L111.948 58.6703C110.949 58.5376 110.188 57.7084 110.142 56.7016L109.561 44.1323C109.535 43.621 109.51 43.1141 109.484 42.6146C109.241 37.9294 109.022 33.7805 109.022 32.4282C109.022 28.3859 108.338 26.6806 107.74 25.9634C107.263 25.3915 106.577 25.1402 105.11 25.1402C104.583 25.1402 104.212 25.2481 103.933 25.4111C103.659 25.5714 103.346 25.8587 103.049 26.4208C102.41 27.6257 101.945 29.891 102.118 33.8479C102.342 38.9804 102.692 42.8146 103.035 46.2718C103.377 49.7231 103.718 52.8561 103.908 56.4971C104.204 62.1966 104.178 66.1256 103.945 68.7924C103.828 70.124 103.656 71.1996 103.423 72.0501C103.202 72.8558 102.871 73.6757 102.296 74.2887C101.6 75.0303 100.608 75.3844 99.577 75.136C98.7592 74.9389 98.1847 74.4215 97.8706 74.0916C97.2141 73.4017 96.7501 72.5106 96.568 72.0512C95.5097 69.3812 92.2352 63.1808 87.8023 59.6811C86.5089 58.6599 85.5666 58.3652 84.9736 58.3204C84.4148 58.2783 84.0094 58.4436 83.6909 58.6967C83.34 58.9756 83.0781 59.3811 82.9479 59.7643C82.9019 59.8999 82.8823 59.9968 82.8741 60.0584C84.0759 62.0865 88.8421 69.5222 91.0896 77.069C92.7648 82.6941 96.8038 88.4259 99.8194 90.8809C102.74 93.258 107.988 94.7313 113.9 95.0218C119.756 95.3095 125.788 94.4121 130.033 92.5092C138.233 88.8334 139.903 80.7382 140.651 77.2292C141.232 74.5057 141.243 71.5987 141.087 68.9009C141.01 67.5551 140.894 66.2969 140.793 65.1373C140.695 64.0105 140.606 62.9215 140.606 62.0292ZM120.986 27.0953C120.986 25.8314 120.648 24.7049 120.089 23.9514C119.583 23.27 118.84 22.7987 117.646 22.7984C116.668 22.7982 116.011 22.9187 115.546 23.1167C115.13 23.2943 114.781 23.5699 114.463 24.0831C113.73 25.2671 113.192 27.6455 113.189 32.384L113.721 43.9088C113.91 47.5661 114.106 51.4922 114.235 54.7707L120.986 55.6666V27.0953ZM125.153 56.2652L131.172 57.218L131.992 43.267V32.5083C131.992 31.031 131.39 30.1275 130.678 29.5489C129.884 28.9039 128.957 28.6731 128.519 28.6731C127.722 28.6731 126.899 28.797 126.306 29.2179C125.849 29.5421 125.153 30.3087 125.153 32.5083V56.2652ZM136.159 35.4278C137.406 34.8069 138.74 34.6083 139.912 34.6868C142.037 34.8292 143.91 35.718 145.037 37.6779C146.06 39.4592 146.273 41.8136 146.041 44.4927C145.72 48.1949 144.772 58.6457 144.772 62.0292C144.772 62.708 144.843 63.6116 144.944 64.7758C145.042 65.907 145.165 67.2389 145.247 68.6606C145.411 71.4987 145.422 74.8383 144.727 78.0987C144.002 81.4953 142.041 91.6918 131.738 96.3108C126.731 98.5551 120.002 99.4936 113.696 99.1838C107.445 98.8767 101.128 97.3189 97.1887 94.1122C93.4809 91.0937 88.9938 84.6307 87.0962 78.2589C84.9529 71.0619 80.3109 63.9646 79.1527 61.9533C78.4706 60.7689 78.684 59.3628 79.0019 58.4258C79.3607 57.3688 80.0554 56.2631 81.0993 55.4337C82.1758 54.5784 83.6043 54.0377 85.2876 54.1647C86.9369 54.2893 88.6462 55.0393 90.3834 56.4107C94.8541 59.9401 98.1342 65.5082 99.7424 68.9231C99.759 68.7664 99.779 68.6024 99.7941 68.4298C100.003 66.0435 100.039 62.3344 99.7467 56.7132C99.5635 53.1942 99.2356 50.1809 98.8888 46.6828C98.5425 43.1904 98.184 39.2713 97.955 34.0302C97.7722 29.8481 98.2012 26.6722 99.3672 24.471C99.9716 23.3302 100.79 22.4223 101.83 21.814C102.866 21.2087 103.995 20.974 105.11 20.974C106.759 20.974 108.813 21.2062 110.448 22.7678C110.593 22.4576 110.75 22.1652 110.921 21.8899C111.676 20.6698 112.681 19.8084 113.912 19.2835C115.095 18.7791 116.378 18.6309 117.646 18.6311C120.195 18.6315 122.164 19.7567 123.434 21.4683C124.256 22.576 124.75 23.8775 124.985 25.1982C126.338 24.5876 127.691 24.5068 128.519 24.5068C129.933 24.5068 131.784 25.0791 133.305 26.3154C134.908 27.6179 136.159 29.6733 136.159 32.5083V35.4278Z" fill="black"/>
|
||||
<path d="M7.15661 62.0292C7.15661 58.409 6.17994 47.6748 5.87291 44.1323C5.6654 41.7374 5.95357 40.4247 6.33875 39.7542C6.62116 39.2626 7.06336 38.915 8.12834 38.8436C8.89759 38.7921 9.73544 39.0114 10.3616 39.5761C10.9484 40.1052 11.6032 41.1682 11.6032 43.3176L11.6074 43.4388L12.5644 59.758C12.5988 60.3451 12.8798 60.8911 13.338 61.2599C13.7961 61.6284 14.3887 61.7859 14.9695 61.6941L24.9988 60.1068L35.8143 58.6703C36.8135 58.5376 37.5741 57.7084 37.6208 56.7016L38.2015 44.1323C38.2279 43.621 38.2525 43.1141 38.2784 42.6146C38.5218 37.9294 38.7401 33.7805 38.7401 32.4282C38.7401 28.3859 39.4246 26.6806 40.0227 25.9634C40.4996 25.3915 41.185 25.1402 42.6523 25.1402C43.1794 25.1402 43.5505 25.2481 43.8295 25.4111C44.1038 25.5714 44.416 25.8587 44.7138 26.4208C45.3521 27.6257 45.8173 29.891 45.6444 33.8479C45.4201 38.9804 45.0703 42.8146 44.7275 46.2718C44.3853 49.7231 44.0443 52.8561 43.8548 56.4971C43.5582 62.1966 43.5847 66.1256 43.8179 68.7924C43.9344 70.124 44.1069 71.1996 44.3396 72.0501C44.5601 72.8558 44.891 73.6757 45.4663 74.2887C46.1625 75.0303 47.1546 75.3844 48.1855 75.136C49.0033 74.9389 49.5778 74.4215 49.8918 74.0916C50.5484 73.4017 51.0123 72.5106 51.1945 72.0512C52.2527 69.3812 55.5272 63.1808 59.9601 59.6811C61.2536 58.6599 62.1958 58.3652 62.7889 58.3204C63.3476 58.2783 63.753 58.4436 64.0715 58.6967C64.4225 58.9756 64.6844 59.3811 64.8146 59.7643C64.8606 59.8999 64.8801 59.9968 64.8883 60.0584C63.6866 62.0865 58.9204 69.5222 56.6729 77.069C54.9977 82.6941 50.9586 88.4259 47.9431 90.8809C45.0229 93.258 39.7747 94.7313 33.8624 95.0218C28.0068 95.3095 21.9748 94.4121 17.7297 92.5092C9.52988 88.8334 7.85961 80.7382 7.11129 77.2292C6.53054 74.5057 6.5195 71.5987 6.67496 68.9009C6.75251 67.5551 6.86809 66.2969 6.96901 65.1373C7.06707 64.0105 7.1566 62.9215 7.15661 62.0292ZM26.7768 27.0953C26.7768 25.8314 27.1147 24.7049 27.6737 23.9514C28.1792 23.27 28.9221 22.7987 30.1167 22.7984C31.0942 22.7982 31.7518 22.9187 32.2162 23.1167C32.6326 23.2943 32.9817 23.5699 33.2996 24.0831C34.0328 25.2671 34.5705 27.6455 34.5738 32.384L34.0416 43.9088C33.8524 47.5661 33.6565 51.4922 33.5273 54.7707L26.7768 55.6666V27.0953ZM22.6095 56.2652L16.5904 57.218L15.7705 43.267V32.5083C15.7705 31.031 16.3726 30.1275 17.0847 29.5489C17.8785 28.9039 18.8058 28.6731 19.2432 28.6731C20.0404 28.6731 20.8634 28.797 21.4565 29.2179C21.9131 29.5421 22.6095 30.3087 22.6095 32.5083V56.2652ZM11.6032 35.4278C10.3568 34.8069 9.02265 34.6083 7.8501 34.6868C5.72541 34.8292 3.85197 35.718 2.72584 37.6779C1.70247 39.4592 1.48924 41.8136 1.72143 44.4927C2.0423 48.1949 2.99038 58.6457 2.99038 62.0292C2.99037 62.708 2.91991 63.6116 2.81859 64.7758C2.72014 65.907 2.59699 67.2389 2.51505 68.6606C2.3515 71.4987 2.34041 74.8383 3.0357 78.0987C3.76005 81.4953 5.72154 91.6918 16.0245 96.3108C21.0311 98.5551 27.7601 99.4936 34.0669 99.1838C40.3172 98.8767 46.6346 97.3189 50.5737 94.1122C54.2816 91.0937 58.7686 84.6307 60.6662 78.2589C62.8095 71.0619 67.4515 63.9646 68.6098 61.9533C69.2919 60.7689 69.0785 59.3628 68.7605 58.4258C68.4018 57.3688 67.707 56.2631 66.6632 55.4337C65.5867 54.5784 64.1582 54.0377 62.4748 54.1647C60.8256 54.2893 59.1162 55.0393 57.379 56.4107C52.9083 59.9401 49.6283 65.5082 48.02 68.9231C48.0034 68.7664 47.9835 68.6024 47.9684 68.4298C47.7597 66.0435 47.7232 62.3344 48.0158 56.7132C48.1989 53.1942 48.5269 50.1809 48.8737 46.6828C49.22 43.1904 49.5784 39.2713 49.8075 34.0302C49.9903 29.8481 49.5612 26.6722 48.3952 24.471C47.7909 23.3302 46.9729 22.4223 45.9321 21.814C44.8964 21.2087 43.7676 20.974 42.6523 20.974C41.0038 20.974 38.9497 21.2062 37.3141 22.7678C37.1698 22.4576 37.0124 22.1652 36.8419 21.8899C36.0863 20.6698 35.0817 19.8084 33.8508 19.2835C32.6679 18.7791 31.3849 18.6309 30.1167 18.6311C27.5677 18.6315 25.5986 19.7567 24.3285 21.4683C23.5066 22.576 23.0121 23.8775 22.7771 25.1982C21.4247 24.5876 20.0718 24.5068 19.2432 24.5068C17.8298 24.5068 15.9788 25.0791 14.4573 26.3154C12.8542 27.6179 11.6032 29.6733 11.6032 32.5083V35.4278Z" fill="black"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 8.7 KiB |
@@ -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,16 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="47" height="30" viewBox="0 0 47 30" fill="none">
|
||||
<g clip-path="url(#clip0_10905_18559)">
|
||||
<path d="M44.731 8.9991C43.271 8.13859 42.2956 9.4574 42.4152 11.248L42.4031 11.2616C42.4071 9.39165 42.1435 7.32642 41.2675 5.65567C40.9573 5.06395 40.3287 4.09128 39.0856 4.54957C38.5402 4.75068 38.0454 5.35594 38.3009 6.9184C38.3009 6.9184 38.5848 8.55821 38.532 10.6196V10.6486C38.1772 4.96339 36.8388 3.22883 34.9246 3.34099C34.3122 3.44541 33.4748 3.69873 33.7566 5.44683C33.7566 5.44683 34.0628 7.27034 34.1622 8.72258L34.1683 8.79606H34.1622C33.2618 5.66147 32.0492 5.61893 31.1712 5.74076C30.3743 5.85098 29.5044 6.64381 29.9444 8.20627C31.3253 13.1083 31.0556 19.012 30.9522 19.857C30.6703 19.2789 30.5831 18.8206 30.1918 18.1863C28.6182 15.6396 27.87 15.452 26.9514 15.4133C26.0389 15.3746 25.0534 15.9141 25.1183 16.941C25.1852 17.9678 25.7307 18.1379 26.5053 19.5689C27.1096 20.6827 27.2819 22.1427 28.4986 24.7958C29.5064 26.9925 32.1405 29.402 36.9382 29.1158C40.8255 28.992 46.631 27.6887 45.6212 19.13C45.3697 17.6429 45.5583 16.3976 45.6901 15.1213C45.8949 13.1412 46.195 9.85962 44.733 8.99717L44.731 8.9991Z" fill="#FFE165"/>
|
||||
<path d="M20.458 15.4707C19.5395 15.5268 18.7973 15.7259 17.2724 18.2998C16.8932 18.9398 16.8161 19.4 16.5444 19.9821C16.4248 19.139 16.0415 13.2411 17.3272 8.31587C17.7368 6.74761 16.8526 5.97024 16.0537 5.87356C15.1736 5.7672 13.959 5.83101 13.1195 8.99654H13.1094L13.1215 8.90566C13.1925 7.45149 13.4642 5.62411 13.4642 5.62411C13.7096 3.87021 12.8701 3.63236 12.2557 3.5376C10.3455 3.46025 9.04367 5.20255 8.79222 10.8375H8.78817C8.70097 8.79737 8.95039 7.17303 8.95039 7.17303C9.17547 5.60477 8.66853 5.00918 8.119 4.81774C6.86786 4.38071 6.25749 5.36498 5.95941 5.96251C5.11585 7.64873 4.89077 9.71783 4.93133 11.5878L4.91916 11.5742C5.0023 9.78164 4.0026 8.48023 2.55882 9.36589C1.11504 10.2535 1.47802 13.5292 1.72135 15.5055C1.87952 16.7798 2.09041 18.0213 1.86735 19.5122C1.02379 28.0864 6.85366 29.2872 10.7429 29.3433C15.5447 29.5464 18.1322 27.0886 19.0974 24.8745C20.2613 22.202 20.4074 20.7382 20.9893 19.6147C21.7355 18.1702 22.279 17.9904 22.3256 16.9635C22.3723 15.9367 21.3766 15.4146 20.4641 15.4688L20.458 15.4707Z" fill="#FFE165"/>
|
||||
<path d="M22.3819 15.4845C21.8952 15.0301 21.1632 14.7884 20.419 14.8309C19.2266 14.9025 18.3811 15.3182 17.0813 17.3487C17.0468 15.0262 17.1826 11.5397 17.9816 8.47281C18.2817 7.3203 17.9796 6.56808 17.6713 6.14072C17.3124 5.64182 16.7548 5.31308 16.1383 5.2396C15.5766 5.17192 14.8426 5.16805 14.1268 5.72884C14.1268 5.7211 14.1288 5.71143 14.1288 5.71143C14.36 4.06389 13.7638 3.12023 12.3586 2.90751L12.2815 2.89978C11.4156 2.86304 10.6735 3.13376 10.0753 3.70228C9.75488 4.00588 9.47707 4.39843 9.23577 4.88379C8.96607 4.50672 8.61932 4.31527 8.34557 4.21859C6.67265 3.63267 5.74799 4.88766 5.34649 5.68823C4.8801 6.62029 4.59012 7.66451 4.4279 8.73C4.39343 8.70873 4.36098 8.68746 4.32651 8.66812C3.95746 8.46508 3.18893 8.21756 2.19126 8.83055C0.500091 9.8709 0.715036 12.8605 1.05165 15.5832C1.0699 15.7282 1.08815 15.8713 1.1064 16.0163C1.25037 17.1321 1.38623 18.186 1.19968 19.4255L1.19562 19.4564C0.85698 22.8966 1.53629 25.5438 3.21529 27.3287C4.8294 29.0458 7.35804 29.9392 10.71 29.9876C10.9553 29.9972 11.1946 30.0011 11.4278 29.9992C17.1543 29.9489 19.2084 26.2845 19.7133 25.1242C20.3663 23.6236 20.7049 22.504 20.9746 21.6029C21.1835 20.9067 21.3497 20.3576 21.585 19.9012C21.8526 19.383 22.0878 19.0465 22.2947 18.7487C22.6475 18.2421 22.9517 17.805 22.9882 16.9929C23.0145 16.405 22.8036 15.8829 22.3758 15.4845H22.3819ZM11.0263 4.61114C11.3487 4.30561 11.7198 4.17024 12.1902 4.17991C12.5978 4.24373 12.9669 4.33848 12.7986 5.5374C12.7864 5.61281 12.5228 7.41312 12.4518 8.87889C12.4518 8.88856 12.4518 8.89823 12.4518 8.9079C12.0807 10.3389 11.7705 12.4002 11.6042 15.413C10.8844 15.4555 10.1665 15.529 9.46896 15.6257C9.24388 9.51316 9.76502 5.80619 11.0243 4.61114H11.0263ZM6.56315 6.24128C7.06807 5.23573 7.49188 5.28601 7.88527 5.42331C8.43074 5.61475 8.34557 6.65316 8.28271 7.08439C8.27257 7.154 8.02924 8.77254 8.11441 10.832C8.05155 12.2765 8.05966 13.9414 8.13468 15.8462C7.46754 15.9718 6.83488 16.1169 6.25696 16.2735C5.98321 15.3956 4.77262 9.81869 6.56315 6.24321V6.24128ZM21.1794 18.039C20.9604 18.3523 20.6887 18.7429 20.3825 19.3346C20.0925 19.8935 19.9141 20.4929 19.6849 21.249C19.4233 22.1173 19.0969 23.1982 18.4743 24.6311C18.0323 25.6444 16.1748 28.9356 10.7505 28.7036C7.7271 28.661 5.58982 27.9301 4.21701 26.4701C2.80162 24.9657 2.23587 22.649 2.53395 19.5879C2.74079 18.1879 2.5887 17.0025 2.44068 15.8578C2.42243 15.7147 2.40418 15.5735 2.38593 15.4304C2.2237 14.1097 1.78976 10.5999 2.91923 9.90571C3.2234 9.71814 3.47282 9.6756 3.65735 9.77615C3.97165 9.94825 4.28798 10.5748 4.24337 11.5455C4.24135 11.5977 4.24743 11.648 4.25757 11.6983C4.31435 13.9608 4.73815 15.9293 4.97946 16.668C4.58404 16.8092 4.23526 16.9561 3.94326 17.1031C3.61476 17.2694 3.49107 17.6561 3.66546 17.9694C3.78712 18.1879 4.02235 18.3117 4.26568 18.3097C4.3691 18.3097 4.47454 18.2846 4.5739 18.2343C6.21438 17.4047 10.1057 16.5616 13.5347 16.6525C13.9078 16.6583 14.214 16.3837 14.2241 16.0299C14.2342 15.676 13.9422 15.3821 13.5712 15.3724C13.3664 15.3666 13.1595 15.3666 12.9527 15.3666C13.2954 9.29078 14.2383 7.3087 14.9724 6.72278C15.2765 6.48106 15.5665 6.46172 15.968 6.51007C16.0795 6.5236 16.3594 6.58548 16.5601 6.86394C16.7771 7.16754 16.8176 7.61616 16.6757 8.16148C15.4347 12.9204 15.7145 18.5166 15.8565 19.8741C15.8321 19.9205 15.8098 19.9669 15.7835 20.0153C15.4935 20.5355 14.9541 21.0769 14.3113 21.0402C13.9443 21.0228 13.6219 21.2896 13.5996 21.6416C13.5772 21.9954 13.8591 22.299 14.2302 22.3203C15.3171 22.3822 16.3411 21.746 16.9697 20.6186C17.0366 20.4987 17.0934 20.3846 17.1441 20.2744C17.1482 20.2667 17.1522 20.257 17.1563 20.2493C17.2739 19.9979 17.3591 19.7678 17.4341 19.5609C17.5517 19.2399 17.6531 18.9614 17.8559 18.6172C19.2956 16.1846 19.8796 16.1497 20.4981 16.113C20.861 16.0917 21.222 16.202 21.4349 16.4031C21.587 16.5442 21.6539 16.7202 21.6438 16.9406C21.6235 17.3951 21.4917 17.5846 21.1733 18.0409L21.1794 18.039Z" fill="#0D0F11"/>
|
||||
<path d="M46.2793 19.0284C46.0704 17.7928 46.186 16.7369 46.3077 15.6193C46.3239 15.4742 46.3401 15.3311 46.3543 15.1861C46.6382 12.4595 46.7964 9.46417 45.0829 8.45476C44.073 7.85916 43.3086 8.12022 42.9436 8.32906C42.9091 8.3484 42.8766 8.3716 42.8422 8.39288C42.6576 7.33125 42.3494 6.29284 41.8648 5.36851C41.4491 4.57568 40.5021 3.33615 38.8393 3.95108C38.5676 4.05164 38.2269 4.24888 37.9633 4.63176C37.7119 4.15026 37.426 3.76351 37.0995 3.46571C36.4912 2.9088 35.7429 2.64968 34.8791 2.70189L34.802 2.70962C33.4008 2.94747 32.8229 3.9008 33.0865 5.54835C33.0865 5.54835 33.0865 5.55608 33.0885 5.56188C32.3626 5.0127 31.6285 5.03011 31.0689 5.10746C30.4545 5.19254 29.9029 5.53094 29.5541 6.03565C29.256 6.46881 28.9661 7.2249 29.2885 8.3716C30.1483 11.425 30.351 14.9096 30.3612 17.232C29.0228 15.2248 28.1692 14.8245 26.9768 14.7742C26.2346 14.7433 25.5026 15.0005 25.0261 15.4626C24.6063 15.8687 24.4056 16.3947 24.4441 16.9806C24.4968 17.7908 24.8091 18.224 25.1721 18.7229C25.385 19.0168 25.6263 19.3494 25.9041 19.8619C26.1495 20.3144 26.3259 20.8597 26.549 21.552C26.8369 22.4473 27.1958 23.5611 27.8792 25.0501C28.4064 26.2007 30.5315 29.8303 36.2417 29.7781C36.4729 29.7761 36.7122 29.7684 36.9555 29.7529C40.3257 29.6466 42.8361 28.7068 44.4178 26.9625C46.0603 25.1487 46.6889 22.4898 46.2853 19.0555L46.2813 19.0246L46.2793 19.0284ZM38.961 6.82075C38.89 6.38372 38.7826 5.34724 39.326 5.14806C39.7153 5.00303 40.1412 4.94696 40.6643 5.94283C42.5238 9.48737 41.4227 15.0855 41.1652 15.9673C40.5832 15.8204 39.9485 15.6869 39.2794 15.5728C39.3159 13.6681 39.2915 12.0012 39.2023 10.5587C39.2469 8.49923 38.9732 6.88456 38.961 6.82075ZM34.9967 3.98009C35.4692 3.96075 35.8423 4.09031 36.1687 4.39197C37.4503 5.56575 38.0444 9.26112 37.937 15.3775C37.2374 15.2924 36.5196 15.2325 35.7977 15.2016C35.5746 12.1907 35.2238 10.1371 34.8243 8.71194C34.8243 8.70227 34.8243 8.69261 34.8243 8.68294C34.725 7.21716 34.4249 5.42266 34.4127 5.35304C34.22 4.15219 34.5871 4.05164 34.9947 3.98009H34.9967ZM44.9511 19.2179C45.308 22.2732 44.7868 24.5995 43.4018 26.1291C42.0574 27.6123 39.9343 28.3819 36.8927 28.4786C31.4988 28.8035 29.5724 25.5471 29.1121 24.5415C28.4591 23.1183 28.1124 22.0451 27.8346 21.1807C27.5912 20.4265 27.4006 19.8329 27.1005 19.2779C26.7842 18.692 26.5043 18.3071 26.2793 17.9977C25.9528 17.5472 25.8169 17.3596 25.7865 16.9052C25.7723 16.6847 25.8372 16.5068 25.9852 16.3637C26.1961 16.1588 26.553 16.0408 26.918 16.0582C27.5365 16.0853 28.1205 16.1085 29.6089 18.516C29.8198 18.8563 29.9252 19.1328 30.0489 19.4519C30.13 19.6588 30.2192 19.8889 30.3429 20.1384C30.347 20.1461 30.349 20.1539 30.3531 20.1597C30.4078 20.2699 30.4666 20.382 30.5356 20.5019C31.1865 21.6177 32.2227 22.2365 33.3075 22.1553C33.6766 22.1282 33.9544 21.8188 33.926 21.4669C33.8976 21.1149 33.5752 20.8539 33.2041 20.8771C32.5613 20.9235 32.0118 20.3917 31.7117 19.8773C31.6833 19.829 31.661 19.7845 31.6367 19.7381C31.7522 18.3806 31.9246 12.7786 30.5903 8.04287C30.4362 7.49949 30.4687 7.05086 30.6795 6.7434C30.8762 6.46107 31.154 6.39339 31.2656 6.37792C31.665 6.32184 31.957 6.33731 32.2653 6.57323C33.0115 7.14755 33.9929 9.11223 34.4512 15.1803C34.2444 15.1822 34.0376 15.188 33.8348 15.1977C33.4637 15.2132 33.1778 15.5129 33.194 15.8668C33.2102 16.2206 33.5184 16.4875 33.8956 16.4778C37.3205 16.327 41.2301 17.1005 42.8848 17.903C42.9841 17.9513 43.0896 17.9726 43.195 17.9726C43.4383 17.9707 43.6715 17.843 43.7891 17.6207C43.9575 17.3055 43.8257 16.9187 43.4931 16.7582C43.1991 16.6151 42.8462 16.4759 42.4488 16.3405C42.6759 15.598 43.0632 13.6217 43.0754 11.3592C43.0855 11.309 43.0896 11.2587 43.0855 11.2065C43.0206 10.2377 43.3268 9.60533 43.6371 9.42742C43.8196 9.323 44.069 9.36168 44.3772 9.54345C45.5209 10.2183 45.1559 13.7339 45.018 15.0585C45.0038 15.2016 44.9876 15.3427 44.9713 15.4858C44.8456 16.6345 44.7158 17.8198 44.9511 19.2179Z" fill="#0D0F11"/>
|
||||
<path d="M26.1508 6.85319C26.0434 6.85319 25.9339 6.83386 25.8304 6.78745C25.4512 6.62114 25.285 6.19379 25.4594 5.83218C26.0231 4.6584 26.8484 3.57551 27.844 2.70146C28.1502 2.43267 28.6288 2.45007 28.9106 2.744C29.1925 3.036 29.1742 3.49236 28.866 3.76115C28.0164 4.50757 27.3127 5.4319 26.8301 6.43357C26.7044 6.69463 26.4347 6.85126 26.1508 6.85319Z" fill="#F9F7F2"/>
|
||||
<path d="M23.608 6.43744C23.2166 6.44131 22.8821 6.15511 22.8496 5.7761C22.7056 4.08021 22.6996 2.36112 22.8354 0.665235C22.8679 0.268818 23.2308 -0.0270433 23.6445 0.0019628C24.0602 0.0329026 24.3704 0.377108 24.34 0.773524C24.2103 2.394 24.2163 4.03767 24.3542 5.65814C24.3887 6.05456 24.0784 6.40263 23.6628 6.43357C23.6445 6.43357 23.6263 6.4355 23.608 6.4355V6.43744Z" fill="#F9F7F2"/>
|
||||
<path d="M21.0084 6.88414C20.6697 6.888 20.3575 6.66949 20.2703 6.34269C19.9499 5.14377 19.3436 4.0048 18.5183 3.05147C18.2526 2.74401 18.2993 2.29151 18.6197 2.03819C18.9421 1.78487 19.4166 1.82935 19.6822 2.13488C20.6474 3.25258 21.3572 4.58492 21.7303 5.98688C21.8337 6.3717 21.5883 6.76425 21.1848 6.86287C21.124 6.87834 21.0652 6.88414 21.0043 6.88607L21.0084 6.88414Z" fill="#F9F7F2"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_10905_18559">
|
||||
<rect width="45.7143" height="30" fill="white" transform="translate(0.818359)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<svg width="47" height="30" viewBox="0 0 148 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M71.754 16.863V2.97414C71.754 1.82355 72.6871 0.890503 73.8377 0.890503C74.9883 0.890503 75.9213 1.82355 75.9213 2.97414V16.863C75.9213 18.0136 74.9883 18.9466 73.8377 18.9466C72.6871 18.9466 71.754 18.0136 71.754 16.863Z" fill="black"/>
|
||||
<path d="M82.5273 18.9329L89.4717 6.90477C90.047 5.90832 91.3215 5.5668 92.318 6.1421C93.3144 6.7174 93.6559 7.99197 93.0806 8.98841L86.1362 21.0165C85.5609 22.0129 84.2864 22.3545 83.2899 21.7792C82.2935 21.2039 81.952 19.9293 82.5273 18.9329Z" fill="black"/>
|
||||
<path d="M65.1481 18.9329L58.2037 6.90477C57.6284 5.90832 56.3538 5.5668 55.3574 6.1421C54.3609 6.7174 54.0194 7.99197 54.5947 8.98841L61.5391 21.0165C62.1144 22.0129 63.389 22.3545 64.3854 21.7792C65.3819 21.2039 65.7234 19.9293 65.1481 18.9329Z" fill="black"/>
|
||||
<path d="M98.5045 92.4969C95.1427 89.7602 90.8793 83.6628 89.0929 77.664C86.8598 70.1655 82.0309 62.777 80.9578 60.9138C80.0056 59.2602 83.0313 53.2605 89.0929 58.0459C93.9421 61.8742 97.3878 68.466 98.5045 71.2833C98.9566 72.4239 103.006 79.2475 101.827 56.6051C101.455 49.445 100.49 44.3126 100.036 33.9388C99.6807 25.7997 101.824 23.057 105.11 23.057C108.396 23.057 111.106 24.1244 111.106 32.4283C111.106 22.8682 113.154 20.7141 117.646 20.7147C121.39 20.7153 123.069 23.9077 123.069 27.0952V32.5084C123.069 27.0952 126.669 26.5896 128.519 26.5896C130.37 26.5896 134.076 28.1957 134.076 32.5084V43.317C134.076 38.116 137.324 36.601 139.773 36.765C142.963 36.9786 144.405 39.2382 143.965 44.3126C143.651 47.9349 142.689 58.5273 142.689 62.0291C142.689 65.1712 143.965 71.6801 142.689 77.664C141.953 81.1168 140.137 90.2628 130.885 94.4102C121.634 98.5575 105.364 98.0807 98.5045 92.4969Z" fill="#FFFF8B"/>
|
||||
<path d="M140.606 62.0292C140.606 58.409 141.583 47.6748 141.89 44.1323C142.097 41.7374 141.809 40.4247 141.424 39.7542C141.141 39.2626 140.699 38.915 139.634 38.8436C138.865 38.7921 138.027 39.0114 137.401 39.5761C136.814 40.1052 136.159 41.1682 136.159 43.3176L136.155 43.4388L135.198 59.758C135.164 60.3451 134.883 60.8911 134.424 61.2599C133.966 61.6284 133.374 61.7859 132.793 61.6941L122.764 60.1068L111.948 58.6703C110.949 58.5376 110.188 57.7084 110.142 56.7016L109.545 43.8055L109.54 43.6959C109.274 38.5609 109.022 33.8767 109.022 32.4282C109.022 28.3859 108.338 26.6806 107.74 25.9634C107.263 25.3915 106.577 25.1402 105.11 25.1402C104.583 25.1402 104.212 25.2481 103.933 25.4111C103.659 25.5714 103.346 25.8587 103.049 26.4208C102.41 27.6257 101.945 29.891 102.118 33.8479C102.342 38.9804 102.692 42.8146 103.035 46.2718C103.377 49.7231 103.718 52.8561 103.908 56.4971C104.204 62.1966 104.178 66.1256 103.945 68.7924C103.828 70.124 103.656 71.1996 103.423 72.0501C103.202 72.8558 102.871 73.6757 102.296 74.2887C101.6 75.0303 100.608 75.3844 99.577 75.136C98.7592 74.9389 98.1847 74.4215 97.8706 74.0916C97.2141 73.4017 96.7501 72.5106 96.568 72.0512C95.5097 69.3812 92.2352 63.1808 87.8023 59.6811C86.5089 58.6599 85.5666 58.3652 84.9736 58.3204C84.4148 58.2783 84.0094 58.4436 83.6909 58.6967C83.34 58.9756 83.0781 59.3811 82.9479 59.7643C82.9019 59.8999 82.8823 59.9968 82.8741 60.0584C84.0759 62.0865 88.8421 69.5222 91.0896 77.069C92.7648 82.6941 96.8038 88.4259 99.8194 90.8809C102.74 93.258 107.988 94.7313 113.9 95.0218C119.756 95.3095 125.788 94.4121 130.033 92.5092C138.233 88.8334 139.903 80.7382 140.651 77.2292C141.232 74.5057 141.243 71.5987 141.087 68.9009C141.01 67.5551 140.894 66.2969 140.793 65.1373C140.695 64.0105 140.606 62.9215 140.606 62.0292ZM120.986 27.0953C120.986 25.8314 120.648 24.7049 120.089 23.9514C119.583 23.27 118.84 22.7987 117.646 22.7984C116.668 22.7982 116.011 22.9187 115.546 23.1167C115.13 23.2943 114.781 23.5699 114.463 24.0831C113.73 25.2671 113.192 27.6455 113.189 32.384L113.707 43.6021C113.901 47.3443 114.103 51.3994 114.236 54.7707L120.986 55.6666V27.0953ZM125.153 56.2652L131.172 57.218L131.992 43.267V32.5083C131.992 31.031 131.39 30.1275 130.678 29.5489C129.884 28.9039 128.957 28.6731 128.519 28.6731C127.722 28.6731 126.899 28.797 126.306 29.2179C125.849 29.5421 125.153 30.3087 125.153 32.5083V56.2652ZM136.159 35.4278C137.406 34.8069 138.74 34.6083 139.912 34.6868C142.037 34.8292 143.91 35.718 145.037 37.6779C146.06 39.4592 146.273 41.8136 146.041 44.4927C145.72 48.1949 144.772 58.6457 144.772 62.0292C144.772 62.708 144.843 63.6116 144.944 64.7758C145.042 65.907 145.165 67.2389 145.247 68.6606C145.411 71.4987 145.422 74.8383 144.727 78.0987C144.002 81.4953 142.041 91.6918 131.738 96.3108C126.731 98.5551 120.002 99.4936 113.696 99.1838C107.445 98.8767 101.128 97.3189 97.1887 94.1122C93.4809 91.0936 88.9938 84.6307 87.0962 78.2589C84.9529 71.0619 80.3109 63.9646 79.1527 61.9533C78.4706 60.7689 78.684 59.3628 79.0019 58.4258C79.3607 57.3688 80.0554 56.2631 81.0993 55.4337C82.1758 54.5784 83.6043 54.0377 85.2876 54.1647C86.9369 54.2893 88.6462 55.0393 90.3834 56.4107C94.8541 59.9401 98.1342 65.5082 99.7424 68.9231C99.759 68.7664 99.779 68.6024 99.7941 68.4298C100.003 66.0435 100.039 62.3344 99.7467 56.7132C99.5635 53.1942 99.2356 50.1809 98.8888 46.6828C98.5425 43.1904 98.184 39.2713 97.955 34.0302C97.7722 29.8481 98.2012 26.6722 99.3672 24.471C99.9716 23.3302 100.79 22.4223 101.83 21.814C102.866 21.2087 103.995 20.974 105.11 20.974C106.759 20.974 108.813 21.2062 110.448 22.7678C110.593 22.4576 110.75 22.1652 110.921 21.8899C111.676 20.6698 112.681 19.8084 113.912 19.2835C115.095 18.7791 116.378 18.6309 117.646 18.6311C120.195 18.6315 122.165 19.7565 123.435 21.4683C124.257 22.576 124.75 23.8776 124.985 25.1982C126.338 24.5876 127.691 24.5068 128.519 24.5068C129.933 24.5068 131.784 25.0791 133.305 26.3154C134.908 27.6179 136.159 29.6733 136.159 32.5083V35.4278Z" fill="black"/>
|
||||
<path d="M49.258 92.4969C52.6198 89.7602 56.8832 83.6628 58.6696 77.664C60.9027 70.1655 65.7316 62.777 66.8047 60.9138C67.757 59.2602 64.7312 53.2605 58.6696 58.0459C53.8204 61.8742 50.3747 68.466 49.258 71.2833C48.8059 72.4239 44.7566 79.2475 45.935 56.6051C46.3077 49.445 47.2728 44.3126 47.7261 33.9388C48.0818 25.7997 45.9381 23.057 42.6523 23.057C39.3666 23.057 36.6568 24.1244 36.6568 32.4283C36.6568 22.8682 34.6087 20.7141 30.1168 20.7147C26.3728 20.7153 24.6934 23.9077 24.6934 27.0952V32.5084C24.6934 27.0952 21.0938 26.5896 19.243 26.5896C17.3923 26.5896 13.687 28.1957 13.687 32.5084V43.317C13.687 38.116 10.4388 36.601 7.98968 36.765C4.79943 36.9786 3.3574 39.2382 3.79721 44.3126C4.11115 47.9349 5.07331 58.5273 5.07331 62.0291C5.07331 65.1712 3.79721 71.6801 5.07331 77.664C5.80964 81.1168 7.62551 90.2628 16.8772 94.4102C26.129 98.5575 42.3987 98.0807 49.258 92.4969Z" fill="#FFFF8B"/>
|
||||
<path d="M7.15667 62.0292C7.15667 58.409 6.18001 47.6748 5.87297 44.1323C5.66546 41.7374 5.95363 40.4247 6.33881 39.7542C6.62122 39.2626 7.06342 38.915 8.1284 38.8436C8.89765 38.7921 9.7355 39.0114 10.3617 39.5761C10.9484 40.1052 11.6032 41.1682 11.6032 43.3176L11.6075 43.4388L12.5644 59.758C12.5989 60.3451 12.8798 60.8911 13.338 61.2599C13.7961 61.6284 14.3888 61.7859 14.9695 61.6941L24.9988 60.1068L35.8144 58.6703C36.8136 58.5376 37.5741 57.7084 37.6208 56.7016L38.2174 43.8055L38.2226 43.6959C38.4887 38.5609 38.7401 33.8767 38.7401 32.4282C38.7401 28.3859 39.4246 26.6806 40.0228 25.9634C40.4997 25.3915 41.1851 25.1402 42.6523 25.1402C43.1795 25.1402 43.5506 25.2481 43.8296 25.4111C44.1038 25.5714 44.416 25.8587 44.7139 26.4208C45.3521 27.6257 45.8174 29.891 45.6445 33.8479C45.4202 38.9804 45.0703 42.8146 44.7276 46.2718C44.3854 49.7231 44.0444 52.8561 43.8549 56.4971C43.5583 62.1966 43.5848 66.1256 43.818 68.7924C43.9345 70.124 44.107 71.1996 44.3397 72.0501C44.5602 72.8558 44.891 73.6757 45.4664 74.2887C46.1626 75.0303 47.1547 75.3844 48.1855 75.136C49.0033 74.9389 49.5779 74.4215 49.8919 74.0916C50.5484 73.4017 51.0124 72.5106 51.1945 72.0512C52.2528 69.3812 55.5273 63.1808 59.9602 59.6811C61.2536 58.6599 62.1959 58.3652 62.7889 58.3204C63.3477 58.2783 63.7531 58.4436 64.0716 58.6967C64.4225 58.9756 64.6844 59.3811 64.8146 59.7643C64.8606 59.8999 64.8802 59.9968 64.8884 60.0584C63.6866 62.0865 58.9205 69.5222 56.6729 77.069C54.9978 82.6941 50.9587 88.4259 47.9431 90.8809C45.0229 93.258 39.7748 94.7313 33.8625 95.0218C28.0069 95.3095 21.9748 94.4121 17.7298 92.5092C9.52994 88.8334 7.85968 80.7382 7.11135 77.2292C6.5306 74.5057 6.51956 71.5987 6.67502 68.9009C6.75257 67.5551 6.86815 66.2969 6.96907 65.1373C7.06713 64.0105 7.15666 62.9215 7.15667 62.0292ZM26.7768 27.0953C26.7768 25.8314 27.1148 24.7049 27.6737 23.9514C28.1793 23.27 28.9221 22.7987 30.1168 22.7984C31.0942 22.7982 31.7519 22.9187 32.2162 23.1167C32.6327 23.2943 32.9818 23.5699 33.2997 24.0831C34.0329 25.2671 34.5706 27.6455 34.5739 32.384L34.0554 43.6021C33.8615 47.3443 33.6592 51.3994 33.5263 54.7707L26.7768 55.6666V27.0953ZM22.6096 56.2652L16.5905 57.218L15.7705 43.267V32.5083C15.7705 31.031 16.3726 30.1275 17.0848 29.5489C17.8786 28.9039 18.8059 28.6731 19.2433 28.6731C20.0405 28.6731 20.8635 28.797 21.4565 29.2179C21.9131 29.5421 22.6095 30.3087 22.6096 32.5083V56.2652ZM11.6032 35.4278C10.3568 34.8069 9.02271 34.6083 7.85016 34.6868C5.72547 34.8292 3.85203 35.718 2.7259 37.6779C1.70253 39.4592 1.4893 41.8136 1.7215 44.4927C2.04237 48.1949 2.99044 58.6457 2.99044 62.0292C2.99043 62.708 2.91997 63.6116 2.81865 64.7758C2.7202 65.907 2.59705 67.2389 2.51511 68.6606C2.35156 71.4987 2.34047 74.8383 3.03576 78.0987C3.76011 81.4953 5.7216 91.6918 16.0245 96.3108C21.0312 98.5551 27.7601 99.4936 34.0669 99.1838C40.3173 98.8767 46.6346 97.3189 50.5738 94.1122C54.2816 91.0936 58.7687 84.6307 60.6663 78.2589C62.8096 71.0619 67.4516 63.9646 68.6099 61.9533C69.292 60.7689 69.0785 59.3628 68.7606 58.4258C68.4018 57.3688 67.7071 56.2631 66.6632 55.4337C65.5867 54.5784 64.1582 54.0377 62.4749 54.1647C60.8256 54.2893 59.1163 55.0393 57.3791 56.4107C52.9084 59.9401 49.6283 65.5082 48.0201 68.9231C48.0035 68.7664 47.9835 68.6024 47.9684 68.4298C47.7597 66.0435 47.7233 62.3344 48.0159 56.7132C48.199 53.1942 48.5269 50.1809 48.8738 46.6828C49.22 43.1904 49.5785 39.2713 49.8076 34.0302C49.9903 29.8481 49.5613 26.6722 48.3953 24.471C47.7909 23.3302 46.9729 22.4223 45.9322 21.814C44.8964 21.2087 43.7676 20.974 42.6523 20.974C41.0038 20.974 38.9498 21.2062 37.3141 22.7678C37.1699 22.4576 37.0125 22.1652 36.842 21.8899C36.0864 20.6698 35.0817 19.8084 33.8509 19.2835C32.668 18.7791 31.3849 18.6309 30.1168 18.6311C27.5676 18.6315 25.5976 19.7565 24.3275 21.4683C23.5057 22.576 23.0121 23.8776 22.7771 25.1982C21.4248 24.5876 20.0718 24.5068 19.2433 24.5068C17.8299 24.5068 15.9789 25.0791 14.4573 26.3154C12.8543 27.6179 11.6033 29.6733 11.6032 32.5083V35.4278Z" fill="black"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -25,10 +25,7 @@ export function ConversationVersionBadge({
|
||||
<Tooltip content={tooltipText} placement="top">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold shrink-0 cursor-help lowercase",
|
||||
version === "V1"
|
||||
? "bg-green-500/20 text-green-500"
|
||||
: "bg-neutral-500/20 text-neutral-400",
|
||||
"inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold shrink-0 cursor-help lowercase bg-neutral-500/20 text-neutral-400",
|
||||
isConversationArchived && "opacity-60",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -27,8 +27,10 @@ import {
|
||||
} from "#/types/v1/type-guards";
|
||||
import { handleActionEventCacheInvalidation } from "#/utils/cache-utils";
|
||||
import { buildWebSocketUrl } from "#/utils/websocket-url";
|
||||
import { isBudgetOrCreditError } from "#/utils/error-handler";
|
||||
import type { V1SendMessageRequest } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
import EventService from "#/api/event-service/event-service.api";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export type V1_WebSocketConnectionState =
|
||||
@@ -69,6 +71,7 @@ export function ConversationWebSocketProvider({
|
||||
const { removeOptimisticUserMessage } = useOptimisticUserMessageStore();
|
||||
const { setExecutionStatus } = useV1ConversationStateStore();
|
||||
const { appendInput, appendOutput } = useCommandStore();
|
||||
const { trackCreditLimitReached } = useTracking();
|
||||
|
||||
// History loading state
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(true);
|
||||
@@ -132,6 +135,13 @@ export function ConversationWebSocketProvider({
|
||||
// Handle AgentErrorEvent specifically
|
||||
if (isAgentErrorEvent(event)) {
|
||||
setErrorMessage(event.error);
|
||||
|
||||
// Track credit limit reached if the error is budget-related
|
||||
if (isBudgetOrCreditError(event.error)) {
|
||||
trackCreditLimitReached({
|
||||
conversationId: conversationId || "unknown",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Clear optimistic user message when a user message is confirmed
|
||||
|
||||
@@ -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
|
||||
|
||||
39
frontend/src/hooks/query/use-sub-conversations.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import { V1AppConversation } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
|
||||
const FIVE_MINUTES = 1000 * 60 * 5;
|
||||
const FIFTEEN_MINUTES = 1000 * 60 * 15;
|
||||
|
||||
/**
|
||||
* React hook to fetch sub-conversations by their IDs
|
||||
*
|
||||
* @param subConversationIds Array of sub-conversation IDs to fetch
|
||||
* @returns React Query result with sub-conversation data, loading, and error states
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { data: subConversations, isLoading, isError } = useSubConversations(
|
||||
* conversation.sub_conversation_ids || []
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export const useSubConversations = (
|
||||
subConversationIds: string[] | null | undefined,
|
||||
) => {
|
||||
const ids = subConversationIds || [];
|
||||
|
||||
return useQuery<(V1AppConversation | null)[]>({
|
||||
queryKey: ["v1", "sub-conversations", ids],
|
||||
queryFn: async () => {
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return V1ConversationService.batchGetAppConversations(ids);
|
||||
},
|
||||
enabled: ids.length > 0,
|
||||
staleTime: FIVE_MINUTES,
|
||||
gcTime: FIFTEEN_MINUTES,
|
||||
retry: false,
|
||||
});
|
||||
};
|
||||
41
frontend/src/hooks/use-sync-posthog-consent.ts
Normal file
@@ -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]);
|
||||
};
|
||||
@@ -67,6 +67,38 @@ export const useTracking = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const trackUserSignupCompleted = () => {
|
||||
posthog.capture("user_signup_completed", {
|
||||
signup_timestamp: new Date().toISOString(),
|
||||
...commonProperties,
|
||||
});
|
||||
};
|
||||
|
||||
const trackCreditsPurchased = ({
|
||||
amountUsd,
|
||||
stripeSessionId,
|
||||
}: {
|
||||
amountUsd: number;
|
||||
stripeSessionId: string;
|
||||
}) => {
|
||||
posthog.capture("credits_purchased", {
|
||||
amount_usd: amountUsd,
|
||||
stripe_session_id: stripeSessionId,
|
||||
...commonProperties,
|
||||
});
|
||||
};
|
||||
|
||||
const trackCreditLimitReached = ({
|
||||
conversationId,
|
||||
}: {
|
||||
conversationId: string;
|
||||
}) => {
|
||||
posthog.capture("credit_limit_reached", {
|
||||
conversation_id: conversationId,
|
||||
...commonProperties,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
trackLoginButtonClick,
|
||||
trackConversationCreated,
|
||||
@@ -74,5 +106,8 @@ export const useTracking = () => {
|
||||
trackPullButtonClick,
|
||||
trackCreatePrButtonClick,
|
||||
trackGitProviderConnected,
|
||||
trackUserSignupCompleted,
|
||||
trackCreditsPurchased,
|
||||
trackCreditLimitReached,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -944,4 +944,7 @@ export enum I18nKey {
|
||||
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",
|
||||
}
|
||||
|
||||
@@ -15102,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": "Агент планування ініціалізовано"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
||||
import { openHands } from "#/api/open-hands-axios";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
|
||||
export default function AcceptTOS() {
|
||||
const posthog = usePostHog();
|
||||
@@ -17,6 +18,7 @@ export default function AcceptTOS() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [isTosAccepted, setIsTosAccepted] = React.useState(false);
|
||||
const { trackUserSignupCompleted } = useTracking();
|
||||
|
||||
// Get the redirect URL from the query parameters
|
||||
const redirectUrl = searchParams.get("redirect_url") || "/";
|
||||
@@ -33,6 +35,9 @@ export default function AcceptTOS() {
|
||||
});
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
// Track user signup completion
|
||||
trackUserSignupCompleted();
|
||||
|
||||
// Get the redirect URL from the response
|
||||
const finalRedirectUrl = response.data.redirect_url || redirectUrl;
|
||||
|
||||
|
||||
@@ -7,21 +7,35 @@ import {
|
||||
displaySuccessToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
|
||||
function BillingSettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { trackCreditsPurchased } = useTracking();
|
||||
const checkoutStatus = searchParams.get("checkout");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (checkoutStatus === "success") {
|
||||
// Get purchase details from URL params
|
||||
const amount = searchParams.get("amount");
|
||||
const sessionId = searchParams.get("session_id");
|
||||
|
||||
// Track credits purchased if we have the necessary data
|
||||
if (amount && sessionId) {
|
||||
trackCreditsPurchased({
|
||||
amountUsd: parseFloat(amount),
|
||||
stripeSessionId: sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
displaySuccessToast(t(I18nKey.PAYMENT$SUCCESS));
|
||||
} else if (checkoutStatus === "cancel") {
|
||||
displayErrorToast(t(I18nKey.PAYMENT$CANCELLED));
|
||||
}
|
||||
|
||||
setSearchParams({});
|
||||
}, [checkoutStatus]);
|
||||
}, [checkoutStatus, searchParams, setSearchParams, t, trackCreditsPurchased]);
|
||||
|
||||
return <PaymentForm />;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -50,3 +50,11 @@ export function showChatError({
|
||||
status_update: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an error message indicates a budget or credit limit issue
|
||||
*/
|
||||
export function isBudgetOrCreditError(errorMessage: string): boolean {
|
||||
const lowerCaseError = errorMessage.toLowerCase();
|
||||
return lowerCaseError.includes("budget") || lowerCaseError.includes("credit");
|
||||
}
|
||||
|
||||
39
microagents/agent-builder.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: agent_sdk_builder
|
||||
version: 1.0.0
|
||||
author: openhands
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- /agent-builder
|
||||
inputs:
|
||||
- name: INITIAL_PROMPT
|
||||
description: "Initial SDK requirements"
|
||||
---
|
||||
|
||||
# Agent Builder and Interviewer Role
|
||||
|
||||
You are an expert requirements gatherer and agent builder. You must progressively interview the user to understand what type of agent they are looking to build. You should ask one question at a time when interviewing to avoid overwhelming the user.
|
||||
|
||||
Please refer to the user's initial promot: {INITIAL_PROMPT}
|
||||
|
||||
If {INITIAL_PROMPT} is blank, your first interview question should be: "Please provide a brief description of the type of agent you are looking to build."
|
||||
|
||||
# Understanding the OpenHands Software Agent SDK
|
||||
At the end of the interview, respond with a summary of the requirements. Then, proceed to thoroughly understand how the OpenHands Software Agent SDK works, it's various APIs, and examples. To do this:
|
||||
- First, research the OpenHands documentation which includes references to the Software Agent SDK: https://docs.openhands.dev/llms.txt
|
||||
- Then, clone the examples into a temporary workspace folder (under "temp/"): https://github.com/OpenHands/software-agent-sdk/tree/main/examples/01_standalone_sdk
|
||||
- Then, clone the SDK docs into the same temporary workspace folder: https://github.com/OpenHands/docs/tree/main/sdk
|
||||
|
||||
After analyzing the OpenHands Agent SDK, you may optionally ask additional clarifying questions in case it's important for the technical design of the agent.
|
||||
|
||||
# Generating the SDK Plan
|
||||
You can then proceed to build a technical implementation plan based on the user requirements and your understanding of how the OpenHands Agent SDK works.
|
||||
- The plan should be stored in "plan/SDK_PLAN.md" from the root of the workspace.
|
||||
- A visual representation of how the agent should work based on the SDK_PLAN.md. This should look like a flow diagram with nodes and edges. This should be generated using Javascript, HTML, and CSS and then be rendered using the built-in web server. Store this in the plan/ directory.
|
||||
|
||||
# Implementing the Plan
|
||||
After the plan is generated, please ask the user if they are ready to generate the SDK implementation. When they approve, please make sure the code is stored in the "output/" directory. Make sure the code provides logging that a user can see in the terminal. Ideally, the SDK is a single python file.
|
||||
|
||||
Additional guidelines:
|
||||
- Users can configure their LLM API Key using an environment variable named "LLM_API_KEY"
|
||||
- Unless otherwise specified, default to this model: openhands/claude-sonnet-4-20250514. This is configurable through the LLM_BASE_MODEL environment variable.
|
||||
@@ -26,6 +26,7 @@ class AppConversationInfoService(ABC):
|
||||
sort_order: AppConversationSortOrder = AppConversationSortOrder.CREATED_AT_DESC,
|
||||
page_id: str | None = None,
|
||||
limit: int = 100,
|
||||
include_sub_conversations: bool = False,
|
||||
) -> AppConversationInfoPage:
|
||||
"""Search for sandboxed conversations."""
|
||||
|
||||
|
||||
@@ -16,6 +16,13 @@ from openhands.sdk.llm import MetricsSnapshot
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
|
||||
|
||||
|
||||
class AgentType(Enum):
|
||||
"""Agent type for conversation."""
|
||||
|
||||
DEFAULT = 'default'
|
||||
PLAN = 'plan'
|
||||
|
||||
|
||||
class AppConversationInfo(BaseModel):
|
||||
"""Conversation info which does not contain status."""
|
||||
|
||||
@@ -34,6 +41,9 @@ class AppConversationInfo(BaseModel):
|
||||
|
||||
metrics: MetricsSnapshot | None = None
|
||||
|
||||
parent_conversation_id: OpenHandsUUID | None = None
|
||||
sub_conversation_ids: list[OpenHandsUUID] = Field(default_factory=list)
|
||||
|
||||
created_at: datetime = Field(default_factory=utc_now)
|
||||
updated_at: datetime = Field(default_factory=utc_now)
|
||||
|
||||
@@ -98,6 +108,8 @@ class AppConversationStartRequest(BaseModel):
|
||||
title: str | None = None
|
||||
trigger: ConversationTrigger | None = None
|
||||
pr_number: list[int] = Field(default_factory=list)
|
||||
parent_conversation_id: OpenHandsUUID | None = None
|
||||
agent_type: AgentType = Field(default=AgentType.DEFAULT)
|
||||
|
||||
|
||||
class AppConversationStartTaskStatus(Enum):
|
||||
|
||||
@@ -99,6 +99,12 @@ async def search_app_conversations(
|
||||
lte=100,
|
||||
),
|
||||
] = 100,
|
||||
include_sub_conversations: Annotated[
|
||||
bool,
|
||||
Query(
|
||||
title='If True, include sub-conversations in the results. If False (default), exclude all sub-conversations.'
|
||||
),
|
||||
] = False,
|
||||
app_conversation_service: AppConversationService = (
|
||||
app_conversation_service_dependency
|
||||
),
|
||||
@@ -114,6 +120,7 @@ async def search_app_conversations(
|
||||
updated_at__lt=updated_at__lt,
|
||||
page_id=page_id,
|
||||
limit=limit,
|
||||
include_sub_conversations=include_sub_conversations,
|
||||
)
|
||||
|
||||
|
||||
@@ -193,7 +200,8 @@ async def stream_app_conversation_start(
|
||||
user_context: UserContext = user_context_dependency,
|
||||
) -> list[AppConversationStartTask]:
|
||||
"""Start an app conversation start task and stream updates from it.
|
||||
Leaves the connection open until either the conversation starts or there was an error"""
|
||||
Leaves the connection open until either the conversation starts or there was an error
|
||||
"""
|
||||
response = StreamingResponse(
|
||||
_stream_app_conversation_start(request, user_context),
|
||||
media_type='application/json',
|
||||
@@ -207,6 +215,10 @@ async def search_app_conversation_start_tasks(
|
||||
UUID | None,
|
||||
Query(title='Filter by conversation ID equal to this value'),
|
||||
] = None,
|
||||
created_at__gte: Annotated[
|
||||
datetime | None,
|
||||
Query(title='Filter by created_at greater than or equal to this datetime'),
|
||||
] = None,
|
||||
sort_order: Annotated[
|
||||
AppConversationStartTaskSortOrder,
|
||||
Query(title='Sort order for the results'),
|
||||
@@ -233,6 +245,7 @@ async def search_app_conversation_start_tasks(
|
||||
return (
|
||||
await app_conversation_start_task_service.search_app_conversation_start_tasks(
|
||||
conversation_id__eq=conversation_id__eq,
|
||||
created_at__gte=created_at__gte,
|
||||
sort_order=sort_order,
|
||||
page_id=page_id,
|
||||
limit=limit,
|
||||
@@ -246,6 +259,10 @@ async def count_app_conversation_start_tasks(
|
||||
UUID | None,
|
||||
Query(title='Filter by conversation ID equal to this value'),
|
||||
] = None,
|
||||
created_at__gte: Annotated[
|
||||
datetime | None,
|
||||
Query(title='Filter by created_at greater than or equal to this datetime'),
|
||||
] = None,
|
||||
app_conversation_start_task_service: AppConversationStartTaskService = (
|
||||
app_conversation_start_task_service_dependency
|
||||
),
|
||||
@@ -253,6 +270,7 @@ async def count_app_conversation_start_tasks(
|
||||
"""Count conversation start tasks matching the given filters."""
|
||||
return await app_conversation_start_task_service.count_app_conversation_start_tasks(
|
||||
conversation_id__eq=conversation_id__eq,
|
||||
created_at__gte=created_at__gte,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ class AppConversationService(ABC):
|
||||
sort_order: AppConversationSortOrder = AppConversationSortOrder.CREATED_AT_DESC,
|
||||
page_id: str | None = None,
|
||||
limit: int = 100,
|
||||
include_sub_conversations: bool = False,
|
||||
) -> AppConversationPage:
|
||||
"""Search for sandboxed conversations."""
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
@@ -18,6 +19,7 @@ class AppConversationStartTaskService(ABC):
|
||||
async def search_app_conversation_start_tasks(
|
||||
self,
|
||||
conversation_id__eq: UUID | None = None,
|
||||
created_at__gte: datetime | None = None,
|
||||
sort_order: AppConversationStartTaskSortOrder = AppConversationStartTaskSortOrder.CREATED_AT_DESC,
|
||||
page_id: str | None = None,
|
||||
limit: int = 100,
|
||||
@@ -28,6 +30,7 @@ class AppConversationStartTaskService(ABC):
|
||||
async def count_app_conversation_start_tasks(
|
||||
self,
|
||||
conversation_id__eq: UUID | None = None,
|
||||
created_at__gte: datetime | None = None,
|
||||
) -> int:
|
||||
"""Count conversation start tasks."""
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from openhands.app_server.app_conversation.app_conversation_info_service import
|
||||
AppConversationInfoService,
|
||||
)
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AgentType,
|
||||
AppConversation,
|
||||
AppConversationInfo,
|
||||
AppConversationPage,
|
||||
@@ -62,6 +63,9 @@ from openhands.app_server.sandbox.sandbox_spec_service import SandboxSpecService
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.services.jwt_service import JwtService
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.app_server.utils.docker_utils import (
|
||||
replace_localhost_hostname_for_docker,
|
||||
)
|
||||
from openhands.experiments.experiment_manager import ExperimentManagerImpl
|
||||
from openhands.integrations.provider import ProviderType
|
||||
from openhands.sdk import LocalWorkspace
|
||||
@@ -70,6 +74,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 +108,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 +120,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 +175,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 +227,8 @@ class LiveStatusAppConversationService(GitAppConversationService):
|
||||
request.initial_message,
|
||||
request.git_provider,
|
||||
sandbox_spec.working_dir,
|
||||
request.agent_type,
|
||||
request.llm_model,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -224,6 +247,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 +265,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
|
||||
@@ -450,13 +475,46 @@ class LiveStatusAppConversationService(GitAppConversationService):
|
||||
for exposed_url in exposed_urls
|
||||
if exposed_url.name == AGENT_SERVER
|
||||
)
|
||||
agent_server_url = replace_localhost_hostname_for_docker(agent_server_url)
|
||||
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 +546,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(
|
||||
|
||||
@@ -95,6 +95,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
|
||||
@@ -117,10 +118,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,
|
||||
@@ -237,6 +246,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:
|
||||
@@ -247,7 +276,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(
|
||||
@@ -266,8 +297,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)
|
||||
|
||||
@@ -302,6 +338,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)
|
||||
@@ -314,7 +355,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
|
||||
@@ -354,6 +399,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
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Update conversation_metadata table to match StoredConversationMetadata dataclass
|
||||
"""add parent_conversation_id to conversation_metadata
|
||||
|
||||
Revision ID: 003
|
||||
Revises: 002
|
||||
Create Date: 2025-11-11 00:00:00.000000
|
||||
Create Date: 2025-11-06 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
@@ -13,32 +13,29 @@ from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '003'
|
||||
down_revision: Union[str, Sequence[str], None] = '002'
|
||||
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."""
|
||||
# Drop columns that are not in the StoredConversationMetadata dataclass
|
||||
op.drop_column('conversation_metadata', 'github_user_id')
|
||||
op.alter_column(
|
||||
op.add_column(
|
||||
'conversation_metadata',
|
||||
'user_id',
|
||||
existing_type=sa.String(),
|
||||
nullable=True,
|
||||
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."""
|
||||
# Add back the dropped columns
|
||||
op.add_column(
|
||||
'conversation_metadata', sa.Column('github_user_id', sa.String(), nullable=True)
|
||||
)
|
||||
op.alter_column(
|
||||
'conversation_metadata',
|
||||
'user_id',
|
||||
existing_type=sa.String(),
|
||||
nullable=False,
|
||||
op.drop_index(
|
||||
op.f('ix_conversation_metadata_parent_conversation_id'),
|
||||
table_name='conversation_metadata',
|
||||
)
|
||||
op.drop_column('conversation_metadata', 'parent_conversation_id')
|
||||
|
||||
@@ -32,6 +32,9 @@ from openhands.app_server.sandbox.sandbox_service import (
|
||||
)
|
||||
from openhands.app_server.sandbox.sandbox_spec_service import SandboxSpecService
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.utils.docker_utils import (
|
||||
replace_localhost_hostname_for_docker,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
SESSION_API_KEY_VARIABLE = 'OH_SESSION_API_KEYS_0'
|
||||
@@ -185,6 +188,9 @@ class DockerSandboxService(SandboxService):
|
||||
if exposed_url.name == AGENT_SERVER
|
||||
)
|
||||
try:
|
||||
# When running in Docker, replace localhost hostname with host.docker.internal for internal requests
|
||||
app_server_url = replace_localhost_hostname_for_docker(app_server_url)
|
||||
|
||||
response = await self.httpx_client.get(
|
||||
f'{app_server_url}{self.health_check_path}'
|
||||
)
|
||||
@@ -192,7 +198,7 @@ class DockerSandboxService(SandboxService):
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_logger.info(f'Sandbox server not running: {exc}')
|
||||
_logger.info(f'Sandbox server not running: {app_server_url} : {exc}')
|
||||
sandbox_info.status = SandboxStatus.ERROR
|
||||
sandbox_info.exposed_urls = None
|
||||
sandbox_info.session_api_key = None
|
||||
|
||||
@@ -81,10 +81,49 @@ class DockerSandboxSpecServiceInjector(SandboxSpecServiceInjector):
|
||||
try:
|
||||
docker_client.images.get(spec.id)
|
||||
except docker.errors.ImageNotFound:
|
||||
_logger.info(f'⬇️ Pulling Docker Image: {spec.id}')
|
||||
# Pull in a background thread to prevent locking up the main runloop
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, docker_client.images.pull, spec.id)
|
||||
_logger.info(f'⬇️ Finished Pulling Docker Image: {spec.id}')
|
||||
_logger.info(f'⬇️ Pulling Docker Image: {spec.id}')
|
||||
await self._pull_with_progress_logging(docker_client, spec.id)
|
||||
_logger.info(f'⬇️ Finished Pulling Docker Image: {spec.id}')
|
||||
except docker.errors.APIError as exc:
|
||||
raise SandboxError(f'Error Getting Docker Image: {spec.id}') from exc
|
||||
|
||||
async def _pull_with_progress_logging(
|
||||
self, docker_client: docker.DockerClient, image_id: str
|
||||
):
|
||||
"""Pull Docker image with periodic progress logging every 5 seconds."""
|
||||
# Event to signal when pull is complete
|
||||
pull_complete = asyncio.Event()
|
||||
|
||||
async def periodic_logger():
|
||||
"""Log progress message every 5 seconds until pull is complete."""
|
||||
while not pull_complete.is_set():
|
||||
try:
|
||||
await asyncio.wait_for(pull_complete.wait(), timeout=5.0)
|
||||
break # Pull completed
|
||||
except asyncio.TimeoutError:
|
||||
# 5 seconds elapsed, log progress message
|
||||
_logger.info(f'🔄 Downloading Docker Image: {image_id}...')
|
||||
|
||||
async def pull_image():
|
||||
"""Perform the actual Docker image pull."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, docker_client.images.pull, image_id)
|
||||
finally:
|
||||
pull_complete.set()
|
||||
|
||||
# Run both tasks concurrently
|
||||
logger_task = asyncio.create_task(periodic_logger())
|
||||
pull_task = asyncio.create_task(pull_image())
|
||||
|
||||
try:
|
||||
# Wait for pull to complete
|
||||
await pull_task
|
||||
finally:
|
||||
# Ensure logger task is cancelled if still running
|
||||
if not logger_task.done():
|
||||
logger_task.cancel()
|
||||
try:
|
||||
await logger_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
@@ -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):
|
||||
|
||||
32
openhands/app_server/utils/docker_utils.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from openhands.utils.environment import is_running_in_docker
|
||||
|
||||
|
||||
def replace_localhost_hostname_for_docker(
|
||||
url: str, replacement: str = 'host.docker.internal'
|
||||
) -> str:
|
||||
"""Replace localhost hostname in URL with the specified replacement when running in Docker.
|
||||
|
||||
This function only performs the replacement when the code is running inside a Docker
|
||||
container. When not running in Docker, it returns the original URL unchanged.
|
||||
|
||||
Only replaces the hostname if it's exactly 'localhost', preserving all other
|
||||
parts of the URL including port, path, query parameters, etc.
|
||||
|
||||
Args:
|
||||
url: The URL to process
|
||||
replacement: The hostname to replace localhost with (default: 'host.docker.internal')
|
||||
|
||||
Returns:
|
||||
URL with localhost hostname replaced if running in Docker and hostname is localhost,
|
||||
otherwise returns the original URL unchanged
|
||||
"""
|
||||
if not is_running_in_docker():
|
||||
return url
|
||||
parsed = urlparse(url)
|
||||
if parsed.hostname == 'localhost':
|
||||
# Replace only the hostname part, preserving port and everything else
|
||||
netloc = parsed.netloc.replace('localhost', replacement, 1)
|
||||
return urlunparse(parsed._replace(netloc=netloc))
|
||||
return url
|
||||
@@ -80,6 +80,8 @@ FUNCTION_CALLING_PATTERNS: list[str] = [
|
||||
'o4-mini*',
|
||||
# Google Gemini
|
||||
'gemini-2.5-pro*',
|
||||
# Groq models (via groq/ provider prefix)
|
||||
'groq/*',
|
||||
# Others
|
||||
'kimi-k2-0711-preview',
|
||||
'kimi-k2-instruct',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
],
|
||||
)
|
||||
|
||||
@@ -697,10 +697,14 @@ class TestDockerSandboxService:
|
||||
assert result is not None
|
||||
assert isinstance(result.created_at, datetime)
|
||||
|
||||
@patch(
|
||||
'openhands.app_server.utils.docker_utils.is_running_in_docker',
|
||||
return_value=True,
|
||||
)
|
||||
async def test_container_to_checked_sandbox_info_health_check_success(
|
||||
self, service, mock_running_container
|
||||
self, mock_is_docker, service, mock_running_container
|
||||
):
|
||||
"""Test health check success."""
|
||||
"""Test health check success when running in Docker."""
|
||||
# Setup
|
||||
service.httpx_client.get.return_value.raise_for_status.return_value = None
|
||||
|
||||
@@ -715,7 +719,34 @@ class TestDockerSandboxService:
|
||||
assert result.exposed_urls is not None
|
||||
assert result.session_api_key == 'session_key_123'
|
||||
|
||||
# Verify health check was called
|
||||
# Verify health check was called with Docker-internal URL
|
||||
service.httpx_client.get.assert_called_once_with(
|
||||
'http://host.docker.internal:12345/health'
|
||||
)
|
||||
|
||||
@patch(
|
||||
'openhands.app_server.utils.docker_utils.is_running_in_docker',
|
||||
return_value=False,
|
||||
)
|
||||
async def test_container_to_checked_sandbox_info_health_check_success_not_in_docker(
|
||||
self, mock_is_docker, service, mock_running_container
|
||||
):
|
||||
"""Test health check success when not running in Docker."""
|
||||
# Setup
|
||||
service.httpx_client.get.return_value.raise_for_status.return_value = None
|
||||
|
||||
# Execute
|
||||
result = await service._container_to_checked_sandbox_info(
|
||||
mock_running_container
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert result is not None
|
||||
assert result.status == SandboxStatus.RUNNING
|
||||
assert result.exposed_urls is not None
|
||||
assert result.session_api_key == 'session_key_123'
|
||||
|
||||
# Verify health check was called with original localhost URL
|
||||
service.httpx_client.get.assert_called_once_with(
|
||||
'http://localhost:12345/health'
|
||||
)
|
||||
|
||||
@@ -447,3 +447,85 @@ class TestDockerSandboxSpecServiceInjector:
|
||||
# Verify no Docker operations were performed
|
||||
mock_get_docker_client.assert_not_called()
|
||||
mock_docker_client.images.get.assert_not_called()
|
||||
|
||||
@patch('openhands.app_server.sandbox.docker_sandbox_spec_service.get_docker_client')
|
||||
@patch('openhands.app_server.sandbox.docker_sandbox_spec_service._logger')
|
||||
async def test_pull_with_progress_logging(
|
||||
self, mock_logger, mock_get_docker_client, sample_spec
|
||||
):
|
||||
"""Test that periodic progress logging occurs during image pull."""
|
||||
# Setup
|
||||
mock_docker_client = MagicMock()
|
||||
mock_get_docker_client.return_value = mock_docker_client
|
||||
mock_docker_client.images.get.side_effect = ImageNotFound('Image not found')
|
||||
|
||||
# Create a future that will be resolved after some delay to simulate slow pull
|
||||
pull_future = asyncio.Future()
|
||||
|
||||
async def delayed_pull_completion():
|
||||
# Wait for multiple logging intervals to pass
|
||||
await asyncio.sleep(12) # 12 seconds = 2 logging intervals (5s each)
|
||||
pull_future.set_result(MagicMock())
|
||||
|
||||
# Start the delayed completion task
|
||||
asyncio.create_task(delayed_pull_completion())
|
||||
|
||||
# Mock the executor to return our delayed future
|
||||
with patch('asyncio.get_running_loop') as mock_get_loop:
|
||||
mock_loop = MagicMock()
|
||||
mock_get_loop.return_value = mock_loop
|
||||
mock_loop.run_in_executor.return_value = pull_future
|
||||
|
||||
injector = DockerSandboxSpecServiceInjector()
|
||||
|
||||
# Execute
|
||||
await injector.pull_spec_if_missing(sample_spec)
|
||||
|
||||
# Verify that progress logging occurred
|
||||
# Should have initial pull message, progress messages, and completion message
|
||||
progress_calls = [
|
||||
call
|
||||
for call in mock_logger.info.call_args_list
|
||||
if '🔄 Downloading Docker Image:' in str(call)
|
||||
]
|
||||
|
||||
# Should have at least 2 progress log messages (every 5 seconds for 12 seconds)
|
||||
assert len(progress_calls) >= 2
|
||||
|
||||
# Verify the progress message format
|
||||
for call in progress_calls:
|
||||
assert '🔄 Downloading Docker Image: test-image:latest...' in str(call)
|
||||
|
||||
@patch('openhands.app_server.sandbox.docker_sandbox_spec_service.get_docker_client')
|
||||
@patch('openhands.app_server.sandbox.docker_sandbox_spec_service._logger')
|
||||
async def test_pull_with_progress_logging_fast_pull(
|
||||
self, mock_logger, mock_get_docker_client, sample_spec
|
||||
):
|
||||
"""Test that no progress logging occurs for fast pulls (< 5 seconds)."""
|
||||
# Setup
|
||||
mock_docker_client = MagicMock()
|
||||
mock_get_docker_client.return_value = mock_docker_client
|
||||
mock_docker_client.images.get.side_effect = ImageNotFound('Image not found')
|
||||
|
||||
# Mock fast pull (completes immediately)
|
||||
with patch('asyncio.get_running_loop') as mock_get_loop:
|
||||
mock_loop = MagicMock()
|
||||
mock_get_loop.return_value = mock_loop
|
||||
fast_future = asyncio.Future()
|
||||
fast_future.set_result(MagicMock())
|
||||
mock_loop.run_in_executor.return_value = fast_future
|
||||
|
||||
injector = DockerSandboxSpecServiceInjector()
|
||||
|
||||
# Execute
|
||||
await injector.pull_spec_if_missing(sample_spec)
|
||||
|
||||
# Verify that no progress logging occurred (only start/end messages)
|
||||
progress_calls = [
|
||||
call
|
||||
for call in mock_logger.info.call_args_list
|
||||
if '🔄 Downloading Docker Image:' in str(call)
|
||||
]
|
||||
|
||||
# Should have no progress log messages for fast pulls
|
||||
assert len(progress_calls) == 0
|
||||
|
||||
297
tests/unit/app_server/test_docker_utils.py
Normal file
@@ -0,0 +1,297 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from openhands.app_server.utils.docker_utils import (
|
||||
replace_localhost_hostname_for_docker,
|
||||
)
|
||||
|
||||
|
||||
class TestReplaceLocalhostHostnameForDocker:
|
||||
"""Test cases for replace_localhost_hostname_for_docker function."""
|
||||
|
||||
@patch(
|
||||
'openhands.app_server.utils.docker_utils.is_running_in_docker',
|
||||
return_value=True,
|
||||
)
|
||||
def test_replace_localhost_basic_in_docker(self, mock_is_docker):
|
||||
"""Test basic localhost replacement when running in Docker."""
|
||||
# Basic HTTP URL
|
||||
result = replace_localhost_hostname_for_docker('http://localhost:8080')
|
||||
assert result == 'http://host.docker.internal:8080'
|
||||
|
||||
# HTTPS URL
|
||||
result = replace_localhost_hostname_for_docker('https://localhost:443')
|
||||
assert result == 'https://host.docker.internal:443'
|
||||
|
||||
# No port specified
|
||||
result = replace_localhost_hostname_for_docker('http://localhost')
|
||||
assert result == 'http://host.docker.internal'
|
||||
|
||||
@patch(
|
||||
'openhands.app_server.utils.docker_utils.is_running_in_docker',
|
||||
return_value=False,
|
||||
)
|
||||
def test_replace_localhost_basic_not_in_docker(self, mock_is_docker):
|
||||
"""Test that localhost is NOT replaced when not running in Docker."""
|
||||
# Basic HTTP URL
|
||||
result = replace_localhost_hostname_for_docker('http://localhost:8080')
|
||||
assert result == 'http://localhost:8080'
|
||||
|
||||
# HTTPS URL
|
||||
result = replace_localhost_hostname_for_docker('https://localhost:443')
|
||||
assert result == 'https://localhost:443'
|
||||
|
||||
# No port specified
|
||||
result = replace_localhost_hostname_for_docker('http://localhost')
|
||||
assert result == 'http://localhost'
|
||||
|
||||
@patch(
|
||||
'openhands.app_server.utils.docker_utils.is_running_in_docker',
|
||||
return_value=True,
|
||||
)
|
||||
def test_replace_localhost_with_path_and_query(self, mock_is_docker):
|
||||
"""Test localhost replacement preserving path and query parameters."""
|
||||
# With path
|
||||
result = replace_localhost_hostname_for_docker(
|
||||
'http://localhost:3000/api/health'
|
||||
)
|
||||
assert result == 'http://host.docker.internal:3000/api/health'
|
||||
|
||||
# With query parameters containing localhost
|
||||
result = replace_localhost_hostname_for_docker(
|
||||
'http://localhost:8080/path?param=localhost&other=value'
|
||||
)
|
||||
assert (
|
||||
result
|
||||
== 'http://host.docker.internal:8080/path?param=localhost&other=value'
|
||||
)
|
||||
|
||||
# With path containing localhost
|
||||
result = replace_localhost_hostname_for_docker(
|
||||
'http://localhost:9000/localhost/endpoint'
|
||||
)
|
||||
assert result == 'http://host.docker.internal:9000/localhost/endpoint'
|
||||
|
||||
# With fragment
|
||||
result = replace_localhost_hostname_for_docker(
|
||||
'http://localhost:8080/path#localhost'
|
||||
)
|
||||
assert result == 'http://host.docker.internal:8080/path#localhost'
|
||||
|
||||
@patch(
|
||||
'openhands.app_server.utils.docker_utils.is_running_in_docker',
|
||||
return_value=True,
|
||||
)
|
||||
def test_replace_localhost_with_authentication(self, mock_is_docker):
|
||||
"""Test localhost replacement with authentication in URL."""
|
||||
result = replace_localhost_hostname_for_docker(
|
||||
'http://user:pass@localhost:8080/path'
|
||||
)
|
||||
assert result == 'http://user:pass@host.docker.internal:8080/path'
|
||||
|
||||
result = replace_localhost_hostname_for_docker(
|
||||
'https://admin:secret@localhost:443/admin'
|
||||
)
|
||||
assert result == 'https://admin:secret@host.docker.internal:443/admin'
|
||||
|
||||
@patch(
|
||||
'openhands.app_server.utils.docker_utils.is_running_in_docker',
|
||||
return_value=True,
|
||||
)
|
||||
def test_replace_localhost_different_protocols(self, mock_is_docker):
|
||||
"""Test localhost replacement with different protocols."""
|
||||
# FTP
|
||||
result = replace_localhost_hostname_for_docker('ftp://localhost:21/files')
|
||||
assert result == 'ftp://host.docker.internal:21/files'
|
||||
|
||||
# WebSocket
|
||||
result = replace_localhost_hostname_for_docker('ws://localhost:8080/socket')
|
||||
assert result == 'ws://host.docker.internal:8080/socket'
|
||||
|
||||
# WebSocket Secure
|
||||
result = replace_localhost_hostname_for_docker(
|
||||
'wss://localhost:443/secure-socket'
|
||||
)
|
||||
assert result == 'wss://host.docker.internal:443/secure-socket'
|
||||
|
||||
@patch(
|
||||
'openhands.app_server.utils.docker_utils.is_running_in_docker',
|
||||
return_value=True,
|
||||
)
|
||||
def test_no_replacement_for_non_localhost(self, mock_is_docker):
|
||||
"""Test that non-localhost hostnames are not replaced even when in Docker."""
|
||||
# IP address
|
||||
result = replace_localhost_hostname_for_docker('http://127.0.0.1:8080')
|
||||
assert result == 'http://127.0.0.1:8080'
|
||||
|
||||
# Different hostname
|
||||
result = replace_localhost_hostname_for_docker('http://example.com:8080')
|
||||
assert result == 'http://example.com:8080'
|
||||
|
||||
# Hostname containing localhost but not exact match
|
||||
result = replace_localhost_hostname_for_docker('http://mylocalhost:8080')
|
||||
assert result == 'http://mylocalhost:8080'
|
||||
|
||||
# Subdomain of localhost
|
||||
result = replace_localhost_hostname_for_docker('http://api.localhost:8080')
|
||||
assert result == 'http://api.localhost:8080'
|
||||
|
||||
# localhost as subdomain
|
||||
result = replace_localhost_hostname_for_docker(
|
||||
'http://localhost.example.com:8080'
|
||||
)
|
||||
assert result == 'http://localhost.example.com:8080'
|
||||
|
||||
@patch(
|
||||
'openhands.app_server.utils.docker_utils.is_running_in_docker',
|
||||
return_value=True,
|
||||
)
|
||||
def test_custom_replacement_hostname(self, mock_is_docker):
|
||||
"""Test using custom replacement hostname."""
|
||||
result = replace_localhost_hostname_for_docker(
|
||||
'http://localhost:8080', 'custom.host'
|
||||
)
|
||||
assert result == 'http://custom.host:8080'
|
||||
|
||||
result = replace_localhost_hostname_for_docker(
|
||||
'https://localhost:443/path', 'internal.docker'
|
||||
)
|
||||
assert result == 'https://internal.docker:443/path'
|
||||
|
||||
@patch(
|
||||
'openhands.app_server.utils.docker_utils.is_running_in_docker',
|
||||
return_value=True,
|
||||
)
|
||||
def test_edge_cases_in_docker(self, mock_is_docker):
|
||||
"""Test edge cases and malformed URLs when in Docker."""
|
||||
# Empty string
|
||||
result = replace_localhost_hostname_for_docker('')
|
||||
assert result == ''
|
||||
|
||||
# Malformed URL (no protocol)
|
||||
result = replace_localhost_hostname_for_docker('localhost:8080')
|
||||
assert result == 'localhost:8080'
|
||||
|
||||
# Just hostname
|
||||
result = replace_localhost_hostname_for_docker('localhost')
|
||||
assert result == 'localhost'
|
||||
|
||||
# URL with no hostname
|
||||
result = replace_localhost_hostname_for_docker('http://:8080/path')
|
||||
assert result == 'http://:8080/path'
|
||||
|
||||
# Invalid URL structure
|
||||
result = replace_localhost_hostname_for_docker('not-a-url')
|
||||
assert result == 'not-a-url'
|
||||
|
||||
@patch(
|
||||
'openhands.app_server.utils.docker_utils.is_running_in_docker',
|
||||
return_value=False,
|
||||
)
|
||||
def test_edge_cases_not_in_docker(self, mock_is_docker):
|
||||
"""Test edge cases and malformed URLs when not in Docker."""
|
||||
# Empty string
|
||||
result = replace_localhost_hostname_for_docker('')
|
||||
assert result == ''
|
||||
|
||||
# Malformed URL (no protocol)
|
||||
result = replace_localhost_hostname_for_docker('localhost:8080')
|
||||
assert result == 'localhost:8080'
|
||||
|
||||
# Just hostname
|
||||
result = replace_localhost_hostname_for_docker('localhost')
|
||||
assert result == 'localhost'
|
||||
|
||||
# URL with no hostname
|
||||
result = replace_localhost_hostname_for_docker('http://:8080/path')
|
||||
assert result == 'http://:8080/path'
|
||||
|
||||
# Invalid URL structure
|
||||
result = replace_localhost_hostname_for_docker('not-a-url')
|
||||
assert result == 'not-a-url'
|
||||
|
||||
@patch(
|
||||
'openhands.app_server.utils.docker_utils.is_running_in_docker',
|
||||
return_value=True,
|
||||
)
|
||||
def test_complex_urls(self, mock_is_docker):
|
||||
"""Test complex URL scenarios."""
|
||||
# Multiple query parameters and fragments
|
||||
complex_url = 'http://localhost:8080/api/v1/health?timeout=30&retry=3&host=localhost#section'
|
||||
result = replace_localhost_hostname_for_docker(complex_url)
|
||||
expected = 'http://host.docker.internal:8080/api/v1/health?timeout=30&retry=3&host=localhost#section'
|
||||
assert result == expected
|
||||
|
||||
# URL with encoded characters
|
||||
encoded_url = (
|
||||
'http://localhost:8080/path%20with%20spaces?param=value%20with%20spaces'
|
||||
)
|
||||
result = replace_localhost_hostname_for_docker(encoded_url)
|
||||
expected = 'http://host.docker.internal:8080/path%20with%20spaces?param=value%20with%20spaces'
|
||||
assert result == expected
|
||||
|
||||
@patch(
|
||||
'openhands.app_server.utils.docker_utils.is_running_in_docker',
|
||||
return_value=True,
|
||||
)
|
||||
def test_integration_with_docker_detection_in_docker(self, mock_is_docker):
|
||||
"""Test integration scenario similar to actual usage when in Docker."""
|
||||
# Simulate the actual usage pattern in the code
|
||||
app_server_url = 'http://localhost:35375'
|
||||
|
||||
# This is how it's used in the actual code
|
||||
internal_url = replace_localhost_hostname_for_docker(app_server_url)
|
||||
|
||||
assert internal_url == 'http://host.docker.internal:35375'
|
||||
|
||||
# Test with health check path appended
|
||||
health_check_url = f'{internal_url}/health'
|
||||
assert health_check_url == 'http://host.docker.internal:35375/health'
|
||||
|
||||
@patch(
|
||||
'openhands.app_server.utils.docker_utils.is_running_in_docker',
|
||||
return_value=False,
|
||||
)
|
||||
def test_integration_with_docker_detection_not_in_docker(self, mock_is_docker):
|
||||
"""Test integration scenario similar to actual usage when not in Docker."""
|
||||
# Simulate the actual usage pattern in the code
|
||||
app_server_url = 'http://localhost:35375'
|
||||
|
||||
# This is how it's used in the actual code
|
||||
internal_url = replace_localhost_hostname_for_docker(app_server_url)
|
||||
|
||||
# Should return original URL when not in Docker
|
||||
assert internal_url == 'http://localhost:35375'
|
||||
|
||||
# Test with health check path appended
|
||||
health_check_url = f'{internal_url}/health'
|
||||
assert health_check_url == 'http://localhost:35375/health'
|
||||
|
||||
@patch(
|
||||
'openhands.app_server.utils.docker_utils.is_running_in_docker',
|
||||
return_value=True,
|
||||
)
|
||||
def test_preserves_original_url_structure(self, mock_is_docker):
|
||||
"""Test that all URL components are preserved correctly."""
|
||||
original_url = 'https://user:pass@localhost:8443/api/v1/endpoint?param1=value1¶m2=value2#fragment'
|
||||
result = replace_localhost_hostname_for_docker(original_url)
|
||||
expected = 'https://user:pass@host.docker.internal:8443/api/v1/endpoint?param1=value1¶m2=value2#fragment'
|
||||
|
||||
assert result == expected
|
||||
|
||||
# Verify each component is preserved
|
||||
from urllib.parse import urlparse
|
||||
|
||||
original_parsed = urlparse(original_url)
|
||||
result_parsed = urlparse(result)
|
||||
|
||||
assert original_parsed.scheme == result_parsed.scheme
|
||||
assert original_parsed.username == result_parsed.username
|
||||
assert original_parsed.password == result_parsed.password
|
||||
assert original_parsed.port == result_parsed.port
|
||||
assert original_parsed.path == result_parsed.path
|
||||
assert original_parsed.query == result_parsed.query
|
||||
assert original_parsed.fragment == result_parsed.fragment
|
||||
|
||||
# Only hostname should be different
|
||||
assert original_parsed.hostname == 'localhost'
|
||||
assert result_parsed.hostname == 'host.docker.internal'
|
||||
@@ -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',
|
||||
|
||||
@@ -563,3 +563,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
|
||||
|
||||
@@ -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
|
||||
|
||||