mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 09:45:24 -04:00
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:
@@ -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 |
|
||||
|
||||
+707
-116
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user