Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions src/test/kotlin/agents_engine/composition/branch/BranchSuspendTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package agents_engine.composition.branch

import agents_engine.core.agent
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.fail

// Tests for #880 — direct invokeSuspend coverage on Branch.
//
// Existing BranchExecutionTest / BranchSafetyTest call the synchronous
// `branch(input)` wrapper which internally does `runBlocking { invokeSuspend(input) }`.
// PIT's mutation-coverage tracking through the coroutine state-machine bytecode is
// unreliable, so those tests show every line of invokeSuspend as NO_COVERAGE.
//
// These tests call `branch.invokeSuspend(input)` directly inside a single
// `runBlocking { }` block — same execution path, but unambiguous in the
// bytecode the coverage tool actually reads.
//
// Note: the null-branch in invokeSuspend (lines 32-38) is defensive-dead-code
// not reachable through the public API. `agent<IN, OUT : Any>` requires
// non-null OUT, so a Skill cannot legitimately produce null at the type level.
// PIT correctly reports those lines as NO_COVERAGE — they exist for safety
// but no legal usage exercises them.
class BranchSuspendTest {

sealed interface Animal
data class Dog(val name: String) : Animal
data class Cat(val name: String) : Animal
data class Fish(val name: String) : Animal

// Open hierarchy for the "no-route + no-onElse" test — validateSealedCompleteness
// short-circuits on non-sealed source types so the runtime error path is reachable.
open class Vehicle
class Car : Vehicle()
class Truck : Vehicle()
class Bike : Vehicle()

private fun dogHandler() = agent<Dog, String>("dog") {
skills { skill<Dog, String>("s") { implementedBy { "dog ${it.name}" } } }
}

private fun catHandler() = agent<Cat, String>("cat") {
skills { skill<Cat, String>("s") { implementedBy { "cat ${it.name}" } } }
}

private fun fishHandler() = agent<Fish, String>("fish") {
skills { skill<Fish, String>("s") { implementedBy { "fish ${it.name}" } } }
}

private fun elseHandler() = agent<Any, String>("else") {
skills { skill<Any, String>("s") { implementedBy { "else: ${it::class.simpleName}" } } }
}

@Test
fun `invokeSuspend hits TypeRoute when result matches`() = runBlocking {
val src = agent<String, Animal>("src") {
skills { skill<String, Animal>("s") { implementedBy { Dog("rex") } } }
}
val branch = src.branch<String, Animal, String> {
on<Dog>() then dogHandler()
on<Cat>() then catHandler()
on<Fish>() then fishHandler()
}
assertEquals("dog rex", branch.invokeSuspend("x"))
}

@Test
fun `invokeSuspend picks first matching route in registration order`() = runBlocking {
// TypeRoute matches via klass.isInstance, which covers subtypes. With
// Animal first then Dog, Animal would match Dog. Order: more specific first.
val src = agent<String, Animal>("src") {
skills { skill<String, Animal>("s") { implementedBy { Cat("luna") } } }
}
val branch = src.branch<String, Animal, String> {
on<Dog>() then dogHandler()
on<Cat>() then catHandler() // ← matches
on<Fish>() then fishHandler()
}
assertEquals("cat luna", branch.invokeSuspend("x"))
}

@Test
fun `invokeSuspend hits ElseRoute when no TypeRoute matches non-null result`() = runBlocking {
// Open hierarchy so completeness check doesn't fire at construction.
val src = agent<String, Vehicle>("src") {
skills { skill<String, Vehicle>("s") { implementedBy { Bike() } } }
}
val carHandler = agent<Car, String>("c") {
skills { skill<Car, String>("s") { implementedBy { "car" } } }
}
val truckHandler = agent<Truck, String>("t") {
skills { skill<Truck, String>("s") { implementedBy { "truck" } } }
}
val branch = src.branch<String, Vehicle, String> {
on<Car>() then carHandler
on<Truck>() then truckHandler
onElse then elseHandler()
}
assertEquals("else: Bike", branch.invokeSuspend("x"))
}

@Test
fun `invokeSuspend errors when no route matches non-null result and no onElse declared`() = runBlocking {
val src = agent<String, Vehicle>("src") {
skills { skill<String, Vehicle>("s") { implementedBy { Bike() } } }
}
val carHandler = agent<Car, String>("c") {
skills { skill<Car, String>("s") { implementedBy { "car" } } }
}
val truckHandler = agent<Truck, String>("t") {
skills { skill<Truck, String>("s") { implementedBy { "truck" } } }
}
val branch = src.branch<String, Vehicle, String> {
on<Car>() then carHandler
on<Truck>() then truckHandler
// no onElse — Bike will hit the post-loop error on L56
}
try {
branch.invokeSuspend("x")
fail("expected error when no route matches non-null and no onElse")
} catch (e: IllegalStateException) {
assertTrue(
e.message!!.contains("Bike") || e.message!!.contains("onElse"),
"error must name the result type or onElse: ${e.message}",
)
}
}
}
166 changes: 166 additions & 0 deletions src/test/kotlin/agents_engine/composition/forum/ForumCastReturnTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package agents_engine.composition.forum

import agents_engine.core.agent
import agents_engine.generation.Generable
import agents_engine.generation.Guide
import agents_engine.model.LlmResponse
import agents_engine.model.ModelClient
import agents_engine.model.ToolCall
import org.junit.jupiter.api.assertThrows
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

// Tests for #888 — direct coverage of Forum.castForumReturn. The captain
// emits `forum_return(value=...)`, the framework throws ForumReturnException
// internally, and Forum.invokeSuspend's catch block routes the value through
// castForumReturn(). Different `value` types exercise each branch.

@Generable("forum verdict shape")
data class ForumVerdict(@Guide("the answer") val answer: String)

class ForumCastReturnTest {

private fun participant() = agent<String, String>("p") {
skills { skill<String, String>("p") { implementedBy { "participant-said-$it" } } }
}

private fun captainEmitting(toolArgs: Map<String, Any?>) = agent<String, String>("c") {
model {
ollama("test")
client = ModelClient { _ ->
LlmResponse.ToolCalls(listOf(ToolCall("forum_return", toolArgs)))
}
}
skills { skill<String, String>("c") { tools() } }
}

private inline fun <reified T : Any> typedCaptainEmitting(toolArgs: Map<String, Any?>) =
agent<String, T>("c") {
model {
ollama("test")
client = ModelClient { _ ->
LlmResponse.ToolCalls(listOf(ToolCall("forum_return", toolArgs)))
}
}
skills { skill<String, T>("c") { tools() } }
}

// L84 — outType == String, value is non-String (toString cast)

@Test
fun `castForumReturn outType String coerces non-String via toString`() {
val result = forum<String, String> {
participant(participant())
captain(captainEmitting(mapOf("value" to 42)))
}("topic")
assertEquals("42", result)
}

// L85 — outType.java.isInstance(raw) — direct pass-through

@Test
fun `castForumReturn passes through when raw is already an instance of OUT`() {
// OUT=Int, value=42 (Int). outType==String fails, then
// Int::class.java.isInstance(42) succeeds → cast through.
val result = forum<String, Int> {
participant(participant())
captain(typedCaptainEmitting<Int>(mapOf("value" to 42)))
}("topic")
assertEquals(42, result)
}

// L87-89 — raw is Map → constructFromMap → success

@Test
fun `castForumReturn constructs Generable from Map raw value`() {
val result = forum<String, ForumVerdict> {
participant(participant())
captain(typedCaptainEmitting<ForumVerdict>(mapOf("value" to mapOf("answer" to "hello"))))
}("topic")
assertEquals("hello", result.answer)
}

// L89 ?: error — raw is Map but constructFromMap fails

@Test
fun `castForumReturn errors when Map cannot be constructed into Generable`() {
val ex = assertThrows<IllegalStateException> {
forum<String, ForumVerdict> {
participant(participant())
captain(
typedCaptainEmitting<ForumVerdict>(
mapOf("value" to mapOf("wrongField" to "boom")),
),
)
}("topic")
}
assertTrue(
ex.message!!.contains("ForumVerdict") && ex.message!!.contains("could not be parsed"),
"expected error to name the type: ${ex.message}",
)
}

// L91-93 — raw is String → fromLlmOutput → success

@Test
fun `castForumReturn parses Generable from JSON String raw value`() {
val result = forum<String, ForumVerdict> {
participant(participant())
captain(
typedCaptainEmitting<ForumVerdict>(
mapOf("value" to """{"answer":"world"}"""),
),
)
}("topic")
assertEquals("world", result.answer)
}

// L93 ?: error — raw is String but fromLlmOutput fails

@Test
fun `castForumReturn errors when String cannot be parsed as Generable`() {
val ex = assertThrows<IllegalStateException> {
forum<String, ForumVerdict> {
participant(participant())
captain(
typedCaptainEmitting<ForumVerdict>(
mapOf("value" to "not even close to JSON"),
),
)
}("topic")
}
assertTrue(
ex.message!!.contains("ForumVerdict"),
"expected error to name the type: ${ex.message}",
)
}

// L95 — catch-all: raw is none of String/Map/instance-of-OUT

@Test
fun `castForumReturn errors when raw is incompatible with OUT (catch-all)`() {
val ex = assertThrows<IllegalStateException> {
forum<String, ForumVerdict> {
participant(participant())
captain(
// raw = Int 42, OUT = ForumVerdict
// outType==String? no
// ForumVerdict.java.isInstance(42)? no
// raw is Map? no
// raw is String? no
// → catch-all error fires
typedCaptainEmitting<ForumVerdict>(mapOf("value" to 42)),
)
}("topic")
}
assertTrue(
ex.message!!.contains("incompatible"),
"expected catch-all error wording: ${ex.message}",
)
assertTrue(
ex.message!!.contains("ForumVerdict"),
"error must name OUT type: ${ex.message}",
)
}
}
Loading
Loading