From 7fa1db834593ff237407be2a2b5d8a62206945ae Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 31 May 2026 16:49:00 +0400 Subject: [PATCH] feat: surface ToolCallAssessedEvent to clients (plane-2 UX) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../apps/server/bridge/DomainEventMapper.kt | 13 ++++ .../com/correx/apps/server/protocol/Dtos.kt | 7 +++ .../apps/server/protocol/ServerMessage.kt | 13 ++++ .../server/bridge/DomainEventMapperTest.kt | 32 ++++++++++ apps/tui-go/internal/app/server.go | 11 ++++ apps/tui-go/internal/protocol/protocol.go | 62 +++++++++++-------- 6 files changed, 112 insertions(+), 26 deletions(-) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt b/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt index b1e1c752..40aead26 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt @@ -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 diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt index e0022f84..1a6810f6 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt @@ -12,6 +12,13 @@ data class RiskSummaryDto( val rationale: List = emptyList(), ) +@Serializable +data class AssessedIssueDto( + val code: String, + val message: String, + val severity: String, +) + @Serializable data class ProviderHealthDto( val providerId: String, diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt index 577baa9c..095e31c2 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt @@ -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, + val occurredAt: Long, + override val sequence: Long, + override val sessionSequence: Long, + ) : ServerMessage, SessionMessage + // -- Approval -- @Serializable diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/bridge/DomainEventMapperTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/bridge/DomainEventMapperTest.kt index c969ea2d..9176a88d 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/bridge/DomainEventMapperTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/bridge/DomainEventMapperTest.kt @@ -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") diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index f09273b6..55f77c8e 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -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) diff --git a/apps/tui-go/internal/protocol/protocol.go b/apps/tui-go/internal/protocol/protocol.go index ef94e841..25e909c1 100644 --- a/apps/tui-go/internal/protocol/protocol.go +++ b/apps/tui-go/internal/protocol/protocol.go @@ -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: