mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
feat: Load workspace hooks for V1 conversations and add hooks viewer UI (#12773)
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: enyst <engel.nyst@gmail.com> Co-authored-by: Alona King <alona@all-hands.dev>
This commit is contained in:
@@ -44,6 +44,7 @@ describe("SystemMessage UI Rendering", () => {
|
||||
<ToolsContextMenu
|
||||
onClose={() => {}}
|
||||
onShowSkills={() => {}}
|
||||
onShowHooks={() => {}}
|
||||
onShowAgentTools={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { HookEventItem } from "#/components/features/conversation-panel/hook-event-item";
|
||||
import { HooksEmptyState } from "#/components/features/conversation-panel/hooks-empty-state";
|
||||
import { HooksLoadingState } from "#/components/features/conversation-panel/hooks-loading-state";
|
||||
import { HooksModalHeader } from "#/components/features/conversation-panel/hooks-modal-header";
|
||||
import { HookEvent } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) => {
|
||||
const translations: Record<string, string> = {
|
||||
HOOKS_MODAL$TITLE: "Available Hooks",
|
||||
HOOKS_MODAL$HOOK_COUNT: `${params?.count ?? 0} hooks`,
|
||||
HOOKS_MODAL$EVENT_PRE_TOOL_USE: "Pre Tool Use",
|
||||
HOOKS_MODAL$EVENT_POST_TOOL_USE: "Post Tool Use",
|
||||
HOOKS_MODAL$EVENT_USER_PROMPT_SUBMIT: "User Prompt Submit",
|
||||
HOOKS_MODAL$EVENT_SESSION_START: "Session Start",
|
||||
HOOKS_MODAL$EVENT_SESSION_END: "Session End",
|
||||
HOOKS_MODAL$EVENT_STOP: "Stop",
|
||||
HOOKS_MODAL$MATCHER: "Matcher",
|
||||
HOOKS_MODAL$COMMANDS: "Commands",
|
||||
HOOKS_MODAL$TYPE: `Type: ${params?.type ?? ""}`,
|
||||
HOOKS_MODAL$TIMEOUT: `Timeout: ${params?.timeout ?? 0}s`,
|
||||
HOOKS_MODAL$ASYNC: "Async",
|
||||
COMMON$FETCH_ERROR: "Failed to fetch data",
|
||||
CONVERSATION$NO_HOOKS: "No hooks configured",
|
||||
BUTTON$REFRESH: "Refresh",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
i18n: {
|
||||
changeLanguage: () => new Promise(() => {}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("HooksLoadingState", () => {
|
||||
it("should render loading spinner", () => {
|
||||
render(<HooksLoadingState />);
|
||||
const spinner = document.querySelector(".animate-spin");
|
||||
expect(spinner).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HooksEmptyState", () => {
|
||||
it("should render no hooks message when not error", () => {
|
||||
render(<HooksEmptyState isError={false} />);
|
||||
expect(screen.getByText("No hooks configured")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render error message when isError is true", () => {
|
||||
render(<HooksEmptyState isError={true} />);
|
||||
expect(screen.getByText("Failed to fetch data")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HooksModalHeader", () => {
|
||||
const defaultProps = {
|
||||
isAgentReady: true,
|
||||
isLoading: false,
|
||||
isRefetching: false,
|
||||
onRefresh: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render title", () => {
|
||||
render(<HooksModalHeader {...defaultProps} />);
|
||||
expect(screen.getByText("Available Hooks")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render refresh button when agent is ready", () => {
|
||||
render(<HooksModalHeader {...defaultProps} />);
|
||||
expect(screen.getByTestId("refresh-hooks")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render refresh button when agent is not ready", () => {
|
||||
render(<HooksModalHeader {...defaultProps} isAgentReady={false} />);
|
||||
expect(screen.queryByTestId("refresh-hooks")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onRefresh when refresh button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRefresh = vi.fn();
|
||||
render(<HooksModalHeader {...defaultProps} onRefresh={onRefresh} />);
|
||||
|
||||
await user.click(screen.getByTestId("refresh-hooks"));
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should disable refresh button when loading", () => {
|
||||
render(<HooksModalHeader {...defaultProps} isLoading={true} />);
|
||||
expect(screen.getByTestId("refresh-hooks")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should disable refresh button when refetching", () => {
|
||||
render(<HooksModalHeader {...defaultProps} isRefetching={true} />);
|
||||
expect(screen.getByTestId("refresh-hooks")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HookEventItem", () => {
|
||||
const mockHookEvent: HookEvent = {
|
||||
event_type: "stop",
|
||||
matchers: [
|
||||
{
|
||||
matcher: "*",
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: ".openhands/hooks/on_stop.sh",
|
||||
timeout: 30,
|
||||
async: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
hookEvent: mockHookEvent,
|
||||
isExpanded: false,
|
||||
onToggle: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render event type label using i18n", () => {
|
||||
render(<HookEventItem {...defaultProps} />);
|
||||
expect(screen.getByText("Stop")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render hook count", () => {
|
||||
render(<HookEventItem {...defaultProps} />);
|
||||
expect(screen.getByText("1 hooks")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onToggle when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggle = vi.fn();
|
||||
render(<HookEventItem {...defaultProps} onToggle={onToggle} />);
|
||||
|
||||
await user.click(screen.getByRole("button"));
|
||||
expect(onToggle).toHaveBeenCalledWith("stop");
|
||||
});
|
||||
|
||||
it("should show collapsed state by default", () => {
|
||||
render(<HookEventItem {...defaultProps} isExpanded={false} />);
|
||||
// Matcher content should not be visible when collapsed
|
||||
expect(screen.queryByText("*")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show expanded state with matcher content", () => {
|
||||
render(<HookEventItem {...defaultProps} isExpanded={true} />);
|
||||
// Matcher content should be visible when expanded
|
||||
expect(screen.getByText("*")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render async badge for async hooks", () => {
|
||||
render(<HookEventItem {...defaultProps} isExpanded={true} />);
|
||||
expect(screen.getByText("Async")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render different event types with correct i18n labels", () => {
|
||||
const eventTypes = [
|
||||
{ type: "pre_tool_use", label: "Pre Tool Use" },
|
||||
{ type: "post_tool_use", label: "Post Tool Use" },
|
||||
{ type: "user_prompt_submit", label: "User Prompt Submit" },
|
||||
{ type: "session_start", label: "Session Start" },
|
||||
{ type: "session_end", label: "Session End" },
|
||||
{ type: "stop", label: "Stop" },
|
||||
];
|
||||
|
||||
eventTypes.forEach(({ type, label }) => {
|
||||
const { unmount } = render(
|
||||
<HookEventItem
|
||||
{...defaultProps}
|
||||
hookEvent={{ ...mockHookEvent, event_type: type }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("should fallback to event_type when no i18n key exists", () => {
|
||||
render(
|
||||
<HookEventItem
|
||||
{...defaultProps}
|
||||
hookEvent={{ ...mockHookEvent, event_type: "unknown_event" }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("unknown_event")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
V1AppConversation,
|
||||
V1AppConversationPage,
|
||||
GetSkillsResponse,
|
||||
GetHooksResponse,
|
||||
V1RuntimeConversationInfo,
|
||||
} from "./v1-conversation-service.types";
|
||||
|
||||
@@ -400,6 +401,18 @@ class V1ConversationService {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all hooks associated with a V1 conversation
|
||||
* @param conversationId The conversation ID
|
||||
* @returns The available hooks associated with the conversation
|
||||
*/
|
||||
static async getHooks(conversationId: string): Promise<GetHooksResponse> {
|
||||
const { data } = await openHands.get<GetHooksResponse>(
|
||||
`/api/v1/app-conversations/${conversationId}/hooks`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conversation info directly from the runtime for a V1 conversation
|
||||
* Uses the custom runtime URL from the conversation
|
||||
|
||||
@@ -135,6 +135,27 @@ export interface GetSkillsResponse {
|
||||
skills: Skill[];
|
||||
}
|
||||
|
||||
export interface HookDefinition {
|
||||
type: string; // 'command' or 'prompt'
|
||||
command: string;
|
||||
timeout: number;
|
||||
async?: boolean;
|
||||
}
|
||||
|
||||
export interface HookMatcher {
|
||||
matcher: string; // Pattern: '*', exact match, or regex
|
||||
hooks: HookDefinition[];
|
||||
}
|
||||
|
||||
export interface HookEvent {
|
||||
event_type: string; // e.g., 'stop', 'pre_tool_use', 'post_tool_use'
|
||||
matchers: HookMatcher[];
|
||||
}
|
||||
|
||||
export interface GetHooksResponse {
|
||||
hooks: HookEvent[];
|
||||
}
|
||||
|
||||
// Runtime conversation types (from agent server)
|
||||
export interface V1RuntimeConversationStats {
|
||||
usage_to_metrics: Record<string, V1RuntimeMetrics>;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { HookExecutionEventMessage } from "#/components/shared/hook-execution-event-message";
|
||||
@@ -8,3 +8,4 @@ export { ObservationPairEventMessage } from "./observation-pair-event-message";
|
||||
export { GenericEventMessageWrapper } from "./generic-event-message-wrapper";
|
||||
export { MicroagentStatusWrapper } from "./microagent-status-wrapper";
|
||||
export { LikertScaleWrapper } from "./likert-scale-wrapper";
|
||||
export { HookExecutionEventMessage } from "./hook-execution-event-message";
|
||||
|
||||
@@ -27,15 +27,19 @@ const contextMenuListItemClassName = cn(
|
||||
interface ToolsContextMenuProps {
|
||||
onClose: () => void;
|
||||
onShowSkills: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onShowHooks: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onShowAgentTools: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
shouldShowAgentTools?: boolean;
|
||||
shouldShowHooks?: boolean;
|
||||
}
|
||||
|
||||
export function ToolsContextMenu({
|
||||
onClose,
|
||||
onShowSkills,
|
||||
onShowHooks,
|
||||
onShowAgentTools,
|
||||
shouldShowAgentTools = true,
|
||||
shouldShowHooks = false,
|
||||
}: ToolsContextMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
@@ -141,6 +145,21 @@ export function ToolsContextMenu({
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
|
||||
{/* Show Hooks - Only show for V1 conversations */}
|
||||
{shouldShowHooks && (
|
||||
<ContextMenuListItem
|
||||
testId="show-hooks-button"
|
||||
onClick={onShowHooks}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<ToolsContextMenuIconText
|
||||
icon={<ToolsIcon width={16} height={16} />}
|
||||
text={t(I18nKey.CONVERSATION$SHOW_HOOKS)}
|
||||
className={CONTEXT_MENU_ICON_TEXT_CLASSNAME}
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
|
||||
{/* Show Agent Tools and Metadata - Only show if system message is available */}
|
||||
{shouldShowAgentTools && (
|
||||
<ContextMenuListItem
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useConversationNameContextMenu } from "#/hooks/use-conversation-name-co
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { SystemMessageModal } from "../conversation-panel/system-message-modal";
|
||||
import { SkillsModal } from "../conversation-panel/skills-modal";
|
||||
import { HooksModal } from "../conversation-panel/hooks-modal";
|
||||
|
||||
export function Tools() {
|
||||
const { t } = useTranslation();
|
||||
@@ -18,12 +19,16 @@ export function Tools() {
|
||||
const {
|
||||
handleShowAgentTools,
|
||||
handleShowSkills,
|
||||
handleShowHooks,
|
||||
systemModalVisible,
|
||||
setSystemModalVisible,
|
||||
skillsModalVisible,
|
||||
setSkillsModalVisible,
|
||||
hooksModalVisible,
|
||||
setHooksModalVisible,
|
||||
systemMessage,
|
||||
shouldShowAgentTools,
|
||||
shouldShowHooks,
|
||||
} = useConversationNameContextMenu({
|
||||
conversationId,
|
||||
conversationStatus: conversation?.status,
|
||||
@@ -52,8 +57,10 @@ export function Tools() {
|
||||
<ToolsContextMenu
|
||||
onClose={() => setContextMenuOpen(false)}
|
||||
onShowSkills={handleShowSkills}
|
||||
onShowHooks={handleShowHooks}
|
||||
onShowAgentTools={handleShowAgentTools}
|
||||
shouldShowAgentTools={shouldShowAgentTools}
|
||||
shouldShowHooks={shouldShowHooks}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -68,6 +75,11 @@ export function Tools() {
|
||||
{skillsModalVisible && (
|
||||
<SkillsModal onClose={() => setSkillsModalVisible(false)} />
|
||||
)}
|
||||
|
||||
{/* Hooks Modal */}
|
||||
{hooksModalVisible && (
|
||||
<HooksModal onClose={() => setHooksModalVisible(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { HookEvent } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
import { HookMatcherContent } from "./hook-matcher-content";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface HookEventItemProps {
|
||||
hookEvent: HookEvent;
|
||||
isExpanded: boolean;
|
||||
onToggle: (eventType: string) => void;
|
||||
}
|
||||
|
||||
const EVENT_TYPE_I18N_KEYS: Record<string, I18nKey> = {
|
||||
pre_tool_use: I18nKey.HOOKS_MODAL$EVENT_PRE_TOOL_USE,
|
||||
post_tool_use: I18nKey.HOOKS_MODAL$EVENT_POST_TOOL_USE,
|
||||
user_prompt_submit: I18nKey.HOOKS_MODAL$EVENT_USER_PROMPT_SUBMIT,
|
||||
session_start: I18nKey.HOOKS_MODAL$EVENT_SESSION_START,
|
||||
session_end: I18nKey.HOOKS_MODAL$EVENT_SESSION_END,
|
||||
stop: I18nKey.HOOKS_MODAL$EVENT_STOP,
|
||||
};
|
||||
|
||||
export function HookEventItem({
|
||||
hookEvent,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: HookEventItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const i18nKey = EVENT_TYPE_I18N_KEYS[hookEvent.event_type];
|
||||
const eventTypeLabel = i18nKey ? t(i18nKey) : hookEvent.event_type;
|
||||
|
||||
const totalHooks = hookEvent.matchers.reduce(
|
||||
(sum, matcher) => sum + matcher.hooks.length,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-md overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(hookEvent.event_type)}
|
||||
className="w-full py-3 px-2 text-left flex items-center justify-between hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Typography.Text className="font-bold text-gray-100">
|
||||
{eventTypeLabel}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Typography.Text className="px-2 py-1 text-xs rounded-full bg-gray-800 mr-2">
|
||||
{t(I18nKey.HOOKS_MODAL$HOOK_COUNT, { count: totalHooks })}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="text-gray-300">
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={18} />
|
||||
) : (
|
||||
<ChevronRight size={18} />
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-2 pb-3 pt-1">
|
||||
{hookEvent.matchers.map((matcher, index) => (
|
||||
<HookMatcherContent
|
||||
key={`${hookEvent.event_type}-${matcher.matcher}-${index}`}
|
||||
matcher={matcher}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { Pre } from "#/ui/pre";
|
||||
import { HookMatcher } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
|
||||
interface HookMatcherContentProps {
|
||||
matcher: HookMatcher;
|
||||
}
|
||||
|
||||
export function HookMatcherContent({ matcher }: HookMatcherContentProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="mb-4 p-3 bg-gray-800 rounded-md">
|
||||
<div className="mb-2">
|
||||
<Typography.Text className="text-sm font-semibold text-gray-300">
|
||||
{t(I18nKey.HOOKS_MODAL$MATCHER)}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="ml-2 px-2 py-1 text-xs rounded-full bg-blue-900">
|
||||
{matcher.matcher}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Typography.Text className="text-sm font-semibold text-gray-300 mb-2">
|
||||
{t(I18nKey.HOOKS_MODAL$COMMANDS)}
|
||||
</Typography.Text>
|
||||
{matcher.hooks.map((hook, index) => (
|
||||
<div key={`${hook.command}-${index}`} className="mt-2">
|
||||
<Pre
|
||||
size="default"
|
||||
font="mono"
|
||||
lineHeight="relaxed"
|
||||
background="dark"
|
||||
textColor="light"
|
||||
padding="medium"
|
||||
borderRadius="medium"
|
||||
shadow="inner"
|
||||
maxHeight="small"
|
||||
overflow="auto"
|
||||
>
|
||||
{hook.command}
|
||||
</Pre>
|
||||
<div className="flex gap-4 mt-1 text-xs text-gray-400">
|
||||
<span>{t(I18nKey.HOOKS_MODAL$TYPE, { type: hook.type })}</span>
|
||||
<span>
|
||||
{t(I18nKey.HOOKS_MODAL$TIMEOUT, { timeout: hook.timeout })}
|
||||
</span>
|
||||
{hook.async ? (
|
||||
<span className="rounded-full bg-emerald-900 px-2 py-0.5 text-emerald-300">
|
||||
{t(I18nKey.HOOKS_MODAL$ASYNC)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Typography } from "#/ui/typography";
|
||||
|
||||
interface HooksEmptyStateProps {
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
export function HooksEmptyState({ isError }: HooksEmptyStateProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<Typography.Text className="text-gray-400">
|
||||
{isError
|
||||
? t(I18nKey.COMMON$FETCH_ERROR)
|
||||
: t(I18nKey.CONVERSATION$NO_HOOKS)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function HooksLoadingState() {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
|
||||
interface HooksModalHeaderProps {
|
||||
isAgentReady: boolean;
|
||||
isLoading: boolean;
|
||||
isRefetching: boolean;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function HooksModalHeader({
|
||||
isAgentReady,
|
||||
isLoading,
|
||||
isRefetching,
|
||||
onRefresh,
|
||||
}: HooksModalHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 w-full">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<BaseModalTitle title={t(I18nKey.HOOKS_MODAL$TITLE)} />
|
||||
{isAgentReady && (
|
||||
<BrandButton
|
||||
testId="refresh-hooks"
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="flex items-center gap-2"
|
||||
onClick={onRefresh}
|
||||
isDisabled={isLoading || isRefetching}
|
||||
>
|
||||
<RefreshCw
|
||||
size={16}
|
||||
className={`${isRefetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t(I18nKey.BUTTON$REFRESH)}
|
||||
</BrandButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useConversationHooks } from "#/hooks/query/use-conversation-hooks";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { HooksModalHeader } from "./hooks-modal-header";
|
||||
import { HooksLoadingState } from "./hooks-loading-state";
|
||||
import { HooksEmptyState } from "./hooks-empty-state";
|
||||
import { HookEventItem } from "./hook-event-item";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
|
||||
interface HooksModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function HooksModal({ onClose }: HooksModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { curAgentState } = useAgentState();
|
||||
const [expandedEvents, setExpandedEvents] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const {
|
||||
data: hooks,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
isRefetching,
|
||||
} = useConversationHooks();
|
||||
|
||||
const toggleEvent = (eventType: string) => {
|
||||
setExpandedEvents((prev) => ({
|
||||
...prev,
|
||||
[eventType]: !prev[eventType],
|
||||
}));
|
||||
};
|
||||
|
||||
const isAgentReady = ![AgentState.LOADING, AgentState.INIT].includes(
|
||||
curAgentState,
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<ModalBody
|
||||
width="medium"
|
||||
className="max-h-[80vh] flex flex-col items-start"
|
||||
testID="hooks-modal"
|
||||
>
|
||||
<HooksModalHeader
|
||||
isAgentReady={isAgentReady}
|
||||
isLoading={isLoading}
|
||||
isRefetching={isRefetching}
|
||||
onRefresh={refetch}
|
||||
/>
|
||||
|
||||
{isAgentReady && (
|
||||
<Typography.Text className="text-sm text-gray-400">
|
||||
{t(I18nKey.HOOKS_MODAL$WARNING)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
|
||||
<div className="w-full h-[60vh] overflow-auto rounded-md custom-scrollbar-always">
|
||||
{!isAgentReady && (
|
||||
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
|
||||
<Typography.Text>
|
||||
{t(I18nKey.DIFF_VIEWER$WAITING_FOR_RUNTIME)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <HooksLoadingState />}
|
||||
|
||||
{!isLoading &&
|
||||
isAgentReady &&
|
||||
(isError || !hooks || hooks.length === 0) && (
|
||||
<HooksEmptyState isError={isError} />
|
||||
)}
|
||||
|
||||
{!isLoading && isAgentReady && hooks && hooks.length > 0 && (
|
||||
<div className="p-2 space-y-3">
|
||||
{hooks.map((hookEvent) => {
|
||||
const isExpanded =
|
||||
expandedEvents[hookEvent.event_type] || false;
|
||||
|
||||
return (
|
||||
<HookEventItem
|
||||
key={hookEvent.event_type}
|
||||
hookEvent={hookEvent}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={toggleEvent}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ interface ConversationNameContextMenuProps {
|
||||
onDisplayCost?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onShowAgentTools?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onShowSkills?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onShowHooks?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onExportConversation?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onDownloadViaVSCode?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onTogglePublic?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
@@ -52,6 +53,7 @@ export function ConversationNameContextMenu({
|
||||
onDisplayCost,
|
||||
onShowAgentTools,
|
||||
onShowSkills,
|
||||
onShowHooks,
|
||||
onExportConversation,
|
||||
onDownloadViaVSCode,
|
||||
onTogglePublic,
|
||||
@@ -77,7 +79,7 @@ export function ConversationNameContextMenu({
|
||||
|
||||
const hasDownload = Boolean(onDownloadViaVSCode || onDownloadConversation);
|
||||
const hasExport = Boolean(onExportConversation);
|
||||
const hasTools = Boolean(onShowAgentTools || onShowSkills);
|
||||
const hasTools = Boolean(onShowAgentTools || onShowSkills || onShowHooks);
|
||||
const hasInfo = Boolean(onDisplayCost);
|
||||
const hasControl = Boolean(onStop || onDelete);
|
||||
|
||||
@@ -119,6 +121,20 @@ export function ConversationNameContextMenu({
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
|
||||
{onShowHooks && (
|
||||
<ContextMenuListItem
|
||||
testId="show-hooks-button"
|
||||
onClick={onShowHooks}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<ConversationNameContextMenuIconText
|
||||
icon={<ToolsIcon width={16} height={16} />}
|
||||
text={t(I18nKey.CONVERSATION$SHOW_HOOKS)}
|
||||
className={CONTEXT_MENU_ICON_TEXT_CLASSNAME}
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
|
||||
{onShowAgentTools && (
|
||||
<ContextMenuListItem
|
||||
testId="show-agent-tools-button"
|
||||
|
||||
@@ -10,6 +10,7 @@ import { EllipsisButton } from "../conversation-panel/ellipsis-button";
|
||||
import { ConversationNameContextMenu } from "./conversation-name-context-menu";
|
||||
import { SystemMessageModal } from "../conversation-panel/system-message-modal";
|
||||
import { SkillsModal } from "../conversation-panel/skills-modal";
|
||||
import { HooksModal } from "../conversation-panel/hooks-modal";
|
||||
import { ConfirmDeleteModal } from "../conversation-panel/confirm-delete-modal";
|
||||
import { ConfirmStopModal } from "../conversation-panel/confirm-stop-modal";
|
||||
import { MetricsModal } from "./metrics-modal/metrics-modal";
|
||||
@@ -34,6 +35,7 @@ export function ConversationName() {
|
||||
handleDisplayCost,
|
||||
handleShowAgentTools,
|
||||
handleShowSkills,
|
||||
handleShowHooks,
|
||||
handleExportConversation,
|
||||
handleTogglePublic,
|
||||
handleCopyShareLink,
|
||||
@@ -46,6 +48,8 @@ export function ConversationName() {
|
||||
setSystemModalVisible,
|
||||
skillsModalVisible,
|
||||
setSkillsModalVisible,
|
||||
hooksModalVisible,
|
||||
setHooksModalVisible,
|
||||
confirmDeleteModalVisible,
|
||||
setConfirmDeleteModalVisible,
|
||||
confirmStopModalVisible,
|
||||
@@ -58,6 +62,7 @@ export function ConversationName() {
|
||||
shouldShowDisplayCost,
|
||||
shouldShowAgentTools,
|
||||
shouldShowSkills,
|
||||
shouldShowHooks,
|
||||
} = useConversationNameContextMenu({
|
||||
conversationId,
|
||||
conversationStatus: conversation?.status,
|
||||
@@ -180,6 +185,7 @@ export function ConversationName() {
|
||||
shouldShowAgentTools ? handleShowAgentTools : undefined
|
||||
}
|
||||
onShowSkills={shouldShowSkills ? handleShowSkills : undefined}
|
||||
onShowHooks={shouldShowHooks ? handleShowHooks : undefined}
|
||||
onExportConversation={
|
||||
shouldShowExport ? handleExportConversation : undefined
|
||||
}
|
||||
@@ -219,6 +225,11 @@ export function ConversationName() {
|
||||
<SkillsModal onClose={() => setSkillsModalVisible(false)} />
|
||||
)}
|
||||
|
||||
{/* Hooks Modal */}
|
||||
{hooksModalVisible && (
|
||||
<HooksModal onClose={() => setHooksModalVisible(false)} />
|
||||
)}
|
||||
|
||||
{/* Confirm Delete Modal */}
|
||||
{confirmDeleteModalVisible && (
|
||||
<ConfirmDeleteModal
|
||||
|
||||
152
frontend/src/components/shared/hook-execution-event-message.tsx
Normal file
152
frontend/src/components/shared/hook-execution-event-message.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isHookExecutionEvent } from "#/types/v1/type-guards";
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
import { GenericEventMessage } from "#/components/features/chat/generic-event-message";
|
||||
|
||||
interface HookExecutionEventMessageProps {
|
||||
event: OpenHandsEvent;
|
||||
}
|
||||
|
||||
function getHookIcon(hookType: string, blocked: boolean): string {
|
||||
if (blocked) {
|
||||
return "🚫";
|
||||
}
|
||||
|
||||
switch (hookType) {
|
||||
case "PreToolUse":
|
||||
return "⏳";
|
||||
case "PostToolUse":
|
||||
return "✅";
|
||||
case "UserPromptSubmit":
|
||||
return "📝";
|
||||
case "SessionStart":
|
||||
return "🚀";
|
||||
case "SessionEnd":
|
||||
return "🏁";
|
||||
case "Stop":
|
||||
return "⏹️";
|
||||
default:
|
||||
return "🔗";
|
||||
}
|
||||
}
|
||||
|
||||
function formatHookCommand(command: string): string {
|
||||
// Truncate long commands for display
|
||||
if (command.length > 80) {
|
||||
return `${command.slice(0, 77)}...`;
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
function getStatusText(blocked: boolean, success: boolean): string {
|
||||
if (blocked) return "blocked";
|
||||
if (success) return "ok";
|
||||
return "failed";
|
||||
}
|
||||
|
||||
function getStatusClassName(blocked: boolean, success: boolean): string {
|
||||
if (blocked) return "bg-amber-900/50 text-amber-300";
|
||||
if (success) return "bg-green-900/50 text-green-300";
|
||||
return "bg-red-900/50 text-red-300";
|
||||
}
|
||||
|
||||
export function HookExecutionEventMessage({
|
||||
event,
|
||||
}: HookExecutionEventMessageProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isHookExecutionEvent(event)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const icon = getHookIcon(event.hook_event_type, event.blocked);
|
||||
const statusText = getStatusText(event.blocked, event.success);
|
||||
const statusClassName = getStatusClassName(event.blocked, event.success);
|
||||
|
||||
// Determine the overall success indicator for GenericEventMessage.
|
||||
// When blocked, suppress the success indicator entirely — the amber "blocked"
|
||||
// badge in the title is the authoritative status signal.
|
||||
const getSuccessStatus = (): "success" | "error" | undefined => {
|
||||
if (event.blocked) return undefined;
|
||||
return event.success ? "success" : "error";
|
||||
};
|
||||
const successStatus = getSuccessStatus();
|
||||
|
||||
const title = (
|
||||
<span>
|
||||
{icon} {t("HOOK$HOOK_LABEL")}: {event.hook_event_type}
|
||||
{event.tool_name && (
|
||||
<span className="text-neutral-400 ml-2">({event.tool_name})</span>
|
||||
)}
|
||||
<span className={`ml-2 px-1 py-0.5 rounded text-xs ${statusClassName}`}>
|
||||
{statusText}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
const details = (
|
||||
<div className="flex flex-col gap-2 text-neutral-400">
|
||||
<div>
|
||||
<span className="text-neutral-500">{t("HOOK$COMMAND")}:</span>{" "}
|
||||
<code className="text-xs bg-neutral-800 px-1 py-0.5 rounded">
|
||||
{formatHookCommand(event.hook_command)}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{event.exit_code !== null && (
|
||||
<div>
|
||||
<span className="text-neutral-500">{t("HOOK$EXIT_CODE")}:</span>{" "}
|
||||
{event.exit_code}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.blocked && event.reason && (
|
||||
<div className="text-amber-400">
|
||||
<span className="text-neutral-500">{t("HOOK$BLOCKED_REASON")}:</span>{" "}
|
||||
{event.reason}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.additional_context && (
|
||||
<div>
|
||||
<span className="text-neutral-500">{t("HOOK$CONTEXT")}:</span>{" "}
|
||||
{event.additional_context}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.error && (
|
||||
<div className="text-red-400">
|
||||
<span className="text-neutral-500">{t("HOOK$ERROR")}:</span>{" "}
|
||||
{event.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.stdout && (
|
||||
<div>
|
||||
<span className="text-neutral-500">{t("HOOK$OUTPUT")}:</span>
|
||||
<pre className="text-xs bg-neutral-800 p-2 rounded mt-1 overflow-x-auto max-h-40 overflow-y-auto">
|
||||
{event.stdout}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.stderr && (
|
||||
<div>
|
||||
<span className="text-neutral-500">{t("HOOK$STDERR")}:</span>
|
||||
<pre className="text-xs bg-neutral-800 p-2 rounded mt-1 overflow-x-auto max-h-40 overflow-y-auto text-amber-300">
|
||||
{event.stderr}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<GenericEventMessage
|
||||
title={title}
|
||||
details={details}
|
||||
success={successStatus}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
isMessageEvent,
|
||||
isAgentErrorEvent,
|
||||
isConversationStateUpdateEvent,
|
||||
isHookExecutionEvent,
|
||||
} from "#/types/v1/type-guards";
|
||||
|
||||
export const shouldRenderEvent = (event: OpenHandsEvent) => {
|
||||
@@ -50,6 +51,11 @@ export const shouldRenderEvent = (event: OpenHandsEvent) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Render hook execution events
|
||||
if (isHookExecutionEvent(event)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Don't render any other event types (system events, etc.)
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { HookExecutionEventMessage } from "#/components/shared/hook-execution-event-message";
|
||||
@@ -4,3 +4,4 @@ export { ErrorEventMessage } from "./error-event-message";
|
||||
export { FinishEventMessage } from "./finish-event-message";
|
||||
export { GenericEventMessageWrapper } from "./generic-event-message-wrapper";
|
||||
export { ThoughtEventMessage } from "./thought-event-message";
|
||||
export { HookExecutionEventMessage } from "./hook-execution-event-message";
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
isAgentErrorEvent,
|
||||
isUserMessageEvent,
|
||||
isPlanningFileEditorObservationEvent,
|
||||
isHookExecutionEvent,
|
||||
} from "#/types/v1/type-guards";
|
||||
import { MicroagentStatus } from "#/types/microagent-status";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
FinishEventMessage,
|
||||
GenericEventMessageWrapper,
|
||||
ThoughtEventMessage,
|
||||
HookExecutionEventMessage,
|
||||
} from "./event-message-components";
|
||||
import { createSkillReadyEvent } from "./event-content-helpers/create-skill-ready-event";
|
||||
import { PlanPreview } from "../../features/chat/plan-preview";
|
||||
@@ -188,6 +190,11 @@ export function EventMessage({
|
||||
return <ErrorEventMessage event={event} {...commonProps} />;
|
||||
}
|
||||
|
||||
// Hook execution events
|
||||
if (isHookExecutionEvent(event)) {
|
||||
return <HookExecutionEventMessage event={event} />;
|
||||
}
|
||||
|
||||
// Finish actions
|
||||
if (isActionEvent(event) && event.action.kind === "FinishAction") {
|
||||
return (
|
||||
|
||||
36
frontend/src/hooks/query/use-conversation-hooks.ts
Normal file
36
frontend/src/hooks/query/use-conversation-hooks.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import { useConversationId } from "../use-conversation-id";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useSettings } from "./use-settings";
|
||||
|
||||
export const useConversationHooks = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const { curAgentState } = useAgentState();
|
||||
const { data: settings } = useSettings();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["conversation", conversationId, "hooks", settings?.v1_enabled],
|
||||
queryFn: async () => {
|
||||
if (!conversationId) {
|
||||
throw new Error("No conversation ID provided");
|
||||
}
|
||||
|
||||
// Hooks are only available for V1 conversations
|
||||
if (!settings?.v1_enabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await V1ConversationService.getHooks(conversationId);
|
||||
return data.hooks;
|
||||
},
|
||||
enabled:
|
||||
!!conversationId &&
|
||||
!!settings?.v1_enabled &&
|
||||
curAgentState !== AgentState.LOADING &&
|
||||
curAgentState !== AgentState.INIT,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
};
|
||||
@@ -53,6 +53,7 @@ export function useConversationNameContextMenu({
|
||||
const [metricsModalVisible, setMetricsModalVisible] = React.useState(false);
|
||||
const [systemModalVisible, setSystemModalVisible] = React.useState(false);
|
||||
const [skillsModalVisible, setSkillsModalVisible] = React.useState(false);
|
||||
const [hooksModalVisible, setHooksModalVisible] = React.useState(false);
|
||||
const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] =
|
||||
React.useState(false);
|
||||
const [confirmStopModalVisible, setConfirmStopModalVisible] =
|
||||
@@ -187,6 +188,12 @@ export function useConversationNameContextMenu({
|
||||
onContextMenuToggle?.(false);
|
||||
};
|
||||
|
||||
const handleShowHooks = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
setHooksModalVisible(true);
|
||||
onContextMenuToggle?.(false);
|
||||
};
|
||||
|
||||
const handleTogglePublic = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -233,6 +240,7 @@ export function useConversationNameContextMenu({
|
||||
handleDisplayCost,
|
||||
handleShowAgentTools,
|
||||
handleShowSkills,
|
||||
handleShowHooks,
|
||||
handleTogglePublic,
|
||||
handleCopyShareLink,
|
||||
shareUrl,
|
||||
@@ -246,6 +254,8 @@ export function useConversationNameContextMenu({
|
||||
setSystemModalVisible,
|
||||
skillsModalVisible,
|
||||
setSkillsModalVisible,
|
||||
hooksModalVisible,
|
||||
setHooksModalVisible,
|
||||
confirmDeleteModalVisible,
|
||||
setConfirmDeleteModalVisible,
|
||||
confirmStopModalVisible,
|
||||
@@ -267,5 +277,11 @@ export function useConversationNameContextMenu({
|
||||
shouldShowDisplayCost: showOptions,
|
||||
shouldShowAgentTools: Boolean(showOptions && systemMessage),
|
||||
shouldShowSkills: Boolean(showOptions && conversationId),
|
||||
shouldShowHooks: Boolean(
|
||||
showOptions &&
|
||||
conversationId &&
|
||||
conversation?.conversation_version === "V1" &&
|
||||
conversationStatus === "RUNNING",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -683,6 +683,8 @@ export enum I18nKey {
|
||||
TOS$ERROR_ACCEPTING = "TOS$ERROR_ACCEPTING",
|
||||
TIPS$CUSTOMIZE_MICROAGENT = "TIPS$CUSTOMIZE_MICROAGENT",
|
||||
CONVERSATION$NO_SKILLS = "CONVERSATION$NO_SKILLS",
|
||||
CONVERSATION$NO_HOOKS = "CONVERSATION$NO_HOOKS",
|
||||
CONVERSATION$SHOW_HOOKS = "CONVERSATION$SHOW_HOOKS",
|
||||
CONVERSATION$FAILED_TO_FETCH_MICROAGENTS = "CONVERSATION$FAILED_TO_FETCH_MICROAGENTS",
|
||||
MICROAGENTS_MODAL$TITLE = "MICROAGENTS_MODAL$TITLE",
|
||||
SKILLS_MODAL$WARNING = "SKILLS_MODAL$WARNING",
|
||||
@@ -1078,6 +1080,28 @@ export enum I18nKey {
|
||||
CONVERSATION$NO_HISTORY_AVAILABLE = "CONVERSATION$NO_HISTORY_AVAILABLE",
|
||||
CONVERSATION$SHARED_CONVERSATION = "CONVERSATION$SHARED_CONVERSATION",
|
||||
CONVERSATION$LINK_COPIED = "CONVERSATION$LINK_COPIED",
|
||||
HOOKS_MODAL$TITLE = "HOOKS_MODAL$TITLE",
|
||||
HOOKS_MODAL$WARNING = "HOOKS_MODAL$WARNING",
|
||||
HOOKS_MODAL$MATCHER = "HOOKS_MODAL$MATCHER",
|
||||
HOOKS_MODAL$COMMANDS = "HOOKS_MODAL$COMMANDS",
|
||||
HOOKS_MODAL$HOOK_COUNT = "HOOKS_MODAL$HOOK_COUNT",
|
||||
HOOKS_MODAL$TYPE = "HOOKS_MODAL$TYPE",
|
||||
HOOKS_MODAL$TIMEOUT = "HOOKS_MODAL$TIMEOUT",
|
||||
HOOKS_MODAL$ASYNC = "HOOKS_MODAL$ASYNC",
|
||||
HOOKS_MODAL$EVENT_PRE_TOOL_USE = "HOOKS_MODAL$EVENT_PRE_TOOL_USE",
|
||||
HOOKS_MODAL$EVENT_POST_TOOL_USE = "HOOKS_MODAL$EVENT_POST_TOOL_USE",
|
||||
HOOKS_MODAL$EVENT_USER_PROMPT_SUBMIT = "HOOKS_MODAL$EVENT_USER_PROMPT_SUBMIT",
|
||||
HOOKS_MODAL$EVENT_SESSION_START = "HOOKS_MODAL$EVENT_SESSION_START",
|
||||
HOOKS_MODAL$EVENT_SESSION_END = "HOOKS_MODAL$EVENT_SESSION_END",
|
||||
HOOKS_MODAL$EVENT_STOP = "HOOKS_MODAL$EVENT_STOP",
|
||||
HOOK$HOOK_LABEL = "HOOK$HOOK_LABEL",
|
||||
HOOK$COMMAND = "HOOK$COMMAND",
|
||||
HOOK$EXIT_CODE = "HOOK$EXIT_CODE",
|
||||
HOOK$BLOCKED_REASON = "HOOK$BLOCKED_REASON",
|
||||
HOOK$CONTEXT = "HOOK$CONTEXT",
|
||||
HOOK$ERROR = "HOOK$ERROR",
|
||||
HOOK$OUTPUT = "HOOK$OUTPUT",
|
||||
HOOK$STDERR = "HOOK$STDERR",
|
||||
COMMON$TYPE_EMAIL_AND_PRESS_SPACE = "COMMON$TYPE_EMAIL_AND_PRESS_SPACE",
|
||||
ORG$INVITE_ORG_MEMBERS = "ORG$INVITE_ORG_MEMBERS",
|
||||
ORG$MANAGE_ORGANIZATION = "ORG$MANAGE_ORGANIZATION",
|
||||
|
||||
@@ -7359,7 +7359,7 @@
|
||||
"es": "Actualmente no hay un plan para este repositorio",
|
||||
"tr": "Şu anda bu depo için bir plan yok"
|
||||
},
|
||||
"SIDEBAR$NAVIGATION_LABEL": {
|
||||
"SIDEBAR$NAVIGATION_LABEL": {
|
||||
"en": "Sidebar navigation",
|
||||
"zh-CN": "侧边栏导航",
|
||||
"zh-TW": "側邊欄導航",
|
||||
@@ -9327,7 +9327,6 @@
|
||||
"de": "Abonnement kündigen",
|
||||
"uk": "Скасувати підписку"
|
||||
},
|
||||
|
||||
"PAYMENT$SUBSCRIPTION_CANCELLED": {
|
||||
"en": "Subscription cancelled successfully",
|
||||
"ja": "サブスクリプションが正常にキャンセルされました",
|
||||
@@ -9344,7 +9343,6 @@
|
||||
"de": "Abonnement erfolgreich gekündigt",
|
||||
"uk": "Підписку успішно скасовано"
|
||||
},
|
||||
|
||||
"PAYMENT$NEXT_BILLING_DATE": {
|
||||
"en": "Next billing date: {{date}}",
|
||||
"ja": "次回請求日: {{date}}",
|
||||
@@ -10529,7 +10527,7 @@
|
||||
"de": "klicken Sie hier für Anweisungen",
|
||||
"uk": "натисніть тут, щоб отримати інструкції"
|
||||
},
|
||||
"BITBUCKET_DATA_CENTER$TOKEN_LABEL": {
|
||||
"BITBUCKET_DATA_CENTER$TOKEN_LABEL": {
|
||||
"en": "Bitbucket Data Center Token",
|
||||
"ja": "Bitbucket Data Centerトークン",
|
||||
"zh-CN": "Bitbucket Data Center令牌",
|
||||
@@ -10929,6 +10927,38 @@
|
||||
"tr": "Bu sohbet için kullanılabilir yetenek bulunamadı.",
|
||||
"uk": "У цій розмові не знайдено доступних навичок."
|
||||
},
|
||||
"CONVERSATION$NO_HOOKS": {
|
||||
"en": "No hooks configured for this conversation.",
|
||||
"ja": "この会話にはフックが設定されていません。",
|
||||
"zh-CN": "此会话未配置钩子。",
|
||||
"zh-TW": "此對話未配置鉤子。",
|
||||
"ko-KR": "이 대화에 구성된 훅이 없습니다.",
|
||||
"no": "Ingen kroker konfigurert for denne samtalen.",
|
||||
"ar": "لم يتم تكوين أي خطافات لهذه المحادثة.",
|
||||
"de": "Keine Hooks für diese Unterhaltung konfiguriert.",
|
||||
"fr": "Aucun hook configuré pour cette conversation.",
|
||||
"it": "Nessun hook configurato per questa conversazione.",
|
||||
"pt": "Nenhum hook configurado para esta conversa.",
|
||||
"es": "No hay hooks configurados para esta conversación.",
|
||||
"tr": "Bu sohbet için yapılandırılmış kanca yok.",
|
||||
"uk": "Для цієї розмови не налаштовано хуків."
|
||||
},
|
||||
"CONVERSATION$SHOW_HOOKS": {
|
||||
"en": "Show Available Hooks",
|
||||
"ja": "利用可能なフックを表示",
|
||||
"zh-CN": "显示可用钩子",
|
||||
"zh-TW": "顯示可用鉤子",
|
||||
"ko-KR": "사용 가능한 훅 표시",
|
||||
"no": "Vis tilgjengelige kroker",
|
||||
"ar": "عرض الخطافات المتاحة",
|
||||
"de": "Verfügbare Hooks anzeigen",
|
||||
"fr": "Afficher les hooks disponibles",
|
||||
"it": "Mostra hook disponibili",
|
||||
"pt": "Mostrar hooks disponíveis",
|
||||
"es": "Mostrar hooks disponibles",
|
||||
"tr": "Kullanılabilir kancaları göster",
|
||||
"uk": "Показати доступні хуки"
|
||||
},
|
||||
"CONVERSATION$FAILED_TO_FETCH_MICROAGENTS": {
|
||||
"en": "Failed to fetch available microagents",
|
||||
"ja": "利用可能なマイクロエージェントの取得に失敗しました",
|
||||
@@ -11777,7 +11807,6 @@
|
||||
"tr": "Git sağlayıcısını bağla",
|
||||
"uk": "Підключити постачальник Git"
|
||||
},
|
||||
|
||||
"TASKS$NO_GIT_PROVIDERS_DESCRIPTION": {
|
||||
"en": "Connect a Git provider to see suggested tasks from your repositories.",
|
||||
"ja": "Gitプロバイダーを接続して、リポジトリからの提案タスクを表示します。",
|
||||
@@ -11794,7 +11823,6 @@
|
||||
"tr": "Depolarınızdan önerilen görevleri görmek için bir Git sağlayıcısı bağlayın.",
|
||||
"uk": "Підключіть постачальник Git, щоб бачити запропоновані завдання з ваших репозиторіїв."
|
||||
},
|
||||
|
||||
"TASKS$NO_GIT_PROVIDERS_CTA": {
|
||||
"en": "Go to Integrations",
|
||||
"ja": "統合へ移動",
|
||||
@@ -17251,6 +17279,358 @@
|
||||
"de": "Link in die Zwischenablage kopiert",
|
||||
"uk": "Посилання скопійовано в буфер обміну"
|
||||
},
|
||||
"HOOKS_MODAL$TITLE": {
|
||||
"en": "Available Hooks",
|
||||
"ja": "利用可能なフック",
|
||||
"zh-CN": "可用钩子",
|
||||
"zh-TW": "可用鉤子",
|
||||
"ko-KR": "사용 가능한 훅",
|
||||
"no": "Tilgjengelige kroker",
|
||||
"ar": "الخطافات المتاحة",
|
||||
"de": "Verfügbare Hooks",
|
||||
"fr": "Hooks disponibles",
|
||||
"it": "Hook disponibili",
|
||||
"pt": "Hooks disponíveis",
|
||||
"es": "Hooks disponibles",
|
||||
"tr": "Kullanılabilir kancalar",
|
||||
"uk": "Доступні хуки"
|
||||
},
|
||||
"HOOKS_MODAL$WARNING": {
|
||||
"en": "Hooks are loaded from your workspace. This view refreshes on demand and may differ from the hooks that were active when the conversation started. Stop and restart the conversation to apply changes.",
|
||||
"ja": "フックはワークスペースから読み込まれます。この表示は要求時にワークスペースから再読み込みするため、会話開始時に有効だったフックと異なる場合があります。変更を適用するには会話を停止して再開してください。",
|
||||
"zh-CN": "Hooks 从工作区读取。本视图会在请求时从工作区刷新,因此可能与会话启动时生效的 hooks 不一致。要应用更改,请停止并重新开始会话。",
|
||||
"zh-TW": "Hooks 從工作區讀取。本視圖會在請求時從工作區重新整理,因此可能與會話啟動時生效的 hooks 不一致。要套用變更,請停止並重新開始會話。",
|
||||
"ko-KR": "훅은 작업공간에서 로드됩니다. 이 화면은 요청 시 작업공간에서 다시 읽어 오므로 대화 시작 시 적용된 훅과 다를 수 있습니다. 변경을 적용하려면 대화를 중지한 뒤 다시 시작하세요.",
|
||||
"no": "Hooks lastes fra arbeidsområdet. Denne visningen leser filen på nytt ved forespørsel og kan derfor avvike fra hookene som var aktive da samtalen startet. Stopp og start samtalen på nytt for å ta i bruk endringer.",
|
||||
"ar": "يتم تحميل الخطافات من مساحة العمل. تقوم هذه الشاشة بإعادة قراءة الملف عند الطلب وقد تختلف عن الخطافات التي كانت فعّالة عند بدء المحادثة. لتطبيق التغييرات، أوقف المحادثة وأعد تشغيلها.",
|
||||
"de": "Hooks werden aus dem Workspace geladen. Diese Ansicht liest die Datei bei Bedarf neu ein und kann daher von den Hooks abweichen, die beim Start der Unterhaltung aktiv waren. Stoppen und starten Sie die Unterhaltung neu, um Änderungen anzuwenden.",
|
||||
"fr": "Les hooks sont chargés depuis votre espace de travail. Cette vue se rafraîchit à la demande depuis l’espace de travail et peut différer des hooks actifs au démarrage de la conversation. Arrêtez puis redémarrez la conversation pour appliquer les modifications.",
|
||||
"it": "Gli hook vengono caricati dal tuo workspace. Questa vista si aggiorna su richiesta dal workspace e può differire dagli hook attivi all’avvio della conversazione. Interrompi e riavvia la conversazione per applicare le modifiche.",
|
||||
"pt": "Os hooks são carregados do seu workspace. Esta visualização é atualizada sob demanda a partir do workspace e pode ser diferente dos hooks que estavam ativos quando a conversa foi iniciada. Pare e reinicie a conversa para aplicar as alterações.",
|
||||
"es": "Los hooks se cargan desde tu espacio de trabajo. Esta vista se actualiza bajo demanda desde el workspace y puede diferir de los hooks que estaban activos cuando comenzó la conversación. Detén y reinicia la conversación para aplicar los cambios.",
|
||||
"tr": "Kancalar çalışma alanınızdan yüklenir. Bu görünüm istek üzerine çalışma alanından yenilenir ve sohbet başlatıldığında etkin olan kancalardan farklı olabilir. Değişiklikleri uygulamak için sohbeti durdurup yeniden başlatın.",
|
||||
"uk": "Хуки завантажуються з вашого робочого простору. Це подання оновлюється з робочого простору на вимогу й може відрізнятися від хуків, які були активні під час запуску розмови. Щоб застосувати зміни, зупиніть і перезапустіть розмову."
|
||||
},
|
||||
"HOOKS_MODAL$MATCHER": {
|
||||
"en": "Matcher",
|
||||
"ja": "マッチャー",
|
||||
"zh-CN": "匹配器",
|
||||
"zh-TW": "匹配器",
|
||||
"ko-KR": "매처",
|
||||
"no": "Matcher",
|
||||
"ar": "المطابق",
|
||||
"de": "Matcher",
|
||||
"fr": "Matcher",
|
||||
"it": "Matcher",
|
||||
"pt": "Matcher",
|
||||
"es": "Matcher",
|
||||
"tr": "Eşleştirici",
|
||||
"uk": "Матчер"
|
||||
},
|
||||
"HOOKS_MODAL$COMMANDS": {
|
||||
"en": "Commands",
|
||||
"ja": "コマンド",
|
||||
"zh-CN": "命令",
|
||||
"zh-TW": "命令",
|
||||
"ko-KR": "명령",
|
||||
"no": "Kommandoer",
|
||||
"ar": "الأوامر",
|
||||
"de": "Befehle",
|
||||
"fr": "Commandes",
|
||||
"it": "Comandi",
|
||||
"pt": "Comandos",
|
||||
"es": "Comandos",
|
||||
"tr": "Komutlar",
|
||||
"uk": "Команди"
|
||||
},
|
||||
"HOOKS_MODAL$HOOK_COUNT": {
|
||||
"en": "{{count}} hook(s)",
|
||||
"ja": "{{count}}個のフック",
|
||||
"zh-CN": "{{count}}个钩子",
|
||||
"zh-TW": "{{count}}個鉤子",
|
||||
"ko-KR": "{{count}}개 훅",
|
||||
"no": "{{count}} krok",
|
||||
"ar": "{{count}} خطاف",
|
||||
"de": "{{count}} Hook",
|
||||
"fr": "{{count}} hook",
|
||||
"it": "{{count}} hook",
|
||||
"pt": "{{count}} hook",
|
||||
"es": "{{count}} hook",
|
||||
"tr": "{{count}} kanca",
|
||||
"uk": "{{count}} хук"
|
||||
},
|
||||
"HOOKS_MODAL$TYPE": {
|
||||
"en": "Type: {{type}}",
|
||||
"ja": "タイプ: {{type}}",
|
||||
"zh-CN": "类型: {{type}}",
|
||||
"zh-TW": "類型: {{type}}",
|
||||
"ko-KR": "유형: {{type}}",
|
||||
"no": "Type: {{type}}",
|
||||
"ar": "النوع: {{type}}",
|
||||
"de": "Typ: {{type}}",
|
||||
"fr": "Type: {{type}}",
|
||||
"it": "Tipo: {{type}}",
|
||||
"pt": "Tipo: {{type}}",
|
||||
"es": "Tipo: {{type}}",
|
||||
"tr": "Tür: {{type}}",
|
||||
"uk": "Тип: {{type}}"
|
||||
},
|
||||
"HOOKS_MODAL$TIMEOUT": {
|
||||
"en": "Timeout: {{timeout}}s",
|
||||
"ja": "タイムアウト: {{timeout}}秒",
|
||||
"zh-CN": "超时: {{timeout}}秒",
|
||||
"zh-TW": "超時: {{timeout}}秒",
|
||||
"ko-KR": "타임아웃: {{timeout}}초",
|
||||
"no": "Tidsavbrudd: {{timeout}}s",
|
||||
"ar": "المهلة: {{timeout}} ثانية",
|
||||
"de": "Timeout: {{timeout}}s",
|
||||
"fr": "Délai: {{timeout}}s",
|
||||
"it": "Timeout: {{timeout}}s",
|
||||
"pt": "Tempo limite: {{timeout}}s",
|
||||
"es": "Tiempo de espera: {{timeout}}s",
|
||||
"tr": "Zaman aşımı: {{timeout}}s",
|
||||
"uk": "Таймаут: {{timeout}}с"
|
||||
},
|
||||
"HOOKS_MODAL$ASYNC": {
|
||||
"en": "Async",
|
||||
"ja": "非同期",
|
||||
"zh-CN": "异步",
|
||||
"zh-TW": "非同步",
|
||||
"ko-KR": "비동기",
|
||||
"no": "Asynkron",
|
||||
"ar": "غير متزامن",
|
||||
"de": "Asynchron",
|
||||
"fr": "Asynchrone",
|
||||
"it": "Asincrono",
|
||||
"pt": "Assíncrono",
|
||||
"es": "Asíncrono",
|
||||
"tr": "Asenkron",
|
||||
"uk": "Асинхронний"
|
||||
},
|
||||
"HOOKS_MODAL$EVENT_PRE_TOOL_USE": {
|
||||
"en": "Pre Tool Use",
|
||||
"ja": "ツール使用前",
|
||||
"zh-CN": "工具使用前",
|
||||
"zh-TW": "工具使用前",
|
||||
"ko-KR": "도구 사용 전",
|
||||
"no": "Før verktøybruk",
|
||||
"ar": "قبل استخدام الأداة",
|
||||
"de": "Vor Werkzeugnutzung",
|
||||
"fr": "Avant utilisation de l'outil",
|
||||
"it": "Prima dell'uso dello strumento",
|
||||
"pt": "Antes do uso da ferramenta",
|
||||
"es": "Antes del uso de la herramienta",
|
||||
"tr": "Araç kullanımı öncesi",
|
||||
"uk": "Перед використанням інструменту"
|
||||
},
|
||||
"HOOKS_MODAL$EVENT_POST_TOOL_USE": {
|
||||
"en": "Post Tool Use",
|
||||
"ja": "ツール使用後",
|
||||
"zh-CN": "工具使用后",
|
||||
"zh-TW": "工具使用後",
|
||||
"ko-KR": "도구 사용 후",
|
||||
"no": "Etter verktøybruk",
|
||||
"ar": "بعد استخدام الأداة",
|
||||
"de": "Nach Werkzeugnutzung",
|
||||
"fr": "Après utilisation de l'outil",
|
||||
"it": "Dopo l'uso dello strumento",
|
||||
"pt": "Após o uso da ferramenta",
|
||||
"es": "Después del uso de la herramienta",
|
||||
"tr": "Araç kullanımı sonrası",
|
||||
"uk": "Після використання інструменту"
|
||||
},
|
||||
"HOOKS_MODAL$EVENT_USER_PROMPT_SUBMIT": {
|
||||
"en": "User Prompt Submit",
|
||||
"ja": "ユーザープロンプト送信",
|
||||
"zh-CN": "用户提示提交",
|
||||
"zh-TW": "使用者提示提交",
|
||||
"ko-KR": "사용자 프롬프트 제출",
|
||||
"no": "Brukerforespørsel sendt",
|
||||
"ar": "إرسال طلب المستخدم",
|
||||
"de": "Benutzeranfrage gesendet",
|
||||
"fr": "Soumission de l'invite utilisateur",
|
||||
"it": "Invio prompt utente",
|
||||
"pt": "Envio de prompt do usuário",
|
||||
"es": "Envío de solicitud del usuario",
|
||||
"tr": "Kullanıcı istemi gönderimi",
|
||||
"uk": "Надсилання запиту користувача"
|
||||
},
|
||||
"HOOKS_MODAL$EVENT_SESSION_START": {
|
||||
"en": "Session Start",
|
||||
"ja": "セッション開始",
|
||||
"zh-CN": "会话开始",
|
||||
"zh-TW": "會話開始",
|
||||
"ko-KR": "세션 시작",
|
||||
"no": "Øktstart",
|
||||
"ar": "بدء الجلسة",
|
||||
"de": "Sitzungsstart",
|
||||
"fr": "Début de session",
|
||||
"it": "Inizio sessione",
|
||||
"pt": "Início da sessão",
|
||||
"es": "Inicio de sesión",
|
||||
"tr": "Oturum başlangıcı",
|
||||
"uk": "Початок сесії"
|
||||
},
|
||||
"HOOKS_MODAL$EVENT_SESSION_END": {
|
||||
"en": "Session End",
|
||||
"ja": "セッション終了",
|
||||
"zh-CN": "会话结束",
|
||||
"zh-TW": "會話結束",
|
||||
"ko-KR": "세션 종료",
|
||||
"no": "Øktslutt",
|
||||
"ar": "نهاية الجلسة",
|
||||
"de": "Sitzungsende",
|
||||
"fr": "Fin de session",
|
||||
"it": "Fine sessione",
|
||||
"pt": "Fim da sessão",
|
||||
"es": "Fin de sesión",
|
||||
"tr": "Oturum sonu",
|
||||
"uk": "Кінець сесії"
|
||||
},
|
||||
"HOOKS_MODAL$EVENT_STOP": {
|
||||
"en": "Stop",
|
||||
"ja": "停止",
|
||||
"zh-CN": "停止",
|
||||
"zh-TW": "停止",
|
||||
"ko-KR": "중지",
|
||||
"no": "Stopp",
|
||||
"ar": "إيقاف",
|
||||
"de": "Stopp",
|
||||
"fr": "Arrêt",
|
||||
"it": "Stop",
|
||||
"pt": "Parar",
|
||||
"es": "Detener",
|
||||
"tr": "Durdur",
|
||||
"uk": "Зупинка"
|
||||
},
|
||||
"HOOK$HOOK_LABEL": {
|
||||
"en": "Hook",
|
||||
"ja": "フック",
|
||||
"zh-CN": "钩子",
|
||||
"zh-TW": "鈎子",
|
||||
"ko-KR": "훅",
|
||||
"no": "Krok",
|
||||
"ar": "خطاف",
|
||||
"de": "Hook",
|
||||
"fr": "Crochet",
|
||||
"it": "Hook",
|
||||
"pt": "Hook",
|
||||
"es": "Gancho",
|
||||
"tr": "Kanca",
|
||||
"uk": "Хук"
|
||||
},
|
||||
"HOOK$COMMAND": {
|
||||
"en": "Command",
|
||||
"ja": "コマンド",
|
||||
"zh-CN": "命令",
|
||||
"zh-TW": "命令",
|
||||
"ko-KR": "명령",
|
||||
"no": "Kommando",
|
||||
"ar": "أمر",
|
||||
"de": "Befehl",
|
||||
"fr": "Commande",
|
||||
"it": "Comando",
|
||||
"pt": "Comando",
|
||||
"es": "Comando",
|
||||
"tr": "Komut",
|
||||
"uk": "Команда"
|
||||
},
|
||||
"HOOK$EXIT_CODE": {
|
||||
"en": "Exit code",
|
||||
"ja": "終了コード",
|
||||
"zh-CN": "退出码",
|
||||
"zh-TW": "退出碼",
|
||||
"ko-KR": "종료 코드",
|
||||
"no": "Avslutningskode",
|
||||
"ar": "رمز الخروج",
|
||||
"de": "Exit-Code",
|
||||
"fr": "Code de sortie",
|
||||
"it": "Codice di uscita",
|
||||
"pt": "Código de saída",
|
||||
"es": "Código de salida",
|
||||
"tr": "Çıkış kodu",
|
||||
"uk": "Код виходу"
|
||||
},
|
||||
"HOOK$BLOCKED_REASON": {
|
||||
"en": "Blocked reason",
|
||||
"ja": "ブロック理由",
|
||||
"zh-CN": "阻止原因",
|
||||
"zh-TW": "阻止原因",
|
||||
"ko-KR": "차단 이유",
|
||||
"no": "Blokkert grunn",
|
||||
"ar": "سبب الحظر",
|
||||
"de": "Blockierungsgrund",
|
||||
"fr": "Raison du blocage",
|
||||
"it": "Motivo del blocco",
|
||||
"pt": "Motivo do bloqueio",
|
||||
"es": "Motivo del bloqueo",
|
||||
"tr": "Engelleme nedeni",
|
||||
"uk": "Причина блокування"
|
||||
},
|
||||
"HOOK$CONTEXT": {
|
||||
"en": "Context",
|
||||
"ja": "コンテキスト",
|
||||
"zh-CN": "上下文",
|
||||
"zh-TW": "上下文",
|
||||
"ko-KR": "컨텍스트",
|
||||
"no": "Kontekst",
|
||||
"ar": "سياق",
|
||||
"de": "Kontext",
|
||||
"fr": "Contexte",
|
||||
"it": "Contesto",
|
||||
"pt": "Contexto",
|
||||
"es": "Contexto",
|
||||
"tr": "Bağlam",
|
||||
"uk": "Контекст"
|
||||
},
|
||||
"HOOK$ERROR": {
|
||||
"en": "Error",
|
||||
"ja": "エラー",
|
||||
"zh-CN": "错误",
|
||||
"zh-TW": "錯誤",
|
||||
"ko-KR": "오류",
|
||||
"no": "Feil",
|
||||
"ar": "خطأ",
|
||||
"de": "Fehler",
|
||||
"fr": "Erreur",
|
||||
"it": "Errore",
|
||||
"pt": "Erro",
|
||||
"es": "Error",
|
||||
"tr": "Hata",
|
||||
"uk": "Помилка"
|
||||
},
|
||||
"HOOK$OUTPUT": {
|
||||
"en": "Output",
|
||||
"ja": "出力",
|
||||
"zh-CN": "输出",
|
||||
"zh-TW": "輸出",
|
||||
"ko-KR": "출력",
|
||||
"no": "Utdata",
|
||||
"ar": "الإخراج",
|
||||
"de": "Ausgabe",
|
||||
"fr": "Sortie",
|
||||
"it": "Output",
|
||||
"pt": "Saída",
|
||||
"es": "Salida",
|
||||
"tr": "Çıktı",
|
||||
"uk": "Вивід"
|
||||
},
|
||||
"HOOK$STDERR": {
|
||||
"en": "Stderr",
|
||||
"ja": "標準エラー",
|
||||
"zh-CN": "标准错误",
|
||||
"zh-TW": "標準錯誤",
|
||||
"ko-KR": "표준 오류",
|
||||
"no": "Standardfeil",
|
||||
"ar": "خطأ قياسي",
|
||||
"de": "Standardfehler",
|
||||
"fr": "Erreur standard",
|
||||
"it": "Errore standard",
|
||||
"pt": "Erro padrão",
|
||||
"es": "Error estándar",
|
||||
"tr": "Standart hata",
|
||||
"uk": "Стандартна помилка"
|
||||
},
|
||||
"COMMON$TYPE_EMAIL_AND_PRESS_SPACE": {
|
||||
"en": "Type email and press Space",
|
||||
"ja": "メールアドレスを入力してスペースキーを押してください",
|
||||
|
||||
@@ -21,7 +21,7 @@ export type OpenHandsEventType =
|
||||
| "task_tracking"
|
||||
| "user_rejected";
|
||||
|
||||
export type OpenHandsSourceType = "agent" | "user" | "environment";
|
||||
export type OpenHandsSourceType = "agent" | "user" | "environment" | "hook";
|
||||
|
||||
interface OpenHandsBaseEvent {
|
||||
id: number;
|
||||
|
||||
@@ -53,7 +53,7 @@ export type EventID = string;
|
||||
export type ToolCallID = string;
|
||||
|
||||
// Source type for events
|
||||
export type SourceType = "agent" | "user" | "environment";
|
||||
export type SourceType = "agent" | "user" | "environment" | "hook";
|
||||
|
||||
// Security risk levels
|
||||
export enum SecurityRisk {
|
||||
|
||||
100
frontend/src/types/v1/core/events/hook-execution-event.ts
Normal file
100
frontend/src/types/v1/core/events/hook-execution-event.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { BaseEvent } from "../base/event";
|
||||
|
||||
/**
|
||||
* Hook event types supported by the system
|
||||
*/
|
||||
export type HookEventType =
|
||||
| "PreToolUse"
|
||||
| "PostToolUse"
|
||||
| "UserPromptSubmit"
|
||||
| "SessionStart"
|
||||
| "SessionEnd"
|
||||
| "Stop";
|
||||
|
||||
/**
|
||||
* HookExecutionEvent - emitted when a hook script executes
|
||||
*
|
||||
* Provides observability into hook execution for PreToolUse, PostToolUse,
|
||||
* UserPromptSubmit, SessionStart, SessionEnd, and Stop hooks.
|
||||
*/
|
||||
export interface HookExecutionEvent extends BaseEvent {
|
||||
/**
|
||||
* Discriminator field for type guards
|
||||
*/
|
||||
kind: "HookExecutionEvent";
|
||||
|
||||
/**
|
||||
* The source is always "hook" for hook execution events
|
||||
*/
|
||||
source: "hook";
|
||||
|
||||
/**
|
||||
* Type of hook that was executed
|
||||
*/
|
||||
hook_event_type: HookEventType;
|
||||
|
||||
/**
|
||||
* The command that was executed
|
||||
*/
|
||||
hook_command: string;
|
||||
|
||||
/**
|
||||
* Whether the hook executed successfully
|
||||
*/
|
||||
success: boolean;
|
||||
|
||||
/**
|
||||
* Whether the hook blocked the action
|
||||
*/
|
||||
blocked: boolean;
|
||||
|
||||
/**
|
||||
* Exit code from the hook script (null if not applicable)
|
||||
*/
|
||||
exit_code: number | null;
|
||||
|
||||
/**
|
||||
* Reason provided by the hook for blocking (if blocked)
|
||||
*/
|
||||
reason: string | null;
|
||||
|
||||
/**
|
||||
* Name of the tool (for PreToolUse/PostToolUse hooks)
|
||||
*/
|
||||
tool_name: string | null;
|
||||
|
||||
/**
|
||||
* ID of the related action event (for tool hooks)
|
||||
*/
|
||||
action_id: string | null;
|
||||
|
||||
/**
|
||||
* ID of the related message event (for UserPromptSubmit hooks)
|
||||
*/
|
||||
message_id: string | null;
|
||||
|
||||
/**
|
||||
* Standard output from the hook script
|
||||
*/
|
||||
stdout: string | null;
|
||||
|
||||
/**
|
||||
* Standard error from the hook script
|
||||
*/
|
||||
stderr: string | null;
|
||||
|
||||
/**
|
||||
* Error message if the hook failed
|
||||
*/
|
||||
error: string | null;
|
||||
|
||||
/**
|
||||
* Additional context provided by the hook
|
||||
*/
|
||||
additional_context: string | null;
|
||||
|
||||
/**
|
||||
* Input data that was passed to the hook
|
||||
*/
|
||||
hook_input: Record<string, unknown> | null;
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
export * from "./action-event";
|
||||
export * from "./condensation-event";
|
||||
export * from "./conversation-state-event";
|
||||
export * from "./hook-execution-event";
|
||||
export * from "./message-event";
|
||||
export * from "./observation-event";
|
||||
export * from "./pause-event";
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
CondensationSummaryEvent,
|
||||
ConversationStateUpdateEvent,
|
||||
ConversationErrorEvent,
|
||||
HookExecutionEvent,
|
||||
PauseEvent,
|
||||
} from "./events/index";
|
||||
|
||||
@@ -26,6 +27,8 @@ export type OpenHandsEvent =
|
||||
| UserRejectObservation
|
||||
| AgentErrorEvent
|
||||
| SystemPromptEvent
|
||||
// Hook events
|
||||
| HookExecutionEvent
|
||||
// Conversation management events
|
||||
| CondensationEvent
|
||||
| CondensationRequestEvent
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ConversationStateUpdateEventStats,
|
||||
ConversationErrorEvent,
|
||||
} from "./core/events/conversation-state-event";
|
||||
import { HookExecutionEvent } from "./core/events/hook-execution-event";
|
||||
import { SystemPromptEvent } from "./core/events/system-event";
|
||||
import type { OpenHandsParsedEvent } from "../core/index";
|
||||
|
||||
@@ -42,7 +43,8 @@ export function isBaseEvent(value: unknown): value is BaseEvent {
|
||||
typeof value.source === "string" &&
|
||||
(value.source === "agent" ||
|
||||
value.source === "user" ||
|
||||
value.source === "environment")
|
||||
value.source === "environment" ||
|
||||
value.source === "hook")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,6 +193,14 @@ export const isConversationErrorEvent = (
|
||||
): event is ConversationErrorEvent =>
|
||||
"kind" in event && event.kind === "ConversationErrorEvent";
|
||||
|
||||
/**
|
||||
* Type guard function to check if an event is a hook execution event
|
||||
*/
|
||||
export const isHookExecutionEvent = (
|
||||
event: OpenHandsEvent,
|
||||
): event is HookExecutionEvent =>
|
||||
"kind" in event && event.kind === "HookExecutionEvent";
|
||||
|
||||
// =============================================================================
|
||||
// TEMPORARY COMPATIBILITY TYPE GUARDS
|
||||
// These will be removed once we fully migrate to V1 events
|
||||
|
||||
@@ -242,3 +242,32 @@ class SkillResponse(BaseModel):
|
||||
type: Literal['repo', 'knowledge', 'agentskills']
|
||||
content: str
|
||||
triggers: list[str] = []
|
||||
|
||||
|
||||
class HookDefinitionResponse(BaseModel):
|
||||
"""Response model for a single hook definition."""
|
||||
|
||||
type: str # 'command' or 'prompt'
|
||||
command: str
|
||||
timeout: int = 60
|
||||
async_: bool = Field(default=False, serialization_alias='async')
|
||||
|
||||
|
||||
class HookMatcherResponse(BaseModel):
|
||||
"""Response model for a hook matcher."""
|
||||
|
||||
matcher: str # Pattern: '*', exact match, or regex
|
||||
hooks: list[HookDefinitionResponse] = []
|
||||
|
||||
|
||||
class HookEventResponse(BaseModel):
|
||||
"""Response model for hooks of a specific event type."""
|
||||
|
||||
event_type: str # e.g., 'stop', 'pre_tool_use', 'post_tool_use'
|
||||
matchers: list[HookMatcherResponse] = []
|
||||
|
||||
|
||||
class GetHooksResponse(BaseModel):
|
||||
"""Response model for hooks endpoint."""
|
||||
|
||||
hooks: list[HookEventResponse] = []
|
||||
|
||||
@@ -5,43 +5,29 @@ import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Annotated, AsyncGenerator, Literal
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
|
||||
from openhands.app_server.services.db_session_injector import set_db_session_keep_open
|
||||
from openhands.app_server.services.httpx_client_injector import (
|
||||
set_httpx_client_keep_open,
|
||||
)
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
|
||||
# Handle anext compatibility for Python < 3.10
|
||||
if sys.version_info >= (3, 10):
|
||||
from builtins import anext
|
||||
else:
|
||||
|
||||
async def anext(async_iterator):
|
||||
"""Compatibility function for anext in Python < 3.10"""
|
||||
return await async_iterator.__anext__()
|
||||
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, Response, status
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversation,
|
||||
AppConversationInfo,
|
||||
AppConversationPage,
|
||||
AppConversationStartRequest,
|
||||
AppConversationStartTask,
|
||||
AppConversationStartTaskPage,
|
||||
AppConversationStartTaskSortOrder,
|
||||
AppConversationUpdateRequest,
|
||||
GetHooksResponse,
|
||||
HookDefinitionResponse,
|
||||
HookEventResponse,
|
||||
HookMatcherResponse,
|
||||
SkillResponse,
|
||||
)
|
||||
from openhands.app_server.app_conversation.app_conversation_service import (
|
||||
@@ -66,15 +52,35 @@ from openhands.app_server.config import (
|
||||
)
|
||||
from openhands.app_server.sandbox.sandbox_models import (
|
||||
AGENT_SERVER,
|
||||
SandboxInfo,
|
||||
SandboxStatus,
|
||||
)
|
||||
from openhands.app_server.sandbox.sandbox_service import SandboxService
|
||||
from openhands.app_server.sandbox.sandbox_spec_models import SandboxSpecInfo
|
||||
from openhands.app_server.sandbox.sandbox_spec_service import SandboxSpecService
|
||||
from openhands.app_server.services.db_session_injector import set_db_session_keep_open
|
||||
from openhands.app_server.services.httpx_client_injector import (
|
||||
set_httpx_client_keep_open,
|
||||
)
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.app_server.utils.docker_utils import (
|
||||
replace_localhost_hostname_for_docker,
|
||||
)
|
||||
from openhands.sdk.context.skills import KeywordTrigger, TaskTrigger
|
||||
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
|
||||
# Handle anext compatibility for Python < 3.10
|
||||
if sys.version_info >= (3, 10):
|
||||
from builtins import anext
|
||||
else:
|
||||
|
||||
async def anext(async_iterator):
|
||||
"""Compatibility function for anext in Python < 3.10"""
|
||||
return await async_iterator.__anext__()
|
||||
|
||||
|
||||
# We use the get_dependencies method here to signal to the OpenAPI docs that this endpoint
|
||||
# is protected. The actual protection is provided by SetAuthCookieMiddleware
|
||||
@@ -92,6 +98,96 @@ httpx_client_dependency = depends_httpx_client()
|
||||
sandbox_service_dependency = depends_sandbox_service()
|
||||
sandbox_spec_service_dependency = depends_sandbox_spec_service()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentServerContext:
|
||||
"""Context for accessing the agent server for a conversation."""
|
||||
|
||||
conversation: AppConversationInfo
|
||||
sandbox: SandboxInfo
|
||||
sandbox_spec: SandboxSpecInfo
|
||||
agent_server_url: str
|
||||
session_api_key: str | None
|
||||
|
||||
|
||||
async def _get_agent_server_context(
|
||||
conversation_id: UUID,
|
||||
app_conversation_service: AppConversationService,
|
||||
sandbox_service: SandboxService,
|
||||
sandbox_spec_service: SandboxSpecService,
|
||||
) -> AgentServerContext | JSONResponse:
|
||||
"""Get the agent server context for a conversation.
|
||||
|
||||
This helper retrieves all necessary information to communicate with the
|
||||
agent server for a given conversation, including the sandbox info,
|
||||
sandbox spec, and agent server URL.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID
|
||||
app_conversation_service: Service for conversation operations
|
||||
sandbox_service: Service for sandbox operations
|
||||
sandbox_spec_service: Service for sandbox spec operations
|
||||
|
||||
Returns:
|
||||
AgentServerContext if successful, or JSONResponse with error details.
|
||||
"""
|
||||
# Get the conversation info
|
||||
conversation = await app_conversation_service.get_app_conversation(conversation_id)
|
||||
if not conversation:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': f'Conversation {conversation_id} not found'},
|
||||
)
|
||||
|
||||
# Get the sandbox info
|
||||
sandbox = await sandbox_service.get_sandbox(conversation.sandbox_id)
|
||||
if not sandbox or sandbox.status != SandboxStatus.RUNNING:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={
|
||||
'error': f'Sandbox not found or not running for conversation {conversation_id}'
|
||||
},
|
||||
)
|
||||
|
||||
# Get the sandbox spec to find the working directory
|
||||
sandbox_spec = await sandbox_spec_service.get_sandbox_spec(sandbox.sandbox_spec_id)
|
||||
if not sandbox_spec:
|
||||
# TODO: This is a temporary work around for the fact that we don't store previous
|
||||
# sandbox spec versions when updating OpenHands. When the SandboxSpecServices
|
||||
# transition to truly multi sandbox spec model this should raise a 404 error
|
||||
logger.warning('Sandbox spec not found - using default.')
|
||||
sandbox_spec = await sandbox_spec_service.get_default_sandbox_spec()
|
||||
|
||||
# Get the agent server URL
|
||||
if not sandbox.exposed_urls:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': 'No agent server URL found for sandbox'},
|
||||
)
|
||||
|
||||
agent_server_url = None
|
||||
for exposed_url in sandbox.exposed_urls:
|
||||
if exposed_url.name == AGENT_SERVER:
|
||||
agent_server_url = exposed_url.url
|
||||
break
|
||||
|
||||
if not agent_server_url:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': 'Agent server URL not found in sandbox'},
|
||||
)
|
||||
|
||||
agent_server_url = replace_localhost_hostname_for_docker(agent_server_url)
|
||||
|
||||
return AgentServerContext(
|
||||
conversation=conversation,
|
||||
sandbox=sandbox,
|
||||
sandbox_spec=sandbox_spec,
|
||||
agent_server_url=agent_server_url,
|
||||
session_api_key=sandbox.session_api_key,
|
||||
)
|
||||
|
||||
|
||||
# Read methods
|
||||
|
||||
|
||||
@@ -493,57 +589,15 @@ async def get_conversation_skills(
|
||||
JSONResponse: A JSON response containing the list of skills.
|
||||
"""
|
||||
try:
|
||||
# Get the conversation info
|
||||
conversation = await app_conversation_service.get_app_conversation(
|
||||
conversation_id
|
||||
# Get agent server context (conversation, sandbox, sandbox_spec, agent_server_url)
|
||||
ctx = await _get_agent_server_context(
|
||||
conversation_id,
|
||||
app_conversation_service,
|
||||
sandbox_service,
|
||||
sandbox_spec_service,
|
||||
)
|
||||
if not conversation:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': f'Conversation {conversation_id} not found'},
|
||||
)
|
||||
|
||||
# Get the sandbox info
|
||||
sandbox = await sandbox_service.get_sandbox(conversation.sandbox_id)
|
||||
if not sandbox or sandbox.status != SandboxStatus.RUNNING:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={
|
||||
'error': f'Sandbox not found or not running for conversation {conversation_id}'
|
||||
},
|
||||
)
|
||||
|
||||
# Get the sandbox spec to find the working directory
|
||||
sandbox_spec = await sandbox_spec_service.get_sandbox_spec(
|
||||
sandbox.sandbox_spec_id
|
||||
)
|
||||
if not sandbox_spec:
|
||||
# TODO: This is a temporary work around for the fact that we don't store previous
|
||||
# sandbox spec versions when updating OpenHands. When the SandboxSpecServices
|
||||
# transition to truly multi sandbox spec model this should raise a 404 error
|
||||
logger.warning('Sandbox spec not found - using default.')
|
||||
sandbox_spec = await sandbox_spec_service.get_default_sandbox_spec()
|
||||
|
||||
# Get the agent server URL
|
||||
if not sandbox.exposed_urls:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': 'No agent server URL found for sandbox'},
|
||||
)
|
||||
|
||||
agent_server_url = None
|
||||
for exposed_url in sandbox.exposed_urls:
|
||||
if exposed_url.name == AGENT_SERVER:
|
||||
agent_server_url = exposed_url.url
|
||||
break
|
||||
|
||||
if not agent_server_url:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': 'Agent server URL not found in sandbox'},
|
||||
)
|
||||
|
||||
agent_server_url = replace_localhost_hostname_for_docker(agent_server_url)
|
||||
if isinstance(ctx, JSONResponse):
|
||||
return ctx
|
||||
|
||||
# Load skills from all sources
|
||||
logger.info(f'Loading skills for conversation {conversation_id}')
|
||||
@@ -552,13 +606,13 @@ async def get_conversation_skills(
|
||||
all_skills: list = []
|
||||
if isinstance(app_conversation_service, AppConversationServiceBase):
|
||||
project_dir = get_project_dir(
|
||||
sandbox_spec.working_dir, conversation.selected_repository
|
||||
ctx.sandbox_spec.working_dir, ctx.conversation.selected_repository
|
||||
)
|
||||
all_skills = await app_conversation_service.load_and_merge_all_skills(
|
||||
sandbox,
|
||||
conversation.selected_repository,
|
||||
ctx.sandbox,
|
||||
ctx.conversation.selected_repository,
|
||||
project_dir,
|
||||
agent_server_url,
|
||||
ctx.agent_server_url,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -608,6 +662,147 @@ async def get_conversation_skills(
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{conversation_id}/hooks')
|
||||
async def get_conversation_hooks(
|
||||
conversation_id: UUID,
|
||||
app_conversation_service: AppConversationService = (
|
||||
app_conversation_service_dependency
|
||||
),
|
||||
sandbox_service: SandboxService = sandbox_service_dependency,
|
||||
sandbox_spec_service: SandboxSpecService = sandbox_spec_service_dependency,
|
||||
httpx_client: httpx.AsyncClient = httpx_client_dependency,
|
||||
) -> JSONResponse:
|
||||
"""Get hooks currently configured in the workspace for this conversation.
|
||||
|
||||
This endpoint loads hooks from the conversation's project directory in the
|
||||
workspace (i.e. `{project_dir}/.openhands/hooks.json`) at request time.
|
||||
|
||||
Note:
|
||||
This is intentionally a "live" view of the workspace configuration.
|
||||
If `.openhands/hooks.json` changes over time, this endpoint reflects the
|
||||
latest file content and may not match the hooks that were used when the
|
||||
conversation originally started.
|
||||
|
||||
Returns:
|
||||
JSONResponse: A JSON response containing the list of hook event types.
|
||||
"""
|
||||
try:
|
||||
# Get agent server context (conversation, sandbox, sandbox_spec, agent_server_url)
|
||||
ctx = await _get_agent_server_context(
|
||||
conversation_id,
|
||||
app_conversation_service,
|
||||
sandbox_service,
|
||||
sandbox_spec_service,
|
||||
)
|
||||
if isinstance(ctx, JSONResponse):
|
||||
return ctx
|
||||
|
||||
from openhands.app_server.app_conversation.hook_loader import (
|
||||
fetch_hooks_from_agent_server,
|
||||
get_project_dir_for_hooks,
|
||||
)
|
||||
|
||||
project_dir = get_project_dir_for_hooks(
|
||||
ctx.sandbox_spec.working_dir,
|
||||
ctx.conversation.selected_repository,
|
||||
)
|
||||
|
||||
# Load hooks from agent-server (using the error-raising variant so
|
||||
# HTTP/connection failures are surfaced to the user, not hidden).
|
||||
logger.debug(
|
||||
f'Loading hooks for conversation {conversation_id}, '
|
||||
f'agent_server_url={ctx.agent_server_url}, '
|
||||
f'project_dir={project_dir}'
|
||||
)
|
||||
|
||||
try:
|
||||
hook_config = await fetch_hooks_from_agent_server(
|
||||
agent_server_url=ctx.agent_server_url,
|
||||
session_api_key=ctx.session_api_key,
|
||||
project_dir=project_dir,
|
||||
httpx_client=httpx_client,
|
||||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.warning(
|
||||
f'Agent-server returned {e.response.status_code} when loading hooks '
|
||||
f'for conversation {conversation_id}: {e.response.text}'
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
content={
|
||||
'error': f'Agent-server returned status {e.response.status_code} when loading hooks'
|
||||
},
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.warning(
|
||||
f'Failed to reach agent-server when loading hooks '
|
||||
f'for conversation {conversation_id}: {e}'
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
content={'error': 'Failed to reach agent-server when loading hooks'},
|
||||
)
|
||||
|
||||
# Transform hook_config to response format
|
||||
hooks_response: list[HookEventResponse] = []
|
||||
|
||||
if hook_config:
|
||||
# Define the event types to check
|
||||
event_types = [
|
||||
'pre_tool_use',
|
||||
'post_tool_use',
|
||||
'user_prompt_submit',
|
||||
'session_start',
|
||||
'session_end',
|
||||
'stop',
|
||||
]
|
||||
|
||||
for field_name in event_types:
|
||||
matchers = getattr(hook_config, field_name, [])
|
||||
if matchers:
|
||||
matcher_responses = []
|
||||
for matcher in matchers:
|
||||
hook_defs = [
|
||||
HookDefinitionResponse(
|
||||
type=hook.type.value
|
||||
if hasattr(hook.type, 'value')
|
||||
else str(hook.type),
|
||||
command=hook.command,
|
||||
timeout=hook.timeout,
|
||||
async_=hook.async_,
|
||||
)
|
||||
for hook in matcher.hooks
|
||||
]
|
||||
matcher_responses.append(
|
||||
HookMatcherResponse(
|
||||
matcher=matcher.matcher,
|
||||
hooks=hook_defs,
|
||||
)
|
||||
)
|
||||
hooks_response.append(
|
||||
HookEventResponse(
|
||||
event_type=field_name,
|
||||
matchers=matcher_responses,
|
||||
)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'Loaded {len(hooks_response)} hook event types for conversation {conversation_id}'
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content=GetHooksResponse(hooks=hooks_response).model_dump(by_alias=True),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting hooks for conversation {conversation_id}: {e}')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={'error': f'Error getting hooks: {str(e)}'},
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{conversation_id}/download')
|
||||
async def export_conversation(
|
||||
conversation_id: UUID,
|
||||
|
||||
148
openhands/app_server/app_conversation/hook_loader.py
Normal file
148
openhands/app_server/app_conversation/hook_loader.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Utilities for loading hooks for V1 conversations.
|
||||
|
||||
This module provides functions to load hooks from the agent-server,
|
||||
which centralizes all hook loading logic. The app-server acts as a
|
||||
thin proxy that calls the agent-server's /api/hooks endpoint.
|
||||
|
||||
All hook loading is handled by the agent-server.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from openhands.sdk.hooks import HookConfig
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_project_dir_for_hooks(
|
||||
working_dir: str,
|
||||
selected_repository: str | None = None,
|
||||
) -> str:
|
||||
"""Get the project directory path for loading hooks.
|
||||
|
||||
When a repository is selected, hooks are loaded from
|
||||
{working_dir}/{repo_name}/.openhands/hooks.json.
|
||||
Otherwise, hooks are loaded from {working_dir}/.openhands/hooks.json.
|
||||
|
||||
Args:
|
||||
working_dir: Base working directory path in the sandbox
|
||||
selected_repository: Repository name (e.g., 'OpenHands/software-agent-sdk')
|
||||
If provided, the repo name is appended to working_dir.
|
||||
|
||||
Returns:
|
||||
The project directory path where hooks.json should be located.
|
||||
"""
|
||||
if selected_repository:
|
||||
repo_name = selected_repository.split('/')[-1]
|
||||
return f'{working_dir}/{repo_name}'
|
||||
return working_dir
|
||||
|
||||
|
||||
async def fetch_hooks_from_agent_server(
|
||||
agent_server_url: str,
|
||||
session_api_key: str | None,
|
||||
project_dir: str,
|
||||
httpx_client: httpx.AsyncClient,
|
||||
) -> HookConfig | None:
|
||||
"""Fetch hooks from the agent-server, raising on HTTP/connection errors.
|
||||
|
||||
This is the low-level function that makes a single API call to the
|
||||
agent-server's /api/hooks endpoint. It raises on HTTP and connection
|
||||
errors so callers can decide how to handle failures.
|
||||
|
||||
Args:
|
||||
agent_server_url: URL of the agent server (e.g., 'http://localhost:8000')
|
||||
session_api_key: Session API key for authentication (optional)
|
||||
project_dir: Workspace directory path for project hooks
|
||||
httpx_client: Shared HTTP client for making the request
|
||||
|
||||
Returns:
|
||||
HookConfig if hooks.json exists and is valid, None if no hooks found.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If the agent-server returns a non-2xx status.
|
||||
httpx.RequestError: If the agent-server is unreachable.
|
||||
"""
|
||||
_logger.debug(
|
||||
f'fetch_hooks_from_agent_server called: '
|
||||
f'agent_server_url={agent_server_url}, project_dir={project_dir}'
|
||||
)
|
||||
payload = {'project_dir': project_dir}
|
||||
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
if session_api_key:
|
||||
headers['X-Session-API-Key'] = session_api_key
|
||||
|
||||
response = await httpx_client.post(
|
||||
f'{agent_server_url}/api/hooks',
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=30.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
hook_config_data = data.get('hook_config')
|
||||
if hook_config_data is None:
|
||||
_logger.debug('No hooks found in workspace')
|
||||
return None
|
||||
|
||||
hook_config = HookConfig.from_dict(hook_config_data)
|
||||
|
||||
if hook_config.is_empty():
|
||||
_logger.debug('Hooks config is empty')
|
||||
return None
|
||||
|
||||
_logger.debug(f'Loaded hooks from agent-server for {project_dir}')
|
||||
return hook_config
|
||||
|
||||
|
||||
async def load_hooks_from_agent_server(
|
||||
agent_server_url: str,
|
||||
session_api_key: str | None,
|
||||
project_dir: str,
|
||||
httpx_client: httpx.AsyncClient,
|
||||
) -> HookConfig | None:
|
||||
"""Load hooks from the agent-server, swallowing errors gracefully.
|
||||
|
||||
Wrapper around fetch_hooks_from_agent_server that catches all errors
|
||||
and returns None. Use this for the conversation-start path where hooks
|
||||
are optional and failures should not block startup.
|
||||
|
||||
For the hooks viewer endpoint, use fetch_hooks_from_agent_server directly
|
||||
so errors can be surfaced to the user.
|
||||
|
||||
Args:
|
||||
agent_server_url: URL of the agent server (e.g., 'http://localhost:8000')
|
||||
session_api_key: Session API key for authentication (optional)
|
||||
project_dir: Workspace directory path for project hooks
|
||||
httpx_client: Shared HTTP client for making the request
|
||||
|
||||
Returns:
|
||||
HookConfig if hooks.json exists and is valid, None otherwise.
|
||||
"""
|
||||
try:
|
||||
return await fetch_hooks_from_agent_server(
|
||||
agent_server_url, session_api_key, project_dir, httpx_client
|
||||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
_logger.warning(
|
||||
f'Agent-server at {agent_server_url} returned error status {e.response.status_code} '
|
||||
f'when loading hooks from {project_dir}: {e.response.text}'
|
||||
)
|
||||
return None
|
||||
except httpx.RequestError as e:
|
||||
_logger.warning(
|
||||
f'Failed to connect to agent-server at {agent_server_url} '
|
||||
f'when loading hooks from {project_dir}: {e}'
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
f'Failed to load hooks from agent-server at {agent_server_url} '
|
||||
f'for project {project_dir}: {e}'
|
||||
)
|
||||
return None
|
||||
@@ -46,6 +46,9 @@ from openhands.app_server.app_conversation.app_conversation_service_base import
|
||||
from openhands.app_server.app_conversation.app_conversation_start_task_service import (
|
||||
AppConversationStartTaskService,
|
||||
)
|
||||
from openhands.app_server.app_conversation.hook_loader import (
|
||||
load_hooks_from_agent_server,
|
||||
)
|
||||
from openhands.app_server.app_conversation.sql_app_conversation_info_service import (
|
||||
SQLAppConversationInfoService,
|
||||
)
|
||||
@@ -84,6 +87,7 @@ from openhands.app_server.utils.llm_metadata import (
|
||||
from openhands.integrations.provider import ProviderType
|
||||
from openhands.integrations.service_types import SuggestedTask
|
||||
from openhands.sdk import Agent, AgentContext, LocalWorkspace
|
||||
from openhands.sdk.hooks import HookConfig
|
||||
from openhands.sdk.llm import LLM
|
||||
from openhands.sdk.plugin import PluginSource
|
||||
from openhands.sdk.secret import LookupSecret, SecretValue, StaticSecret
|
||||
@@ -312,6 +316,12 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
body_json = start_conversation_request.model_dump(
|
||||
mode='json', context={'expose_secrets': True}
|
||||
)
|
||||
# Log hook_config to verify it's being passed
|
||||
hook_config_in_request = body_json.get('hook_config')
|
||||
_logger.debug(
|
||||
f'Sending StartConversationRequest with hook_config: '
|
||||
f'{hook_config_in_request}'
|
||||
)
|
||||
response = await self.httpx_client.post(
|
||||
f'{agent_server_url}/api/conversations',
|
||||
json=body_json,
|
||||
@@ -1295,6 +1305,46 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
run=initial_message.run,
|
||||
)
|
||||
|
||||
async def _load_hooks_from_workspace(
|
||||
self,
|
||||
remote_workspace: AsyncRemoteWorkspace,
|
||||
project_dir: str,
|
||||
) -> HookConfig | None:
|
||||
"""Load hooks from .openhands/hooks.json in the remote workspace.
|
||||
|
||||
This enables project-level hooks to be automatically loaded when starting
|
||||
a conversation, similar to how OpenHands-CLI loads hooks from the workspace.
|
||||
|
||||
Uses the agent-server's /api/hooks endpoint, consistent with how skills
|
||||
are loaded via /api/skills.
|
||||
|
||||
Args:
|
||||
remote_workspace: AsyncRemoteWorkspace for accessing the agent server
|
||||
project_dir: Project root directory path in the sandbox. This should
|
||||
already be the resolved project directory (e.g.,
|
||||
{working_dir}/{repo_name} when a repo is selected).
|
||||
|
||||
Returns:
|
||||
HookConfig if hooks.json exists and is valid, None otherwise.
|
||||
Returns None in the following cases:
|
||||
- hooks.json file does not exist
|
||||
- hooks.json contains invalid JSON
|
||||
- hooks.json contains an empty hooks configuration
|
||||
- Agent server is unreachable or returns an error
|
||||
|
||||
Note:
|
||||
This method implements graceful degradation - if hooks cannot be loaded
|
||||
for any reason, it returns None rather than raising an exception. This
|
||||
ensures that conversation startup is not blocked by hook loading failures.
|
||||
Errors are logged as warnings for debugging purposes.
|
||||
"""
|
||||
return await load_hooks_from_agent_server(
|
||||
agent_server_url=remote_workspace.host,
|
||||
session_api_key=remote_workspace._headers.get('X-Session-API-Key'),
|
||||
project_dir=project_dir,
|
||||
httpx_client=self.httpx_client,
|
||||
)
|
||||
|
||||
async def _finalize_conversation_request(
|
||||
self,
|
||||
agent: Agent,
|
||||
@@ -1334,6 +1384,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
agent = self._update_agent_with_llm_metadata(agent, conversation_id, user.id)
|
||||
|
||||
# Load and merge skills if remote workspace is available
|
||||
hook_config: HookConfig | None = None
|
||||
if remote_workspace:
|
||||
try:
|
||||
agent = await self._load_skills_and_update_agent(
|
||||
@@ -1343,6 +1394,28 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
_logger.warning(f'Failed to load skills: {e}', exc_info=True)
|
||||
# Continue without skills - don't fail conversation startup
|
||||
|
||||
# Load hooks from workspace (.openhands/hooks.json)
|
||||
# Note: working_dir is already the resolved project_dir
|
||||
# (includes repo name when a repo is selected), so we pass
|
||||
# it directly without appending the repo name again.
|
||||
try:
|
||||
_logger.debug(
|
||||
f'Attempting to load hooks from workspace: '
|
||||
f'project_dir={working_dir}'
|
||||
)
|
||||
hook_config = await self._load_hooks_from_workspace(
|
||||
remote_workspace, working_dir
|
||||
)
|
||||
if hook_config:
|
||||
_logger.debug(
|
||||
f'Successfully loaded hooks: {hook_config.model_dump()}'
|
||||
)
|
||||
else:
|
||||
_logger.debug('No hooks found in workspace')
|
||||
except Exception as e:
|
||||
_logger.warning(f'Failed to load hooks: {e}', exc_info=True)
|
||||
# Continue without hooks - don't fail conversation startup
|
||||
|
||||
# Incorporate plugin parameters into initial message if specified
|
||||
final_initial_message = self._construct_initial_message_with_plugin_params(
|
||||
initial_message, plugins
|
||||
@@ -1371,6 +1444,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
initial_message=final_initial_message,
|
||||
secrets=secrets,
|
||||
plugins=sdk_plugins,
|
||||
hook_config=hook_config,
|
||||
)
|
||||
|
||||
async def _build_start_conversation_request_for_user(
|
||||
|
||||
293
tests/unit/app_server/test_app_conversation_hooks_endpoint.py
Normal file
293
tests/unit/app_server/test_app_conversation_hooks_endpoint.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""Unit tests for the V1 hooks endpoint in app_conversation_router.
|
||||
|
||||
This module tests the GET /{conversation_id}/hooks endpoint functionality.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import status
|
||||
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversation,
|
||||
)
|
||||
from openhands.app_server.app_conversation.app_conversation_router import (
|
||||
get_conversation_hooks,
|
||||
)
|
||||
from openhands.app_server.sandbox.sandbox_models import (
|
||||
AGENT_SERVER,
|
||||
ExposedUrl,
|
||||
SandboxInfo,
|
||||
SandboxStatus,
|
||||
)
|
||||
from openhands.app_server.sandbox.sandbox_spec_models import SandboxSpecInfo
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetConversationHooks:
|
||||
async def test_get_hooks_returns_hook_events(self):
|
||||
conversation_id = uuid4()
|
||||
sandbox_id = str(uuid4())
|
||||
working_dir = '/workspace'
|
||||
|
||||
mock_conversation = AppConversation(
|
||||
id=conversation_id,
|
||||
created_by_user_id='test-user',
|
||||
sandbox_id=sandbox_id,
|
||||
selected_repository='owner/repo',
|
||||
sandbox_status=SandboxStatus.RUNNING,
|
||||
)
|
||||
|
||||
mock_sandbox = SandboxInfo(
|
||||
id=sandbox_id,
|
||||
created_by_user_id='test-user',
|
||||
status=SandboxStatus.RUNNING,
|
||||
sandbox_spec_id=str(uuid4()),
|
||||
session_api_key='test-api-key',
|
||||
exposed_urls=[
|
||||
ExposedUrl(name=AGENT_SERVER, url='http://agent-server:8000', port=8000)
|
||||
],
|
||||
)
|
||||
|
||||
mock_sandbox_spec = SandboxSpecInfo(
|
||||
id=str(uuid4()), command=None, working_dir=working_dir
|
||||
)
|
||||
|
||||
mock_app_conversation_service = MagicMock()
|
||||
mock_app_conversation_service.get_app_conversation = AsyncMock(
|
||||
return_value=mock_conversation
|
||||
)
|
||||
|
||||
mock_sandbox_service = MagicMock()
|
||||
mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox)
|
||||
|
||||
mock_sandbox_spec_service = MagicMock()
|
||||
mock_sandbox_spec_service.get_sandbox_spec = AsyncMock(
|
||||
return_value=mock_sandbox_spec
|
||||
)
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = Mock()
|
||||
mock_response.json.return_value = {
|
||||
'hook_config': {
|
||||
'stop': [
|
||||
{
|
||||
'matcher': '*',
|
||||
'hooks': [
|
||||
{
|
||||
'type': 'command',
|
||||
'command': '.openhands/hooks/on_stop.sh',
|
||||
'timeout': 60,
|
||||
'async': True,
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
mock_httpx_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_httpx_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
response = await get_conversation_hooks(
|
||||
conversation_id=conversation_id,
|
||||
app_conversation_service=mock_app_conversation_service,
|
||||
sandbox_service=mock_sandbox_service,
|
||||
sandbox_spec_service=mock_sandbox_spec_service,
|
||||
httpx_client=mock_httpx_client,
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = __import__('json').loads(response.body.decode('utf-8'))
|
||||
assert 'hooks' in data
|
||||
assert data['hooks']
|
||||
assert data['hooks'][0]['event_type'] == 'stop'
|
||||
assert data['hooks'][0]['matchers'][0]['matcher'] == '*'
|
||||
assert data['hooks'][0]['matchers'][0]['hooks'][0]['type'] == 'command'
|
||||
assert (
|
||||
data['hooks'][0]['matchers'][0]['hooks'][0]['command']
|
||||
== '.openhands/hooks/on_stop.sh'
|
||||
)
|
||||
assert data['hooks'][0]['matchers'][0]['hooks'][0]['async'] is True
|
||||
assert 'async_' not in data['hooks'][0]['matchers'][0]['hooks'][0]
|
||||
|
||||
mock_httpx_client.post.assert_called_once()
|
||||
called_url = mock_httpx_client.post.call_args[0][0]
|
||||
assert called_url == 'http://agent-server:8000/api/hooks'
|
||||
|
||||
async def test_get_hooks_returns_502_when_agent_server_unreachable(self):
|
||||
conversation_id = uuid4()
|
||||
sandbox_id = str(uuid4())
|
||||
|
||||
mock_conversation = AppConversation(
|
||||
id=conversation_id,
|
||||
created_by_user_id='test-user',
|
||||
sandbox_id=sandbox_id,
|
||||
selected_repository=None,
|
||||
sandbox_status=SandboxStatus.RUNNING,
|
||||
)
|
||||
|
||||
mock_sandbox = SandboxInfo(
|
||||
id=sandbox_id,
|
||||
created_by_user_id='test-user',
|
||||
status=SandboxStatus.RUNNING,
|
||||
sandbox_spec_id=str(uuid4()),
|
||||
session_api_key='test-api-key',
|
||||
exposed_urls=[
|
||||
ExposedUrl(name=AGENT_SERVER, url='http://agent-server:8000', port=8000)
|
||||
],
|
||||
)
|
||||
|
||||
mock_sandbox_spec = SandboxSpecInfo(
|
||||
id=str(uuid4()), command=None, working_dir='/workspace'
|
||||
)
|
||||
|
||||
mock_app_conversation_service = MagicMock()
|
||||
mock_app_conversation_service.get_app_conversation = AsyncMock(
|
||||
return_value=mock_conversation
|
||||
)
|
||||
|
||||
mock_sandbox_service = MagicMock()
|
||||
mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox)
|
||||
|
||||
mock_sandbox_spec_service = MagicMock()
|
||||
mock_sandbox_spec_service.get_sandbox_spec = AsyncMock(
|
||||
return_value=mock_sandbox_spec
|
||||
)
|
||||
|
||||
mock_httpx_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
|
||||
def _raise_request_error(*args, **_kwargs):
|
||||
request = httpx.Request('POST', args[0])
|
||||
raise httpx.RequestError('Connection error', request=request)
|
||||
|
||||
mock_httpx_client.post = AsyncMock(side_effect=_raise_request_error)
|
||||
|
||||
response = await get_conversation_hooks(
|
||||
conversation_id=conversation_id,
|
||||
app_conversation_service=mock_app_conversation_service,
|
||||
sandbox_service=mock_sandbox_service,
|
||||
sandbox_spec_service=mock_sandbox_spec_service,
|
||||
httpx_client=mock_httpx_client,
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_502_BAD_GATEWAY
|
||||
data = __import__('json').loads(response.body.decode('utf-8'))
|
||||
assert 'error' in data
|
||||
|
||||
async def test_get_hooks_returns_502_when_agent_server_returns_error(self):
|
||||
conversation_id = uuid4()
|
||||
sandbox_id = str(uuid4())
|
||||
|
||||
mock_conversation = AppConversation(
|
||||
id=conversation_id,
|
||||
created_by_user_id='test-user',
|
||||
sandbox_id=sandbox_id,
|
||||
selected_repository=None,
|
||||
sandbox_status=SandboxStatus.RUNNING,
|
||||
)
|
||||
|
||||
mock_sandbox = SandboxInfo(
|
||||
id=sandbox_id,
|
||||
created_by_user_id='test-user',
|
||||
status=SandboxStatus.RUNNING,
|
||||
sandbox_spec_id=str(uuid4()),
|
||||
session_api_key='test-api-key',
|
||||
exposed_urls=[
|
||||
ExposedUrl(name=AGENT_SERVER, url='http://agent-server:8000', port=8000)
|
||||
],
|
||||
)
|
||||
|
||||
mock_sandbox_spec = SandboxSpecInfo(
|
||||
id=str(uuid4()), command=None, working_dir='/workspace'
|
||||
)
|
||||
|
||||
mock_app_conversation_service = MagicMock()
|
||||
mock_app_conversation_service.get_app_conversation = AsyncMock(
|
||||
return_value=mock_conversation
|
||||
)
|
||||
|
||||
mock_sandbox_service = MagicMock()
|
||||
mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox)
|
||||
|
||||
mock_sandbox_spec_service = MagicMock()
|
||||
mock_sandbox_spec_service.get_sandbox_spec = AsyncMock(
|
||||
return_value=mock_sandbox_spec
|
||||
)
|
||||
|
||||
mock_httpx_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 500
|
||||
|
||||
def _raise_http_status_error(*args, **_kwargs):
|
||||
request = httpx.Request('POST', args[0])
|
||||
response = httpx.Response(status_code=500, text='Internal Server Error')
|
||||
raise httpx.HTTPStatusError(
|
||||
'Server error', request=request, response=response
|
||||
)
|
||||
|
||||
mock_httpx_client.post = AsyncMock(side_effect=_raise_http_status_error)
|
||||
|
||||
response = await get_conversation_hooks(
|
||||
conversation_id=conversation_id,
|
||||
app_conversation_service=mock_app_conversation_service,
|
||||
sandbox_service=mock_sandbox_service,
|
||||
sandbox_spec_service=mock_sandbox_spec_service,
|
||||
httpx_client=mock_httpx_client,
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_502_BAD_GATEWAY
|
||||
data = __import__('json').loads(response.body.decode('utf-8'))
|
||||
assert 'error' in data
|
||||
|
||||
async def test_get_hooks_returns_404_when_conversation_not_found(self):
|
||||
conversation_id = uuid4()
|
||||
|
||||
mock_app_conversation_service = MagicMock()
|
||||
mock_app_conversation_service.get_app_conversation = AsyncMock(
|
||||
return_value=None
|
||||
)
|
||||
|
||||
response = await get_conversation_hooks(
|
||||
conversation_id=conversation_id,
|
||||
app_conversation_service=mock_app_conversation_service,
|
||||
sandbox_service=MagicMock(),
|
||||
sandbox_spec_service=MagicMock(),
|
||||
httpx_client=AsyncMock(spec=httpx.AsyncClient),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
async def test_get_hooks_returns_404_when_sandbox_not_running(self):
|
||||
conversation_id = uuid4()
|
||||
sandbox_id = str(uuid4())
|
||||
|
||||
mock_conversation = AppConversation(
|
||||
id=conversation_id,
|
||||
created_by_user_id='test-user',
|
||||
sandbox_id=sandbox_id,
|
||||
sandbox_status=SandboxStatus.RUNNING,
|
||||
)
|
||||
|
||||
mock_app_conversation_service = MagicMock()
|
||||
mock_app_conversation_service.get_app_conversation = AsyncMock(
|
||||
return_value=mock_conversation
|
||||
)
|
||||
|
||||
mock_sandbox_service = MagicMock()
|
||||
mock_sandbox_service.get_sandbox = AsyncMock(return_value=None)
|
||||
|
||||
response = await get_conversation_hooks(
|
||||
conversation_id=conversation_id,
|
||||
app_conversation_service=mock_app_conversation_service,
|
||||
sandbox_service=mock_sandbox_service,
|
||||
sandbox_spec_service=MagicMock(),
|
||||
httpx_client=AsyncMock(spec=httpx.AsyncClient),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
@@ -123,6 +123,10 @@ class TestLiveStatusAppConversationService:
|
||||
self.mock_sandbox.id = uuid4()
|
||||
self.mock_sandbox.status = SandboxStatus.RUNNING
|
||||
|
||||
# Default mock for hooks loading - returns None (no hooks found)
|
||||
# Tests that specifically test hooks loading can override this mock
|
||||
self.service._load_hooks_from_workspace = AsyncMock(return_value=None)
|
||||
|
||||
def test_apply_suggested_task_sets_prompt_and_trigger(self):
|
||||
"""Test suggested task prompts populate initial message and trigger."""
|
||||
suggested_task = SuggestedTask(
|
||||
@@ -179,6 +183,7 @@ class TestLiveStatusAppConversationService:
|
||||
with pytest.raises(ValueError, match='empty prompt'):
|
||||
self.service._apply_suggested_task(request)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_secrets_for_git_providers_no_provider_tokens(self):
|
||||
"""Test _setup_secrets_for_git_providers with no provider tokens."""
|
||||
# Arrange
|
||||
@@ -1139,6 +1144,8 @@ class TestLiveStatusAppConversationService:
|
||||
side_effect=Exception('Skills loading failed')
|
||||
)
|
||||
|
||||
# Note: hooks loading is already mocked in setup_method() to return None
|
||||
|
||||
# Act
|
||||
with patch(
|
||||
'openhands.app_server.app_conversation.live_status_app_conversation_service._logger'
|
||||
@@ -3144,3 +3151,275 @@ class TestAppConversationStartRequestWithPlugins:
|
||||
assert request.plugins[0].source == 'github:owner/plugin1'
|
||||
assert request.plugins[1].repo_path == 'plugins/sub'
|
||||
assert request.plugins[2].source == '/local/path'
|
||||
|
||||
|
||||
class TestLoadHooksFromWorkspace:
|
||||
"""Test cases for _load_hooks_from_workspace method."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
# Create mock dependencies
|
||||
self.mock_user_context = Mock(spec=UserContext)
|
||||
self.mock_jwt_service = Mock()
|
||||
self.mock_sandbox_service = Mock()
|
||||
self.mock_sandbox_spec_service = Mock()
|
||||
self.mock_app_conversation_info_service = Mock()
|
||||
self.mock_app_conversation_start_task_service = Mock()
|
||||
self.mock_event_callback_service = Mock()
|
||||
self.mock_event_service = Mock()
|
||||
self.mock_httpx_client = AsyncMock()
|
||||
|
||||
# Create service instance
|
||||
self.service = LiveStatusAppConversationService(
|
||||
init_git_in_empty_workspace=True,
|
||||
user_context=self.mock_user_context,
|
||||
app_conversation_info_service=self.mock_app_conversation_info_service,
|
||||
app_conversation_start_task_service=self.mock_app_conversation_start_task_service,
|
||||
event_callback_service=self.mock_event_callback_service,
|
||||
event_service=self.mock_event_service,
|
||||
sandbox_service=self.mock_sandbox_service,
|
||||
sandbox_spec_service=self.mock_sandbox_spec_service,
|
||||
jwt_service=self.mock_jwt_service,
|
||||
sandbox_startup_timeout=30,
|
||||
sandbox_startup_poll_frequency=1,
|
||||
httpx_client=self.mock_httpx_client,
|
||||
web_url='https://test.example.com',
|
||||
openhands_provider_base_url='https://provider.example.com',
|
||||
access_token_hard_timeout=None,
|
||||
app_mode='test',
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_hooks_from_workspace_success(self):
|
||||
"""Test loading hooks from workspace when hooks.json exists."""
|
||||
# Arrange
|
||||
mock_remote_workspace = Mock(spec=AsyncRemoteWorkspace)
|
||||
mock_remote_workspace.host = 'http://agent-server:8000'
|
||||
mock_remote_workspace._headers = {'X-Session-API-Key': 'test-key'}
|
||||
|
||||
hooks_response = {
|
||||
'hook_config': {
|
||||
'stop': [
|
||||
{
|
||||
'matcher': '*',
|
||||
'hooks': [{'type': 'command', 'command': 'echo "stop hook"'}],
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = hooks_response
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
self.mock_httpx_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Act
|
||||
result = await self.service._load_hooks_from_workspace(
|
||||
mock_remote_workspace, '/workspace'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert not result.is_empty()
|
||||
self.mock_httpx_client.post.assert_called_once_with(
|
||||
'http://agent-server:8000/api/hooks',
|
||||
json={'project_dir': '/workspace'},
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'X-Session-API-Key': 'test-key',
|
||||
},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_hooks_from_workspace_file_not_found(self):
|
||||
"""Test loading hooks when hooks.json does not exist."""
|
||||
# Arrange
|
||||
mock_remote_workspace = Mock(spec=AsyncRemoteWorkspace)
|
||||
mock_remote_workspace.host = 'http://agent-server:8000'
|
||||
mock_remote_workspace._headers = {}
|
||||
|
||||
# Agent server returns hook_config: None when file not found
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {'hook_config': None}
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
self.mock_httpx_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Act
|
||||
result = await self.service._load_hooks_from_workspace(
|
||||
mock_remote_workspace, '/workspace'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_hooks_from_workspace_empty_hooks(self):
|
||||
"""Test loading hooks when hooks.json is empty or has no hooks."""
|
||||
# Arrange
|
||||
mock_remote_workspace = Mock(spec=AsyncRemoteWorkspace)
|
||||
mock_remote_workspace.host = 'http://agent-server:8000'
|
||||
mock_remote_workspace._headers = {}
|
||||
|
||||
# Agent server returns empty hook_config
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {'hook_config': {}}
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
self.mock_httpx_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Act
|
||||
result = await self.service._load_hooks_from_workspace(
|
||||
mock_remote_workspace, '/workspace'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_hooks_from_workspace_http_error(self):
|
||||
"""Test loading hooks when HTTP request fails."""
|
||||
# Arrange
|
||||
mock_remote_workspace = Mock(spec=AsyncRemoteWorkspace)
|
||||
mock_remote_workspace.host = 'http://agent-server:8000'
|
||||
mock_remote_workspace._headers = {}
|
||||
|
||||
self.mock_httpx_client.post = AsyncMock(
|
||||
side_effect=Exception('Connection error')
|
||||
)
|
||||
|
||||
# Act
|
||||
result = await self.service._load_hooks_from_workspace(
|
||||
mock_remote_workspace, '/workspace'
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
def test_get_project_dir_for_hooks_with_selected_repository(self):
|
||||
"""Test get_project_dir_for_hooks with a selected repository."""
|
||||
from openhands.app_server.app_conversation.hook_loader import (
|
||||
get_project_dir_for_hooks,
|
||||
)
|
||||
|
||||
result = get_project_dir_for_hooks(
|
||||
'/workspace/project',
|
||||
'OpenHands/software-agent-sdk',
|
||||
)
|
||||
assert result == '/workspace/project/software-agent-sdk'
|
||||
|
||||
def test_get_project_dir_for_hooks_without_selected_repository(self):
|
||||
"""Test get_project_dir_for_hooks without a selected repository."""
|
||||
from openhands.app_server.app_conversation.hook_loader import (
|
||||
get_project_dir_for_hooks,
|
||||
)
|
||||
|
||||
result = get_project_dir_for_hooks('/workspace/project', None)
|
||||
assert result == '/workspace/project'
|
||||
|
||||
def test_get_project_dir_for_hooks_with_empty_string(self):
|
||||
"""Test get_project_dir_for_hooks with empty string repository."""
|
||||
from openhands.app_server.app_conversation.hook_loader import (
|
||||
get_project_dir_for_hooks,
|
||||
)
|
||||
|
||||
# Empty string should be treated as no repository
|
||||
result = get_project_dir_for_hooks('/workspace/project', '')
|
||||
assert result == '/workspace/project'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_hooks_from_workspace_with_project_dir(self):
|
||||
"""Test loading hooks with a pre-resolved project_dir.
|
||||
|
||||
The caller is responsible for computing the project_dir (which
|
||||
already includes the repo name when a repo is selected).
|
||||
_load_hooks_from_workspace should use the project_dir as-is.
|
||||
"""
|
||||
# Arrange
|
||||
mock_remote_workspace = Mock(spec=AsyncRemoteWorkspace)
|
||||
mock_remote_workspace.host = 'http://agent-server:8000'
|
||||
mock_remote_workspace._headers = {'X-Session-API-Key': 'test-key'}
|
||||
|
||||
hooks_response = {
|
||||
'hook_config': {
|
||||
'stop': [
|
||||
{
|
||||
'matcher': '*',
|
||||
'hooks': [{'type': 'command', 'command': 'echo "stop hook"'}],
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = hooks_response
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
self.mock_httpx_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Act - project_dir already includes repo name
|
||||
result = await self.service._load_hooks_from_workspace(
|
||||
mock_remote_workspace,
|
||||
'/workspace/project/software-agent-sdk',
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert not result.is_empty()
|
||||
# The project_dir should be passed as-is without doubling
|
||||
self.mock_httpx_client.post.assert_called_once_with(
|
||||
'http://agent-server:8000/api/hooks',
|
||||
json={'project_dir': '/workspace/project/software-agent-sdk'},
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'X-Session-API-Key': 'test-key',
|
||||
},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_hooks_from_workspace_base_dir(self):
|
||||
"""Test loading hooks with a base workspace directory (no repo selected)."""
|
||||
# Arrange
|
||||
mock_remote_workspace = Mock(spec=AsyncRemoteWorkspace)
|
||||
mock_remote_workspace.host = 'http://agent-server:8000'
|
||||
mock_remote_workspace._headers = {'X-Session-API-Key': 'test-key'}
|
||||
|
||||
hooks_response = {
|
||||
'hook_config': {
|
||||
'stop': [
|
||||
{
|
||||
'matcher': '*',
|
||||
'hooks': [{'type': 'command', 'command': 'echo "stop hook"'}],
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = hooks_response
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
self.mock_httpx_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Act - no repo selected, project_dir is base working_dir
|
||||
result = await self.service._load_hooks_from_workspace(
|
||||
mock_remote_workspace,
|
||||
'/workspace/project',
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
self.mock_httpx_client.post.assert_called_once_with(
|
||||
'http://agent-server:8000/api/hooks',
|
||||
json={'project_dir': '/workspace/project'},
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'X-Session-API-Key': 'test-key',
|
||||
},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user