diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 78c276dcfc..1986bbd374 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -2729,6 +2729,27 @@ class GatewaySlashCommandsMixin: state = mgr.resume() if state is None: return t("gateway.goal.no_resume") + # Resume must restart work, not just flip persisted state + # (#75362): enqueue the canonical continuation through the + # adapter FIFO — the same path the post-turn judge uses — so + # the next turn fires as soon as this reply is delivered. A + # real user message already queued still preempts naturally, + # and pause/clear's stale-continuation cleanup recognizes it. + prompt = mgr.next_continuation_prompt() + try: + adapter = self.adapters.get(event.source.platform) if event.source else None + _quick_key = self._session_key_for_source(event.source) if event.source else None + if prompt and adapter and _quick_key: + cont_event = MessageEvent( + text=prompt, + message_type=MessageType.TEXT, + source=event.source, + message_id=None, + channel_prompt=None, + ) + self._enqueue_fifo(_quick_key, cont_event, adapter) + except Exception as exc: + logger.debug("goal resume: continuation enqueue failed: %s", exc) return t("gateway.goal.resumed", goal=state.goal) if lower in {"clear", "stop", "done"}: diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index ceca64c676..f5f51c865e 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -2761,10 +2761,24 @@ class CLICommandsMixin: _cprint(f" {_DIM}No goal to resume.{_RST}") else: _cprint(f" ▶ Goal resumed: {state.goal}") - _cprint( - f" {_DIM}Send any message (or press Enter on an empty prompt " - f"is a no-op; type 'continue' to kick it off).{_RST}" - ) + # Resume must restart work, not just flip persisted state + # (#75362): queue the canonical continuation prompt the same + # way /goal queues its kickoff, so the loop takes the + # next step without the user sending another message. + prompt = mgr.next_continuation_prompt() + queued = False + if prompt: + try: + self._pending_input.put(prompt) + queued = True + except Exception: + pass + if queued: + _cprint(f" {_DIM}Continuing now — taking the next step.{_RST}") + else: + _cprint( + f" {_DIM}Send any message to kick off the next step.{_RST}" + ) return if lower in {"clear", "stop", "done"}: diff --git a/locales/af.yaml b/locales/af.yaml index 3b6c7fcf7a..2c578ebc2a 100644 --- a/locales/af.yaml +++ b/locales/af.yaml @@ -142,7 +142,7 @@ gateway: no_goal_set: "Geen doelwit gestel nie." paused: "⏸ Doelwit gepouse: {goal}" no_resume: "Geen doelwit om voort te sit nie." - resumed: "▶ Doelwit hervat: {goal}\nStuur enige boodskap om voort te gaan, of wag — ek sal die volgende stap met die volgende beurt neem." + resumed: "▶ Doelwit hervat: {goal}\nGaan nou voort — ek neem dadelik die volgende stap." invalid: "Ongeldige doelwit: {error}" set: "⊙ Doelwit gestel ({budget}-beurt-begroting): {goal}\nEk sal aanhou werk totdat die doelwit klaar is, jy dit pouseer/verwyder, of die begroting opgebruik is.\nBeheer: /goal status · /goal pause · /goal resume · /goal clear" diff --git a/locales/ar.yaml b/locales/ar.yaml index 13694d8162..7512a05622 100644 --- a/locales/ar.yaml +++ b/locales/ar.yaml @@ -165,7 +165,7 @@ gateway: no_goal_set: "لم يُحدَّد هدف." paused: "⏸ أُوقف الهدف مؤقتًا: {goal}" no_resume: "لا يوجد هدف لاستئنافه." - resumed: "▶ استُؤنف الهدف: {goal}\nأرسل أي رسالة للمتابعة، أو انتظر — سأتخذ الخطوة التالية في الدور القادم." + resumed: "▶ استُؤنف الهدف: {goal}\nسأتابع الآن — سأتخذ الخطوة التالية فورًا." invalid: "هدف غير صالح: {error}" set: "⊙ حُدِّد الهدف (ميزانية {budget} دور): {goal}\nسأواصل العمل حتى يكتمل الهدف، أو توقفه/تمسحه، أو تنفد الميزانية.\nالتحكم: /goal status · /goal pause · /goal resume · /goal clear" diff --git a/locales/de.yaml b/locales/de.yaml index 5115dd6f39..24fb2645c6 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -142,7 +142,7 @@ gateway: no_goal_set: "Kein Ziel gesetzt." paused: "⏸ Ziel pausiert: {goal}" no_resume: "Kein Ziel zum Fortsetzen." - resumed: "▶ Ziel fortgesetzt: {goal}\nSenden Sie eine Nachricht zum Fortfahren oder warten Sie — ich übernehme den nächsten Schritt im nächsten Zug." + resumed: "▶ Ziel fortgesetzt: {goal}\nEs geht sofort weiter — ich übernehme jetzt den nächsten Schritt." invalid: "Ungültiges Ziel: {error}" set: "⊙ Ziel gesetzt ({budget}-Zug-Budget): {goal}\nIch arbeite weiter, bis das Ziel erreicht ist, Sie es pausieren/löschen oder das Budget aufgebraucht ist.\nSteuerung: /goal status · /goal pause · /goal resume · /goal clear" diff --git a/locales/en.yaml b/locales/en.yaml index 842404b5ad..b395fb99ab 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -157,7 +157,7 @@ gateway: no_goal_set: "No goal set." paused: "⏸ Goal paused: {goal}" no_resume: "No goal to resume." - resumed: "▶ Goal resumed: {goal}\nSend any message to continue, or wait — I'll take the next step on the next turn." + resumed: "▶ Goal resumed: {goal}\nContinuing now — I'll take the next step right away." invalid: "Invalid goal: {error}" set: "⊙ Goal set ({budget}-turn budget): {goal}\nI'll keep working until the goal is done, you pause/clear it, or the budget is exhausted.\nControls: /goal status · /goal pause · /goal resume · /goal clear" diff --git a/locales/es.yaml b/locales/es.yaml index 57395422f9..1044902377 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -142,7 +142,7 @@ gateway: no_goal_set: "No hay objetivo establecido." paused: "⏸ Objetivo pausado: {goal}" no_resume: "No hay objetivo para reanudar." - resumed: "▶ Objetivo reanudado: {goal}\nEnvía cualquier mensaje para continuar, o espera — daré el siguiente paso en el próximo turno." + resumed: "▶ Objetivo reanudado: {goal}\nContinuando ahora — daré el siguiente paso de inmediato." invalid: "Objetivo no válido: {error}" set: "⊙ Objetivo establecido (presupuesto de {budget} turnos): {goal}\nSeguiré trabajando hasta que el objetivo se complete, lo pauses/elimines o se agote el presupuesto.\nControles: /goal status · /goal pause · /goal resume · /goal clear" diff --git a/locales/fr.yaml b/locales/fr.yaml index d6127b8418..44ad06b476 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -142,7 +142,7 @@ gateway: no_goal_set: "Aucun objectif défini." paused: "⏸ Objectif en pause : {goal}" no_resume: "Aucun objectif à reprendre." - resumed: "▶ Objectif repris : {goal}\nEnvoyez un message pour continuer, ou attendez — je passerai à l'étape suivante au prochain tour." + resumed: "▶ Objectif repris : {goal}\nReprise immédiate — je passe tout de suite à l'étape suivante." invalid: "Objectif invalide : {error}" set: "⊙ Objectif défini (budget de {budget} tours) : {goal}\nJe continuerai jusqu'à ce que l'objectif soit terminé, que vous le mettiez en pause/effaciez, ou que le budget soit épuisé.\nContrôles : /goal status · /goal pause · /goal resume · /goal clear" diff --git a/locales/ga.yaml b/locales/ga.yaml index 06ec627d91..2b6ab3d661 100644 --- a/locales/ga.yaml +++ b/locales/ga.yaml @@ -146,7 +146,7 @@ gateway: no_goal_set: "Níl aon sprioc socraithe." paused: "⏸ Sprioc curtha ar sos: {goal}" no_resume: "Níl aon sprioc le hatosú." - resumed: "▶ Sprioc atosaithe: {goal}\nSeol teachtaireacht ar bith chun leanúint, nó fan — déanfaidh mé an chéad chéim eile sa chéad seal eile." + resumed: "▶ Sprioc atosaithe: {goal}\nAg leanúint ar aghaidh anois — déanfaidh mé an chéad chéim eile láithreach." invalid: "Sprioc neamhbhailí: {error}" set: "⊙ Sprioc socraithe (buiséad {budget} seal): {goal}\nLeanfaidh mé ag obair go dtí go bhfuil an sprioc críochnaithe, go gcuirfidh tú ar sos / go nglanfaidh tú í, nó go n-úsáidfear an buiséad.\nSmacht: /goal status · /goal pause · /goal resume · /goal clear" diff --git a/locales/hu.yaml b/locales/hu.yaml index 833ed202c1..1533722f6a 100644 --- a/locales/hu.yaml +++ b/locales/hu.yaml @@ -142,7 +142,7 @@ gateway: no_goal_set: "Nincs cél beállítva." paused: "⏸ Cél szüneteltetve: {goal}" no_resume: "Nincs folytatható cél." - resumed: "▶ Cél folytatva: {goal}\nKüldj bármilyen üzenetet a folytatáshoz, vagy várj — a következő körben megteszem a következő lépést." + resumed: "▶ Cél folytatva: {goal}\nMáris folytatom — azonnal megteszem a következő lépést." invalid: "Érvénytelen cél: {error}" set: "⊙ Cél beállítva ({budget} körös keret): {goal}\nDolgozni fogok rajta, amíg a cél el nem készül, te nem szünetelteted/törlöd, vagy a keret ki nem merül.\nVezérlés: /goal status · /goal pause · /goal resume · /goal clear" diff --git a/locales/it.yaml b/locales/it.yaml index 023fff4e4f..e809e5abaa 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -142,7 +142,7 @@ gateway: no_goal_set: "Nessun obiettivo impostato." paused: "⏸ Obiettivo in pausa: {goal}" no_resume: "Nessun obiettivo da riprendere." - resumed: "▶ Obiettivo ripreso: {goal}\nInvia un messaggio per continuare, oppure aspetta — farò il prossimo passo al turno successivo." + resumed: "▶ Obiettivo ripreso: {goal}\nContinuo subito — farò immediatamente il prossimo passo." invalid: "Obiettivo non valido: {error}" set: "⊙ Obiettivo impostato (budget di {budget} turni): {goal}\nContinuerò a lavorare finché l'obiettivo non sarà completato, lo metterai in pausa/lo cancellerai, oppure il budget sarà esaurito.\nControlli: /goal status · /goal pause · /goal resume · /goal clear" diff --git a/locales/ja.yaml b/locales/ja.yaml index 695b662384..df730df306 100644 --- a/locales/ja.yaml +++ b/locales/ja.yaml @@ -142,7 +142,7 @@ gateway: no_goal_set: "目標が設定されていません。" paused: "⏸ 目標を一時停止しました: {goal}" no_resume: "再開する目標がありません。" - resumed: "▶ 目標を再開しました: {goal}\nメッセージを送って続行するか、お待ちください — 次のターンで続きを進めます。" + resumed: "▶ 目標を再開しました: {goal}\nすぐに続行します — 直ちに次のステップに進みます。" invalid: "無効な目標: {error}" set: "⊙ 目標を設定しました ({budget} ターンの予算): {goal}\n目標が完了するか、一時停止/解除されるか、予算が尽きるまで作業を続けます。\nコントロール: /goal status · /goal pause · /goal resume · /goal clear" diff --git a/locales/ko.yaml b/locales/ko.yaml index 1ef6671c0d..a3ba80ce3f 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -142,7 +142,7 @@ gateway: no_goal_set: "설정된 목표가 없습니다." paused: "⏸ 목표 일시정지: {goal}" no_resume: "재개할 목표가 없습니다." - resumed: "▶ 목표 재개: {goal}\n메시지를 보내 계속하거나 기다려 주세요 — 다음 차례에 다음 단계를 진행하겠습니다." + resumed: "▶ 목표 재개: {goal}\n바로 계속합니다 — 즉시 다음 단계를 진행하겠습니다." invalid: "잘못된 목표: {error}" set: "⊙ 목표 설정됨 ({budget}회 예산): {goal}\n목표가 완료되거나, 일시정지/삭제하거나, 예산이 소진될 때까지 계속 작업하겠습니다.\n제어: /goal status · /goal pause · /goal resume · /goal clear" diff --git a/locales/pt.yaml b/locales/pt.yaml index d9bdf1b7c6..fc615a7eb4 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -142,7 +142,7 @@ gateway: no_goal_set: "Nenhum objetivo definido." paused: "⏸ Objetivo pausado: {goal}" no_resume: "Nenhum objetivo para retomar." - resumed: "▶ Objetivo retomado: {goal}\nEnvia qualquer mensagem para continuar, ou aguarda — darei o próximo passo no próximo turno." + resumed: "▶ Objetivo retomado: {goal}\nContinuando agora — darei o próximo passo imediatamente." invalid: "Objetivo inválido: {error}" set: "⊙ Objetivo definido (orçamento de {budget} turnos): {goal}\nVou continuar a trabalhar até o objetivo estar concluído, pausares/limpares ou o orçamento esgotar.\nControlos: /goal status · /goal pause · /goal resume · /goal clear" diff --git a/locales/ru.yaml b/locales/ru.yaml index 92309f6294..3ec27bee9c 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -142,7 +142,7 @@ gateway: no_goal_set: "Цель не задана." paused: "⏸ Цель приостановлена: {goal}" no_resume: "Нет цели для возобновления." - resumed: "▶ Цель возобновлена: {goal}\nОтправьте любое сообщение, чтобы продолжить, или подождите — я сделаю следующий шаг на следующем ходу." + resumed: "▶ Цель возобновлена: {goal}\nПродолжаю прямо сейчас — немедленно сделаю следующий шаг." invalid: "Недопустимая цель: {error}" set: "⊙ Цель задана (бюджет {budget} ходов): {goal}\nЯ продолжу работу, пока цель не будет достигнута, вы её не приостановите/очистите, или бюджет не исчерпается.\nУправление: /goal status · /goal pause · /goal resume · /goal clear" diff --git a/locales/tr.yaml b/locales/tr.yaml index db3cf75b94..3c92ec6552 100644 --- a/locales/tr.yaml +++ b/locales/tr.yaml @@ -142,7 +142,7 @@ gateway: no_goal_set: "Hedef ayarlanmadı." paused: "⏸ Hedef duraklatıldı: {goal}" no_resume: "Devam ettirilecek hedef yok." - resumed: "▶ Hedef devam ettirildi: {goal}\nDevam etmek için herhangi bir mesaj gönderin veya bekleyin — bir sonraki turda adımı atacağım." + resumed: "▶ Hedef devam ettirildi: {goal}\nHemen devam ediyorum — bir sonraki adımı derhal atacağım." invalid: "Geçersiz hedef: {error}" set: "⊙ Hedef ayarlandı ({budget} turluk bütçe): {goal}\nHedef tamamlanana, siz duraklatana/temizleyene veya bütçe tükenene kadar çalışmaya devam edeceğim.\nKontroller: /goal status · /goal pause · /goal resume · /goal clear" diff --git a/locales/uk.yaml b/locales/uk.yaml index b4a5e414d2..a4ca070828 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -142,7 +142,7 @@ gateway: no_goal_set: "Ціль не встановлено." paused: "⏸ Ціль призупинено: {goal}" no_resume: "Немає цілі для продовження." - resumed: "▶ Ціль відновлено: {goal}\nНадішліть будь-яке повідомлення, щоб продовжити, або зачекайте — я зроблю наступний крок у наступному ході." + resumed: "▶ Ціль відновлено: {goal}\nПродовжую зараз — негайно зроблю наступний крок." invalid: "Неприпустима ціль: {error}" set: "⊙ Ціль встановлено (бюджет {budget} ходів): {goal}\nЯ продовжуватиму працювати, доки ціль не буде досягнута, ви її не призупините/очистите, або бюджет не вичерпається.\nКерування: /goal status · /goal pause · /goal resume · /goal clear" diff --git a/locales/zh-hant.yaml b/locales/zh-hant.yaml index a79f5f983b..224c2f21bf 100644 --- a/locales/zh-hant.yaml +++ b/locales/zh-hant.yaml @@ -142,7 +142,7 @@ gateway: no_goal_set: "未設定目標。" paused: "⏸ 目標已暫停:{goal}" no_resume: "沒有可恢復的目標。" - resumed: "▶ 目標已恢復:{goal}\n傳送任意訊息繼續,或等待 — 我會在下一輪繼續推進。" + resumed: "▶ 目標已恢復:{goal}\n立即繼續 — 我馬上進行下一步。" invalid: "無效目標:{error}" set: "⊙ 目標已設定({budget} 輪預算):{goal}\n我會持續工作直到目標完成、你暫停/清除目標,或預算耗盡。\n控制指令:/goal status · /goal pause · /goal resume · /goal clear" diff --git a/locales/zh.yaml b/locales/zh.yaml index e6c0e1e124..de32de5115 100644 --- a/locales/zh.yaml +++ b/locales/zh.yaml @@ -142,7 +142,7 @@ gateway: no_goal_set: "未设置目标。" paused: "⏸ 目标已暂停:{goal}" no_resume: "没有可恢复的目标。" - resumed: "▶ 目标已恢复:{goal}\n发送任意消息继续,或等待 — 我会在下一轮继续推进。" + resumed: "▶ 目标已恢复:{goal}\n立即继续 — 我马上进行下一步。" invalid: "无效目标:{error}" set: "⊙ 目标已设置({budget} 轮预算):{goal}\n我将持续工作直到目标完成、你暂停/清除它,或预算耗尽。\n控制命令:/goal status · /goal pause · /goal resume · /goal clear" diff --git a/tests/gateway/test_goal_resume_restart.py b/tests/gateway/test_goal_resume_restart.py new file mode 100644 index 0000000000..bc7efa7745 --- /dev/null +++ b/tests/gateway/test_goal_resume_restart.py @@ -0,0 +1,187 @@ +"""#75362 — /goal resume must restart work, not just flip persisted state. + +After a goal auto-pauses on turn-budget exhaustion, each surface's +``resume`` handler must schedule exactly one canonical +``GoalManager.next_continuation_prompt()`` turn through that surface's +existing input path: + +- classic CLI → ``self._pending_input`` +- messaging gateway → the adapter FIFO (``_enqueue_fifo``) + +The TUI/Desktop ``command.dispatch`` boundary is covered in +``tests/tui_gateway/test_goal_command.py``. +""" + +from __future__ import annotations + +import queue +import uuid +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.platforms.base import MessageEvent, MessageType +from gateway.run import GatewayRunner +from gateway.session import SessionSource +from hermes_cli import goals + + +@pytest.fixture +def hermes_home(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(home)) + goals._DB_CACHE.clear() + yield home + goals._DB_CACHE.clear() + + +def _exhaust_budget(session_id: str, goal_text: str = "ship the benchmark"): + """Set a 1-turn goal and drive it to budget-exhaustion auto-pause.""" + mgr = goals.GoalManager(session_id) + mgr.set(goal_text, max_turns=1) + with patch( + "hermes_cli.goals.judge_goal", + return_value=("continue", "needs more steps", False, None, False), + ): + decision = mgr.evaluate_after_turn("worked a bit") + assert decision["status"] == "paused" + assert decision["should_continue"] is False + assert "turn budget exhausted" in (mgr.state.paused_reason or "") + return mgr + + +# ────────────────────────────────────────────────────────────────────── +# Classic CLI +# ────────────────────────────────────────────────────────────────────── + + +def _make_cli(session_id: str): + from cli import HermesCLI + + cli = HermesCLI.__new__(HermesCLI) + cli._pending_input = queue.Queue() + cli.session_id = session_id + cli.agent = MagicMock() + cli.agent.session_id = session_id + return cli + + +class TestCliResumeRestartsWork: + def test_resume_after_budget_exhaustion_queues_continuation(self, hermes_home): + sid = f"sid-cli-resume-{uuid.uuid4().hex}" + cli = _make_cli(sid) + _exhaust_budget(sid) + + cli._handle_goal_command("goal resume") + + assert not cli._pending_input.empty(), ( + "/goal resume must queue the continuation prompt on _pending_input " + "— otherwise the goal sits idle until the user types something" + ) + queued = cli._pending_input.get_nowait() + assert queued.startswith("[Continuing toward your standing goal]") + + state = goals.GoalManager(sid).state + assert state.status == "active" + assert state.turns_used == 0 + + def test_resume_without_goal_queues_nothing(self, hermes_home): + sid = f"sid-cli-noresume-{uuid.uuid4().hex}" + cli = _make_cli(sid) + + cli._handle_goal_command("goal resume") + + assert cli._pending_input.empty() + + +# ────────────────────────────────────────────────────────────────────── +# Messaging gateway +# ────────────────────────────────────────────────────────────────────── + +_GW_SID = "sid-gateway-goal-resume" +_GW_KEY = "agent:main:discord:channel:goal-resume" + + +class _FakeSessionEntry: + session_id = _GW_SID + + +class _FakeSessionStore: + def __init__(self): + self.entry = _FakeSessionEntry() + + def get_or_create_session(self, source, **_kwargs): + return self.entry + + def _generate_session_key(self, source): + return _GW_KEY + + +class _FakeAdapter: + def __init__(self): + self._pending_messages = {} + + +def _make_runner() -> tuple[GatewayRunner, _FakeAdapter]: + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={Platform.DISCORD: PlatformConfig(enabled=True, token="token")} + ) + runner.session_store = _FakeSessionStore() + adapter = _FakeAdapter() + runner.adapters = {Platform.DISCORD: adapter} + runner._queued_events = {} + return runner, adapter + + +def _resume_event() -> MessageEvent: + return MessageEvent( + text="/goal resume", + message_type=MessageType.TEXT, + source=SessionSource( + platform=Platform.DISCORD, + chat_id="chat-goal-resume", + chat_type="channel", + user_id="user-goal-resume", + ), + message_id="msg-goal-resume", + ) + + +class TestGatewayResumeRestartsWork: + @pytest.mark.asyncio + async def test_resume_after_budget_exhaustion_enqueues_continuation( + self, hermes_home + ): + runner, adapter = _make_runner() + _exhaust_budget(_GW_SID) + + response = await GatewayRunner._handle_goal_command(runner, _resume_event()) + + assert "resume" in response.lower() or "Goal" in response + pending = adapter._pending_messages.get(_GW_KEY) + assert pending is not None, ( + "/goal resume must enqueue the continuation on the adapter FIFO " + "— otherwise the goal sits idle until the next real user message" + ) + assert pending.text.startswith("[Continuing toward your standing goal]") + # The pause/clear stale-work guard must recognize the queued turn as + # a synthetic goal continuation so it can be cleaned up on /goal pause. + assert GatewayRunner._is_goal_continuation_event(pending) + + state = goals.GoalManager(_GW_SID).state + assert state.status == "active" + assert state.turns_used == 0 + + @pytest.mark.asyncio + async def test_resume_without_goal_enqueues_nothing(self, hermes_home): + runner, adapter = _make_runner() + + response = await GatewayRunner._handle_goal_command(runner, _resume_event()) + + assert "No goal to resume" in response + assert adapter._pending_messages == {} diff --git a/tests/tui_gateway/test_goal_command.py b/tests/tui_gateway/test_goal_command.py index e08e906b66..14b8effa4d 100644 --- a/tests/tui_gateway/test_goal_command.py +++ b/tests/tui_gateway/test_goal_command.py @@ -180,6 +180,57 @@ def test_goal_bare_shows_status_when_none_set(server, session): assert "No active goal" in r["result"]["output"] +def _exhaust_budget(session_key: str, goal_text: str = "finish the benchmark"): + """Set a 1-turn goal and drive it to budget-exhaustion auto-pause.""" + from hermes_cli.goals import GoalManager + + mgr = GoalManager(session_key) + mgr.set(goal_text, max_turns=1) + with patch( + "hermes_cli.goals.judge_goal", + return_value=("continue", "needs more steps", False, None, False), + ): + decision = mgr.evaluate_after_turn("worked a bit") + assert decision["status"] == "paused" + assert decision["should_continue"] is False + return mgr + + +def test_goal_resume_after_budget_exhaustion_dispatches_continuation( + server, session +): + """#75362: /goal resume must restart work, not just flip state. + + The pre-fix handler returned a display-only `exec` payload, so the + resumed goal sat idle until the user sent another message. Resume + must return a sendable dispatch carrying the canonical continuation + prompt, with a concise `/goal resume` transcript projection. + """ + from hermes_cli.goals import GoalManager + + sid, session_key, _ = session + _exhaust_budget(session_key) + assert GoalManager(session_key).state.status == "paused" + + r = _call(server, "command.dispatch", name="goal", arg="resume", session_id=sid) + result = r["result"] + assert result["type"] == "send" + assert result["message"].startswith("[Continuing toward your standing goal]") + assert result["display"] == "/goal resume" + assert "Goal resumed" in result["notice"] + + state = GoalManager(session_key).state + assert state.status == "active" + assert state.turns_used == 0, "resume must reset the turn budget" + + +def test_goal_resume_without_goal_stays_exec(server, session): + sid, _, _ = session + r = _call(server, "command.dispatch", name="goal", arg="resume", session_id=sid) + assert r["result"]["type"] == "exec" + assert "No goal to resume" in r["result"]["output"] + + # ── slash.exec /goal routing ────────────────────────────────────────── diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index bf7d61e599..f92e2d4d9a 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -793,14 +793,24 @@ def _(rid, params: dict) -> dict: state = mgr.resume() if state is None: return _ok(rid, {"type": "exec", "output": "No goal to resume."}) + # Resume must restart work, not just flip persisted state + # (#75362). An `exec` result is display-only — nothing would + # re-enter the conversation loop until the user typed another + # message. Return a `send` dispatch carrying the canonical + # continuation prompt so the client fires the next turn + # immediately; `display` keeps the transcript showing the + # concise invocation instead of the model-facing scaffolding. + prompt = mgr.next_continuation_prompt() + notice = f"▶ Goal resumed: {state.goal}\nContinuing now — taking the next step." + if not prompt: + return _ok(rid, {"type": "exec", "output": f"▶ Goal resumed: {state.goal}"}) return _ok( rid, { - "type": "exec", - "output": ( - f"▶ Goal resumed: {state.goal}\n" - "Send any message to continue, or wait — I'll take the next step on the next turn." - ), + "type": "send", + "notice": notice, + "message": prompt, + "display": "/goal resume", }, ) if lower in {"clear", "stop", "done"}: