iop/packages/go/streamgate/consumer_contract_test.go
toki 69d2759155 archive completed tasks and apply Go streamgate improvements
- 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
2026-07-26 14:22:15 +09:00

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