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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions .firebender/plans/trail_prerequisites_feature_6230e625.plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<!--firebender-plan
name: Trail Prerequisites Feature
overview: Ensure prerequisites are visible in config UI and actually execute from both MCP and Desktop UI run paths.
todos:
- id: confirm-server-path
content: "Keep MCP `trail(action=RUN)` prerequisite auto-execution behavior in TrailMcpTool.handleRun()"
status: completed
- id: desktop-root-cause
content: "Document Desktop UI run-path gap: DesktopYamlRunner/TrailblazeHostYamlRunner bypass TrailMcpTool prerequisite executor"
status: completed
- id: flatten-helper
content: "Add and validate TrailFileManager.flattenPrerequisites(yamlContent, variantFilePath) with cycle detection"
status: completed
- id: wire-trails-tab
content: "Call flattenPrerequisites before requestFactory.create(...) in TrailsBrowserTabComposable"
status: completed
- id: wire-yaml-tab
content: "Call flattenPrerequisites before run in YamlTabComposables"
status: completed
- id: add-session-logs
content: "Optionally emit prerequisite start/complete logs in Desktop path so session timeline matches MCP visibility"
- id: test-matrix
content: "Verify runs from MCP, Trails tab, and Session/YAML tab with direct + nested prerequisites"
-->

# Trail Prerequisites Feature - Handoff Notes

## Current State (What is already true)
- `TrailConfig.prerequisites` is already implemented and editable in the visual config editor.
- MCP run path (`TrailMcpTool.handleRun`) already executes prerequisites before the main trail.
- Prerequisite log types exist (`PrerequisiteStartLog`, `PrerequisiteCompleteLog`) and are handled by timeline/event summary UI.
- `TrailDetailsView` now surfaces prerequisites in the details panel.

## Root Cause of "it doesn't run from UI"
The user-reported mismatch is real:
- **Works** when running via MCP `trail(action=RUN)`.
- **Does not auto-run prerequisites** when running from Desktop UI tabs.

Why:
- Desktop UI uses `DesktopYamlRunner` -> `TrailblazeHostYamlRunner` with raw `runYamlRequest.yaml`.
- That path bypasses `TrailMcpTool.handleRun()` where prerequisite orchestration lives.

## Implementation Direction Chosen
To make Desktop UI run behavior match MCP behavior, we are flattening prerequisites into the YAML before dispatching host/on-device execution.

### Added utility (in progress)
- `TrailFileManager.flattenPrerequisites(yamlContent: String, variantFilePath: String? = null): String`
- Intended behavior:
- Parse YAML.
- Resolve effective config (including NL definition config via `variantFilePath`).
- Resolve prerequisite IDs using `findTrailByName`.
- Collect prerequisite prompt steps (including nested prerequisites) and prepend to main steps.
- Return re-encoded YAML for normal runner pipeline.

## Required Next Edits

### 1) Wire into Trails Browser run path
File: `TrailsBrowserTabComposable.kt`
- Before `requestFactory.create(...)`, transform YAML:
- Input: `yamlContentToRun!!`
- Output: `flattenedYaml`
- Use `flattenedYaml` in request creation.
- Pass selected variant absolute path as `variantFilePath` when available for proper `blaze.yaml` prerequisite resolution.

### 2) Wire into Session/YAML tab run path
File: `YamlTabComposables.kt`
- Apply same flattening before invoking runner.
- Keep behavior consistent with Trails Browser tab.

### 3) Validate/fix flattening utility details
File: `TrailFileManager.kt`
- Ensure recursion handles cycles safely (visited set), avoiding infinite recursion.
- Ensure deterministic ordering of prerequisites.
- Ensure config output does not retain unresolved `prerequisites` after flattening.
- Preserve non-prompt items correctly.

## Testing Matrix (must pass)
1. Run trail with no prerequisites (no behavior change).
2. Run trail with one prerequisite from Trails tab.
3. Run trail with one prerequisite from Session/YAML tab.
4. Run trail with nested prerequisites (`A -> B -> C`).
5. Run trail with missing prerequisite ID (clear error/warning behavior).
6. Run trail with circular prerequisite graph (must fail safely, no hang).
7. Confirm MCP run path still works unchanged.

## Optional Follow-up (if UX parity desired)
Desktop flattened execution may not emit explicit prerequisite start/complete logs like MCP path. If parity is desired in session timeline, add explicit logging in host run path around flattened prerequisite boundaries.

## Key Files for Next Agent
- `trailblaze-server/.../TrailMcpTool.kt` (MCP behavior reference)
- `trailblaze-server/.../TrailFileManager.kt` (flatten helper + prerequisite resolution)
- `trailblaze-host/.../ui/tabs/trails/TrailsBrowserTabComposable.kt` (Desktop Trails tab run entry)
- `trailblaze-host/.../ui/tabs/sessions/YamlTabComposables.kt` (Desktop YAML/session run entry)
- `trailblaze-host/.../host/yaml/DesktopYamlRunner.kt` (Desktop execution pipeline)
- `trailblaze-host/.../host/TrailblazeHostYamlRunner.kt` (host execution dispatch)
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import xyz.block.trailblaze.mcp.sampling.LocalLlmSamplingSource
import xyz.block.trailblaze.model.TrailblazeConfig
import xyz.block.trailblaze.model.TrailblazeHostAppTarget
import xyz.block.trailblaze.recordings.TrailRecordings
import java.io.File
import xyz.block.trailblaze.rules.RetryRule
import xyz.block.trailblaze.rules.TrailblazeLoggingRule
import xyz.block.trailblaze.rules.TrailblazeRunnerUtil
Expand All @@ -57,6 +58,7 @@ import xyz.block.trailblaze.toolcalls.TrailblazeToolResult
import xyz.block.trailblaze.toolcalls.TrailblazeToolSet
import xyz.block.trailblaze.util.Console
import xyz.block.trailblaze.util.TemplatingUtil
import xyz.block.trailblaze.yaml.TrailConfig
import xyz.block.trailblaze.yaml.TrailYamlItem
import xyz.block.trailblaze.yaml.createTrailblazeYaml
import kotlin.reflect.KClass
Expand Down Expand Up @@ -360,6 +362,146 @@ abstract class BaseHostTrailblazeTest(
)
}

/**
* Loads and executes prerequisite trails before the main trail.
*
* For each prerequisite ID, locates its trail directory (sibling to the current trail),
* loads the NL definition file, decodes it, and runs it. Emits PrerequisiteStart/Complete
* logs so the session detail page shows prerequisite execution.
*/
private suspend fun runPrerequisiteTrails(
prerequisiteIds: List<String>,
trailFilePath: String?,
currentTrailId: String,
useRecordedSteps: Boolean,
visited: MutableSet<String> = mutableSetOf(),
) {
if (prerequisiteIds.isEmpty() || trailFilePath == null) return
visited.add(currentTrailId)

// Derive the trails root directory from the current trail's file path.
// Trail files live at: <trailsDir>/<trailId>/blaze.yaml (or *.trail.yaml)
val trailFile = File(trailFilePath)
val trailDir = trailFile.parentFile ?: return
val trailsRootDir = trailDir.parentFile ?: return

val session = loggingRule.session

for (prereqId in prerequisiteIds) {
val prereqDir = File(trailsRootDir, prereqId)
if (!prereqDir.isDirectory) {
Console.log("⚠️ Prerequisite trail directory not found: $prereqId")
continue
}

// Find the NL definition file (blaze.yaml or trailblaze.yaml)
val prereqYamlFile = TrailRecordings.NL_DEFINITION_FILENAMES
.map { File(prereqDir, it) }
.firstOrNull { it.exists() }

if (prereqYamlFile == null) {
Console.log("⚠️ No NL definition file found for prerequisite: $prereqId")
continue
}

val prereqYaml: String
val prereqItems: List<TrailYamlItem>
val prereqConfig: TrailConfig?
try {
prereqYaml = prereqYamlFile.readText()
prereqItems = trailblazeYaml.decodeTrail(prereqYaml)
prereqConfig = trailblazeYaml.extractTrailConfig(prereqItems)
} catch (e: Exception) {
Console.log("⚠️ Failed to load prerequisite '$prereqId': ${e.message}")
continue
}

// Recursively run nested prerequisites before this one
val nestedPrereqs = prereqConfig?.prerequisites.orEmpty().filter { it !in visited }
if (nestedPrereqs.isNotEmpty()) {
runPrerequisiteTrails(
prerequisiteIds = nestedPrereqs,
trailFilePath = prereqYamlFile.absolutePath,
currentTrailId = prereqId,
useRecordedSteps = useRecordedSteps,
visited = visited,
)
}

val prereqTitle = prereqConfig?.title
val totalStepCount = prereqItems.filterIsInstance<TrailYamlItem.PromptsTrailItem>()
.sumOf { it.promptSteps.size }

// Emit prerequisite start log
if (session != null) {
loggingRule.logger.log(
session,
TrailblazeLog.PrerequisiteStartLog(
prerequisiteTrailId = prereqId,
prerequisiteTitle = prereqTitle,
parentTrailId = currentTrailId,
session = session.sessionId,
timestamp = Clock.System.now(),
),
)
}

Console.log("▶️ Running prerequisite: ${prereqTitle ?: prereqId} ($totalStepCount steps)")
val startTime = Clock.System.now()
var passed = true
var failureReason: String? = null
var stepsExecuted = 0

try {
for (item in prereqItems) {
val itemResult = when (item) {
is TrailYamlItem.PromptsTrailItem -> {
trailblazeRunnerUtil.runPromptSuspend(item.promptSteps, useRecordedSteps)
}
is TrailYamlItem.ToolTrailItem -> trailblazeRunnerUtil.runTrailblazeTool(item.tools.map { it.trailblazeTool })
is TrailYamlItem.ConfigTrailItem -> item.config.context?.let { trailblazeRunner.appendToSystemPrompt(it) }
}
if (itemResult is TrailblazeToolResult.Error) {
throw TrailblazeException(itemResult.errorMessage)
}
if (item is TrailYamlItem.PromptsTrailItem) {
stepsExecuted += item.promptSteps.size
}
}
} catch (e: Exception) {
passed = false
failureReason = e.message
Console.log("❌ Prerequisite '$prereqId' failed: ${e.message}")
}

val durationMs = (Clock.System.now() - startTime).inWholeMilliseconds

// Emit prerequisite complete log
if (session != null) {
loggingRule.logger.log(
session,
TrailblazeLog.PrerequisiteCompleteLog(
prerequisiteTrailId = prereqId,
prerequisiteTitle = prereqTitle,
parentTrailId = currentTrailId,
passed = passed,
stepsExecuted = stepsExecuted,
durationMs = durationMs,
failureReason = failureReason,
session = session.sessionId,
timestamp = Clock.System.now(),
),
)
}

if (passed) {
Console.log("✅ Prerequisite '$prereqId' passed (${durationMs}ms)")
} else {
throw TrailblazeException("Prerequisite trail '$prereqId' failed: $failureReason")
}
}
}

/**
* Suspend version of runTrail that checks for coroutine cancellation.
* This allows proper cancellation propagation when running in a coroutine context.
Expand Down Expand Up @@ -422,6 +564,13 @@ abstract class BaseHostTrailblazeTest(
)
}
}

// Execute prerequisite trails before the main trail
val prerequisiteIds = trailConfig?.prerequisites.orEmpty()
val currentTrailId = trailConfig?.id ?: trailFilePath?.let { File(it).parentFile?.name } ?: "unknown"
runPrerequisiteTrails(prerequisiteIds, trailFilePath, currentTrailId, useRecordedSteps)

// Run the main trail
runTrail(trailItems, useRecordedSteps)
return loggingRule.session?.sessionId ?: SessionId("unknown")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import xyz.block.trailblaze.devices.TrailblazeConnectedDeviceSummary
import xyz.block.trailblaze.llm.TrailblazeReferrer
import xyz.block.trailblaze.model.DesktopAppRunYamlParams
Expand All @@ -57,6 +59,8 @@ import xyz.block.trailblaze.ui.editors.yaml.YamlEditorMode
import xyz.block.trailblaze.ui.editors.yaml.YamlTextEditor
import xyz.block.trailblaze.ui.editors.yaml.YamlVisualEditor
import xyz.block.trailblaze.ui.editors.yaml.YamlVisualEditorView
import xyz.block.trailblaze.mcp.newtools.TrailFileManager
import xyz.block.trailblaze.ui.TrailblazeDesktopUtil
import xyz.block.trailblaze.ui.editors.yaml.validateYaml

/**
Expand All @@ -76,6 +80,8 @@ fun YamlTabComposable(
additionalInstrumentationArgs: (suspend () -> Map<String, String>),
) {
val serverState by trailblazeSettingsRepo.serverStateFlow.collectAsState()
val trailsDir = TrailblazeDesktopUtil.getEffectiveTrailsDirectory(serverState.appConfig)
val trailFileManager = remember(trailsDir) { TrailFileManager(trailsDir) }
val savedYamlContent = serverState.appConfig.yamlContent
val savedEditorMode = serverState.appConfig.yamlEditorMode
val savedVisualEditorView = serverState.appConfig.yamlVisualEditorView
Expand Down Expand Up @@ -287,29 +293,37 @@ fun YamlTabComposable(
}

val targetTestApp = deviceManager.getCurrentSelectedTargetApp()
// Run on each selected device
selectedDevices.forEach { device ->
val runYamlRequest = requestFactory.create(
device = device,
yaml = localYamlContent,
testName = "Yaml",
referrer = TrailblazeReferrer.YAML_TAB,
)

coroutineScope.launch {
try {
yamlRunner(
DesktopAppRunYamlParams(
forceStopTargetApp = forceStopApp,
runYamlRequest = runYamlRequest,
onProgressMessage = onProgressMessage,
onConnectionStatus = onConnectionStatus,
targetTestApp = targetTestApp,
additionalInstrumentationArgs = additionalInstrumentationArgs()
coroutineScope.launch {
// Flatten prerequisites into YAML so Desktop UI runs them like MCP does
val flattenedYaml = withContext(Dispatchers.IO) {
trailFileManager.flattenPrerequisites(localYamlContent)
}

// Run on each selected device
selectedDevices.forEach { device ->
val runYamlRequest = requestFactory.create(
device = device,
yaml = flattenedYaml,
testName = "Yaml",
referrer = TrailblazeReferrer.YAML_TAB,
)

launch {
try {
yamlRunner(
DesktopAppRunYamlParams(
forceStopTargetApp = forceStopApp,
runYamlRequest = runYamlRequest,
onProgressMessage = onProgressMessage,
onConnectionStatus = onConnectionStatus,
targetTestApp = targetTestApp,
additionalInstrumentationArgs = additionalInstrumentationArgs()
)
)
)
} catch (e: Exception) {
onProgressMessage("Error on device ${device.instanceId}: ${e.message}")
} catch (e: Exception) {
onProgressMessage("Error on device ${device.instanceId}: ${e.message}")
}
}
}

Expand Down
Loading