diff --git a/__tests__/api/agent-server-adapter.test.ts b/__tests__/api/agent-server-adapter.test.ts index 017f589dd9..e0169e25a8 100644 --- a/__tests__/api/agent-server-adapter.test.ts +++ b/__tests__/api/agent-server-adapter.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui"; +import { LAUNCH_CHILD_CONVERSATION_TOOL_NAME } from "#/constants/child-conversation"; import { ACP_SERVER_TAG_KEY, @@ -677,13 +678,12 @@ describe("buildStartConversationRequest", () => { }); }); - describe("canvas_ui client tool injection", () => { + describe("client tool injection", () => { it("sends canvas_ui as a client-defined JSON tool", () => { const payload = buildStartConversationRequest({ settings: DEFAULT_SETTINGS, }); - expect(payload.client_tools).toHaveLength(1); expect(payload.client_tools[0]).toMatchObject({ name: CANVAS_UI_CLIENT_TOOL_NAME, parameters: { @@ -761,6 +761,7 @@ describe("buildStartConversationRequest", () => { expect(payload.client_tools.map((tool) => tool.name)).toEqual([ CANVAS_UI_CLIENT_TOOL_NAME, + LAUNCH_CHILD_CONVERSATION_TOOL_NAME, ]); }); @@ -773,6 +774,7 @@ describe("buildStartConversationRequest", () => { expect(payload.conversation_id).toBe("legacy-conversation-id"); expect(payload.client_tools.map((tool) => tool.name)).toEqual([ CANVAS_UI_CLIENT_TOOL_NAME, + LAUNCH_CHILD_CONVERSATION_TOOL_NAME, ]); }); diff --git a/__tests__/api/agent-server-conversation-service.test.ts b/__tests__/api/agent-server-conversation-service.test.ts index f1d958c7a9..78b6457ba3 100644 --- a/__tests__/api/agent-server-conversation-service.test.ts +++ b/__tests__/api/agent-server-conversation-service.test.ts @@ -463,6 +463,40 @@ describe("AgentServerConversationService", () => { expect(payload.workspace.working_dir).toBe("/Users/jane/projects/foo"); expect(payload.worktree).toBe(true); }); + + it("links a local conversation to its parent", async () => { + mockGetSettings.mockResolvedValue({ + agent_settings: { llm: { model: "gpt-4o" } }, + conversation_settings: {}, + }); + mockGetSettingsForConversation.mockResolvedValue({ + agentSettings: { llm: { model: "gpt-4o" } }, + conversationSettings: {}, + secretsEncrypted: true, + }); + mockHttpPost.mockResolvedValue({ + data: { + id: "ignored-server-id", + created_at: "2024-01-01", + updated_at: "2024-01-01", + }, + }); + + await AgentServerConversationService.createConversation( + undefined, + undefined, + undefined, + undefined, + "/Users/jane/projects/foo", + "new_worktree", + "parent-conversation-id", + ); + + const [payloadCall] = mockHttpPost.mock.calls; + expect(payloadCall[1]).toMatchObject({ + parent_conversation_id: "parent-conversation-id", + }); + }); }); describe("downloadConversation local branch", () => { diff --git a/__tests__/api/cloud-conversation-service.test.ts b/__tests__/api/cloud-conversation-service.test.ts index 4248b23e0d..44ad6b2ce4 100644 --- a/__tests__/api/cloud-conversation-service.test.ts +++ b/__tests__/api/cloud-conversation-service.test.ts @@ -9,6 +9,7 @@ import { setStoredConversationMetadata } from "#/api/conversation-metadata-store import { batchGetCloudConversations, createCloudAppConversation, + pickCloudBackendForLaunch, searchCloudConversations, } from "#/api/cloud/conversation-service.api"; import { AGENT_CANVAS_CLIENT_HEADERS } from "#/api/client-source"; @@ -64,6 +65,52 @@ describe("cloud conversation-service overlay", () => { ); }); + it("starts a Cloud conversation on a registered backend while a local one is active", async () => { + const localBackend: Backend = { + id: "local-1", + kind: "local", + host: "http://localhost:8000", + apiKey: "local-key", + name: "Local", + }; + setRegisteredBackends([localBackend, cloudBackend]); + setActiveSelection({ backendId: localBackend.id, orgId: null }); + mockCallCloudProxy.mockResolvedValueOnce({ id: "start-task" }); + + await createCloudAppConversation( + { + initial_message: null, + selected_repository: null, + selected_branch: null, + git_provider: null, + parent_conversation_id: null, + }, + pickCloudBackendForLaunch()!, + ); + + expect(mockCallCloudProxy).toHaveBeenCalledWith( + expect.objectContaining({ + backend: cloudBackend, + path: "/api/v1/app-conversations", + headers: AGENT_CANVAS_CLIENT_HEADERS, + }), + ); + }); + + it("has no cloud backend to launch on when only local backends are registered", () => { + const localBackend: Backend = { + id: "local-1", + kind: "local", + host: "http://localhost:8000", + apiKey: "local-key", + name: "Local", + }; + setRegisteredBackends([localBackend]); + setActiveSelection({ backendId: localBackend.id, orgId: null }); + + expect(pickCloudBackendForLaunch()).toBeNull(); + }); + it("overlays locally-stored repo selection onto batchGetCloudConversations results when the server returns nulls", async () => { setStoredConversationMetadata("conv-1", { selected_repository: "octocat/hello-world", diff --git a/__tests__/components/conversation-events/chat/event-content-helpers/should-render-event.test.ts b/__tests__/components/conversation-events/chat/event-content-helpers/should-render-event.test.ts index 7e5b00b7bf..9920ec6f3b 100644 --- a/__tests__/components/conversation-events/chat/event-content-helpers/should-render-event.test.ts +++ b/__tests__/components/conversation-events/chat/event-content-helpers/should-render-event.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { shouldRenderEvent } from "#/components/conversation-events/chat/event-content-helpers/should-render-event"; +import { CHILD_CONVERSATION_RESULT_PREFIX } from "#/constants/child-conversation"; import { createPlanningFileEditorActionEvent, createOtherActionEvent, @@ -277,4 +278,14 @@ describe("shouldRenderEvent - /goal loop re-prompts", () => { ).toBe(true); expect(shouldRenderEvent(makeUserMessage("hello"))).toBe(true); }); + + it("hides the child-conversation launch result the frontend posts back", () => { + expect( + shouldRenderEvent( + makeUserMessage( + `${CHILD_CONVERSATION_RESULT_PREFIX}{"status":"launched","target":"local"}`, + ), + ), + ).toBe(false); + }); }); diff --git a/__tests__/components/conversation-events/get-event-content.test.tsx b/__tests__/components/conversation-events/get-event-content.test.tsx index 44eefa10ae..44dc346359 100644 --- a/__tests__/components/conversation-events/get-event-content.test.tsx +++ b/__tests__/components/conversation-events/get-event-content.test.tsx @@ -4,6 +4,10 @@ import { CANVAS_UI_CLIENT_ACTION_KIND, CANVAS_UI_CLIENT_TOOL_NAME, } from "#/constants/canvas-ui"; +import { + LAUNCH_CHILD_CONVERSATION_ACTION_KIND, + LAUNCH_CHILD_CONVERSATION_TOOL_NAME, +} from "#/constants/child-conversation"; import { getEventContent } from "#/components/conversation-events/chat"; import { ActionEvent, @@ -392,4 +396,59 @@ describe("getEventContent", () => { "UI command 'open_tab' dispatched to the Agent Canvas frontend.", ); }); + + // Without the explicit mapping the default branch would surface the SDK's + // generated discriminator ("CLIENTACTION_LAUNCH_CHILD_CONVERSATION") to users. + it("titles the launch-child-conversation tool call and its acknowledgement", () => { + const launchAction: ActionEvent = { + id: "action-launch", + timestamp: new Date().toISOString(), + source: "agent", + thought: [], + thinking_blocks: [], + action: { + kind: LAUNCH_CHILD_CONVERSATION_ACTION_KIND, + target: "local", + task: "Add a regression test for the parser", + }, + tool_name: LAUNCH_CHILD_CONVERSATION_TOOL_NAME, + tool_call_id: "tool-launch", + tool_call: { + id: "tool-launch", + type: "function", + function: { + name: LAUNCH_CHILD_CONVERSATION_TOOL_NAME, + arguments: '{"target":"local","task":"Add a regression test"}', + }, + }, + llm_response_id: "response-launch", + security_risk: SecurityRisk.LOW, + summary: "", + }; + const launchObservation: ObservationEvent = { + id: "obs-launch", + timestamp: new Date().toISOString(), + source: "environment", + tool_name: LAUNCH_CHILD_CONVERSATION_TOOL_NAME, + tool_call_id: "tool-launch", + action_id: "action-launch", + observation: { + kind: "ClientToolObservation", + content: [{ type: "text", text: "Tool call dispatched to client." }], + is_error: false, + }, + }; + + render({getEventContent(launchAction).title}); + expect( + screen.getByText("ACTION_MESSAGE$LAUNCH_CHILD_CONVERSATION"), + ).toBeInTheDocument(); + + render( + {getEventContent(launchObservation, launchAction).title}, + ); + expect( + screen.getByText("OBSERVATION_MESSAGE$LAUNCH_CHILD_CONVERSATION"), + ).toBeInTheDocument(); + }); }); diff --git a/__tests__/services/child-conversation-launch.test.ts b/__tests__/services/child-conversation-launch.test.ts new file mode 100644 index 0000000000..88d12c225f --- /dev/null +++ b/__tests__/services/child-conversation-launch.test.ts @@ -0,0 +1,573 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + CHILD_CONVERSATION_RESULT_PREFIX, + LAUNCH_CHILD_CONVERSATION_ACTION_KIND, + LAUNCH_CHILD_CONVERSATION_TOOL_NAME, +} from "#/constants/child-conversation"; +import { setStoredConversationMetadata } from "#/api/conversation-metadata-store"; +import { useGoalStore } from "#/stores/goal-store"; +import type { LaunchChildConversationAction } from "#/types/agent-server/core"; +import { isLaunchChildConversationActionEvent } from "#/types/agent-server/type-guards"; +import { + handleLaunchChildConversationAction, + type LaunchChildConversationResult, +} from "#/services/child-conversation-launch"; + +const { + mockCreateConversation, + mockResolveWorkingDir, + mockSendMessage, + mockUpdateTitle, + mockCreateCloudAppConversation, + mockGetCloudStartTask, + mockPickCloudBackend, + mockGetCachedAgentServerVersion, +} = vi.hoisted(() => ({ + mockCreateConversation: vi.fn(), + mockResolveWorkingDir: vi.fn(), + mockSendMessage: vi.fn(), + mockUpdateTitle: vi.fn(), + mockCreateCloudAppConversation: vi.fn(), + mockGetCloudStartTask: vi.fn(), + mockPickCloudBackend: vi.fn(), + mockGetCachedAgentServerVersion: vi.fn(), +})); + +vi.mock( + "#/api/conversation-service/agent-server-conversation-service.api", + () => ({ + default: { + createConversation: mockCreateConversation, + resolveConversationWorkingDir: mockResolveWorkingDir, + sendMessage: mockSendMessage, + updateConversationTitle: mockUpdateTitle, + }, + }), +); + +vi.mock("#/api/cloud/conversation-service.api", () => ({ + createCloudAppConversation: mockCreateCloudAppConversation, + getCloudAppConversationStartTask: mockGetCloudStartTask, + pickCloudBackendForLaunch: mockPickCloudBackend, +})); + +vi.mock("#/api/agent-server-compatibility", () => ({ + getCachedAgentServerVersion: mockGetCachedAgentServerVersion, + compareAgentServerVersions: (actual: string, required: string) => { + const parse = (v: string) => v.split(".").map(Number); + const [a, b] = [parse(actual), parse(required)]; + for (let i = 0; i < 3; i += 1) { + if (a[i] > b[i]) return 1; + if (a[i] < b[i]) return -1; + } + return 0; + }, +})); + +vi.mock("#/utils/custom-toast-handlers", () => ({ + displayErrorToast: vi.fn(), + displaySuccessToastWithLink: vi.fn(), +})); + +const PARENT_ID = "parent-conversation-id"; + +function action( + overrides: Partial, +): LaunchChildConversationAction { + return { + kind: LAUNCH_CHILD_CONVERSATION_ACTION_KIND, + target: "local", + task: "Add a regression test for the parser", + ...overrides, + } as LaunchChildConversationAction; +} + +/** Read back the JSON payload the service posted into the parent conversation. */ +function reportedResult(): LaunchChildConversationResult { + const [, message] = mockSendMessage.mock.calls.at(-1) as [ + string, + { content: { text: string }[] }, + ]; + return JSON.parse( + message.content[0].text.slice(CHILD_CONVERSATION_RESULT_PREFIX.length), + ); +} + +let toolCall = 0; +const nextToolCallId = () => { + toolCall += 1; + return `tool-call-${toolCall}`; +}; + +/** The repo fields the parent's metadata hands down to its children. */ +const PARENT_REPO_METADATA = { + selected_repository: "octocat/hello-world", + selected_branch: "main", + git_provider: null, +}; + +/** + * Give the parent a repository/workspace, which is what marks its workspace as + * able to host a worktree. Without stored metadata the parent is a scratch + * directory and the worktree is skipped. + */ +function seedWorktreeCapableParent() { + setStoredConversationMetadata(PARENT_ID, { + ...PARENT_REPO_METADATA, + selected_workspace: "/Users/jane/projects/foo", + }); +} + +describe("handleLaunchChildConversationAction", () => { + beforeEach(() => { + window.localStorage.clear(); + useGoalStore.setState({ statusByConversation: {} }); + vi.clearAllMocks(); + mockResolveWorkingDir.mockResolvedValue("/Users/jane/projects/foo"); + mockCreateConversation.mockResolvedValue({ + id: "start-task-id", + app_conversation_id: "child-id", + status: "READY", + }); + mockUpdateTitle.mockResolvedValue(undefined); + mockSendMessage.mockResolvedValue(undefined); + mockGetCachedAgentServerVersion.mockReturnValue("1.37.1"); + }); + + describe("parameter validation", () => { + // `enum` is advertised to the LLM but dropped when the agent-server builds + // the action model, so these values arrive intact and must be caught here. + it.each([ + [ + "an unknown target", + { target: "clould" }, + '`target` must be exactly "local" or "cloud"', + ], + [ + "an unknown isolation", + { isolation: "worktre" }, + '`isolation` must be exactly "worktree" or "shared"', + ], + [ + "a repository on a local target", + { target: "local", repository: "octocat/hello-world" }, + "A local child always runs in this conversation's workspace", + ], + [ + "an isolation on a cloud target", + { target: "cloud", isolation: "worktree" }, + "Cloud children always run in their own isolated sandbox", + ], + [ + "a branch without a repository", + { target: "cloud", branch: "main" }, + 'Pass `repository` as "owner/repo" alongside `branch`', + ], + [ + "an empty task", + { task: " " }, + "`task` must be a self-contained brief", + ], + ])( + "rejects %s with corrective guidance and launches nothing", + async (_label, overrides, guidance) => { + await handleLaunchChildConversationAction( + action(overrides), + PARENT_ID, + nextToolCallId(), + ); + + const result = reportedResult(); + expect(result.status).toBe("error"); + expect(result.status === "error" && result.guidance).toContain( + guidance, + ); + expect(mockCreateConversation).not.toHaveBeenCalled(); + expect(mockCreateCloudAppConversation).not.toHaveBeenCalled(); + }, + ); + }); + + describe("local target", () => { + // The agent server rejects a parent in a different workspace, so the child + // has to request the parent's own directory and isolate via the worktree. + it("launches into the parent's workspace as a linked, isolated child", async () => { + seedWorktreeCapableParent(); + + await handleLaunchChildConversationAction( + action({}), + PARENT_ID, + nextToolCallId(), + ); + + expect(mockCreateConversation).toHaveBeenCalledWith( + "Add a regression test for the parser", + undefined, + undefined, + PARENT_REPO_METADATA, + "/Users/jane/projects/foo", + "new_worktree", + PARENT_ID, + ); + }); + + it("reports the child's id, url and status back to the agent", async () => { + seedWorktreeCapableParent(); + + await handleLaunchChildConversationAction( + action({}), + PARENT_ID, + nextToolCallId(), + ); + + expect(reportedResult()).toMatchObject({ + status: "launched", + target: "local", + conversation_id: "child-id", + url: "http://localhost:3000/conversations/child-id", + initial_status: "READY", + workspace: "/Users/jane/projects/foo", + isolation: "worktree", + parent_link: true, + }); + }); + + // Local start requests carry no title field — only `autotitle` — so an + // explicit title has to be applied as a follow-up rename. + it("renames the child when a title is given", async () => { + await handleLaunchChildConversationAction( + action({ title: "Parser tests" }), + PARENT_ID, + nextToolCallId(), + ); + + expect(mockUpdateTitle).toHaveBeenCalledWith("child-id", "Parser tests"); + }); + + it("runs a shared-isolation child in the parent's directory itself", async () => { + await handleLaunchChildConversationAction( + action({ isolation: "shared" }), + PARENT_ID, + nextToolCallId(), + ); + + expect(mockCreateConversation).toHaveBeenCalledWith( + expect.anything(), + undefined, + undefined, + null, + "/Users/jane/projects/foo", + "local_repo", + PARENT_ID, + ); + }); + + // `parent_conversation_id` landed in agent-server 1.37.1; older servers + // drop it silently, so the agent must not be told the link exists. + it("reports that an older agent server did not persist the parent link", async () => { + mockGetCachedAgentServerVersion.mockReturnValue("1.37.0"); + + await handleLaunchChildConversationAction( + action({}), + PARENT_ID, + nextToolCallId(), + ); + + const result = reportedResult(); + expect(result).toMatchObject({ status: "launched", parent_link: false }); + expect(result.status === "launched" && result.parent_link_note).toContain( + "1.37.1", + ); + }); + + it("turns a failed launch into corrective guidance", async () => { + mockCreateConversation.mockRejectedValue(new Error("boom")); + + await handleLaunchChildConversationAction( + action({}), + PARENT_ID, + nextToolCallId(), + ); + + expect(reportedResult()).toMatchObject({ + status: "error", + error: "boom", + }); + }); + + // A conversation started without a repository runs in a scratch directory + // that is `git init`-ed but never committed to. `git worktree add` cannot + // branch from an unborn HEAD, and the agent-server raises that as a 500 + // that used to take the whole launch down. + it("skips the worktree when the parent workspace cannot host one", async () => { + await handleLaunchChildConversationAction( + action({}), + PARENT_ID, + nextToolCallId(), + ); + + expect(mockCreateConversation).toHaveBeenCalledTimes(1); + expect(mockCreateConversation).toHaveBeenCalledWith( + expect.anything(), + undefined, + undefined, + null, + "/Users/jane/projects/foo", + "local_repo", + PARENT_ID, + ); + + const result = reportedResult(); + expect(result).toMatchObject({ status: "launched", isolation: "shared" }); + expect(result.status === "launched" && result.isolation_note).toContain( + "no commits", + ); + }); + + // The metadata check cannot see the workspace's git state, so a worktree + // that fails anyway must not lose the launch. + it("falls back to a shared child when the worktree cannot be created", async () => { + seedWorktreeCapableParent(); + mockCreateConversation.mockRejectedValueOnce( + new Error("fatal: not a valid object name: 'HEAD'"), + ); + + await handleLaunchChildConversationAction( + action({}), + PARENT_ID, + nextToolCallId(), + ); + + expect(mockCreateConversation).toHaveBeenCalledTimes(2); + expect(mockCreateConversation).toHaveBeenLastCalledWith( + expect.anything(), + undefined, + undefined, + PARENT_REPO_METADATA, + "/Users/jane/projects/foo", + "local_repo", + PARENT_ID, + ); + + const result = reportedResult(); + expect(result).toMatchObject({ + status: "launched", + conversation_id: "child-id", + isolation: "shared", + }); + // The agent promised the user an isolated child, so it has to learn that + // this one shares the parent's directory after all. + expect(result.status === "launched" && result.isolation_note).toContain( + "not a valid object name", + ); + }); + + // Both attempts failing means the worktree was not the problem, so the + // agent gets the original error rather than the fallback's. + it("reports the original failure when the shared fallback also fails", async () => { + seedWorktreeCapableParent(); + mockCreateConversation + .mockRejectedValueOnce(new Error("worktree boom")) + .mockRejectedValueOnce(new Error("fallback boom")); + + await handleLaunchChildConversationAction( + action({}), + PARENT_ID, + nextToolCallId(), + ); + + expect(mockCreateConversation).toHaveBeenCalledTimes(2); + expect(reportedResult()).toMatchObject({ + status: "error", + error: "worktree boom", + }); + }); + + it("does not retry a child that never asked for a worktree", async () => { + mockCreateConversation.mockRejectedValue(new Error("boom")); + + await handleLaunchChildConversationAction( + action({ isolation: "shared" }), + PARENT_ID, + nextToolCallId(), + ); + + expect(mockCreateConversation).toHaveBeenCalledTimes(1); + expect(reportedResult()).toMatchObject({ + status: "error", + error: "boom", + }); + }); + }); + + describe("cloud target", () => { + const cloudBackend = { + id: "cloud-1", + kind: "cloud" as const, + host: "https://app.all-hands.dev", + apiKey: "secret", + name: "Cloud", + }; + + beforeEach(() => { + mockPickCloudBackend.mockReturnValue(cloudBackend); + }); + + it("starts the child on the connected cloud backend without a dangling parent link", async () => { + mockCreateCloudAppConversation.mockResolvedValue({ + id: "start-task-id", + app_conversation_id: "cloud-child-id", + status: "READY", + }); + + await handleLaunchChildConversationAction( + action({ + target: "cloud", + repository: "octocat/hello-world", + branch: "main", + }), + PARENT_ID, + nextToolCallId(), + ); + + expect(mockCreateCloudAppConversation).toHaveBeenCalledWith( + expect.objectContaining({ + selected_repository: "octocat/hello-world", + selected_branch: "main", + // The parent lives on the local agent server, so Cloud has no + // conversation to link the child to — and a dangling id would hide + // the child from the Cloud conversation list. + parent_conversation_id: null, + }), + cloudBackend, + ); + }); + + // Cloud provisions the sandbox asynchronously and only fills in + // `app_conversation_id` at READY, so reporting the first response would + // hand the agent a result with no conversation to open. + it("waits for the sandbox to expose the conversation id before reporting", async () => { + mockCreateCloudAppConversation.mockResolvedValue({ + id: "start-task-id", + app_conversation_id: null, + status: "WORKING", + }); + mockGetCloudStartTask.mockResolvedValue({ + id: "start-task-id", + app_conversation_id: "cloud-child-id", + status: "READY", + }); + + await handleLaunchChildConversationAction( + action({ target: "cloud" }), + PARENT_ID, + nextToolCallId(), + ); + + expect(reportedResult()).toMatchObject({ + status: "launched", + target: "cloud", + conversation_id: "cloud-child-id", + url: "https://app.all-hands.dev/conversations/cloud-child-id", + initial_status: "READY", + parent_link: false, + }); + }); + + it("guides the agent back to local when no cloud backend is connected", async () => { + mockPickCloudBackend.mockReturnValueOnce(null); + + await handleLaunchChildConversationAction( + action({ target: "cloud" }), + PARENT_ID, + nextToolCallId(), + ); + + const result = reportedResult(); + expect(result.status).toBe("error"); + expect(result.status === "error" && result.guidance).toContain( + 'target="local"', + ); + expect(mockCreateCloudAppConversation).not.toHaveBeenCalled(); + }); + }); + + // A replayed ActionEvent (socket reconnect, or a REST/WebSocket race after a + // reload) must not start a second — on Cloud, billable — conversation. + it("ignores a replayed tool call", async () => { + const toolCallId = nextToolCallId(); + + await handleLaunchChildConversationAction( + action({}), + PARENT_ID, + toolCallId, + ); + await handleLaunchChildConversationAction( + action({}), + PARENT_ID, + toolCallId, + ); + + expect(mockCreateConversation).toHaveBeenCalledTimes(1); + expect(mockSendMessage).toHaveBeenCalledTimes(1); + }); + + // The agent server cancels an active /goal loop on any inbound message, so + // the result stays in the toast rather than ending the user's loop. + it("does not post the result while a goal loop is running", async () => { + useGoalStore.setState({ + statusByConversation: { + [PARENT_ID]: { + active: true, + status: "running", + iteration: 1, + max_iterations: 5, + objective: "ship it", + verdict: null, + }, + }, + }); + + await handleLaunchChildConversationAction( + action({}), + PARENT_ID, + nextToolCallId(), + ); + + expect(mockCreateConversation).toHaveBeenCalledTimes(1); + expect(mockSendMessage).not.toHaveBeenCalled(); + }); +}); + +describe("isLaunchChildConversationActionEvent", () => { + const makeActionEvent = (toolName: string) => + ({ + id: "evt-1", + timestamp: "2026-07-26T00:00:00Z", + source: "agent", + action: { + kind: LAUNCH_CHILD_CONVERSATION_ACTION_KIND, + target: "local", + task: "do the thing", + }, + tool_name: toolName, + tool_call_id: "call-1", + }) as never; + + it("returns true for an ActionEvent from the launch tool", () => { + expect( + isLaunchChildConversationActionEvent( + makeActionEvent(LAUNCH_CHILD_CONVERSATION_TOOL_NAME), + ), + ).toBe(true); + }); + + it("returns false when tool_name belongs to a different tool", () => { + expect( + isLaunchChildConversationActionEvent( + makeActionEvent("canvas_ui_control"), + ), + ).toBe(false); + }); +}); diff --git a/__tests__/tools/launch-child-conversation-tool.test.ts b/__tests__/tools/launch-child-conversation-tool.test.ts new file mode 100644 index 0000000000..74fcc0d52d --- /dev/null +++ b/__tests__/tools/launch-child-conversation-tool.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { + LAUNCH_CHILD_CONVERSATION_ACTION_KIND, + LAUNCH_CHILD_CONVERSATION_CLIENT_TOOL, + LAUNCH_CHILD_CONVERSATION_TOOL_NAME, +} from "#/api/launch-child-conversation-client-tool"; + +describe("launch_child_conversation client tool", () => { + it("exports the semantic tool name and generated action kind", () => { + expect(LAUNCH_CHILD_CONVERSATION_TOOL_NAME).toBe( + "launch_child_conversation", + ); + expect(LAUNCH_CHILD_CONVERSATION_ACTION_KIND).toBe( + "ClientAction_launch_child_conversation", + ); + expect(LAUNCH_CHILD_CONVERSATION_CLIENT_TOOL.name).toBe( + LAUNCH_CHILD_CONVERSATION_TOOL_NAME, + ); + }); + + it("pins the validated parameter schema", () => { + expect(LAUNCH_CHILD_CONVERSATION_CLIENT_TOOL.parameters).toMatchObject({ + type: "object", + // Misspelled parameter names must fail server-side (the SDK builds a + // pydantic model with extra="forbid") instead of launching a child with + // silently dropped arguments. + additionalProperties: false, + properties: { + target: { enum: ["local", "cloud"] }, + isolation: { enum: ["worktree", "shared"] }, + }, + required: ["target", "task"], + }); + }); + + it("is annotated as a non-idempotent, outward-reaching tool", () => { + expect(LAUNCH_CHILD_CONVERSATION_CLIENT_TOOL.annotations).toEqual({ + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }); + }); + + it("tells the agent the result arrives as a follow-up message", () => { + expect(LAUNCH_CHILD_CONVERSATION_CLIENT_TOOL.description).toContain( + "[child-conversation]", + ); + }); +}); diff --git a/src/api/agent-server-adapter.test.ts b/src/api/agent-server-adapter.test.ts index d78a9b62b7..bfa5b3920a 100644 --- a/src/api/agent-server-adapter.test.ts +++ b/src/api/agent-server-adapter.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui"; +import { LAUNCH_CHILD_CONVERSATION_TOOL_NAME } from "#/constants/child-conversation"; import { DEFAULT_SETTINGS } from "#/services/settings"; import type { Settings } from "#/types/settings"; import { buildStartConversationRequest } from "./agent-server-adapter"; @@ -190,6 +191,7 @@ describe("buildStartConversationRequest — agentProfileId path", () => { expect(payload.agent_settings).toBeUndefined(); expect(payload.client_tools.map((tool) => tool.name)).toEqual([ CANVAS_UI_CLIENT_TOOL_NAME, + LAUNCH_CHILD_CONVERSATION_TOOL_NAME, ]); }); diff --git a/src/api/agent-server-adapter.ts b/src/api/agent-server-adapter.ts index cdc6b97044..4ea06e28a4 100644 --- a/src/api/agent-server-adapter.ts +++ b/src/api/agent-server-adapter.ts @@ -38,6 +38,10 @@ import { LEGACY_CANVAS_UI_TOOL_NAME, type ClientToolSpec, } from "./canvas-ui-client-tool"; +import { + LAUNCH_CHILD_CONVERSATION_CLIENT_TOOL, + LAUNCH_CHILD_CONVERSATION_TOOL_NAME, +} from "./launch-child-conversation-client-tool"; export interface DirectConversationInfo { id: string; @@ -933,6 +937,7 @@ type StartConversationPayload = Record & { worktree: boolean; secrets_encrypted?: true; conversation_id?: string; + parent_conversation_id?: string; secrets?: Record; tags?: Record; client_tools: ClientToolSpec[]; @@ -945,6 +950,11 @@ export interface StartConversationOptions { conversationInstructions?: string; plugins?: PluginSpec[]; conversationId?: string; + // Links the new conversation to an existing one as its child. The + // agent-server requires the parent to exist and to share this + // conversation's requested `workspace.working_dir` (software-agent-sdk + // #4188, agent-server >= 1.37.1); older servers ignore the field. + parentConversationId?: string; workingDir?: string; worktree?: boolean; encryptedAgentSettings?: Record; @@ -1020,8 +1030,15 @@ export function buildStartConversationRequest( ? { agent_profile_id: options.agentProfileId } : { agent_settings: agentSettings }), workspace: conversationSettings.workspace, + // The agent-server caches each client tool's schema per tool *name* for the + // life of the process and rejects a re-registration with a different schema + // (`ClientToolSchemaConflictError`). Editing either schema below therefore + // requires restarting a long-running dev agent-server before new + // conversations can start. client_tools: - launchAgentKind === "openhands" ? [CANVAS_UI_CLIENT_TOOL] : [], + launchAgentKind === "openhands" + ? [CANVAS_UI_CLIENT_TOOL, LAUNCH_CHILD_CONVERSATION_CLIENT_TOOL] + : [], confirmation_policy: getConversationConfirmationPolicy(conversationSettings), max_iterations: @@ -1060,6 +1077,10 @@ export function buildStartConversationRequest( payload.conversation_id = options.conversationId; } + if (options.parentConversationId) { + payload.parent_conversation_id = options.parentConversationId; + } + const securityAnalyzer = getConversationSecurityAnalyzer(conversationSettings); if (securityAnalyzer) { @@ -1085,6 +1106,7 @@ export function buildStartConversationRequest( }; delete toolModuleQualnames[LEGACY_CANVAS_UI_TOOL_NAME]; delete toolModuleQualnames[CANVAS_UI_CLIENT_TOOL_NAME]; + delete toolModuleQualnames[LAUNCH_CHILD_CONVERSATION_TOOL_NAME]; if (Object.keys(toolModuleQualnames).length > 0) { payload.tool_module_qualnames = toolModuleQualnames; } @@ -1150,6 +1172,7 @@ export async function buildStartConversationRequestWithEncryptedSettings(options conversationInstructions?: string; plugins?: PluginSpec[]; conversationId?: string; + parentConversationId?: string; workingDir?: string; worktree?: boolean; agentProfileId?: string; diff --git a/src/api/cloud/conversation-service.api.ts b/src/api/cloud/conversation-service.api.ts index f5e5f29d65..74dfc53e32 100644 --- a/src/api/cloud/conversation-service.api.ts +++ b/src/api/cloud/conversation-service.api.ts @@ -1,4 +1,7 @@ -import { getActiveBackend } from "../backend-registry/active-store"; +import { + getActiveBackend, + getRegisteredBackends, +} from "../backend-registry/active-store"; import type { Backend } from "../backend-registry/types"; import { getStoredConversationMetadata } from "../conversation-metadata-store"; import type { @@ -51,6 +54,31 @@ function getActiveCloudBackend(): Backend { return active; } +/** + * Resolve the cloud backend a Cloud conversation should be launched against + * when the active backend may not be a cloud one. Cloud conversations cannot + * register client tools, so the typed launch action always runs from a local + * parent and has to reach a registered-but-inactive cloud backend. + * + * Prefers the active backend when it is already cloud, then the first + * registered cloud backend that carries credentials. Returns `null` when the + * user has no cloud backend connected, so callers can give the agent + * corrective guidance instead of failing opaquely. + * + * Note: `createCloudClient` only sends `X-Org-Id` for the *active* backend, so + * a conversation launched against an inactive backend lands in the API key's + * own organization. + */ +export function pickCloudBackendForLaunch(): Backend | null { + const active = getActiveBackend().backend; + if (active.kind === "cloud") return active; + return ( + getRegisteredBackends().find( + (backend) => backend.kind === "cloud" && !!backend.apiKey, + ) ?? null + ); +} + /** * Search the cloud app-conversations list. Mirrors the local * `AgentServerConversationService.searchConversations` interface but calls @@ -120,8 +148,11 @@ export async function batchGetCloudConversations( */ export async function createCloudAppConversation( request: AppConversationStartRequest, + // Defaults to the active backend. The typed launch action passes an explicit + // backend because it always runs from a *local* parent conversation. + backendOverride?: Backend, ): Promise { - const backend = getActiveCloudBackend(); + const backend = backendOverride ?? getActiveCloudBackend(); const data = await callCloudProxy({ backend, method: "POST", @@ -250,8 +281,9 @@ export async function readCloudConversationFile( */ export async function getCloudAppConversationStartTask( taskId: string, + backendOverride?: Backend, ): Promise { - const backend = getActiveCloudBackend(); + const backend = backendOverride ?? getActiveCloudBackend(); const params = new URLSearchParams(); params.set("ids", taskId); const data = await callCloudProxy<(AppConversationStartTask | null)[]>({ diff --git a/src/api/conversation-service/agent-server-conversation-service.api.ts b/src/api/conversation-service/agent-server-conversation-service.api.ts index 9c86b87081..999da8f1a0 100644 --- a/src/api/conversation-service/agent-server-conversation-service.api.ts +++ b/src/api/conversation-service/agent-server-conversation-service.api.ts @@ -439,6 +439,11 @@ class AgentServerConversationService { conversationInstructions, plugins, conversationId, + // The agent-server rejects a parent in a different workspace, so callers + // launching a child must pass the parent's own `working_dir` as + // `workingDirOverride` (see `resolveConversationWorkingDir`). Servers + // older than 1.37.1 ignore the field and create an unlinked conversation. + parentConversationId, workingDir, worktree: resolvedWorkspaceMode === "new_worktree", agentProfileId, diff --git a/src/api/launch-child-conversation-client-tool.ts b/src/api/launch-child-conversation-client-tool.ts new file mode 100644 index 0000000000..17ca180111 --- /dev/null +++ b/src/api/launch-child-conversation-client-tool.ts @@ -0,0 +1,112 @@ +import { + CHILD_CONVERSATION_ISOLATIONS, + CHILD_CONVERSATION_RESULT_PREFIX, + CHILD_CONVERSATION_TARGETS, + LAUNCH_CHILD_CONVERSATION_TOOL_NAME, +} from "#/constants/child-conversation"; +import type { ClientToolSpec } from "./canvas-ui-client-tool"; + +export { + LAUNCH_CHILD_CONVERSATION_ACTION_KIND, + LAUNCH_CHILD_CONVERSATION_TOOL_NAME, +} from "#/constants/child-conversation"; + +const LAUNCH_CHILD_CONVERSATION_DESCRIPTION = `Start a NEW, independent conversation to work on a well-scoped task in parallel with this one, without the user having to run any CLI command. The new conversation is recorded as a child of this one. + +The child runs on its own. It does not block you, you do not see its output, +and it cannot see this conversation's history — everything it needs must be in +the task brief. + +Choosing target: + +* target="local" — runs on the same machine, in this conversation's workspace. + Fast, no repository clone, no sandbox provisioning. Use this by default when + the work is on code that is already checked out here. + +* target="cloud" — runs on OpenHands Cloud in its own isolated sandbox, from a + git repository. Use this when the work should not touch the user's machine or + when it needs a repository that is not checked out locally. Requires the user + to have an OpenHands Cloud backend connected in Agent Canvas; if none is + connected you will be told so and should fall back to target="local". + +Writing the task brief: + +* Put everything the child needs in "task": the goal, the relevant file paths, + the constraints, the expected deliverable, and how it should report back. +* Keep each child's scope independent of its siblings so parallel children do + not fight over the same files. +* One call per delegated task. Do NOT call this tool twice for the same task. + +Parameter rules (a call that breaks one of these launches nothing and comes +back with corrective guidance): + +* "repository" and "branch" apply to target="cloud" only. A local child always + inherits this conversation's workspace, so do not pass them with + target="local". +* "isolation" applies to target="local" only. Cloud sandboxes are always + isolated, so do not pass it with target="cloud". +* isolation="worktree" (the default) gives the child its own git worktree and + branch, cut from the repository's default branch — it will NOT see this + conversation's uncommitted or committed work. isolation="shared" puts the + child in this conversation's exact directory; only choose it when the child + genuinely must see work in progress, and expect the two agents to conflict. + +What comes back: this tool is acknowledged immediately, before the conversation +exists. The real outcome arrives moments later as a follow-up message starting +with "${CHILD_CONVERSATION_RESULT_PREFIX.trim()}" that carries the child's +conversation id, URL, target and initial status — or an error with guidance. +Wait for it, then tell the user what you launched and give them the URL. If it +reports an error, fix the parameters and call this tool again.`; + +export const LAUNCH_CHILD_CONVERSATION_CLIENT_TOOL: ClientToolSpec = { + name: LAUNCH_CHILD_CONVERSATION_TOOL_NAME, + description: LAUNCH_CHILD_CONVERSATION_DESCRIPTION, + parameters: { + type: "object", + additionalProperties: false, + properties: { + target: { + type: "string", + enum: [...CHILD_CONVERSATION_TARGETS], + description: + "Where the child runs. 'local' reuses this machine and this conversation's workspace; 'cloud' runs in an isolated OpenHands Cloud sandbox.", + }, + task: { + type: "string", + description: + "Self-contained task brief, sent as the child's first message. The child cannot see this conversation, so state the goal, constraints, expected output and handoff criteria.", + }, + title: { + type: "string", + description: + "Optional short title for the child conversation. Omit to let it title itself from the task.", + }, + repository: { + type: "string", + description: + "target='cloud' only. Repository to clone into the sandbox, as 'owner/repo'. Defaults to this conversation's repository when it has one.", + }, + branch: { + type: "string", + description: + "target='cloud' only, and only together with 'repository'. Defaults to the repository's default branch.", + }, + isolation: { + type: "string", + enum: [...CHILD_CONVERSATION_ISOLATIONS], + description: + "target='local' only. 'worktree' (default) gives the child its own git worktree and branch; 'shared' runs it in this conversation's exact directory.", + }, + }, + required: ["target", "task"], + }, + annotations: { + // Launching a conversation writes real state and is not repeatable, so the + // agent is asked to predict a security risk before every call. + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + // The cloud target reaches OpenHands Cloud. + openWorldHint: true, + }, +}; diff --git a/src/components/conversation-events/chat/event-content-helpers/get-action-event-title.ts b/src/components/conversation-events/chat/event-content-helpers/get-action-event-title.ts index 9e49321606..95d68e0e57 100644 --- a/src/components/conversation-events/chat/event-content-helpers/get-action-event-title.ts +++ b/src/components/conversation-events/chat/event-content-helpers/get-action-event-title.ts @@ -1,4 +1,5 @@ import { CANVAS_UI_CLIENT_ACTION_KIND } from "#/constants/canvas-ui"; +import { LAUNCH_CHILD_CONVERSATION_ACTION_KIND } from "#/constants/child-conversation"; import type { ActionEvent } from "#/types/agent-server/core"; export type EventTitleDescriptor = @@ -131,6 +132,12 @@ export const getActionEventTitleDescriptor = ( case "CanvasUIAction": case CANVAS_UI_CLIENT_ACTION_KIND: return { kind: "text", text: "CANVASUI" }; + case LAUNCH_CHILD_CONVERSATION_ACTION_KIND: + return { + kind: "translation", + key: "ACTION_MESSAGE$LAUNCH_CHILD_CONVERSATION", + values: {}, + }; default: return { kind: "text", diff --git a/src/components/conversation-events/chat/event-content-helpers/get-event-content.tsx b/src/components/conversation-events/chat/event-content-helpers/get-event-content.tsx index e8dd3d840d..e05afb92ee 100644 --- a/src/components/conversation-events/chat/event-content-helpers/get-event-content.tsx +++ b/src/components/conversation-events/chat/event-content-helpers/get-event-content.tsx @@ -1,6 +1,7 @@ import { Trans } from "react-i18next"; import React from "react"; import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui"; +import { LAUNCH_CHILD_CONVERSATION_TOOL_NAME } from "#/constants/child-conversation"; import { OpenHandsEvent, ObservationEvent, @@ -222,6 +223,10 @@ const getObservationEventTitle = ( observationKey = "OBSERVATION_MESSAGE$CANVAS_UI"; break; } + if (event.tool_name === LAUNCH_CHILD_CONVERSATION_TOOL_NAME) { + observationKey = "OBSERVATION_MESSAGE$LAUNCH_CHILD_CONVERSATION"; + break; + } return observationType.replace("Observation", "").toUpperCase(); case "SwitchLLMObservation": observationKey = event.observation.is_error diff --git a/src/components/conversation-events/chat/event-content-helpers/should-render-event.ts b/src/components/conversation-events/chat/event-content-helpers/should-render-event.ts index f3001db297..5e8005b361 100644 --- a/src/components/conversation-events/chat/event-content-helpers/should-render-event.ts +++ b/src/components/conversation-events/chat/event-content-helpers/should-render-event.ts @@ -1,3 +1,4 @@ +import { CHILD_CONVERSATION_RESULT_PREFIX } from "#/constants/child-conversation"; import { MessageEvent, OpenHandsEvent } from "#/types/agent-server/core"; import { isActionEvent, @@ -24,18 +25,31 @@ const GOAL_REPROMPT_PREFIXES = [ "Resuming a goal that was paused or interrupted.", ]; -const isGoalLoopReprompt = (event: MessageEvent): boolean => { - if (event.llm_message?.role !== "user") return false; +const userMessageText = (event: MessageEvent): string | null => { + if (event.llm_message?.role !== "user") return null; const content = event.llm_message.content; - const text = Array.isArray(content) + return Array.isArray(content) ? content .filter((c) => c.type === "text") .map((c) => c.text) .join("\n") : ""; +}; + +const isGoalLoopReprompt = (event: MessageEvent): boolean => { + const text = userMessageText(event); + if (text === null) return false; return GOAL_REPROMPT_PREFIXES.some((prefix) => text.startsWith(prefix)); }; +// The frontend posts the outcome of a `launch_child_conversation` call back as +// a `user` message because client tools have no result channel (see +// `services/child-conversation-launch.ts`). It is a JSON payload addressed to +// the agent, which reports the child's id and link in its own reply, so the +// raw message does not belong in the chat. +const isChildConversationResult = (event: MessageEvent): boolean => + userMessageText(event)?.startsWith(CHILD_CONVERSATION_RESULT_PREFIX) ?? false; + export const shouldRenderEvent = (event: OpenHandsEvent) => { if (isConversationStateUpdateEvent(event)) { // A finished `/goal` loop renders inline so it settles into the @@ -90,9 +104,11 @@ export const shouldRenderEvent = (event: OpenHandsEvent) => { // Render message events (user and assistant messages), except the goal loop's // injected re-prompts — the judge feedback they carry is shown in the goal - // banner, so otherwise they leak into the chat as fake user turns. + // banner, so otherwise they leak into the chat as fake user turns — and the + // child-conversation launch results, which are machine payloads the agent + // relays in its own reply. if (isMessageEvent(event)) { - return !isGoalLoopReprompt(event); + return !isGoalLoopReprompt(event) && !isChildConversationResult(event); } // Render agent error events diff --git a/src/constants/child-conversation.ts b/src/constants/child-conversation.ts new file mode 100644 index 0000000000..2cbb4eb062 --- /dev/null +++ b/src/constants/child-conversation.ts @@ -0,0 +1,37 @@ +export const LAUNCH_CHILD_CONVERSATION_TOOL_NAME = "launch_child_conversation"; + +/** + * Action discriminator generated by the SDK for the client-defined launch tool + * (`ClientAction_`). Keep the SDK naming convention contained here + * instead of repeating its generated class name throughout the frontend. + */ +export const LAUNCH_CHILD_CONVERSATION_ACTION_KIND = + `ClientAction_${LAUNCH_CHILD_CONVERSATION_TOOL_NAME}` as const; + +/** + * Prefix of the machine-generated message the frontend posts back into the + * parent conversation after a launch. Client tools are acknowledged by the + * agent-server before the browser has done any work, so this message is the + * only way to hand the agent the child's id, URL and status — or corrective + * guidance when the call was malformed. The chat hides it (see + * `should-render-event.ts`) because the agent relays the same information in + * its own reply. + */ +export const CHILD_CONVERSATION_RESULT_PREFIX = "[child-conversation] "; + +export const CHILD_CONVERSATION_TARGETS = ["local", "cloud"] as const; +export type ChildConversationTarget = + (typeof CHILD_CONVERSATION_TARGETS)[number]; + +export const CHILD_CONVERSATION_ISOLATIONS = ["worktree", "shared"] as const; +export type ChildConversationIsolation = + (typeof CHILD_CONVERSATION_ISOLATIONS)[number]; + +/** + * `parent_conversation_id` on the local start-conversation request landed in + * software-agent-sdk#4188. Older agent-servers accept the request and drop the + * field (the pydantic model ignores extras), so the launch still succeeds — we + * just tell the agent the relationship was not persisted instead of letting it + * believe in a link that does not exist. + */ +export const MIN_AGENT_SERVER_VERSION_FOR_PARENT_LINK = "1.37.1"; diff --git a/src/contexts/conversation-websocket-context.tsx b/src/contexts/conversation-websocket-context.tsx index f21aa89ccd..d1e984dd30 100644 --- a/src/contexts/conversation-websocket-context.tsx +++ b/src/contexts/conversation-websocket-context.tsx @@ -38,8 +38,10 @@ import { isBrowserNavigateActionEvent, isSwitchLLMObservationEvent, isCanvasUIActionEvent, + isLaunchChildConversationActionEvent, } from "#/types/agent-server/type-guards"; import { handleCanvasUIAction } from "#/services/canvas-ui"; +import { handleLaunchChildConversationAction } from "#/services/child-conversation-launch"; import { ConversationStateUpdateEventStats } from "#/types/agent-server/core/events/conversation-state-event"; import type { ConversationErrorEvent, @@ -644,6 +646,17 @@ export function ConversationWebSocketProvider({ if (isCanvasUIActionEvent(event)) { handleCanvasUIAction(event.action, conversationId ?? null); } + + // Same client-tool pattern, but the work is a network call: launch + // the requested child conversation and post the outcome back so the + // agent learns the id the server-side acknowledgement can't carry. + if (conversationId && isLaunchChildConversationActionEvent(event)) { + void handleLaunchChildConversationAction( + event.action, + conversationId, + event.tool_call_id, + ); + } } } catch (error) { console.warn("Failed to parse WebSocket message as JSON:", error); diff --git a/src/i18n/translation.json b/src/i18n/translation.json index 6c7eb8f938..78b7004d0f 100644 --- a/src/i18n/translation.json +++ b/src/i18n/translation.json @@ -37364,5 +37364,107 @@ "de": "Nach Updates suchen", "uk": "Перевірити наявність оновлень", "ca": "Comprova si hi ha actualitzacions" + }, + "CHILD_CONVERSATION$LAUNCHED_LOCAL": { + "en": "Launched a local child conversation", + "ja": "ローカルの子会話を開始しました", + "zh-CN": "已启动本地子会话", + "zh-TW": "已啟動本機子對話", + "ko-KR": "로컬 하위 대화를 시작했습니다", + "no": "Startet en lokal undersamtale", + "ar": "تم إطلاق محادثة فرعية محلية", + "de": "Lokale untergeordnete Unterhaltung gestartet", + "fr": "Conversation enfant locale lancée", + "it": "Conversazione figlia locale avviata", + "pt": "Conversa filha local iniciada", + "es": "Se inició una conversación hija local", + "tr": "Yerel alt sohbet başlatıldı", + "uk": "Запущено локальну дочірню розмову", + "ca": "S'ha iniciat una conversa filla local" + }, + "CHILD_CONVERSATION$LAUNCHED_CLOUD": { + "en": "Launched a Cloud child conversation", + "ja": "クラウドの子会話を開始しました", + "zh-CN": "已启动云端子会话", + "zh-TW": "已啟動雲端子對話", + "ko-KR": "클라우드 하위 대화를 시작했습니다", + "no": "Startet en Cloud-undersamtale", + "ar": "تم إطلاق محادثة فرعية على السحابة", + "de": "Untergeordnete Cloud-Unterhaltung gestartet", + "fr": "Conversation enfant Cloud lancée", + "it": "Conversazione figlia Cloud avviata", + "pt": "Conversa filha na Cloud iniciada", + "es": "Se inició una conversación hija en la nube", + "tr": "Cloud alt sohbeti başlatıldı", + "uk": "Запущено хмарну дочірню розмову", + "ca": "S'ha iniciat una conversa filla al núvol" + }, + "CHILD_CONVERSATION$OPEN": { + "en": "Open", + "ja": "開く", + "zh-CN": "打开", + "zh-TW": "開啟", + "ko-KR": "열기", + "no": "Åpne", + "ar": "فتح", + "de": "Öffnen", + "fr": "Ouvrir", + "it": "Apri", + "pt": "Abrir", + "es": "Abrir", + "tr": "Aç", + "uk": "Відкрити", + "ca": "Obre" + }, + "CHILD_CONVERSATION$LAUNCH_FAILED": { + "en": "Could not launch the child conversation: {{error}}", + "ja": "子会話を開始できませんでした: {{error}}", + "zh-CN": "无法启动子会话:{{error}}", + "zh-TW": "無法啟動子對話:{{error}}", + "ko-KR": "하위 대화를 시작할 수 없습니다: {{error}}", + "no": "Kunne ikke starte undersamtalen: {{error}}", + "ar": "تعذّر إطلاق المحادثة الفرعية: {{error}}", + "de": "Untergeordnete Unterhaltung konnte nicht gestartet werden: {{error}}", + "fr": "Impossible de lancer la conversation enfant : {{error}}", + "it": "Impossibile avviare la conversazione figlia: {{error}}", + "pt": "Não foi possível iniciar a conversa filha: {{error}}", + "es": "No se pudo iniciar la conversación hija: {{error}}", + "tr": "Alt sohbet başlatılamadı: {{error}}", + "uk": "Не вдалося запустити дочірню розмову: {{error}}", + "ca": "No s'ha pogut iniciar la conversa filla: {{error}}" + }, + "ACTION_MESSAGE$LAUNCH_CHILD_CONVERSATION": { + "en": "Launching a child conversation", + "ja": "子会話を開始しています", + "zh-CN": "正在启动子会话", + "zh-TW": "正在啟動子對話", + "ko-KR": "하위 대화를 시작하는 중", + "no": "Starter en undersamtale", + "ar": "جارٍ إطلاق محادثة فرعية", + "de": "Untergeordnete Unterhaltung wird gestartet", + "fr": "Lancement d'une conversation enfant", + "it": "Avvio di una conversazione figlia", + "pt": "Iniciando uma conversa filha", + "es": "Iniciando una conversación hija", + "tr": "Alt sohbet başlatılıyor", + "uk": "Запуск дочірньої розмови", + "ca": "S'està iniciant una conversa filla" + }, + "OBSERVATION_MESSAGE$LAUNCH_CHILD_CONVERSATION": { + "en": "Requested a child conversation", + "ja": "子会話をリクエストしました", + "zh-CN": "已请求子会话", + "zh-TW": "已請求子對話", + "ko-KR": "하위 대화를 요청했습니다", + "no": "Ba om en undersamtale", + "ar": "تم طلب محادثة فرعية", + "de": "Untergeordnete Unterhaltung angefordert", + "fr": "Conversation enfant demandée", + "it": "Conversazione figlia richiesta", + "pt": "Conversa filha solicitada", + "es": "Se solicitó una conversación hija", + "tr": "Alt sohbet istendi", + "uk": "Запитано дочірню розмову", + "ca": "S'ha sol·licitat una conversa filla" } } diff --git a/src/services/child-conversation-launch.ts b/src/services/child-conversation-launch.ts new file mode 100644 index 0000000000..c4d0a06824 --- /dev/null +++ b/src/services/child-conversation-launch.ts @@ -0,0 +1,538 @@ +import i18n from "#/i18n"; +import { I18nKey } from "#/i18n/declaration"; +import { + compareAgentServerVersions, + getCachedAgentServerVersion, +} from "#/api/agent-server-compatibility"; +import { + createCloudAppConversation, + getCloudAppConversationStartTask, + pickCloudBackendForLaunch, +} from "#/api/cloud/conversation-service.api"; +import type { Backend } from "#/api/backend-registry/types"; +import { + getStoredConversationMetadata, + type ConversationMetadata, +} from "#/api/conversation-metadata-store"; +import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; +import type { AppConversationStartTask } from "#/api/conversation-service/agent-server-conversation-service.types"; +import { + CHILD_CONVERSATION_ISOLATIONS, + CHILD_CONVERSATION_RESULT_PREFIX, + CHILD_CONVERSATION_TARGETS, + LAUNCH_CHILD_CONVERSATION_TOOL_NAME, + MIN_AGENT_SERVER_VERSION_FOR_PARENT_LINK, + type ChildConversationIsolation, + type ChildConversationTarget, +} from "#/constants/child-conversation"; +import { useGoalStore } from "#/stores/goal-store"; +import type { LaunchChildConversationAction } from "#/types/agent-server/core"; +import { buildAgentCanvasPath } from "#/utils/base-path"; +import { + displayErrorToast, + displaySuccessToastWithLink, +} from "#/utils/custom-toast-handlers"; + +/** Cadence and ceiling for waiting on a Cloud sandbox to expose its id. */ +const CLOUD_START_POLL_INTERVAL_MS = 3_000; +const CLOUD_START_POLL_TIMEOUT_MS = 180_000; + +const LEDGER_STORAGE_KEY_PREFIX = "openhands-child-conversation-launches:"; + +interface LaunchSuccess { + status: "launched"; + target: ChildConversationTarget; + conversation_id: string | null; + url: string | null; + initial_status: string; + title: string | null; + /** Local only: the directory the child inherited from this conversation. */ + workspace?: string; + /** Local only: which isolation mode was actually applied. */ + isolation?: ChildConversationIsolation; + /** + * Present when the applied isolation is not the one that was asked for, + * explaining why and what it means for the child. + */ + isolation_note?: string; + /** Cloud only: poll this if `conversation_id` is still null. */ + start_task_id?: string; + /** Cloud only: which connected Cloud backend the child was launched on. */ + backend?: string; + /** Whether the parent/child link was persisted server-side. */ + parent_link: boolean; + /** Present when `parent_link` is false, explaining why. */ + parent_link_note?: string; +} + +interface LaunchFailure { + status: "error"; + error: string; + guidance: string; +} + +export type LaunchChildConversationResult = LaunchSuccess | LaunchFailure; + +interface ValidatedParams { + target: ChildConversationTarget; + task: string; + title: string | null; + repository: string | null; + branch: string | null; + isolation: ChildConversationIsolation; +} + +const failure = (error: string, guidance: string): LaunchFailure => ({ + status: "error", + error, + guidance, +}); + +const quoted = (values: readonly string[]) => + values.map((value) => `"${value}"`).join(" or "); + +const blankToNull = (value: string | null | undefined) => { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +}; + +/** + * Validate what the agent-server cannot. + * + * The tool's JSON Schema is turned into a pydantic model with `extra="forbid"`, + * so unknown/misspelled parameter names, missing required parameters and wrong + * types already fail server-side with a corrective message. `enum` is the gap: + * the SDK advertises it to the LLM but drops it when building the model, so a + * misspelled `target` reaches us intact and must be rejected here. The + * cross-target rules (cloud-only / local-only parameters) are ours to enforce + * too, since a schema cannot express them. + */ +function validateLaunchParams( + action: LaunchChildConversationAction, +): + | { ok: true; params: ValidatedParams } + | { ok: false; failure: LaunchFailure } { + const target = action.target as ChildConversationTarget; + if (!CHILD_CONVERSATION_TARGETS.includes(target)) { + return { + ok: false, + failure: failure( + `Unknown target ${JSON.stringify(action.target)}.`, + `\`target\` must be exactly ${quoted(CHILD_CONVERSATION_TARGETS)}. Nothing was launched — call ${LAUNCH_CHILD_CONVERSATION_TOOL_NAME} again with a valid target.`, + ), + }; + } + + const task = blankToNull(action.task); + if (!task) { + return { + ok: false, + failure: failure( + "`task` is empty.", + "`task` must be a self-contained brief: the child conversation cannot see this one, so state the goal, constraints and expected output.", + ), + }; + } + + const repository = blankToNull(action.repository); + const branch = blankToNull(action.branch); + const isolation = blankToNull( + action.isolation, + ) as ChildConversationIsolation | null; + + if (target === "local" && (repository || branch)) { + return { + ok: false, + failure: failure( + '`repository`/`branch` were passed with target="local".', + 'A local child always runs in this conversation\'s workspace. Drop `repository` and `branch`, or use target="cloud" to run against a repository in a Cloud sandbox.', + ), + }; + } + + if (target === "cloud" && isolation) { + return { + ok: false, + failure: failure( + '`isolation` was passed with target="cloud".', + 'Cloud children always run in their own isolated sandbox. Drop `isolation`, or use target="local" to choose between "worktree" and "shared".', + ), + }; + } + + if (branch && !repository) { + return { + ok: false, + failure: failure( + "`branch` was passed without `repository`.", + 'Pass `repository` as "owner/repo" alongside `branch`, or drop `branch` to use the repository\'s default branch.', + ), + }; + } + + if (isolation && !CHILD_CONVERSATION_ISOLATIONS.includes(isolation)) { + return { + ok: false, + failure: failure( + `Unknown isolation ${JSON.stringify(action.isolation)}.`, + `\`isolation\` must be exactly ${quoted(CHILD_CONVERSATION_ISOLATIONS)}. Nothing was launched — call ${LAUNCH_CHILD_CONVERSATION_TOOL_NAME} again with a valid isolation.`, + ), + }; + } + + return { + ok: true, + params: { + target, + task, + title: blankToNull(action.title), + repository, + branch, + isolation: isolation ?? "worktree", + }, + }; +} + +/** + * Remember which tool calls have already been acted on. + * + * Unlike the Canvas UI tool, a launch is not idempotent: replaying its + * ActionEvent (a socket reconnect that falls back to `resend_mode: "all"`, or a + * REST/WebSocket race after a reload) would start a second — on Cloud, billable + * — conversation. Claim the tool call before any network work so a replay that + * arrives mid-flight is dropped too. + */ +function claimToolCall(parentConversationId: string, toolCallId: string) { + const key = `${LEDGER_STORAGE_KEY_PREFIX}${parentConversationId}`; + let handled: string[] = []; + try { + const raw = window.localStorage.getItem(key); + const parsed: unknown = raw ? JSON.parse(raw) : null; + if (Array.isArray(parsed)) { + handled = parsed.filter((id): id is string => typeof id === "string"); + } + } catch { + // A corrupt ledger must not block the launch; start a fresh one. + } + + if (handled.includes(toolCallId)) return false; + + try { + window.localStorage.setItem(key, JSON.stringify([...handled, toolCallId])); + } catch { + // Storage full or unavailable — proceed, accepting replay risk over never + // launching at all. + } + return true; +} + +function absoluteCanvasUrl(path: string) { + const canvasPath = buildAgentCanvasPath(path); + if (typeof window === "undefined") return canvasPath; + return new URL(canvasPath, window.location.origin).toString(); +} + +/** + * `parent_conversation_id` on the local start request landed in agent-server + * 1.37.1. Older servers ignore unknown fields, so the child is created without + * the link — report that rather than letting the agent assume a relationship + * that does not exist. + */ +function localParentLinkNote(): string | null { + const version = getCachedAgentServerVersion(); + if (!version) return null; + const comparison = compareAgentServerVersions( + version, + MIN_AGENT_SERVER_VERSION_FOR_PARENT_LINK, + ); + if (comparison === null || comparison >= 0) return null; + return `Agent server ${version} does not persist parent/child conversation links (needs ${MIN_AGENT_SERVER_VERSION_FOR_PARENT_LINK}); the child was created but is not linked to this conversation.`; +} + +/** + * Whether the parent's workspace can host a git worktree. + * + * A conversation started without a repository or an attached workspace runs in + * a scratch directory that is `git init`-ed but never committed to. `git + * worktree add` cannot branch from an unborn HEAD, and the agent-server raises + * the resulting error straight out of its start-conversation handler as a 500, + * so asking for a worktree there fails the whole launch. Conversation metadata + * is only persisted when a repository or an explicit workspace was chosen (see + * `createConversation`), so its absence pinpoints exactly those scratch + * workspaces. Anything else is attempted and falls back on failure, because the + * frontend cannot inspect the workspace's git state directly. + */ +const parentSupportsWorktree = ( + metadata: ConversationMetadata | null, +): boolean => !!(metadata?.selected_repository || metadata?.selected_workspace); + +const SHARED_FALLBACK_CONSEQUENCE = + "The child was launched in this conversation's directory instead, so it can see work in progress here and the two agents may conflict over the same files."; + +async function launchLocalChild( + params: ValidatedParams, + parentConversationId: string, +): Promise { + // The agent-server rejects a parent whose workspace differs from the child's, + // so the child requests the parent's own directory. `worktree` then carves an + // isolated worktree out of it, which is what keeps siblings from colliding. + const workspace = + await AgentServerConversationService.resolveConversationWorkingDir( + parentConversationId, + ); + const parentMetadata = getStoredConversationMetadata(parentConversationId); + + const createChild = (isolation: ChildConversationIsolation) => + AgentServerConversationService.createConversation( + params.task, + undefined, + undefined, + parentMetadata + ? { + selected_repository: parentMetadata.selected_repository, + selected_branch: parentMetadata.selected_branch, + git_provider: parentMetadata.git_provider, + } + : null, + workspace, + isolation === "shared" ? "local_repo" : "new_worktree", + parentConversationId, + ); + + let isolation = params.isolation; + let isolationNote: string | null = null; + + if (isolation === "worktree" && !parentSupportsWorktree(parentMetadata)) { + isolation = "shared"; + isolationNote = `This conversation's workspace is a scratch directory with no commits, which git cannot cut a worktree from. ${SHARED_FALLBACK_CONSEQUENCE}`; + } + + let startTask; + try { + startTask = await createChild(isolation); + } catch (worktreeError) { + if (isolation !== "worktree") throw worktreeError; + // Creating the worktree is the one part of a local launch that can fail on + // an otherwise healthy workspace, so retry without it rather than losing + // the launch. Anything else fails both attempts and is reported as-is. + try { + startTask = await createChild("shared"); + } catch { + throw worktreeError; + } + isolation = "shared"; + isolationNote = `Creating a git worktree in this conversation's workspace failed (${worktreeError instanceof Error ? worktreeError.message : String(worktreeError)}). ${SHARED_FALLBACK_CONSEQUENCE}`; + } + + const conversationId = startTask.app_conversation_id ?? startTask.id; + + if (params.title) { + // Local start requests carry no title — only `autotitle`. Best effort: a + // failed rename must not fail the launch. + await AgentServerConversationService.updateConversationTitle( + conversationId, + params.title, + ).catch(() => undefined); + } + + const parentLinkNote = localParentLinkNote(); + return { + status: "launched", + target: "local", + conversation_id: conversationId, + url: absoluteCanvasUrl(`/conversations/${conversationId}`), + initial_status: startTask.status, + title: params.title, + workspace, + isolation, + ...(isolationNote ? { isolation_note: isolationNote } : {}), + parent_link: !parentLinkNote, + ...(parentLinkNote ? { parent_link_note: parentLinkNote } : {}), + }; +} + +const delay = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +/** + * Wait for the Cloud start task to expose its conversation id. + * + * The Cloud API provisions a sandbox asynchronously and only fills in + * `app_conversation_id` once the task reaches READY, so without this the agent + * would get a result with no conversation to open. Bounded: on timeout the + * caller reports the still-provisioning task instead of hanging. + */ +async function waitForCloudConversationId( + task: AppConversationStartTask, + backend: Backend, +): Promise { + const deadline = Date.now() + CLOUD_START_POLL_TIMEOUT_MS; + let latest = task; + while (!latest.app_conversation_id && latest.status !== "ERROR") { + if (Date.now() >= deadline) break; + + await delay(CLOUD_START_POLL_INTERVAL_MS); + + const next = await getCloudAppConversationStartTask( + latest.id, + backend, + ).catch(() => null); + if (!next) break; + latest = next; + } + return latest; +} + +async function launchCloudChild( + params: ValidatedParams, + parentConversationId: string, +): Promise { + const backend = pickCloudBackendForLaunch(); + if (!backend) { + return failure( + "No OpenHands Cloud backend is connected in Agent Canvas.", + 'Ask the user to connect OpenHands Cloud from the backend picker, then call this tool again — or relaunch now with target="local".', + ); + } + + const parentMetadata = getStoredConversationMetadata(parentConversationId); + const startTask = await createCloudAppConversation( + { + initial_message: { + role: "user", + content: [{ type: "text", text: params.task }], + }, + title: params.title, + // Fall back to this conversation's repository so a bare `target: "cloud"` + // call still lands the child on the code the user is working on. The + // provider only travels with the inherited repository — it may not apply + // to one the agent named itself. + selected_repository: + params.repository ?? parentMetadata?.selected_repository ?? null, + selected_branch: params.branch ?? null, + git_provider: params.repository + ? null + : (parentMetadata?.git_provider ?? null), + // The parent runs on the local agent-server, so its id means nothing to + // Cloud. Sending it would only hide the child from the Cloud + // conversation list, which filters out anything with a parent. + parent_conversation_id: null, + }, + backend, + ); + + const settled = await waitForCloudConversationId(startTask, backend); + if (settled.status === "ERROR") { + return failure( + settled.detail || "The Cloud conversation failed to start.", + 'The Cloud sandbox could not be provisioned. Report this to the user; you can retry, or fall back to target="local".', + ); + } + + const conversationId = settled.app_conversation_id; + return { + status: "launched", + target: "cloud", + conversation_id: conversationId, + url: conversationId + ? `${backend.host.replace(/\/$/, "")}/conversations/${conversationId}` + : null, + initial_status: settled.status, + title: params.title, + start_task_id: settled.id, + backend: backend.name, + // Cloud children of a local parent carry no server-side link; see above. + parent_link: false, + parent_link_note: + "This conversation runs on the local agent server, so OpenHands Cloud has no parent to link the child to.", + }; +} + +/** + * Hand the outcome back to the agent and the user. + * + * Client tools are acknowledged by the agent-server before the browser does any + * work, so a message is the only way to give the agent the child's id or tell + * it how to fix a malformed call. `sendMessage` runs the agent, which is what + * makes it relay the result to the user in its next turn. + */ +async function reportLaunchResult( + parentConversationId: string, + result: LaunchChildConversationResult, +) { + if (result.status === "error") { + displayErrorToast( + i18n.t(I18nKey.CHILD_CONVERSATION$LAUNCH_FAILED, { + error: result.error, + }), + ); + } else if (result.url) { + displaySuccessToastWithLink( + i18n.t( + result.target === "cloud" + ? I18nKey.CHILD_CONVERSATION$LAUNCHED_CLOUD + : I18nKey.CHILD_CONVERSATION$LAUNCHED_LOCAL, + ), + i18n.t(I18nKey.CHILD_CONVERSATION$OPEN), + result.url, + ); + } + + // The agent-server cancels an active `/goal` loop on any inbound message, so + // a launch must not silently end one. The toast above still tells the user + // what happened. + const goalStatus = + useGoalStore.getState().statusByConversation[parentConversationId]; + if (goalStatus?.active) return; + + await AgentServerConversationService.sendMessage(parentConversationId, { + role: "user", + content: [ + { + type: "text", + text: `${CHILD_CONVERSATION_RESULT_PREFIX}${JSON.stringify(result)}`, + }, + ], + }); +} + +/** + * Execute a `launch_child_conversation` tool call. + * + * Never rejects: every failure is turned into corrective guidance for the + * agent, because the agent-server has already told it the call succeeded. + */ +export async function handleLaunchChildConversationAction( + action: LaunchChildConversationAction, + parentConversationId: string, + toolCallId: string, +): Promise { + if (!claimToolCall(parentConversationId, toolCallId)) return; + + const validation = validateLaunchParams(action); + let result: LaunchChildConversationResult; + if (!validation.ok) { + result = validation.failure; + } else { + try { + result = + validation.params.target === "cloud" + ? await launchCloudChild(validation.params, parentConversationId) + : await launchLocalChild(validation.params, parentConversationId); + } catch (error) { + result = failure( + error instanceof Error ? error.message : String(error), + "The launch request failed. Report the error to the user; retry only if the cause looks transient.", + ); + } + } + + await reportLaunchResult(parentConversationId, result).catch((error) => { + console.warn( + `[${LAUNCH_CHILD_CONVERSATION_TOOL_NAME}] Failed to report the launch result:`, + error, + ); + }); +} diff --git a/src/types/agent-server/core/base/action.ts b/src/types/agent-server/core/base/action.ts index a316bf584c..60a502db03 100644 --- a/src/types/agent-server/core/base/action.ts +++ b/src/types/agent-server/core/base/action.ts @@ -1,4 +1,5 @@ import { CANVAS_UI_CLIENT_ACTION_KIND } from "#/constants/canvas-ui"; +import { LAUNCH_CHILD_CONVERSATION_ACTION_KIND } from "#/constants/child-conversation"; import { ActionBase } from "./base"; import { TaskItem } from "./common"; @@ -321,6 +322,24 @@ export interface CanvasUIAction extends ActionBase< tab?: string | null; } +/** + * Request to launch a child conversation, emitted over the existing WebSocket + * and intercepted by handleLaunchChildConversationAction. The agent-server + * validates the parameter *names* and types against the tool schema but not + * `enum` values, so the fields stay loosely typed here and the dispatcher + * narrows them (returning corrective guidance on a mismatch). + */ +export interface LaunchChildConversationAction extends ActionBase< + typeof LAUNCH_CHILD_CONVERSATION_ACTION_KIND +> { + target: string; + task: string; + title?: string | null; + repository?: string | null; + branch?: string | null; + isolation?: string | null; +} + export type Action = | MCPToolAction | FinishAction @@ -346,4 +365,5 @@ export type Action = | InvokeSkillAction | TaskAction | SwitchLLMAction - | CanvasUIAction; + | CanvasUIAction + | LaunchChildConversationAction; diff --git a/src/types/agent-server/core/base/base.ts b/src/types/agent-server/core/base/base.ts index 891e105851..31eea2470b 100644 --- a/src/types/agent-server/core/base/base.ts +++ b/src/types/agent-server/core/base/base.ts @@ -1,4 +1,5 @@ import { CANVAS_UI_CLIENT_ACTION_KIND } from "#/constants/canvas-ui"; +import { LAUNCH_CHILD_CONVERSATION_ACTION_KIND } from "#/constants/child-conversation"; type EventType = | "MCPTool" @@ -37,7 +38,8 @@ type ActionEventType = | "GrepAction" // The `task` tool delegating work to a spawned subagent. | "TaskAction" - | typeof CANVAS_UI_CLIENT_ACTION_KIND; + | typeof CANVAS_UI_CLIENT_ACTION_KIND + | typeof LAUNCH_CHILD_CONVERSATION_ACTION_KIND; type ObservationEventType = | `${ObservationOnlyType}Observation` | `${EventType}Observation` diff --git a/src/types/agent-server/type-guards.ts b/src/types/agent-server/type-guards.ts index 542a9da400..65504665ba 100644 --- a/src/types/agent-server/type-guards.ts +++ b/src/types/agent-server/type-guards.ts @@ -2,6 +2,7 @@ import { CANVAS_UI_CLIENT_TOOL_NAME, LEGACY_CANVAS_UI_TOOL_NAME, } from "#/constants/canvas-ui"; +import { LAUNCH_CHILD_CONVERSATION_TOOL_NAME } from "#/constants/child-conversation"; import { OpenHandsEvent, @@ -16,6 +17,7 @@ import { BrowserNavigateAction, SwitchLLMObservation, CanvasUIAction, + LaunchChildConversationAction, } from "./core"; import { AgentErrorEvent } from "./core/events/observation-event"; import { MessageEvent } from "./core/events/message-event"; @@ -184,6 +186,18 @@ export const isCanvasUIActionEvent = ( (event.tool_name === LEGACY_CANVAS_UI_TOOL_NAME || event.tool_name === CANVAS_UI_CLIENT_TOOL_NAME); +/** + * Type guard for launch-child-conversation tool ActionEvents. + * + * Discriminates on tool_name, like `isCanvasUIActionEvent`, so the + * SDK-generated action kind stays contained in the constants module. + */ +export const isLaunchChildConversationActionEvent = ( + event: OpenHandsEvent, +): event is ActionEvent => + isActionEvent(event) && + event.tool_name === LAUNCH_CHILD_CONVERSATION_TOOL_NAME; + /** * Type guard function to check if an event is a system prompt event */