fix(frontend): add rendering support for GlobObservation and GrepObservation events (#13379)

This commit is contained in:
Hiep Le
2026-03-14 19:56:57 +07:00
committed by GitHub
parent a8ff720b40
commit c66a112bf5
7 changed files with 406 additions and 3 deletions

View File

@@ -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<GlobObservation> = {
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<GlobObservation> = {
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<GlobObservation> = {
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<GlobObservation> = {
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<GrepObservation> = {
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<GrepObservation> = {
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<GrepObservation> = {
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");
});
});

View File

@@ -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();

View File

@@ -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<GlobObservation>,
): 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<GrepObservation>,
): 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<FinishObservation>,
);
case "GlobObservation":
return getGlobObservationContent(
event as ObservationEvent<GlobObservation>,
);
case "GrepObservation":
return getGrepObservationContent(
event as ObservationEvent<GrepObservation>,
);
default:
return getDefaultEventContent(event);
}

View File

@@ -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",

View File

@@ -8575,6 +8575,38 @@
"de": "Gedanke",
"uk": "Думка"
},
"OBSERVATION_MESSAGE$GLOB": {
"en": "Glob: <cmd>{{pattern}}</cmd>",
"ja": "Glob: <cmd>{{pattern}}</cmd>",
"zh-CN": "Glob: <cmd>{{pattern}}</cmd>",
"zh-TW": "Glob: <cmd>{{pattern}}</cmd>",
"ko-KR": "Glob: <cmd>{{pattern}}</cmd>",
"no": "Glob: <cmd>{{pattern}}</cmd>",
"it": "Glob: <cmd>{{pattern}}</cmd>",
"pt": "Glob: <cmd>{{pattern}}</cmd>",
"es": "Glob: <cmd>{{pattern}}</cmd>",
"ar": "Glob: <cmd>{{pattern}}</cmd>",
"fr": "Glob: <cmd>{{pattern}}</cmd>",
"tr": "Glob: <cmd>{{pattern}}</cmd>",
"de": "Glob: <cmd>{{pattern}}</cmd>",
"uk": "Glob: <cmd>{{pattern}}</cmd>"
},
"OBSERVATION_MESSAGE$GREP": {
"en": "Grep: <cmd>{{pattern}}</cmd>",
"ja": "Grep: <cmd>{{pattern}}</cmd>",
"zh-CN": "Grep: <cmd>{{pattern}}</cmd>",
"zh-TW": "Grep: <cmd>{{pattern}}</cmd>",
"ko-KR": "Grep: <cmd>{{pattern}}</cmd>",
"no": "Grep: <cmd>{{pattern}}</cmd>",
"it": "Grep: <cmd>{{pattern}}</cmd>",
"pt": "Grep: <cmd>{{pattern}}</cmd>",
"es": "Grep: <cmd>{{pattern}}</cmd>",
"ar": "Grep: <cmd>{{pattern}}</cmd>",
"fr": "Grep: <cmd>{{pattern}}</cmd>",
"tr": "Grep: <cmd>{{pattern}}</cmd>",
"de": "Grep: <cmd>{{pattern}}</cmd>",
"uk": "Grep: <cmd>{{pattern}}</cmd>"
},
"OBSERVATION_MESSAGE$TASK_TRACKING_PLAN": {
"en": "Agent updated the plan",
"zh-CN": "代理更新了计划",

View File

@@ -27,7 +27,9 @@ type ActionEventType = `${ActionOnlyType}Action` | `${EventType}Action`;
type ObservationEventType =
| `${ObservationOnlyType}Observation`
| `${EventType}Observation`
| "TerminalObservation";
| "TerminalObservation"
| "GlobObservation"
| "GrepObservation";
export interface ActionBase<T extends ActionEventType = ActionEventType> {
kind: T;

View File

@@ -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<TextContent | ImageContent>;
/**
* 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<TextContent | ImageContent>;
/**
* 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;