返回 ViMax
test_main_agent_cli.py
根目录 / tests / test_main_agent_cli.py
1 import contextlib
2 import io
3 import json
4 import sys
5 import unittest
6 from unittest.mock import patch
7
8 import main_agent
9
10
11 class FakeSessionIndex:
12 def __init__(self, fail_session=False):
13 self.fail_session = fail_session
14 self.activated = ""
15 self.created = 0
16 self.project_name = ""
17
18 def set_active(self, session_id):
19 if self.fail_session:
20 raise KeyError(session_id)
21 self.activated = session_id
22
23 def create(self, project_name=""):
24 self.created += 1
25 self.project_name = project_name
26 self.activated = f"new-{self.created}"
27 return {"session_id": self.activated}
28
29 def snapshot(self):
30 return {"active_session_id": self.activated, "session": {"session_id": self.activated, "stage": "created"}}
31
32
33 class FakeRuntime:
34 def __init__(self, fail_session=False):
35 self.session_index = FakeSessionIndex(fail_session=fail_session)
36 self.inputs = []
37
38 async def compact_history(self, reason="manual"):
39 return "Compacted context 100 -> 50 (fallback-local)."
40
41 async def stream_events(self, user_input):
42 self.inputs.append(user_input)
43 turn_id = "turn-test"
44 yield {"type": "turn", "turn_id": turn_id, "turn": {"id": turn_id}}
45 yield {"type": "status", "turn_id": turn_id, "phase": "sampling_assistant", "message": "Sampling assistant"}
46 yield {"type": "tool_progress", "turn_id": turn_id, "tool": {"name": "fake_tool"}, "progress": {"stage": "running", "message": "working"}}
47 yield {"type": "terminal", "turn_id": turn_id, "stream": "stdout", "line": "pipeline output"}
48 yield {"type": "token", "turn_id": turn_id, "delta": "done"}
49 yield {"type": "done", "turn_id": turn_id, "assistant": "done", "tool_results": []}
50 yield {"type": "session", "turn_id": turn_id, "session": {"session": {"session_id": "s1", "stage": "narrative_planned"}}}
51
52
53 class MainAgentCliTests(unittest.IsolatedAsyncioTestCase):
54 async def run_cli(self, argv, runtime=None, stdin_text="", session_index=None, load_runtime_side_effect=None):
55 runtime = runtime or FakeRuntime()
56 session_index = session_index or runtime.session_index
57 stdout = io.StringIO()
58 stderr = io.StringIO()
59 stdin = io.StringIO(stdin_text)
60 load_runtime_patch = patch.object(main_agent, "load_runtime", return_value=runtime)
61 if load_runtime_side_effect is not None:
62 load_runtime_patch = patch.object(main_agent, "load_runtime", side_effect=load_runtime_side_effect)
63 with load_runtime_patch, \
64 patch.object(main_agent, "load_session_index", return_value=session_index), \
65 patch.object(sys, "stdin", stdin), \
66 contextlib.redirect_stdout(stdout), \
67 contextlib.redirect_stderr(stderr):
68 code = await main_agent.amain(argv)
69 return code, stdout.getvalue(), stderr.getvalue(), runtime
70
71 def test_help_parser_contains_once(self):
72 stdout = io.StringIO()
73 with contextlib.redirect_stdout(stdout), self.assertRaises(SystemExit) as ctx:
74 main_agent.parse_args(["--help"])
75 self.assertEqual(ctx.exception.code, 0)
76 self.assertIn("--once", stdout.getvalue())
77
78 async def test_jsonl_once_outputs_valid_events_with_turn_id(self):
79 code, stdout, stderr, runtime = await self.run_cli(["--jsonl", "--once", "hello"])
80 self.assertEqual(code, 0)
81 self.assertEqual(stderr, "")
82 self.assertEqual(runtime.inputs, ["hello"])
83 lines = [json.loads(line) for line in stdout.splitlines()]
84 self.assertTrue(lines)
85 self.assertEqual({event["turn_id"] for event in lines}, {"turn-test"})
86 self.assertEqual(lines[0]["type"], "turn")
87 self.assertIn("terminal", [event["type"] for event in lines])
88 self.assertNotIn("›", stdout)
89
90 async def test_stdin_non_tty_is_single_prompt(self):
91 code, stdout, stderr, runtime = await self.run_cli(["--jsonl"], stdin_text="from stdin\n")
92 self.assertEqual(code, 0)
93 self.assertEqual(runtime.inputs, ["from stdin"])
94 self.assertTrue(stdout.strip())
95
96
97 async def test_stdin_repl_reads_each_line_as_a_turn(self):
98 code, stdout, stderr, runtime = await self.run_cli(["--jsonl", "--stdin-repl"], stdin_text="first\nsecond\n")
99 self.assertEqual(code, 0)
100 self.assertEqual(stderr, "")
101 self.assertEqual(runtime.inputs, ["first", "second"])
102 events = [json.loads(line) for line in stdout.splitlines()]
103 self.assertEqual([event["type"] for event in events if event["type"] == "done"], ["done", "done"])
104
105 async def test_session_error_is_clear_before_runtime_load(self):
106 runtime = FakeRuntime()
107 failing_index = FakeSessionIndex(fail_session=True)
108 code, stdout, stderr, runtime = await self.run_cli(
109 ["--session", "missing", "--once", "hello"],
110 runtime=runtime,
111 session_index=failing_index,
112 load_runtime_side_effect=AssertionError("runtime should not load"),
113 )
114 self.assertEqual(code, 2)
115 self.assertEqual(stdout, "")
116 self.assertIn("unknown session id", stderr)
117 self.assertEqual(runtime.inputs, [])
118
119
120 async def test_new_session_is_created_before_runtime_load(self):
121 runtime = FakeRuntime()
122 session_index = FakeSessionIndex()
123 code, stdout, stderr, runtime = await self.run_cli(
124 ["--new-session", "--jsonl", "--once", "hello"],
125 runtime=runtime,
126 session_index=session_index,
127 )
128 self.assertEqual(code, 0)
129 self.assertEqual(stderr, "")
130 self.assertEqual(session_index.created, 1)
131 self.assertEqual(session_index.activated, "new-1")
132 self.assertEqual(runtime.inputs, ["hello"])
133
134 async def test_named_new_session_passes_project_name(self):
135 runtime = FakeRuntime()
136 session_index = FakeSessionIndex()
137 code, stdout, stderr, runtime = await self.run_cli(
138 ["--new-session", "--new-session-name", "Ocean campaign", "--jsonl", "--once", "hello"],
139 runtime=runtime,
140 session_index=session_index,
141 )
142 self.assertEqual(code, 0)
143 self.assertEqual(stderr, "")
144 self.assertEqual(session_index.project_name, "Ocean campaign")
145 self.assertEqual(runtime.inputs, ["hello"])
146
147 async def test_new_session_and_session_are_mutually_exclusive(self):
148 runtime = FakeRuntime()
149 code, stdout, stderr, runtime = await self.run_cli(
150 ["--new-session", "--session", "s1", "--once", "hello"],
151 runtime=runtime,
152 load_runtime_side_effect=AssertionError("runtime should not load"),
153 )
154 self.assertEqual(code, 2)
155 self.assertEqual(stdout, "")
156 self.assertIn("cannot be used together", stderr)
157 self.assertEqual(runtime.inputs, [])
158
159
160 async def test_compact_command_outputs_jsonl_without_llm_turn(self):
161 code, stdout, stderr, runtime = await self.run_cli(["--jsonl", "--once", "/compact"])
162 self.assertEqual(code, 0)
163 self.assertEqual(stderr, "")
164 self.assertEqual(runtime.inputs, [])
165 events = [json.loads(line) for line in stdout.splitlines()]
166 self.assertEqual([event["type"] for event in events], ["turn", "status", "token", "done", "session"])
167 turn_ids = {event["turn_id"] for event in events}
168 self.assertEqual(len(turn_ids), 1)
169 self.assertTrue(next(iter(turn_ids)).startswith("turn-"))
170 self.assertIn("Compacted context", events[2]["delta"])
171
172 async def test_plain_mode_prints_progress_terminal_and_session(self):
173 code, stdout, stderr, _ = await self.run_cli(["--once", "hello"])
174 self.assertEqual(code, 0)
175 self.assertIn("tool: fake_tool running: working", stdout)
176 self.assertIn("terminal[stdout]: pipeline output", stdout)
177 self.assertIn("session: s1 narrative_planned", stdout)
178
178 lines PYTHON