fix(goals): make /goal work in TUI and fix gateway verdict delivery (#19209)
/goal was silently broken outside the classic CLI.
TUI: /goal was routed through the HermesCLI slash-worker subprocess,
which set the goal row in SessionDB but then called
_pending_input.put(state.goal) — the subprocess has no reader for that
queue, so the kickoff message was discarded. No post-turn judge was
wired into prompt.submit either, so even a manual kickoff would not
continue the goal loop. Intercept /goal in command.dispatch instead,
drive GoalManager directly, and return {type: send, notice, message}
so the TUI client renders the Goal-set notice and fires the kickoff.
Run the judge in _run_prompt_submit after message.complete, surface
the verdict via status.update {kind: goal}, and chain the continuation
turn after the running guard is released.
Gateway: _post_turn_goal_continuation was gated on
hasattr(adapter, 'send_message'), but adapters only expose send().
That branch was dead on every platform — users never saw
'✓ Goal achieved', 'Continuing toward goal', or budget-exhausted
messages. Replace the dead call with adapter.send(chat_id, content,
metadata) and drop a broken reference to self._loop.
Tests:
- tests/tui_gateway/test_goal_command.py — full /goal dispatch matrix
(set / status / pause / resume / clear / stop / done / whitespace)
plus regressions for slash.exec → 4018 and 'goal' staying in
_PENDING_INPUT_COMMANDS.
- tests/gateway/test_goal_verdict_send.py — locks in the adapter.send
path for done / continue / budget-exhausted and verifies the hook
no-ops when no goal is set or the adapter lacks send().
This commit is contained in:
@@ -7887,24 +7887,33 @@ class GatewayRunner:
|
||||
msg = decision.get("message") or ""
|
||||
|
||||
# Send the status line back to the user so they see the judge's
|
||||
# verdict. Fire-and-forget via the adapter.
|
||||
# verdict. Fire-and-forget via the adapter's ``send()`` method —
|
||||
# adapters expose ``send(chat_id, content, reply_to, metadata)``,
|
||||
# not a ``send_message(source, msg)`` wrapper, so an earlier
|
||||
# ``hasattr(adapter, "send_message")`` gate here was dead code and
|
||||
# users never saw ``✓ Goal achieved`` / ``⏸ budget exhausted``
|
||||
# verdicts.
|
||||
if msg and source is not None:
|
||||
try:
|
||||
adapter = self.adapters.get(source.platform)
|
||||
if adapter and hasattr(adapter, "send_message"):
|
||||
if adapter is not None and hasattr(adapter, "send"):
|
||||
import asyncio as _asyncio
|
||||
coro = adapter.send_message(source, msg)
|
||||
thread_meta = (
|
||||
{"thread_id": source.thread_id} if source.thread_id else None
|
||||
)
|
||||
coro = adapter.send(
|
||||
chat_id=source.chat_id,
|
||||
content=msg,
|
||||
metadata=thread_meta,
|
||||
)
|
||||
if _asyncio.iscoroutine(coro):
|
||||
try:
|
||||
loop = _asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
loop.create_task(coro)
|
||||
else:
|
||||
loop.run_until_complete(coro)
|
||||
loop = _asyncio.get_running_loop()
|
||||
loop.create_task(coro)
|
||||
except RuntimeError:
|
||||
# No event loop in this thread — schedule on the main one.
|
||||
# No running loop in this thread — best effort.
|
||||
try:
|
||||
_asyncio.run_coroutine_threadsafe(coro, self._loop)
|
||||
_asyncio.run(coro)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
|
||||
Reference in New Issue
Block a user