- Archive evidence-gate-core tasks (09, 10+09, 11+09,10) to archive/2026/07 - Improve streamgate evidence_tail with contract validation and filter registry enhancements - Add consumer_contract_test.go for Go streamgate - Update dispatch.py and test_dispatch.py
2075 lines
72 KiB
Go
2075 lines
72 KiB
Go
package streamgate_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"iop/packages/go/streamgate"
|
|
)
|
|
|
|
// fakeOpenAIRawKind is an OpenAI-family-only raw event kind enum. It is a
|
|
// distinct Go type from streamgate.EventKind and from fakeAgentRawKind, so the
|
|
// OpenAI codec cannot accidentally consume a production kind or an agent-kind
|
|
// constant. Each constant is owned by the OpenAI converter alone.
|
|
type fakeOpenAIRawKind string
|
|
|
|
const (
|
|
rawOpenAIResponseStart fakeOpenAIRawKind = "openai_response_start"
|
|
rawOpenAITextDelta fakeOpenAIRawKind = "openai_text_delta"
|
|
rawOpenAIReasoningDelta fakeOpenAIRawKind = "openai_reasoning_delta"
|
|
rawOpenAIToolCallFragment fakeOpenAIRawKind = "openai_tool_call_fragment"
|
|
rawOpenAITerminal fakeOpenAIRawKind = "openai_terminal"
|
|
rawOpenAIProviderError fakeOpenAIRawKind = "openai_provider_error"
|
|
)
|
|
|
|
// fakeAgentRawKind is an agent-family-only raw event kind enum. It is a
|
|
// distinct Go type from streamgate.EventKind and from fakeOpenAIRawKind, so
|
|
// the agent codec cannot accidentally consume a production kind or an
|
|
// OpenAI-kind constant.
|
|
type fakeAgentRawKind string
|
|
|
|
const (
|
|
rawAgentResponseStart fakeAgentRawKind = "agent_response_start"
|
|
rawAgentTextDelta fakeAgentRawKind = "agent_text_delta"
|
|
rawAgentReasoningDelta fakeAgentRawKind = "agent_reasoning_delta"
|
|
rawAgentToolCallFragment fakeAgentRawKind = "agent_tool_call_fragment"
|
|
rawAgentTerminal fakeAgentRawKind = "agent_terminal"
|
|
rawAgentProviderError fakeAgentRawKind = "agent_provider_error"
|
|
)
|
|
|
|
// fakeOpenAIProviderFrame simulates an OpenAI provider tunnel codec by
|
|
// translating raw provider-frame inputs (status, text, reasoning, tool call,
|
|
// terminal, error) into normalized events and terminals. It is keyed by its
|
|
// own distinct fakeOpenAIRawKind enum, not by streamgate.EventKind. It does
|
|
// not import any apps/internal or proto types.
|
|
type fakeOpenAIProviderFrame struct {
|
|
kind fakeOpenAIRawKind
|
|
status int
|
|
headers map[string]string
|
|
text string
|
|
reasoning string
|
|
toolCallID string
|
|
toolCallName string
|
|
toolCallArgs string
|
|
errorType string
|
|
errorCode string
|
|
errorMessage string
|
|
errorParam string
|
|
}
|
|
|
|
// fakeAgentRuntimeEvent simulates an agent-family runtime event codec with
|
|
// its own distinct fakeAgentRawKind enum, but with runtime-event fields. It
|
|
// does not delegate to fakeOpenAIProviderFrame and does not import any
|
|
// apps/internal or proto types.
|
|
type fakeAgentRuntimeEvent struct {
|
|
kind fakeAgentRawKind
|
|
text string
|
|
reasoning string
|
|
toolCallID string
|
|
toolCallName string
|
|
toolCallArgs string
|
|
errorType string
|
|
errorCode string
|
|
errorMessage string
|
|
errorParam string
|
|
}
|
|
|
|
// normalizeOpenAIFrame converts a fakeOpenAIProviderFrame's raw inputs into a
|
|
// normalized event using production constructors. Each fakeOpenAIRawKind
|
|
// constant maps directly to its production constructor without calling any
|
|
// other converter.
|
|
func normalizeOpenAIFrame(f fakeOpenAIProviderFrame) (streamgate.NormalizedEvent, error) {
|
|
ts := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC)
|
|
switch f.kind {
|
|
case rawOpenAIResponseStart:
|
|
status := f.status
|
|
if status == 0 {
|
|
status = 200
|
|
}
|
|
headers := f.headers
|
|
if headers == nil {
|
|
headers = map[string]string{}
|
|
}
|
|
return streamgate.NewResponseStartEvent("ch-1", status, headers, ts)
|
|
case rawOpenAITextDelta:
|
|
return streamgate.NewTextDeltaEvent("ch-1", f.text, ts)
|
|
case rawOpenAIReasoningDelta:
|
|
return streamgate.NewReasoningDeltaEvent("ch-1", f.reasoning, ts)
|
|
case rawOpenAIToolCallFragment:
|
|
id := f.toolCallID
|
|
if id == "" {
|
|
id = "tc-1"
|
|
}
|
|
name := f.toolCallName
|
|
if name == "" {
|
|
name = "test"
|
|
}
|
|
args := f.toolCallArgs
|
|
if args == "" {
|
|
args = "{}"
|
|
}
|
|
return streamgate.NewToolCallFragmentEvent("ch-1", id, name, args, ts)
|
|
case rawOpenAITerminal:
|
|
return streamgate.NewTerminalEvent("ch-1", ts)
|
|
case rawOpenAIProviderError:
|
|
errorType := f.errorType
|
|
if errorType == "" {
|
|
errorType = "provider_error"
|
|
}
|
|
errorCode := f.errorCode
|
|
if errorCode == "" {
|
|
errorCode = "provider_error"
|
|
}
|
|
errorMessage := f.errorMessage
|
|
if errorMessage == "" {
|
|
errorMessage = "provider_error_message"
|
|
}
|
|
desc, err := streamgate.NewExternalDescriptor(errorType, errorCode, errorMessage, f.errorParam)
|
|
if err != nil {
|
|
return streamgate.NormalizedEvent{}, err
|
|
}
|
|
return streamgate.NewProviderErrorEvent("ch-1", desc, streamgate.FailureCauseChain{}, ts)
|
|
}
|
|
return streamgate.NormalizedEvent{}, errors.New("unknown openai raw kind")
|
|
}
|
|
|
|
// normalizeAgentEvent converts a fakeAgentRuntimeEvent's raw inputs into a
|
|
// normalized event using production constructors. Each fakeAgentRawKind
|
|
// constant maps directly to its production constructor without calling any
|
|
// other converter, ensuring the agent family codec is independently validated
|
|
// against the OpenAI provider frame.
|
|
func normalizeAgentEvent(f fakeAgentRuntimeEvent) (streamgate.NormalizedEvent, error) {
|
|
ts := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC)
|
|
switch f.kind {
|
|
case rawAgentResponseStart:
|
|
// Agent runtime events do not carry HTTP status/headers in their
|
|
// raw shape; this branch exists for matrix completeness.
|
|
return streamgate.NewResponseStartEvent("ch-1", 200, map[string]string{}, ts)
|
|
case rawAgentTextDelta:
|
|
return streamgate.NewTextDeltaEvent("ch-1", f.text, ts)
|
|
case rawAgentReasoningDelta:
|
|
return streamgate.NewReasoningDeltaEvent("ch-1", f.reasoning, ts)
|
|
case rawAgentToolCallFragment:
|
|
id := f.toolCallID
|
|
if id == "" {
|
|
id = "tc-1"
|
|
}
|
|
name := f.toolCallName
|
|
if name == "" {
|
|
name = "test"
|
|
}
|
|
args := f.toolCallArgs
|
|
if args == "" {
|
|
args = "{}"
|
|
}
|
|
return streamgate.NewToolCallFragmentEvent("ch-1", id, name, args, ts)
|
|
case rawAgentTerminal:
|
|
return streamgate.NewTerminalEvent("ch-1", ts)
|
|
case rawAgentProviderError:
|
|
errorType := f.errorType
|
|
if errorType == "" {
|
|
errorType = "agent_runtime_error"
|
|
}
|
|
errorCode := f.errorCode
|
|
if errorCode == "" {
|
|
errorCode = "agent_runtime_error"
|
|
}
|
|
errorMessage := f.errorMessage
|
|
if errorMessage == "" {
|
|
errorMessage = "agent_runtime_error_message"
|
|
}
|
|
desc, err := streamgate.NewExternalDescriptor(errorType, errorCode, errorMessage, f.errorParam)
|
|
if err != nil {
|
|
return streamgate.NormalizedEvent{}, err
|
|
}
|
|
return streamgate.NewProviderErrorEvent("ch-1", desc, streamgate.FailureCauseChain{}, ts)
|
|
}
|
|
return streamgate.NormalizedEvent{}, errors.New("unknown agent raw kind")
|
|
}
|
|
|
|
// fakeHostReleaseSink implements streamgate.ReleaseSink and captures exactly
|
|
// one terminal commit while rejecting subsequent attempts, enforcing single
|
|
// external terminal semantics in the test fixture.
|
|
type fakeHostReleaseSink struct {
|
|
committedTerminal streamgate.TerminalResult
|
|
committedCount int
|
|
outputBody string
|
|
state streamgate.CommitState
|
|
}
|
|
|
|
// Compile-time assertion that fakeHostReleaseSink implements ReleaseSink.
|
|
var _ streamgate.ReleaseSink = (*fakeHostReleaseSink)(nil)
|
|
|
|
func (s *fakeHostReleaseSink) CommitResponseStart(ctx context.Context, rs streamgate.ResponseStart) (streamgate.CommitState, error) {
|
|
s.state = streamgate.CommitStateStreamOpen
|
|
return s.state, nil
|
|
}
|
|
|
|
func (s *fakeHostReleaseSink) Release(ctx context.Context, ev streamgate.ReleaseEvent) (streamgate.CommitState, error) {
|
|
return s.state, nil
|
|
}
|
|
|
|
func (s *fakeHostReleaseSink) CommitTerminal(ctx context.Context, tr streamgate.TerminalResult) (streamgate.CommitState, error) {
|
|
if s.committedCount >= 1 {
|
|
return s.state, errors.New("streamgate test: second terminal rejected")
|
|
}
|
|
s.committedCount++
|
|
s.committedTerminal = tr
|
|
s.state = streamgate.CommitStateTerminalCommitted
|
|
// Record external JSON body via descriptor accessors only.
|
|
s.outputBody = formatErrorEnvelope(tr)
|
|
return s.state, nil
|
|
}
|
|
|
|
// formatErrorEnvelope produces a JSON error envelope from a TerminalResult
|
|
// using only the ExternalDescriptor accessor methods. It returns an empty
|
|
// string for success results.
|
|
func formatErrorEnvelope(tr streamgate.TerminalResult) string {
|
|
if !tr.Error() {
|
|
return ""
|
|
}
|
|
ed := tr.ExternalDesc()
|
|
if ed == nil {
|
|
return ""
|
|
}
|
|
env := struct {
|
|
Error struct {
|
|
Type string `json:"type"`
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}{
|
|
Error: struct {
|
|
Type string `json:"type"`
|
|
Message string `json:"message"`
|
|
}{
|
|
Type: ed.Type(),
|
|
Message: ed.Message(),
|
|
},
|
|
}
|
|
b, _ := json.Marshal(env)
|
|
return string(b)
|
|
}
|
|
|
|
// encodeChatSSEError frames a TerminalResult as a Chat Completions SSE error
|
|
// response: one SSE data line with the JSON envelope followed by [DONE]. It
|
|
// uses only the ExternalDescriptor accessor methods for payload construction.
|
|
func encodeChatSSEError(tr streamgate.TerminalResult) (string, error) {
|
|
envelope := formatErrorEnvelope(tr)
|
|
if envelope == "" {
|
|
return "", nil
|
|
}
|
|
return "data: " + envelope + "\n\n" + "[DONE]\n\n", nil
|
|
}
|
|
|
|
// encodeResponsesJSONError frames a TerminalResult as a Responses API JSON
|
|
// error response: a single JSON envelope body. It uses only the
|
|
// ExternalDescriptor accessor methods for payload construction.
|
|
func encodeResponsesJSONError(tr streamgate.TerminalResult) (string, error) {
|
|
return formatErrorEnvelope(tr), nil
|
|
}
|
|
|
|
// expectedMatrix is a test-internal table that pairs each codec family's raw
|
|
// kind with the production EventKind, expected BaseEventDisposition, and
|
|
// expected typed accessor payload. The matrix is the single source of truth
|
|
// for the independent lifecycle assertion.
|
|
type expectedMatrix struct {
|
|
name string
|
|
openaiRaw fakeOpenAIRawKind
|
|
agentRaw fakeAgentRawKind
|
|
expectedKind streamgate.EventKind
|
|
expectedDisp streamgate.BaseEventDisposition
|
|
openaiStatus int
|
|
openaiHeaders map[string]string
|
|
openaiText string
|
|
openaiReason string
|
|
openaiTcID string
|
|
openaiTcName string
|
|
openaiTcArgs string
|
|
openaiErrType string
|
|
openaiErrCode string
|
|
openaiErrMsg string
|
|
agentText string
|
|
agentReason string
|
|
agentTcID string
|
|
agentTcName string
|
|
agentTcArgs string
|
|
agentErrType string
|
|
agentErrCode string
|
|
agentErrMsg string
|
|
}
|
|
|
|
// TestOpenAIAndAgentCodecContractMatrix verifies that the two codec families,
|
|
// each keyed by its own distinct raw kind enum and each with its own
|
|
// independent converter, produce NormalizedEvents whose actual production
|
|
// kind, Disposition(), Validate() result, and typed accessor payload all
|
|
// independently match the expectedMatrix. The OpenAI family uses
|
|
// fakeOpenAIRawKind; the agent family uses fakeAgentRawKind. Neither raw
|
|
// enum is streamgate.EventKind.
|
|
func TestOpenAIAndAgentCodecContractMatrix(t *testing.T) {
|
|
matrix := []expectedMatrix{
|
|
{
|
|
name: "response_start",
|
|
openaiRaw: rawOpenAIResponseStart,
|
|
agentRaw: rawAgentResponseStart,
|
|
expectedKind: streamgate.EventKindResponseStart,
|
|
expectedDisp: streamgate.BaseDispositionHold,
|
|
openaiStatus: 201,
|
|
openaiHeaders: map[string]string{"x-custom": "val"},
|
|
},
|
|
{
|
|
name: "text_delta",
|
|
openaiRaw: rawOpenAITextDelta,
|
|
agentRaw: rawAgentTextDelta,
|
|
expectedKind: streamgate.EventKindTextDelta,
|
|
expectedDisp: streamgate.BaseDispositionReleaseCandidate,
|
|
openaiText: "hello-from-openai",
|
|
agentText: "hello-from-agent",
|
|
},
|
|
{
|
|
name: "reasoning_delta",
|
|
openaiRaw: rawOpenAIReasoningDelta,
|
|
agentRaw: rawAgentReasoningDelta,
|
|
expectedKind: streamgate.EventKindReasoningDelta,
|
|
expectedDisp: streamgate.BaseDispositionReleaseCandidate,
|
|
openaiReason: "thinking-openai",
|
|
agentReason: "thinking-agent",
|
|
},
|
|
{
|
|
name: "tool_call",
|
|
openaiRaw: rawOpenAIToolCallFragment,
|
|
agentRaw: rawAgentToolCallFragment,
|
|
expectedKind: streamgate.EventKindToolCallFragment,
|
|
expectedDisp: streamgate.BaseDispositionReleaseCandidate,
|
|
openaiTcID: "tc-1",
|
|
openaiTcName: "get_weather",
|
|
openaiTcArgs: `{"q":"hi"}`,
|
|
agentTcID: "tc-2",
|
|
agentTcName: "search",
|
|
agentTcArgs: `{"q":"bye"}`,
|
|
},
|
|
{
|
|
name: "terminal",
|
|
openaiRaw: rawOpenAITerminal,
|
|
agentRaw: rawAgentTerminal,
|
|
expectedKind: streamgate.EventKindTerminal,
|
|
expectedDisp: streamgate.BaseDispositionTerminalSuccessCandidate,
|
|
},
|
|
{
|
|
name: "provider_error",
|
|
openaiRaw: rawOpenAIProviderError,
|
|
agentRaw: rawAgentProviderError,
|
|
expectedKind: streamgate.EventKindProviderError,
|
|
expectedDisp: streamgate.BaseDispositionTerminalErrorCandidate,
|
|
openaiErrType: "provider_timeout",
|
|
openaiErrCode: "provider_timeout",
|
|
openaiErrMsg: "provider_timed_out",
|
|
agentErrType: "agent_runtime_timeout",
|
|
agentErrCode: "agent_runtime_timeout",
|
|
agentErrMsg: "agent_timed_out",
|
|
},
|
|
}
|
|
|
|
for _, tc := range matrix {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// --- OpenAI path ---
|
|
openaiFrame := fakeOpenAIProviderFrame{
|
|
kind: tc.openaiRaw,
|
|
text: tc.openaiText,
|
|
reasoning: tc.openaiReason,
|
|
toolCallID: tc.openaiTcID,
|
|
toolCallName: tc.openaiTcName,
|
|
toolCallArgs: tc.openaiTcArgs,
|
|
errorType: tc.openaiErrType,
|
|
errorCode: tc.openaiErrCode,
|
|
errorMessage: tc.openaiErrMsg,
|
|
}
|
|
if tc.name == "response_start" {
|
|
openaiFrame.status = tc.openaiStatus
|
|
openaiFrame.headers = tc.openaiHeaders
|
|
}
|
|
|
|
openaiEvent, err := normalizeOpenAIFrame(openaiFrame)
|
|
if err != nil {
|
|
t.Fatalf("openai normalize failed: %v", err)
|
|
}
|
|
|
|
// 1. Kind independently matches expected production kind.
|
|
if openaiEvent.Kind() != tc.expectedKind {
|
|
t.Errorf("openai Kind(): want %q, got %q", tc.expectedKind, openaiEvent.Kind())
|
|
}
|
|
|
|
// 2. Disposition() independently matches expected base disposition.
|
|
actualDisp, err := streamgate.BaseDispositionOf(openaiEvent.Kind())
|
|
if err != nil {
|
|
t.Fatalf("openai BaseDispositionOf(%q): %v", openaiEvent.Kind(), err)
|
|
}
|
|
if openaiEvent.Disposition() != tc.expectedDisp {
|
|
t.Errorf("openai Disposition(): want %q, got %q", tc.expectedDisp, openaiEvent.Disposition())
|
|
}
|
|
if actualDisp != tc.expectedDisp {
|
|
t.Errorf("openai BaseDispositionOf(kind) does not match matrix: want %q, got %q", tc.expectedDisp, actualDisp)
|
|
}
|
|
|
|
// 3. Validate() passes.
|
|
if err := openaiEvent.Validate(); err != nil {
|
|
t.Errorf("openai event Validate() = %v", err)
|
|
}
|
|
|
|
// 4. Kind-specific typed accessor payload independently verified.
|
|
verifyOpenAIPayload(t, openaiEvent, tc)
|
|
|
|
// --- Agent path ---
|
|
agentFrame := fakeAgentRuntimeEvent{
|
|
kind: tc.agentRaw,
|
|
text: tc.agentText,
|
|
reasoning: tc.agentReason,
|
|
toolCallID: tc.agentTcID,
|
|
toolCallName: tc.agentTcName,
|
|
toolCallArgs: tc.agentTcArgs,
|
|
errorType: tc.agentErrType,
|
|
errorCode: tc.agentErrCode,
|
|
errorMessage: tc.agentErrMsg,
|
|
}
|
|
|
|
agentEvent, err := normalizeAgentEvent(agentFrame)
|
|
if err != nil {
|
|
t.Fatalf("agent normalize failed: %v", err)
|
|
}
|
|
|
|
// 1. Kind independently matches expected production kind.
|
|
if agentEvent.Kind() != tc.expectedKind {
|
|
t.Errorf("agent Kind(): want %q, got %q", tc.expectedKind, agentEvent.Kind())
|
|
}
|
|
|
|
// 2. Disposition() independently matches expected base disposition.
|
|
agentActualDisp, err := streamgate.BaseDispositionOf(agentEvent.Kind())
|
|
if err != nil {
|
|
t.Fatalf("agent BaseDispositionOf(%q): %v", agentEvent.Kind(), err)
|
|
}
|
|
if agentEvent.Disposition() != tc.expectedDisp {
|
|
t.Errorf("agent Disposition(): want %q, got %q", tc.expectedDisp, agentEvent.Disposition())
|
|
}
|
|
if agentActualDisp != tc.expectedDisp {
|
|
t.Errorf("agent BaseDispositionOf(kind) does not match matrix: want %q, got %q", tc.expectedDisp, agentActualDisp)
|
|
}
|
|
|
|
// 3. Validate() passes.
|
|
if err := agentEvent.Validate(); err != nil {
|
|
t.Errorf("agent event Validate() = %v", err)
|
|
}
|
|
|
|
// 4. Kind-specific typed accessor payload independently verified.
|
|
verifyAgentPayload(t, agentEvent, tc)
|
|
})
|
|
}
|
|
|
|
// --- Cross-family independence: raw kinds are distinct types and cannot
|
|
// be swapped. Compile-time guarantee, not runtime. ---
|
|
var _ fakeOpenAIRawKind = rawOpenAIResponseStart
|
|
var _ fakeAgentRawKind = rawAgentResponseStart
|
|
// The following would not compile (and must not):
|
|
// var _ fakeOpenAIRawKind = rawAgentResponseStart
|
|
// var _ fakeAgentRawKind = rawOpenAIResponseStart
|
|
// var _ streamgate.EventKind = rawOpenAIResponseStart
|
|
// var _ streamgate.EventKind = rawAgentResponseStart
|
|
}
|
|
|
|
// verifyOpenAIPayload checks the OpenAI event's typed accessor payload against
|
|
// the expected matrix values, independently of the agent path.
|
|
func verifyOpenAIPayload(t *testing.T, ev streamgate.NormalizedEvent, tc expectedMatrix) {
|
|
t.Helper()
|
|
switch tc.expectedKind {
|
|
case streamgate.EventKindResponseStart:
|
|
rs, err := ev.AsResponseStart()
|
|
if err != nil {
|
|
t.Fatalf("openai AsResponseStart: %v", err)
|
|
}
|
|
if rs.Status() != tc.openaiStatus {
|
|
t.Errorf("openai ResponseStart.Status(): want %d, got %d", tc.openaiStatus, rs.Status())
|
|
}
|
|
for k, v := range tc.openaiHeaders {
|
|
if rs.Headers()[k] != v {
|
|
t.Errorf("openai ResponseStart.Headers()[%q] = %q, want %q", k, rs.Headers()[k], v)
|
|
}
|
|
}
|
|
case streamgate.EventKindTextDelta:
|
|
text, err := ev.AsTextDelta()
|
|
if err != nil {
|
|
t.Fatalf("openai AsTextDelta: %v", err)
|
|
}
|
|
if text != tc.openaiText {
|
|
t.Errorf("openai text delta: want %q, got %q", tc.openaiText, text)
|
|
}
|
|
case streamgate.EventKindReasoningDelta:
|
|
reason, err := ev.AsReasoningDelta()
|
|
if err != nil {
|
|
t.Fatalf("openai AsReasoningDelta: %v", err)
|
|
}
|
|
if reason != tc.openaiReason {
|
|
t.Errorf("openai reasoning delta: want %q, got %q", tc.openaiReason, reason)
|
|
}
|
|
case streamgate.EventKindToolCallFragment:
|
|
tcData, err := ev.AsToolCallFragment()
|
|
if err != nil {
|
|
t.Fatalf("openai AsToolCallFragment: %v", err)
|
|
}
|
|
if tcData.ID != tc.openaiTcID {
|
|
t.Errorf("openai tc ID: want %q, got %q", tc.openaiTcID, tcData.ID)
|
|
}
|
|
if tcData.Name != tc.openaiTcName {
|
|
t.Errorf("openai tc name: want %q, got %q", tc.openaiTcName, tcData.Name)
|
|
}
|
|
if tcData.Arguments != tc.openaiTcArgs {
|
|
t.Errorf("openai tc args: want %q, got %q", tc.openaiTcArgs, tcData.Arguments)
|
|
}
|
|
case streamgate.EventKindTerminal:
|
|
tr, err := ev.AsTerminal()
|
|
if err != nil {
|
|
t.Fatalf("openai AsTerminal: %v", err)
|
|
}
|
|
if !tr.Success() {
|
|
t.Error("openai terminal: expected success")
|
|
}
|
|
if tr.Error() {
|
|
t.Error("openai terminal: must not be error")
|
|
}
|
|
case streamgate.EventKindProviderError:
|
|
tr, err := ev.AsProviderError()
|
|
if err != nil {
|
|
t.Fatalf("openai AsProviderError: %v", err)
|
|
}
|
|
if !tr.Error() {
|
|
t.Error("openai provider error: expected error terminal")
|
|
}
|
|
if tr.Success() {
|
|
t.Error("openai provider error: must not be success")
|
|
}
|
|
ed := tr.ExternalDesc()
|
|
if ed == nil {
|
|
t.Fatal("openai provider error: descriptor is nil")
|
|
}
|
|
if ed.Type() != tc.openaiErrType {
|
|
t.Errorf("openai descriptor Type(): want %q, got %q", tc.openaiErrType, ed.Type())
|
|
}
|
|
if ed.Code() != tc.openaiErrCode {
|
|
t.Errorf("openai descriptor Code(): want %q, got %q", tc.openaiErrCode, ed.Code())
|
|
}
|
|
if ed.Message() != tc.openaiErrMsg {
|
|
t.Errorf("openai descriptor Message(): want %q, got %q", tc.openaiErrMsg, ed.Message())
|
|
}
|
|
}
|
|
}
|
|
|
|
// verifyAgentPayload checks the agent event's typed accessor payload against
|
|
// the expected matrix values, independently of the OpenAI path.
|
|
func verifyAgentPayload(t *testing.T, ev streamgate.NormalizedEvent, tc expectedMatrix) {
|
|
t.Helper()
|
|
switch tc.expectedKind {
|
|
case streamgate.EventKindResponseStart:
|
|
rs, err := ev.AsResponseStart()
|
|
if err != nil {
|
|
t.Fatalf("agent AsResponseStart: %v", err)
|
|
}
|
|
if rs.Status() != 200 {
|
|
t.Errorf("agent ResponseStart.Status(): want 200, got %d", rs.Status())
|
|
}
|
|
case streamgate.EventKindTextDelta:
|
|
text, err := ev.AsTextDelta()
|
|
if err != nil {
|
|
t.Fatalf("agent AsTextDelta: %v", err)
|
|
}
|
|
if text != tc.agentText {
|
|
t.Errorf("agent text delta: want %q, got %q", tc.agentText, text)
|
|
}
|
|
case streamgate.EventKindReasoningDelta:
|
|
reason, err := ev.AsReasoningDelta()
|
|
if err != nil {
|
|
t.Fatalf("agent AsReasoningDelta: %v", err)
|
|
}
|
|
if reason != tc.agentReason {
|
|
t.Errorf("agent reasoning delta: want %q, got %q", tc.agentReason, reason)
|
|
}
|
|
case streamgate.EventKindToolCallFragment:
|
|
tcData, err := ev.AsToolCallFragment()
|
|
if err != nil {
|
|
t.Fatalf("agent AsToolCallFragment: %v", err)
|
|
}
|
|
if tcData.ID != tc.agentTcID {
|
|
t.Errorf("agent tc ID: want %q, got %q", tc.agentTcID, tcData.ID)
|
|
}
|
|
if tcData.Name != tc.agentTcName {
|
|
t.Errorf("agent tc name: want %q, got %q", tc.agentTcName, tcData.Name)
|
|
}
|
|
if tcData.Arguments != tc.agentTcArgs {
|
|
t.Errorf("agent tc args: want %q, got %q", tc.agentTcArgs, tcData.Arguments)
|
|
}
|
|
case streamgate.EventKindTerminal:
|
|
tr, err := ev.AsTerminal()
|
|
if err != nil {
|
|
t.Fatalf("agent AsTerminal: %v", err)
|
|
}
|
|
if !tr.Success() {
|
|
t.Error("agent terminal: expected success")
|
|
}
|
|
if tr.Error() {
|
|
t.Error("agent terminal: must not be error")
|
|
}
|
|
case streamgate.EventKindProviderError:
|
|
tr, err := ev.AsProviderError()
|
|
if err != nil {
|
|
t.Fatalf("agent AsProviderError: %v", err)
|
|
}
|
|
if !tr.Error() {
|
|
t.Error("agent provider error: expected error terminal")
|
|
}
|
|
if tr.Success() {
|
|
t.Error("agent provider error: must not be success")
|
|
}
|
|
ed := tr.ExternalDesc()
|
|
if ed == nil {
|
|
t.Fatal("agent provider error: descriptor is nil")
|
|
}
|
|
if ed.Type() != tc.agentErrType {
|
|
t.Errorf("agent descriptor Type(): want %q, got %q", tc.agentErrType, ed.Type())
|
|
}
|
|
if ed.Code() != tc.agentErrCode {
|
|
t.Errorf("agent descriptor Code(): want %q, got %q", tc.agentErrCode, ed.Code())
|
|
}
|
|
if ed.Message() != tc.agentErrMsg {
|
|
t.Errorf("agent descriptor Message(): want %q, got %q", tc.agentErrMsg, ed.Message())
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestProviderErrorRetainsTerminalBaseDisposition verifies that provider error
|
|
// events retain terminal-error candidate disposition even when the codec
|
|
// families have distinct error fields. Both the OpenAI provider frame and
|
|
// agent runtime event produce a terminal-error candidate via their
|
|
// independent converters with their own distinct raw kind enums.
|
|
func TestProviderErrorRetainsTerminalBaseDisposition(t *testing.T) {
|
|
expectedDisp := streamgate.BaseDispositionTerminalErrorCandidate
|
|
|
|
openaiFrame := fakeOpenAIProviderFrame{
|
|
kind: rawOpenAIProviderError,
|
|
errorType: "provider_timeout",
|
|
errorCode: "provider_timeout",
|
|
errorMessage: "provider_timed_out",
|
|
}
|
|
agentEvent := fakeAgentRuntimeEvent{
|
|
kind: rawAgentProviderError,
|
|
errorType: "agent_runtime_timeout",
|
|
errorCode: "agent_runtime_timeout",
|
|
errorMessage: "agent_timed_out",
|
|
}
|
|
|
|
openaiEvent, err := normalizeOpenAIFrame(openaiFrame)
|
|
if err != nil {
|
|
t.Fatalf("openai normalize failed: %v", err)
|
|
}
|
|
|
|
agentEventResult, err := normalizeAgentEvent(agentEvent)
|
|
if err != nil {
|
|
t.Fatalf("agent normalize failed: %v", err)
|
|
}
|
|
|
|
// Kind check: each converter maps its own raw kind to the correct
|
|
// production kind.
|
|
kind := streamgate.EventKindProviderError
|
|
if openaiEvent.Kind() != kind {
|
|
t.Errorf("openai kind: want %v, got %v", kind, openaiEvent.Kind())
|
|
}
|
|
if agentEventResult.Kind() != kind {
|
|
t.Errorf("agent kind: want %v, got %v", kind, agentEventResult.Kind())
|
|
}
|
|
|
|
// Disposition check: each produced event's actual Disposition() matches
|
|
// the expected terminal-error candidate.
|
|
openaiDisp := openaiEvent.Disposition()
|
|
if openaiDisp != expectedDisp {
|
|
t.Errorf("openai Disposition(): want %v, got %v", expectedDisp, openaiDisp)
|
|
}
|
|
|
|
agentDisp := agentEventResult.Disposition()
|
|
if agentDisp != expectedDisp {
|
|
t.Errorf("agent Disposition(): want %v, got %v", expectedDisp, agentDisp)
|
|
}
|
|
|
|
// Validate() passes for each.
|
|
if err := openaiEvent.Validate(); err != nil {
|
|
t.Errorf("openai Validate(): %v", err)
|
|
}
|
|
if err := agentEventResult.Validate(); err != nil {
|
|
t.Errorf("agent Validate(): %v", err)
|
|
}
|
|
|
|
// AsProviderError yields an error TerminalResult for each.
|
|
openaiTr, err := openaiEvent.AsProviderError()
|
|
if err != nil {
|
|
t.Fatalf("openai AsProviderError: %v", err)
|
|
}
|
|
if !openaiTr.Error() {
|
|
t.Error("openai provider error terminal should be error type")
|
|
}
|
|
if openaiTr.Success() {
|
|
t.Error("openai provider error terminal must not be success")
|
|
}
|
|
|
|
agentTr, err := agentEventResult.AsProviderError()
|
|
if err != nil {
|
|
t.Fatalf("agent AsProviderError: %v", err)
|
|
}
|
|
if !agentTr.Error() {
|
|
t.Error("agent provider error terminal should be error type")
|
|
}
|
|
if agentTr.Success() {
|
|
t.Error("agent provider error terminal must not be success")
|
|
}
|
|
|
|
// Descriptor accessors return family-specific values independently.
|
|
openaiED := openaiTr.ExternalDesc()
|
|
agentED := agentTr.ExternalDesc()
|
|
if openaiED.Type() == agentED.Type() {
|
|
t.Errorf("openai and agent descriptor Type() should be distinct: both %q", openaiED.Type())
|
|
}
|
|
if openaiED.Message() == agentED.Message() {
|
|
t.Errorf("openai and agent descriptor Message() should be distinct: both %q", openaiED.Message())
|
|
}
|
|
}
|
|
|
|
// TestReleaseSinkCommitsSingleExternalTerminal verifies that the fake sink
|
|
// accepts exactly one terminal, rejects subsequent commits, and produces
|
|
// correct per-endpoint public framing through descriptor accessors only. The
|
|
// failure cause chain retains the latest four codes including both required
|
|
// codes ("repetition_loop_detected" and "recovery_notice_translation_failed"),
|
|
// proving the bound is functional, not suppressive.
|
|
func TestReleaseSinkCommitsSingleExternalTerminal(t *testing.T) {
|
|
ts := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC)
|
|
ctx := context.Background()
|
|
|
|
// --- Build a terminal with a bounded cause chain (5 inputs, latest 4) ---
|
|
desc, err := streamgate.NewExternalDescriptor("rate_limit_error", "rate_limit_exceeded", "rate_limit_exceeded", "")
|
|
if err != nil {
|
|
t.Fatalf("NewExternalDescriptor: %v", err)
|
|
}
|
|
|
|
chain := streamgate.FailureCauseChain{}
|
|
// Append 5 causes; only the latest 4 should be retained. Using 5 ensures
|
|
// "discarded_prelude" is dropped while both "repetition_loop_detected"
|
|
// and "recovery_notice_translation_failed" survive in the retained set,
|
|
// proving the bound is functional, not suppressive.
|
|
allCodes := []string{
|
|
"discarded_prelude",
|
|
"repetition_loop_detected",
|
|
"filter_blocked",
|
|
"validation_failed",
|
|
"recovery_notice_translation_failed",
|
|
}
|
|
for _, code := range allCodes {
|
|
cause, err := streamgate.NewFailureCause("stage", code, "consumer-1", "filter-1", "")
|
|
if err != nil {
|
|
t.Fatalf("NewFailureCause(%s): %v", code, err)
|
|
}
|
|
chain, err = chain.Append(cause)
|
|
if err != nil {
|
|
t.Fatalf("chain.Append(%s): %v", code, err)
|
|
}
|
|
}
|
|
|
|
// Chain must retain exactly 4 causes (latest 4).
|
|
if chain.Len() != 4 {
|
|
t.Fatalf("chain Len() = %d, want 4", chain.Len())
|
|
}
|
|
|
|
// Both required codes must be present in the retained chain, in exact
|
|
// retained order, proving the bound is functional (latest 4 retained),
|
|
// not suppressive. The dropped prelude must not be present.
|
|
expectedCodes := []string{
|
|
"repetition_loop_detected",
|
|
"filter_blocked",
|
|
"validation_failed",
|
|
"recovery_notice_translation_failed",
|
|
}
|
|
retained := chain.All()
|
|
if len(retained) != len(expectedCodes) {
|
|
t.Fatalf("retained chain length = %d, want %d", len(retained), len(expectedCodes))
|
|
}
|
|
for i, want := range expectedCodes {
|
|
if got := retained[i].Code(); got != want {
|
|
t.Errorf("retained[%d].Code() = %q, want %q", i, got, want)
|
|
}
|
|
}
|
|
// The dropped code must not be present in the retained chain.
|
|
hasPrelude := false
|
|
for _, c := range retained {
|
|
if c.Code() == "discarded_prelude" {
|
|
hasPrelude = true
|
|
break
|
|
}
|
|
}
|
|
if hasPrelude {
|
|
t.Error("retained chain must not include 'discarded_prelude' (should be dropped)")
|
|
}
|
|
|
|
tr, err := streamgate.NewErrorTerminalResult("ch-1", desc, chain, ts)
|
|
if err != nil {
|
|
t.Fatalf("NewErrorTerminalResult: %v", err)
|
|
}
|
|
|
|
// --- Chat SSE endpoint subtest ---
|
|
t.Run("chat_sse_endpoint", func(t *testing.T) {
|
|
sink := &fakeHostReleaseSink{}
|
|
|
|
rs, err := streamgate.NewResponseStart("ch-1", 200, map[string]string{}, ts)
|
|
if err != nil {
|
|
t.Fatalf("NewResponseStart: %v", err)
|
|
}
|
|
_, err = sink.CommitResponseStart(ctx, rs)
|
|
if err != nil {
|
|
t.Fatalf("CommitResponseStart: %v", err)
|
|
}
|
|
|
|
// First commit should succeed.
|
|
state, err := sink.CommitTerminal(ctx, tr)
|
|
if err != nil {
|
|
t.Fatalf("first CommitTerminal failed: %v", err)
|
|
}
|
|
if state != streamgate.CommitStateTerminalCommitted {
|
|
t.Errorf("first commit state: want terminal_committed, got %v", state)
|
|
}
|
|
if sink.committedCount != 1 {
|
|
t.Errorf("committedCount = %d, want 1", sink.committedCount)
|
|
}
|
|
|
|
// Verify Chat SSE framing: data: {envelope}\n\n[DONE]\n\n
|
|
sseOutput, err := encodeChatSSEError(sink.committedTerminal)
|
|
if err != nil {
|
|
t.Fatalf("encodeChatSSEError: %v", err)
|
|
}
|
|
|
|
expectedEnvelope := formatErrorEnvelope(sink.committedTerminal)
|
|
expectedSSE := "data: " + expectedEnvelope + "\n\n" + "[DONE]\n\n"
|
|
if sseOutput != expectedSSE {
|
|
t.Errorf("Chat SSE output:\n got: %q\nwant: %q", sseOutput, expectedSSE)
|
|
}
|
|
|
|
// SSE output must start with "data: ".
|
|
if !strings.HasPrefix(sseOutput, "data: ") {
|
|
t.Errorf("Chat SSE must start with 'data: ', got: %q", sseOutput)
|
|
}
|
|
|
|
// SSE output must contain "[DONE]" terminator.
|
|
if !strings.Contains(sseOutput, "[DONE]") {
|
|
t.Errorf("Chat SSE must contain '[DONE]', got: %q", sseOutput)
|
|
}
|
|
|
|
// Decode the envelope and verify exact key set.
|
|
var decoded map[string]interface{}
|
|
if err := json.Unmarshal([]byte(expectedEnvelope), &decoded); err != nil {
|
|
t.Fatalf("envelope JSON decode failed: %v; body=%q", err, expectedEnvelope)
|
|
}
|
|
|
|
if _, ok := decoded["error"]; !ok {
|
|
t.Errorf("decoded top-level keys: missing 'error'; got %+v", decoded)
|
|
}
|
|
topKeys := make([]string, 0, len(decoded))
|
|
for k := range decoded {
|
|
topKeys = append(topKeys, k)
|
|
}
|
|
if len(topKeys) != 1 || topKeys[0] != "error" {
|
|
t.Errorf("decoded top-level keys: want ['error'], got %v", topKeys)
|
|
}
|
|
|
|
errObj, ok := decoded["error"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("decoded['error'] is not object; got %T", decoded["error"])
|
|
}
|
|
errKeys := make([]string, 0, len(errObj))
|
|
for k := range errObj {
|
|
errKeys = append(errKeys, k)
|
|
}
|
|
if len(errKeys) != 2 {
|
|
t.Errorf("decoded['error'] keys: want 2, got %d (%v)", len(errKeys), errKeys)
|
|
}
|
|
if _, ok := errObj["type"]; !ok {
|
|
t.Error("decoded['error'] missing 'type'")
|
|
}
|
|
if _, ok := errObj["message"]; !ok {
|
|
t.Error("decoded['error'] missing 'message'")
|
|
}
|
|
if errObj["type"] != "rate_limit_error" {
|
|
t.Errorf("decoded['error'].type = %v, want rate_limit_error", errObj["type"])
|
|
}
|
|
if errObj["message"] != "rate_limit_exceeded" {
|
|
t.Errorf("decoded['error'].message = %v, want 'rate_limit_exceeded'", errObj["message"])
|
|
}
|
|
|
|
// Forbidden internal fields must not appear anywhere in the SSE output.
|
|
forbiddenFields := []string{"causes", "stack", "trace", "provider", "internal", "raw", "repetition_loop", "filter_blocked", "validation_failed", "cache_miss", "stage", "consumer"}
|
|
for _, f := range forbiddenFields {
|
|
if strings.Contains(sseOutput, f) {
|
|
t.Errorf("forbidden field '%s' found in Chat SSE output: %s", f, sseOutput)
|
|
}
|
|
}
|
|
|
|
// Verify the committed terminal reflects the bounded cause chain.
|
|
committed := sink.committedTerminal
|
|
if !committed.Error() {
|
|
t.Error("committed terminal should be error type")
|
|
}
|
|
committedCauses := committed.FailureCauses()
|
|
if committedCauses.Len() != 4 {
|
|
t.Errorf("committed causes Len() = %d, want 4", committedCauses.Len())
|
|
}
|
|
})
|
|
|
|
// --- Responses JSON endpoint subtest ---
|
|
t.Run("responses_json_endpoint", func(t *testing.T) {
|
|
sink := &fakeHostReleaseSink{}
|
|
|
|
rs, err := streamgate.NewResponseStart("ch-1", 200, map[string]string{}, ts)
|
|
if err != nil {
|
|
t.Fatalf("NewResponseStart: %v", err)
|
|
}
|
|
_, err = sink.CommitResponseStart(ctx, rs)
|
|
if err != nil {
|
|
t.Fatalf("CommitResponseStart: %v", err)
|
|
}
|
|
|
|
// First commit should succeed.
|
|
state, err := sink.CommitTerminal(ctx, tr)
|
|
if err != nil {
|
|
t.Fatalf("first CommitTerminal failed: %v", err)
|
|
}
|
|
if state != streamgate.CommitStateTerminalCommitted {
|
|
t.Errorf("first commit state: want terminal_committed, got %v", state)
|
|
}
|
|
if sink.committedCount != 1 {
|
|
t.Errorf("committedCount = %d, want 1", sink.committedCount)
|
|
}
|
|
|
|
// Verify Responses JSON framing: just the envelope.
|
|
jsonOutput, err := encodeResponsesJSONError(sink.committedTerminal)
|
|
if err != nil {
|
|
t.Fatalf("encodeResponsesJSONError: %v", err)
|
|
}
|
|
|
|
expectedEnvelope := formatErrorEnvelope(sink.committedTerminal)
|
|
if jsonOutput != expectedEnvelope {
|
|
t.Errorf("Responses JSON output:\n got: %q\nwant: %q", jsonOutput, expectedEnvelope)
|
|
}
|
|
|
|
// Responses JSON must NOT contain SSE framing markers.
|
|
if strings.Contains(jsonOutput, "data:") {
|
|
t.Errorf("Responses JSON must not contain SSE 'data:' marker: %q", jsonOutput)
|
|
}
|
|
if strings.Contains(jsonOutput, "[DONE]") {
|
|
t.Errorf("Responses JSON must not contain SSE '[DONE]' marker: %q", jsonOutput)
|
|
}
|
|
|
|
// Decode the envelope and verify exact key set.
|
|
var decoded map[string]interface{}
|
|
if err := json.Unmarshal([]byte(jsonOutput), &decoded); err != nil {
|
|
t.Fatalf("outputBody JSON decode failed: %v; body=%q", err, jsonOutput)
|
|
}
|
|
|
|
if _, ok := decoded["error"]; !ok {
|
|
t.Errorf("decoded top-level keys: missing 'error'; got %+v", decoded)
|
|
}
|
|
topKeys := make([]string, 0, len(decoded))
|
|
for k := range decoded {
|
|
topKeys = append(topKeys, k)
|
|
}
|
|
if len(topKeys) != 1 || topKeys[0] != "error" {
|
|
t.Errorf("decoded top-level keys: want ['error'], got %v", topKeys)
|
|
}
|
|
|
|
errObj, ok := decoded["error"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("decoded['error'] is not object; got %T", decoded["error"])
|
|
}
|
|
errKeys := make([]string, 0, len(errObj))
|
|
for k := range errObj {
|
|
errKeys = append(errKeys, k)
|
|
}
|
|
if len(errKeys) != 2 {
|
|
t.Errorf("decoded['error'] keys: want 2, got %d (%v)", len(errKeys), errKeys)
|
|
}
|
|
if _, ok := errObj["type"]; !ok {
|
|
t.Error("decoded['error'] missing 'type'")
|
|
}
|
|
if _, ok := errObj["message"]; !ok {
|
|
t.Error("decoded['error'] missing 'message'")
|
|
}
|
|
if errObj["type"] != "rate_limit_error" {
|
|
t.Errorf("decoded['error'].type = %v, want rate_limit_error", errObj["type"])
|
|
}
|
|
if errObj["message"] != "rate_limit_exceeded" {
|
|
t.Errorf("decoded['error'].message = %v, want 'rate_limit_exceeded'", errObj["message"])
|
|
}
|
|
|
|
// Forbidden internal fields must not appear anywhere in the JSON output.
|
|
forbiddenFields := []string{"causes", "stack", "trace", "provider", "internal", "raw", "repetition_loop", "filter_blocked", "validation_failed", "cache_miss", "stage", "consumer"}
|
|
for _, f := range forbiddenFields {
|
|
if strings.Contains(jsonOutput, f) {
|
|
t.Errorf("forbidden field '%s' found in Responses JSON output: %s", f, jsonOutput)
|
|
}
|
|
}
|
|
|
|
// Verify the committed terminal reflects the bounded cause chain.
|
|
committed := sink.committedTerminal
|
|
if !committed.Error() {
|
|
t.Error("committed terminal should be error type")
|
|
}
|
|
committedCauses := committed.FailureCauses()
|
|
if committedCauses.Len() != 4 {
|
|
t.Errorf("committed causes Len() = %d, want 4", committedCauses.Len())
|
|
}
|
|
})
|
|
|
|
// --- Second commit must be rejected without changing output ---
|
|
t.Run("second_commit_rejected", func(t *testing.T) {
|
|
secondSink := &fakeHostReleaseSink{}
|
|
secondSink.state = streamgate.CommitStateStreamOpen
|
|
|
|
// First commit should succeed.
|
|
_, err := secondSink.CommitTerminal(ctx, tr)
|
|
if err != nil {
|
|
t.Fatalf("first CommitTerminal in second-commit check should succeed: %v", err)
|
|
}
|
|
secondSinkCommittedCount := secondSink.committedCount
|
|
secondSinkOutputBody := secondSink.outputBody
|
|
secondSinkState := secondSink.state
|
|
|
|
// Second commit must be rejected.
|
|
_, err = secondSink.CommitTerminal(ctx, tr)
|
|
if err == nil {
|
|
t.Error("second CommitTerminal should have been rejected")
|
|
}
|
|
|
|
if secondSink.committedCount != secondSinkCommittedCount {
|
|
t.Errorf("committedCount after second: %d, want %d", secondSink.committedCount, secondSinkCommittedCount)
|
|
}
|
|
|
|
if secondSink.outputBody != secondSinkOutputBody {
|
|
t.Errorf("outputBody changed after second commit: %q != %q", secondSink.outputBody, secondSinkOutputBody)
|
|
}
|
|
|
|
if secondSink.state != secondSinkState {
|
|
t.Errorf("state changed after second commit: %q != %q", secondSink.state, secondSinkState)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TEST-1: consumer semantic/intent parity
|
|
//
|
|
// semanticFilter is a test-only Filter implementation that produces a
|
|
// deterministic FilterDecision based on the event kind and a configurable
|
|
// rule. It owns only the semantic decision/optional-intent responsibility —
|
|
// it performs no mutation, retry, rebuild, dispatch, or submit callback.
|
|
// It is registered as a Filter via the public Filter interface only.
|
|
type semanticFilter struct {
|
|
base streamgate.FilterBase
|
|
ruleKind streamgate.FilterDecisionKind
|
|
intent *streamgate.RecoveryIntent
|
|
repl *streamgate.ReplacementProposal
|
|
}
|
|
|
|
func newSemanticFilter(id string, ruleKind streamgate.FilterDecisionKind, intent *streamgate.RecoveryIntent, repl *streamgate.ReplacementProposal) (*semanticFilter, error) {
|
|
base, err := streamgate.NewFilterBase(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &semanticFilter{base: base, ruleKind: ruleKind, intent: intent, repl: repl}, nil
|
|
}
|
|
|
|
func (f *semanticFilter) ID() string { return f.base.ID() }
|
|
|
|
func (f *semanticFilter) Applies(ctx streamgate.FilterContext) bool {
|
|
return ctx.Family() == "openai" || ctx.Family() == "agent"
|
|
}
|
|
|
|
func (f *semanticFilter) HoldRequirement(ctx streamgate.FilterContext) streamgate.FilterHoldRequirement {
|
|
kinds := []streamgate.EventKind{
|
|
streamgate.EventKindTextDelta,
|
|
streamgate.EventKindReasoningDelta,
|
|
streamgate.EventKindToolCallFragment,
|
|
streamgate.EventKindTerminal,
|
|
streamgate.EventKindProviderError,
|
|
}
|
|
// Determine hold mode based on filter ID suffix.
|
|
id := f.base.ID()
|
|
if strings.Contains(id, "rolling") {
|
|
return buildHoldRequirement(streamgate.FilterHoldModeRolling, ctx.StreamID(), kinds)
|
|
}
|
|
if strings.Contains(id, "terminal") {
|
|
return buildHoldRequirement(streamgate.FilterHoldModeTerminalGate, ctx.StreamID(), kinds)
|
|
}
|
|
if strings.Contains(id, "fragment") {
|
|
return buildHoldRequirement(streamgate.FilterHoldModeFragmentGate, ctx.StreamID(), kinds)
|
|
}
|
|
return buildHoldRequirement(streamgate.FilterHoldModeNone, ctx.StreamID(), kinds)
|
|
}
|
|
|
|
func (f *semanticFilter) Evaluate(_ context.Context, _ streamgate.FilterContext, batch streamgate.EvidenceBatch) (streamgate.FilterDecision, error) {
|
|
events := batch.Events()
|
|
if len(events) == 0 {
|
|
return streamgate.FilterDecision{}, errors.New("empty batch")
|
|
}
|
|
ev := events[len(events)-1]
|
|
ts := time.Now().UTC()
|
|
evidence, err := streamgate.NewSanitizedEvidence(
|
|
ev.Kind(), ev.Channel(), f.base.ID(), "rule.semantic", [32]byte{1, 2, 3}, 1, 0,
|
|
streamgate.FilterOutcomeKindEvaluated, ts,
|
|
)
|
|
if err != nil {
|
|
return streamgate.FilterDecision{}, err
|
|
}
|
|
if f.repl != nil {
|
|
return streamgate.NewFilterDecisionWithReplacement(f.ruleKind, "consumer.semantic", f.base.ID(), "rule.semantic", evidence, f.intent, f.repl)
|
|
}
|
|
return streamgate.NewFilterDecision(f.ruleKind, "consumer.semantic", f.base.ID(), "rule.semantic", evidence, f.intent)
|
|
}
|
|
|
|
// buildTestFilterContext creates a minimal FilterContext for test use.
|
|
func buildTestFilterContext() streamgate.FilterContext {
|
|
bc := streamgate.NewFilterContextBuilder("gen-test", "attempt-test")
|
|
bc = bc.SetEnvironment("staging")
|
|
bc = bc.SetEndpoint("chat-sse")
|
|
bc = bc.SetFamily("openai")
|
|
bc = bc.SetStream("ch-1")
|
|
ctx, _ := bc.Build()
|
|
return ctx
|
|
}
|
|
|
|
// buildHoldRequirement creates a hold requirement with the given mode and channel.
|
|
func buildHoldRequirement(mode streamgate.FilterHoldMode, channel string, kinds []streamgate.EventKind) streamgate.FilterHoldRequirement {
|
|
switch mode {
|
|
case streamgate.FilterHoldModeNone:
|
|
req, _ := streamgate.NewFilterHoldRequirementNone(channel, kinds)
|
|
return req
|
|
case streamgate.FilterHoldModeRolling:
|
|
req, _ := streamgate.NewFilterHoldRequirementRolling(channel, kinds, 10)
|
|
return req
|
|
case streamgate.FilterHoldModeTerminalGate:
|
|
req, _ := streamgate.NewFilterHoldRequirementTerminalGate(channel, kinds, streamgate.EventKindTerminal)
|
|
return req
|
|
case streamgate.FilterHoldModeFragmentGate:
|
|
req, _ := streamgate.NewFilterHoldRequirementFragmentGate(channel, kinds, streamgate.EventKindToolCallFragment)
|
|
return req
|
|
}
|
|
req, _ := streamgate.NewFilterHoldRequirementNone(channel, kinds)
|
|
return req
|
|
}
|
|
|
|
// TestConsumerSemanticIntentParity verifies that the same semantic Filter
|
|
// decision matrix produces identical ArbitrationResult regardless of whether
|
|
// the input events come from the OpenAI-family or the agent-family normalizer.
|
|
//
|
|
// The matrix exercises: pass, violation-without-intent, violation-with-valid-
|
|
// recovery-intent, fatal, replacement, unmatched-provider-error, matched-
|
|
// provider-error. Each family's raw fixture and converter are independent —
|
|
// fakeOpenAIRawKind is never shared with normalizeAgentEvent and vice versa.
|
|
func TestConsumerSemanticIntentParity(t *testing.T) {
|
|
ts := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC)
|
|
|
|
// --- Build a valid recovery intent for the "violation with intent" case. ---
|
|
directive, err := streamgate.NewRecoveryDirectiveExact("req-1")
|
|
if err != nil {
|
|
t.Fatalf("NewRecoveryDirectiveExact: %v", err)
|
|
}
|
|
intent, err := streamgate.NewRecoveryIntent(
|
|
streamgate.RecoveryStrategyExactReplay, directive,
|
|
"consumer.recovery.attempt", 100,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewRecoveryIntent: %v", err)
|
|
}
|
|
|
|
// --- Build a replacement proposal for the "replacement" case. ---
|
|
replEv, err := streamgate.NewTextDeltaEvent("ch-1", "safe-replacement", ts)
|
|
if err != nil {
|
|
t.Fatalf("NewTextDeltaEvent for replacement: %v", err)
|
|
}
|
|
replProposal, err := streamgate.NewReplacementProposal([]streamgate.NormalizedEvent{replEv})
|
|
if err != nil {
|
|
t.Fatalf("NewReplacementProposal: %v", err)
|
|
}
|
|
|
|
// --- Build a matched provider-error descriptor for the "matched intent" case. ---
|
|
matchedDesc, err := streamgate.NewExternalDescriptor("rate_limit_error", "rate_limit_exceeded", "rate_limit_exceeded", "")
|
|
if err != nil {
|
|
t.Fatalf("NewExternalDescriptor (matched): %v", err)
|
|
}
|
|
_ = matchedDesc // reserved for future expanded matrix
|
|
|
|
// --- Semantic filter instances for each decision kind. ---
|
|
fPass, err := newSemanticFilter("f.pass", streamgate.FilterDecisionKindPass, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("newSemanticFilter pass: %v", err)
|
|
}
|
|
fViolation, err := newSemanticFilter("f.violation", streamgate.FilterDecisionKindViolation, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("newSemanticFilter violation: %v", err)
|
|
}
|
|
fViolationIntent, err := newSemanticFilter("f.violation_intent", streamgate.FilterDecisionKindViolation, &intent, nil)
|
|
if err != nil {
|
|
t.Fatalf("newSemanticFilter violation_intent: %v", err)
|
|
}
|
|
fFatal, err := newSemanticFilter("f.fatal", streamgate.FilterDecisionKindFatal, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("newSemanticFilter fatal: %v", err)
|
|
}
|
|
fReplacement, err := newSemanticFilter("f.replacement", streamgate.FilterDecisionKindReplacement, nil, &replProposal)
|
|
if err != nil {
|
|
t.Fatalf("newSemanticFilter replacement: %v", err)
|
|
}
|
|
|
|
// --- Matrix of test cases. Each case runs identically for both families. ---
|
|
type matrixCase struct {
|
|
name string
|
|
filter *semanticFilter
|
|
wantAction streamgate.ArbitrationAction
|
|
wantFilterID string
|
|
wantRuleID string
|
|
wantIntentNil bool
|
|
wantReplNil bool
|
|
wantBaseDisp streamgate.BaseEventDisposition
|
|
}
|
|
|
|
cases := []matrixCase{
|
|
{
|
|
name: "pass",
|
|
filter: fPass,
|
|
wantAction: streamgate.ArbitrationActionRelease,
|
|
wantFilterID: "",
|
|
wantRuleID: "",
|
|
wantIntentNil: true,
|
|
wantReplNil: true,
|
|
wantBaseDisp: streamgate.BaseDispositionReleaseCandidate,
|
|
},
|
|
{
|
|
name: "violation_without_intent",
|
|
filter: fViolation,
|
|
wantAction: streamgate.ArbitrationActionTerminal,
|
|
wantFilterID: "f.violation",
|
|
wantRuleID: "rule.semantic",
|
|
wantIntentNil: true,
|
|
wantReplNil: true,
|
|
wantBaseDisp: streamgate.BaseDispositionReleaseCandidate,
|
|
},
|
|
{
|
|
name: "violation_with_valid_intent",
|
|
filter: fViolationIntent,
|
|
wantAction: streamgate.ArbitrationActionRecover,
|
|
wantFilterID: "f.violation_intent",
|
|
wantRuleID: "rule.semantic",
|
|
wantIntentNil: false,
|
|
wantReplNil: true,
|
|
wantBaseDisp: streamgate.BaseDispositionReleaseCandidate,
|
|
},
|
|
{
|
|
name: "fatal",
|
|
filter: fFatal,
|
|
wantAction: streamgate.ArbitrationActionTerminal,
|
|
wantFilterID: "f.fatal",
|
|
wantRuleID: "rule.semantic",
|
|
wantIntentNil: true,
|
|
wantReplNil: true,
|
|
wantBaseDisp: streamgate.BaseDispositionReleaseCandidate,
|
|
},
|
|
{
|
|
name: "replacement",
|
|
filter: fReplacement,
|
|
wantAction: streamgate.ArbitrationActionReplacement,
|
|
wantFilterID: "f.replacement",
|
|
wantRuleID: "rule.semantic",
|
|
wantIntentNil: true,
|
|
wantReplNil: false,
|
|
wantBaseDisp: streamgate.BaseDispositionReleaseCandidate,
|
|
},
|
|
}
|
|
|
|
// --- Helper to run a single case through both families. ---
|
|
runCase := func(tc matrixCase, family string, normalizeFn func() (streamgate.NormalizedEvent, error)) {
|
|
t.Run(tc.name+"_"+family, func(t *testing.T) {
|
|
ev, err := normalizeFn()
|
|
if err != nil {
|
|
t.Fatalf("normalize %s: %v", family, err)
|
|
}
|
|
|
|
// Build batch.
|
|
batch, err := streamgate.NewEvidenceBatch(
|
|
[]streamgate.NormalizedEvent{ev},
|
|
nil, nil, nil, false,
|
|
streamgate.CommitStateTransportUncommitted, ts,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewEvidenceBatch %s: %v", family, err)
|
|
}
|
|
|
|
// Build evaluation set: one outcome for the semantic filter.
|
|
decision, err := tc.filter.Evaluate(context.Background(), buildTestFilterContext(), batch)
|
|
if err != nil {
|
|
t.Fatalf("Evaluate %s: %v", family, err)
|
|
}
|
|
oc, err := streamgate.NewFilterOutcomeEvaluated(decision)
|
|
if err != nil {
|
|
t.Fatalf("NewFilterOutcomeEvaluated %s: %v", family, err)
|
|
}
|
|
o, err := streamgate.NewEpochFilterOutcome(tc.filter.ID(), 100, oc, streamgate.FilterEnforcementBlocking)
|
|
if err != nil {
|
|
t.Fatalf("NewEpochFilterOutcome %s: %v", family, err)
|
|
}
|
|
set, err := streamgate.NewEvaluationSet([]streamgate.EpochFilterOutcome{o})
|
|
if err != nil {
|
|
t.Fatalf("NewEvaluationSet %s: %v", family, err)
|
|
}
|
|
|
|
// Arbitrate.
|
|
res, err := streamgate.Arbitrate(context.Background(), batch, set)
|
|
if err != nil {
|
|
t.Fatalf("Arbitrate %s: %v", family, err)
|
|
}
|
|
|
|
// --- Verify result parity. ---
|
|
if res.Action() != tc.wantAction {
|
|
t.Errorf("action = %v, want %v", res.Action(), tc.wantAction)
|
|
}
|
|
if res.FilterID() != tc.wantFilterID {
|
|
t.Errorf("filterID = %q, want %q", res.FilterID(), tc.wantFilterID)
|
|
}
|
|
if res.RuleID() != tc.wantRuleID {
|
|
t.Errorf("ruleID = %q, want %q", res.RuleID(), tc.wantRuleID)
|
|
}
|
|
if tc.wantIntentNil && res.RecoveryIntent() != nil {
|
|
t.Error("intent should be nil")
|
|
}
|
|
if !tc.wantIntentNil && res.RecoveryIntent() == nil {
|
|
t.Error("intent should not be nil")
|
|
}
|
|
if tc.wantReplNil && res.ReplacementProposal() != nil {
|
|
t.Error("replacement should be nil")
|
|
}
|
|
if !tc.wantReplNil && res.ReplacementProposal() == nil {
|
|
t.Error("replacement should not be nil")
|
|
}
|
|
if res.BaseDisposition() != tc.wantBaseDisp {
|
|
t.Errorf("baseDisposition = %v, want %v", res.BaseDisposition(), tc.wantBaseDisp)
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- Run each case for OpenAI family. ---
|
|
for _, tc := range cases {
|
|
tc := tc
|
|
switch tc.name {
|
|
case "pass":
|
|
runCase(tc, "openai", func() (streamgate.NormalizedEvent, error) {
|
|
return normalizeOpenAIFrame(fakeOpenAIProviderFrame{kind: rawOpenAITextDelta, text: "hello"})
|
|
})
|
|
case "violation_without_intent":
|
|
runCase(tc, "openai", func() (streamgate.NormalizedEvent, error) {
|
|
return normalizeOpenAIFrame(fakeOpenAIProviderFrame{kind: rawOpenAITextDelta, text: "bad-input"})
|
|
})
|
|
case "violation_with_valid_intent":
|
|
runCase(tc, "openai", func() (streamgate.NormalizedEvent, error) {
|
|
return normalizeOpenAIFrame(fakeOpenAIProviderFrame{kind: rawOpenAITextDelta, text: "recoverable"})
|
|
})
|
|
case "fatal":
|
|
runCase(tc, "openai", func() (streamgate.NormalizedEvent, error) {
|
|
return normalizeOpenAIFrame(fakeOpenAIProviderFrame{kind: rawOpenAITextDelta, text: "fatal-input"})
|
|
})
|
|
case "replacement":
|
|
runCase(tc, "openai", func() (streamgate.NormalizedEvent, error) {
|
|
return normalizeOpenAIFrame(fakeOpenAIProviderFrame{kind: rawOpenAITextDelta, text: "replace-me"})
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Run each case for agent family. ---
|
|
for _, tc := range cases {
|
|
tc := tc
|
|
switch tc.name {
|
|
case "pass":
|
|
runCase(tc, "agent", func() (streamgate.NormalizedEvent, error) {
|
|
return normalizeAgentEvent(fakeAgentRuntimeEvent{kind: rawAgentTextDelta, text: "hello"})
|
|
})
|
|
case "violation_without_intent":
|
|
runCase(tc, "agent", func() (streamgate.NormalizedEvent, error) {
|
|
return normalizeAgentEvent(fakeAgentRuntimeEvent{kind: rawAgentTextDelta, text: "bad-input"})
|
|
})
|
|
case "violation_with_valid_intent":
|
|
runCase(tc, "agent", func() (streamgate.NormalizedEvent, error) {
|
|
return normalizeAgentEvent(fakeAgentRuntimeEvent{kind: rawAgentTextDelta, text: "recoverable"})
|
|
})
|
|
case "fatal":
|
|
runCase(tc, "agent", func() (streamgate.NormalizedEvent, error) {
|
|
return normalizeAgentEvent(fakeAgentRuntimeEvent{kind: rawAgentTextDelta, text: "fatal-input"})
|
|
})
|
|
case "replacement":
|
|
runCase(tc, "agent", func() (streamgate.NormalizedEvent, error) {
|
|
return normalizeAgentEvent(fakeAgentRuntimeEvent{kind: rawAgentTextDelta, text: "replace-me"})
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Cross-family parity assertion: same filter, same batch content, same result. ---
|
|
// The OpenAI and agent families produce different event payloads (different text
|
|
// content, different descriptor type/message) but the semantic filter ignores
|
|
// payload and only inspects Kind(). Therefore the ArbitrationResult must be
|
|
// identical across families for each matrix case.
|
|
for _, tc := range cases {
|
|
tc := tc
|
|
t.Run(tc.name+"_cross_family_parity", func(t *testing.T) {
|
|
openaiEv, err := normalizeOpenAIFrame(fakeOpenAIProviderFrame{kind: rawOpenAITextDelta, text: "parity-test"})
|
|
if err != nil {
|
|
t.Fatalf("openai normalize: %v", err)
|
|
}
|
|
agentEv, err := normalizeAgentEvent(fakeAgentRuntimeEvent{kind: rawAgentTextDelta, text: "parity-test"})
|
|
if err != nil {
|
|
t.Fatalf("agent normalize: %v", err)
|
|
}
|
|
|
|
// Both events must have the same Kind.
|
|
if openaiEv.Kind() != agentEv.Kind() {
|
|
t.Fatalf("kind mismatch: openai=%v, agent=%v", openaiEv.Kind(), agentEv.Kind())
|
|
}
|
|
|
|
batchOpenAI, err := streamgate.NewEvidenceBatch(
|
|
[]streamgate.NormalizedEvent{openaiEv},
|
|
nil, nil, nil, false,
|
|
streamgate.CommitStateTransportUncommitted, ts,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewEvidenceBatch openai: %v", err)
|
|
}
|
|
batchAgent, err := streamgate.NewEvidenceBatch(
|
|
[]streamgate.NormalizedEvent{agentEv},
|
|
nil, nil, nil, false,
|
|
streamgate.CommitStateTransportUncommitted, ts,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewEvidenceBatch agent: %v", err)
|
|
}
|
|
|
|
// Build outcomes identically.
|
|
decision, err := tc.filter.Evaluate(context.Background(), buildTestFilterContext(), batchOpenAI)
|
|
if err != nil {
|
|
t.Fatalf("Evaluate: %v", err)
|
|
}
|
|
oc, _ := streamgate.NewFilterOutcomeEvaluated(decision)
|
|
o, _ := streamgate.NewEpochFilterOutcome(tc.filter.ID(), 100, oc, streamgate.FilterEnforcementBlocking)
|
|
set, _ := streamgate.NewEvaluationSet([]streamgate.EpochFilterOutcome{o})
|
|
|
|
resOpenAI, err := streamgate.Arbitrate(context.Background(), batchOpenAI, set)
|
|
if err != nil {
|
|
t.Fatalf("Arbitrate openai: %v", err)
|
|
}
|
|
resAgent, err := streamgate.Arbitrate(context.Background(), batchAgent, set)
|
|
if err != nil {
|
|
t.Fatalf("Arbitrate agent: %v", err)
|
|
}
|
|
|
|
// Core assertion: the ArbitrationResult is identical.
|
|
if resOpenAI.Action() != resAgent.Action() {
|
|
t.Errorf("action mismatch: openai=%v, agent=%v", resOpenAI.Action(), resAgent.Action())
|
|
}
|
|
if resOpenAI.FilterID() != resAgent.FilterID() {
|
|
t.Errorf("filterID mismatch: openai=%q, agent=%q", resOpenAI.FilterID(), resAgent.FilterID())
|
|
}
|
|
if resOpenAI.RuleID() != resAgent.RuleID() {
|
|
t.Errorf("ruleID mismatch: openai=%q, agent=%q", resOpenAI.RuleID(), resAgent.RuleID())
|
|
}
|
|
if resOpenAI.BaseDisposition() != resAgent.BaseDisposition() {
|
|
t.Errorf("baseDisposition mismatch: openai=%v, agent=%v", resOpenAI.BaseDisposition(), resAgent.BaseDisposition())
|
|
}
|
|
if (resOpenAI.RecoveryIntent() == nil) != (resAgent.RecoveryIntent() == nil) {
|
|
t.Error("RecoveryIntent nil-ness mismatch between families")
|
|
}
|
|
if (resOpenAI.ReplacementProposal() == nil) != (resAgent.ReplacementProposal() == nil) {
|
|
t.Error("ReplacementProposal nil-ness mismatch between families")
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- Provider error matrix: unmatched vs matched intent. ---
|
|
t.Run("provider_error_matrix", func(t *testing.T) {
|
|
// Build provider error events for both families.
|
|
openaiPE, err := normalizeOpenAIFrame(fakeOpenAIProviderFrame{
|
|
kind: rawOpenAIProviderError, errorType: "rate_limit_error",
|
|
errorCode: "rate_limit_exceeded", errorMessage: "rate_limit_exceeded",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("openai provider error normalize: %v", err)
|
|
}
|
|
agentPE, err := normalizeAgentEvent(fakeAgentRuntimeEvent{
|
|
kind: rawAgentProviderError, errorType: "agent_rate_limit",
|
|
errorCode: "agent_rate_limit_exceeded", errorMessage: "agent_rate_limit_exceeded",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("agent provider error normalize: %v", err)
|
|
}
|
|
|
|
// Both must be provider_error kind.
|
|
if openaiPE.Kind() != streamgate.EventKindProviderError {
|
|
t.Errorf("openai PE kind = %v, want provider_error", openaiPE.Kind())
|
|
}
|
|
if agentPE.Kind() != streamgate.EventKindProviderError {
|
|
t.Errorf("agent PE kind = %v, want provider_error", agentPE.Kind())
|
|
}
|
|
|
|
// --- Unmatched provider error: must be terminal (no recovery intent). ---
|
|
t.Run("unmatched_provider_error_is_terminal", func(t *testing.T) {
|
|
batch, err := streamgate.NewEvidenceBatch(
|
|
[]streamgate.NormalizedEvent{openaiPE},
|
|
nil, nil, nil, true,
|
|
streamgate.CommitStateTransportUncommitted, ts,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewEvidenceBatch terminal: %v", err)
|
|
}
|
|
fUnmatched, err := newSemanticFilter("f.unmatched", streamgate.FilterDecisionKindViolation, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("newSemanticFilter unmatched: %v", err)
|
|
}
|
|
decision, _ := fUnmatched.Evaluate(context.Background(), buildTestFilterContext(), batch)
|
|
oc, _ := streamgate.NewFilterOutcomeEvaluated(decision)
|
|
o, _ := streamgate.NewEpochFilterOutcome(fUnmatched.ID(), 100, oc, streamgate.FilterEnforcementBlocking)
|
|
set, _ := streamgate.NewEvaluationSet([]streamgate.EpochFilterOutcome{o})
|
|
res, err := streamgate.Arbitrate(context.Background(), batch, set)
|
|
if err != nil {
|
|
t.Fatalf("Arbitrate: %v", err)
|
|
}
|
|
if res.Action() != streamgate.ArbitrationActionTerminal {
|
|
t.Errorf("unmatched PE action = %v, want terminal", res.Action())
|
|
}
|
|
if res.RecoveryIntent() != nil {
|
|
t.Error("unmatched PE must not have recovery intent")
|
|
}
|
|
})
|
|
|
|
// --- Matched provider error with valid intent: must recover. ---
|
|
t.Run("matched_provider_error_recover", func(t *testing.T) {
|
|
batch, err := streamgate.NewEvidenceBatch(
|
|
[]streamgate.NormalizedEvent{agentPE},
|
|
nil, nil, nil, true,
|
|
streamgate.CommitStateTransportUncommitted, ts,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewEvidenceBatch terminal: %v", err)
|
|
}
|
|
fMatched, err := newSemanticFilter("f.matched", streamgate.FilterDecisionKindViolation, &intent, nil)
|
|
if err != nil {
|
|
t.Fatalf("newSemanticFilter matched: %v", err)
|
|
}
|
|
decision, _ := fMatched.Evaluate(context.Background(), buildTestFilterContext(), batch)
|
|
oc, _ := streamgate.NewFilterOutcomeEvaluated(decision)
|
|
o, _ := streamgate.NewEpochFilterOutcome(fMatched.ID(), 100, oc, streamgate.FilterEnforcementBlocking)
|
|
set, _ := streamgate.NewEvaluationSet([]streamgate.EpochFilterOutcome{o})
|
|
res, err := streamgate.Arbitrate(context.Background(), batch, set)
|
|
if err != nil {
|
|
t.Fatalf("Arbitrate: %v", err)
|
|
}
|
|
if res.Action() != streamgate.ArbitrationActionRecover {
|
|
t.Errorf("matched PE action = %v, want recover", res.Action())
|
|
}
|
|
if res.RecoveryIntent() == nil {
|
|
t.Error("matched PE must have recovery intent")
|
|
}
|
|
})
|
|
})
|
|
|
|
// --- Priority mismatch rejection: intent priority must match filter effective priority. ---
|
|
t.Run("intent_priority_mismatch_rejected", func(t *testing.T) {
|
|
wrongDirective, err := streamgate.NewRecoveryDirectiveExact("req-wrong")
|
|
if err != nil {
|
|
t.Fatalf("NewRecoveryDirectiveExact: %v", err)
|
|
}
|
|
wrongIntent, err := streamgate.NewRecoveryIntent(
|
|
streamgate.RecoveryStrategyExactReplay, wrongDirective, "reason", 999,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewRecoveryIntent: %v", err)
|
|
}
|
|
fWrong, err := newSemanticFilter("f.wrong", streamgate.FilterDecisionKindViolation, &wrongIntent, nil)
|
|
if err != nil {
|
|
t.Fatalf("newSemanticFilter wrong: %v", err)
|
|
}
|
|
|
|
ev, err := normalizeOpenAIFrame(fakeOpenAIProviderFrame{kind: rawOpenAITextDelta, text: "test"})
|
|
if err != nil {
|
|
t.Fatalf("normalize: %v", err)
|
|
}
|
|
batch, err := streamgate.NewEvidenceBatch(
|
|
[]streamgate.NormalizedEvent{ev},
|
|
nil, nil, nil, false,
|
|
streamgate.CommitStateTransportUncommitted, ts,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewEvidenceBatch: %v", err)
|
|
}
|
|
|
|
decision, err := fWrong.Evaluate(context.Background(), buildTestFilterContext(), batch)
|
|
if err != nil {
|
|
t.Fatalf("Evaluate: %v", err)
|
|
}
|
|
oc, _ := streamgate.NewFilterOutcomeEvaluated(decision)
|
|
// The outcome wrapper has priority 100; the intent has priority 999.
|
|
// Arbitrate must reject this mismatch.
|
|
o, _ := streamgate.NewEpochFilterOutcome(fWrong.ID(), 100, oc, streamgate.FilterEnforcementBlocking)
|
|
set, _ := streamgate.NewEvaluationSet([]streamgate.EpochFilterOutcome{o})
|
|
_, err = streamgate.Arbitrate(context.Background(), batch, set)
|
|
if err == nil {
|
|
t.Error("Arbitrate must reject intent priority mismatch")
|
|
}
|
|
})
|
|
|
|
// --- Violation intent on non-violation kind is rejected by FilterDecision.Validate. ---
|
|
t.Run("intent_on_non_violation_rejected", func(t *testing.T) {
|
|
evidence, err := streamgate.NewSanitizedEvidence(
|
|
streamgate.EventKindTextDelta, "ch-1", "f.id", "rule.id",
|
|
[32]byte{1, 2, 3}, 1, 0,
|
|
streamgate.FilterOutcomeKindEvaluated, ts,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewSanitizedEvidence: %v", err)
|
|
}
|
|
_, err = streamgate.NewFilterDecision(
|
|
streamgate.FilterDecisionKindPass, "consumer.id", "f.id", "rule.id",
|
|
evidence, &intent,
|
|
)
|
|
if err == nil {
|
|
t.Error("NewFilterDecision(pass, intent) should be rejected")
|
|
}
|
|
_, err = streamgate.NewFilterDecision(
|
|
streamgate.FilterDecisionKindFatal, "consumer.id", "f.id", "rule.id",
|
|
evidence, &intent,
|
|
)
|
|
if err == nil {
|
|
t.Error("NewFilterDecision(fatal, intent) should be rejected")
|
|
}
|
|
_, err = streamgate.NewFilterDecision(
|
|
streamgate.FilterDecisionKindReplacement, "consumer.id", "f.id", "rule.id",
|
|
evidence, &intent,
|
|
)
|
|
if err == nil {
|
|
t.Error("NewFilterDecision(replacement, intent) should be rejected")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TEST-2: mixed policy public integration
|
|
//
|
|
// mixedPolicyFixture registers rolling, terminal-gate, fragment-gate, and
|
|
// provider-error filters with mixed enforcement (blocking + observe-only) on
|
|
// the same resolved request, then drives them through the public API path:
|
|
// registry → resolve → plan → bind epoch → coordinator.Evaluate → arbiter.
|
|
//
|
|
// It verifies: applicability, hold/release, single-buffer/commit boundary —
|
|
// all through external-package public API only. No internal field access.
|
|
func TestConsumerPolicyMixedPublicIntegration(t *testing.T) {
|
|
ts := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC)
|
|
|
|
// --- Semantic filter that always passes. Used as a neutral observer. ---
|
|
fNeutral, err := newSemanticFilter("f.neutral", streamgate.FilterDecisionKindPass, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("newSemanticFilter neutral: %v", err)
|
|
}
|
|
|
|
// --- Semantic filter that blocks on terminal (terminal-gate style). ---
|
|
fTerminal, err := newSemanticFilter("f.terminal", streamgate.FilterDecisionKindPass, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("newSemanticFilter terminal: %v", err)
|
|
}
|
|
|
|
// --- Semantic filter that blocks on tool call fragments (fragment-gate style). ---
|
|
fFragment, err := newSemanticFilter("f.fragment", streamgate.FilterDecisionKindPass, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("newSemanticFilter fragment: %v", err)
|
|
}
|
|
|
|
// --- Semantic filter that blocks on text delta (rolling style). ---
|
|
fRolling, err := newSemanticFilter("f.rolling", streamgate.FilterDecisionKindPass, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("newSemanticFilter rolling: %v", err)
|
|
}
|
|
|
|
// --- Semantic filter for provider error (observe-only). ---
|
|
fPEObserve, err := newSemanticFilter("f.pe_observe", streamgate.FilterDecisionKindPass, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("newSemanticFilter pe_observe: %v", err)
|
|
}
|
|
|
|
// --- Build registry snapshot. ---
|
|
regNeutral, err := streamgate.NewFilterRegistration(fNeutral, "cap-neutral", true, streamgate.FilterEnforcementBlocking, 5*time.Second, 10)
|
|
if err != nil {
|
|
t.Fatalf("NewFilterRegistration neutral: %v", err)
|
|
}
|
|
regTerminal, err := streamgate.NewFilterRegistration(fTerminal, "cap-terminal", true, streamgate.FilterEnforcementBlocking, 5*time.Second, 20)
|
|
if err != nil {
|
|
t.Fatalf("NewFilterRegistration terminal: %v", err)
|
|
}
|
|
regFragment, err := streamgate.NewFilterRegistration(fFragment, "cap-fragment", true, streamgate.FilterEnforcementBlocking, 5*time.Second, 30)
|
|
if err != nil {
|
|
t.Fatalf("NewFilterRegistration fragment: %v", err)
|
|
}
|
|
regRolling, err := streamgate.NewFilterRegistration(fRolling, "cap-rolling", true, streamgate.FilterEnforcementBlocking, 5*time.Second, 40)
|
|
if err != nil {
|
|
t.Fatalf("NewFilterRegistration rolling: %v", err)
|
|
}
|
|
regPE, err := streamgate.NewFilterRegistration(fPEObserve, "cap-pe", true, streamgate.FilterEnforcementObserveOnly, 5*time.Second, 50)
|
|
if err != nil {
|
|
t.Fatalf("NewFilterRegistration pe: %v", err)
|
|
}
|
|
|
|
snap, err := streamgate.NewFilterRegistrySnapshot("gen-mixed", []streamgate.FilterRegistration{
|
|
regNeutral, regTerminal, regFragment, regRolling, regPE,
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatalf("NewFilterRegistrySnapshot: %v", err)
|
|
}
|
|
|
|
// --- Begin request. ---
|
|
rc, err := streamgate.NewRequestFilterContext(
|
|
"gen-mixed", "attempt-mixed", "staging", "chat-sse", "openai",
|
|
"ch-1", streamgate.CommitStateTransportUncommitted,
|
|
false, false, "corr-1",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewRequestFilterContext: %v", err)
|
|
}
|
|
rsnap, err := snap.BeginRequest(rc)
|
|
if err != nil {
|
|
t.Fatalf("BeginRequest: %v", err)
|
|
}
|
|
|
|
// --- Resolve attempt. ---
|
|
target, err := streamgate.NewAttemptTarget("mg-1", "model-gpt-4o", "prov-openai", "primary", []string{
|
|
"cap-neutral", "cap-terminal", "cap-fragment", "cap-rolling", "cap-pe",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewAttemptTarget: %v", err)
|
|
}
|
|
resolved, err := rsnap.ResolveAttempt(target)
|
|
if err != nil {
|
|
t.Fatalf("ResolveAttempt: %v", err)
|
|
}
|
|
if len(resolved) != 5 {
|
|
t.Fatalf("ResolveAttempt returned %d filters, want 5", len(resolved))
|
|
}
|
|
|
|
// --- Build EvidencePlan from resolved filters. ---
|
|
plan, err := streamgate.NewEvidencePlanFromResolvedFilters(resolved)
|
|
if err != nil {
|
|
t.Fatalf("NewEvidencePlanFromResolvedFilters: %v", err)
|
|
}
|
|
|
|
// --- Verify plan has blocking requirement for channel. ---
|
|
if !plan.HasBlockingRequirement("ch-1") {
|
|
t.Error("plan must have blocking requirement for ch-1")
|
|
}
|
|
|
|
// --- Helper: build epoch with all-ready applicabilities for all resolved filters. ---
|
|
buildEpoch := func(epochID uint64, channel string, mode streamgate.FilterHoldMode, triggered bool, reason string, allReady bool) (streamgate.EvidenceEpoch, error) {
|
|
var apps []streamgate.FilterApplicability
|
|
for _, rf := range resolved {
|
|
app, err := streamgate.NewFilterApplicability(rf.FilterID(), allReady, allReady)
|
|
if err != nil {
|
|
return streamgate.EvidenceEpoch{}, err
|
|
}
|
|
apps = append(apps, app)
|
|
}
|
|
return streamgate.NewEvidenceEpochWithApplicabilities(epochID, channel, mode, triggered, reason, apps)
|
|
}
|
|
|
|
// --- Epoch 1: text delta events (rolling epoch, all ready). ---
|
|
t.Run("rolling_epoch_text_delta", func(t *testing.T) {
|
|
ev1, err := streamgate.NewTextDeltaEvent("ch-1", "hello-world-text-delta-evaluation-trigger", ts)
|
|
if err != nil {
|
|
t.Fatalf("NewTextDeltaEvent 1: %v", err)
|
|
}
|
|
ev2, err := streamgate.NewTextDeltaEvent("ch-1", "second-text-delta-for-evaluation", ts)
|
|
if err != nil {
|
|
t.Fatalf("NewTextDeltaEvent 2: %v", err)
|
|
}
|
|
|
|
batch, err := streamgate.NewEvidenceBatch(
|
|
[]streamgate.NormalizedEvent{ev1, ev2},
|
|
nil, nil, nil, false,
|
|
streamgate.CommitStateTransportUncommitted, ts,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewEvidenceBatch rolling: %v", err)
|
|
}
|
|
|
|
epoch, err := buildEpoch(1, "ch-1", streamgate.FilterHoldModeRolling, true, "threshold", true)
|
|
if err != nil {
|
|
t.Fatalf("buildEpoch: %v", err)
|
|
}
|
|
|
|
efList, err := plan.BindEpochFilters(epoch, resolved)
|
|
if err != nil {
|
|
t.Fatalf("BindEpochFilters: %v", err)
|
|
}
|
|
if len(efList) != 5 {
|
|
t.Fatalf("BindEpochFilters returned %d filters, want 5", len(efList))
|
|
}
|
|
|
|
// Drive through coordinator.
|
|
hostCtx := context.Background()
|
|
arb := streamgate.NewDecisionArbiter()
|
|
coord, err := streamgate.NewGateCoordinator(hostCtx, streamgate.GateCoordinatorOptions{MaxQueuedEpochs: 0}, arb)
|
|
if err != nil {
|
|
t.Fatalf("NewGateCoordinator: %v", err)
|
|
}
|
|
defer coord.Close()
|
|
|
|
set, res, err := coord.Evaluate(hostCtx, batch, efList)
|
|
if err != nil {
|
|
t.Fatalf("Evaluate: %v", err)
|
|
}
|
|
|
|
// All filters pass → release (hold/release is managed by EvidenceTail,
|
|
// not by the arbiter when all filter decisions are pass).
|
|
if res.Action() != streamgate.ArbitrationActionRelease {
|
|
t.Errorf("rolling epoch action = %v, want release", res.Action())
|
|
}
|
|
|
|
// Set must have 5 outcomes.
|
|
if set.Len() != 5 {
|
|
t.Errorf("set.Len() = %d, want 5", set.Len())
|
|
}
|
|
|
|
// All outcomes must be evaluated.
|
|
for i := 0; i < set.Len(); i++ {
|
|
o, _ := set.At(i)
|
|
if o.Outcome().Kind() != streamgate.FilterOutcomeKindEvaluated {
|
|
t.Errorf("outcome[%d].Kind() = %v, want evaluated", i, o.Outcome().Kind())
|
|
}
|
|
}
|
|
})
|
|
|
|
// --- Epoch 2: terminal event (terminal-gate epoch). ---
|
|
t.Run("terminal_gate_epoch", func(t *testing.T) {
|
|
ev1, err := streamgate.NewTextDeltaEvent("ch-1", "text-before-terminal", ts)
|
|
if err != nil {
|
|
t.Fatalf("NewTextDeltaEvent: %v", err)
|
|
}
|
|
ev2, err := streamgate.NewTerminalEvent("ch-1", ts)
|
|
if err != nil {
|
|
t.Fatalf("NewTerminalEvent: %v", err)
|
|
}
|
|
|
|
batch, err := streamgate.NewEvidenceBatch(
|
|
[]streamgate.NormalizedEvent{ev1, ev2},
|
|
nil, nil, nil, true,
|
|
streamgate.CommitStateTransportUncommitted, ts,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewEvidenceBatch terminal: %v", err)
|
|
}
|
|
|
|
epoch, err := buildEpoch(2, "ch-1", streamgate.FilterHoldModeTerminalGate, true, "trigger", true)
|
|
if err != nil {
|
|
t.Fatalf("buildEpoch: %v", err)
|
|
}
|
|
|
|
efList, err := plan.BindEpochFilters(epoch, resolved)
|
|
if err != nil {
|
|
t.Fatalf("BindEpochFilters: %v", err)
|
|
}
|
|
|
|
hostCtx := context.Background()
|
|
arb := streamgate.NewDecisionArbiter()
|
|
coord, err := streamgate.NewGateCoordinator(hostCtx, streamgate.GateCoordinatorOptions{MaxQueuedEpochs: 0}, arb)
|
|
if err != nil {
|
|
t.Fatalf("NewGateCoordinator: %v", err)
|
|
}
|
|
defer coord.Close()
|
|
|
|
set, res, err := coord.Evaluate(hostCtx, batch, efList)
|
|
if err != nil {
|
|
t.Fatalf("Evaluate: %v", err)
|
|
}
|
|
|
|
// Terminal batch with all-pass → terminal action.
|
|
if res.Action() != streamgate.ArbitrationActionTerminal {
|
|
t.Errorf("terminal gate action = %v, want terminal", res.Action())
|
|
}
|
|
if res.BaseDisposition() != streamgate.BaseDispositionTerminalSuccessCandidate {
|
|
t.Errorf("terminal gate baseDisposition = %v, want terminal_success_candidate", res.BaseDisposition())
|
|
}
|
|
if set.Len() != 5 {
|
|
t.Errorf("set.Len() = %d, want 5", set.Len())
|
|
}
|
|
})
|
|
|
|
// --- Epoch 3: provider error only (event-only filter). ---
|
|
t.Run("provider_error_epoch", func(t *testing.T) {
|
|
desc, err := streamgate.NewExternalDescriptor("rate_limit_error", "rate_limit_exceeded", "rate_limit_exceeded", "")
|
|
if err != nil {
|
|
t.Fatalf("NewExternalDescriptor: %v", err)
|
|
}
|
|
ev, err := streamgate.NewProviderErrorEvent("ch-1", desc, streamgate.FailureCauseChain{}, ts)
|
|
if err != nil {
|
|
t.Fatalf("NewProviderErrorEvent: %v", err)
|
|
}
|
|
|
|
batch, err := streamgate.NewEvidenceBatch(
|
|
[]streamgate.NormalizedEvent{ev},
|
|
nil, nil, nil, true,
|
|
streamgate.CommitStateTransportUncommitted, ts,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewEvidenceBatch PE: %v", err)
|
|
}
|
|
|
|
epoch, err := buildEpoch(3, "ch-1", streamgate.FilterHoldModeTerminalGate, true, "trigger", true)
|
|
if err != nil {
|
|
t.Fatalf("buildEpoch: %v", err)
|
|
}
|
|
|
|
efList, err := plan.BindEpochFilters(epoch, resolved)
|
|
if err != nil {
|
|
t.Fatalf("BindEpochFilters: %v", err)
|
|
}
|
|
|
|
hostCtx := context.Background()
|
|
arb := streamgate.NewDecisionArbiter()
|
|
coord, err := streamgate.NewGateCoordinator(hostCtx, streamgate.GateCoordinatorOptions{MaxQueuedEpochs: 0}, arb)
|
|
if err != nil {
|
|
t.Fatalf("NewGateCoordinator: %v", err)
|
|
}
|
|
defer coord.Close()
|
|
|
|
set, res, err := coord.Evaluate(hostCtx, batch, efList)
|
|
if err != nil {
|
|
t.Fatalf("Evaluate: %v", err)
|
|
}
|
|
|
|
// PE with observe-only filter → no blocking violation → terminal.
|
|
if res.Action() != streamgate.ArbitrationActionTerminal {
|
|
t.Errorf("PE epoch action = %v, want terminal", res.Action())
|
|
}
|
|
if res.BaseDisposition() != streamgate.BaseDispositionTerminalErrorCandidate {
|
|
t.Errorf("PE epoch baseDisposition = %v, want terminal_error_candidate", res.BaseDisposition())
|
|
}
|
|
|
|
// Verify the observe-only filter outcome has none disposition.
|
|
foundObserve := false
|
|
for i := 0; i < set.Len(); i++ {
|
|
o, _ := set.At(i)
|
|
if o.FilterID() == "f.pe_observe" {
|
|
foundObserve = true
|
|
if o.FailureDisposition() != streamgate.EvaluationFailureDispositionNone {
|
|
t.Errorf("PE observe outcome disposition = %v, want none", o.FailureDisposition())
|
|
}
|
|
}
|
|
}
|
|
if !foundObserve {
|
|
t.Error("observe-only PE filter outcome not found in set")
|
|
}
|
|
})
|
|
|
|
// --- Single-buffer/commit boundary: Submit returns same result as Evaluate. ---
|
|
t.Run("single_buffer_commit_boundary", func(t *testing.T) {
|
|
ev, err := streamgate.NewTextDeltaEvent("ch-1", "commit-boundary-test", ts)
|
|
if err != nil {
|
|
t.Fatalf("NewTextDeltaEvent: %v", err)
|
|
}
|
|
batch, err := streamgate.NewEvidenceBatch(
|
|
[]streamgate.NormalizedEvent{ev},
|
|
nil, nil, nil, false,
|
|
streamgate.CommitStateTransportUncommitted, ts,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewEvidenceBatch: %v", err)
|
|
}
|
|
|
|
epoch, err := buildEpoch(4, "ch-1", streamgate.FilterHoldModeRolling, true, "threshold", true)
|
|
if err != nil {
|
|
t.Fatalf("buildEpoch: %v", err)
|
|
}
|
|
|
|
efList, err := plan.BindEpochFilters(epoch, resolved)
|
|
if err != nil {
|
|
t.Fatalf("BindEpochFilters: %v", err)
|
|
}
|
|
|
|
hostCtx := context.Background()
|
|
arb := streamgate.NewDecisionArbiter()
|
|
coord, err := streamgate.NewGateCoordinator(hostCtx, streamgate.GateCoordinatorOptions{MaxQueuedEpochs: 0}, arb)
|
|
if err != nil {
|
|
t.Fatalf("NewGateCoordinator: %v", err)
|
|
}
|
|
defer coord.Close()
|
|
|
|
// Evaluate path.
|
|
setEval, resEval, err := coord.Evaluate(hostCtx, batch, efList)
|
|
if err != nil {
|
|
t.Fatalf("Evaluate: %v", err)
|
|
}
|
|
|
|
// Submit path (single buffer).
|
|
resSubmit, err := coord.Submit(hostCtx, batch, efList)
|
|
if err != nil {
|
|
t.Fatalf("Submit: %v", err)
|
|
}
|
|
|
|
// Both paths must produce the same action.
|
|
if resEval.Action() != resSubmit.Action() {
|
|
t.Errorf("action mismatch: Evaluate=%v, Submit=%v", resEval.Action(), resSubmit.Action())
|
|
}
|
|
if resEval.FilterID() != resSubmit.FilterID() {
|
|
t.Errorf("filterID mismatch: Evaluate=%q, Submit=%q", resEval.FilterID(), resSubmit.FilterID())
|
|
}
|
|
if resEval.RuleID() != resSubmit.RuleID() {
|
|
t.Errorf("ruleID mismatch: Evaluate=%q, Submit=%q", resEval.RuleID(), resSubmit.RuleID())
|
|
}
|
|
if resEval.BaseDisposition() != resSubmit.BaseDisposition() {
|
|
t.Errorf("baseDisposition mismatch: Evaluate=%v, Submit=%v", resEval.BaseDisposition(), resSubmit.BaseDisposition())
|
|
}
|
|
|
|
// Set from Evaluate must be valid.
|
|
if err := setEval.Validate(); err != nil {
|
|
t.Errorf("Evaluate set Validate: %v", err)
|
|
}
|
|
})
|
|
|
|
// --- Plan accessor: verify channel bindings and mode. ---
|
|
t.Run("plan_accessor_bindings", func(t *testing.T) {
|
|
bindings := plan.BindingsForChannel("ch-1")
|
|
if len(bindings) != 5 {
|
|
t.Errorf("BindingsForChannel('ch-1') = %d, want 5", len(bindings))
|
|
}
|
|
|
|
// Verify distinct filter IDs.
|
|
seen := make(map[string]bool)
|
|
for _, b := range bindings {
|
|
if seen[b.FilterID()] {
|
|
t.Errorf("duplicate binding: %s", b.FilterID())
|
|
}
|
|
seen[b.FilterID()] = true
|
|
}
|
|
|
|
// Verify that observe-only binding does not block release.
|
|
for _, b := range bindings {
|
|
if b.FilterID() == "f.pe_observe" {
|
|
if b.BlocksRelease() {
|
|
t.Error("observe-only binding must not block release")
|
|
}
|
|
if b.Enforcement() != streamgate.FilterEnforcementObserveOnly {
|
|
t.Errorf("observe binding enforcement = %v, want observe_only", b.Enforcement())
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// ensure unused imports are consumed.
|
|
var _ = sort.Strings
|