feat: surface ToolCallAssessedEvent to clients (plane-2 UX)

Final plane-2 step: the tool-call intent assessment was decided
server-side but never reached the client — DomainEventMapper dropped
ToolCallAssessedEvent through the else/null branch.

- ServerMessage.ToolAssessed (tool.assessed): disposition as a plain
  String + AssessedIssueDto list; observations stay off the wire
  (internal replay facts, invariant #9).
- DomainEventMapper maps the event 1:1 (no filtering — rendering
  decisions belong in the client).
- Go TUI consumes tool.assessed, records an event entry only for
  non-PROCEED or issue-bearing assessments (noise control), and is
  added to IsEventBearing() so it buffers correctly during snapshot
  replay like its sibling tool.* events.
This commit is contained in:
2026-05-31 16:49:00 +04:00
parent ff166ed311
commit 7fa1db8345
6 changed files with 112 additions and 26 deletions
@@ -1,5 +1,6 @@
package com.correx.apps.server.bridge
import com.correx.apps.server.protocol.AssessedIssueDto
import com.correx.apps.server.protocol.PauseReason
import com.correx.apps.server.protocol.RiskSummaryDto
import com.correx.apps.server.protocol.ServerMessage
@@ -21,6 +22,7 @@ import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolExecutionCompletedEvent
import com.correx.core.events.events.ToolExecutionFailedEvent
import com.correx.core.events.events.ToolExecutionRejectedEvent
import com.correx.core.events.events.ToolCallAssessedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.events.WorkflowCompletedEvent
@@ -160,6 +162,17 @@ suspend fun domainEventToServerMessage(
sessionSequence = sessionSequence,
)
is ToolCallAssessedEvent -> ServerMessage.ToolAssessed(
sessionId = p.sessionId,
stageId = p.stageId,
toolName = p.toolName,
disposition = p.disposition.name,
issues = p.issues.map { AssessedIssueDto(it.code, it.message, it.severity) },
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
sequence = seq,
sessionSequence = sessionSequence,
)
is ApprovalRequestedEvent -> mapApprovalRequested(p, seq, sessionSequence)
is ApprovalDecisionResolvedEvent -> ServerMessage.ApprovalResolved(
// ApprovalDecisionResolvedEvent has no sessionId on its payload — read it from the event envelope
@@ -12,6 +12,13 @@ data class RiskSummaryDto(
val rationale: List<String> = emptyList(),
)
@Serializable
data class AssessedIssueDto(
val code: String,
val message: String,
val severity: String,
)
@Serializable
data class ProviderHealthDto(
val providerId: String,
@@ -208,6 +208,19 @@ sealed interface ServerMessage {
override val sessionSequence: Long,
) : ServerMessage, SessionMessage
@Serializable
@SerialName("tool.assessed")
data class ToolAssessed(
val sessionId: SessionId,
val stageId: StageId,
val toolName: String,
val disposition: String,
val issues: List<AssessedIssueDto>,
val occurredAt: Long,
override val sequence: Long,
override val sessionSequence: Long,
) : ServerMessage, SessionMessage
// -- Approval --
@Serializable
@@ -1,5 +1,6 @@
package com.correx.apps.server.bridge
import com.correx.apps.server.protocol.AssessedIssueDto
import com.correx.apps.server.protocol.PauseReason
import com.correx.apps.server.protocol.ServerMessage
import com.correx.core.approvals.ApprovalOutcome
@@ -20,6 +21,8 @@ import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolExecutionCompletedEvent
import com.correx.core.events.events.ToolExecutionFailedEvent
import com.correx.core.events.events.ToolExecutionRejectedEvent
import com.correx.core.events.events.AssessedIssue
import com.correx.core.events.events.ToolCallAssessedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.ToolReceipt
import com.correx.core.events.events.ToolRequest
@@ -355,6 +358,35 @@ class DomainEventMapperTest {
)
}
@Test
fun `ToolCallAssessedEvent maps to ToolAssessed`(): Unit = runTest {
val event = storedEvent(
ToolCallAssessedEvent(
invocationId = ToolInvocationId("inv-1"),
sessionId = sessionId,
stageId = stageId,
toolName = "file_write",
issues = listOf(AssessedIssue(code = "PATH_ESCAPE", message = "escapes workspace", severity = "HIGH")),
disposition = RiskAction.BLOCK,
timestampMs = occurredAt,
),
)
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L)
assertEquals(
ServerMessage.ToolAssessed(
sessionId = sessionId,
stageId = stageId,
toolName = "file_write",
disposition = "BLOCK",
issues = listOf(AssessedIssueDto("PATH_ESCAPE", "escapes workspace", "HIGH")),
occurredAt = occurredAt,
sequence = event.sequence,
sessionSequence = 0L,
),
result,
)
}
@Test
fun `ApprovalRequestedEvent maps to ApprovalRequired with null riskSummary falls back to tier name`(): Unit = runTest {
val requestId = ApprovalRequestId("req-1")