|
| 1 | +import asyncio |
| 2 | +from types import SimpleNamespace |
| 3 | +from typing import Any |
| 4 | + |
| 5 | +from ag_ui.core import RunAgentInput |
| 6 | +from copilotkit import CopilotKitMiddleware |
| 7 | +from deepagents import create_deep_agent |
| 8 | +from langchain_core.language_models import BaseChatModel |
| 9 | +from langchain_core.messages import AIMessage, BaseMessage, ToolMessage |
| 10 | +from langchain_core.outputs import ChatGeneration, ChatResult |
| 11 | +from langgraph.checkpoint.memory import MemorySaver |
| 12 | +from pydantic import Field |
| 13 | + |
| 14 | +from agui import build_agui_agent |
| 15 | +from coding.github_credentials import GitHubIdentity |
| 16 | +from coding.subagent import build_coder_subagent |
| 17 | + |
| 18 | + |
| 19 | +class ApprovalResumeModel(BaseChatModel): |
| 20 | + tool_names: frozenset[str] = Field(default_factory=frozenset) |
| 21 | + |
| 22 | + @property |
| 23 | + def _llm_type(self): |
| 24 | + return "coder-approval-resume" |
| 25 | + |
| 26 | + def bind_tools(self, tools, **_kwargs): |
| 27 | + return self.model_copy( |
| 28 | + update={"tool_names": frozenset(tool.name for tool in tools)} |
| 29 | + ) |
| 30 | + |
| 31 | + def _generate( |
| 32 | + self, |
| 33 | + messages: list[BaseMessage], |
| 34 | + stop=None, |
| 35 | + run_manager=None, |
| 36 | + **_kwargs: Any, |
| 37 | + ): |
| 38 | + del stop, run_manager |
| 39 | + results = { |
| 40 | + message.tool_call_id |
| 41 | + for message in messages |
| 42 | + if isinstance(message, ToolMessage) |
| 43 | + } |
| 44 | + if "prepare_repository" not in self.tool_names: |
| 45 | + message = ( |
| 46 | + AIMessage(content="done") |
| 47 | + if "task-1" in results |
| 48 | + else AIMessage( |
| 49 | + content="", |
| 50 | + tool_calls=[ |
| 51 | + { |
| 52 | + "id": "task-1", |
| 53 | + "name": "task", |
| 54 | + "args": { |
| 55 | + "description": "Make the requested change", |
| 56 | + "subagent_type": "coder", |
| 57 | + }, |
| 58 | + } |
| 59 | + ], |
| 60 | + ) |
| 61 | + ) |
| 62 | + elif "prepare-1" not in results: |
| 63 | + message = AIMessage( |
| 64 | + content="", |
| 65 | + tool_calls=[ |
| 66 | + { |
| 67 | + "id": "prepare-1", |
| 68 | + "name": "prepare_repository", |
| 69 | + "args": { |
| 70 | + "repo": "org/repo", |
| 71 | + "base_branch": "main", |
| 72 | + "head_branch": "opentag/test", |
| 73 | + }, |
| 74 | + } |
| 75 | + ], |
| 76 | + ) |
| 77 | + elif "publish-1" not in results: |
| 78 | + message = AIMessage( |
| 79 | + content="", |
| 80 | + tool_calls=[ |
| 81 | + { |
| 82 | + "id": "publish-1", |
| 83 | + "name": "publish_changes", |
| 84 | + "args": { |
| 85 | + "repo": "org/repo", |
| 86 | + "base_branch": "main", |
| 87 | + "head_branch": "opentag/test", |
| 88 | + "title": "Test approval resume", |
| 89 | + "body": "Test body", |
| 90 | + "test_command": "true", |
| 91 | + "test_exit_code": 0, |
| 92 | + }, |
| 93 | + } |
| 94 | + ], |
| 95 | + ) |
| 96 | + else: |
| 97 | + message = AIMessage(content="coder done") |
| 98 | + return ChatResult(generations=[ChatGeneration(message=message)]) |
| 99 | + |
| 100 | + |
| 101 | +class ApprovalResumeProvider: |
| 102 | + git_username = "x-access-token" |
| 103 | + |
| 104 | + def __init__(self): |
| 105 | + self.requests = [] |
| 106 | + |
| 107 | + def token(self): |
| 108 | + return "operation-secret" |
| 109 | + |
| 110 | + def identity(self): |
| 111 | + return GitHubIdentity( |
| 112 | + "open-tag[bot]", |
| 113 | + 42, |
| 114 | + "42+open-tag[bot]@users.noreply.github.com", |
| 115 | + ) |
| 116 | + |
| 117 | + def request_json(self, method, path, *, json=None): |
| 118 | + self.requests.append((method, path, json)) |
| 119 | + if method == "POST": |
| 120 | + return {"html_url": "https://github.com/org/repo/pull/9"} |
| 121 | + raise AssertionError((method, path, json)) |
| 122 | + |
| 123 | + |
| 124 | +class ApprovalResumeBackend: |
| 125 | + def __init__(self): |
| 126 | + self.state = {} |
| 127 | + self.branch = "" |
| 128 | + self.pushes = 0 |
| 129 | + |
| 130 | + @property |
| 131 | + def id(self): |
| 132 | + return "approval-resume" |
| 133 | + |
| 134 | + def job_state(self): |
| 135 | + return self.state |
| 136 | + |
| 137 | + def clone_repository(self, **kwargs): |
| 138 | + self.branch = kwargs["branch"] |
| 139 | + |
| 140 | + def set_git_identity(self, **_kwargs): |
| 141 | + pass |
| 142 | + |
| 143 | + def push_repository(self, **_kwargs): |
| 144 | + self.pushes += 1 |
| 145 | + |
| 146 | + def execute(self, command, **_kwargs): |
| 147 | + if "switch -c" in command: |
| 148 | + self.branch = "opentag/test" |
| 149 | + return SimpleNamespace(output="", exit_code=0) |
| 150 | + if "branch --show-current" in command: |
| 151 | + return SimpleNamespace(output=self.branch, exit_code=0) |
| 152 | + if "status --porcelain" in command: |
| 153 | + return SimpleNamespace(output="", exit_code=0) |
| 154 | + if "rev-parse HEAD" in command: |
| 155 | + return SimpleNamespace(output="abc123", exit_code=0) |
| 156 | + raise AssertionError(command) |
| 157 | + |
| 158 | + def stop_current(self): |
| 159 | + pass |
| 160 | + |
| 161 | + |
| 162 | +def test_coder_confirmation_survives_subagent_tool_replay(): |
| 163 | + model = ApprovalResumeModel() |
| 164 | + checkpointer = MemorySaver() |
| 165 | + backend = ApprovalResumeBackend() |
| 166 | + provider = ApprovalResumeProvider() |
| 167 | + coder = build_coder_subagent( |
| 168 | + model=model, |
| 169 | + checkpointer=checkpointer, |
| 170 | + provider=provider, |
| 171 | + backend=backend, |
| 172 | + ) |
| 173 | + graph = create_deep_agent( |
| 174 | + model=model, |
| 175 | + middleware=[CopilotKitMiddleware()], |
| 176 | + subagents=[coder], |
| 177 | + checkpointer=checkpointer, |
| 178 | + ) |
| 179 | + agent = build_agui_agent(graph, recursion_limit=80) |
| 180 | + request = { |
| 181 | + "threadId": "approval-resume-thread", |
| 182 | + "state": {}, |
| 183 | + "messages": [{"id": "user-1", "role": "user", "content": "go"}], |
| 184 | + "tools": [], |
| 185 | + "context": [], |
| 186 | + } |
| 187 | + |
| 188 | + first = asyncio.run( |
| 189 | + _collect( |
| 190 | + agent.run( |
| 191 | + RunAgentInput(runId="run-1", forwardedProps={}, **request) |
| 192 | + ) |
| 193 | + ) |
| 194 | + ) |
| 195 | + assert any(getattr(event, "name", None) == "on_interrupt" for event in first) |
| 196 | + |
| 197 | + # A nested interrupt can replay the parent task if its subgraph checkpoint |
| 198 | + # is unavailable. The prepared sandbox state still survives that replay. |
| 199 | + namespaces = checkpointer.storage["approval-resume-thread"] |
| 200 | + for namespace in list(namespaces): |
| 201 | + if namespace: |
| 202 | + del namespaces[namespace] |
| 203 | + |
| 204 | + asyncio.run( |
| 205 | + _collect( |
| 206 | + agent.run( |
| 207 | + RunAgentInput( |
| 208 | + runId="run-2", |
| 209 | + forwardedProps={ |
| 210 | + "command": {"resume": {"confirmed": True}} |
| 211 | + }, |
| 212 | + **request, |
| 213 | + ) |
| 214 | + ) |
| 215 | + ) |
| 216 | + ) |
| 217 | + |
| 218 | + assert backend.pushes == 1 |
| 219 | + assert provider.requests[-1][:2] == ("POST", "/repos/org/repo/pulls") |
| 220 | + |
| 221 | + |
| 222 | +async def _collect(stream): |
| 223 | + return [event async for event in stream] |
0 commit comments