From 81e62d4eeeb1aaf267e06544ef94594b1a45de95 Mon Sep 17 00:00:00 2001 From: nasser1941 <9349840+nasser1941@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:19:46 +0200 Subject: [PATCH] Add lcode acp: lcode inside editors over the Agent Client Protocol lcode acp speaks ACP version 1 over stdio, so Zed, JetBrains IDEs, Neovim, Emacs and other ACP editors can run lcode as their agent. The model's text and thinking stream as message chunks; tool calls carry their kind, files and whole-file diffs; permission questions and plan approvals use the editor's dialog; lcode's permission modes are the session's modes; the todo list is the editor's plan; saved sessions load with their conversation replayed. Files are read from and written through the editor's buffers when it supports that, and MCP servers the editor passes join lcode's own. /review and your own commands are offered as commands. Cancelling now also ends a request whose answer was cut off mid-stream. Closes #49 Co-Authored-By: Claude Opus 5.5 --- README.md | 2 + docs/editors.md | 85 ++++++ docs/usage.md | 1 + mkdocs.yml | 1 + pyproject.toml | 1 + src/lcode/acp.py | 635 +++++++++++++++++++++++++++++++++++++++ src/lcode/agent.py | 11 + src/lcode/cli.py | 12 + src/lcode/permissions.py | 8 +- src/lcode/planning.py | 5 + src/lcode/tools.py | 25 +- tests/test_acp.py | 267 ++++++++++++++++ tests/test_api.py | 7 +- 13 files changed, 1051 insertions(+), 9 deletions(-) create mode 100644 docs/editors.md create mode 100644 src/lcode/acp.py create mode 100644 tests/test_acp.py diff --git a/README.md b/README.md index 433912f..7a9c2a2 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ through [Ollama](https://ollama.com), and when it needs current information it c - **Git workflow.** `/commit` writes messages in your repository's style, `/review` checks the changes with `file:line` findings, `/pr` opens the pull request, each after you approve it, and `lcode --worktree` runs parallel sessions on one repository. +- **In your editor.** Zed, JetBrains IDEs, Neovim and other editors that speak the Agent Client + Protocol run lcode with `lcode acp`, with diffs and approvals in the editor. - **Automation.** `lcode -p --output json` for scripts and CI, a Python API (`from lcode import Session`), and a GitHub Action that answers `@lcode` in issues and pull requests on your own self-hosted runner. diff --git a/docs/editors.md b/docs/editors.md new file mode 100644 index 0000000..48c94ef --- /dev/null +++ b/docs/editors.md @@ -0,0 +1,85 @@ +# Editors + +lcode also works inside editors that support the +[Agent Client Protocol](https://agentclientprotocol.com) (ACP), such as Zed, JetBrains IDEs, +Neovim and Emacs. The editor starts `lcode acp` and shows lcode's work in its own UI: answers, +tool calls, a diff for each edit, and permission questions. + +## Zed + +Add lcode to Zed's `settings.json`: + +```json +{ + "agent_servers": { + "lcode": { + "type": "custom", + "command": "lcode", + "args": ["acp"], + "env": {} + } + } +} +``` + +Then open the Agent Panel and start a new thread with **lcode** from the new-thread menu. + +- **Zed can't start lcode:** use the full path in `command`, from `which lcode` (usually + `~/.local/bin/lcode`). An editor started from the desktop may not see your shell's `PATH`. +- **Something goes wrong:** run **dev: open acp logs** in Zed's command palette to see the + messages lcode and Zed exchange. + +## JetBrains IDEs + +Add lcode to `~/.jetbrains/acp.json`, with the full path from `which lcode`: + +```json +{ + "agent_servers": { + "lcode": { + "command": "/home/you/.local/bin/lcode", + "args": ["acp"] + } + } +} +``` + +Then pick lcode in the AI Chat tool window. + +## Other editors + +Any [ACP client](https://agentclientprotocol.com/overview/clients) works the same way: tell it +to run `lcode acp`. That includes Neovim (CodeCompanion, avante.nvim), Emacs (agent-shell), +VS Code extensions and more. + +## What the editor shows + +| In the editor | From lcode | +|---|---| +| The conversation | lcode's answers and its thinking, as they stream | +| Tool calls | Each tool call, with its kind (read, search, edit, run, fetch) and the files it touches | +| Edits | A diff of the whole file for each edit | +| Open files | Edits go through the editor, so open files update right away. The model reads what's in the editor, unsaved changes included | +| Permission questions | **Allow**, **Always allow** (for this session) or **Reject**, in the editor's own dialog | +| Modes | lcode's [permission modes](usage.md#permissions): Ask, Auto-edit, Plan and Yolo | +| Plans | In Plan mode, the plan is approved in the editor, which then switches to Ask or Auto-edit | +| The plan list | The model's todo list | +| Context | How much of the context window is in use | +| Commands | `/review` and [your own commands](commands.md) | +| Past threads | lcode's saved sessions: an editor that reopens a thread gets its conversation back | + +[Checkpoints](usage.md#undo-and-checkpoints), [hooks and permission rules](hooks.md), +[memory](memory.md), [subagents](agents.md) and [MCP servers](mcp.md) work as in the terminal. +MCP servers the editor passes to its agents are added to lcode's own. + +## Settings + +lcode uses your [configuration](configuration.md): the model, the context window, the +[model server](servers.md) and the default permission mode. To use different settings in an +editor, set them in the agent's `env`, for example `"env": {"LCODE_MODEL": "qwen3.5-9b"}`. + +A repository's own commands, skills, agents and hooks are used only after you +[approve them](commands.md#approving-a-repositorys-commands-skills-and-agents). An editor can't +ask, so run `lcode` in the repository once and approve them there. + +To see lcode's own output while it runs in an editor, set `LCODE_ACP_LOG` to a file in `env`. diff --git a/docs/usage.md b/docs/usage.md index b7e8aae..713d385 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -10,6 +10,7 @@ lcode doctor # check the installation (include this in bug repor lcode config [set|unset] # show or change settings lcode bench [models] # score models on small coding tasks on this machine lcode mcp [catalog|add|list] # connect MCP servers: Jira, GitHub, AWS, databases… (see MCP servers) +lcode acp # run inside an editor over the Agent Client Protocol (see Editors) ``` | Option | Meaning | diff --git a/mkdocs.yml b/mkdocs.yml index a5d58b6..2706b40 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -97,6 +97,7 @@ nav: - Commands and skills: commands.md - Git workflow: git.md - Automation: automation.md + - Editors: editors.md - Code intelligence: lsp.md - Repository map and code search: search.md - Models & context windows: models.md diff --git a/pyproject.toml b/pyproject.toml index e274e19..72b007f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,6 +86,7 @@ ignore = [ "src/lcode/backends.py" = ["E501"] "src/lcode/gitflow.py" = ["E501"] "src/lcode/action.py" = ["E501"] +"src/lcode/acp.py" = ["E501"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/lcode/acp.py b/src/lcode/acp.py new file mode 100644 index 0000000..3b9c010 --- /dev/null +++ b/src/lcode/acp.py @@ -0,0 +1,635 @@ +"""`lcode acp`: lcode as the agent inside an editor, over the Agent Client Protocol (ACP, version 1). + +The editor starts `lcode acp` and talks JSON-RPC 2.0 over its stdin and stdout, one message per +line. The editor shows what happens: +- the model's answers and thinking stream as message chunks; +- each tool call is a tool call with its kind, files and, for edits, a diff; +- permission requests and plan approvals use the editor's own dialog; +- the todo list becomes the editor's plan; +- lcode's permission modes are the session's modes. + +Files are read from and written through the editor's buffers when it supports that, so the model +sees unsaved changes and the editor tracks the edits. Everything else (tools, checkpoints, MCP +servers, memory) works as in the terminal. + +Nothing but protocol messages may reach stdout: lcode's own output is discarded (or written to +the file in LCODE_ACP_LOG), and stdin is the protocol channel only. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import sys +import threading +from pathlib import Path +from typing import IO, TYPE_CHECKING, Any +from urllib.parse import unquote, urlparse + +from rich.console import Console + +from lcode import __version__ + +if TYPE_CHECKING: + from lcode.agent import Agent + +PROTOCOL_VERSION = 1 +MODES = [ + ("ask", "Ask", "Ask before changing files or running commands"), + ("auto-edit", "Auto-edit", "Change files without asking; ask before commands"), + ("plan", "Plan", "Look around and plan; change nothing until you approve the plan"), + ("yolo", "Yolo", "Never ask"), +] +KINDS = { + "read_file": "read", "view_image": "read", "list_dir": "search", "glob": "search", "grep": "search", + "repo_map": "search", "search_code": "search", "lsp": "search", "edit_file": "edit", "write_file": "edit", + "bash": "execute", "web_search": "fetch", "web_fetch": "fetch", "agent": "think", "todo_write": "think", + "present_plan": "switch_mode", +} # fmt: skip +STOP = {"success": "end_turn", "max_steps": "max_turn_requests", "interrupted": "cancelled"} +MAX_INLINE = 200_000 # bytes of a file the editor attaches that go into the prompt +METHOD_NOT_FOUND, INVALID_PARAMS, INTERNAL_ERROR, NOT_FOUND = -32601, -32602, -32603, -32002 + + +class RpcError(Exception): + def __init__(self, code: int, message: str): + super().__init__(message) + self.code = code + + +# ----------------------------------------------------------------------------- JSON-RPC over stdio + + +class Connection: + """Newline-delimited JSON-RPC 2.0, both ways: the editor's requests and lcode's own.""" + + def __init__(self, inp: IO[bytes], out: IO[bytes]): + self.inp, self.out = inp, out + self.lock = threading.Lock() + self.pending: dict[int, tuple[threading.Event, dict]] = {} + self.next_id = 0 + self.closed = False + + def send(self, message: dict) -> None: + data = (json.dumps({"jsonrpc": "2.0", **message}, ensure_ascii=False) + "\n").encode() + with self.lock: + self.out.write(data) + self.out.flush() + + def notify(self, method: str, params: dict) -> None: + self.send({"method": method, "params": params}) + + def request(self, method: str, params: dict) -> dict: + """Ask the editor and wait for its answer.""" + with self.lock: + self.next_id += 1 + request_id = self.next_id + done, slot = threading.Event(), {} + self.pending[request_id] = (done, slot) + self.send({"id": request_id, "method": method, "params": params}) + done.wait() + if "error" in slot: + error = slot["error"] or {} + raise RpcError(int(error.get("code", INTERNAL_ERROR)), str(error.get("message", "the editor failed"))) + return slot.get("result") or {} + + def serve(self, handle) -> None: + """Read messages until the editor closes stdin; requests are handled in threads of their own.""" + for raw in self.inp: + line = raw.strip() + if not line: + continue + try: + message = json.loads(line) + except ValueError: + self.send({"id": None, "error": {"code": -32700, "message": "parse error"}}) + continue + if "method" in message: + threading.Thread(target=self._dispatch, args=(handle, message), daemon=True).start() + elif message.get("id") in self.pending: + done, slot = self.pending.pop(message["id"]) + slot.update({k: message[k] for k in ("result", "error") if k in message}) + done.set() + self.closed = True + for done, slot in list(self.pending.values()): # nobody will answer now + slot["error"] = {"code": INTERNAL_ERROR, "message": "the editor closed the connection"} + done.set() + + def _dispatch(self, handle, message: dict) -> None: + request_id = message.get("id") + try: + result = handle(message["method"], message.get("params") or {}) + if request_id is not None: + self.send({"id": request_id, "result": result if result is not None else {}}) + except RpcError as e: + if request_id is not None: + self.send({"id": request_id, "error": {"code": e.code, "message": str(e)}}) + except Exception as e: # never leave the editor waiting + if request_id is not None: + self.send({"id": request_id, "error": {"code": INTERNAL_ERROR, "message": f"{type(e).__name__}: {e}"}}) + + +# ----------------------------------------------------------------------------- one editor session + + +def file_path(uri: str) -> Path | None: + parsed = urlparse(uri) + if parsed.scheme in ("", "file"): + return Path(unquote(parsed.path)) if parsed.path else None + return None + + +def call_title(agent: Agent, name: str, args: dict) -> str: + path = args.get("path") + rel = agent.tools.rel(agent.tools.resolve(path)) if isinstance(path, str) and path else "" + titles = { + "read_file": f"Read {rel}", + "list_dir": f"List {rel or '.'}", + "glob": f"Find {args.get('pattern', '')}", + "grep": f"Search for {args.get('pattern', '')}", + "edit_file": f"Edit {rel}", + "write_file": f"Write {rel}", + "bash": str(args.get("command", "")).strip()[:200], + "web_search": f"Search the web for {args.get('query', '')}", + "web_fetch": f"Fetch {args.get('url', '')}", + "todo_write": "Update the plan", + "present_plan": f"Plan: {args.get('title', '')}", + "agent": f"Subagent: {args.get('description') or args.get('type', '')}", + } + return titles.get(name) or agent.describe_call(name, args) + + +class Session: + """An lcode agent wired to the editor: its output becomes session/update notifications.""" + + def __init__(self, server: Server, agent: Agent): + self.server, self.agent, self.id = server, agent, agent.session_id + self.conn = server.conn + self.before: dict[str, str | None] = {} # file contents before an edit, by tool call id + self.mode = agent.perms.mode + agent.cancel = threading.Event() + agent.on_delta = self.delta + agent.on_event = self.event + agent.on_tool = self.started + agent.perms.approve = self.permission + agent.plan_review = self.review_plan + caps = server.client_caps.get("fs") or {} + if caps.get("readTextFile"): + agent.tools.reader = self.read_buffer + if caps.get("writeTextFile"): + agent.tools.writer = self.write_buffer + + def update(self, update: dict) -> None: + self.conn.notify("session/update", {"sessionId": self.id, "update": update}) + + # -- the model's output + def delta(self, kind: str, text: str) -> None: + chunk = "agent_message_chunk" if kind == "text" else "agent_thought_chunk" + self.update({"sessionUpdate": chunk, "content": {"type": "text", "text": text}}) + + def call(self, call_id: str, name: str, args: dict, status: str) -> dict: + update: dict[str, Any] = { + "toolCallId": call_id, + "title": call_title(self.agent, name, args), + "kind": KINDS.get(name, "other"), + "status": status, + "rawInput": args, + } + path = args.get("path") + if isinstance(path, str) and path: + location: dict[str, Any] = {"path": str(self.agent.tools.resolve(path))} + if isinstance(args.get("offset"), int): + location["line"] = args["offset"] + update["locations"] = [location] + diff = self.proposed_diff(call_id, name, args) + if diff: + update["content"] = [diff] + return update + + def proposed_diff(self, call_id: str, name: str, args: dict) -> dict | None: + """What an edit will change, as the editor's diff: the whole file before and after.""" + if name not in ("edit_file", "write_file") or not isinstance(args.get("path"), str): + return None + path = self.agent.tools.resolve(args["path"]) + try: + old = self.agent.tools.read_text(path) if path.is_file() else None + except OSError: + old = None + if name == "write_file": + new = str(args.get("content", "")) + else: + find, replace = str(args.get("old_string", "")), str(args.get("new_string", "")) + if old is None or not find or find not in old: + return {"type": "diff", "path": str(path), "oldText": find, "newText": replace} + new = old.replace(find, replace) if args.get("replace_all") else old.replace(find, replace, 1) + self.before[call_id] = old + return {"type": "diff", "path": str(path), "oldText": old, "newText": new} + + def event(self, event: dict) -> None: + if event["type"] == "assistant": + for call in event["tool_calls"]: + self.update( + {"sessionUpdate": "tool_call", **self.call(call["id"], call["name"], call["arguments"], "pending")} + ) + elif event["type"] == "tool_result": + update: dict[str, Any] = { + "sessionUpdate": "tool_call_update", + "toolCallId": event["id"], + "status": "failed" if event["error"] else "completed", + "rawOutput": {"output": event["output"]}, + } + content = [{"type": "content", "content": {"type": "text", "text": event["output"]}}] + if event["name"] in ("edit_file", "write_file") and not event["error"]: + path = next((c for c in self.pending_paths(event["id"])), None) + if path is not None: + try: + after = path.read_text(errors="replace") + content = [{"type": "diff", "path": str(path), "oldText": self.before.pop(event["id"], None), + "newText": after}] # fmt: skip + except OSError: + pass + update["content"] = content + self.update(update) + if event["name"] == "todo_write": + self.send_plan() + if self.agent.perms.mode != self.mode: # an approved plan switched the mode + self.mode = self.agent.perms.mode + self.update({"sessionUpdate": "current_mode_update", "currentModeId": self.mode}) + + def pending_paths(self, call_id: str): + for message in reversed(self.agent.messages): + for call in message.get("tool_calls") or []: + if call.get("id") == call_id: + args = call.get("function", {}).get("arguments") or {} + if isinstance(args, dict) and isinstance(args.get("path"), str): + yield self.agent.tools.resolve(args["path"]) + return + + def started(self, name: str, args: dict) -> None: + self.update( + {"sessionUpdate": "tool_call_update", "toolCallId": self.agent.current_call, "status": "in_progress"} + ) + + def send_plan(self) -> None: + entries = [ + {"content": str(t.get("content", "")), "priority": "medium", + "status": t.get("status") if t.get("status") in ("pending", "in_progress", "completed") else "pending"} + for t in self.agent.tools.todos + ] # fmt: skip + self.update({"sessionUpdate": "plan", "entries": entries}) + + # -- asking the user, in the editor + def ask(self, tool_call: dict, options: list[dict]) -> str | None: + """The option the user picked, or None if the request was cancelled.""" + try: + reply = self.conn.request( + "session/request_permission", {"sessionId": self.id, "toolCall": tool_call, "options": options} + ) + except RpcError: + return None + outcome = reply.get("outcome") or {} + return outcome.get("optionId") if outcome.get("outcome") == "selected" else None + + def permission(self, request: dict) -> bool | str: + call_id = self.agent.current_call + name = next( + (c.get("function", {}).get("name", "") for m in reversed(self.agent.messages) + for c in m.get("tool_calls") or [] if c.get("id") == call_id), "", + ) # fmt: skip + args = next( + (c.get("function", {}).get("arguments") or {} for m in reversed(self.agent.messages) + for c in m.get("tool_calls") or [] if c.get("id") == call_id), {}, + ) # fmt: skip + tool_call = self.call(call_id, name, args if isinstance(args, dict) else {}, "pending") if name else { + "toolCallId": call_id or "permission", "title": request["title"], "kind": "other", "status": "pending" + } # fmt: skip + tool_call["title"] = request["title"] + scope = "file edits" if request["kind"] == "edit" else request["key"].split(":", 1)[-1] + choice = self.ask( + tool_call, + [ + {"optionId": "allow", "name": "Allow", "kind": "allow_once"}, + {"optionId": "always", "name": f"Always allow {scope} in this session", "kind": "allow_always"}, + {"optionId": "reject", "name": "Reject", "kind": "reject_once"}, + ], + ) + if choice == "always": + self.agent.perms.always.add("edit" if request["kind"] == "edit" else request["key"]) + if choice in ("allow", "always"): + return True + if choice is None and self.agent.cancel is not None and self.agent.cancel.is_set(): + return "The user denied this: they cancelled the request." + return "The user denied this in the editor. Ask how to proceed, or choose a different approach." + + def review_plan(self, title: str, plan: str) -> str: + tool_call = { + "toolCallId": self.agent.current_call or "plan", + "title": f"Plan: {title}", + "kind": "switch_mode", + "status": "pending", + "content": [{"type": "content", "content": {"type": "text", "text": plan}}], + } + choice = self.ask( + tool_call, + [ + {"optionId": "ask", "name": "Approve, and ask before changes", "kind": "allow_once"}, + {"optionId": "auto-edit", "name": "Approve, and change files without asking", "kind": "allow_always"}, + {"optionId": "keep", "name": "Keep planning", "kind": "reject_once"}, + ], + ) + if choice in ("ask", "auto-edit"): + return choice + return "The user wants to keep planning. Ask what they'd like to change in the plan; don't change anything yet." + + # -- the editor's buffers + def read_buffer(self, path: Path) -> str | None: + try: + return self.conn.request("fs/read_text_file", {"sessionId": self.id, "path": str(path)}).get("content") + except RpcError: + return None # not open in the editor, or not readable there: lcode reads the file itself + + def write_buffer(self, path: Path, content: str) -> None: + try: + self.conn.request("fs/write_text_file", {"sessionId": self.id, "path": str(path), "content": content}) + except RpcError: + pass # lcode writes the file itself + + # -- requests + def prompt(self, blocks: list[dict]) -> dict: + from lcode import api + + text = self.render(blocks) + if not text.strip(): + return {"stopReason": "end_turn"} + command = self.command(text) + if command == "": + return {"stopReason": "end_turn"} + if command is not None: + text = command + self.agent.cancel.clear() + result = api.run_request(self.agent, text) + self.update({"sessionUpdate": "usage_update", "used": self.agent.ctx_used, "size": self.agent.settings.context}) + self.update({"sessionUpdate": "session_info_update", "title": self.agent.session_title or None}) + if result.status == "error": + raise RpcError(INTERNAL_ERROR, result.error) + return {"stopReason": STOP.get(result.status, "end_turn")} + + def command(self, text: str) -> str | None: + """A /command from the editor: the user's own commands, and /review.""" + if not text.startswith("/"): + return None + name, _, arg = text[1:].partition(" ") + commands = self.agent.extensions().commands + if name in commands: + return commands[name].render(arg.strip()) + if name == "review": + from lcode import gitflow + + try: + prompt = gitflow.review_prompt(self.agent, arg.strip()) + except gitflow.GitError as e: + self.delta("text", f"Nothing to review: {e}") + return "" + self.agent.no_changes = "This is a review, so nothing can be changed: report what should change instead." + return prompt + return None + + def render(self, blocks: list[dict]) -> str: + """The editor's prompt (text, attached files, images) as one request for lcode.""" + parts = [] + for block in blocks: + kind = block.get("type") + if kind == "text": + parts.append(str(block.get("text", ""))) + elif kind == "resource_link": + parts.append(self.attached(block.get("uri", ""), None)) + elif kind == "resource": + resource = block.get("resource") or {} + parts.append(self.attached(resource.get("uri", ""), resource.get("text"))) + elif kind == "image" and block.get("data"): + parts.append(self.image(block["data"], block.get("mimeType", "image/png"))) + return "\n\n".join(p for p in parts if p) + + def attached(self, uri: str, text: str | None) -> str: + path = file_path(uri) + where = self.agent.tools.rel(path) if path else uri + if text is None and path is not None and path.is_file() and path.stat().st_size <= MAX_INLINE: + text = self.agent.tools.read_text(path) + if text is None: + return f"[The user points at {where}]" + return f"[The user attached {where}]\n```\n{text[:MAX_INLINE].rstrip(chr(10))}\n```" + + def image(self, data: str, mime: str) -> str: + from lcode import config + + raw = base64.b64decode(data) + folder = config.STATE_DIR / "acp-images" + folder.mkdir(parents=True, exist_ok=True) + path = folder / f"{hashlib.sha256(raw).hexdigest()[:16]}.{mime.split('/')[-1].split('+')[0] or 'png'}" + path.write_bytes(raw) + return f"@{path}" + + def replay(self) -> None: + """A loaded session's conversation, sent to the editor as it happened.""" + results: dict[str, str] = {} + names: dict[str, str] = {} + for message in self.agent.messages[1:]: + role, content = message.get("role"), message.get("content") or "" + if role == "user" and content: + self.update({"sessionUpdate": "user_message_chunk", "content": {"type": "text", "text": content}}) + elif role == "assistant": + if content: + self.update({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": content}}) + for i, call in enumerate(message.get("tool_calls") or []): + call.setdefault("id", f"call_loaded_{len(names)}_{i}") + fn = call.get("function", {}) + args = fn.get("arguments") if isinstance(fn.get("arguments"), dict) else {} + names[call["id"]] = fn.get("name", "") + update = {"sessionUpdate": "tool_call", "toolCallId": call["id"], + "title": call_title(self.agent, fn.get("name", ""), args), + "kind": KINDS.get(fn.get("name", ""), "other"), "status": "completed", "rawInput": args} # fmt: skip + self.update(update) + elif role == "tool": + call_id = message.get("tool_call_id") or next((i for i in reversed(names) if i not in results), "") + if call_id: + results[call_id] = content + self.update({"sessionUpdate": "tool_call_update", "toolCallId": call_id, "status": "completed", + "content": [{"type": "content", "content": {"type": "text", "text": content[:4000]}}]}) # fmt: skip + + +# ----------------------------------------------------------------------------- the agent side + + +class Server: + def __init__(self, conn: Connection, console: Console): + self.conn, self.console = conn, console + self.client_caps: dict = {} + self.sessions: dict[str, Session] = {} + + def handle(self, method: str, params: dict) -> dict | None: + handlers = { + "initialize": self.initialize, + "authenticate": lambda p: {}, + "session/new": self.new_session, + "session/load": self.load_session, + "session/prompt": self.prompt, + "session/cancel": self.cancel, + "session/set_mode": self.set_mode, + } + if method not in handlers: + raise RpcError(METHOD_NOT_FOUND, f"lcode doesn't support {method}") + return handlers[method](params) + + def initialize(self, params: dict) -> dict: + self.client_caps = params.get("clientCapabilities") or {} + return { + "protocolVersion": PROTOCOL_VERSION, + "agentCapabilities": { + "loadSession": True, + "promptCapabilities": {"image": True, "audio": False, "embeddedContext": True}, + "mcpCapabilities": {"http": True, "sse": False}, + }, + "agentInfo": {"name": "lcode", "title": "lcode", "version": __version__}, + "authMethods": [], + } + + def open(self, params: dict) -> Session: + from lcode import api + + cwd = Path(str(params.get("cwd") or "")) + if not cwd.is_absolute(): + raise RpcError(INVALID_PARAMS, "cwd must be an absolute path") + try: + agent = api.open_agent(cwd, api.Options(interactive=False), self.console) + except api.SetupError as e: + raise RpcError(INTERNAL_ERROR, str(e)) from e + agent.mcp = self.mcp(cwd, params.get("mcpServers") or [], agent) + session = Session(self, agent) + self.sessions[session.id] = session + return session + + def mcp(self, cwd: Path, editor_servers: list[dict], agent: Agent): + """lcode's MCP servers, plus the ones the editor passes.""" + from lcode import config + from lcode.mcp import config as mcp_config + from lcode.mcp.manager import McpManager + + project = mcp_config.project_file(cwd) + servers, problems = mcp_config.load_all(cwd, include_project=bool(project and mcp_config.is_approved(project))) + names = {s.name for s in servers} + for entry in editor_servers: + name = str(entry.get("name") or "editor") + if name in names: + continue + if entry.get("url"): + raw = { + "type": "http", + "url": entry["url"], + "headers": {h["name"]: h["value"] for h in entry.get("headers") or []}, + } + else: + raw = {"command": entry.get("command", ""), "args": entry.get("args") or [], + "env": {e["name"]: e["value"] for e in entry.get("env") or []}} # fmt: skip + server = mcp_config.parse(name, raw, source="editor") + if server.error: + problems.append(f"{name}: {server.error}") + else: + servers.append(server) + for problem in problems: + self.console.print(f"MCP: {problem}") + if not servers: + return None + manager = McpManager(cwd, servers, config.load()["mcp_tools"]) + manager.start() + return manager + + def modes(self, session: Session) -> dict: + return { + "currentModeId": session.agent.perms.mode, + "availableModes": [{"id": i, "name": n, "description": d} for i, n, d in MODES], + } + + def new_session(self, params: dict) -> dict: + session = self.open(params) + self.commands(session) + return {"sessionId": session.id, "modes": self.modes(session)} + + def load_session(self, params: dict) -> dict: + from lcode import sessions + + session = self.open(params) + wanted = str(params.get("sessionId", "")) + found = next((s for s in sessions.list_sessions(session.agent.cwd, limit=1000) if s.id == wanted), None) + if found is None: + self.sessions.pop(session.id, None) + raise RpcError(NOT_FOUND, f"no saved lcode session {wanted} in {session.agent.cwd}") + self.sessions.pop(session.id, None) + session.agent.load(found) + session.id = session.agent.session_id + self.sessions[session.id] = session + session.replay() + self.commands(session) + return {"modes": self.modes(session)} + + def commands(self, session: Session) -> None: + available = [{"name": "review", "description": "Review the uncommitted changes, or the branch against a base", + "input": {"hint": "base branch (optional)"}}] # fmt: skip + for name, command in session.agent.extensions().commands.items(): + if name != "review": + entry: dict = {"name": name, "description": command.description} + if command.argument_hint: + entry["input"] = {"hint": command.argument_hint} + available.append(entry) + session.update({"sessionUpdate": "available_commands_update", "availableCommands": available}) + + def session(self, params: dict) -> Session: + session = self.sessions.get(str(params.get("sessionId", ""))) + if session is None: + raise RpcError(NOT_FOUND, f"no session {params.get('sessionId')}") + return session + + def prompt(self, params: dict) -> dict: + session = self.session(params) + try: + return session.prompt(params.get("prompt") or []) + finally: + session.agent.no_changes = "" + + def cancel(self, params: dict) -> None: + session = self.sessions.get(str(params.get("sessionId", ""))) + if session is not None and session.agent.cancel is not None: + session.agent.cancel.set() + from lcode import subagents + + if session.agent.response is not None: + subagents.abort(session.agent.response) # stop the model's answer now + return None + + def set_mode(self, params: dict) -> dict: + session = self.session(params) + mode = str(params.get("modeId", "")) + if mode not in {m[0] for m in MODES}: + raise RpcError(INVALID_PARAMS, f"unknown mode {mode}") + session.agent.perms.mode = session.mode = mode + return {} + + +def main() -> int: + """Serve one editor over stdin and stdout until it disconnects.""" + inp, out = sys.stdin.buffer, sys.stdout.buffer + sys.stdout = sys.stderr # a stray print must not corrupt the protocol + # Open for the life of the process: + sys.stdin = open(os.devnull) # noqa: SIM115 # nothing may wait for typed input: stdin is the protocol + log = os.environ.get("LCODE_ACP_LOG") + output = open(log, "a") if log else open(os.devnull, "w") # noqa: SIM115 # lcode's own output + console = Console(file=output, width=120, force_terminal=False) + conn = Connection(inp, out) + server = Server(conn, console) + conn.serve(server.handle) + for session in server.sessions.values(): + from lcode import api + + api.close_agent(session.agent) + return 0 diff --git a/src/lcode/agent.py b/src/lcode/agent.py index de7839c..3c76650 100644 --- a/src/lcode/agent.py +++ b/src/lcode/agent.py @@ -257,6 +257,10 @@ def __init__(self, ollama: Ollama, settings: Settings, cwd: Path, console: Conso self.cancel: threading.Event | None = None # set from another thread to stop self.on_tool = None # called with (name, arguments) before each tool runs self.on_event: Callable[[dict], None] | None = None # each step and tool result, for --output stream-json + self.on_delta: Callable[[str, str], None] | None = None # ("text" or "thinking", piece) as it streams + self.current_call = "" # id of the tool call that's running, so a permission request can name it + # Decides on a presented plan instead of asking in the terminal: "ask", "auto-edit", or why not. + self.plan_review: Callable[[str, str], str] | None = None self.max_steps = MAX_STEPS_PER_TURN self.turn_status = "" # how the last request ended: success, max_steps self.response = None # the streaming response, so another thread can abort it @@ -649,6 +653,8 @@ def stop_spinner() -> None: msg = chunk.get("message", {}) if msg.get("thinking"): thinking += msg["thinking"] + if self.on_delta: + self.on_delta("thinking", msg["thinking"]) progress.update("Thinking", msg["thinking"]) if self.settings.show_thinking: stop_spinner() # raw text is printed as it streams @@ -659,6 +665,8 @@ def stop_spinner() -> None: self.console.print("\n") printed_thinking = False content += msg["content"] + if self.on_delta: + self.on_delta("text", msg["content"]) progress.update("Writing", msg["content"]) # Finished markdown blocks print above the spinner, which keeps running meanwhile. start_spinner() @@ -667,6 +675,8 @@ def stop_spinner() -> None: tool_calls.extend(msg["tool_calls"]) if chunk.get("done"): final = chunk + if self.cancel is not None and self.cancel.is_set(): + raise KeyboardInterrupt # cancelled while the answer streamed: its end never came except KeyboardInterrupt: stop_spinner() md.flush() @@ -833,6 +843,7 @@ def _run_turn(self, user_text: str) -> None: raise for i, call in enumerate(calls): name, args = call.get("function", {}).get("name", ""), call_arguments(call) + self.current_call = call.get("id", "") if self.on_tool: self.on_tool(name, args) if name not in ("bash", "todo_write", "web_search", "web_fetch", "agent"): # those print their own diff --git a/src/lcode/cli.py b/src/lcode/cli.py index c12792c..6e9d581 100644 --- a/src/lcode/cli.py +++ b/src/lcode/cli.py @@ -774,6 +774,11 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]: from lcode.mcp.commands import build_parser as mcp_parser subs["mcp"] = mcp_parser() + subs["acp"] = argparse.ArgumentParser( + prog="lcode acp", + description="Run as the agent inside an editor, over the Agent Client Protocol on stdin and stdout " + "(Zed and other ACP editors start it for you; see the docs).", + ) subs["action"] = argparse.ArgumentParser( prog="lcode action", description="Run as a GitHub Action on a self-hosted runner: answer @lcode in issues and pull requests, " @@ -782,6 +787,12 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]: return subs +def cmd_acp(args) -> None: + from lcode import acp + + sys.exit(acp.main()) + + def cmd_action(args) -> None: from lcode import action @@ -851,6 +862,7 @@ def cmd_mcp(args) -> None: "mcp": cmd_mcp, "index": cmd_index, "action": cmd_action, + "acp": cmd_acp, } diff --git a/src/lcode/permissions.py b/src/lcode/permissions.py index 465b9af..2bb7dec 100644 --- a/src/lcode/permissions.py +++ b/src/lcode/permissions.py @@ -76,7 +76,8 @@ def __init__(self, console: Console, mode: str = "ask"): self.always: set[str] = set() self.rules = Rules() # allow and deny lists from the settings (lcode.hooks) self.on_prompt = None # called with the title before lcode asks the user (notification hooks) - self.approve: Callable[[dict], bool] | None = None # decides instead of asking (see lcode.api) + # Decides instead of asking (lcode.api, lcode acp): True, False, or a message saying why not. + self.approve: Callable[[dict], bool | str] | None = None def rule(self, kind: str, target: str) -> tuple[bool, str] | None: """A rule's verdict on an action: (allowed, message for the model), or None when no rule matches.""" @@ -109,9 +110,10 @@ def request(self, key: str, kind: str, title: str, body: RenderableType, target: target = target or key.split(":", 1)[-1] if kind == "bash": target = LEADING_CD.sub("", target) # the command itself, without `cd &&` - if self.approve({"kind": kind, "title": title, "target": target}): + verdict = self.approve({"kind": kind, "title": title, "target": target, "key": key}) + if verdict is True: return True, "" - return False, NOT_ASKED + return False, verdict if isinstance(verdict, str) and verdict else NOT_ASKED if self.on_prompt: self.on_prompt(title) self.console.print(Panel(body, title=title, title_align="left", border_style="yellow")) diff --git a/src/lcode/planning.py b/src/lcode/planning.py index 2dbe3f9..5e1b517 100644 --- a/src/lcode/planning.py +++ b/src/lcode/planning.py @@ -92,6 +92,11 @@ def present(agent: Agent, title: str, plan: str) -> str: plan = plan.strip() if not plan: return "Error: the plan is empty. Write the steps, the files, the risks and how you'll verify the work." + if agent.plan_review is not None: # an editor shows the plan (lcode acp) + choice = agent.plan_review(title, plan) + if choice in ("ask", "auto-edit"): + return approve(agent, title, plan, choice, False) + return choice or "The user didn't approve the plan. Ask them what they'd like to change." edited = False while True: c.print(Panel(Markdown(plan), title=f"Plan: {escape(title)}", title_align="left", border_style="cyan")) diff --git a/src/lcode/tools.py b/src/lcode/tools.py index 4e9559b..9005efd 100644 --- a/src/lcode/tools.py +++ b/src/lcode/tools.py @@ -15,6 +15,7 @@ import subprocess import threading import time +from collections.abc import Callable from pathlib import Path from typing import TYPE_CHECKING @@ -366,6 +367,9 @@ def __init__(self, agent: Agent): self.agent = agent self.read_mtimes: dict[str, float] = {} self.todos: list[dict] = [] + # An editor's buffers (lcode acp): read unsaved changes and write through the editor. + self.reader: Callable[[Path], str | None] | None = None + self.writer: Callable[[Path, str], None] | None = None @property def console(self): @@ -611,7 +615,7 @@ def t_read_file(self, path: str, offset: int = 1, limit: int = 2000) -> str: raise ToolError(f"{path} is an image; look at it with view_image") if is_binary(p): raise ToolError(f"{path} is a binary file ({p.stat().st_size} bytes)") - lines = p.read_text(errors="replace").splitlines() + lines = self.read_text(p).splitlines() offset, limit = max(1, int(offset or 1)), max(1, int(limit or 2000)) chunk = lines[offset - 1 : offset - 1 + limit] self.read_mtimes[str(p)] = p.stat().st_mtime @@ -704,9 +708,20 @@ def _diff(self, p: Path, old: str, new: str) -> Syntax: diff = diff[:12_000] + "\n... (diff truncated for display)\n" return Syntax(diff or "(no changes)", "diff", theme="monokai", word_wrap=True) + def read_text(self, p: Path) -> str: + """A file's text: the editor's buffer when lcode runs inside one, else the file on disk.""" + if self.reader is not None: + text = self.reader(p) + if text is not None: + return text + return p.read_text(errors="replace") + def _write(self, p: Path, content: str) -> None: p.parent.mkdir(parents=True, exist_ok=True) - p.write_text(content) + if self.writer is not None: + self.writer(p, content) # the editor updates its buffer (and the file) + if not p.exists() or p.read_text(errors="replace") != content: + p.write_text(content) # the file on disk must match, for checkpoints and the tools self.read_mtimes[str(p)] = p.stat().st_mtime def t_write_file(self, path: str, content: str) -> str: @@ -716,7 +731,7 @@ def t_write_file(self, path: str, content: str) -> str: if p.is_dir(): raise ToolError(f"{path} is a directory") self._check_fresh(p) - body = self._diff(p, p.read_text(errors="replace"), content) + body = self._diff(p, self.read_text(p), content) else: lines = content.splitlines() preview = "\n".join(lines[:60]) + (f"\n... ({len(lines) - 60} more lines)" if len(lines) > 60 else "") @@ -725,7 +740,7 @@ def t_write_file(self, path: str, content: str) -> str: ok, feedback = self.agent.perms.request("edit", "edit", f"{verb} {self.rel(p)}", body, self.project_rel(p)) if not ok: return feedback - before = p.read_text(errors="replace") if exists else None + before = self.read_text(p) if exists else None self.agent.checkpoint() self._write(p, content) n = len(content.splitlines()) @@ -741,7 +756,7 @@ def t_edit_file(self, path: str, old_string: str, new_string: str, replace_all: raise ToolError("old_string and new_string are identical") if not old_string: raise ToolError("old_string is empty; use write_file to create or overwrite files") - text = p.read_text(errors="replace") + text = self.read_text(p) count = text.count(old_string) if count == 0: new_text = fuzzy_replace(text, old_string, new_string) diff --git a/tests/test_acp.py b/tests/test_acp.py new file mode 100644 index 0000000..836893e --- /dev/null +++ b/tests/test_acp.py @@ -0,0 +1,267 @@ +import base64 +import json +import os +import queue +import threading +import time + +import pytest +from rich.console import Console + +from conftest import FakeOllama, call, reply +from lcode import acp, api, cli, config +from lcode.hardware import Hardware + +HW = Hardware("linux", "x", 31, "GPU", 12) + + +class Editor: + """The editor's side of the connection: sends requests, answers lcode's.""" + + def __init__(self, server_in, server_out): + self.to_server = server_in + self.messages: queue.Queue = queue.Queue() + self.updates: list[dict] = [] + self.requests: list[dict] = [] # lcode's requests to the editor + self.choice = "allow" # the option picked in permission dialogs + self.buffers: dict[str, str] = {} + self.next_id = 0 + threading.Thread(target=self._read, args=(server_out,), daemon=True).start() + + def _read(self, stream): + for line in stream: + message = json.loads(line) + if message.get("method") == "session/update": + self.updates.append(message["params"]["update"]) + elif "method" in message: + self.requests.append(message) + self.send({"id": message["id"], **self.answer(message["method"], message["params"])}) + else: + self.messages.put(message) + + def answer(self, method, params): + if method == "session/request_permission": + return {"result": {"outcome": {"outcome": "selected", "optionId": self.choice}}} + if method == "fs/read_text_file": + if params["path"] in self.buffers: + return {"result": {"content": self.buffers[params["path"]]}} + return {"error": {"code": -32002, "message": "not open"}} + if method == "fs/write_text_file": + self.buffers[params["path"]] = params["content"] + return {"result": {}} + return {"error": {"code": -32601, "message": "no"}} + + def send(self, message): + self.to_server.write((json.dumps({"jsonrpc": "2.0", **message}) + "\n").encode()) + self.to_server.flush() + + def request(self, method, params, timeout=20): + self.next_id += 1 + self.send({"id": self.next_id, "method": method, "params": params}) + reply = self.messages.get(timeout=timeout) + assert reply["id"] == self.next_id + if "error" in reply: + raise acp.RpcError(reply["error"]["code"], reply["error"]["message"]) + return reply["result"] + + def kinds(self): + return [u["sessionUpdate"] for u in self.updates] + + +@pytest.fixture +def editor(repo, tmp_path_factory, monkeypatch): + home = tmp_path_factory.mktemp("home") + monkeypatch.setattr(config, "CONFIG_PATH", home / "config.toml") + monkeypatch.setattr(config, "STATE_DIR", home / "state") + config.save({"model": "qwen3.6-35b", "context": 32768, "memory": "off", "lsp": "off"}) + model = FakeOllama() + monkeypatch.setattr(cli, "Ollama", lambda host: model) + monkeypatch.setattr("lcode.hardware.detect", lambda: HW) + monkeypatch.setattr(api, "check_server", lambda *args: "0.32.0") + to_server_r, to_server_w = os.pipe() + to_editor_r, to_editor_w = os.pipe() + conn = acp.Connection(os.fdopen(to_server_r, "rb"), os.fdopen(to_editor_w, "wb")) + server = acp.Server(conn, Console(file=open(os.devnull, "w"))) # noqa: SIM115 + threading.Thread(target=conn.serve, args=(server.handle,), daemon=True).start() + client = Editor(os.fdopen(to_server_w, "wb"), os.fdopen(to_editor_r, "rb")) + client.model, client.server = model, server + client.request( + "initialize", + {"protocolVersion": 1, "clientCapabilities": {"fs": {"readTextFile": True, "writeTextFile": True}}}, + ) + yield client + client.to_server.close() + + +def new_session(editor, repo, mode="ask"): + session = editor.request("session/new", {"cwd": str(repo), "mcpServers": []}) + editor.request("session/set_mode", {"sessionId": session["sessionId"], "modeId": mode}) + return session["sessionId"] + + +def prompt(editor, sid, text): + return editor.request("session/prompt", {"sessionId": sid, "prompt": [{"type": "text", "text": text}]}) + + +def test_initialize_and_sessions(editor, repo): + info = editor.server.initialize({"protocolVersion": 1}) + assert info["protocolVersion"] == 1 and info["agentInfo"]["name"] == "lcode" + assert info["agentCapabilities"]["loadSession"] and info["agentCapabilities"]["promptCapabilities"]["image"] + session = editor.request("session/new", {"cwd": str(repo), "mcpServers": []}) + assert [m["id"] for m in session["modes"]["availableModes"]] == ["ask", "auto-edit", "plan", "yolo"] + assert session["modes"]["currentModeId"] == "ask" + commands = next(u for u in editor.updates if u["sessionUpdate"] == "available_commands_update") + assert commands["availableCommands"][0]["name"] == "review" + with pytest.raises(acp.RpcError, match="doesn't support session/fork"): + editor.request("session/fork", {}) + with pytest.raises(acp.RpcError, match="no session nope"): + prompt(editor, "nope", "hi") + with pytest.raises(acp.RpcError, match="cwd must be an absolute path"): + editor.request("session/new", {"cwd": "relative", "mcpServers": []}) + + +def test_edits_ask_in_the_editor_with_diffs(editor, repo): + sid = new_session(editor, repo) + editor.model.scripts = [ + reply(tool_calls=[call("read_file", path="README.md")]), + reply(tool_calls=[call("edit_file", path="README.md", old_string="Demo", new_string="Demo app")]), + reply("Renamed it.", thinking="The title should say app."), + ] + assert prompt(editor, sid, "rename the title") == {"stopReason": "end_turn"} + [ask] = [r for r in editor.requests if r["method"] == "session/request_permission"] + tool_call = ask["params"]["toolCall"] + assert tool_call["title"] == "Edit README.md" and tool_call["kind"] == "edit" + assert tool_call["content"][0] == { + "type": "diff", + "path": str(repo / "README.md"), + "oldText": "# Demo\n", + "newText": "# Demo app\n", + } + assert [o["kind"] for o in ask["params"]["options"]] == ["allow_once", "allow_always", "reject_once"] + assert (repo / "README.md").read_text() == "# Demo app\n" + assert editor.buffers[str(repo / "README.md")] == "# Demo app\n" # written through the editor + started = [u for u in editor.updates if u["sessionUpdate"] == "tool_call"] + assert [(u["title"], u["kind"], u["status"]) for u in started] == [ + ("Read README.md", "read", "pending"), + ("Edit README.md", "edit", "pending"), + ] + assert started[0]["locations"] == [{"path": str(repo / "README.md")}] + done = [u for u in editor.updates if u["sessionUpdate"] == "tool_call_update" and u.get("status") == "completed"] + assert done[-1]["content"][0]["type"] == "diff" and done[-1]["content"][0]["newText"] == "# Demo app\n" + assert "in_progress" in [u.get("status") for u in editor.updates if u["sessionUpdate"] == "tool_call_update"] + kinds = editor.kinds() + assert "agent_thought_chunk" in kinds and kinds.count("agent_message_chunk") == 1 + usage = next(u for u in editor.updates if u["sessionUpdate"] == "usage_update") + assert usage["size"] == 32768 and usage["used"] > 0 + + +def test_rejecting_and_always_allowing(editor, repo): + sid = new_session(editor, repo) + editor.choice = "reject" + editor.model.scripts = [reply(tool_calls=[call("bash", command="touch made")]), reply("ok")] + prompt(editor, sid, "make a file") + result = next(u for u in reversed(editor.updates) if u["sessionUpdate"] == "tool_call_update" and u.get("content")) + assert ( + result["status"] == "failed" and "The user denied this in the editor" in result["content"][0]["content"]["text"] + ) + assert not (repo / "made").exists() + editor.choice = "always" + editor.model.scripts = [ + reply(tool_calls=[call("bash", command="touch one")]), + reply(tool_calls=[call("bash", command="touch two")]), + reply("ok"), + ] + prompt(editor, sid, "make two files") + asked = [r for r in editor.requests if r["method"] == "session/request_permission"] + assert len(asked) == 2 and (repo / "one").exists() and (repo / "two").exists() # asked once for touch + + +def test_the_model_reads_unsaved_changes(editor, repo): + sid = new_session(editor, repo) + editor.buffers[str(repo / "README.md")] = "# Demo\n\nNot saved yet.\n" + editor.model.scripts = [reply(tool_calls=[call("read_file", path="README.md")]), reply("It says not saved.")] + prompt(editor, sid, "read the readme") + result = next(u for u in reversed(editor.updates) if u["sessionUpdate"] == "tool_call_update" and u.get("content")) + assert "Not saved yet." in result["content"][0]["content"]["text"] + assert (repo / "README.md").read_text() == "# Demo\n" + + +def test_plans_are_approved_in_the_editor(editor, repo): + sid = new_session(editor, repo, mode="plan") + editor.choice = "auto-edit" + editor.model.scripts = [ + reply(tool_calls=[call("todo_write", todos=[{"content": "Rename the title", "status": "pending"}])]), + reply(tool_calls=[call("present_plan", title="Rename", plan="1. Edit README.md")]), + reply("Approved; starting."), + ] + prompt(editor, sid, "plan the rename") + [ask] = [r for r in editor.requests if r["method"] == "session/request_permission"] + assert ask["params"]["toolCall"]["kind"] == "switch_mode" and ask["params"]["toolCall"]["title"] == "Plan: Rename" + assert ask["params"]["toolCall"]["content"][0]["content"]["text"] == "1. Edit README.md" + plan = next(u for u in editor.updates if u["sessionUpdate"] == "plan") + assert plan["entries"] == [{"content": "Rename the title", "priority": "medium", "status": "pending"}] + mode = next(u for u in editor.updates if u["sessionUpdate"] == "current_mode_update") + assert mode["currentModeId"] == "auto-edit" and editor.server.sessions[sid].agent.perms.mode == "auto-edit" + + +def test_cancelling_stops_the_request(editor, repo): + sid = new_session(editor, repo) + + def slow(): + yield {"message": {"content": "Once upon"}, "done": False} + agent = editor.server.sessions[sid].agent + for _ in range(100): + if agent.cancel.is_set(): + break + time.sleep(0.05) + yield {"message": {"content": " a time"}, "done": False} + + editor.model.scripts = [slow()] + editor.next_id += 1 + editor.send( + { + "id": editor.next_id, + "method": "session/prompt", + "params": {"sessionId": sid, "prompt": [{"type": "text", "text": "story"}]}, + } + ) + time.sleep(0.3) + editor.send({"method": "session/cancel", "params": {"sessionId": sid}}) + assert editor.messages.get(timeout=10)["result"] == {"stopReason": "cancelled"} + + +def test_prompts_with_files_images_and_commands(editor, repo): + sid = new_session(editor, repo) + png = base64.b64encode(b"\x89PNG\r\n\x1a\nfake").decode() + editor.model.scripts = [reply("ok")] + editor.request("session/prompt", {"sessionId": sid, "prompt": [ + {"type": "text", "text": "compare these"}, + {"type": "resource_link", "uri": (repo / "README.md").as_uri(), "name": "README.md"}, + {"type": "resource", "resource": {"uri": "file:///tmp/notes.txt", "text": "remember the milk"}}, + {"type": "image", "data": png, "mimeType": "image/png"}, + ]}) # fmt: skip + sent = [m["content"] for m in editor.model.payloads[0]["messages"] if m["role"] == "user"][-1] + assert sent.startswith("compare these\n\n[The user attached README.md]\n```\n# Demo\n```") + assert "[The user attached /tmp/notes.txt]\n```\nremember the milk\n```" in sent + saved = list((config.STATE_DIR / "acp-images").glob("*.png")) + assert len(saved) == 1 and saved[0].read_bytes().startswith(b"\x89PNG") + before = len(editor.updates) + assert prompt(editor, sid, "/review") == {"stopReason": "end_turn"} # not a git repository + assert "Nothing to review: this isn't a git repository" in "".join( + u["content"]["text"] for u in editor.updates[before:] if u["sessionUpdate"] == "agent_message_chunk" + ) + + +def test_loading_a_session_replays_it(editor, repo): + sid = new_session(editor, repo) + editor.model.scripts = [reply(tool_calls=[call("read_file", path="README.md")]), reply("It says Demo.")] + prompt(editor, sid, "what does the readme say?") + editor.updates.clear() + loaded = editor.request("session/load", {"sessionId": sid, "cwd": str(repo), "mcpServers": []}) + assert loaded["modes"]["currentModeId"] == "ask" + kinds = editor.kinds() + assert kinds[:4] == ["user_message_chunk", "tool_call", "tool_call_update", "agent_message_chunk"] + assert editor.updates[0]["content"]["text"] == "what does the readme say?" + assert editor.updates[3]["content"]["text"] == "It says Demo." + with pytest.raises(acp.RpcError, match="no saved lcode session nope"): + editor.request("session/load", {"sessionId": "nope", "cwd": str(repo), "mcpServers": []}) diff --git a/tests/test_api.py b/tests/test_api.py index 97fc024..47ea038 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -54,7 +54,12 @@ def test_permission_requests_go_to_approve(server, repo): ] with api.Session(repo, permission_mode="ask", approve=lambda r: asked.append(r) or len(asked) == 1) as session: result = session.run("make files") - assert asked[0] == {"kind": "bash", "title": f"Run command (in {repo})", "target": "touch made-by-bash"} + assert asked[0] == { + "kind": "bash", + "title": f"Run command (in {repo})", + "target": "touch made-by-bash", + "key": "bash:touch", + } assert (repo / "made-by-bash").exists() and not (repo / "second").exists() assert result.tool_calls[1]["output"] == NOT_ASKED and result.tool_calls[1]["error"] is True assert result.tool_calls[0]["error"] is False