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
|
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.PauseReason
|
||||||
import com.correx.apps.server.protocol.RiskSummaryDto
|
import com.correx.apps.server.protocol.RiskSummaryDto
|
||||||
import com.correx.apps.server.protocol.ServerMessage
|
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.ToolExecutionCompletedEvent
|
||||||
import com.correx.core.events.events.ToolExecutionFailedEvent
|
import com.correx.core.events.events.ToolExecutionFailedEvent
|
||||||
import com.correx.core.events.events.ToolExecutionRejectedEvent
|
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.ToolInvocationRequestedEvent
|
||||||
import com.correx.core.events.events.TransitionExecutedEvent
|
import com.correx.core.events.events.TransitionExecutedEvent
|
||||||
import com.correx.core.events.events.WorkflowCompletedEvent
|
import com.correx.core.events.events.WorkflowCompletedEvent
|
||||||
@@ -160,6 +162,17 @@ suspend fun domainEventToServerMessage(
|
|||||||
sessionSequence = sessionSequence,
|
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 ApprovalRequestedEvent -> mapApprovalRequested(p, seq, sessionSequence)
|
||||||
is ApprovalDecisionResolvedEvent -> ServerMessage.ApprovalResolved(
|
is ApprovalDecisionResolvedEvent -> ServerMessage.ApprovalResolved(
|
||||||
// ApprovalDecisionResolvedEvent has no sessionId on its payload — read it from the event envelope
|
// 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(),
|
val rationale: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AssessedIssueDto(
|
||||||
|
val code: String,
|
||||||
|
val message: String,
|
||||||
|
val severity: String,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ProviderHealthDto(
|
data class ProviderHealthDto(
|
||||||
val providerId: String,
|
val providerId: String,
|
||||||
|
|||||||
@@ -208,6 +208,19 @@ sealed interface ServerMessage {
|
|||||||
override val sessionSequence: Long,
|
override val sessionSequence: Long,
|
||||||
) : ServerMessage, SessionMessage
|
) : 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 --
|
// -- Approval --
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.correx.apps.server.bridge
|
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.PauseReason
|
||||||
import com.correx.apps.server.protocol.ServerMessage
|
import com.correx.apps.server.protocol.ServerMessage
|
||||||
import com.correx.core.approvals.ApprovalOutcome
|
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.ToolExecutionCompletedEvent
|
||||||
import com.correx.core.events.events.ToolExecutionFailedEvent
|
import com.correx.core.events.events.ToolExecutionFailedEvent
|
||||||
import com.correx.core.events.events.ToolExecutionRejectedEvent
|
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.ToolInvocationRequestedEvent
|
||||||
import com.correx.core.events.events.ToolReceipt
|
import com.correx.core.events.events.ToolReceipt
|
||||||
import com.correx.core.events.events.ToolRequest
|
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
|
@Test
|
||||||
fun `ApprovalRequestedEvent maps to ApprovalRequired with null riskSummary falls back to tier name`(): Unit = runTest {
|
fun `ApprovalRequestedEvent maps to ApprovalRequired with null riskSummary falls back to tier name`(): Unit = runTest {
|
||||||
val requestId = ApprovalRequestId("req-1")
|
val requestId = ApprovalRequestId("req-1")
|
||||||
|
|||||||
@@ -134,6 +134,17 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
|||||||
if msg.Diff != nil && *msg.Diff != "" {
|
if msg.Diff != nil && *msg.Diff != "" {
|
||||||
m.appendRouter(msg.SessionID, RouterEntry{"tool", *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:
|
case protocol.TypeToolFailed:
|
||||||
if s := m.session(msg.SessionID); s != nil {
|
if s := m.session(msg.SessionID); s != nil {
|
||||||
s.markTool(msg.ToolName, ToolFailed)
|
s.markTool(msg.ToolName, ToolFailed)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const (
|
|||||||
TypeRouterResponse = "router.response"
|
TypeRouterResponse = "router.response"
|
||||||
TypeSnapshotComplete = "snapshot_complete"
|
TypeSnapshotComplete = "snapshot_complete"
|
||||||
TypeWorkflowList = "workflow.list"
|
TypeWorkflowList = "workflow.list"
|
||||||
|
TypeToolAssessed = "tool.assessed"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServerMessage is a flat decode of every server->client variant. Field names
|
// ServerMessage is a flat decode of every server->client variant. Field names
|
||||||
@@ -46,35 +47,37 @@ const (
|
|||||||
type ServerMessage struct {
|
type ServerMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
|
||||||
SessionID string `json:"sessionId"`
|
SessionID string `json:"sessionId"`
|
||||||
WorkflowID string `json:"workflowId"`
|
WorkflowID string `json:"workflowId"`
|
||||||
StageID string `json:"stageId"`
|
StageID string `json:"stageId"`
|
||||||
ToolName string `json:"toolName"`
|
ToolName string `json:"toolName"`
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
Summary string `json:"outputSummary"`
|
Summary string `json:"outputSummary"`
|
||||||
Response string `json:"responseText"`
|
Response string `json:"responseText"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Diff *string `json:"diff"`
|
Diff *string `json:"diff"`
|
||||||
Tier string `json:"tier"`
|
Tier string `json:"tier"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
RequestID string `json:"requestId"`
|
RequestID string `json:"requestId"`
|
||||||
ArtifactID string `json:"artifactId"`
|
ArtifactID string `json:"artifactId"`
|
||||||
ProviderID string `json:"providerId"`
|
ProviderID string `json:"providerId"`
|
||||||
Preview *string `json:"preview"`
|
Preview *string `json:"preview"`
|
||||||
|
Disposition string `json:"disposition"`
|
||||||
|
|
||||||
SteeringEmitted bool `json:"steeringEmitted"`
|
SteeringEmitted bool `json:"steeringEmitted"`
|
||||||
OccurredAt int64 `json:"occurredAt"`
|
OccurredAt int64 `json:"occurredAt"`
|
||||||
ElapsedMs int64 `json:"elapsedMs"`
|
ElapsedMs int64 `json:"elapsedMs"`
|
||||||
LastSequence int64 `json:"lastSequence"`
|
LastSequence int64 `json:"lastSequence"`
|
||||||
|
|
||||||
State *SessionStateDto `json:"state"`
|
State *SessionStateDto `json:"state"`
|
||||||
RiskSummary *RiskSummaryDto `json:"riskSummary"`
|
RiskSummary *RiskSummaryDto `json:"riskSummary"`
|
||||||
Status *ProviderHealthDto `json:"status"`
|
Status *ProviderHealthDto `json:"status"`
|
||||||
PendingAppr []ApprovalDto `json:"pendingApprovals"`
|
PendingAppr []ApprovalDto `json:"pendingApprovals"`
|
||||||
Tools []ToolRecordDto `json:"tools"`
|
Tools []ToolRecordDto `json:"tools"`
|
||||||
RecentEvents []EventEntryDto `json:"recentEvents"`
|
RecentEvents []EventEntryDto `json:"recentEvents"`
|
||||||
Stages []StageToolDecl `json:"stages"`
|
Stages []StageToolDecl `json:"stages"`
|
||||||
Workflows []WorkflowDto `json:"workflows"`
|
Workflows []WorkflowDto `json:"workflows"`
|
||||||
|
AssessedIssues []AssessedIssueDto `json:"issues"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SessionStateDto struct {
|
type SessionStateDto struct {
|
||||||
@@ -84,9 +87,15 @@ type SessionStateDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RiskSummaryDto struct {
|
type RiskSummaryDto struct {
|
||||||
Level string `json:"level"`
|
Level string `json:"level"`
|
||||||
Factors []string `json:"factors"`
|
Factors []string `json:"factors"`
|
||||||
RecommendedAction string `json:"recommendedAction"`
|
RecommendedAction string `json:"recommendedAction"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AssessedIssueDto struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProviderHealthDto struct {
|
type ProviderHealthDto struct {
|
||||||
@@ -147,6 +156,7 @@ func (m ServerMessage) IsEventBearing() bool {
|
|||||||
TypeStageStarted, TypeStageCompleted, TypeStageFailed,
|
TypeStageStarted, TypeStageCompleted, TypeStageFailed,
|
||||||
TypeInferenceStarted, TypeInferenceDone, TypeInferenceTimeout,
|
TypeInferenceStarted, TypeInferenceDone, TypeInferenceTimeout,
|
||||||
TypeToolStarted, TypeToolCompleted, TypeToolFailed, TypeToolRejected,
|
TypeToolStarted, TypeToolCompleted, TypeToolFailed, TypeToolRejected,
|
||||||
|
TypeToolAssessed,
|
||||||
TypeApprovalRequired, TypeArtifactCreated:
|
TypeApprovalRequired, TypeArtifactCreated:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
|
|||||||
Reference in New Issue
Block a user