Reported: Ctrl+C during an active /goal loop felt like it did nothing —
the agent would interrupt the current turn, then immediately queue another
continuation and keep going until the session ended or the 20-turn budget
ran out.
Root cause: cli.py's _maybe_continue_goal_after_turn() ran in the finally:
block around self.chat(...) unconditionally. Whether the turn completed
normally, got interrupted, or returned an empty string, the judge ran on
whatever was in conversation_history and — because the judge is fail-open
— a "continue" verdict pushed another CONTINUATION_PROMPT onto
_pending_input. Ctrl+C was invisible to the hook.
Fix:
- chat() now captures result['interrupted'] onto self._last_turn_interrupted
(resets to False at entry so early-returns don't leak prior state).
- _maybe_continue_goal_after_turn() checks the flag first: on interrupt,
auto-pause via mgr.pause(reason='user-interrupted (Ctrl+C)') and print
a one-liner pointing the user at /goal resume or /goal clear. No judge
call, no continuation enqueued.
- Also added an empty-response guard that mirrors gateway/run.py's
_handle_message logic (empty reply → transient failure → skip judging
so we don't trip the consecutive-parse-failures backstop unnecessarily).
The goal stays in the DB as paused, so /goal resume recovers it after
the user has sorted out whatever made them cancel. /goal clear still
works as before for a full stop.
Tests: tests/cli/test_cli_goal_interrupt.py covers:
- interrupted turn pauses + doesn't queue + judge is NOT called
- paused goal is resumable
- empty / whitespace / missing assistant reply skips judging
- healthy turn still enqueues continuation / marks done
- chat() resets _last_turn_interrupted at entry (anti-leak guard)
All 55 existing goal tests still pass.