Files
correx/apps/tui-go/internal/protocol/protocol.go
T

274 lines
9.5 KiB
Go

// Package protocol implements the correx WebSocket wire contract used to talk
// to apps/server over /stream. The server (Kotlin/kotlinx.serialization) uses a
// "type" class discriminator. Server->client variants carry @SerialName values
// (e.g. "session.announced"); client->server variants have no @SerialName, so the
// discriminator is the fully-qualified Kotlin class name.
package protocol
import "encoding/json"
// clientPrefix is the kotlinx default serial name for nested ClientMessage
// subclasses (qualified class name). The server decodes against this exact value.
const clientPrefix = "com.correx.apps.server.protocol.ClientMessage."
// Server message discriminator values (@SerialName on ServerMessage variants).
const (
TypeSessionAnnounced = "session.announced"
TypeChatTurn = "chat.turn"
TypeSessionPaused = "session.paused"
TypeSessionResumed = "session.resumed"
TypeSessionCompleted = "session.completed"
TypeSessionFailed = "session.failed"
TypeSessionSnapshot = "session_snapshot"
TypeStageStarted = "stage.started"
TypeStageCompleted = "stage.completed"
TypeStageFailed = "stage.failed"
TypeInferenceStarted = "inference.started"
TypeInferenceDone = "inference.completed"
TypeInferenceTimeout = "inference.timed_out"
TypeToolStarted = "tool.started"
TypeToolCompleted = "tool.completed"
TypeToolFailed = "tool.failed"
TypeToolRejected = "tool.rejected"
TypeApprovalRequired = "approval.required"
TypeApprovalResolved = "approval.resolved"
TypeWorkspaceBound = "session.workspace_bound"
TypeStageManifest = "stage.tool_manifest"
TypeProviderStatus = "provider.status_changed"
TypeProtocolError = "protocol_error"
TypeArtifactCreated = "artifact.created"
TypeRouterResponse = "router.response"
TypeSnapshotComplete = "snapshot_complete"
TypeWorkflowList = "workflow.list"
TypeToolAssessed = "tool.assessed"
TypeModelChanged = "model.changed"
TypeModelList = "model.list"
TypeResourceStatus = "resource.status"
TypeRouterNarration = "router.narration"
)
// ServerMessage is a flat decode of every server->client variant. Field names
// across variants don't collide in incompatible JSON types, so a single struct
// keyed by Type decodes the whole protocol. Value-class IDs serialize as plain
// strings (verified against ServerMessageSerializationTest).
type ServerMessage struct {
Type string `json:"type"`
SessionID string `json:"sessionId"`
WorkflowID string `json:"workflowId"`
StageID string `json:"stageId"`
ToolName string `json:"toolName"`
Role string `json:"role"`
TurnID string `json:"turnId"`
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"`
Outcome string `json:"outcome"` // approval.resolved: APPROVED | REJECTED | AUTO_APPROVED
WorkspaceRoot string `json:"workspaceRoot"` // session.workspace_bound: the bound cwd
SteeringEmitted bool `json:"steeringEmitted"`
OccurredAt int64 `json:"occurredAt"`
ElapsedMs int64 `json:"elapsedMs"`
LastSequence int64 `json:"lastSequence"`
// model.changed
ModelID string `json:"modelId"`
Loaded bool `json:"loaded"`
// model.list (current reuses the field below)
Models []string `json:"models"`
Current string `json:"current"`
// chat.turn router metrics (nil when not a ROUTER turn or server omits them)
LatencyMs *int64 `json:"latencyMs"`
TotalTokens *int `json:"totalTokens"`
// resource.status (nil = unavailable)
GpuMemoryUsedMb *int64 `json:"gpuMemoryUsedMb"`
GpuMemoryTotalMb *int64 `json:"gpuMemoryTotalMb"`
GpuUtilizationPct *int `json:"gpuUtilizationPct"`
ProcessRssMb *int64 `json:"processRssMb"`
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 {
Status string `json:"status"`
CurrentStageID *string `json:"currentStageId"`
PauseReason *string `json:"pauseReason"`
}
type RiskSummaryDto struct {
Level string `json:"level"`
Factors []string `json:"factors"`
RecommendedAction string `json:"recommendedAction"`
Rationale []string `json:"rationale"`
}
type AssessedIssueDto struct {
Code string `json:"code"`
Message string `json:"message"`
Severity string `json:"severity"`
}
type ProviderHealthDto struct {
ProviderID string `json:"providerId"`
Status string `json:"status"`
LoadPercent *int `json:"loadPercent"`
}
type ApprovalDto struct {
RequestID string `json:"requestId"`
Tier string `json:"tier"`
ToolName *string `json:"toolName"`
Preview *string `json:"preview"`
}
type StageToolDecl struct {
StageID string `json:"stageId"`
Tools []ToolDecl `json:"tools"`
}
type ToolDecl struct {
Name string `json:"name"`
Tier int `json:"tier"`
}
type EventEntryDto struct {
Timestamp int64 `json:"timestamp"`
Type string `json:"type"`
Detail string `json:"detail"`
}
type ToolRecordDto struct {
Name string `json:"name"`
Tier int `json:"tier"`
Status string `json:"status"`
Diff *string `json:"diff"`
}
type WorkflowDto struct {
WorkflowID string `json:"workflowId"`
Description string `json:"description"`
}
// Decode parses one server frame into a ServerMessage.
func Decode(raw []byte) (ServerMessage, error) {
var m ServerMessage
err := json.Unmarshal(raw, &m)
return m, err
}
// IsEventBearing reports whether a message carries a session event (and so must
// be buffered during the snapshot phase). Control/snapshot messages return false
// and are applied immediately.
func (m ServerMessage) IsEventBearing() bool {
switch m.Type {
case TypeSessionAnnounced, TypeChatTurn, TypeSessionPaused, TypeSessionResumed,
TypeSessionCompleted, TypeSessionFailed,
TypeStageStarted, TypeStageCompleted, TypeStageFailed,
TypeInferenceStarted, TypeInferenceDone, TypeInferenceTimeout,
TypeToolStarted, TypeToolCompleted, TypeToolFailed, TypeToolRejected,
TypeToolAssessed,
TypeApprovalRequired, TypeApprovalResolved, TypeWorkspaceBound, TypeArtifactCreated,
TypeRouterNarration:
return true
default:
return false
}
}
// --- Client -> server encoders ---
func encode(typeName string, fields map[string]any) []byte {
fields["type"] = clientPrefix + typeName
b, _ := json.Marshal(fields)
return b
}
// StartChatSession opens a new chat session with a client-generated sessionId.
func StartChatSession(sessionID, text string) []byte {
return encode("StartChatSession", map[string]any{"sessionId": sessionID, "text": text})
}
// ChatInput sends a turn into an existing session. mode is "CHAT" or "STEERING".
func ChatInput(sessionID, text, mode string) []byte {
return encode("ChatInput", map[string]any{"sessionId": sessionID, "text": text, "mode": mode})
}
// StartSession launches a workflow-backed session. input is the optional freeform request
// seeded into the decision journal; pass "" for fixed-task workflows (e.g. healthcheck).
func StartSession(workflowID, input string) []byte {
msg := map[string]any{"workflowId": workflowID, "config": nil}
if input != "" {
msg["input"] = input
}
return encode("StartSession", msg)
}
// CancelSession cancels a running session.
func CancelSession(sessionID string) []byte {
return encode("CancelSession", map[string]any{"sessionId": sessionID})
}
// ApprovalResponse answers an approval gate. decision is "APPROVE" or "REJECT".
func ApprovalResponse(requestID, decision string, steeringNote *string) []byte {
return encode("ApprovalResponse", map[string]any{
"requestId": requestID,
"decision": decision,
"steeringNote": steeringNote,
})
}
// Ping is a keepalive.
func Ping(ts int64) []byte {
return encode("Ping", map[string]any{"timestamp": ts})
}
// SwapModel asks the server to make modelID resident and pin it.
func SwapModel(modelID string) []byte {
return encode("SwapModel", map[string]any{"modelId": modelID})
}
// ClearModelPin releases the manual-swap pin so per-stage selection resumes.
func ClearModelPin() []byte {
return encode("ClearModelPin", map[string]any{})
}
// CreateGrant pre-authorizes a tool/tier so future calls skip the approval gate.
// scope is "SESSION" or "STAGE"; permittedTiers are tier names like "T3".
func CreateGrant(sessionID, scope string, permittedTiers []string, reason, toolName string) []byte {
return encode("CreateGrant", map[string]any{
"sessionId": sessionID,
"scope": scope,
"stageId": nil,
"permittedTiers": permittedTiers,
"reason": reason,
"expiresAt": nil,
"toolName": toolName,
})
}
// Hello is the first frame sent on every (re)connect. It carries the client's
// working directory so the server can bind a workspace before any session starts.
func Hello(workingDir string) []byte {
return encode("Hello", map[string]any{"workingDir": workingDir})
}