diff --git a/README.md b/README.md index 5f21568..ed61f45 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@
-# Connpy (v6.2.0) +# Connpy (v6.3.0) [](https://pypi.org/pypi/connpy/) [](https://pypi.org/pypi/connpy/) [](https://pypi.org/pypi/connpy/) diff --git a/connpy/__init__.py b/connpy/__init__.py index 734aa00..6f6f89a 100644 --- a/connpy/__init__.py +++ b/connpy/__init__.py @@ -5,7 +5,7 @@ -# Connpy (v6.2.0) +# Connpy (v6.3.0) [](https://pypi.org/pypi/connpy/) [](https://pypi.org/pypi/connpy/) [](https://pypi.org/pypi/connpy/) diff --git a/connpy/_version.py b/connpy/_version.py index 0a895f3..833eacf 100644 --- a/connpy/_version.py +++ b/connpy/_version.py @@ -1 +1 @@ -__version__ = "6.2.0" +__version__ = "6.3.0" diff --git a/connpy/cli/terminal_ui.py b/connpy/cli/terminal_ui.py index 6cd529a..006a469 100644 --- a/connpy/cli/terminal_ui.py +++ b/connpy/cli/terminal_ui.py @@ -560,14 +560,13 @@ class CopilotInterface: mission.setdefault('scratchpad_notes', []).append(notes) if guide: mission['last_guide'] = guide - - if guide or notes: - asst_msg = f"Notes: {notes}\nGuide: {guide}" if notes else guide - if not asst_msg and guide: asst_msg = guide - hist = self.session_state.setdefault("copilot_chat_history", []) - hist.append({"role": "user", "content": clean_question}) - hist.append({"role": "assistant", "content": asst_msg}) - self.session_state["copilot_chat_history"] = hist[-10:] + else: + if guide or notes: + asst_msg = f"Notes: {notes}\nGuide: {guide}" if notes else guide + hist = self.session_state.setdefault("copilot_chat_history", []) + hist.append({"role": "user", "content": clean_question}) + hist.append({"role": "assistant", "content": asst_msg}) + self.session_state["copilot_chat_history"] = hist[-10:] commands = result.get("commands", []) mission = self.session_state.get('mission', {}) if not commands: diff --git a/connpy/grpc_layer/server.py b/connpy/grpc_layer/server.py index 19774be..f384125 100644 --- a/connpy/grpc_layer/server.py +++ b/connpy/grpc_layer/server.py @@ -277,7 +277,8 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer): is_single = saved_mode in (1, 'SINGLE', 'single') if is_range or is_single: - if last_total_cmds is not None and total_cmds > last_total_cmds and saved_cmd > 1: + is_mission = session_state.get('mission', {}).get('active', False) or (isinstance(node_info, dict) and node_info.get('mission', {}).get('active', False)) + if last_total_cmds is not None and total_cmds > last_total_cmds and (saved_cmd > 1 or is_mission): new_cmds = total_cmds - last_total_cmds initial_cmd = saved_cmd + new_cmds else: @@ -321,6 +322,14 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer): )) while True: + # Refresh latest buffer from terminal log + raw_bytes = n.mylog.getvalue() if hasattr(n, 'mylog') else buffer + if not isinstance(raw_bytes, bytes): + raw_bytes = str(raw_bytes).encode() + from connpy.utils import log_cleaner + cleaned_buffer = log_cleaner(raw_bytes.decode(errors='replace')) + buffer = cleaned_buffer + # 0. Drain the queue of any stale messages before starting a new interaction while not remote_stream.copilot_queue.empty(): try: @@ -454,16 +463,74 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer): if action == "send_all": commands = result.get("commands", []) await n.inject_commands(commands, child_fd, on_inject=on_inject) - return elif action.startswith("custom:"): custom_cmds_raw = action[7:] custom_cmds = [cmd.strip() for cmd in custom_cmds_raw.split('\n') if cmd.strip()] await n.inject_commands(custom_cmds, child_fd, on_inject=on_inject) - return else: os.write(child_fd, b'\x15\r') return + # Dynamic Wait for Device Prompt Settle + prompt_pattern = node_info.get("prompt", r'>$|#$|\$$|>.$|#.$|\$.$') + import time + import re + start_t = time.time() + last_len = 0 + quiet_count = 0 + while time.time() - start_t < 60: + await asyncio.sleep(0.15) + cur_bytes = n.mylog.getvalue() + if len(cur_bytes) == last_len: + quiet_count += 1 + else: + quiet_count = 0 + last_len = len(cur_bytes) + + clean_txt = n._logclean(cur_bytes.decode(errors='replace'), True).strip() + lines = [l.strip() for l in clean_txt.split('\n') if l.strip()] + last_line = lines[-1] if lines else "" + if quiet_count >= 2 and re.search(prompt_pattern, last_line): + break + + # Recalculate context blocks with updated buffer + raw_bytes = n.mylog.getvalue() + from connpy.utils import log_cleaner + cleaned_buffer = log_cleaner(raw_bytes.decode(errors='replace')) + last_line = cleaned_buffer.split('\n')[-1].strip() if cleaned_buffer.strip() else "(prompt)" + blocks = service.build_context_blocks(raw_bytes, n.cmd_byte_positions, node_info, last_line=last_line) + node_info["context_blocks"] = blocks + + total_cmds = len(blocks) + total_lines = len(cleaned_buffer.split('\n')) + + last_total_cmds = session_state.get('last_total_cmds', None) + saved_cmd = session_state.get('context_cmd', 1) + is_mission = session_state.get('mission', {}).get('active', False) or (isinstance(node_info, dict) and node_info.get('mission', {}).get('active', False)) + if last_total_cmds is not None and total_cmds > last_total_cmds and (saved_cmd > 1 or is_mission): + new_cmds = total_cmds - last_total_cmds + initial_cmd = saved_cmd + new_cmds + else: + initial_cmd = saved_cmd + + session_state['context_cmd'] = max(1, initial_cmd) + session_state['last_total_cmds'] = total_cmds + session_state['last_total_lines'] = total_lines + + node_info['context_cmd'] = min(session_state['context_cmd'], max(1, total_cmds)) + node_info['context_lines'] = min(session_state.get('context_lines', 50), max(1, total_lines)) + node_info_json = json.dumps(node_info) + + preview_str = raw_bytes[-200:].decode(errors='replace') if isinstance(raw_bytes, bytes) else str(raw_bytes)[-200:] + + # Signal prompt settle and send updated blocks to client + response_queue.put(connpy_pb2.InteractResponse( + copilot_prompt=True, + copilot_buffer_preview=preview_str, + copilot_node_info_json=node_info_json + )) + continue + asyncio.run(n._async_interact_loop(remote_stream, resize_callback, copilot_handler=remote_copilot_handler)) except Exception as e: import traceback diff --git a/connpy/grpc_layer/stubs.py b/connpy/grpc_layer/stubs.py index 7a0450e..4cf9341 100644 --- a/connpy/grpc_layer/stubs.py +++ b/connpy/grpc_layer/stubs.py @@ -44,7 +44,7 @@ class NodeStub: self.config = config def _handle_remote_copilot(self, res, request_queue, response_queue, client_buffer_bytes, pause_generator, resume_generator, old_tty): - import json, asyncio, termios, sys, tty, queue + import json, asyncio, termios, sys, tty, queue, os from ..core import copilot_terminal_mode from . import connpy_pb2 @@ -62,6 +62,7 @@ class NodeStub: ) self.copilot_history = interface.history self.copilot_state = interface.session_state + interface.session_state['banner_shown'] = False async def on_ai_call_remote(active_buffer, question, chunk_callback, merged_node_info): # Send request to server @@ -84,6 +85,7 @@ class NodeStub: # Wrap in async loop async def run_remote_copilot(): + nonlocal blocks, node_info while True: action, commands, custom_cmd = await interface.run_session( raw_bytes=bytes(client_buffer_bytes), @@ -97,6 +99,32 @@ class NodeStub: request_queue.put(connpy_pb2.InteractRequest(copilot_action="continue")) continue + if action in ("send_all", "custom"): + action_sent = "cancel" + if action == "send_all" and commands: + action_sent = f"custom:{chr(10).join(commands)}" + elif action == "custom" and custom_cmd: + action_sent = f"custom:{chr(10).join(custom_cmd)}" + request_queue.put(connpy_pb2.InteractRequest(copilot_action=action_sent)) + + print("\r\n\033[2m [Remote] Ejecutando comandos y esperando al prompt...\033[0m\r\n", flush=True) + while True: + try: + prompt_res = response_queue.get(timeout=0.1) + if prompt_res is None: + return "cancel", None, None + if prompt_res.stdout_data: + os.write(sys.stdout.fileno(), prompt_res.stdout_data) + client_buffer_bytes.extend(prompt_res.stdout_data) + if prompt_res.copilot_prompt: + if prompt_res.copilot_node_info_json: + node_info = json.loads(prompt_res.copilot_node_info_json) + blocks = node_info.get("context_blocks", []) + break + except queue.Empty: + await asyncio.sleep(0.05) + continue + return action, commands, custom_cmd with copilot_terminal_mode(): @@ -104,14 +132,7 @@ class NodeStub: print("\033[2m Returning to session...\033[0m", flush=True) # Prepare final action for server - action_sent = "cancel" - if action == "send_all" and commands: - # In remote mode, send the selected commands as a custom block - # so the server executes exactly what the user picked (e.g., selection '1') - action_sent = f"custom:{chr(10).join(commands)}" - elif action == "custom" and custom_cmd: - action_sent = f"custom:{chr(10).join(custom_cmd)}" - request_queue.put(connpy_pb2.InteractRequest(copilot_action=action_sent)) + request_queue.put(connpy_pb2.InteractRequest(copilot_action="cancel")) resume_generator() tty.setraw(sys.stdin.fileno()) diff --git a/connpy/tests/test_grpc_layer.py b/connpy/tests/test_grpc_layer.py index 1290b0b..3580615 100644 --- a/connpy/tests/test_grpc_layer.py +++ b/connpy/tests/test_grpc_layer.py @@ -211,3 +211,80 @@ class TestGRPCIntegration: finally: if os.path.exists(temp_path): os.remove(temp_path) + + @patch("sys.stdin.fileno", return_value=0) + @patch("termios.tcsetattr") + @patch("termios.tcgetattr") + @patch("tty.setraw") + @patch("os.write") + def test_handle_remote_copilot_command_execution_loop(self, mock_os_write, mock_setraw, mock_getattr, mock_setattr, mock_fileno): + import queue + from connpy.grpc_layer.stubs import NodeStub + from connpy.cli.terminal_ui import CopilotInterface + + mock_getattr.return_value = [0, 0, 0, 0, 0, 0, [0] * 32] + mock_channel = MagicMock() + stub = NodeStub(mock_channel, "localhost:8048") + + initial_node_info = { + "name": "r1", + "context_blocks": [[0, 10, "router#"]] + } + res = connpy_pb2.InteractResponse( + copilot_prompt=True, + copilot_node_info_json=json.dumps(initial_node_info) + ) + + request_queue = queue.Queue() + response_queue = queue.Queue() + client_buffer_bytes = bytearray(b"router# ") + pause_gen = MagicMock() + resume_gen = MagicMock() + old_tty = [0] * 7 + + call_count = 0 + received_blocks_per_call = [] + + async def mock_run_session(raw_bytes, node_info, on_ai_call, blocks): + nonlocal call_count + call_count += 1 + received_blocks_per_call.append(list(blocks)) + if call_count == 1: + # Step 1: User approves commands + # Simulate server putting stdout data and next copilot prompt + updated_node_info = { + "name": "r1", + "context_blocks": [[0, 10, "router#"], [10, 30, "router# show ip route"]] + } + response_queue.put(connpy_pb2.InteractResponse(stdout_data=b"show ip route\r\n10.0.0.0/24\r\nrouter# ")) + response_queue.put(connpy_pb2.InteractResponse( + copilot_prompt=True, + copilot_node_info_json=json.dumps(updated_node_info) + )) + return "send_all", ["show ip route"], None + else: + # Step 2: Second turn, mission completes or user cancels + return "cancel", None, None + + with patch.object(CopilotInterface, "run_session", side_effect=mock_run_session): + stub._handle_remote_copilot( + res, request_queue, response_queue, client_buffer_bytes, + pause_gen, resume_gen, old_tty + ) + + # Assert that run_session was called twice + assert call_count == 2 + # First call should have initial blocks + assert len(received_blocks_per_call[0]) == 1 + # Second call should have updated blocks received from server + assert len(received_blocks_per_call[1]) == 2 + assert received_blocks_per_call[1][1][2] == "router# show ip route" + # Assert client_buffer_bytes was updated with stdout_data + assert b"10.0.0.0/24" in bytes(client_buffer_bytes) + # Assert action sent to server + reqs = [] + while not request_queue.empty(): + reqs.append(request_queue.get_nowait()) + assert reqs[0].copilot_action == "custom:show ip route" + assert reqs[1].copilot_action == "cancel" + diff --git a/docs/connpy/ai.html b/docs/connpy/ai.html index 43930c3..9f71e5f 100644 --- a/docs/connpy/ai.html +++ b/docs/connpy/ai.html @@ -3,7 +3,7 @@ - +var SAFE_COMMANDSThe type of the None singleton.
var deferred_class_hooksThe type of the None singleton.