feat: publish plans from sandbox files (#1635)

* feat: publish plans from sandbox files

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* fix: avoid fixed plan filenames

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* fix: virtualize local sandbox file paths

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* fix: preserve plan_file_path across set_plan_status

set_plan_status was rewriting the content record with only markdown
and status, dropping plan_file_path. After a reject, the owner's
dashboard edit would mirror to a different file than the agent's
original, and the next save_plan could republish the stale file.
Preserve plan_file_path when updating status.

---------

Co-authored-by: Ramon Nogueira <270434257+ramon-langchain@users.noreply.github.com>
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Ramon Nogueira 2026-06-29 16:02:55 -04:00 • committed by GitHub
parent c03a6be7a8
commit ae04b72b41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 327 additions and 87 deletions

View file

@ -33,6 +33,7 @@ from .plan_store import (
delete_plan_comment, delete_plan_comment,
get_plan_content, get_plan_content,
list_plan_comments, list_plan_comments,
plan_file_path_for_thread,
save_plan_content, save_plan_content,
set_plan_status, set_plan_status,
write_plan_to_sandbox, write_plan_to_sandbox,
@ -103,7 +104,7 @@ async def update_plan(
"""Owner-only manual edit of the plan markdown. """Owner-only manual edit of the plan markdown.
Re-publishes the edited plan as ``ready`` (and mirrors it into the sandbox Re-publishes the edited plan as ``ready`` (and mirrors it into the sandbox
``plan.md``) while preserving reviewer comments, so the owner can refine the plan file) while preserving reviewer comments, so the owner can refine the
plan before approving it.""" plan before approving it."""
metadata = await _thread_metadata(thread_id) metadata = await _thread_metadata(thread_id)
if not _user_owns_thread(metadata, session["sub"], session.get("email")): if not _user_owns_thread(metadata, session["sub"], session.get("email")):
@ -115,10 +116,18 @@ async def update_plan(
status = content.get("status") or metadata.get("plan_status") or "planning" status = content.get("status") or metadata.get("plan_status") or "planning"
if status in (PLAN_STATUS_APPROVED, PLAN_STATUS_CANCELLED): if status in (PLAN_STATUS_APPROVED, PLAN_STATUS_CANCELLED):
raise HTTPException(409, f"cannot edit a {status} plan") raise HTTPException(409, f"cannot edit a {status} plan")
await save_plan_content( plan_file_path = content.get("plan_file_path")
thread_id, markdown=markdown, status=PLAN_STATUS_READY, clear_comments=False plan_file_path = (
plan_file_path if isinstance(plan_file_path, str) else plan_file_path_for_thread(thread_id)
) )
await write_plan_to_sandbox(thread_id, markdown) await save_plan_content(
thread_id,
markdown=markdown,
status=PLAN_STATUS_READY,
clear_comments=False,
plan_file_path=plan_file_path,
)
await write_plan_to_sandbox(thread_id, markdown, plan_file_path=plan_file_path)
return {"status": PLAN_STATUS_READY, "markdown": markdown} return {"status": PLAN_STATUS_READY, "markdown": markdown}
@ -210,7 +219,8 @@ async def reject_plan(thread_id: str, session: dict[str, Any] = _SESSION_DEP) ->
await set_plan_status(thread_id, PLAN_STATUS_REVISING, plan_mode=True) await set_plan_status(thread_id, PLAN_STATUS_REVISING, plan_mode=True)
text = ( text = (
"The plan needs changes before implementation. Address this reviewer " "The plan needs changes before implementation. Address this reviewer "
"feedback and publish an updated plan with the save_plan tool:\n\n" "feedback in the existing Markdown file under /workspace/plans/, then "
"publish an updated plan with the save_plan tool:\n\n"
f"{feedback or '(no specific comments were left)'}" f"{feedback or '(no specific comments were left)'}"
) )
await _dispatch_followup(thread_id, metadata, text, plan_mode=True) await _dispatch_followup(thread_id, metadata, text, plan_mode=True)

View file

@ -1,8 +1,7 @@
"""Persistence for the plan-review feature. """Persistence for the plan-review feature.
The plan lives in two places: The plan lives in two places:
- the agent's sandbox, as a real ``plan.md`` file (written by the ``save_plan`` - the agent's sandbox, as a real Markdown file the agent creates and edits, and
tool — the source artifact the agent produces and can re-read), and
- the LangGraph store, as the published snapshot the dashboard renders. - the LangGraph store, as the published snapshot the dashboard renders.
Reviewers leave whole-document comments, stored one item per comment under Reviewers leave whole-document comments, stored one item per comment under
@ -13,6 +12,7 @@ store operations (no CRDT/WebSocket).
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
@ -24,8 +24,8 @@ logger = logging.getLogger(__name__)
PLAN_CONTENT_NAMESPACE = ["plan", "content"] PLAN_CONTENT_NAMESPACE = ["plan", "content"]
PLAN_COMMENTS_NAMESPACE = ["plan", "comments"] PLAN_COMMENTS_NAMESPACE = ["plan", "comments"]
# The plan is mirrored into the sandbox as a real file the agent can re-read. # Plans are mirrored into the sandbox outside cloned repositories.
PLAN_FILE_PATH = "plan.md" PLAN_FILE_DIRECTORY = "/workspace/plans"
# Plan lifecycle, stored on both the content record and the thread metadata. # Plan lifecycle, stored on both the content record and the thread metadata.
PLAN_STATUS_PLANNING = "planning" PLAN_STATUS_PLANNING = "planning"
@ -35,6 +35,12 @@ PLAN_STATUS_APPROVED = "approved"
PLAN_STATUS_CANCELLED = "cancelled" PLAN_STATUS_CANCELLED = "cancelled"
def plan_file_path_for_thread(thread_id: str) -> str:
date = datetime.now(UTC).strftime("%Y-%m-%d")
slug = re.sub(r"[^a-zA-Z0-9-]+", "-", thread_id).strip("-").lower()[:48]
return f"{PLAN_FILE_DIRECTORY}/{date}-{slug or 'plan'}.md"
def _client() -> Any: def _client() -> Any:
return get_client() return get_client()
@ -46,12 +52,22 @@ def _item_value(item: Any) -> dict[str, Any] | None:
return value if isinstance(value, dict) else None return value if isinstance(value, dict) else None
async def _stored_plan_file_path(client: Any, thread_id: str) -> str | None:
try:
value = _item_value(await client.store.get_item(PLAN_CONTENT_NAMESPACE, thread_id)) or {}
except Exception:
return None
path = value.get("plan_file_path")
return path if isinstance(path, str) and path else None
async def save_plan_content( async def save_plan_content(
thread_id: str, thread_id: str,
*, *,
markdown: str, markdown: str,
status: str = PLAN_STATUS_READY, status: str = PLAN_STATUS_READY,
clear_comments: bool = True, clear_comments: bool = True,
plan_file_path: str | None = None,
) -> None: ) -> None:
"""Publish the plan markdown + status for the dashboard to render. """Publish the plan markdown + status for the dashboard to render.
@ -60,10 +76,15 @@ async def save_plan_content(
and be fed back to the agent on the next approve/reject. A manual owner edit and be fed back to the agent on the next approve/reject. A manual owner edit
passes ``clear_comments=False`` so reviewer feedback survives the edit.""" passes ``clear_comments=False`` so reviewer feedback survives the edit."""
client = _client() client = _client()
if plan_file_path is None:
plan_file_path = await _stored_plan_file_path(client, thread_id)
record = {"markdown": markdown, "status": status}
if plan_file_path:
record["plan_file_path"] = plan_file_path
await client.store.put_item( await client.store.put_item(
PLAN_CONTENT_NAMESPACE, PLAN_CONTENT_NAMESPACE,
thread_id, thread_id,
{"markdown": markdown, "status": status}, record,
) )
if clear_comments: if clear_comments:
try: try:
@ -74,18 +95,24 @@ async def save_plan_content(
await _merge_thread_metadata(thread_id, {"plan_status": status, "plan_mode": True}) await _merge_thread_metadata(thread_id, {"plan_status": status, "plan_mode": True})
async def write_plan_to_sandbox(thread_id: str, content: str) -> str: async def write_plan_to_sandbox(
"""Write ``plan.md`` into the thread's sandbox. Best-effort: a missing sandbox thread_id: str, content: str, *, plan_file_path: str | None = None
must not block publishing the plan to the review page.""" ) -> str:
"""Mirror the dashboard plan edit into the thread's sandbox.
Best-effort: a missing sandbox must not block publishing the plan to the
review page.
"""
path = plan_file_path or plan_file_path_for_thread(thread_id)
try: try:
from ..utils.sandbox_state import get_sandbox_backend from ..utils.sandbox_state import get_sandbox_backend
backend = await get_sandbox_backend(thread_id) backend = await get_sandbox_backend(thread_id)
await backend.awrite(PLAN_FILE_PATH, content) await backend.awrite(path, content)
return PLAN_FILE_PATH return path
except Exception: except Exception:
logger.warning("Could not write plan.md to sandbox for %s", thread_id, exc_info=True) logger.warning("Could not write plan file to sandbox for %s", thread_id, exc_info=True)
return PLAN_FILE_PATH return path
async def get_plan_content( async def get_plan_content(
@ -110,10 +137,14 @@ async def set_plan_status(thread_id: str, status: str, *, plan_mode: bool | None
"""Update the plan lifecycle status on both the content record and metadata.""" """Update the plan lifecycle status on both the content record and metadata."""
existing = await get_plan_content(thread_id) or {} existing = await get_plan_content(thread_id) or {}
client = _client() client = _client()
record: dict[str, Any] = {"markdown": existing.get("markdown", ""), "status": status}
plan_file_path = existing.get("plan_file_path")
if isinstance(plan_file_path, str) and plan_file_path:
record["plan_file_path"] = plan_file_path
await client.store.put_item( await client.store.put_item(
PLAN_CONTENT_NAMESPACE, PLAN_CONTENT_NAMESPACE,
thread_id, thread_id,
{"markdown": existing.get("markdown", ""), "status": status}, record,
) )
metadata: dict[str, Any] = {"plan_status": status} metadata: dict[str, Any] = {"plan_status": status}
if plan_mode is not None: if plan_mode is not None:

View file

@ -24,5 +24,6 @@ def create_local_sandbox(sandbox_id: str | None = None):
return LocalShellBackend( return LocalShellBackend(
root_dir=root_dir, root_dir=root_dir,
virtual_mode=True,
inherit_env=True, inherit_env=True,
) )

View file

@ -1,11 +1,12 @@
"""Plan-mode tool gating. """Plan-mode tool gating.
Hides the mutating tools whenever plan mode is active — either when the run Hides tools that mutate external systems whenever plan mode is active — either
starts in plan mode (the per-thread ``plan_mode`` carried in configurable, e.g. when the run starts in plan mode (the per-thread ``plan_mode`` carried in
a reject re-dispatch) OR after the model calls ``enter_plan_mode`` mid-run, which configurable, e.g. a reject re-dispatch) OR after the model calls
sets ``plan_mode`` in the run state. Installed unconditionally so self-activation ``enter_plan_mode`` mid-run, which sets ``plan_mode`` in the run state. Installed
actually restricts the *next* model turn (the tool list is recomputed on every unconditionally so self-activation actually restricts the *next* model turn (the
model call), rather than only affecting a future run. tool list is recomputed on every model call), rather than only affecting a future
run.
""" """
from __future__ import annotations from __future__ import annotations
@ -37,7 +38,7 @@ def _tool_name(tool: BaseTool | dict[str, Any] | Any) -> str | None:
class PlanModeMiddleware(AgentMiddleware): class PlanModeMiddleware(AgentMiddleware):
"""Strip mutating tools from each model request while plan mode is active.""" """Strip disallowed tools from each model request while plan mode is active."""
state_schema = PlanModeState state_schema = PlanModeState

View file

@ -99,7 +99,7 @@ PLAN_MODE_GUIDANCE_SECTION = """---
### Plan Mode ### Plan Mode
If a task would genuinely benefit from a structured plan before any code — complex, many files, or multiple valid approaches — call the `enter_plan_mode` tool. This is NOT triggered by the word "plan" in the request; use judgment. Once in plan mode, stay read-only, research the code, save your plan with `save_plan` (it writes `plan.md` and publishes a review page), and share the plan-review link with the user, who approves before you implement. If a task would genuinely benefit from a structured plan before any code — complex, many files, or multiple valid approaches — call the `enter_plan_mode` tool. This is NOT triggered by the word "plan" in the request; use judgment. Once in plan mode, stay read-only for the target repo, research the code, create/edit your plan as a dated Markdown file under `/workspace/plans/` (for example, `/workspace/plans/YYYY-MM-DD-short-task-slug.md`), publish it with `save_plan`, and share the plan-review link with the user, who approves before you implement.
Plan-review link for this conversation: {plan_review_url}""" Plan-review link for this conversation: {plan_review_url}"""
@ -109,15 +109,15 @@ PLAN_MODE_SECTION = """---
**Plan mode is enabled for this run. This supersedes any instruction telling you to edit code, commit, push, or open a pull request.** **Plan mode is enabled for this run. This supersedes any instruction telling you to edit code, commit, push, or open a pull request.**
You are in a read-only research-and-planning phase. Your single deliverable is a clear, reviewable implementation plan saved with `save_plan` — NOT code changes. Share the plan-review link below with the user right after entering plan mode and again when the plan is ready. You are in a read-only research-and-planning phase for the target repo. Your single deliverable is a clear, reviewable implementation plan saved as a Markdown file outside any repo and published with `save_plan` — NOT code changes. Share the plan-review link below with the user right after entering plan mode and again when the plan is ready.
**Plan-review link:** {plan_url} **Plan-review link:** {plan_url}
**You MUST NOT** edit/create/delete files, run state-changing `execute` commands (no `git commit`/`push`/`checkout -b`, installs, code generators, or file-rewriting formatters), commit, push, open/update a PR, call `request_pr_review`, or mutate Linear/external systems. The `task` subagent is disabled here (subagents wouldn't inherit these restrictions) — research directly. **You MUST NOT** edit/create/delete files inside the target repo, run state-changing `execute` commands except creating `/workspace/plans` (no `git commit`/`push`/`checkout -b`, installs, code generators, or file-rewriting formatters), commit, push, open/update a PR, call `request_pr_review`, or mutate Linear/external systems. The `task` subagent is disabled here (subagents wouldn't inherit these restrictions) — research directly.
**You MAY (read-only):** clone and read the repo (`read_file`, `ls`, `glob`, `grep`, read-only `execute` like `git clone`/`status`/`log`/`diff`, `cat`, `rg`), research with `web_search`/`fetch_url`, and ask clarifying questions via `slack_thread_reply` / `linear_comment`. **You MAY:** clone and read the repo (`read_file`, `ls`, `glob`, `grep`, read-only `execute` like `git clone`/`status`/`log`/`diff`, `cat`, `rg`), research with `web_search`/`fetch_url`, ask clarifying questions via `slack_thread_reply` / `linear_comment`, use `execute` only if needed to create `/workspace/plans`, and use `write_file` / `edit_file` only to create or revise the plan file outside any repo under `/workspace/plans/`.
**Workflow:** explore the relevant code enough to choose a sound approach, clarify ambiguity, then save ONE concise recommended plan with `save_plan` (pass the full Markdown as `plan_markdown`) using this structure. Keep it high level: focus on desired behavior, architecture boundaries, product decisions, tradeoffs, rollout/migration concerns, and verification. Avoid file/function-level details and exhaustive file lists unless a specific implementation detail is unusually tricky, risky, or controversial. Aim for about one page or less unless the task truly requires more. **Workflow:** explore the relevant code enough to choose a sound approach, clarify ambiguity, choose a dated, descriptive plan path like `/workspace/plans/YYYY-MM-DD-short-task-slug.md`, create it with ONE recommended plan, refine it with normal file-editing tools if needed, then publish it with `save_plan` by passing that exact `plan_file_path`. Keep it high level: focus on desired behavior, architecture boundaries, product decisions, tradeoffs, rollout/migration concerns, and verification. Avoid file/function-level details and exhaustive file lists unless a specific implementation detail is unusually tricky, risky, or controversial. Aim for about one page or less unless the task truly requires more. Use this structure:
``` ```
## Plan: <short title> ## Plan: <short title>

View file

@ -529,18 +529,19 @@ DEFAULT_RECURSION_LIMIT = 9_999
# signal via notify_step_limit_reached rather than dying silently. # signal via notify_step_limit_reached rather than dying silently.
MODEL_CALL_RECURSION_LIMIT = 5_000 MODEL_CALL_RECURSION_LIMIT = 5_000
# Mutating tools hidden from the model while plan mode is active so it can only # Mutating external tools hidden from the model while plan mode is active so it
# research and propose a plan. `execute` stays available; plan-mode shell # can only research and propose a plan. File edit tools stay available so the
# discipline (no mutating commands) is instructed via the system prompt rather # agent can draft and revise a plan under `/workspace/plans/`; prompt guidance
# than enforced. `http_request` is excluded because it can POST/PUT/PATCH/DELETE # restricts them to that plan file outside cloned repositories. `execute` stays available;
# to external services — read-only web research goes through `web_search` / # plan-mode shell discipline (no mutating commands) is instructed via the system
# `fetch_url`. `task` is excluded because the general-purpose subagent is built # prompt rather than enforced. `http_request` is excluded because it can
# with its own filesystem/PR/Linear tools and does not inherit this exclusion, so # POST/PUT/PATCH/DELETE to external services — read-only web research goes
# delegating to it would bypass the read-only intent. # through `web_search` / `fetch_url`. `task` is excluded because the
# general-purpose subagent is built with its own filesystem/PR/Linear tools and
# does not inherit this exclusion, so delegating to it would bypass the read-only
# intent.
PLAN_MODE_EXCLUDED_TOOLS: frozenset[str] = frozenset( PLAN_MODE_EXCLUDED_TOOLS: frozenset[str] = frozenset(
{ {
"write_file",
"edit_file",
"task", "task",
"http_request", "http_request",
"open_pull_request", "open_pull_request",

View file

@ -15,10 +15,11 @@ from ..dashboard.plan_store import PLAN_STATUS_PLANNING, set_plan_status
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_ENTERED_MESSAGE = ( _ENTERED_MESSAGE = (
"Plan mode is active. Stay read-only: research the codebase, then record a " "Plan mode is active. Stay read-only for the target repo: research the codebase, "
"concise, high-level plan with the `save_plan` tool (it publishes the plan to " "create or edit a dated, concise plan file under `/workspace/plans/`, then publish "
"the review page) and share the plan-review link in the source channel. Do not " "it with the `save_plan` tool and share the plan-review link in the source channel. "
"edit files, commit, push, or open a PR — wait for the user to approve the plan." "Do not edit repo files, commit, push, or open a PR — wait for the user to approve "
"the plan."
) )
@ -31,11 +32,12 @@ async def enter_plan_mode(tool_call_id: Annotated[str, InjectedToolCallId]) -> C
NOT triggered by the word "plan" appearing in the request; use your NOT triggered by the word "plan" appearing in the request; use your
judgment about whether planning is genuinely warranted. judgment about whether planning is genuinely warranted.
Once activated, stay read-only: research the codebase, then record a concise, Once activated, stay read-only for the target repo: research the codebase,
high-level plan with the ``save_plan`` tool (it publishes the plan to the create or edit a dated, concise Markdown plan outside any repo (for example,
review page) and share the plan-review link with the user. Do not edit files, ``/workspace/plans/YYYY-MM-DD-short-task-slug.md``), then publish it with
commit, push, or open a PR — the user reviews the plan and approves it before the ``save_plan`` tool and share the plan-review link with the user. Do not
you implement. edit repo files, commit, push, or open a PR — the user reviews the plan and
approves it before you implement.
""" """
thread_id = _thread_id_from_config() thread_id = _thread_id_from_config()
if thread_id: if thread_id:

View file

@ -1,35 +1,37 @@
"""Tool: ``save_plan``. Record the implementation plan for review. """Tool: ``save_plan``. Publish the sandbox plan file for review.
Writes the plan as a real ``plan.md`` file in the sandbox (the artifact the Reads the Markdown plan file the agent created in the sandbox and publishes it to
agent produces and can re-read) and publishes it to the plan-review page, where the plan-review page, where the user and collaborators read it, comment inline,
the user and collaborators read it, comment inline, and approve or request and approve or request changes. Available in plan mode (it does not modify the
changes. Available in plan mode (it does not modify the repository under review). repository under review).
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from collections.abc import Mapping
from typing import Any from typing import Any
from langgraph.config import get_config from langgraph.config import get_config
from ..dashboard.plan_store import ( from ..dashboard.plan_store import PLAN_FILE_DIRECTORY, PLAN_STATUS_READY, save_plan_content
PLAN_STATUS_READY, from ..utils.sandbox_state import get_sandbox_backend
save_plan_content,
write_plan_to_sandbox,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_MAX_PLAN_LINES = 20_000
_MARKDOWN_EXTENSIONS = (".md", ".markdown")
async def save_plan(plan_markdown: str) -> dict[str, Any]:
"""Write your implementation plan as a markdown file and publish it for review.
Use this in plan mode once your plan is ready. The plan is saved as async def save_plan(plan_file_path: str) -> dict[str, Any]:
``plan.md`` in the sandbox and published to the plan-review page linked in """Publish a Markdown plan file from the sandbox for review.
the conversation, where the user (the owner) and any collaborators can read
it, leave inline comments, and then approve it or request changes. Call it Use this in plan mode once your plan is ready. First create a Markdown file
again to overwrite the plan with a revised version when addressing feedback. under ``/workspace/plans/`` using a dated, descriptive filename, then pass
that file path here. The file contents are published to the plan-review page
linked in the conversation, where the user (the owner) and any collaborators
can read it, leave inline comments, and then approve it or request changes.
Call it again to publish a revised file when addressing feedback.
Write the plan in standard Markdown — headings, bullet/numbered lists, and Write the plan in standard Markdown — headings, bullet/numbered lists, and
fenced code blocks all render. Keep it concise and high level, focusing on fenced code blocks all render. Keep it concise and high level, focusing on
@ -37,14 +39,21 @@ async def save_plan(plan_markdown: str) -> dict[str, Any]:
details unless they are unusually tricky or controversial. details unless they are unusually tricky or controversial.
Args: Args:
plan_markdown: The full plan, as a Markdown document. plan_file_path: Path to the Markdown plan file in the sandbox.
Returns: Returns:
``{success: True, path}`` on success, or ``{success: False, error}``. ``{success: True, path}`` on success, or ``{success: False, error}``.
""" """
content = plan_markdown.strip() if not isinstance(plan_file_path, str):
if not content: return {"success": False, "error": "plan_file_path must be a string"}
return {"success": False, "error": "plan_markdown cannot be empty"} path = plan_file_path.strip()
if not path:
return {"success": False, "error": "plan_file_path cannot be empty"}
if not _is_markdown_path(path):
return {
"success": False,
"error": f"plan_file_path must point to a Markdown file in {PLAN_FILE_DIRECTORY}",
}
try: try:
config = get_config() config = get_config()
@ -56,14 +65,52 @@ async def save_plan(plan_markdown: str) -> dict[str, Any]:
return {"success": False, "error": "no thread_id in run config"} return {"success": False, "error": "no thread_id in run config"}
try: try:
path = await _save(str(thread_id), content) content = (await _read_plan_file(str(thread_id), path)).strip()
if not content:
return {"success": False, "error": "plan file cannot be empty"}
await _save(str(thread_id), content, path)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.exception("save_plan failed for thread %s", thread_id) logger.exception("save_plan failed for thread %s", thread_id)
return {"success": False, "error": f"failed to save plan: {exc}"} return {"success": False, "error": f"failed to save plan: {exc}"}
return {"success": True, "path": path} return {"success": True, "path": path}
async def _save(thread_id: str, content: str) -> str: async def _save(thread_id: str, content: str, path: str) -> None:
sandbox_path = await write_plan_to_sandbox(thread_id, content) await save_plan_content(
await save_plan_content(thread_id, markdown=content, status=PLAN_STATUS_READY) thread_id, markdown=content, status=PLAN_STATUS_READY, plan_file_path=path
return sandbox_path )
async def _read_plan_file(thread_id: str, path: str) -> str:
backend = await get_sandbox_backend(thread_id)
result = await backend.aread(path, offset=0, limit=_MAX_PLAN_LINES)
error = _value(result, "error")
if error:
raise ValueError(error)
file_data = _value(result, "file_data")
if file_data is None:
raise ValueError("plan file could not be read")
encoding = _value(file_data, "encoding")
if encoding is not None and encoding != "utf-8":
raise ValueError("plan file must be UTF-8 text")
content = _value(file_data, "content")
if not isinstance(content, str):
raise ValueError("plan file content was not text")
if content.count("\n") + 1 >= _MAX_PLAN_LINES:
raise ValueError("plan file is too large")
return content
def _value(value: Any, key: str) -> Any:
if isinstance(value, Mapping):
return value.get(key)
return getattr(value, key, None)
def _is_markdown_path(path: str) -> bool:
if "\x00" in path or not path.startswith(f"{PLAN_FILE_DIRECTORY}/"):
return False
filename = path.removeprefix(f"{PLAN_FILE_DIRECTORY}/")
if not filename or "/" in filename:
return False
return filename.lower().endswith(_MARKDOWN_EXTENSIONS)

View file

@ -136,6 +136,8 @@ def _step_reply(messages: list[BaseMessage]) -> AIMessage:
# --- plan-mode flow -------------------------------------------------------- # --- plan-mode flow --------------------------------------------------------
PLAN_FILE_PATH = "/workspace/plans/2026-06-29-greet-helper.md"
PLAN_MARKDOWN = """## Plan: Add greet() helper PLAN_MARKDOWN = """## Plan: Add greet() helper
### Overview ### Overview
@ -185,11 +187,28 @@ def _step_plan_research(_messages: list[BaseMessage]) -> AIMessage:
) )
def _step_write_plan(_messages: list[BaseMessage]) -> AIMessage:
return AIMessage(
content="Writing the plan file for review.",
tool_calls=[
{
"name": "write_file",
"args": {"file_path": PLAN_FILE_PATH, "content": PLAN_MARKDOWN},
"id": "call-write-plan",
}
],
)
def _step_save_plan(_messages: list[BaseMessage]) -> AIMessage: def _step_save_plan(_messages: list[BaseMessage]) -> AIMessage:
return AIMessage( return AIMessage(
content="Saving the plan for review.", content="Saving the plan for review.",
tool_calls=[ tool_calls=[
{"name": "save_plan", "args": {"plan_markdown": PLAN_MARKDOWN}, "id": "call-save-plan"} {
"name": "save_plan",
"args": {"plan_file_path": PLAN_FILE_PATH},
"id": "call-save-plan",
}
], ],
) )
@ -220,6 +239,7 @@ def build_plan_script() -> list[Any]:
_step_enter_plan, _step_enter_plan,
_step_plan_link, _step_plan_link,
_step_plan_research, _step_plan_research,
_step_write_plan,
_step_save_plan, _step_save_plan,
_step_plan_complete, _step_plan_complete,
_step_plan_end, _step_plan_end,

View file

@ -2,8 +2,9 @@ import agent.integrations.local as local_mod
class _StubLocalShellBackend: class _StubLocalShellBackend:
def __init__(self, *, root_dir, inherit_env): def __init__(self, *, root_dir, virtual_mode, inherit_env):
self.root_dir = root_dir self.root_dir = root_dir
self.virtual_mode = virtual_mode
self.inherit_env = inherit_env self.inherit_env = inherit_env
@ -16,6 +17,7 @@ def test_create_local_sandbox_creates_missing_root_dir(monkeypatch, tmp_path):
assert root.is_dir() assert root.is_dir()
assert backend.root_dir == str(root) assert backend.root_dir == str(root)
assert backend.virtual_mode is True
assert backend.inherit_env is True assert backend.inherit_env is True
@ -27,3 +29,4 @@ def test_create_local_sandbox_defaults_to_cwd(monkeypatch, tmp_path):
backend = local_mod.create_local_sandbox() backend = local_mod.create_local_sandbox()
assert backend.root_dir == str(tmp_path) assert backend.root_dir == str(tmp_path)
assert backend.virtual_mode is True

View file

@ -24,8 +24,6 @@ def test_plan_mode_prompt_absent_by_default() -> None:
def test_plan_mode_excluded_tools_cover_mutating_tools() -> None: def test_plan_mode_excluded_tools_cover_mutating_tools() -> None:
excluded = server.PLAN_MODE_EXCLUDED_TOOLS excluded = server.PLAN_MODE_EXCLUDED_TOOLS
for tool in ( for tool in (
"write_file",
"edit_file",
"task", "task",
"open_pull_request", "open_pull_request",
"request_pr_review", "request_pr_review",
@ -34,8 +32,10 @@ def test_plan_mode_excluded_tools_cover_mutating_tools() -> None:
"linear_delete_issue", "linear_delete_issue",
): ):
assert tool in excluded assert tool in excluded
# Read-only tools must stay available. # Read-only tools and plan-file editing tools must stay available.
assert "read_file" not in excluded assert "read_file" not in excluded
assert "write_file" not in excluded
assert "edit_file" not in excluded
assert "execute" not in excluded assert "execute" not in excluded

View file

@ -108,12 +108,12 @@ async def test_save_plan_requires_run_context() -> None:
from agent.tools.save_plan import save_plan from agent.tools.save_plan import save_plan
# No LangGraph run context → no thread_id → graceful error, not a crash. # No LangGraph run context → no thread_id → graceful error, not a crash.
result = await save_plan("## Plan") result = await save_plan("/workspace/plans/2026-06-29-test-plan.md")
assert result["success"] is False assert result["success"] is False
assert "thread_id" in result["error"] assert "thread_id" in result["error"]
async def test_save_plan_rejects_empty_markdown() -> None: async def test_save_plan_rejects_empty_path() -> None:
from agent.tools.save_plan import save_plan from agent.tools.save_plan import save_plan
result = await save_plan(" ") result = await save_plan(" ")
@ -121,6 +121,70 @@ async def test_save_plan_rejects_empty_markdown() -> None:
assert "empty" in result["error"] assert "empty" in result["error"]
async def test_save_plan_rejects_non_markdown_path() -> None:
from agent.tools.save_plan import save_plan
result = await save_plan("/workspace/plans/plan.txt")
assert result["success"] is False
assert "Markdown" in result["error"]
async def test_save_plan_rejects_markdown_outside_plans_dir() -> None:
from agent.tools.save_plan import save_plan
result = await save_plan("/workspace/plan.md")
assert result["success"] is False
assert "/workspace/plans" in result["error"]
async def test_save_plan_reads_markdown_file_from_sandbox(
monkeypatch: pytest.MonkeyPatch,
) -> None:
import importlib
save_plan_tool = importlib.import_module("agent.tools.save_plan")
saved: dict[str, Any] = {}
reads: list[tuple[str, int, int]] = []
class _Backend:
async def aread(self, file_path: str, offset: int = 0, limit: int = 2000) -> dict[str, Any]:
reads.append((file_path, offset, limit))
return {"file_data": {"encoding": "utf-8", "content": "# Plan\n\nDo it.\n"}}
async def fake_backend(thread_id: str) -> _Backend:
assert thread_id == "thread-1"
return _Backend()
async def fake_save_content(
thread_id: str, *, markdown: str, status: str, plan_file_path: str | None = None
) -> None:
saved.update(
thread_id=thread_id, markdown=markdown, status=status, plan_file_path=plan_file_path
)
monkeypatch.setattr(
save_plan_tool,
"get_config",
lambda: {"configurable": {"thread_id": "thread-1"}},
)
monkeypatch.setattr(save_plan_tool, "get_sandbox_backend", fake_backend)
monkeypatch.setattr(save_plan_tool, "save_plan_content", fake_save_content)
result = await save_plan_tool.save_plan("/workspace/plans/2026-06-29-test-plan.md")
assert result == {"success": True, "path": "/workspace/plans/2026-06-29-test-plan.md"}
assert reads == [
("/workspace/plans/2026-06-29-test-plan.md", 0, save_plan_tool._MAX_PLAN_LINES)
]
assert saved == {
"thread_id": "thread-1",
"markdown": "# Plan\n\nDo it.",
"status": "ready",
"plan_file_path": "/workspace/plans/2026-06-29-test-plan.md",
}
def test_plan_routes_registered() -> None: def test_plan_routes_registered() -> None:
from agent.webapp import app from agent.webapp import app
@ -150,12 +214,27 @@ def test_plan_status_constants() -> None:
assert plan_store.PLAN_STATUS_REVISING == "revising" assert plan_store.PLAN_STATUS_REVISING == "revising"
def test_plan_file_path_for_thread_uses_plans_dir_and_slug() -> None:
from agent.dashboard import plan_store
path = plan_store.plan_file_path_for_thread("Thread ABC/123")
assert path.startswith("/workspace/plans/")
assert path.endswith("-thread-abc-123.md")
def test_http_request_excluded_in_plan_mode() -> None: def test_http_request_excluded_in_plan_mode() -> None:
from agent.server import PLAN_MODE_EXCLUDED_TOOLS from agent.server import PLAN_MODE_EXCLUDED_TOOLS
assert "http_request" in PLAN_MODE_EXCLUDED_TOOLS assert "http_request" in PLAN_MODE_EXCLUDED_TOOLS
def test_file_edit_tools_available_in_plan_mode_for_plan_file() -> None:
from agent.server import PLAN_MODE_EXCLUDED_TOOLS
assert "write_file" not in PLAN_MODE_EXCLUDED_TOOLS
assert "edit_file" not in PLAN_MODE_EXCLUDED_TOOLS
class _FakeReq: class _FakeReq:
def __init__(self, tools: list[Any], state: dict[str, Any]) -> None: def __init__(self, tools: list[Any], state: dict[str, Any]) -> None:
self.tools = tools self.tools = tools
@ -192,6 +271,34 @@ def test_plan_mode_middleware_self_activation_via_state() -> None:
# --- manual plan editing ------------------------------------------------- # --- manual plan editing -------------------------------------------------
async def test_set_plan_status_preserves_plan_file_path(monkeypatch: pytest.MonkeyPatch) -> None:
from agent.dashboard import plan_store
existing = {
"markdown": "# Plan",
"status": "ready",
"plan_file_path": "/workspace/plans/foo.md",
}
saved: dict[str, Any] = {}
class _Store:
async def get_item(self, *a: Any, **k: Any) -> Any:
return {"value": existing}
async def put_item(self, namespace: Any, key: str, value: Any, *a: Any, **k: Any) -> None:
saved.update(value)
async def fake_merge(thread_id: str, metadata: dict[str, Any]) -> None:
return None
monkeypatch.setattr(plan_store, "_client", lambda: _fake_client(_Store()))
monkeypatch.setattr(plan_store, "_merge_thread_metadata", fake_merge)
await plan_store.set_plan_status("t", plan_store.PLAN_STATUS_REVISING, plan_mode=True)
assert saved["plan_file_path"] == "/workspace/plans/foo.md"
assert saved["status"] == plan_store.PLAN_STATUS_REVISING
async def test_save_plan_content_clear_comments_flag(monkeypatch: pytest.MonkeyPatch) -> None: async def test_save_plan_content_clear_comments_flag(monkeypatch: pytest.MonkeyPatch) -> None:
from agent.dashboard import plan_store from agent.dashboard import plan_store
@ -236,13 +343,24 @@ def _patch_update_plan_deps(
return content return content
async def fake_save( async def fake_save(
thread_id: str, *, markdown: str, status: str, clear_comments: bool = True thread_id: str,
*,
markdown: str,
status: str,
clear_comments: bool = True,
plan_file_path: str | None = None,
) -> None: ) -> None:
saved.update(markdown=markdown, status=status, clear_comments=clear_comments) saved.update(
markdown=markdown,
status=status,
clear_comments=clear_comments,
plan_file_path=plan_file_path,
)
async def fake_write(thread_id: str, c: str) -> str: async def fake_write(thread_id: str, c: str, *, plan_file_path: str | None = None) -> str:
sandbox["content"] = c sandbox["content"] = c
return "plan.md" sandbox["plan_file_path"] = plan_file_path
return plan_file_path or "/workspace/plans/fallback.md"
monkeypatch.setattr(plan_api, "_thread_metadata", fake_meta) monkeypatch.setattr(plan_api, "_thread_metadata", fake_meta)
monkeypatch.setattr(plan_api, "_user_owns_thread", lambda *a, **k: owner) monkeypatch.setattr(plan_api, "_user_owns_thread", lambda *a, **k: owner)
@ -262,7 +380,11 @@ async def test_update_plan_owner_saves_and_mirrors_sandbox(
monkeypatch, monkeypatch,
metadata={"plan_status": "ready"}, metadata={"plan_status": "ready"},
owner=True, owner=True,
content={"markdown": "old", "status": "ready"}, content={
"markdown": "old",
"status": "ready",
"plan_file_path": "/workspace/plans/2026-06-29-existing.md",
},
saved=saved, saved=saved,
sandbox=sandbox, sandbox=sandbox,
) )
@ -273,7 +395,9 @@ async def test_update_plan_owner_saves_and_mirrors_sandbox(
assert result == {"status": "ready", "markdown": "# New\n\ndo x"} assert result == {"status": "ready", "markdown": "# New\n\ndo x"}
assert saved["status"] == "ready" assert saved["status"] == "ready"
assert saved["clear_comments"] is False assert saved["clear_comments"] is False
assert saved["plan_file_path"] == "/workspace/plans/2026-06-29-existing.md"
assert sandbox["content"] == "# New\n\ndo x" assert sandbox["content"] == "# New\n\ndo x"
assert sandbox["plan_file_path"] == "/workspace/plans/2026-06-29-existing.md"
async def test_update_plan_rejects_non_owner(monkeypatch: pytest.MonkeyPatch) -> None: async def test_update_plan_rejects_non_owner(monkeypatch: pytest.MonkeyPatch) -> None: