Skip to content

Commit 339ddce

Browse files
Skobeltsynclaude
andcommitted
test(#1978): LiveRunner CLI parsing + --once + help/version/error exit codes
LiveRunner cluster was 39 unkilled. Mirrors McpRunner's well-tested pattern. 19 new tests across three groups: **parseArgs branches (13 tests):** - empty args / --help / -h / --version / -V — flag dispatch - --once value capture + missing-value error - --max-history value applied to builder (kills the removed-call mutant on setMaxHistoryTurns at parseArgs:173) - --max-history missing value / non-numeric / -1 below-boundary / 0 exact-boundary-accepted (kills the `parsed < 0` ConditionalsBoundary mutant at line 172 — flipping to `<= 0` would reject 0) - unknown flag error includes flag name - multi-error accumulation (kills "early-return on first error" mutants) **--once happy path + serve overload dispatch (4 tests):** - Agent overload — output reaches stdout (kills serve$lambda$0 "return null") - Pipeline overload — chains both stages (kills serve$lambda$2) - empty prompt — still invokes agent - agent-throws → exit 2 + error to stdout (kills the int-return mutant on the catch block + the println mutant) **Help / version / error output (3 tests):** - --help → exit 0 + banner + flag-documentation visible - --version → exit 0 + "Agents.KT" line - unknown flag → exit 2 + error: prefix + offending flag name + usage (kills printHelp removal + println removal + the int-return mutant distinguishing error from happy path) Expected PIT impact: LiveRunner cluster 39 → low double digits or single digits. The serve$lambda$4-11 (Forum/Parallel/Loop/Branch overloads) remain uncovered — same shape as Agent/Pipeline, can be killed by adding the other 4 overload tests if needed; this batch covers the two most common usage shapes. Part of #1978 (per-cluster child of #889 umbrella). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d1fe437 commit 339ddce

1 file changed

Lines changed: 246 additions & 0 deletions

File tree

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
package agents_engine.runtime
2+
3+
import agents_engine.composition.pipeline.then
4+
import agents_engine.core.agent
5+
import java.io.ByteArrayOutputStream
6+
import java.io.PrintWriter
7+
import java.io.StringReader
8+
import kotlin.test.Test
9+
import kotlin.test.assertEquals
10+
import kotlin.test.assertNotEquals
11+
import kotlin.test.assertTrue
12+
13+
// Tests for #1978 — LiveRunner cluster (39 unkilled). Mirrors McpRunner's test
14+
// pattern. Three groups of mutants targeted:
15+
//
16+
// 1. parseArgs branches (lines 159, 172, 173) — flag dispatch, --max-history
17+
// boundary at 0, builder.maxHistoryTurns side-effect.
18+
// 2. run() control flow (lines 96/97/99/100/111/117/122/138/140) — printHelp
19+
// invocation on help / error path, println side effects, exit code
20+
// distinction between happy / error / unknown, shutdown hook registration.
21+
// 3. serve() overload dispatch + lambda return values (serve$lambda$0-11) —
22+
// each of six serve() overloads must actually invoke its agent/pipeline/etc.
23+
// in --once mode and surface the result to stdout.
24+
class LiveRunnerCliAndOnceTest {
25+
26+
// Stub I/O — the runner writes to PrintWriter(out) and reads from Reader(in).
27+
private fun captureOut(): Pair<PrintWriter, ByteArrayOutputStream> {
28+
val baos = ByteArrayOutputStream()
29+
return PrintWriter(baos, true) to baos
30+
}
31+
32+
private fun trivialAgent(name: String = "greeter") = agent<String, String>(name) {
33+
skills { skill<String, String>("greet", "Greet") { implementedBy { "hi $it" } } }
34+
}
35+
36+
// ── parseArgs branches ───────────────────────────────────────────────────
37+
38+
@Test
39+
fun `parseArgs default empty args produces no errors no help no version no once`() {
40+
val parsed = LiveRunner.parseArgs(emptyArray()) { /* no configure */ }
41+
assertTrue(parsed.errors.isEmpty(), "empty args must be valid")
42+
assertEquals(false, parsed.helpRequested)
43+
assertEquals(false, parsed.versionRequested)
44+
assertEquals(null, parsed.once)
45+
}
46+
47+
@Test
48+
fun `parseArgs --help long-form sets helpRequested`() {
49+
val parsed = LiveRunner.parseArgs(arrayOf("--help")) {}
50+
assertTrue(parsed.helpRequested, "--help must set helpRequested")
51+
}
52+
53+
@Test
54+
fun `parseArgs -h short-form sets helpRequested`() {
55+
val parsed = LiveRunner.parseArgs(arrayOf("-h")) {}
56+
assertTrue(parsed.helpRequested)
57+
}
58+
59+
@Test
60+
fun `parseArgs --version long-form sets versionRequested`() {
61+
val parsed = LiveRunner.parseArgs(arrayOf("--version")) {}
62+
assertTrue(parsed.versionRequested)
63+
}
64+
65+
@Test
66+
fun `parseArgs -V short-form sets versionRequested`() {
67+
val parsed = LiveRunner.parseArgs(arrayOf("-V")) {}
68+
assertTrue(parsed.versionRequested)
69+
}
70+
71+
@Test
72+
fun `parseArgs --once captures the prompt`() {
73+
// parseArgs:159 negated conditional — the `when (val a)` flag dispatch.
74+
// If the --once branch's body is mutated to skip, `once` stays null.
75+
val parsed = LiveRunner.parseArgs(arrayOf("--once", "hello world")) {}
76+
assertEquals("hello world", parsed.once)
77+
assertTrue(parsed.errors.isEmpty())
78+
}
79+
80+
@Test
81+
fun `parseArgs --once at end of args without value reports error`() {
82+
val parsed = LiveRunner.parseArgs(arrayOf("--once")) {}
83+
assertTrue(parsed.errors.any { it.contains("--once requires a value") },
84+
"missing --once value must surface a descriptive error: ${parsed.errors}")
85+
}
86+
87+
@Test
88+
fun `parseArgs --max-history applies value to the builder`() {
89+
// parseArgs:173 — the `removed call to setMaxHistoryTurns` mutant.
90+
// If the side-effect line is removed, builder.maxHistoryTurns stays default.
91+
val parsed = LiveRunner.parseArgs(arrayOf("--max-history", "42")) {}
92+
assertTrue(parsed.errors.isEmpty(), "valid --max-history must produce no errors: ${parsed.errors}")
93+
assertEquals(42, parsed.builder.maxHistoryTurns, "--max-history value must be applied to builder")
94+
}
95+
96+
@Test
97+
fun `parseArgs --max-history at end of args without value reports error`() {
98+
val parsed = LiveRunner.parseArgs(arrayOf("--max-history")) {}
99+
assertTrue(parsed.errors.any { it.contains("--max-history requires a value") },
100+
"missing --max-history value must surface error: ${parsed.errors}")
101+
}
102+
103+
@Test
104+
fun `parseArgs --max-history with non-numeric value reports error`() {
105+
val parsed = LiveRunner.parseArgs(arrayOf("--max-history", "abc")) {}
106+
assertTrue(parsed.errors.any { it.contains("invalid --max-history value") },
107+
"non-numeric --max-history must surface error: ${parsed.errors}")
108+
}
109+
110+
@Test
111+
fun `parseArgs --max-history 0 is accepted (boundary at lower bound)`() {
112+
// parseArgs:172 boundary mutant on `parsed < 0`. Mutated `<= 0` would
113+
// reject 0 (saying "invalid"); unmutated accepts 0 (saying "valid").
114+
val parsed = LiveRunner.parseArgs(arrayOf("--max-history", "0")) {}
115+
assertTrue(parsed.errors.isEmpty(),
116+
"--max-history 0 is the exact boundary; must be valid. Errors: ${parsed.errors}")
117+
assertEquals(0, parsed.builder.maxHistoryTurns)
118+
}
119+
120+
@Test
121+
fun `parseArgs --max-history -1 is rejected (just below boundary)`() {
122+
// Anchors the other side of the boundary.
123+
val parsed = LiveRunner.parseArgs(arrayOf("--max-history", "-1")) {}
124+
assertTrue(parsed.errors.any { it.contains("invalid --max-history value") },
125+
"--max-history -1 below boundary must reject: ${parsed.errors}")
126+
}
127+
128+
@Test
129+
fun `parseArgs unknown flag reports error including flag name`() {
130+
val parsed = LiveRunner.parseArgs(arrayOf("--nope")) {}
131+
assertTrue(parsed.errors.any { it.contains("unknown flag") && it.contains("--nope") },
132+
"unknown flag error must include the flag name: ${parsed.errors}")
133+
}
134+
135+
@Test
136+
fun `parseArgs accumulates multiple errors without bailing on first`() {
137+
// Catches mutant that early-returns from parseArgs on the first error.
138+
val parsed = LiveRunner.parseArgs(arrayOf("--bad1", "--max-history", "abc", "--bad2")) {}
139+
assertTrue(parsed.errors.size >= 3,
140+
"all three errors must accumulate: ${parsed.errors}")
141+
}
142+
143+
// ── --once happy path (kills serve$lambda$0 + run$lambda$3 mutants) ──────
144+
145+
@Test
146+
fun `serve --once invokes the agent and prints its output to stdout`() {
147+
// serve$lambda$0 (line 48) is the `{ agent.invokeSuspend(it) }` lambda
148+
// bound into run(). PIT "replaced return value with null" would make
149+
// serve print "null" instead of the agent's actual output.
150+
val (pw, baos) = captureOut()
151+
val exit = LiveRunner.serve(trivialAgent(), arrayOf("--once", "world")) {
152+
output = pw
153+
}
154+
assertEquals(0, exit, "--once happy path must return 0")
155+
val out = baos.toString().trim()
156+
assertEquals("hi world", out, "agent output must reach stdout, not 'null' or empty")
157+
}
158+
159+
@Test
160+
fun `serve --once with empty prompt still invokes the agent`() {
161+
val (pw, baos) = captureOut()
162+
val exit = LiveRunner.serve(trivialAgent(), arrayOf("--once", "")) { output = pw }
163+
assertEquals(0, exit)
164+
// "hi " (empty input passed through)
165+
assertTrue(baos.toString().contains("hi"), "empty prompt should still invoke agent: '${baos.toString().trim()}'")
166+
}
167+
168+
@Test
169+
fun `serve --once for Pipeline overload invokes the pipeline`() {
170+
// Drives serve$lambda$2 (line 54) — the pipeline.invokeSuspend lambda.
171+
val pipeline = trivialAgent("a") then agent<String, String>("b") {
172+
skills { skill<String, String>("op", "transform") { implementedBy { "$it!" } } }
173+
}
174+
val (pw, baos) = captureOut()
175+
val exit = LiveRunner.serve(pipeline, arrayOf("--once", "test")) { output = pw }
176+
assertEquals(0, exit)
177+
assertEquals("hi test!", baos.toString().trim(), "pipeline must chain both stages and surface the final output")
178+
}
179+
180+
@Test
181+
fun `serve --once when agent throws returns exit code 2 and prints error`() {
182+
// run() line 121-123 — the catch block. Kills the int-return mutant
183+
// (`replaced int return with 0`) and the println mutant.
184+
val explodingAgent = agent<String, String>("boomer") {
185+
skills {
186+
skill<String, String>("boom", "Always throws") {
187+
implementedBy { _ -> throw IllegalStateException("kaboom") }
188+
}
189+
}
190+
}
191+
val (pw, baos) = captureOut()
192+
val exit = LiveRunner.serve(explodingAgent, arrayOf("--once", "x")) { output = pw }
193+
assertEquals(2, exit, "agent exception must return exit 2, not 0")
194+
val out = baos.toString()
195+
assertTrue(out.contains("kaboom"), "error message must reach stdout: '$out'")
196+
}
197+
198+
// ── help / version output (kills printHelp + println mutants) ────────────
199+
200+
@Test
201+
fun `serve --help returns exit 0 and prints help banner with version`() {
202+
// run() lines 96 + printHelp (line 190+). Kills removed-call mutants
203+
// on printHelp and the println inside printHelp.
204+
val (pw, baos) = captureOut()
205+
val exit = LiveRunner.serve(trivialAgent(), arrayOf("--help")) { output = pw }
206+
assertEquals(0, exit)
207+
val out = baos.toString()
208+
assertTrue(out.contains("Agents.KT"), "help must include 'Agents.KT' banner: '$out'")
209+
assertTrue(out.contains("--once"), "help must document --once flag: '$out'")
210+
assertTrue(out.contains("--max-history"), "help must document --max-history flag")
211+
assertTrue(out.contains("--help"), "help must document --help flag")
212+
}
213+
214+
@Test
215+
fun `serve --version returns exit 0 and prints version line to stdout`() {
216+
// run() line 97 — `out.println("Agents.KT $VERSION")`.
217+
// Kills the println-removal mutant.
218+
val (pw, baos) = captureOut()
219+
val exit = LiveRunner.serve(trivialAgent(), arrayOf("--version")) { output = pw }
220+
assertEquals(0, exit)
221+
val out = baos.toString()
222+
assertTrue(out.contains("Agents.KT"), "version line must mention 'Agents.KT': '$out'")
223+
}
224+
225+
// ── error / unknown flag (kills exit code mutants + println + printHelp) ─
226+
227+
@Test
228+
fun `serve unknown flag returns exit 2 and prints error PLUS usage`() {
229+
// run() lines 98-101 — error iteration println + printHelp invocation.
230+
val (pw, baos) = captureOut()
231+
val exit = LiveRunner.serve(trivialAgent(), arrayOf("--nonsense")) { output = pw }
232+
assertEquals(2, exit)
233+
val out = baos.toString()
234+
assertTrue(out.contains("error:"), "errors must be prefixed with 'error:': '$out'")
235+
assertTrue(out.contains("--nonsense"), "the offending flag should appear in the error: '$out'")
236+
assertTrue(out.contains("--once"), "usage should follow the error line (kills printHelp removal): '$out'")
237+
}
238+
239+
@Test
240+
fun `serve unknown flag exit code is distinct from happy path`() {
241+
// Catches `replaced int return with 0` on the error path.
242+
val (pw, _) = captureOut()
243+
val errExit = LiveRunner.serve(trivialAgent(), arrayOf("--nonsense")) { output = pw }
244+
assertNotEquals(0, errExit, "error path must NOT return 0 (happy-path exit code)")
245+
}
246+
}

0 commit comments

Comments
 (0)