feat(opencode-native): render live tool-call cards in the web chat UI (#1882)

* feat(opencode-native): render live tool-call cards in the web chat UI

Extend live tool-call cards (spinner + ticking elapsed timer) to
opencode-native sessions, matching claude-native (#1499). The forwarder
already stamps each turn's assistant messageID as the response_id on its
function_call items but never put it on the status edges, so the server
never learned the in-flight turn id and the web rendered static cards.

- _post_status now stamps an optional response_id on the edge.
- Capture the assistant messageID in _on_message_updated; emit a running
  edge carrying it once per turn and stamp the same id on idle.
- Defer the running edge until the id is known (session.status busy can
  precede the assistant message.updated).

Closes #1872

* retrigger CI

* retrigger
CI

* Attach response id to the idle edge

* retrigger
CI
This commit is contained in:
Yi Lyu
2026-07-14 15:24:56 -07:00
committed by GitHub
parent 59a6b068bd
commit b401b722aa
2 changed files with 194 additions and 7 deletions
+60 -7
View File
@@ -181,6 +181,14 @@ class OpenCodeNativeForwarder:
# the cumulative reasoning text on each ``part.updated``; we forward only
# the new suffix so the web reasoning block grows once, not duplicated.
self._reasoning_posted: dict[str, int] = {}
# The in-flight turn's assistant messageID (its per-turn ``response_id``),
# captured from ``message.updated`` and stamped on the running/idle status
# edges so the web chat renders this turn's tool calls live — the mirrored
# ``function_call`` items carry the SAME id. ``_running_response_id``
# records the id the ``running`` edge went out with, gating it to once per
# turn; both reset in :meth:`_end_turn`.
self._active_message_id: str | None = None
self._running_response_id: str | None = None
async def seed_dedupe_from_history(self) -> None:
"""
@@ -342,7 +350,17 @@ class OpenCodeNativeForwarder:
return None
async def _post_status(self, status: str, *, extra: Mapping[str, Any] | None = None) -> None:
"""Publish a coarse session status edge."""
"""Publish a coarse session status edge.
:param extra: Extra fields merged into the edge payload. On the
``running``/``idle`` edges this carries ``{"response_id": <assistant
messageID>}``: when it matches the ``response_id`` on this turn's
mirrored ``function_call`` items, the web chat renders the in-flight
tool calls live (spinner + ticking elapsed timer) instead of static
completed cards, and the server tracks it (``active_response_id``) so
a mid-turn reconnect stays live. A ``failed`` edge instead carries
``output`` / ``reauth_required``.
"""
data: dict[str, Any] = {"status": status}
if extra:
data.update(extra)
@@ -420,23 +438,54 @@ class OpenCodeNativeForwarder:
)
async def _begin_turn_if_needed(self) -> None:
"""Post a single ``running`` status at the start of a turn."""
if not self.state.turn_active:
self.state.turn_active = True
await self._post_status(_STATUS_RUNNING)
"""Emit the turn's id-bearing ``running`` edge once, when the id is known.
The ``running`` edge carries the assistant ``response_id`` (the opencode
messageID held in ``_active_message_id``) so the web chat can render this
turn's in-flight tool calls live — the mirrored ``function_call`` items
carry the SAME id. It fires once per turn and is deferred until the id is
known: a bare ``session.status`` busy can open the turn before the
assistant ``message.updated`` supplies the id, and emitting an id-less
(session-id-fallback) edge then would never match the tool-call items.
"""
self.state.turn_active = True
if self._running_response_id is None and self._active_message_id is not None:
self._running_response_id = self._active_message_id
await self._post_status(
_STATUS_RUNNING, extra={"response_id": self._running_response_id}
)
async def _end_turn(
self, *, status: str = _STATUS_IDLE, extra: Mapping[str, Any] | None = None
) -> None:
"""Post the terminal status (idle by default) and clear active state."""
"""Post the terminal status (idle by default), stamped with the turn's id.
The terminal edge carries the same ``response_id`` the ``running`` edge
used so the server retires this turn's live tool-call cards for the right
response; a caller may pass extra fields (e.g. ``output`` /
``reauth_required`` on a ``failed`` edge), which are merged on top.
"""
self.state.turn_active = False
# Reasoning deltas are per-turn; drop the per-part offsets so the map
# can't grow across a long-lived session (the next turn's reasoning
# parts carry fresh ids anyway).
self._reasoning_posted.clear()
# Stamp the terminal edge with the id the ``running`` edge actually went
# out with (``_running_response_id``), then merge any caller-supplied
# fields on top. If a turn produced more than one assistant messageID,
# ``_active_message_id`` has advanced past the id that went live; using
# the running id keeps both edges consistent so the web retires the cards
# that were rendered live. Fall back to the latest assistant id (then the
# session id) when no running edge fired.
terminal_id = self._running_response_id or self._active_message_id
merged_extra: dict[str, Any] = {"response_id": self._response_id(terminal_id)}
if extra:
merged_extra.update(extra)
if self._bridge_dir is not None:
update_active_message_id(self._bridge_dir, None, status="idle")
await self._post_status(status, extra=extra)
await self._post_status(status, extra=merged_extra)
self._active_message_id = None
self._running_response_id = None
# --- per-event handlers ----------------------------------------------
@@ -455,6 +504,10 @@ class OpenCodeNativeForwarder:
return
self._msg_role[message_id] = role
if role == "assistant":
# This turn's per-turn ``response_id`` — the running/idle edges carry
# it so the web chat can correlate them with the tool-call items that
# already stamp the same id (renders in-flight tool calls live).
self._active_message_id = message_id
if self._bridge_dir is not None:
update_active_message_id(self._bridge_dir, message_id, status="busy")
await self._begin_turn_if_needed()
+134
View File
@@ -292,6 +292,140 @@ async def test_session_error_message_aborted_takes_idle_path() -> None:
assert "output" not in status
def _status_edges(posts: list[tuple[str, dict[str, Any]]]) -> list[dict[str, Any]]:
return [b["data"] for _u, b in posts if b["type"] == "external_session_status"]
async def test_running_and_idle_carry_assistant_response_id() -> None:
"""running/idle edges carry the turn's assistant messageID as ``response_id``.
The web chat renders in-flight tool calls live only when the ``running`` edge
and the mirrored ``function_call`` items share the SAME ``response_id``. Here
the tool call and both status edges must all group under ``msg_1``.
"""
server, opencode = _RecordingServerClient(), _FakeOpenCodeClient()
fwd = _forwarder(server, opencode)
await fwd.handle_event(_event("message.updated", info={"id": "msg_1", "role": "assistant"}))
await fwd.handle_event(
_event(
"message.part.updated",
part={
"id": "prt_t",
"messageID": "msg_1",
"type": "tool",
"callID": "call_1",
"tool": "bash",
"state": {"status": "completed", "input": {"command": "ls"}, "output": "ok"},
},
)
)
await fwd.handle_event(_event("session.idle"))
edges = _status_edges(server.posts)
assert [(e["status"], e["response_id"]) for e in edges] == [
("running", "msg_1"),
("idle", "msg_1"),
]
call = next(b for _u, b in server.posts if b["data"].get("item_type") == "function_call")
# The live-card contract: same id on the running edge and the tool call.
assert call["data"]["response_id"] == edges[0]["response_id"]
async def test_running_edge_fires_once_per_turn() -> None:
"""A turn's many parts still produce exactly one ``running`` edge."""
server, opencode = _RecordingServerClient(), _FakeOpenCodeClient()
fwd = _forwarder(server, opencode)
await fwd.handle_event(_event("message.updated", info={"id": "msg_1", "role": "assistant"}))
for part in (
{"id": "s", "messageID": "msg_1", "type": "step-start"},
{"id": "prt_x", "messageID": "msg_1", "type": "text", "text": "hi"},
{
"id": "prt_t",
"messageID": "msg_1",
"type": "tool",
"callID": "c1",
"tool": "bash",
"state": {"status": "running", "input": {"command": "ls"}},
},
):
await fwd.handle_event(_event("message.part.updated", part=part))
running = [e for e in _status_edges(server.posts) if e["status"] == "running"]
assert len(running) == 1
assert running[0]["response_id"] == "msg_1"
async def test_running_edge_deferred_until_message_id_known() -> None:
"""A bare ``session.status`` busy before ``message.updated`` still yields the id.
opencode can open a turn with ``session.status`` busy (no messageID) before
the assistant ``message.updated`` arrives. The ``running`` edge must defer
until the id is known and carry ``msg_1`` — not an id-less/session-id edge
that would never match the tool-call items — and still fire exactly once.
"""
server, opencode = _RecordingServerClient(), _FakeOpenCodeClient()
fwd = _forwarder(server, opencode)
await fwd.handle_event(_event("session.status", status={"type": "busy"}))
# No running edge yet: the id is unknown.
assert _status_edges(server.posts) == []
await fwd.handle_event(_event("message.updated", info={"id": "msg_1", "role": "assistant"}))
running = [e for e in _status_edges(server.posts) if e["status"] == "running"]
assert len(running) == 1
assert running[0]["response_id"] == "msg_1"
async def test_second_turn_gets_its_own_running_response_id() -> None:
"""Each turn's running/idle edges carry that turn's own assistant id."""
server, opencode = _RecordingServerClient(), _FakeOpenCodeClient()
fwd = _forwarder(server, opencode)
for msg in ("msg_a", "msg_b"):
await fwd.handle_event(_event("message.updated", info={"id": msg, "role": "assistant"}))
await fwd.handle_event(_event("session.idle"))
edges = _status_edges(server.posts)
assert [(e["status"], e["response_id"]) for e in edges] == [
("running", "msg_a"),
("idle", "msg_a"),
("running", "msg_b"),
("idle", "msg_b"),
]
async def test_multi_assistant_message_turn_retires_with_the_live_id() -> None:
"""Two assistant messages in ONE turn: idle carries the id that went live.
If opencode emits more than one assistant ``message.updated`` before
``session.idle`` (no idle between them), the ``running`` edge locks to the
first id (``msg_1``) while ``_active_message_id`` advances to ``msg_2``. The
terminal ``idle`` edge must still carry ``msg_1`` — the id the running edge
used — so the web retires the tool cards that were actually rendered live.
"""
server, opencode = _RecordingServerClient(), _FakeOpenCodeClient()
fwd = _forwarder(server, opencode)
await fwd.handle_event(_event("message.updated", info={"id": "msg_1", "role": "assistant"}))
await fwd.handle_event(_event("message.updated", info={"id": "msg_2", "role": "assistant"}))
await fwd.handle_event(_event("session.idle"))
edges = _status_edges(server.posts)
assert [(e["status"], e["response_id"]) for e in edges] == [
("running", "msg_1"),
("idle", "msg_1"),
]
async def test_turn_without_assistant_message_idles_with_session_fallback() -> None:
"""A turn that opens (busy) and idles with no assistant ``message.updated``.
No ``running`` edge fires (there was never an id to carry) and the terminal
``idle`` edge falls back to the session id. Benign — there are no live tool
cards to retire — but the fallback id is deliberate, not a mismatch bug.
"""
server, opencode = _RecordingServerClient(), _FakeOpenCodeClient()
fwd = _forwarder(server, opencode)
await fwd.handle_event(_event("session.status", status={"type": "busy"}))
await fwd.handle_event(_event("session.idle"))
edges = _status_edges(server.posts)
assert [e["status"] for e in edges] == ["idle"]
assert edges[0]["response_id"] == _SESSION
async def test_permission_asked_rejects_when_no_policy_wired() -> None:
"""Absent a policy evaluator the forwarder FAILS CLOSED (no auto-approve).