feat: store plan.md file in appropriate configuration folders (#12713)

This commit is contained in:
Hiep Le
2026-02-06 16:09:39 +07:00
committed by GitHub
parent 545257f870
commit ee2885eb0b
14 changed files with 159 additions and 39 deletions

View File

@@ -233,7 +233,13 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
result = result_set.first() result = result_set.first()
if result: if result:
stored_metadata, saas_metadata = result stored_metadata, saas_metadata = result
return self._to_info_with_user_id(stored_metadata, saas_metadata) # Fetch sub-conversation IDs
sub_conversation_ids = await self.get_sub_conversation_ids(conversation_id)
return self._to_info_with_user_id(
stored_metadata,
saas_metadata,
sub_conversation_ids=sub_conversation_ids,
)
return None return None
async def batch_get_app_conversation_info( async def batch_get_app_conversation_info(
@@ -262,8 +268,16 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
for conversation_id in conversation_id_strs: for conversation_id in conversation_id_strs:
if conversation_id in info_by_id: if conversation_id in info_by_id:
stored_metadata, saas_metadata = info_by_id[conversation_id] stored_metadata, saas_metadata = info_by_id[conversation_id]
# Fetch sub-conversation IDs for each conversation
sub_conversation_ids = await self.get_sub_conversation_ids(
UUID(conversation_id)
)
results.append( results.append(
self._to_info_with_user_id(stored_metadata, saas_metadata) self._to_info_with_user_id(
stored_metadata,
saas_metadata,
sub_conversation_ids=sub_conversation_ids,
)
) )
else: else:
results.append(None) results.append(None)
@@ -316,10 +330,11 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
self, self,
stored: StoredConversationMetadata, stored: StoredConversationMetadata,
saas_metadata: StoredConversationMetadataSaas, saas_metadata: StoredConversationMetadataSaas,
sub_conversation_ids: list[UUID] | None = None,
) -> AppConversationInfo: ) -> AppConversationInfo:
"""Convert stored metadata to AppConversationInfo with user_id from SAAS metadata.""" """Convert stored metadata to AppConversationInfo with user_id from SAAS metadata."""
# Use the base _to_info method to get the basic info # Use the base _to_info method to get the basic info
info = self._to_info(stored) info = self._to_info(stored, sub_conversation_ids=sub_conversation_ids)
# Override the created_by_user_id with the user_id from SAAS metadata # Override the created_by_user_id with the user_id from SAAS metadata
info.created_by_user_id = ( info.created_by_user_id = (

View File

@@ -0,0 +1,27 @@
import { describe, expect, it, vi } from "vitest";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() }));
vi.mock("#/api/open-hands-axios", () => ({
openHands: { get: mockGet },
}));
describe("V1ConversationService", () => {
describe("readConversationFile", () => {
it("uses default plan path when filePath is not provided", async () => {
// Arrange
const conversationId = "conv-123";
mockGet.mockResolvedValue({ data: "# PLAN content" });
// Act
await V1ConversationService.readConversationFile(conversationId);
// Assert
expect(mockGet).toHaveBeenCalledTimes(1);
const callUrl = mockGet.mock.calls[0][0] as string;
expect(callUrl).toContain(
"file_path=%2Fworkspace%2Fproject%2F.agents_tmp%2FPLAN.md",
);
});
});
});

View File

@@ -170,7 +170,7 @@ describe("PlanPreview", () => {
// Arrange // Arrange
const user = userEvent.setup(); const user = userEvent.setup();
const expectedPrompt = const expectedPrompt =
"Execute the plan based on the workspace/project/PLAN.md file."; "Execute the plan based on the .agents_tmp/PLAN.md file.";
renderPlanPreview(<PlanPreview planContent="Plan content" />); renderPlanPreview(<PlanPreview planContent="Plan content" />);
const buildButton = screen.getByTestId("plan-preview-build-button"); const buildButton = screen.getByTestId("plan-preview-build-button");
@@ -201,7 +201,7 @@ describe("PlanPreview", () => {
useOptimisticUserMessageStore.setState({ optimisticUserMessage: null }); useOptimisticUserMessageStore.setState({ optimisticUserMessage: null });
const user = userEvent.setup(); const user = userEvent.setup();
const expectedPrompt = const expectedPrompt =
"Execute the plan based on the workspace/project/PLAN.md file."; "Execute the plan based on the .agents_tmp/PLAN.md file.";
renderPlanPreview(<PlanPreview planContent="Plan content" />); renderPlanPreview(<PlanPreview planContent="Plan content" />);
const buildButton = screen.getByTestId("plan-preview-build-button"); const buildButton = screen.getByTestId("plan-preview-build-button");

View File

@@ -41,8 +41,7 @@ describe("useHandleBuildPlanClick", () => {
(createChatMessage as unknown as ReturnType<typeof vi.fn>).mockReturnValue({ (createChatMessage as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
action: "message", action: "message",
args: { args: {
content: content: "Execute the plan based on the .agents_tmp/PLAN.md file.",
"Execute the plan based on the workspace/project/PLAN.md file.",
image_urls: [], image_urls: [],
file_urls: [], file_urls: [],
timestamp: expect.any(String), timestamp: expect.any(String),
@@ -78,7 +77,7 @@ describe("useHandleBuildPlanClick", () => {
// Arrange // Arrange
const { result } = renderHook(() => useHandleBuildPlanClick()); const { result } = renderHook(() => useHandleBuildPlanClick());
const expectedPrompt = const expectedPrompt =
"Execute the plan based on the workspace/project/PLAN.md file."; "Execute the plan based on the .agents_tmp/PLAN.md file.";
// Act // Act
act(() => { act(() => {
@@ -109,7 +108,7 @@ describe("useHandleBuildPlanClick", () => {
useOptimisticUserMessageStore.setState({ optimisticUserMessage: null }); useOptimisticUserMessageStore.setState({ optimisticUserMessage: null });
const { result } = renderHook(() => useHandleBuildPlanClick()); const { result } = renderHook(() => useHandleBuildPlanClick());
const expectedPrompt = const expectedPrompt =
"Execute the plan based on the workspace/project/PLAN.md file."; "Execute the plan based on the .agents_tmp/PLAN.md file.";
// Act // Act
act(() => { act(() => {
@@ -155,7 +154,7 @@ describe("useHandleBuildPlanClick", () => {
expect(useConversationStore.getState().conversationMode).toBe("code"); expect(useConversationStore.getState().conversationMode).toBe("code");
expect(mockSend).toHaveBeenCalledTimes(1); expect(mockSend).toHaveBeenCalledTimes(1);
expect(useOptimisticUserMessageStore.getState().optimisticUserMessage).toBe( expect(useOptimisticUserMessageStore.getState().optimisticUserMessage).toBe(
"Execute the plan based on the workspace/project/PLAN.md file.", "Execute the plan based on the .agents_tmp/PLAN.md file.",
); );
}); });

View File

@@ -319,12 +319,12 @@ class V1ConversationService {
/** /**
* Read a file from a specific conversation's sandbox workspace * Read a file from a specific conversation's sandbox workspace
* @param conversationId The conversation ID * @param conversationId The conversation ID
* @param filePath Path to the file to read within the sandbox workspace (defaults to /workspace/project/PLAN.md) * @param filePath Path to the file to read within the sandbox workspace (defaults to /workspace/project/.agents_tmp/PLAN.md)
* @returns The content of the file or an empty string if the file doesn't exist * @returns The content of the file or an empty string if the file doesn't exist
*/ */
static async readConversationFile( static async readConversationFile(
conversationId: string, conversationId: string,
filePath: string = "/workspace/project/PLAN.md", filePath: string = "/workspace/project/.agents_tmp/PLAN.md",
): Promise<string> { ): Promise<string> {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append("file_path", filePath); params.append("file_path", filePath);

View File

@@ -55,7 +55,9 @@ export function ChatMessage({
"flex flex-col gap-2", "flex flex-col gap-2",
type === "user" && "p-4 bg-tertiary self-end", type === "user" && "p-4 bg-tertiary self-end",
type === "agent" && "mt-6 w-full max-w-full bg-transparent", type === "agent" && "mt-6 w-full max-w-full bg-transparent",
isFromPlanningAgent && "border border-[#597ff4] bg-tertiary p-4 mt-2", isFromPlanningAgent &&
type === "agent" &&
"border border-[#597ff4] bg-tertiary p-4 mt-2",
)} )}
> >
<div <div

View File

@@ -39,14 +39,14 @@ export function PlanPreview({
isBuildDisabled, isBuildDisabled,
}: PlanPreviewProps) { }: PlanPreviewProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { selectTab } = useSelectConversationTab(); const { navigateToTab } = useSelectConversationTab();
const { handleBuildPlanClick } = useHandleBuildPlanClick(); const { handleBuildPlanClick } = useHandleBuildPlanClick();
const { scrollDomToBottom } = useScrollContext(); const { scrollDomToBottom } = useScrollContext();
const shouldUsePlanningAgent = USE_PLANNING_AGENT(); const shouldUsePlanningAgent = USE_PLANNING_AGENT();
const handleViewClick = () => { const handleViewClick = () => {
selectTab("planner"); navigateToTab("planner");
}; };
// Handle Build action with scroll to bottom // Handle Build action with scroll to bottom

View File

@@ -1,6 +1,5 @@
import { OpenHandsEvent } from "#/types/v1/core"; import { OpenHandsEvent } from "#/types/v1/core";
import { GenericEventMessage } from "../../../features/chat/generic-event-message"; import { GenericEventMessage } from "../../../features/chat/generic-event-message";
import { ChatMessage } from "../../../features/chat/chat-message";
import { getEventContent } from "../event-content-helpers/get-event-content"; import { getEventContent } from "../event-content-helpers/get-event-content";
import { getObservationResult } from "../event-content-helpers/get-observation-result"; import { getObservationResult } from "../event-content-helpers/get-observation-result";
import { isObservationEvent } from "#/types/v1/type-guards"; import { isObservationEvent } from "#/types/v1/type-guards";
@@ -14,13 +13,11 @@ import { ObservationResultStatus } from "../../../features/chat/event-content-he
interface GenericEventMessageWrapperProps { interface GenericEventMessageWrapperProps {
event: OpenHandsEvent | SkillReadyEvent; event: OpenHandsEvent | SkillReadyEvent;
isLastMessage: boolean; isLastMessage: boolean;
isFromPlanningAgent?: boolean;
} }
export function GenericEventMessageWrapper({ export function GenericEventMessageWrapper({
event, event,
isLastMessage, isLastMessage,
isFromPlanningAgent = false,
}: GenericEventMessageWrapperProps) { }: GenericEventMessageWrapperProps) {
const { title, details } = getEventContent(event); const { title, details } = getEventContent(event);
@@ -30,17 +27,6 @@ export function GenericEventMessageWrapper({
if (event.observation.kind === "TaskTrackerObservation") { if (event.observation.kind === "TaskTrackerObservation") {
return <div>{details}</div>; return <div>{details}</div>;
} }
if (event.observation.kind === "FinishObservation") {
const message = typeof details === "string" ? details : String(details);
// Use ChatMessage for proper styling (blue border for planning agent, text-sm)
return (
<ChatMessage
type="agent"
message={message}
isFromPlanningAgent={isFromPlanningAgent}
/>
);
}
} }
} }

View File

@@ -121,7 +121,6 @@ const renderUserMessageWithSkillReady = (
<GenericEventMessageWrapper <GenericEventMessageWrapper
event={skillReadyEvent} event={skillReadyEvent}
isLastMessage={isLastMessage} isLastMessage={isLastMessage}
isFromPlanningAgent={commonProps.isFromPlanningAgent}
/> />
</> </>
); );
@@ -212,7 +211,6 @@ export function EventMessage({
<GenericEventMessageWrapper <GenericEventMessageWrapper
event={event} event={event}
isLastMessage={isLastMessage} isLastMessage={isLastMessage}
isFromPlanningAgent={isFromPlanningAgent}
/> />
</> </>
); );
@@ -261,7 +259,6 @@ export function EventMessage({
<GenericEventMessageWrapper <GenericEventMessageWrapper
event={event} event={event}
isLastMessage={isLastMessage} isLastMessage={isLastMessage}
isFromPlanningAgent={isFromPlanningAgent}
/> />
</> </>
); );
@@ -292,10 +289,6 @@ export function EventMessage({
// Generic fallback for all other events // Generic fallback for all other events
return ( return (
<GenericEventMessageWrapper <GenericEventMessageWrapper event={event} isLastMessage={isLastMessage} />
event={event}
isLastMessage={isLastMessage}
isFromPlanningAgent={isFromPlanningAgent}
/>
); );
} }

View File

@@ -24,7 +24,7 @@ export const useHandleBuildPlanClick = () => {
setConversationMode("code"); setConversationMode("code");
// Create the build prompt to execute the plan // Create the build prompt to execute the plan
const buildPrompt = `Execute the plan based on the workspace/project/PLAN.md file.`; const buildPrompt = `Execute the plan based on the .agents_tmp/PLAN.md file.`;
// Send the message to the code agent // Send the message to the code agent
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();

View File

@@ -48,6 +48,19 @@ export function useSelectConversationTab() {
} }
}; };
/**
* Navigates to a tab without toggle behavior.
* Always shows the panel and selects the tab, even if already selected.
* Use this for "View" or "Read More" buttons that should always navigate.
*/
const navigateToTab = (tab: ConversationTab) => {
onTabChange(tab);
if (!isRightPanelShown) {
setHasRightPanelToggled(true);
setPersistedRightPanelShown(true);
}
};
/** /**
* Checks if a specific tab is currently active (selected and panel is visible). * Checks if a specific tab is currently active (selected and panel is visible).
*/ */
@@ -56,6 +69,7 @@ export function useSelectConversationTab() {
return { return {
selectTab, selectTab,
navigateToTab,
isTabActive, isTabActive,
onTabChange, onTabChange,
selectedTab, selectedTab,

View File

@@ -19,6 +19,13 @@ export const handleEventForUI = (
return newUiEvents; return newUiEvents;
} }
// Don't add FinishObservation at all - we keep the FinishAction instead
// Both contain the same message content, so we only need to display one
// This also prevents duplicate messages when events arrive out of order due to React batching
if (event.observation.kind === "FinishObservation") {
return newUiEvents;
}
// Find and replace the corresponding action from uiEvents // Find and replace the corresponding action from uiEvents
const actionIndex = newUiEvents.findIndex( const actionIndex = newUiEvents.findIndex(
(uiEvent) => uiEvent.id === event.action_id, (uiEvent) => uiEvent.id === event.action_id,

View File

@@ -569,6 +569,28 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
if not request.llm_model and parent_info.llm_model: if not request.llm_model and parent_info.llm_model:
request.llm_model = parent_info.llm_model request.llm_model = parent_info.llm_model
def _compute_plan_path(
self,
working_dir: str,
git_provider: ProviderType | None,
) -> str:
"""Compute the PLAN.md path based on provider type.
Args:
working_dir: The workspace working directory
git_provider: The git provider type (GitHub, GitLab, Azure DevOps, etc.)
Returns:
Absolute path to PLAN.md file in the appropriate config directory
"""
# GitLab and Azure DevOps use agents-tmp-config (since .agents_tmp is invalid)
if git_provider in (ProviderType.GITLAB, ProviderType.AZURE_DEVOPS):
config_dir = 'agents-tmp-config'
else:
config_dir = '.agents_tmp'
return f'{working_dir}/{config_dir}/PLAN.md'
async def _setup_secrets_for_git_providers(self, user: UserInfo) -> dict: async def _setup_secrets_for_git_providers(self, user: UserInfo) -> dict:
"""Set up secrets for all git provider authentication. """Set up secrets for all git provider authentication.
@@ -855,6 +877,8 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
mcp_config: dict, mcp_config: dict,
condenser_max_size: int | None, condenser_max_size: int | None,
secrets: dict[str, SecretValue] | None = None, secrets: dict[str, SecretValue] | None = None,
git_provider: ProviderType | None = None,
working_dir: str | None = None,
) -> Agent: ) -> Agent:
"""Create an agent with appropriate tools and context based on agent type. """Create an agent with appropriate tools and context based on agent type.
@@ -865,6 +889,8 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
mcp_config: MCP configuration dictionary mcp_config: MCP configuration dictionary
condenser_max_size: condenser_max_size setting condenser_max_size: condenser_max_size setting
secrets: Optional dictionary of secrets for authentication secrets: Optional dictionary of secrets for authentication
git_provider: Optional git provider type for computing plan path
working_dir: Optional working directory for computing plan path
Returns: Returns:
Configured Agent instance with context Configured Agent instance with context
@@ -874,9 +900,14 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
# Create agent based on type # Create agent based on type
if agent_type == AgentType.PLAN: if agent_type == AgentType.PLAN:
# Compute plan path if working_dir is provided
plan_path = None
if working_dir:
plan_path = self._compute_plan_path(working_dir, git_provider)
agent = Agent( agent = Agent(
llm=llm, llm=llm,
tools=get_planning_tools(), tools=get_planning_tools(plan_path=plan_path),
system_prompt_filename='system_prompt_planning.j2', system_prompt_filename='system_prompt_planning.j2',
system_prompt_kwargs={'plan_structure': format_plan_structure()}, system_prompt_kwargs={'plan_structure': format_plan_structure()},
condenser=condenser, condenser=condenser,
@@ -1153,6 +1184,8 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
mcp_config, mcp_config,
user.condenser_max_size, user.condenser_max_size,
secrets=secrets, secrets=secrets,
git_provider=git_provider,
working_dir=working_dir,
) )
# Finalize and return the conversation request # Finalize and return the conversation request

View File

@@ -682,6 +682,41 @@ class TestLiveStatusAppConversationService:
== 'https://mcp.tavily.com/mcp/?tavilyApiKey=env_tavily_key' == 'https://mcp.tavily.com/mcp/?tavilyApiKey=env_tavily_key'
) )
def test_compute_plan_path_default_uses_agents_tmp(self):
"""Test _compute_plan_path returns .agents_tmp/PLAN.md for default/GitHub."""
# Arrange
working_dir = '/workspace/project'
# Act
path_none = self.service._compute_plan_path(working_dir, None)
path_github = self.service._compute_plan_path(working_dir, ProviderType.GITHUB)
# Assert
assert path_none == '/workspace/project/.agents_tmp/PLAN.md'
assert path_github == '/workspace/project/.agents_tmp/PLAN.md'
def test_compute_plan_path_gitlab_uses_agents_tmp_config(self):
"""Test _compute_plan_path returns agents-tmp-config/PLAN.md for GitLab."""
# Arrange
working_dir = '/workspace/project'
# Act
path = self.service._compute_plan_path(working_dir, ProviderType.GITLAB)
# Assert
assert path == '/workspace/project/agents-tmp-config/PLAN.md'
def test_compute_plan_path_azure_uses_agents_tmp_config(self):
"""Test _compute_plan_path returns agents-tmp-config/PLAN.md for Azure."""
# Arrange
working_dir = '/workspace/project'
# Act
path = self.service._compute_plan_path(working_dir, ProviderType.AZURE_DEVOPS)
# Assert
assert path == '/workspace/project/agents-tmp-config/PLAN.md'
@patch( @patch(
'openhands.app_server.app_conversation.live_status_app_conversation_service.get_planning_tools' 'openhands.app_server.app_conversation.live_status_app_conversation_service.get_planning_tools'
) )
@@ -704,6 +739,8 @@ class TestLiveStatusAppConversationService:
mock_format_plan.return_value = 'test_plan_structure' mock_format_plan.return_value = 'test_plan_structure'
mcp_config = {'default': {'url': 'test'}} mcp_config = {'default': {'url': 'test'}}
system_message_suffix = 'Test suffix' system_message_suffix = 'Test suffix'
working_dir = '/workspace/project'
git_provider = ProviderType.GITHUB
# Act # Act
with patch( with patch(
@@ -719,9 +756,14 @@ class TestLiveStatusAppConversationService:
system_message_suffix, system_message_suffix,
mcp_config, mcp_config,
self.mock_user.condenser_max_size, self.mock_user.condenser_max_size,
git_provider=git_provider,
working_dir=working_dir,
) )
# Assert # Assert
mock_get_tools.assert_called_once_with(
plan_path='/workspace/project/.agents_tmp/PLAN.md'
)
mock_agent_class.assert_called_once() mock_agent_class.assert_called_once()
call_kwargs = mock_agent_class.call_args[1] call_kwargs = mock_agent_class.call_args[1]
assert call_kwargs['llm'] == mock_llm assert call_kwargs['llm'] == mock_llm
@@ -1006,6 +1048,8 @@ class TestLiveStatusAppConversationService:
mock_mcp_config, mock_mcp_config,
self.mock_user.condenser_max_size, self.mock_user.condenser_max_size,
secrets=mock_secrets, secrets=mock_secrets,
git_provider=ProviderType.GITHUB,
working_dir='/test/dir',
) )
self.service._finalize_conversation_request.assert_called_once() self.service._finalize_conversation_request.assert_called_once()