Private/start-virtualdeveloper-session.ps1

<#
.SYNOPSIS
    Orchestrator for the Virtual Developer Agent.
.DESCRIPTION
    Polls a work item, checks for updates, and invokes the agent reasoning logic
    to reply or update the work item.
 
    Copyright (c) Microsoft Corporation.
    Licensed under the MIT License.
#>

[CmdletBinding(SupportsShouldProcess=$true)]
param(
    [Parameter(Mandatory=$true)][int]$WorkItemId,
    [Parameter(Mandatory=$false)][string]$AgentIdentity = "Virtual Developer",
    [Parameter(Mandatory=$false)][switch]$MockAgent
)

# Dot-source dependencies
. (Join-Path $PSScriptRoot 'env-constants.ps1')
. (Join-Path $PSScriptRoot 'check-prerequisites.ps1')
. (Join-Path $PSScriptRoot 'azuredevops-core.ps1')

# Ensure temp folder exists
$script:TempFolder = Join-Path $env:TEMP 'VirtualDeveloper'
if (-not (Test-Path $script:TempFolder)) { New-Item -ItemType Directory -Path $script:TempFolder -Force | Out-Null }

# Helper functions (included for self-contained execution)
function Select-UserComment {
    param(
        [Parameter(Mandatory=$false)] [array] $Comments,
        [Parameter(Mandatory=$true)] [string] $AgentIdentity
    )
    if (-not $Comments) { return @() }
    return $Comments | Where-Object { $_.CreatedBy.UniqueName -ne $AgentIdentity }
}

function Test-IsFrozen {
    param(
        [Parameter(Mandatory=$false)] [array] $Comments
    )
    if (-not $Comments) { return $false }
    foreach ($c in $Comments) {
        if ($c.Text -match '#frozen') { return $true }
    }
    return $false
}



function Update-ParentEstimatesRecursive {
    param([int]$WorkItemId, [double]$Delta)

    if ($Delta -eq 0) { return }

    # Get Parent
    $wi = Get-AdoWorkItemCore -WorkItemId $WorkItemId -ExpandRelations
    $parentRel = $wi.relations | Where-Object { $_.rel -eq 'System.LinkTypes.Hierarchy-Reverse' }
    if (-not $parentRel) { return }

    $parentId = $null
    if ($parentRel.url -match '/(\d+)$') {
        $parentId = [int]$matches[1]
    }

    if ($parentId) {
        Write-Host "Propagating estimate delta ($Delta) to Parent $parentId..." -ForegroundColor Cyan

        # Get Parent to check type and current value
        try {
            $parent = Get-AdoWorkItemCore -WorkItemId $parentId
            $type = $parent.fields.'System.WorkItemType'

            # Determine field
            $field = if ($type -eq 'Task') { 'Microsoft.VSTS.Scheduling.RemainingWork' } else { 'Microsoft.VSTS.Scheduling.Effort' }

            $currentVal = if ($parent.fields.$field) { $parent.fields.$field } else { 0 }
            $newVal = $currentVal + $Delta

            # Update
            if ($type -eq 'Task') {
                Update-AdoWorkItemFieldsCore -WorkItemId $parentId -RemainingWork $newVal | Out-Null
            } else {
                Update-AdoWorkItemFieldsCore -WorkItemId $parentId -Effort $newVal | Out-Null
            }

            # Recurse
            Update-ParentEstimatesRecursive -WorkItemId $parentId -Delta $Delta
        } catch {
            Write-Warning "Failed to propagate estimate to Parent $($parentId): $_"
        }
    }
}

function Write-Trace {
    param($Message)
    $logFile = Join-Path $env:TEMP "VirtualDeveloper\session_trace_$(Get-Date -Format 'yyyyMMdd').log"
    "$(Get-Date -Format 'HH:mm:ss') - $Message" | Out-File -FilePath $logFile -Encoding utf8 -Append
}

function Invoke-NodeCommand {
    param($ScriptPath, $Arguments, $StdinInput)
    
    $pInfo = New-Object System.Diagnostics.ProcessStartInfo
    $pInfo.FileName = "node"
    $pInfo.ArgumentList.Add($ScriptPath)
    foreach ($arg in $Arguments) {
        $pInfo.ArgumentList.Add($arg)
    }
    
    $pInfo.RedirectStandardOutput = $true
    $pInfo.RedirectStandardError = $true
    $pInfo.RedirectStandardInput = ($null -ne $StdinInput)
    $pInfo.UseShellExecute = $false
    $pInfo.StandardOutputEncoding = [System.Text.Encoding]::UTF8
    
    $p = New-Object System.Diagnostics.Process
    $p.StartInfo = $pInfo
    $p.Start() | Out-Null
    
    # Write to stdin if provided
    if ($StdinInput) {
        $p.StandardInput.Write($StdinInput)
        $p.StandardInput.Close()
    }
    
    $stdout = $p.StandardOutput.ReadToEnd()
    $stderr = $p.StandardError.ReadToEnd()
    $p.WaitForExit()
    
    return [pscustomobject]@{
        Output = $stdout
        Error = $stderr
        ExitCode = $p.ExitCode
    }
}

function Invoke-CopilotCli {
    <#
    .SYNOPSIS
        Invokes the Copilot CLI with a prompt, using stdin to avoid command line length limits.
    #>

    param(
        [string]$Prompt,
        [string]$Model,
        [switch]$Silent,
        [switch]$AllowAllTools,
        [switch]$AllowAllPaths
    )
    
    # Find copilot's node script
    $copilotCmd = Get-Command copilot -ErrorAction SilentlyContinue
    if (-not $copilotCmd) {
        throw "Copilot CLI not found. Please ensure it's installed and in PATH."
    }
    $copilotDir = Split-Path $copilotCmd.Source -Parent
    $copilotJs = Join-Path $copilotDir "node_modules/@github/copilot/index.js"
    if (-not (Test-Path $copilotJs)) {
        throw "Copilot index.js not found at $copilotJs"
    }
    
    # Build arguments (without the prompt - that goes via stdin)
    $copilotArgs = @('--stream', 'off')
    if ($Silent) { $copilotArgs += '-s' }
    if ($Model) { $copilotArgs += @('--model', $Model) }
    if ($AllowAllTools) { $copilotArgs += '--allow-all-tools' }
    if ($AllowAllPaths) { $copilotArgs += '--allow-all-paths' }
    
    # Write prompt to temp file (avoids pipe encoding issues)
    $tempDir = Join-Path $env:TEMP "VirtualDeveloper"
    if (-not (Test-Path $tempDir)) { New-Item -ItemType Directory -Path $tempDir -Force | Out-Null }
    $promptFile = Join-Path $tempDir "prompt_$([guid]::NewGuid().ToString('N')).txt"
    # Write with BOM-less UTF-8
    [System.IO.File]::WriteAllText($promptFile, $Prompt, [System.Text.UTF8Encoding]::new($false))
    
    try {
        # Call node.js directly with proper encoding
        $pInfo = New-Object System.Diagnostics.ProcessStartInfo
        $pInfo.FileName = "node"
        $pInfo.ArgumentList.Add($copilotJs)
        foreach ($a in $copilotArgs) {
            $pInfo.ArgumentList.Add($a)
        }
        
        $pInfo.RedirectStandardInput = $true
        $pInfo.RedirectStandardOutput = $true
        $pInfo.RedirectStandardError = $true
        $pInfo.UseShellExecute = $false
        $pInfo.StandardOutputEncoding = [System.Text.Encoding]::UTF8
        $pInfo.StandardInputEncoding = [System.Text.Encoding]::UTF8
        if ($env:AzDevOpsRepoRoot) {
            $pInfo.WorkingDirectory = $env:AzDevOpsRepoRoot
        }
        
        $p = New-Object System.Diagnostics.Process
        $p.StartInfo = $pInfo
        $p.Start() | Out-Null
        
        # Read prompt from file and write to stdin
        $promptContent = [System.IO.File]::ReadAllText($promptFile, [System.Text.Encoding]::UTF8)
        $p.StandardInput.Write($promptContent)
        $p.StandardInput.Close()
        
        $stdout = $p.StandardOutput.ReadToEnd()
        $stderr = $p.StandardError.ReadToEnd()
        $p.WaitForExit()
        
        return [pscustomobject]@{
            Output = $stdout
            Error = $stderr
            ExitCode = $p.ExitCode
        }
    } finally {
        Remove-Item $promptFile -Force -ErrorAction SilentlyContinue
    }
}

function Invoke-AgentReasoning {
    param($Context, [switch]$MockAgent)
    Write-Trace "Invoke-AgentReasoning called for WI $($Context.Id)"

    if ($MockAgent) {
        Write-Host "MockAgent: Returning canned response." -ForegroundColor Yellow
        # For Tasks in New/To Do state, return Update action (description expansion)
        if ($Context.Type -eq 'Task' -and ($Context.State -eq 'New' -or $Context.State -eq 'To Do')) {
            # Build expanded description from existing Description + AC field
            $expandedDesc = $Context.Description
            if (-not $expandedDesc) { $expandedDesc = "Task: $($Context.Title)" }
            
            # Append AC from the AC field if it exists and not already in Description
            if ($Context.AC -and $expandedDesc -notmatch 'Acceptance Criteria') {
                $expandedDesc += "`n`n### Acceptance Criteria`n$($Context.AC)"
            }
            
            return [pscustomobject]@{
                Action = "Update"
                Description = $expandedDesc
            }
        }
        return [pscustomobject]@{
            Action = "Reply"
            Text = "This is a mock response from the Virtual Developer Agent."
        }
    }

    # Load System Prompt
    $configPath = Join-Path $PSScriptRoot 'config\system-prompt.txt'
    if (Test-Path $configPath) {
        $systemPrompt = Get-Content $configPath -Raw
    } else {
        Write-Warning "System prompt not found at $configPath. Using default."
        $systemPrompt = "You are a helpful assistant. Output JSON."
    }

    # Prepare Download Path
    $downloadPath = Join-Path $env:TEMP "VirtualDeveloper\downloads\$($Context.Id)"
    if (-not (Test-Path $downloadPath)) {
        New-Item -ItemType Directory -Path $downloadPath -Force | Out-Null
    }
    # Placeholder for downloading files
    # TODO: Implement file download logic here if needed.

    # Inject Path into Prompt
    $systemPrompt = $systemPrompt -replace '{{DOWNLOAD_PATH}}', $downloadPath
    $systemPrompt = $systemPrompt -replace '{{REPO_ROOT}}', $env:AzDevOpsRepoRoot
    $systemPrompt = $systemPrompt -replace '{{CURRENT_DATETIME}}', (Get-Date).ToString("yyyy-MM-ddTHH:mm:ssZ")

    # Build User Prompt with Context
    $userPrompt = @"
Work Item ID: $($Context.Id)
Title: $($Context.Title)
Description:
$($Context.Description)
 
Acceptance Criteria:
$($Context.AC)
 
History:
$($Context.History | ForEach-Object { "$($_.CreatedBy.DisplayName): $($_.Text)" } | Out-String)
 
Respond with JSON.
"@


    $fullPrompt = "$systemPrompt`n`n$userPrompt"

    # Debug: Log Prompt
    $debugFile = Join-Path $env:TEMP "VirtualDeveloper\debug_prompt_$(Get-Date -Format 'yyyyMMdd-HHmmss').txt"
    $fullPrompt | Out-File -FilePath $debugFile -Encoding utf8

    # Call Copilot CLI using wrapper to handle long prompts
    $rawOutput = $null
    try {
        try {
            Push-Location $env:AzDevOpsRepoRoot

            $result = Invoke-CopilotCli -Prompt $fullPrompt -Model $env:CopilotModel -Silent -AllowAllTools -AllowAllPaths

            if ($result.ExitCode -ne 0) {
                throw "Copilot CLI failed with exit code $($result.ExitCode). Stderr: $($result.Error)"
            }

            # Combine output if needed, but usually we just want stdout for JSON
            $rawOutput = $result.Output
            if (-not $rawOutput) {
                # If stdout is empty, maybe it went to stderr?
                $rawOutput = $result.Error
            }
            
            # Always save raw output for debugging
            $rawOutputFile = Join-Path $env:TEMP "VirtualDeveloper\agent_raw_$(Get-Date -Format 'yyyyMMddHHmmss').txt"
            $rawOutput | Out-File -FilePath $rawOutputFile -Encoding utf8
        } finally {
            Pop-Location
        }

        # Attempt to parse JSON
        $jsonOutput = $rawOutput
        # Copilot might return markdown code blocks ```json ... ```
        if ($jsonOutput -match '(?ms)```json\s*(.*?)\s*```') {
            $jsonOutput = $matches[1]
        } elseif ($jsonOutput -match '(?ms)```\s*(.*?)\s*```') {
             $jsonOutput = $matches[1]
        } elseif ($jsonOutput -match '(?ms)\{.*\}') {
             # Fallback: Try to find JSON object { ... }
             $jsonOutput = $matches[0]
        } else {
             # No JSON block found - log the raw output for debugging
             throw "No JSON content found in Copilot output. Raw output saved to $rawOutputFile"
        }
        
        # Fix line-wrapped JSON: Copilot CLI wraps long lines for terminal display
        # This breaks JSON strings that span multiple lines
        # Pattern: line ends without closing quote, next line starts with spaces
        # We need to join these lines, replacing newline+spaces with a single space
        $jsonOutput = $jsonOutput -replace '(?m)(?<!")\r?\n\s{2,}(?!")', ' '

        $decision = $jsonOutput | ConvertFrom-Json
        Write-Trace "Agent decision: $($decision.Action)"

        return $decision
    } catch {
        $logFile = Join-Path $env:TEMP "VirtualDeveloper\copilot_error_$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
        $errorDetails = @"
Error: $_
Exception: $($_.Exception.GetType().FullName)
Stack: $($_.ScriptStackTrace)
 
=== RAW OUTPUT (length: $(if ($rawOutput) { $rawOutput.Length } else { 'N/A' })) ===
$rawOutput
 
=== EXTRACTED JSON ===
$jsonOutput
"@

        $errorDetails | Out-File -FilePath $logFile -Encoding utf8
        Write-Error "Failed to invoke Copilot or parse response: $_. Raw output saved to $logFile"
        return $null
    }
}

function Invoke-BreakdownSession {
    param($WorkItemId, $WorkItem, $Comments, $Tags, [switch]$MockAgent, [switch]$ForceBreakdown)
    Write-Trace "Invoke-BreakdownSession called for WI $WorkItemId (ForceBreakdown: $ForceBreakdown)"

    Write-Host "Starting Breakdown Session for WI $WorkItemId..." -ForegroundColor Cyan

    try {
        # Idempotency: Add processing tag
        try {
            Add-AdoWorkItemTagCore -WorkItemId $WorkItemId -Tag 'processing-virtual-dev' | Out-Null
        } catch {
            Write-Warning "Failed to add processing tag: $_"
        }

        # Determine State
        $isContinue = $Tags -contains 'continue'
        $isGoForIt = $Tags -contains 'go-for-it' -or $ForceBreakdown
        
        # Fetch Existing Children
        $children = Get-AdoWorkItemChildrenCore -WorkItemId $WorkItemId
        $childIds = if ($children) { $children.id } else { @() }
        $childrenContext = if ($children) {
            $children | ForEach-Object { "ID: $($_.id) - Title: $($_.fields.'System.Title') - Desc: $($_.fields.'System.Description')" } | Out-String
        } else {
            "None"
        }

        # Load Prompt
        $configPath = Join-Path $PSScriptRoot 'config\system-prompt-breakdown.txt'
        if (Test-Path $configPath) {
            $systemPrompt = Get-Content $configPath -Raw
        } else {
            $systemPrompt = "You are a helpful assistant. Output JSON."
        }
        
        $systemPrompt = $systemPrompt -replace '{{REPO_ROOT}}', $env:AzDevOpsRepoRoot
        $systemPrompt = $systemPrompt -replace '{{CURRENT_DATETIME}}', (Get-Date).ToString("yyyy-MM-ddTHH:mm:ssZ")
        
        # Build History String
        $historyStr = $Comments | ForEach-Object { "$($_.createdBy.displayName): $($_.text)" } | Out-String
        if ($historyStr.Length -gt 15000) {
            $historyStr = "...(truncated)...`n" + $historyStr.Substring($historyStr.Length - 15000)
        }

        # Build User Prompt
        $userPrompt = @"
Work Item ID: $($WorkItem.id)
Title: $($WorkItem.fields.'System.Title')
Description:
$($WorkItem.fields.'System.Description')
 
Acceptance Criteria:
$($WorkItem.fields.'Microsoft.VSTS.Common.AcceptanceCriteria')
 
History:
$historyStr
 
Existing Sub-tasks:
$childrenContext
 
State:
Continue: $isContinue
GoForIt: $isGoForIt
 
Respond with JSON.
"@

        if ($isGoForIt) {
            $userPrompt += "`n`nIMPORTANT: The user has approved the plan. Please extract the 'Proposed Implementation Plan' from the history and output the corresponding tasks in the JSON response."
        }

        $fullPrompt = "$systemPrompt`n`n$userPrompt"
        
        Write-Host "Prompt length: $($fullPrompt.Length)" -ForegroundColor Cyan
        
        # Call Copilot
        if ($MockAgent) {
            Write-Host "MockAgent: Returning canned breakdown." -ForegroundColor Yellow
            $decision = [pscustomobject]@{
                Action = "Plan"
                Summary = "Mock Plan"
                Tasks = @(@{ Title = "Task 1"; Description = "Desc 1" })
            }
        } else {
            try {
                Write-Host "Calling Copilot..." -ForegroundColor Cyan
                Push-Location $env:AzDevOpsRepoRoot

                $result = Invoke-CopilotCli -Prompt $fullPrompt -Model $env:CopilotModel -Silent -AllowAllTools -AllowAllPaths

                if ($result.ExitCode -ne 0) {
                    throw "Copilot CLI failed with exit code $($result.ExitCode). Stderr: $($result.Error)"
                }

                # Combine output if needed, but usually we just want stdout for JSON
                $jsonOutput = $result.Output
                if (-not $jsonOutput) {
                    # If stdout is empty, maybe it went to stderr?
                    $jsonOutput = $result.Error
                }
                Write-Host "Copilot returned. Output length: $($jsonOutput.Length)" -ForegroundColor Cyan
                
                # Save raw output for debugging
                $rawOutputFile = Join-Path $env:TEMP "VirtualDeveloper\copilot_raw_$(Get-Date -Format 'yyyyMMddHHmmss').txt"
                $jsonOutput | Out-File -FilePath $rawOutputFile -Encoding utf8
            } finally {
                Pop-Location
            }

            try {
                # Try to extract JSON from various formats
                $extractedJson = $null
                
                # Method 1: ```json ... ``` block
                if ($jsonOutput -match '(?ms)```json\s*([\s\S]*?)\s*```') {
                    $extractedJson = $matches[1]
                }
                # Method 2: ``` ... ``` block (generic code block)
                elseif ($jsonOutput -match '(?ms)```\s*([\s\S]*?)\s*```') {
                    $extractedJson = $matches[1]
                }
                # Method 3: Find JSON object anywhere in text (greedy match from first { to last })
                elseif ($jsonOutput -match '(?s)\{[\s\S]*\}') {
                    $extractedJson = $matches[0]
                }
                
                if (-not $extractedJson) {
                    $errorFile = Join-Path $env:TEMP "VirtualDeveloper\no_json_$(Get-Date -Format 'yyyyMMddHHmmss').log"
                    "Copilot output did not contain JSON:`n$jsonOutput" | Out-File -FilePath $errorFile -Encoding utf8
                    throw "No JSON content found in Copilot output. Raw output saved to $errorFile"
                }
                
                $jsonOutput = $extractedJson
                
                # Fix line-wrapped JSON: Copilot CLI wraps long lines for terminal display
                # This breaks JSON strings that span multiple lines
                # Pattern: newline followed by 2+ spaces within JSON content
                $jsonOutput = $jsonOutput -replace '(?m)\r?\n\s{2,}', ' '
                
                $decision = $jsonOutput | ConvertFrom-Json
            } catch {
                throw $_
            }
        }
        
        # Handle Decision
        if ($decision.Action -eq 'Clarify') {
            $questions = @()
            foreach ($q in $decision.ClarificationQuestions) {
                if ($q -is [string]) {
                    $questions += $q
                } elseif ($q.Question) {
                    $agent = if ($q.Agent) { "**$($q.Agent)**: " } else { "" }
                    $questions += "$agent$($q.Question)"
                } else {
                    $questions += $q.ToString()
                }
            }
            $qList = $questions -join "`n- "
            $comment = "virtual-dev: I need clarification on the following:`n- $qList"
            Add-AdoWorkItemCommentCore -WorkItemId $WorkItemId -Text $comment | Out-Null
            # Wait for user to reply and tag 'continue'
        } elseif ($decision.Action -eq 'Plan') {
            if ($isGoForIt) {
                # Execute Plan
                Write-Host "Generating/Updating tasks..." -ForegroundColor Green
                $totalEstimate = 0
                $processedTaskIds = @()

                foreach ($task in $decision.Tasks) {
                    if (-not $task.Title) {
                        Write-Warning "Task missing title. Skipping."
                        continue
                    }
                    $est = if ($task.Estimate) { $task.Estimate } else { 0 }
                    $totalEstimate += $est

                    # Prepare Description with AC
                    $descStr = if ($task.Description -is [array]) { $task.Description -join "`n" } else { $task.Description }
                    if ($task.AcceptanceCriteria) {
                        $acText = if ($task.AcceptanceCriteria -is [array]) { "- " + ($task.AcceptanceCriteria -join "`n- ") } else { $task.AcceptanceCriteria }
                        $descStr += "`n`n### Acceptance Criteria`n$acText"
                    }

                    $validId = $null
                    if ($task.Id) {
                        if ($childIds -contains $task.Id) {
                            $validId = $task.Id
                        } else {
                            Write-Warning "Agent returned Task ID $($task.Id) which is not a known child of Work Item $WorkItemId. Treating as new task."
                        }
                    }

                    if ($validId) {
                        Write-Host "Updating Task $($validId)..." -ForegroundColor Cyan
                        
                        try {
                            # Check and apply prefix if needed
                            $newTitle = $null
                            if (-not [string]::IsNullOrEmpty($env:VirtualDeveloperTaskPrefix)) {
                                $existingTask = Get-AdoWorkItemCore -WorkItemId $validId
                                $currentTitle = $existingTask.fields.'System.Title'
                                if (-not $currentTitle.StartsWith($env:VirtualDeveloperTaskPrefix)) {
                                    $newTitle = "$env:VirtualDeveloperTaskPrefix $currentTitle"
                                }
                            }
                            
                            Update-AdoWorkItemFieldsCore -WorkItemId $validId -Title $newTitle -Description $descStr
                            $processedTaskIds += $validId
                        } catch {
                            Write-Warning "Failed to update Task $($validId): $_"
                        }
                    } else {
                        # Logic to determine intended title
                        $prefixStr = ""
                        if (-not [string]::IsNullOrEmpty($env:VirtualDeveloperTaskPrefix)) {
                            $prefixStr = "$env:VirtualDeveloperTaskPrefix "
                        }
                        
                        $intendedTitle = $task.Title.Trim()
                        if (-not [string]::IsNullOrEmpty($prefixStr) -and -not $intendedTitle.StartsWith($env:VirtualDeveloperTaskPrefix)) {
                            $intendedTitle = "$prefixStr$($task.Title.Trim())"
                        }
                        
                        # Fallback: If no ID, check if title matches an existing child
                        $existingChild = $children | Where-Object { $_.fields.'System.Title' -and $_.fields.'System.Title'.Trim() -eq $intendedTitle } | Select-Object -First 1
                        
                        # If not found, and we added a prefix, check for the raw title (unprefixed existing task)
                        if (-not $existingChild -and $intendedTitle -ne $task.Title.Trim()) {
                             $existingChild = $children | Where-Object { $_.fields.'System.Title' -and $_.fields.'System.Title'.Trim() -eq $task.Title.Trim() } | Select-Object -First 1
                        }
                        
                        if ($existingChild) {
                            $validId = $existingChild.id
                            Write-Host "Found existing task by title: '$($existingChild.fields.'System.Title')' (ID: $validId). Updating instead of creating." -ForegroundColor Yellow
                            
                            # Update the found task
                            try {
                                $newTitle = $null
                                if ($existingChild.fields.'System.Title' -ne $intendedTitle) {
                                    $newTitle = $intendedTitle
                                }
                                Update-AdoWorkItemFieldsCore -WorkItemId $validId -Title $newTitle -Description $descStr
                                $processedTaskIds += $validId
                            } catch {
                                Write-Warning "Failed to update Task $($validId): $_"
                            }
                        } else {
                            $project = $WorkItem.fields.'System.TeamProject'
                            $areaPath = $WorkItem.fields.'System.AreaPath'
                            $iterationPath = $WorkItem.fields.'System.IterationPath'

                            if (-not $project) {
                                Write-Warning "Parent Work Item $WorkItemId does not have a Team Project set. Cannot create child task '$intendedTitle'."
                                continue
                            }
                            Write-Host "Creating Task '$intendedTitle'..." -ForegroundColor Cyan
                            # Use RemainingWork as it is more commonly available on Tasks across process templates (Scrum, Agile, CMMI)
                            try {
                                $newTask = New-AdoWorkItemCore -Project $project -Type 'Task' -Title $intendedTitle -Description $descStr -ParentId $WorkItemId -ParentUrl $WorkItem.url -RemainingWork $est -AreaPath $areaPath -IterationPath $iterationPath
                                if ($newTask -and $newTask.id) {
                                    $processedTaskIds += $newTask.id
                                }
                            } catch {
                                $logFile = Join-Path $env:TEMP "VirtualDeveloper\breakdown_error_$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
                                "Failed to create Task '$intendedTitle'`n$_" | Out-File -FilePath $logFile -Encoding utf8 -Append
                                Write-Warning "Failed to create Task '$intendedTitle': $_"
                            }
                        }
                    }
                }
                
                # Calculate Final Total (Decision Tasks + Untouched Existing Children)
                $finalTotal = $totalEstimate
                
                if ($children) {
                    foreach ($child in $children) {
                        if ($processedTaskIds -notcontains $child.id) {
                            $childEst = 0
                            if ($child.fields.'Microsoft.VSTS.Scheduling.RemainingWork') {
                                $childEst = $child.fields.'Microsoft.VSTS.Scheduling.RemainingWork'
                            }
                            $finalTotal += $childEst
                        }
                    }
                }
                
                # Calculate Delta
                $oldParentVal = 0
                $parentType = $WorkItem.fields.'System.WorkItemType'
                $field = if ($parentType -eq 'Task') { 'Microsoft.VSTS.Scheduling.RemainingWork' } else { 'Microsoft.VSTS.Scheduling.Effort' }
                if ($WorkItem.fields.$field) {
                    $oldParentVal = $WorkItem.fields.$field
                }
                
                $delta = $finalTotal - $oldParentVal
                
                # Update Parent Effort
                if ($delta -ne 0) {
                    Write-Host "Updating Parent Work Item Effort/RemainingWork to $finalTotal (Delta: $delta)..." -ForegroundColor Cyan
                    try {
                        if ($parentType -eq 'Task') {
                            Update-AdoWorkItemFieldsCore -WorkItemId $WorkItemId -RemainingWork $finalTotal | Out-Null
                        } else {
                            Update-AdoWorkItemFieldsCore -WorkItemId $WorkItemId -Effort $finalTotal | Out-Null
                        }
                        
                        # Propagate
                        Update-ParentEstimatesRecursive -WorkItemId $WorkItemId -Delta $delta
                    } catch {
                        Write-Warning "Failed to update parent effort: $_"
                    }
                }
                
                # Attach Docs (Summary as comment for now)
                $summaryText = if ($decision.Summary -is [array]) { $decision.Summary -join "`n" } else { $decision.Summary }
                Add-AdoWorkItemCommentCore -WorkItemId $WorkItemId -Text "## Implementation Plan Generated`n`n$summaryText" | Out-Null
                
                # Finalize Tags
                try {
                    Remove-AdoWorkItemTagCore -WorkItemId $WorkItemId -Tag 'actionable' | Out-Null
                    Remove-AdoWorkItemTagCore -WorkItemId $WorkItemId -Tag 'go-for-it' | Out-Null
                    Remove-AdoWorkItemTagCore -WorkItemId $WorkItemId -Tag 'continue' | Out-Null
                    Add-AdoWorkItemTagCore -WorkItemId $WorkItemId -Tag 'processed-virtual-dev' | Out-Null
                } catch {
                    Write-Warning "Failed to update tags: $_"
                }

                # Deep Expand
                if ($Tags -contains 'deep-expand') {
                    Write-Trace "Deep Expand enabled. Processing $($processedTaskIds.Count) tasks."
                    Write-Host "Deep Expand enabled. Triggering agent for child tasks..." -ForegroundColor Magenta
                    foreach ($childId in $processedTaskIds) {
                        Write-Host "Expanding Task $childId..." -ForegroundColor Magenta
                        try {
                            # Do NOT pass ForceBreakdown - we want description expansion via Invoke-AgentReasoning, not breakdown
                            Start-VirtualDeveloperSession -WorkItemId $childId -AgentIdentity $AgentIdentity -MockAgent:$MockAgent
                        } catch {
                            $logFile = Join-Path $env:TEMP "VirtualDeveloper\breakdown_error_$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
                            "Failed to expand Task $childId`n$_" | Out-File -FilePath $logFile -Encoding utf8 -Append
                            Write-Warning "Failed to expand Task $($childId): $_"
                        }
                    }
                }
                
            } else {
                # Propose Plan
                $summaryText = if ($decision.Summary -is [array]) { $decision.Summary -join "`n" } else { $decision.Summary }
                $summary = "## Proposed Implementation Plan`n`n$summaryText`n`n### Tasks`n"
                foreach ($t in $decision.Tasks) {
                    $est = if ($t.Estimate) { " ($($t.Estimate)h)" } else { "" }
                    $desc = if ($t.Description -is [array]) { $t.Description -join "<br/>" } else { $t.Description }
                    $summary += "- **$($t.Title)**${est}: $desc`n"
                }
                $summary += "`n`nTag this work item with **go-for-it** to proceed with generation."
                Add-AdoWorkItemCommentCore -WorkItemId $WorkItemId -Text $summary | Out-Null
            }
        }
        
    } catch {
        $logFile = Join-Path $env:TEMP "VirtualDeveloper\breakdown_error_$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
        $errorDetails = @"
=== BREAKDOWN ERROR ===
Timestamp: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
WorkItemId: $WorkItemId
Error: $_
Exception Type: $($_.Exception.GetType().FullName)
Stack Trace: $($_.ScriptStackTrace)
 
=== RAW COPILOT OUTPUT ===
Length: $(if ($jsonOutput) { $jsonOutput.Length } else { 'N/A' })
Content:
$jsonOutput
 
=== PROMPT INFO ===
Prompt Length: $(if ($fullPrompt) { $fullPrompt.Length } else { 'N/A' })
"@

        $errorDetails | Out-File -FilePath $logFile -Encoding utf8
        Write-Host "Breakdown failed. See log at $logFile" -ForegroundColor Red
        Write-Error "Breakdown failed: $_"
    } finally {
        # Always remove processing tag
        try {
            Remove-AdoWorkItemTagCore -WorkItemId $WorkItemId -Tag 'processing-virtual-dev' | Out-Null
        } catch {
            Write-Warning "Failed to remove processing tag: $_"
        }
    }
}

function Start-VirtualDeveloperSession {
    [CmdletBinding(SupportsShouldProcess=$true)]
    param(
        [int]$WorkItemId,
        [string]$AgentIdentity = "Virtual Developer",
        [switch]$MockAgent,
        [switch]$ForceBreakdown
    )
    Write-Trace "Start-VirtualDeveloperSession called for WI $WorkItemId (ForceBreakdown: $ForceBreakdown)"

    # Pre-flight check
    try {
        Write-Host "Checking prerequisites..." -ForegroundColor Cyan
        $null = Invoke-CheckPrerequisites
        Write-Host "Prerequisites checked." -ForegroundColor Green
        
        # Cache token to avoid repeated az calls
        if (-not $env:AzDevOpsAccessToken) {
            Write-Host "Acquiring Access Token..." -ForegroundColor Cyan
            $env:AzDevOpsAccessToken = Get-AzAccessTokenCore $env:AzDevOpsAadAppId
        }
    } catch {
        Write-Error "Prerequisite check failed: $_"
        return
    }

    Write-Host "Fetching Work Item $WorkItemId..." -ForegroundColor Cyan
    try {
        $wi = Get-AdoWorkItemCore -WorkItemId $WorkItemId
        $commentsRaw = Get-AdoWorkItemCommentsCore -WorkItemId $WorkItemId
    } catch {
        $logFile = Join-Path $env:TEMP "VirtualDeveloper\session_error_$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
        "Failed to fetch Work Item $WorkItemId`n$_" | Out-File -FilePath $logFile -Encoding utf8
        Write-Error "Failed to fetch Work Item: $_"
        return
    }

    # Check State
    $state = $wi.fields.'System.State'
    if ($state -ne 'New' -and $state -ne 'To Do') {
        Write-Host "Work Item is in state '$state'. Skipping processing (only 'New' or 'To Do' is processed)." -ForegroundColor Yellow
        return
    }
    
    $comments = @()
    if ($commentsRaw -and $commentsRaw.comments) {
        $comments = $commentsRaw.comments
    }

    # Filter history
    $history = Select-UserComment -Comments $comments -AgentIdentity $AgentIdentity

    # Check Frozen
    if (Test-IsFrozen -Comments $comments) {
        Write-Verbose "Work Item is frozen. Triggering breakdown (TODO)."
        return
    }

    # Check Breakdown Trigger
    $tags = if ($wi.fields.'System.Tags') { $wi.fields.'System.Tags' -split ';' | ForEach-Object { $_.Trim() } } else { @() }
    $type = $wi.fields.'System.WorkItemType'

    if (($tags -contains 'actionable' -or $ForceBreakdown) -and $tags -notcontains 'children-done' -and ($type -in 'Product Backlog Item','Bug','Task','Feature')) {
        if ($tags -contains 'processing-virtual-dev') {
            Write-Host "Work Item is already being processed (tag 'processing-virtual-dev'). Skipping." -ForegroundColor Yellow
            return
        }
        
        try {
            Invoke-BreakdownSession -WorkItemId $WorkItemId -WorkItem $wi -Comments $comments -Tags $tags -MockAgent:$MockAgent -ForceBreakdown:$ForceBreakdown
        } catch {
            $logFile = Join-Path $env:TEMP "VirtualDeveloper\breakdown_error_$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
            $errorDetails = @"
=== BREAKDOWN SESSION ERROR ===
Timestamp: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
WorkItemId: $WorkItemId
Error: $_
Exception Type: $($_.Exception.GetType().FullName)
Stack Trace: $($_.ScriptStackTrace)
"@

            $errorDetails | Out-File -FilePath $logFile -Encoding utf8
            Write-Error "Breakdown session failed: $_. Details saved to $logFile"
        }
        return
    }

    # Build Context
    $context = @{
        Id = $WorkItemId
        Title = $wi.fields.'System.Title'
        Description = $wi.fields.'System.Description'
        AC = $wi.fields.'Microsoft.VSTS.Common.AcceptanceCriteria'
        History = $history
        Type = $type
        State = $state
    }

    Write-Host "Invoking Agent Reasoning..." -ForegroundColor Cyan
    Write-Host "Agent is thinking (this may take a while)..." -ForegroundColor DarkGray

    # Post status comment
    try {
        Add-AdoWorkItemCommentCore -WorkItemId $WorkItemId -Text "Agent is analyzing the request..." | Out-Null
    } catch {
        Write-Warning "Failed to post status comment: $_"
    }

    # Reason
    $decision = Invoke-AgentReasoning -Context $context -MockAgent:$MockAgent

    if ($decision) {
        try {
            if ($decision.Action -eq 'Reply') {
                if ($PSCmdlet.ShouldProcess("WorkItem $WorkItemId", "Reply: $($decision.Text)")) {
                    Write-Verbose "Agent replying..."
                    Add-AdoWorkItemCommentCore -WorkItemId $WorkItemId -Text $decision.Text
                }
            }
            elseif ($decision.Action -eq 'Update') {
                if ($PSCmdlet.ShouldProcess("WorkItem $WorkItemId", "Update Description/AC")) {
                    Write-Verbose "Agent updating..."
                    
                    # Append AC to Description if present
                    $descStr = $decision.Description
                    if ($decision.AcceptanceCriteria) {
                        $acText = if ($decision.AcceptanceCriteria -is [array]) { "- " + ($decision.AcceptanceCriteria -join "`n- ") } else { $decision.AcceptanceCriteria }
                        $descStr += "`n`n### Acceptance Criteria`n$acText"
                    }

                    Update-AdoWorkItemFieldsCore -WorkItemId $WorkItemId -Description $descStr
                    
                    # Post completion comment
                    try {
                        Add-AdoWorkItemCommentCore -WorkItemId $WorkItemId -Text "I have updated the Description and Acceptance Criteria based on my analysis." | Out-Null
                    } catch {
                        Write-Warning "Failed to post completion comment: $_"
                    }
                }
            }
        } catch {
            $logFile = Join-Path $env:TEMP "VirtualDeveloper\session_error_$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
            $errInfo = @{
                Error = $_.ToString()
                Decision = $decision
            }
            $errInfo | ConvertTo-Json -Depth 10 | Out-File -FilePath $logFile -Encoding utf8
            Write-Error "Session processing failed: $_. Details saved to $logFile"
        }
    } else {
        Write-Verbose "No action required."
    }
}

# Entry point
if ($MyInvocation.InvocationName -ne '.') {
    Start-VirtualDeveloperSession -WorkItemId $WorkItemId -AgentIdentity $AgentIdentity -MockAgent:$MockAgent
}