fix(chat): render InvokeSkill action/observation with skill name (#283)

Previously, an agent's invoke_skill tool call rendered as the literal
'INVOKESKILL' in the chat — the default fallback in getActionEventTitle
was uppercasing the action kind because InvokeSkillAction wasn't a
recognized kind.

Add InvokeSkillAction / InvokeSkillObservation to the core type unions
and route them through the same translation-key machinery as the other
built-in tools, so the title now reads 'Invoking skill <name>' / 'Invoked
skill <name>' and the expanded card shows the rendered skill content
instead of a JSON blob.

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Robert Brennan
2026-05-10 15:08:29 -07:00
committed by GitHub
co-authored by openhands
parent 400be5a3d4
commit 0eb0ca5dfa
8 changed files with 174 additions and 3 deletions
@@ -180,4 +180,65 @@ describe("getEventContent", () => {
expect(screen.getByText("Check repository status")).toBeInTheDocument();
expect(screen.queryByText("$ git status")).not.toBeInTheDocument();
});
it("renders InvokeSkillAction with the skill name instead of 'INVOKESKILL'", () => {
const invokeSkillAction: ActionEvent = {
id: "action-skill",
timestamp: new Date().toISOString(),
source: "agent",
thought: [],
thinking_blocks: [],
action: {
kind: "InvokeSkillAction",
name: "worktree-switch",
},
tool_name: "invoke_skill",
tool_call_id: "tool-skill",
tool_call: {
id: "tool-skill",
type: "function",
function: {
name: "invoke_skill",
arguments: '{"name":"worktree-switch"}',
},
},
llm_response_id: "response-skill",
security_risk: SecurityRisk.LOW,
};
const { title, details } = getEventContent(invokeSkillAction);
render(<span>{title}</span>);
// Without i18n loaded, the translation key renders as the raw key —
// the important thing is that we no longer fall back to "INVOKESKILL".
expect(screen.getByText("ACTION_MESSAGE$INVOKE_SKILL")).toBeInTheDocument();
expect(screen.queryByText("INVOKESKILL")).not.toBeInTheDocument();
expect(details).toContain("worktree-switch");
});
it("renders InvokeSkillObservation with the skill name", () => {
const invokeSkillObservation: ObservationEvent = {
id: "obs-skill",
timestamp: new Date().toISOString(),
source: "environment",
tool_name: "invoke_skill",
tool_call_id: "tool-skill",
action_id: "action-skill",
observation: {
kind: "InvokeSkillObservation",
skill_name: "worktree-switch",
content: [{ type: "text", text: "# Skill content" }],
},
};
const { title, details } = getEventContent(invokeSkillObservation);
render(<span>{title}</span>);
expect(
screen.getByText("OBSERVATION_MESSAGE$INVOKE_SKILL"),
).toBeInTheDocument();
expect(screen.queryByText("INVOKESKILL")).not.toBeInTheDocument();
expect(details).toContain("worktree-switch");
expect(details).toContain("# Skill content");
});
});
@@ -23,6 +23,7 @@ import {
BrowserCloseTabAction,
GlobAction,
GrepAction,
InvokeSkillAction,
} from "#/types/agent-server/core/base/action";
const getRiskText = (risk: SecurityRisk) => {
@@ -41,6 +42,10 @@ const getRiskText = (risk: SecurityRisk) => {
const getNoContentActionContent = (): string => "";
// Invoke-skill actions
const getInvokeSkillActionContent = (action: InvokeSkillAction): string =>
action.name ? `**Skill:** \`${action.name}\`` : getNoContentActionContent();
// Grep/Glob search actions
const getSearchActionContent = (
event: ActionEvent<GlobAction | GrepAction>,
@@ -254,6 +259,9 @@ export const getActionContent = (event: ActionEvent): string => {
event as ActionEvent<GlobAction | GrepAction>,
);
case "InvokeSkillAction":
return getInvokeSkillActionContent(action as InvokeSkillAction);
default:
return getDefaultEventContent(event);
}
@@ -114,6 +114,12 @@ const getActionEventTitle = (event: OpenHandsEvent): React.ReactNode => {
mcp_tool_name: event.tool_name,
};
break;
case "InvokeSkillAction":
actionKey = "ACTION_MESSAGE$INVOKE_SKILL";
actionValues = {
name: event.action.name,
};
break;
case "ThinkAction":
actionKey = "ACTION_MESSAGE$THINK";
break;
@@ -213,6 +219,12 @@ const getObservationEventTitle = (
mcp_tool_name: event.observation.tool_name,
};
break;
case "InvokeSkillObservation":
observationKey = "OBSERVATION_MESSAGE$INVOKE_SKILL";
observationValues = {
name: event.observation.skill_name,
};
break;
case "BrowserObservation":
observationKey = "OBSERVATION_MESSAGE$BROWSE";
break;
@@ -14,6 +14,7 @@ import {
TaskTrackerObservation,
GlobObservation,
GrepObservation,
InvokeSkillObservation,
} from "#/types/agent-server/core/base/observation";
// File Editor Observations
@@ -145,6 +146,31 @@ const getMCPToolObservationContent = (
return content;
};
// Invoke-skill observations
const getInvokeSkillObservationContent = (
event: ObservationEvent<InvokeSkillObservation>,
): string => {
const { observation } = event;
const textContent = observation.content
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("\n");
const header = observation.skill_name
? `**Skill:** \`${observation.skill_name}\`\n\n`
: "";
const body = observation.is_error
? `**Error:**\n${textContent}`
: textContent;
let content = `${header}${body}`;
if (content.length > MAX_CONTENT_LENGTH) {
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`;
}
return content;
};
// Complex Observations
const getTaskTrackerObservationContent = (
event: ObservationEvent<TaskTrackerObservation>,
@@ -342,6 +368,11 @@ export const getObservationContent = (event: ObservationEvent): string => {
event as ObservationEvent<GrepObservation>,
);
case "InvokeSkillObservation":
return getInvokeSkillObservationContent(
event as ObservationEvent<InvokeSkillObservation>,
);
default:
return getDefaultEventContent(event);
}
+34
View File
@@ -12920,6 +12920,40 @@
"uk": "Пошук файлів: <cmd>{{pattern}}</cmd>",
"ca": "Cerca fitxers: <cmd>{{pattern}}</cmd>"
},
"ACTION_MESSAGE$INVOKE_SKILL": {
"en": "Invoking skill <cmd>{{name}}</cmd>",
"ja": "スキルを呼び出し中: <cmd>{{name}}</cmd>",
"zh-CN": "调用技能 <cmd>{{name}}</cmd>",
"zh-TW": "呼叫技能 <cmd>{{name}}</cmd>",
"ko-KR": "스킬 호출 중 <cmd>{{name}}</cmd>",
"no": "Kaller ferdighet <cmd>{{name}}</cmd>",
"it": "Invocazione skill <cmd>{{name}}</cmd>",
"pt": "Invocando skill <cmd>{{name}}</cmd>",
"es": "Invocando skill <cmd>{{name}}</cmd>",
"ar": "استدعاء المهارة <cmd>{{name}}</cmd>",
"fr": "Invocation de la compétence <cmd>{{name}}</cmd>",
"tr": "Yetenek çağrılıyor <cmd>{{name}}</cmd>",
"de": "Skill wird aufgerufen <cmd>{{name}}</cmd>",
"uk": "Виклик навички <cmd>{{name}}</cmd>",
"ca": "Invocant skill <cmd>{{name}}</cmd>"
},
"OBSERVATION_MESSAGE$INVOKE_SKILL": {
"en": "Invoked skill <cmd>{{name}}</cmd>",
"ja": "スキルを呼び出しました: <cmd>{{name}}</cmd>",
"zh-CN": "已调用技能 <cmd>{{name}}</cmd>",
"zh-TW": "已呼叫技能 <cmd>{{name}}</cmd>",
"ko-KR": "스킬 호출됨 <cmd>{{name}}</cmd>",
"no": "Kalte ferdighet <cmd>{{name}}</cmd>",
"it": "Skill invocata <cmd>{{name}}</cmd>",
"pt": "Skill invocada <cmd>{{name}}</cmd>",
"es": "Skill invocada <cmd>{{name}}</cmd>",
"ar": "تم استدعاء المهارة <cmd>{{name}}</cmd>",
"fr": "Compétence invoquée <cmd>{{name}}</cmd>",
"tr": "Yetenek çağrıldı <cmd>{{name}}</cmd>",
"de": "Skill aufgerufen <cmd>{{name}}</cmd>",
"uk": "Навичку викликано <cmd>{{name}}</cmd>",
"ca": "Skill invocada <cmd>{{name}}</cmd>"
},
"OBSERVATION_MESSAGE$RUN": {
"en": "Ran <cmd>{{command}}</cmd>",
"zh-CN": "运行 <cmd>{{command}}</cmd>",
+9 -1
View File
@@ -270,6 +270,13 @@ export interface GrepAction extends ActionBase<"GrepAction"> {
include: string | null;
}
export interface InvokeSkillAction extends ActionBase<"InvokeSkillAction"> {
/**
* Name of the loaded skill to invoke.
*/
name: string;
}
export type Action =
| MCPToolAction
| FinishAction
@@ -291,4 +298,5 @@ export type Action =
| BrowserSwitchTabAction
| BrowserCloseTabAction
| GlobAction
| GrepAction;
| GrepAction
| InvokeSkillAction;
+2 -1
View File
@@ -7,7 +7,8 @@ type EventType =
| "FileEditor"
| "StrReplaceEditor"
| "TaskTracker"
| "PlanningFileEditor";
| "PlanningFileEditor"
| "InvokeSkill";
type ActionOnlyType =
| "BrowserNavigate"
@@ -275,6 +275,21 @@ export interface GrepObservation extends ObservationBase<"GrepObservation"> {
truncated: boolean;
}
export interface InvokeSkillObservation extends ObservationBase<"InvokeSkillObservation"> {
/**
* Name of the skill this observation corresponds to.
*/
skill_name: string;
/**
* Rendered skill content returned to the agent.
*/
content: Array<TextContent | ImageContent>;
/**
* Whether the invocation resulted in an error.
*/
is_error?: boolean;
}
export type Observation =
| MCPToolObservation
| FinishObservation
@@ -287,4 +302,5 @@ export type Observation =
| TaskTrackerObservation
| PlanningFileEditorObservation
| GlobObservation
| GrepObservation;
| GrepObservation
| InvokeSkillObservation;