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")
+11
View File
@@ -134,6 +134,17 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
if msg.Diff != nil && *msg.Diff != "" {
m.appendRouter(msg.SessionID, RouterEntry{"tool", *msg.Diff})
}
case protocol.TypeToolAssessed:
if s := m.session(msg.SessionID); s != nil {
if msg.Disposition != "PROCEED" || len(msg.AssessedIssues) > 0 {
detail := msg.Disposition
if len(msg.AssessedIssues) > 0 {
detail += " [" + msg.AssessedIssues[0].Code + "]"
}
s.LastOutput = msg.ToolName + " assessed: " + detail
s.addEvent(msg.OccurredAt, "ToolAssessed", detail)
}
}
case protocol.TypeToolFailed:
if s := m.session(msg.SessionID); s != nil {
s.markTool(msg.ToolName, ToolFailed)
+36 -26
View File
@@ -37,6 +37,7 @@ const (
TypeRouterResponse = "router.response"
TypeSnapshotComplete = "snapshot_complete"
TypeWorkflowList = "workflow.list"
TypeToolAssessed = "tool.assessed"
)
// ServerMessage is a flat decode of every server->client variant. Field names
@@ -46,35 +47,37 @@ const (
type ServerMessage struct {
Type string `json:"type"`
SessionID string `json:"sessionId"`
WorkflowID string `json:"workflowId"`
StageID string `json:"stageId"`
ToolName string `json:"toolName"`
Reason string `json:"reason"`
Summary string `json:"outputSummary"`
Response string `json:"responseText"`
Content string `json:"content"`
Diff *string `json:"diff"`
Tier string `json:"tier"`
Message string `json:"message"`
RequestID string `json:"requestId"`
ArtifactID string `json:"artifactId"`
ProviderID string `json:"providerId"`
Preview *string `json:"preview"`
SessionID string `json:"sessionId"`
WorkflowID string `json:"workflowId"`
StageID string `json:"stageId"`
ToolName string `json:"toolName"`
Reason string `json:"reason"`
Summary string `json:"outputSummary"`
Response string `json:"responseText"`
Content string `json:"content"`
Diff *string `json:"diff"`
Tier string `json:"tier"`
Message string `json:"message"`
RequestID string `json:"requestId"`
ArtifactID string `json:"artifactId"`
ProviderID string `json:"providerId"`
Preview *string `json:"preview"`
Disposition string `json:"disposition"`
SteeringEmitted bool `json:"steeringEmitted"`
OccurredAt int64 `json:"occurredAt"`
ElapsedMs int64 `json:"elapsedMs"`
LastSequence int64 `json:"lastSequence"`
State *SessionStateDto `json:"state"`
RiskSummary *RiskSummaryDto `json:"riskSummary"`
Status *ProviderHealthDto `json:"status"`
PendingAppr []ApprovalDto `json:"pendingApprovals"`
Tools []ToolRecordDto `json:"tools"`
RecentEvents []EventEntryDto `json:"recentEvents"`
Stages []StageToolDecl `json:"stages"`
Workflows []WorkflowDto `json:"workflows"`
State *SessionStateDto `json:"state"`
RiskSummary *RiskSummaryDto `json:"riskSummary"`
Status *ProviderHealthDto `json:"status"`
PendingAppr []ApprovalDto `json:"pendingApprovals"`
Tools []ToolRecordDto `json:"tools"`
RecentEvents []EventEntryDto `json:"recentEvents"`
Stages []StageToolDecl `json:"stages"`
Workflows []WorkflowDto `json:"workflows"`
AssessedIssues []AssessedIssueDto `json:"issues"`
}
type SessionStateDto struct {
@@ -84,9 +87,15 @@ type SessionStateDto struct {
}
type RiskSummaryDto struct {
Level string `json:"level"`
Factors []string `json:"factors"`
RecommendedAction string `json:"recommendedAction"`
Level string `json:"level"`
Factors []string `json:"factors"`
RecommendedAction string `json:"recommendedAction"`
}
type AssessedIssueDto struct {
Code string `json:"code"`
Message string `json:"message"`
Severity string `json:"severity"`
}
type ProviderHealthDto struct {
@@ -147,6 +156,7 @@ func (m ServerMessage) IsEventBearing() bool {
TypeStageStarted, TypeStageCompleted, TypeStageFailed,
TypeInferenceStarted, TypeInferenceDone, TypeInferenceTimeout,
TypeToolStarted, TypeToolCompleted, TypeToolFailed, TypeToolRejected,
TypeToolAssessed,
TypeApprovalRequired, TypeArtifactCreated:
return true
default: