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:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user