This is an automated email from the ASF dual-hosted git repository.

dspavlov pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ignite-teamcity-bot.git


The following commit(s) were added to refs/heads/master by this push:
     new 63eaafb8 IGNITE-28635 Add AI prompt log wait fallback (#219)
63eaafb8 is described below

commit 63eaafb8b3706d11c68721828e638da0b05c015c
Author: ignitetcbot <[email protected]>
AuthorDate: Sat May 9 15:10:29 2026 +0300

    IGNITE-28635 Add AI prompt log wait fallback (#219)
    
    add cached-only fallback for long TeamCity/log processing
    improve AI prompt loading dialog and no-wait mode
    ---------
    Codex co-authored-by: Dmitriy Pavlov <[email protected]>
---
 .../ignite/ci/web/rest/pr/GetPrTestFailures.java   |    7 +-
 .../rest/tracked/GetTrackedBranchTestResults.java  |    6 +-
 ignite-tc-helper-web/src/main/webapp/current.html  |   31 -
 .../src/main/webapp/js/common-1.7.js               |  326 +++++++
 ignite-tc-helper-web/src/main/webapp/pr.html       |   31 -
 .../ci/tcbot/chain/PrChainsProcessorTest.java      |   28 +
 .../org/apache/ignite/ci/web/CommonScriptTest.java |   55 ++
 .../engine/build/SingleBuildResultsService.java    |   15 +-
 .../engine/build/TestFailuresAiPromptBuilder.java  | 1022 ++++++++++++++++++--
 .../ignite/tcbot/engine/chain/FullChainRunCtx.java |   22 +
 .../ignite/tcbot/engine/chain/MultBuildRunCtx.java |   30 +
 .../tcbot/engine/chain/SingleBuildRunCtx.java      |   40 +
 .../ignite/tcbot/engine/pr/PrChainsProcessor.java  |   73 +-
 .../tracked/TrackedBranchChainsProcessor.java      |   75 +-
 .../build/TestFailuresAiPromptBuilderTest.java     |   99 ++
 15 files changed, 1704 insertions(+), 156 deletions(-)

diff --git 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/pr/GetPrTestFailures.java
 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/pr/GetPrTestFailures.java
index 0d9c22a0..4bb9085a 100644
--- 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/pr/GetPrTestFailures.java
+++ 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/pr/GetPrTestFailures.java
@@ -125,6 +125,7 @@ public class GetPrTestFailures {
      * @param maxDetailsChars Max chars per TeamCity failure details block. 
Non-positive means default cap.
      * @param testName Optional full test name filter.
      * @param promptSuiteId Optional suite id filter.
+     * @param waitForTc Wait for fresh TeamCity context and build log 
processing.
      */
     @GET
     @Path("results/aiPrompt")
@@ -138,7 +139,8 @@ public class GetPrTestFailures {
         @Nullable @QueryParam("baseBranchForTc") String baseBranchForTc,
         @Nullable @QueryParam("maxDetailsChars") Integer maxDetailsChars,
         @Nullable @QueryParam("testName") String testName,
-        @Nullable @QueryParam("promptSuiteId") String promptSuiteId) {
+        @Nullable @QueryParam("promptSuiteId") String promptSuiteId,
+        @Nullable @QueryParam("waitForTc") Boolean waitForTc) {
         final TcBotApplicationContext appCtx = 
CtxListener.getApplicationContext(ctx);
 
         return 
appCtx.getInstance(PrChainsProcessor.class).getPrFailuresAiPrompt(
@@ -151,6 +153,7 @@ public class GetPrTestFailures {
             baseBranchForTc,
             TestFailuresAiPromptBuilder.restMaxDetailsChars(maxDetailsChars),
             testName,
-            promptSuiteId);
+            promptSuiteId,
+            waitForTc == null || waitForTc);
     }
 }
diff --git 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/tracked/GetTrackedBranchTestResults.java
 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/tracked/GetTrackedBranchTestResults.java
index b88d0cff..a29b0f40 100644
--- 
a/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/tracked/GetTrackedBranchTestResults.java
+++ 
b/ignite-tc-helper-web/src/main/java/org/apache/ignite/ci/web/rest/tracked/GetTrackedBranchTestResults.java
@@ -96,7 +96,8 @@ public class GetTrackedBranchTestResults {
         @Nullable @QueryParam("count") Integer mergeCnt,
         @Nullable @QueryParam("maxDetailsChars") Integer maxDetailsChars,
         @Nullable @QueryParam("testName") String testName,
-        @Nullable @QueryParam("promptSuiteId") String promptSuiteId) {
+        @Nullable @QueryParam("promptSuiteId") String promptSuiteId,
+        @Nullable @QueryParam("waitForTc") Boolean waitForTc) {
         int actualMergeBuilds = (mergeCnt == null || mergeCnt < 1) ? 1 : 
mergeCnt;
 
         return CtxListener.getApplicationContext(ctx)
@@ -109,7 +110,8 @@ public class GetTrackedBranchTestResults {
                 SortOption.parseStringValue(sortOption),
                 maxDetailsChars,
                 testName,
-                promptSuiteId);
+                promptSuiteId,
+                waitForTc == null || waitForTc);
     }
 
     @GET
diff --git a/ignite-tc-helper-web/src/main/webapp/current.html 
b/ignite-tc-helper-web/src/main/webapp/current.html
index 48e71eb9..c7cbce7c 100644
--- a/ignite-tc-helper-web/src/main/webapp/current.html
+++ b/ignite-tc-helper-web/src/main/webapp/current.html
@@ -360,37 +360,6 @@ function openAiPromptForSuite(suiteId) {
     openAiPrompt(aiPromptUrlForSuite(suiteId));
 }
 
-function openAiPrompt(url) {
-    let promptWindow = window.open("about:blank", "_blank");
-    if (promptWindow) {
-        promptWindow.document.title = "AI prompt";
-        promptWindow.document.body.innerHTML = "<pre>Loading AI 
prompt...</pre>";
-    }
-
-    $.ajax({
-        url: url,
-        timeout: 300000,
-        success: function (result) {
-            writeAiPromptWindow(promptWindow, result);
-        },
-        error: function (xhr, status, error) {
-            showErrInLoadStatus(xhr, status, error);
-            writeAiPromptWindow(promptWindow, "AI prompt request failed: " + 
status + "\n\n" + xhr.responseText);
-        }
-    });
-}
-
-function writeAiPromptWindow(promptWindow, text) {
-    if (!promptWindow)
-        return;
-
-    promptWindow.document.body.innerHTML = "";
-
-    let pre = promptWindow.document.createElement("pre");
-    pre.textContent = text;
-    promptWindow.document.body.appendChild(pre);
-}
-
 </script>
 
 
diff --git a/ignite-tc-helper-web/src/main/webapp/js/common-1.7.js 
b/ignite-tc-helper-web/src/main/webapp/js/common-1.7.js
index 69bd7af0..0ecb431c 100644
--- a/ignite-tc-helper-web/src/main/webapp/js/common-1.7.js
+++ b/ignite-tc-helper-web/src/main/webapp/js/common-1.7.js
@@ -126,6 +126,332 @@ function showErrInLoadStatus(jqXHR, exception) {
     }
 }
 
+function openAiPrompt(url) {
+    openTextCommandDialog({
+        dialogId: "aiPromptDialog",
+        statusId: "aiPromptStatus",
+        logId: "aiPromptProgressLog",
+        errorId: "aiPromptError",
+        title: "Generating AI prompt",
+        initialMode: true,
+        requestUrl: function (waitForTc) {
+            return aiPromptUrlWithWaitForTc(url, waitForTc);
+        },
+        timeoutMs: 70000,
+        skip: {
+            isVisible: function (waitForTc) {
+                return waitForTc;
+            },
+            nextMode: false,
+            buttonText: "Use current context now",
+            runningText: "Using current context...",
+            stepText: "Building prompt from current cached context."
+        },
+        statusText: function (waitForTc) {
+            return waitForTc ? "Generating prompt..." : "Generating prompt 
from current context...";
+        },
+        progressMessage: aiPromptProgressMessage,
+        openButtonText: "Open prompt",
+        downloadButtonText: "Download .txt",
+        downloadFileName: "ai-prompt.txt",
+        readyStatusText: "AI prompt is ready.",
+        readyStepText: "Prompt text is ready. Use Open prompt or Download 
.txt.",
+        failureStatusText: "AI prompt request failed.",
+        failureMessagePrefix: "AI prompt request failed: "
+    });
+}
+
+function openTextCommandDialog(options) {
+    let state = createTextCommandDialog(options);
+
+    requestTextCommand(options, state, options.initialMode);
+}
+
+function requestTextCommand(options, state, mode, firstStep) {
+    if (state.timer)
+        clearInterval(state.timer);
+
+    let skip = options.skip;
+    let skipVisible = skip != null && (skip.isVisible == null || 
skip.isVisible(mode));
+
+    state.skipBtn.toggle(skipVisible).prop("disabled", false)
+        .text(skip == null ? "" : skip.buttonText);
+    state.openBtn.hide();
+    state.downloadBtn.hide();
+    state.errorBlock.hide();
+
+    state.skipBtn.off("click");
+
+    if (skipVisible) {
+        state.skipBtn.on("click", function () {
+            if (state.xhr)
+                state.xhr.abort();
+
+            let nextMode = typeof skip.nextMode === "function" ? 
skip.nextMode(mode) : skip.nextMode;
+
+            state.skipBtn.prop("disabled", true).text(skip.runningText);
+            requestTextCommand(options, state, nextMode, skip.stepText);
+        });
+    }
+
+    startTextCommandProgress(options, state, mode, firstStep);
+
+    state.xhr = $.ajax({
+        url: options.requestUrl(mode),
+        timeout: options.timeoutMs == null ? 70000 : options.timeoutMs,
+        success: function (result) {
+            finishTextCommandDialog(options, state, result);
+        },
+        error: function (jqXHR, status, error) {
+            if (status === "abort")
+                return;
+
+            failTextCommandDialog(options, state, jqXHR, status, error);
+        }
+    });
+}
+
+function createTextCommandDialog(options) {
+    let dialog = $("#" + options.dialogId);
+
+    if (dialog.length > 0)
+        dialog.remove();
+
+    dialog = $("<div>", {id: options.dialogId});
+
+    let status = $("<div>", {
+        id: options.statusId,
+        css: {
+            "font-weight": "600",
+            "margin-bottom": "12px"
+        }
+    });
+
+    let log = $("<div>", {
+        id: options.logId,
+        css: {
+            "background": "#f7f7f7",
+            "border": "1px solid #d8d8d8",
+            "border-radius": "4px",
+            "font-family": "monospace",
+            "line-height": "1.45",
+            "max-height": "260px",
+            "min-height": "145px",
+            "overflow-y": "auto",
+            "padding": "10px",
+            "white-space": "pre-wrap"
+        }
+    });
+
+    let errorBlock = $("<pre>", {
+        id: options.errorId,
+        css: {
+            "background": "#fff2f2",
+            "border": "1px solid #d09090",
+            "border-radius": "4px",
+            "display": "none",
+            "margin-top": "12px",
+            "max-height": "180px",
+            "overflow": "auto",
+            "padding": "10px",
+            "white-space": "pre-wrap"
+        }
+    });
+
+    let actions = $("<div>", {
+        css: {
+            "display": "flex",
+            "gap": "8px",
+            "justify-content": "flex-end",
+            "margin-top": "14px"
+        }
+    });
+
+    let skipBtn = $("<button>", {type: "button", text: options.skip == null ? 
"" : options.skip.buttonText});
+    let openBtn = $("<button>", {type: "button", text: options.openButtonText 
|| "Open"}).hide();
+    let downloadBtn = $("<button>", {type: "button", text: 
options.downloadButtonText || "Download"}).hide();
+
+    actions.append(skipBtn, openBtn, downloadBtn);
+    dialog.append(status, log, errorBlock, actions);
+    $("body").append(dialog);
+
+    let state = {
+        dialog: dialog,
+        status: status,
+        log: log,
+        errorBlock: errorBlock,
+        skipBtn: skipBtn,
+        openBtn: openBtn,
+        downloadBtn: downloadBtn,
+        resultUrl: null,
+        timer: null,
+        xhr: null
+    };
+
+    dialog.dialog({
+        close: function () {
+            closeTextCommandDialog(state);
+        },
+        modal: true,
+        resizable: false,
+        title: options.title,
+        width: Math.min(options.width || 620, $(window).width() - 40)
+    });
+
+    openBtn.on("click", function () {
+        openTextCommandResult(state);
+    });
+
+    downloadBtn.on("click", function () {
+        downloadTextCommandResult(options, state);
+    });
+
+    return state;
+}
+
+function aiPromptUrlWithWaitForTc(url, waitForTc) {
+    return url + (url.indexOf("?") >= 0 ? "&" : "?") + "waitForTc=" + 
waitForTc;
+}
+
+function startTextCommandProgress(options, state, mode, firstStep) {
+    let idx = 0;
+    let startedTs = Date.now();
+
+    state.status.text(options.statusText == null ? "Running command..." : 
options.statusText(mode));
+    state.log.empty();
+
+    if (firstStep)
+        appendTextCommandStep(state, firstStep);
+
+    function showNextStatus() {
+        let message = options.progressMessage == null
+            ? defaultTextCommandProgressMessage(mode, idx, Date.now() - 
startedTs)
+            : options.progressMessage(mode, idx, Date.now() - startedTs);
+
+        appendTextCommandStep(state, message);
+
+        idx++;
+    }
+
+    showNextStatus();
+
+    state.timer = setInterval(showNextStatus, options.progressIntervalMs || 
5000);
+}
+
+function defaultTextCommandProgressMessage(mode, idx, elapsedMs) {
+    let elapsedSec = Math.round(elapsedMs / 1000);
+
+    if (idx === 0)
+        return "Sending request to the bot server.";
+
+    return "No response yet after " + elapsedSec + "s. Command is still 
running.";
+}
+
+function aiPromptProgressMessage(waitForTc, idx, elapsedMs) {
+    let elapsedSec = Math.round(elapsedMs / 1000);
+
+    if (!waitForTc) {
+        if (idx === 0)
+            return "Sending no-wait request to the bot server.";
+
+        if (idx === 1)
+            return "Using cached chain context and cached log analysis only.";
+
+        return "No prompt response yet after " + elapsedSec
+            + "s. Bot is still building from cache; no new TeamCity/log wait 
was requested.";
+    }
+
+    if (idx === 0)
+        return "Sending request to the bot server.";
+
+    if (idx === 1)
+        return "Bot is loading the TeamCity build list and dependency chain.";
+
+    if (idx === 2)
+        return "No prompt response yet after " + elapsedSec
+            + "s. Bot may still be downloading build/test metadata from 
TeamCity.";
+
+    if (idx === 3)
+        return "No prompt response yet after " + elapsedSec
+            + "s. Bot may be loading build logs for failed or incomplete 
suites.";
+
+    if (idx === 4)
+        return "No prompt response yet after " + elapsedSec
+            + "s. Bot may be parsing build logs and attaching cached log 
analysis to the prompt.";
+
+    if (elapsedSec < 65)
+        return "No prompt response yet after " + elapsedSec
+            + "s. Fresh context/log wait timeout is 60s; you can use current 
cached context now.";
+
+    return "No prompt response yet after " + elapsedSec
+        + "s. TeamCity loading or build-log processing is taking longer than 
expected; you can use current context now.";
+}
+
+function appendTextCommandStep(state, text) {
+    let line = $("<div>").text("> " + text);
+    state.log.append(line);
+    state.log.scrollTop(state.log[0].scrollHeight);
+}
+
+function finishTextCommandDialog(options, state, result) {
+    if (state.timer)
+        clearInterval(state.timer);
+
+    if (state.resultUrl)
+        URL.revokeObjectURL(state.resultUrl);
+
+    state.resultUrl = URL.createObjectURL(new Blob([result], {
+        type: options.resultMimeType || "text/plain;charset=utf-8"
+    }));
+    state.status.text(options.readyStatusText || "Command result is ready.");
+    appendTextCommandStep(state, options.readyStepText || "Command result is 
ready.");
+    state.skipBtn.hide();
+    state.openBtn.toggle(options.showOpenButton !== false);
+    state.downloadBtn.toggle(options.showDownloadButton !== false);
+}
+
+function failTextCommandDialog(options, state, jqXHR, status, error) {
+    if (state.timer)
+        clearInterval(state.timer);
+
+    state.status.text(options.failureStatusText || "Command request failed.");
+    state.skipBtn.hide();
+    state.errorBlock.text((options.failureMessagePrefix || "Command request 
failed: ")
+        + status + "\n\n" + jqXHR.responseText).show();
+    appendTextCommandStep(state, "Request failed: " + (error || status));
+    showErrInLoadStatus(jqXHR, status);
+}
+
+function openTextCommandResult(state) {
+    if (!state.resultUrl)
+        return false;
+
+    return window.open(state.resultUrl, "_blank") != null;
+}
+
+function downloadTextCommandResult(options, state) {
+    if (!state.resultUrl)
+        return;
+
+    let link = document.createElement("a");
+    link.href = state.resultUrl;
+    link.download = options.downloadFileName || "command-result.txt";
+    document.body.appendChild(link);
+    link.click();
+    document.body.removeChild(link);
+}
+
+function closeTextCommandDialog(state) {
+    if (state.timer)
+        clearInterval(state.timer);
+
+    if (state.xhr && state.xhr.readyState !== 4)
+        state.xhr.abort();
+
+    if (state.resultUrl)
+        URL.revokeObjectURL(state.resultUrl);
+}
+
 
 //requires element on page: <div id="version"></div>
 function showVersionInfo(result) {
diff --git a/ignite-tc-helper-web/src/main/webapp/pr.html 
b/ignite-tc-helper-web/src/main/webapp/pr.html
index ca6f8f58..2c6d2945 100644
--- a/ignite-tc-helper-web/src/main/webapp/pr.html
+++ b/ignite-tc-helper-web/src/main/webapp/pr.html
@@ -144,37 +144,6 @@ function openAiPromptForSuite(suiteId) {
     openAiPrompt(aiPromptUrlForSuite(suiteId));
 }
 
-function openAiPrompt(url) {
-    let promptWindow = window.open("about:blank", "_blank");
-    if (promptWindow) {
-        promptWindow.document.title = "AI prompt";
-        promptWindow.document.body.innerHTML = "<pre>Loading AI 
prompt...</pre>";
-    }
-
-    $.ajax({
-        url: url,
-        timeout: 300000,
-        success: function (result) {
-            writeAiPromptWindow(promptWindow, result);
-        },
-        error: function (xhr, status, error) {
-            showErrInLoadStatus(xhr, status, error);
-            writeAiPromptWindow(promptWindow, "AI prompt request failed: " + 
status + "\n\n" + xhr.responseText);
-        }
-    });
-}
-
-function writeAiPromptWindow(promptWindow, text) {
-    if (!promptWindow)
-        return;
-
-    promptWindow.document.body.innerHTML = "";
-
-    let pre = promptWindow.document.createElement("pre");
-    pre.textContent = text;
-    promptWindow.document.body.appendChild(pre);
-}
-
 function checkForUpdate() {
     var curFailuresUrl = "rest/pr/updates" + parmsForRest();
 
diff --git 
a/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/tcbot/chain/PrChainsProcessorTest.java
 
b/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/tcbot/chain/PrChainsProcessorTest.java
index a832185e..b4fcd4fe 100644
--- 
a/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/tcbot/chain/PrChainsProcessorTest.java
+++ 
b/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/tcbot/chain/PrChainsProcessorTest.java
@@ -33,6 +33,7 @@ import org.apache.ignite.tcbot.persistence.IStringCompactor;
 import org.apache.ignite.tcignited.ITeamcityIgnitedProvider;
 import org.apache.ignite.tcignited.SyncMode;
 import org.apache.ignite.tcignited.build.TestCompactedV2;
+import org.apache.ignite.tcignited.buildlog.IBuildLogProcessor;
 import org.apache.ignite.tcservice.ITeamcity;
 import org.apache.ignite.tcservice.model.conf.BuildType;
 import org.apache.ignite.tcservice.model.hist.BuildRef;
@@ -54,7 +55,13 @@ import java.util.concurrent.TimeUnit;
 import java.util.function.Predicate;
 
 import static org.junit.Assert.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.atLeastOnce;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
 
 /**
  * Unit test for {@link PrChainsProcessor} and blockers detection. Emulates 
builds using Mockito. Does not start an
@@ -185,6 +192,27 @@ public class PrChainsProcessorTest {
         return suiteOpt.flatMap(suite -> 
suite.testFailures().stream().filter(tf -> name.equals(tf.name)).findAny());
     }
 
+    @Test
+    public void aiPromptNoWaitUsesCachedLogAnalysisOnly() {
+        IStringCompactor c = injector.getInstance(IStringCompactor.class);
+        IBuildLogProcessor logProcessor = 
injector.getInstance(IBuildLogProcessor.class);
+
+        final String btId = "RunAll";
+        final String branch = "ignite-ai-prompt";
+
+        initBuildChain(c, btId, branch);
+
+        PrChainsProcessor prcp = injector.getInstance(PrChainsProcessor.class);
+
+        String prompt = prcp.getPrFailuresAiPrompt(mock(ITcBotUserCreds.class),
+            SRV_ID, btId, branch, null, 1, null, 1024, null, null, false);
+
+        assertTrue(prompt.contains("AI Prompt"));
+
+        verify(logProcessor, atLeastOnce()).getCachedBuildLogAnalysis(any(), 
anyInt());
+        verify(logProcessor, never()).analyzeBuildLog(any(), anyInt(), 
anyBoolean());
+    }
+
     /**
      * @param name Test failure Name to find.
      */
diff --git 
a/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/CommonScriptTest.java
 
b/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/CommonScriptTest.java
new file mode 100644
index 00000000..7c878d9c
--- /dev/null
+++ 
b/ignite-tc-helper-web/src/test/java/org/apache/ignite/ci/web/CommonScriptTest.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.ci.web;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.junit.Test;
+
+import static org.junit.Assert.assertTrue;
+
+public class CommonScriptTest {
+    @Test
+    public void aiPromptSkipButtonRequestsCurrentContext() throws IOException {
+        String js = readFile(commonJs());
+
+        assertTrue(js.contains("buttonText: \"Use current context now\""));
+        assertTrue(js.contains("nextMode: false"));
+        assertTrue(js.contains("requestTextCommand(options, state, nextMode, 
skip.stepText)"));
+        assertTrue(js.contains("return aiPromptUrlWithWaitForTc(url, 
waitForTc)"));
+        assertTrue(js.contains("\"waitForTc=\" + waitForTc"));
+    }
+
+    private static Path commonJs() {
+        Path projectPath = Paths.get("src/main/webapp/js/common-1.7.js");
+
+        if (Files.exists(projectPath))
+            return projectPath;
+
+        return 
Paths.get("ignite-tc-helper-web/src/main/webapp/js/common-1.7.js");
+    }
+
+    private static String readFile(Path path) throws IOException {
+        return new String(Files.readAllBytes(path), StandardCharsets.UTF_8)
+            .replace("\r\n", "\n")
+            .replace('\r', '\n');
+    }
+}
diff --git 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/SingleBuildResultsService.java
 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/SingleBuildResultsService.java
index eab55dc0..17b455a0 100644
--- 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/SingleBuildResultsService.java
+++ 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/SingleBuildResultsService.java
@@ -47,6 +47,9 @@ import org.apache.ignite.tcservice.ITeamcity;
  * Displays single build at server by ID.
  */
 public class SingleBuildResultsService {
+    /** Max time to wait for fresh AI prompt build context. */
+    private static final long AI_PROMPT_CONTEXT_WAIT_MS = 
TimeUnit.MINUTES.toMillis(1);
+
     @Inject BuildChainProcessor buildChainProcessor;
     @Inject ITeamcityIgnitedProvider tcIgnitedProv;
     @Inject BranchEquivalence branchEquivalence;
@@ -154,18 +157,19 @@ public class SingleBuildResultsService {
         Future<FullChainRunCtx> live = null;
 
         try {
-            aiPromptMonitor.stage(reqId, "trying fresh context for up to 1s");
+            aiPromptMonitor.stage(reqId, "loading fresh build context from 
TeamCity for up to "
+                + TimeUnit.MILLISECONDS.toSeconds(AI_PROMPT_CONTEXT_WAIT_MS) + 
"s");
 
             live = tcUpdatePool.getService().submit(() ->
-                loadSingleBuildContext(srvCodeOrAlias, buildId, null, 
liveSyncMode, prov, ProcessLogsMode.CACHED_ONLY));
+                loadSingleBuildContext(srvCodeOrAlias, buildId, null, 
liveSyncMode, prov, ProcessLogsMode.ALL));
 
-            return live.get(1, TimeUnit.SECONDS);
+            return live.get(AI_PROMPT_CONTEXT_WAIT_MS, TimeUnit.MILLISECONDS);
         }
         catch (TimeoutException e) {
             if (live != null)
                 live.cancel(true);
 
-            aiPromptMonitor.stage(reqId, "fresh context timed out, using stale 
cache");
+            aiPromptMonitor.stage(reqId, "fresh TeamCity reload timed out, 
loading best-effort cached context");
         }
         catch (InterruptedException e) {
             if (live != null)
@@ -178,7 +182,8 @@ public class SingleBuildResultsService {
             throw new IllegalStateException("Interrupted while loading fresh 
TeamCity context", e);
         }
         catch (Exception e) {
-            aiPromptMonitor.stage(reqId, "fresh context failed, using stale 
cache: " + e.getMessage());
+            aiPromptMonitor.stage(reqId, "fresh TeamCity reload failed, 
loading best-effort cached context: "
+                + e.getMessage());
         }
 
         return loadSingleBuildContext(srvCodeOrAlias, buildId, null, 
SyncMode.NONE, prov, ProcessLogsMode.CACHED_ONLY);
diff --git 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/TestFailuresAiPromptBuilder.java
 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/TestFailuresAiPromptBuilder.java
index f262ca7e..d5eebebf 100644
--- 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/TestFailuresAiPromptBuilder.java
+++ 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/build/TestFailuresAiPromptBuilder.java
@@ -18,12 +18,17 @@
 package org.apache.ignite.tcbot.engine.build;
 
 import com.google.common.base.Strings;
-import java.util.Comparator;
 import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Set;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
@@ -52,9 +57,64 @@ public class TestFailuresAiPromptBuilder {
     /** Default limit for a single TeamCity failure details block. */
     public static final int DFLT_MAX_DETAILS_CHARS = 40000;
 
+    /** Max cached log scanner chars to include in one log context block. */
+    private static final int LOG_CONTEXT_CHAR_BUDGET = 12000;
+
+    /** Max cached log scanner tail chars to include when all messages are too 
large. */
+    private static final int LOG_CONTEXT_TAIL_CHAR_BUDGET = 5000;
+
+    /** Max suggested local search commands. */
+    private static final int SUGGESTED_SEARCHES_LIMIT = 5;
+
+    /** Max lines to keep from TeamCity stdout/stderr details sections. */
+    private static final int DETAILS_SECTION_TAIL_LINES = 25;
+
+    /** Max chars to keep from TeamCity stdout/stderr details sections. */
+    private static final int DETAILS_SECTION_TAIL_CHARS = 6000;
+
+    /** Throwable class in a log line, with an optional message. */
+    private static final Pattern THROWABLE_SIGNAL = Pattern.compile(
+        
"\\b((?:[\\w$]+\\.)*[\\w$]*(?:Exception|Error))\\b(?:\\s*:\\s*([^\\r\\n]+))?");
+
+    /** Inline stack frame tail after a compacted exception message. */
+    private static final Pattern INLINE_STACK_TAIL = 
Pattern.compile("\\s+at\\s+[\\w.$]+\\([^)]*\\).*$");
+
+    /** Java source location in stack trace. */
+    private static final Pattern JAVA_SOURCE_LOCATION = 
Pattern.compile("\\(([^():]+\\.java):(\\d+)\\)");
+
+    /** Java stack frame. */
+    private static final Pattern STACK_FRAME = Pattern.compile(
+        "^\\s*at\\s+((?:[\\w$]+\\.)+)([\\w$]+)\\(([^():]+\\.java):(\\d+)\\)");
+
     /** String compactor. */
     private final IStringCompactor compactor;
 
+    /** Prompt section skeleton, ordered by diagnostic value rather than data 
source shape. */
+    private enum Section {
+        FAST_SUMMARY("Fast Summary"),
+        FAILURE_SIGNAL("Failure Signal"),
+        SUGGESTED_LOCAL_SEARCHES("Suggested Local Searches"),
+        HISTORY("History"),
+        LOG_CONTEXT("Log Context"),
+        SUITE_CONTEXT("Suite / Build Context"),
+        TEST_CONTEXT("Test Context"),
+        CHANGE_CONTEXT("Changed Files / PR Context"),
+        CHAIN_CONTEXT("Chain Context"),
+        INCLUDED_SCOPE("Included Scope"),
+        INVESTIGATION_INSTRUCTIONS("Investigation Instructions"),
+        REQUIRED_FINAL_ANSWER("Required Final Answer");
+
+        /** Section title. */
+        private final String title;
+
+        /**
+         * @param title Section title.
+         */
+        Section(String title) {
+            this.title = title;
+        }
+    }
+
     /**
      * @param compactor String compactor.
      */
@@ -112,7 +172,8 @@ public class TestFailuresAiPromptBuilder {
         String normalizedBaseBranch = normalizeBranch(baseBranchTc);
         Integer baseBranchId = 
compactor.getStringIdIfPresent(normalizedBaseBranch);
 
-        appendHeader(res, tcIgnited, ctx, normalizedBaseBranch);
+        appendHeader(res);
+        appendChainContext(res, tcIgnited, ctx, normalizedBaseBranch);
 
         AtomicInteger suiteCnt = new AtomicInteger();
         AtomicInteger testCnt = new AtomicInteger();
@@ -138,7 +199,7 @@ public class TestFailuresAiPromptBuilder {
                 .collect(Collectors.toList());
 
             if (failedTests.isEmpty())
-                appendSuiteFailure(res, suite);
+                appendSuiteFailure(res, tcIgnited, suite, baseBranchId);
             else {
                 for (TestCompactedMult test : failedTests) {
                     testCnt.incrementAndGet();
@@ -147,7 +208,7 @@ public class TestFailuresAiPromptBuilder {
             }
         });
 
-        res.append("## Included Scope\n");
+        appendSection(res, Section.INCLUDED_SCOPE);
         res.append("Failed suites included: 
").append(suiteCnt.get()).append('\n');
         res.append("Failed tests included: 
").append(testCnt.get()).append('\n');
 
@@ -156,17 +217,33 @@ public class TestFailuresAiPromptBuilder {
 
     /**
      * @param res Result builder.
-     * @param tcIgnited TeamCity facade.
-     * @param ctx Full chain context.
-     * @param normalizedBaseBranch Normalized base branch.
      */
-    private void appendHeader(StringBuilder res, ITeamcityIgnited tcIgnited, 
FullChainRunCtx ctx,
-        @Nullable String normalizedBaseBranch) {
+    private void appendHeader(StringBuilder res) {
         res.append("# AI Prompt: Investigate TeamCity Failure\n\n");
         res.append("You are in the local checkout of the project that produced 
this TeamCity failure. ");
         res.append("Use this compact investigation brief to find the likely 
root cause with minimal assumptions.\n\n");
+    }
+
+    /**
+     * @param res Result builder.
+     * @param section Section.
+     */
+    private void appendSection(StringBuilder res, Section section) {
+        if (res.length() > 0 && res.charAt(res.length() - 1) != '\n')
+            res.append('\n');
+
+        res.append("## ").append(section.title).append('\n');
+    }
 
-        res.append("## Run Context\n");
+    /**
+     * @param res Result builder.
+     * @param tcIgnited TeamCity facade.
+     * @param ctx Full chain context.
+     * @param normalizedBaseBranch Normalized base branch.
+     */
+    private void appendChainContext(StringBuilder res, ITeamcityIgnited 
tcIgnited, FullChainRunCtx ctx,
+        @Nullable String normalizedBaseBranch) {
+        appendSection(res, Section.CHAIN_CONTEXT);
         appendLine(res, "TeamCity server", tcIgnited.serverCode());
         appendLine(res, "Chain", ctx.suiteName());
         appendLine(res, "Suite id", ctx.suiteId());
@@ -176,7 +253,6 @@ public class TestFailuresAiPromptBuilder {
         appendLine(res, "Entry build", tcIgnited.host() + 
"viewLog.html?buildId=" + ctx.getSuiteBuildId());
         appendLine(res, "Duration", ctx.getDurationPrintable(suite -> true));
         res.append('\n');
-
     }
 
     /**
@@ -189,7 +265,7 @@ public class TestFailuresAiPromptBuilder {
         @Nullable Integer baseBranchId) {
         IRunHistory baseHist = suite.history(tcIgnited, baseBranchId, null);
 
-        res.append("## Run Context\n");
+        appendSection(res, Section.SUITE_CONTEXT);
         res.append("Suite: 
").append(nullToUnknown(suite.suiteName())).append('\n');
         appendLine(res, "Suite id", suite.suiteId());
         appendLine(res, "Build id", String.valueOf(suite.getBuildId()));
@@ -238,22 +314,36 @@ public class TestFailuresAiPromptBuilder {
         }
     }
 
+    /**
+     * @param res Result builder.
+     * @param compareTarget What to compare changed packages against.
+     */
+    private void appendChangeContext(StringBuilder res, String compareTarget) {
+        appendSection(res, Section.CHANGE_CONTEXT);
+        res.append("Changed files and patches are not available in this cached 
TeamCity bot context. ");
+        res.append("Inspect the local checkout diff/PR metadata if needed, 
then compare changed packages with the ");
+        res.append(compareTarget).append(".\n");
+    }
+
     /**
      * @param res Result builder.
      * @param suite Suite context.
      */
     private void appendLogChecks(StringBuilder res, MultBuildRunCtx suite) {
-        List<Map.Entry<String, ITestLogCheckResult>> warnings = 
suite.getLogsCheckResults()
+        List<Map.Entry<String, ITestLogCheckResult>> messages = 
suite.getLogsCheckResults()
             .flatMap(map -> map.entrySet().stream())
             .filter(entry -> entry.getValue() != null && 
entry.getValue().hasWarns())
             .collect(Collectors.toList());
 
-        if (warnings.isEmpty())
+        if (messages.isEmpty()) {
+            appendLogAnalysisNote(res, suite);
+
             return;
+        }
 
-        res.append("Build log scanner warnings:\n");
+        res.append("Build log scanner messages:\n");
 
-        for (Map.Entry<String, ITestLogCheckResult> entry : warnings) {
+        for (Map.Entry<String, ITestLogCheckResult> entry : messages) {
             ITestLogCheckResult checkRes = entry.getValue();
 
             res.append("- ").append(nullToUnknown(entry.getKey()));
@@ -276,8 +366,25 @@ public class TestFailuresAiPromptBuilder {
         TestCompactedMult test, @Nullable Integer baseBranchId, int 
maxDetailsChars) {
         String fullName = test.getName();
         IRunHistory testHist = test.history(tcIgnited, baseBranchId);
+        List<ITest> failedInvocations = test.getInvocationsStream()
+            .filter(Objects::nonNull)
+            .filter(invocation -> invocation.isFailedButNotMuted(compactor))
+            .collect(Collectors.toList());
+        String primaryDetails = failedInvocations.stream()
+            .map(ITest::getDetailsText)
+            .filter(details -> !Strings.isNullOrEmpty(details))
+            .map(this::cleanDetails)
+            .findFirst()
+            .orElse(null);
+        Integer primaryDuration = failedInvocations.stream()
+            .map(ITest::getDuration)
+            .filter(Objects::nonNull)
+            .findFirst()
+            .orElse(null);
 
-        res.append("## Failure Signal\n");
+        appendFastSummary(res, suite, fullName, test.failuresCount(), 
primaryDetails, primaryDuration);
+
+        appendSection(res, Section.FAILURE_SIGNAL);
         appendLine(res, "Full test name", fullName);
         appendLine(res, "Suite before colon", testSuitePart(fullName));
         appendLine(res, "Test after colon", testCasePart(fullName));
@@ -285,19 +392,23 @@ public class TestFailuresAiPromptBuilder {
 
         AtomicInteger invocationIdx = new AtomicInteger();
 
-        test.getInvocationsStream()
-            .filter(Objects::nonNull)
-            .filter(invocation -> invocation.isFailedButNotMuted(compactor))
-            .forEach(invocation -> appendInvocation(res, tcIgnited, suite, 
invocation, fullName,
+        failedInvocations.forEach(invocation -> appendInvocation(res, 
tcIgnited, suite, invocation, fullName,
                 invocationIdx.incrementAndGet(), maxDetailsChars));
 
-        appendRelevantLogChecks(res, suite, fullName);
+        appendSuggestedLocalSearches(res, suite, fullName, failedInvocations);
 
-        res.append("\n## Changed Files / PR Context\n");
-        res.append("Changed files and patches are not available in this cached 
TeamCity bot context. ");
-        res.append("Inspect the local checkout diff/PR metadata if needed, 
then compare changed packages with the failing test/module.\n");
+        if (testHist != null) {
+            appendSection(res, Section.HISTORY);
+            appendLine(res, "Base branch test failure rate", 
testHist.getFailPercentPrintable() + "%");
+            appendLine(res, "Base branch flaky", 
String.valueOf(testHist.isFlaky()));
+            appendLine(res, "Base branch latest runs", 
String.valueOf(testHist.getLatestRunResults()));
+            appendLine(res, "Break boundary", breakBoundary(testHist));
+            appendLine(res, "Recent execution history", 
recentHistory(testHist, 12));
+        }
 
-        res.append("\n## Test Context\n");
+        appendRelevantLogChecks(res, suite, fullName);
+
+        appendSection(res, Section.TEST_CONTEXT);
         appendLine(res, "Full test name", fullName);
         appendLine(res, "Suite before colon", testSuitePart(fullName));
         appendLine(res, "Test after colon", testCasePart(fullName));
@@ -307,44 +418,40 @@ public class TestFailuresAiPromptBuilder {
         appendLine(res, "Investigated in TeamCity", 
String.valueOf(test.isInvestigated()));
         appendLine(res, "Possible blocker note", 
test.getPossibleBlockerComment(testHist));
 
-        if (testHist != null) {
-            res.append("\n## History\n");
-            appendLine(res, "Base branch test failure rate", 
testHist.getFailPercentPrintable() + "%");
-            appendLine(res, "Base branch flaky", 
String.valueOf(testHist.isFlaky()));
-            appendLine(res, "Base branch latest runs", 
String.valueOf(testHist.getLatestRunResults()));
-            appendLine(res, "Break boundary", breakBoundary(testHist));
-            appendLine(res, "Recent execution history", 
recentHistory(testHist, 12));
-        }
+        appendChangeContext(res, "failing test/module");
 
-        appendInvestigationInstructions(res);
+        appendInvestigationInstructions(res, 
masterChangeCauseInstruction(suite.branchName(), testHist));
 
         res.append('\n');
     }
 
     /**
      * @param res Result builder.
+     * @param tcIgnited TeamCity facade.
      * @param suite Suite context.
+     * @param baseBranchId Base branch compacted id.
      */
-    private void appendSuiteFailure(StringBuilder res, MultBuildRunCtx suite) {
-        res.append("## Failure Signal\n");
+    private void appendSuiteFailure(StringBuilder res, ITeamcityIgnited 
tcIgnited, MultBuildRunCtx suite,
+        @Nullable Integer baseBranchId) {
+        appendSection(res, Section.FAILURE_SIGNAL);
         res.append("No failed non-muted test was reported for this suite. 
Investigate this as a suite/build-level failure.\n");
         appendProblems(res, suite);
         appendEmptyDetailsDiagnosis(res, suite);
 
-        res.append("\n## Log Context\n");
-        appendLogChecks(res, suite);
+        appendSuggestedLocalSearches(res, suite, suite.suiteName(), 
Collections.emptyList());
 
-        res.append("\n## Changed Files / PR Context\n");
-        res.append("Changed files and patches are not available in this cached 
TeamCity bot context. ");
-        res.append("Inspect the local checkout diff/PR metadata if needed, 
then compare changed packages with the failed suite/module.\n");
+        appendSection(res, Section.LOG_CONTEXT);
+        appendLogChecks(res, suite);
 
-        res.append("\n## Test Context\n");
+        appendSection(res, Section.TEST_CONTEXT);
         appendLine(res, "Suite id", suite.suiteId());
         appendLine(res, "Suite name", suite.suiteName());
         appendLine(res, "Failed tests reported", 
String.valueOf(suite.failedTests()));
         appendLine(res, "Total tests", String.valueOf(suite.totalTests()));
 
-        appendInvestigationInstructions(res);
+        appendChangeContext(res, "failed suite/module");
+
+        appendInvestigationInstructions(res, null);
 
         res.append('\n');
     }
@@ -388,11 +495,155 @@ public class TestFailuresAiPromptBuilder {
             res.append(limit(firstFailureSignal(cleanedDetails), 6000));
             res.append("\n```\n");
 
-            res.append("Failure details text from TeamCity:\n");
-            res.append("```text\n");
-            res.append(limit(cleanedDetails, maxDetailsChars));
-            res.append("\n```\n");
+            String summary = relevantStdoutStderrSummary(cleanedDetails, 
duration);
+
+            if (!Strings.isNullOrEmpty(summary)) {
+                res.append("Relevant stdout/stderr:\n");
+                res.append(summary);
+            }
+
+            String detailsTails = detailsTailsForPrompt(cleanedDetails);
+
+            if (!Strings.isNullOrEmpty(detailsTails)) {
+                res.append("<details>\n");
+                res.append("<summary>Raw TeamCity stdout/stderr tail 
(secondary)</summary>\n\n");
+                res.append("```text\n");
+                res.append(limit(detailsTails, Math.min(maxDetailsChars, 
DETAILS_SECTION_TAIL_CHARS)));
+                res.append("\n```\n");
+                res.append("</details>\n");
+            }
+        }
+    }
+
+    /**
+     * @param res Result builder.
+     * @param suite Suite context.
+     * @param fullName Full TeamCity test name.
+     * @param failuresCnt Failures in loaded context.
+     * @param details Cleaned primary invocation details.
+     * @param duration Primary invocation duration.
+     */
+    private void appendFastSummary(StringBuilder res, MultBuildRunCtx suite, 
@Nullable String fullName, int failuresCnt,
+        @Nullable String details, @Nullable Integer duration) {
+        appendSection(res, Section.FAST_SUMMARY);
+        res.append(fastSummary(suite.suiteName(), suite.branchName(), 
fullName, failuresCnt, details, duration));
+        res.append('\n');
+    }
+
+    /**
+     * @param suiteName Suite name.
+     * @param branch Branch.
+     * @param fullName Full TeamCity test name.
+     * @param failuresCnt Failures in loaded context.
+     * @param details Cleaned primary invocation details.
+     * @param duration Primary invocation duration.
+     * @return Compact diagnosis-first summary.
+     */
+    String fastSummary(@Nullable String suiteName, @Nullable String branch, 
@Nullable String fullName, int failuresCnt,
+        @Nullable String details, @Nullable Integer duration) {
+        StringBuilder res = new StringBuilder();
+        ThrowableSignal throwable = firstThrowableSignal(details);
+        StackFrame nearestFrame = nearestProjectFrame(details, 
simpleClassName(testCasePart(fullName)));
+
+        res.append(failuresCnt <= 1 ? "Single test failure" : failuresCnt + " 
test failures");
+        res.append(" in ").append(nullToUnknown(suiteName));
+        res.append(" on ").append(nullToUnknown(branch)).append(".\n");
+
+        if (duration != null)
+            res.append("Failure happens quickly, within 
~").append(duration).append(" ms.\n");
+
+        if (throwable != null) {
+            res.append("Exception: ").append(throwable.cls);
+
+            if (!Strings.isNullOrEmpty(throwable.msg))
+                res.append(": ").append(throwable.msg);
+
+            res.append(".\n");
+        }
+
+        if (nearestFrame != null)
+            res.append("Nearest project frame: 
").append(nearestFrame.shortLocation()).append(".\n");
+
+        String likelyArea = likelyArea(details, nearestFrame);
+
+        if (!Strings.isNullOrEmpty(likelyArea))
+            res.append("Likely area: ").append(likelyArea).append(".\n");
+
+        return res.toString();
+    }
+
+    /**
+     * @param details Cleaned TeamCity details.
+     * @return First throwable signal.
+     */
+    @Nullable private ThrowableSignal firstThrowableSignal(@Nullable String 
details) {
+        if (Strings.isNullOrEmpty(details))
+            return null;
+
+        Matcher matcher = THROWABLE_SIGNAL.matcher(details);
+
+        if (!matcher.find())
+            return null;
+
+        return new ThrowableSignal(simpleThrowableName(matcher.group(1)), 
normalizeSearchMessage(matcher.group(2)));
+    }
+
+    /**
+     * @param details Cleaned TeamCity details.
+     * @param preferredClass Preferred test class.
+     * @return Nearest project stack frame.
+     */
+    @Nullable private StackFrame nearestProjectFrame(@Nullable String details, 
@Nullable String preferredClass) {
+        if (Strings.isNullOrEmpty(details))
+            return null;
+
+        StackFrame firstIgniteFrame = null;
+
+        for (String line : details.split("\\r?\\n")) {
+            Matcher matcher = STACK_FRAME.matcher(line);
+
+            if (!matcher.find())
+                continue;
+
+            StackFrame frame = new StackFrame(matcher.group(1), 
matcher.group(2), matcher.group(3), matcher.group(4));
+
+            if (!Strings.isNullOrEmpty(preferredClass) && 
frame.owner.contains("." + preferredClass + "."))
+                return frame;
+
+            if (firstIgniteFrame == null && 
frame.owner.startsWith("org.apache.ignite."))
+                firstIgniteFrame = frame;
         }
+
+        return firstIgniteFrame;
+    }
+
+    /**
+     * @param details Cleaned TeamCity details.
+     * @param nearestFrame Nearest project frame.
+     * @return Likely area summary.
+     */
+    @Nullable private String likelyArea(@Nullable String details, @Nullable 
StackFrame nearestFrame) {
+        if (Strings.isNullOrEmpty(details) && nearestFrame == null)
+            return null;
+
+        String lower = Strings.nullToEmpty(details).toLowerCase();
+        List<String> terms = new ArrayList<>();
+
+        if (lower.contains("affinity"))
+            terms.add("affinity");
+        if (lower.contains("topology"))
+            terms.add("topology");
+        if (lower.contains("exchange"))
+            terms.add("exchange");
+        if (lower.contains("partition"))
+            terms.add("partition");
+        if (lower.contains("cache"))
+            terms.add("cache");
+
+        if (nearestFrame != null && 
!Strings.isNullOrEmpty(nearestFrame.method))
+            terms.add("near " + nearestFrame.method);
+
+        return terms.isEmpty() ? null : 
terms.stream().distinct().collect(Collectors.joining("/"));
     }
 
     /**
@@ -401,11 +652,11 @@ public class TestFailuresAiPromptBuilder {
      * @param fullName Full test name.
      */
     private void appendRelevantLogChecks(StringBuilder res, MultBuildRunCtx 
suite, @Nullable String fullName) {
-        List<String> snippets = new ArrayList<>();
+        List<List<String>> relevantBlocks = new ArrayList<>();
+        List<List<String>> otherBlocks = new ArrayList<>();
 
         suite.getLogsCheckResults()
             .flatMap(map -> map.entrySet().stream())
-            .filter(entry -> isSameTestLog(entry.getKey(), fullName))
             .filter(entry -> entry.getValue() != null)
             .forEach(entry -> {
                 ITestLogCheckResult checkRes = entry.getValue();
@@ -413,30 +664,431 @@ public class TestFailuresAiPromptBuilder {
                 if (!checkRes.hasWarns())
                     return;
 
-                snippets.add("Log grep for " + entry.getKey() + " (log size "
-                    + checkRes.getLogSizeBytes() + " bytes):");
+                List<String> block = logCheckBlock(entry);
 
-                snippets.addAll(checkRes.getWarns());
+                if (isSameTestLog(entry.getKey(), fullName))
+                    relevantBlocks.add(block);
+                else
+                    otherBlocks.add(block);
             });
 
-        if (snippets.isEmpty()) {
-            res.append("\n## Log Context\n");
-            res.append("Cached build log analysis has no warning snippets for 
this test. ");
-            res.append("Raw 200-500 line windows around test start/end are not 
available in the current bot cache.\n");
+        if (relevantBlocks.isEmpty() && otherBlocks.isEmpty()) {
+            appendSection(res, Section.LOG_CONTEXT);
+            appendLogAnalysisNote(res, suite);
+
+            return;
+        }
+
+        List<List<String>> allBlocks = new ArrayList<>(relevantBlocks);
+        allBlocks.addAll(otherBlocks);
+
+        appendSection(res, Section.LOG_CONTEXT);
+        if (logBlocksChars(allBlocks) <= LOG_CONTEXT_CHAR_BUDGET) {
+            res.append("Cached build-log scanner messages. Full cached message 
set is included because it is small enough:\n");
+            appendLogBlocks(res, allBlocks);
 
             return;
         }
 
-        res.append("\n## Log Context\n");
-        res.append("Cached log grep from processed build log:\n");
+        if (!relevantBlocks.isEmpty()) {
+            res.append("Cached log grep for the failing test:\n");
+            appendLogBlocks(res, relevantBlocks);
+
+            if (!otherBlocks.isEmpty()) {
+                res.append("\nAdditional cached build-log scanner messages 
near the end of the processed log context");
+                res.append(" (truncated to keep the prompt compact):\n");
+                appendLogBlocks(res, tailLogBlocks(otherBlocks));
+            }
+
+            return;
+        }
+
+        res.append("Cached build-log scanner messages are too large to include 
fully. ");
+        res.append("Showing messages near the end of the processed log 
context:\n");
+        appendLogBlocks(res, tailLogBlocks(allBlocks));
+    }
+
+    /**
+     * @param res Result builder.
+     * @param blocks Log check blocks.
+     */
+    private void appendLogBlocks(StringBuilder res, List<List<String>> blocks) 
{
         res.append("```text\n");
 
-        for (String snippet : snippets)
-            res.append(snippet).append('\n');
+        for (List<String> block : blocks) {
+            for (String line : block)
+                res.append(line).append('\n');
+
+            res.append('\n');
+        }
 
         res.append("```\n");
     }
 
+    /**
+     * @param entry Log check entry.
+     * @return Prompt lines for the entry.
+     */
+    private List<String> logCheckBlock(Map.Entry<String, ITestLogCheckResult> 
entry) {
+        ITestLogCheckResult checkRes = entry.getValue();
+        List<String> block = new ArrayList<>();
+
+        block.add("Log grep for " + entry.getKey() + " (log size " + 
checkRes.getLogSizeBytes() + " bytes):");
+        block.addAll(checkRes.getWarns());
+
+        return block;
+    }
+
+    /**
+     * @param blocks Log check blocks.
+     * @return Approximate char count.
+     */
+    private int logBlocksChars(List<List<String>> blocks) {
+        return blocks.stream()
+            .flatMap(List::stream)
+            .mapToInt(line -> line.length() + 1)
+            .sum();
+    }
+
+    /**
+     * @param blocks Log check blocks.
+     * @return Tail blocks fitting the tail budget.
+     */
+    private List<List<String>> tailLogBlocks(List<List<String>> blocks) {
+        List<List<String>> res = new ArrayList<>();
+        int chars = 0;
+
+        for (int i = blocks.size() - 1; i >= 0; i--) {
+            List<String> block = blocks.get(i);
+            int blockChars = logBlocksChars(Collections.singletonList(block));
+
+            if (!res.isEmpty() && chars + blockChars > 
LOG_CONTEXT_TAIL_CHAR_BUDGET)
+                break;
+
+            res.add(0, block);
+            chars += blockChars;
+        }
+
+        return res;
+    }
+
+    /**
+     * @param res Result builder.
+     * @param suite Suite context.
+     * @param fullName Full TeamCity test name.
+     */
+    private void appendSuggestedLocalSearches(StringBuilder res, 
MultBuildRunCtx suite, @Nullable String fullName,
+        List<ITest> failedInvocations) {
+        Set<String> commands = new LinkedHashSet<>();
+        String testCase = testCasePart(fullName);
+        String className = simpleClassName(testCase);
+        String methodName = methodName(testCase);
+        List<String> details = failedInvocations.stream()
+            .map(ITest::getDetailsText)
+            .filter(detail -> !Strings.isNullOrEmpty(detail))
+            .map(this::cleanDetails)
+            .collect(Collectors.toList());
+        StackFrame nearestFrame = details.stream()
+            .map(detail -> nearestProjectFrame(detail, className))
+            .filter(Objects::nonNull)
+            .findFirst()
+            .orElse(null);
+
+        String firstSearchRegex = joinRegex(methodName, nearestFrame == null ? 
null : nearestFrame.method);
+
+        if (!Strings.isNullOrEmpty(firstSearchRegex))
+            addSearch(commands, firstSearchRegex, "modules");
+
+        if (!Strings.isNullOrEmpty(className))
+            addSearch(commands, escapeRgTerm(className), "modules");
+
+        List<String> warns = suite.getLogsCheckResults()
+            .flatMap(map -> map.entrySet().stream())
+            .filter(entry -> entry.getValue() != null)
+            .flatMap(entry -> entry.getValue().getWarns().stream())
+            .collect(Collectors.toList());
+        List<String> searchSources = new ArrayList<>(warns);
+        searchSources.addAll(details);
+
+        String logRegex = logSearchRegex(searchSources);
+
+        if (!Strings.isNullOrEmpty(logRegex))
+            addSearch(commands, logRegex, "modules");
+
+        stackSearchTerms(details).forEach(term -> addSearch(commands, 
escapeRgTerm(term), searchPathForStackTerm(term)));
+
+        if (!Strings.isNullOrEmpty(className) && warns.stream().anyMatch(warn 
-> warn.contains("AssertionError")))
+            addSearch(commands, escapeRgTerm(className) + "|AssertionError", 
"modules");
+
+        if (commands.isEmpty())
+            return;
+
+        appendSection(res, Section.SUGGESTED_LOCAL_SEARCHES);
+
+        commands.stream().limit(SUGGESTED_SEARCHES_LIMIT).forEach(cmd -> 
res.append("- ").append(cmd).append('\n'));
+    }
+
+    /**
+     * @param commands Commands.
+     * @param regex Search regex.
+     * @param path Search path.
+     */
+    private void addSearch(Set<String> commands, String regex, String path) {
+        commands.add("rg -n \"" + regex.replace("\"", "\\\"") + "\" " + path);
+    }
+
+    /**
+     * @param fullName Full TeamCity test name.
+     * @return Local search regex for test class/method.
+     */
+    @Nullable private String testSearchRegex(@Nullable String fullName) {
+        String testCase = testCasePart(fullName);
+        String className = simpleClassName(testCase);
+        String methodName = methodName(testCase);
+
+        if (!Strings.isNullOrEmpty(methodName) && 
!Strings.isNullOrEmpty(className))
+            return escapeRgTerm(methodName) + "|" + escapeRgTerm(className);
+
+        if (!Strings.isNullOrEmpty(className))
+            return escapeRgTerm(className);
+
+        return null;
+    }
+
+    /**
+     * @param warns Log scanner messages from the current compacted API.
+     * @return Search regex for notable log messages.
+     */
+    @Nullable String logSearchRegex(List<String> warns) {
+        String joined = String.join("\n", warns);
+        List<String> terms = new ArrayList<>();
+
+        terms.addAll(throwableSearchTerms(warns));
+
+        addIfPresent(terms, joined, "SYSTEM_WORKER_TERMINATION");
+        addIfPresent(terms, joined, "Critical system error detected");
+        addIfPresent(terms, joined, "AssertionError");
+        addIfPresent(terms, joined, "OutOfMemoryError");
+        addIfPresent(terms, joined, "Process exited with code");
+
+        return 
terms.stream().distinct().map(this::escapeRgTerm).collect(Collectors.joining("|"));
+    }
+
+    /**
+     * @param terms Plain terms.
+     * @return Regex.
+     */
+    @Nullable private String joinRegex(@Nullable String... terms) {
+        String regex = java.util.Arrays.stream(terms)
+            .filter(term -> !Strings.isNullOrEmpty(term))
+            .distinct()
+            .map(this::escapeRgTerm)
+            .collect(Collectors.joining("|"));
+
+        return Strings.isNullOrEmpty(regex) ? null : regex;
+    }
+
+    /**
+     * @param warns Log scanner messages from the current compacted API.
+     * @return Throwable class names and messages worth searching in the 
checkout.
+     */
+    private List<String> throwableSearchTerms(List<String> warns) {
+        Set<String> terms = new LinkedHashSet<>();
+
+        for (String warn : warns) {
+            if (Strings.isNullOrEmpty(warn))
+                continue;
+
+            for (String line : warn.split("\\r?\\n")) {
+                Matcher matcher = THROWABLE_SIGNAL.matcher(line);
+
+                while (matcher.find()) {
+                    String throwableCls = 
simpleThrowableName(matcher.group(1));
+                    String msg = normalizeSearchMessage(matcher.group(2));
+
+                    if (!Strings.isNullOrEmpty(throwableCls))
+                        terms.add(throwableCls);
+                    if (!Strings.isNullOrEmpty(msg))
+                        terms.add(msg);
+                }
+            }
+        }
+
+        return new ArrayList<>(terms);
+    }
+
+    /**
+     * @param details Cleaned TeamCity details.
+     * @return Stack/search terms likely useful in local checkout.
+     */
+    private List<String> stackSearchTerms(List<String> details) {
+        Set<String> terms = new LinkedHashSet<>();
+
+        for (String detail : details) {
+            if (Strings.isNullOrEmpty(detail))
+                continue;
+
+            for (String line : detail.split("\\r?\\n")) {
+                Matcher matcher = STACK_FRAME.matcher(line);
+
+                if (!matcher.find())
+                    continue;
+
+                String owner = matcher.group(1);
+                String method = matcher.group(2);
+
+                if (isInterestingStackMethod(owner, method))
+                    terms.add(method);
+            }
+        }
+
+        return terms.stream().limit(3).collect(Collectors.toList());
+    }
+
+    /**
+     * @param owner Class owner.
+     * @param method Method.
+     * @return {@code true} if method is useful for search.
+     */
+    private boolean isInterestingStackMethod(String owner, String method) {
+        String lower = owner.toLowerCase() + method.toLowerCase();
+
+        return lower.contains("affinity")
+            || lower.contains("exchange")
+            || lower.contains("topology")
+            || lower.contains("partition")
+            || lower.contains("cache")
+            || method.startsWith("await");
+    }
+
+    /**
+     * @param term Search term.
+     * @return Search path.
+     */
+    private String searchPathForStackTerm(String term) {
+        if (term.startsWith("await"))
+            return "modules/core/src/test modules/core/src/main";
+
+        return "modules/core/src/main modules/core/src/test";
+    }
+
+    /**
+     * @param cls Throwable class name.
+     * @return Simple throwable class name.
+     */
+    @Nullable private String simpleThrowableName(@Nullable String cls) {
+        if (Strings.isNullOrEmpty(cls))
+            return null;
+
+        int idx = cls.lastIndexOf('.');
+
+        return idx >= 0 ? cls.substring(idx + 1) : cls;
+    }
+
+    /**
+     * @param msg Exception/assertion message.
+     * @return Searchable message.
+     */
+    @Nullable private String normalizeSearchMessage(@Nullable String msg) {
+        if (Strings.isNullOrEmpty(msg))
+            return null;
+
+        String normalized = INLINE_STACK_TAIL.matcher(msg).replaceFirst("")
+            .replaceAll("\\s+", " ")
+            .trim();
+
+        return normalized.length() < 5 ? null : normalized;
+    }
+
+    /**
+     * @param terms Terms.
+     * @param text Text.
+     * @param term Term.
+     */
+    private void addIfPresent(List<String> terms, String text, String term) {
+        if (text.contains(term))
+            terms.add(term);
+    }
+
+    /**
+     * @param fullTestName Test name with package/class/method.
+     * @return Simple class name.
+     */
+    @Nullable private String simpleClassName(@Nullable String fullTestName) {
+        if (Strings.isNullOrEmpty(fullTestName))
+            return null;
+
+        int methodSep = fullTestName.lastIndexOf('.');
+
+        if (methodSep < 0)
+            return fullTestName;
+
+        int classSep = fullTestName.lastIndexOf('.', methodSep - 1);
+
+        return fullTestName.substring(classSep + 1, methodSep);
+    }
+
+    /**
+     * @param fullTestName Test name with package/class/method.
+     * @return Method name.
+     */
+    @Nullable private String methodName(@Nullable String fullTestName) {
+        if (Strings.isNullOrEmpty(fullTestName))
+            return null;
+
+        int methodSep = fullTestName.lastIndexOf('.');
+
+        if (methodSep < 0 || methodSep + 1 >= fullTestName.length())
+            return null;
+
+        return fullTestName.substring(methodSep + 1);
+    }
+
+    /**
+     * @param term Plain search term.
+     * @return Regex-safe search term.
+     */
+    private String escapeRgTerm(String term) {
+        StringBuilder res = new StringBuilder();
+
+        for (int i = 0; i < term.length(); i++) {
+            char ch = term.charAt(i);
+
+            if ("\\.^$|?*+()[]{}".indexOf(ch) >= 0)
+                res.append('\\');
+
+            res.append(ch);
+        }
+
+        return res.toString();
+    }
+
+    /**
+     * @param res Result builder.
+     * @param suite Suite context.
+     */
+    private void appendLogAnalysisNote(StringBuilder res, MultBuildRunCtx 
suite) {
+        long pending = suite.pendingLogChecksCount();
+
+        if (pending > 0) {
+            res.append("Build log analysis was requested for this prompt, but 
");
+            res.append(pending).append(" log task(s) did not finish before the 
prompt timeout. ");
+            res.append("The prompt may miss log grep context; retry later to 
use cached results.\n");
+
+            return;
+        }
+
+        if (suite.logChecksStartedCount() > 0) {
+            res.append("Build log analysis completed or failed without 
matching scanner messages for this scope. ");
+            res.append("Raw 200-500 line windows around test start/end are not 
available in the current bot cache.\n");
+
+            return;
+        }
+
+        res.append("Build log analysis was not available for this prompt. ");
+        res.append("Raw 200-500 line windows around test start/end are not 
available in the current bot cache.\n");
+    }
+
     /**
      * @param logTestName Test name from log scanner.
      * @param fullName Full TeamCity test name.
@@ -552,6 +1204,135 @@ public class TestFailuresAiPromptBuilder {
             .replace(" ------- Stderr: ------- ", "\n\n------- Stderr: 
-------\n");
     }
 
+    /**
+     * @param details Cleaned TeamCity details.
+     * @return Short stdout/stderr tails for prompt context.
+     */
+    String detailsTailsForPrompt(String details) {
+        String[] lines = details.split("\\r?\\n");
+        StringBuilder res = new StringBuilder();
+
+        for (int i = 0; i < lines.length; i++) {
+            if (!isDetailsSectionHeader(lines[i]))
+                continue;
+
+            int end = i + 1;
+
+            while (end < lines.length && !isDetailsSectionHeader(lines[end]))
+                end++;
+
+            appendSectionTail(res, lines, i, end);
+        }
+
+        return res.toString().trim();
+    }
+
+    /**
+     * @param details Cleaned TeamCity details.
+     * @param duration Test duration in millis.
+     * @return Compact stdout/stderr interpretation.
+     */
+    String relevantStdoutStderrSummary(String details, @Nullable Integer 
duration) {
+        List<String> bullets = new ArrayList<>();
+
+        if (duration != null)
+            bullets.add("Test started and failed within ~" + duration + " 
ms.");
+
+        String location = firstJavaSourceLocation(details);
+
+        if (!Strings.isNullOrEmpty(location)) {
+            if (details.contains("Assertion"))
+                bullets.add("Assertion at " + location + ".");
+            else
+                bullets.add("Failure stack points to " + location + ".");
+        }
+
+        if (igniteNodeStartedAndStopped(details))
+            bullets.add("Ignite node started and stopped normally.");
+
+        if (!hasTimeoutOrCrashSignal(details))
+            bullets.add("No timeout or crash signal found in included 
details.");
+
+        return bullets.stream().map(bullet -> "- " + bullet + 
'\n').collect(Collectors.joining());
+    }
+
+    /**
+     * @param details Details.
+     * @return First Java source location.
+     */
+    @Nullable private String firstJavaSourceLocation(String details) {
+        Matcher matcher = JAVA_SOURCE_LOCATION.matcher(details);
+
+        return matcher.find() ? matcher.group(1) + ":" + matcher.group(2) : 
null;
+    }
+
+    /**
+     * @param details Details.
+     * @return {@code true} if stdout/stderr contains a normal Ignite 
lifecycle signal.
+     */
+    private boolean igniteNodeStartedAndStopped(String details) {
+        String lower = details.toLowerCase();
+
+        boolean started = lower.contains("ignite node started")
+            || lower.contains("topology snapshot")
+            || lower.contains(">>> started cache");
+
+        boolean stopped = lower.contains("ignite node stopped")
+            || lower.contains("stopping grid")
+            || lower.contains("grid stopped")
+            || lower.contains("ignite instance stopped");
+
+        return started && stopped;
+    }
+
+    /**
+     * @param details Details.
+     * @return {@code true} if details contains timeout/crash/process-failure 
signal.
+     */
+    private boolean hasTimeoutOrCrashSignal(String details) {
+        String lower = details.toLowerCase();
+
+        return lower.contains("timeout")
+            || lower.contains("timed out")
+            || lower.contains("outofmemoryerror")
+            || lower.contains("hs_err")
+            || lower.contains("jvm crash")
+            || lower.contains("process exited")
+            || lower.contains("exit code");
+    }
+
+    /**
+     * @param line Details line.
+     * @return {@code true} if line starts a stdout/stderr details section.
+     */
+    private boolean isDetailsSectionHeader(String line) {
+        return "------- Stdout: -------".equals(line) || "------- Stderr: 
-------".equals(line);
+    }
+
+    /**
+     * @param res Result.
+     * @param lines Details lines.
+     * @param headerIdx Header line index.
+     * @param end Exclusive section end.
+     */
+    private void appendSectionTail(StringBuilder res, String[] lines, int 
headerIdx, int end) {
+        int start = Math.max(headerIdx + 1, end - DETAILS_SECTION_TAIL_LINES);
+
+        if (start >= end)
+            return;
+
+        if (res.length() > 0)
+            res.append('\n');
+
+        res.append(lines[headerIdx]).append('\n');
+
+        if (start > headerIdx + 1)
+            res.append("... skipped ").append(start - headerIdx - 1).append(" 
earlier lines ...\n");
+
+        for (int i = start; i < end; i++)
+            res.append(lines[i]).append('\n');
+    }
+
     /**
      * @param details Details from TeamCity.
      */
@@ -618,15 +1399,17 @@ public class TestFailuresAiPromptBuilder {
     /**
      * @param res Result builder.
      */
-    private void appendInvestigationInstructions(StringBuilder res) {
-        res.append("\n## Investigation Instructions\n");
+    private void appendInvestigationInstructions(StringBuilder res, @Nullable 
String masterCauseInstruction) {
+        appendSection(res, Section.INVESTIGATION_INSTRUCTIONS);
         res.append("- Start from the First real failure signal and nearest 
project frame.\n");
         res.append("- Search for the failing class, method, assertion text, 
and error message in the checkout.\n");
         res.append("- Compare test history and suite history separately before 
calling it flaky.\n");
+        if (!Strings.isNullOrEmpty(masterCauseInstruction))
+            res.append("- ").append(masterCauseInstruction).append('\n');
         res.append("- Classify the cause as exactly one of: caused by current 
change; pre-existing flaky test; ");
         res.append("environmental/infra; product bug exposed by test; 
inconclusive.\n");
 
-        res.append("\n## Required Final Answer\n");
+        appendSection(res, Section.REQUIRED_FINAL_ANSWER);
         res.append("- Likely root cause.\n");
         res.append("- Confidence level.\n");
         res.append("- Files/classes/methods to inspect.\n");
@@ -689,6 +1472,40 @@ public class TestFailuresAiPromptBuilder {
      * @param hist Run history.
      */
     @Nullable private String breakBoundary(IRunHistory hist) {
+        BuildBoundary boundary = findBreakBoundary(hist);
+
+        if (boundary == null)
+            return null;
+
+        return "last OK build " + boundary.lastOk.buildId()
+            + ", first later failure build " + 
boundary.firstBadAfterOk.buildId()
+            + ", change state at first failure " + 
boundary.firstBadAfterOk.changesState();
+    }
+
+    /**
+     * @param branch Branch.
+     * @param hist Run history.
+     * @return Master branch cause interpretation instruction.
+     */
+    @Nullable private String masterChangeCauseInstruction(@Nullable String 
branch, @Nullable IRunHistory hist) {
+        if (hist == null || !"master".equals(normalizeBranch(branch)))
+            return null;
+
+        BuildBoundary boundary = findBreakBoundary(hist);
+
+        if (boundary == null)
+            return null;
+
+        return "For master builds, interpret \"caused by current change\" as 
caused by a recent master change " +
+            "between last OK build " + boundary.lastOk.buildId() + " and first 
failing build " +
+            boundary.firstBadAfterOk.buildId() + ".";
+    }
+
+    /**
+     * @param hist Run history.
+     * @return Break boundary.
+     */
+    @Nullable private BuildBoundary findBreakBoundary(IRunHistory hist) {
         List<Invocation> invocations = hist.getInvocations()
             .filter(inv -> inv.status() != InvocationData.MISSING)
             .filter(inv -> !Invocation.isMutedOrIgnored(inv.status()))
@@ -710,9 +1527,7 @@ public class TestFailuresAiPromptBuilder {
         if (lastOk == null || firstBadAfterOk == null)
             return null;
 
-        return "last OK build " + lastOk.buildId()
-            + ", first later failure build " + firstBadAfterOk.buildId()
-            + ", change state at first failure " + 
firstBadAfterOk.changesState();
+        return new BuildBoundary(lastOk, firstBadAfterOk);
     }
 
     /**
@@ -766,4 +1581,77 @@ public class TestFailuresAiPromptBuilder {
 
         return String.valueOf(status);
     }
+
+    /** Break boundary. */
+    private static class BuildBoundary {
+        /** Last successful run before break. */
+        private final Invocation lastOk;
+
+        /** First failing run after success. */
+        private final Invocation firstBadAfterOk;
+
+        /**
+         * @param lastOk Last successful run before break.
+         * @param firstBadAfterOk First failing run after success.
+         */
+        private BuildBoundary(Invocation lastOk, Invocation firstBadAfterOk) {
+            this.lastOk = lastOk;
+            this.firstBadAfterOk = firstBadAfterOk;
+        }
+    }
+
+    /** Throwable search signal. */
+    private static class ThrowableSignal {
+        /** Throwable class. */
+        private final String cls;
+
+        /** Throwable message. */
+        private final String msg;
+
+        /**
+         * @param cls Throwable class.
+         * @param msg Throwable message.
+         */
+        private ThrowableSignal(@Nullable String cls, @Nullable String msg) {
+            this.cls = Strings.nullToEmpty(cls);
+            this.msg = msg;
+        }
+    }
+
+    /** Stack frame signal. */
+    private static class StackFrame {
+        /** Owner prefix ending with dot. */
+        private final String owner;
+
+        /** Method. */
+        private final String method;
+
+        /** File. */
+        private final String file;
+
+        /** Line. */
+        private final String line;
+
+        /**
+         * @param owner Owner.
+         * @param method Method.
+         * @param file File.
+         * @param line Line.
+         */
+        private StackFrame(String owner, String method, String file, String 
line) {
+            this.owner = owner;
+            this.method = method;
+            this.file = file;
+            this.line = line;
+        }
+
+        /** @return Short location. */
+        private String shortLocation() {
+            String ownerNoDot = owner.endsWith(".") ? owner.substring(0, 
owner.length() - 1) : owner;
+            int clsSep = ownerNoDot.lastIndexOf('.');
+            String cls = clsSep >= 0 ? ownerNoDot.substring(clsSep + 1) : 
ownerNoDot;
+
+            return cls + "." + method + ":" + line;
+        }
+    }
 }
diff --git 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/FullChainRunCtx.java
 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/FullChainRunCtx.java
index e9fb23ae..4b95a503 100644
--- 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/FullChainRunCtx.java
+++ 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/FullChainRunCtx.java
@@ -21,6 +21,7 @@ import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
 import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
 import java.util.function.Predicate;
 import java.util.stream.Stream;
 import org.apache.ignite.tcservice.model.result.Build;
@@ -108,6 +109,27 @@ public class FullChainRunCtx {
         this.buildCfgsResults.addAll(suites);
     }
 
+    /** @return Count of build log analyses requested for this chain context. 
*/
+    public long logChecksStartedCount() {
+        return 
suites().mapToLong(MultBuildRunCtx::logChecksStartedCount).sum();
+    }
+
+    /** @return Count of build log analyses still running for this chain 
context. */
+    public long pendingLogChecksCount() {
+        return 
suites().mapToLong(MultBuildRunCtx::pendingLogChecksCount).sum();
+    }
+
+    /**
+     * Waits for build log analyses until timeout expires.
+     *
+     * @param timeoutMs Timeout in milliseconds.
+     */
+    public void awaitLogChecks(long timeoutMs) {
+        long deadlineNanos = System.nanoTime() + 
TimeUnit.MILLISECONDS.toNanos(timeoutMs);
+
+        suites().forEach(suite -> suite.awaitLogChecksUntil(deadlineNanos));
+    }
+
     public boolean isFakeStub() {
         return fakeStub;
     }
diff --git 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/MultBuildRunCtx.java
 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/MultBuildRunCtx.java
index 46c1d34e..590468bb 100644
--- 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/MultBuildRunCtx.java
+++ 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/MultBuildRunCtx.java
@@ -31,6 +31,7 @@ import java.util.Set;
 import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.Predicate;
 import java.util.stream.Collectors;
@@ -110,6 +111,35 @@ public class MultBuildRunCtx implements ISuiteResults {
         return 
buildsStream().map(SingleBuildRunCtx::getTestLogCheckResult).filter(Objects::nonNull);
     }
 
+    /** @return Count of build log analyses requested for this suite. */
+    public long logChecksStartedCount() {
+        return 
buildsStream().filter(SingleBuildRunCtx::hasLogCheckStarted).count();
+    }
+
+    /** @return Count of build log analyses still running for this suite. */
+    public long pendingLogChecksCount() {
+        return 
buildsStream().filter(SingleBuildRunCtx::hasPendingLogCheck).count();
+    }
+
+    /**
+     * Waits for suite build log analyses until the shared deadline.
+     *
+     * @param deadlineNanos Absolute {@link System#nanoTime()} deadline.
+     */
+    public void awaitLogChecksUntil(long deadlineNanos) {
+        for (SingleBuildRunCtx build : builds) {
+            if (!build.hasPendingLogCheck())
+                continue;
+
+            long remainingMs = TimeUnit.NANOSECONDS.toMillis(deadlineNanos - 
System.nanoTime());
+
+            if (remainingMs <= 0)
+                return;
+
+            build.awaitLogCheck(remainingMs);
+        }
+    }
+
     /** {@inheritDoc} */
     @Override public String suiteId() {
         return firstBuild().map(SingleBuildRunCtx::suiteId).orElse(null);
diff --git 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/SingleBuildRunCtx.java
 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/SingleBuildRunCtx.java
index d0634103..79a12eeb 100644
--- 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/SingleBuildRunCtx.java
+++ 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/chain/SingleBuildRunCtx.java
@@ -29,6 +29,8 @@ import java.util.Objects;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 import java.util.regex.Pattern;
 import java.util.stream.Stream;
 import javax.annotation.Nonnull;
@@ -134,6 +136,44 @@ public class SingleBuildRunCtx implements ISuiteResults {
         this.logCheckResFut = logCheckResFut;
     }
 
+    /** @return {@code True} if build log analysis was requested for this 
build. */
+    public boolean hasLogCheckStarted() {
+        return logCheckResFut != null;
+    }
+
+    /** @return {@code True} if build log analysis is still running. */
+    public boolean hasPendingLogCheck() {
+        return logCheckResFut != null && !logCheckResFut.isDone() && 
!logCheckResFut.isCancelled();
+    }
+
+    /**
+     * Waits for build log analysis to finish.
+     *
+     * @param timeoutMs Timeout in milliseconds.
+     * @return {@code True} if no log analysis was requested or it finished 
before the timeout.
+     */
+    public boolean awaitLogCheck(long timeoutMs) {
+        if (logCheckResFut == null || logCheckResFut.isDone() || 
logCheckResFut.isCancelled())
+            return true;
+
+        try {
+            logCheckResFut.get(Math.max(timeoutMs, 1), TimeUnit.MILLISECONDS);
+
+            return true;
+        }
+        catch (TimeoutException e) {
+            return false;
+        }
+        catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+
+            return false;
+        }
+        catch (Exception e) {
+            return true;
+        }
+    }
+
     @Nullable public String getCriticalFailLastStartedTest() {
         ILogCheckResult logCheckRes = getLogCheckIfFinished();
         if (logCheckRes == null)
diff --git 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/pr/PrChainsProcessor.java
 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/pr/PrChainsProcessor.java
index 4b524dea..dd574b9a 100644
--- 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/pr/PrChainsProcessor.java
+++ 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/pr/PrChainsProcessor.java
@@ -71,6 +71,12 @@ import org.apache.ignite.tcservice.ITeamcity;
  * Process pull request/untracked branch chain at particular server.
  */
 public class PrChainsProcessor {
+    /** Max time to wait for fresh AI prompt build context. */
+    private static final long AI_PROMPT_CONTEXT_WAIT_MS = 
TimeUnit.MINUTES.toMillis(1);
+
+    /** Max time to wait for AI prompt build log processing. */
+    private static final long AI_PROMPT_LOG_WAIT_MS = 
TimeUnit.MINUTES.toMillis(1);
+
     private static class Action {
         public static final String HISTORY = "History";
         public static final String LATEST = "Latest";
@@ -454,6 +460,7 @@ public class PrChainsProcessor {
      * @param maxDetailsChars Max chars to include for every test details 
block. Non-positive means default cap.
      * @param testName Optional full test name filter.
      * @param promptSuiteId Optional suite id filter.
+     * @param waitForTc Wait for fresh TeamCity context and build log 
processing.
      * @return AI prompt with PR failure context.
      */
     @AutoProfiling
@@ -467,7 +474,8 @@ public class PrChainsProcessor {
         @Nullable String tcBaseBranchParm,
         int maxDetailsChars,
         @Nullable String testName,
-        @Nullable String promptSuiteId) {
+        @Nullable String promptSuiteId,
+        boolean waitForTc) {
         long reqId = aiPromptMonitor.start("pr", branchForTc, srvCodeOrAlias, 
suiteId, testName);
 
         try {
@@ -494,7 +502,10 @@ public class PrChainsProcessor {
             aiPromptMonitor.stage(reqId, "loading chain context: " + 
srvCodeOrAlias + "/" + suiteId);
 
             FullChainRunCtx ctx = loadAiPromptContextBestEffort(reqId, 
tcIgnited, hist, rebuild,
-                buildResMergeCnt == 1, baseBranchForTc, srvCodeOrAlias + "/" + 
suiteId);
+                buildResMergeCnt == 1, baseBranchForTc, srvCodeOrAlias + "/" + 
suiteId, waitForTc);
+
+            if (waitForTc)
+                waitForAiPromptLogs(reqId, ctx, srvCodeOrAlias + "/" + 
suiteId);
 
             aiPromptMonitor.stage(reqId, "building prompt: " + srvCodeOrAlias 
+ "/" + suiteId);
 
@@ -521,6 +532,7 @@ public class PrChainsProcessor {
      * @param includeScheduledInfo Include scheduled info.
      * @param baseBranchForTc Base branch.
      * @param stageSuffix Stage suffix.
+     * @param waitForTc Wait for fresh TeamCity context and build log 
processing.
      */
     private FullChainRunCtx loadAiPromptContextBestEffort(
         long reqId,
@@ -529,29 +541,46 @@ public class PrChainsProcessor {
         LatestRebuildMode rebuild,
         boolean includeScheduledInfo,
         String baseBranchForTc,
-        String stageSuffix) {
+        String stageSuffix,
+        boolean waitForTc) {
+        if (!waitForTc) {
+            aiPromptMonitor.stage(reqId, "using cached context without waiting 
for TeamCity: " + stageSuffix);
+
+            return buildChainProcessor.loadFullChainContext(
+                tcIgnited,
+                hist,
+                LatestRebuildMode.NONE,
+                ProcessLogsMode.CACHED_ONLY,
+                false,
+                baseBranchForTc,
+                SyncMode.NONE,
+                null, null);
+        }
+
         Future<FullChainRunCtx> live = null;
 
         try {
-            aiPromptMonitor.stage(reqId, "trying fresh context for up to 1s: " 
+ stageSuffix);
+            aiPromptMonitor.stage(reqId, "loading fresh build chain from 
TeamCity for up to "
+                + TimeUnit.MILLISECONDS.toSeconds(AI_PROMPT_CONTEXT_WAIT_MS) + 
"s: " + stageSuffix);
 
             live = tcUpdatePool.getService().submit(() -> 
buildChainProcessor.loadFullChainContext(
                 tcIgnited,
                 hist,
                 rebuild,
-                ProcessLogsMode.CACHED_ONLY,
+                ProcessLogsMode.ALL,
                 includeScheduledInfo,
                 baseBranchForTc,
                 SyncMode.RELOAD_QUEUED,
                 null, null));
 
-            return live.get(1, TimeUnit.SECONDS);
+            return live.get(AI_PROMPT_CONTEXT_WAIT_MS, TimeUnit.MILLISECONDS);
         }
         catch (TimeoutException e) {
             if (live != null)
                 live.cancel(true);
 
-            aiPromptMonitor.stage(reqId, "fresh context timed out, using stale 
cache: " + stageSuffix);
+            aiPromptMonitor.stage(reqId, "fresh TeamCity reload timed out, 
loading best-effort cached context: "
+                + stageSuffix);
         }
         catch (InterruptedException e) {
             if (live != null)
@@ -564,17 +593,43 @@ public class PrChainsProcessor {
             throw new IllegalStateException("Interrupted while loading fresh 
TeamCity context: " + stageSuffix, e);
         }
         catch (Exception e) {
-            aiPromptMonitor.stage(reqId, "fresh context failed, using stale 
cache: " + stageSuffix + " - " + e.getMessage());
+            aiPromptMonitor.stage(reqId, "fresh TeamCity reload failed, 
loading best-effort cached context: "
+                + stageSuffix + " - " + e.getMessage());
         }
 
         return buildChainProcessor.loadFullChainContext(
             tcIgnited,
             hist,
-            rebuild,
+            LatestRebuildMode.NONE,
             ProcessLogsMode.CACHED_ONLY,
             false,
             baseBranchForTc,
             SyncMode.NONE,
             null, null);
     }
+
+    /**
+     * @param reqId AI prompt request id.
+     * @param ctx Chain context.
+     * @param stageSuffix Stage suffix.
+     */
+    private void waitForAiPromptLogs(long reqId, FullChainRunCtx ctx, String 
stageSuffix) {
+        long started = ctx.logChecksStartedCount();
+
+        if (started == 0)
+            return;
+
+        aiPromptMonitor.stage(reqId, "processing build logs: " + started + " 
task(s), waiting up to "
+            + TimeUnit.MILLISECONDS.toSeconds(AI_PROMPT_LOG_WAIT_MS) + "s: " + 
stageSuffix);
+
+        ctx.awaitLogChecks(AI_PROMPT_LOG_WAIT_MS);
+
+        long pending = ctx.pendingLogChecksCount();
+
+        if (pending > 0)
+            aiPromptMonitor.stage(reqId, "build log processing timeout: " + 
pending
+                + " task(s) still running: " + stageSuffix);
+        else
+            aiPromptMonitor.stage(reqId, "build log processing finished: " + 
stageSuffix);
+    }
 }
diff --git 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/tracked/TrackedBranchChainsProcessor.java
 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/tracked/TrackedBranchChainsProcessor.java
index db5633d9..35963682 100644
--- 
a/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/tracked/TrackedBranchChainsProcessor.java
+++ 
b/tcbot-engine/src/main/java/org/apache/ignite/tcbot/engine/tracked/TrackedBranchChainsProcessor.java
@@ -76,6 +76,12 @@ public class TrackedBranchChainsProcessor implements 
IDetailedStatusForTrackedBr
     private static final long SLOW_TRACKED_BRANCH_WARN_MS =
         Long.getLong("tcbot.tracked.slowOperationWarnMs", 1000L);
 
+    /** Max time to wait for fresh AI prompt build context. */
+    private static final long AI_PROMPT_CONTEXT_WAIT_MS = 
TimeUnit.MINUTES.toMillis(1);
+
+    /** Max time to wait for AI prompt build log processing. */
+    private static final long AI_PROMPT_LOG_WAIT_MS = 
TimeUnit.MINUTES.toMillis(1);
+
     /** TC ignited server provider. */
     @Inject private ITeamcityIgnitedProvider tcIgnitedProv;
 
@@ -109,6 +115,7 @@ public class TrackedBranchChainsProcessor implements 
IDetailedStatusForTrackedBr
      * @param maxDetailsChars Max chars to include for every test details 
block. Non-positive means default cap.
      * @param testName Full test name to include.
      * @param suiteId Suite id to include.
+     * @param waitForTc Wait for fresh TeamCity context and build log 
processing.
      */
     @Nonnull public String getTrackedBranchFailuresAiPrompt(
         @Nullable String branch,
@@ -119,7 +126,8 @@ public class TrackedBranchChainsProcessor implements 
IDetailedStatusForTrackedBr
         @Nullable SortOption sortOption,
         @Nullable Integer maxDetailsChars,
         @Nullable String testName,
-        @Nullable String suiteId) {
+        @Nullable String suiteId,
+        boolean waitForTc) {
         long reqId = aiPromptMonitor.start("trackedBranch", branch, null, 
null, testName);
         StringBuilder res = new StringBuilder();
 
@@ -154,7 +162,10 @@ public class TrackedBranchChainsProcessor implements 
IDetailedStatusForTrackedBr
 
                     FullChainRunCtx ctx = loadAiPromptContextBestEffort(reqId, 
tcIgnited, chains, rebuild,
                         buildResMergeCnt == 1, baseBranchTc, syncMode, 
sortOption, requireParamVal,
-                        srvCodeOrAlias + "/" + suiteIdMandatory);
+                        srvCodeOrAlias + "/" + suiteIdMandatory, waitForTc);
+
+                    if (waitForTc)
+                        waitForAiPromptLogs(reqId, ctx, srvCodeOrAlias + "/" + 
suiteIdMandatory);
 
                     if (res.length() > 0)
                         res.append("\n\n");
@@ -187,6 +198,7 @@ public class TrackedBranchChainsProcessor implements 
IDetailedStatusForTrackedBr
      * @param sortOption Sort option.
      * @param requireParamVal Required parameter values.
      * @param stageSuffix Stage suffix.
+     * @param waitForTc Wait for fresh TeamCity context and build log 
processing.
      */
     private FullChainRunCtx loadAiPromptContextBestEffort(
         long reqId,
@@ -198,17 +210,35 @@ public class TrackedBranchChainsProcessor implements 
IDetailedStatusForTrackedBr
         SyncMode liveSyncMode,
         @Nullable SortOption sortOption,
         @Nullable Map<Integer, Integer> requireParamVal,
-        String stageSuffix) {
+        String stageSuffix,
+        boolean waitForTc) {
+        if (!waitForTc) {
+            aiPromptMonitor.stage(reqId, "using cached context without waiting 
for TeamCity: " + stageSuffix);
+
+            return chainProc.loadFullChainContext(
+                tcIgnited,
+                chains,
+                LatestRebuildMode.NONE,
+                ProcessLogsMode.CACHED_ONLY,
+                false,
+                baseBranchTc,
+                SyncMode.NONE,
+                sortOption,
+                requireParamVal
+            );
+        }
+
         Future<FullChainRunCtx> live = null;
 
         try {
-            aiPromptMonitor.stage(reqId, "trying fresh context for up to 1s: " 
+ stageSuffix);
+            aiPromptMonitor.stage(reqId, "loading fresh build chain from 
TeamCity for up to "
+                + TimeUnit.MILLISECONDS.toSeconds(AI_PROMPT_CONTEXT_WAIT_MS) + 
"s: " + stageSuffix);
 
             live = tcUpdatePool.getService().submit(() -> 
chainProc.loadFullChainContext(
                 tcIgnited,
                 chains,
                 rebuild,
-                ProcessLogsMode.CACHED_ONLY,
+                ProcessLogsMode.ALL,
                 includeScheduledInfo,
                 baseBranchTc,
                 liveSyncMode,
@@ -216,13 +246,14 @@ public class TrackedBranchChainsProcessor implements 
IDetailedStatusForTrackedBr
                 requireParamVal
             ));
 
-            return live.get(1, TimeUnit.SECONDS);
+            return live.get(AI_PROMPT_CONTEXT_WAIT_MS, TimeUnit.MILLISECONDS);
         }
         catch (TimeoutException e) {
             if (live != null)
                 live.cancel(true);
 
-            aiPromptMonitor.stage(reqId, "fresh context timed out, using stale 
cache: " + stageSuffix);
+            aiPromptMonitor.stage(reqId, "fresh TeamCity reload timed out, 
loading best-effort cached context: "
+                + stageSuffix);
         }
         catch (InterruptedException e) {
             if (live != null)
@@ -235,13 +266,14 @@ public class TrackedBranchChainsProcessor implements 
IDetailedStatusForTrackedBr
             throw new IllegalStateException("Interrupted while loading fresh 
TeamCity context: " + stageSuffix, e);
         }
         catch (Exception e) {
-            aiPromptMonitor.stage(reqId, "fresh context failed, using stale 
cache: " + stageSuffix + " - " + e.getMessage());
+            aiPromptMonitor.stage(reqId, "fresh TeamCity reload failed, 
loading best-effort cached context: "
+                + stageSuffix + " - " + e.getMessage());
         }
 
         return chainProc.loadFullChainContext(
             tcIgnited,
             chains,
-            rebuild,
+            LatestRebuildMode.NONE,
             ProcessLogsMode.CACHED_ONLY,
             false,
             baseBranchTc,
@@ -251,6 +283,31 @@ public class TrackedBranchChainsProcessor implements 
IDetailedStatusForTrackedBr
         );
     }
 
+    /**
+     * @param reqId AI prompt request id.
+     * @param ctx Chain context.
+     * @param stageSuffix Stage suffix.
+     */
+    private void waitForAiPromptLogs(long reqId, FullChainRunCtx ctx, String 
stageSuffix) {
+        long started = ctx.logChecksStartedCount();
+
+        if (started == 0)
+            return;
+
+        aiPromptMonitor.stage(reqId, "processing build logs: " + started + " 
task(s), waiting up to "
+            + TimeUnit.MILLISECONDS.toSeconds(AI_PROMPT_LOG_WAIT_MS) + "s: " + 
stageSuffix);
+
+        ctx.awaitLogChecks(AI_PROMPT_LOG_WAIT_MS);
+
+        long pending = ctx.pendingLogChecksCount();
+
+        if (pending > 0)
+            aiPromptMonitor.stage(reqId, "build log processing timeout: " + 
pending
+                + " task(s) still running: " + stageSuffix);
+        else
+            aiPromptMonitor.stage(reqId, "build log processing finished: " + 
stageSuffix);
+    }
+
     /** {@inheritDoc} */
     @AutoProfiling
     @Nonnull
diff --git 
a/tcbot-engine/src/test/java/org/apache/ignite/tcbot/engine/build/TestFailuresAiPromptBuilderTest.java
 
b/tcbot-engine/src/test/java/org/apache/ignite/tcbot/engine/build/TestFailuresAiPromptBuilderTest.java
index 33793fa5..95b14871 100644
--- 
a/tcbot-engine/src/test/java/org/apache/ignite/tcbot/engine/build/TestFailuresAiPromptBuilderTest.java
+++ 
b/tcbot-engine/src/test/java/org/apache/ignite/tcbot/engine/build/TestFailuresAiPromptBuilderTest.java
@@ -17,9 +17,12 @@
 
 package org.apache.ignite.tcbot.engine.build;
 
+import java.util.Arrays;
 import org.junit.Test;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
 
 public class TestFailuresAiPromptBuilderTest {
     @Test
@@ -34,4 +37,100 @@ public class TestFailuresAiPromptBuilderTest {
             
TestFailuresAiPromptBuilder.restMaxDetailsChars(TestFailuresAiPromptBuilder.DFLT_MAX_DETAILS_CHARS
 + 1));
         assertEquals(1024, 
TestFailuresAiPromptBuilder.restMaxDetailsChars(1024));
     }
+
+    @Test
+    public void logSearchRegexIncludesExceptionAndAssertionMessages() {
+        TestFailuresAiPromptBuilder builder = new 
TestFailuresAiPromptBuilder(null);
+
+        String regex = builder.logSearchRegex(Arrays.asList(
+            "java.lang.IllegalStateException: Node left topology unexpectedly 
at " +
+                "org.apache.ignite.internal.Test.test(Test.java:42)",
+            "Test failed. junit.framework.AssertionFailedError: expected cache 
size to be 42 at " +
+                "junit.framework.Assert.fail(Assert.java:57)",
+            "java.lang.AssertionError: Partition did not rebalance",
+            "Caused by: org.apache.ignite.IgniteCheckedException",
+            "java.lang.OutOfMemoryError",
+            "ava.lang.IllegalStateException: Getting affinity for too old 
topology version that is already out of " +
+                "history (try to increase 'IGNITE_AFFINITY_HISTORY_SIZE' 
system property)"
+        ));
+
+        assertTrue(regex.contains("IllegalStateException"));
+        assertTrue(regex.contains("Node left topology unexpectedly"));
+        assertTrue(regex.contains("AssertionFailedError"));
+        assertTrue(regex.contains("expected cache size to be 42"));
+        assertTrue(regex.contains("Partition did not rebalance"));
+        assertTrue(regex.contains("IgniteCheckedException"));
+        assertTrue(regex.contains("OutOfMemoryError"));
+        assertTrue(regex.contains("AssertionError"));
+        assertTrue(regex.contains("Getting affinity for too old topology 
version"));
+        assertTrue(regex.contains("IGNITE_AFFINITY_HISTORY_SIZE"));
+        assertFalse(regex.contains("Test\\.java:42"));
+    }
+
+    @Test
+    public void fastSummaryExtractsThrowableAndNearestProjectFrame() {
+        TestFailuresAiPromptBuilder builder = new 
TestFailuresAiPromptBuilder(null);
+        String details = "java.lang.IllegalStateException: Getting affinity 
for too old topology version " +
+            "that is already out of history\n" +
+            "    at 
org.apache.ignite.internal.processors.cache.GridCacheAffinityManager.cachedAffinity"
 +
+            "(GridCacheAffinityManager.java:188)\n" +
+            "    at 
org.apache.ignite.internal.processors.cache.CacheEventWithTxLabelTest.prepareCache"
 +
+            "(CacheEventWithTxLabelTest.java:402)\n";
+
+        String summary = builder.fastSummary("Cache 4", "master",
+            "Cache 4: 
org.apache.ignite.internal.processors.cache.CacheEventWithTxLabelTest.testPassTxLabelInCashEventForAllCases",
+            1, details, 341);
+
+        assertTrue(summary.contains("Single test failure in Cache 4 on 
master."));
+        assertTrue(summary.contains("within ~341 ms"));
+        assertTrue(summary.contains("Exception: IllegalStateException: Getting 
affinity for too old topology version"));
+        assertTrue(summary.contains("Nearest project frame: 
CacheEventWithTxLabelTest.prepareCache:402."));
+        assertTrue(summary.contains("Likely area: affinity/topology/cache/near 
prepareCache."));
+    }
+
+    @Test
+    public void detailsTailsForPromptKeepsOnlyStdoutAndStderrTails() {
+        TestFailuresAiPromptBuilder builder = new 
TestFailuresAiPromptBuilder(null);
+        StringBuilder details = new StringBuilder();
+
+        details.append("junit.framework.AssertionFailedError: expected:<11> 
but was:<12>\n");
+        details.append("    at org.apache.ignite.Test.test(Test.java:42)\n");
+        details.append("------- Stdout: -------\n");
+
+        for (int i = 0; i < 30; i++)
+            details.append("stdout line ").append(i).append('\n');
+
+        details.append("------- Stderr: -------\n");
+
+        for (int i = 0; i < 3; i++)
+            details.append("stderr line ").append(i).append('\n');
+
+        String tails = builder.detailsTailsForPrompt(details.toString());
+
+        assertFalse(tails.contains("expected:<11> but was:<12>"));
+        assertFalse(tails.contains("stdout line 0"));
+        assertTrue(tails.contains("... skipped 5 earlier lines ..."));
+        assertTrue(tails.contains("stdout line 5"));
+        assertTrue(tails.contains("stdout line 29"));
+        assertTrue(tails.contains("stderr line 0"));
+        assertTrue(tails.contains("stderr line 2"));
+    }
+
+    @Test
+    public void relevantStdoutStderrSummaryKeepsHighValueSignals() {
+        TestFailuresAiPromptBuilder builder = new 
TestFailuresAiPromptBuilder(null);
+        String details = "junit.framework.AssertionFailedError: expected:<11> 
but was:<12>\n" +
+            "    at 
org.apache.ignite.internal.WalDeletionArchiveAbstractTest.test(WalDeletionArchiveAbstractTest.java:212)\n"
 +
+            "------- Stdout: -------\n" +
+            "Topology snapshot [ver=1, locNode=abc]\n" +
+            "Grid stopped.\n";
+
+        String summary = builder.relevantStdoutStderrSummary(details, 341);
+
+        assertTrue(summary.contains("Test started and failed within ~341 
ms."));
+        assertTrue(summary.contains("Assertion at 
WalDeletionArchiveAbstractTest.java:212."));
+        assertTrue(summary.contains("Ignite node started and stopped 
normally."));
+        assertTrue(summary.contains("No timeout or crash signal found in 
included details."));
+        assertFalse(summary.contains("expected:<11> but was:<12>"));
+    }
 }

Reply via email to