282f9ceb by Ean Schuessler

Fix FTL macro errors and stabilize semantic state extraction.

- Resolve NonHashException in macros by avoiding .add() on sequences.
- Fix 'Maps with null keys' JSON error by ensuring string keys.
- Stabilize form macros with null-safe checks.
- Update McpServices and CustomScreenTestImpl for better semantic data handling.
1 parent 87e0b6a4
...@@ -79,9 +79,7 @@ ...@@ -79,9 +79,7 @@
79 79
80 [${linkText}](${slashPath})<#t> 80 [${linkText}](${slashPath})<#t>
81 <#if mcpSemanticData??> 81 <#if mcpSemanticData??>
82 <#if !mcpSemanticData.links??><#assign dummy = mcpSemanticData.put("links", [])></#if> 82 <#assign dummy = ec.resource.expression("mcpSemanticData.links.add([text: '" + (linkText!"")?js_string + "', path: '" + (slashPath!"")?js_string + "', type: 'navigation'])", "")!>
83 <#assign linkInfo = {"text": linkText, "path": slashPath, "type": "navigation"}>
84 <#assign dummy = mcpSemanticData.links.add(linkInfo)>
85 </#if> 83 </#if>
86 </#if> 84 </#if>
87 </#macro> 85 </#macro>
...@@ -105,15 +103,10 @@ ...@@ -105,15 +103,10 @@
105 <#assign formMap = ec.resource.expression(mapName, "")!> 103 <#assign formMap = ec.resource.expression(mapName, "")!>
106 104
107 <#if mcpSemanticData??> 105 <#if mcpSemanticData??>
108 <#if !mcpSemanticData.formMetadata??><#assign dummy = mcpSemanticData.put("formMetadata", {})></#if> 106 <#assign formName = (.node["@name"]!"")?string>
109
110 <#assign formMeta = {"name": (.node["@name"]!""), "map": mapName}>
111 <#assign fieldMetaList = []> 107 <#assign fieldMetaList = []>
112 108 <#assign dummy = ec.resource.expression("if (mcpSemanticData.formMetadata == null) mcpSemanticData.formMetadata = [:]; mcpSemanticData.formMetadata.put('" + formName?js_string + "', [name: '" + formName?js_string + "', map: '" + (mapName!"")?js_string + "'])", "")!>
113 <#assign dummy = mcpSemanticData.formMetadata.put(.node["@name"], formMeta)>
114 </#if> 109 </#if>
115
116 <#if mcpSemanticData?? && formMap?has_content><#assign dummy = mcpSemanticData.put(.node["@name"], formMap)></#if>
117 <#t>${sri.pushSingleFormMapContext(mapName)} 110 <#t>${sri.pushSingleFormMapContext(mapName)}
118 <#list formNode["field"] as fieldNode> 111 <#list formNode["field"] as fieldNode>
119 <#assign fieldSubNode = ""> 112 <#assign fieldSubNode = "">
...@@ -125,13 +118,13 @@ ...@@ -125,13 +118,13 @@
125 <#if mcpSemanticData??> 118 <#if mcpSemanticData??>
126 <#assign fieldMeta = {"name": (fieldNode["@name"]!""), "title": (title!), "required": (fieldNode["@required"]! == "true")}> 119 <#assign fieldMeta = {"name": (fieldNode["@name"]!""), "title": (title!), "required": (fieldNode["@required"]! == "true")}>
127 120
128 <#if fieldSubNode["text-line"]?has_content><#assign dummy = fieldMeta.put("type", "text")></#if> 121 <#if fieldSubNode["text-line"]?has_content><#assign fieldMeta = fieldMeta + {"type": "text"}></#if>
129 <#if fieldSubNode["text-area"]?has_content><#assign dummy = fieldMeta.put("type", "textarea")></#if> 122 <#if fieldSubNode["text-area"]?has_content><#assign fieldMeta = fieldMeta + {"type": "textarea"}></#if>
130 <#if fieldSubNode["drop-down"]?has_content><#assign dummy = fieldMeta.put("type", "dropdown")></#if> 123 <#if fieldSubNode["drop-down"]?has_content><#assign fieldMeta = fieldMeta + {"type": "dropdown"}></#if>
131 <#if fieldSubNode["check"]?has_content><#assign dummy = fieldMeta.put("type", "checkbox")></#if> 124 <#if fieldSubNode["check"]?has_content><#assign fieldMeta = fieldMeta + {"type": "checkbox"}></#if>
132 <#if fieldSubNode["date-find"]?has_content><#assign dummy = fieldMeta.put("type", "date")></#if> 125 <#if fieldSubNode["date-find"]?has_content><#assign fieldMeta = fieldMeta + {"type": "date"}></#if>
133 126
134 <#assign dummy = fieldMetaList.add(fieldMeta)> 127 <#assign fieldMetaList = fieldMetaList + [fieldMeta]>
135 </#if> 128 </#if>
136 129
137 * **${title}**: <#recurse fieldSubNode> 130 * **${title}**: <#recurse fieldSubNode>
...@@ -139,7 +132,9 @@ ...@@ -139,7 +132,9 @@
139 </#list> 132 </#list>
140 133
141 <#if mcpSemanticData?? && fieldMetaList?has_content> 134 <#if mcpSemanticData?? && fieldMetaList?has_content>
142 <#assign dummy = mcpSemanticData.formMetadata[.node["@name"]!].put("fields", fieldMetaList)> 135 <#assign formName = (.node["@name"]!"")?string>
136 <#assign dummy = ec.context.put("tempFieldMetaList", fieldMetaList)!>
137 <#assign dummy = ec.resource.expression("def formMeta = mcpSemanticData.formMetadata?.get('" + formName?js_string + "'); if (formMeta != null) formMeta.put('fields', tempFieldMetaList)", "")!>
143 </#if> 138 </#if>
144 139
145 <#t>${sri.popContext()} 140 <#t>${sri.popContext()}
...@@ -154,24 +149,17 @@ ...@@ -154,24 +149,17 @@
154 <#assign totalItems = listObject?size> 149 <#assign totalItems = listObject?size>
155 150
156 <#if mcpSemanticData?? && listObject?has_content> 151 <#if mcpSemanticData?? && listObject?has_content>
157 <#assign truncatedList = listObject> 152 <#assign formName = (.node["@name"]!"")?string>
158 <#assign dummy = mcpSemanticData.put(.node["@name"], truncatedList)> 153 <#assign displayedItems = (totalItems > 50)?then(50, totalItems)>
159 154 <#assign isTruncated = (totalItems > 50)>
160 <#if !mcpSemanticData.listMetadata??><#assign dummy = mcpSemanticData.put("listMetadata", {})></#if>
161
162 <#assign columnNames = []> 155 <#assign columnNames = []>
163 <#list formListColumnList as columnFieldList> 156 <#list formListColumnList as columnFieldList>
164 <#assign fieldNode = columnFieldList[0]> 157 <#assign fieldNode = columnFieldList[0]>
165 <#assign dummy = columnNames.add(fieldNode["@name"]!"")> 158 <#assign columnNames = columnNames + [fieldNode["@name"]!""]>
166 </#list> 159 </#list>
167 160 <#assign dummy = ec.context.put("tempListObject", listObject)!>
168 <#assign dummy = mcpSemanticData.listMetadata.put(.node["@name"]!"", { 161 <#assign dummy = ec.context.put("tempColumnNames", columnNames)!>
169 "name": .node["@name"]!"", 162 <#assign dummy = ec.resource.expression("mcpSemanticData.put('" + formName?js_string + "', tempListObject); if (mcpSemanticData.listMetadata == null) mcpSemanticData.listMetadata = [:]; mcpSemanticData.listMetadata.put('" + formName?js_string + "', [name: '" + formName?js_string + "', totalItems: " + totalItems + ", displayedItems: " + displayedItems + ", truncated: " + isTruncated?string + ", columns: tempColumnNames])", "")!>
170 "totalItems": totalItems,
171 "displayedItems": (totalItems > 50)?then(50, totalItems),
172 "truncated": (totalItems > 50),
173 "columns": columnNames
174 })>
175 </#if> 163 </#if>
176 164
177 <#-- Header Row --> 165 <#-- Header Row -->
...@@ -227,8 +215,8 @@ ...@@ -227,8 +215,8 @@
227 <#-- ================== Form Field Widgets ==================== --> 215 <#-- ================== Form Field Widgets ==================== -->
228 <#macro "check"> 216 <#macro "check">
229 <#assign options = sri.getFieldOptions(.node)!> 217 <#assign options = sri.getFieldOptions(.node)!>
230 <#assign currentValue = sri.getFieldValueString(.node)> 218 <#assign currentValue = sri.getFieldValueString(.node)!>
231 <#t>${(options.get(currentValue))!(currentValue)} 219 <#t>${(options[currentValue])!currentValue}
232 </#macro> 220 </#macro>
233 221
234 <#macro "date-find"></#macro> 222 <#macro "date-find"></#macro>
...@@ -264,9 +252,9 @@ ...@@ -264,9 +252,9 @@
264 </#macro> 252 </#macro>
265 253
266 <#macro "drop-down"> 254 <#macro "drop-down">
267 <#assign options = sri.getFieldOptions(.node)> 255 <#assign options = sri.getFieldOptions(.node)!>
268 <#assign currentValue = sri.getFieldValueString(.node)> 256 <#assign currentValue = sri.getFieldValueString(.node)!>
269 <#t>${(options.get(currentValue))!(currentValue)} 257 <#t>${(options[currentValue])!currentValue}
270 </#macro> 258 </#macro>
271 259
272 <#macro "text-area"><#t>${sri.getFieldValueString(.node)}</#macro> 260 <#macro "text-area"><#t>${sri.getFieldValueString(.node)}</#macro>
......
...@@ -670,21 +670,21 @@ serializeMoquiObject = { obj, depth = 0, isTerse = false -> ...@@ -670,21 +670,21 @@ serializeMoquiObject = { obj, depth = 0, isTerse = false ->
670 _totalCount: list.size(), 670 _totalCount: list.size(),
671 _truncated: true, 671 _truncated: true,
672 _hasMore: true, 672 _hasMore: true,
673 _message: "Terse mode: showing first 10 of ${list.size()} items. Set terse=false to get full data." 673 _message: "Terse mode: showing first 10 of ${list.size()} items. Set terse=false to get more data."
674 ] 674 ]
675 } 675 }
676 // Full data in non-terse mode (but still apply depth protection) 676 // Increased limits for non-terse mode
677 def maxItems = isTerse ? 10 : 50 677 def maxItems = isTerse ? 10 : 250
678 def truncated = list.take(maxItems) 678 def truncated = list.take(maxItems)
679 def resultList = truncated.collect { serializeMoquiObject(it, depth + 1, isTerse) } 679 def resultList = truncated.collect { serializeMoquiObject(it, depth + 1, isTerse) }
680 if (!isTerse && list.size() > 50) { 680 if (!isTerse && list.size() > maxItems) {
681 ec.logger.info("serializeMoquiObject: Non-terse mode - truncating large list from ${list.size()} to 50 items for size limits") 681 ec.logger.info("serializeMoquiObject: Non-terse mode - truncating large list from ${list.size()} to ${maxItems} items for safety")
682 return [ 682 return [
683 _items: resultList, 683 _items: resultList,
684 _totalCount: list.size(), 684 _totalCount: list.size(),
685 _truncated: true, 685 _truncated: true,
686 _hasMore: true, 686 _hasMore: true,
687 _message: "Non-terse mode: showing first 50 of ${list.size()} items (size limit reached)" 687 _message: "Truncated to ${maxItems} items (size safety limit reached)"
688 ] 688 ]
689 } 689 }
690 return resultList 690 return resultList
...@@ -705,10 +705,18 @@ serializeMoquiObject = { obj, depth = 0, isTerse = false -> ...@@ -705,10 +705,18 @@ serializeMoquiObject = { obj, depth = 0, isTerse = false ->
705 _value: obj.substring(0, 200) + "...", 705 _value: obj.substring(0, 200) + "...",
706 _fullLength: obj.length(), 706 _fullLength: obj.length(),
707 _truncated: true, 707 _truncated: true,
708 _message: "Terse mode: truncated to 200 chars. Set terse=false to get full data." 708 _message: "Terse mode: truncated to 200 chars. Set terse=false to get more data."
709 ]
710 }
711 // Non-terse limit increased to 2000 for safety but much larger than before
712 if (!isTerse && obj.length() > 2000) {
713 return [
714 _value: obj.substring(0, 2000) + "...",
715 _fullLength: obj.length(),
716 _truncated: true,
717 _message: "Truncated to 2000 chars for safety."
709 ] 718 ]
710 } 719 }
711 // Full data in non-terse mode (no string truncation)
712 return obj 720 return obj
713 } 721 }
714 if (obj.getClass().getName().startsWith("org.moqui.impl.screen.ScreenDefinition")) { 722 if (obj.getClass().getName().startsWith("org.moqui.impl.screen.ScreenDefinition")) {
...@@ -924,193 +932,14 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -924,193 +932,14 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
924 semanticState: semanticState 932 semanticState: semanticState
925 ] 933 ]
926 934
927 // Truncate text preview to 500 chars to save tokens, since we have structured data 935 // Truncate text preview only if terse=true
928 if (output) mcpResult.textPreview = output.take(500) + (output.length() > 500 ? "..." : "") 936 if (output) {
929 if (wikiInstructions) mcpResult.wikiInstructions = wikiInstructions 937 if (isTerse) {
930 938 mcpResult.textPreview = output.take(500) + (output.length() > 500 ? "..." : "")
931 content << [
932 type: "text",
933 text: new groovy.json.JsonBuilder(mcpResult).toString()
934 ]
935 } else {
936 // Return raw output for other modes (text, html, etc)
937 def textOutput = output
938 if (wikiInstructions) {
939 textOutput = "--- Wiki Instructions ---\n\n${wikiInstructions}\n\n--- Screen Output ---\n\n${output}"
940 }
941 content << [
942 type: "text",
943 text: textOutput,
944 screenPath: screenPath,
945 screenUrl: screenUrl,
946 executionTime: executionTime,
947 isError: isError
948 ]
949 }
950
951 result = [
952 content: content,
953 isError: false
954 ]
955 return // Success!
956
957 } catch (Exception e) {
958 isError = true
959 ec.logger.error("MCP Screen Execution: Full exception for ${screenPath}", e)
960 output = "SCREEN RENDERING ERROR: ${e.message}"
961 result = [
962 isError: true,
963 content: [[type: "text", text: output]]
964 ]
965 }
966 }
967 }
968
969 // 2. If literal resolution failed, try Component-based resolution
970 if (reachedIndex == -1 && pathSegments.size() >= 2) {
971 def componentName = pathSegments[0]
972 def rootScreenName = pathSegments[1]
973 def compRootLoc = "component://${componentName}/screen/${rootScreenName}.xml"
974
975 if (ec.resource.getLocationReference(compRootLoc).exists) {
976 ec.logger.info("MCP Path Resolution: Found component root at ${compRootLoc}")
977 rootScreen = compRootLoc
978 testScreenPath = pathSegments.size() > 2 ? pathSegments[2..-1].join('/') : ""
979 resolvedScreenDef = ec.screen.getScreenDefinition(rootScreen)
980
981 // Resolve further if there are remaining segments
982 if (testScreenPath) {
983 def remainingSegments = pathSegments[2..-1]
984 def compPathList = org.moqui.impl.screen.ScreenUrlInfo.parseSubScreenPath(
985 resolvedScreenDef, resolvedScreenDef, remainingSegments, testScreenPath, [:], ec.screenFacade
986 )
987 if (compPathList) {
988 for (String screenName in compPathList) {
989 def ssi = resolvedScreenDef?.getSubscreensItem(screenName)
990 if (ssi && ssi.getLocation()) {
991 resolvedScreenDef = ec.screen.getScreenDefinition(ssi.getLocation())
992 } else {
993 break
994 }
995 }
996 }
997 }
998 }
999 }
1000
1001 // 3. Fallback to double-slash search if still not found
1002 if (reachedIndex == -1 && !resolvedScreenDef && pathSegments.size() > 0 && !testPath.startsWith("//")) {
1003 def searchPath = "//" + pathSegments.join('/')
1004 ec.logger.info("MCP Path Resolution: Fallback to search path ${searchPath}")
1005
1006 rootScreen = "component://webroot/screen/webroot.xml"
1007 def searchPathList = org.moqui.impl.screen.ScreenUrlInfo.parseSubScreenPath(
1008 webrootSd, webrootSd, pathSegments, searchPath, [:], ec.screenFacade
1009 )
1010
1011 if (searchPathList) {
1012 testScreenPath = searchPath
1013 resolvedScreenDef = webrootSd
1014 for (String screenName in searchPathList) {
1015 def ssi = resolvedScreenDef?.getSubscreensItem(screenName)
1016 if (ssi && ssi.getLocation()) {
1017 resolvedScreenDef = ec.screen.getScreenDefinition(ssi.getLocation())
1018 } else { 939 } else {
1019 break 940 mcpResult.textPreview = output
1020 }
1021 }
1022 }
1023 }
1024
1025 // If we found a specific target, we're good.
1026 // If not, default to webroot with full path (original behavior, but now we know it failed)
1027 if (!resolvedScreenDef) {
1028 rootScreen = "component://webroot/screen/webroot.xml"
1029 resolvedScreenDef = webrootSd
1030 testScreenPath = testPath
1031 } 941 }
1032 } 942 }
1033
1034 // Regular screen rendering with current user context - use our custom ScreenTestImpl
1035 def screenTest = new org.moqui.mcp.CustomScreenTestImpl(ec.ecfi)
1036 .rootScreen(rootScreen)
1037 .renderMode(renderMode ?: "mcp")
1038 .auth(ec.user.username)
1039
1040 def renderParams = parameters ?: [:]
1041 renderParams.userId = ec.user.userId
1042 renderParams.username = ec.user.username
1043
1044 def relativePath = testScreenPath
1045 ec.logger.info("TESTRENDER root=${rootScreen} path=${relativePath} params=${renderParams}")
1046
1047 def testRender = screenTest.render(relativePath, renderParams, "POST")
1048 output = testRender.getOutput()
1049
1050 // --- NEW: Semantic State Extraction ---
1051 def postContext = testRender.getPostRenderContext()
1052 def semanticState = [:]
1053 def isTerse = context.terse == true
1054
1055 // Get final screen definition using resolved screen location
1056 def finalScreenDef = resolvedScreenDef
1057
1058 if (finalScreenDef && postContext) {
1059 semanticState.screenPath = inputScreenPath
1060 semanticState.terse = isTerse
1061 semanticState.data = [:]
1062
1063 // Use the explicit semantic data captured by macros if available
1064 def explicitData = postContext.get("mcpSemanticData")
1065 if (explicitData instanceof Map) {
1066 explicitData.each { k, v ->
1067 semanticState.data[k] = serializeMoquiObject(v, 0, isTerse)
1068 }
1069 }
1070
1071 // Extract transitions (Actions) with metadata (from screen definition, not macros)
1072 semanticState.actions = []
1073 finalScreenDef.getAllTransitions().each { trans ->
1074 def actionInfo = [
1075 name: trans.getName(),
1076 service: trans.getSingleServiceName()
1077 ]
1078 semanticState.actions << actionInfo
1079 }
1080
1081 // 3. Extract parameters that are currently set
1082 semanticState.parameters = [:]
1083 if (finalScreenDef.parameterByName) {
1084 finalScreenDef.parameterByName.each { name, param ->
1085 def value = postContext.get(name) ?: parameters?.get(name)
1086 if (value != null) semanticState.parameters[name] = serializeMoquiObject(value, 0, isTerse)
1087 }
1088 }
1089
1090 // Log semantic state size for optimization tracking
1091 def semanticStateJson = new groovy.json.JsonBuilder(semanticState).toString()
1092 def semanticStateSize = semanticStateJson.length()
1093 ec.logger.info("MCP Screen Execution: Semantic state size: ${semanticStateSize} bytes, data keys: ${semanticState.data.keySet()}, actions count: ${semanticState.actions.size()}, terse=${isTerse}")
1094 }
1095
1096 ec.logger.info("MCP Screen Execution: Successfully rendered screen ${screenPath}, output length: ${output?.length() ?: 0}")
1097
1098 def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
1099
1100 // Build result based on renderMode
1101 def content = []
1102 if ((renderMode == "mcp" || renderMode == "json") && semanticState) {
1103 // Return structured MCP data
1104 def mcpResult = [
1105 screenPath: screenPath,
1106 screenUrl: screenUrl,
1107 executionTime: executionTime,
1108 isError: isError,
1109 semanticState: semanticState
1110 ]
1111
1112 // Truncate text preview to 500 chars to save tokens, since we have structured data
1113 if (output) mcpResult.textPreview = output.take(500) + (output.length() > 500 ? "..." : "")
1114 if (wikiInstructions) mcpResult.wikiInstructions = wikiInstructions 943 if (wikiInstructions) mcpResult.wikiInstructions = wikiInstructions
1115 944
1116 content << [ 945 content << [
...@@ -1575,18 +1404,76 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -1575,18 +1404,76 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
1575 ] 1404 ]
1576 } 1405 }
1577 } else { 1406 } else {
1578 // Forward slash path resolution using Moqui standard 1407 // Forward slash path resolution using Moqui standard with robust component-based fallback
1579 def webrootSd = ec.screen.getScreenDefinition("component://webroot/screen/webroot.xml") 1408 def webrootSd = ec.screen.getScreenDefinition("component://webroot/screen/webroot.xml")
1580 def pathSegments = [] 1409 def pathSegments = []
1581 currentPath.split('/').each { if (it && it.trim()) pathSegments.add(it) } 1410 currentPath.split('/').each { if (it && it.trim()) pathSegments.add(it) }
1582 1411
1412 // 1. Try literal resolution from webroot
1583 def screenPathList = org.moqui.impl.screen.ScreenUrlInfo.parseSubScreenPath( 1413 def screenPathList = org.moqui.impl.screen.ScreenUrlInfo.parseSubScreenPath(
1584 webrootSd, webrootSd, pathSegments, currentPath, [:], ec.screenFacade 1414 webrootSd, webrootSd, pathSegments, currentPath, [:], ec.screenFacade
1585 ) 1415 )
1586 1416
1417 def currentSd = webrootSd
1418 def reachedIndex = -1
1587 if (screenPathList) { 1419 if (screenPathList) {
1420 for (int i = 0; i < screenPathList.size(); i++) {
1421 def screenName = screenPathList[i]
1422 def ssi = currentSd?.getSubscreensItem(screenName)
1423 if (ssi && ssi.getLocation()) {
1424 currentSd = ec.screen.getScreenDefinition(ssi.getLocation())
1425 reachedIndex = i
1426 } else {
1427 break
1428 }
1429 }
1430 }
1431
1432 resolvedScreenDef = currentSd
1433
1434 // 2. If literal resolution failed, try Component-based resolution
1435 if (reachedIndex == -1 && pathSegments.size() >= 2) {
1436 def componentName = pathSegments[0]
1437 def rootScreenName = pathSegments[1]
1438 def compRootLoc = "component://${componentName}/screen/${rootScreenName}.xml"
1439
1440 if (ec.resource.getLocationReference(compRootLoc).exists) {
1441 ec.logger.info("BrowseScreens Path Resolution: Found component root at ${compRootLoc}")
1442 resolvedScreenDef = ec.screen.getScreenDefinition(compRootLoc)
1443 def subScreenPath = pathSegments.size() > 2 ? pathSegments[2..-1].join('/') : ""
1444
1445 // Resolve further if there are remaining segments
1446 if (subScreenPath) {
1447 def remainingSegments = pathSegments[2..-1]
1448 def compPathList = org.moqui.impl.screen.ScreenUrlInfo.parseSubScreenPath(
1449 resolvedScreenDef, resolvedScreenDef, remainingSegments, subScreenPath, [:], ec.screenFacade
1450 )
1451 if (compPathList) {
1452 for (String screenName in compPathList) {
1453 def ssi = resolvedScreenDef?.getSubscreensItem(screenName)
1454 if (ssi && ssi.getLocation()) {
1455 resolvedScreenDef = ec.screen.getScreenDefinition(ssi.getLocation())
1456 } else {
1457 break
1458 }
1459 }
1460 }
1461 }
1462 }
1463 }
1464
1465 // 3. Fallback to double-slash search if still not found
1466 if (reachedIndex == -1 && resolvedScreenDef == webrootSd && pathSegments.size() > 0 && !currentPath.startsWith("//")) {
1467 def searchPath = "//" + pathSegments.join('/')
1468 ec.logger.info("BrowseScreens Path Resolution: Fallback to search path ${searchPath}")
1469
1470 def searchPathList = org.moqui.impl.screen.ScreenUrlInfo.parseSubScreenPath(
1471 webrootSd, webrootSd, pathSegments, searchPath, [:], ec.screenFacade
1472 )
1473
1474 if (searchPathList) {
1588 resolvedScreenDef = webrootSd 1475 resolvedScreenDef = webrootSd
1589 for (String screenName in screenPathList) { 1476 for (String screenName in searchPathList) {
1590 def ssi = resolvedScreenDef?.getSubscreensItem(screenName) 1477 def ssi = resolvedScreenDef?.getSubscreensItem(screenName)
1591 if (ssi && ssi.getLocation()) { 1478 if (ssi && ssi.getLocation()) {
1592 resolvedScreenDef = ec.screen.getScreenDefinition(ssi.getLocation()) 1479 resolvedScreenDef = ec.screen.getScreenDefinition(ssi.getLocation())
...@@ -1595,6 +1482,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -1595,6 +1482,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
1595 } 1482 }
1596 } 1483 }
1597 } 1484 }
1485 }
1598 1486
1599 if (resolvedScreenDef) { 1487 if (resolvedScreenDef) {
1600 resolvedScreenDef.getSubscreensItemsSorted().each { subItem -> 1488 resolvedScreenDef.getSubscreensItemsSorted().each { subItem ->
...@@ -1629,11 +1517,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -1629,11 +1517,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
1629 def foundTransition = resolvedScreenDef.getAllTransitions().find { it.getName() == action } 1517 def foundTransition = resolvedScreenDef.getAllTransitions().find { it.getName() == action }
1630 1518
1631 if (foundTransition) { 1519 if (foundTransition) {
1632 def serviceName = null 1520 def serviceName = foundTransition.getSingleServiceName()
1633 if (foundTransition.xmlTransition) {
1634 def serviceCallNode = foundTransition.xmlTransition.first("service-call")
1635 if (serviceCallNode) serviceName = serviceCallNode.attribute("name")
1636 }
1637 1521
1638 if (serviceName) { 1522 if (serviceName) {
1639 ec.logger.info("BrowseScreens: Executing service: ${serviceName}") 1523 ec.logger.info("BrowseScreens: Executing service: ${serviceName}")
...@@ -1725,11 +1609,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -1725,11 +1609,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
1725 // Build UI narrative for LLM guidance 1609 // Build UI narrative for LLM guidance
1726 try { 1610 try {
1727 def narrativeBuilder = new org.moqui.mcp.UiNarrativeBuilder() 1611 def narrativeBuilder = new org.moqui.mcp.UiNarrativeBuilder()
1728 // Get screen definition for narrative building 1612 // Use the screen definition we already resolved
1729 def screenDefForNarrative = null 1613 def screenDefForNarrative = resolvedScreenDef
1730 if (resultObj.semanticState.screenPath) {
1731 screenDefForNarrative = ec.screen.getScreenDefinition(resultObj.semanticState.screenPath)
1732 }
1733 1614
1734 def uiNarrative = narrativeBuilder.buildNarrative( 1615 def uiNarrative = narrativeBuilder.buildNarrative(
1735 screenDefForNarrative, 1616 screenDefForNarrative,
...@@ -1738,13 +1619,13 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -1738,13 +1619,13 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
1738 context.terse == true 1619 context.terse == true
1739 ) 1620 )
1740 resultMap.uiNarrative = uiNarrative 1621 resultMap.uiNarrative = uiNarrative
1741 ec.logger.info("BrowseScreens: Generated UI narrative for ${currentPath}") 1622 ec.logger.info("BrowseScreens: Generated UI narrative for ${currentPath}: ${uiNarrative?.keySet()}")
1742 } catch (Exception e) { 1623 } catch (Exception e) {
1743 ec.logger.warn("BrowseScreens: Failed to generate UI narrative: ${e.message}") 1624 ec.logger.warn("BrowseScreens: Failed to generate UI narrative: ${e.message}")
1744 } 1625 }
1745 1626
1746 // If we have semantic state, we can truncate rendered content to save tokens 1627 // Only truncate if terse=true
1747 if (renderedContent && renderedContent.length() > 500) { 1628 if (renderedContent && context.terse == true && renderedContent.length() > 500) {
1748 renderedContent = renderedContent.take(500) + "... (truncated, see uiNarrative for actions)" 1629 renderedContent = renderedContent.take(500) + "... (truncated, see uiNarrative for actions)"
1749 } 1630 }
1750 } 1631 }
......
...@@ -321,6 +321,9 @@ class CustomScreenTestImpl implements McpScreenTest { ...@@ -321,6 +321,9 @@ class CustomScreenTestImpl implements McpScreenTest {
321 321
322 // Create a persistent map for semantic data that survives nested pops 322 // Create a persistent map for semantic data that survives nested pops
323 Map<String, Object> mcpSemanticData = new HashMap<>() 323 Map<String, Object> mcpSemanticData = new HashMap<>()
324 mcpSemanticData.put("links", new ArrayList<>())
325 mcpSemanticData.put("formMetadata", new HashMap<>())
326 mcpSemanticData.put("listMetadata", new HashMap<>())
324 cs.put("mcpSemanticData", mcpSemanticData) 327 cs.put("mcpSemanticData", mcpSemanticData)
325 328
326 // create the WebFacadeStub using our custom method 329 // create the WebFacadeStub using our custom method
......
...@@ -80,9 +80,11 @@ class UiNarrativeBuilder { ...@@ -80,9 +80,11 @@ class UiNarrativeBuilder {
80 80
81 def forms = semanticState?.data 81 def forms = semanticState?.data
82 if (forms) { 82 if (forms) {
83 def formNames = forms.keySet().findAll { k -> k.contains('Form') || k.contains('form') }[0..2] 83 def maxForms = isTerse ? 2 : 10
84 def formNames = forms.keySet().findAll { k -> k.contains('Form') || k.contains('form') }
84 if (formNames) { 85 if (formNames) {
85 def fields = getFormFieldNames(forms, formNames[0]) 86 def formNamesToDescribe = formNames.take(maxForms + 1)
87 def fields = getFormFieldNames(forms, formNamesToDescribe[0])
86 if (fields) { 88 if (fields) {
87 sb.append("Form contains: ${fields.join(', ')}. ") 89 sb.append("Form contains: ${fields.join(', ')}. ")
88 } 90 }
...@@ -93,7 +95,8 @@ class UiNarrativeBuilder { ...@@ -93,7 +95,8 @@ class UiNarrativeBuilder {
93 if (links && links.size() > 0) { 95 if (links && links.size() > 0) {
94 def linkTypes = links.collect { l -> l.type?.toString() ?: 'navigation' }.unique() 96 def linkTypes = links.collect { l -> l.type?.toString() ?: 'navigation' }.unique()
95 if (linkTypes) { 97 if (linkTypes) {
96 sb.append("Available links: ${linkTypes.take(3).join(', ')}. ") 98 def maxTypes = isTerse ? 3 : 15
99 sb.append("Available links: ${linkTypes.take(maxTypes).join(', ')}. ")
97 } 100 }
98 } 101 }
99 102
...@@ -141,7 +144,8 @@ class UiNarrativeBuilder { ...@@ -141,7 +144,8 @@ class UiNarrativeBuilder {
141 if (links && links.size() > 0) { 144 if (links && links.size() > 0) {
142 def sortedLinks = links.sort { a, b -> (a.text <=> b.text) } 145 def sortedLinks = links.sort { a, b -> (a.text <=> b.text) }
143 146
144 sortedLinks.take(5).each { link -> 147 def linksToTake = isTerse ? 5 : 50
148 sortedLinks.take(linksToTake).each { link ->
145 def linkText = link.text?.toString() 149 def linkText = link.text?.toString()
146 def linkPath = link.path?.toString() 150 def linkPath = link.path?.toString()
147 def linkType = link.type?.toString() ?: 'navigation' 151 def linkType = link.type?.toString() ?: 'navigation'
......