fix: P1 TUI UX batch — race root cause, diff content, per-session routerMessages, optimistic IDLE submit

P1-1: Replace check-then-act with computeIfAbsent for activeSessionJobs to
prevent double-launch. Register pendingApprovals[requestId] BEFORE emitting
ApprovalRequestedEvent to close the approve-before-register window.
P1-2: ToolCompleted with a diff now appends msg.diff to routerMessages
instead of the static "--- diff from $toolName ---" marker.
P1-3: Change routerMessages from List<String> to Map<String, List<String>>
keyed by sessionId.value. RootReducer appends to the correct session's list;
RouterPanel renders only routerMessages[selectedId].
P1-4: On IDLE chat submit, SessionsReducer creates an optimistic
SessionSummary(status="STARTING") and sets selectedId.
processSessionStartedMessage removes any STARTING session and inserts the
real one on echo. RootReducer sets sessionEntered=true for the optimistic
session.
Tests: LaunchRegistrationRaceTest, ApprovalRegisterBeforeEmitTest,
RootReducerTest (diff/per-session/isolation/optimistic-submit),
SessionsReducerTest (optimistic/reconciliation). All P0/P1 tests pass.
./gradlew check green.
This commit is contained in:
2026-05-28 23:24:07 +04:00
parent eed557fe9c
commit 8ea3c381ef
10 changed files with 345 additions and 49 deletions
@@ -407,6 +407,8 @@ abstract class SessionOrchestrator(
}
// grant auto-approved — fall through to execute
} else {
val deferred = CompletableDeferred<ApprovalDecision>()
pendingApprovals[requestId] = deferred
emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING"))
emit(
sessionId,
@@ -422,8 +424,6 @@ abstract class SessionOrchestrator(
preview = toolPreview ?: toolCall.function.arguments.take(200),
),
)
val deferred = CompletableDeferred<ApprovalDecision>()
pendingApprovals[requestId] = deferred
val userDecision = try {
deferred.await()
} finally {
@@ -830,9 +830,11 @@ abstract class SessionOrchestrator(
outcome: ValidationOutcome.NeedsApproval,
): StageExecutionResult {
log.debug("[Orchestrator] approval required session={} stage={}", sessionId.value, stageId.value)
emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING"))
val domainRequest = buildApprovalRequest(sessionId, stageId, outcome)
val deferred = CompletableDeferred<ApprovalDecision>()
pendingApprovals[domainRequest.id] = deferred
emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING"))
emit(
sessionId,
ApprovalRequestedEvent(
@@ -846,9 +848,6 @@ abstract class SessionOrchestrator(
),
)
val deferred = CompletableDeferred<ApprovalDecision>()
pendingApprovals[domainRequest.id] = deferred
return try {
log.debug(
"[Orchestrator] awaiting approval session={} requestId={}",