diff --git a/flocks/input/events.py b/flocks/input/events.py index d0fd4e54c..4714c1097 100644 --- a/flocks/input/events.py +++ b/flocks/input/events.py @@ -8,6 +8,7 @@ from flocks.command.command import CommandInfo from flocks.input.types import InputSourceType, surface_for_source +from flocks.session.execution_mode import SessionExecutionMode class UserInputEvent(BaseModel): @@ -31,6 +32,10 @@ class UserInputEvent(BaseModel): mock_reply: Optional[str] = Field(None, alias="mockReply") system: Optional[str] = None tools: Optional[Dict[str, bool]] = None + execution_mode: SessionExecutionMode = Field( + SessionExecutionMode.BUILD, + alias="executionMode", + ) @property def surface(self) -> str: diff --git a/flocks/server/routes/session.py b/flocks/server/routes/session.py index 72c6441e4..ac604fd80 100644 --- a/flocks/server/routes/session.py +++ b/flocks/server/routes/session.py @@ -31,6 +31,7 @@ is_model_auto_session_category, ) from flocks.session.policy import SessionPolicy +from flocks.session.execution_mode import SessionExecutionMode from flocks.utils.log import Log from flocks.utils.json_repair import parse_json_robust, repair_truncated_json from flocks.utils.monitor import get_monitor @@ -1677,6 +1678,11 @@ class PromptRequest(BaseModel): tools: Optional[Dict[str, bool]] = Field(None, description="Tool settings (deprecated)") system: Optional[str] = Field(None, description="System prompt override") variant: Optional[str] = Field(None, description="Model variant") + execution_mode: SessionExecutionMode = Field( + SessionExecutionMode.BUILD, + alias="executionMode", + description="Execution mode for this user turn", + ) class UserMessageInfo(BaseModel): @@ -1705,6 +1711,7 @@ class UserMessageInfo(BaseModel): tools: Optional[Dict[str, bool]] = None variant: Optional[str] = None compacted: Optional[bool] = None + executionMode: SessionExecutionMode = SessionExecutionMode.BUILD class AssistantMessageInfo(BaseModel): @@ -1861,6 +1868,11 @@ async def _message_to_response_info(msg: Any, *, cwd: str) -> MessageInfo: agent=getattr(msg, "agent", None) or DEFAULT_AGENT, model=model_info, compacted=getattr(msg, "compacted", None), + executionMode=getattr( + msg, + "executionMode", + SessionExecutionMode.BUILD, + ), ) tokens_raw = getattr(msg, "tokens", None) @@ -2628,6 +2640,25 @@ async def send_session_message(sessionID: str, request: PromptRequest, http_requ _require_session_write_access(session, current_user) working_directory = await _resolve_session_working_directory(session) + _validate_execution_mode_request(request) + if request.execution_mode == SessionExecutionMode.GOAL: + from flocks.session.goal import GoalManager + + objective = _extract_text_from_parts(request.parts).strip() + state = await GoalManager.set_goal(sessionID, objective) + await publish_event("session.goal.updated", { + "sessionID": sessionID, + "status": state.status, + "objective": state.objective, + "reason": state.last_reason, + }) + request = request.model_copy(update={ + "parts": _replace_text_parts( + request.parts, + GoalManager.goal_prompt(state.objective), + ), + "display_text": request.display_text or objective, + }) log.info("session.message.send.processing", { "sessionID": sessionID, @@ -3077,6 +3108,7 @@ async def _process_session_message( time={"created": now_ms}, agent=agent_name, model={"providerID": provider_id, "modelID": model_id}, + executionMode=request.execution_mode, part_id=user_part_id, part_metadata=display_metadata, synthetic=True if _is_no_reply else None, @@ -3091,6 +3123,7 @@ async def _process_session_message( "time": {"created": now_ms}, "agent": agent_name, "model": {"providerID": provider_id, "modelID": model_id}, + "executionMode": request.execution_mode.value, } }) _part_event: dict = { @@ -3508,6 +3541,32 @@ def _extract_text_from_parts(parts: List[Dict[str, Any]]) -> str: return "".join(part.get("text", "") for part in parts if part.get("type") == "text") +def _validate_execution_mode_request(request: PromptRequest) -> None: + if request.execution_mode != SessionExecutionMode.GOAL: + return + objective = _extract_text_from_parts(request.parts).strip() + if not objective: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Goal mode requires a non-empty text objective", + ) + if any(part.get("type") != "text" for part in request.parts): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Goal mode does not support attachments", + ) + + +def _event_text_for_execution_mode( + parts: List[Dict[str, Any]], + execution_mode: SessionExecutionMode, +) -> str: + text = _extract_text_from_parts(parts) + if execution_mode == SessionExecutionMode.GOAL: + return f"/goal {text.strip()}" + return text + + def _replace_text_parts( parts: Optional[List[Dict[str, Any]]], text: str, @@ -3613,7 +3672,7 @@ def _event_from_queued_prompt(item, working_directory: str): return UserInputEvent( source_type="webui", sessionID=item.sessionID, - text=_extract_text_from_parts(item.parts), + text=_event_text_for_execution_mode(item.parts, item.executionMode), parts=[dict(part) for part in item.parts], agent=item.agent, model=item.model, @@ -3624,6 +3683,7 @@ def _event_from_queued_prompt(item, working_directory: str): mockReply=item.mockReply, tools=item.tools, system=item.system, + executionMode=item.executionMode, working_directory=working_directory, ) @@ -3760,13 +3820,14 @@ def _build_prompt_request_from_event(event, prompt_text: str, display_text: Opti noReply=event.no_reply, tools=event.tools, system=event.system, + execution_mode=event.execution_mode, ) async def _dispatch_sse_input(sessionID: str, session, event, working_directory: str) -> None: import time as _time - from flocks.input.dispatcher import dispatch_user_input + from flocks.input.dispatcher import dispatch_user_input, parse_slash_command from flocks.input.output import SSEOutputSink from flocks.server.routes.event import publish_event from flocks.session.message import Message, MessageRole @@ -3791,6 +3852,7 @@ async def _create_user_message( id=user_msg_id, time={"created": now_ms}, agent=message_agent, + executionMode=event.execution_mode, **({"model": model_info} if model_info else {}), part_id=user_part_id, ) @@ -3801,6 +3863,7 @@ async def _create_user_message( "role": "user", "time": {"created": now_ms}, "agent": message_agent, + "executionMode": event.execution_mode.value, **({"model": model_info} if model_info else {}), } }) @@ -3874,6 +3937,18 @@ async def _publish_direct_response(output_event, text: str) -> None: ) async def _run_llm(output_event, prompt_text: str, display_text: Optional[str] = None) -> None: + parsed = parse_slash_command(output_event.text, output_event.metadata) + if parsed is not None and parsed.canonical_name == "goal": + from flocks.session.goal import GoalManager + + state = await GoalManager.get(sessionID) + if state is not None: + await publish_event("session.goal.updated", { + "sessionID": sessionID, + "status": state.status, + "objective": state.objective, + "reason": state.last_reason, + }) request = _build_prompt_request_from_event(output_event, prompt_text, display_text) await _process_session_message(sessionID, session, request, working_directory) @@ -3918,18 +3993,7 @@ async def _run_session_control(output_event, parsed) -> bool: session_control=_run_session_control, clear_history=_clear_history, ) - result = await dispatch_user_input(event, sink) - if result.command_name == "goal" and result.action == "llm": - from flocks.session.goal import GoalManager - - state = await GoalManager.get(sessionID) - if state is not None: - await publish_event("session.goal.updated", { - "sessionID": sessionID, - "status": state.status, - "objective": state.objective, - "reason": state.last_reason, - }) + await dispatch_user_input(event, sink) class PromptQueueUpdateRequest(BaseModel): @@ -3942,6 +4006,7 @@ async def _enqueue_prompt_request( ): from flocks.session.interaction_queue import InteractionQueue + _validate_execution_mode_request(request) await _require_agent_usable_for_chat(request.agent) model = request.model.model_dump(by_alias=True) if request.model else None parts = _materialize_queued_parts(session_id, [dict(part) for part in request.parts]) @@ -3957,6 +4022,7 @@ async def _enqueue_prompt_request( mock_reply=request.mockReply, tools=request.tools, system=request.system, + execution_mode=request.execution_mode, ) @@ -4105,6 +4171,7 @@ async def send_session_message_async( _require_session_write_access(session, current_user) working_directory = await _resolve_session_working_directory(session) + _validate_execution_mode_request(request) await _require_agent_usable_for_chat(request.agent) log.info("session.prompt_async.accepted", { @@ -4112,20 +4179,32 @@ async def send_session_message_async( "directory": working_directory, }) + event_text = _event_text_for_execution_mode( + request.parts, + request.execution_mode, + ) + event_display_text = request.display_text + if request.execution_mode == SessionExecutionMode.GOAL: + event_display_text = ( + event_display_text + or _extract_text_from_parts(request.parts).strip() + ) + event = UserInputEvent( source_type="webui", sessionID=sessionID, - text=_extract_text_from_parts(request.parts), + text=event_text, parts=[dict(part) for part in request.parts], agent=request.agent, model=request.model.model_dump(by_alias=True) if request.model else None, variant=request.variant, - display_text=request.display_text, + display_text=event_display_text, messageID=request.messageID, noReply=request.noReply, mockReply=request.mockReply, tools=request.tools, system=request.system, + executionMode=request.execution_mode, working_directory=working_directory, ) diff --git a/flocks/session/execution_mode.py b/flocks/session/execution_mode.py new file mode 100644 index 000000000..fbad85d6f --- /dev/null +++ b/flocks/session/execution_mode.py @@ -0,0 +1,196 @@ +"""Session execution-mode policy derived from OpenCode and Codex.""" + +from __future__ import annotations + +from enum import Enum +from typing import Any, Iterable, Optional + +from flocks.session.plan_file import ( + SessionPlanFile, + is_current_plan_path, + plan_edit_patterns_allowed, + plan_file_prompt, +) + + +class SessionExecutionMode(str, Enum): + """Execution mode selected for a user turn.""" + + BUILD = "build" + PLAN = "plan" + GOAL = "goal" + + +PLAN_ONLY_TOOL_NAMES = frozenset({"plan_exit"}) +PLAN_DENIED_TOOL_NAMES = frozenset( + { + # Explicit slash commands keep their existing direct user-only path. + "run_slash_command", + } +) +PLAN_DELEGATION_TOOL_NAMES = frozenset({"delegate_task", "task"}) +PLAN_DELEGATABLE_AGENT_NAMES = frozenset({"explore", "librarian"}) +PLAN_PATH_SCOPED_TOOL_NAMES = frozenset({"apply_patch", "edit", "write"}) + +PLAN_MODE_PROMPT = """# Plan Mode + +You are in a planning turn. You may inspect files, configuration, types, +tests, and documentation. Bash is available only for read-only exploration +and validation. Do not use shell commands to modify files, configuration, +services, dependencies, version control state, or any other system state. + +The only file you may modify is the session plan file named below. The runtime +enforces this boundary for file-editing tools. + +Follow this workflow: + +1. Explore first. Ground the plan in the existing environment and resolve + discoverable facts through inspection before asking the user. + Delegation is limited to the `explore` and `librarian` subagents. +2. Use the question tool only for material ambiguities, preferences, or + trade-offs that cannot be resolved from the environment. After the user + answers, continue exploring and planning as needed. +3. Review the proposed approach for remaining gaps. Ask another focused + question if a decision is still required. +4. Write the decision-complete implementation plan to the session plan file. + The plan must be detailed enough for another engineer to execute without + making additional design decisions. +5. Present the final plan to the user, then immediately call plan_exit. That + tool asks the user whether to start implementation. If approved, it switches + the next turn to Build and starts implementing the approved plan. If + declined, remain in Plan and use the feedback to refine it. + +Do not ask for implementation approval with ordinary prose or the question +tool; plan_exit owns that transition. A Plan turn may end only by asking a +material clarification question or by calling plan_exit after the final plan. +""" + + +def coerce_execution_mode(value: object) -> SessionExecutionMode: + """Return a valid execution mode, defaulting legacy values to Build.""" + + if isinstance(value, SessionExecutionMode): + return value + try: + return SessionExecutionMode(str(value or SessionExecutionMode.BUILD.value)) + except ValueError: + return SessionExecutionMode.BUILD + + +def runtime_execution_mode(value: object) -> SessionExecutionMode: + """Resolve the permission mode used while executing a turn.""" + + mode = coerce_execution_mode(value) + if mode == SessionExecutionMode.GOAL: + return SessionExecutionMode.BUILD + return mode + + +def is_tool_allowed(value: object, tool_name: str) -> bool: + """Evaluate tool visibility against OpenCode-style Plan permissions.""" + + mode = runtime_execution_mode(value) + if tool_name in PLAN_ONLY_TOOL_NAMES: + return mode == SessionExecutionMode.PLAN + if mode == SessionExecutionMode.BUILD: + return True + return tool_name not in PLAN_DENIED_TOOL_NAMES + + +def tool_call_denial_reason( + value: object, + tool_name: str, + arguments: dict[str, Any], + ctx: Any, +) -> Optional[str]: + """Return a hard Plan-mode denial reason for a concrete tool call.""" + + if runtime_execution_mode(value) != SessionExecutionMode.PLAN: + return None + if tool_name in PLAN_DELEGATION_TOOL_NAMES: + subagent_type = str(arguments.get("subagent_type") or "").strip().lower() + if ( + subagent_type in PLAN_DELEGATABLE_AGENT_NAMES + and not arguments.get("category") + and not arguments.get("session_id") + ): + return None + allowed = ", ".join(sorted(PLAN_DELEGATABLE_AGENT_NAMES)) + return ( + f"Tool {tool_name!r} may only delegate to {allowed} via " + "subagent_type while Plan mode is active." + ) + if tool_name not in PLAN_PATH_SCOPED_TOOL_NAMES: + return None + + if tool_name in {"edit", "write"}: + paths = [arguments.get("filePath")] + else: + try: + from flocks.tool.file.apply_patch import parse_patch + + hunks = parse_patch(str(arguments.get("patchText") or "")) + except Exception: + hunks = [] + paths = [ + path + for hunk in hunks + for path in (getattr(hunk, "path", None), getattr(hunk, "move_path", None)) + if path + ] + + if paths and all(is_current_plan_path(ctx, path) for path in paths): + return None + return ( + f"Tool {tool_name!r} may only edit the current session plan file " + "while Plan mode is active." + ) + + +def is_permission_allowed( + value: object, + permission: str, + patterns: Iterable[object], + ctx: Any, +) -> bool: + """Apply the same Plan file boundary at the permission entry point.""" + + if runtime_execution_mode(value) != SessionExecutionMode.PLAN: + return True + if permission != "edit": + return True + return plan_edit_patterns_allowed(ctx, patterns) + + +def is_plan_file_edit(value: object, ctx: Any, path: object) -> bool: + """Return whether a read-only sandbox may allow this Plan artifact edit.""" + + return ( + runtime_execution_mode(value) == SessionExecutionMode.PLAN + and is_current_plan_path(ctx, path) + ) + + +def filter_tool_names(value: object, tool_names: Iterable[str]) -> list[str]: + """Return only tool names allowed by the selected execution mode.""" + + return [name for name in tool_names if is_tool_allowed(value, name)] + + +def execution_mode_prompt( + value: object, + *, + session: Any = None, + plan_file: Optional[SessionPlanFile] = None, +) -> str: + """Return the per-turn developer guidance for a mode.""" + + mode = runtime_execution_mode(value) + if mode == SessionExecutionMode.PLAN: + file_prompt = ( + plan_file_prompt(session, plan=plan_file) + if session is not None + else "" + ) + return f"{PLAN_MODE_PROMPT.rstrip()}\n\n{file_prompt}".strip() + return "" diff --git a/flocks/session/interaction_queue.py b/flocks/session/interaction_queue.py index b92464f9e..8fa0d86fe 100644 --- a/flocks/session/interaction_queue.py +++ b/flocks/session/interaction_queue.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, Field +from flocks.session.execution_mode import SessionExecutionMode from flocks.utils.id import Identifier @@ -35,6 +36,7 @@ class QueuedPrompt(BaseModel): mockReply: Optional[str] = None tools: Optional[Dict[str, bool]] = None system: Optional[str] = None + executionMode: SessionExecutionMode = SessionExecutionMode.BUILD status: str = "pending" createdAt: int = Field(default_factory=lambda: int(time.time() * 1000)) updatedAt: int = Field(default_factory=lambda: int(time.time() * 1000)) @@ -69,6 +71,7 @@ async def enqueue( mock_reply: Optional[str] = None, tools: Optional[Dict[str, bool]] = None, system: Optional[str] = None, + execution_mode: SessionExecutionMode = SessionExecutionMode.BUILD, ) -> QueuedPrompt: async with cls._lock_for(session_id): queue = cls._queues.setdefault(session_id, []) @@ -87,6 +90,7 @@ async def enqueue( mockReply=mock_reply, tools=dict(tools) if tools else None, system=system, + executionMode=execution_mode, ) queue.append(item) return item diff --git a/flocks/session/message.py b/flocks/session/message.py index da4e5ae12..12d240ebc 100644 --- a/flocks/session/message.py +++ b/flocks/session/message.py @@ -21,6 +21,7 @@ from flocks.utils.id import Identifier from flocks.storage.storage import Storage from flocks.session.recorder import Recorder +from flocks.session.execution_mode import SessionExecutionMode log = Log.create(service="message") @@ -376,6 +377,10 @@ class UserMessageInfo(BaseModel): tools: Optional[Dict[str, bool]] = Field(None, description="Tool availability") variant: Optional[str] = Field(None, description="Prompt variant") compacted: Optional[bool] = Field(None, description="Archived by compaction (soft-deleted)") + executionMode: SessionExecutionMode = Field( + SessionExecutionMode.BUILD, + description="Execution mode used for this user turn", + ) class AssistantMessageInfo(BaseModel): @@ -942,6 +947,11 @@ def _normalize_stored_message( if not isinstance(model_raw, dict): model_raw = {} normalized["agent"] = normalized.get("agent") or "rex" + normalized["executionMode"] = ( + normalized.get("executionMode") + or normalized.get("execution_mode") + or SessionExecutionMode.BUILD.value + ) normalized["model"] = { "providerID": model_raw.get("providerID") or normalized.get("providerID") diff --git a/flocks/session/plan_file.py b/flocks/session/plan_file.py new file mode 100644 index 000000000..ee650ac6d --- /dev/null +++ b/flocks/session/plan_file.py @@ -0,0 +1,180 @@ +"""Session-scoped plan files modeled after OpenCode's plan artifacts.""" + +from __future__ import annotations + +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable, Optional + + +PLAN_DIRECTORY = Path(".flocks") / "plans" +_SAFE_COMPONENT_RE = re.compile(r"[^A-Za-z0-9._-]+") + + +@dataclass(frozen=True) +class SessionPlanFile: + """Resolved plan artifact for one session.""" + + path: Path + relative_path: str + permission_path: str + + +def _safe_component(value: object, fallback: str) -> str: + normalized = _SAFE_COMPONENT_RE.sub("-", str(value or "")).strip(".-") + return normalized or fallback + + +def session_plan_file( + session: Any, + *, + worktree: Optional[str] = None, +) -> SessionPlanFile: + """Return the stable, project-local plan file for a session.""" + + session_root = Path(str(session.directory)).expanduser().resolve(strict=False) + root = ( + Path(worktree).expanduser().resolve(strict=False) + if worktree and Path(worktree) != Path("/") + else session_root + ) + created = int(getattr(getattr(session, "time", None), "created", 0) or 0) + slug = _safe_component(getattr(session, "slug", None), "session") + filename = f"{created}-{slug}.md" + path = root / PLAN_DIRECTORY / filename + relative_path = Path(os.path.relpath(path, session_root)).as_posix() + return SessionPlanFile( + path=path, + relative_path=relative_path, + permission_path=(PLAN_DIRECTORY / filename).as_posix(), + ) + + +def context_plan_file(ctx: Any) -> Optional[SessionPlanFile]: + """Resolve the plan artifact in the tool's host or sandbox workspace.""" + + extra = getattr(ctx, "extra", {}) or {} + relative_path = str(extra.get("plan_relative_path") or "").strip() + permission_path = str(extra.get("plan_permission_path") or "").strip() + sandbox = extra.get("sandbox") + if isinstance(sandbox, dict) and sandbox.get("workspace_dir") and relative_path: + path = ( + Path(str(sandbox["workspace_dir"])).expanduser() + / Path(relative_path) + ).resolve(strict=False) + else: + raw_path = str(extra.get("plan_file_path") or "").strip() + if not raw_path: + return None + path = Path(raw_path).expanduser().absolute() + if not relative_path: + return None + return SessionPlanFile( + path=path, + relative_path=relative_path, + permission_path=permission_path, + ) + + +def _validation_root(ctx: Any) -> Path: + extra = getattr(ctx, "extra", {}) or {} + sandbox = extra.get("sandbox") + if isinstance(sandbox, dict) and sandbox.get("workspace_dir"): + return Path(str(sandbox["workspace_dir"])).expanduser().resolve(strict=False) + workspace_dir = extra.get("workspace_dir") + if workspace_dir: + return Path(str(workspace_dir)).expanduser().resolve(strict=False) + return Path.cwd().resolve(strict=False) + + +def _expected_plan_path(ctx: Any) -> Optional[Path]: + extra = getattr(ctx, "extra", {}) or {} + sandbox = extra.get("sandbox") + relative_path = str(extra.get("plan_relative_path") or "").strip() + if isinstance(sandbox, dict) and sandbox.get("workspace_dir") and relative_path: + return _validation_root(ctx) / Path(relative_path) + absolute_path = str(extra.get("plan_file_path") or "").strip() + if absolute_path: + return Path(absolute_path).expanduser().absolute() + return None + + +def _normalize_tool_path(ctx: Any, raw_path: object) -> Optional[Path]: + value = str(raw_path or "").strip() + if not value: + return None + path = Path(value).expanduser() + if not path.is_absolute(): + path = _validation_root(ctx) / path + return path.resolve(strict=False) + + +def is_current_plan_path(ctx: Any, raw_path: object) -> bool: + """Return whether a tool path is exactly the current session plan file.""" + + expected = _expected_plan_path(ctx) + candidate = _normalize_tool_path(ctx, raw_path) + if expected is None or candidate is None: + return False + # Keep the expected path lexical. If .flocks/plans or the file itself is a + # symlink, resolving the candidate moves it elsewhere and the comparison + # fails instead of granting an external write. + return os.path.normcase(str(candidate)) == os.path.normcase(str(expected.absolute())) + + +def plan_edit_patterns_allowed(ctx: Any, patterns: Iterable[object]) -> bool: + """Validate resolved edit permission patterns for Plan mode.""" + + values = list(patterns) + expected = _expected_plan_path(ctx) + expected_is_safe = bool( + expected + and os.path.normcase(str(expected.resolve(strict=False))) + == os.path.normcase(str(expected.absolute())) + ) + extra = getattr(ctx, "extra", {}) or {} + accepted_relative_paths = { + Path(str(value)).as_posix() + for value in ( + extra.get("plan_relative_path"), + extra.get("plan_permission_path"), + ) + if value + } + return bool(values) and all( + ( + expected_is_safe + and Path(str(value)).as_posix() in accepted_relative_paths + ) + or is_current_plan_path(ctx, value) + for value in values + ) + + +def plan_file_prompt( + session: Any, + *, + plan: Optional[SessionPlanFile] = None, +) -> str: + """Build the OpenCode-style per-turn plan file reminder.""" + + plan = plan or session_plan_file(session) + if plan.path.is_file(): + file_guidance = ( + f"A plan file already exists at `{plan.relative_path}`. Read it and " + "update it incrementally with the edit or write tool." + ) + else: + file_guidance = ( + f"No plan file exists yet. Create it at `{plan.relative_path}` with " + "the write tool." + ) + return f"""## Plan File + +{file_guidance} + +This is the only file you may edit in Plan mode. Keep the decision-complete +implementation plan in this file, then call plan_exit. +""" diff --git a/flocks/session/prompt.py b/flocks/session/prompt.py index 56466f8cf..21fb0ebb3 100644 --- a/flocks/session/prompt.py +++ b/flocks/session/prompt.py @@ -1077,6 +1077,7 @@ async def build_system_prompts( agent_prompt: Optional[str], provider_id: str, model_id: str, + execution_mode_prompt: Optional[str] = None, prompt_tool_names: Iterable[str] = (), tool_revision: Optional[int] = None, memory_bootstrap_data: Optional[Dict[str, Any]] = None, @@ -1165,6 +1166,13 @@ async def build_custom_context() -> Optional[str]: digest_inputs={"agent_name": agent_name, "agent_prompt": agent_prompt or ""}, builder=lambda: cls._normalize_prompt_text(agent_prompt), ), + cls._build_cached_prompt_block( + static_cache=static_cache, + name="execution_mode", + cache_scope="runtime", + digest_inputs={"prompt": execution_mode_prompt or ""}, + builder=lambda: cls._normalize_prompt_text(execution_mode_prompt), + ), cls._build_cached_prompt_block( static_cache=static_cache, name="memory_snapshot", diff --git a/flocks/session/runner.py b/flocks/session/runner.py index 28d61426d..ab388b073 100644 --- a/flocks/session/runner.py +++ b/flocks/session/runner.py @@ -71,6 +71,13 @@ is_text_extractable_mime, extract_file_text, ) +from flocks.session.execution_mode import ( + SessionExecutionMode, + execution_mode_prompt, + is_tool_allowed, + runtime_execution_mode, +) +from flocks.session.plan_file import session_plan_file log = Log.create(service="session.runner") @@ -571,13 +578,43 @@ async def _list_callable_tool_infos_for_turn( agent: AgentInfo, messages: List[MessageInfo], ) -> Tuple[List[Any], Dict[str, Any]]: + execution_mode = self._execution_mode_from_messages(messages) result = await list_session_callable_tool_infos( session_id=self.session.id, declared_tool_names=getattr(agent, "tools", None), step=self._step, event_publish_callback=self.callbacks.event_publish_callback, ) - return result.tool_infos, dict(result.metadata) + tool_infos = [ + tool_info + for tool_info in result.tool_infos + if is_tool_allowed(execution_mode, tool_info.name) + ] + if ( + execution_mode == SessionExecutionMode.PLAN + and all(tool_info.name != "plan_exit" for tool_info in tool_infos) + ): + plan_exit = ToolRegistry.get("plan_exit") + if plan_exit is not None and getattr(plan_exit.info, "enabled", True): + tool_infos.append(plan_exit.info) + metadata = dict(result.metadata) + metadata["executionMode"] = execution_mode.value + metadata["modeAllowedToolNames"] = sorted( + tool_info.name for tool_info in tool_infos + ) + return tool_infos, metadata + + @staticmethod + def _execution_mode_from_messages( + messages: Optional[List[MessageInfo]], + ) -> SessionExecutionMode: + for message in reversed(messages or []): + if getattr(message, "role", None) != MessageRole.USER: + continue + return runtime_execution_mode( + getattr(message, "executionMode", None) + ) + return runtime_execution_mode(None) @staticmethod def _get_prompt_tool_names_from_schema(tools: List[Dict[str, Any]]) -> Tuple[str, ...]: @@ -1290,6 +1327,16 @@ async def _process_step( ) -> StepResult: """Process a single step in the loop with retry logic.""" self._attempt_state = LlmAttemptState() + turn_execution_mode = runtime_execution_mode( + getattr(last_user, "executionMode", None) + ) + self._turn_execution_mode = turn_execution_mode + from flocks.project.instance import Instance + + self._turn_plan_file = session_plan_file( + self.session, + worktree=Instance.get_worktree(), + ) # Check for CLI callbacks (if running in CLI mode) # Only use CLI fallback if no callbacks were explicitly provided via constructor has_explicit_callbacks = any([ @@ -1426,6 +1473,11 @@ async def device_asset_prompt_factory() -> Optional[str]: agent_prompt=getattr(agent, "prompt", None), provider_id=self.provider_id, model_id=self.model_id, + execution_mode_prompt=execution_mode_prompt( + turn_execution_mode, + session=self.session, + plan_file=self._turn_plan_file, + ), prompt_tool_names=prompt_tool_names, tool_revision=ToolRegistry.revision(), memory_bootstrap_data=self._memory_bootstrap_data, @@ -3071,6 +3123,9 @@ async def _on_tool_execution_start( if self.callbacks.on_tool_start: await self.callbacks.on_tool_start(tool_name, tool_input) + turn_plan_file = getattr(self, "_turn_plan_file", None) + if turn_plan_file is None: + turn_plan_file = session_plan_file(self.session) processor = StreamProcessor( session_id=self.session.id, assistant_message=assistant_msg, @@ -3087,6 +3142,16 @@ async def _on_tool_execution_start( workspace_dir=self.session.directory, langfuse_generation=None, step_index=self._step, + execution_mode=runtime_execution_mode( + getattr( + self, + "_turn_execution_mode", + SessionExecutionMode.BUILD, + ) + ).value, + plan_file_path=str(turn_plan_file.path), + plan_relative_path=turn_plan_file.relative_path, + plan_permission_path=turn_plan_file.permission_path, ) # Build provider options (thinking / reasoning / max_tokens) diff --git a/flocks/session/streaming/stream_processor.py b/flocks/session/streaming/stream_processor.py index b8ff44985..ca1666474 100644 --- a/flocks/session/streaming/stream_processor.py +++ b/flocks/session/streaming/stream_processor.py @@ -119,6 +119,10 @@ def __init__( workspace_dir: Optional[str] = None, langfuse_generation: Optional[Any] = None, step_index: Optional[int] = None, + execution_mode: str = "build", + plan_file_path: Optional[str] = None, + plan_relative_path: Optional[str] = None, + plan_permission_path: Optional[str] = None, ): self.session_id = session_id self.assistant_message = assistant_message @@ -136,6 +140,10 @@ def __init__( self._workspace_dir = workspace_dir self._langfuse_generation = langfuse_generation self._step_index = step_index + self._execution_mode = execution_mode + self._plan_file_path = plan_file_path + self._plan_relative_path = plan_relative_path + self._plan_permission_path = plan_permission_path self._sandbox_runtime_cache = None self._sandbox_config_cache = None self._sandbox_context_cache = None @@ -839,6 +847,26 @@ def _mark_finished() -> None: _cb.mark_finished = _mark_finished return _cb + tool_extra = { + **sandbox_meta["extra"], + "execution_mode": self._execution_mode, + "workspace_dir": self._workspace_dir, + "model": { + "providerID": getattr( + self.assistant_message, + "providerID", + None, + ), + "modelID": getattr( + self.assistant_message, + "modelID", + None, + ), + }, + "plan_file_path": self._plan_file_path, + "plan_relative_path": self._plan_relative_path, + "plan_permission_path": self._plan_permission_path, + } ctx = ToolContext( session_id=self.session_id, message_id=self.assistant_message.id, @@ -846,7 +874,7 @@ def _mark_finished() -> None: call_id=tool_call_id, abort_event=self.abort_event, permission_callback=self.permission_callback, - extra=sandbox_meta["extra"], + extra=tool_extra, metadata_callback=_make_metadata_cb(), event_publish_callback=self.event_publish_callback, ) diff --git a/flocks/tool/file/apply_patch.py b/flocks/tool/file/apply_patch.py index af46ef698..e7ef476e1 100644 --- a/flocks/tool/file/apply_patch.py +++ b/flocks/tool/file/apply_patch.py @@ -293,7 +293,12 @@ async def apply_patch_tool( ) sandbox = ctx.extra.get("sandbox") if ctx.extra else None - if isinstance(sandbox, dict) and sandbox.get("workspace_access") == "ro": + sandbox_read_only = ( + isinstance(sandbox, dict) + and sandbox.get("workspace_access") == "ro" + ) + execution_mode = ctx.extra.get("execution_mode") if ctx.extra else None + if sandbox_read_only and execution_mode != "plan": return ToolResult( success=False, error=( @@ -397,6 +402,25 @@ async def apply_patch_tool( success=False, error=f"Failed to process hunk for {hunk.path}: {str(e)}" ) + + if sandbox_read_only: + from flocks.session.execution_mode import is_plan_file_edit + + if not all( + is_plan_file_edit(execution_mode, ctx, change["filePath"]) + and ( + not change.get("movePath") + or is_plan_file_edit(execution_mode, ctx, change["movePath"]) + ) + for change in file_changes + ): + return ToolResult( + success=False, + error=( + "Patch is blocked in sandbox read-only workspace mode. " + "Only the current session plan file may be changed in Plan mode." + ), + ) # Request permission await ctx.ask( diff --git a/flocks/tool/file/edit.py b/flocks/tool/file/edit.py index abc0566cc..377ab212d 100644 --- a/flocks/tool/file/edit.py +++ b/flocks/tool/file/edit.py @@ -526,6 +526,20 @@ async def edit_tool( sandbox = ctx.extra.get("sandbox") if ctx.extra else None if isinstance(sandbox, dict) and sandbox.get("workspace_access") == "ro": + from flocks.session.execution_mode import is_plan_file_edit + + plan_file_edit = is_plan_file_edit( + ctx.extra.get("execution_mode"), + ctx, + filepath, + ) + else: + plan_file_edit = False + if ( + isinstance(sandbox, dict) + and sandbox.get("workspace_access") == "ro" + and not plan_file_edit + ): return ToolResult( success=False, error=( diff --git a/flocks/tool/file/write.py b/flocks/tool/file/write.py index eee0379cf..50aee858e 100644 --- a/flocks/tool/file/write.py +++ b/flocks/tool/file/write.py @@ -273,6 +273,20 @@ async def write_tool( sandbox = ctx.extra.get("sandbox") if ctx.extra else None if isinstance(sandbox, dict) and sandbox.get("workspace_access") == "ro": + from flocks.session.execution_mode import is_plan_file_edit + + plan_file_edit = is_plan_file_edit( + ctx.extra.get("execution_mode"), + ctx, + filepath, + ) + else: + plan_file_edit = False + if ( + isinstance(sandbox, dict) + and sandbox.get("workspace_access") == "ro" + and not plan_file_edit + ): return ToolResult( success=False, error=( diff --git a/flocks/tool/registry.py b/flocks/tool/registry.py index b172a847d..19424ac83 100644 --- a/flocks/tool/registry.py +++ b/flocks/tool/registry.py @@ -267,6 +267,20 @@ async def ask( always: Always-allow patterns metadata: Additional metadata """ + execution_mode = self.extra.get("execution_mode") + if execution_mode: + from flocks.session.execution_mode import is_permission_allowed + + if not is_permission_allowed( + execution_mode, + permission, + patterns, + self, + ): + raise PermissionError( + "Plan mode may only edit the current session plan file." + ) + request = PermissionRequest( permission=permission, patterns=patterns, @@ -870,6 +884,40 @@ async def execute( message_id="default" ) + execution_mode = ctx.extra.get("execution_mode") + if execution_mode: + from flocks.session.execution_mode import ( + is_tool_allowed, + tool_call_denial_reason, + ) + + if not is_tool_allowed(execution_mode, tool_name): + log.warn("tool.execute.execution_mode_denied", { + "name": tool_name, + "execution_mode": str(execution_mode), + "session_id": ctx.session_id, + }) + return ToolResult( + success=False, + error=( + f"Tool {tool_name!r} is not available in " + f"{str(execution_mode)!r} execution mode." + ), + ) + denial_reason = tool_call_denial_reason( + execution_mode, + tool_name, + kwargs, + ctx, + ) + if denial_reason: + log.warn("tool.execute.execution_mode_call_denied", { + "name": tool_name, + "execution_mode": str(execution_mode), + "session_id": ctx.session_id, + }) + return ToolResult(success=False, error=denial_reason) + log.info("tool.execute", { "name": tool_name, "params": list(kwargs.keys()), @@ -1616,7 +1664,7 @@ def _register_builtin_tools(cls) -> None: # security/ — SSH forensics + threat intelligence (optional: asyncssh) ("flocks.tool.security", ["ssh_host_cmd", "ssh_run_script"]), # system/ — questions, model config, memory, MCP management, session management, slash commands - ("flocks.tool.system", ["question", "model_config", "memory", "flocks_mcp", "session_manage", "slash_command", "tool_search"]), + ("flocks.tool.system", ["question", "plan_exit", "model_config", "memory", "flocks_mcp", "session_manage", "slash_command", "tool_search"]), # skill/ — skill management (search, install, status, deps, remove, load) ("flocks.tool.skill", ["flocks_skills", "skill_load"]), # device/ — security device asset context and status probes diff --git a/flocks/tool/system/plan_exit.py b/flocks/tool/system/plan_exit.py new file mode 100644 index 000000000..2dacda008 --- /dev/null +++ b/flocks/tool/system/plan_exit.py @@ -0,0 +1,186 @@ +"""Plan completion and Build handoff modeled after OpenCode's plan_exit tool.""" + +from __future__ import annotations + +from typing import Any + +from flocks.session.execution_mode import SessionExecutionMode +from flocks.session.message import Message, MessageRole +from flocks.session.plan_file import context_plan_file +from flocks.tool.registry import ( + ToolCategory, + ToolContext, + ToolRegistry, + ToolResult, +) +from flocks.tool.system.question import question_tool + + +START_IMPLEMENTING = "开始实施" +CONTINUE_PLANNING = "调整计划" + +DESCRIPTION = """Finish a completed plan and ask the user whether to implement it. + +Call this only after presenting a decision-complete plan. Approval starts a new +Build turn in the current session loop to implement the approved plan. +Declining keeps the session in Plan so the plan can be refined. +""" + + +async def _publish(ctx: ToolContext, event_type: str, properties: dict[str, Any]) -> None: + if ctx.event_publish_callback: + await ctx.event_publish_callback(event_type, properties) + + +async def _turn_model_and_variant( + ctx: ToolContext, +) -> tuple[dict[str, str] | None, str | None]: + """Copy the active Plan turn model like OpenCode's synthetic Build turn.""" + + try: + messages = await Message.list(ctx.session_id) + last_user = next( + ( + message + for message in reversed(messages) + if getattr(message, "role", None) == MessageRole.USER + ), + None, + ) + except Exception: + last_user = None + + model = getattr(last_user, "model", None) + if not isinstance(model, dict) or not all( + model.get(key) for key in ("providerID", "modelID") + ): + model = ctx.extra.get("model") + if not isinstance(model, dict) or not all( + model.get(key) for key in ("providerID", "modelID") + ): + model = None + return model, getattr(last_user, "variant", None) + + +@ToolRegistry.register_function( + name="plan_exit", + description=DESCRIPTION, + category=ToolCategory.SYSTEM, + parameters=[], +) +async def plan_exit_tool(ctx: ToolContext) -> ToolResult: + """Ask for plan approval and continue immediately in Build mode.""" + + plan = context_plan_file(ctx) + if plan is None or not plan.path.is_file(): + return ToolResult( + success=False, + error="Write the session plan file before calling plan_exit.", + ) + try: + if not plan.path.read_text(encoding="utf-8").strip(): + return ToolResult( + success=False, + error="The session plan file is empty. Complete it before calling plan_exit.", + ) + except OSError as exc: + return ToolResult(success=False, error=f"Could not read the session plan file: {exc}") + + confirmation = await question_tool( + ctx, + questions=[ + { + "header": "Plan complete", + "question": ( + f"The plan at {plan.relative_path} is complete. Would you like " + "to switch to Build and start implementing?" + ), + "type": "choice", + "options": [ + { + "label": START_IMPLEMENTING, + "description": "Switch to Build and implement the approved plan now.", + }, + { + "label": CONTINUE_PLANNING, + "description": "Stay in Plan and describe what should be changed.", + "allowText": True, + }, + ], + "multiple": False, + "custom": False, + } + ], + ) + if not confirmation.success: + return confirmation + if confirmation.metadata.get("deferred"): + return confirmation + + answers = confirmation.metadata.get("answers") or [] + selected = answers[0] if answers else [] + if START_IMPLEMENTING not in selected: + feedback = "\n".join( + str(value).strip() + for value in selected + if str(value).strip() and value != CONTINUE_PLANNING + ) + output = ( + "The user chose to remain in Plan. Continue refining the plan " + "using their feedback." + ) + metadata = { + "approved": False, + "executionMode": SessionExecutionMode.PLAN.value, + } + if feedback: + output = f"{output}\n\nUser feedback:\n{feedback}" + metadata["feedback"] = feedback + return ToolResult( + success=True, + output=output, + title="Continue planning", + metadata=metadata, + ) + + build_model, build_variant = await _turn_model_and_variant(ctx) + build_message = await Message.create( + session_id=ctx.session_id, + role=MessageRole.USER, + content=( + f"The plan at {plan.relative_path} has been approved. " + "Switch to Build mode, read that file, and implement it now." + ), + agent=ctx.agent, + model=build_model, + variant=build_variant, + executionMode=SessionExecutionMode.BUILD, + synthetic=True, + part_metadata={ + "planImplementation": True, + "planPath": plan.relative_path, + }, + ) + await _publish( + ctx, + "session.execution_mode.changed", + { + "sessionID": ctx.session_id, + "executionMode": SessionExecutionMode.BUILD.value, + "reason": "plan-approved", + }, + ) + return ToolResult( + success=True, + output=( + "The plan was approved. Continue immediately in Build mode and " + "implement the approved plan." + ), + title="Plan approved", + metadata={ + "approved": True, + "executionMode": SessionExecutionMode.BUILD.value, + "buildMessageID": build_message.id, + "planPath": plan.relative_path, + }, + ) diff --git a/flocks/tool/system/question.py b/flocks/tool/system/question.py index 4fb80273e..e83af3201 100644 --- a/flocks/tool/system/question.py +++ b/flocks/tool/system/question.py @@ -107,7 +107,7 @@ def _first_non_empty_string(data: Dict[str, Any], keys: tuple[str, ...]) -> str: return "" -def normalize_question_option(opt: Any) -> Optional[Dict[str, str]]: +def normalize_question_option(opt: Any) -> Optional[Dict[str, Any]]: """Normalize LLM-produced choice options into the UI's label/description shape.""" if isinstance(opt, str): label = opt.strip() @@ -123,7 +123,13 @@ def normalize_question_option(opt: Any) -> Optional[Dict[str, str]]: label, description = description, "" if not label: return None - return {"label": label, "description": description} + normalized: Dict[str, Any] = { + "label": label, + "description": description, + } + if opt.get("allowText") is True: + normalized["allowText"] = True + return normalized def _format_channel_question_text(questions: List[Dict[str, Any]]) -> str: @@ -296,6 +302,13 @@ async def default_question_handler( "properties": { "label": {"type": "string"}, "description": {"type": "string"}, + "allowText": { + "type": "boolean", + "description": ( + "Show a text input for this option and " + "return both its label and entered text." + ), + }, }, "required": ["label"], "additionalProperties": False, diff --git a/tests/sandbox/test_sandbox_runtime_integration.py b/tests/sandbox/test_sandbox_runtime_integration.py index 734e0959c..7223845af 100644 --- a/tests/sandbox/test_sandbox_runtime_integration.py +++ b/tests/sandbox/test_sandbox_runtime_integration.py @@ -297,6 +297,13 @@ async def fake_resolve_sandbox_context(**_kwargs): assert len(captured_extras) == 2 assert captured_extras[0][0] == "bash" - assert captured_extras[0][1] == {} + assert captured_extras[0][1] == { + "execution_mode": "build", + "workspace_dir": "/tmp", + "model": {"providerID": "test-provider", "modelID": "test-model"}, + "plan_file_path": None, + "plan_relative_path": None, + "plan_permission_path": None, + } assert "sandbox" in captured_extras[1][1] assert captured_extras[1][1]["sandbox"]["container_name"] == "flocks-sbx-test" diff --git a/tests/server/test_input_dispatcher.py b/tests/server/test_input_dispatcher.py index 17a11d8f0..1ee53aa45 100644 --- a/tests/server/test_input_dispatcher.py +++ b/tests/server/test_input_dispatcher.py @@ -288,6 +288,63 @@ async def test_channel_unsafe_command_is_rejected(self): class TestSessionRoutesUseDispatcher: + @pytest.mark.asyncio + async def test_goal_mode_publishes_active_goal_before_llm(self, monkeypatch): + from flocks.input.events import UserInputEvent + from flocks.server.routes import session as session_routes + + order = [] + goal_state = SimpleNamespace( + status="active", + objective="fix tests", + last_reason=None, + ) + + async def publish(event_type, _properties): + if event_type == "session.goal.updated": + order.append("goal") + + async def process(*_args, **_kwargs): + order.append("llm") + + monkeypatch.setattr( + "flocks.command.direct.GoalManager.set_goal", + AsyncMock(return_value=goal_state), + ) + monkeypatch.setattr( + "flocks.command.direct.GoalManager.goal_prompt", + MagicMock(return_value="goal prompt"), + ) + monkeypatch.setattr( + "flocks.session.goal.GoalManager.get", + AsyncMock(return_value=goal_state), + ) + monkeypatch.setattr( + "flocks.server.routes.event.publish_event", + publish, + ) + monkeypatch.setattr( + session_routes, + "_process_session_message", + process, + ) + + await session_routes._dispatch_sse_input( + "ses_goal_mode", + SimpleNamespace(id="ses_goal_mode"), + UserInputEvent( + source_type="webui", + sessionID="ses_goal_mode", + text="/goal fix tests", + parts=[{"type": "text", "text": "fix tests"}], + display_text="fix tests", + executionMode="goal", + ), + "/tmp/project", + ) + + assert order == ["goal", "llm"] + @pytest.mark.asyncio async def test_prompt_async_routes_through_dispatcher(self, monkeypatch): from flocks.server.routes import session as session_routes diff --git a/tests/session/test_execution_mode.py b/tests/session/test_execution_mode.py new file mode 100644 index 000000000..005042d3c --- /dev/null +++ b/tests/session/test_execution_mode.py @@ -0,0 +1,482 @@ +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException +from pydantic import ValidationError + +from flocks.server.routes.session import ( + PromptRequest, + _event_text_for_execution_mode, + _validate_execution_mode_request, +) +from flocks.session.execution_mode import ( + SessionExecutionMode, + execution_mode_prompt, + is_tool_allowed, + runtime_execution_mode, + tool_call_denial_reason, +) +from flocks.session.interaction_queue import InteractionQueue +from flocks.session.plan_file import is_current_plan_path, session_plan_file +from flocks.session.session import SessionInfo, SessionTime +from flocks.tool.registry import ( + Tool, + ToolCategory, + ToolContext, + ToolInfo, + ToolParameter, + ToolRegistry, + ToolResult, + ParameterType, +) +from flocks.tool.file.write import write_tool + + +def test_prompt_request_defaults_to_build_and_accepts_plan() -> None: + default_request = PromptRequest(parts=[{"type": "text", "text": "hello"}]) + plan_request = PromptRequest.model_validate({ + "parts": [{"type": "text", "text": "hello"}], + "executionMode": "plan", + }) + + assert default_request.execution_mode == SessionExecutionMode.BUILD + assert plan_request.execution_mode == SessionExecutionMode.PLAN + + +def test_prompt_request_rejects_removed_ask_mode() -> None: + with pytest.raises(ValidationError): + PromptRequest.model_validate({ + "parts": [{"type": "text", "text": "hello"}], + "executionMode": "ask", + }) + + +def test_goal_transport_uses_build_permissions_and_slash_dispatch() -> None: + parts = [{"type": "text", "text": " finish the feature "}] + + assert runtime_execution_mode("goal") == SessionExecutionMode.BUILD + assert _event_text_for_execution_mode( + parts, + SessionExecutionMode.GOAL, + ) == "/goal finish the feature" + + +def test_goal_requires_text_only_objective() -> None: + empty = PromptRequest.model_validate({ + "parts": [], + "executionMode": "goal", + }) + attachment = PromptRequest.model_validate({ + "parts": [ + {"type": "text", "text": "inspect this"}, + {"type": "file", "url": "file:///tmp/report.txt"}, + ], + "executionMode": "goal", + }) + + with pytest.raises(HTTPException, match="non-empty text objective"): + _validate_execution_mode_request(empty) + with pytest.raises(HTTPException, match="does not support attachments"): + _validate_execution_mode_request(attachment) + + +def test_plan_uses_read_only_permission_rules() -> None: + assert is_tool_allowed(SessionExecutionMode.PLAN, "read") + assert is_tool_allowed(SessionExecutionMode.PLAN, "grep") + assert is_tool_allowed(SessionExecutionMode.PLAN, "question") + assert is_tool_allowed(SessionExecutionMode.PLAN, "plan_exit") + assert is_tool_allowed(SessionExecutionMode.PLAN, "bash") + assert is_tool_allowed(SessionExecutionMode.PLAN, "edit") + assert is_tool_allowed(SessionExecutionMode.PLAN, "write") + assert is_tool_allowed(SessionExecutionMode.PLAN, "unknown_plugin_tool") + assert is_tool_allowed(SessionExecutionMode.PLAN, "task") + assert is_tool_allowed(SessionExecutionMode.PLAN, "delegate_task") + assert not is_tool_allowed(SessionExecutionMode.PLAN, "run_slash_command") + + assert is_tool_allowed(SessionExecutionMode.BUILD, "bash") + assert not is_tool_allowed(SessionExecutionMode.BUILD, "plan_exit") + assert "decision-complete implementation plan" in execution_mode_prompt("plan") + assert "material clarification question" in execution_mode_prompt("plan") + assert "call plan_exit" in execution_mode_prompt("plan") + assert "`explore` and `librarian`" in execution_mode_prompt("plan") + assert execution_mode_prompt("build") == "" + + +@pytest.mark.parametrize("tool_name", ["task", "delegate_task"]) +def test_plan_delegation_only_allows_explore_and_librarian(tool_name) -> None: + ctx = ToolContext(session_id="session-1", message_id="message-1") + + for subagent_type in ("explore", "librarian"): + assert tool_call_denial_reason( + SessionExecutionMode.PLAN, + tool_name, + {"subagent_type": subagent_type}, + ctx, + ) is None + + for arguments in ( + {"subagent_type": "general"}, + {"category": "quick"}, + {"session_id": "child-session"}, + {}, + ): + reason = tool_call_denial_reason( + SessionExecutionMode.PLAN, + tool_name, + arguments, + ctx, + ) + assert reason is not None + assert "explore, librarian" in reason + + +def test_plan_file_is_stable_and_session_scoped(tmp_path) -> None: + first = SessionInfo( + id="session-1", + slug="first-plan", + projectID="project-1", + directory=str(tmp_path), + time=SessionTime(created=1234, updated=1234), + ) + second = first.model_copy(update={ + "slug": "second-plan", + "time": SessionTime(created=5678, updated=5678), + }) + + first_plan = session_plan_file(first) + second_plan = session_plan_file(second) + + assert first_plan.path == tmp_path / ".flocks" / "plans" / "1234-first-plan.md" + assert first_plan.relative_path == ".flocks/plans/1234-first-plan.md" + assert first_plan.permission_path == ".flocks/plans/1234-first-plan.md" + assert session_plan_file(first) == first_plan + assert second_plan.path != first_plan.path + + +def test_plan_file_uses_worktree_root_and_directory_relative_tool_path( + tmp_path, +) -> None: + worktree = tmp_path / "repo" + directory = worktree / "packages" / "app" + directory.mkdir(parents=True) + session = SessionInfo( + slug="nested-plan", + projectID="project-1", + directory=str(directory), + time=SessionTime(created=1234, updated=1234), + ) + + plan = session_plan_file(session, worktree=str(worktree)) + + assert plan.path == worktree / ".flocks" / "plans" / "1234-nested-plan.md" + assert plan.relative_path == "../../.flocks/plans/1234-nested-plan.md" + assert plan.permission_path == ".flocks/plans/1234-nested-plan.md" + + +def test_plan_prompt_describes_create_then_incremental_edit(tmp_path) -> None: + session = SessionInfo( + slug="prompt-plan", + projectID="project-1", + directory=str(tmp_path), + time=SessionTime(created=1234, updated=1234), + ) + plan = session_plan_file(session) + + create_prompt = execution_mode_prompt("plan", session=session) + assert plan.relative_path in create_prompt + assert "No plan file exists yet" in create_prompt + assert "Bash is available only for read-only exploration" in create_prompt + + plan.path.parent.mkdir(parents=True) + plan.path.write_text("# Plan\n", encoding="utf-8") + + edit_prompt = execution_mode_prompt("plan", session=session) + assert plan.relative_path in edit_prompt + assert "already exists" in edit_prompt + assert "update it incrementally" in edit_prompt + + +def test_plan_path_guard_rejects_symlink_escape(tmp_path) -> None: + plan_relative = ".flocks/plans/1234-plan.md" + plan_path = tmp_path / plan_relative + external = tmp_path / "external.md" + external.write_text("outside\n", encoding="utf-8") + plan_path.parent.mkdir(parents=True) + plan_path.symlink_to(external) + ctx = ToolContext( + session_id="session-1", + message_id="message-1", + extra={ + "execution_mode": "plan", + "workspace_dir": str(tmp_path), + "plan_file_path": str(plan_path), + "plan_relative_path": plan_relative, + "plan_permission_path": plan_relative, + }, + ) + + assert not is_current_plan_path(ctx, plan_relative) + + +@pytest.mark.asyncio +async def test_prompt_queue_preserves_execution_mode() -> None: + session_id = "execution-mode-queue" + await InteractionQueue.clear(session_id) + + item = await InteractionQueue.enqueue( + session_id, + parts=[{"type": "text", "text": "plan this"}], + execution_mode=SessionExecutionMode.PLAN, + ) + + queued = await InteractionQueue.list(session_id) + assert item.executionMode == SessionExecutionMode.PLAN + assert queued[0].executionMode == SessionExecutionMode.PLAN + + await InteractionQueue.clear(session_id) + + +@pytest.mark.asyncio +async def test_registry_scopes_plan_delegation_before_handler(monkeypatch) -> None: + calls: list[dict] = [] + + async def handler(_ctx, **_kwargs): + calls.append(_kwargs) + return ToolResult(success=True, output="ok") + + tool = Tool( + info=ToolInfo( + name="task", + description="Delegation test tool", + category=ToolCategory.FILE, + ), + handler=handler, + ) + monkeypatch.setattr( + ToolRegistry, + "get", + classmethod(lambda _cls, _name: tool), + ) + + explore = await ToolRegistry.execute( + "task", + ctx=ToolContext( + session_id="session-1", + message_id="message-1", + extra={"execution_mode": "plan"}, + ), + subagent_type="explore", + ) + denied = await ToolRegistry.execute( + "delegate_task", + ctx=ToolContext( + session_id="session-1", + message_id="message-1", + extra={"execution_mode": "plan"}, + ), + subagent_type="general", + ) + + assert explore.success + assert not denied.success + assert "explore, librarian" in (denied.error or "") + assert calls == [{"subagent_type": "explore"}] + + +@pytest.mark.asyncio +async def test_registry_scopes_plan_edits_to_current_plan_file( + monkeypatch, + tmp_path, +) -> None: + calls: list[str] = [] + + async def handler(_ctx, **kwargs): + calls.append(kwargs["filePath"]) + return ToolResult(success=True, output="ok") + + tool = Tool( + info=ToolInfo( + name="write", + description="Write test", + category=ToolCategory.FILE, + parameters=[ + ToolParameter( + name="filePath", + type=ParameterType.STRING, + required=True, + ) + ], + ), + handler=handler, + ) + monkeypatch.setattr( + ToolRegistry, + "get", + classmethod(lambda _cls, _name: tool), + ) + plan_relative = ".flocks/plans/1234-plan.md" + ctx = ToolContext( + session_id="session-1", + message_id="message-1", + extra={ + "execution_mode": "plan", + "workspace_dir": str(tmp_path), + "plan_file_path": str(tmp_path / plan_relative), + "plan_relative_path": plan_relative, + "plan_permission_path": plan_relative, + }, + ) + + allowed = await ToolRegistry.execute( + "write", + ctx=ctx, + filePath=plan_relative, + ) + denied = await ToolRegistry.execute( + "write", + ctx=ctx, + filePath=".flocks/plans/other.md", + ) + traversal = await ToolRegistry.execute( + "write", + ctx=ctx, + filePath=".flocks/plans/../outside.md", + ) + + assert allowed.success + assert not denied.success + assert not traversal.success + assert calls == [plan_relative] + + +@pytest.mark.asyncio +async def test_tool_context_rechecks_plan_edit_permission(tmp_path) -> None: + plan_relative = ".flocks/plans/1234-plan.md" + ctx = ToolContext( + session_id="session-1", + message_id="message-1", + extra={ + "execution_mode": "plan", + "workspace_dir": str(tmp_path), + "plan_file_path": str(tmp_path / plan_relative), + "plan_relative_path": plan_relative, + "plan_permission_path": plan_relative, + }, + ) + + await ctx.ask(permission="edit", patterns=[plan_relative]) + with pytest.raises(PermissionError, match="current session plan file"): + await ctx.ask(permission="edit", patterns=["src/main.py"]) + + +@pytest.mark.asyncio +async def test_read_only_sandbox_allows_only_plan_artifact_write(tmp_path) -> None: + plan_relative = ".flocks/plans/1234-plan.md" + plan_path = tmp_path / plan_relative + ctx = ToolContext( + session_id="session-1", + message_id="message-1", + extra={ + "execution_mode": "plan", + "workspace_dir": str(tmp_path), + "plan_file_path": str(plan_path), + "plan_relative_path": plan_relative, + "plan_permission_path": plan_relative, + "sandbox": { + "workspace_dir": str(tmp_path), + "workspace_access": "ro", + }, + }, + ) + + allowed = await write_tool(ctx, "# Plan\n", plan_relative) + denied = await write_tool(ctx, "bad\n", "src/main.py") + + assert allowed.success + assert plan_path.read_text(encoding="utf-8") == "# Plan\n" + assert not denied.success + assert not (tmp_path / "src" / "main.py").exists() + + +@pytest.mark.asyncio +async def test_runner_filters_tools_with_message_mode(monkeypatch) -> None: + from flocks.session.runner import SessionRunner + + runner = object.__new__(SessionRunner) + runner.session = SimpleNamespace(id="session-1") + runner._step = 1 + runner.callbacks = SimpleNamespace(event_publish_callback=None) + agent = SimpleNamespace( + tools=[ + "read", + "bash", + "write", + "edit", + "task", + "delegate_task", + "run_slash_command", + ] + ) + + result = SimpleNamespace( + tool_infos=[ + SimpleNamespace(name="read"), + SimpleNamespace(name="bash"), + SimpleNamespace(name="write"), + SimpleNamespace(name="edit"), + SimpleNamespace(name="task"), + SimpleNamespace(name="delegate_task"), + SimpleNamespace(name="run_slash_command"), + ], + metadata={}, + ) + + async def list_tools(**_kwargs): + return result + + monkeypatch.setattr( + "flocks.session.runner.list_session_callable_tool_infos", + list_tools, + ) + monkeypatch.setattr( + ToolRegistry, + "get", + classmethod( + lambda _cls, name: ( + SimpleNamespace(info=SimpleNamespace(name="plan_exit", enabled=True)) + if name == "plan_exit" + else None + ) + ), + ) + messages = [ + SimpleNamespace( + role="user", + executionMode=SessionExecutionMode.PLAN, + ) + ] + + tools, metadata = await runner._list_callable_tool_infos_for_turn( + agent, + messages, + ) + + assert [tool.name for tool in tools] == [ + "read", + "bash", + "write", + "edit", + "task", + "delegate_task", + "plan_exit", + ] + assert metadata["executionMode"] == "plan" + assert metadata["modeAllowedToolNames"] == [ + "bash", + "delegate_task", + "edit", + "plan_exit", + "read", + "task", + "write", + ] diff --git a/tests/tool/test_plan_exit.py b/tests/tool/test_plan_exit.py new file mode 100644 index 000000000..47e257252 --- /dev/null +++ b/tests/tool/test_plan_exit.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from flocks.session.execution_mode import SessionExecutionMode +from flocks.session.interaction_queue import InteractionQueue +from flocks.session.message import Message, MessageRole +from flocks.tool.registry import ToolContext, ToolResult +from flocks.tool.system import plan_exit + + +@pytest.fixture(autouse=True) +async def clear_queue(): + session_id = "plan-exit-session" + await InteractionQueue.clear(session_id) + await Message.clear(session_id) + yield + await InteractionQueue.clear(session_id) + await Message.clear(session_id) + + +def _context( + events: list[tuple[str, dict]], + tmp_path, + *, + plan_content: str = "# Approved plan\n", +) -> ToolContext: + async def publish(event_type: str, properties: dict) -> None: + events.append((event_type, properties)) + + plan_path = tmp_path / ".flocks" / "plans" / "1234-plan.md" + plan_path.parent.mkdir(parents=True, exist_ok=True) + plan_path.write_text(plan_content, encoding="utf-8") + return ToolContext( + session_id="plan-exit-session", + message_id="message-1", + agent="rex", + extra={ + "execution_mode": "plan", + "workspace_dir": str(tmp_path), + "model": {"providerID": "openai", "modelID": "gpt-test"}, + "plan_file_path": str(plan_path), + "plan_relative_path": ".flocks/plans/1234-plan.md", + "plan_permission_path": ".flocks/plans/1234-plan.md", + }, + event_publish_callback=publish, + ) + + +@pytest.mark.asyncio +async def test_plan_exit_approval_continues_immediately_in_build( + monkeypatch, + tmp_path, +) -> None: + events: list[tuple[str, dict]] = [] + + async def approve(*_args, **_kwargs): + return ToolResult( + success=True, + output="approved", + metadata={"answers": [[plan_exit.START_IMPLEMENTING]]}, + ) + + monkeypatch.setattr(plan_exit, "question_tool", approve) + + result = await plan_exit.plan_exit_tool(_context(events, tmp_path)) + messages = await Message.list("plan-exit-session") + build_message = messages[-1] + parts = await Message.parts(build_message.id, "plan-exit-session") + + assert result.success + assert result.metadata["approved"] is True + assert await InteractionQueue.list("plan-exit-session") == [] + assert build_message.role == MessageRole.USER + assert build_message.executionMode == SessionExecutionMode.BUILD + assert build_message.agent == "rex" + assert build_message.model == {"providerID": "openai", "modelID": "gpt-test"} + assert parts[0].synthetic is True + assert ".flocks/plans/1234-plan.md" in parts[0].text + assert result.metadata["planPath"] == ".flocks/plans/1234-plan.md" + assert [event_type for event_type, _ in events] == [ + "session.execution_mode.changed", + ] + + +@pytest.mark.asyncio +async def test_plan_exit_decline_stays_in_plan(monkeypatch, tmp_path) -> None: + async def decline(*_args, **_kwargs): + return ToolResult( + success=True, + output="declined", + metadata={"answers": [[plan_exit.CONTINUE_PLANNING]]}, + ) + + monkeypatch.setattr(plan_exit, "question_tool", decline) + + result = await plan_exit.plan_exit_tool(_context([], tmp_path)) + + assert result.success + assert result.metadata == {"approved": False, "executionMode": "plan"} + assert await InteractionQueue.list("plan-exit-session") == [] + + +@pytest.mark.asyncio +async def test_plan_exit_returns_continue_planning_feedback( + monkeypatch, + tmp_path, +) -> None: + async def provide_feedback(*_args, **_kwargs): + return ToolResult( + success=True, + output="feedback", + metadata={ + "answers": [ + [ + plan_exit.CONTINUE_PLANNING, + "Keep the public API unchanged.", + ] + ] + }, + ) + + monkeypatch.setattr(plan_exit, "question_tool", provide_feedback) + + result = await plan_exit.plan_exit_tool(_context([], tmp_path)) + + assert result.success + assert result.metadata == { + "approved": False, + "executionMode": "plan", + "feedback": "Keep the public API unchanged.", + } + assert "Keep the public API unchanged." in result.output + assert await InteractionQueue.list("plan-exit-session") == [] + + +@pytest.mark.asyncio +async def test_plan_exit_does_not_approve_deferred_channel_question( + monkeypatch, + tmp_path, +) -> None: + async def deferred(*_args, **_kwargs): + return ToolResult( + success=True, + output="sent", + metadata={"deferred": True}, + ) + + monkeypatch.setattr(plan_exit, "question_tool", deferred) + + result = await plan_exit.plan_exit_tool(_context([], tmp_path)) + + assert result.metadata["deferred"] is True + assert await InteractionQueue.list("plan-exit-session") == [] + + +@pytest.mark.asyncio +async def test_plan_exit_requires_non_empty_plan_file(tmp_path) -> None: + missing_ctx = _context([], tmp_path) + missing_path = missing_ctx.extra["plan_file_path"] + Path(missing_path).unlink() + + missing = await plan_exit.plan_exit_tool(missing_ctx) + empty = await plan_exit.plan_exit_tool( + _context([], tmp_path, plan_content=" \n") + ) + + assert not missing.success + assert "Write the session plan file" in (missing.error or "") + assert not empty.success + assert "empty" in (empty.error or "") diff --git a/tests/tool/test_question_channel.py b/tests/tool/test_question_channel.py index 50d5b6fee..963335f24 100644 --- a/tests/tool/test_question_channel.py +++ b/tests/tool/test_question_channel.py @@ -21,6 +21,14 @@ def test_normalize_question_option_accepts_common_llm_shapes() -> None: "label": "Only descriptive text", "description": "", } + assert normalize_question_option({ + "label": "调整计划", + "allowText": True, + }) == { + "label": "调整计划", + "description": "", + "allowText": True, + } assert normalize_question_option({"label": ""}) is None diff --git a/webui/src/api/session.ts b/webui/src/api/session.ts index a034889b6..fb6fa077d 100644 --- a/webui/src/api/session.ts +++ b/webui/src/api/session.ts @@ -1,4 +1,5 @@ import client from './client'; +import type { SessionExecutionMode } from '@/utils/sessionExecutionMode'; export interface SessionMessagePartPayload { id: string; @@ -29,6 +30,7 @@ export interface QueuedPrompt { status: 'pending' | 'executing' | string; createdAt: number; updatedAt: number; + executionMode?: SessionExecutionMode; } export interface PromptQueueResponse { @@ -221,6 +223,7 @@ export const sessionApi = { model?: Record; variant?: string; displayText?: string; + executionMode?: SessionExecutionMode; }) => { const response = await client.post(`/api/session/${sessionId}/prompt_queue`, data); return response.data; diff --git a/webui/src/components/common/QuestionTool.test.tsx b/webui/src/components/common/QuestionTool.test.tsx index 5399e521b..4be6c0cc6 100644 --- a/webui/src/components/common/QuestionTool.test.tsx +++ b/webui/src/components/common/QuestionTool.test.tsx @@ -195,6 +195,37 @@ describe('QuestionTool', () => { expect(screen.queryByRole('button', { name: /自定义 \/ 补充说明/ })).not.toBeInTheDocument(); }); + it('accepts feedback through the continue-planning option', async () => { + const user = userEvent.setup(); + const onAnswer = vi.fn().mockResolvedValue(undefined); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: /调整计划/ })); + await user.type(screen.getByRole('textbox'), 'Keep the public API unchanged.'); + await user.click(screen.getByRole('button', { name: /确认/ })); + + expect(onAnswer).toHaveBeenCalledWith([ + ['调整计划', 'Keep the public API unchanged.'], + ]); + }); + it('falls back to text input when a choice question has no visible options', async () => { const user = userEvent.setup(); const onAnswer = vi.fn().mockResolvedValue(undefined); diff --git a/webui/src/components/common/QuestionTool.tsx b/webui/src/components/common/QuestionTool.tsx index 99ac8f631..79117e314 100644 --- a/webui/src/components/common/QuestionTool.tsx +++ b/webui/src/components/common/QuestionTool.tsx @@ -23,6 +23,8 @@ export type QuestionType = 'choice' | 'text' | 'number' | 'file' | 'confirm' | ' export interface QuestionOption { label?: string; description?: string; + /** Show a text input and submit both the option label and entered text. */ + allowText?: boolean; [key: string]: unknown; } @@ -95,10 +97,14 @@ function optionDescription(opt: QuestionOption | string): string { return ''; } +function optionAllowsText(opt: QuestionOption | string): boolean { + return typeof opt !== 'string' && opt.allowText === true; +} + const CUSTOM_CHOICE_PREFIX = '__flocks_custom_choice__:'; function isCustomChoiceLabel(label: string): boolean { - return /^(其他|其它|自定义|补充)|\b(other|custom)\b|请补充|补充说明|type your answer/i.test(label.trim()); + return /^(其他|其它|自定义|补充)|\b(other|custom|feedback)\b|请补充|补充说明|type your answer/i.test(label.trim()); } function customChoiceValue(text: string): string { @@ -187,22 +193,26 @@ function ChoiceInput({ .map(opt => ({ label: optionLabel(opt), description: optionDescription(opt), + allowText: optionAllowsText(opt), custom: false, })) .filter(opt => opt.label); - const hasProvidedCustomOption = visibleOptions.some(opt => isCustomChoiceLabel(opt.label)); + const hasProvidedCustomOption = visibleOptions.some( + opt => opt.allowText || isCustomChoiceLabel(opt.label), + ); const options = shouldOfferCustomChoice(q) && !hasProvidedCustomOption ? [ ...visibleOptions, { label: t('question.customAnswer'), description: t('question.textPlaceholder'), + allowText: false, custom: true, }, ] : visibleOptions.map(opt => ({ ...opt, - custom: isCustomChoiceLabel(opt.label), + custom: opt.allowText || isCustomChoiceLabel(opt.label), })); const customSelected = hasCustomChoice(answer); const customText = customChoiceText(answer); @@ -213,7 +223,15 @@ function ChoiceInput({ onChange([label]); } }; - const toggleCustom = () => { + const toggleCustom = (label: string, preserveLabel: boolean) => { + if (preserveLabel) { + onChange( + customSelected && answer.includes(label) + ? [] + : [label, customChoiceValue(customText)], + ); + return; + } if (multiple) { if (customSelected) { onChange(answer.filter(value => !isCustomChoiceValue(value))); @@ -224,8 +242,12 @@ function ChoiceInput({ } onChange(customSelected ? [] : [customChoiceValue(customText)]); }; - const setCustomText = (text: string) => { + const setCustomText = (label: string, preserveLabel: boolean, text: string) => { const nextCustom = customChoiceValue(text); + if (preserveLabel) { + onChange([label, nextCustom]); + return; + } if (multiple) { const withoutCustom = answer.filter(value => !isCustomChoiceValue(value)); onChange([...withoutCustom, nextCustom]); @@ -244,11 +266,17 @@ function ChoiceInput({ {options.map(opt => { const label = opt.label; const desc = opt.description; - const selected = opt.custom ? customSelected : answer.includes(label); + const selected = opt.custom + ? customSelected && (!opt.allowText || answer.includes(label)) + : answer.includes(label); return (
+ +
); }, @@ -376,6 +411,94 @@ describe('SessionPage session actions menu', () => { vi.stubGlobal('confirm', vi.fn(() => true)); }); + it('renders Build before the Agent selector by default', async () => { + renderSessionPage(); + + const modeButton = await screen.findByRole('button', { name: 'executionMode.title' }); + const agentButton = screen.getByRole('button', { name: /Rex/i }); + + expect(screen.getByTestId('session-chat')).toHaveAttribute('data-execution-mode', 'build'); + expect( + modeButton.compareDocumentPosition(agentButton) + & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it('persists Plan per session', async () => { + const user = userEvent.setup(); + renderSessionPage('/sessions?session=session-1'); + + const modeButton = await screen.findByRole('button', { name: 'executionMode.title' }); + await user.click(modeButton); + await user.click(screen.getByRole('menuitemradio', { + name: /executionMode.options.plan.label/, + })); + + expect(screen.getByTestId('session-chat')).toHaveAttribute('data-execution-mode', 'plan'); + expect(localStorage.getItem('flocks:session-execution-mode:session-1')).toBe('plan'); + }); + + it('restores a persisted session execution mode', async () => { + localStorage.setItem('flocks:session-execution-mode:session-1', 'plan'); + renderSessionPage('/sessions?session=session-1'); + + await waitFor(() => { + expect(screen.getByTestId('session-chat')).toHaveAttribute('data-execution-mode', 'plan'); + }); + }); + + it('switches an approved Plan to Build from the session event', async () => { + const user = userEvent.setup(); + renderSessionPage('/sessions?session=session-1'); + + await user.click(await screen.findByRole('button', { name: 'executionMode.title' })); + await user.click(screen.getByRole('menuitemradio', { + name: /executionMode.options.plan.label/, + })); + expect(screen.getByTestId('session-chat')).toHaveAttribute('data-execution-mode', 'plan'); + + await user.click(screen.getByRole('button', { name: 'mock-plan-approved' })); + + expect(screen.getByTestId('session-chat')).toHaveAttribute('data-execution-mode', 'build'); + expect(localStorage.getItem('flocks:session-execution-mode:session-1')).toBeNull(); + }); + + it('resets Goal to Build after the prompt is accepted', async () => { + const user = userEvent.setup(); + renderSessionPage('/sessions?session=session-1'); + + await user.click(await screen.findByRole('button', { name: 'executionMode.title' })); + await user.click(screen.getByRole('menuitemradio', { + name: /executionMode.options.goal.label/, + })); + expect(screen.getByTestId('session-chat')).toHaveAttribute('data-execution-mode', 'goal'); + + await user.click(screen.getByRole('button', { name: 'mock-accept-mode' })); + + expect(screen.getByTestId('session-chat')).toHaveAttribute('data-execution-mode', 'build'); + expect(localStorage.getItem('flocks:session-execution-mode:session-1')).toBeNull(); + }); + + it('promotes a draft Plan mode when the first message creates a session', async () => { + const user = userEvent.setup(); + renderSessionPage(); + + await user.click(await screen.findByRole('button', { name: 'executionMode.title' })); + await user.click(screen.getByRole('menuitemradio', { + name: /executionMode.options.plan.label/, + })); + await user.click(screen.getByRole('button', { name: 'mock-create-and-send' })); + + await waitFor(() => { + expect(client.post).toHaveBeenCalledWith( + '/api/session/session-2/prompt_async', + expect.objectContaining({ executionMode: 'plan' }), + ); + }); + expect(localStorage.getItem('flocks:session-execution-mode:session-2')).toBe('plan'); + expect(localStorage.getItem('flocks:session-execution-mode:draft')).toBeNull(); + }); + it('keeps the workbench visible and shows a page refresh state while sessions load', () => { useSessions.mockReturnValue({ sessions: [], diff --git a/webui/src/pages/Session/index.tsx b/webui/src/pages/Session/index.tsx index bb3bd9503..30ff45ac1 100644 --- a/webui/src/pages/Session/index.tsx +++ b/webui/src/pages/Session/index.tsx @@ -4,8 +4,9 @@ import { ChevronDown, ChevronRight, Sparkles, Shield, Search, AlertTriangle, PanelLeftClose, PanelLeft, Bot, Loader2, Workflow as WorkflowIcon, Settings2, CheckSquare, - MoreHorizontal, PencilLine, Download, Share2, Cpu, Info, X, Check, + MoreHorizontal, PencilLine, Download, Share2, Cpu, Info, X, FolderGit2, FolderPlus, FolderOpen, Copy, ArrowUp, HardDrive, + Hammer, ClipboardList, Target, Check, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; @@ -36,6 +37,15 @@ import { getAgentDisplayDescription, getAgentDisplayName, isAgentUsableInChat } import { formatRelativeTime, formatSessionDate } from '@/utils/time'; import type { ModelDefinitionV2, Session } from '@/types'; import { useAuth } from '@/contexts/AuthContext'; +import { + DEFAULT_SESSION_EXECUTION_MODE, + promoteDraftExecutionMode, + readSessionExecutionMode, + resetDraftExecutionMode, + writeSessionExecutionMode, + type PersistentSessionExecutionMode, + type SessionExecutionMode, +} from '@/utils/sessionExecutionMode'; function sanitizeSessionExportName(value: string) { const trimmed = value.trim(); @@ -53,7 +63,20 @@ const INSTALLED_HUB_STATES = new Set(['installed', 'localOnly', 'updateAvailable const SESSION_UPDATE_REFETCH_DEBOUNCE_MS = 500; const AUTO_MODEL_KEY = '__flocks_auto__'; const TASK_SESSION_GROUP_ID = 'tasks'; +const SESSION_EXECUTION_MODES: SessionExecutionMode[] = ['build', 'plan', 'goal']; type AgentSourceFilter = 'all' | 'builtin' | 'custom'; + +function ExecutionModeIcon({ + mode, + className = 'h-3 w-3', +}: { + mode: SessionExecutionMode; + className?: string; +}) { + if (mode === 'plan') return ; + if (mode === 'goal') return ; + return ; +} type ProjectSummary = { id: string; worktree: string; @@ -464,6 +487,14 @@ export default function SessionPage() { const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [selectedAgent, setSelectedAgent] = useState('rex'); const [showAgentOptions, setShowAgentOptions] = useState(false); + const [selectedExecutionMode, setSelectedExecutionMode] = useState( + () => readSessionExecutionMode(null), + ); + const [showExecutionModeOptions, setShowExecutionModeOptions] = useState(false); + const executionModeHandoffRef = useRef<{ + sessionId: string; + mode: SessionExecutionMode; + } | null>(null); const [showProjectOptions, setShowProjectOptions] = useState(false); const [selectedModelKey, setSelectedModelKey] = useState(null); const [showModelOptions, setShowModelOptions] = useState(false); @@ -906,6 +937,19 @@ export default function SessionPage() { }, []); const handleSSEEvent = useCallback((event: SSEChatEvent) => { + if ( + event.type === 'session.execution_mode.changed' + && event.properties?.sessionID === selectedSessionId + && event.properties?.executionMode === 'build' + ) { + setSelectedExecutionMode(DEFAULT_SESSION_EXECUTION_MODE); + writeSessionExecutionMode( + selectedSessionId, + DEFAULT_SESSION_EXECUTION_MODE, + ); + setShowExecutionModeOptions(false); + return; + } if (event.type === 'session.notice' && event.properties?.kind === 'directory-fallback') { toast.warning( t('projectDirectoryFallbackTitle'), @@ -925,7 +969,13 @@ export default function SessionPage() { // those bursts don't turn into a request/re-render storm. scheduleSessionListRefetch(); } - }, [scheduleSessionListRefetch, t, toast, updateSessionTitle]); + }, [ + scheduleSessionListRefetch, + selectedSessionId, + t, + toast, + updateSessionTitle, + ]); useEffect(() => { void fetchProjects(undefined, searchQuery); @@ -1033,6 +1083,16 @@ export default function SessionPage() { writeLastSelectedSessionId(selectedSessionId); }, [selectedSession?.id, selectedSessionId]); + useEffect(() => { + const handoff = executionModeHandoffRef.current; + if (selectedSessionId && handoff?.sessionId === selectedSessionId) { + executionModeHandoffRef.current = null; + setSelectedExecutionMode(handoff.mode); + return; + } + setSelectedExecutionMode(readSessionExecutionMode(selectedSessionId)); + }, [selectedSessionId]); + useEffect(() => { if (!selectedSessionId) { setSelectedSessionFallback(null); @@ -1078,6 +1138,25 @@ export default function SessionPage() { return () => document.removeEventListener('mousedown', handle); }, [showAgentOptions]); + useEffect(() => { + if (!showExecutionModeOptions) return; + const handlePointerDown = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if (!target.closest('[data-execution-mode-selector]')) { + setShowExecutionModeOptions(false); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setShowExecutionModeOptions(false); + }; + document.addEventListener('mousedown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [showExecutionModeOptions]); + useEffect(() => { if (!showProjectOptions) return; const handle = (e: MouseEvent) => { @@ -1141,9 +1220,19 @@ export default function SessionPage() { }, [chatModelOptions, loadingEnabledModels, selectedModelAuto, selectedModelKey]); useEffect(() => { - if (showAgentOptions || showModelOptions || showProjectOptions) return; + if ( + showAgentOptions + || showExecutionModeOptions + || showModelOptions + || showProjectOptions + ) return; setSelectorTooltip(null); - }, [showAgentOptions, showModelOptions, showProjectOptions]); + }, [ + showAgentOptions, + showExecutionModeOptions, + showModelOptions, + showProjectOptions, + ]); useEffect(() => { if (!openMenuSessionId) return; @@ -1171,6 +1260,26 @@ export default function SessionPage() { setRenameValue(''); }, [selectMode]); + const handleSelectExecutionMode = useCallback((mode: SessionExecutionMode) => { + setSelectedExecutionMode(mode); + setShowExecutionModeOptions(false); + if (mode !== 'goal') { + writeSessionExecutionMode( + selectedSessionId, + mode as PersistentSessionExecutionMode, + ); + } + }, [selectedSessionId]); + + const handleExecutionModeAccepted = useCallback((mode: SessionExecutionMode) => { + if (mode !== 'goal') return; + setSelectedExecutionMode(DEFAULT_SESSION_EXECUTION_MODE); + writeSessionExecutionMode( + selectedSessionId, + DEFAULT_SESSION_EXECUTION_MODE, + ); + }, [selectedSessionId]); + const handleStartNewSession = useCallback(() => { writeLastSelectedSessionId(null); setSelectedSessionId(null); @@ -1207,6 +1316,17 @@ export default function SessionPage() { return next; }); setSelectedAgent('rex'); + executionModeHandoffRef.current = { + sessionId: response.data.id, + mode: DEFAULT_SESSION_EXECUTION_MODE, + }; + resetDraftExecutionMode(); + writeSessionExecutionMode( + response.data.id, + DEFAULT_SESSION_EXECUTION_MODE, + ); + setSelectedExecutionMode(DEFAULT_SESSION_EXECUTION_MODE); + setShowExecutionModeOptions(false); setSelectedModelKey(carryAutoSelection ? AUTO_MODEL_KEY : null); setSelectedSessionId(response.data.id); } catch (err: any) { @@ -1265,8 +1385,10 @@ export default function SessionPage() { agentOverride?: string, modelOverride?: { providerID: string; modelID: string } | null, options?: PromptDisplayOptions, + executionModeOverride?: SessionExecutionMode, ) => { try { + const effectiveExecutionMode = executionModeOverride || selectedExecutionMode; const response = await client.post('/api/session', { title: 'New Session', ...(selectedProjectIDForCreate ? { projectID: selectedProjectIDForCreate } : {}), @@ -1277,6 +1399,22 @@ export default function SessionPage() { addSession(response.data); await fetchProjects(undefined, searchQuery); setSelectedSessionFallback(response.data); + executionModeHandoffRef.current = { + sessionId: newSessionId, + mode: effectiveExecutionMode, + }; + if (effectiveExecutionMode === 'goal') { + writeSessionExecutionMode( + newSessionId, + DEFAULT_SESSION_EXECUTION_MODE, + ); + resetDraftExecutionMode(); + } else { + promoteDraftExecutionMode( + newSessionId, + effectiveExecutionMode as PersistentSessionExecutionMode, + ); + } setSelectedModelKey(selectedModelAuto ? AUTO_MODEL_KEY : null); setSelectedSessionId(newSessionId); @@ -1287,13 +1425,30 @@ export default function SessionPage() { if (effectiveAgent) payload.agent = effectiveAgent; if (!selectedModelAuto && modelOverride) payload.model = modelOverride; if (options?.displayText) payload.displayText = options.displayText; - client.post(`/api/session/${newSessionId}/prompt_async`, payload).catch((err: any) => { - toast.error(t('chat.sendFailed', 'Send failed'), err.message); - }); + payload.executionMode = effectiveExecutionMode; + await client.post(`/api/session/${newSessionId}/prompt_async`, payload); + if (effectiveExecutionMode === 'goal') { + setSelectedExecutionMode(DEFAULT_SESSION_EXECUTION_MODE); + writeSessionExecutionMode( + newSessionId, + DEFAULT_SESSION_EXECUTION_MODE, + ); + } } catch (err: any) { - toast.error(t('createFailed'), err.message); + toast.error(t('chat.sendFailed', 'Send failed'), err.message); + throw err; } - }, [addSession, fetchProjects, searchQuery, selectedAgent, selectedModelAuto, selectedProjectIDForCreate, toast, t]); + }, [ + addSession, + fetchProjects, + searchQuery, + selectedAgent, + selectedExecutionMode, + selectedModelAuto, + selectedProjectIDForCreate, + toast, + t, + ]); const handleSuiteInstallProgress = useCallback((progress: HubInstallProgressEvent) => { setSuiteInstallProgress(current => applySuiteInstallProgressEvent(current, progress)); @@ -2342,6 +2497,8 @@ export default function SessionPage() { processGroupsOpenWhileActive: true, }} agentName={selectedAgent} + executionMode={selectedExecutionMode} + onExecutionModeAccepted={handleExecutionModeAccepted} mentionAgents={chatAgents} className="flex-1 min-h-0" composerTextareaMinHeight={56} @@ -2371,14 +2528,90 @@ export default function SessionPage() { /> )} toolbarSlot={ - <> - {!activeChatSessionId && ( +
+
+ + {showExecutionModeOptions && ( +
+
+
+ {t('executionMode.title')} +
+
+ {t('executionMode.hint')} +
+
+
+ {SESSION_EXECUTION_MODES.map((mode) => { + const selected = selectedExecutionMode === mode; + return ( + + ); + })} +
+
+ )} +
+ {!activeChatSessionId && (
)}
- )} -
+ )} +
)} + - } centerToolbarSlot={
@@ -2565,6 +2799,7 @@ export default function SessionPage() { onClick={() => { if (!showModelOptions) updateModelMenuLeftOffset(); setShowModelOptions(!showModelOptions); + setShowExecutionModeOptions(false); setShowProjectOptions(false); setShowAgentOptions(false); }} diff --git a/webui/src/utils/sessionExecutionMode.test.ts b/webui/src/utils/sessionExecutionMode.test.ts new file mode 100644 index 000000000..98b3ca40d --- /dev/null +++ b/webui/src/utils/sessionExecutionMode.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + DEFAULT_SESSION_EXECUTION_MODE, + EXECUTION_MODE_DRAFT_STORAGE_KEY, + EXECUTION_MODE_STORAGE_PREFIX, + promoteDraftExecutionMode, + readSessionExecutionMode, + resetDraftExecutionMode, + writeSessionExecutionMode, +} from './sessionExecutionMode'; + +describe('sessionExecutionMode storage', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('defaults new and existing composers to Build', () => { + expect(readSessionExecutionMode()).toBe(DEFAULT_SESSION_EXECUTION_MODE); + expect(readSessionExecutionMode('session-1')).toBe(DEFAULT_SESSION_EXECUTION_MODE); + }); + + it('persists Plan independently by session', () => { + writeSessionExecutionMode('session-1', 'plan'); + + expect(readSessionExecutionMode('session-1')).toBe('plan'); + }); + + it('promotes the draft mode to a newly created session', () => { + writeSessionExecutionMode(null, 'plan'); + promoteDraftExecutionMode('session-new', 'plan'); + + expect(readSessionExecutionMode('session-new')).toBe('plan'); + expect(localStorage.getItem(EXECUTION_MODE_DRAFT_STORAGE_KEY)).toBeNull(); + }); + + it('stores Build by removing the override', () => { + localStorage.setItem(`${EXECUTION_MODE_STORAGE_PREFIX}session-1`, 'plan'); + writeSessionExecutionMode('session-1', 'build'); + writeSessionExecutionMode(null, 'plan'); + resetDraftExecutionMode(); + + expect(localStorage.getItem(`${EXECUTION_MODE_STORAGE_PREFIX}session-1`)).toBeNull(); + expect(localStorage.getItem(EXECUTION_MODE_DRAFT_STORAGE_KEY)).toBeNull(); + }); + + it('ignores removed and one-shot persisted modes', () => { + localStorage.setItem(`${EXECUTION_MODE_STORAGE_PREFIX}session-1`, 'ask'); + expect(readSessionExecutionMode('session-1')).toBe('build'); + + localStorage.setItem(`${EXECUTION_MODE_STORAGE_PREFIX}session-1`, 'goal'); + expect(readSessionExecutionMode('session-1')).toBe('build'); + }); +}); diff --git a/webui/src/utils/sessionExecutionMode.ts b/webui/src/utils/sessionExecutionMode.ts new file mode 100644 index 000000000..386eb56b5 --- /dev/null +++ b/webui/src/utils/sessionExecutionMode.ts @@ -0,0 +1,62 @@ +export type SessionExecutionMode = 'build' | 'plan' | 'goal'; +export type PersistentSessionExecutionMode = Exclude; + +export const DEFAULT_SESSION_EXECUTION_MODE: PersistentSessionExecutionMode = 'build'; +export const EXECUTION_MODE_STORAGE_PREFIX = 'flocks:session-execution-mode:'; +export const EXECUTION_MODE_DRAFT_STORAGE_KEY = `${EXECUTION_MODE_STORAGE_PREFIX}draft`; + +function isPersistentMode(value: unknown): value is PersistentSessionExecutionMode { + return value === 'build' || value === 'plan'; +} + +function storageKey(sessionId?: string | null): string { + return sessionId + ? `${EXECUTION_MODE_STORAGE_PREFIX}${sessionId}` + : EXECUTION_MODE_DRAFT_STORAGE_KEY; +} + +export function readSessionExecutionMode( + sessionId?: string | null, +): PersistentSessionExecutionMode { + if (typeof window === 'undefined') return DEFAULT_SESSION_EXECUTION_MODE; + try { + const stored = window.localStorage.getItem(storageKey(sessionId)); + return isPersistentMode(stored) ? stored : DEFAULT_SESSION_EXECUTION_MODE; + } catch { + return DEFAULT_SESSION_EXECUTION_MODE; + } +} + +export function writeSessionExecutionMode( + sessionId: string | null | undefined, + mode: PersistentSessionExecutionMode, +): void { + if (typeof window === 'undefined') return; + try { + const key = storageKey(sessionId); + if (mode === DEFAULT_SESSION_EXECUTION_MODE) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, mode); + } + } catch { + // Composer preferences must never block the chat flow. + } +} + +export function promoteDraftExecutionMode( + sessionId: string, + mode: PersistentSessionExecutionMode, +): void { + writeSessionExecutionMode(sessionId, mode); + if (typeof window === 'undefined') return; + try { + window.localStorage.removeItem(EXECUTION_MODE_DRAFT_STORAGE_KEY); + } catch { + // Composer preferences must never block the chat flow. + } +} + +export function resetDraftExecutionMode(): void { + writeSessionExecutionMode(null, DEFAULT_SESSION_EXECUTION_MODE); +}