diff --git a/__tests__/components/conversation-events/chat/event-content-helpers/get-invoke-skill-items.test.ts b/__tests__/components/conversation-events/chat/event-content-helpers/get-invoke-skill-items.test.ts new file mode 100644 index 000000000..b67ebecb3 --- /dev/null +++ b/__tests__/components/conversation-events/chat/event-content-helpers/get-invoke-skill-items.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { getInvokeSkillItems } from "#/components/conversation-events/chat/event-content-helpers/get-invoke-skill-items"; +import { ObservationEvent } from "#/types/agent-server/core"; +import { InvokeSkillObservation } from "#/types/agent-server/core/base/observation"; + +const makeEvent = ( + observation: Partial, +): ObservationEvent => + ({ + id: "obs-skill", + timestamp: "2026-06-08T00:00:00.000Z", + source: "environment", + tool_name: "invoke_skill", + tool_call_id: "tool-skill", + action_id: "action-skill", + observation: { + kind: "InvokeSkillObservation", + skill_name: "worktree-switch", + content: [], + ...observation, + }, + }) as ObservationEvent; + +describe("getInvokeSkillItems", () => { + it("returns a single item with the skill name and joined text content", () => { + const items = getInvokeSkillItems( + makeEvent({ + skill_name: "worktree-switch", + content: [{ type: "text", text: "# Skill content" }], + }), + ); + + expect(items).toEqual([ + { name: "worktree-switch", content: "# Skill content" }, + ]); + }); + + it("joins multiple text blocks with newlines and trims", () => { + const items = getInvokeSkillItems( + makeEvent({ + skill_name: "docker", + content: [ + { type: "text", text: " line one" }, + { type: "text", text: "line two " }, + ], + }), + ); + + expect(items).toEqual([{ name: "docker", content: "line one\nline two" }]); + }); + + it("ignores non-text content blocks", () => { + const items = getInvokeSkillItems( + makeEvent({ + skill_name: "docker", + content: [ + { type: "image", image_urls: ["data:image/png;base64,abc"] }, + { type: "text", text: "only text survives" }, + ] as InvokeSkillObservation["content"], + }), + ); + + expect(items).toEqual([{ name: "docker", content: "only text survives" }]); + }); + + it("returns an empty array when there is no skill name and no content", () => { + const items = getInvokeSkillItems( + makeEvent({ skill_name: "", content: [] }), + ); + + expect(items).toEqual([]); + }); +}); diff --git a/__tests__/components/conversation-events/chat/event-content-helpers/get-observation-result.test.ts b/__tests__/components/conversation-events/chat/event-content-helpers/get-observation-result.test.ts index da4628ca7..40a23e5c3 100644 --- a/__tests__/components/conversation-events/chat/event-content-helpers/get-observation-result.test.ts +++ b/__tests__/components/conversation-events/chat/event-content-helpers/get-observation-result.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { getACPToolCallResult } from "#/components/conversation-events/chat/event-content-helpers/get-observation-result"; +import { + getACPToolCallResult, + getObservationResult, +} from "#/components/conversation-events/chat/event-content-helpers/get-observation-result"; import { ACPToolCallEvent } from "#/types/agent-server/core/events/acp-tool-call-event"; +import { ObservationEvent } from "#/types/agent-server/core"; const makeACPEvent = ( overrides: Partial = {}, @@ -50,3 +54,69 @@ describe("getACPToolCallResult", () => { expect(getACPToolCallResult(makeACPEvent({ status: null }))).toBeUndefined(); }); }); + +const makeObs = ( + observation: ObservationEvent["observation"], +): ObservationEvent => ({ + id: "obs-1", + timestamp: "2024-01-01T00:00:00Z", + source: "environment", + tool_name: "tool", + tool_call_id: "tc-1", + action_id: "act-1", + observation, +}); + +describe("getObservationResult", () => { + it("maps InvokeSkillObservation is_error → error, otherwise success", () => { + expect( + getObservationResult( + makeObs({ + kind: "InvokeSkillObservation", + skill_name: "s", + content: [], + is_error: true, + }), + ), + ).toBe("error"); + expect( + getObservationResult( + makeObs({ + kind: "InvokeSkillObservation", + skill_name: "s", + content: [], + is_error: false, + }), + ), + ).toBe("success"); + }); + + it("maps TaskObservation is_error or failed status → error, otherwise success", () => { + const task = (extra: { status: string; is_error?: boolean }) => + makeObs({ + kind: "TaskObservation", + content: [], + task_id: "t1", + subagent: "code-explorer", + ...extra, + }); + expect(getObservationResult(task({ status: "completed" }))).toBe("success"); + expect(getObservationResult(task({ status: "failed" }))).toBe("error"); + expect( + getObservationResult(task({ status: "completed", is_error: true })), + ).toBe("error"); + }); + + it("maps CanvasUIObservation is_error → error, otherwise success", () => { + expect( + getObservationResult( + makeObs({ kind: "CanvasUIObservation", content: [], is_error: true }), + ), + ).toBe("error"); + expect( + getObservationResult( + makeObs({ kind: "CanvasUIObservation", content: [], is_error: false }), + ), + ).toBe("success"); + }); +}); diff --git a/__tests__/components/conversation-events/chat/event-message-components/skill-ready-content-list.test.tsx b/__tests__/components/conversation-events/chat/event-message-components/skill-ready-content-list.test.tsx index e7c18518c..7ab2eedfc 100644 --- a/__tests__/components/conversation-events/chat/event-message-components/skill-ready-content-list.test.tsx +++ b/__tests__/components/conversation-events/chat/event-message-components/skill-ready-content-list.test.tsx @@ -2,6 +2,7 @@ import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, it, expect } from "vitest"; import { renderWithProviders } from "test-utils"; +import { I18nKey } from "#/i18n/declaration"; import { SkillReadyContentList } from "#/components/conversation-events/chat/event-message-components/skill-ready-content-list"; import { SkillReadyItem } from "#/components/conversation-events/chat/event-content-helpers/create-skill-ready-event"; @@ -30,6 +31,24 @@ describe("SkillReadyContentList", () => { ).toBeInTheDocument(); }); + it("renders a custom header label when titleKey is provided", () => { + const items = makeItems(["docker", "content"]); + + renderWithProviders( + , + ); + + expect( + screen.getByText("SKILLS$INVOKED_SKILL_KNOWLEDGE"), + ).toBeInTheDocument(); + expect( + screen.queryByText("SKILLS$TRIGGERED_SKILL_KNOWLEDGE"), + ).not.toBeInTheDocument(); + }); + it("does not show content before clicking", () => { const items = makeItems(["docker", "Docker usage guide"]); diff --git a/__tests__/components/conversation-events/get-event-content.test.tsx b/__tests__/components/conversation-events/get-event-content.test.tsx index f93fd7cab..f2b9c9472 100644 --- a/__tests__/components/conversation-events/get-event-content.test.tsx +++ b/__tests__/components/conversation-events/get-event-content.test.tsx @@ -116,7 +116,7 @@ describe("getEventContent", () => { ).not.toBeInTheDocument(); }); - it("returns empty details for file view action instead of 'Unknown event'", () => { + it("renders a file view action through the file-editor visualizer", () => { const fileViewAction: ActionEvent = { id: "action-2", timestamp: new Date().toISOString(), @@ -151,7 +151,11 @@ describe("getEventContent", () => { render({title}); expect(screen.getByText("ACTION_MESSAGE$READ")).toBeInTheDocument(); - expect(details).toBe(""); + // FileEditor is now migrated to a React visualizer: details is a node that + // renders the file-path chip rather than the old empty markdown string. + expect(typeof details).not.toBe("string"); + render(
{details}
); + expect(screen.getByText("/workspace/README.md")).toBeInTheDocument(); }); it("shows action kind for action-like events missing tool_name/tool_call_id", () => { @@ -241,4 +245,85 @@ describe("getEventContent", () => { expect(details).toContain("worktree-switch"); expect(details).toContain("# Skill content"); }); + + it("titles a TaskAction with the subagent and shows the query", () => { + const taskAction: ActionEvent = { + id: "act-task", + timestamp: new Date().toISOString(), + source: "agent", + thought: [], + thinking_blocks: [], + tool_name: "task", + tool_call_id: "tool-task", + action: { + kind: "TaskAction", + prompt: "Summarize the README", + subagent_type: "code-explorer", + }, + } as unknown as ActionEvent; + + const { title } = getEventContent(taskAction); + + render({title}); + expect(screen.getByText("ACTION_MESSAGE$TASK")).toBeInTheDocument(); + expect(screen.queryByText("TASK")).not.toBeInTheDocument(); + }); + + it("titles a TaskObservation with the subagent", () => { + const taskObservation: ObservationEvent = { + id: "obs-task", + timestamp: new Date().toISOString(), + source: "environment", + tool_name: "task", + tool_call_id: "tool-task", + action_id: "act-task", + observation: { + kind: "TaskObservation", + content: [{ type: "text", text: "All done." }], + is_error: false, + task_id: "task_00000001", + subagent: "code-explorer", + status: "completed", + }, + }; + + const { title } = getEventContent(taskObservation); + + render({title}); + expect(screen.getByText("OBSERVATION_MESSAGE$TASK")).toBeInTheDocument(); + // The body is rendered by the task visualizer (covered in task.test.tsx), + // so only the title is asserted here. + }); + + it("renders CanvasUIObservation as just its acknowledgement text", () => { + const canvasUIObservation: ObservationEvent = { + id: "obs-canvas", + timestamp: new Date().toISOString(), + source: "environment", + tool_name: "canvas_ui", + tool_call_id: "tool-canvas", + action_id: "action-canvas", + observation: { + kind: "CanvasUIObservation", + content: [ + { + type: "text", + text: "UI command 'open_tab' dispatched to the Agent Canvas frontend.", + }, + ], + is_error: false, + }, + }; + + const { title, details } = getEventContent(canvasUIObservation); + + render({title}); + expect( + screen.getByText("OBSERVATION_MESSAGE$CANVAS_UI"), + ).toBeInTheDocument(); + // The body is exactly the acknowledgement text, not a JSON dump. + expect(details).toBe( + "UI command 'open_tab' dispatched to the Agent Canvas frontend.", + ); + }); }); diff --git a/__tests__/components/features/chat/tool-visualizers/bash/__snapshots__/bash.test.tsx.snap b/__tests__/components/features/chat/tool-visualizers/bash/__snapshots__/bash.test.tsx.snap new file mode 100644 index 000000000..459cfa027 --- /dev/null +++ b/__tests__/components/features/chat/tool-visualizers/bash/__snapshots__/bash.test.tsx.snap @@ -0,0 +1,89 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`bashVisualizer > matches snapshot 1`] = ` +
+
+
+ +
+
+ + + ls + + +
+
+
+
+
+ +
+
+        ok
+      
+
+
+
+`; diff --git a/__tests__/components/features/chat/tool-visualizers/bash/bash.test.tsx b/__tests__/components/features/chat/tool-visualizers/bash/bash.test.tsx new file mode 100644 index 000000000..e6424ac25 --- /dev/null +++ b/__tests__/components/features/chat/tool-visualizers/bash/bash.test.tsx @@ -0,0 +1,82 @@ +import { describe, it, expect } from "vitest"; +import { screen } from "@testing-library/react"; +import { SecurityRisk } from "#/types/agent-server/core"; +import { bashVisualizer } from "#/components/features/chat/tool-visualizers/bash/bash"; +import { + renderVisualizer, + bashAction, + bashObservation, + terminalObservation, +} from "../test-utils"; + +const Body = bashVisualizer.Body; + +describe("bashVisualizer", () => { + it("renders the command in the action card", () => { + const { container } = renderVisualizer( + , + ); + expect(container).toHaveTextContent("echo hello"); + }); + + it("warns about high-risk actions", () => { + renderVisualizer( + , + ); + expect(screen.getByText("SECURITY$HIGH_RISK")).toBeInTheDocument(); + }); + + it("shows output without an exit badge on success", () => { + const { container } = renderVisualizer( + , + ); + expect(container).toHaveTextContent("hello world"); + expect(screen.queryByText("OBSERVATION$EXIT_CODE")).not.toBeInTheDocument(); + }); + + it("badges a non-zero exit code (error state)", () => { + renderVisualizer(); + expect(screen.getByText("OBSERVATION$EXIT_CODE")).toBeInTheDocument(); + expect(screen.getByText("boom")).toBeInTheDocument(); + }); + + it("shows a placeholder when there is no output", () => { + renderVisualizer(); + expect( + screen.getByText("OBSERVATION$COMMAND_NO_OUTPUT"), + ).toBeInTheDocument(); + }); + + it("exposes a copy button for the command and the output", () => { + renderVisualizer( + , + ); + // One for the command code block, one for the output pane. + expect(screen.getAllByTestId("copy-to-clipboard")).toHaveLength(2); + }); + + it("has no copy button when there is no output to copy", () => { + renderVisualizer(); + // Only the command's copy button remains. + expect(screen.getAllByTestId("copy-to-clipboard")).toHaveLength(1); + }); + + it("renders command and output for the terminal tool", () => { + const { container } = renderVisualizer( + , + ); + expect(container).toHaveTextContent("wc -l"); + expect(container).toHaveTextContent("362 index.html"); + }); + + it("matches snapshot", () => { + const { container } = renderVisualizer( + , + ); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/__tests__/components/features/chat/tool-visualizers/dispatcher.test.tsx b/__tests__/components/features/chat/tool-visualizers/dispatcher.test.tsx new file mode 100644 index 000000000..5ce20c529 --- /dev/null +++ b/__tests__/components/features/chat/tool-visualizers/dispatcher.test.tsx @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { ActionEvent, SecurityRisk } from "#/types/agent-server/core"; +import { resolveVisualizerBody } from "#/components/features/chat/tool-visualizers/dispatcher"; +import { bashAction, grepObservation, terminalObservation } from "./test-utils"; + +describe("resolveVisualizerBody", () => { + it("returns a body for a migrated action kind", () => { + expect(resolveVisualizerBody(bashAction("ls"))).not.toBeNull(); + }); + + it("returns a body for a migrated observation kind", () => { + expect( + resolveVisualizerBody(grepObservation({ pattern: "x" })), + ).not.toBeNull(); + }); + + it("returns a body for the terminal tool (shares the bash visualizer)", () => { + expect(resolveVisualizerBody(terminalObservation("ok", 0))).not.toBeNull(); + }); + + it("returns null for an unmigrated tool so the markdown fallback runs", () => { + const thinkAction: ActionEvent = { + id: "t1", + timestamp: "2024-01-01T00:00:00Z", + source: "agent", + thought: [], + thinking_blocks: [], + action: { kind: "ThinkAction", thought: "hmm" }, + tool_name: "think", + tool_call_id: "c1", + tool_call: { + id: "c1", + type: "function", + function: { name: "think", arguments: "{}" }, + }, + llm_response_id: "r1", + security_risk: SecurityRisk.LOW, + }; + expect(resolveVisualizerBody(thinkAction)).toBeNull(); + }); +}); diff --git a/__tests__/components/features/chat/tool-visualizers/file-editor/__snapshots__/file-editor.test.tsx.snap b/__tests__/components/features/chat/tool-visualizers/file-editor/__snapshots__/file-editor.test.tsx.snap new file mode 100644 index 000000000..3b539c008 --- /dev/null +++ b/__tests__/components/features/chat/tool-visualizers/file-editor/__snapshots__/file-editor.test.tsx.snap @@ -0,0 +1,55 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`fileEditorVisualizer > matches snapshot for a diff 1`] = ` +
+ + + + + + /workspace/app.ts + + +
+
+
+ a +
+
+ - b +
+
+ + c +
+
+
+
+`; diff --git a/__tests__/components/features/chat/tool-visualizers/file-editor/file-editor.test.tsx b/__tests__/components/features/chat/tool-visualizers/file-editor/file-editor.test.tsx new file mode 100644 index 000000000..dc6b12be0 --- /dev/null +++ b/__tests__/components/features/chat/tool-visualizers/file-editor/file-editor.test.tsx @@ -0,0 +1,155 @@ +import { describe, it, expect } from "vitest"; +import { screen } from "@testing-library/react"; +import { fileEditorVisualizer } from "#/components/features/chat/tool-visualizers/file-editor/file-editor"; +import { + renderVisualizer, + fileEditorAction, + fileEditorObservation, +} from "../test-utils"; + +const Body = fileEditorVisualizer.Body; + +describe("fileEditorVisualizer", () => { + it("shows path and content for a create action", () => { + const { container } = renderVisualizer( + , + ); + expect(container).toHaveTextContent("/workspace/app.ts"); + expect(container).toHaveTextContent("const x = 1;"); + }); + + it("shows the path with a line range for a view action", () => { + const { container } = renderVisualizer( + , + ); + expect(container).toHaveTextContent("/workspace/app.ts:1-10"); + }); + + it("shows the file snippet the agent saw for a view observation", () => { + const { container } = renderVisualizer( + , + ); + expect(container).toHaveTextContent("const x = 1;"); + }); + + it("renders a diff for an edit observation", () => { + const { container } = renderVisualizer( + , + ); + expect(container).toHaveTextContent("- OLD"); + expect(container).toHaveTextContent("+ NEW"); + }); + + it("renders a diff when clearing a file (new_content is an empty string)", () => { + const { container } = renderVisualizer( + , + ); + // The empty `new_content` must not short-circuit the diff to the fallback. + expect(container).toHaveTextContent("- keep"); + expect(container).toHaveTextContent("- remove me"); + }); + + it("renders a diff when inserting into an empty file (old_content is an empty string)", () => { + const { container } = renderVisualizer( + , + ); + expect(container).toHaveTextContent("+ first line"); + expect(container).toHaveTextContent("+ second line"); + }); + + it("renders the inserted text for an in-flight insert action (no old_str)", () => { + const { container } = renderVisualizer( + , + ); + // Inserts carry `new_str` only; the card must show it, not just the path. + expect(container).toHaveTextContent("/workspace/app.ts"); + expect(container).toHaveTextContent("+ inserted line"); + }); + + it("renders a diff for an in-flight str_replace action", () => { + const { container } = renderVisualizer( + , + ); + expect(container).toHaveTextContent("- OLD"); + expect(container).toHaveTextContent("+ NEW"); + }); + + it("renders the error message for a failed edit (error state)", () => { + renderVisualizer( + , + ); + expect(screen.getByText("No replacement performed")).toBeInTheDocument(); + }); + + it("matches snapshot for a diff", () => { + const { container } = renderVisualizer( + , + ); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/__tests__/components/features/chat/tool-visualizers/search/__snapshots__/search.test.tsx.snap b/__tests__/components/features/chat/tool-visualizers/search/__snapshots__/search.test.tsx.snap new file mode 100644 index 000000000..6ceffce03 --- /dev/null +++ b/__tests__/components/features/chat/tool-visualizers/search/__snapshots__/search.test.tsx.snap @@ -0,0 +1,50 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`searchVisualizer > matches snapshot 1`] = ` +
+
+ + COMMON$PATTERN + + + TODO + + + COMMON$PATH + + + /workspace + +
+
+ + COMMON$RESULTS + +
+ + a.ts + +
+
+
+`; diff --git a/__tests__/components/features/chat/tool-visualizers/search/search.test.tsx b/__tests__/components/features/chat/tool-visualizers/search/search.test.tsx new file mode 100644 index 000000000..93bc8a0df --- /dev/null +++ b/__tests__/components/features/chat/tool-visualizers/search/search.test.tsx @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; +import { screen } from "@testing-library/react"; +import { searchVisualizer } from "#/components/features/chat/tool-visualizers/search/search"; +import { + renderVisualizer, + grepAction, + grepObservation, + globObservation, +} from "../test-utils"; + +const Body = searchVisualizer.Body; + +describe("searchVisualizer", () => { + it("shows search params on the action card", () => { + const { container } = renderVisualizer( + , + ); + expect(screen.getByText("COMMON$PATTERN")).toBeInTheDocument(); + expect(container).toHaveTextContent("TODO"); + expect(container).toHaveTextContent("*.ts"); + }); + + it("lists matches with a count for a grep observation", () => { + renderVisualizer( + , + ); + expect(screen.getByText("COMMON$RESULTS")).toBeInTheDocument(); + expect(screen.getByText("a.ts")).toBeInTheDocument(); + expect(screen.getByText("b.ts")).toBeInTheDocument(); + }); + + it("shows a no-results message when empty", () => { + renderVisualizer( + , + ); + expect(screen.getByText("COMMON$NO_RESULTS")).toBeInTheDocument(); + }); + + it("renders the error text when the search failed (error state)", () => { + renderVisualizer( + , + ); + expect(screen.getByText("invalid regex")).toBeInTheDocument(); + }); + + it("flags truncated glob results", () => { + renderVisualizer( + , + ); + expect(screen.getByText("COMMON$TRUNCATED")).toBeInTheDocument(); + }); + + it("matches snapshot", () => { + const { container } = renderVisualizer( + , + ); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/__tests__/components/features/chat/tool-visualizers/task/task.test.tsx b/__tests__/components/features/chat/tool-visualizers/task/task.test.tsx new file mode 100644 index 000000000..e9d1e2cee --- /dev/null +++ b/__tests__/components/features/chat/tool-visualizers/task/task.test.tsx @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import { screen } from "@testing-library/react"; +import { taskVisualizer } from "#/components/features/chat/tool-visualizers/task/task"; +import { renderVisualizer, taskAction, taskObservation } from "../test-utils"; + +const Body = taskVisualizer.Body; + +describe("taskVisualizer", () => { + it("shows the subagent and query while the observation is pending", () => { + renderVisualizer( + , + ); + // Subagent comes from the action; no task id / result yet. + expect(screen.getByText("TASK$SUBAGENT")).toBeInTheDocument(); + expect(screen.getByText("code-explorer")).toBeInTheDocument(); + expect(screen.getByText("TASK$QUERY")).toBeInTheDocument(); + expect(screen.getByText("Summarize the README")).toBeInTheDocument(); + expect(screen.queryByText("TASK$TASK_ID")).not.toBeInTheDocument(); + expect(screen.queryByText("TASK$RESULT")).not.toBeInTheDocument(); + }); + + it("keeps showing the query alongside the result once both are present", () => { + renderVisualizer( + , + ); + expect(screen.getByText("TASK$QUERY")).toBeInTheDocument(); + expect(screen.getByText("Summarize the README")).toBeInTheDocument(); + expect(screen.getByText("TASK$RESULT")).toBeInTheDocument(); + expect(screen.getByText("All done.")).toBeInTheDocument(); + }); + + it("shows the subagent and task id", () => { + renderVisualizer( + , + ); + expect(screen.getByText("TASK$SUBAGENT")).toBeInTheDocument(); + expect(screen.getByText("code-explorer")).toBeInTheDocument(); + expect(screen.getByText("TASK$TASK_ID")).toBeInTheDocument(); + expect(screen.getByText("task_00000001")).toBeInTheDocument(); + }); + + it("renders the answer as formatted markdown", () => { + const { container } = renderVisualizer( + , + ); + expect(screen.getByText("TASK$RESULT")).toBeInTheDocument(); + // Markdown headings render as real heading elements, not raw "## ..." text. + const heading = container.querySelector("h2"); + expect(heading).toHaveTextContent("README Summary"); + expect(container).toHaveTextContent("Licensed under Apache."); + }); + + it("omits the result section when there is no text content", () => { + renderVisualizer(); + expect(screen.queryByText("TASK$RESULT")).not.toBeInTheDocument(); + }); + + it("exposes a copy button for the result", () => { + renderVisualizer( + , + ); + expect(screen.getByTestId("copy-to-clipboard")).toBeInTheDocument(); + }); +}); diff --git a/__tests__/components/features/chat/tool-visualizers/test-utils.tsx b/__tests__/components/features/chat/tool-visualizers/test-utils.tsx new file mode 100644 index 000000000..f6be38ff0 --- /dev/null +++ b/__tests__/components/features/chat/tool-visualizers/test-utils.tsx @@ -0,0 +1,217 @@ +import React from "react"; +import { renderWithProviders } from "test-utils"; +import { + ActionEvent, + ObservationEvent, + SecurityRisk, +} from "#/types/agent-server/core"; +import { + ExecuteBashAction, + FileEditorAction, + GrepAction, + TaskAction, +} from "#/types/agent-server/core/base/action"; +import { + ExecuteBashObservation, + FileEditorObservation, + GrepObservation, + GlobObservation, + TerminalObservation, + TaskObservation, +} from "#/types/agent-server/core/base/observation"; + +/** Renders a visualizer body with the standard chat providers. */ +export const renderVisualizer = (ui: React.ReactElement) => + renderWithProviders(ui); + +const actionEnvelope = (id: string, toolName: string) => ({ + id, + timestamp: "2024-01-01T00:00:00Z", + source: "agent" as const, + thought: [], + thinking_blocks: [], + tool_name: toolName, + tool_call_id: `call_${id}`, + tool_call: { + id: `call_${id}`, + type: "function" as const, + function: { name: toolName, arguments: "{}" }, + }, + llm_response_id: `resp_${id}`, + security_risk: SecurityRisk.LOW, +}); + +const observationEnvelope = (id: string, toolName: string) => ({ + id, + timestamp: "2024-01-01T00:00:00Z", + source: "environment" as const, + tool_name: toolName, + tool_call_id: `call_${id}`, + action_id: `action_${id}`, +}); + +export const bashAction = ( + command: string, + risk: SecurityRisk = SecurityRisk.LOW, +): ActionEvent => ({ + ...actionEnvelope("bash", "execute_bash"), + security_risk: risk, + action: { + kind: "ExecuteBashAction", + command, + is_input: false, + timeout: null, + reset: false, + }, +}); + +export const bashObservation = ( + output: string, + exitCode: number | null, + command = "echo hi", +): ObservationEvent => ({ + ...observationEnvelope("bash", "execute_bash"), + observation: { + kind: "ExecuteBashObservation", + content: [{ type: "text", text: output }], + command, + exit_code: exitCode, + error: exitCode !== 0, + timeout: false, + metadata: { + exit_code: exitCode ?? 0, + pid: 1, + username: "openhands", + hostname: "runtime", + prefix: "", + suffix: "", + working_dir: "/workspace", + py_interpreter_path: null, + }, + }, +}); + +export const terminalObservation = ( + output: string, + exitCode: number | null, + command = "ls", +): ObservationEvent => ({ + ...observationEnvelope("terminal", "terminal"), + observation: { + kind: "TerminalObservation", + content: [{ type: "text", text: output }], + command, + exit_code: exitCode, + is_error: exitCode !== 0, + timeout: false, + metadata: { + exit_code: exitCode ?? 0, + pid: 1, + username: "openhands", + hostname: "runtime", + prefix: "", + suffix: "", + working_dir: "/workspace", + py_interpreter_path: null, + }, + }, +}); + +export const fileEditorAction = ( + action: Partial & + Pick, +): ActionEvent => ({ + ...actionEnvelope("fe", "file_editor"), + action: { + kind: "FileEditorAction", + file_text: null, + old_str: null, + new_str: null, + insert_line: null, + view_range: null, + ...action, + }, +}); + +export const fileEditorObservation = ( + observation: Partial & + Pick, +): ObservationEvent => ({ + ...observationEnvelope("fe", "file_editor"), + observation: { + kind: "FileEditorObservation", + output: "", + path: "/workspace/app.ts", + prev_exist: true, + old_content: null, + new_content: null, + error: null, + ...observation, + }, +}); + +export const grepAction = ( + action: Partial & Pick, +): ActionEvent => ({ + ...actionEnvelope("grep", "grep"), + action: { kind: "GrepAction", path: null, include: null, ...action }, +}); + +export const grepObservation = ( + observation: Partial & Pick, +): ObservationEvent => ({ + ...observationEnvelope("grep", "grep"), + observation: { + kind: "GrepObservation", + content: [], + is_error: false, + matches: [], + search_path: "/workspace", + include_pattern: null, + truncated: false, + ...observation, + }, +}); + +export const globObservation = ( + observation: Partial & Pick, +): ObservationEvent => ({ + ...observationEnvelope("glob", "glob"), + observation: { + kind: "GlobObservation", + content: [], + is_error: false, + files: [], + search_path: "/workspace", + truncated: false, + ...observation, + }, +}); + +export const taskAction = ( + action: Partial & Pick, +): ActionEvent => ({ + ...actionEnvelope("task", "task"), + action: { + kind: "TaskAction", + subagent_type: "code-explorer", + description: null, + resume: null, + ...action, + }, +}); + +export const taskObservation = ( + observation: Partial = {}, +): ObservationEvent => ({ + ...observationEnvelope("task", "task"), + observation: { + kind: "TaskObservation", + content: [{ type: "text", text: "## Summary\n\nAll done." }], + is_error: false, + task_id: "task_00000001", + subagent: "code-explorer", + status: "completed", + ...observation, + }, +}); diff --git a/__tests__/contexts/conversation-websocket-context.test.tsx b/__tests__/contexts/conversation-websocket-context.test.tsx index 9e934db02..462212b33 100644 --- a/__tests__/contexts/conversation-websocket-context.test.tsx +++ b/__tests__/contexts/conversation-websocket-context.test.tsx @@ -8,13 +8,31 @@ import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message- import { useBrowserStore } from "#/stores/browser-store"; import { useUserConversation } from "#/hooks/query/use-user-conversation"; import EventService from "#/api/event-service/event-service.api"; +import { getStoredConversationMetadata } from "#/api/conversation-metadata-store"; import type { MessageEvent } from "#/types/agent-server/core"; +// Captures the main socket's `onMessage` (`handleMainMessage`) so tests can +// drive the live message path without a real WebSocket. Only the main socket +// gets a non-empty url (planning stays ""), so url presence discriminates it. +const wsCapture = vi.hoisted(() => ({ + mainOnMessage: null as null | ((event: { data: string }) => void), +})); + // Keep the units under test real (the provider, `useConversationHistory`, the // event store). Only the network is stubbed: the WebSocket transport and the // REST service the history query depends on. vi.mock("#/hooks/use-websocket", () => ({ - useWebSocket: vi.fn(() => ({ socket: null, reconnect: vi.fn() })), + useWebSocket: vi.fn( + ( + url: string, + options?: { onMessage?: (event: { data: string }) => void }, + ) => { + if (url && options?.onMessage) { + wsCapture.mainOnMessage = options.onMessage; + } + return { socket: null, reconnect: vi.fn() }; + }, + ), })); vi.mock("#/hooks/query/use-user-conversation", () => ({ useUserConversation: vi.fn(), @@ -52,6 +70,8 @@ describe("ConversationWebSocketProvider — conversation-scoped event store", () ); beforeEach(() => { + wsCapture.mainOnMessage = null; + window.localStorage.clear(); queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); @@ -81,6 +101,56 @@ describe("ConversationWebSocketProvider — conversation-scoped event store", () afterEach(() => { vi.clearAllMocks(); + window.localStorage.clear(); + }); + + // A successful model switch the agent performed on its own (via the + // SwitchLLM tool), delivered over the main WebSocket. + const makeAgentSwitchObservation = (profileName: string) => ({ + id: "evt-switch-1", + timestamp: new Date().toISOString(), + source: "environment", + action_id: "action-switch-1", + tool_name: "switch_llm", + tool_call_id: "call-switch-1", + observation: { + kind: "SwitchLLMObservation", + content: [{ type: "text", text: `Switched to ${profileName}` }], + is_error: false, + profile_name: profileName, + reason: null, + active_model: null, + }, + }); + + it("stamps active_profile on a successful agent-triggered model switch so it survives reload", async () => { + // Arrange: open a conversation with a real ws url so the main socket's + // onMessage (handleMainMessage) is wired and captured. + render( + + +
+ + , + ); + await waitFor(() => expect(wsCapture.mainOnMessage).not.toBeNull()); + + // Act: the agent switches to "fast-opus" via the SwitchLLM tool. + act(() => { + wsCapture.mainOnMessage!({ + data: JSON.stringify(makeAgentSwitchObservation("fast-opus")), + }); + }); + + // Assert: the profile identity is persisted to stored metadata — the same + // field the chat-header switcher reads after a reload (#1082). Without the + // stamp this stays null and the header falls back to ambiguous matching. + expect(getStoredConversationMetadata("conv-switch")?.active_profile).toBe( + "fast-opus", + ); }); it("clears the previous conversation's events when switching conversations", async () => { diff --git a/__tests__/hooks/use-load-older-events.test.tsx b/__tests__/hooks/use-load-older-events.test.tsx index 87df5d58c..d6921b7d9 100644 --- a/__tests__/hooks/use-load-older-events.test.tsx +++ b/__tests__/hooks/use-load-older-events.test.tsx @@ -8,6 +8,7 @@ import EventService from "#/api/event-service/event-service.api"; import { useUserConversation } from "#/hooks/query/use-user-conversation"; import { useConversationHistory } from "#/hooks/query/use-conversation-history"; import { useEventStore } from "#/stores/use-event-store"; +import { useModelStore } from "#/stores/model-store"; import { INITIAL_HISTORY_PAGE_SIZE } from "#/hooks/query/use-conversation-history"; import type { Conversation } from "#/api/open-hands.types"; import type { OpenHandsEvent } from "#/types/agent-server/core"; @@ -78,6 +79,7 @@ describe("useLoadOlderEvents", () => { // Reset event store between tests so prior tests don't leak state. act(() => { useEventStore.getState().clearEvents(); + useModelStore.getState().clearAll(); }); vi.mocked(useUserConversation).mockReturnValue({ @@ -161,6 +163,68 @@ describe("useLoadOlderEvents", () => { }); }); + it("seeds inline model-switch messages for switches in a paginated older page", async () => { + // A successful SwitchLLMObservation is hidden as a card and surfaced via an + // inline "Switched to" message seeded from history. The initial preload only + // seeds the tail page; a switch in an older page must be seeded when that + // page paginates in, or it vanishes from the transcript entirely. + const recent = makeEvent("evt-recent", "2024-06-01T00:00:00Z"); + act(() => { + useEventStore.getState().addEvent(recent); + }); + + // Older page (server returns TIMESTAMP_DESC; the hook reverses it): a user + // message followed by the agent's successful model switch. + const userMsg = { + id: "evt-user-old", + timestamp: "2024-05-01T00:00:00Z", + source: "user", + llm_message: { role: "user", content: [{ type: "text", text: "hi" }] }, + activated_microagents: [], + extended_content: [], + } as unknown as OpenHandsEvent; + const switchObs = { + id: "evt-switch-old", + timestamp: "2024-05-02T00:00:00Z", + source: "environment", + action_id: "action-switch-old", + tool_name: "switch_llm", + tool_call_id: "call-switch-old", + observation: { + kind: "SwitchLLMObservation", + content: [{ type: "text", text: "Switched to fast-opus" }], + is_error: false, + profile_name: "fast-opus", + reason: null, + active_model: null, + }, + } as unknown as OpenHandsEvent; + + vi.spyOn(EventService, "searchEvents").mockResolvedValue( + // Descending order, as the server returns it. + makePage([switchObs, userMsg], null), + ); + + const { result } = renderHook(() => useLoadOlderEvents("conv-1"), { + wrapper, + }); + + await act(async () => { + await result.current.loadOlder(); + }); + + // The switch is seeded (idempotent id derived from the observation event), + // anchored to the renderable user message that precedes it. + const entries = useModelStore.getState().entriesByConversation["conv-1"]; + expect(entries).toEqual([ + expect.objectContaining({ + id: "history-switch:evt-switch-old", + switchedTo: "fast-opus", + anchorEventId: "evt-user-old", + }), + ]); + }); + it("keeps paginating while the server keeps returning full pages", async () => { act(() => { useEventStore 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 8aee37b3a..688687a8d 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 @@ -22,6 +22,7 @@ import { import { TaskTrackingObservationContent } from "../task-tracking/task-tracking-observation-content"; import { TaskTrackerObservation } from "#/types/agent-server/core/base/observation"; import { SkillReadyEvent, isSkillReadyEvent } from "./create-skill-ready-event"; +import { resolveVisualizerBody } from "../../../features/chat/tool-visualizers/dispatcher"; import i18n from "#/i18n"; import { I18nKey } from "#/i18n/declaration"; @@ -122,6 +123,12 @@ const getActionEventTitle = (event: OpenHandsEvent): React.ReactNode => { name: event.action.name, }; break; + case "TaskAction": + actionKey = "ACTION_MESSAGE$TASK"; + actionValues = { + name: event.action.subagent_type, + }; + break; case "ThinkAction": actionKey = "ACTION_MESSAGE$THINK"; break; @@ -227,6 +234,15 @@ const getObservationEventTitle = ( name: event.observation.skill_name, }; break; + case "TaskObservation": + observationKey = "OBSERVATION_MESSAGE$TASK"; + observationValues = { + name: event.observation.subagent, + }; + break; + case "CanvasUIObservation": + observationKey = "OBSERVATION_MESSAGE$CANVAS_UI"; + break; case "SwitchLLMObservation": observationKey = event.observation.is_error ? "MODEL$SWITCH_FAILED" @@ -298,7 +314,8 @@ export const getEventContent = ( details = event._skillReadyContent; } else if (isActionEvent(event)) { title = getActionEventTitle(event); - details = getActionContent(event); + // Per-tool React visualizer when one is registered; markdown otherwise. + details = resolveVisualizerBody(event) ?? getActionContent(event); } else if (isObservationEvent(event)) { title = getObservationEventTitle(event, correspondingAction); @@ -310,7 +327,9 @@ export const getEventContent = ( /> ); } else { - details = getObservationContent(event); + details = + resolveVisualizerBody(event, correspondingAction) ?? + getObservationContent(event); } } else if (isACPToolCallEvent(event)) { // ACP sub-agent tool calls reuse the same card shape as observations: diff --git a/src/components/conversation-events/chat/event-content-helpers/get-invoke-skill-items.ts b/src/components/conversation-events/chat/event-content-helpers/get-invoke-skill-items.ts new file mode 100644 index 000000000..855cc775f --- /dev/null +++ b/src/components/conversation-events/chat/event-content-helpers/get-invoke-skill-items.ts @@ -0,0 +1,28 @@ +import { ObservationEvent } from "#/types/agent-server/core"; +import { InvokeSkillObservation } from "#/types/agent-server/core/base/observation"; +import { SkillReadyItem } from "./get-skill-ready-content"; + +/** + * Maps an InvokeSkillObservation to the SkillReadyItem shape so invoke-skill + * tool calls can reuse the Skill Ready expandable list. A single skill is + * invoked per observation, so this yields at most one item. Returns an empty + * array when there is neither a skill name nor any text content to show, which + * tells the caller to fall back to the default markdown body. + */ +export const getInvokeSkillItems = ( + event: ObservationEvent, +): SkillReadyItem[] => { + const { observation } = event; + + const content = observation.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n") + .trim(); + + if (!observation.skill_name && !content) { + return []; + } + + return [{ name: observation.skill_name, content }]; +}; diff --git a/src/components/conversation-events/chat/event-content-helpers/get-observation-content.ts b/src/components/conversation-events/chat/event-content-helpers/get-observation-content.ts index dd6ddaa19..e209bb664 100644 --- a/src/components/conversation-events/chat/event-content-helpers/get-observation-content.ts +++ b/src/components/conversation-events/chat/event-content-helpers/get-observation-content.ts @@ -16,6 +16,7 @@ import { GlobObservation, GrepObservation, InvokeSkillObservation, + CanvasUIObservation, SwitchLLMObservation, } from "#/types/agent-server/core/base/observation"; @@ -173,6 +174,15 @@ const getInvokeSkillObservationContent = ( return content; }; +// Canvas UI observations — just surface the acknowledgement text. +const getCanvasUIObservationContent = ( + event: ObservationEvent, +): string => + event.observation.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); + const getSwitchLLMObservationContent = ( event: ObservationEvent, ): string => { @@ -402,6 +412,11 @@ export const getObservationContent = (event: ObservationEvent): string => { event as ObservationEvent, ); + case "CanvasUIObservation": + return getCanvasUIObservationContent( + event as ObservationEvent, + ); + case "SwitchLLMObservation": return getSwitchLLMObservationContent( event as ObservationEvent, diff --git a/src/components/conversation-events/chat/event-content-helpers/get-observation-result.ts b/src/components/conversation-events/chat/event-content-helpers/get-observation-result.ts index 860181592..0ac7a1eb1 100644 --- a/src/components/conversation-events/chat/event-content-helpers/get-observation-result.ts +++ b/src/components/conversation-events/chat/event-content-helpers/get-observation-result.ts @@ -54,6 +54,16 @@ export const getObservationResult = ( case "SwitchLLMObservation": if (observation.is_error) return "error"; return "success"; + case "InvokeSkillObservation": + if (observation.is_error) return "error"; + return "success"; + case "TaskObservation": + if (observation.is_error || observation.status === "failed") + return "error"; + return "success"; + case "CanvasUIObservation": + if (observation.is_error) return "error"; + return "success"; default: return "success"; } diff --git a/src/components/conversation-events/chat/event-message-components/generic-event-message-wrapper.tsx b/src/components/conversation-events/chat/event-message-components/generic-event-message-wrapper.tsx index b02837f73..6dd0c02f0 100644 --- a/src/components/conversation-events/chat/event-message-components/generic-event-message-wrapper.tsx +++ b/src/components/conversation-events/chat/event-message-components/generic-event-message-wrapper.tsx @@ -1,4 +1,10 @@ -import { OpenHandsEvent, ActionEvent } from "#/types/agent-server/core"; +import { + OpenHandsEvent, + ActionEvent, + ObservationEvent, +} from "#/types/agent-server/core"; +import { InvokeSkillObservation } from "#/types/agent-server/core/base/observation"; +import { I18nKey } from "#/i18n/declaration"; import { GenericEventMessage } from "../../../features/chat/generic-event-message"; import { getEventContent } from "../event-content-helpers/get-event-content"; import { @@ -12,8 +18,10 @@ import { } from "#/types/agent-server/type-guards"; import { SkillReadyEvent, + SkillReadyItem, isSkillReadyEvent, } from "../event-content-helpers/create-skill-ready-event"; +import { getInvokeSkillItems } from "../event-content-helpers/get-invoke-skill-items"; import { ConversationConfirmationButtons } from "#/components/shared/buttons/conversation-confirmation-buttons"; import { SkillReadyContentList } from "./skill-ready-content-list"; import SkillsIcon from "#/icons/skills.svg?react"; @@ -24,6 +32,38 @@ interface GenericEventMessageWrapperProps { correspondingAction?: ActionEvent; } +/** + * Resolves the expandable skill-knowledge list shared by Skill Ready events and + * invoke-skill tool observations. Returns the list items and their header + * label, or null when the event carries no skill knowledge (so the caller keeps + * the default details body). Additional skill-knowledge sources slot in as one + * more branch here. + */ +function getSkillKnowledge( + event: OpenHandsEvent | SkillReadyEvent, +): { items: SkillReadyItem[]; titleKey: I18nKey } | null { + if (isSkillReadyEvent(event)) { + return event._skillReadyItems.length > 0 + ? { + items: event._skillReadyItems, + titleKey: I18nKey.SKILLS$TRIGGERED_SKILL_KNOWLEDGE, + } + : null; + } + if ( + isObservationEvent(event) && + event.observation.kind === "InvokeSkillObservation" + ) { + const items = getInvokeSkillItems( + event as ObservationEvent, + ); + return items.length > 0 + ? { items, titleKey: I18nKey.SKILLS$INVOKED_SKILL_KNOWLEDGE } + : null; + } + return null; +} + export function GenericEventMessageWrapper({ event, isLastMessage, @@ -50,24 +90,28 @@ export function GenericEventMessageWrapper({ success = getACPToolCallResult(event); } - // For Skill Ready events with items, render expandable skill list - const isSkillReady = isSkillReadyEvent(event); - const skillReadyDetails = - isSkillReady && event._skillReadyItems.length > 0 ? ( - - ) : ( - details - ); + // Skill Ready events and invoke-skill tool observations both render the + // expandable skill-knowledge list (with the skills icon); they differ only in + // the header label. + const skillKnowledge = getSkillKnowledge(event); + const bodyDetails = skillKnowledge ? ( + + ) : ( + details + ); return (
) : undefined } diff --git a/src/components/conversation-events/chat/event-message-components/skill-ready-content-list.tsx b/src/components/conversation-events/chat/event-message-components/skill-ready-content-list.tsx index 19520ed2f..97018eb26 100644 --- a/src/components/conversation-events/chat/event-message-components/skill-ready-content-list.tsx +++ b/src/components/conversation-events/chat/event-message-components/skill-ready-content-list.tsx @@ -8,9 +8,18 @@ import { SkillItemExpanded } from "./skill-item-expanded"; interface SkillReadyContentListProps { items: SkillReadyItem[]; + /** + * Translation key for the list header. Defaults to the "Triggered Skill + * Knowledge:" label used by Skill Ready events; invoke-skill observations + * pass "Invoked Skill Knowledge:" instead. + */ + titleKey?: I18nKey; } -export function SkillReadyContentList({ items }: SkillReadyContentListProps) { +export function SkillReadyContentList({ + items, + titleKey = I18nKey.SKILLS$TRIGGERED_SKILL_KNOWLEDGE, +}: SkillReadyContentListProps) { const { t } = useTranslation("openhands"); const [expandedSkills, setExpandedSkills] = React.useState< Record @@ -23,7 +32,7 @@ export function SkillReadyContentList({ items }: SkillReadyContentListProps) { return (
- {t(I18nKey.SKILLS$TRIGGERED_SKILL_KNOWLEDGE)} + {t(titleKey)} {items.map((item) => { const isExpanded = expandedSkills[item.name] || false; diff --git a/src/components/features/chat/tool-visualizers/bash/bash.tsx b/src/components/features/chat/tool-visualizers/bash/bash.tsx new file mode 100644 index 000000000..7048c0162 --- /dev/null +++ b/src/components/features/chat/tool-visualizers/bash/bash.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { SecurityRisk } from "#/types/agent-server/core"; +import { I18nKey } from "#/i18n/declaration"; +import { defineVisualizer } from "../define"; +import { textFromContent } from "../text-content"; +import { CodeBlock } from "../primitives/code-block"; +import { OutputPane } from "../primitives/output-pane"; + +/** + * Bash / terminal visualizer. The action card shows the command (plus a risk + * warning for HIGH/MEDIUM actions); the observation card shows the command and + * its output (with an exit-code badge). Both the command and the output carry a + * hover copy button. Covers both the `execute_bash` and `terminal` tools, which + * carry the same `command` / `content` / `exit_code` fields. + */ +export const bashVisualizer = defineVisualizer({ + actionKinds: ["ExecuteBashAction", "TerminalAction"], + observationKinds: ["ExecuteBashObservation", "TerminalObservation"], + Body: function BashBody({ action, observation }) { + const { t } = useTranslation("openhands"); + const command = + observation?.observation.command ?? action?.action.command ?? ""; + const risk = action?.security_risk; + + return ( +
+ {command && } + {(risk === SecurityRisk.HIGH || risk === SecurityRisk.MEDIUM) && ( + + {t( + risk === SecurityRisk.HIGH + ? I18nKey.SECURITY$HIGH_RISK + : I18nKey.SECURITY$MEDIUM_RISK, + )} + + )} + {observation && ( + + )} +
+ ); + }, +}); diff --git a/src/components/features/chat/tool-visualizers/define.ts b/src/components/features/chat/tool-visualizers/define.ts new file mode 100644 index 000000000..e9315a083 --- /dev/null +++ b/src/components/features/chat/tool-visualizers/define.ts @@ -0,0 +1,62 @@ +import React from "react"; +import { + Action, + Observation, + ActionEvent, + ObservationEvent, +} from "#/types/agent-server/core"; + +type ActionKind = Action["kind"]; +type ObservationKind = Observation["kind"]; +type ActionByKind = Extract; +type ObservationByKind = Extract< + Observation, + { kind: K } +>; + +/** + * Props a visualizer `Body` receives. The chat uses a two-card layout, so each + * card passes only the half it owns: the action card sets `action`, and the + * observation card sets `observation` (plus the originating `action` when it + * can be resolved). A `Body` must render whichever halves are present. + */ +export interface VisualizerProps { + action?: ActionEvent; + observation?: ObservationEvent; +} + +/** + * Author-facing visualizer shape. `actionKinds` / `observationKinds` are + * narrowed string-literal arrays, so `Body` is type-checked against the exact + * `Action` / `Observation` members for those kinds — no casts in the body. + */ +export interface ToolVisualizer< + AK extends ActionKind, + OK extends ObservationKind, +> { + actionKinds: AK[]; + observationKinds?: OK[]; + Body: React.FC, ObservationByKind>>; +} + +/** + * Type-erased shape stored in the registry so visualizers with different kinds + * can share one `Map`. Lookups re-narrow by the registered `kind`. + */ +export interface RegisteredVisualizer { + actionKinds: string[]; + observationKinds?: string[]; + Body: React.FC>; +} + +/** + * Identity helper that infers the narrow generics from `actionKinds` / + * `observationKinds` (giving `Body` precise prop types and autocompleted + * kinds) and returns the erased form for the registry. + */ +export const defineVisualizer = < + AK extends ActionKind, + OK extends ObservationKind = never, +>( + visualizer: ToolVisualizer, +): RegisteredVisualizer => visualizer as unknown as RegisteredVisualizer; diff --git a/src/components/features/chat/tool-visualizers/dispatcher.tsx b/src/components/features/chat/tool-visualizers/dispatcher.tsx new file mode 100644 index 000000000..4ee48c60a --- /dev/null +++ b/src/components/features/chat/tool-visualizers/dispatcher.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { OpenHandsEvent, ActionEvent } from "#/types/agent-server/core"; +import { + isActionEvent, + isObservationEvent, +} from "#/types/agent-server/type-guards"; +import { actionVisualizers, observationVisualizers } from "./index"; + +/** + * Returns the React body for an action/observation event when a tool visualizer + * is registered for its `kind`, or `null` to tell the caller to fall back to + * the markdown pipeline. Only the collapsible details body is produced here — + * the title pipeline, success indicator, and ACP path are untouched. + * + * `correspondingAction` is the action an observation responds to (when it can + * be resolved); it lets observation cards reuse action-side fields such as a + * file view range. + */ +export function resolveVisualizerBody( + event: OpenHandsEvent, + correspondingAction?: ActionEvent, +): React.ReactNode | null { + if (isActionEvent(event)) { + const visualizer = actionVisualizers.get(event.action.kind); + if (visualizer) { + return ; + } + } else if (isObservationEvent(event)) { + const visualizer = observationVisualizers.get(event.observation.kind); + if (visualizer) { + return ( + + ); + } + } + return null; +} diff --git a/src/components/features/chat/tool-visualizers/file-editor/file-editor.tsx b/src/components/features/chat/tool-visualizers/file-editor/file-editor.tsx new file mode 100644 index 000000000..ee337a4f4 --- /dev/null +++ b/src/components/features/chat/tool-visualizers/file-editor/file-editor.tsx @@ -0,0 +1,89 @@ +import React from "react"; +import { getLanguageFromPath } from "#/utils/get-language-from-path"; +import { defineVisualizer } from "../define"; +import { textFromContent } from "../text-content"; +import { CodeBlock } from "../primitives/code-block"; +import { DiffView } from "../primitives/diff-view"; +import { FilePathChip } from "../primitives/file-path-chip"; + +/** + * File-editor visualizer for `file_editor` / `str_replace_editor` tools. + * + * Observation card: error → message; `str_replace`/`insert` → diff of the file + * before vs after; `create`/`view` → the file content. Action card (shown while + * the edit is in flight): `create` → new content; `str_replace` → diff of the + * replaced snippet; `view`/`undo_edit` → just the path + range. + */ +export const fileEditorVisualizer = defineVisualizer({ + actionKinds: ["FileEditorAction", "StrReplaceEditorAction"], + observationKinds: ["FileEditorObservation", "StrReplaceEditorObservation"], + Body: function FileEditorBody({ action, observation }) { + const path = observation?.observation.path ?? action?.action.path ?? ""; + const command = observation?.observation.command ?? action?.action.command; + const language = getLanguageFromPath(path); + + const viewRange = action?.action.view_range; + const range = + command === "view" && viewRange + ? `${viewRange[0]}-${viewRange[1]}` + : undefined; + const chip = path ? : null; + + if (observation) { + const obs = observation.observation; + let body: React.ReactNode = null; + if (obs.error) { + body = ( + + {obs.error} + + ); + } else if (obs.old_content != null && obs.new_content != null) { + // Nullish, not truthy: an empty string is a valid side of the diff — + // clearing a file or inserting into an empty file must still render it. + body = ; + } else { + // `view` returns the snippet the agent saw in `content` (the `cat -n` + // output) rather than `output`/`new_content`, so fall back to it. + // Mirrors the markdown path's "prefer content for view" handling. + const content = + obs.new_content || + obs.output || + (obs.content ? textFromContent(obs.content) : ""); + body = content ? ( + + ) : null; + } + return ( +
+ {chip} + {body} +
+ ); + } + + if (action) { + const act = action.action; + let body: React.ReactNode = null; + if (act.command === "create" && act.file_text) { + body = ; + } else if ( + (act.command === "str_replace" || act.command === "insert") && + act.new_str != null + ) { + // `insert` carries only `new_str`, no `old_str`. Key on `new_str` and + // default `old_str` to "" so an in-flight insert shows an addition diff + // instead of nothing. + body = ; + } + return ( +
+ {chip} + {body} +
+ ); + } + + return null; + }, +}); diff --git a/src/components/features/chat/tool-visualizers/index.ts b/src/components/features/chat/tool-visualizers/index.ts new file mode 100644 index 000000000..64befa964 --- /dev/null +++ b/src/components/features/chat/tool-visualizers/index.ts @@ -0,0 +1,39 @@ +import { RegisteredVisualizer } from "./define"; +import { bashVisualizer } from "./bash/bash"; +import { fileEditorVisualizer } from "./file-editor/file-editor"; +import { searchVisualizer } from "./search/search"; +import { taskVisualizer } from "./task/task"; + +/** + * Tool visualizers render a tool call's action / observation card body as React + * components instead of markdown. Unregistered tools keep using the markdown + * pipeline (see `dispatcher.ts`), so this list can grow one tool at a time. + * + * To add a visualizer: + * 1. Create `tool-visualizers//.tsx` exporting + * `defineVisualizer({ actionKinds, observationKinds, Body })`. + * 2. Add one import below and one entry to `ALL`. + * 3. Add `tool-visualizers//.test.tsx` (render with fixtures). + * + * TypeScript does the rest: `actionKinds` autocompletes from the `Action` + * union, `Body` receives the narrowed event types, and unknown kinds are + * compile errors. + */ +const ALL: RegisteredVisualizer[] = [ + bashVisualizer, + fileEditorVisualizer, + searchVisualizer, + taskVisualizer, +]; + +const indexByKind = ( + kindsOf: (visualizer: RegisteredVisualizer) => string[] | undefined, +): Map => + new Map( + ALL.flatMap((visualizer) => + (kindsOf(visualizer) ?? []).map((kind) => [kind, visualizer] as const), + ), + ); + +export const actionVisualizers = indexByKind((v) => v.actionKinds); +export const observationVisualizers = indexByKind((v) => v.observationKinds); diff --git a/src/components/features/chat/tool-visualizers/primitives/code-block.tsx b/src/components/features/chat/tool-visualizers/primitives/code-block.tsx new file mode 100644 index 000000000..16556cb2f --- /dev/null +++ b/src/components/features/chat/tool-visualizers/primitives/code-block.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"; +import { SyntaxHighlighter } from "../../../markdown/syntax-highlighter"; +import { CopyableContentWrapper } from "#/components/shared/buttons/copyable-content-wrapper"; +import { MAX_CONTENT_LENGTH } from "#/components/conversation-events/chat/event-content-helpers/shared"; + +interface CodeBlockProps { + code: string; + /** Prism language hint (e.g. "bash", "python"). */ + language?: string; + /** Show a copy button on hover. Defaults to true. */ + copy?: boolean; +} + +/** + * Syntax-highlighted code block with an optional hover copy button. Long + * content is truncated to the same limit the markdown path uses; the copy + * button always yields the full, untruncated text. + */ +export function CodeBlock({ code, language, copy = true }: CodeBlockProps) { + const display = + code.length > MAX_CONTENT_LENGTH + ? `${code.slice(0, MAX_CONTENT_LENGTH)}…` + : code; + + const block = ( + + {display} + + ); + + return copy ? ( + {block} + ) : ( + block + ); +} diff --git a/src/components/features/chat/tool-visualizers/primitives/diff-view.tsx b/src/components/features/chat/tool-visualizers/primitives/diff-view.tsx new file mode 100644 index 000000000..7ba92bb95 --- /dev/null +++ b/src/components/features/chat/tool-visualizers/primitives/diff-view.tsx @@ -0,0 +1,138 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { cn } from "#/utils/utils"; + +type DiffRow = { type: "add" | "del" | "ctx"; text: string }; + +/** Lines of unchanged context kept on each side of a change. */ +const CONTEXT = 3; +/** Max rendered rows before the view is truncated. */ +const MAX_ROWS = 300; +/** Above this `old x new` line product we skip the O(n*m) LCS and show a + * wholesale replacement instead, so a full-file rewrite can't blow up. */ +const LCS_CELL_BUDGET = 250_000; + +const lcsDiff = (a: string[], b: string[]): DiffRow[] => { + if (a.length * b.length > LCS_CELL_BUDGET) { + return [ + ...a.map((text): DiffRow => ({ type: "del", text })), + ...b.map((text): DiffRow => ({ type: "add", text })), + ]; + } + const n = a.length; + const m = b.length; + const lcs: number[][] = Array.from({ length: n + 1 }, () => + new Array(m + 1).fill(0), + ); + for (let i = n - 1; i >= 0; i -= 1) { + for (let j = m - 1; j >= 0; j -= 1) { + lcs[i][j] = + a[i] === b[j] + ? lcs[i + 1][j + 1] + 1 + : Math.max(lcs[i + 1][j], lcs[i][j + 1]); + } + } + const rows: DiffRow[] = []; + let i = 0; + let j = 0; + while (i < n && j < m) { + if (a[i] === b[j]) { + rows.push({ type: "ctx", text: a[i] }); + i += 1; + j += 1; + } else if (lcs[i + 1][j] >= lcs[i][j + 1]) { + rows.push({ type: "del", text: a[i] }); + i += 1; + } else { + rows.push({ type: "add", text: b[j] }); + j += 1; + } + } + while (i < n) { + rows.push({ type: "del", text: a[i] }); + i += 1; + } + while (j < m) { + rows.push({ type: "add", text: b[j] }); + j += 1; + } + return rows; +}; + +/** + * Computes a unified line diff. Common leading/trailing lines are trimmed (with + * a few kept as context) so a localized edit inside a large file stays small + * and cheap to diff. + */ +const computeLineDiff = (oldText: string, newText: string): DiffRow[] => { + const a = oldText.split("\n"); + const b = newText.split("\n"); + + let lo = 0; + while (lo < a.length && lo < b.length && a[lo] === b[lo]) lo += 1; + let hiA = a.length; + let hiB = b.length; + while (hiA > lo && hiB > lo && a[hiA - 1] === b[hiB - 1]) { + hiA -= 1; + hiB -= 1; + } + + const lead = a.slice(Math.max(0, lo - CONTEXT), lo); + const trail = a.slice(hiA, Math.min(a.length, hiA + CONTEXT)); + + return [ + ...lead.map((text): DiffRow => ({ type: "ctx", text })), + ...lcsDiff(a.slice(lo, hiA), b.slice(lo, hiB)), + ...trail.map((text): DiffRow => ({ type: "ctx", text })), + ]; +}; + +const ROW_STYLE: Record = { + add: "bg-status-success-bg text-status-success-text", + del: "bg-status-fail-bg text-status-fail-text", + ctx: "text-muted", +}; +const ROW_PREFIX: Record = { + add: "+ ", + del: "- ", + ctx: " ", +}; + +/** + * Unified before/after line diff for file edits. + */ +export function DiffView({ + oldText, + newText, +}: { + oldText: string; + newText: string; +}) { + const { t } = useTranslation("openhands"); + const rows = computeLineDiff(oldText, newText); + const truncated = rows.length > MAX_ROWS; + const shown = truncated ? rows.slice(0, MAX_ROWS) : rows; + + return ( +
+
+ {shown.map((row, index) => ( +
+ {`${ROW_PREFIX[row.type]}${row.text}`} +
+ ))} +
+ {truncated && ( + + {t(I18nKey.COMMON$TRUNCATED)} + + )} +
+ ); +} diff --git a/src/components/features/chat/tool-visualizers/primitives/file-path-chip.tsx b/src/components/features/chat/tool-visualizers/primitives/file-path-chip.tsx new file mode 100644 index 000000000..3e10bb79e --- /dev/null +++ b/src/components/features/chat/tool-visualizers/primitives/file-path-chip.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import FileIcon from "#/icons/file.svg?react"; + +interface FilePathChipProps { + path: string; + /** Optional line-range suffix, e.g. "12-48". */ + range?: string; +} + +/** + * Monospace file-path pill with a file icon and an optional line-range suffix. + */ +export function FilePathChip({ path, range }: FilePathChipProps) { + return ( + + + {range ? `${path}:${range}` : path} + + ); +} diff --git a/src/components/features/chat/tool-visualizers/primitives/key-value-grid.tsx b/src/components/features/chat/tool-visualizers/primitives/key-value-grid.tsx new file mode 100644 index 000000000..2c9f4a7f4 --- /dev/null +++ b/src/components/features/chat/tool-visualizers/primitives/key-value-grid.tsx @@ -0,0 +1,23 @@ +import React from "react"; + +interface KeyValueRow { + /** Already-translated label. */ + label: string; + value: React.ReactNode; +} + +/** + * Two-column label / value grid for compact parameter displays. + */ +export function KeyValueGrid({ rows }: { rows: KeyValueRow[] }) { + return ( +
+ {rows.map(({ label, value }) => ( + + {label} + {value} + + ))} +
+ ); +} diff --git a/src/components/features/chat/tool-visualizers/primitives/output-pane.tsx b/src/components/features/chat/tool-visualizers/primitives/output-pane.tsx new file mode 100644 index 000000000..0fec6bc5b --- /dev/null +++ b/src/components/features/chat/tool-visualizers/primitives/output-pane.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { CopyableContentWrapper } from "#/components/shared/buttons/copyable-content-wrapper"; +import { MAX_CONTENT_LENGTH } from "#/components/conversation-events/chat/event-content-helpers/shared"; + +interface OutputPaneProps { + output: string; + /** Process exit code, when known. `0` and `-1` (timeout) are not badged — + * the card's success indicator already conveys those. */ + exitCode?: number | null; + /** Show a hover copy button that yields the full, untruncated output. + * Defaults to true; suppressed automatically when there is no output. */ + copy?: boolean; +} + +/** + * Monospace output block for command results, with a failure exit-code badge + * and an optional hover copy button. Long content is truncated in the display + * to the same limit the markdown path uses; the copy button always yields the + * full, untruncated output. + */ +export function OutputPane({ output, exitCode, copy = true }: OutputPaneProps) { + const { t } = useTranslation("openhands"); + const display = + output.length > MAX_CONTENT_LENGTH + ? `${output.slice(0, MAX_CONTENT_LENGTH)}…` + : output; + const text = display.trim(); + const showExitBadge = exitCode != null && exitCode !== 0 && exitCode !== -1; + + const pane = ( +
+      {text || t(I18nKey.OBSERVATION$COMMAND_NO_OUTPUT)}
+    
+ ); + + return ( +
+ {showExitBadge && ( + + {t(I18nKey.OBSERVATION$EXIT_CODE, { code: exitCode })} + + )} + {copy && text ? ( + {pane} + ) : ( + pane + )} +
+ ); +} diff --git a/src/components/features/chat/tool-visualizers/search/search.tsx b/src/components/features/chat/tool-visualizers/search/search.tsx new file mode 100644 index 000000000..1723d586d --- /dev/null +++ b/src/components/features/chat/tool-visualizers/search/search.tsx @@ -0,0 +1,75 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { defineVisualizer } from "../define"; +import { textFromContent } from "../text-content"; +import { KeyValueGrid } from "../primitives/key-value-grid"; + +/** + * Search visualizer for `grep` / `glob`. Both cards show the pattern / path / + * include parameters; the observation card adds the match count and the list of + * matching files (or an error / empty state). + */ +export const searchVisualizer = defineVisualizer({ + actionKinds: ["GrepAction", "GlobAction"], + observationKinds: ["GrepObservation", "GlobObservation"], + Body: function SearchBody({ action, observation }) { + const { t } = useTranslation("openhands"); + const obs = observation?.observation; + const act = action?.action; + + const pattern = obs?.pattern ?? act?.pattern ?? ""; + const path = obs?.search_path ?? act?.path ?? ""; + const include = obs + ? "include_pattern" in obs + ? obs.include_pattern + : null + : act && "include" in act + ? act.include + : null; + + const results = obs ? ("matches" in obs ? obs.matches : obs.files) : []; + + const rows = [ + { label: t(I18nKey.COMMON$PATTERN), value: pattern }, + ...(path ? [{ label: t(I18nKey.COMMON$PATH), value: path }] : []), + ...(include + ? [{ label: t(I18nKey.COMMON$INCLUDE), value: include }] + : []), + ]; + + return ( +
+ + {obs && + (obs.is_error ? ( + + {textFromContent(obs.content)} + + ) : results.length === 0 ? ( + + {t(I18nKey.COMMON$NO_RESULTS)} + + ) : ( +
+ + {t(I18nKey.COMMON$RESULTS, { count: results.length })} + +
+ {results.map((file) => ( + + {file} + + ))} +
+ {obs.truncated && ( + + {t(I18nKey.COMMON$TRUNCATED)} + + )} +
+ ))} +
+ ); + }, +}); diff --git a/src/components/features/chat/tool-visualizers/task/task.tsx b/src/components/features/chat/tool-visualizers/task/task.tsx new file mode 100644 index 000000000..1737a9e19 --- /dev/null +++ b/src/components/features/chat/tool-visualizers/task/task.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { MarkdownRenderer } from "#/components/features/markdown/markdown-renderer"; +import { CopyableContentWrapper } from "#/components/shared/buttons/copyable-content-wrapper"; +import { defineVisualizer } from "../define"; +import { textFromContent } from "../text-content"; +import { KeyValueGrid } from "../primitives/key-value-grid"; + +/** + * Labelled markdown block with a hover copy button, used for both the query the + * parent agent sent and the result the subagent returned. + */ +function MarkdownSection({ + label, + text, + isError = false, +}: { + label: string; + text: string; + isError?: boolean; +}) { + return ( +
+ {label} + +
+ + {text} + +
+
+
+ ); +} + +/** + * Task visualizer for the `task` tool, which delegates work to a spawned + * subagent. The action card shows which subagent is being run and the query + * (the prompt the parent agent sent); the observation card adds the task id and + * the subagent's returned result. Until the observation arrives, the card just + * shows the query, so an in-flight delegation is still legible. Both the query + * and the result are rendered as markdown with a copy button. + */ +export const taskVisualizer = defineVisualizer({ + actionKinds: ["TaskAction"], + observationKinds: ["TaskObservation"], + Body: function TaskBody({ action, observation }) { + const { t } = useTranslation("openhands"); + const act = action?.action; + const obs = observation?.observation; + + const subagent = obs?.subagent ?? act?.subagent_type ?? ""; + const taskId = obs?.task_id; + const query = act?.prompt?.trim() ?? ""; + const answer = obs ? textFromContent(obs.content).trim() : ""; + + const rows = [ + ...(subagent + ? [{ label: t(I18nKey.TASK$SUBAGENT), value: subagent }] + : []), + ...(taskId ? [{ label: t(I18nKey.TASK$TASK_ID), value: taskId }] : []), + ]; + + return ( +
+ {rows.length > 0 && } + {query && ( + + )} + {answer && ( + + )} +
+ ); + }, +}); diff --git a/src/components/features/chat/tool-visualizers/text-content.ts b/src/components/features/chat/tool-visualizers/text-content.ts new file mode 100644 index 000000000..3c067d2fd --- /dev/null +++ b/src/components/features/chat/tool-visualizers/text-content.ts @@ -0,0 +1,17 @@ +import { + TextContent, + ImageContent, +} from "#/types/agent-server/core/base/common"; + +/** + * Joins the text parts of a tool observation's `content` array, dropping image + * parts. Mirrors the extraction the markdown helpers do so migrated tools show + * the same text the fallback path would have. + */ +export const textFromContent = ( + content: Array, +): string => + content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); diff --git a/src/contexts/conversation-websocket-context.tsx b/src/contexts/conversation-websocket-context.tsx index aaf1459a3..c3c8389fd 100644 --- a/src/contexts/conversation-websocket-context.tsx +++ b/src/contexts/conversation-websocket-context.tsx @@ -58,11 +58,18 @@ import { useReadConversationFile } from "#/hooks/mutation/use-read-conversation- import useMetricsStore from "#/stores/metrics-store"; import { useConversationHistory } from "#/hooks/query/use-conversation-history"; import { setConversationState } from "#/utils/conversation-local-storage"; -import { recordModelSwitchMessage } from "#/hooks/chat/record-model-switch-message"; +import { + recordModelSwitchMessage, + seedModelSwitchesFromHistory, +} from "#/hooks/chat/record-model-switch-message"; import { invalidateConversationQueries, updateConversationLlmModelInCache, } from "#/hooks/mutation/conversation-mutation-utils"; +import { + getStoredConversationMetadata, + setStoredConversationMetadata, +} from "#/api/conversation-metadata-store"; export type WebSocketConnectionState = | "CONNECTING" @@ -257,6 +264,17 @@ export function ConversationWebSocketProvider({ // timestamp). Consume any matching optimistic "Sending…" bubble here too — // mirroring the WS handler — so it doesn't linger as a duplicate of the echo. if (conversationId) { + // Rebuild inline "Switched to" messages from the REST-preloaded history. + // The live store writers (WS handler / user action) never see preloaded + // events, so without this past model switches wouldn't render on reload. + // Read the post-`addEvents` `uiEvents` (actions replaced by observations, + // Think/Finish observations dropped) — not the raw history — so anchors + // match the ids the renderer actually mounts. + seedModelSwitchesFromHistory( + conversationId, + useEventStore.getState().uiEvents, + ); + for (const event of preloadedHistory.events) { if (isUserMessageEvent(event)) { consumeMatchingPendingMessage( @@ -573,6 +591,18 @@ export function ConversationWebSocketProvider({ switchLLMObservation.observation.profile_name, ); + // Mirror the user-driven `/model` path: persist the profile so the + // chat-header switcher shows the right name after a reload, even + // when several profiles share a model (#1082). + const prevMetadata = getStoredConversationMetadata(conversationId); + setStoredConversationMetadata(conversationId, { + selected_repository: prevMetadata?.selected_repository ?? null, + selected_branch: prevMetadata?.selected_branch ?? null, + git_provider: prevMetadata?.git_provider ?? null, + selected_workspace: prevMetadata?.selected_workspace ?? null, + active_profile: switchLLMObservation.observation.profile_name, + }); + if (switchLLMObservation.observation.active_model) { updateConversationLlmModelInCache( queryClient, diff --git a/src/hooks/chat/record-model-switch-message.test.ts b/src/hooks/chat/record-model-switch-message.test.ts new file mode 100644 index 000000000..84b036074 --- /dev/null +++ b/src/hooks/chat/record-model-switch-message.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { useModelStore } from "#/stores/model-store"; +import { OpenHandsEvent } from "#/types/agent-server/core"; +import { seedModelSwitchesFromHistory } from "./record-model-switch-message"; + +const userMessage = (id: string): OpenHandsEvent => + ({ + id, + timestamp: "2024-01-01T00:00:00Z", + source: "user", + llm_message: { role: "user", content: [{ type: "text", text: "hi" }] }, + }) as unknown as OpenHandsEvent; + +const switchObservation = ( + id: string, + profileName: string, + isError = false, +): OpenHandsEvent => + ({ + id, + timestamp: "2024-01-01T00:00:00Z", + source: "environment", + action_id: `action-${id}`, + observation: { + kind: "SwitchLLMObservation", + content: [], + is_error: isError, + profile_name: profileName, + reason: null, + active_model: null, + }, + }) as unknown as OpenHandsEvent; + +// An agent action event. `ThinkAction` is renderable (shown as a thinking +// block); `PlanningFileEditorAction` is hidden by `shouldRenderEvent`. +const agentAction = (id: string, kind: string): OpenHandsEvent => + ({ + id, + timestamp: "2024-01-01T00:00:00Z", + source: "agent", + action: { kind, thought: "t" }, + tool_name: "tool", + tool_call_id: `call-${id}`, + }) as unknown as OpenHandsEvent; + +const entriesFor = (conversationId: string) => + useModelStore.getState().entriesByConversation[conversationId] ?? []; + +describe("seedModelSwitchesFromHistory", () => { + beforeEach(() => { + useModelStore.getState().clearAll(); + }); + + it("seeds a successful switch anchored to the prior renderable event", () => { + seedModelSwitchesFromHistory("c1", [ + userMessage("u1"), + switchObservation("o1", "fast"), + ]); + + const entries = entriesFor("c1"); + expect(entries).toHaveLength(1); + expect(entries[0].switchedTo).toBe("fast"); + expect(entries[0].anchorEventId).toBe("u1"); + expect(entries[0].id).toBe("history-switch:o1"); + }); + + it("is idempotent across re-seeds (e.g. reloads)", () => { + const events = [userMessage("u1"), switchObservation("o1", "fast")]; + seedModelSwitchesFromHistory("c1", events); + seedModelSwitchesFromHistory("c1", events); + + expect(entriesFor("c1")).toHaveLength(1); + }); + + it("ignores failed switches (they still render as error cards)", () => { + seedModelSwitchesFromHistory("c1", [ + userMessage("u1"), + switchObservation("e1", "fast", true), + ]); + + expect(entriesFor("c1")).toHaveLength(0); + }); + + it("never anchors to a non-rendered event (must land on a rendered id)", () => { + // PlanningFileEditorAction is hidden by shouldRenderEvent, so the renderer + // never mounts it; anchoring there would orphan the message. The anchor + // must fall back to the prior rendered event (the user message). + seedModelSwitchesFromHistory("c1", [ + userMessage("u1"), + agentAction("p1", "PlanningFileEditorAction"), + switchObservation("o1", "fast"), + ]); + + const entries = entriesFor("c1"); + expect(entries).toHaveLength(1); + expect(entries[0].anchorEventId).toBe("u1"); + }); + + it("anchors to a renderable ThinkAction that precedes the switch", () => { + // In uiEvents the ThinkObservation is dropped and the ThinkAction is kept + // (and rendered as a thinking block), so it is a valid anchor. + seedModelSwitchesFromHistory("c1", [ + userMessage("u1"), + agentAction("t1", "ThinkAction"), + switchObservation("o1", "architect"), + ]); + + const entries = entriesFor("c1"); + expect(entries).toHaveLength(1); + expect(entries[0].anchorEventId).toBe("t1"); + }); + + it("anchors to null when no renderable event precedes the switch", () => { + seedModelSwitchesFromHistory("c1", [switchObservation("o1", "architect")]); + + const entries = entriesFor("c1"); + expect(entries).toHaveLength(1); + expect(entries[0].anchorEventId).toBeNull(); + }); + + it("preserves order and anchors for multiple switches", () => { + seedModelSwitchesFromHistory("c1", [ + userMessage("u1"), + switchObservation("o1", "fast"), + userMessage("u2"), + switchObservation("o2", "architect"), + ]); + + const entries = entriesFor("c1"); + expect(entries).toHaveLength(2); + expect(entries[0]).toMatchObject({ + switchedTo: "fast", + anchorEventId: "u1", + }); + expect(entries[1]).toMatchObject({ + switchedTo: "architect", + anchorEventId: "u2", + }); + }); +}); diff --git a/src/hooks/chat/record-model-switch-message.ts b/src/hooks/chat/record-model-switch-message.ts index bd7e0a200..8c98ee0cb 100644 --- a/src/hooks/chat/record-model-switch-message.ts +++ b/src/hooks/chat/record-model-switch-message.ts @@ -1,5 +1,8 @@ import { getLastRenderableEventId } from "#/hooks/chat/model-command-event-anchor"; -import { useModelStore } from "#/stores/model-store"; +import { useModelStore, SeededSwitch } from "#/stores/model-store"; +import { OpenHandsEvent } from "#/types/agent-server/core"; +import { isSwitchLLMObservationEvent } from "#/types/agent-server/type-guards"; +import { shouldRenderEvent } from "#/components/conversation-events/chat/event-content-helpers/should-render-event"; export function recordModelSwitchMessage( conversationId: string, @@ -10,3 +13,50 @@ export function recordModelSwitchMessage( .getState() .recordSwitch(conversationId, anchorEventId, profileName); } + +/** + * Rebuilds the inline "Switched to" messages for a conversation from its loaded + * history. + * + * The live messages live in an in-memory store written only by the WebSocket + * handler and the user `/model` action. Existing conversations load via REST + * history, which bypasses that handler, so without this replay no past agent + * switches would render after a reload (the SwitchLLMObservation events are + * also hidden as cards by `shouldRenderEvent`). + * + * `uiEvents` MUST be the event store's `uiEvents` (the same list the renderer + * and the live `getLastRenderableEventId()` use) — NOT the raw history. The + * renderer anchors a message after an event only if that event's id is in + * `uiEvents.filter(shouldRenderEvent)`, and `uiEvents` differs from raw history: + * actions are replaced by their observations and `ThinkObservation` / + * `FinishObservation` are dropped. Anchoring off raw history would point at ids + * that never mount (e.g. a dropped `ThinkObservation`), orphaning the message. + * + * Each successful switch is anchored to the last renderable event before it, + * matching where the live handler would have placed it. Idempotent: entries are + * keyed by the observation event id, so re-seeding on every reload is a no-op. + */ +export function seedModelSwitchesFromHistory( + conversationId: string, + uiEvents: OpenHandsEvent[], +) { + const switches: SeededSwitch[] = []; + let lastRenderableId: string | null = null; + + for (const event of uiEvents) { + if (isSwitchLLMObservationEvent(event) && !event.observation.is_error) { + switches.push({ + id: `history-switch:${event.id}`, + anchorEventId: lastRenderableId, + profileName: event.observation.profile_name, + }); + } + if (shouldRenderEvent(event)) { + lastRenderableId = String(event.id); + } + } + + if (switches.length > 0) { + useModelStore.getState().seedSwitches(conversationId, switches); + } +} diff --git a/src/hooks/use-load-older-events.ts b/src/hooks/use-load-older-events.ts index a67b06471..1578d1efa 100644 --- a/src/hooks/use-load-older-events.ts +++ b/src/hooks/use-load-older-events.ts @@ -7,6 +7,7 @@ import { useConversationHistory, } from "#/hooks/query/use-conversation-history"; import { isTaskConversationId } from "#/utils/conversation-local-storage"; +import { seedModelSwitchesFromHistory } from "#/hooks/chat/record-model-switch-message"; import type { OpenHandsEvent } from "#/types/agent-server/core"; const getEventTimestamp = (event: OpenHandsEvent): string | undefined => @@ -141,6 +142,13 @@ export const useLoadOlderEvents = ( const older = [...page.items].reverse(); if (older.length > 0) { addEvents(older); + // The initial preload only seeds switches from the tail page; a switch + // in an older page is hidden as a card but never seeded — silently lost. + // Reseed over the merged `uiEvents` (idempotent) so it still surfaces. + seedModelSwitchesFromHistory( + conversationId, + useEventStore.getState().uiEvents, + ); } // Stop once the server signals there are no more pages, OR — for // servers that don't fill in `next_page_id` for filtered queries — diff --git a/src/i18n/translation.json b/src/i18n/translation.json index 973e6f9a2..5d6e0c5f4 100644 --- a/src/i18n/translation.json +++ b/src/i18n/translation.json @@ -1,4 +1,225 @@ { + "COMMON$PATTERN": { + "ar": "النمط:", + "ca": "Patró:", + "de": "Muster:", + "en": "Pattern:", + "es": "Patrón:", + "fr": "Motif :", + "it": "Modello:", + "ja": "パターン:", + "ko-KR": "패턴:", + "no": "Mønster:", + "pt": "Padrão:", + "tr": "Desen:", + "uk": "Шаблон:", + "zh-CN": "模式:", + "zh-TW": "模式:" + }, + "COMMON$INCLUDE": { + "ar": "تضمين:", + "ca": "Inclou:", + "de": "Einschließen:", + "en": "Include:", + "es": "Incluir:", + "fr": "Inclure:", + "it": "Includi:", + "ja": "対象:", + "ko-KR": "포함:", + "no": "Inkluder:", + "pt": "Incluir:", + "tr": "Dahil:", + "uk": "Включити:", + "zh-CN": "包含:", + "zh-TW": "包含:" + }, + "COMMON$RESULTS": { + "ar": "النتائج ({{count}})", + "ca": "Resultats ({{count}})", + "de": "Ergebnisse ({{count}})", + "en": "Results ({{count}})", + "es": "Resultados ({{count}})", + "fr": "Résultats ({{count}})", + "it": "Risultati ({{count}})", + "ja": "結果 ({{count}})", + "ko-KR": "결과 ({{count}})", + "no": "Resultater ({{count}})", + "pt": "Resultados ({{count}})", + "tr": "Sonuçlar ({{count}})", + "uk": "Результати ({{count}})", + "zh-CN": "结果 ({{count}})", + "zh-TW": "結果 ({{count}})" + }, + "COMMON$NO_RESULTS": { + "ar": "لم يتم العثور على نتائج", + "ca": "No s'han trobat resultats", + "de": "Keine Ergebnisse gefunden", + "en": "No results found", + "es": "No se encontraron resultados", + "fr": "Aucun résultat trouvé", + "it": "Nessun risultato trovato", + "ja": "結果が見つかりません", + "ko-KR": "결과를 찾을 수 없습니다", + "no": "Ingen resultater funnet", + "pt": "Nenhum resultado encontrado", + "tr": "Sonuç bulunamadı", + "uk": "Результатів не знайдено", + "zh-CN": "未找到结果", + "zh-TW": "未找到結果" + }, + "COMMON$TRUNCATED": { + "ar": "تم اقتطاع النتائج", + "ca": "Resultats truncats", + "de": "Ergebnisse gekürzt", + "en": "Results truncated", + "es": "Resultados truncados", + "fr": "Résultats tronqués", + "it": "Risultati troncati", + "ja": "結果は切り捨てられました", + "ko-KR": "결과가 잘렸습니다", + "no": "Resultater avkortet", + "pt": "Resultados truncados", + "tr": "Sonuçlar kısaltıldı", + "uk": "Результати скорочено", + "zh-CN": "结果已截断", + "zh-TW": "結果已截斷" + }, + "OBSERVATION$EXIT_CODE": { + "ar": "خروج {{code}}", + "ca": "sortida {{code}}", + "de": "Exit {{code}}", + "en": "exit {{code}}", + "es": "salida {{code}}", + "fr": "sortie {{code}}", + "it": "uscita {{code}}", + "ja": "終了 {{code}}", + "ko-KR": "종료 {{code}}", + "no": "avslutning {{code}}", + "pt": "saída {{code}}", + "tr": "çıkış {{code}}", + "uk": "вихід {{code}}", + "zh-CN": "退出 {{code}}", + "zh-TW": "退出 {{code}}" + }, + "ACTION_MESSAGE$TASK": { + "en": "Running subagent {{name}}", + "ja": "サブエージェント {{name}} を実行中", + "zh-CN": "正在运行子代理 {{name}}", + "zh-TW": "正在執行子代理 {{name}}", + "ko-KR": "서브에이전트 {{name}} 실행 중", + "no": "Kjører underagent {{name}}", + "ar": "جارٍ تشغيل الوكيل الفرعي {{name}}", + "de": "Unteragent {{name}} wird ausgeführt", + "fr": "Exécution du sous-agent {{name}}", + "it": "Esecuzione del sottoagente {{name}}", + "pt": "Executando subagente {{name}}", + "es": "Ejecutando subagente {{name}}", + "tr": "Alt aracı {{name}} çalıştırılıyor", + "uk": "Запуск підагента {{name}}", + "ca": "S'està executant el subagent {{name}}" + }, + "OBSERVATION_MESSAGE$TASK": { + "en": "Ran subagent {{name}}", + "ja": "サブエージェント {{name}} を実行しました", + "zh-CN": "已运行子代理 {{name}}", + "zh-TW": "已執行子代理 {{name}}", + "ko-KR": "서브에이전트 {{name}} 실행됨", + "no": "Kjørte underagent {{name}}", + "ar": "تم تشغيل الوكيل الفرعي {{name}}", + "de": "Unteragent {{name}} ausgeführt", + "fr": "Sous-agent {{name}} exécuté", + "it": "Sottoagente {{name}} eseguito", + "pt": "Subagente {{name}} executado", + "es": "Subagente {{name}} ejecutado", + "tr": "Alt aracı {{name}} çalıştırıldı", + "uk": "Запущено підагент {{name}}", + "ca": "S'ha executat el subagent {{name}}" + }, + "TASK$SUBAGENT": { + "en": "Subagent:", + "ja": "サブエージェント:", + "zh-CN": "子代理:", + "zh-TW": "子代理:", + "ko-KR": "서브에이전트:", + "no": "Underagent:", + "ar": "وكيل فرعي:", + "de": "Unteragent:", + "fr": "Sous-agent :", + "it": "Sottoagente:", + "pt": "Subagente:", + "es": "Subagente:", + "tr": "Alt aracı:", + "uk": "Підагент:", + "ca": "Subagent:" + }, + "TASK$TASK_ID": { + "en": "Task ID:", + "ja": "タスクID:", + "zh-CN": "任务 ID:", + "zh-TW": "任務 ID:", + "ko-KR": "작업 ID:", + "no": "Oppgave-ID:", + "ar": "معرّف المهمة:", + "de": "Aufgaben-ID:", + "fr": "ID de tâche :", + "it": "ID attività:", + "pt": "ID da tarefa:", + "es": "ID de tarea:", + "tr": "Görev kimliği:", + "uk": "ID завдання:", + "ca": "ID de tasca:" + }, + "TASK$RESULT": { + "en": "Result", + "ja": "結果", + "zh-CN": "结果", + "zh-TW": "結果", + "ko-KR": "결과", + "no": "Resultat", + "ar": "النتيجة", + "de": "Ergebnis", + "fr": "Résultat", + "it": "Risultato", + "pt": "Resultado", + "es": "Resultado", + "tr": "Sonuç", + "uk": "Результат", + "ca": "Resultat" + }, + "TASK$QUERY": { + "en": "Query", + "ja": "クエリ", + "zh-CN": "查询", + "zh-TW": "查詢", + "ko-KR": "쿼리", + "no": "Forespørsel", + "ar": "الاستعلام", + "de": "Anfrage", + "fr": "Requête", + "it": "Query", + "pt": "Consulta", + "es": "Consulta", + "tr": "Sorgu", + "uk": "Запит", + "ca": "Consulta" + }, + "OBSERVATION_MESSAGE$CANVAS_UI": { + "en": "Updated the workspace view", + "ja": "ワークスペース表示を更新しました", + "zh-CN": "已更新工作区视图", + "zh-TW": "已更新工作區檢視", + "ko-KR": "작업 공간 보기를 업데이트했습니다", + "no": "Oppdaterte arbeidsområdevisningen", + "ar": "تم تحديث عرض مساحة العمل", + "de": "Arbeitsbereichsansicht aktualisiert", + "fr": "Vue de l'espace de travail mise à jour", + "it": "Vista dell'area di lavoro aggiornata", + "pt": "Visualização do espaço de trabalho atualizada", + "es": "Vista del espacio de trabajo actualizada", + "tr": "Çalışma alanı görünümü güncellendi", + "uk": "Оновлено вигляд робочої області", + "ca": "S'ha actualitzat la vista de l'espai de treball" + }, "ONBOARDING$ACP_SECRETS_SUBSCRIPTION_NOTE": { "ar": "Already signed in with a subscription or login? Leave these blank — no API key needed.", "ca": "Already signed in with a subscription or login? Leave these blank — no API key needed.", @@ -18512,6 +18733,23 @@ "uk": "Знання активованих навичок:", "ca": "Coneixement d'habilitats activades:" }, + "SKILLS$INVOKED_SKILL_KNOWLEDGE": { + "en": "Invoked Skill Knowledge:", + "ja": "呼び出されたスキル知識:", + "zh-CN": "调用的技能知识:", + "zh-TW": "調用的技能知識:", + "ko-KR": "호출된 스킬 지식:", + "no": "Påkalt ferdighetskunnskap:", + "ar": "معرفة المهارات المُستدعاة:", + "de": "Aufgerufenes Skill-Wissen:", + "fr": "Connaissances de compétences invoquées :", + "it": "Conoscenze delle competenze invocate:", + "pt": "Conhecimento de habilidades invocadas:", + "es": "Conocimiento de habilidades invocadas:", + "tr": "Çağrılan yetenek bilgisi:", + "uk": "Знання викликаних навичок:", + "ca": "Coneixement d'habilitats invocades:" + }, "COMMON$CONTENT": { "en": "Content", "ja": "コンテンツ", @@ -23002,54 +23240,54 @@ }, "CHAT_INTERFACE$ACP_RESUME_SANDBOX_DESCRIPTION": { "en": "This conversation's sandbox was recycled. Resume to continue where you left off.", - "ja": "This conversation's sandbox was recycled. Resume to continue where you left off.", - "zh-CN": "This conversation's sandbox was recycled. Resume to continue where you left off.", - "zh-TW": "This conversation's sandbox was recycled. Resume to continue where you left off.", - "ko-KR": "This conversation's sandbox was recycled. Resume to continue where you left off.", - "no": "This conversation's sandbox was recycled. Resume to continue where you left off.", - "it": "This conversation's sandbox was recycled. Resume to continue where you left off.", - "pt": "This conversation's sandbox was recycled. Resume to continue where you left off.", - "es": "This conversation's sandbox was recycled. Resume to continue where you left off.", - "ar": "This conversation's sandbox was recycled. Resume to continue where you left off.", - "fr": "This conversation's sandbox was recycled. Resume to continue where you left off.", - "tr": "This conversation's sandbox was recycled. Resume to continue where you left off.", - "de": "This conversation's sandbox was recycled. Resume to continue where you left off.", - "uk": "This conversation's sandbox was recycled. Resume to continue where you left off.", - "ca": "This conversation's sandbox was recycled. Resume to continue where you left off." + "ja": "この会話のサンドボックスは再利用されました。再開すると、中断したところから続行できます。", + "zh-CN": "此对话的沙盒已被回收。恢复以从上次中断的地方继续。", + "zh-TW": "此對話的沙盒已被回收。恢復以從上次中斷的地方繼續。", + "ko-KR": "이 대화의 샌드박스가 재활용되었습니다. 재개하여 중단한 지점부터 계속하세요.", + "no": "Sandkassen til denne samtalen ble resirkulert. Gjenoppta for å fortsette der du slapp.", + "ar": "تمت إعادة تدوير بيئة اختبار هذه المحادثة. استأنف للمتابعة من حيث توقفت.", + "de": "Die Sandbox dieser Unterhaltung wurde recycelt. Setze fort, um dort weiterzumachen, wo du aufgehört hast.", + "fr": "Le sandbox de cette conversation a été recyclé. Reprenez pour continuer là où vous vous êtes arrêté.", + "it": "La sandbox di questa conversazione è stata riciclata. Riprendi per continuare da dove avevi interrotto.", + "pt": "A sandbox desta conversa foi reciclada. Retome para continuar de onde parou.", + "es": "El sandbox de esta conversación se recicló. Reanuda para continuar donde lo dejaste.", + "ca": "El sandbox d'aquesta conversa s'ha reciclat. Reprèn per continuar on ho vas deixar.", + "tr": "Bu konuşmanın sandbox'ı geri dönüştürüldü. Kaldığınız yerden devam etmek için sürdürün.", + "uk": "Пісочницю цієї розмови було перероблено. Відновіть, щоб продовжити з того місця, де ви зупинилися." }, "CHAT_INTERFACE$ACP_RESUME_BUTTON": { "en": "Resume conversation", - "ja": "Resume conversation", - "zh-CN": "Resume conversation", - "zh-TW": "Resume conversation", - "ko-KR": "Resume conversation", - "no": "Resume conversation", - "it": "Resume conversation", - "pt": "Resume conversation", - "es": "Resume conversation", - "ar": "Resume conversation", - "fr": "Resume conversation", - "tr": "Resume conversation", - "de": "Resume conversation", - "uk": "Resume conversation", - "ca": "Resume conversation" + "ja": "会話を再開", + "zh-CN": "恢复对话", + "zh-TW": "恢復對話", + "ko-KR": "대화 재개", + "no": "Gjenoppta samtale", + "ar": "استئناف المحادثة", + "de": "Unterhaltung fortsetzen", + "fr": "Reprendre la conversation", + "it": "Riprendi conversazione", + "pt": "Retomar conversa", + "es": "Reanudar conversación", + "ca": "Reprèn la conversa", + "tr": "Konuşmayı sürdür", + "uk": "Відновити розмову" }, "CHAT_INTERFACE$ACP_RESUME_STARTING": { "en": "Resuming…", - "ja": "Resuming…", - "zh-CN": "Resuming…", - "zh-TW": "Resuming…", - "ko-KR": "Resuming…", - "no": "Resuming…", - "it": "Resuming…", - "pt": "Resuming…", - "es": "Resuming…", - "ar": "Resuming…", - "fr": "Resuming…", - "tr": "Resuming…", - "de": "Resuming…", - "uk": "Resuming…", - "ca": "Resuming…" + "ja": "再開中…", + "zh-CN": "正在恢复…", + "zh-TW": "正在恢復…", + "ko-KR": "재개 중…", + "no": "Gjenopptar…", + "ar": "جارٍ الاستئناف…", + "de": "Wird fortgesetzt…", + "fr": "Reprise…", + "it": "Ripresa in corso…", + "pt": "Retomando…", + "es": "Reanudando…", + "ca": "S'està reprenent…", + "tr": "Sürdürülüyor…", + "uk": "Відновлення…" }, "CHAT_INTERFACE$ERROR_SANDBOX_TITLE": { "en": "Sandbox error", diff --git a/src/stores/model-store.ts b/src/stores/model-store.ts index da9a6b90f..0b6ff5211 100644 --- a/src/stores/model-store.ts +++ b/src/stores/model-store.ts @@ -15,6 +15,17 @@ export interface ModelListEntry { switchedTo?: string; } +/** + * A historical switch to seed via `seedSwitches`. `id` must be stable across + * reloads (derived from the source observation event id) so re-seeding the + * same loaded history is idempotent. + */ +export interface SeededSwitch { + id: string; + anchorEventId: string | null; + profileName: string; +} + interface ModelState { entriesByConversation: Record; /** @@ -37,6 +48,12 @@ interface ModelActions { anchorEventId: string | null, profileName: string, ) => void; + /** + * Seeds "Switched to" entries reconstructed from loaded history. Skips any + * whose `id` is already present, so it can run on every history (re)load + * without duplicating, and it preserves live-recorded entries. + */ + seedSwitches: (conversationId: string, switches: SeededSwitch[]) => void; /** Drops only the optimistic active-profile entry for a conversation. */ clearActiveProfile: (conversationId: string) => void; clear: (conversationId: string) => void; @@ -85,6 +102,28 @@ export const useModelStore = create()( [conversationId]: profileName, }, })), + seedSwitches: (conversationId, switches) => + set((s) => { + const existing = s.entriesByConversation[conversationId] ?? []; + const existingIds = new Set(existing.map((e) => e.id)); + const additions = switches + .filter((sw) => !existingIds.has(sw.id)) + .map( + (sw): ModelListEntry => ({ + id: sw.id, + anchorEventId: sw.anchorEventId, + profiles: [], + switchedTo: sw.profileName, + }), + ); + if (additions.length === 0) return s; + return { + entriesByConversation: { + ...s.entriesByConversation, + [conversationId]: [...existing, ...additions], + }, + }; + }), clearActiveProfile: (conversationId) => set((s) => { if (!(conversationId in s.activeProfileByConversation)) return s; diff --git a/src/types/agent-server/core/base/action.ts b/src/types/agent-server/core/base/action.ts index 6c5eba9f4..2595c1cb6 100644 --- a/src/types/agent-server/core/base/action.ts +++ b/src/types/agent-server/core/base/action.ts @@ -277,6 +277,25 @@ export interface InvokeSkillAction extends ActionBase<"InvokeSkillAction"> { name: string; } +export interface TaskAction extends ActionBase<"TaskAction"> { + /** + * The task/query the parent agent sends to the spawned subagent. + */ + prompt: string; + /** + * The type of specialized subagent to handle the task. + */ + subagent_type: string; + /** + * Short (3-5 word) description of the task. + */ + description?: string | null; + /** + * Task id to resume from, when continuing an existing subagent task. + */ + resume?: string | null; +} + export interface SwitchLLMAction extends ActionBase<"SwitchLLMAction"> { /** * Name of the saved LLM profile to use for future agent steps. @@ -322,5 +341,6 @@ export type Action = | GlobAction | GrepAction | InvokeSkillAction + | TaskAction | SwitchLLMAction | CanvasUIAction; diff --git a/src/types/agent-server/core/base/base.ts b/src/types/agent-server/core/base/base.ts index b3bc4ee3a..2f1700a58 100644 --- a/src/types/agent-server/core/base/base.ts +++ b/src/types/agent-server/core/base/base.ts @@ -33,13 +33,19 @@ type ActionEventType = | `${ActionOnlyType}Action` | `${EventType}Action` | "GlobAction" - | "GrepAction"; + | "GrepAction" + // The `task` tool delegating work to a spawned subagent. + | "TaskAction"; type ObservationEventType = | `${ObservationOnlyType}Observation` | `${EventType}Observation` | "TerminalObservation" | "GlobObservation" - | "GrepObservation"; + | "GrepObservation" + // Result of the `task` tool, which delegates work to a spawned subagent. + | "TaskObservation" + // Acknowledgement emitted after a `canvas_ui` command is dispatched. + | "CanvasUIObservation"; export interface ActionBase { kind: T; diff --git a/src/types/agent-server/core/base/observation.ts b/src/types/agent-server/core/base/observation.ts index 38461811a..6fc6d5127 100644 --- a/src/types/agent-server/core/base/observation.ts +++ b/src/types/agent-server/core/base/observation.ts @@ -109,6 +109,12 @@ export interface TerminalObservation extends ObservationBase<"TerminalObservatio } export interface FileEditorObservation extends ObservationBase<"FileEditorObservation"> { + /** + * Content returned from the tool as TextContent/ImageContent. For `view` + * commands this carries the `cat -n` snippet the agent saw; `output`, + * `old_content`, and `new_content` are not populated for views. + */ + content?: Array; /** * The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`. */ @@ -141,6 +147,12 @@ export interface FileEditorObservation extends ObservationBase<"FileEditorObserv // Keep StrReplaceEditorObservation as a separate interface for backward compatibility export interface StrReplaceEditorObservation extends ObservationBase<"StrReplaceEditorObservation"> { + /** + * Content returned from the tool as TextContent/ImageContent. For `view` + * commands this carries the `cat -n` snippet the agent saw; `output`, + * `old_content`, and `new_content` are not populated for views. + */ + content?: Array; /** * The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`. */ @@ -290,6 +302,40 @@ export interface InvokeSkillObservation extends ObservationBase<"InvokeSkillObse is_error?: boolean; } +export interface TaskObservation extends ObservationBase<"TaskObservation"> { + /** + * Rendered result the spawned subagent returned to the parent agent. + */ + content: Array; + /** + * Whether the delegated task resulted in an error. + */ + is_error?: boolean; + /** + * Identifier of the delegated task. + */ + task_id: string; + /** + * Name of the subagent that handled the task. + */ + subagent: string; + /** + * Lifecycle status of the task (e.g. "completed"). + */ + status: string; +} + +export interface CanvasUIObservation extends ObservationBase<"CanvasUIObservation"> { + /** + * Acknowledgement text returned after the canvas UI command is dispatched. + */ + content: Array; + /** + * Whether dispatching the canvas UI command resulted in an error. + */ + is_error?: boolean; +} + export interface SwitchLLMObservation extends ObservationBase<"SwitchLLMObservation"> { /** * Content returned from the switch LLM tool. @@ -327,4 +373,6 @@ export type Observation = | GlobObservation | GrepObservation | InvokeSkillObservation + | TaskObservation + | CanvasUIObservation | SwitchLLMObservation;