Skip to content

Commit 46c2520

Browse files
committed
Merge pull request #23 from lzwjava/refactor-login-cleanup
Refactor and clean up login process
2 parents 561cb65 + d8302ca commit 46c2520

9 files changed

Lines changed: 349 additions & 2 deletions

File tree

.coverage

-52 KB
Binary file not shown.

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,4 @@ public/config.json
3232
node_modules/
3333
package-lock.json
3434
**/package-lock.json
35-
node_modules/
3635
.coverage
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import os
2+
import unittest
3+
from mini_copilot.main import MiniCopilot
4+
from tools.patch_tool import PatchTool
5+
6+
7+
class TestEditToolIntegration(unittest.TestCase):
8+
def setUp(self):
9+
self.copilot = MiniCopilot()
10+
self.test_file = "integration_test_file.py"
11+
with open(self.test_file, "w") as f:
12+
f.write("def hello():\n print('hello world')\n")
13+
14+
def tearDown(self):
15+
if os.path.exists(self.test_file):
16+
os.remove(self.test_file)
17+
18+
def test_apply_patch_integration(self):
19+
# We simulate what the agent would do: generate a patch and call PatchTool
20+
patch = f"""--- {self.test_file}
21+
+++ {self.test_file}
22+
@@ -1,2 +1,2 @@
23+
def hello():
24+
- print('hello world')
25+
+ print('hello integration')
26+
"""
27+
new_content = PatchTool.apply_patch(self.test_file, patch)
28+
29+
with open(self.test_file, "w") as f:
30+
f.write(new_content)
31+
32+
with open(self.test_file, "r") as f:
33+
updated_content = f.read()
34+
35+
self.assertIn("print('hello integration')", updated_content)
36+
self.assertNotIn("print('hello world')", updated_content)
37+
38+
39+
if __name__ == "__main__":
40+
unittest.main()
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import subprocess
2+
import time
3+
import sys
4+
import os
5+
6+
7+
def run_integration_test():
8+
print("🚀 Starting Integration Test for 'exec' tool...")
9+
10+
# Ensure current directory is in PYTHONPATH for the subprocess
11+
env = os.environ.copy()
12+
env["PYTHONPATH"] = os.getcwd() + ":" + env.get("PYTHONPATH", "")
13+
14+
# Start the mini-copilot process
15+
process = subprocess.Popen(
16+
[sys.executable, "-m", "mini_copilot.main"],
17+
stdin=subprocess.PIPE,
18+
stdout=subprocess.PIPE,
19+
stderr=subprocess.STDOUT, # Combine stdout and stderr
20+
text=True,
21+
bufsize=1,
22+
cwd=os.getcwd(),
23+
env=env,
24+
)
25+
26+
try:
27+
# 1. Wait for prompt
28+
print("Waiting for prompt...")
29+
output = ""
30+
start_time = time.time()
31+
while time.time() - start_time < 30:
32+
char = process.stdout.read(1)
33+
if not char:
34+
break
35+
output += char
36+
if "> " in output:
37+
print("✅ Prompt detected.")
38+
break
39+
40+
if "> " not in output:
41+
print(f"❌ Timed out waiting for prompt. Last output: {output}")
42+
return False
43+
44+
# 2. Trigger tool
45+
print("Sending message to trigger 'exec' tool...")
46+
process.stdin.write("run command: echo integration_test_success\n")
47+
process.stdin.flush()
48+
49+
# 3. Look for tool execution
50+
print("Monitoring for tool invocation and response...")
51+
found_tool = False
52+
found_output = False
53+
start_time = time.time()
54+
output = ""
55+
while time.time() - start_time < 60:
56+
char = process.stdout.read(1)
57+
if not char:
58+
break
59+
output += char
60+
sys.stdout.write(char) # Echo for observability
61+
sys.stdout.flush()
62+
63+
if "[exec] Running command: echo integration_test_success" in output:
64+
found_tool = True
65+
66+
if "integration_test_success" in output and found_tool:
67+
found_output = True
68+
print("\n✅ Found expected output in responses!")
69+
break
70+
71+
if found_output:
72+
print("\n🎉 Integration Test PASSED!")
73+
return True
74+
else:
75+
print("\n❌ Integration Test FAILED.")
76+
return False
77+
78+
finally:
79+
process.terminate()
80+
81+
82+
if __name__ == "__main__":
83+
if run_integration_test():
84+
sys.exit(0)
85+
else:
86+
sys.exit(1)

mini_copilot/exec_tool.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import subprocess
2+
3+
4+
def exec_command(command: str) -> str:
5+
"""Execute a shell command and return its output (stdout or stderr)."""
6+
print(f"[exec] Running command: {command}")
7+
try:
8+
# Run command with a 30s timeout, capturing both stdout and stderr
9+
result = subprocess.run(
10+
command, shell=True, capture_output=True, text=True, timeout=30
11+
)
12+
output = result.stdout if result.returncode == 0 else result.stderr
13+
if not output.strip():
14+
output = f"(Process exited with code {result.returncode})"
15+
return output
16+
except subprocess.TimeoutExpired:
17+
return "Error: Command timed out after 30 seconds."
18+
except Exception as e:
19+
return f"Error executing command: {str(e)}"
20+
21+
22+
if __name__ == "__main__":
23+
import sys
24+
25+
if len(sys.argv) > 1:
26+
print(exec_command(" ".join(sys.argv[1:])))

mini_copilot/main.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ def completer(text, state):
2020

2121
from mini_copilot.github_api import chat, get_copilot_token
2222
from mini_copilot.web_search import web_search
23+
from mini_copilot.exec_tool import exec_command as exec
2324
from mini_copilot.commands.auth import handle_login_command
2425
from mini_copilot.commands.model import handle_model_command
2526
from mini_copilot.commands.search_provider import handle_search_provider_command
@@ -59,7 +60,25 @@ def completer(text, state):
5960
},
6061
},
6162
}
62-
TOOLS = [WEB_SEARCH_TOOL]
63+
64+
EXEC_COMMAND_TOOL = {
65+
"type": "function",
66+
"function": {
67+
"name": "exec",
68+
"description": "Execute a shell command on the local system and return the output.",
69+
"parameters": {
70+
"type": "object",
71+
"properties": {
72+
"command": {
73+
"type": "string",
74+
"description": "The shell command to execute.",
75+
},
76+
},
77+
"required": ["command"],
78+
},
79+
},
80+
}
81+
TOOLS = [WEB_SEARCH_TOOL, EXEC_COMMAND_TOOL]
6382

6483

6584
def load_github_token():
@@ -167,6 +186,19 @@ def main():
167186
"content": search_context,
168187
}
169188
)
189+
190+
if function_name == "exec":
191+
command = function_args.get("command")
192+
output = exec(command)
193+
194+
messages.append(
195+
{
196+
"tool_call_id": tool_call["id"],
197+
"role": "tool",
198+
"name": function_name,
199+
"content": output,
200+
}
201+
)
170202
response_message = chat(
171203
messages, copilot_token, current_model, tools=TOOLS
172204
)

tests/test_exec_tool.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import unittest
2+
from mini_copilot.exec_tool import exec_command
3+
4+
5+
class TestExecTool(unittest.TestCase):
6+
def test_exec_echo(self):
7+
output = exec_command("echo 'hello world'")
8+
self.assertEqual(output.strip(), "hello world")
9+
10+
def test_exec_ls(self):
11+
output = exec_command("ls pyproject.toml")
12+
self.assertIn("pyproject.toml", output)
13+
14+
def test_exec_error(self):
15+
# Depending on the system, the error message might vary, but it should contain some indication of failure
16+
output = exec_command("non_existent_command_12345")
17+
self.assertIn("not found", output.lower())
18+
19+
20+
if __name__ == "__main__":
21+
unittest.main()

tests/test_patch_tool.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import unittest
2+
import os
3+
from tools.patch_tool import PatchTool
4+
5+
6+
class TestPatchTool(unittest.TestCase):
7+
def setUp(self):
8+
self.test_file = "test_file.txt"
9+
with open(self.test_file, "w") as f:
10+
f.write("Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n")
11+
12+
def tearDown(self):
13+
if os.path.exists(self.test_file):
14+
os.remove(self.test_file)
15+
16+
def test_basic_replace(self):
17+
patch = """--- test_file.txt
18+
+++ test_file.txt
19+
@@ -2,1 +2,1 @@
20+
-Line 2
21+
+Line Two Modified
22+
"""
23+
new_content = PatchTool.apply_patch(self.test_file, patch)
24+
self.assertIn("Line Two Modified\n", new_content)
25+
self.assertNotIn("Line 2\n", new_content)
26+
self.assertEqual(new_content.splitlines()[1], "Line Two Modified")
27+
28+
def test_multi_line_hunk(self):
29+
patch = """--- test_file.txt
30+
+++ test_file.txt
31+
@@ -3,2 +3,3 @@
32+
-Line 3
33+
-Line 4
34+
+Line Three
35+
+Line Three.Five
36+
+Line Four
37+
"""
38+
new_content = PatchTool.apply_patch(self.test_file, patch)
39+
lines = new_content.splitlines()
40+
self.assertEqual(lines[2], "Line Three")
41+
self.assertEqual(lines[3], "Line Three.Five")
42+
self.assertEqual(lines[4], "Line Four")
43+
self.assertEqual(len(lines), 6)
44+
45+
def test_multiple_hunks(self):
46+
patch = """--- test_file.txt
47+
+++ test_file.txt
48+
@@ -1,1 +1,1 @@
49+
-Line 1
50+
+First Line
51+
@@ -5,1 +5,1 @@
52+
-Line 5
53+
+Last Line
54+
"""
55+
new_content = PatchTool.apply_patch(self.test_file, patch)
56+
lines = new_content.splitlines()
57+
self.assertEqual(lines[0], "First Line")
58+
self.assertEqual(lines[-1], "Last Line")
59+
60+
61+
if __name__ == "__main__":
62+
unittest.main()

tools/patch_tool.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import os
2+
import re
3+
4+
5+
class PatchTool:
6+
@staticmethod
7+
def apply_patch(file_path: str, patch_content: str) -> str:
8+
"""
9+
Applies a unified diff patch to a file.
10+
Returns the new content of the file.
11+
"""
12+
if not os.path.exists(file_path):
13+
content = []
14+
else:
15+
with open(file_path, "r", encoding="utf-8") as f:
16+
content = f.readlines()
17+
18+
patch_lines = patch_content.splitlines(keepends=True)
19+
hunk_re = re.compile(r"^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@")
20+
21+
hunks = []
22+
current_hunk = None
23+
24+
for line in patch_lines:
25+
if line.startswith("---") or line.startswith("+++"):
26+
continue
27+
28+
match = hunk_re.match(line)
29+
if match:
30+
if current_hunk:
31+
hunks.append(current_hunk)
32+
current_hunk = {
33+
"start_old": int(match.group(1)),
34+
"len_old": int(match.group(2) or 1),
35+
"start_new": int(match.group(3)),
36+
"len_new": int(match.group(4) or 1),
37+
"lines": [],
38+
}
39+
elif current_hunk:
40+
current_hunk["lines"].append(line)
41+
42+
if current_hunk:
43+
hunks.append(current_hunk)
44+
45+
result_lines = list(content)
46+
offset = 0
47+
48+
for hunk in hunks:
49+
start_in_file = hunk["start_old"] - 1 + offset
50+
old_len = hunk["len_old"]
51+
52+
new_hunk_lines = []
53+
for h_line in hunk["lines"]:
54+
if h_line.startswith(" "):
55+
new_hunk_lines.append(h_line[1:])
56+
elif h_line.startswith("+"):
57+
new_hunk_lines.append(h_line[1:])
58+
elif h_line.startswith("-"):
59+
pass
60+
61+
# Pad result_lines if the patch references lines beyond current EOF
62+
while len(result_lines) < start_in_file:
63+
result_lines.append("\n")
64+
65+
result_lines[start_in_file : start_in_file + old_len] = new_hunk_lines
66+
offset += len(new_hunk_lines) - old_len
67+
68+
return "".join(result_lines)
69+
70+
71+
if __name__ == "__main__":
72+
import sys
73+
74+
if len(sys.argv) == 3:
75+
path, patch_file = sys.argv[1], sys.argv[2]
76+
with open(patch_file, "r") as pf:
77+
patch = pf.read()
78+
new_text = PatchTool.apply_patch(path, patch)
79+
with open(path, "w") as f:
80+
f.write(new_text)
81+
print(f"Applied patch to {path}")

0 commit comments

Comments
 (0)