diff --git a/frontend/__tests__/components/v1/chat/event-content-helpers/get-observation-content.test.ts b/frontend/__tests__/components/v1/chat/event-content-helpers/get-observation-content.test.ts index 9e2da14a26..cebdbdab0c 100644 --- a/frontend/__tests__/components/v1/chat/event-content-helpers/get-observation-content.test.ts +++ b/frontend/__tests__/components/v1/chat/event-content-helpers/get-observation-content.test.ts @@ -1,7 +1,11 @@ import { describe, it, expect } from "vitest"; import { getObservationContent } from "#/components/v1/chat/event-content-helpers/get-observation-content"; import { ObservationEvent } from "#/types/v1/core"; -import { BrowserObservation } from "#/types/v1/core/base/observation"; +import { + BrowserObservation, + GlobObservation, + GrepObservation, +} from "#/types/v1/core/base/observation"; describe("getObservationContent - BrowserObservation", () => { it("should return output content when available", () => { @@ -90,3 +94,212 @@ describe("getObservationContent - BrowserObservation", () => { expect(result).toBe("**Output:**\nPage loaded successfully"); }); }); + +describe("getObservationContent - GlobObservation", () => { + it("should display files found when glob matches files", () => { + // Arrange + const mockEvent: ObservationEvent = { + id: "test-id", + timestamp: "2024-01-01T00:00:00Z", + source: "environment", + tool_name: "glob", + tool_call_id: "call-id", + action_id: "action-id", + observation: { + kind: "GlobObservation", + content: [{ type: "text", text: "Found 2 files", cache_prompt: false }], + is_error: false, + files: ["/workspace/src/index.ts", "/workspace/src/app.ts"], + pattern: "**/*.ts", + search_path: "/workspace", + truncated: false, + }, + }; + + // Act + const result = getObservationContent(mockEvent); + + // Assert + expect(result).toContain("**Pattern:** `**/*.ts`"); + expect(result).toContain("**Search Path:** `/workspace`"); + expect(result).toContain("**Files Found (2):**"); + expect(result).toContain("- `/workspace/src/index.ts`"); + expect(result).toContain("- `/workspace/src/app.ts`"); + }); + + it("should display no files found message when glob matches nothing", () => { + // Arrange + const mockEvent: ObservationEvent = { + id: "test-id", + timestamp: "2024-01-01T00:00:00Z", + source: "environment", + tool_name: "glob", + tool_call_id: "call-id", + action_id: "action-id", + observation: { + kind: "GlobObservation", + content: [{ type: "text", text: "No files found", cache_prompt: false }], + is_error: false, + files: [], + pattern: "**/*.xyz", + search_path: "/workspace", + truncated: false, + }, + }; + + // Act + const result = getObservationContent(mockEvent); + + // Assert + expect(result).toContain("**Pattern:** `**/*.xyz`"); + expect(result).toContain("**Result:** No files found."); + }); + + it("should display error when glob operation fails", () => { + // Arrange + const mockEvent: ObservationEvent = { + id: "test-id", + timestamp: "2024-01-01T00:00:00Z", + source: "environment", + tool_name: "glob", + tool_call_id: "call-id", + action_id: "action-id", + observation: { + kind: "GlobObservation", + content: [{ type: "text", text: "Permission denied", cache_prompt: false }], + is_error: true, + files: [], + pattern: "**/*", + search_path: "/restricted", + truncated: false, + }, + }; + + // Act + const result = getObservationContent(mockEvent); + + // Assert + expect(result).toContain("**Error:**"); + expect(result).toContain("Permission denied"); + }); + + it("should indicate truncation when results exceed limit", () => { + // Arrange + const mockEvent: ObservationEvent = { + id: "test-id", + timestamp: "2024-01-01T00:00:00Z", + source: "environment", + tool_name: "glob", + tool_call_id: "call-id", + action_id: "action-id", + observation: { + kind: "GlobObservation", + content: [{ type: "text", text: "Found files", cache_prompt: false }], + is_error: false, + files: ["/workspace/file1.ts"], + pattern: "**/*.ts", + search_path: "/workspace", + truncated: true, + }, + }; + + // Act + const result = getObservationContent(mockEvent); + + // Assert + expect(result).toContain("**Files Found (1+, truncated):**"); + }); +}); + +describe("getObservationContent - GrepObservation", () => { + it("should display matches found when grep finds results", () => { + // Arrange + const mockEvent: ObservationEvent = { + id: "test-id", + timestamp: "2024-01-01T00:00:00Z", + source: "environment", + tool_name: "grep", + tool_call_id: "call-id", + action_id: "action-id", + observation: { + kind: "GrepObservation", + content: [{ type: "text", text: "Found 2 matches", cache_prompt: false }], + is_error: false, + matches: ["/workspace/src/api.ts", "/workspace/src/routes.ts"], + pattern: "fetchData", + search_path: "/workspace", + include_pattern: "*.ts", + truncated: false, + }, + }; + + // Act + const result = getObservationContent(mockEvent); + + // Assert + expect(result).toContain("**Pattern:** `fetchData`"); + expect(result).toContain("**Search Path:** `/workspace`"); + expect(result).toContain("**Include:** `*.ts`"); + expect(result).toContain("**Matches (2):**"); + expect(result).toContain("- `/workspace/src/api.ts`"); + }); + + it("should display no matches found when grep finds nothing", () => { + // Arrange + const mockEvent: ObservationEvent = { + id: "test-id", + timestamp: "2024-01-01T00:00:00Z", + source: "environment", + tool_name: "grep", + tool_call_id: "call-id", + action_id: "action-id", + observation: { + kind: "GrepObservation", + content: [{ type: "text", text: "No matches", cache_prompt: false }], + is_error: false, + matches: [], + pattern: "nonExistentFunction", + search_path: "/workspace", + include_pattern: null, + truncated: false, + }, + }; + + // Act + const result = getObservationContent(mockEvent); + + // Assert + expect(result).toContain("**Pattern:** `nonExistentFunction`"); + expect(result).toContain("**Result:** No matches found."); + expect(result).not.toContain("**Include:**"); + }); + + it("should display error when grep operation fails", () => { + // Arrange + const mockEvent: ObservationEvent = { + id: "test-id", + timestamp: "2024-01-01T00:00:00Z", + source: "environment", + tool_name: "grep", + tool_call_id: "call-id", + action_id: "action-id", + observation: { + kind: "GrepObservation", + content: [{ type: "text", text: "Invalid regex pattern", cache_prompt: false }], + is_error: true, + matches: [], + pattern: "[invalid", + search_path: "/workspace", + include_pattern: null, + truncated: false, + }, + }; + + // Act + const result = getObservationContent(mockEvent); + + // Assert + expect(result).toContain("**Error:**"); + expect(result).toContain("Invalid regex pattern"); + }); +}); diff --git a/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx b/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx index dec57f385f..88f546d5b1 100644 --- a/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx +++ b/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx @@ -162,6 +162,22 @@ const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => { case "ThinkObservation": observationKey = "OBSERVATION_MESSAGE$THINK"; break; + case "GlobObservation": + observationKey = "OBSERVATION_MESSAGE$GLOB"; + observationValues = { + pattern: event.observation.pattern + ? trimText(event.observation.pattern, 50) + : "", + }; + break; + case "GrepObservation": + observationKey = "OBSERVATION_MESSAGE$GREP"; + observationValues = { + pattern: event.observation.pattern + ? trimText(event.observation.pattern, 50) + : "", + }; + break; default: // For unknown observations, use the type name return observationType.replace("Observation", "").toUpperCase(); diff --git a/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts b/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts index 7fb1c2ce1c..082cef7d74 100644 --- a/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts +++ b/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts @@ -12,6 +12,8 @@ import { FileEditorObservation, StrReplaceEditorObservation, TaskTrackerObservation, + GlobObservation, + GrepObservation, } from "#/types/v1/core/base/observation"; // File Editor Observations @@ -221,6 +223,72 @@ const getFinishObservationContent = ( return content; }; +// Glob Observations +const getGlobObservationContent = ( + event: ObservationEvent, +): string => { + const { observation } = event; + + // Extract text content from the observation + const textContent = observation.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); + + let content = `**Pattern:** \`${observation.pattern}\`\n`; + content += `**Search Path:** \`${observation.search_path}\`\n\n`; + + if (observation.is_error) { + content += `**Error:**\n${textContent}`; + } else if (observation.files.length === 0) { + content += "**Result:** No files found."; + } else { + content += `**Files Found (${observation.files.length}${observation.truncated ? "+, truncated" : ""}):**\n`; + content += observation.files.map((f) => `- \`${f}\``).join("\n"); + } + + if (content.length > MAX_CONTENT_LENGTH) { + content = `${content.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`; + } + + return content; +}; + +// Grep Observations +const getGrepObservationContent = ( + event: ObservationEvent, +): string => { + const { observation } = event; + + // Extract text content from the observation + const textContent = observation.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); + + let content = `**Pattern:** \`${observation.pattern}\`\n`; + content += `**Search Path:** \`${observation.search_path}\`\n`; + if (observation.include_pattern) { + content += `**Include:** \`${observation.include_pattern}\`\n`; + } + content += "\n"; + + if (observation.is_error) { + content += `**Error:**\n${textContent}`; + } else if (observation.matches.length === 0) { + content += "**Result:** No matches found."; + } else { + content += `**Matches (${observation.matches.length}${observation.truncated ? "+, truncated" : ""}):**\n`; + content += observation.matches.map((f) => `- \`${f}\``).join("\n"); + } + + if (content.length > MAX_CONTENT_LENGTH) { + content = `${content.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`; + } + + return content; +}; + export const getObservationContent = (event: ObservationEvent): string => { const observationType = event.observation.kind; @@ -264,6 +332,16 @@ export const getObservationContent = (event: ObservationEvent): string => { event as ObservationEvent, ); + case "GlobObservation": + return getGlobObservationContent( + event as ObservationEvent, + ); + + case "GrepObservation": + return getGrepObservationContent( + event as ObservationEvent, + ); + default: return getDefaultEventContent(event); } diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index aac6e1b0f6..10e9d885fd 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -536,6 +536,8 @@ export enum I18nKey { OBSERVATION_MESSAGE$MCP = "OBSERVATION_MESSAGE$MCP", OBSERVATION_MESSAGE$RECALL = "OBSERVATION_MESSAGE$RECALL", OBSERVATION_MESSAGE$THINK = "OBSERVATION_MESSAGE$THINK", + OBSERVATION_MESSAGE$GLOB = "OBSERVATION_MESSAGE$GLOB", + OBSERVATION_MESSAGE$GREP = "OBSERVATION_MESSAGE$GREP", OBSERVATION_MESSAGE$TASK_TRACKING_PLAN = "OBSERVATION_MESSAGE$TASK_TRACKING_PLAN", OBSERVATION_MESSAGE$TASK_TRACKING_VIEW = "OBSERVATION_MESSAGE$TASK_TRACKING_VIEW", EXPANDABLE_MESSAGE$SHOW_DETAILS = "EXPANDABLE_MESSAGE$SHOW_DETAILS", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 129784a357..abeba30110 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -8575,6 +8575,38 @@ "de": "Gedanke", "uk": "Думка" }, + "OBSERVATION_MESSAGE$GLOB": { + "en": "Glob: {{pattern}}", + "ja": "Glob: {{pattern}}", + "zh-CN": "Glob: {{pattern}}", + "zh-TW": "Glob: {{pattern}}", + "ko-KR": "Glob: {{pattern}}", + "no": "Glob: {{pattern}}", + "it": "Glob: {{pattern}}", + "pt": "Glob: {{pattern}}", + "es": "Glob: {{pattern}}", + "ar": "Glob: {{pattern}}", + "fr": "Glob: {{pattern}}", + "tr": "Glob: {{pattern}}", + "de": "Glob: {{pattern}}", + "uk": "Glob: {{pattern}}" + }, + "OBSERVATION_MESSAGE$GREP": { + "en": "Grep: {{pattern}}", + "ja": "Grep: {{pattern}}", + "zh-CN": "Grep: {{pattern}}", + "zh-TW": "Grep: {{pattern}}", + "ko-KR": "Grep: {{pattern}}", + "no": "Grep: {{pattern}}", + "it": "Grep: {{pattern}}", + "pt": "Grep: {{pattern}}", + "es": "Grep: {{pattern}}", + "ar": "Grep: {{pattern}}", + "fr": "Grep: {{pattern}}", + "tr": "Grep: {{pattern}}", + "de": "Grep: {{pattern}}", + "uk": "Grep: {{pattern}}" + }, "OBSERVATION_MESSAGE$TASK_TRACKING_PLAN": { "en": "Agent updated the plan", "zh-CN": "代理更新了计划", diff --git a/frontend/src/types/v1/core/base/base.ts b/frontend/src/types/v1/core/base/base.ts index 7704f1105d..531168d17b 100644 --- a/frontend/src/types/v1/core/base/base.ts +++ b/frontend/src/types/v1/core/base/base.ts @@ -27,7 +27,9 @@ type ActionEventType = `${ActionOnlyType}Action` | `${EventType}Action`; type ObservationEventType = | `${ObservationOnlyType}Observation` | `${EventType}Observation` - | "TerminalObservation"; + | "TerminalObservation" + | "GlobObservation" + | "GrepObservation"; export interface ActionBase { kind: T; diff --git a/frontend/src/types/v1/core/base/observation.ts b/frontend/src/types/v1/core/base/observation.ts index a1c8a1a48d..ac2a90fdf2 100644 --- a/frontend/src/types/v1/core/base/observation.ts +++ b/frontend/src/types/v1/core/base/observation.ts @@ -217,6 +217,64 @@ export interface PlanningFileEditorObservation extends ObservationBase<"Planning new_content: string | null; } +export interface GlobObservation extends ObservationBase<"GlobObservation"> { + /** + * Content returned from the tool as a list of TextContent/ImageContent objects. + */ + content: Array; + /** + * Whether the call resulted in an error. + */ + is_error: boolean; + /** + * List of matching file paths sorted by modification time. + */ + files: string[]; + /** + * The glob pattern that was used. + */ + pattern: string; + /** + * The directory that was searched. + */ + search_path: string; + /** + * Whether results were truncated to 100 files. + */ + truncated: boolean; +} + +export interface GrepObservation extends ObservationBase<"GrepObservation"> { + /** + * Content returned from the tool as a list of TextContent/ImageContent objects. + */ + content: Array; + /** + * Whether the call resulted in an error. + */ + is_error: boolean; + /** + * List of file paths containing the pattern. + */ + matches: string[]; + /** + * The regex pattern that was used. + */ + pattern: string; + /** + * The directory that was searched. + */ + search_path: string; + /** + * The file pattern filter that was used. + */ + include_pattern: string | null; + /** + * Whether results were truncated to 100 files. + */ + truncated: boolean; +} + export type Observation = | MCPToolObservation | FinishObservation @@ -227,4 +285,6 @@ export type Observation = | FileEditorObservation | StrReplaceEditorObservation | TaskTrackerObservation - | PlanningFileEditorObservation; + | PlanningFileEditorObservation + | GlobObservation + | GrepObservation;