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:
+52
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user