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:
Teknium
2026-08-18 16:43:42 -07:00
parent 51013e9ae0
commit bdd0a79c6a
22 changed files with 309 additions and 26 deletions
+21
View File
@@ -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"}:
+18 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+187
View File
@@ -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 == {}
+51
View File
@@ -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 ──────────────────────────────────────────
+15 -5
View File
@@ -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"}: