diff --git a/internal/gateway/contracts.go b/internal/gateway/contracts.go new file mode 100644 index 00000000..88eba546 --- /dev/null +++ b/internal/gateway/contracts.go @@ -0,0 +1,174 @@ +package gateway + +import ( + "context" + "time" +) + +// RuntimeEventType 表示运行事件类型。 +type RuntimeEventType string + +const ( + // RuntimeEventTypeRunProgress 表示运行过程事件。 + RuntimeEventTypeRunProgress RuntimeEventType = "run_progress" + // RuntimeEventTypeRunDone 表示运行完成事件。 + RuntimeEventTypeRunDone RuntimeEventType = "run_done" + // RuntimeEventTypeRunError 表示运行错误事件。 + RuntimeEventTypeRunError RuntimeEventType = "run_error" +) + +// PermissionResolutionDecision 表示权限审批最终决策。 +type PermissionResolutionDecision string + +const ( + // PermissionResolutionAllowOnce 表示仅本次允许。 + PermissionResolutionAllowOnce PermissionResolutionDecision = "allow_once" + // PermissionResolutionAllowSession 表示在当前会话中持续允许。 + PermissionResolutionAllowSession PermissionResolutionDecision = "allow_session" + // PermissionResolutionReject 表示拒绝本次审批。 + PermissionResolutionReject PermissionResolutionDecision = "reject" +) + +// PermissionResolutionInput 表示一次权限审批决策输入。 +type PermissionResolutionInput struct { + // RequestID 是待审批请求标识。 + RequestID string `json:"request_id"` + // Decision 是审批决策值。 + Decision PermissionResolutionDecision `json:"decision"` +} + +// RunInput 表示网关向下游运行端口发起 run 动作时的输入。 +type RunInput struct { + // RequestID 是客户端请求标识。 + RequestID string + // SessionID 是会话标识。 + SessionID string + // RunID 是运行标识。 + RunID string + // InputText 是文本输入。 + InputText string + // InputParts 是多模态输入分片。 + InputParts []InputPart + // Workdir 是请求级工作目录覆盖值。 + Workdir string +} + +// CompactInput 表示网关向下游运行端口发起 compact 动作时的输入。 +type CompactInput struct { + // RequestID 是客户端请求标识。 + RequestID string + // SessionID 是会话标识。 + SessionID string + // RunID 是运行标识。 + RunID string +} + +// CompactResult 表示 compact 动作完成后返回的结果。 +type CompactResult struct { + // Applied 表示是否实际应用压缩结果。 + Applied bool + // BeforeChars 是压缩前字符数。 + BeforeChars int + // AfterChars 是压缩后字符数。 + AfterChars int + // SavedRatio 是压缩节省比例。 + SavedRatio float64 + // TriggerMode 是触发模式标识。 + TriggerMode string + // TranscriptID 是压缩产物标识。 + TranscriptID string + // TranscriptPath 是压缩产物路径。 + TranscriptPath string +} + +// RuntimeEvent 表示运行端口推送给网关的统一事件。 +type RuntimeEvent struct { + // Type 是事件类型。 + Type RuntimeEventType `json:"type"` + // RunID 是运行标识。 + RunID string `json:"run_id,omitempty"` + // SessionID 是会话标识。 + SessionID string `json:"session_id,omitempty"` + // Payload 是事件扩展负载。 + Payload any `json:"payload,omitempty"` +} + +// ToolCall 表示助手消息中的工具调用元数据。 +type ToolCall struct { + // ID 是工具调用标识。 + ID string `json:"id"` + // Name 是工具名。 + Name string `json:"name"` + // Arguments 是工具参数 JSON 字符串。 + Arguments string `json:"arguments"` +} + +// SessionMessage 表示会话消息快照中的单条消息。 +type SessionMessage struct { + // Role 是消息角色。 + Role string `json:"role"` + // Content 是消息内容。 + Content string `json:"content"` + // ToolCalls 是 assistant 发起的工具调用元数据。 + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + // ToolCallID 是工具消息关联的调用标识。 + ToolCallID string `json:"tool_call_id,omitempty"` + // IsError 表示该消息是否为错误结果。 + IsError bool `json:"is_error,omitempty"` +} + +// Session 表示网关视角的会话详情。 +type Session struct { + // ID 是会话标识。 + ID string `json:"id"` + // Title 是会话标题。 + Title string `json:"title"` + // CreatedAt 是会话创建时间。 + CreatedAt time.Time `json:"created_at"` + // UpdatedAt 是会话更新时间。 + UpdatedAt time.Time `json:"updated_at"` + // Workdir 是会话工作目录。 + Workdir string `json:"workdir,omitempty"` + // Messages 是会话消息快照。 + Messages []SessionMessage `json:"messages,omitempty"` +} + +// SessionSummary 表示会话列表项摘要。 +type SessionSummary struct { + // ID 是会话标识。 + ID string `json:"id"` + // Title 是会话标题。 + Title string `json:"title"` + // CreatedAt 是会话创建时间。 + CreatedAt time.Time `json:"created_at"` + // UpdatedAt 是会话更新时间。 + UpdatedAt time.Time `json:"updated_at"` +} + +// RuntimePort 定义网关访问运行时编排的下游端口契约。 +type RuntimePort interface { + // Run 启动一次运行编排。 + Run(ctx context.Context, input RunInput) error + // Compact 对指定会话触发一次手动压缩。 + Compact(ctx context.Context, input CompactInput) (CompactResult, error) + // ResolvePermission 向运行时提交一次权限审批决策。 + ResolvePermission(ctx context.Context, input PermissionResolutionInput) error + // CancelActiveRun 取消当前活跃运行。 + CancelActiveRun() bool + // Events 返回统一运行事件流。 + Events() <-chan RuntimeEvent + // ListSessions 返回会话摘要列表。 + ListSessions(ctx context.Context) ([]SessionSummary, error) + // LoadSession 加载指定会话详情。 + LoadSession(ctx context.Context, id string) (Session, error) + // SetSessionWorkdir 更新会话工作目录。 + SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (Session, error) +} + +// Gateway 定义网关主契约。 +type Gateway interface { + // Serve 启动网关服务并绑定运行端口。 + Serve(ctx context.Context, runtimePort RuntimePort) error + // Close 优雅关闭网关服务。 + Close(ctx context.Context) error +} diff --git a/internal/gateway/contracts_test.go b/internal/gateway/contracts_test.go new file mode 100644 index 00000000..e4a4e50c --- /dev/null +++ b/internal/gateway/contracts_test.go @@ -0,0 +1,40 @@ +package gateway + +import "context" + +// runtimePortCompileStub 用于编译期验证 RuntimePort 契约完整性。 +type runtimePortCompileStub struct{} + +func (s *runtimePortCompileStub) Run(_ context.Context, _ RunInput) error { + return nil +} + +func (s *runtimePortCompileStub) Compact(_ context.Context, _ CompactInput) (CompactResult, error) { + return CompactResult{}, nil +} + +func (s *runtimePortCompileStub) ResolvePermission(_ context.Context, _ PermissionResolutionInput) error { + return nil +} + +func (s *runtimePortCompileStub) CancelActiveRun() bool { + return false +} + +func (s *runtimePortCompileStub) Events() <-chan RuntimeEvent { + return nil +} + +func (s *runtimePortCompileStub) ListSessions(_ context.Context) ([]SessionSummary, error) { + return nil, nil +} + +func (s *runtimePortCompileStub) LoadSession(_ context.Context, _ string) (Session, error) { + return Session{}, nil +} + +func (s *runtimePortCompileStub) SetSessionWorkdir(_ context.Context, _, _ string) (Session, error) { + return Session{}, nil +} + +var _ RuntimePort = (*runtimePortCompileStub)(nil) diff --git a/internal/gateway/errors.go b/internal/gateway/errors.go new file mode 100644 index 00000000..c5e72aba --- /dev/null +++ b/internal/gateway/errors.go @@ -0,0 +1,54 @@ +package gateway + +import "fmt" + +// ErrorCode 表示网关协议层稳定错误码。 +type ErrorCode string + +const ( + // ErrorCodeInvalidFrame 表示帧结构或帧类型非法。 + ErrorCodeInvalidFrame ErrorCode = "invalid_frame" + // ErrorCodeInvalidAction 表示动作值非法。 + ErrorCodeInvalidAction ErrorCode = "invalid_action" + // ErrorCodeInvalidMultimodalPayload 表示多模态输入负载非法。 + ErrorCodeInvalidMultimodalPayload ErrorCode = "invalid_multimodal_payload" + // ErrorCodeMissingRequiredField 表示缺少必填字段。 + ErrorCodeMissingRequiredField ErrorCode = "missing_required_field" + // ErrorCodeUnsupportedAction 表示动作暂不支持。 + ErrorCodeUnsupportedAction ErrorCode = "unsupported_action" + // ErrorCodeInternalError 表示网关内部错误。 + ErrorCodeInternalError ErrorCode = "internal_error" +) + +var stableErrorCodes = map[string]struct{}{ + string(ErrorCodeInvalidFrame): {}, + string(ErrorCodeInvalidAction): {}, + string(ErrorCodeInvalidMultimodalPayload): {}, + string(ErrorCodeMissingRequiredField): {}, + string(ErrorCodeUnsupportedAction): {}, + string(ErrorCodeInternalError): {}, +} + +// String 返回错误码的字符串值。 +func (c ErrorCode) String() string { + return string(c) +} + +// NewFrameError 创建统一格式的协议错误对象。 +func NewFrameError(code ErrorCode, message string) *FrameError { + return &FrameError{ + Code: code.String(), + Message: message, + } +} + +// NewMissingRequiredFieldError 创建缺少必填字段的错误对象。 +func NewMissingRequiredFieldError(field string) *FrameError { + return NewFrameError(ErrorCodeMissingRequiredField, fmt.Sprintf("missing required field: %s", field)) +} + +// IsStableErrorCode 判断给定字符串是否为网关稳定错误码。 +func IsStableErrorCode(code string) bool { + _, exists := stableErrorCodes[code] + return exists +} diff --git a/internal/gateway/errors_test.go b/internal/gateway/errors_test.go new file mode 100644 index 00000000..1c07cc5d --- /dev/null +++ b/internal/gateway/errors_test.go @@ -0,0 +1,37 @@ +package gateway + +import "testing" + +func TestStableErrorCodes(t *testing.T) { + codes := []ErrorCode{ + ErrorCodeInvalidFrame, + ErrorCodeInvalidAction, + ErrorCodeInvalidMultimodalPayload, + ErrorCodeMissingRequiredField, + ErrorCodeUnsupportedAction, + ErrorCodeInternalError, + } + + for _, code := range codes { + if !IsStableErrorCode(code.String()) { + t.Fatalf("expected code %q to be stable", code) + } + } + + if IsStableErrorCode("unknown_code") { + t.Fatalf("unknown code should not be stable") + } +} + +func TestNewMissingRequiredFieldError(t *testing.T) { + err := NewMissingRequiredFieldError("session_id") + if err == nil { + t.Fatalf("expected non-nil error") + } + if err.Code != ErrorCodeMissingRequiredField.String() { + t.Fatalf("error code mismatch: got %q", err.Code) + } + if err.Message == "" { + t.Fatalf("error message should not be empty") + } +} diff --git a/internal/gateway/types.go b/internal/gateway/types.go new file mode 100644 index 00000000..281c8132 --- /dev/null +++ b/internal/gateway/types.go @@ -0,0 +1,97 @@ +package gateway + +// FrameType 表示网关协议帧类型。 +type FrameType string + +const ( + // FrameTypeRequest 表示客户端发往网关的请求帧。 + FrameTypeRequest FrameType = "request" + // FrameTypeEvent 表示网关推送给客户端的事件帧。 + FrameTypeEvent FrameType = "event" + // FrameTypeError 表示网关推送给客户端的错误帧。 + FrameTypeError FrameType = "error" + // FrameTypeAck 表示网关对请求的接收确认帧。 + FrameTypeAck FrameType = "ack" +) + +// FrameAction 表示请求动作类型。 +type FrameAction string + +const ( + // FrameActionRun 表示发起一次运行。 + FrameActionRun FrameAction = "run" + // FrameActionCompact 表示触发一次手动压缩。 + FrameActionCompact FrameAction = "compact" + // FrameActionCancel 表示取消当前活跃运行。 + FrameActionCancel FrameAction = "cancel" + // FrameActionListSessions 表示获取会话摘要列表。 + FrameActionListSessions FrameAction = "list_sessions" + // FrameActionLoadSession 表示加载指定会话详情。 + FrameActionLoadSession FrameAction = "load_session" + // FrameActionSetSessionWorkdir 表示设置会话工作目录。 + FrameActionSetSessionWorkdir FrameAction = "set_session_workdir" + // FrameActionResolvePermission 表示提交一次权限审批决策。 + FrameActionResolvePermission FrameAction = "resolve_permission" +) + +// InputPartType 表示多模态输入分片类型。 +type InputPartType string + +const ( + // InputPartTypeText 表示文本分片。 + InputPartTypeText InputPartType = "text" + // InputPartTypeImage 表示图片分片。 + InputPartTypeImage InputPartType = "image" +) + +// Media 表示非文本输入的媒体描述。 +type Media struct { + // URI 是媒体资源地址。 + URI string `json:"uri"` + // MimeType 是媒体 MIME 类型。 + MimeType string `json:"mime_type"` + // FileName 是媒体文件名。 + FileName string `json:"file_name,omitempty"` +} + +// InputPart 表示网关协议中的多模态输入分片。 +type InputPart struct { + // Type 表示分片类型,如 text / image。 + Type InputPartType `json:"type"` + // Text 是文本分片内容,仅 text 类型使用。 + Text string `json:"text,omitempty"` + // Media 是非文本分片媒体信息,仅 image 等类型使用。 + Media *Media `json:"media,omitempty"` +} + +// FrameError 表示协议帧中的错误信息。 +type FrameError struct { + // Code 是稳定错误码,供客户端做分支判断。 + Code string `json:"code"` + // Message 是面向用户或日志的错误描述。 + Message string `json:"message"` +} + +// MessageFrame 是网关与客户端之间的统一通信帧。 +type MessageFrame struct { + // Type 是帧类型。 + Type FrameType `json:"type"` + // Action 是请求动作,非 request 帧可为空。 + Action FrameAction `json:"action,omitempty"` + // RequestID 是客户端请求幂等标识。 + RequestID string `json:"request_id,omitempty"` + // RunID 是运行标识。 + RunID string `json:"run_id,omitempty"` + // SessionID 是会话标识。 + SessionID string `json:"session_id,omitempty"` + // InputText 是文本输入内容。 + InputText string `json:"input_text,omitempty"` + // InputParts 是多模态输入分片。 + InputParts []InputPart `json:"input_parts,omitempty"` + // Workdir 是本次请求的工作目录覆盖值。 + Workdir string `json:"workdir,omitempty"` + // Payload 是动作扩展负载或事件负载。 + Payload any `json:"payload,omitempty"` + // Error 是错误帧负载。 + Error *FrameError `json:"error,omitempty"` +} diff --git a/internal/gateway/types_test.go b/internal/gateway/types_test.go new file mode 100644 index 00000000..8641b0f9 --- /dev/null +++ b/internal/gateway/types_test.go @@ -0,0 +1,120 @@ +package gateway + +import ( + "encoding/json" + "testing" + "time" +) + +func TestMessageFrameJSONRoundTrip(t *testing.T) { + original := MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionRun, + RequestID: "req_001", + RunID: "run_123", + SessionID: "sess_abc", + InputText: "请分析这张图", + InputParts: []InputPart{ + { + Type: InputPartTypeText, + Text: "请先读取图片中的文字", + }, + { + Type: InputPartTypeImage, + Media: &Media{ + URI: "file:///workspace/assets/screen.png", + MimeType: "image/png", + FileName: "screen.png", + }, + }, + }, + Workdir: "/workspace/project", + Error: &FrameError{ + Code: ErrorCodeInvalidFrame.String(), + Message: "invalid frame", + }, + } + + payload, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded MessageFrame + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.Type != original.Type { + t.Fatalf("type mismatch: got %q want %q", decoded.Type, original.Type) + } + if decoded.Action != original.Action { + t.Fatalf("action mismatch: got %q want %q", decoded.Action, original.Action) + } + if decoded.RequestID != original.RequestID { + t.Fatalf("request_id mismatch: got %q want %q", decoded.RequestID, original.RequestID) + } + if decoded.SessionID != original.SessionID { + t.Fatalf("session_id mismatch: got %q want %q", decoded.SessionID, original.SessionID) + } + if len(decoded.InputParts) != 2 { + t.Fatalf("input_parts mismatch: got %d want %d", len(decoded.InputParts), 2) + } + if decoded.InputParts[1].Media == nil || decoded.InputParts[1].Media.MimeType != "image/png" { + t.Fatalf("media mime_type mismatch in image part") + } + if decoded.Error == nil || decoded.Error.Code != original.Error.Code { + t.Fatalf("error code mismatch") + } +} + +func TestSessionMessageToolCallsRoundTrip(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + original := Session{ + ID: "sess_1", + Title: "demo", + CreatedAt: now, + UpdatedAt: now, + Messages: []SessionMessage{ + { + Role: "assistant", + Content: "我准备调用工具", + ToolCalls: []ToolCall{ + { + ID: "call_1", + Name: "filesystem_read", + Arguments: `{"path":"README.md"}`, + }, + }, + }, + { + Role: "tool", + Content: "tool result", + ToolCallID: "call_1", + }, + }, + } + + encoded, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal session failed: %v", err) + } + + var decoded Session + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatalf("unmarshal session failed: %v", err) + } + + if len(decoded.Messages) != 2 { + t.Fatalf("message count mismatch: got %d want %d", len(decoded.Messages), 2) + } + if len(decoded.Messages[0].ToolCalls) != 1 { + t.Fatalf("assistant tool_calls count mismatch: got %d want %d", len(decoded.Messages[0].ToolCalls), 1) + } + if decoded.Messages[0].ToolCalls[0].Name != "filesystem_read" { + t.Fatalf("tool call name mismatch: got %q", decoded.Messages[0].ToolCalls[0].Name) + } + if decoded.Messages[0].ToolCalls[0].Arguments != `{"path":"README.md"}` { + t.Fatalf("tool call arguments mismatch: got %q", decoded.Messages[0].ToolCalls[0].Arguments) + } +} diff --git a/internal/gateway/validate.go b/internal/gateway/validate.go new file mode 100644 index 00000000..3be96857 --- /dev/null +++ b/internal/gateway/validate.go @@ -0,0 +1,189 @@ +package gateway + +import ( + "encoding/json" + "errors" + "strings" +) + +// ValidateFrame 校验网关协议帧是否满足基础契约约束。 +func ValidateFrame(frame MessageFrame) *FrameError { + if !isValidFrameType(frame.Type) { + return NewFrameError(ErrorCodeInvalidFrame, "invalid frame type") + } + + if strings.TrimSpace(string(frame.Action)) != "" && !isValidFrameAction(frame.Action) { + return NewFrameError(ErrorCodeInvalidAction, "invalid action") + } + + if frame.Type == FrameTypeRequest { + return validateRequestFrame(frame) + } + + return nil +} + +// validateRequestFrame 校验 request 帧的动作及动作所需字段。 +func validateRequestFrame(frame MessageFrame) *FrameError { + if strings.TrimSpace(string(frame.Action)) == "" { + return NewMissingRequiredFieldError("action") + } + + switch frame.Action { + case FrameActionRun: + return validateRunFrame(frame) + case FrameActionCompact, FrameActionLoadSession: + if strings.TrimSpace(frame.SessionID) == "" { + return NewMissingRequiredFieldError("session_id") + } + case FrameActionSetSessionWorkdir: + if strings.TrimSpace(frame.SessionID) == "" { + return NewMissingRequiredFieldError("session_id") + } + if strings.TrimSpace(frame.Workdir) == "" { + return NewMissingRequiredFieldError("workdir") + } + case FrameActionResolvePermission: + return validateResolvePermissionFrame(frame) + case FrameActionCancel, FrameActionListSessions: + return nil + default: + return NewFrameError(ErrorCodeInvalidAction, "invalid action") + } + + if len(frame.InputParts) > 0 { + return validateInputParts(frame.InputParts) + } + + return nil +} + +// validateRunFrame 校验 run 动作的输入字段是否完整且合法。 +func validateRunFrame(frame MessageFrame) *FrameError { + hasText := strings.TrimSpace(frame.InputText) != "" + hasParts := len(frame.InputParts) > 0 + if !hasText && !hasParts { + return NewMissingRequiredFieldError("input_text_or_input_parts") + } + + if hasParts { + return validateInputParts(frame.InputParts) + } + + return nil +} + +// validateResolvePermissionFrame 校验 resolve_permission 动作所需字段。 +func validateResolvePermissionFrame(frame MessageFrame) *FrameError { + if frame.Payload == nil { + return NewMissingRequiredFieldError("payload") + } + + input, err := decodePermissionResolutionInput(frame.Payload) + if err != nil { + return NewFrameError(ErrorCodeInvalidAction, "invalid resolve_permission payload") + } + if strings.TrimSpace(input.RequestID) == "" { + return NewMissingRequiredFieldError("payload.request_id") + } + if !isValidPermissionResolutionDecision(input.Decision) { + return NewFrameError(ErrorCodeInvalidAction, "invalid resolve_permission decision") + } + + return nil +} + +// decodePermissionResolutionInput 将 payload 解析为权限审批决策输入。 +func decodePermissionResolutionInput(payload any) (PermissionResolutionInput, error) { + if direct, ok := payload.(PermissionResolutionInput); ok { + return direct, nil + } + if ptr, ok := payload.(*PermissionResolutionInput); ok { + if ptr == nil { + return PermissionResolutionInput{}, errors.New("permission payload is nil") + } + return *ptr, nil + } + + raw, err := json.Marshal(payload) + if err != nil { + return PermissionResolutionInput{}, err + } + + var input PermissionResolutionInput + if err := json.Unmarshal(raw, &input); err != nil { + return PermissionResolutionInput{}, err + } + return input, nil +} + +// isValidPermissionResolutionDecision 判断审批决策是否属于受支持集合。 +func isValidPermissionResolutionDecision(decision PermissionResolutionDecision) bool { + switch decision { + case PermissionResolutionAllowOnce, PermissionResolutionAllowSession, PermissionResolutionReject: + return true + default: + return false + } +} + +// validateInputParts 校验多模态输入分片数组。 +func validateInputParts(parts []InputPart) *FrameError { + for index := range parts { + if err := validateInputPart(parts[index], index); err != nil { + return err + } + } + return nil +} + +// validateInputPart 校验单个多模态输入分片。 +func validateInputPart(part InputPart, index int) *FrameError { + switch part.Type { + case InputPartTypeText: + if strings.TrimSpace(part.Text) == "" { + return NewFrameError(ErrorCodeInvalidMultimodalPayload, "input_parts[text] requires non-empty text") + } + case InputPartTypeImage: + if part.Media == nil { + return NewFrameError(ErrorCodeInvalidMultimodalPayload, "input_parts[image] requires media") + } + if strings.TrimSpace(part.Media.URI) == "" { + return NewFrameError(ErrorCodeInvalidMultimodalPayload, "input_parts[image] requires media.uri") + } + if strings.TrimSpace(part.Media.MimeType) == "" { + return NewFrameError(ErrorCodeInvalidMultimodalPayload, "input_parts[image] requires media.mime_type") + } + default: + _ = index + return NewFrameError(ErrorCodeInvalidMultimodalPayload, "input_parts contains unsupported type") + } + + return nil +} + +// isValidFrameType 判断帧类型是否属于协议定义集合。 +func isValidFrameType(frameType FrameType) bool { + switch frameType { + case FrameTypeRequest, FrameTypeEvent, FrameTypeError, FrameTypeAck: + return true + default: + return false + } +} + +// isValidFrameAction 判断动作是否属于协议定义集合。 +func isValidFrameAction(action FrameAction) bool { + switch action { + case FrameActionRun, + FrameActionCompact, + FrameActionCancel, + FrameActionListSessions, + FrameActionLoadSession, + FrameActionSetSessionWorkdir, + FrameActionResolvePermission: + return true + default: + return false + } +} diff --git a/internal/gateway/validate_test.go b/internal/gateway/validate_test.go new file mode 100644 index 00000000..d1e86c92 --- /dev/null +++ b/internal/gateway/validate_test.go @@ -0,0 +1,287 @@ +package gateway + +import ( + "strings" + "testing" +) + +func TestValidateFrame_BasicRules(t *testing.T) { + tests := []struct { + name string + frame MessageFrame + wantNil bool + wantCode string + wantField string + }{ + { + name: "valid run with input_text", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionRun, + InputText: "hello", + }, + wantNil: true, + }, + { + name: "invalid frame type", + frame: MessageFrame{ + Type: FrameType("unknown"), + }, + wantCode: ErrorCodeInvalidFrame.String(), + }, + { + name: "request missing action", + frame: MessageFrame{ + Type: FrameTypeRequest, + }, + wantCode: ErrorCodeMissingRequiredField.String(), + wantField: "action", + }, + { + name: "request invalid action", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameAction("foo"), + }, + wantCode: ErrorCodeInvalidAction.String(), + }, + { + name: "run missing both input_text and input_parts", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionRun, + }, + wantCode: ErrorCodeMissingRequiredField.String(), + wantField: "input_text_or_input_parts", + }, + { + name: "compact missing session_id", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionCompact, + }, + wantCode: ErrorCodeMissingRequiredField.String(), + wantField: "session_id", + }, + { + name: "load_session missing session_id", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionLoadSession, + }, + wantCode: ErrorCodeMissingRequiredField.String(), + wantField: "session_id", + }, + { + name: "set_session_workdir missing workdir", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionSetSessionWorkdir, + SessionID: "sess_1", + }, + wantCode: ErrorCodeMissingRequiredField.String(), + wantField: "workdir", + }, + { + name: "set_session_workdir valid", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionSetSessionWorkdir, + SessionID: "sess_1", + Workdir: "/workspace", + }, + wantNil: true, + }, + { + name: "resolve_permission valid struct payload", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionResolvePermission, + Payload: PermissionResolutionInput{ + RequestID: "perm-1", + Decision: PermissionResolutionAllowOnce, + }, + }, + wantNil: true, + }, + { + name: "resolve_permission missing payload", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionResolvePermission, + }, + wantCode: ErrorCodeMissingRequiredField.String(), + wantField: "payload", + }, + { + name: "resolve_permission missing request_id", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionResolvePermission, + Payload: map[string]any{ + "decision": "allow_session", + }, + }, + wantCode: ErrorCodeMissingRequiredField.String(), + wantField: "payload.request_id", + }, + { + name: "resolve_permission invalid decision", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionResolvePermission, + Payload: map[string]any{ + "request_id": "perm-1", + "decision": "allow_forever", + }, + }, + wantCode: ErrorCodeInvalidAction.String(), + }, + { + name: "event frame allows empty action", + frame: MessageFrame{ + Type: FrameTypeEvent, + }, + wantNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateFrame(tt.frame) + if tt.wantNil { + if err != nil { + t.Fatalf("expected nil error, got: %#v", err) + } + return + } + + if err == nil { + t.Fatalf("expected validation error, got nil") + } + if err.Code != tt.wantCode { + t.Fatalf("error code mismatch: got %q want %q", err.Code, tt.wantCode) + } + if tt.wantField != "" && !strings.Contains(err.Message, tt.wantField) { + t.Fatalf("expected message to contain %q, got %q", tt.wantField, err.Message) + } + }) + } +} + +func TestValidateFrame_MultimodalPayloadRules(t *testing.T) { + tests := []struct { + name string + frame MessageFrame + wantNil bool + wantCode string + }{ + { + name: "valid text part", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionRun, + InputParts: []InputPart{ + {Type: InputPartTypeText, Text: "hello"}, + }, + }, + wantNil: true, + }, + { + name: "valid image part", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionRun, + InputParts: []InputPart{ + { + Type: InputPartTypeImage, + Media: &Media{ + URI: "file:///a.png", + MimeType: "image/png", + }, + }, + }, + }, + wantNil: true, + }, + { + name: "text part with empty text", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionRun, + InputParts: []InputPart{ + {Type: InputPartTypeText, Text: " "}, + }, + }, + wantCode: ErrorCodeInvalidMultimodalPayload.String(), + }, + { + name: "image part missing media", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionRun, + InputParts: []InputPart{ + {Type: InputPartTypeImage}, + }, + }, + wantCode: ErrorCodeInvalidMultimodalPayload.String(), + }, + { + name: "image part missing media.uri", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionRun, + InputParts: []InputPart{ + { + Type: InputPartTypeImage, + Media: &Media{MimeType: "image/png"}, + }, + }, + }, + wantCode: ErrorCodeInvalidMultimodalPayload.String(), + }, + { + name: "image part missing media.mime_type", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionRun, + InputParts: []InputPart{ + { + Type: InputPartTypeImage, + Media: &Media{URI: "file:///a.png"}, + }, + }, + }, + wantCode: ErrorCodeInvalidMultimodalPayload.String(), + }, + { + name: "unsupported part type", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionRun, + InputParts: []InputPart{ + {Type: InputPartType("audio")}, + }, + }, + wantCode: ErrorCodeInvalidMultimodalPayload.String(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateFrame(tt.frame) + if tt.wantNil { + if err != nil { + t.Fatalf("expected nil error, got: %#v", err) + } + return + } + if err == nil { + t.Fatalf("expected validation error, got nil") + } + if err.Code != tt.wantCode { + t.Fatalf("error code mismatch: got %q want %q", err.Code, tt.wantCode) + } + }) + } +}