feat(code): add interactive goal management (#4693)

Deep Agents Code now supports amending, pausing, and resuming persistent
goals while keeping their status visible above the input. Amendments are
reviewed before they apply, and paused goals remain saved without
driving work or grading until resumed.

---

Deep Agents Code now keeps goals visible above the input and lets users
steer ongoing goal-driven work without cancelling the current task and
replaying work.

- `/goal amend <feedback>` proposes coordinated updates to the objective
and acceptance criteria while preserving unaffected criteria and
explicit constraints. The inline review supports accepting, editing,
rejecting, or cancelling the proposal before it becomes canonical state.
- `/goal pause` saves the goal without letting it drive work or grading.
`/goal resume` continues from the existing conversation and checkpoint
rather than resubmitting the original task.
- The goal panel shows the current objective and its active, paused,
blocked, or completed state.
- Ordinary messages submitted during active goal work retain queue and
interruption ordering and steer the next turn under the current goal.

Goal checkpoint changes wait for graph execution and turn-end
reconciliation to reach a safe boundary. A goal update accepted during
execution applies after the current turn finishes and before a
subsequent turn begins. If a user message is already queued, it runs
under the updated goal before any synthetic continuation turn.

## User stories and before/after examples

<details>
<summary>Amend a goal without restarting the work</summary>

**User story:** As a user whose requirements change during a
long-running task, I want to update the current objective and acceptance
criteria without discarding context or repeating work that is already
complete.

**Before**

1. I start a goal to “Add CSV and JSON export with tests.”
2. Partway through, I decide that JSON export is out of scope and CSV
must also support streaming.
3. There is no goal-specific amendment flow. I must replace or clear the
goal and describe the revised work again, without a review that
preserves unaffected criteria and constraints.

**After**

1. I run `/goal amend Remove JSON export, add streaming CSV support, and
keep the existing CSV test requirements`.
2. dcode drafts a coordinated revision of both the objective and
criteria while preserving requirements that the feedback did not change.
3. The inline review lets me accept the proposal, edit its criteria,
request another revision, or cancel it.
4. Once accepted, dcode continues from the existing conversation and
work instead of replaying the original task.

</details>

<details>
<summary>Pause a goal while keeping it available to resume</summary>

**User story:** As a user who needs to switch to unrelated work
temporarily, I want to pause my current goal so it remains saved but
does not affect the intervening prompts.

**Before**

1. I have an active multi-turn goal.
2. To stop that goal from driving later work and grading, I must clear
it.
3. Returning to it requires creating the goal again and redrafting its
acceptance criteria.

**After**

1. `/goal pause` saves the objective and criteria with a paused status.
2. Unrelated prompts run without the paused goal driving work or
grading.
3. `/goal resume` reactivates the saved goal and continues from the
existing conversation and checkpoint without resubmitting the original
objective.

</details>

<details>
<summary>See the current goal and lifecycle state while
working</summary>

**User story:** As a user working across multiple turns, I want the
current goal and its status to remain visible so I can tell what dcode
is working toward and whether it needs my input.

**Before**

The current objective is not persistently visible above the input. I
must use `/goal show` or infer its state from earlier transcript
messages.

**After**

- A persistent panel above the input shows the current objective.
- The panel distinguishes active, paused, blocked, and completed states.
- The display follows checkpoint restoration and lifecycle changes, so
reopening a thread or changing the goal state keeps the visible status
aligned with the saved goal.

</details>

<details>
<summary>Apply in-flight goal changes without reordering queued
input</summary>

**User story:** As a user who amends a goal while the agent is still
working, I want the current turn to finish safely and my next message to
run under the accepted revision in the order I submitted it.

**Before**

There is no accepted amendment to coordinate with graph execution,
checkpoint writes, and messages already waiting in the queue.

**After**

1. If I accept a goal update during an active turn, dcode waits until
that turn and its reconciliation finish before changing the saved goal.
2. The accepted update is applied before a subsequent turn begins.
3. If I already submitted another message, that message runs first under
the updated goal; dcode does not race ahead with a synthetic
continuation turn.

</details>
This commit is contained in:
Mason Daugherty
2026-07-14 01:49:18 -04:00
committed by GitHub
parent 5d878bf134
commit 64205e238c
17 changed files with 1847 additions and 167 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ aliases, descriptions, visibility, or hidden-command metadata.
| `/effort` | | Set reasoning effort for the current model |
| `/feedback` | | Send feedback or report an issue |
| `/force-clear` | | Stop active work, clear the chat, and start a new thread |
| `/goal` | | Set a persistent objective by drafting acceptance criteria |
| `/goal` | | Set and manage a persistent objective with acceptance criteria |
| `/help` | | Show help and available commands |
| `/install` | | Install an optional integration |
| `/mcp` | | Manage MCP servers and authentication |
File diff suppressed because it is too large Load Diff
+11
View File
@@ -252,6 +252,17 @@ ToolCallMessage.-ascii:hover {
background: $surface;
}
/* Persistent goal status */
.goal-status-panel {
height: auto;
max-height: 5;
margin: 0 1;
padding: 0 1;
color: $text;
background: $surface-darken-1;
border-left: thick $primary;
}
/* Goal review widget */
.goal-review-menu {
height: auto;
@@ -106,12 +106,16 @@ COMMANDS: tuple[SlashCommand, ...] = (
),
SlashCommand(
name="/goal",
description="Set a persistent objective by drafting acceptance criteria",
description="Set and manage a persistent objective with acceptance criteria",
bypass_tier=BypassTier.QUEUED,
hidden_keywords=(
"objective criteria acceptance rubric grader grading model iterations"
"objective criteria acceptance amend pause resume rubric grader grading "
"model iterations"
),
argument_hint=(
"[<objective>|amend <feedback>|pause|resume|show|clear|model|"
"max-iterations]"
),
argument_hint="[<objective>|show|clear|model|max-iterations]",
),
SlashCommand(
name="/editor",
+89 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import Any
from typing import Any, TypedDict, cast
from langchain_core.messages import HumanMessage, SystemMessage
@@ -14,6 +14,21 @@ GOAL_RUBRIC_SYSTEM_PROMPT = (
"user-visible behavior when relevant. Do not start implementing the goal."
)
GOAL_AMENDMENT_SYSTEM_PROMPT = (
"You amend an existing coding-agent goal from user feedback. Preserve every "
"unaffected acceptance criterion and explicit user constraint. Change only "
"the objective and criteria needed to incorporate the feedback. Return a "
"revised objective and a concise markdown bullet list of concrete, testable "
"acceptance criteria. Do not start implementing the goal."
)
class GoalAmendment(TypedDict):
"""Structured proposed update to an existing goal."""
objective: str
criteria: str
def _goal_rubric_human_prompt(
objective: str,
@@ -67,6 +82,79 @@ def _goal_rubric_human_prompt(
return "\n".join(parts)
def _goal_amendment_human_prompt(
objective: str,
criteria: str,
feedback: str,
) -> str:
"""Build the bounded prompt for amending an accepted goal.
Args:
objective: Current accepted objective.
criteria: Current accepted criteria.
feedback: User-requested changes.
Returns:
Prompt text with each user-controlled value in an explicit boundary.
"""
return (
f"<current_goal>\n{objective}\n</current_goal>\n\n"
f"<current_criteria>\n{criteria}\n</current_criteria>\n\n"
f"<user_feedback>\n{feedback}\n</user_feedback>"
)
def generate_goal_amendment(
objective: str,
criteria: str,
feedback: str,
*,
model_spec: str | None,
model_params: dict[str, Any] | None = None,
profile_override: dict[str, Any] | None = None,
) -> GoalAmendment:
"""Generate a proposed objective and criteria amendment.
Args:
objective: Current accepted objective.
criteria: Current accepted criteria.
feedback: User-requested changes.
model_spec: Model spec used to draft the amendment.
model_params: Optional model constructor kwargs.
profile_override: Optional profile metadata overrides.
Returns:
Proposed amended objective and criteria.
"""
from deepagents_code.config import create_model
result = create_model(
model_spec,
extra_kwargs=model_params,
profile_overrides=profile_override,
)
model = result.model.with_structured_output(GoalAmendment)
response = cast(
"GoalAmendment",
model.invoke(
[
SystemMessage(content=GOAL_AMENDMENT_SYSTEM_PROMPT),
HumanMessage(
content=_goal_amendment_human_prompt(
objective,
criteria,
feedback,
)
),
]
),
)
return {
"objective": str(response.get("objective", "")).strip(),
"criteria": str(response.get("criteria", "")).strip(),
}
def generate_goal_rubric(
objective: str,
*,
+34 -14
View File
@@ -46,6 +46,7 @@ GOAL_TOOLS_SYSTEM_PROMPT = """## Goal and Rubric Tools
Use `get_rubric` to inspect active acceptance criteria before deciding whether work is
complete.
When a goal is active, use `get_goal` to inspect the objective and current status.
A paused goal is persisted for later but must not drive work until the user resumes it.
Use `update_goal` only when you have evidence that the goal is complete or blocked."""
"""Model-visible guidance injected before each request by `GoalToolsMiddleware`."""
@@ -91,11 +92,12 @@ class GoalSnapshot(TypedDict):
"""
active: bool
"""Whether the goal is unfinished.
"""Whether the goal is actionable (should drive work).
Derived from `status`: a set goal is active until it is `complete`. `False`
when no goal is set (the `objective is None` branch), where `status` is also
`None`.
Derived from `status`: `active` and `blocked` goals are actionable, while
`paused` and `complete` goals are not. Note a `paused` goal is unfinished
yet reports `active=False`. `False` when no goal is set (the
`objective is None` branch), where `status` is also `None`.
"""
objective: str | None
@@ -110,7 +112,7 @@ class GoalSnapshot(TypedDict):
"""
criteria: str | None
"""Accepted criteria (from the shared rubric snapshot)."""
"""Persisted goal criteria, or shared rubric criteria when no goal rubric exists."""
note: str | None
"""Latest evidence or blocker note recorded by `update_goal`."""
@@ -155,22 +157,25 @@ def _rubric_snapshot(state: dict[str, Any]) -> RubricSnapshot:
goal_rubric = _clean_state_text(state, "_goal_rubric")
sticky_rubric = _clean_state_text(state, "_sticky_rubric")
objective = _clean_state_text(state, "_goal_objective")
status = coerce_goal_status(state.get("_goal_status")) or "active"
goal_is_actionable = objective is not None and status in {"active", "blocked"}
sticky_is_goal_rubric = objective is not None and sticky_rubric == goal_rubric
source: RubricSource | None = None
if criteria is not None:
if objective is not None and goal_rubric == criteria:
if goal_is_actionable and goal_rubric == criteria:
source = "goal"
elif sticky_rubric == criteria:
elif sticky_rubric == criteria and not sticky_is_goal_rubric:
source = "sticky"
else:
source = "invocation"
# Fallback branches below run only when there is no public `rubric` input,
# so `invocation` is unreachable here by construction — the criteria can
# only be attributed to a `goal` or a `sticky` rubric.
elif objective is not None and goal_rubric is not None:
# only be attributed to an actionable `goal` or a standalone `sticky` rubric.
elif goal_is_actionable and goal_rubric is not None:
criteria = goal_rubric
source = "goal"
elif sticky_rubric is not None:
elif sticky_rubric is not None and not sticky_is_goal_rubric:
criteria = sticky_rubric
source = "sticky"
@@ -207,14 +212,15 @@ def _goal_snapshot(state: dict[str, Any]) -> GoalSnapshot:
# A set-but-unlabeled or unrecognized status defaults to "active"; an
# unknown persisted value never leaks to the model as a bogus status.
status: GoalStatus = coerce_goal_status(state.get("_goal_status")) or "active"
criteria = _clean_state_text(state, "_goal_rubric") or rubric["criteria"]
note = _clean_state_text(state, "_goal_status_note")
return {
# A goal is active until it is complete; `blocked` is still unfinished.
# Derive `active` from `status` so the two never disagree.
"active": status != "complete",
# Blocked goals remain actionable, while paused and complete goals do not
# drive work until the user changes their state.
"active": status in {"active", "blocked"},
"objective": objective,
"status": status,
"criteria": rubric["criteria"],
"criteria": criteria,
"note": note,
}
@@ -260,6 +266,20 @@ def _update_goal_command(
]
}
)
goal_status = coerce_goal_status(state.get("_goal_status")) or "active"
if goal_status in {"paused", "complete"}:
if goal_status == "paused":
message = (
"The goal is paused. The user must run `/goal resume` before its "
"status can be updated."
)
else:
message = "The goal is already complete and cannot be updated."
return Command(
update={
"messages": [ToolMessage(content=message, tool_call_id=tool_call_id)]
}
)
clean_note = note.strip()
if not clean_note:
# Evidence is required: refuse to commit a status with no justification
+31 -6
View File
@@ -26,8 +26,8 @@ have no model-node write site; the two exceptions are called out below:
- `_sticky_rubric` — the TUI-owned persistent rubric. This is separate from
the public `rubric` graph input so one-shot rubric turns can be checkpointed
without being restored as sticky state.
- `_pending_goal_objective` / `_pending_goal_rubric` — a proposed goal awaiting
user acceptance of its criteria.
- `_pending_goal_objective` / `_pending_goal_rubric` / `_pending_goal_kind` — a
proposed goal or amendment awaiting user acceptance of its criteria.
All of these are facts the CLI reads back from `state_values` on thread resume
so it can rehydrate the session without replaying or re-tokenizing history.
@@ -66,14 +66,34 @@ from langchain_core.messages import AIMessage
if TYPE_CHECKING:
from langgraph.runtime import Runtime
GoalStatus = Literal["active", "blocked", "complete"]
GoalStatus = Literal["active", "paused", "blocked", "complete"]
"""Lifecycle status of a TUI-owned goal.
`active` and `blocked` are unfinished states; `complete` is terminal. A blocked
goal is still considered active (unfinished) by `get_goal`.
`active` and `blocked` are unfinished working states, `paused` preserves the goal
without driving work, and `complete` is terminal. A blocked goal is still
considered actionable (`active=True`) by `get_goal`, whereas a paused goal is
unfinished but reports `active=False`.
"""
GoalProposalKind = Literal["create", "amend"]
"""Whether a pending review creates a goal or amends the current one."""
_GOAL_STATUS_VALUES: frozenset[str] = frozenset(get_args(GoalStatus))
_GOAL_PROPOSAL_KIND_VALUES: frozenset[str] = frozenset(get_args(GoalProposalKind))
def coerce_goal_proposal_kind(value: object) -> GoalProposalKind | None:
"""Narrow a persisted proposal kind to a known value.
Args:
value: Raw value read from checkpoint state.
Returns:
The recognized proposal kind, otherwise `None`.
"""
if isinstance(value, str) and value in _GOAL_PROPOSAL_KIND_VALUES:
return cast("GoalProposalKind", value)
return None
def coerce_goal_status(value: object) -> GoalStatus | None:
@@ -115,7 +135,7 @@ class GoalRubricChannels(AgentState):
"""Accepted goal objective restored by the TUI on resume."""
_goal_status: Annotated[NotRequired[GoalStatus | None], PrivateStateAttr]
"""Goal lifecycle status (`active`, `blocked`, `complete`, or `None`)."""
"""Goal lifecycle status (`active`, `paused`, `blocked`, `complete`, or `None`)."""
_goal_rubric: Annotated[NotRequired[str | None], PrivateStateAttr]
"""Accepted rubric associated with `_goal_objective`."""
@@ -153,6 +173,11 @@ class ResumeState(GoalRubricChannels):
_pending_goal_rubric: Annotated[NotRequired[str | None], PrivateStateAttr]
"""Proposed criteria awaiting user acceptance."""
_pending_goal_kind: Annotated[
NotRequired[GoalProposalKind | None], PrivateStateAttr
]
"""Whether the pending review creates or amends a goal."""
def _extract_context_tokens(message: AIMessage) -> int | None:
"""Return the context-token count from an AI message, or `None` if absent.
@@ -132,6 +132,8 @@ class GoalReviewMenu(Container):
self,
objective: str,
criteria: str,
*,
amendment: bool = False,
id: str | None = None, # noqa: A002
) -> None:
"""Initialize the goal review menu."""
@@ -142,6 +144,9 @@ class GoalReviewMenu(Container):
self._criteria = criteria
"""Generated acceptance criteria proposed for the goal."""
self._amendment = amendment
"""Whether this review updates an existing goal."""
self._selected = 0
"""Index of the currently highlighted action option."""
@@ -174,18 +179,22 @@ class GoalReviewMenu(Container):
Widgets for the title, criteria preview, actions, editor, and help text.
"""
glyphs = get_glyphs()
title = "Review goal amendment" if self._amendment else "Review goal criteria"
yield Static(
Content.from_markup("$cursor Review goal criteria", cursor=glyphs.cursor),
Content.from_markup("$cursor $title", cursor=glyphs.cursor, title=title),
classes="goal-review-title",
)
with (
VerticalScroll(classes="goal-review-content"),
Vertical(classes="goal-review-body"),
):
yield Markdown(
f"**Proposed criteria**\n\n{self._criteria}",
classes="goal-review-markdown",
)
source = f"**Proposed criteria**\n\n{self._criteria}"
if self._amendment:
source = (
f"**Proposed objective**\n\n{self._objective}\n\n"
f"**Proposed criteria**\n\n{self._criteria}"
)
yield Markdown(source, classes="goal-review-markdown")
with Container(classes="goal-review-options-container"):
for _ in _OPTIONS:
widget = Static("", classes="goal-review-option")
@@ -0,0 +1,50 @@
"""Persistent inline display for the current goal."""
from __future__ import annotations
from typing import TYPE_CHECKING
from textual.content import Content
from textual.widgets import Static
if TYPE_CHECKING:
from deepagents_code.resume_state import GoalStatus
class GoalStatusPanel(Static):
"""Keep the current goal and lifecycle state visible above the input."""
def __init__(self, *, id: str | None = None) -> None: # noqa: A002
"""Initialize an empty hidden goal panel."""
super().__init__("", id=id, classes="goal-status-panel")
self.display = False
def set_goal(
self,
objective: str | None,
status: GoalStatus | None,
note: str | None,
) -> None:
"""Render the current goal or hide the panel when no goal exists.
Args:
objective: Persisted goal objective, if set.
status: Current lifecycle state.
note: Blocker or completion note associated with the state.
"""
if not objective:
self.update("")
self.display = False
return
current = status or "active"
label = "completed" if current == "complete" else current
content = Content.from_markup(
"[bold]Goal · $status[/bold]\n$objective",
status=label,
objective=objective,
)
if note and current in {"blocked", "complete"}:
content += Content.from_markup("\n[dim]$note[/dim]", note=note)
self.update(content)
self.display = True
@@ -33,6 +33,7 @@ _TIPS: dict[str, int] = {
"Ask for a workflow to fan work out to subagents in parallel": 3,
"Use /auto-update to toggle automatic updates": 1,
"Use /timestamps to show or hide message timestamp footers": 1,
"Use /goal amend to steer an active goal without restarting it": 2,
"Use /agents to browse and switch between your available agents": 2,
"In /agents, press Ctrl+S to set the highlighted agent as your default": 1,
"Press Shift+Tab to toggle auto-approve mode": 2,
@@ -384,6 +384,7 @@ Available subagent types:
Use `get_rubric` to inspect active acceptance criteria before deciding whether work is
complete.
When a goal is active, use `get_goal` to inspect the objective and current status.
A paused goal is persisted for later but must not drive work until the user resumes it.
Use `update_goal` only when you have evidence that the goal is complete or blocked.
## `ask_user`
+698 -20
View File
@@ -25,6 +25,7 @@ if TYPE_CHECKING:
from deepagents_code.mcp_auth import McpServerSpec
from deepagents_code.notifications import PendingNotification
from deepagents_code.resume_state import GoalStatus
from deepagents_code.sessions import ThreadInfo
from deepagents_code.tui.widgets.messages import ToolCallMessage
@@ -39,6 +40,7 @@ from textual.screen import ModalScreen
from textual.widget import Widget
from textual.widgets import Checkbox, Input, Static
from deepagents_code._constants import SYSTEM_MESSAGE_PREFIX
from deepagents_code._session_stats import SessionStats
from deepagents_code._version import CHANGELOG_URL, __version__
from deepagents_code.app import (
@@ -53,6 +55,7 @@ from deepagents_code.app import (
_ChatScroll,
_display_model_label,
_extra_is_ready,
_GoalApplication,
_parse_rubric_max_iterations,
_ThreadHistoryPayload,
_warn_discarded_goal_channels,
@@ -62,6 +65,7 @@ from deepagents_code.media_utils import ImageData, VideoData
from deepagents_code.tui.widgets.ask_user import AskUserTextArea
from deepagents_code.tui.widgets.chat_input import ChatInput
from deepagents_code.tui.widgets.goal_review import GoalReviewMenu, GoalReviewResult
from deepagents_code.tui.widgets.goal_status import GoalStatusPanel
from deepagents_code.tui.widgets.launch_init import (
LaunchDependenciesScreen,
LaunchNameScreen,
@@ -264,6 +268,7 @@ class TestInitialPromptOnMount:
chat = MagicMock()
chat.styles = SimpleNamespace(scrollbar_size_vertical=None)
status_bar = MagicMock(spec=StatusBar)
goal_status_panel = MagicMock(spec=GoalStatusPanel)
chat_input = MagicMock(spec=ChatInput)
chat_input.input_widget = None
@@ -272,6 +277,8 @@ class TestInitialPromptOnMount:
return chat
if selector == "#status-bar":
return status_bar
if selector == "#goal-status-panel":
return goal_status_panel
if selector == "#input-area":
return chat_input
raise NoMatches(str(selector))
@@ -5531,7 +5538,7 @@ class TestWarnDiscardedGoalChannels:
) -> None:
"""An unrecognized status string is discarded and surfaced at WARNING."""
with caplog.at_level(logging.WARNING, logger="deepagents_code.app"):
discarded = _warn_discarded_goal_channels({"_goal_status": "paused"})
discarded = _warn_discarded_goal_channels({"_goal_status": "deleted"})
assert discarded == ["_goal_status"]
assert any(record.levelno == logging.WARNING for record in caplog.records)
@@ -5540,6 +5547,21 @@ class TestWarnDiscardedGoalChannels:
"""A recognized status must not be reported as discarded."""
assert _warn_discarded_goal_channels({"_goal_status": "complete"}) == []
def test_flags_unknown_pending_goal_kind(
self,
caplog: pytest.LogCaptureFixture,
) -> None:
"""An unrecognized pending proposal kind is discarded and warned."""
with caplog.at_level(logging.WARNING, logger="deepagents_code.app"):
discarded = _warn_discarded_goal_channels({"_pending_goal_kind": "replace"})
assert discarded == ["_pending_goal_kind"]
assert any(record.levelno == logging.WARNING for record in caplog.records)
def test_known_pending_goal_kind_is_not_flagged(self) -> None:
"""A recognized pending proposal kind must not be reported as discarded."""
assert _warn_discarded_goal_channels({"_pending_goal_kind": "amend"}) == []
class TestGoalCommand:
"""Tests for goal-backed rubric proposal workflow."""
@@ -5555,6 +5577,600 @@ class TestGoalCommand:
future.set_result(result)
return future
@pytest.mark.parametrize(
("status", "label"),
[
("active", "active"),
("paused", "paused"),
("blocked", "blocked"),
("complete", "completed"),
],
)
async def test_goal_panel_keeps_current_state_visible(
self,
status: GoalStatus,
label: str,
) -> None:
"""The persistent panel should display every goal lifecycle state."""
app = DeepAgentsApp(agent=MagicMock())
async with app.run_test() as pilot:
await pilot.pause()
app._active_goal = "ship interactive goals"
app._goal_status = status
app._goal_status_note = "waiting for input" if status == "blocked" else None
app._sync_status_rubric()
panel = app.query_one("#goal-status-panel", GoalStatusPanel)
assert panel.display is True
assert label in str(panel.content)
assert "ship interactive goals" in str(panel.content)
async def test_goal_amend_applies_objective_and_criteria_after_review(
self,
) -> None:
"""An approved amendment should replace both canonical goal fields."""
app = DeepAgentsApp(agent=MagicMock())
async with app.run_test() as pilot:
await pilot.pause()
app._active_goal = "ship login"
app._goal_status = "active"
app._active_rubric = "- password login works\n- keep API stable"
review = AsyncMock(
return_value=self._goal_review_future({"type": "accepted"})
)
continuation = AsyncMock()
with (
patch.object(
app,
"_generate_goal_amendment",
return_value=(
"ship login with passkeys",
"- password login works\n- passkeys work\n- keep API stable",
),
),
patch.object(app, "_request_goal_review", review),
patch.object(app, "_continue_goal_work", continuation),
patch.object(app, "_set_spinner", new_callable=AsyncMock),
):
await app._handle_command("/goal amend add passkey support")
for _ in range(20):
await pilot.pause()
if app._active_goal == "ship login with passkeys":
break
assert app._active_goal == "ship login with passkeys"
assert app._active_rubric == (
"- password login works\n- passkeys work\n- keep API stable"
)
assert app._pending_goal_objective is None
assert app._pending_goal_rubric is None
assert app._pending_goal_kind is None
review.assert_awaited_once_with(
"ship login with passkeys",
"- password login works\n- passkeys work\n- keep API stable",
amendment=True,
)
continuation.assert_awaited_once_with("amended")
async def test_rejecting_goal_amendment_keeps_current_goal(self) -> None:
"""Review feedback must not apply a proposed amendment prematurely."""
app = DeepAgentsApp(agent=MagicMock())
async with app.run_test() as pilot:
await pilot.pause()
app._active_goal = "ship login"
app._goal_status = "active"
app._active_rubric = "- password login works"
app._pending_goal_objective = "ship login with passkeys"
app._pending_goal_rubric = "- passkeys work"
app._pending_goal_kind = "amend"
future = self._goal_review_future(
{"type": "rejected", "message": "keep password login too"}
)
with patch.object(
app,
"_propose_goal_amendment",
new_callable=AsyncMock,
) as propose:
await app._finish_pending_goal_rubric_review(future)
await pilot.pause()
assert app._active_goal == "ship login"
assert app._active_rubric == "- password login works"
propose.assert_awaited_once_with(
"keep password login too",
objective="ship login with passkeys",
criteria="- passkeys work",
)
async def test_goal_amend_persists_cleared_proposal_before_drafting(
self,
) -> None:
"""Replacing a pending review must remove it from the checkpoint first."""
app = DeepAgentsApp(agent=MagicMock())
async with app.run_test() as pilot:
await pilot.pause()
app._active_goal = "ship login"
app._goal_status = "active"
app._active_rubric = "- password login works"
app._pending_goal_objective = "stale proposal"
app._pending_goal_rubric = "- stale criteria"
app._pending_goal_kind = "create"
events: list[str] = []
persisted_updates: list[dict[str, Any]] = []
def persist() -> bool:
events.append("persist")
persisted_updates.append(app._goal_state_update())
return True
def fail_generation(*_args: object) -> tuple[str, str]:
events.append("generate")
msg = "model down"
raise RuntimeError(msg)
with (
patch.object(app, "_persist_goal_rubric_state", side_effect=persist),
patch.object(
app,
"_generate_goal_amendment",
side_effect=fail_generation,
),
patch.object(app, "_set_spinner", new_callable=AsyncMock),
):
await app._handle_command("/goal amend add passkeys")
for _ in range(20):
await pilot.pause()
if "generate" in events:
break
assert events == ["persist", "generate"]
persisted = persisted_updates[0]
assert persisted["_pending_goal_objective"] is None
assert persisted["_pending_goal_rubric"] is None
assert persisted["_pending_goal_kind"] is None
async def test_goal_amend_without_active_goal_shows_guidance(self) -> None:
"""Amending without a goal should explain how to create one."""
app = DeepAgentsApp(agent=MagicMock())
async with app.run_test() as pilot:
await pilot.pause()
await app._handle_command("/goal amend add passkeys")
await pilot.pause()
rendered = "\n".join(str(w._content) for w in app.query(AppMessage))
assert "No active goal to amend" in rendered
assert "/goal <objective>" in rendered
async def test_paused_goal_suppresses_rubric_for_next_turn(self) -> None:
"""Paused goals should remain saved without invoking rubric grading."""
app = DeepAgentsApp(agent=MagicMock())
async with app.run_test() as pilot:
await pilot.pause()
app._active_goal = "ship login"
app._goal_status = "active"
app._active_rubric = "- tests pass"
await app._pause_goal()
assert app._goal_status == "paused"
assert app._active_goal == "ship login"
assert app._active_rubric == "- tests pass"
assert app._goal_state_update()["rubric"] is None
execute = AsyncMock(return_value=SessionStats())
with (
patch(
"deepagents_code.tui.textual_adapter.execute_task_textual",
execute,
),
patch.object(app, "_cleanup_agent_task", new_callable=AsyncMock),
):
await app._run_agent_task("answer an unrelated question")
assert execute.await_args is not None
assert execute.await_args.kwargs["rubric"] is None
async def test_pause_goal_persist_failure_reverts_and_warns(self) -> None:
"""A failed pause write reverts to active and surfaces an inline error."""
app = DeepAgentsApp(agent=MagicMock())
async with app.run_test() as pilot:
await pilot.pause()
app._agent = SimpleNamespace(
aupdate_state=AsyncMock(side_effect=RuntimeError("down"))
)
app._lc_thread_id = "thread-1"
app._active_goal = "ship login"
app._goal_status = "active"
app._active_rubric = "- tests pass"
await app._pause_goal()
assert app._goal_status == "active"
assert any(
"Goal pause could not be saved" in str(w._content)
for w in app.query(ErrorMessage)
)
assert not any(
str(w._content).startswith("Goal paused.")
for w in app.query(AppMessage)
)
async def test_resume_goal_persist_failure_reverts_and_warns(self) -> None:
"""A failed resume write reverts to paused and surfaces an inline error."""
app = DeepAgentsApp(agent=MagicMock())
async with app.run_test() as pilot:
await pilot.pause()
app._agent = SimpleNamespace(
aupdate_state=AsyncMock(side_effect=RuntimeError("down"))
)
app._lc_thread_id = "thread-1"
app._active_goal = "ship login"
app._goal_status = "paused"
app._active_rubric = "- tests pass"
send = AsyncMock()
with patch.object(app, "_send_to_agent", send):
await app._resume_goal()
assert app._goal_status == "paused"
assert any(
"Goal resume could not be saved" in str(w._content)
for w in app.query(ErrorMessage)
)
send.assert_not_awaited()
async def test_pause_goal_rejects_when_no_goal_or_non_pausable(self) -> None:
"""Pause guards cover the absent, already-paused, and blocked cases."""
app = DeepAgentsApp(agent=MagicMock())
async with app.run_test() as pilot:
await pilot.pause()
await app._pause_goal() # no active goal
app._active_goal = "ship login"
app._goal_status = "paused"
await app._pause_goal() # already paused
app._goal_status = "blocked"
app._goal_status_note = "waiting for input"
await app._pause_goal() # blocked, awaiting user
rendered = "\n".join(str(w._content) for w in app.query(AppMessage))
assert "No active goal to pause" in rendered
assert "Goal is already paused." in rendered
assert "Goal is blocked and already waiting" in rendered
# A rejected pause never mutates status.
assert app._goal_status == "blocked"
async def test_resume_goal_requires_paused_state(self) -> None:
"""Resume guards cover the absent and not-paused cases."""
app = DeepAgentsApp(agent=MagicMock())
async with app.run_test() as pilot:
await pilot.pause()
await app._resume_goal() # no goal
app._active_goal = "ship login"
app._goal_status = "active"
await app._resume_goal() # active, not paused
rendered = "\n".join(str(w._content) for w in app.query(AppMessage))
assert "No paused goal to resume" in rendered
assert "Goal is not paused" in rendered
assert app._goal_status == "active"
async def test_queued_goal_apply_failure_requeues_and_releases_gate(
self,
) -> None:
"""A raising boundary apply re-queues the goal, drains, and frees the gate."""
app = DeepAgentsApp(agent=MagicMock())
async with app.run_test() as pilot:
await pilot.pause()
application = _GoalApplication("ship login", "- tests pass", "amend")
app._queued_goal_application = application
process = AsyncMock()
with (
patch.object(
app,
"_apply_goal_application",
new=AsyncMock(side_effect=RuntimeError("dom torn down")),
),
patch.object(
app,
"_sync_goal_rubric_state_from_thread",
new_callable=AsyncMock,
),
patch.object(app, "_maybe_drain_deferred", new_callable=AsyncMock),
patch.object(app, "_process_next_from_queue", process),
patch.object(app, "_set_spinner", new_callable=AsyncMock),
):
await app._cleanup_agent_task()
# The goal stays queued for the next turn instead of being lost, the
# failure is surfaced, the queue is still drained (not stranded), and
# the quiescence gate is released so a parked mutation can't deadlock.
assert app._queued_goal_application is application
assert any(
"could not be applied" in str(w._content)
for w in app.query(ErrorMessage)
)
process.assert_awaited()
assert app._agent_quiescent.is_set()
async def test_cleanup_releases_quiescence_when_reconciliation_raises(
self,
) -> None:
"""The gate must free even if reconciliation raises, or waiters deadlock."""
app = DeepAgentsApp(agent=MagicMock())
async with app.run_test() as pilot:
await pilot.pause()
with (
patch.object(
app,
"_sync_goal_rubric_state_from_thread",
new=AsyncMock(side_effect=RuntimeError("reconcile boom")),
),
patch.object(app, "_set_spinner", new_callable=AsyncMock),
pytest.raises(RuntimeError, match="reconcile boom"),
):
await app._cleanup_agent_task()
assert app._agent_reconciling is False
assert app._agent_quiescent.is_set()
async def test_apply_goal_application_aborts_when_goal_ended(self) -> None:
"""A queued amendment must abort if the goal vanished before it applied."""
app = DeepAgentsApp(agent=MagicMock())
async with app.run_test() as pilot:
await pilot.pause()
app._active_goal = None
app._goal_status = None
app._pending_goal_objective = "ship login with passkeys"
app._pending_goal_rubric = "- passkeys work"
app._pending_goal_kind = "amend"
persist = AsyncMock(return_value=True)
with patch.object(app, "_persist_goal_rubric_state", persist):
result = await app._apply_goal_application(
_GoalApplication(
"ship login with passkeys", "- passkeys work", "amend"
),
continue_work=False,
at_boundary=True,
)
assert result is None
persist.assert_not_awaited()
assert app._pending_goal_objective is None
assert app._pending_goal_kind is None
assert any(
"The goal ended before the amendment could be applied"
in str(w._content)
for w in app.query(AppMessage)
)
async def test_accept_amendment_after_goal_cleared_shows_guidance(self) -> None:
"""Accepting an amendment whose goal was cleared must not resurrect it."""
app = DeepAgentsApp(agent=MagicMock())
async with app.run_test() as pilot:
await pilot.pause()
app._active_goal = None
app._goal_status = None
app._pending_goal_objective = "ship login with passkeys"
app._pending_goal_rubric = "- passkeys work"
app._pending_goal_kind = "amend"
apply_mock = AsyncMock()
with patch.object(app, "_apply_goal_application", apply_mock):
await app._accept_goal_rubric("- passkeys work")
apply_mock.assert_not_awaited()
assert app._queued_goal_application is None
assert any(
"The goal is no longer active" in str(w._content)
for w in app.query(AppMessage)
)
async def test_goal_panel_renders_note_only_for_terminal_states(self) -> None:
"""Blocker/completion notes render for blocked/complete goals only."""
app = DeepAgentsApp(agent=MagicMock())
async with app.run_test() as pilot:
await pilot.pause()
panel = app.query_one("#goal-status-panel", GoalStatusPanel)
panel.set_goal("ship login", "blocked", "waiting for review")
assert "waiting for review" in str(panel.content)
panel.set_goal("ship login", "active", "stale note")
assert "stale note" not in str(panel.content)
async def test_goal_panel_hides_when_goal_cleared(self) -> None:
"""Clearing the goal hides the persistent panel and empties its content."""
app = DeepAgentsApp(agent=MagicMock())
async with app.run_test() as pilot:
await pilot.pause()
panel = app.query_one("#goal-status-panel", GoalStatusPanel)
panel.set_goal("ship login", "active", None)
assert panel.display is True
panel.set_goal(None, None, None)
assert panel.display is False
assert str(panel.content) == ""
async def test_goal_application_rejects_empty_fields(self) -> None:
"""The queued-goal value object refuses an empty objective or rubric."""
with pytest.raises(ValueError, match="non-empty objective and rubric"):
_GoalApplication("", "- tests pass", "create")
with pytest.raises(ValueError, match="non-empty objective and rubric"):
_GoalApplication("ship login", "", "create")
async def test_completed_goal_suppresses_rubric_for_later_turns(self) -> None:
"""A completed goal remains terminal and does not keep grading."""
app = DeepAgentsApp(agent=MagicMock())
async with app.run_test() as pilot:
await pilot.pause()
app._active_goal = "ship login"
app._goal_status = "complete"
app._active_rubric = "- tests pass"
assert app._goal_state_update()["rubric"] is None
execute = AsyncMock(return_value=SessionStats())
with (
patch(
"deepagents_code.tui.textual_adapter.execute_task_textual",
execute,
),
patch.object(app, "_cleanup_agent_task", new_callable=AsyncMock),
):
await app._run_agent_task("start a separate task")
assert execute.await_args is not None
assert execute.await_args.kwargs["rubric"] is None
async def test_goal_resume_preserves_state_without_replaying_objective(
self,
) -> None:
"""Resume should continue through hidden control context, not task replay."""
app = DeepAgentsApp(agent=MagicMock())
async with app.run_test() as pilot:
await pilot.pause()
app._active_goal = "ship login"
app._goal_status = "paused"
app._active_rubric = "- tests pass"
send = AsyncMock()
with patch.object(app, "_send_to_agent", send):
await app._resume_goal()
assert app._goal_status == "active"
assert app._active_goal == "ship login"
assert app._active_rubric == "- tests pass"
send.assert_awaited_once()
assert send.await_args is not None
control_message = send.await_args.args[0]
assert control_message.startswith(SYSTEM_MESSAGE_PREFIX)
assert "ship login" not in control_message
assert "Do not repeat completed work" in control_message
async def test_inflight_amendment_applies_after_reconciliation(self) -> None:
"""An accepted in-flight amendment waits for the checkpoint boundary."""
app = DeepAgentsApp(agent=MagicMock())
async with app.run_test() as pilot:
await pilot.pause()
app._lc_thread_id = "thread-1"
app._active_goal = "ship login"
app._goal_status = "active"
app._active_rubric = "- password login works"
app._pending_goal_objective = "ship login with passkeys"
app._pending_goal_rubric = "- passkeys work"
app._pending_goal_kind = "amend"
app._agent_running = True
persist = AsyncMock(return_value=True)
fetch = AsyncMock(
return_value={
"_goal_objective": "ship login",
"_goal_status": "active",
"_goal_rubric": "- password login works",
"_pending_goal_objective": "ship login with passkeys",
"_pending_goal_rubric": "- passkeys work",
"_pending_goal_kind": "amend",
}
)
continuation = AsyncMock()
with (
patch.object(app, "_persist_goal_rubric_state", persist),
patch.object(app, "_get_thread_state_values", fetch),
patch.object(
app,
"_remount_pending_goal_rubric_review",
new_callable=AsyncMock,
),
patch.object(app, "_continue_goal_work", continuation),
patch.object(app, "_maybe_drain_deferred", new_callable=AsyncMock),
patch.object(app, "_process_next_from_queue", new_callable=AsyncMock),
patch.object(app, "_set_spinner", new_callable=AsyncMock),
):
await app._accept_goal_rubric("- passkeys work")
persist.assert_not_awaited()
assert app._active_goal == "ship login"
await app._cleanup_agent_task()
fetch.assert_awaited_once_with("thread-1")
persist.assert_awaited_once()
assert app._active_goal == "ship login with passkeys"
assert app._active_rubric == "- passkeys work"
assert app._queued_goal_application is None
continuation.assert_awaited_once_with("amended")
async def test_inflight_amendment_preserves_queued_message_order(self) -> None:
"""Queued steering should run instead of an extra synthetic continuation."""
app = DeepAgentsApp(agent=MagicMock())
async with app.run_test() as pilot:
await pilot.pause()
app._active_goal = "ship login"
app._goal_status = "active"
app._active_rubric = "- password login works"
app._pending_goal_objective = "ship login with passkeys"
app._pending_goal_rubric = "- passkeys work"
app._pending_goal_kind = "amend"
app._agent_running = True
app._pending_messages.append(
QueuedMessage("steer toward passkeys", "normal")
)
process = AsyncMock()
continuation = AsyncMock()
with (
patch.object(
app,
"_persist_goal_rubric_state",
new=AsyncMock(return_value=True),
),
patch.object(
app,
"_sync_goal_rubric_state_from_thread",
new_callable=AsyncMock,
),
patch.object(app, "_continue_goal_work", continuation),
patch.object(app, "_maybe_drain_deferred", new_callable=AsyncMock),
patch.object(app, "_process_next_from_queue", process),
patch.object(app, "_set_spinner", new_callable=AsyncMock),
):
await app._accept_goal_rubric("- passkeys work")
await app._cleanup_agent_task()
process.assert_awaited_once()
continuation.assert_not_awaited()
assert app._active_goal == "ship login with passkeys"
assert app._active_rubric == "- passkeys work"
async def test_repeated_amendments_do_not_add_status_transcript_messages(
self,
) -> None:
"""The persistent panel should replace per-amendment status messages."""
app = DeepAgentsApp(agent=MagicMock())
async with app.run_test() as pilot:
await pilot.pause()
app._active_goal = "ship login"
app._goal_status = "active"
app._active_rubric = "- login works"
with patch.object(
app,
"_persist_goal_rubric_state",
new=AsyncMock(return_value=True),
):
await app._apply_goal_application(
_GoalApplication("ship login safely", "- security tests", "amend"),
continue_work=False,
)
await app._apply_goal_application(
_GoalApplication(
"ship login safely and accessibly",
"- security tests\n- accessibility tests",
"amend",
),
continue_work=False,
)
assert not any(
"Goal amended" in str(w._content) for w in app.query(AppMessage)
)
panel = app.query_one("#goal-status-panel", GoalStatusPanel)
assert "ship login safely and accessibly" in str(panel.content)
def test_goal_usage_text_explains_goal_vs_rubric(self) -> None:
"""Goal help should explain drafting and that a goal persists once set."""
usage = DeepAgentsApp._goal_usage_text()
@@ -6123,6 +6739,7 @@ class TestGoalCommand:
"_pending_goal_completion_note": None,
"_pending_goal_objective": None,
"_pending_goal_rubric": None,
"_pending_goal_kind": None,
},
)
@@ -6531,6 +7148,7 @@ class TestGoalCommand:
"_pending_goal_completion_note": None,
"_pending_goal_objective": None,
"_pending_goal_rubric": None,
"_pending_goal_kind": None,
}
async def test_completed_goal_is_cleared_before_next_turn(self) -> None:
@@ -6587,6 +7205,9 @@ class TestGoalCommand:
app._goal_status = "active"
app._active_rubric = "- tests pass"
app._pending_goal_completion_note = "all acceptance tests pass"
app._pending_goal_objective = "add refresh tokens safely"
app._pending_goal_rubric = "- security tests pass"
app._pending_goal_kind = "amend"
committed = await app._resolve_pending_goal_completion(
rubric_status="satisfied",
@@ -6599,6 +7220,9 @@ class TestGoalCommand:
assert app._goal_status == "active"
assert app._active_rubric == "- tests pass"
assert app._pending_goal_completion_note == "all acceptance tests pass"
assert app._pending_goal_objective == "add refresh tokens safely"
assert app._pending_goal_rubric == "- security tests pass"
assert app._pending_goal_kind == "amend"
rendered = "\n".join(str(w._content) for w in app.query(AppMessage))
assert "Goal marked complete by the agent." not in rendered
errors = "\n".join(str(w._content) for w in app.query(ErrorMessage))
@@ -6657,7 +7281,12 @@ class TestGoalCommand:
"remains active for resume, amendment, retry, or clearing" in rendered
)
assert "Goal marked complete" not in rendered
assert "Commands:\n/goal clear\n/goal show" in rendered
assert (
"Commands:\n/goal amend <feedback>\n/goal pause\n/goal resume\n"
"/goal clear\n/goal show\n"
"/goal model [provider:model|clear]\n"
"/goal max-iterations <N|clear>" in rendered
)
async def test_grader_failed_keeps_goal_active_with_evaluation_error(self) -> None:
"""A `failed` grade must keep the goal active and blame the grader."""
@@ -6906,11 +7535,12 @@ class TestGoalCommand:
assert "Goal:\nmake the app start faster" in rendered
assert "Status:\nactive" in rendered
assert "Criteria:\n- measure baseline\n- improve startup" in rendered
assert "Goal is active for this thread until completed" in rendered
assert "Goal is active for this thread until paused, completed" in rendered
assert (
"Follow-up prompts will continue working toward this goal." in rendered
)
assert "Commands:\n/goal clear\n/goal show" in rendered
assert "Commands:\n/goal amend <feedback>\n/goal pause" in rendered
assert "/goal clear\n/goal show" in rendered
assert "Goal status:" not in rendered
assert "Accepted criteria:" not in rendered
@@ -7021,7 +7651,9 @@ class TestGoalCommand:
str(w._content) == "Goal cleared." for w in app.query(AppMessage)
)
@pytest.mark.parametrize("verb", ["show", "status", "accept", "edit", "clear"])
@pytest.mark.parametrize(
"verb", ["show", "status", "accept", "edit", "clear", "pause", "resume"]
)
async def test_goal_reserved_word_objective_drafts_goal(self, verb: str) -> None:
"""Reserved words only act as `/goal` subcommands when used alone."""
app = DeepAgentsApp(agent=MagicMock())
@@ -7051,7 +7683,7 @@ class TestGoalCommand:
assert app._active_rubric == "- existing rubric"
async def test_goal_accept_warns_when_persist_fails(self) -> None:
"""A failed thread write should warn without dumping criteria as an error."""
"""A failed thread write should warn and still start accepted goal work."""
app = DeepAgentsApp(agent=MagicMock())
async with app.run_test() as pilot:
await pilot.pause()
@@ -7064,14 +7696,17 @@ class TestGoalCommand:
request = AsyncMock(
return_value=self._goal_review_future({"type": "accepted"})
)
handle = AsyncMock()
with (
patch.object(app, "_request_goal_review", request),
patch.object(app, "_handle_user_message", AsyncMock()),
patch.object(app, "_handle_user_message", handle),
):
await app._review_pending_goal_rubric()
await pilot.pause()
await pilot.pause()
for _ in range(20):
await pilot.pause()
if handle.await_count:
break
# State still applies in-session, but the warning stays concise and
# does not render the accepted criteria as an error body.
@@ -7087,6 +7722,35 @@ class TestGoalCommand:
assert not any(
"- tests pass" in str(w._content) for w in app.query(ErrorMessage)
)
handle.assert_awaited_once_with("add refresh tokens")
async def test_goal_amend_does_not_continue_when_persist_fails(self) -> None:
"""Synthetic continuation must not read an unchanged checkpoint."""
app = DeepAgentsApp(agent=MagicMock())
async with app.run_test() as pilot:
await pilot.pause()
app._agent = SimpleNamespace(
aupdate_state=AsyncMock(side_effect=RuntimeError("down"))
)
app._lc_thread_id = "thread-1"
app._active_goal = "ship login"
app._goal_status = "active"
app._active_rubric = "- password login works"
app._pending_goal_objective = "ship login with passkeys"
app._pending_goal_rubric = "- passkeys work"
app._pending_goal_kind = "amend"
continuation = AsyncMock()
with patch.object(app, "_continue_goal_work", continuation):
await app._accept_goal_rubric("- passkeys work")
assert app._active_goal == "ship login with passkeys"
assert app._active_rubric == "- passkeys work"
assert any(
"could not be saved to the thread" in str(w._content)
for w in app.query(ErrorMessage)
)
continuation.assert_not_awaited()
async def test_goal_cancel_omits_unsaved_thread_warning(self) -> None:
"""Cancelling a goal proposal should not render the generic save warning."""
@@ -7398,6 +8062,7 @@ class TestRubricCommand:
"_pending_goal_completion_note": None,
"_pending_goal_objective": None,
"_pending_goal_rubric": None,
"_pending_goal_kind": None,
},
)
@@ -7410,6 +8075,7 @@ class TestRubricCommand:
app._active_rubric = "tests pass"
app._goal_status = "active"
app._goal_status_note = None
app._sync_status_rubric()
started = asyncio.Event()
def execute_stub(*_args: object, **_kwargs: object) -> SessionStats:
@@ -7426,10 +8092,14 @@ class TestRubricCommand:
assert mock_execute.await_args is not None
assert mock_execute.await_args.kwargs["user_input"] == "keep going"
assert mock_execute.await_args.kwargs["blocked_goal_retry_context"] is None
assert any(
str(w._content) == "Continuing active goal: add refresh tokens"
assert not any(
str(w._content).startswith("Continuing active goal")
for w in app.query(AppMessage)
)
panel = app.query_one("#goal-status-panel", GoalStatusPanel)
assert panel.display is True
assert "active" in str(panel.content)
assert "add refresh tokens" in str(panel.content)
assert app._goal_status == "active"
async def test_resume_notice_wording_when_goal_was_blocked(self) -> None:
@@ -7653,6 +8323,7 @@ class TestRubricCommand:
"_pending_goal_completion_note": None,
"_pending_goal_objective": None,
"_pending_goal_rubric": None,
"_pending_goal_kind": None,
}
config = {"configurable": {"thread_id": "thread-1"}}
updater.aupdate_state.assert_awaited_once_with(config, state_update)
@@ -7722,6 +8393,7 @@ class TestRubricCommand:
"_pending_goal_completion_note": None,
"_pending_goal_objective": None,
"_pending_goal_rubric": None,
"_pending_goal_kind": None,
}
config = {"configurable": {"thread_id": "thread-1"}}
updater.aupdate_state.assert_has_awaits(
@@ -21872,7 +22544,7 @@ class TestRestartCommand:
async def test_cancels_inflight_worker_before_respawn(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Active agent worker is cancelled and queue drained before respawn."""
"""Cancellation keeps goal mutations gated until worker cleanup finishes."""
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
@@ -21882,7 +22554,7 @@ class TestRestartCommand:
worker = MagicMock()
app._agent_worker = worker
app._agent_running = True
app._set_agent_running(True)
app._pending_messages.append(QueuedMessage(text="hi", mode="normal"))
from deepagents_code.config import settings
@@ -21896,17 +22568,23 @@ class TestRestartCommand:
monkeypatch.setattr(settings, "reload_from_environment", _reload)
monkeypatch.setattr("deepagents_code.model_config.clear_caches", _clear)
async def _noop_restart() -> None: # noqa: RUF029 # awaited by handler
return
async def _noop_restart() -> bool: # noqa: RUF029 # awaited by handler
assert app._agent_running is True
assert not app._agent_quiescent.is_set()
return False
monkeypatch.setattr(app, "_restart_server_manual", _noop_restart)
await app._handle_command("/restart")
await pilot.pause()
try:
await app._handle_command("/restart")
await pilot.pause()
worker.cancel.assert_called_once()
assert app._agent_running is False
assert len(app._pending_messages) == 0
worker.cancel.assert_called_once()
assert app._agent_running is True
assert not app._agent_quiescent.is_set()
assert len(app._pending_messages) == 0
finally:
app._set_agent_running(False)
async def test_case_insensitive_bypass_when_busy(
self, monkeypatch: pytest.MonkeyPatch
+65 -1
View File
@@ -5,7 +5,12 @@ from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
from deepagents_code.goal_rubric import _goal_rubric_human_prompt, generate_goal_rubric
from deepagents_code.goal_rubric import (
_goal_amendment_human_prompt,
_goal_rubric_human_prompt,
generate_goal_amendment,
generate_goal_rubric,
)
class _FakeModel:
@@ -21,6 +26,65 @@ class _FakeModel:
return SimpleNamespace(text=self._text)
class _FakeStructuredModel:
"""Structured-output model double for goal amendments."""
def __init__(self, response: dict[str, str]) -> None:
self._response = response
self.invoked_with: object | None = None
self.schema: object | None = None
def with_structured_output(self, schema: object) -> _FakeStructuredModel:
"""Record the requested schema and return this model."""
self.schema = schema
return self
def invoke(self, messages: object) -> dict[str, str]:
"""Record the prompt and return the configured amendment."""
self.invoked_with = messages
return self._response
class TestGoalAmendment:
"""Goal amendments preserve bounded current state and use structured output."""
def test_prompt_contains_current_state_and_feedback(self) -> None:
prompt = _goal_amendment_human_prompt(
"ship login",
"- password login works\n- keep API stable",
"add passkeys",
)
assert "<current_goal>\nship login\n</current_goal>" in prompt
assert "- keep API stable" in prompt
assert "<user_feedback>\nadd passkeys\n</user_feedback>" in prompt
def test_generate_returns_trimmed_structured_amendment(self) -> None:
model = _FakeStructuredModel(
{
"objective": " ship login with passkeys ",
"criteria": " - password login works\n- passkeys work ",
}
)
with patch(
"deepagents_code.config.create_model",
return_value=SimpleNamespace(model=model),
):
result = generate_goal_amendment(
"ship login",
"- password login works",
"add passkeys",
model_spec="openai:gpt-5.5",
)
assert result == {
"objective": "ship login with passkeys",
"criteria": "- password login works\n- passkeys work",
}
assert model.schema is not None
assert model.invoked_with is not None
class TestGoalRubricHumanPrompt:
"""Prompt construction wraps user-controlled content in explicit boundaries."""
@@ -63,6 +63,41 @@ def test_rubric_snapshot_identifies_goal_rubric() -> None:
}
@pytest.mark.parametrize("status", ["paused", "complete"])
def test_rubric_snapshot_suppresses_inactive_goal_rubric(status: str) -> None:
"""Paused and completed goal criteria must not drive later work."""
assert _rubric_snapshot(
{
"_goal_objective": "ship it",
"_goal_status": status,
"_goal_rubric": "- tests pass",
"_sticky_rubric": "- tests pass",
}
) == {
"active": False,
"criteria": None,
"source": None,
"grading_status": None,
}
def test_rubric_snapshot_keeps_standalone_sticky_rubric_for_paused_goal() -> None:
"""An unrelated sticky rubric remains active while a goal is paused."""
assert _rubric_snapshot(
{
"_goal_objective": "ship it",
"_goal_status": "paused",
"_goal_rubric": "- goal criteria",
"_sticky_rubric": "- standalone criteria",
}
) == {
"active": True,
"criteria": "- standalone criteria",
"source": "sticky",
"grading_status": None,
}
def test_rubric_snapshot_restores_sticky_rubric_without_public_input() -> None:
"""Persisted sticky rubric should be visible outside an active turn."""
assert _rubric_snapshot({"_sticky_rubric": "- sticky criteria"}) == {
@@ -102,6 +137,25 @@ def test_goal_snapshot_with_active_goal() -> None:
}
def test_goal_snapshot_paused_goal_is_inactive_but_persisted() -> None:
"""A paused goal remains readable without being actionable."""
snapshot = _goal_snapshot(
{
"_goal_objective": "add refresh tokens",
"_goal_status": "paused",
"_goal_rubric": "- tests pass",
}
)
assert snapshot == {
"active": False,
"objective": "add refresh tokens",
"status": "paused",
"criteria": "- tests pass",
"note": None,
}
def test_goal_snapshot_complete_goal_is_inactive() -> None:
"""A completed goal must report `active=False` (status drives the flag)."""
snapshot = _goal_snapshot(
@@ -201,6 +255,40 @@ def test_update_goal_marks_blocked_with_note() -> None:
)
def test_update_goal_rejects_status_change_while_paused() -> None:
"""The model cannot resume or complete a user-paused goal."""
command = _update_goal_command(
status="complete",
note="tests pass",
tool_call_id="call-1",
state={
"_goal_objective": "add refresh tokens",
"_goal_status": "paused",
},
)
assert command.update is not None
assert set(command.update) == {"messages"}
assert "`/goal resume`" in command.update["messages"][0].content
def test_update_goal_rejects_status_change_after_completion() -> None:
"""A completed goal remains terminal on later agent turns."""
command = _update_goal_command(
status="blocked",
note="new blocker",
tool_call_id="call-1",
state={
"_goal_objective": "add refresh tokens",
"_goal_status": "complete",
},
)
assert command.update is not None
assert set(command.update) == {"messages"}
assert "already complete" in command.update["messages"][0].content
def test_update_goal_rejects_empty_note() -> None:
"""Evidence is required: an empty note must not commit a status."""
command = _update_goal_command(
@@ -538,9 +538,11 @@ class TestAgentRunningGuard:
_setup_offload_app(app)
running_during_offload: list[bool] = []
quiescent_during_offload: list[bool] = []
def capture_running(**_kwargs: Any) -> OffloadResult:
running_during_offload.append(app._agent_running)
quiescent_during_offload.append(app._agent_quiescent.is_set())
return _make_offload_result()
with patch(
@@ -553,8 +555,10 @@ class TestAgentRunningGuard:
# _agent_running should have been True during perform_offload
assert running_during_offload == [True]
assert quiescent_during_offload == [False]
# And reset after completion
assert app._agent_running is False
assert app._agent_quiescent.is_set()
async def test_agent_running_reset_after_failure(self) -> None:
"""Should reset _agent_running=False even when offload fails."""
@@ -11,6 +11,7 @@ from deepagents_code.resume_state import (
ResumeState,
ResumeStateMiddleware,
_extract_context_tokens,
coerce_goal_proposal_kind,
coerce_goal_status,
)
@@ -44,6 +45,12 @@ class TestResumeState:
metadata = getattr(hints["_sticky_rubric"], "__metadata__", ())
assert PrivateStateAttr in metadata
def test_pending_goal_kind_is_private(self) -> None:
"""Proposal mode should persist without entering public graph I/O."""
hints = get_type_hints(ResumeState, include_extras=True)
metadata = getattr(hints["_pending_goal_kind"], "__metadata__", ())
assert PrivateStateAttr in metadata
def test_middleware_exposes_state_schema(self):
"""ResumeStateMiddleware registers the correct state schema."""
assert ResumeStateMiddleware.state_schema is ResumeState
@@ -54,6 +61,7 @@ class TestCoerceGoalStatus:
def test_returns_known_statuses(self) -> None:
assert coerce_goal_status("active") == "active"
assert coerce_goal_status("paused") == "paused"
assert coerce_goal_status("blocked") == "blocked"
assert coerce_goal_status("complete") == "complete"
@@ -67,6 +75,18 @@ class TestCoerceGoalStatus:
assert coerce_goal_status(["active"]) is None
class TestCoerceGoalProposalKind:
"""Tests for persisted pending-review mode coercion."""
def test_returns_known_kinds(self) -> None:
assert coerce_goal_proposal_kind("create") == "create"
assert coerce_goal_proposal_kind("amend") == "amend"
def test_unknown_value_coerces_to_none(self) -> None:
assert coerce_goal_proposal_kind("replace") is None
assert coerce_goal_proposal_kind(None) is None
class TestExtractContextTokens:
"""Tests for `_extract_context_tokens`."""
@@ -21,6 +21,18 @@ class _GoalReviewTestApp(App[None]):
yield GoalReviewMenu("add refresh tokens", "- tests pass", id="goal-review")
class _GoalAmendmentReviewTestApp(App[None]):
CSS_PATH = Path(deepagents_code.__file__).resolve().parent / "app.tcss"
def compose(self) -> ComposeResult:
yield GoalReviewMenu(
"add refresh tokens with rotation",
"- tests pass\n- rotation works",
amendment=True,
id="goal-review",
)
class TestGoalReviewMenu:
"""Tests for goal criteria review interactions."""
@@ -36,6 +48,20 @@ class TestGoalReviewMenu:
assert "- tests pass" in markdown.source
assert "Proposed criteria" in markdown.source
async def test_amendment_review_shows_objective_and_criteria(self) -> None:
"""Amendment review should expose both canonical fields before approval."""
app = _GoalAmendmentReviewTestApp()
async with app.run_test() as pilot:
await pilot.pause()
markdown = app.query_one(".goal-review-markdown", Markdown)
title = app.query_one(".goal-review-title", Static)
assert "Proposed objective" in markdown.source
assert "add refresh tokens with rotation" in markdown.source
assert "rotation works" in markdown.source
assert "amendment" in str(title.content).lower()
async def test_accept_resolves_accepted(self) -> None:
"""Accept should resolve with the accepted result."""
app = _GoalReviewTestApp()