iop/packages/go/streamgate/consumer_contract_test.go
toki c90bb755a9 feat: streamline plan/code-review/finalize router, add stream gate SDDs, sync dev-test inventory, update roadmap milestones
- Refactor plan, code-review, finalize-task-routing, refine-local-plans, router skills
- Add agent-workflow-loop-orchestration skill and plan agent configs
- Update roadmap: knowledge-tool-optimization milestones, stream-evidence-gate-core SDD
- Add stream-evidence-gate-core task, archive, and Go streamgate package
- Update dev-test inventory (edge/node smoke), agent-contract, edge-local-dev-guide
- Deprecate USER_REVIEW for output-validation-filters SDD
2026-07-24 15:11:00 +09:00

1065 lines
36 KiB
Go

package streamgate_test
import (
"context"
"encoding/json"
"errors"
"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)
}
})
}