Skip to content

Commit dfe6afe

Browse files
committed
Added missing chat test file.
1 parent 131cd29 commit dfe6afe

1 file changed

Lines changed: 232 additions & 0 deletions

File tree

tests/test-chat.py

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
#!/usr/bin/env python3
2+
"""Test interactive chat mode."""
3+
import subprocess
4+
import os
5+
import json
6+
import threading
7+
import time
8+
import pty
9+
import select
10+
from http.server import HTTPServer, BaseHTTPRequestHandler
11+
12+
MOCK_PORT = 18860
13+
passed = 0
14+
failed = 0
15+
16+
class MockHandler(BaseHTTPRequestHandler):
17+
responses = []
18+
request_count = 0
19+
received_requests = []
20+
21+
def do_POST(self):
22+
content_length = int(self.headers.get('Content-Length', 0))
23+
body = self.rfile.read(content_length).decode('utf-8')
24+
MockHandler.received_requests.append({
25+
'path': self.path,
26+
'body': json.loads(body) if body else None
27+
})
28+
response = MockHandler.responses[MockHandler.request_count] \
29+
if MockHandler.request_count < len(MockHandler.responses) \
30+
else {"choices": [{"message": {"content": "No more responses"}}]}
31+
MockHandler.request_count += 1
32+
self.send_response(200)
33+
self.send_header('Content-Type', 'application/json')
34+
self.end_headers()
35+
self.wfile.write(json.dumps(response).encode('utf-8'))
36+
37+
def log_message(self, format, *args):
38+
pass
39+
40+
def run_server(server):
41+
server.serve_forever()
42+
43+
def start_server(port, responses):
44+
MockHandler.responses = responses
45+
MockHandler.request_count = 0
46+
MockHandler.received_requests = []
47+
server = HTTPServer(('127.0.0.1', port), MockHandler)
48+
thread = threading.Thread(target=run_server, args=(server,))
49+
thread.daemon = True
50+
thread.start()
51+
time.sleep(0.1)
52+
return server
53+
54+
def run_with_pty(args, env, interactions, timeout=5):
55+
"""Run a command with a pty, sending input based on expected output.
56+
57+
interactions: list of (expect, send) tuples
58+
- expect: string to wait for in output, or None to send immediately
59+
- send: string to send (newline added automatically)
60+
"""
61+
master_fd, slave_fd = pty.openpty()
62+
proc = subprocess.Popen(
63+
args,
64+
stdin=slave_fd,
65+
stdout=slave_fd,
66+
stderr=slave_fd,
67+
env=env,
68+
)
69+
os.close(slave_fd)
70+
71+
output = b''
72+
interaction_idx = 0
73+
start_time = time.time()
74+
75+
# Send any immediate inputs (expect=None) before reading output
76+
while interaction_idx < len(interactions):
77+
expect, send = interactions[interaction_idx]
78+
if expect is None:
79+
os.write(master_fd, (send + '\n').encode())
80+
interaction_idx += 1
81+
else:
82+
break
83+
84+
while proc.poll() is None:
85+
if time.time() - start_time > timeout:
86+
proc.kill()
87+
proc.wait()
88+
raise subprocess.TimeoutExpired(
89+
args, timeout, output.decode(), output.decode())
90+
91+
# Check if there's output to read
92+
ready, _, _ = select.select([master_fd], [], [], 0.1)
93+
if ready:
94+
try:
95+
chunk = os.read(master_fd, 1024)
96+
if chunk:
97+
output += chunk
98+
except OSError:
99+
break
100+
101+
# Check if we should send the next input
102+
if interaction_idx < len(interactions):
103+
expect, send = interactions[interaction_idx]
104+
if expect is None or expect.encode() in output:
105+
os.write(master_fd, (send + '\n').encode())
106+
interaction_idx += 1
107+
108+
# Read any remaining output
109+
while True:
110+
ready, _, _ = select.select([master_fd], [], [], 0.1)
111+
if not ready:
112+
break
113+
try:
114+
chunk = os.read(master_fd, 1024)
115+
if not chunk:
116+
break
117+
output += chunk
118+
except OSError:
119+
break
120+
121+
os.close(master_fd)
122+
proc.wait()
123+
124+
decoded = output.decode()
125+
return proc.returncode, decoded, decoded
126+
127+
def test(name, func):
128+
global passed, failed
129+
try:
130+
func()
131+
print("✅ %s" % name)
132+
passed += 1
133+
except AssertionError as e:
134+
print("❌ %s" % name)
135+
print(" %s" % e)
136+
failed += 1
137+
138+
def test_chat_loop():
139+
"""Test that chat mode loops and maintains history."""
140+
responses = [
141+
{"choices": [{"message": {"role": "assistant", "content": "Hello there!"}}]},
142+
{"choices": [{"message": {"role": "assistant", "content": "I am doing well."}}]}
143+
]
144+
server = start_server(MOCK_PORT, responses)
145+
try:
146+
env = os.environ.copy()
147+
env['OPENAI_BASE_URL'] = 'http://127.0.0.1:%d' % MOCK_PORT
148+
env['OPENAI_API_KEY'] = 'test-key'
149+
150+
# Remove RUNPROMPT_ vars
151+
for key in list(env.keys()):
152+
if key.startswith('RUNPROMPT_'):
153+
del env[key]
154+
155+
returncode, stdout, stderr = run_with_pty(
156+
['./runprompt', '--chat', 'tests/hello.prompt', '{"name": "World"}'],
157+
env=env,
158+
interactions=[
159+
('Hello there!', 'How are you?'), # Wait for 1st response, send 2nd message
160+
('I am doing well.', ''), # Wait for 2nd response, send empty line to exit
161+
],
162+
timeout=5
163+
)
164+
165+
assert returncode == 0, "Expected success, got: %s" % stderr
166+
assert len(MockHandler.received_requests) == 2, \
167+
"Expected 2 API requests, got %d.\nOutput:\n%s" % (
168+
len(MockHandler.received_requests), stdout)
169+
170+
# Verify the second request contains the conversation history
171+
second_req = MockHandler.received_requests[1]['body']
172+
messages = second_req['messages']
173+
174+
assert len(messages) == 3, "Expected 3 messages in history (user, assistant, user)"
175+
assert messages[0]['role'] == 'user'
176+
assert 'World' in messages[0]['content']
177+
assert messages[1]['role'] == 'assistant'
178+
assert messages[1]['content'] == 'Hello there!'
179+
assert messages[2]['role'] == 'user'
180+
assert messages[2]['content'] == 'How are you?'
181+
182+
finally:
183+
server.shutdown()
184+
185+
def test_chat_frontmatter():
186+
"""Test that chat mode can be enabled via frontmatter."""
187+
responses = [
188+
{"choices": [{"message": {"role": "assistant", "content": "Hi!"}}]},
189+
{"choices": [{"message": {"role": "assistant", "content": "Bye!"}}]}
190+
]
191+
server = start_server(MOCK_PORT + 1, responses)
192+
import tempfile
193+
import shutil
194+
temp_dir = tempfile.mkdtemp()
195+
try:
196+
prompt_file = os.path.join(temp_dir, "chat.prompt")
197+
with open(prompt_file, "w") as f:
198+
f.write("---\nmodel: openai/gpt-4o\nchat: true\n---\nHello")
199+
200+
env = os.environ.copy()
201+
env['OPENAI_BASE_URL'] = 'http://127.0.0.1:%d' % (MOCK_PORT + 1)
202+
env['OPENAI_API_KEY'] = 'test-key'
203+
204+
# Remove RUNPROMPT_ vars
205+
for key in list(env.keys()):
206+
if key.startswith('RUNPROMPT_'):
207+
del env[key]
208+
209+
returncode, stdout, stderr = run_with_pty(
210+
['./runprompt', prompt_file],
211+
env=env,
212+
interactions=[
213+
('Hi!', 'How are you?'),
214+
('Bye!', ''),
215+
],
216+
timeout=5
217+
)
218+
219+
assert returncode == 0, "Expected success, got: %s" % stderr
220+
assert len(MockHandler.received_requests) == 2, "Expected 2 API requests"
221+
222+
finally:
223+
server.shutdown()
224+
shutil.rmtree(temp_dir)
225+
226+
if __name__ == '__main__':
227+
test("chat loop maintains history", test_chat_loop)
228+
test("chat frontmatter", test_chat_frontmatter)
229+
print("")
230+
print("Passed: %d, Failed: %d" % (passed, failed))
231+
if failed > 0:
232+
exit(1)

0 commit comments

Comments
 (0)