diff --git a/CHANGELOG.md b/CHANGELOG.md index fdcbf3346..89ab799d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -173,6 +173,7 @@ - fix: List the test requests of every verb in 'testUrlReferences' of `explyt_trace_spring_call_chain` for a functional route that answers every HTTP method, as for a bare `@RequestMapping` handler (#659) - feat: Describe a handler returning `PagedModel` in `explyt_get_spring_endpoint_contract` as the paged model it writes (`content` and `page`, `wireShapeSource: DECLARED_TYPE`) instead of its element type, use Boot's direct default for a blank `spring.data.web.pageable.serialization-mode`, and read a composed `@EnableSpringDataWebSupport` annotation from the class that uses it; a composed annotation that declares its own `pageSerializationMode` without `@AliasFor`, or leaves its mode alias unset, reports an unknown source (#639) - feat: List test-source applications last with `testSource` in the MCP application listing (#602) +- fix: Serve an active candidate on the first page of `explyt_find_spring_bean` whenever the budget holds one: the `inactiveCandidates` sample now gives way to the first active candidate, down to none with `inactiveCandidatesTruncated`, and still takes at most half of `maxChars`, instead of leaving a small budget with `RESPONSE_TOO_LARGE`, whose hint now names the `maxChars` that serves the first active candidate; a continuation of a static answer of `explyt_find_spring_bean` or `explyt_get_spring_beans` across a toggle of bean condition evaluation answers `RESULT_CHANGED` (#634) - feat: List project beans (and those of unknown origin) excluded by a `@Conditional` other than `@Profile` in `explyt_get_spring_beans` instead of hiding them: they come after every other row with `condition` — `state: INACTIVE` and the `annotation`, `carrier` and `detail` of the excluding condition — and count in `totalCount`, the `origin` filter and the continuation; the rows of library beans excluded by a condition are counted in `inactiveLibraryCount` and listed only with `origin=LIBRARY`, after the active library rows; a bean whose condition cannot be decided keeps its place with `condition` in `state: UNDECIDED` and its `reasons`, and the envelope-level `limitations` names `CONDITIONS_NOT_EVALUATED` when condition evaluation is disabled (#496) - fix: Name the handler of a Kotlin router DSL route `path("/x", handler)` in `explyt_get_spring_endpoint_contract`: its partial contract listed no `serviceCalls`, because only routes named after an HTTP verb were read, so `handler::handle` is now its `serviceCall` like that of `GET("/x", handler::handle)`, and `explyt_trace_spring_call_chain` started at such a route begins at its handler and marks it with the route `ALL /x` (#607) - feat: Show why a project Actuator endpoint's bean is off in `explyt_find_spring_endpoint`, `explyt_get_spring_http_endpoints` and `explyt_get_spring_endpoint_contract`: the endpoint stays listed with `beanCondition` — `state: INACTIVE` with the `annotation`, `carrier` and `detail` of the condition that excludes it, or `state: UNDECIDED` when its conditions cannot be decided statically, naming the first undecided condition and listing every undecided reason in `reasons` — while `exposed` and `access` keep describing the configured exposure; the key is absent when no condition excludes the bean or leaves it undecided, and when bean condition evaluation is disabled in the settings (#496) diff --git a/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/BoundedPageWriter.kt b/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/BoundedPageWriter.kt index 02d5fe087..b03a4c971 100644 --- a/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/BoundedPageWriter.kt +++ b/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/BoundedPageWriter.kt @@ -5,6 +5,7 @@ package com.explyt.spring.ai.mcp +import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.node.ObjectNode @@ -94,17 +95,18 @@ class BoundedPageWriter { sampleTruncatedField: String, totalCount: Int, revision: String, - page: PageRequest + page: PageRequest, + firstItem: ObjectNode? = null, + maxSampleChars: Int = Int.MAX_VALUE, ): ObjectNode { val fitted = envelope.deepCopy() val served = fitted.putArray(sampleField) fitted.put(sampleTruncatedField, true) for (item in sample) { served.add(item) - val probe = pageEnvelope(fitted, revision, totalCount, page.offset).apply { putArray(itemsField) } - val placeholderLength = encodedLength(probe) - setContinuation(probe, totalCount, page.offset) - if (maxOf(placeholderLength, encodedLength(probe)) > page.maxChars) { + if (encodedLength(served) > maxSampleChars || + firstPageLength(fitted, itemsField, firstItem, totalCount, revision, page.offset) > page.maxChars + ) { served.remove(served.size() - 1) break } @@ -113,6 +115,24 @@ class BoundedPageWriter { return fitted } + private fun firstPageLength( + envelope: ObjectNode, + itemsField: String, + firstItem: ObjectNode?, + totalCount: Int, + revision: String, + offset: Int + ): Int { + val probe = pageEnvelope(envelope, revision, totalCount, offset) + val items = probe.putArray(itemsField) + val placeholderLength = encodedLength(probe) + val emptyLength = maxOf(placeholderLength, terminalLength(probe, totalCount, offset)) + if (firstItem == null) return emptyLength + items.add(firstItem) + setContinuation(probe, totalCount, offset + 1) + return maxOf(emptyLength, encodedLength(probe)) + } + fun writeError( code: String, message: String, @@ -237,7 +257,7 @@ class BoundedPageWriter { private fun terminalLength(root: ObjectNode, totalCount: Int, offset: Int): Int = root.deepCopy().apply { setContinuation(this, totalCount, offset) }.let(::encodedLength) - private fun encodedLength(node: ObjectNode): Int = mapper.writeValueAsString(node).length + private fun encodedLength(node: JsonNode): Int = mapper.writeValueAsString(node).length private val mapper = ObjectMapper() diff --git a/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/beans/BoundedBeanResponseWriter.kt b/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/beans/BoundedBeanResponseWriter.kt index cbc3d6f46..6efffa3f9 100644 --- a/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/beans/BoundedBeanResponseWriter.kt +++ b/modules/spring-ai/src/main/kotlin/com/explyt/spring/ai/mcp/beans/BoundedBeanResponseWriter.kt @@ -42,13 +42,22 @@ typealias BeanPageRequest = PageRequest */ class BoundedBeanResponseWriter { - fun write(content: BeanResponseContent, revision: String, page: BeanPageRequest): String = - writer.write(envelope(content, page), FIELD_CANDIDATES, content.totalCount, content.candidateAt, revision, page) + fun write(content: BeanResponseContent, revision: String, page: BeanPageRequest): String { + val first by lazy { content.candidateAt(page.offset) } + val candidateAt = { index: Int -> if (index == page.offset) first else content.candidateAt(index) } + val envelope = envelope(content) + val sampled = when { + content.inactiveCount == 0 || page.offset != 0 -> envelope + content.totalCount == 0 -> envelope.withInactiveSample(content, revision, page, firstCandidate = null) + else -> envelope.withInactiveSample(content, revision, page, first) + } + return writer.write(sampled, FIELD_CANDIDATES, content.totalCount, candidateAt, revision, page) + } fun writeError(problem: BeanQueryProblem, maxChars: Int): String = writer.writeError(problem.code, problem.message, problem.choices, maxChars) - private fun envelope(content: BeanResponseContent, page: BeanPageRequest): ObjectNode { + private fun envelope(content: BeanResponseContent): ObjectNode { val envelope = mapper.createObjectNode() envelope.put("mode", content.mode) envelope.set("model", content.model) @@ -56,24 +65,25 @@ class BoundedBeanResponseWriter { envelope.put("matchCompleteness", content.matchCompleteness) envelope.put("unresolvedCount", content.unresolvedCount) content.injection?.let { envelope.set("injection", it) } - if (content.inactiveCount > 0) { - envelope.put(FIELD_INACTIVE_COUNT, content.inactiveCount) - if (page.offset == 0) putInactiveSample(envelope, content, page.maxChars.coerceIn(0, MAX_CHARS) / 2) - } + if (content.inactiveCount > 0) envelope.put(FIELD_INACTIVE_COUNT, content.inactiveCount) return envelope } - private fun putInactiveSample(envelope: ObjectNode, content: BeanResponseContent, budget: Int) { - val sample = mapper.createArrayNode() - for (index in 0 until minOf(content.inactiveCount, MAX_INACTIVE_SAMPLE)) { - sample.add(content.inactiveAt(index)) - if (mapper.writeValueAsString(sample).length > budget) { - sample.remove(sample.size() - 1) - break - } - } - if (!sample.isEmpty) envelope.set(FIELD_INACTIVE_CANDIDATES, sample) - if (sample.size() < content.inactiveCount) envelope.put(FIELD_INACTIVE_TRUNCATED, true) + private fun ObjectNode.withInactiveSample( + content: BeanResponseContent, + revision: String, + page: BeanPageRequest, + firstCandidate: ObjectNode? + ): ObjectNode { + val sample = List(minOf(content.inactiveCount, MAX_INACTIVE_SAMPLE), content.inactiveAt) + val fitted = writer.fitSample( + this, FIELD_CANDIDATES, FIELD_INACTIVE_CANDIDATES, sample, FIELD_INACTIVE_TRUNCATED, + content.totalCount, revision, page, firstCandidate, maxSampleChars = page.maxChars / 2 + ) + val served = fitted[FIELD_INACTIVE_CANDIDATES].size() + if (served == 0) fitted.remove(FIELD_INACTIVE_CANDIDATES) + if (served < content.inactiveCount) fitted.put(FIELD_INACTIVE_TRUNCATED, true) + return fitted } private val writer = BoundedPageWriter() diff --git a/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/beans/BeanListingConditionTest.kt b/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/beans/BeanListingConditionTest.kt index 54574a6d2..cfc19eb69 100644 --- a/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/beans/BeanListingConditionTest.kt +++ b/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/beans/BeanListingConditionTest.kt @@ -300,6 +300,42 @@ class BeanListingConditionTest : ExplytJavaLightTestCase() { } } + fun testTogglingConditionEvaluationChangesTheRevision() = runBlockingReleasingWriteIntent { + addProperties("admin.marker=present") + addActiveControllers() + addSyncAdminController() + assertVerdict(SYNC_ADMIN_CONTROLLER) + val settings = SpringToolRunConfigurationsSettingsState.getInstance() + val enabled = settings.isBeanFilterEnabled + assertTrue("Precondition: condition evaluation starts enabled", enabled) + val first = listing(origin = PROJECT, limit = 1) + assertEquals(listOf(ALPHA), rows(first).map { it["beanName"].asText() }) + assertTrue("Precondition: the first page is truncated, got $first", first["truncated"].asBoolean()) + assertFalse("Precondition: page 1 is read with conditions evaluated, got $first", first.has("limitations")) + + settings.isBeanFilterEnabled = false + try { + val fresh = listing(origin = PROJECT, limit = 1) + assertEquals( + "Precondition: the toggle changes the answer, got $fresh", + listOf("CONDITIONS_NOT_EVALUATED"), fresh["limitations"]?.map { it.asText() } + ) + + val stale = listing( + origin = PROJECT, + limit = 1, + offset = first["nextOffset"].asInt(), + expectedRevision = first["revision"].asText() + ) + + assertEquals("a continuation across the toggle must restart, got $stale", "ERROR", stale["status"].asText()) + assertEquals("RESULT_CHANGED", stale["error"]["code"].asText()) + } finally { + settings.isBeanFilterEnabled = enabled + ModificationTrackerManager.getInstance(project).invalidateAll() + } + } + fun testInactiveLibraryBeansAreCountedInsteadOfListedByDefault() = runBlockingReleasingWriteIntent { addProperties("admin.marker=present") addActiveControllers() diff --git a/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/beans/BoundedBeanResponseWriterTest.kt b/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/beans/BoundedBeanResponseWriterTest.kt index 36c2adffa..3677c67f5 100644 --- a/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/beans/BoundedBeanResponseWriterTest.kt +++ b/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/beans/BoundedBeanResponseWriterTest.kt @@ -5,10 +5,12 @@ package com.explyt.spring.ai.mcp.beans +import com.explyt.spring.ai.mcp.BoundedPageWriter import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.node.ObjectNode import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Test @@ -17,7 +19,7 @@ class BoundedBeanResponseWriterTest { private val mapper = ObjectMapper() - private fun content(size: Int): BeanResponseContent = BeanResponseContent( + private fun content(size: Int, segments: Int = 8): BeanResponseContent = BeanResponseContent( "LOOKUP", mapper.createObjectNode().put("source", "STATIC").put("precision", "MODULE_ESTIMATE"), if (size == 1) "SINGLE" else "MULTIPLE", "COMPLETE", 0, @@ -27,7 +29,7 @@ class BoundedBeanResponseWriterTest { .put("id", "b-$n").put("name", "clock$n").put("type", "java.time.Clock") .put("kind", "BEAN_METHOD").set( "declaration", mapper.createObjectNode() - .put("filePath", "src/" + "long-segment/".repeat(8) + "Clock$n.kt").put("line", n + 1) + .put("filePath", "src/" + "long-segment/".repeat(segments) + "Clock$n.kt").put("line", n + 1) ) } ) @@ -201,4 +203,165 @@ class BoundedBeanResponseWriterTest { } fail("Continuation did not terminate") } + + @Test + fun `a budget that holds one active candidate without the sample serves it`() { + val geometry = sampleGeometry() + + val json = BoundedBeanResponseWriter() + .write(geometry.content, "r-fixed", BeanPageRequest(0, 5, geometry.sampleFreeLength, null)) + + val root = mapper.readTree(json) + assertEquals("answer was $json", "OK", root["status"].asText()) + assertTrue("Serialized ${json.length} chars", json.length <= geometry.sampleFreeLength) + assertTrue("an active candidate must be served, got $json", root["candidates"].size() >= 1) + assertEquals(INACTIVE_COUNT, root["inactiveCount"].asInt()) + assertEquals("a cut sample is flagged, got $json", true, root["inactiveCandidatesTruncated"]?.asBoolean()) + } + + @Test + fun `the sample shrinks to leave room for the first active candidate`() { + val geometry = sampleGeometry() + + val json = BoundedBeanResponseWriter() + .write(geometry.content, "r-fixed", BeanPageRequest(0, 5, geometry.oneSampledLength, null)) + + val root = mapper.readTree(json) + assertEquals("answer was $json", "OK", root["status"].asText()) + assertTrue("Serialized ${json.length} chars", json.length <= geometry.oneSampledLength) + assertTrue("an active candidate must be served, got $json", root["candidates"].size() >= 1) + val sampled = root["inactiveCandidates"]?.size() ?: 0 + assertTrue("a sample that fits beside the first candidate is kept, got $json", sampled in 1 until INACTIVE_COUNT) + assertEquals("a cut sample is flagged, got $json", true, root["inactiveCandidatesTruncated"]?.asBoolean()) + } + + @Test + fun `the budget a too-large answer names serves the first active candidate`() { + val geometry = sampleGeometry() + val writer = BoundedBeanResponseWriter() + val tight = geometry.sampleFreeLength - 1 + assertTrue("Precondition: $tight is a valid budget", tight >= BoundedPageWriter.MIN_CHARS) + + val refusal = mapper.readTree(writer.write(geometry.content, "r-fixed", BeanPageRequest(0, 5, tight, null))) + assertEquals( + "Precondition: no candidate fits $tight, got $refusal", + "RESPONSE_TOO_LARGE", refusal["error"]?.get("code")?.asText() + ) + val named = BUDGET_HINT.find(refusal["error"]["message"].asText())?.groupValues?.get(1)?.toInt() + assertNotNull("Precondition: the refusal names a budget, got $refusal", named) + + val json = writer.write(geometry.content, "r-fixed", BeanPageRequest(0, 5, named!!, null)) + + val root = mapper.readTree(json) + assertEquals("retrying with the named maxChars $named must succeed, got $json", "OK", root["status"].asText()) + assertTrue("an active candidate must be served, got $json", root["candidates"].size() >= 1) + assertTrue( + "the named budget $named must not pay for the sample, the sample-free page is ${geometry.sampleFreeLength}", + named <= geometry.sampleFreeLength + ) + } + + @Test + fun `the inactive sample takes at most half the budget even when it fits beside the first candidate`() { + val active = content(ACTIVE_COUNT) + val sampleFreeLength = lengthOf(sampledPageOf(active, candidates = 1, sample = emptyList(), flagged = true)) + val sample = List(MAX_INACTIVE_SAMPLE) { inactiveCandidate(it, sampleFreeLength / 2) } + val twoEntrySample = sample.take(2) + val content = active.copy(inactiveCount = sample.size, inactiveAt = sample::get) + val budget = lengthOf(sampledPageOf(active, candidates = 1, sample = sample, flagged = true)) + val halfBudget = budget / 2 + assertTrue("Precondition: $budget is a valid budget", budget in BoundedPageWriter.MIN_CHARS..BoundedPageWriter.MAX_CHARS) + assertTrue("Precondition: $budget holds the sample-free page of $sampleFreeLength", budget >= sampleFreeLength) + assertTrue( + "Precondition: the full sample of ${lengthOf(sample)} chars is longer than half of $budget", + lengthOf(sample) > halfBudget + ) + assertTrue( + "Precondition: the full sample leaves no room for a second active candidate within $budget", + lengthOf(sampledPageOf(active, candidates = 2, sample = sample, flagged = false)) > budget + ) + assertTrue( + "Precondition: a two-entry sample of ${lengthOf(twoEntrySample)} chars fits half of $budget", + lengthOf(twoEntrySample) <= halfBudget + ) + assertTrue( + "Precondition: two active candidates fit beside a two-entry sample within $budget", + lengthOf(sampledPageOf(active, candidates = 2, sample = twoEntrySample, flagged = true)) <= budget + ) + + val json = BoundedBeanResponseWriter().write(content, "r-fixed", BeanPageRequest(0, 5, budget, null)) + + val root = mapper.readTree(json) + assertEquals("answer was $json", "OK", root["status"].asText()) + assertTrue("Serialized ${json.length} chars", json.length <= budget) + assertEquals("Precondition: inactive candidates exist, got $json", MAX_INACTIVE_SAMPLE, root["inactiveCount"]?.asInt()) + val servedSampleLength = root[INACTIVE_CANDIDATES]?.let(::lengthOf) ?: 0 + assertTrue( + "the sample of $servedSampleLength chars must take at most half of $budget, got $json", + servedSampleLength <= halfBudget + ) + assertEquals("a capped sample is flagged, got $json", true, root["inactiveCandidatesTruncated"]?.asBoolean()) + assertTrue("two active candidates fit beside a half-budget sample, got $json", root["candidates"].size() >= 2) + } + + private fun sampledPageOf( + active: BeanResponseContent, + candidates: Int, + sample: List, + flagged: Boolean + ): ObjectNode { + val json = BoundedBeanResponseWriter() + .write(active, "r-fixed", BeanPageRequest(0, candidates, BoundedBeanResponseWriter.MAX_CHARS, null)) + val page = mapper.readTree(json) as ObjectNode + assertEquals("Precondition: the sample-free page is served, got $json", "OK", page["status"].asText()) + assertEquals("Precondition: the sample-free page holds $candidates candidates, got $json", candidates, page["candidates"].size()) + page.put("inactiveCount", MAX_INACTIVE_SAMPLE) + if (sample.isNotEmpty()) page.putArray(INACTIVE_CANDIDATES).addAll(sample) + if (flagged) page.put("inactiveCandidatesTruncated", true) + return page + } + + private fun lengthOf(value: Any): Int = mapper.writeValueAsString(value).length + + private class SampleGeometry(val content: BeanResponseContent, val sampleFreeLength: Int, val oneSampledLength: Int) + + private fun sampleGeometry(): SampleGeometry { + val active = content(ACTIVE_COUNT, segments = WIDE_SEGMENTS) + val sampleFree = sampleFreeFirstPage(active) + val sampleFreeLength = mapper.writeValueAsString(sampleFree).length + val emptySampleOverhead = + mapper.writeValueAsString(sampleFree.deepCopy().apply { putArray(INACTIVE_CANDIDATES) }).length - sampleFreeLength + val inactiveLength = (sampleFreeLength + emptySampleOverhead - 6) / 3 - 1 + val content = active.copy(inactiveCount = INACTIVE_COUNT, inactiveAt = { n -> inactiveCandidate(n, inactiveLength) }) + val oneSampled = sampleFree.deepCopy().apply { putArray(INACTIVE_CANDIDATES).add(inactiveCandidate(0, inactiveLength)) } + return SampleGeometry(content, sampleFreeLength, mapper.writeValueAsString(oneSampled).length) + } + + private fun sampleFreeFirstPage(active: BeanResponseContent): ObjectNode { + val json = BoundedBeanResponseWriter() + .write(active, "r-fixed", BeanPageRequest(0, 1, BoundedBeanResponseWriter.MAX_CHARS, null)) + val page = mapper.readTree(json) as ObjectNode + assertEquals("Precondition: the sample-free page is served, got $json", "OK", page["status"].asText()) + assertEquals("Precondition: the sample-free page holds one candidate, got $json", 1, page["candidates"].size()) + return page.put("inactiveCount", INACTIVE_COUNT).put("inactiveCandidatesTruncated", true) + } + + private fun inactiveCandidate(n: Int, length: Int): ObjectNode { + val candidate = mapper.createObjectNode() + .put("id", "i-$n").put("name", "conditionalClock$n").put("type", "java.time.Clock") + val condition = candidate.putObject("condition").put("state", "INACTIVE").put("detail", "") + val padding = length - mapper.writeValueAsString(candidate).length + assertTrue("Precondition: an inactive candidate of $length chars can be built", padding >= 0) + condition.put("detail", "d".repeat(padding)) + return candidate + } + + private companion object { + const val ACTIVE_COUNT = 3 + const val INACTIVE_COUNT = 4 + const val WIDE_SEGMENTS = 20 + const val MAX_INACTIVE_SAMPLE = 3 + const val INACTIVE_CANDIDATES = "inactiveCandidates" + val BUDGET_HINT = Regex("""at least (\d+)""") + } } diff --git a/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/beans/SpringBeanMcpConditionVerdictTest.kt b/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/beans/SpringBeanMcpConditionVerdictTest.kt index 17385ea94..b42ab3197 100644 --- a/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/beans/SpringBeanMcpConditionVerdictTest.kt +++ b/modules/spring-ai/src/test/kotlin/com/explyt/spring/ai/mcp/beans/SpringBeanMcpConditionVerdictTest.kt @@ -5,6 +5,7 @@ package com.explyt.spring.ai.mcp.beans +import com.explyt.spring.ai.mcp.BoundedPageWriter import com.explyt.spring.core.runconfiguration.SpringToolRunConfigurationsSettingsState import com.explyt.spring.core.service.SpringSearchService import com.explyt.spring.core.service.beans.NativeBeanSnapshotReader @@ -18,6 +19,7 @@ import com.explyt.spring.test.TestLibrary import com.explyt.spring.test.util.runBlockingReleasingWriteIntent import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode import com.intellij.mcpserver.McpToolset import com.intellij.psi.PsiDocumentManager import com.intellij.psi.PsiFile @@ -259,6 +261,36 @@ class SpringBeanMcpConditionVerdictTest : ExplytJavaLightTestCase() { } } + fun testTogglingConditionEvaluationChangesTheRevision() = runBlockingReleasingWriteIntent { + addWorkers(active = 2, inactive = 1) + val settings = SpringToolRunConfigurationsSettingsState.getInstance() + val enabled = settings.isBeanFilterEnabled + assertTrue("Precondition: condition evaluation starts enabled", enabled) + val first = call(typeFqn = WORKER, limit = 1) + assertEquals("Precondition: the first page is served, got $first", "OK", first["status"].asText()) + assertTrue("Precondition: the first page is truncated, got $first", first["truncated"].asBoolean()) + assertFalse("Precondition: page 1 is read with conditions evaluated, got $first", CONDITIONS_NOT_EVALUATED in limitationsOf(first)) + + settings.isBeanFilterEnabled = false + try { + val fresh = call(typeFqn = WORKER, limit = 1) + assertTrue("Precondition: the toggle changes the answer, got $fresh", CONDITIONS_NOT_EVALUATED in limitationsOf(fresh)) + + val stale = call( + typeFqn = WORKER, + limit = 1, + offset = first["nextOffset"].asInt(), + expectedRevision = first["revision"].asText() + ) + + assertEquals("a continuation across the toggle must restart, got $stale", "ERROR", stale["status"].asText()) + assertEquals("RESULT_CHANGED", stale["error"]["code"].asText()) + } finally { + settings.isBeanFilterEnabled = enabled + ModificationTrackerManager.getInstance(project).invalidateAll() + } + } + fun testTheDefaultAnswerWithAnInactiveCandidateFitsTheClientBudget() = runBlockingReleasingWriteIntent { addProperties("admin.marker=present") addSyncAdminController() @@ -517,6 +549,67 @@ class SpringBeanMcpConditionVerdictTest : ExplytJavaLightTestCase() { assertEquals(INACTIVE_WORKERS, root["inactiveCount"]?.asInt()) } + fun testASmallBudgetServesAnActiveCandidateBeforeTheInactiveSample() = runBlockingReleasingWriteIntent { + addWorkers(active = 2, inactive = MAX_INACTIVE_SAMPLE + 1) + val roomy = call(typeFqn = WORKER, limit = 1, maxChars = BoundedPageWriter.MAX_CHARS) as ObjectNode + assertEquals("Precondition: the roomy page is served, got $roomy", "OK", roomy["status"].asText()) + assertEquals("Precondition: the roomy page holds one active candidate, got $roomy", 1, roomy["candidates"].size()) + val sample = inactiveCandidatesOf(roomy) + assertEquals("Precondition: the roomy page samples inactive candidates, got $roomy", MAX_INACTIVE_SAMPLE, sample.size) + assertEquals("Precondition: the roomy sample is flagged as cut, got $roomy", true, roomy["inactiveCandidatesTruncated"]?.asBoolean()) + val sampleFree = roomy.deepCopy().apply { remove("inactiveCandidates") } + val sampleFreeLength = mapper.writeValueAsString(sampleFree).length + val firstSampledLength = mapper.writeValueAsString(sample.first()).length + val budget = maxOf(sampleFreeLength, 2 * (firstSampledLength + 2), BoundedPageWriter.MIN_CHARS) + val oneSampled = sampleFree.deepCopy().apply { remove("inactiveCandidatesTruncated"); putArray("inactiveCandidates").add(sample.first()) } + assertTrue( + "Precondition: no inactive candidate fits beside the first active one within $budget chars", + mapper.writeValueAsString(oneSampled).length > budget + ) + + val json = callJson(typeFqn = WORKER, maxChars = budget) + + val root = mapper.readTree(json) + assertEquals("answer was $json", "OK", root["status"].asText()) + assertTrue("payload was ${json.length} chars within $budget", json.length <= budget) + assertFalse("an active candidate must be served, got $json", root["candidates"].isEmpty) + assertEquals(MAX_INACTIVE_SAMPLE + 1, root["inactiveCount"]?.asInt()) + assertEquals("a cut sample is flagged, got $json", true, root["inactiveCandidatesTruncated"]?.asBoolean()) + } + + fun testTheDefaultBudgetSampleDisplacesOnlyTheActiveCandidatesItsOwnLengthAccountsFor() = runBlockingReleasingWriteIntent { + addWorkers(active = DEFAULT_LIMIT + 1, inactive = MAX_INACTIVE_SAMPLE) + addWorkers(active = DEFAULT_LIMIT + 1, inactive = 0, role = HELPER_ROLE) + val unsampled = call(typeFqn = HELPER) + assertEquals("Precondition: the baseline is served, got $unsampled", "OK", unsampled["status"].asText()) + assertFalse("Precondition: the baseline has no inactive candidates, got $unsampled", unsampled.has("inactiveCount")) + assertTrue("Precondition: the baseline page is cut, got $unsampled", unsampled["truncated"].asBoolean()) + + val json = callJson(typeFqn = WORKER) + + val sampled = mapper.readTree(json) + assertEquals("answer was $json", "OK", sampled["status"].asText()) + assertEquals("Precondition: every inactive worker is counted, got $json", MAX_INACTIVE_SAMPLE, sampled["inactiveCount"]?.asInt()) + val sample = sampled["inactiveCandidates"] + assertNotNull("Precondition: the default page samples inactive candidates, got $json", sample) + val sampleCost = envelopeLengthOf(sampled) - envelopeLengthOf(unsampled) + val baseline = unsampled["candidates"].size() + val served = sampled["candidates"].size() + val displaced = baseline - served + val shortest = unsampled["candidates"].minOf { mapper.writeValueAsString(it).length + 1 } + val sampleLength = mapper.writeValueAsString(sample).length + val measurement = "a sample of ${sample.size()} ($sampleLength chars) costing $sampleCost chars moved $displaced of " + + "$baseline active candidates to later pages (shortest candidate $shortest chars, page ${json.length} " + + "chars, budget $MAX_DEFAULT_PAYLOAD)" + println("#634 measurement: $measurement") + assertTrue(measurement, served >= 1) + assertTrue(measurement, sample.size() <= MAX_INACTIVE_SAMPLE) + assertTrue(measurement, sampleLength <= MAX_DEFAULT_PAYLOAD / 2) + } + + private fun envelopeLengthOf(page: JsonNode): Int = + mapper.writeValueAsString((page as ObjectNode).deepCopy().apply { remove("candidates") }).length + fun testANonConstantQualifierEmitsNoUnfilteredInactiveCandidates() = runBlockingReleasingWriteIntent { val root = qualifiedInjection("EngineNames.FAST", nonConstantNames = true) @@ -562,16 +655,16 @@ class SpringBeanMcpConditionVerdictTest : ExplytJavaLightTestCase() { } } - private fun addWorkers(active: Int, inactive: Int) { + private fun addWorkers(active: Int, inactive: Int, role: String = WORKER_ROLE) { addJava( - "Worker", + role, """ package com.explyt.demo; - public interface Worker {} + public interface $role {} """ ) - val activeWorkers = (1..active).map { "ActiveWorker$it" } + val activeWorkers = (1..active).map { "Active$role$it" } activeWorkers.forEach { name -> addJava( name, @@ -581,11 +674,11 @@ class SpringBeanMcpConditionVerdictTest : ExplytJavaLightTestCase() { import org.springframework.stereotype.Component; @Component - public class $name implements Worker {} + public class $name implements $role {} """ ) } - val inactiveWorkers = (1..inactive).map { "ConditionalWorker$it" } + val inactiveWorkers = (1..inactive).map { "Conditional$role$it" } inactiveWorkers.forEach { name -> addJava( name, @@ -596,8 +689,8 @@ class SpringBeanMcpConditionVerdictTest : ExplytJavaLightTestCase() { import org.springframework.stereotype.Component; @Component - @ConditionalOnProperty(name = "worker.$name.enabled", havingValue = "true") - public class $name implements Worker {} + @ConditionalOnProperty(name = "${role.lowercase()}.$name.enabled", havingValue = "true") + public class $name implements $role {} """ ) } @@ -720,21 +813,38 @@ class SpringBeanMcpConditionVerdictTest : ExplytJavaLightTestCase() { column: Int? = null, includeDetails: Boolean = false, offset: Int = 0, - expectedRevision: String? = null + expectedRevision: String? = null, + limit: Int = DEFAULT_LIMIT, + maxChars: Int = MAX_DEFAULT_PAYLOAD ): JsonNode = mapper.readTree( - toolset().findSpringBean( - projectPath = project.basePath!!, - applicationClassName = APPLICATION, - source = "STATIC", - typeFqn = typeFqn, - beanName = beanName, - filePath = filePath, - line = line, - column = column, - includeDetails = includeDetails, - offset = offset, - expectedRevision = expectedRevision - ) + callJson(typeFqn, beanName, filePath, line, column, includeDetails, offset, expectedRevision, limit, maxChars) + ) + + private suspend fun callJson( + typeFqn: String? = null, + beanName: String? = null, + filePath: String? = null, + line: Int? = null, + column: Int? = null, + includeDetails: Boolean = false, + offset: Int = 0, + expectedRevision: String? = null, + limit: Int = DEFAULT_LIMIT, + maxChars: Int = MAX_DEFAULT_PAYLOAD + ): String = toolset().findSpringBean( + projectPath = project.basePath!!, + applicationClassName = APPLICATION, + source = "STATIC", + typeFqn = typeFqn, + beanName = beanName, + filePath = filePath, + line = line, + column = column, + offset = offset, + limit = limit, + maxChars = maxChars, + expectedRevision = expectedRevision, + includeDetails = includeDetails ) private fun toolset(): SpringBeanMcpToolset { @@ -873,7 +983,10 @@ class SpringBeanMcpConditionVerdictTest : ExplytJavaLightTestCase() { const val INACTIVE_WORKERS = 15 const val MAX_INACTIVE_SAMPLE = 3 const val DEFAULT_LIMIT = 5 + const val WORKER_ROLE = "Worker" const val WORKER = "com.explyt.demo.Worker" + const val HELPER_ROLE = "Helper" + const val HELPER = "com.explyt.demo.Helper" const val CONDITIONS_UNDECIDED = "CONDITIONS_UNDECIDED" const val PROFILE_NOT_DECIDABLE = "PROFILE_NOT_DECIDABLE" const val CONDITIONS_NOT_EVALUATED = "CONDITIONS_NOT_EVALUATED" diff --git a/modules/spring-core/src/main/kotlin/com/explyt/spring/core/service/beans/BeanSnapshotService.kt b/modules/spring-core/src/main/kotlin/com/explyt/spring/core/service/beans/BeanSnapshotService.kt index c9ec0bdea..21a34a4ba 100644 --- a/modules/spring-core/src/main/kotlin/com/explyt/spring/core/service/beans/BeanSnapshotService.kt +++ b/modules/spring-core/src/main/kotlin/com/explyt/spring/core/service/beans/BeanSnapshotService.kt @@ -77,6 +77,7 @@ class BeanSnapshotService(private val project: Project) { // a recorded context and does not move when the IDE's profile inputs do. if (selection.nativeContext == null) { parts += ProfilesService.getInstance(project).beanQueryProfileInputs() + parts += "conditions-evaluated:${SpringToolRunConfigurationsSettingsState.getInstance().isBeanFilterEnabled}" } else { parts += "native-root:${selection.nativeContext.linkedPath}" }