fix(goals): /goal resume actually restarts work after budget exhaustion
After a standing goal auto-paused on turn-budget exhaustion, every
surface's /goal resume handler only flipped the persisted state back to
active (and reset turns_used) and rendered an acknowledgement — nothing
re-entered the conversation loop, so the goal sat idle until the user
sent another ordinary message.
Fix the whole class by scheduling the canonical
GoalManager.next_continuation_prompt() through each surface's existing
input path after a successful resume:
- Desktop/TUI (tui_gateway/methods_tools.py command.dispatch): return a
sendable {type: "send"} dispatch with the continuation as the message,
a "Continuing now" notice, and display "/goal resume" so the
transcript shows the concise invocation instead of the model-facing
scaffolding. No-goal keeps the exec response.
- Classic CLI (hermes_cli/cli_commands_mixin.py): put the continuation
on _pending_input, same as the /goal <text> kickoff.
- Messaging gateway (gateway/slash_commands.py): enqueue a continuation
MessageEvent through the adapter FIFO — the same path the post-turn
judge uses — so queued real user messages preempt naturally and the
pause/clear stale-continuation cleanup recognizes it.
Also correct the now-misleading gateway.goal.resumed copy ("Send any
message to continue…") across all 17 locale files.
Regression tests cover exact budget exhaustion → resume on the real CLI
handler, the real gateway handler (including the
_is_goal_continuation_event guard contract), and the TUI
command.dispatch boundary; verified each fails on the pre-fix code.
Fixes #75362
This commit is contained in:
@@ -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"}:
|
||||
|
||||
@@ -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 <text> 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"}:
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
@@ -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 == {}
|
||||
@@ -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 ──────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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"}:
|
||||
|
||||
Reference in New Issue
Block a user