mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 04:33:12 +00:00
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>
This commit is contained in:
parent
eac293d8a7
commit
3a0e2b4672
43 changed files with 2904 additions and 80 deletions
188
agent/dashboard/plan_api.py
Normal file
188
agent/dashboard/plan_api.py
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
"""REST API for the plan-review page: read the plan, approve, or request changes.
|
||||
|
||||
Comment threads themselves live in the collaborative Yjs document (BlockNote's
|
||||
``YjsThreadStore``) and are harvested client-side; on approve/reject the client
|
||||
sends the harvested feedback here, where it is formatted and handed to the agent
|
||||
as the instruction for the follow-up run. The agent never sees comments during
|
||||
review — only this aggregated feedback at the decision point.
|
||||
|
||||
Permissions: any authenticated org member can read a surfaced thread and request
|
||||
changes (reject); only the thread owner can approve.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from langgraph_sdk import get_client
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .oauth import require_same_origin_for_mutations, require_session
|
||||
from .plan_store import (
|
||||
PLAN_STATUS_APPROVED,
|
||||
PLAN_STATUS_REVISING,
|
||||
get_plan_content,
|
||||
set_plan_status,
|
||||
)
|
||||
from .thread_api import (
|
||||
_repo_config_from_metadata,
|
||||
_thread_is_readable,
|
||||
_thread_source,
|
||||
_user_owns_thread,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
plan_router = APIRouter(
|
||||
prefix="/dashboard/api/plan",
|
||||
tags=["plan"],
|
||||
dependencies=[Depends(require_same_origin_for_mutations)],
|
||||
)
|
||||
_SESSION_DEP = Depends(require_session)
|
||||
|
||||
|
||||
class PlanComment(BaseModel):
|
||||
author: str | None = None
|
||||
body: str = ""
|
||||
quote: str | None = None
|
||||
resolved: bool = False
|
||||
|
||||
|
||||
class PlanDecisionBody(BaseModel):
|
||||
comments: list[PlanComment] = Field(default_factory=list)
|
||||
|
||||
|
||||
async def _thread_metadata(thread_id: str) -> dict[str, Any]:
|
||||
client = get_client()
|
||||
try:
|
||||
thread = await client.threads.get(thread_id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(404, "thread not found") from exc
|
||||
metadata = (
|
||||
thread.get("metadata") if isinstance(thread, dict) else getattr(thread, "metadata", None)
|
||||
)
|
||||
return metadata if isinstance(metadata, dict) else {}
|
||||
|
||||
|
||||
@plan_router.get("/{thread_id}")
|
||||
async def get_plan(thread_id: str, session: dict[str, Any] = _SESSION_DEP) -> dict[str, Any]:
|
||||
metadata = await _thread_metadata(thread_id)
|
||||
if not _thread_is_readable(metadata):
|
||||
raise HTTPException(404, "thread not found")
|
||||
login = session["sub"]
|
||||
email = session.get("email")
|
||||
content = await get_plan_content(thread_id) or {}
|
||||
return {
|
||||
"threadId": thread_id,
|
||||
"status": content.get("status") or metadata.get("plan_status") or "planning",
|
||||
"markdown": content.get("markdown", ""),
|
||||
"isOwner": _user_owns_thread(metadata, login, email),
|
||||
"user": {
|
||||
"id": login,
|
||||
"login": login,
|
||||
"email": email,
|
||||
"name": session.get("name") or login,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@plan_router.post("/{thread_id}/approve")
|
||||
async def approve_plan(
|
||||
thread_id: str,
|
||||
body: PlanDecisionBody,
|
||||
session: dict[str, Any] = _SESSION_DEP,
|
||||
) -> dict[str, Any]:
|
||||
metadata = await _thread_metadata(thread_id)
|
||||
if not _user_owns_thread(metadata, session["sub"], session.get("email")):
|
||||
raise HTTPException(403, "only the plan owner can approve")
|
||||
await set_plan_status(thread_id, PLAN_STATUS_APPROVED, plan_mode=False)
|
||||
feedback = _format_comments(body.comments)
|
||||
if feedback:
|
||||
text = (
|
||||
"The plan has been approved. Implement it now, taking this reviewer "
|
||||
f"feedback into account:\n\n{feedback}"
|
||||
)
|
||||
else:
|
||||
text = "The plan has been approved. Implement it now as described in the plan."
|
||||
await _dispatch_followup(thread_id, metadata, text, plan_mode=False)
|
||||
return {"status": PLAN_STATUS_APPROVED}
|
||||
|
||||
|
||||
@plan_router.post("/{thread_id}/reject")
|
||||
async def reject_plan(
|
||||
thread_id: str,
|
||||
body: PlanDecisionBody,
|
||||
session: dict[str, Any] = _SESSION_DEP,
|
||||
) -> dict[str, Any]:
|
||||
metadata = await _thread_metadata(thread_id)
|
||||
if not _thread_is_readable(metadata):
|
||||
raise HTTPException(404, "thread not found")
|
||||
await set_plan_status(thread_id, PLAN_STATUS_REVISING, plan_mode=True)
|
||||
feedback = _format_comments(body.comments)
|
||||
text = (
|
||||
"The plan needs changes before implementation. Address this reviewer "
|
||||
"feedback and publish an updated plan with the save_plan tool:\n\n"
|
||||
f"{feedback or '(no specific comments were left)'}"
|
||||
)
|
||||
await _dispatch_followup(thread_id, metadata, text, plan_mode=True)
|
||||
return {"status": PLAN_STATUS_REVISING}
|
||||
|
||||
|
||||
def _format_comments(comments: list[PlanComment]) -> str:
|
||||
lines: list[str] = []
|
||||
for index, comment in enumerate(comments, start=1):
|
||||
body = comment.body.strip()
|
||||
if not body:
|
||||
continue
|
||||
author = (comment.author or "reviewer").strip()
|
||||
quote = (comment.quote or "").strip()
|
||||
status = " (resolved)" if comment.resolved else ""
|
||||
if quote:
|
||||
lines.append(f'{index}. On "{quote}"{status}:\n - {author}: {body}')
|
||||
else:
|
||||
lines.append(f"{index}. {author}{status}: {body}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def _dispatch_followup(
|
||||
thread_id: str, metadata: dict[str, Any], text: str, *, plan_mode: bool
|
||||
) -> None:
|
||||
"""Continue the existing thread with a new instruction run.
|
||||
|
||||
Runs on the same LangGraph thread, so the agent resumes from the checkpoint
|
||||
with the full planning history plus this instruction. The configurable is
|
||||
rebuilt from the thread's stored owner/repo/Slack context so the agent can
|
||||
push, open a PR, and reply in the original channel.
|
||||
"""
|
||||
configurable: dict[str, Any] = {
|
||||
"thread_id": thread_id,
|
||||
"source": _thread_source(metadata) or "slack",
|
||||
}
|
||||
email = metadata.get("triggering_user_email")
|
||||
if isinstance(email, str) and email:
|
||||
configurable["user_email"] = email
|
||||
login = metadata.get("github_login")
|
||||
if isinstance(login, str) and login:
|
||||
configurable["github_login"] = login
|
||||
repo = _repo_config_from_metadata(metadata)
|
||||
if repo:
|
||||
configurable["repo"] = repo
|
||||
source_context = metadata.get("source_context")
|
||||
if isinstance(source_context, dict):
|
||||
slack_thread = source_context.get("slack_thread")
|
||||
if isinstance(slack_thread, dict):
|
||||
configurable["slack_thread"] = slack_thread
|
||||
# Carry the decision to the follow-up run: approve continues out of plan
|
||||
# mode (implement), reject stays in plan mode (revise the plan).
|
||||
configurable["plan_mode"] = plan_mode
|
||||
|
||||
client = get_client()
|
||||
await client.runs.create(
|
||||
thread_id,
|
||||
"agent",
|
||||
input={"messages": [{"role": "user", "content": text}]},
|
||||
config={"configurable": configurable},
|
||||
if_not_exists="create",
|
||||
)
|
||||
178
agent/dashboard/plan_collab.py
Normal file
178
agent/dashboard/plan_collab.py
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
"""Yjs collaboration server for the plan-review document.
|
||||
|
||||
A single :class:`WebsocketServer` (started for the app lifetime via
|
||||
:func:`collab_lifespan`) relays Yjs sync/awareness frames between the reviewers
|
||||
connected to a plan. Each plan is a room keyed by ``thread_id``; the room's
|
||||
document is seeded from / snapshotted to the LangGraph store so a plan and its
|
||||
comment threads survive a restart.
|
||||
|
||||
The browser connects with the standard ``y-websocket`` client, which appends the
|
||||
room name to the provider URL — hence the ``/yjs/{thread_id}`` route shape.
|
||||
|
||||
Any user who can read the thread can both edit the plan and comment — there is
|
||||
no document-level owner/commenter split (only plan *approval* is owner-gated, in
|
||||
the REST API). So relaying frames to every readable connection is intentional.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from langgraph_sdk import get_client
|
||||
from pycrdt.websocket import WebsocketServer, YRoom
|
||||
from pycrdt.websocket.websocket import HttpxWebsocket
|
||||
|
||||
from .oauth import COOKIE_NAME, _origin_of, allowed_dashboard_origins, decode_session
|
||||
from .plan_store import load_yjs_snapshot, save_yjs_snapshot
|
||||
from .thread_api import _thread_is_readable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
collab_router = APIRouter(prefix="/dashboard/api/plan", tags=["plan-collab"])
|
||||
|
||||
_SNAPSHOT_DEBOUNCE_SECONDS = 1.5
|
||||
|
||||
_server: WebsocketServer | None = None
|
||||
_dirty: set[str] = set()
|
||||
# Per-room state, alive only while at least one reviewer is connected.
|
||||
_connections: dict[str, int] = {}
|
||||
_flushers: dict[str, asyncio.Task[None]] = {}
|
||||
_subscriptions: dict[str, Any] = {}
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def collab_lifespan() -> AsyncIterator[None]:
|
||||
"""Run the Yjs collaboration server for the app's lifetime."""
|
||||
global _server
|
||||
async with WebsocketServer(auto_clean_rooms=False) as server:
|
||||
_server = server
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_server = None
|
||||
for task in _flushers.values():
|
||||
task.cancel()
|
||||
_flushers.clear()
|
||||
_subscriptions.clear()
|
||||
_connections.clear()
|
||||
_dirty.clear()
|
||||
|
||||
|
||||
def _session_login(websocket: WebSocket) -> str | None:
|
||||
token = websocket.cookies.get(COOKIE_NAME)
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
session = decode_session(token)
|
||||
except Exception:
|
||||
return None
|
||||
sub = session.get("sub") if isinstance(session, dict) else None
|
||||
return sub if isinstance(sub, str) and sub else None
|
||||
|
||||
|
||||
def _origin_allowed(websocket: WebSocket) -> bool:
|
||||
"""Same-origin (CSRF) gate for the WS handshake, mirroring the REST
|
||||
``require_same_origin`` check: a no-op when no dashboard origins are
|
||||
configured (local/dev), otherwise the handshake ``Origin`` must be in the
|
||||
allowlist so a malicious site can't open a cross-site socket on the
|
||||
victim's cookie."""
|
||||
allowed = allowed_dashboard_origins()
|
||||
if not allowed:
|
||||
return True
|
||||
origin = _origin_of(websocket.headers.get("origin", "") or "")
|
||||
return bool(origin) and origin in allowed
|
||||
|
||||
|
||||
async def _can_read_thread(thread_id: str) -> bool:
|
||||
"""Same read gate as the plan REST API: only surfaced threads are joinable."""
|
||||
try:
|
||||
thread = await get_client().threads.get(thread_id)
|
||||
except Exception:
|
||||
return False
|
||||
metadata = (
|
||||
thread.get("metadata") if isinstance(thread, dict) else getattr(thread, "metadata", None)
|
||||
)
|
||||
return _thread_is_readable(metadata if isinstance(metadata, dict) else {})
|
||||
|
||||
|
||||
async def _open_room(server: WebsocketServer, thread_id: str) -> None:
|
||||
"""Seed the room from the store and start its snapshot flusher (idempotent
|
||||
while connected)."""
|
||||
if thread_id in _flushers:
|
||||
return
|
||||
room = await server.get_room(thread_id)
|
||||
snapshot = await load_yjs_snapshot(thread_id)
|
||||
if snapshot:
|
||||
try:
|
||||
room.ydoc.apply_update(snapshot)
|
||||
except Exception:
|
||||
logger.warning("Failed to seed plan doc %s from snapshot", thread_id, exc_info=True)
|
||||
|
||||
def _on_change(_event: object) -> None:
|
||||
_dirty.add(thread_id)
|
||||
|
||||
_subscriptions[thread_id] = room.ydoc.observe(_on_change)
|
||||
_flushers[thread_id] = asyncio.create_task(_flush_loop(thread_id, room))
|
||||
|
||||
|
||||
async def _close_room(thread_id: str) -> None:
|
||||
"""Tear down a room's flusher + observer once the last reviewer leaves."""
|
||||
task = _flushers.pop(thread_id, None)
|
||||
_subscriptions.pop(thread_id, None)
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
if _server is not None and thread_id in _server.rooms:
|
||||
await _persist(thread_id, _server.rooms[thread_id])
|
||||
_dirty.discard(thread_id)
|
||||
|
||||
|
||||
async def _flush_loop(thread_id: str, room: YRoom) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(_SNAPSHOT_DEBOUNCE_SECONDS)
|
||||
if thread_id in _dirty:
|
||||
_dirty.discard(thread_id)
|
||||
await _persist(thread_id, room)
|
||||
|
||||
|
||||
async def _persist(thread_id: str, room: YRoom) -> None:
|
||||
try:
|
||||
await save_yjs_snapshot(thread_id, bytes(room.ydoc.get_update()))
|
||||
except Exception:
|
||||
logger.warning("Failed to snapshot plan doc %s", thread_id, exc_info=True)
|
||||
|
||||
|
||||
@collab_router.websocket("/yjs/{thread_id}")
|
||||
async def plan_yjs_socket(websocket: WebSocket, thread_id: str) -> None:
|
||||
if _session_login(websocket) is None:
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
if not _origin_allowed(websocket):
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
if not await _can_read_thread(thread_id):
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
if _server is None:
|
||||
await websocket.close(code=1013) # try again later
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
await _open_room(_server, thread_id)
|
||||
_connections[thread_id] = _connections.get(thread_id, 0) + 1
|
||||
channel = HttpxWebsocket(websocket, thread_id)
|
||||
try:
|
||||
with contextlib.suppress(WebSocketDisconnect):
|
||||
await _server.serve(channel)
|
||||
finally:
|
||||
_connections[thread_id] = _connections.get(thread_id, 1) - 1
|
||||
if _connections.get(thread_id, 0) <= 0:
|
||||
_connections.pop(thread_id, None)
|
||||
await _close_room(thread_id)
|
||||
114
agent/dashboard/plan_store.py
Normal file
114
agent/dashboard/plan_store.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Persistence for the collaborative plan-review feature.
|
||||
|
||||
The plan lives in two places:
|
||||
- the agent's sandbox, as a real ``plan.md`` file (written by the ``save_plan``
|
||||
tool — the source artifact the agent produces and can re-read), and
|
||||
- the LangGraph store, as the published snapshot the dashboard renders and the
|
||||
seed for the collaborative Yjs document.
|
||||
|
||||
Comment threads live in the Yjs document (BlockNote's ``YjsThreadStore``); the
|
||||
binary Yjs state is snapshotted here so a plan + its comments survive a sandbox
|
||||
teardown or a server restart.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import Any
|
||||
|
||||
from langgraph_sdk import get_client
|
||||
|
||||
PLAN_CONTENT_NAMESPACE = ["plan", "content"]
|
||||
PLAN_YJS_NAMESPACE = ["plan", "yjs"]
|
||||
|
||||
# Plan lifecycle, stored on both the content record and the thread metadata.
|
||||
PLAN_STATUS_PLANNING = "planning"
|
||||
PLAN_STATUS_READY = "ready"
|
||||
PLAN_STATUS_REVISING = "revising"
|
||||
PLAN_STATUS_APPROVED = "approved"
|
||||
PLAN_STATUS_CANCELLED = "cancelled"
|
||||
|
||||
|
||||
def _client() -> Any:
|
||||
return get_client()
|
||||
|
||||
|
||||
def _item_value(item: Any) -> dict[str, Any] | None:
|
||||
if item is None:
|
||||
return None
|
||||
value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None)
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
async def save_plan_content(
|
||||
thread_id: str, *, markdown: str, status: str = PLAN_STATUS_READY
|
||||
) -> None:
|
||||
"""Publish the plan markdown + status for the dashboard to render."""
|
||||
client = _client()
|
||||
await client.store.put_item(
|
||||
PLAN_CONTENT_NAMESPACE,
|
||||
thread_id,
|
||||
{"markdown": markdown, "status": status},
|
||||
)
|
||||
await _merge_thread_metadata(thread_id, {"plan_status": status, "plan_mode": True})
|
||||
|
||||
|
||||
async def get_plan_content(thread_id: str) -> dict[str, Any] | None:
|
||||
client = _client()
|
||||
try:
|
||||
item = await client.store.get_item(PLAN_CONTENT_NAMESPACE, thread_id)
|
||||
except Exception:
|
||||
return None
|
||||
return _item_value(item)
|
||||
|
||||
|
||||
async def set_plan_status(thread_id: str, status: str, *, plan_mode: bool | None = None) -> None:
|
||||
"""Update the plan lifecycle status on both the content record and metadata."""
|
||||
existing = await get_plan_content(thread_id) or {}
|
||||
client = _client()
|
||||
await client.store.put_item(
|
||||
PLAN_CONTENT_NAMESPACE,
|
||||
thread_id,
|
||||
{"markdown": existing.get("markdown", ""), "status": status},
|
||||
)
|
||||
metadata: dict[str, Any] = {"plan_status": status}
|
||||
if plan_mode is not None:
|
||||
metadata["plan_mode"] = plan_mode
|
||||
await _merge_thread_metadata(thread_id, metadata)
|
||||
|
||||
|
||||
async def load_yjs_snapshot(thread_id: str) -> bytes | None:
|
||||
client = _client()
|
||||
try:
|
||||
item = await client.store.get_item(PLAN_YJS_NAMESPACE, thread_id)
|
||||
except Exception:
|
||||
return None
|
||||
value = _item_value(item)
|
||||
if not value:
|
||||
return None
|
||||
encoded = value.get("b64")
|
||||
if not isinstance(encoded, str) or not encoded:
|
||||
return None
|
||||
try:
|
||||
return base64.b64decode(encoded)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
async def save_yjs_snapshot(thread_id: str, data: bytes) -> None:
|
||||
client = _client()
|
||||
await client.store.put_item(
|
||||
PLAN_YJS_NAMESPACE,
|
||||
thread_id,
|
||||
{"b64": base64.b64encode(data).decode("ascii")},
|
||||
)
|
||||
|
||||
|
||||
async def _merge_thread_metadata(thread_id: str, metadata: dict[str, Any]) -> None:
|
||||
client = _client()
|
||||
try:
|
||||
await client.threads.update(thread_id=thread_id, metadata=metadata)
|
||||
except Exception:
|
||||
# The thread always exists by the time a plan is saved (the run created
|
||||
# it); a transient update failure must not crash the agent mid-run.
|
||||
pass
|
||||
|
|
@ -114,6 +114,7 @@ class ThreadMessageBody(BaseModel):
|
|||
images: list[DashboardImageBody] = Field(default_factory=list)
|
||||
model_id: str | None = None
|
||||
effort: str | None = None
|
||||
plan_mode: bool = False
|
||||
|
||||
|
||||
class ThreadResolveBody(BaseModel):
|
||||
|
|
@ -358,6 +359,8 @@ def _thread_summary(
|
|||
"branch": metadata.get("branch_name") or metadata.get("base_branch") or "main",
|
||||
"model": model,
|
||||
"effort": effort,
|
||||
"planMode": metadata.get("plan_mode") is True,
|
||||
"planStatus": metadata.get("plan_status"),
|
||||
"source": _thread_source(metadata),
|
||||
"status": status,
|
||||
"viewed": _is_thread_viewed(metadata, latest_run_id),
|
||||
|
|
@ -913,6 +916,7 @@ async def _create_dashboard_thread_record(
|
|||
title: str | None = None,
|
||||
model_id: str | None = None,
|
||||
effort: str | None = None,
|
||||
plan_mode: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Create or update dashboard thread metadata without starting a run."""
|
||||
profile = await get_profile(login) or {}
|
||||
|
|
@ -937,6 +941,7 @@ async def _create_dashboard_thread_record(
|
|||
"effort": metadata_effort,
|
||||
"resolved_model": resolved_model,
|
||||
"resolved_effort": resolved_effort,
|
||||
"plan_mode": plan_mode,
|
||||
"created_at_ms": now_ms,
|
||||
"updated_at_ms": now_ms,
|
||||
}
|
||||
|
|
@ -985,6 +990,8 @@ async def _build_dashboard_configurable(
|
|||
if isinstance(source_context, dict):
|
||||
for key, value in source_context.items():
|
||||
configurable.setdefault(key, value)
|
||||
if metadata.get("plan_mode") is True:
|
||||
configurable["plan_mode"] = True
|
||||
if overrides:
|
||||
for key, value in overrides.items():
|
||||
if value is not None:
|
||||
|
|
@ -1123,6 +1130,7 @@ async def _enrich_run_start_command(
|
|||
client_configurable.get("agent_model_id"),
|
||||
client_configurable.get("agent_effort"),
|
||||
)
|
||||
plan_mode_requested = client_configurable.get("plan_mode") is True
|
||||
content = _command_message_content(params)
|
||||
overrides: dict[str, Any] = {}
|
||||
|
||||
|
|
@ -1142,6 +1150,7 @@ async def _enrich_run_start_command(
|
|||
images=_dashboard_images_from_content(content),
|
||||
model_id=client_configurable.get("agent_model_id"),
|
||||
effort=client_configurable.get("agent_effort"),
|
||||
plan_mode=plan_mode_requested,
|
||||
)
|
||||
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else metadata
|
||||
if chosen_model and chosen_effort:
|
||||
|
|
@ -1152,7 +1161,7 @@ async def _enrich_run_start_command(
|
|||
prefix = _attribution_prefix(metadata, login, email)
|
||||
if prefix:
|
||||
_set_command_last_message_content(params, _prefix_message_content(content, prefix))
|
||||
metadata_update: dict[str, Any] = {}
|
||||
metadata_update: dict[str, Any] = {"plan_mode": plan_mode_requested}
|
||||
if chosen_model and chosen_effort:
|
||||
overrides["agent_model_id"] = chosen_model
|
||||
overrides["agent_effort"] = chosen_effort
|
||||
|
|
@ -1202,7 +1211,11 @@ async def send_dashboard_message(
|
|||
prompt = f"{_attribution_prefix(metadata, login, email)}{body.content.strip()}"
|
||||
now_ms = _now_ms()
|
||||
chosen_model, chosen_effort = _normalize_model_choice(body.model_id, body.effort)
|
||||
metadata_update: dict[str, Any] = {"source": _DASHBOARD_SOURCE, "updated_at_ms": now_ms}
|
||||
metadata_update: dict[str, Any] = {
|
||||
"source": _DASHBOARD_SOURCE,
|
||||
"updated_at_ms": now_ms,
|
||||
"plan_mode": body.plan_mode,
|
||||
}
|
||||
if chosen_model and chosen_effort:
|
||||
metadata_update["model"] = chosen_model
|
||||
metadata_update["effort"] = chosen_effort
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from .check_message_queue import check_message_queue_before_model
|
|||
from .exclude_tools import ExcludeToolsMiddleware
|
||||
from .model_fallback import ModelFallbackMiddleware
|
||||
from .notify_step_limit import notify_step_limit_reached
|
||||
from .plan_mode import PlanModeMiddleware
|
||||
from .refresh_github_proxy import refresh_github_proxy_before_model
|
||||
from .refresh_slack_status import SlackAssistantStatusMiddleware
|
||||
from .sandbox_circuit_breaker import SandboxCircuitBreakerMiddleware
|
||||
|
|
@ -14,6 +15,7 @@ from .tool_error_handler import ToolErrorMiddleware
|
|||
__all__ = [
|
||||
"ExcludeToolsMiddleware",
|
||||
"ModelFallbackMiddleware",
|
||||
"PlanModeMiddleware",
|
||||
"SanitizeThinkingBlocksMiddleware",
|
||||
"SanitizeToolInputsMiddleware",
|
||||
"ToolArtifactMiddleware",
|
||||
|
|
|
|||
83
agent/middleware/plan_mode.py
Normal file
83
agent/middleware/plan_mode.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""Plan-mode tool gating.
|
||||
|
||||
Hides the mutating tools whenever plan mode is active — either when the run
|
||||
starts in plan mode (the per-thread ``plan_mode`` carried in configurable, e.g.
|
||||
a reject re-dispatch) OR after the model calls ``enter_plan_mode`` mid-run, which
|
||||
sets ``plan_mode`` in the run state. Installed unconditionally so self-activation
|
||||
actually restricts the *next* model turn (the tool list is recomputed on every
|
||||
model call), rather than only affecting a future run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, NotRequired
|
||||
|
||||
from langchain.agents.middleware.types import (
|
||||
AgentMiddleware,
|
||||
AgentState,
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
)
|
||||
from langchain_core.tools import BaseTool
|
||||
|
||||
|
||||
class PlanModeState(AgentState):
|
||||
# Declared so ``enter_plan_mode``'s Command update is a tracked channel that
|
||||
# this middleware can read back from ``request.state``.
|
||||
plan_mode: NotRequired[bool]
|
||||
|
||||
|
||||
def _tool_name(tool: BaseTool | dict[str, Any] | Any) -> str | None:
|
||||
if isinstance(tool, dict):
|
||||
name = tool.get("name")
|
||||
return name if isinstance(name, str) else None
|
||||
name = getattr(tool, "name", None)
|
||||
return name if isinstance(name, str) else None
|
||||
|
||||
|
||||
class PlanModeMiddleware(AgentMiddleware):
|
||||
"""Strip mutating tools from each model request while plan mode is active."""
|
||||
|
||||
state_schema = PlanModeState
|
||||
|
||||
def __init__(self, *, excluded: frozenset[str], initial: bool = False) -> None:
|
||||
self._excluded = excluded
|
||||
self._initial = initial
|
||||
|
||||
def before_agent(self, state: Any, runtime: Any) -> dict[str, Any] | None: # noqa: ARG002
|
||||
# Reset plan_mode to the value resolved for THIS run so a stale ``True``
|
||||
# left in the thread state by a previous run's ``enter_plan_mode`` does
|
||||
# not silently force a later (e.g. approved/implementing) run back into
|
||||
# plan mode. ``enter_plan_mode`` can still flip it on within this run.
|
||||
return {"plan_mode": self._initial}
|
||||
|
||||
def _active(self, request: ModelRequest) -> bool:
|
||||
if self._initial:
|
||||
return True
|
||||
state = getattr(request, "state", None)
|
||||
if isinstance(state, dict):
|
||||
return state.get("plan_mode") is True
|
||||
return getattr(state, "plan_mode", None) is True
|
||||
|
||||
def _filter(self, request: ModelRequest) -> ModelRequest:
|
||||
if not self._excluded or not self._active(request):
|
||||
return request
|
||||
filtered = [t for t in request.tools if _tool_name(t) not in self._excluded]
|
||||
if len(filtered) == len(request.tools):
|
||||
return request
|
||||
return request.override(tools=filtered)
|
||||
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], ModelResponse],
|
||||
) -> ModelResponse:
|
||||
return handler(self._filter(request))
|
||||
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> ModelResponse:
|
||||
return await handler(self._filter(request))
|
||||
|
|
@ -69,6 +69,67 @@ You are currently executing a software engineering task. You have access to:
|
|||
- Project-specific rules and conventions from the repository's `AGENTS.md` file (read after cloning — see Repository Setup)"""
|
||||
|
||||
|
||||
PLAN_MODE_GUIDANCE_SECTION = """---
|
||||
|
||||
### Plan Mode
|
||||
|
||||
If you believe the task would benefit from a structured implementation plan before writing any code — e.g. when the request is complex, touches many files, or has multiple valid approaches — call the `enter_plan_mode` tool. This is NOT triggered by the word "plan" appearing in the request; use your judgment about whether planning is genuinely warranted. Once plan mode is active, stay read-only: research the code, then record your plan with the `save_plan` tool (it writes `plan.md` and publishes the plan to a review page) and share the plan-review link with the user. The user reviews and approves the plan before you implement.
|
||||
|
||||
Plan-review link for this conversation (share it with the user when you enter plan mode): {plan_review_url}"""
|
||||
|
||||
PLAN_MODE_SECTION = """---
|
||||
|
||||
### Plan Mode (ACTIVE)
|
||||
|
||||
**Plan mode is enabled for this run. This section supersedes any other instruction that tells 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 the `save_plan` tool — NOT code changes. The user (and any collaborators) review the plan on the plan-review page, leave inline comments, and approve it (or request changes); only then do you implement.
|
||||
|
||||
**Plan-review link:** {plan_url}
|
||||
Share this exact link with the user (via `slack_thread_reply` or `linear_comment`) right after you enter plan mode, so they know where to follow along, and again when the plan is ready for review.
|
||||
|
||||
**You MUST NOT:**
|
||||
- Edit, create, or delete any files in the repository (no `write_file`, no `edit_file`).
|
||||
- Run any state-changing command via `execute` — no `git commit`, `git push`, `git checkout -b`, package installs, code generators, formatters that rewrite files, or anything that mutates the filesystem, git state, or remote services. Keep `execute` to read-only commands only.
|
||||
- Commit, push, open or update a pull request, or call `request_pr_review`.
|
||||
- Create, update, or delete Linear issues, or otherwise mutate external systems.
|
||||
|
||||
**You MAY (read-only):**
|
||||
- Clone the repo and read it: `read_file`, `ls`, `glob`, `grep`, and read-only `execute` commands (`git clone`, `git status`, `git log`, `git diff`, `cat`, `rg`, `ls`).
|
||||
- Research the web with `web_search` / `fetch_url`.
|
||||
- Ask the user clarifying questions via `slack_thread_reply` (Slack) or `linear_comment` (Linear) when the source channel is known.
|
||||
|
||||
(The `task` subagent tool is disabled in plan mode because subagents would not inherit these read-only restrictions. Do your research directly with the read-only tools above.)
|
||||
|
||||
**Workflow:**
|
||||
1. **Explore** — Clone (if needed) and read the relevant code to understand existing patterns, the files involved, and constraints. Read aggressively; a good plan is grounded in the actual codebase, not assumptions.
|
||||
2. **Clarify** — If the request is ambiguous or has multiple valid approaches, ask focused questions before finalizing the plan.
|
||||
3. **Plan** — Write ONE recommended implementation plan and save it with the `save_plan` tool (pass the full Markdown as `plan_markdown`). Use this structure:
|
||||
|
||||
```
|
||||
## Plan: <short title>
|
||||
|
||||
### Overview
|
||||
<1-3 sentences on the approach and why.>
|
||||
|
||||
### Files to change
|
||||
- `path/to/file` — <what changes and why>
|
||||
- ...
|
||||
|
||||
### Steps
|
||||
1. <ordered, concrete implementation steps>
|
||||
2. ...
|
||||
|
||||
### Risks & considerations
|
||||
- <edge cases, migrations, cross-file impacts, anything risky>
|
||||
|
||||
### Verification
|
||||
- <how the change will be tested/validated: specific test files, lint, manual checks>
|
||||
```
|
||||
|
||||
**Ending your turn:** After saving the plan with `save_plan`, post a brief completion message with the plan-review link via `slack_thread_reply` (Slack) or `linear_comment` (Linear), then stop. Explicitly invite the user to review the plan, comment, and approve it. Do not begin implementing — wait until the plan is approved (you will be re-invoked with the approval and any reviewer feedback)."""
|
||||
|
||||
|
||||
SELF_AWARENESS_SECTION = """---
|
||||
|
||||
### About You
|
||||
|
|
@ -443,6 +504,8 @@ def _render_repo_instructions_section(instructions: str | None) -> str:
|
|||
SYSTEM_PROMPT_TEMPLATE = (
|
||||
WORKING_ENV_SECTION
|
||||
+ TASK_OVERVIEW_SECTION
|
||||
+ PLAN_MODE_GUIDANCE_SECTION
|
||||
+ "{plan_mode_section}"
|
||||
+ SELF_AWARENESS_SECTION
|
||||
+ "{default_prompt_section}"
|
||||
+ REPO_SETUP_SECTION
|
||||
|
|
@ -471,6 +534,8 @@ def construct_system_prompt(
|
|||
triggering_user_identity: CollaboratorIdentity | None = None,
|
||||
create_prs: bool = False,
|
||||
default_repo: dict[str, str] | None = None,
|
||||
plan_mode: bool = False,
|
||||
plan_url: str | None = None,
|
||||
repo_custom_instructions: str | None = None,
|
||||
thread_url: str | None = None,
|
||||
corridor_enabled: bool = False,
|
||||
|
|
@ -494,6 +559,12 @@ def construct_system_prompt(
|
|||
working_dir=working_dir,
|
||||
linear_project_id=linear_project_id or "<PROJECT_ID>",
|
||||
linear_issue_number=linear_issue_number or "<ISSUE_NUMBER>",
|
||||
plan_review_url=plan_url or "(the dashboard plan-review page)",
|
||||
plan_mode_section=(
|
||||
PLAN_MODE_SECTION.format(plan_url=plan_url or "(plan-review link unavailable)")
|
||||
if plan_mode
|
||||
else ""
|
||||
),
|
||||
default_prompt_section=default_prompt_section,
|
||||
corridor_prompt_section=CORRIDOR_PROMPT if corridor_enabled else "",
|
||||
pr_policy_override_section=ALWAYS_CREATE_PR_SECTION if create_prs else "",
|
||||
|
|
|
|||
|
|
@ -41,7 +41,10 @@ from .dashboard.agent_overrides import (
|
|||
)
|
||||
from .dashboard.agent_usage import record_agent_thread_usage
|
||||
from .dashboard.options import DEFAULT_MODEL_ID, SUPPORTED_MODEL_IDS, model_supports_effort
|
||||
from .dashboard.team_settings import get_team_default_model_pair, get_team_default_repo
|
||||
from .dashboard.team_settings import (
|
||||
get_team_default_model_pair,
|
||||
get_team_default_repo,
|
||||
)
|
||||
from .dashboard.user_mappings import email_for_login
|
||||
from .integrations.corridor_mcp import load_corridor_tools
|
||||
from .integrations.currents_tools import load_currents_tools
|
||||
|
|
@ -50,6 +53,7 @@ from .integrations.langsmith import _configure_github_proxy
|
|||
from .integrations.langsmith_tools import load_langsmith_tools
|
||||
from .middleware import (
|
||||
ModelFallbackMiddleware,
|
||||
PlanModeMiddleware,
|
||||
SandboxCircuitBreakerMiddleware,
|
||||
SanitizeThinkingBlocksMiddleware,
|
||||
SanitizeToolInputsMiddleware,
|
||||
|
|
@ -62,6 +66,7 @@ from .middleware import (
|
|||
)
|
||||
from .prompt import construct_system_prompt
|
||||
from .tools import (
|
||||
enter_plan_mode,
|
||||
fetch_url,
|
||||
http_request,
|
||||
linear_comment,
|
||||
|
|
@ -73,6 +78,7 @@ from .tools import (
|
|||
linear_update_issue,
|
||||
open_pull_request,
|
||||
request_pr_review,
|
||||
save_plan,
|
||||
schedule_thread_wakeup,
|
||||
slack_read_thread_messages,
|
||||
slack_thread_reply,
|
||||
|
|
@ -84,7 +90,7 @@ from .utils.authorship import (
|
|||
OPEN_SWE_BOT_NAME,
|
||||
resolve_triggering_user_identity,
|
||||
)
|
||||
from .utils.dashboard_links import dashboard_thread_url
|
||||
from .utils.dashboard_links import dashboard_plan_url, dashboard_thread_url
|
||||
from .utils.github_app import (
|
||||
get_github_app_installation_token_with_expiry,
|
||||
)
|
||||
|
|
@ -477,6 +483,28 @@ DEFAULT_LLM_MAX_TOKENS = 64_000
|
|||
DEFAULT_RECURSION_LIMIT = 9_999
|
||||
MODEL_CALL_RECURSION_LIMIT = 5_000 # ~half the recursion limit to account for tool calls
|
||||
|
||||
# Mutating tools hidden from the model while plan mode is active so it can only
|
||||
# research and propose a plan. `execute` stays available; plan-mode shell
|
||||
# discipline (no mutating commands) is instructed via the system prompt rather
|
||||
# than enforced. `http_request` is excluded because it can POST/PUT/PATCH/DELETE
|
||||
# to external services — read-only web research goes 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(
|
||||
{
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"task",
|
||||
"http_request",
|
||||
"open_pull_request",
|
||||
"request_pr_review",
|
||||
"linear_create_issue",
|
||||
"linear_update_issue",
|
||||
"linear_delete_issue",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _general_purpose_subagent(model: BaseChatModel) -> SubAgent:
|
||||
return {
|
||||
|
|
@ -662,6 +690,19 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
)
|
||||
logger.info("Configured model fallback %s -> %s", model_id, fallback_model_id)
|
||||
|
||||
# Plan mode is entered only when the model decides to (the `enter_plan_mode`
|
||||
# tool sets it in run state). The configurable value just carries that
|
||||
# decision across a thread's messages and the approve/reject follow-ups; a
|
||||
# fresh run with nothing set starts out of plan mode.
|
||||
plan_mode = configurable.get("plan_mode") is True
|
||||
if plan_mode:
|
||||
logger.info("Plan mode enabled for thread %s", thread_id)
|
||||
# Installed unconditionally and state-aware: it also restricts tools after a
|
||||
# mid-run `enter_plan_mode` call, not just when plan mode is set up front.
|
||||
plan_mode_middleware: list[Any] = [
|
||||
PlanModeMiddleware(excluded=PLAN_MODE_EXCLUDED_TOOLS, initial=plan_mode)
|
||||
]
|
||||
|
||||
source = (
|
||||
configurable.get("source") if isinstance(configurable.get("source"), str) else "dashboard"
|
||||
)
|
||||
|
|
@ -675,6 +716,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
"model": model_id,
|
||||
"effort": profile_effort,
|
||||
"source": source,
|
||||
"plan_mode": plan_mode,
|
||||
},
|
||||
)
|
||||
await record_agent_thread_usage(
|
||||
|
|
@ -716,6 +758,8 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
triggering_user_identity=triggering_user_identity,
|
||||
create_prs=always_create_prs,
|
||||
default_repo=prompt_default_repo,
|
||||
plan_mode=plan_mode,
|
||||
plan_url=dashboard_plan_url(thread_id),
|
||||
repo_custom_instructions=repo_custom_instructions,
|
||||
thread_url=dashboard_thread_url(thread_id),
|
||||
corridor_enabled=bool(corridor_tools),
|
||||
|
|
@ -724,6 +768,8 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
http_request,
|
||||
fetch_url,
|
||||
web_search,
|
||||
enter_plan_mode,
|
||||
save_plan,
|
||||
linear_comment,
|
||||
linear_create_issue,
|
||||
linear_delete_issue,
|
||||
|
|
@ -753,6 +799,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
notify_step_limit_reached,
|
||||
SandboxCircuitBreakerMiddleware(),
|
||||
*fallback_middleware,
|
||||
*plan_mode_middleware,
|
||||
SanitizeThinkingBlocksMiddleware(),
|
||||
],
|
||||
).with_config(config)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from .add_finding import add_finding
|
||||
from .enter_plan_mode import enter_plan_mode
|
||||
from .fetch_url import fetch_url
|
||||
from .http_request import http_request
|
||||
from .linear_comment import linear_comment
|
||||
|
|
@ -16,6 +17,7 @@ from .read_repo_file import read_repo_file
|
|||
from .reply_to_finding_thread import reply_to_finding_thread
|
||||
from .request_pr_review import request_pr_review
|
||||
from .resolve_finding_thread import resolve_finding_thread
|
||||
from .save_plan import save_plan
|
||||
from .schedule_thread_wakeup import schedule_thread_wakeup
|
||||
from .search_repo_code import search_repo_code
|
||||
from .slack_read_thread_messages import slack_read_thread_messages
|
||||
|
|
@ -25,6 +27,7 @@ from .web_search import web_search
|
|||
|
||||
__all__ = [
|
||||
"add_finding",
|
||||
"enter_plan_mode",
|
||||
"fetch_url",
|
||||
"http_request",
|
||||
"linear_comment",
|
||||
|
|
@ -42,6 +45,7 @@ __all__ = [
|
|||
"request_pr_review",
|
||||
"reply_to_finding_thread",
|
||||
"resolve_finding_thread",
|
||||
"save_plan",
|
||||
"schedule_thread_wakeup",
|
||||
"search_repo_code",
|
||||
"slack_read_thread_messages",
|
||||
|
|
|
|||
62
agent/tools/enter_plan_mode.py
Normal file
62
agent/tools/enter_plan_mode.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""Tool: ``enter_plan_mode``. Switch the run into read-only planning."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langchain_core.tools import InjectedToolCallId
|
||||
from langgraph.config import get_config
|
||||
from langgraph.types import Command
|
||||
|
||||
from ..dashboard.plan_store import PLAN_STATUS_PLANNING, set_plan_status
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ENTERED_MESSAGE = (
|
||||
"Plan mode is active. Stay read-only: research the codebase, then record your "
|
||||
"implementation plan with the `save_plan` tool (it publishes the plan to the "
|
||||
"review page) and share the plan-review link in the source channel. Do not edit "
|
||||
"files, commit, push, or open a PR — wait for the user to approve the plan."
|
||||
)
|
||||
|
||||
|
||||
def enter_plan_mode(tool_call_id: Annotated[str, InjectedToolCallId]) -> Command:
|
||||
"""Activate plan mode mid-run.
|
||||
|
||||
Call this when you believe the task would benefit from a structured
|
||||
implementation plan before writing any code — e.g. when the request is
|
||||
complex, touches many files, or has multiple valid approaches. This is
|
||||
NOT triggered by the word "plan" appearing in the request; use your
|
||||
judgment about whether planning is genuinely warranted.
|
||||
|
||||
Once activated, stay read-only: research the codebase, then record your plan
|
||||
with the ``save_plan`` tool (it publishes the plan to the review page) and
|
||||
share the plan-review link with the user. Do not edit files, commit, push,
|
||||
or open a PR — the user reviews the plan and approves it before you
|
||||
implement.
|
||||
"""
|
||||
thread_id = _thread_id_from_config()
|
||||
if thread_id:
|
||||
try:
|
||||
asyncio.run(set_plan_status(thread_id, PLAN_STATUS_PLANNING, plan_mode=True))
|
||||
except Exception:
|
||||
logger.warning("Failed to persist plan-mode entry for %s", thread_id, exc_info=True)
|
||||
return Command(
|
||||
update={
|
||||
"plan_mode": True,
|
||||
"messages": [ToolMessage(content=_ENTERED_MESSAGE, tool_call_id=tool_call_id)],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _thread_id_from_config() -> str | None:
|
||||
try:
|
||||
config = get_config()
|
||||
except Exception:
|
||||
return None
|
||||
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
|
||||
thread_id = configurable.get("thread_id") if isinstance(configurable, dict) else None
|
||||
return str(thread_id) if thread_id else None
|
||||
81
agent/tools/save_plan.py
Normal file
81
agent/tools/save_plan.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
"""Tool: ``save_plan``. Record the implementation plan for review.
|
||||
|
||||
Writes the plan as a real ``plan.md`` file in the sandbox (the artifact the
|
||||
agent produces and can re-read) and publishes it to the plan-review page, where
|
||||
the user and collaborators read it, comment inline, and approve or request
|
||||
changes. Available in plan mode (it does not modify the repository under review).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from langgraph.config import get_config
|
||||
|
||||
from ..dashboard.plan_store import PLAN_STATUS_READY, save_plan_content
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PLAN_FILE_PATH = "plan.md"
|
||||
|
||||
|
||||
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
|
||||
``plan.md`` in the sandbox and 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 overwrite the plan with a revised version when addressing feedback.
|
||||
|
||||
Write the plan in standard Markdown — headings, bullet/numbered lists, and
|
||||
fenced code blocks all render. Structure it clearly (overview, files to
|
||||
change, ordered steps, risks).
|
||||
|
||||
Args:
|
||||
plan_markdown: The full plan, as a Markdown document.
|
||||
|
||||
Returns:
|
||||
``{success: True, path}`` on success, or ``{success: False, error}``.
|
||||
"""
|
||||
content = plan_markdown.strip()
|
||||
if not content:
|
||||
return {"success": False, "error": "plan_markdown cannot be empty"}
|
||||
|
||||
try:
|
||||
config = get_config()
|
||||
except Exception:
|
||||
config = {}
|
||||
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
|
||||
thread_id = configurable.get("thread_id") if isinstance(configurable, dict) else None
|
||||
if not thread_id:
|
||||
return {"success": False, "error": "no thread_id in run config"}
|
||||
|
||||
try:
|
||||
path = asyncio.run(_save(str(thread_id), content))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("save_plan failed for thread %s", thread_id)
|
||||
return {"success": False, "error": f"failed to save plan: {exc}"}
|
||||
return {"success": True, "path": path}
|
||||
|
||||
|
||||
async def _save(thread_id: str, content: str) -> str:
|
||||
sandbox_path = await _write_to_sandbox(thread_id, content)
|
||||
await save_plan_content(thread_id, markdown=content, status=PLAN_STATUS_READY)
|
||||
return sandbox_path
|
||||
|
||||
|
||||
async def _write_to_sandbox(thread_id: str, content: str) -> str:
|
||||
"""Write ``plan.md`` into the thread's sandbox. Best-effort: a missing sandbox
|
||||
must not block publishing the plan to the review page."""
|
||||
try:
|
||||
from ..utils.sandbox_state import get_sandbox_backend
|
||||
|
||||
backend = await get_sandbox_backend(thread_id)
|
||||
await backend.awrite(PLAN_FILE_PATH, content)
|
||||
return PLAN_FILE_PATH
|
||||
except Exception:
|
||||
logger.warning("Could not write plan.md to sandbox for %s", thread_id, exc_info=True)
|
||||
return PLAN_FILE_PATH
|
||||
|
|
@ -21,6 +21,7 @@ def slack_thread_reply(
|
|||
message: str,
|
||||
options: list[str] | None = None,
|
||||
blocks: list[dict[str, Any]] | None = None,
|
||||
plan_approval: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Post a message to the current Slack thread.
|
||||
|
||||
|
|
@ -39,6 +40,8 @@ def slack_thread_reply(
|
|||
render interactive buttons and the web UI will render the same choices.
|
||||
The user can still reply manually in the Slack thread.
|
||||
|
||||
To present a plan for approval with action buttons, pass plan_approval=True. This renders "Approve & Implement", "Revise Plan", and "Cancel" buttons. The plan itself should be posted to the dashboard thread; use this to post a summary with a link to the dashboard thread view where the full plan can be reviewed.
|
||||
|
||||
To mention/tag a user, use Slack's mention format: <@USER_ID>.
|
||||
You can find user IDs in the conversation context (e.g. @Name(U06KD8BFY95)).
|
||||
Example: <@U06KD8BFY95> will tag that user in the message."""
|
||||
|
|
@ -58,7 +61,10 @@ def slack_thread_reply(
|
|||
return {"success": False, "error": "Message cannot be empty"}
|
||||
|
||||
message = convert_mentions_to_slack_format(message)
|
||||
slack_blocks = blocks or _build_option_blocks(message, options)
|
||||
if plan_approval:
|
||||
slack_blocks = _build_plan_approval_blocks(message)
|
||||
else:
|
||||
slack_blocks = blocks or _build_option_blocks(message, options)
|
||||
message_ts, slack_error = asyncio.run(
|
||||
_post_and_store_mapping(channel_id, thread_ts, message, blocks=slack_blocks)
|
||||
)
|
||||
|
|
@ -96,6 +102,37 @@ def _build_option_blocks(message: str, options: list[str] | None) -> list[dict[s
|
|||
]
|
||||
|
||||
|
||||
def _build_plan_approval_blocks(message: str) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{"type": "section", "text": {"type": "mrkdwn", "text": message}},
|
||||
{
|
||||
"type": "actions",
|
||||
"elements": [
|
||||
{
|
||||
"type": "button",
|
||||
"text": {"type": "plain_text", "text": "Approve & Implement", "emoji": True},
|
||||
"style": "primary",
|
||||
"value": json.dumps({"type": "plan_approval", "action": "approve"}),
|
||||
"action_id": "open_swe_option_select",
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"text": {"type": "plain_text", "text": "Revise Plan", "emoji": True},
|
||||
"value": json.dumps({"type": "plan_approval", "action": "revise"}),
|
||||
"action_id": "open_swe_option_select",
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"text": {"type": "plain_text", "text": "Cancel", "emoji": True},
|
||||
"style": "danger",
|
||||
"value": json.dumps({"type": "plan_approval", "action": "cancel"}),
|
||||
"action_id": "open_swe_option_select",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _slack_reply_failure_hint(slack_error: str | None) -> str:
|
||||
if slack_error == "msg_too_long":
|
||||
return "Slack rejected the message as too long; retry with a shorter message."
|
||||
|
|
|
|||
|
|
@ -18,6 +18,14 @@ def dashboard_thread_url(thread_id: str) -> str | None:
|
|||
return f"{base_url}/agents/{quote(thread_id, safe='')}"
|
||||
|
||||
|
||||
def dashboard_plan_url(thread_id: str) -> str | None:
|
||||
"""Build the dashboard plan-review URL for a given thread id."""
|
||||
base_url = _dashboard_base_url()
|
||||
if not base_url or not thread_id:
|
||||
return None
|
||||
return f"{base_url}/agents/{quote(thread_id, safe='')}/plan"
|
||||
|
||||
|
||||
def dashboard_review_url(owner: str, repo: str, pr_number: int) -> str | None:
|
||||
"""Build the dashboard review-detail URL for a PR."""
|
||||
base_url = _dashboard_base_url()
|
||||
|
|
|
|||
|
|
@ -33,54 +33,50 @@ def _build_prod_langsmith_client() -> LangSmithClient | None:
|
|||
|
||||
|
||||
def _resolve_project_id_by_name(project_name: str) -> str | None:
|
||||
"""Resolve a LangSmith project id from its name, caching successful lookups."""
|
||||
cached = _PROJECT_ID_CACHE.get(project_name)
|
||||
if cached:
|
||||
return cached
|
||||
"""Resolve a LangSmith project id from its name, caching both successful and
|
||||
failed lookups so an unconfigured/unauthorized tenant isn't re-queried per call."""
|
||||
if project_name in _PROJECT_ID_CACHE:
|
||||
return _PROJECT_ID_CACHE[project_name] or None
|
||||
client = _build_prod_langsmith_client()
|
||||
if client is None:
|
||||
return None
|
||||
try:
|
||||
project = client.read_project(project_name=project_name)
|
||||
except LangSmithNotFoundError:
|
||||
_PROJECT_ID_CACHE[project_name] = ""
|
||||
return None
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("Failed to resolve LangSmith project id for %s", project_name, exc_info=True)
|
||||
logger.debug("Could not resolve LangSmith project id for %s", project_name)
|
||||
_PROJECT_ID_CACHE[project_name] = ""
|
||||
return None
|
||||
project_id = getattr(project, "id", None)
|
||||
if not project_id:
|
||||
return None
|
||||
resolved = str(project_id)
|
||||
resolved = str(project_id) if project_id else ""
|
||||
_PROJECT_ID_CACHE[project_name] = resolved
|
||||
return resolved
|
||||
return resolved or None
|
||||
|
||||
|
||||
def _compose_langsmith_project_url(project_name: str = AGENT_TRACING_PROJECT) -> str:
|
||||
"""Build the LangSmith project URL base for a given tracing project name."""
|
||||
host_url = os.environ.get("LANGSMITH_URL_PROD", "https://smith.langchain.com")
|
||||
def _compose_langsmith_project_url(project_name: str = AGENT_TRACING_PROJECT) -> str | None:
|
||||
"""Build the LangSmith project URL base, or None when tracing isn't configured
|
||||
for the prod tenant. Bails before any API call when the tenant id is unset."""
|
||||
tenant_id = os.environ.get("LANGSMITH_TENANT_ID_PROD")
|
||||
if not tenant_id:
|
||||
return None
|
||||
host_url = os.environ.get("LANGSMITH_URL_PROD", "https://smith.langchain.com")
|
||||
project_id = _resolve_project_id_by_name(project_name) or os.environ.get(
|
||||
"LANGSMITH_TRACING_PROJECT_ID_PROD"
|
||||
)
|
||||
if not tenant_id or not project_id:
|
||||
raise ValueError(
|
||||
"LANGSMITH_TENANT_ID_PROD must be set and the tracing project id must be resolvable"
|
||||
)
|
||||
if not project_id:
|
||||
return None
|
||||
return f"{host_url}/o/{tenant_id}/projects/p/{project_id}"
|
||||
|
||||
|
||||
def get_langsmith_trace_url(
|
||||
thread_id: str, project_name: str = AGENT_TRACING_PROJECT
|
||||
) -> str | None:
|
||||
"""Build the LangSmith thread URL for a given thread ID."""
|
||||
try:
|
||||
project_url = _compose_langsmith_project_url(project_name)
|
||||
return f"{project_url}/t/{thread_id}"
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning(
|
||||
"Failed to build LangSmith trace URL for thread %s", thread_id, exc_info=True
|
||||
)
|
||||
return None
|
||||
"""Build the LangSmith thread URL for a given thread ID, or None if tracing
|
||||
isn't configured. This is a best-effort convenience link, not an error path."""
|
||||
project_url = _compose_langsmith_project_url(project_name)
|
||||
return f"{project_url}/t/{thread_id}" if project_url else None
|
||||
|
||||
|
||||
def _build_langsmith_feedback_clients() -> tuple[LangSmithClient, ...]:
|
||||
|
|
|
|||
126
agent/webapp.py
126
agent/webapp.py
|
|
@ -131,12 +131,14 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
||||
from .dashboard.plan_collab import collab_lifespan
|
||||
from .utils.model import validate_local_dev_llm_config
|
||||
from .utils.sandbox import validate_sandbox_startup_config
|
||||
|
||||
validate_sandbox_startup_config()
|
||||
validate_local_dev_llm_config()
|
||||
yield
|
||||
async with collab_lifespan():
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
|
@ -159,6 +161,12 @@ if DASHBOARD_ALLOWED_ORIGINS:
|
|||
|
||||
app.include_router(dashboard_router)
|
||||
|
||||
from .dashboard.plan_api import plan_router # noqa: E402
|
||||
from .dashboard.plan_collab import collab_router # noqa: E402
|
||||
|
||||
app.include_router(plan_router)
|
||||
app.include_router(collab_router)
|
||||
|
||||
LINEAR_WEBHOOK_SECRET = os.environ.get("LINEAR_WEBHOOK_SECRET", "")
|
||||
GITHUB_WEBHOOK_SECRET = os.environ.get("GITHUB_WEBHOOK_SECRET", "")
|
||||
SLACK_SIGNING_SECRET = os.environ.get("SLACK_SIGNING_SECRET", "")
|
||||
|
|
@ -691,6 +699,68 @@ async def _ensure_thread_exists_for_metadata(
|
|||
return False
|
||||
|
||||
|
||||
async def _slack_user_is_thread_owner(thread_id: str, slack_user_id: str) -> bool:
|
||||
"""Whether the clicking Slack user is the user who requested the plan.
|
||||
|
||||
Plan approval is owner-only (mirrors the dashboard plan API's
|
||||
``_user_owns_thread`` gate). The original requester's Slack id is stored in
|
||||
``source_context.slack_thread.triggering_user_id`` when the run is created.
|
||||
Fails closed when ownership can't be determined.
|
||||
"""
|
||||
if not slack_user_id:
|
||||
return False
|
||||
langgraph_client = get_client(url=LANGGRAPH_URL)
|
||||
try:
|
||||
thread = await langgraph_client.threads.get(thread_id)
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
metadata = thread.get("metadata") if isinstance(thread, dict) else None
|
||||
if not isinstance(metadata, dict):
|
||||
return False
|
||||
source_context = metadata.get("source_context")
|
||||
slack_thread = source_context.get("slack_thread") if isinstance(source_context, dict) else None
|
||||
owner_id = slack_thread.get("triggering_user_id") if isinstance(slack_thread, dict) else None
|
||||
return isinstance(owner_id, str) and bool(owner_id) and owner_id == slack_user_id
|
||||
|
||||
|
||||
async def _get_thread_plan_mode(thread_id: str) -> bool | None:
|
||||
"""Return the persisted plan-mode flag for a thread, or ``None`` if unset."""
|
||||
langgraph_client = get_client(url=LANGGRAPH_URL)
|
||||
try:
|
||||
thread = await langgraph_client.threads.get(thread_id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if _is_not_found_error(exc):
|
||||
return None
|
||||
logger.warning("Failed to fetch plan-mode metadata for thread %s", thread_id)
|
||||
return None
|
||||
metadata = thread.get("metadata") if isinstance(thread, dict) else None
|
||||
if not isinstance(metadata, dict):
|
||||
return None
|
||||
value = metadata.get("plan_mode")
|
||||
return value if isinstance(value, bool) else None
|
||||
|
||||
|
||||
async def _set_thread_plan_mode(thread_id: str, enabled: bool) -> None:
|
||||
"""Persist the plan-mode flag onto thread metadata."""
|
||||
langgraph_client = get_client(url=LANGGRAPH_URL)
|
||||
try:
|
||||
await langgraph_client.threads.update(
|
||||
thread_id=thread_id, metadata={"plan_mode": bool(enabled)}
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if _is_not_found_error(exc):
|
||||
try:
|
||||
await langgraph_client.threads.create(
|
||||
thread_id=thread_id,
|
||||
if_exists="do_nothing",
|
||||
metadata={"plan_mode": bool(enabled)},
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("Failed to create thread %s while persisting plan_mode", thread_id)
|
||||
return
|
||||
logger.exception("Failed to persist plan_mode for thread %s", thread_id)
|
||||
|
||||
|
||||
async def process_linear_issue( # noqa: PLR0912, PLR0915
|
||||
issue_data: dict[str, Any], repo_config: dict[str, str]
|
||||
) -> None:
|
||||
|
|
@ -1173,6 +1243,10 @@ async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[st
|
|||
if mapped_login:
|
||||
configurable["github_login"] = mapped_login
|
||||
|
||||
thread_plan_mode = await _get_thread_plan_mode(thread_id)
|
||||
if thread_plan_mode is not None:
|
||||
configurable["plan_mode"] = thread_plan_mode
|
||||
|
||||
langgraph_client = get_client(url=LANGGRAPH_URL)
|
||||
is_first_mention = not await _thread_exists(thread_id)
|
||||
await _upsert_slack_thread_repo_metadata(thread_id, repo_config, langgraph_client)
|
||||
|
|
@ -1566,6 +1640,56 @@ async def slack_interactivity(
|
|||
action_value = json.loads(str(action.get("value") or "{}"))
|
||||
except json.JSONDecodeError:
|
||||
return {"status": "ignored", "reason": "Invalid action value"}
|
||||
if action_value.get("type") == "plan_approval":
|
||||
plan_action = str(action_value.get("action") or "").strip()
|
||||
channel = payload.get("channel") if isinstance(payload.get("channel"), dict) else {}
|
||||
message = payload.get("message") if isinstance(payload.get("message"), dict) else {}
|
||||
container = payload.get("container") if isinstance(payload.get("container"), dict) else {}
|
||||
user = payload.get("user") if isinstance(payload.get("user"), dict) else {}
|
||||
channel_id = str(channel.get("id") or container.get("channel_id") or "")
|
||||
thread_ts = str(
|
||||
message.get("thread_ts") or message.get("ts") or container.get("thread_ts") or ""
|
||||
)
|
||||
user_id = str(user.get("id") or "")
|
||||
if not channel_id or not thread_ts:
|
||||
return {"status": "ignored", "reason": "Missing Slack action context"}
|
||||
|
||||
thread_id = generate_thread_id_from_slack_thread(channel_id, thread_ts)
|
||||
|
||||
if plan_action == "cancel":
|
||||
await post_slack_thread_reply(
|
||||
channel_id=channel_id,
|
||||
thread_ts=thread_ts,
|
||||
text="Plan cancelled. No changes will be made.",
|
||||
)
|
||||
return {"status": "accepted", "message": "Plan cancelled"}
|
||||
|
||||
if plan_action == "approve":
|
||||
if not await _slack_user_is_thread_owner(thread_id, user_id):
|
||||
await post_slack_thread_reply(
|
||||
channel_id=channel_id,
|
||||
thread_ts=thread_ts,
|
||||
text="Only the person who requested this plan can approve it. Anyone can reply with feedback or use *Revise Plan*.",
|
||||
)
|
||||
return {"status": "ignored", "reason": "approver is not the thread owner"}
|
||||
await _set_thread_plan_mode(thread_id, False)
|
||||
repo_config = await get_slack_repo_config(channel_id, thread_ts, slack_user_id=user_id)
|
||||
background_tasks.add_task(
|
||||
process_slack_mention,
|
||||
{
|
||||
"channel_id": channel_id,
|
||||
"thread_ts": thread_ts,
|
||||
"event_ts": str(message.get("ts") or ""),
|
||||
"user_id": user_id,
|
||||
"text": "Proceed with the approved plan. Implement the changes as described in the plan.",
|
||||
"bot_user_id": SLACK_BOT_USER_ID,
|
||||
},
|
||||
repo_config,
|
||||
)
|
||||
return {"status": "accepted", "message": "Plan approved, starting implementation"}
|
||||
|
||||
return {"status": "accepted", "message": "Reply to revise the plan"}
|
||||
|
||||
if action_value.get("type") != "open_swe_option":
|
||||
return {"status": "ignored", "reason": "Unknown action type"}
|
||||
|
||||
|
|
|
|||
7
package.json
Normal file
7
package.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "open-swe",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev:mock": "bash tests/e2e/dev-mock.sh"
|
||||
}
|
||||
}
|
||||
|
|
@ -29,6 +29,8 @@ dependencies = [
|
|||
"exa-py>=2.13.0",
|
||||
"langchain-google-genai>=4.2.4",
|
||||
"langchain-mcp-adapters>=0.2.2",
|
||||
"pycrdt>=0.14.1,<0.15",
|
||||
"pycrdt-websocket>=0.16.3,<0.17",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
|
|
|||
63
tests/e2e/dev-mock.sh
Normal file
63
tests/e2e/dev-mock.sh
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
#!/usr/bin/env bash
|
||||
# Local dev against the mock harness, with a REAL LLM and a Vite HMR dev server.
|
||||
# The browser uses a SINGLE origin ($UI, the Vite HMR server): your ui/src edits
|
||||
# hot-reload, and Vite proxies the API + Yjs collaboration WebSocket + mock
|
||||
# Slack/GitHub + sign-in to the harness ($API). Same-origin is what lets the
|
||||
# session cookie ride the plan-review WebSocket (cross-origin WS drops it).
|
||||
# The LLM is real; only the Slack/GitHub SaaS boundaries are faked.
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
API_PORT="${E2E_PORT:-2024}"
|
||||
UI_PORT="${UI_PORT:-3000}"
|
||||
API="http://127.0.0.1:${API_PORT}"
|
||||
UI="http://127.0.0.1:${UI_PORT}"
|
||||
|
||||
# Use a real OpenAI key. The agent talks to OpenAI directly, so a LangSmith
|
||||
# gateway key (lsv2_*) stray in the shell won't authenticate — prefer .env's
|
||||
# OPENAI_API_KEY unless the shell already holds an OpenAI-looking key (sk-…).
|
||||
# Pulled line-by-line, not by sourcing .env, which would clobber the harness's
|
||||
# test env (SANDBOX_TYPE, DASHBOARD_*, …).
|
||||
case "${OPENAI_API_KEY:-}" in
|
||||
sk-*) : ;;
|
||||
*)
|
||||
if [ -f .env ]; then
|
||||
export OPENAI_API_KEY="$(grep -E '^OPENAI_API_KEY=' .env | head -1 | cut -d= -f2- | tr -d "\"'")"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
case "${OPENAI_API_KEY:-}" in
|
||||
sk-*) : ;;
|
||||
*) echo "WARNING: OPENAI_API_KEY is not an OpenAI key (sk-…); model calls will 401." >&2 ;;
|
||||
esac
|
||||
|
||||
# Single origin = the Vite HMR server ($UI). Agent links, the sign-in redirect,
|
||||
# and the same-origin/WS allowlist all point there; the UI calls the API on its
|
||||
# own origin and Vite proxies it (and the WS) to the harness.
|
||||
export DASHBOARD_BASE_URL="$UI"
|
||||
export DASHBOARD_ALLOWED_ORIGINS="$UI"
|
||||
|
||||
[ -d ui/node_modules ] || (cd ui && bun install)
|
||||
|
||||
echo
|
||||
echo "┌──────────────────────────────────────────────────────────────────────┐"
|
||||
echo "│ START HERE → ${UI}/login │"
|
||||
echo "│ Continue with GitHub → pick Alice or Bob (mock sign-in) │"
|
||||
echo "└──────────────────────────────────────────────────────────────────────┘"
|
||||
echo " Mock Slack: ${UI}/mock/slack (send as Alice or Bob)"
|
||||
echo " Everything is on ${UI} (Vite HMR; API + WS proxied to the harness)."
|
||||
echo " LLM is real; Slack/GitHub are mocked. Open two browser profiles to be"
|
||||
echo " two users (Alice owns the plan; Bob can comment + request changes)."
|
||||
echo
|
||||
|
||||
E2E_REAL_LLM=1 uv run langgraph dev --config tests/e2e/langgraph.e2e.json \
|
||||
--port "${API_PORT}" --no-browser --allow-blocking --no-reload &
|
||||
HARNESS=$!
|
||||
trap 'kill "${HARNESS}" 2>/dev/null || true' EXIT INT TERM
|
||||
|
||||
# E2E_HARNESS activates the dev-only proxy plugin in ui/vite.config.ts; an empty
|
||||
# API base means the UI calls its own origin (proxied), keeping it same-origin.
|
||||
cd ui
|
||||
exec env E2E_HARNESS="${API}" VITE_DASHBOARD_API_BASE_URL="" \
|
||||
./node_modules/.bin/vite dev --host 127.0.0.1 --port "${UI_PORT}"
|
||||
|
|
@ -67,11 +67,20 @@ _DEFAULTS = {
|
|||
"DASHBOARD_JWT_SECRET": "test-dashboard-jwt-secret",
|
||||
}
|
||||
|
||||
# The Slack run's triggering user (fake users.info returns this email) — the
|
||||
# thread owner. A session with this email may continue the thread on the web.
|
||||
SAME_USER = {"login": "dev-user", "email": "dev@example.com"}
|
||||
# Any other logged-in user: can view the thread read-only, cannot post.
|
||||
OTHER_USER = {"login": "someone-else", "email": "someone@example.com"}
|
||||
# Named test users (the Slack sender dropdown + the dashboard login picker, and
|
||||
# the identities the automated tests log in as). Each maps a Slack sender id to
|
||||
# a dashboard login with a matching email, so the Slack thread's owner (resolved
|
||||
# by email) is the same person when they sign in. The first (Alice) is the
|
||||
# default Slack sender, hence the default thread owner.
|
||||
TEST_USERS = [
|
||||
{"name": "Alice", "slack_id": "U_ALICE", "login": "alice", "email": "alice@example.com"},
|
||||
{"name": "Bob", "slack_id": "U_BOB", "login": "bob", "email": "bob@example.com"},
|
||||
]
|
||||
|
||||
# The default Slack sender / thread owner; a session with this email may continue
|
||||
# the thread on the web. Any other logged-in user is read-only.
|
||||
SAME_USER = {"login": TEST_USERS[0]["login"], "email": TEST_USERS[0]["email"]}
|
||||
OTHER_USER = {"login": TEST_USERS[1]["login"], "email": TEST_USERS[1]["email"]}
|
||||
|
||||
for _k, _v in _DEFAULTS.items():
|
||||
os.environ.setdefault(_k, _v)
|
||||
|
|
|
|||
|
|
@ -46,6 +46,19 @@ echo PUSHED_OK
|
|||
""".strip()
|
||||
|
||||
|
||||
_PLAN_URL_RE = re.compile(r"https?://[^\s\"'<>)\]|]+/plan\b")
|
||||
|
||||
|
||||
def _text(content: Any) -> str:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return " ".join(
|
||||
part.get("text", "") if isinstance(part, dict) else str(part) for part in content
|
||||
)
|
||||
return str(content)
|
||||
|
||||
|
||||
def _pr_url_from_messages(messages: list[BaseMessage]) -> str | None:
|
||||
for msg in reversed(messages):
|
||||
if isinstance(msg, ToolMessage):
|
||||
|
|
@ -56,6 +69,28 @@ def _pr_url_from_messages(messages: list[BaseMessage]) -> str | None:
|
|||
return None
|
||||
|
||||
|
||||
def _plan_url_from_messages(messages: list[BaseMessage]) -> str | None:
|
||||
"""The plan-review URL is injected into the system prompt; a real model would
|
||||
read it the same way."""
|
||||
for msg in messages:
|
||||
match = _PLAN_URL_RE.search(_text(msg.content))
|
||||
if match:
|
||||
return match.group(0).rstrip(".,")
|
||||
return None
|
||||
|
||||
|
||||
def _reviewer_feedback(messages: list[BaseMessage]) -> str | None:
|
||||
"""The harvested reviewer comments the backend hands the agent on approval."""
|
||||
humans = [m for m in messages if isinstance(m, HumanMessage)]
|
||||
if not humans:
|
||||
return None
|
||||
text = _text(humans[-1].content)
|
||||
idx = text.lower().find("feedback")
|
||||
if "approved" in text.lower() and idx != -1:
|
||||
return text[idx:].strip()
|
||||
return None
|
||||
|
||||
|
||||
def _step_implement(_messages: list[BaseMessage]) -> AIMessage:
|
||||
return AIMessage(
|
||||
content="Setting up the repo and implementing the change.",
|
||||
|
|
@ -86,9 +121,11 @@ def _step_open_pr(_messages: list[BaseMessage]) -> AIMessage:
|
|||
|
||||
def _step_reply(messages: list[BaseMessage]) -> AIMessage:
|
||||
url = _pr_url_from_messages(messages) or "(PR url unavailable)"
|
||||
feedback = _reviewer_feedback(messages)
|
||||
extra = f"\n\nReviewer feedback I addressed:\n{feedback}" if feedback else ""
|
||||
text = (
|
||||
f"✅ Done! I implemented the change and opened a PR: <{url}|{PR_TITLE}>\n\n"
|
||||
f"• Added `{FEATURE_FILE}` with a `greet()` helper.\n"
|
||||
f"• Added `{FEATURE_FILE}` with a `greet()` helper.{extra}\n"
|
||||
"Let me know if you'd like any changes."
|
||||
)
|
||||
return AIMessage(
|
||||
|
|
@ -97,6 +134,97 @@ def _step_reply(messages: list[BaseMessage]) -> AIMessage:
|
|||
)
|
||||
|
||||
|
||||
# --- plan-mode flow --------------------------------------------------------
|
||||
PLAN_MARKDOWN = """## Plan: Add greet() helper
|
||||
|
||||
### Overview
|
||||
Add a tiny greeting helper to the demo repo.
|
||||
|
||||
### Files to change
|
||||
- `greet.py` — new module exposing a `greet(name)` function.
|
||||
|
||||
### Steps
|
||||
1. Create `greet.py` with a `greet(name)` function.
|
||||
2. Open a draft PR with the change.
|
||||
|
||||
### Verification
|
||||
- Import `greet` and confirm it returns the expected string.
|
||||
"""
|
||||
|
||||
|
||||
def _step_enter_plan(_messages: list[BaseMessage]) -> AIMessage:
|
||||
return AIMessage(
|
||||
content="This is worth planning first — entering plan mode.",
|
||||
tool_calls=[{"name": "enter_plan_mode", "args": {}, "id": "call-enter-plan"}],
|
||||
)
|
||||
|
||||
|
||||
def _step_plan_link(messages: list[BaseMessage]) -> AIMessage:
|
||||
url = _plan_url_from_messages(messages) or "(plan link unavailable)"
|
||||
return AIMessage(
|
||||
content="Sharing the plan-review link.",
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "slack_thread_reply",
|
||||
"args": {
|
||||
"message": f"I'm putting together a plan. Follow along and review it here: <{url}|plan review>"
|
||||
},
|
||||
"id": "call-plan-link",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _step_plan_research(_messages: list[BaseMessage]) -> AIMessage:
|
||||
return AIMessage(
|
||||
content="Reading the repo to ground the plan.",
|
||||
tool_calls=[
|
||||
{"name": "execute", "args": {"command": "echo planning && ls"}, "id": "call-plan-read"}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _step_save_plan(_messages: list[BaseMessage]) -> AIMessage:
|
||||
return AIMessage(
|
||||
content="Saving the plan for review.",
|
||||
tool_calls=[
|
||||
{"name": "save_plan", "args": {"plan_markdown": PLAN_MARKDOWN}, "id": "call-save-plan"}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _step_plan_complete(messages: list[BaseMessage]) -> AIMessage:
|
||||
url = _plan_url_from_messages(messages) or "(plan link unavailable)"
|
||||
return AIMessage(
|
||||
content="Announcing the plan is ready.",
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "slack_thread_reply",
|
||||
"args": {
|
||||
"message": f"✅ The plan is ready for review: <{url}|open the plan>. "
|
||||
"Take a look, leave comments, and approve it when you're happy."
|
||||
},
|
||||
"id": "call-plan-done",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _step_plan_end(_messages: list[BaseMessage]) -> AIMessage:
|
||||
return AIMessage(content="I'll wait for your review and approval before implementing.")
|
||||
|
||||
|
||||
def build_plan_script() -> list[Any]:
|
||||
return [
|
||||
_step_enter_plan,
|
||||
_step_plan_link,
|
||||
_step_plan_research,
|
||||
_step_save_plan,
|
||||
_step_plan_complete,
|
||||
_step_plan_end,
|
||||
]
|
||||
|
||||
|
||||
FOLLOW_UP_REPLY = "Thanks! The PR is ready for review — anything else you'd like changed?"
|
||||
|
||||
|
||||
|
|
@ -133,13 +261,23 @@ class FakeScriptedChatModel(BaseChatModel):
|
|||
run_manager: CallbackManagerForLLMRun | None = None, # noqa: ARG002
|
||||
**kwargs: Any,
|
||||
) -> ChatResult:
|
||||
# First human turn implements + opens the PR; later turns (a web/Slack
|
||||
# follow-up on the same thread) just reply.
|
||||
human_turns = sum(1 for m in messages if isinstance(m, HumanMessage))
|
||||
script = build_script() if human_turns <= 1 else build_followup_script()
|
||||
humans = [m for m in messages if isinstance(m, HumanMessage)]
|
||||
first_text = _text(humans[0].content) if humans else ""
|
||||
last_text = _text(humans[-1].content) if humans else ""
|
||||
|
||||
# Pick the script for the current turn by what the latest human asked.
|
||||
if _is_approval(last_text):
|
||||
script = build_script() # implement + open PR + reply
|
||||
elif _is_revision(last_text):
|
||||
script = build_plan_script() # re-plan after requested changes
|
||||
elif _is_plan_request(first_text) and len(humans) <= 1:
|
||||
script = build_plan_script() # first ask was to plan
|
||||
elif len(humans) <= 1:
|
||||
script = build_script()
|
||||
else:
|
||||
script = build_followup_script()
|
||||
|
||||
# Step within the *current* turn: AIMessages since the last human turn.
|
||||
# (Counting the whole thread would short-circuit reused/multi-turn threads.)
|
||||
last_human = max(
|
||||
(i for i, m in enumerate(messages) if isinstance(m, HumanMessage)), default=-1
|
||||
)
|
||||
|
|
@ -147,5 +285,19 @@ class FakeScriptedChatModel(BaseChatModel):
|
|||
if step < len(script):
|
||||
message = script[step](messages)
|
||||
else:
|
||||
message = AIMessage(content="All set — the PR is open and linked in the thread.")
|
||||
message = AIMessage(content="All set — let me know if you'd like anything else.")
|
||||
return ChatResult(generations=[ChatGeneration(message=message)])
|
||||
|
||||
|
||||
def _is_plan_request(text: str) -> bool:
|
||||
return "plan" in text.lower()
|
||||
|
||||
|
||||
def _is_approval(text: str) -> bool:
|
||||
t = text.lower()
|
||||
return "approved" in t and "implement" in t
|
||||
|
||||
|
||||
def _is_revision(text: str) -> bool:
|
||||
t = text.lower()
|
||||
return "needs changes" in t or "publish an updated plan" in t
|
||||
|
|
|
|||
|
|
@ -36,9 +36,26 @@ from e2e_env import ( # noqa: E402
|
|||
DEMO_CHANNEL,
|
||||
HUMAN_USER,
|
||||
REPO_ROOT,
|
||||
TEST_USERS,
|
||||
)
|
||||
from fastapi import HTTPException, Request # noqa: E402
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse # noqa: E402
|
||||
from fastapi.responses import ( # noqa: E402
|
||||
FileResponse,
|
||||
HTMLResponse,
|
||||
JSONResponse,
|
||||
RedirectResponse,
|
||||
Response,
|
||||
)
|
||||
|
||||
# Slack-user directory the fake ``users.info`` resolves: the default sender used
|
||||
# by the automated tests plus the named manual-test users.
|
||||
_SLACK_USERS: dict[str, dict[str, str]] = {
|
||||
HUMAN_USER: {"name": "devuser", "real_name": "Dev User", "email": "dev@example.com"},
|
||||
**{
|
||||
u["slack_id"]: {"name": u["login"], "real_name": u["name"], "email": u["email"]}
|
||||
for u in TEST_USERS
|
||||
},
|
||||
}
|
||||
|
||||
from agent.dashboard.oauth import COOKIE_NAME, issue_session # noqa: E402
|
||||
from agent.webapp import app, generate_thread_id_from_slack_thread # noqa: E402
|
||||
|
|
@ -74,11 +91,14 @@ async def slack_send(request: Request) -> JSONResponse:
|
|||
form = await request.json()
|
||||
text = str(form.get("text", ""))
|
||||
mention_bot = bool(form.get("mention_bot", True))
|
||||
# Sender defaults to the first test user (Alice) — the canonical owner the
|
||||
# automated tests log in as; the mock UI passes the chosen test user.
|
||||
user_id = str(form.get("user") or TEST_USERS[0]["slack_id"])
|
||||
channel = DEMO_CHANNEL
|
||||
|
||||
ts = fakes.new_thread_ts()
|
||||
CURRENT_THREAD["thread_ts"] = ts
|
||||
fakes.add_slack_message(channel, ts, user=HUMAN_USER, text=text, is_bot=False)
|
||||
fakes.add_slack_message(channel, ts, user=user_id, text=text, is_bot=False)
|
||||
|
||||
payload = {
|
||||
"type": "event_callback",
|
||||
|
|
@ -87,7 +107,7 @@ async def slack_send(request: Request) -> JSONResponse:
|
|||
"event": {
|
||||
"type": "app_mention" if mention_bot else "message",
|
||||
"channel": channel,
|
||||
"user": HUMAN_USER,
|
||||
"user": user_id,
|
||||
"text": text,
|
||||
"ts": ts,
|
||||
"thread_ts": ts,
|
||||
|
|
@ -131,6 +151,82 @@ async def control_login(request: Request) -> JSONResponse:
|
|||
return resp
|
||||
|
||||
|
||||
@app.get("/control/login")
|
||||
async def control_login_get(login: str = "", email: str = "", next_url: str = "") -> Response:
|
||||
"""Browser login. With no ``login``, render a dropdown of the test users;
|
||||
with ``?login=<u>`` (email resolved from the registry, or pass ``&email=``),
|
||||
mint the session cookie and redirect into the dashboard. Use a separate
|
||||
browser/profile per user — each has its own cookie jar."""
|
||||
# Land on the dashboard origin (DASHBOARD_BASE_URL — the Vite HMR server in
|
||||
# dev:mock), not this harness, so the cookie + the hot-reloading UI line up.
|
||||
ui = os.environ.get("DASHBOARD_BASE_URL", "").rstrip("/")
|
||||
dest = next_url or (f"{ui}/agents" if ui else "/agents")
|
||||
if not login:
|
||||
options = "".join(f'<option value="{u["login"]}">{u["name"]}</option>' for u in TEST_USERS)
|
||||
return HTMLResponse(
|
||||
f"""<!doctype html><meta charset=utf-8><title>Mock login</title>
|
||||
<body style="font-family:system-ui;max-width:420px;margin:3rem auto;padding:0 1rem">
|
||||
<h1 style="font-size:1.1rem">Sign in (mock)</h1>
|
||||
<form method=get action=/control/login>
|
||||
<select name=login style="font:inherit;padding:0.4rem">{options}</select>
|
||||
<button style="font:inherit;padding:0.45rem 0.9rem;cursor:pointer">Sign in</button>
|
||||
</form>
|
||||
<p style="color:#888;font-size:0.85rem">Tip: use a separate browser or profile per
|
||||
user so their sessions don't overwrite each other.</p>
|
||||
</body>"""
|
||||
)
|
||||
if not email:
|
||||
match = next((u for u in TEST_USERS if u["login"] == login), None)
|
||||
email = match["email"] if match else f"{login}@example.com"
|
||||
token = issue_session(login=login, email=email, avatar_url=None)
|
||||
resp = RedirectResponse(url=dest, status_code=303)
|
||||
resp.set_cookie(COOKIE_NAME, token, httponly=True, samesite="lax", secure=False, path="/")
|
||||
return resp
|
||||
|
||||
|
||||
@app.get("/dashboard/api/auth/login")
|
||||
async def mock_github_login(redirect_to: str = "", login: str = "") -> Response:
|
||||
"""Mock stand-in for GitHub OAuth: the dashboard's "Continue with GitHub"
|
||||
button lands here. With no ``login``, render a picker of the fake GitHub
|
||||
test users; once one is chosen, mint the real session cookie and redirect
|
||||
back into the dashboard (``redirect_to``)."""
|
||||
ui = os.environ.get("DASHBOARD_BASE_URL", "").rstrip("/")
|
||||
dest = redirect_to or (f"{ui}/agents" if ui else "/agents")
|
||||
if not login:
|
||||
options = "".join(
|
||||
f'<option value="{u["login"]}">{u["name"]} (@{u["login"]})</option>' for u in TEST_USERS
|
||||
)
|
||||
return HTMLResponse(
|
||||
f"""<!doctype html><meta charset=utf-8><title>Continue with GitHub (mock)</title>
|
||||
<body style="font-family:system-ui;max-width:420px;margin:3rem auto;padding:0 1rem">
|
||||
<h1 style="font-size:1.1rem">Continue with GitHub (mock)</h1>
|
||||
<p style="color:#888;font-size:0.9rem">Pick a fake GitHub account to sign in as.</p>
|
||||
<form method=get action=/dashboard/api/auth/login>
|
||||
<input type=hidden name=redirect_to value="{dest}">
|
||||
<select name=login style="font:inherit;padding:0.4rem">{options}</select>
|
||||
<button style="font:inherit;padding:0.45rem 0.9rem;cursor:pointer">Continue</button>
|
||||
</form>
|
||||
<p style="color:#888;font-size:0.85rem">Tip: use a separate browser or profile per
|
||||
user so their sessions don't overwrite each other.</p>
|
||||
</body>"""
|
||||
)
|
||||
match = next((u for u in TEST_USERS if u["login"] == login), None)
|
||||
email = match["email"] if match else f"{login}@example.com"
|
||||
token = issue_session(login=login, email=email, avatar_url=None)
|
||||
resp = RedirectResponse(url=dest, status_code=303)
|
||||
resp.set_cookie(COOKIE_NAME, token, httponly=True, samesite="lax", secure=False, path="/")
|
||||
return resp
|
||||
|
||||
|
||||
# The real dashboard registered /dashboard/api/auth/login first (via
|
||||
# include_router), so Starlette would match it before ours. Move ours to the
|
||||
# front of the table so the mock picker shadows the real OAuth redirect.
|
||||
for _i, _route in enumerate(app.router.routes):
|
||||
if getattr(_route, "endpoint", None) is mock_github_login:
|
||||
app.router.routes.insert(0, app.router.routes.pop(_i))
|
||||
break
|
||||
|
||||
|
||||
@app.post("/control/logout")
|
||||
async def control_logout() -> JSONResponse:
|
||||
resp = JSONResponse({"ok": True})
|
||||
|
|
@ -195,11 +291,22 @@ async def ui_agents_thread(thread_id: str) -> FileResponse: # noqa: ARG001
|
|||
return _ui_file("_shell.html")
|
||||
|
||||
|
||||
@app.get("/agents/{thread_id}/plan", response_class=HTMLResponse)
|
||||
async def ui_agents_plan(thread_id: str) -> FileResponse: # noqa: ARG001
|
||||
return _ui_file("_shell.html")
|
||||
|
||||
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
async def ui_login() -> FileResponse:
|
||||
return _ui_file("_shell.html")
|
||||
|
||||
|
||||
@app.get("/mock/users")
|
||||
async def mock_users() -> JSONResponse:
|
||||
"""The named test users that drive the Slack sender + login dropdowns."""
|
||||
return JSONResponse(TEST_USERS)
|
||||
|
||||
|
||||
@app.get("/mock/slack/messages")
|
||||
async def slack_messages() -> JSONResponse:
|
||||
thread = CURRENT_THREAD["thread_ts"]
|
||||
|
|
@ -363,16 +470,19 @@ async def slack_reactions_add(request: Request) -> JSONResponse:
|
|||
|
||||
@app.get("/fake-slack/users.info")
|
||||
async def slack_users_info(user: str = "") -> JSONResponse:
|
||||
info = _SLACK_USERS.get(
|
||||
user, {"name": "devuser", "real_name": "Dev User", "email": "dev@example.com"}
|
||||
)
|
||||
return _ok(
|
||||
{
|
||||
"user": {
|
||||
"id": user,
|
||||
"name": "devuser",
|
||||
"real_name": "Dev User",
|
||||
"name": info["name"],
|
||||
"real_name": info["real_name"],
|
||||
"profile": {
|
||||
"email": "dev@example.com",
|
||||
"display_name": "Dev User",
|
||||
"real_name": "Dev User",
|
||||
"email": info["email"],
|
||||
"display_name": info["real_name"],
|
||||
"real_name": info["real_name"],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,13 @@ process), so it runs before the first run regardless of import order. Idempotent
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import e2e_env # noqa: F401 (sets env before any agent import)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_applied = False
|
||||
|
||||
|
||||
|
|
@ -34,12 +39,20 @@ def apply() -> None:
|
|||
opr = importlib.import_module("agent.tools.open_pull_request")
|
||||
|
||||
from e2e_env import FAKE_GITHUB_API, FAKE_SLACK_API
|
||||
from fake_llm import FakeScriptedChatModel, build_script
|
||||
|
||||
def _fake_make_model(model_id: str, **kwargs: object): # noqa: ARG001
|
||||
return FakeScriptedChatModel(script=build_script())
|
||||
# The LLM is the only agent-internal piece we fake, and only by default.
|
||||
# Set E2E_REAL_LLM=1 to drive the harness (mock Slack/GitHub, real agent)
|
||||
# with a real model — useful for manually exercising plan review etc. The
|
||||
# provider key (e.g. ANTHROPIC_API_KEY) must be in the environment.
|
||||
if os.environ.get("E2E_REAL_LLM"):
|
||||
logger.warning("E2E_REAL_LLM set — using the real model factory, not the scripted fake")
|
||||
else:
|
||||
from fake_llm import FakeScriptedChatModel, build_script
|
||||
|
||||
server.make_model = _fake_make_model
|
||||
def _fake_make_model(model_id: str, **kwargs: object): # noqa: ARG001
|
||||
return FakeScriptedChatModel(script=build_script())
|
||||
|
||||
server.make_model = _fake_make_model
|
||||
|
||||
async def _dummy_install_token_with_expiry() -> tuple[str, str | None]:
|
||||
return "dummy-installation-token", None
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
<body>
|
||||
<h1>#demo — Slack (mock)</h1>
|
||||
<div class="compose">
|
||||
<select id="user" title="sender"></select>
|
||||
<input id="text" value="<@U0BOT> please add a greet() helper and open a PR" />
|
||||
<button id="send">Send</button>
|
||||
<button id="reset">Reset</button>
|
||||
|
|
@ -29,11 +30,22 @@
|
|||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const userNames = {}; // slack_id -> display name
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const users = await (await fetch("/mock/users")).json();
|
||||
$("user").innerHTML = users
|
||||
.map((u) => `<option value="${u.slack_id}">${u.name}</option>`)
|
||||
.join("");
|
||||
for (const u of users) userNames[u.slack_id] = u.name;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function render(messages) {
|
||||
const html = messages
|
||||
.map((m) => {
|
||||
const who = m.is_bot ? "open-swe (bot)" : "you";
|
||||
const who = m.is_bot ? "open-swe (bot)" : userNames[m.user] || m.user;
|
||||
const linked = m.text.replace(/<(https?:\/\/[^|>]+)\|([^>]+)>/g, '<a href="$1">$2</a>');
|
||||
return `<div class="msg ${m.is_bot ? "bot" : ""}" data-bot="${m.is_bot}"><div class="who">${who}</div><div class="text">${linked}</div></div>`;
|
||||
})
|
||||
|
|
@ -52,7 +64,11 @@
|
|||
await fetch("/mock/slack/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text: $("text").value, mention_bot: $("mention").checked }),
|
||||
body: JSON.stringify({
|
||||
text: $("text").value,
|
||||
mention_bot: $("mention").checked,
|
||||
user: $("user").value,
|
||||
}),
|
||||
});
|
||||
poll();
|
||||
});
|
||||
|
|
@ -62,6 +78,7 @@
|
|||
render([]);
|
||||
});
|
||||
|
||||
loadUsers();
|
||||
poll();
|
||||
setInterval(poll, 1000);
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { test, expect, type Page } from "@playwright/test";
|
|||
|
||||
// Drives the REAL built ui/ app (served same-origin from the harness) for the
|
||||
// Slack → web handoff. Only the LLM/GitHub/Slack/token boundaries are faked.
|
||||
const SAME_USER = { login: "dev-user", email: "dev@example.com" };
|
||||
const OTHER_USER = { login: "someone-else", email: "someone@example.com" };
|
||||
const SAME_USER = { login: "alice", email: "alice@example.com" };
|
||||
const OTHER_USER = { login: "bob", email: "bob@example.com" };
|
||||
|
||||
async function loginAs(page: Page, user: { login: string; email: string }) {
|
||||
const res = await page.request.post("/control/login", { data: user });
|
||||
|
|
|
|||
145
tests/e2e/tests/plan_review.spec.ts
Normal file
145
tests/e2e/tests/plan_review.spec.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import {
|
||||
test,
|
||||
expect,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
|
||||
// Full plan-review flow, driven through the mock Slack UI + the real dashboard:
|
||||
// user asks Open SWE in Slack to PLAN something ->
|
||||
// agent calls enter_plan_mode, posts the plan-review link to Slack, writes the
|
||||
// plan as a markdown file (save_plan), and posts "ready" back to Slack ->
|
||||
// owner (user1) and a collaborator (user2) both open the plan and leave inline
|
||||
// comments using BlockNote's native comment UI (synced live via Yjs) ->
|
||||
// only the owner can approve -> on approval the agent implements, opens a PR,
|
||||
// and replies in Slack with the link, having received the reviewers' comments.
|
||||
// Only the LLM is faked; all agent + dashboard code runs for real.
|
||||
|
||||
const OWNER = { login: "alice", email: "alice@example.com" };
|
||||
const COLLABORATOR = { login: "bob", email: "bob@example.com" };
|
||||
|
||||
async function botMessages(request: APIRequestContext): Promise<Array<string>> {
|
||||
const res = await request.get("/mock/slack/messages");
|
||||
const msgs = (await res.json()) as Array<{ text: string; is_bot: boolean }>;
|
||||
return msgs.filter((m) => m.is_bot).map((m) => m.text);
|
||||
}
|
||||
|
||||
// Create an inline comment via BlockNote's native UI: select text in the plan,
|
||||
// click "Add comment" in the formatting toolbar, type, and save.
|
||||
async function addInlineComment(page: Page, anchorText: string, comment: string) {
|
||||
await page.locator(".bn-editor").getByText(anchorText, { exact: false }).first().selectText();
|
||||
await page.locator('[data-test="addcomment"]').click();
|
||||
await page.locator(".bn-comment-editor .ProseMirror").last().waitFor({ state: "visible" });
|
||||
await page.keyboard.type(comment);
|
||||
await page.locator('[data-test="save"]').click();
|
||||
}
|
||||
|
||||
test.describe("Plan review (BlockNote native comments)", () => {
|
||||
test("Slack plan request → inline comments → owner approves → PR", async ({
|
||||
browser,
|
||||
request,
|
||||
}) => {
|
||||
// 1. A user asks the bot to PLAN something in Slack.
|
||||
await request.post("/control/reset");
|
||||
const send = await request.post("/mock/slack/send", {
|
||||
data: { text: "<@U0BOT> plan how to add a greet() helper", mention_bot: true },
|
||||
});
|
||||
const { thread_id: threadId } = (await send.json()) as { thread_id: string };
|
||||
expect(threadId).toBeTruthy();
|
||||
const planPath = `/agents/${threadId}/plan`;
|
||||
|
||||
// 1a. enter_plan_mode must actually engage, not error out. Its Command must
|
||||
// carry a terminating ToolMessage; without it the tool call fails and is
|
||||
// swallowed into an error tool message while the agent silently proceeds
|
||||
// as a normal run. Assert the tool's success message landed in the
|
||||
// thread (it wouldn't if the call had errored).
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const res = await request.get(`/threads/${threadId}/state`);
|
||||
const state = (await res.json()) as {
|
||||
values?: { messages?: Array<{ content?: unknown }> };
|
||||
};
|
||||
return (state.values?.messages ?? [])
|
||||
.map((m) =>
|
||||
typeof m.content === "string" ? m.content : JSON.stringify(m.content),
|
||||
)
|
||||
.some((c) => c.includes("Plan mode is active"));
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
// 2. The agent shares the plan-review link, then announces the plan is ready.
|
||||
await expect
|
||||
.poll(async () => (await botMessages(request)).join("\n"), { timeout: 60_000 })
|
||||
.toMatch(/\/agents\/[^/]+\/plan\b/);
|
||||
await expect
|
||||
.poll(async () => (await botMessages(request)).join("\n"), { timeout: 60_000 })
|
||||
.toMatch(/ready for review/i);
|
||||
|
||||
// 3. The OWNER opens the conversation, sees the "Review plan" banner, and
|
||||
// follows it into the plan — which renders inside the agents shell.
|
||||
const ownerCtx = await browser.newContext();
|
||||
await ownerCtx.request.post("/control/login", { data: OWNER });
|
||||
const owner = await ownerCtx.newPage();
|
||||
await owner.goto(`/agents/${threadId}`);
|
||||
const reviewLink = owner.getByTestId("review-plan-link");
|
||||
await expect(reviewLink).toBeVisible({ timeout: 30_000 });
|
||||
await reviewLink.click();
|
||||
await expect(owner).toHaveURL(new RegExp(`/agents/${threadId}/plan$`));
|
||||
await expect(owner.getByTestId("plan-review")).toBeVisible({ timeout: 30_000 });
|
||||
// The agents sidebar is present (plan lives inside the shell).
|
||||
await expect(owner.getByText("Back to conversation")).toBeVisible();
|
||||
await expect(owner.getByTestId("plan-document")).toContainText("greet", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(owner.getByTestId("approve-plan")).toBeVisible();
|
||||
|
||||
// Owner leaves an inline comment on the plan heading (a comment wraps the
|
||||
// text in a `.bn-thread-mark`).
|
||||
await addInlineComment(owner, "Add greet() helper", "Owner: looks solid, ship it.");
|
||||
await expect(owner.locator(".bn-thread-mark")).toHaveCount(1);
|
||||
|
||||
// 4. A COLLABORATOR opens the same plan: sees it AND the owner's comment
|
||||
// (synced live via Yjs), but has NO approve button.
|
||||
const collabCtx = await browser.newContext();
|
||||
await collabCtx.request.post("/control/login", { data: COLLABORATOR });
|
||||
const collab = await collabCtx.newPage();
|
||||
await collab.goto(planPath);
|
||||
await expect(collab.getByTestId("plan-review")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(collab.getByTestId("plan-document")).toContainText("greet", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(collab.locator(".bn-thread-mark")).toHaveCount(1); // owner's comment, synced
|
||||
await expect(collab.getByTestId("approve-plan")).toHaveCount(0);
|
||||
await expect(collab.getByTestId("reject-plan")).toBeVisible();
|
||||
|
||||
// Collaborator leaves inline feedback on a different part of the plan.
|
||||
await addInlineComment(
|
||||
collab,
|
||||
"tiny greeting helper",
|
||||
"Reviewer: please also add a docstring.",
|
||||
);
|
||||
await expect(collab.locator(".bn-thread-mark")).toHaveCount(2);
|
||||
|
||||
// 5. The owner sees the collaborator's comment sync in, then approves.
|
||||
await expect(owner.locator(".bn-thread-mark")).toHaveCount(2);
|
||||
await owner.getByTestId("approve-plan").click();
|
||||
await expect(owner.getByTestId("plan-decision")).toContainText(/implementing/i);
|
||||
|
||||
// 6. The agent implements, opens a PR, and links it back in the Slack thread,
|
||||
// echoing the reviewers' feedback it received — which proves the
|
||||
// collaborator's comment synced to the owner and was harvested on approve.
|
||||
await expect
|
||||
.poll(async () => (await botMessages(request)).join("\n"), { timeout: 90_000 })
|
||||
.toMatch(/\/pull\//);
|
||||
expect((await botMessages(request)).join("\n")).toMatch(/docstring/);
|
||||
|
||||
const prs = (await (await request.get("/mock/github/data")).json()) as Array<unknown>;
|
||||
expect(prs.length).toBeGreaterThan(0);
|
||||
|
||||
await ownerCtx.close();
|
||||
await collabCtx.close();
|
||||
});
|
||||
});
|
||||
|
|
@ -66,3 +66,29 @@ def test_resolve_project_id_caches_success(monkeypatch: pytest.MonkeyPatch) -> N
|
|||
assert first == "pid-123"
|
||||
assert second == "pid-123"
|
||||
assert calls == [AGENT_TRACING_PROJECT]
|
||||
|
||||
|
||||
def test_resolve_project_id_caches_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
class _FakeClient:
|
||||
def read_project(self, *, project_name: str) -> None:
|
||||
calls.append(project_name)
|
||||
raise RuntimeError("403 Forbidden")
|
||||
|
||||
monkeypatch.setattr(ls_utils, "_build_prod_langsmith_client", lambda: _FakeClient())
|
||||
|
||||
assert ls_utils._resolve_project_id_by_name(AGENT_TRACING_PROJECT) is None
|
||||
assert ls_utils._resolve_project_id_by_name(AGENT_TRACING_PROJECT) is None
|
||||
assert calls == [AGENT_TRACING_PROJECT]
|
||||
|
||||
|
||||
def test_trace_url_none_when_tenant_unset(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("LANGSMITH_TENANT_ID_PROD", raising=False)
|
||||
|
||||
def _boom() -> None:
|
||||
raise AssertionError("must not build a client when the tenant id is unset")
|
||||
|
||||
monkeypatch.setattr(ls_utils, "_build_prod_langsmith_client", _boom)
|
||||
|
||||
assert ls_utils.get_langsmith_trace_url("t5") is None
|
||||
|
|
|
|||
223
tests/test_plan_mode.py
Normal file
223
tests/test_plan_mode.py
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from agent import server
|
||||
from agent.dashboard import thread_api
|
||||
from agent.prompt import construct_system_prompt
|
||||
|
||||
|
||||
def test_plan_mode_prompt_included_when_enabled() -> None:
|
||||
prompt = construct_system_prompt(working_dir="/work", plan_mode=True)
|
||||
assert "Plan Mode (ACTIVE)" in prompt
|
||||
assert "read-only research-and-planning phase" in prompt
|
||||
|
||||
|
||||
def test_plan_mode_prompt_absent_by_default() -> None:
|
||||
prompt = construct_system_prompt(working_dir="/work")
|
||||
assert "Plan Mode (ACTIVE)" not in prompt
|
||||
|
||||
|
||||
def test_plan_mode_excluded_tools_cover_mutating_tools() -> None:
|
||||
excluded = server.PLAN_MODE_EXCLUDED_TOOLS
|
||||
for tool in (
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"task",
|
||||
"open_pull_request",
|
||||
"request_pr_review",
|
||||
"linear_create_issue",
|
||||
"linear_update_issue",
|
||||
"linear_delete_issue",
|
||||
):
|
||||
assert tool in excluded
|
||||
# Read-only tools must stay available.
|
||||
assert "read_file" not in excluded
|
||||
assert "execute" not in excluded
|
||||
|
||||
|
||||
class _FakeThreadsClient:
|
||||
async def create(
|
||||
self, *, thread_id: str, metadata: dict[str, Any], if_exists: str
|
||||
) -> dict[str, Any]:
|
||||
return {"thread_id": thread_id, "metadata": metadata}
|
||||
|
||||
async def update(self, *, thread_id: str, metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"thread_id": thread_id, "metadata": metadata}
|
||||
|
||||
async def get(self, thread_id: str) -> dict[str, Any]:
|
||||
return {"thread_id": thread_id, "metadata": {}}
|
||||
|
||||
|
||||
class _FakeRunsClient:
|
||||
def __init__(self) -> None:
|
||||
self.configurable: dict[str, Any] | None = None
|
||||
|
||||
async def create(
|
||||
self,
|
||||
thread_id: str,
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: dict[str, Any],
|
||||
config: dict[str, Any],
|
||||
if_not_exists: str = "reject",
|
||||
stream_mode: list[str] | None = None,
|
||||
stream_resumable: bool = False,
|
||||
) -> dict[str, str]:
|
||||
self.configurable = config["configurable"]
|
||||
return {"run_id": "run-id"}
|
||||
|
||||
|
||||
class _FakeLangGraphClient:
|
||||
def __init__(self) -> None:
|
||||
self.threads = _FakeThreadsClient()
|
||||
self.runs = _FakeRunsClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dashboard_run_client(monkeypatch: pytest.MonkeyPatch) -> _FakeLangGraphClient:
|
||||
client = _FakeLangGraphClient()
|
||||
|
||||
async def fake_get_profile(login: str) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
async def fake_ensure_token(login: str) -> None:
|
||||
return None
|
||||
|
||||
async def fake_resolve_email(login: str, profile: dict[str, Any]) -> str:
|
||||
return "octo@example.com"
|
||||
|
||||
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
|
||||
monkeypatch.setattr(thread_api, "get_profile", fake_get_profile)
|
||||
monkeypatch.setattr(thread_api, "_ensure_dashboard_github_token", fake_ensure_token)
|
||||
monkeypatch.setattr(thread_api, "_resolve_run_email", fake_resolve_email)
|
||||
return client
|
||||
|
||||
|
||||
def _run_start_command(plan_mode: bool | None) -> dict[str, Any]:
|
||||
configurable: dict[str, Any] = {}
|
||||
if plan_mode is not None:
|
||||
configurable["plan_mode"] = plan_mode
|
||||
return {
|
||||
"method": "run.start",
|
||||
"params": {
|
||||
"input": {"messages": [{"role": "user", "content": "do work"}]},
|
||||
"config": {"configurable": configurable},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_run_start_passes_plan_mode_when_enabled(
|
||||
dashboard_run_client: _FakeLangGraphClient,
|
||||
) -> None:
|
||||
enriched = asyncio.run(
|
||||
thread_api._enrich_run_start_command(
|
||||
"thread-id",
|
||||
"octo",
|
||||
_run_start_command(True),
|
||||
metadata={"source": "dashboard", "github_login": "octo"},
|
||||
creating=False,
|
||||
)
|
||||
)
|
||||
|
||||
configurable = enriched["params"]["config"]["configurable"]
|
||||
assert configurable["plan_mode"] is True
|
||||
|
||||
|
||||
def test_run_start_omits_plan_mode_when_disabled(
|
||||
dashboard_run_client: _FakeLangGraphClient,
|
||||
) -> None:
|
||||
enriched = asyncio.run(
|
||||
thread_api._enrich_run_start_command(
|
||||
"thread-id",
|
||||
"octo",
|
||||
_run_start_command(None),
|
||||
metadata={"source": "dashboard", "github_login": "octo"},
|
||||
creating=False,
|
||||
)
|
||||
)
|
||||
|
||||
configurable = enriched["params"]["config"]["configurable"]
|
||||
assert "plan_mode" not in configurable
|
||||
|
||||
|
||||
def test_thread_summary_reports_plan_mode() -> None:
|
||||
summary = thread_api._thread_summary(
|
||||
{"thread_id": "t1", "metadata": {"source": "dashboard", "plan_mode": True}}
|
||||
)
|
||||
assert summary["planMode"] is True
|
||||
|
||||
summary_off = thread_api._thread_summary(
|
||||
{"thread_id": "t2", "metadata": {"source": "dashboard"}}
|
||||
)
|
||||
assert summary_off["planMode"] is False
|
||||
|
||||
|
||||
def test_plan_mode_guidance_section_always_present() -> None:
|
||||
"""The guidance section telling the agent about enter_plan_mode should be in every prompt."""
|
||||
prompt = construct_system_prompt(working_dir="/work", plan_mode=False)
|
||||
assert "enter_plan_mode" in prompt
|
||||
assert "Plan Mode" in prompt
|
||||
|
||||
|
||||
def test_plan_mode_guidance_section_present_when_enabled() -> None:
|
||||
prompt = construct_system_prompt(working_dir="/work", plan_mode=True)
|
||||
assert "enter_plan_mode" in prompt
|
||||
assert "Plan Mode (ACTIVE)" in prompt
|
||||
|
||||
|
||||
def test_enter_plan_mode_tool_returns_command() -> None:
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langchain_core.tools import tool as as_tool
|
||||
from langgraph.types import Command
|
||||
|
||||
from agent.tools.enter_plan_mode import enter_plan_mode
|
||||
|
||||
# Wrap as the agent does so the InjectedToolCallId is supplied from the call.
|
||||
wrapped = as_tool(enter_plan_mode)
|
||||
result = wrapped.invoke(
|
||||
{"name": "enter_plan_mode", "args": {}, "id": "call-1", "type": "tool_call"}
|
||||
)
|
||||
assert isinstance(result, Command)
|
||||
assert result.update["plan_mode"] is True
|
||||
messages = result.update["messages"]
|
||||
assert len(messages) == 1
|
||||
assert isinstance(messages[0], ToolMessage)
|
||||
assert messages[0].tool_call_id == "call-1"
|
||||
|
||||
|
||||
def test_enter_plan_mode_exported() -> None:
|
||||
from agent.tools import enter_plan_mode
|
||||
|
||||
assert callable(enter_plan_mode)
|
||||
|
||||
|
||||
def test_build_plan_approval_blocks_has_three_buttons() -> None:
|
||||
from agent.tools.slack_thread_reply import _build_plan_approval_blocks
|
||||
|
||||
blocks = _build_plan_approval_blocks("Here is my plan")
|
||||
assert len(blocks) == 2
|
||||
assert blocks[0]["type"] == "section"
|
||||
actions = blocks[1]
|
||||
assert actions["type"] == "actions"
|
||||
elements = actions["elements"]
|
||||
assert len(elements) == 3
|
||||
texts = [e["text"]["text"] for e in elements]
|
||||
assert "Approve & Implement" in texts
|
||||
assert "Revise Plan" in texts
|
||||
assert "Cancel" in texts
|
||||
|
||||
|
||||
def test_build_plan_approval_blocks_values_have_plan_approval_type() -> None:
|
||||
import json
|
||||
|
||||
from agent.tools.slack_thread_reply import _build_plan_approval_blocks
|
||||
|
||||
blocks = _build_plan_approval_blocks("plan text")
|
||||
for element in blocks[1]["elements"]:
|
||||
value = json.loads(element["value"])
|
||||
assert value["type"] == "plan_approval"
|
||||
assert value["action"] in ("approve", "revise", "cancel")
|
||||
156
tests/test_plan_review.py
Normal file
156
tests/test_plan_review.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_dashboard_plan_url_uses_plan_path(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://example.test")
|
||||
from agent.utils.dashboard_links import dashboard_plan_url
|
||||
|
||||
assert dashboard_plan_url("abc-123") == "https://example.test/agents/abc-123/plan"
|
||||
|
||||
|
||||
def test_dashboard_plan_url_none_without_thread() -> None:
|
||||
from agent.utils.dashboard_links import dashboard_plan_url
|
||||
|
||||
assert dashboard_plan_url("") is None
|
||||
|
||||
|
||||
def test_format_comments_with_quote_and_author() -> None:
|
||||
from agent.dashboard.plan_api import PlanComment, _format_comments
|
||||
|
||||
text = _format_comments(
|
||||
[
|
||||
PlanComment(author="alice", body="add a docstring", quote="def greet"),
|
||||
PlanComment(author="bob", body="looks good", resolved=True),
|
||||
PlanComment(author="carol", body=" "), # blank → skipped
|
||||
]
|
||||
)
|
||||
assert 'On "def greet"' in text
|
||||
assert "alice: add a docstring" in text
|
||||
assert "bob (resolved): looks good" in text
|
||||
assert "carol" not in text
|
||||
|
||||
|
||||
def test_format_comments_empty() -> None:
|
||||
from agent.dashboard.plan_api import _format_comments
|
||||
|
||||
assert _format_comments([]) == ""
|
||||
|
||||
|
||||
def test_plan_decision_body_defaults() -> None:
|
||||
from agent.dashboard.plan_api import PlanComment, PlanDecisionBody
|
||||
|
||||
body = PlanDecisionBody()
|
||||
assert body.comments == []
|
||||
comment = PlanComment(body="hi")
|
||||
assert comment.author is None
|
||||
assert comment.resolved is False
|
||||
|
||||
|
||||
def test_save_plan_requires_run_context() -> None:
|
||||
from agent.tools.save_plan import save_plan
|
||||
|
||||
# No LangGraph run context → no thread_id → graceful error, not a crash.
|
||||
result = save_plan("## Plan")
|
||||
assert result["success"] is False
|
||||
assert "thread_id" in result["error"]
|
||||
|
||||
|
||||
def test_save_plan_rejects_empty_markdown() -> None:
|
||||
from agent.tools.save_plan import save_plan
|
||||
|
||||
result = save_plan(" ")
|
||||
assert result["success"] is False
|
||||
assert "empty" in result["error"]
|
||||
|
||||
|
||||
def test_plan_routes_registered() -> None:
|
||||
from agent.webapp import app
|
||||
|
||||
paths = {getattr(route, "path", "") for route in app.routes}
|
||||
assert "/dashboard/api/plan/{thread_id}" in paths
|
||||
assert "/dashboard/api/plan/{thread_id}/approve" in paths
|
||||
assert "/dashboard/api/plan/{thread_id}/reject" in paths
|
||||
assert "/dashboard/api/plan/yjs/{thread_id}" in paths
|
||||
|
||||
|
||||
def test_save_plan_exported_and_wired() -> None:
|
||||
from agent.tools import save_plan
|
||||
|
||||
assert callable(save_plan)
|
||||
|
||||
|
||||
def test_plan_status_constants() -> None:
|
||||
from agent.dashboard import plan_store
|
||||
|
||||
assert plan_store.PLAN_STATUS_READY == "ready"
|
||||
assert plan_store.PLAN_STATUS_PLANNING == "planning"
|
||||
assert plan_store.PLAN_STATUS_APPROVED == "approved"
|
||||
assert plan_store.PLAN_STATUS_REVISING == "revising"
|
||||
|
||||
|
||||
def test_http_request_excluded_in_plan_mode() -> None:
|
||||
from agent.server import PLAN_MODE_EXCLUDED_TOOLS
|
||||
|
||||
assert "http_request" in PLAN_MODE_EXCLUDED_TOOLS
|
||||
|
||||
|
||||
class _FakeWS:
|
||||
def __init__(self, origin: str | None) -> None:
|
||||
self.headers = {"origin": origin} if origin is not None else {}
|
||||
|
||||
|
||||
def test_collab_origin_allowed_noop_without_allowlist(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from agent.dashboard.plan_collab import _origin_allowed
|
||||
|
||||
monkeypatch.delenv("DASHBOARD_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("DASHBOARD_ALLOWED_ORIGINS", raising=False)
|
||||
# No configured origins (local/dev): the gate is a no-op.
|
||||
assert _origin_allowed(_FakeWS(None)) is True
|
||||
assert _origin_allowed(_FakeWS("https://evil.test")) is True
|
||||
|
||||
|
||||
def test_collab_origin_allowed_enforced_with_allowlist(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from agent.dashboard.plan_collab import _origin_allowed
|
||||
|
||||
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://app.example")
|
||||
monkeypatch.delenv("DASHBOARD_ALLOWED_ORIGINS", raising=False)
|
||||
assert _origin_allowed(_FakeWS("https://app.example")) is True
|
||||
assert _origin_allowed(_FakeWS("https://evil.test")) is False
|
||||
assert _origin_allowed(_FakeWS(None)) is False
|
||||
|
||||
|
||||
class _FakeReq:
|
||||
def __init__(self, tools: list[Any], state: dict[str, Any]) -> None:
|
||||
self.tools = tools
|
||||
self.state = state
|
||||
|
||||
def override(self, **kw: Any) -> _FakeReq:
|
||||
return _FakeReq(kw.get("tools", self.tools), self.state)
|
||||
|
||||
|
||||
def _names(req: _FakeReq) -> set[str]:
|
||||
return {t["name"] for t in req.tools}
|
||||
|
||||
|
||||
def test_plan_mode_middleware_initial_always_filters() -> None:
|
||||
from agent.middleware import PlanModeMiddleware
|
||||
|
||||
mw = PlanModeMiddleware(excluded=frozenset({"write_file"}), initial=True)
|
||||
req = _FakeReq([{"name": "read_file"}, {"name": "write_file"}], {})
|
||||
assert _names(mw._filter(req)) == {"read_file"}
|
||||
|
||||
|
||||
def test_plan_mode_middleware_self_activation_via_state() -> None:
|
||||
from agent.middleware import PlanModeMiddleware
|
||||
|
||||
mw = PlanModeMiddleware(excluded=frozenset({"write_file"}), initial=False)
|
||||
# Plan mode not yet active: nothing filtered.
|
||||
off = _FakeReq([{"name": "read_file"}, {"name": "write_file"}], {})
|
||||
assert _names(mw._filter(off)) == {"read_file", "write_file"}
|
||||
# After enter_plan_mode sets state: the next request is filtered.
|
||||
on = _FakeReq([{"name": "read_file"}, {"name": "write_file"}], {"plan_mode": True})
|
||||
assert _names(mw._filter(on)) == {"read_file"}
|
||||
143
ui/bun.lock
143
ui/bun.lock
|
|
@ -6,6 +6,9 @@
|
|||
"name": "open-swe-dashboard",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.1",
|
||||
"@blocknote/core": "^0.51.4",
|
||||
"@blocknote/mantine": "^0.51.4",
|
||||
"@blocknote/react": "^0.51.4",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@langchain/core": "^1.1.48",
|
||||
"@langchain/langgraph-sdk": "^1.9.21",
|
||||
|
|
@ -38,6 +41,8 @@
|
|||
"tailwindcss": "^4.2.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"y-websocket": "^3.0.0",
|
||||
"yjs": "^13.6.27",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/devtools-vite": "^0.6.0",
|
||||
|
|
@ -52,7 +57,7 @@
|
|||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite": "^7.3.5",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^4.1.0",
|
||||
"web-vitals": "^5.1.0",
|
||||
|
|
@ -272,6 +277,12 @@
|
|||
|
||||
"@base-ui/utils": ["@base-ui/utils@0.2.9", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-x/PDDCYzoqPpjrdyb3VcyylTI2IjUXEtYDGi5foh7KsnmNJIIaVwA2GLgDH1dps1GgXiJbA60hM+AyuTfQzIvw=="],
|
||||
|
||||
"@blocknote/core": ["@blocknote/core@0.51.4", "", { "dependencies": { "@emoji-mart/data": "^1.2.1", "@handlewithcare/prosemirror-inputrules": "^0.1.4", "@shikijs/types": "^4", "@tanstack/store": "^0.7.7", "@tiptap/core": "^3.13.0", "@tiptap/extension-bold": "^3.13.0", "@tiptap/extension-code": "^3.13.0", "@tiptap/extension-horizontal-rule": "^3.13.0", "@tiptap/extension-italic": "^3.13.0", "@tiptap/extension-paragraph": "^3.13.0", "@tiptap/extension-strike": "^3.13.0", "@tiptap/extension-text": "^3.13.0", "@tiptap/extension-underline": "^3.13.0", "@tiptap/extensions": "^3.13.0", "@tiptap/pm": "^3.13.0", "emoji-mart": "^5.6.0", "fast-deep-equal": "^3.1.3", "lib0": "^0.2.99", "prosemirror-highlight": "^0.15.1", "prosemirror-model": "^1.25.4", "prosemirror-state": "^1.4.4", "prosemirror-tables": "^1.8.3", "prosemirror-transform": "^1.11.0", "prosemirror-view": "^1.41.4", "y-prosemirror": "^1.3.7", "y-protocols": "^1.0.6", "yjs": "^13.6.27" } }, "sha512-uR7/BlW6VVlCx/JzfGbkaLGz7O5uI3bu2tbNIdzGTfSloiEp8Qc14if36Dy7DDs+dVWfZgwds0jWiGfE3AzJiw=="],
|
||||
|
||||
"@blocknote/mantine": ["@blocknote/mantine@0.51.4", "", { "dependencies": { "@blocknote/core": "0.51.4", "@blocknote/react": "0.51.4", "react-icons": "^5.5.0" }, "peerDependencies": { "@mantine/core": "^8.3.11 || ^9.0.2", "@mantine/hooks": "^8.3.11 || ^9.0.2", "react": "^18.0 || ^19.0 || >= 19.0.0-rc", "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc" } }, "sha512-Ka7QjzhgB/T2ekwtXgwa9zIiiixAm4dob3FklJIedu+9j/eO4auokmRnWAhZQmNJsOdE7mQTUmnv6X4k/lHyCQ=="],
|
||||
|
||||
"@blocknote/react": ["@blocknote/react@0.51.4", "", { "dependencies": { "@blocknote/core": "0.51.4", "@emoji-mart/data": "^1.2.1", "@floating-ui/react": "^0.27.18", "@floating-ui/utils": "^0.2.10", "@tanstack/react-store": "0.7.7", "@tiptap/core": "^3.13.0", "@tiptap/pm": "^3.13.0", "@tiptap/react": "^3.13.0", "@types/use-sync-external-store": "1.5.0", "emoji-mart": "^5.6.0", "fast-deep-equal": "^3.1.3", "lodash.merge": "^4.6.2", "react-icons": "^5.5.0", "use-sync-external-store": "1.6.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || >= 19.0.0-rc", "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc" } }, "sha512-tWe039t/Gkj9YDoEXruclXQvI+toI0ctcyikbHa8dm3Q6zTgy24+T/P1t1fYSNPBkSnhq+ILz2fLLnCK994qkA=="],
|
||||
|
||||
"@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
|
||||
|
||||
"@cfworker/json-schema": ["@cfworker/json-schema@4.1.1", "", {}, "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="],
|
||||
|
|
@ -300,6 +311,8 @@
|
|||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
|
||||
"@emoji-mart/data": ["@emoji-mart/data@1.2.1", "", {}, "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
|
||||
|
|
@ -374,12 +387,16 @@
|
|||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
|
||||
|
||||
"@floating-ui/react": ["@floating-ui/react@0.27.19", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog=="],
|
||||
|
||||
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
|
||||
|
||||
"@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="],
|
||||
|
||||
"@handlewithcare/prosemirror-inputrules": ["@handlewithcare/prosemirror-inputrules@0.1.4", "", { "dependencies": { "prosemirror-history": "^1.4.1", "prosemirror-transform": "^1.0.0" }, "peerDependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-view": "^1.0.0" } }, "sha512-GMqlBeG2MKM+tXEFd2N+wIv5z4VvJTg8JtfJUrdjvFq2W6v+AW8oTgiWyFw8L3iEQwvtQcVJxU873iB0LXUNNw=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
|
||||
|
|
@ -418,6 +435,10 @@
|
|||
|
||||
"@langchain/react": ["@langchain/react@1.0.22", "", { "dependencies": { "@langchain/langgraph-sdk": "1.9.21" }, "peerDependencies": { "@langchain/core": "^1.1.48", "react": "^18 || ^19" } }, "sha512-e3llirWyH6RI6XoTwm7i9e7jiV3A6GDp50JCFKyo8XfZiufE6HiMzZHtpiMLkR/JezfdOTEHDyEC148C0gVB8A=="],
|
||||
|
||||
"@mantine/core": ["@mantine/core@8.3.18", "", { "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", "react-number-format": "^5.4.4", "react-remove-scroll": "^2.7.1", "react-textarea-autosize": "8.5.9", "type-fest": "^4.41.0" }, "peerDependencies": { "@mantine/hooks": "8.3.18", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-9tph1lTVogKPjTx02eUxDUOdXacPzK62UuSqb4TdGliI54/Xgxftq0Dfqu6XuhCxn9J5MDJaNiLDvL/1KRkYqA=="],
|
||||
|
||||
"@mantine/hooks": ["@mantine/hooks@8.3.18", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw=="],
|
||||
|
||||
"@mermaid-js/parser": ["@mermaid-js/parser@1.1.1", "", { "dependencies": { "@chevrotain/types": "~11.1.1" } }, "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||
|
|
@ -660,7 +681,7 @@
|
|||
|
||||
"@tanstack/react-start-server": ["@tanstack/react-start-server@1.167.19", "", { "dependencies": { "@tanstack/react-router": "1.170.15", "@tanstack/router-core": "1.171.13", "@tanstack/start-server-core": "1.169.14" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-+eMpAwDreQvCwgX45MdUHTUCF/Wad36+PwQafe6W5wa3qVkGyN3P131ShGyRwT/0WwKa5EVGdW1zFgwby8UNqA=="],
|
||||
|
||||
"@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="],
|
||||
"@tanstack/react-store": ["@tanstack/react-store@0.7.7", "", { "dependencies": { "@tanstack/store": "0.7.7", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg=="],
|
||||
|
||||
"@tanstack/router-core": ["@tanstack/router-core@1.171.13", "", { "dependencies": { "@tanstack/history": "1.162.0", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-+NOwEj1kO/6IGmpHRIZHasYxYWpyBQGNIZAST9aNrk9Q3YlU9SgqVnl1pbLa9qAKfeNdXQIRve0RQb/0kyDeDA=="],
|
||||
|
||||
|
|
@ -684,7 +705,7 @@
|
|||
|
||||
"@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.167.15", "", { "dependencies": { "@tanstack/router-core": "1.171.13" } }, "sha512-Jy0q4vdG6pv76N92+X+ag3fuOV2zINQagYyMN1/es7tPI1vzpKECIU8AqHqzI6ahkwaph7XDvmfUkiLJ3i4LOA=="],
|
||||
|
||||
"@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="],
|
||||
"@tanstack/store": ["@tanstack/store@0.7.7", "", {}, "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ=="],
|
||||
|
||||
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="],
|
||||
|
||||
|
|
@ -692,6 +713,34 @@
|
|||
|
||||
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
|
||||
|
||||
"@tiptap/core": ["@tiptap/core@3.27.1", "", { "peerDependencies": { "@tiptap/pm": "3.27.1" } }, "sha512-rV6Qn4wmC6BxfF+4mu6bqGWj9vA4oXXhsrpXaJL2uhjxeHAGofjwcHof2X84VYzeyXgdlsGmqKie4TAppVXZUQ=="],
|
||||
|
||||
"@tiptap/extension-bold": ["@tiptap/extension-bold@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-TlC5bsS+pqETTrlz4CZz9RO/cKBYtELGIxwtKeivUn3eNfnOxQbbu4WDsiwIfzRFyd0OMnKl6BPM2KnYEehoEQ=="],
|
||||
|
||||
"@tiptap/extension-bubble-menu": ["@tiptap/extension-bubble-menu@3.27.1", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1" } }, "sha512-j/j8Qp9Z5nViade2m7zjrO/CYH/Ca80Qj7aqo0eUaei6FZQ5izlF9o4XQU5EFMAutV6mwynsPUp8FVo5sCuYfw=="],
|
||||
|
||||
"@tiptap/extension-code": ["@tiptap/extension-code@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-epOUpFfEmBzjvnqvjv2qHX7NAuLo5dlOGV690lWu+sAYMjibuJBeVvAiKPyFCfRCCTUxdbDB3jbaOA1yEcEJ7w=="],
|
||||
|
||||
"@tiptap/extension-floating-menu": ["@tiptap/extension-floating-menu@3.27.1", "", { "peerDependencies": { "@floating-ui/dom": "^1.0.0", "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1" } }, "sha512-BmJF1VqB7dSJkgAalrpVFj88WLhxKjcWPuWHOqf2ITrUU2832BhKLXKmxjWUy1gqV8PfNNVWtGfIERy7I0y0+Q=="],
|
||||
|
||||
"@tiptap/extension-horizontal-rule": ["@tiptap/extension-horizontal-rule@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1" } }, "sha512-QlKE7qn5qMnIGVGhXQlvYedvLtNJ9z0dmit5w8vPb8tKzW4Spk6M7N2kruprrDA8GBwHfeR5wmF+njfUm34qxg=="],
|
||||
|
||||
"@tiptap/extension-italic": ["@tiptap/extension-italic@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-jGGeyn9uRUnNjSTHpbqhiGsp6KaYTSbV09jDXPJI9cDwfV9hpugLvpaCZd0BMBbhU1B1W6kOfX0BE15qX/HQfA=="],
|
||||
|
||||
"@tiptap/extension-paragraph": ["@tiptap/extension-paragraph@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-7K7eo1gruOgAsnbK+GCV23AUVUI0cL1bTig8HaPneoFMVbig7vddk8jNLKBWO8TXVbG7TuHdnDN4F98vdtwh5Q=="],
|
||||
|
||||
"@tiptap/extension-strike": ["@tiptap/extension-strike@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-Y3DW1jlSlCNCyMGHP3+3qBNNPS83wuFz4RTYGjZtvRRTCRh7apZme9XRWMq1rN5mJ2Cr7fKocA2/5Bs13KgN6Q=="],
|
||||
|
||||
"@tiptap/extension-text": ["@tiptap/extension-text@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-6ZwaZwSrDh+KFFv6V1J79oO37yPs7y1bFxvk1/9Ih2rn3Xr5AWz+eMS+n8RpH3djBVVAQpdIAeYQgcn+VCSsTg=="],
|
||||
|
||||
"@tiptap/extension-underline": ["@tiptap/extension-underline@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-N889J4nXN/TPfVt8uF9N1A0SY82E90zwc1y26lqOcw6KWNLmQrlhMh/9OD4ikLDbekmFpOBq/UicpHf/6S8hbQ=="],
|
||||
|
||||
"@tiptap/extensions": ["@tiptap/extensions@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1" } }, "sha512-1Tdx9faw8k0/83V6X+xCDVhV8yElGt95JxeW3YMkKQJI56QdlPz0xOdJPlMiSGJKinPyVier+x9LJD/YZUZIaw=="],
|
||||
|
||||
"@tiptap/pm": ["@tiptap/pm@3.27.1", "", { "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-commands": "^1.6.2", "prosemirror-dropcursor": "^1.8.1", "prosemirror-gapcursor": "^1.3.2", "prosemirror-history": "^1.4.1", "prosemirror-inputrules": "^1.4.0", "prosemirror-keymap": "^1.2.3", "prosemirror-model": "^1.25.7", "prosemirror-schema-list": "^1.5.0", "prosemirror-state": "^1.4.4", "prosemirror-tables": "^1.8.0", "prosemirror-transform": "^1.12.0", "prosemirror-view": "^1.41.8" } }, "sha512-Ffjx+vimmBU7zH/KrpXzJid3+pziCe/VL2aexSTP63cyQwKQ65LkFkCKaIsSpFdQQuakVZBGWjCA5RoBV852pw=="],
|
||||
|
||||
"@tiptap/react": ["@tiptap/react@3.27.1", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "fast-equals": "^5.3.3", "use-sync-external-store": "^1.4.0" }, "optionalDependencies": { "@tiptap/extension-bubble-menu": "^3.27.1", "@tiptap/extension-floating-menu": "^3.27.1" }, "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-/Wn2fc9zMtX08MXYScDFsm4wJ8lzfhfPEdbtls7WCDlbtrop48PWlkHDBBJrywARfAQTB2mFs9KiFy9yrQm5Lg=="],
|
||||
|
||||
"@trickfilm400/rollup-plugin-off-main-thread": ["@trickfilm400/rollup-plugin-off-main-thread@3.0.0-pre1", "", { "dependencies": { "ejs": "^3.1.10", "json5": "^2.2.3", "magic-string": "^0.30.21", "string.prototype.matchall": "^4.0.12" } }, "sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q=="],
|
||||
|
||||
"@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],
|
||||
|
|
@ -804,6 +853,8 @@
|
|||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@types/use-sync-external-store": ["@types/use-sync-external-store@1.5.0", "", {}, "sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw=="],
|
||||
|
||||
"@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.61.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/type-utils": "8.61.0", "@typescript-eslint/utils": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.61.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw=="],
|
||||
|
|
@ -1158,6 +1209,8 @@
|
|||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||
|
|
@ -1178,6 +1231,8 @@
|
|||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.371", "", {}, "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w=="],
|
||||
|
||||
"emoji-mart": ["emoji-mart@5.6.0", "", {}, "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
|
@ -1274,6 +1329,8 @@
|
|||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-equals": ["fast-equals@5.4.0", "", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="],
|
||||
|
||||
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
|
@ -1336,6 +1393,8 @@
|
|||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||
|
||||
"get-own-enumerable-keys": ["get-own-enumerable-keys@1.0.0", "", {}, "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA=="],
|
||||
|
||||
"get-own-enumerable-property-symbols": ["get-own-enumerable-property-symbols@3.0.2", "", {}, "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g=="],
|
||||
|
|
@ -1538,6 +1597,8 @@
|
|||
|
||||
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
||||
|
||||
"isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="],
|
||||
|
||||
"jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="],
|
||||
|
||||
"jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
|
||||
|
|
@ -1590,6 +1651,8 @@
|
|||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"lib0": ["lib0@0.2.117", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
|
@ -1622,6 +1685,8 @@
|
|||
|
||||
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
|
||||
|
||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||
|
||||
"lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="],
|
||||
|
||||
"log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
|
||||
|
|
@ -1818,6 +1883,8 @@
|
|||
|
||||
"ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="],
|
||||
|
||||
"orderedmap": ["orderedmap@2.1.1", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="],
|
||||
|
||||
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
|
||||
|
||||
"p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="],
|
||||
|
|
@ -1902,6 +1969,34 @@
|
|||
|
||||
"property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
|
||||
|
||||
"prosemirror-changeset": ["prosemirror-changeset@2.4.1", "", { "dependencies": { "prosemirror-transform": "^1.0.0" } }, "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw=="],
|
||||
|
||||
"prosemirror-commands": ["prosemirror-commands@1.7.1", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.10.2" } }, "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w=="],
|
||||
|
||||
"prosemirror-dropcursor": ["prosemirror-dropcursor@1.8.2", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0", "prosemirror-view": "^1.1.0" } }, "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw=="],
|
||||
|
||||
"prosemirror-gapcursor": ["prosemirror-gapcursor@1.4.1", "", { "dependencies": { "prosemirror-keymap": "^1.0.0", "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-view": "^1.0.0" } }, "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw=="],
|
||||
|
||||
"prosemirror-highlight": ["prosemirror-highlight@0.15.2", "", { "peerDependencies": { "@lezer/common": "^1.0.0", "@lezer/highlight": "^1.0.0", "@shikijs/types": "^1.29.2 || ^2.0.0 || ^3.0.0 || ^4.0.0", "@types/hast": "^3.0.0", "highlight.js": "^11.9.0", "lowlight": "^3.1.0", "prosemirror-model": "^1.19.3", "prosemirror-state": "^1.4.3", "prosemirror-transform": "^1.8.0", "prosemirror-view": "^1.32.4", "refractor": "^5.0.0", "sugar-high": "^0.6.1 || ^0.7.0 || ^0.8.0 || ^0.9.0 || ^1.0.0" }, "optionalPeers": ["@lezer/common", "@lezer/highlight", "@shikijs/types", "@types/hast", "highlight.js", "lowlight", "prosemirror-model", "prosemirror-state", "prosemirror-transform", "prosemirror-view", "refractor", "sugar-high"] }, "sha512-G5Wn4kn+MZMus/7vSh4ZNsC+3yrMpjKsgV6QKKYfwKOPMknRX+3H6FWr+E+Qji4xhmkqdXiToG1VTQ2bZn5b0w=="],
|
||||
|
||||
"prosemirror-history": ["prosemirror-history@1.5.0", "", { "dependencies": { "prosemirror-state": "^1.2.2", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.31.0", "rope-sequence": "^1.3.0" } }, "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg=="],
|
||||
|
||||
"prosemirror-inputrules": ["prosemirror-inputrules@1.5.1", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.0.0" } }, "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw=="],
|
||||
|
||||
"prosemirror-keymap": ["prosemirror-keymap@1.2.3", "", { "dependencies": { "prosemirror-state": "^1.0.0", "w3c-keyname": "^2.2.0" } }, "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw=="],
|
||||
|
||||
"prosemirror-model": ["prosemirror-model@1.25.9", "", { "dependencies": { "orderedmap": "^2.0.0" } }, "sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA=="],
|
||||
|
||||
"prosemirror-schema-list": ["prosemirror-schema-list@1.5.1", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.7.3" } }, "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q=="],
|
||||
|
||||
"prosemirror-state": ["prosemirror-state@1.4.4", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.27.0" } }, "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw=="],
|
||||
|
||||
"prosemirror-tables": ["prosemirror-tables@1.8.5", "", { "dependencies": { "prosemirror-keymap": "^1.2.3", "prosemirror-model": "^1.25.4", "prosemirror-state": "^1.4.4", "prosemirror-transform": "^1.10.5", "prosemirror-view": "^1.41.4" } }, "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw=="],
|
||||
|
||||
"prosemirror-transform": ["prosemirror-transform@1.12.0", "", { "dependencies": { "prosemirror-model": "^1.21.0" } }, "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w=="],
|
||||
|
||||
"prosemirror-view": ["prosemirror-view@1.41.9", "", { "dependencies": { "prosemirror-model": "^1.25.8", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, "sha512-clTunTX+eaLbr87L1V1QPheRlEQJyTlL3gXe9x3jQIk3rL0RVWxviDGz8tFaydwIVm+hKhYCyr+R/zBtWr9s6A=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
|
@ -1922,8 +2017,18 @@
|
|||
|
||||
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
||||
|
||||
"react-number-format": ["react-number-format@5.4.5", "", { "peerDependencies": { "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
|
||||
|
||||
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
|
||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
|
||||
"react-textarea-autosize": ["react-textarea-autosize@8.5.9", "", { "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A=="],
|
||||
|
||||
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
|
||||
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
|
||||
|
|
@ -1984,6 +2089,8 @@
|
|||
|
||||
"rollup": ["rollup@4.61.1", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.61.1", "@rollup/rollup-android-arm64": "4.61.1", "@rollup/rollup-darwin-arm64": "4.61.1", "@rollup/rollup-darwin-x64": "4.61.1", "@rollup/rollup-freebsd-arm64": "4.61.1", "@rollup/rollup-freebsd-x64": "4.61.1", "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", "@rollup/rollup-linux-arm-musleabihf": "4.61.1", "@rollup/rollup-linux-arm64-gnu": "4.61.1", "@rollup/rollup-linux-arm64-musl": "4.61.1", "@rollup/rollup-linux-loong64-gnu": "4.61.1", "@rollup/rollup-linux-loong64-musl": "4.61.1", "@rollup/rollup-linux-ppc64-gnu": "4.61.1", "@rollup/rollup-linux-ppc64-musl": "4.61.1", "@rollup/rollup-linux-riscv64-gnu": "4.61.1", "@rollup/rollup-linux-riscv64-musl": "4.61.1", "@rollup/rollup-linux-s390x-gnu": "4.61.1", "@rollup/rollup-linux-x64-gnu": "4.61.1", "@rollup/rollup-linux-x64-musl": "4.61.1", "@rollup/rollup-openbsd-x64": "4.61.1", "@rollup/rollup-openharmony-arm64": "4.61.1", "@rollup/rollup-win32-arm64-msvc": "4.61.1", "@rollup/rollup-win32-ia32-msvc": "4.61.1", "@rollup/rollup-win32-x64-gnu": "4.61.1", "@rollup/rollup-win32-x64-msvc": "4.61.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA=="],
|
||||
|
||||
"rope-sequence": ["rope-sequence@1.3.4", "", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="],
|
||||
|
||||
"rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="],
|
||||
|
||||
"roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="],
|
||||
|
|
@ -2114,6 +2221,8 @@
|
|||
|
||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||
|
||||
"tabbable": ["tabbable@6.5.0", "", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
|
||||
|
|
@ -2234,6 +2343,16 @@
|
|||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||
|
||||
"use-composed-ref": ["use-composed-ref@1.4.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w=="],
|
||||
|
||||
"use-isomorphic-layout-effect": ["use-isomorphic-layout-effect@1.2.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA=="],
|
||||
|
||||
"use-latest": ["use-latest@1.3.0", "", { "dependencies": { "use-isomorphic-layout-effect": "^1.1.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ=="],
|
||||
|
||||
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
|
@ -2262,6 +2381,8 @@
|
|||
|
||||
"vue-eslint-parser": ["vue-eslint-parser@10.4.1", "", { "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0 || ^9.0.0", "eslint-visitor-keys": "^4.2.0 || ^5.0.0", "espree": "^10.3.0 || ^11.0.0", "esquery": "^1.6.0", "semver": "^7.6.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" } }, "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA=="],
|
||||
|
||||
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
|
||||
|
||||
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
|
||||
|
||||
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
|
||||
|
|
@ -2336,8 +2457,16 @@
|
|||
|
||||
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
|
||||
|
||||
"y-prosemirror": ["y-prosemirror@1.3.7", "", { "dependencies": { "lib0": "^0.2.109" }, "peerDependencies": { "prosemirror-model": "^1.7.1", "prosemirror-state": "^1.2.3", "prosemirror-view": "^1.9.10", "y-protocols": "^1.0.1", "yjs": "^13.5.38" } }, "sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg=="],
|
||||
|
||||
"y-protocols": ["y-protocols@1.0.7", "", { "dependencies": { "lib0": "^0.2.85" }, "peerDependencies": { "yjs": "^13.0.0" } }, "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw=="],
|
||||
|
||||
"y-websocket": ["y-websocket@3.0.0", "", { "dependencies": { "lib0": "^0.2.102", "y-protocols": "^1.0.5" }, "peerDependencies": { "yjs": "^13.5.6" } }, "sha512-mUHy7AzkOZ834T/7piqtlA8Yk6AchqKqcrCXjKW8J1w2lPtRDjz8W5/CvXz9higKAHgKRKqpI3T33YkRFLkPtg=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"yjs": ["yjs@13.6.31", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"yocto-spinner": ["yocto-spinner@1.2.0", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw=="],
|
||||
|
|
@ -2360,6 +2489,8 @@
|
|||
|
||||
"@langchain/langgraph-sdk/p-queue": ["p-queue@9.3.0", "", { "dependencies": { "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" } }, "sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang=="],
|
||||
|
||||
"@mantine/core/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
|
||||
"@pierre/diffs/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="],
|
||||
|
||||
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
|
@ -2380,12 +2511,16 @@
|
|||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@tanstack/react-router/@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="],
|
||||
|
||||
"@tanstack/router-utils/diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
|
||||
|
||||
"@tanstack/start-plugin-core/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
|
||||
|
||||
"@tanstack/start-plugin-core/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"@tiptap/react/@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="],
|
||||
|
|
@ -2508,6 +2643,8 @@
|
|||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
|
||||
"@tanstack/react-router/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="],
|
||||
|
||||
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.1",
|
||||
"@blocknote/core": "^0.51.4",
|
||||
"@blocknote/mantine": "^0.51.4",
|
||||
"@blocknote/react": "^0.51.4",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@langchain/core": "^1.1.48",
|
||||
"@langchain/langgraph-sdk": "^1.9.21",
|
||||
|
|
@ -44,7 +47,9 @@
|
|||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"y-websocket": "^3.0.0",
|
||||
"yjs": "^13.6.27"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/devtools-vite": "^0.6.0",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useMemo, useState } from "react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { useStreamContext as useAgentThreadStream } from "@langchain/react"
|
||||
import { Map as MapIcon } from "lucide-react"
|
||||
|
||||
import type { AgentThread, Message } from "@/lib/agents/types"
|
||||
import type { ModelSelection } from "@/lib/agents/provider/useModelOptions"
|
||||
|
|
@ -36,9 +38,15 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
|||
}, [models, thread.model, thread.effort])
|
||||
const [selection, setSelection] = useState<ModelSelection | null>(null)
|
||||
const activeSelection = selection ?? threadSelection ?? defaultSelection
|
||||
const [planMode, setPlanMode] = useState<boolean | null>(null)
|
||||
const activePlanMode = planMode ?? thread.planMode ?? false
|
||||
|
||||
const baseMessages = useMemo<Array<Message>>(() => {
|
||||
const live = streamMessagesToUi(stream.messages, stream.toolCalls, stream.subagents)
|
||||
const live = streamMessagesToUi(
|
||||
stream.messages,
|
||||
stream.toolCalls,
|
||||
stream.subagents
|
||||
)
|
||||
if (live.length > 0) return live
|
||||
// Optimistic transcript seeded by `AgentsHome` on thread creation (the
|
||||
// only case where a fetched thread carries messages — `getThread` returns
|
||||
|
|
@ -46,12 +54,7 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
|||
// lands in `stream.messages`.
|
||||
if (thread.messages.length > 0) return thread.messages
|
||||
return live
|
||||
}, [
|
||||
stream.messages,
|
||||
stream.toolCalls,
|
||||
stream.subagents,
|
||||
thread.messages,
|
||||
])
|
||||
}, [stream.messages, stream.toolCalls, stream.subagents, thread.messages])
|
||||
|
||||
const hasMessages = baseMessages.length > 0
|
||||
const isStreaming = thread.status === "running" || stream.isLoading
|
||||
|
|
@ -67,6 +70,26 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
|||
className="flex min-w-0 flex-1 flex-col"
|
||||
style={isMobile ? undefined : { minWidth: PANEL_MIN_CHAT_WIDTH }}
|
||||
>
|
||||
{thread.planStatus &&
|
||||
thread.planStatus !== "approved" &&
|
||||
thread.planStatus !== "cancelled" && (
|
||||
<Link
|
||||
to="/agents/$threadId/plan"
|
||||
params={{ threadId: thread.id }}
|
||||
data-testid="review-plan-link"
|
||||
className="flex items-center justify-between gap-2 border-b border-[var(--ui-border)] bg-[var(--ui-panel)] px-4 py-2 text-xs text-[var(--ui-text)] hover:bg-[var(--ui-panel-2)]"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<MapIcon className="size-3.5 text-[var(--ui-accent)]" />
|
||||
{thread.planStatus === "revising"
|
||||
? "The agent is revising the plan."
|
||||
: "A plan is ready for your review."}
|
||||
</span>
|
||||
<span className="font-medium text-[var(--ui-accent)]">
|
||||
Review plan →
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
{hasMessages ? (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<Messages
|
||||
|
|
@ -89,11 +112,14 @@ export function AgentThreadView({ thread }: AgentThreadViewProps) {
|
|||
images,
|
||||
model_id: activeSelection?.modelId ?? null,
|
||||
effort: activeSelection?.effort ?? null,
|
||||
plan_mode: activePlanMode,
|
||||
})
|
||||
}
|
||||
models={models}
|
||||
selection={activeSelection}
|
||||
onSelectionChange={setSelection}
|
||||
planMode={activePlanMode}
|
||||
onPlanModeChange={setPlanMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export function AgentsHome() {
|
|||
const { models, defaultSelection } = useModelOptions()
|
||||
const [selection, setSelection] = useState<ModelSelection | null>(null)
|
||||
const activeSelection = selection ?? defaultSelection
|
||||
const [planMode, setPlanMode] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const reposQuery = useRepos()
|
||||
|
|
@ -94,6 +95,7 @@ export function AgentsHome() {
|
|||
}
|
||||
if (repo) configurable.repo = repo
|
||||
if (repoOverride === null) configurable.repo_explicitly_none = true
|
||||
if (planMode) configurable.plan_mode = true
|
||||
|
||||
stream
|
||||
.submit(
|
||||
|
|
@ -125,6 +127,8 @@ export function AgentsHome() {
|
|||
repos={reposQuery.data?.repositories}
|
||||
selectedRepo={repo}
|
||||
onRepoChange={setRepoOverride}
|
||||
planMode={planMode}
|
||||
onPlanModeChange={setPlanMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
256
ui/src/components/agents/PlanReview.tsx
Normal file
256
ui/src/components/agents/PlanReview.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,11 @@
|
|||
import { ArrowUp, ChevronDown, ImagePlus, LoaderCircle, X } from "lucide-react"
|
||||
import {
|
||||
ArrowUp,
|
||||
ChevronDown,
|
||||
ImagePlus,
|
||||
LoaderCircle,
|
||||
Map as MapIcon,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { StopIcon } from "@phosphor-icons/react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { useStreamContext as useAgentThreadStream } from "@langchain/react"
|
||||
|
|
@ -119,6 +126,9 @@ export interface CloudPromptBarProps {
|
|||
repos?: Array<{ full_name: string }>
|
||||
selectedRepo?: string | null
|
||||
onRepoChange?: (repo: string | null) => void
|
||||
/** When provided, a Plan mode toggle is shown. Plan mode researches read-only and proposes a plan before editing. */
|
||||
planMode?: boolean
|
||||
onPlanModeChange?: (next: boolean) => void
|
||||
}
|
||||
|
||||
function fileToImageChunk(file: File): Promise<ImageChunk | null> {
|
||||
|
|
@ -160,6 +170,8 @@ export const CloudPromptBar = memo(function CloudPromptBarComponent({
|
|||
repos,
|
||||
selectedRepo = null,
|
||||
onRepoChange,
|
||||
planMode = false,
|
||||
onPlanModeChange,
|
||||
}: CloudPromptBarProps) {
|
||||
const [value, setValue] = useState("")
|
||||
const [pendingImages, setPendingImages] = useState<Array<ImageChunk>>([])
|
||||
|
|
@ -320,6 +332,10 @@ export const CloudPromptBar = memo(function CloudPromptBarComponent({
|
|||
e.preventDefault()
|
||||
void handleSubmit()
|
||||
}
|
||||
if (e.key === "Tab" && e.shiftKey && onPlanModeChange) {
|
||||
e.preventDefault()
|
||||
onPlanModeChange(!planMode)
|
||||
}
|
||||
}
|
||||
|
||||
const pickerDisabled = combos.length === 0 || !onSelectionChange
|
||||
|
|
@ -469,6 +485,24 @@ export const CloudPromptBar = memo(function CloudPromptBarComponent({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{onPlanModeChange && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPlanModeChange(!planMode)}
|
||||
aria-pressed={planMode}
|
||||
title="Plan mode: research read-only and propose a plan before editing (Shift+Tab)"
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1 rounded-full border px-2 py-1 text-[12px] transition-colors",
|
||||
planMode
|
||||
? "border-[var(--ui-accent)] bg-[var(--ui-accent)]/10 text-[color:var(--ui-accent)]"
|
||||
: "border-[var(--ui-border)] text-[color:var(--ui-text-muted)] hover:bg-[var(--ui-panel-2)] hover:text-[color:var(--ui-text)]"
|
||||
)}
|
||||
>
|
||||
<MapIcon className="size-3.5" />
|
||||
<span>Plan</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export interface ThreadMessageRequest {
|
|||
images?: Array<ImageChunk>
|
||||
model_id?: string | null
|
||||
effort?: string | null
|
||||
plan_mode?: boolean
|
||||
}
|
||||
|
||||
export interface ScheduleCreateRequest {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export function useSubmitAgentMessage(threadId: string) {
|
|||
images: vars.images,
|
||||
model_id: vars.model_id,
|
||||
effort: vars.effort,
|
||||
plan_mode: vars.plan_mode,
|
||||
});
|
||||
|
||||
if (stream.isLoading) {
|
||||
|
|
@ -64,14 +65,16 @@ export function useSubmitAgentMessage(threadId: string) {
|
|||
}
|
||||
}
|
||||
|
||||
const config = (!vars.model_id || !vars.effort)
|
||||
? undefined
|
||||
: {
|
||||
configurable: {
|
||||
agent_model_id: vars.model_id,
|
||||
agent_effort: vars.effort,
|
||||
},
|
||||
};
|
||||
const configurable: Record<string, unknown> = {};
|
||||
if (vars.model_id && vars.effort) {
|
||||
configurable.agent_model_id = vars.model_id;
|
||||
configurable.agent_effort = vars.effort;
|
||||
}
|
||||
if (vars.plan_mode) {
|
||||
configurable.plan_mode = true;
|
||||
}
|
||||
const config =
|
||||
Object.keys(configurable).length > 0 ? { configurable } : undefined;
|
||||
|
||||
// Don't await: `stream.submit` resolves only when the run *finishes*, so
|
||||
// awaiting would keep the mutation `isPending` (and the prompt bar
|
||||
|
|
|
|||
|
|
@ -225,6 +225,7 @@ export interface SendAgentMessageVariables {
|
|||
images?: Array<ImageChunk>
|
||||
model_id?: string | null
|
||||
effort?: string | null
|
||||
plan_mode?: boolean
|
||||
}
|
||||
|
||||
export function useCancelAgentThread(threadId: string) {
|
||||
|
|
|
|||
|
|
@ -180,6 +180,8 @@ export interface AgentThread {
|
|||
branch: string
|
||||
model: string
|
||||
effort?: string | null
|
||||
planMode?: boolean
|
||||
planStatus?: string | null
|
||||
source?: AgentSource
|
||||
status: AgentStatus
|
||||
viewed: boolean
|
||||
|
|
|
|||
111
ui/src/lib/plan.ts
Normal file
111
ui/src/lib/plan.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* Client for the plan-review API + the Yjs collaboration endpoint.
|
||||
*
|
||||
* The plan document and its comment threads sync over a y-websocket connection;
|
||||
* approve/reject post the client-harvested comments so the agent receives them
|
||||
* as the instruction for the follow-up run.
|
||||
*/
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_DASHBOARD_API_BASE_URL ?? "").replace(
|
||||
/\/$/,
|
||||
""
|
||||
)
|
||||
|
||||
function apiBase(): string {
|
||||
if (API_BASE) return API_BASE
|
||||
return typeof window !== "undefined" ? window.location.origin : ""
|
||||
}
|
||||
|
||||
export interface PlanUser {
|
||||
id: string
|
||||
login: string
|
||||
email: string | null
|
||||
name: string
|
||||
}
|
||||
|
||||
export type PlanStatus =
|
||||
| "planning"
|
||||
| "ready"
|
||||
| "revising"
|
||||
| "approved"
|
||||
| "cancelled"
|
||||
|
||||
export interface PlanData {
|
||||
threadId: string
|
||||
status: PlanStatus
|
||||
markdown: string
|
||||
isOwner: boolean
|
||||
user: PlanUser
|
||||
}
|
||||
|
||||
export interface HarvestedComment {
|
||||
author: string
|
||||
body: string
|
||||
quote?: string
|
||||
resolved: boolean
|
||||
}
|
||||
|
||||
export class PlanApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = "PlanApiError"
|
||||
}
|
||||
}
|
||||
|
||||
async function req<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`${apiBase()}/dashboard/api${path}`, {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...(init.headers ?? {}) },
|
||||
})
|
||||
if (!res.ok) {
|
||||
let message = res.statusText
|
||||
try {
|
||||
const body = await res.json()
|
||||
if (body?.detail)
|
||||
message =
|
||||
typeof body.detail === "string"
|
||||
? body.detail
|
||||
: JSON.stringify(body.detail)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new PlanApiError(res.status, message)
|
||||
}
|
||||
if (res.status === 204) return undefined as T
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
export function getPlan(threadId: string): Promise<PlanData> {
|
||||
return req<PlanData>(`/plan/${encodeURIComponent(threadId)}`)
|
||||
}
|
||||
|
||||
export function approvePlan(
|
||||
threadId: string,
|
||||
comments: Array<HarvestedComment>
|
||||
): Promise<{ status: string }> {
|
||||
return req(`/plan/${encodeURIComponent(threadId)}/approve`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ comments }),
|
||||
})
|
||||
}
|
||||
|
||||
export function rejectPlan(
|
||||
threadId: string,
|
||||
comments: Array<HarvestedComment>
|
||||
): Promise<{ status: string }> {
|
||||
return req(`/plan/${encodeURIComponent(threadId)}/reject`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ comments }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Base URL the y-websocket provider connects to; it appends `/<threadId>`. */
|
||||
export function planCollabUrl(): string {
|
||||
const base = apiBase()
|
||||
const wsBase = base.replace(/^http/, "ws")
|
||||
return `${wsBase}/dashboard/api/plan/yjs`
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ import { Route as AgentsAutomationsIndexRouteImport } from './routes/agents/auto
|
|||
import { Route as ReviewRepositoriesOwnerRouteImport } from './routes/review_.repositories.$owner'
|
||||
import { Route as AgentsAutomationsNewRouteImport } from './routes/agents/automations/new'
|
||||
import { Route as AgentsAutomationsScheduleIdRouteImport } from './routes/agents/automations/$scheduleId'
|
||||
import { Route as AgentsThreadIdPlanRouteImport } from './routes/agents/$threadId_.plan'
|
||||
import { Route as AgentsReviewsOwnerRepoNumberRouteImport } from './routes/agents/reviews/$owner.$repo.$number'
|
||||
|
||||
const UsageRoute = UsageRouteImport.update({
|
||||
|
|
@ -132,6 +133,11 @@ const AgentsAutomationsScheduleIdRoute =
|
|||
path: '/automations/$scheduleId',
|
||||
getParentRoute: () => AgentsRoute,
|
||||
} as any)
|
||||
const AgentsThreadIdPlanRoute = AgentsThreadIdPlanRouteImport.update({
|
||||
id: '/$threadId_/plan',
|
||||
path: '/$threadId/plan',
|
||||
getParentRoute: () => AgentsRoute,
|
||||
} as any)
|
||||
const AgentsReviewsOwnerRepoNumberRoute =
|
||||
AgentsReviewsOwnerRepoNumberRouteImport.update({
|
||||
id: '/reviews/$owner/$repo/$number',
|
||||
|
|
@ -155,6 +161,7 @@ export interface FileRoutesByFullPath {
|
|||
'/agents/instructions': typeof AgentsInstructionsRoute
|
||||
'/review/styles': typeof ReviewStylesRoute
|
||||
'/agents/': typeof AgentsIndexRoute
|
||||
'/agents/$threadId/plan': typeof AgentsThreadIdPlanRoute
|
||||
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
|
||||
'/agents/automations/new': typeof AgentsAutomationsNewRoute
|
||||
'/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
|
||||
|
|
@ -177,6 +184,7 @@ export interface FileRoutesByTo {
|
|||
'/agents/instructions': typeof AgentsInstructionsRoute
|
||||
'/review/styles': typeof ReviewStylesRoute
|
||||
'/agents': typeof AgentsIndexRoute
|
||||
'/agents/$threadId/plan': typeof AgentsThreadIdPlanRoute
|
||||
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
|
||||
'/agents/automations/new': typeof AgentsAutomationsNewRoute
|
||||
'/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
|
||||
|
|
@ -201,6 +209,7 @@ export interface FileRoutesById {
|
|||
'/agents_/instructions': typeof AgentsInstructionsRoute
|
||||
'/review_/styles': typeof ReviewStylesRoute
|
||||
'/agents/': typeof AgentsIndexRoute
|
||||
'/agents/$threadId_/plan': typeof AgentsThreadIdPlanRoute
|
||||
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
|
||||
'/agents/automations/new': typeof AgentsAutomationsNewRoute
|
||||
'/review_/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
|
||||
|
|
@ -226,6 +235,7 @@ export interface FileRouteTypes {
|
|||
| '/agents/instructions'
|
||||
| '/review/styles'
|
||||
| '/agents/'
|
||||
| '/agents/$threadId/plan'
|
||||
| '/agents/automations/$scheduleId'
|
||||
| '/agents/automations/new'
|
||||
| '/review/repositories/$owner'
|
||||
|
|
@ -248,6 +258,7 @@ export interface FileRouteTypes {
|
|||
| '/agents/instructions'
|
||||
| '/review/styles'
|
||||
| '/agents'
|
||||
| '/agents/$threadId/plan'
|
||||
| '/agents/automations/$scheduleId'
|
||||
| '/agents/automations/new'
|
||||
| '/review/repositories/$owner'
|
||||
|
|
@ -271,6 +282,7 @@ export interface FileRouteTypes {
|
|||
| '/agents_/instructions'
|
||||
| '/review_/styles'
|
||||
| '/agents/'
|
||||
| '/agents/$threadId_/plan'
|
||||
| '/agents/automations/$scheduleId'
|
||||
| '/agents/automations/new'
|
||||
| '/review_/repositories/$owner'
|
||||
|
|
@ -437,6 +449,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof AgentsAutomationsScheduleIdRouteImport
|
||||
parentRoute: typeof AgentsRoute
|
||||
}
|
||||
'/agents/$threadId_/plan': {
|
||||
id: '/agents/$threadId_/plan'
|
||||
path: '/$threadId/plan'
|
||||
fullPath: '/agents/$threadId/plan'
|
||||
preLoaderRoute: typeof AgentsThreadIdPlanRouteImport
|
||||
parentRoute: typeof AgentsRoute
|
||||
}
|
||||
'/agents/reviews/$owner/$repo/$number': {
|
||||
id: '/agents/reviews/$owner/$repo/$number'
|
||||
path: '/reviews/$owner/$repo/$number'
|
||||
|
|
@ -451,6 +470,7 @@ interface AgentsRouteChildren {
|
|||
AgentsThreadIdRoute: typeof AgentsThreadIdRoute
|
||||
AgentsThreadsRoute: typeof AgentsThreadsRoute
|
||||
AgentsIndexRoute: typeof AgentsIndexRoute
|
||||
AgentsThreadIdPlanRoute: typeof AgentsThreadIdPlanRoute
|
||||
AgentsAutomationsScheduleIdRoute: typeof AgentsAutomationsScheduleIdRoute
|
||||
AgentsAutomationsNewRoute: typeof AgentsAutomationsNewRoute
|
||||
AgentsAutomationsIndexRoute: typeof AgentsAutomationsIndexRoute
|
||||
|
|
@ -462,6 +482,7 @@ const AgentsRouteChildren: AgentsRouteChildren = {
|
|||
AgentsThreadIdRoute: AgentsThreadIdRoute,
|
||||
AgentsThreadsRoute: AgentsThreadsRoute,
|
||||
AgentsIndexRoute: AgentsIndexRoute,
|
||||
AgentsThreadIdPlanRoute: AgentsThreadIdPlanRoute,
|
||||
AgentsAutomationsScheduleIdRoute: AgentsAutomationsScheduleIdRoute,
|
||||
AgentsAutomationsNewRoute: AgentsAutomationsNewRoute,
|
||||
AgentsAutomationsIndexRoute: AgentsAutomationsIndexRoute,
|
||||
|
|
|
|||
109
ui/src/routes/agents/$threadId_.plan.tsx
Normal file
109
ui/src/routes/agents/$threadId_.plan.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { Link, createFileRoute } from "@tanstack/react-router"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useEffect, useState } from "react"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
import { PlanReview } from "@/components/agents/PlanReview"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { PlanApiError, getPlan } from "@/lib/plan"
|
||||
|
||||
export const Route = createFileRoute("/agents/$threadId_/plan")({
|
||||
component: PlanPage,
|
||||
})
|
||||
|
||||
function Centered({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center p-6">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BackLink({ threadId }: { threadId: string }) {
|
||||
return (
|
||||
<Link
|
||||
to="/agents/$threadId"
|
||||
params={{ threadId }}
|
||||
className="inline-flex items-center gap-1 text-xs text-[var(--ui-text-dim)] hover:text-[var(--ui-text)]"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
Back to conversation
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function PlanPage() {
|
||||
const { threadId } = Route.useParams()
|
||||
|
||||
// BlockNote + Yjs are browser-only; mount client-side before rendering.
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => setMounted(true), [])
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["plan", threadId],
|
||||
queryFn: () => getPlan(threadId),
|
||||
// The agent shares the link as soon as it enters plan mode, before the plan
|
||||
// is written — poll until the plan content is published.
|
||||
refetchInterval: (q) => (q.state.data?.markdown ? false : 2000),
|
||||
retry: (count, error) =>
|
||||
!(
|
||||
error instanceof PlanApiError &&
|
||||
(error.status === 401 || error.status === 404)
|
||||
) && count < 3,
|
||||
})
|
||||
|
||||
if (!mounted || query.isLoading) {
|
||||
return (
|
||||
<Centered>
|
||||
<Skeleton className="h-48 w-full max-w-2xl" />
|
||||
</Centered>
|
||||
)
|
||||
}
|
||||
|
||||
if (query.isError) {
|
||||
const status = query.error instanceof PlanApiError ? query.error.status : 0
|
||||
return (
|
||||
<Centered>
|
||||
<div className="space-y-3 text-center text-sm text-[var(--ui-text-dim)]">
|
||||
<p>
|
||||
{status === 401
|
||||
? "Please sign in to view this plan."
|
||||
: "This plan could not be found."}
|
||||
</p>
|
||||
<BackLink threadId={threadId} />
|
||||
</div>
|
||||
</Centered>
|
||||
)
|
||||
}
|
||||
|
||||
const plan = query.data
|
||||
if (!plan) {
|
||||
return (
|
||||
<Centered>
|
||||
<Skeleton className="h-48 w-full max-w-2xl" />
|
||||
</Centered>
|
||||
)
|
||||
}
|
||||
if (!plan.markdown.trim()) {
|
||||
return (
|
||||
<Centered>
|
||||
<div className="space-y-3 text-center text-sm text-[var(--ui-text-dim)]">
|
||||
<p>
|
||||
The agent is still writing the plan. This page will update
|
||||
automatically…
|
||||
</p>
|
||||
<BackLink threadId={threadId} />
|
||||
</div>
|
||||
</Centered>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="border-b border-[var(--ui-border)] px-6 pt-3">
|
||||
<BackLink threadId={threadId} />
|
||||
</div>
|
||||
<PlanReview plan={plan} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { defineConfig } from "vite"
|
||||
import http from "node:http"
|
||||
import { defineConfig, type Plugin } from "vite"
|
||||
import { devtools } from "@tanstack/devtools-vite"
|
||||
import { tanstackStart } from "@tanstack/react-start/plugin/vite"
|
||||
import viteReact from "@vitejs/plugin-react"
|
||||
|
|
@ -7,10 +8,77 @@ import tailwindcss from "@tailwindcss/vite"
|
|||
import { nitro } from "nitro/vite"
|
||||
import { VitePWA } from "vite-plugin-pwa"
|
||||
|
||||
// Dev-only: when E2E_HARNESS is set (the `dev:mock` local harness) serve the app
|
||||
// and the harness from one origin by proxying the API routes + the Yjs collab
|
||||
// WebSocket to the harness. Same-origin keeps the session cookie on the WS, which
|
||||
// the plan-review collab requires. Inert in production (E2E_HARNESS unset).
|
||||
function mockHarnessProxy(): Plugin | null {
|
||||
const target = process.env.E2E_HARNESS
|
||||
if (!target) return null
|
||||
const prefixes = [
|
||||
"/dashboard/api",
|
||||
"/webhooks",
|
||||
"/mock",
|
||||
"/control",
|
||||
"/fake-gh",
|
||||
"/fake-slack",
|
||||
"/static",
|
||||
"/ok",
|
||||
]
|
||||
const matches = (url?: string): boolean =>
|
||||
!!url &&
|
||||
prefixes.some(
|
||||
(p) => url === p || url.startsWith(`${p}/`) || url.startsWith(`${p}?`)
|
||||
)
|
||||
const upstream = new URL(target)
|
||||
return {
|
||||
name: "mock-harness-proxy",
|
||||
enforce: "pre",
|
||||
async configureServer(server) {
|
||||
const { createProxyServer } = await import("httpxy")
|
||||
const proxy = createProxyServer({ target })
|
||||
proxy.on("error", () => {})
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (matches(req.url)) void proxy.web(req, res).catch(() => {})
|
||||
else next()
|
||||
})
|
||||
// Proxy the Yjs WebSocket by hand (httpxy's ws upgrade is unreliable here).
|
||||
// Only claim our paths; Vite's own HMR socket upgrade is left untouched.
|
||||
server.httpServer?.on("upgrade", (req, socket, head) => {
|
||||
if (!matches(req.url)) return
|
||||
const proxyReq = http.request({
|
||||
host: upstream.hostname,
|
||||
port: upstream.port,
|
||||
method: "GET",
|
||||
path: req.url,
|
||||
headers: req.headers,
|
||||
})
|
||||
proxyReq.on("upgrade", (proxyRes, proxySocket, proxyHead) => {
|
||||
const lines = Object.entries(proxyRes.headers)
|
||||
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : v}`)
|
||||
.join("\r\n")
|
||||
socket.write(
|
||||
`HTTP/1.1 ${proxyRes.statusCode} ${proxyRes.statusMessage}\r\n${lines}\r\n\r\n`
|
||||
)
|
||||
if (proxyHead?.length) socket.write(proxyHead)
|
||||
if (head?.length) proxySocket.write(head)
|
||||
proxySocket.on("error", () => socket.destroy())
|
||||
socket.on("error", () => proxySocket.destroy())
|
||||
proxySocket.pipe(socket)
|
||||
socket.pipe(proxySocket)
|
||||
})
|
||||
proxyReq.on("error", () => socket.destroy())
|
||||
proxyReq.end()
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const config = defineConfig({
|
||||
optimizeDeps: { include: ["workbox-window"] },
|
||||
worker: { format: "es" },
|
||||
plugins: [
|
||||
mockHarnessProxy(),
|
||||
devtools(),
|
||||
nitro(),
|
||||
viteTsConfigPaths({
|
||||
|
|
|
|||
115
uv.lock
generated
115
uv.lock
generated
|
|
@ -2010,6 +2010,8 @@ dependencies = [
|
|||
{ name = "langgraph-sdk" },
|
||||
{ name = "langsmith" },
|
||||
{ name = "markdownify" },
|
||||
{ name = "pycrdt" },
|
||||
{ name = "pycrdt-websocket" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
|
|
@ -2044,6 +2046,8 @@ requires-dist = [
|
|||
{ name = "langgraph-sdk", specifier = ">=0.4.2" },
|
||||
{ name = "langsmith", specifier = "==0.8.18" },
|
||||
{ name = "markdownify", specifier = ">=1.2.2" },
|
||||
{ name = "pycrdt", specifier = ">=0.14.1,<0.15" },
|
||||
{ name = "pycrdt-websocket", specifier = ">=0.16.3,<0.17" },
|
||||
{ name = "pygments", marker = "extra == 'dev'", specifier = ">=2.20.0" },
|
||||
{ name = "pyjwt", specifier = ">=2.12.1" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" },
|
||||
|
|
@ -2481,6 +2485,104 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycrdt"
|
||||
version = "0.14.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5d/4b/827ec39efadd52cf9dca9a663e7a5e215a4415a3ddd1b187536e21238f99/pycrdt-0.14.1.tar.gz", hash = "sha256:56e7c2d36407716f4977b5f3b972892c51758d31dafff9d043702b5c7fd1aa87", size = 95362, upload-time = "2026-06-17T14:18:04.784Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/2f/ed28aa8a47ad152e18e171176a049084f852c70c740a8378a4ade5740f4c/pycrdt-0.14.1-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:444a26e13c369d1e61062c523de3ea8532c6e699932a504cd84368434b761132", size = 1931105, upload-time = "2026-06-17T14:16:16.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/fe/effa6f6fab73473f2e86af459c69779da6d4de3a080b246f53ed78424418/pycrdt-0.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7e9bc0aa1af974f207772230a8213c7e3da48b0d694d7849533936408e071e92", size = 1048952, upload-time = "2026-06-17T14:16:18.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/07/119fdc7000c4465e73bf00d7dab498300fa3d1a900929ec07d0362e22fd0/pycrdt-0.14.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:90142ecf27f0f318fed6049a84d48da8eb7d76bc031799be424e3501c3b45f6c", size = 1068708, upload-time = "2026-06-17T14:16:22.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/61/7eea3f81cc70f24e6148c51b8dc24f3fdd288ce12c6808c1a3ebf1b1118d/pycrdt-0.14.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b2cb83a5519a6421d6891b1ffbcd26245125aa9a66cec891c385d6f16c1a491", size = 1253482, upload-time = "2026-06-17T14:16:23.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/0e/8f6a7c21721af39da5df4c053444e4e17e1c28fa66c04b0ec1eb3db63164/pycrdt-0.14.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1068ca7466009058cc33ee0d65410c1a6c09dae13aa9471d22f8d0d9a586f466", size = 1103057, upload-time = "2026-06-17T14:16:25.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/73/43f9b51ab818ccf83d3a8a1ba0e4adb23701aab8ee8a97037ce9d916c746/pycrdt-0.14.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48d6845b72d88dfbbf1ff9927b5070ea09c7d4eec9b8c498eeb1b5083f514b79", size = 1074008, upload-time = "2026-06-17T14:16:26.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/95/785ba888d26886e5f468760123d3119086f8de5ea760725eea3a00d3c285/pycrdt-0.14.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c8a50a1a5a934d45414bffce04c2e447587a7031dd650b92100364b6b4807e26", size = 1171836, upload-time = "2026-06-17T14:16:28.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/4e/407bd9fcb8520675c2fcc9047e0df53962a78f1be1e1baf91db7362364f7/pycrdt-0.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b35a8da35b6dc690088e78be9990e6628a5693ecbe80a0645d2be004dec469c7", size = 1223056, upload-time = "2026-06-17T14:16:29.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/c2/95ff0a5d390ee2d91eb953d0c7ee7fa4c7ce10dac21d3d6d895c9212781f/pycrdt-0.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:9a4ce8e1646b01aba9c288b58b870a72e1fbbb287ce76636b73158ebf7434b47", size = 1341061, upload-time = "2026-06-17T14:16:31.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/cc/853994459b08ec21cfb1901c864707778036840514db4c088ee8f2e964e7/pycrdt-0.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bfa85fbe2cea468b23b79361acc45f810dd2e835859b0f3513796ab178cf027b", size = 1339013, upload-time = "2026-06-17T14:16:33.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/10/f97044ae2d958d196d254ed9df6e72b6f4e34a7d3cdc9533827df3795ce8/pycrdt-0.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:98e0d92ce0ad441dbbe943122c7891a43b34ceb6360f5cfde803e9c9da3272f4", size = 1286805, upload-time = "2026-06-17T14:16:35.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/3d/2a74498c213660a4048c446000f7609d4046140271b84be007a8f6f36aa2/pycrdt-0.14.1-cp311-cp311-win32.whl", hash = "sha256:f140d6dba639b7af665ad178f1e8be7723ef616dd9887a75851a9fb2d1fd5053", size = 802987, upload-time = "2026-06-17T14:16:36.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/7c/c66dfcbf37f813ab7b983bc730c7c4dd44582803dee0fabf567b59cf6eef/pycrdt-0.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:c593d61e1ee90d23677883372b59cf7b5e188f6070d77eb80e67379dbd54b053", size = 865483, upload-time = "2026-06-17T14:16:39.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/d1/a61cb33ccaca7d5ede9f59bf16c4d4e254e57dbc1d306cf7c92cdeb56bf0/pycrdt-0.14.1-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:35a3f4bbad8b5848c653997a9cb2bc8df4c6cad8aaea55e9261d23d71a574e55", size = 1920341, upload-time = "2026-06-17T14:16:41.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/01/abbf4134d7353690a6ebece3beace80ec581c67deb2e264957ec9da85241/pycrdt-0.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53ea32083193b8740c5409795087254dd01c4df65aca6edf1f83ceda3fc2980d", size = 1039888, upload-time = "2026-06-17T14:16:42.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/67/1adbb5acbc784200501851ec56d7d64b6110fa1e9b9e3dfd67c29ae1b737/pycrdt-0.14.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f301fee6960320a64075494e1ac25ba6da5e3984e126c13f9e3f890043cac0f", size = 1061004, upload-time = "2026-06-17T14:16:44.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/83/0e9b77b867f03cd5040b0508f05071c8903af43801e1232cfd13ff0e16c6/pycrdt-0.14.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b9c99f44b3d89e76cd2edda5fd0ad74e03dd02abbe1524feff939909ff29e91", size = 1246302, upload-time = "2026-06-17T14:16:46.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/3a/956e915b3d9546869a7136564d1b9a122b354b45c1264efbc0ddb4ca31eb/pycrdt-0.14.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:12ea055f21596320f00f181ec8d082049a4157703b07594ee4f6b5195e15d6c8", size = 1096588, upload-time = "2026-06-17T14:16:48.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/53/b30626fdb8dc635728b2bc3860eb0ae6966fd96595215abe09efd7f978cc/pycrdt-0.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62219393c1c9aa60a00ac7a0da574746edd62f571a83aca05b1632ceabefa4db", size = 1069006, upload-time = "2026-06-17T14:16:50.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/7b/18efd6c541112c962515d1f181761d10953aaae3ab07083d6924af8a2f30/pycrdt-0.14.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4a6abaede60fb9d2acf0dd8ced85d0d328da7a5a03823a25eb3203acc56d3d6b", size = 1163604, upload-time = "2026-06-17T14:16:51.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/e8/e0299e9e8173dcf0f001ffc2ae6f8cf25b39419ef1db65b7e26623fc8b5a/pycrdt-0.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6a669c1a3ffb621c5034ce08b562196fd1d2d02b138e2cfb39345dfc16bc9d59", size = 1214943, upload-time = "2026-06-17T14:16:53.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/b3/7aad8b7a9797ad98ea1b2d70021a44b01af4cbea2d0cc37beb17499dd994/pycrdt-0.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ca57f015894aa3b560d73b6f48d5455a61e69bcde30601295409924f47724195", size = 1337232, upload-time = "2026-06-17T14:16:54.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/22/ff4a93fc6b92f4173e11ce7ebce57f45b979d35da3a3e3a6899bdb9da362/pycrdt-0.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f253e6a9ac9e9487c7d69e078e8904918f1f356e6e9565564e36cefd44faa055", size = 1332373, upload-time = "2026-06-17T14:16:56.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/40/eae0b6d990577fa5b59145783d117d8984b1a9540d4ba037905f4824b9f2/pycrdt-0.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5a07a3c3b8424458793805abf9592d078dec454f9bc345e71e0d0fb788d21f", size = 1282048, upload-time = "2026-06-17T14:16:58.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/a9/e97e637ab9703cb4d951f3ff1e62641db0ca2517f842837598eaaf3667ff/pycrdt-0.14.1-cp312-cp312-win32.whl", hash = "sha256:a638ed29386eec9b202114cbab3f35ad89d8a44c7883d9ee84cf90761dd8ae52", size = 798254, upload-time = "2026-06-17T14:17:00.201Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/b2/5cdc49f8a021732a1a7070b8cbec2073ca5045fbf6c0b5718c3e283d254e/pycrdt-0.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:b7e6876eda749d79f15c2dcc10316f08f1f47756bb25631341a3314e7356ed41", size = 863716, upload-time = "2026-06-17T14:17:02.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/17/d970446b932d614445dca61739f592fdd328ed435dd54e67b5c804a31ef7/pycrdt-0.14.1-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5371deb5d40d3c9ddd5757adb5556ea8566b2fb46c1553ad313e9a5dfc84154e", size = 1919734, upload-time = "2026-06-17T14:17:03.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/d4/9ad8ee06131ee9a8b3366698bd68b39ebdc59764f68c436b61e4f18f7278/pycrdt-0.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cbadc42dd0a6c3869859cf8900b59b21e8160e415a95e85d82e40cc11c887660", size = 1039641, upload-time = "2026-06-17T14:17:05.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/5c/2828a1414478e1df6c51289962bf483a55377a888439d92fc8c6e6699f8d/pycrdt-0.14.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7770624c7003198312646cea57bb0c4d7a231ef57b1975f4487dd7d790fa1196", size = 1061111, upload-time = "2026-06-17T14:17:07.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/02/171aa04758453db0e976b3aaf87de7dbd8f3da6145d3d48d40dbf1073eac/pycrdt-0.14.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c348b780da2792d6cdc2e4dc237b6e8d7f87fe9b1fc2f24e467bf294f0f7581", size = 1246233, upload-time = "2026-06-17T14:17:08.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/d8/1f186f211ba1e4fffc8124d21ae9547f876c2c9c1e2d834919d5c5569f75/pycrdt-0.14.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d28a206a061cb44b284a53587561028547c3a12c4a12408bd0e499f887753a5", size = 1096827, upload-time = "2026-06-17T14:17:10.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/e5/5a027de04406e92282c29c3ec49e5a60c457255aac524cf8ed88064c124b/pycrdt-0.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35149ce7388788681c1d36115545b5c5ceda8d46982eb36f7ed0fab4379f8907", size = 1068703, upload-time = "2026-06-17T14:17:12.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/08/2bc8005634e5f0c067cb9d942f0a2c628e88152f218a159a1e4aea51785f/pycrdt-0.14.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b708985f4aaddeb7d7bf7888fe65f49c91fa0b36ed09a78a1041cb888cd64b50", size = 1162543, upload-time = "2026-06-17T14:17:13.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/53/b10ef7aaadfd24d74ca7d9894af9b4c925eaed5a4ee3a098728d0ac81bc1/pycrdt-0.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eeeee3284e9c9dc2dfcc45db7c55445e70f17bb12015583bd388f6c8f901237c", size = 1214684, upload-time = "2026-06-17T14:17:15.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/9c/19af353a9b2d407cf942f8bec752dc94a7e82d40fb039be34db9a91eba6c/pycrdt-0.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4e715421a47702dbf81d90a32213f6868567e751926629f60912b03af397bd9a", size = 1337139, upload-time = "2026-06-17T14:17:17.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/a4/e1bf0c56a9b6bae6a89cda3ac7ad2ada525c827693f531435a14922b859f/pycrdt-0.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:34288b0a97ccc38e1e87f55448b1c08734deb9ad3360042d4653084bee6a4f80", size = 1331647, upload-time = "2026-06-17T14:17:19.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/04/30f05d4f277f76bfefd7d856592922abaf1d17983c0b084aa347a96ad580/pycrdt-0.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07fecd0e9275e336e46f81203778ab811ee97c7272dfd7937b66844af6fcc13b", size = 1281303, upload-time = "2026-06-17T14:17:20.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/5b/5b05f492e36f31b3e4be062675750b049ad86dbce64c5197dab660f70988/pycrdt-0.14.1-cp313-cp313-win32.whl", hash = "sha256:3b8324b7fb6fb31b2b55eb8dfeedf2e0a40d2e7143c6011d4b63ee102b44f3de", size = 798158, upload-time = "2026-06-17T14:17:22.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/06/e0f436b1b769dc8befd25e27dd133fb1690eba3e82734cd173aa95609340/pycrdt-0.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1bfb808acae8e97e5725086e334c4b5dc79d693962080abf1ab97e70d7400ba1", size = 863084, upload-time = "2026-06-17T14:17:24.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/22/4dec10d664f3f4d09e1142504ad354f993919c9a9ad9a7bb9d3b2eb277b2/pycrdt-0.14.1-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:008b67d78cc8a99a07c2bbe876a866c5d4ef7e3a90cf33463af7521fa5837a6c", size = 1931551, upload-time = "2026-06-17T14:17:26.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/ec/42e16b63a70b1907301945ea3a9e85cbfad0a4a1cfd669367e02f28a8e0f/pycrdt-0.14.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389c8dda4a41a1d6d664ab5c4c66d859e75d24add7e2f5df8ce6954effbe23c6", size = 1041602, upload-time = "2026-06-17T14:17:27.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/76/ba53f5f3ebd5a104b431a4d3433dba9770bded5ece259e582530e289f89b/pycrdt-0.14.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b89eb0020711b2faaec41bd701af64b7c201ef039e781fe5fc945d148301fde", size = 1061937, upload-time = "2026-06-17T14:17:29.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/86/6127c295d5c9fdfa13f61e569c2fb1cba3ce3bb1fbb7a912f14e3baac04a/pycrdt-0.14.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ba13fcb11665d05f3e3a80942dda42ff0d91a33f685909c2e90d0e57cacdad5", size = 1249379, upload-time = "2026-06-17T14:17:31.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/63/fa06b2b8488788bf073d81fb8020eed5c8084465037c18478aaa91c06a8a/pycrdt-0.14.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9a64e7a2e4ea5bc27f1675b64a3669c588adb6a8f2ed33a798d42d7d1f607845", size = 1099321, upload-time = "2026-06-17T14:17:33.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/24/a19c5825f25135485527f2c83c4b4151ba6688a3fac7c7a0c8d20bd2b25f/pycrdt-0.14.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a153994b1f32ec6012d00ec645f80bf7e77ebea907759ee5fbe4e08a6f242bd7", size = 1069402, upload-time = "2026-06-17T14:17:35.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/0a/f91f8689d5abb8e0d3fa73ee71e52326185913f27ac0189223d0b7bad77c/pycrdt-0.14.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fa6f02d8272737e734033a7ec62382d3929b2ab70681aac54b9d21373d78bf82", size = 1165406, upload-time = "2026-06-17T14:17:37.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/59/829f7aab8706e26fd5876ff37371359ff2930211b534570cd3d11305d6f6/pycrdt-0.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e83047e92cf7248c2d40d7be7a42fd7065598c3cc31f3d422def2886499787d6", size = 1216928, upload-time = "2026-06-17T14:17:39.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/8f/a69823e4b02f1da3329d82316b2c733b1875c96e3aff5a2c1ebd8cefd1f8/pycrdt-0.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9d33a6dd2dab10a6380ce9bd7fee79f7e3b868214e95e977842adf9e54906fdf", size = 1337450, upload-time = "2026-06-17T14:17:40.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/5d/040bbcaf59a0e590e3f89946afa611960d08f87c60367260a3ecf01c02de/pycrdt-0.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e347942c2ac71c1ac6ef04212fcd3eb8456f88053b71bf51a288084531c27135", size = 1334972, upload-time = "2026-06-17T14:17:42.6Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/e4/be739b1301575b04e85eab7d76cb557a9abd167451d7442592e3f3d65b95/pycrdt-0.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cce1a249ab5b3d9757ceb42f22da51043c50f1d5c18b0673522b41a7311ce232", size = 1280543, upload-time = "2026-06-17T14:17:45.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/06/1760c307048b431a195b41027faf48906dc8f22b8ee647817d6a15258c9c/pycrdt-0.14.1-cp314-cp314-win32.whl", hash = "sha256:fb93e7e277fc9b2cc26ce5b51d3e3b6436a762d0a0985ceaa50459ff6a33230f", size = 800359, upload-time = "2026-06-17T14:17:47.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/5c/ef3db9970413a661680972274f6b93f961130748e581a41c0c9960e29275/pycrdt-0.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:e3334b8f9fc8caeb41ac8a754adeb9dc640bb8fa1420c38cdb8dc2001a4777c8", size = 864167, upload-time = "2026-06-17T14:17:49.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/f0/33cf0c68c70e541e26a08b8d6eede09e26b134b87a9f825337b28b698206/pycrdt-0.14.1-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d814be8a83cf15238b7c5b8b4c6c775234f1254aea61b17cf9d71dea40d9ca8e", size = 1935886, upload-time = "2026-06-17T14:17:51.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/7f/7fdd3f5fbff47233db93488766995db52fa80c9b73af33fe85d102d8da97/pycrdt-0.14.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:827943404994a71177742b56b47cde92ec97cc4de9c816efc2d19772416b22d9", size = 1050174, upload-time = "2026-06-17T14:17:53.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/ad/b866000af356fef4f2f0271822698334bef7716166ed27a65de83ebdbb22/pycrdt-0.14.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7cc796cdea989f80057c07632667d44cacbe4c6001bd5d7910ef0487a5b971e", size = 1070987, upload-time = "2026-06-17T14:17:55.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/aa/5a93d3959e1cca6db70a10d0660bf6dee2deafac384c2b7e1171ff81198d/pycrdt-0.14.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fd7b2e2e1b81e5fa5a550d835bee73b50cd48ccc999ed66b21900078d3e24027", size = 1261468, upload-time = "2026-06-17T14:17:57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/10/3f6499c2fcc41fabc47d4e5fb1e292cc3f09e96a7b352685f6bfa09750b2/pycrdt-0.14.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c41f0487f2c913eb8c1835d0c386e322c482124e1b9b0b90d69976808501f05", size = 1104527, upload-time = "2026-06-17T14:17:59.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/1e/1e70d8050741d96f32e138b6adb7148f7813def4b8f2404bd6f280668515/pycrdt-0.14.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7dfef69c501bf15cc7f7b6056ef9cab9c91bfc5d4e86f4ea979240947a75c50", size = 1075181, upload-time = "2026-06-17T14:18:00.983Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/49/871a8a110fef882fd1a4f330d8ae06a42f7cbe56b4258de810dbf5bb881c/pycrdt-0.14.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:18ea987fd4346f28463fe80acf8c94658227ad631852e3419eb421bbd90a5f59", size = 1176188, upload-time = "2026-06-17T14:18:02.773Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycrdt-store"
|
||||
version = "0.1.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "pycrdt" },
|
||||
{ name = "sqlite-anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ec/aa/e6d175db0106b72dcfe33d8cd718f9625e34c1d9cd0d3852052b75e9aab8/pycrdt_store-0.1.5.tar.gz", hash = "sha256:10cb0a71b86e167735235b3ab82816015ba746a2e660bcb133adb67a8d5c8818", size = 14845, upload-time = "2026-06-11T07:42:01.876Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/65/2edb1bb1cdce06df2aa2dcf617594344b5fc7f823d0770db2647e0306010/pycrdt_store-0.1.5-py3-none-any.whl", hash = "sha256:5ef6978d363be01275ffc337567072050363fa18fd6a51f0a48917dc210025f2", size = 11840, upload-time = "2026-06-11T07:42:00.827Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycrdt-websocket"
|
||||
version = "0.16.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "pycrdt" },
|
||||
{ name = "pycrdt-store" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a4/0f/e9a9f3b298d6e6b6abacf8a41ea7f4f697aa8116215a15c8674fa1445f39/pycrdt_websocket-0.16.3.tar.gz", hash = "sha256:dd2723d531d9ca7048a67dd8d27c63e4362f5d909ca8c97efcfb2bd1d15d8d2a", size = 23822, upload-time = "2026-06-11T09:03:43.46Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/c6/c4176d2093bce6304e9f34470aae7f733c3818d3e7b54bf31892f1b3429e/pycrdt_websocket-0.16.3-py3-none-any.whl", hash = "sha256:dc3585705d3c2060b24ec329bb7c2065dae9dc50eb7d85854dcf0ed018958c4b", size = 14906, upload-time = "2026-06-11T09:03:42.355Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.5"
|
||||
|
|
@ -3149,6 +3251,19 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-anyio"
|
||||
version = "0.2.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/20/a3/7830c95b37f1268dbb47e559d1f1ae027f3a4c36b1f7fc1b2dc5de1c5073/sqlite_anyio-0.2.8.tar.gz", hash = "sha256:d68b51a18c01a7dfa9cedbc319871ce77ab3ed0822518fb32810bb465b52d761", size = 3271, upload-time = "2026-03-02T10:37:43.466Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/aa/182981b92659df83c3eb7d6f8fb0874984d72ad688fa4054cb96bc044bb0/sqlite_anyio-0.2.8-py3-none-any.whl", hash = "sha256:bbdfefb144aed2633d2618ee1508edd3abe67a00389379360949da4671640d86", size = 4041, upload-time = "2026-03-02T10:37:42.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sse-starlette"
|
||||
version = "3.3.4"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue