Skip to content

Commit 464e98c

Browse files
committed
Merge branch 'main' into rel-1.9.0
2 parents eaedb18 + 39919ec commit 464e98c

8 files changed

Lines changed: 153 additions & 43 deletions

File tree

openhands_cli/acp_impl/events/shared_event_handler.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,11 @@ async def handle_observation(
164164
async def handle_action_event(self, ctx: _ACPContext, event: ActionEvent):
165165
content = None
166166
tool_kind = get_tool_kind(tool_name=event.tool_name, action=event.action)
167-
title = get_tool_title(tool_name=event.tool_name, action=event.action)
167+
# Use LLM-generated summary for the title when available
168+
summary = str(event.summary) if event.summary else None
169+
title = get_tool_title(
170+
tool_name=event.tool_name, action=event.action, summary=summary
171+
)
168172
if event.action:
169173
action_viz = _event_visualize_to_plain(event)
170174
content = format_content_blocks(action_viz)

openhands_cli/acp_impl/events/token_streamer.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,40 @@ def _prune_tool_call_state(self, tool_call_id: str) -> None:
108108
for idx in indices_to_remove:
109109
del self._streaming_tool_calls[idx]
110110

111+
def _is_tool_call_started(self, tool_call_id: str) -> bool:
112+
"""Check if a tool call was already started during streaming."""
113+
return any(
114+
state.tool_call_id == tool_call_id and state.started
115+
for state in self._streaming_tool_calls.values()
116+
)
117+
118+
async def _update_tool_call_with_summary(self, event: ActionEvent) -> None:
119+
"""Update an existing tool call with the LLM-generated summary.
120+
121+
This is called when an ActionEvent arrives for a tool call that was
122+
already started during streaming. We update the title to include
123+
the summary for better context.
124+
"""
125+
from openhands_cli.acp_impl.events.utils import get_tool_kind, get_tool_title
126+
127+
summary = str(event.summary) if event.summary else None
128+
title = get_tool_title(
129+
tool_name=event.tool_name, action=event.action, summary=summary
130+
)
131+
kind = get_tool_kind(tool_name=event.tool_name, action=event.action)
132+
133+
await self.conn.session_update(
134+
session_id=self.session_id,
135+
update=update_tool_call(
136+
tool_call_id=event.tool_call_id,
137+
title=title,
138+
kind=kind,
139+
status="in_progress",
140+
content=format_content_blocks(str(event.visualize.plain)),
141+
),
142+
field_meta=get_metadata(self.conversation),
143+
)
144+
111145
async def unstreamed_event_handler(self, event: Event):
112146
# Skip ConversationStateUpdateEvent (internal state management)
113147
if isinstance(event, ConversationStateUpdateEvent):
@@ -117,7 +151,13 @@ async def unstreamed_event_handler(self, event: Event):
117151
self.reset_header_state()
118152

119153
if isinstance(event, ActionEvent):
120-
await self.shared_events_handler.handle_action_event(self, event)
154+
# Check if this tool call was already started during streaming
155+
if self._is_tool_call_started(event.tool_call_id):
156+
# Update the existing tool call with the summary
157+
await self._update_tool_call_with_summary(event)
158+
else:
159+
# Start a new tool call (non-streaming case)
160+
await self.shared_events_handler.handle_action_event(self, event)
121161
if isinstance(event, UserRejectObservation) or isinstance(
122162
event, AgentErrorEvent
123163
):
@@ -257,7 +297,6 @@ def _handle_tool_call_streaming(self, tool_call) -> None:
257297
)
258298
self._schedule_update(tool_call_start)
259299

260-
# Emit progress updates after start
261300
if state.started and arguments_chunk:
262301
self._schedule_update(
263302
update_tool_call(

openhands_cli/acp_impl/events/tool_state.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ def __init__(self, tool_call_id: str, tool_name: str):
2929
self._valid_skeleton_cached = False
3030
# Kind is cached once skeleton is valid (depends only on command, not path)
3131
self._cached_kind: ToolKind | None = None
32+
# Incrementally streamed summary (from assistant content prior to tool call)
33+
self.summary: str = ""
3234

3335
def append_args(self, args_part: str) -> None:
3436
"""Append new arguments part to the accumulated args and lexer."""
@@ -130,24 +132,36 @@ def _compute_title(self) -> str:
130132
return "Plan updated"
131133

132134
args = self._parse_args()
135+
clean_summary = self.summary.strip().replace("\n", " ") if self.summary else ""
136+
137+
# If no args yet, fall back to summary or tool name
133138
if not args:
134-
return self.tool_name
139+
return clean_summary or self.tool_name
135140

136141
if self.tool_name == "file_editor":
137142
path = args.get("path")
138143
command = args.get("command")
139144
if isinstance(path, str) and path:
140145
# Prefix match: streaming may yield "v", "vi", etc. before full "view"
141146
if isinstance(command, str) and "view".startswith(command):
142-
return f"Reading {path}"
143-
return f"Editing {path}"
147+
return (
148+
f"{clean_summary}: Reading {path}"
149+
if clean_summary
150+
else f"Reading {path}"
151+
)
152+
return (
153+
f"{clean_summary}: Editing {path}"
154+
if clean_summary
155+
else f"Editing {path}"
156+
)
144157

145158
if self.tool_name == "terminal":
146159
command = args.get("command")
147160
if isinstance(command, str) and command:
148-
return command
161+
return f"{clean_summary}: $ {command}" if clean_summary else command
149162

150-
return self.tool_name
163+
# Other tools: prefer summary if present
164+
return clean_summary or self.tool_name
151165

152166
def _parse_args(self) -> dict | None:
153167
"""Parse current args using lexer's best-effort completion."""

openhands_cli/acp_impl/events/utils.py

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
from openhands.tools.file_editor.definition import (
1212
FileEditorAction,
1313
)
14-
from openhands.tools.task_tracker import TaskTrackerAction
1514
from openhands.tools.terminal import TerminalAction
1615
from openhands_cli.utils import abbreviate_number, format_cost
1716

@@ -160,23 +159,42 @@ def get_tool_kind(tool_name: str, *, action: Action | None = None) -> ToolKind:
160159
return TOOL_KIND_MAPPING.get(tool_name, "other")
161160

162161

163-
def get_tool_title(tool_name: str, *, action: Action | None = None) -> str:
164-
"""Get tool title from tool name and optional complete action.
162+
def get_tool_title(
163+
tool_name: str, *, action: Action | None = None, summary: str | None = None
164+
) -> str:
165+
"""Get tool title from tool name, action, and optional LLM-generated summary.
166+
167+
When a summary is provided, it is used as the primary title with action
168+
details appended for context. This matches the TUI behavior.
165169
166170
For streaming tool calls, use ToolCallState.title instead.
171+
172+
Args:
173+
tool_name: The name of the tool being called
174+
action: Optional complete action object for extracting details
175+
summary: Optional LLM-generated summary describing the action's purpose
176+
177+
Returns:
178+
A descriptive title for the tool call
167179
"""
168-
if tool_name == "task_tracker":
169-
return "Plan updated"
180+
# Clean up summary if provided
181+
clean_summary = summary.strip().replace("\n", " ") if summary else ""
170182

171183
if isinstance(action, FileEditorAction):
172-
if action.command == "view":
173-
return f"Reading {action.path}"
174-
return f"Editing {action.path}"
184+
op = "Reading" if action.command == "view" else "Editing"
185+
path = action.path or ""
186+
if clean_summary:
187+
return f"{clean_summary}: {op} {path}"
188+
return f"{op} {path}"
175189

176190
if isinstance(action, TerminalAction):
177-
return f"{action.command}"
178-
179-
if isinstance(action, TaskTrackerAction):
180-
return "Plan updated"
191+
cmd = action.command.strip().replace("\n", " ") if action.command else ""
192+
if clean_summary:
193+
return f"{clean_summary}: $ {cmd}"
194+
return f"$ {cmd}" if cmd else tool_name
195+
196+
# For other actions, use summary if available
197+
if clean_summary:
198+
return clean_summary
181199

182200
return ""

tests/acp/events/test_event_subscriber.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ class MockEvent:
9393
action = MockAction()
9494
tool_call = MockToolCall()
9595
visualize = Text("Executing test action")
96+
summary = "Running a test command"
9697

9798
event = MockEvent()
9899

tests/acp/events/test_event_utils.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ def test_file_editor_kind_from_action(self, command: str, expected: str):
5151

5252

5353
class TestGetToolTitle:
54-
def test_task_tracker_title_constant(self):
55-
assert get_tool_title("task_tracker") == "Plan updated"
54+
def test_task_tracker_title_empty_without_action(self):
55+
assert get_tool_title("task_tracker") == ""
5656

5757
@pytest.mark.parametrize(
5858
"action,expected",
@@ -65,8 +65,8 @@ def test_task_tracker_title_constant(self):
6565
FileEditorAction(command="str_replace", path="/src/main.py"),
6666
"Editing /src/main.py",
6767
),
68-
(TerminalAction(command="git status"), "git status"),
69-
(TaskTrackerAction(command="plan", task_list=[]), "Plan updated"),
68+
(TerminalAction(command="git status"), "$ git status"),
69+
(TaskTrackerAction(command="plan", task_list=[]), ""),
7070
],
7171
)
7272
def test_title_from_action(self, action, expected: str):
@@ -83,6 +83,37 @@ def test_title_from_action(self, action, expected: str):
8383
def test_title_no_action_returns_empty(self, tool_name: str):
8484
assert get_tool_title(tool_name) == ""
8585

86+
def test_title_with_summary_terminal(self):
87+
"""Test that summary is included in terminal action title."""
88+
action = TerminalAction(command="git status")
89+
title = get_tool_title("terminal", action=action, summary="Check git status")
90+
assert title == "Check git status: $ git status"
91+
92+
def test_title_with_summary_file_editor_view(self):
93+
"""Test that summary is included in file editor view title."""
94+
action = FileEditorAction(command="view", path="/src/main.py")
95+
title = get_tool_title("file_editor", action=action, summary="Read main file")
96+
assert title == "Read main file: Reading /src/main.py"
97+
98+
def test_title_with_summary_file_editor_edit(self):
99+
"""Test that summary is included in file editor edit title."""
100+
action = FileEditorAction(command="str_replace", path="/src/main.py")
101+
title = get_tool_title("file_editor", action=action, summary="Fix bug in main")
102+
assert title == "Fix bug in main: Editing /src/main.py"
103+
104+
def test_title_with_summary_only(self):
105+
"""Test that summary alone is used when no action details available."""
106+
title = get_tool_title("unknown_tool", summary="Do something important")
107+
assert title == "Do something important"
108+
109+
def test_title_with_summary_newlines_stripped(self):
110+
"""Test that newlines in summary are replaced with spaces."""
111+
action = TerminalAction(command="ls")
112+
title = get_tool_title(
113+
"terminal", action=action, summary="List files\nin directory"
114+
)
115+
assert title == "List files in directory: $ ls"
116+
86117

87118
class TestFormatContentBlocks:
88119
@pytest.mark.parametrize(

tests/acp/events/test_token_streamer.py

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -671,14 +671,15 @@ def test_schedule_update_loop_running_uses_run_coroutine_threadsafe(
671671
loop.close()
672672

673673

674-
@pytest.mark.asyncio
675-
async def test_terminal_tool_lifecycle_stream_then_action_then_observation():
674+
def test_terminal_tool_lifecycle_stream_then_action_then_observation(
675+
mock_connection, event_loop
676+
):
676677
"""
677678
Lifecycle:
678679
1) on_token streams partial TerminalAction -> emits
679680
ToolCallStart(in_progress) then ToolCallProgress(in_progress)
680681
2) unstreamed ActionEvent arrives (same tool_call_id) -> may emit
681-
another ToolCallStart(in_progress)
682+
ToolCallProgress(in_progress) to update title with summary
682683
3) unstreamed ObservationEvent arrives (same tool_call_id) -> emits
683684
ToolCallProgress(completed)
684685
@@ -687,12 +688,10 @@ async def test_terminal_tool_lifecycle_stream_then_action_then_observation():
687688
tool_call (in_progress) -> tool_call_update (in_progress) ->
688689
tool_call_update (completed)
689690
"""
690-
conn = AsyncMock()
691-
loop = asyncio.get_running_loop()
692691
subscriber = TokenBasedEventSubscriber(
693692
session_id="test-session",
694-
conn=conn,
695-
loop=loop,
693+
conn=mock_connection,
694+
loop=event_loop,
696695
)
697696

698697
tool_call_id = "call-123"
@@ -714,9 +713,10 @@ async def test_terminal_tool_lifecycle_stream_then_action_then_observation():
714713
arguments='and":"ls"}',
715714
)
716715

717-
# with patch.object(loop, "is_running", return_value=False):
718-
subscriber.on_token(_chunk(tool_calls=[tc1]))
719-
subscriber.on_token(_chunk(tool_calls=[tc2]))
716+
# Patch is_running to False so updates are executed synchronously
717+
with patch.object(event_loop, "is_running", return_value=False):
718+
subscriber.on_token(_chunk(tool_calls=[tc1]))
719+
subscriber.on_token(_chunk(tool_calls=[tc2]))
720720

721721
# -----------------------
722722
# 2) Final ActionEvent arrives (same tool_call_id)
@@ -742,7 +742,8 @@ async def test_terminal_tool_lifecycle_stream_then_action_then_observation():
742742
llm_response_id="llm-1",
743743
tool_call=message_tool_call,
744744
)
745-
await subscriber.unstreamed_event_handler(action_event)
745+
with patch.object(event_loop, "is_running", return_value=False):
746+
event_loop.run_until_complete(subscriber.unstreamed_event_handler(action_event))
746747

747748
# -----------------------
748749
# 3) ObservationEvent arrives (same tool_call_id)
@@ -761,14 +762,15 @@ async def test_terminal_tool_lifecycle_stream_then_action_then_observation():
761762
action_id=action_id, # REQUIRED linkage
762763
)
763764

764-
await subscriber.unstreamed_event_handler(obs_event)
765+
with patch.object(event_loop, "is_running", return_value=False):
766+
event_loop.run_until_complete(subscriber.unstreamed_event_handler(obs_event))
765767

766768
# -----------------------
767769
# Assert: status progression
768770
# -----------------------
769771
# Gather ONLY the updates related to our tool_call_id.
770772
updates = []
771-
for call in conn.session_update.call_args_list:
773+
for call in mock_connection.session_update.call_args_list:
772774
update = call.kwargs["update"]
773775
# Filter only tool-call updates for this tool_call_id
774776
if getattr(update, "tool_call_id", None) == tool_call_id:
@@ -777,17 +779,18 @@ async def test_terminal_tool_lifecycle_stream_then_action_then_observation():
777779
assert updates, "Expected at least one ACP update for tool_call_id"
778780

779781
# We want to see:
780-
# - at least one ToolCallStart with status in_progress
781-
# - at least one ToolCallProgress with status in_progress
782+
# - at least one ToolCallStart with status in_progress (from streaming)
783+
# - at least one ToolCallProgress with status in_progress (from streaming or
784+
# ActionEvent updating the title with summary)
782785
# - final ToolCallProgress with status completed
783786
starts = [u for u in updates if isinstance(u, ToolCallStart)]
784787
progresses = [u for u in updates if isinstance(u, ToolCallProgress)]
785788

786789
assert any(isinstance(s, ToolCallStart) for s in starts), (
787-
"Expected a ToolCallStart from streaming and/or ActionEvent"
790+
"Expected a ToolCallStart from streaming"
788791
)
789792
assert any(isinstance(p, ToolCallProgress) for p in progresses), (
790-
"Expected at least one ToolCallProgress during streaming"
793+
"Expected at least one ToolCallProgress during streaming or from ActionEvent"
791794
)
792795

793796
# The last tool-call-related update should be completion.

tests/tui/widgets/test_richlog_visualizer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,8 +338,8 @@ def test_visualizer_handles_mistral_xml_function_call_syntax(self, visualizer):
338338

339339
# Verify that the visualizer successfully created a collapsible widget
340340
title_str = str(collapsible.title)
341-
# Title should show the tool name (new implementation uses clean titles)
342-
assert "execute_bash" in title_str
341+
# For non-terminal/file-editor actions without summary, title is just tool_name
342+
assert "execute_bash" in title_str # The function name should be present
343343
assert len(title_str) > 0 # Title should not be empty
344344

345345

0 commit comments

Comments
 (0)