open-swe/ui/src/components/agents/PlanReview.tsx

257 lines
8.3 KiB
TypeScript
Raw Normal View History

feat: plan mode with model-driven entry and collaborative review (#1580) * feat: add plan mode for read-only research and planning Adds a per-run plan_mode flag that puts the agent in a read-only research phase: a strong prompt section is injected and mutating tools are stripped via ExcludeToolsMiddleware so the agent proposes a reviewable implementation plan before any edits. Surfaced in the dashboard UI with a Plan toggle (Shift+Tab) wired through the thread API. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: enforce plan-mode read-only at tool layer and disable subagents Addresses PR review: plan mode previously relied on prompt text to keep the shell read-only and left the task subagent (built with its own write/PR/Linear tools) unrestricted. Now `task` is excluded so research cannot be delegated to a mutating subagent, and a new PlanModeShellGuardMiddleware enforces a read-only command allowlist on `execute`, blocking writes, git state changes, installs, redirection, and command substitution regardless of model/prompt-injection compliance. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: harden plan-mode shell guard against wrapped mutations Block git global options that take values (-C, --git-dir, ...) from being misread as the subcommand, reject config-injection options (-c, --config-env, --exec-path), and drop the env command wrapper that could run arbitrary commands. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * feat: add plan mode with enter_plan_mode tool, profile/team defaults, Slack commands and approval flow - enter_plan_mode tool: agent self-activates plan mode via Command(update={'plan_mode': True}) - Plan mode resolution: per-thread > profile default > team default > False - PLAN_MODE_GUIDANCE_SECTION: always-present prompt section telling agent about the tool - profile_plan_mode_default and team plan_mode_default settings - Slack plan on/off/status commands with thread metadata persistence - slack_thread_reply plan_approval=True renders Approve/Revise/Cancel buttons - Interactivity handler: approve triggers implementation run, cancel posts confirmation - Frontend: plan_mode_default in Profile/ProfileUpdate/TeamSettings types and UI toggles Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * test: add tests for enter_plan_mode tool, profile/team defaults, Slack plan commands, approval blocks Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * refactor(plan-mode): drop shell guard, rely on prompt for read-only discipline Remove PlanModeShellGuardMiddleware and its enforcement of read-only shell commands during plan mode. Plan mode now relies on the system prompt to instruct the agent not to run mutating commands; the mutating-tool exclusion (ExcludeToolsMiddleware) is retained. * test(open-swe): add Playwright E2E for the Slack → PR → web handoff Local, secrets-free end-to-end suite that drives the full happy path through mock Slack/GitHub control panels and the real dashboard UI. Only the LLM and external SaaS HTTP boundaries (GitHub/Slack APIs, OAuth token mint) are faked — the real process_slack_mention, get_agent, deepagents loop, tools, middleware, and dashboard authorization all run under `langgraph dev` with a scripted fake chat model and a local temp-dir sandbox. - full_flow: a Slack mention runs the agent, which implements a change in the sandbox, opens a PR against a fake GitHub remote, and replies with the PR link in the same thread. - dashboard: clicking the bot's real "Open in Web" link loads the built ui/ app (served same-origin); the thread owner can continue the conversation, while a different user sees the same thread read-only (no composer). Wired into Agent CI as a `Playwright E2E` job that runs on pull requests. * fix(open-swe): serve E2E UI assets via explicit route; pin Playwright The dashboard E2E served the built ui/ SPA's /assets via app.mount(StaticFiles), but LangGraph's custom-app loader serves APIRoutes and drops sub-app Mounts, so /assets 404'd under `langgraph dev` in CI — the React app never booted and the composer/transcript never rendered. Serve assets via an explicit route instead. Also pin @playwright/test to the latest (1.61.0) for reproducible runs, and make the owner composer assertion tolerant of either hydration state. * test(open-swe): record Playwright trace + video on every E2E run Capture a replayable trace (DOM snapshots, network, console, source) and a screen recording for every test, not just retries, plus a screenshot on failure. The CI job already uploads playwright-report/ and test-results/, so each run now has a downloadable replay; documented how to open it. * feat(plan-mode): collaborative plan review with BlockNote + Yjs When the agent enters plan mode it writes the plan as a markdown file in the sandbox (save_plan tool), publishes it, and posts a review link to the source channel. Reviewers open the plan inside the dashboard (under the /agents shell), read it rendered in a BlockNote editor, and leave inline comments synced live over Yjs. Only the thread owner can approve; any reviewer can request changes. On approve/reject the comments are harvested and handed to the agent for the follow-up run; the agent never sees comments mid-review. - agent: enter_plan_mode persists plan state; new save_plan tool; prompt shares the plan-review link. - dashboard: Yjs WebSocket collab server (pycrdt-websocket) with store-backed snapshots; plan content/status store; plan REST API (get/approve/reject, owner-only approve, client-harvested comments); planStatus on thread summaries. - ui: BlockNote native comments (CommentsExtension + YjsThreadStore) plan page mounted under the agents shell, with a "Review plan" banner in the thread view and a back-link; theme-aware (dark mode) using the dashboard tokens. - e2e: Playwright coverage of the full Slack -> plan -> review -> approve -> PR flow, including cross-user comment sync and owner-only approval. * fix(plan-mode): address review feedback (authz, overrides, leaks, deps) - plan-collab WS: authorize per-thread before joining a room (same read gate as the REST API) — previously any logged-in user could join any thread (IDOR). - plan-collab: tie the snapshot flusher to active connections (refcount) so each opened plan no longer leaks a permanent 1.5s task on the shared event loop. - plan decisions: include thread_id in the follow-up run configurable so the run resumes the existing thread; set plan_mode explicitly so approve forces it off. - get_agent: an explicit per-thread plan_mode (Slack `plan off`, approved plan, dashboard toggle) now overrides profile/team defaults instead of falling back. - plan mode tool gating moved to a state-aware PlanModeMiddleware installed unconditionally, so a mid-run enter_plan_mode restricts the next model turn; before_agent resets stale plan_mode so a later run isn't forced back into it. - exclude write-capable http_request from plan mode. - pin pycrdt / pycrdt-websocket with upper bounds. Includes the latest base (#1583): E2E UI assets served via explicit route (fixes the Playwright CI failure — LangGraph's app loader drops sub-app mounts). * style: ruff format plan_collab.py * fix(plan-mode): owner-gate Slack approval + same-origin check on collab WS - Slack "Approve & Implement" now verifies the clicking user is the plan requester (owner, via the stored triggering_user_id) before implementing — matching the dashboard API's owner-only approval. Non-owners are pointed to Revise / feedback. - The plan-collab WebSocket validates the handshake Origin against the dashboard allowlist before accept() (no-op when unconfigured, e.g. local/dev), mirroring the REST require_same_origin CSRF defense. * fix(plan-mode): enter plan mode only via the model + local mock dev harness Plan mode is now entered solely when the model calls enter_plan_mode. Removed the per-user and team plan_mode_default settings (backend + UI) and the Slack `plan on/off/status` toggle. - enter_plan_mode returns a terminating ToolMessage, fixing the missing ToolMessage error that silently dropped plan mode mid-run. - PlanReview: defer Yjs provider/doc teardown so React StrictMode's dev remount doesn't destroy and then reuse the collaboration provider. - e2e plan_review spec asserts plan_mode actually engages. - LangSmith trace-url resolution is best-effort: bail before any API call when the tenant is unset, cache failures, log at debug. - Add `pnpm run dev:mock`: same-origin Vite HMR harness with a real LLM, Alice/Bob mock users, and a GitHub login picker. * docs(plan-mode): drop stale references to removed profile/team defaults The plan_mode middleware docstring and the approve/reject dispatch comment still described the profile/team plan_mode_default resolution that no longer exists; reword to match model-driven entry + the per-thread carry. * feat(plan-mode): let any reviewer edit the plan, not just comment Drop the owner/commenter split for the plan document: everyone with read access edits and comments alike (DefaultThreadStoreAuth "editor" for all, editor always editable until a decision, anyone seeds the empty doc). This matches the collab WS, which already relays frames to every readable user. Plan approval stays owner-gated. * test(plan-mode): assert plan-mode entry via the tool's success message plan_mode lives only in run state for tool gating; it is not a persisted thread-state channel, so the previous `values.plan_mode === true` poll could never pass. Assert instead that enter_plan_mode's success ToolMessage ("Plan mode is active …") lands in the thread — which only happens when the tool's Command applies cleanly, the exact regression this guards. --------- Co-authored-by: Johannes du Plessis <51395795+johannes117@users.noreply.github.com> Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-23 15:06:58 -04:00
import "@blocknote/core/fonts/inter.css"
import "@blocknote/mantine/style.css"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import * as Y from "yjs"
import { WebsocketProvider } from "y-websocket"
import {
CommentsExtension,
DefaultThreadStoreAuth,
YjsThreadStore,
} from "@blocknote/core/comments"
import { useCreateBlockNote } from "@blocknote/react"
import { BlockNoteView } from "@blocknote/mantine"
import type { HarvestedComment, PlanData } from "@/lib/plan"
import { approvePlan, planCollabUrl, rejectPlan } from "@/lib/plan"
import { Button } from "@/components/ui/button"
import { useResolvedTheme } from "@/lib/theme"
const CURSOR_COLORS = [
"#e11d48",
"#2563eb",
"#059669",
"#d97706",
"#7c3aed",
"#0891b2",
]
function colorFor(id: string): string {
let hash = 0
for (let i = 0; i < id.length; i++)
hash = (hash * 31 + id.charCodeAt(i)) >>> 0
return CURSOR_COLORS[hash % CURSOR_COLORS.length] ?? "#2563eb"
}
function bodyToText(body: unknown): string {
if (typeof body === "string") return body
if (!Array.isArray(body)) return ""
const parts: Array<string> = []
for (const block of body) {
const content = (block as { content?: unknown }).content
if (typeof content === "string") parts.push(content)
else if (Array.isArray(content))
parts.push(
content.map((node) => (node as { text?: string }).text ?? "").join("")
)
}
return parts.join("\n").trim()
}
export function PlanReview({ plan }: { plan: PlanData }) {
const resolvedTheme = useResolvedTheme()
const { doc, provider, threadStore } = useMemo(() => {
const ydoc = new Y.Doc()
const wsProvider = new WebsocketProvider(
planCollabUrl(),
plan.threadId,
ydoc,
{ connect: true }
)
wsProvider.awareness.setLocalStateField("user", {
name: plan.user.name,
color: colorFor(plan.user.id),
})
// Everyone with read access edits the plan and comments alike — there's no
// owner/commenter split for the document (only approval is owner-gated). The
// collab WS already relays to any readable user, so this matches the server.
const auth = new DefaultThreadStoreAuth(plan.user.id, "editor")
const store = new YjsThreadStore(plan.user.id, ydoc.getMap("threads"), auth)
return { doc: ydoc, provider: wsProvider, threadStore: store }
}, [plan.threadId, plan.user.id, plan.user.name])
// Defer teardown so React StrictMode's dev-only unmount→remount of the same
// memoized provider doesn't destroy instances the remount reuses: the remount
// cancels the pending destroy before it runs. A real unmount still tears down.
const pendingDestroy = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (pendingDestroy.current) {
clearTimeout(pendingDestroy.current)
pendingDestroy.current = null
}
return () => {
pendingDestroy.current = setTimeout(() => {
provider.destroy()
doc.destroy()
}, 0)
}
}, [provider, doc])
// Track reviewers' display names from awareness so harvested comments are
// attributed to people, not raw ids.
const usersRef = useRef<Record<string, string>>({
[plan.user.id]: plan.user.name,
})
useEffect(() => {
const sync = () => {
for (const state of provider.awareness.getStates().values()) {
const user = (state as { user?: { name?: string } }).user
if (user?.name) usersRef.current[user.name] = user.name
}
}
provider.awareness.on("change", sync)
sync()
return () => provider.awareness.off("change", sync)
}, [provider])
const resolveUsers = useCallback(
(userIds: Array<string>) =>
Promise.resolve(
userIds.map((id) => ({
id,
username: usersRef.current[id] ?? id,
avatarUrl: "",
}))
),
[]
)
const editor = useCreateBlockNote(
{
collaboration: {
provider,
fragment: doc.getXmlFragment("blocknote"),
user: { name: plan.user.name, color: colorFor(plan.user.id) },
showCursorLabels: "activity",
},
extensions: [CommentsExtension({ threadStore, resolveUsers })],
},
[provider, threadStore, resolveUsers]
)
// Seed the shared document from the agent's plan markdown the first time
// anyone opens an empty plan. The `seeded` flag + sync barrier prevent a
// second reviewer from double-seeding.
useEffect(() => {
let cancelled = false
const seed = async (isSynced: boolean) => {
if (!isSynced || cancelled) return
const meta = doc.getMap<boolean>("meta")
if (meta.get("seeded")) return
const blocks = editor.document
const empty =
blocks.length === 0 ||
(blocks.length === 1 && bodyToText(blocks) === "")
if (!empty || !plan.markdown.trim()) return
const parsed = await editor.tryParseMarkdownToBlocks(plan.markdown)
// Re-check after the await: another reviewer may have seeded concurrently.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (cancelled || !parsed.length || meta.get("seeded")) return
editor.replaceBlocks(editor.document, parsed)
meta.set("seeded", true)
}
provider.on("sync", seed)
if (provider.synced) void seed(true)
return () => {
cancelled = true
provider.off("sync", seed)
}
}, [editor, provider, doc, plan.markdown])
const harvest = useCallback((): Array<HarvestedComment> => {
const out: Array<HarvestedComment> = []
for (const thread of threadStore.getThreads().values()) {
for (const comment of thread.comments) {
if (!comment.body) continue
out.push({
author: usersRef.current[comment.userId] ?? comment.userId,
body: bodyToText(comment.body),
resolved: thread.resolved,
})
}
}
return out
}, [threadStore])
const [decision, setDecision] = useState<string | null>(null)
const [busy, setBusy] = useState<"approve" | "reject" | null>(null)
const decide = useCallback(
async (kind: "approve" | "reject") => {
setBusy(kind)
try {
const comments = harvest()
if (kind === "approve") await approvePlan(plan.threadId, comments)
else await rejectPlan(plan.threadId, comments)
setDecision(
kind === "approve"
? "Plan approved — the agent is implementing it."
: "Changes requested — the agent is revising the plan."
)
} finally {
setBusy(null)
}
},
[harvest, plan.threadId]
)
return (
<div
data-testid="plan-review"
className="flex min-h-0 flex-1 flex-col bg-[var(--ui-bg)] text-[var(--ui-text)]"
>
<div className="flex items-center justify-between gap-4 border-b border-[var(--ui-border)] px-6 py-3">
<div>
<h1 className="text-base font-semibold text-[var(--ui-text)]">
Implementation plan
</h1>
<p className="text-xs text-[var(--ui-text-dim)]">
Reviewing as {plan.user.name}
{plan.isOwner ? " (owner)" : ""} · status:{" "}
<span data-testid="plan-status">{plan.status}</span>
{" · edit the plan, or select text to comment"}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{decision && (
<span
data-testid="plan-decision"
className="text-xs text-[var(--ui-text-dim)]"
>
{decision}
</span>
)}
{plan.isOwner && (
<Button
data-testid="approve-plan"
disabled={busy !== null || decision !== null}
onClick={() => void decide("approve")}
>
Approve
</Button>
)}
<Button
data-testid="reject-plan"
variant="secondary"
disabled={busy !== null || decision !== null}
onClick={() => void decide("reject")}
>
Request changes
</Button>
</div>
</div>
<div
className="min-h-0 flex-1 overflow-auto py-4"
data-testid="plan-document"
data-color-scheme={resolvedTheme}
>
<BlockNoteView
editor={editor}
editable={!decision}
theme={resolvedTheme}
/>
</div>
</div>
)
}