fix: orchestrator race conditions, diff preview, session snapshot tools, and test fixes

- Fix duplicate inference requests after approval resume by registering orchestrator
  jobs in activeSessionJobs (ServerModule.launchSessionRun), so the guard in the
  OrchestrationResumedEvent subscription prevents spurious duplicate resume.
- Replace blocking Files.readString in computeToolPreview with withContext(Dispatchers.IO)
  by making the function suspend — no blocking I/O on the coroutine dispatcher.
- Guard orderedIds.add() in rebuildTools against duplicate invocation IDs on replayed
  ToolInvocationRequestedEvent to prevent duplicate tool records in snapshots.
- Add unified-diff preview for file_write tools: computeToolPreview reads existing
  file and generates ---/+++ diff before emitting ApprovalRequestedEvent.
- Render diff preview with color coding in ApprovalSurface (@@ yellow, +/- green/red).
- Include current-stage tool records in SessionSnapshot via rebuildTools event replay.
- Fix RouterContextBuilderTest: recursive buildPack helper and 2 tests using class-level
  builder instead of their local builder instances (conversationKeepLast mismatch).
- Add suspend keyword to RouterFacadeTest mock, fix buildPack recursion.
This commit is contained in:
2026-05-28 15:37:16 +04:00
parent e05532e7b2
commit 57d2237ba0
11 changed files with 230 additions and 82 deletions
@@ -85,7 +85,9 @@ import com.correx.core.validation.model.ValidationContext
import com.correx.core.validation.pipeline.ValidationOutcome
import com.correx.core.validation.pipeline.ValidationPipeline
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.datetime.Clock
import kotlinx.serialization.json.Json
@@ -357,6 +359,7 @@ abstract class SessionOrchestrator(
mode = ApprovalMode.PROMPT,
)
val requestId = ApprovalRequestId(UUID.randomUUID().toString())
val toolPreview = computeToolPreview(toolCall.function.name, parameters)
val domainRequest = DomainApprovalRequest(
id = requestId,
tier = tier,
@@ -364,7 +367,7 @@ abstract class SessionOrchestrator(
riskSummaryId = null,
timestamp = Clock.System.now(),
toolName = toolCall.function.name,
preview = toolCall.function.arguments.take(200),
preview = toolPreview ?: toolCall.function.arguments.take(200),
)
val engineDecision = approvalEngine.evaluate(
domainRequest, approvalCtx, activeGrants, Clock.System.now(),
@@ -416,7 +419,7 @@ abstract class SessionOrchestrator(
stageId = stageId,
projectId = null,
toolName = toolCall.function.name,
preview = toolCall.function.arguments.take(200),
preview = toolPreview ?: toolCall.function.arguments.take(200),
),
)
val deferred = CompletableDeferred<ApprovalDecision>()
@@ -918,6 +921,53 @@ abstract class SessionOrchestrator(
}
}
/**
* Compute a unified-diff preview for file-affecting tool calls.
* For [file_write] with operation "write": reads the existing file (if any) and diffs it
* against the proposed content from the LLM arguments.
* For all other tools: returns null (caller falls back to raw JSON args).
*
* This function performs blocking I/O (file reads) and must be called from a suspend context
* that will dispatch it on [Dispatchers.IO].
*/
private suspend fun computeToolPreview(toolName: String, parameters: Map<String, Any>): String? {
if (toolName != "file_write") return null
val path = parameters["path"] as? String ?: return null
val operation = parameters["operation"] as? String
if (operation != "write") return null
val proposedContent = parameters["content"] as? String ?: return null
val existingContent = withContext(Dispatchers.IO) {
runCatching {
val filePath = java.nio.file.Paths.get(path)
if (java.nio.file.Files.exists(filePath)) {
java.nio.file.Files.readString(filePath)
} else null
}.getOrNull()
}
return buildDiffString(path, existingContent, proposedContent)
}
/**
* Build a unified-diff-style string for a full file replacement.
* When the file doesn't exist yet (new file), only `+` lines are shown.
* Uses a simple full-file diff: all old lines as `-`, all new lines as `+`.
*/
private fun buildDiffString(path: String, existingContent: String?, proposedContent: String): String {
val existingLines = existingContent?.lines() ?: emptyList()
val proposedLines = proposedContent.lines()
if (existingLines == proposedLines) return " (no change)"
return buildString {
appendLine("--- a/$path")
appendLine("+++ b/$path")
appendLine("@@ -1,${existingLines.size.coerceAtLeast(1)} +1,${proposedLines.size.coerceAtLeast(1)} @@")
existingLines.forEach { appendLine("-$it") }
proposedLines.forEach { appendLine("+$it") }
}.trimEnd()
}
internal sealed interface InferenceResult {
data class Success(val response: InferenceResponse) : InferenceResult
data class Failed(val reason: String) : InferenceResult