iop/apps/edge/internal/openai/chat_stream_session_test.go

203 lines
7.2 KiB
Go

package openai
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"iop/packages/go/config"
edgeservice "iop/apps/edge/internal/service"
eventpkg "iop/packages/go/events"
iop "iop/proto/gen/iop"
)
// countTerminals reports how many SSE terminal markers a session wrote. Every
// terminal path — finish, fail, and the timeout notice — ends with [DONE], so a
// value other than 1 means the session emitted no terminal or emitted twice.
func countTerminals(body string) int {
return strings.Count(body, "data: [DONE]")
}
// runStreamSession drives a live SSE session over the supplied events and
// returns the response body.
func runStreamSession(t *testing.T, req chatCompletionRequest, outputPolicy strictOutputPolicy, waitTimeout time.Duration, ctx context.Context, emit func(events chan *iop.RunEvent, nodeEvents chan *iop.EdgeNodeEvent)) string {
t.Helper()
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, &fakeRunService{}, nil)
events := make(chan *iop.RunEvent, 8)
nodeEvents := make(chan *iop.EdgeNodeEvent, 4)
handle := &fakeRunResultWithTimeout{
dispatch: edgeservice.RunDispatch{
RunID: "run-session",
NodeID: "node-1",
Adapter: "ollama",
Target: "llama3",
},
stream: edgeservice.RunStream{Events: events, NodeEvents: nodeEvents},
waitTimeout: waitTimeout,
}
emit(events, nodeEvents)
httpReq := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil).WithContext(ctx)
w := httptest.NewRecorder()
dc := newTestChatDispatchContext(httpReq, req)
dc.outputPolicy = outputPolicy
srv.streamChatCompletion(w, dc, handle)
return w.Body.String()
}
// streamSessionCase is one terminal outcome of the live SSE session.
type streamSessionCase struct {
name string
req chatCompletionRequest
outputPolicy strictOutputPolicy
waitTimeout time.Duration
emit func(events chan *iop.RunEvent, nodeEvents chan *iop.EdgeNodeEvent)
wantBody []string
wantMissing []string
}
// emitReasoningThenAnswer emits a reasoning delta, a content delta, and a
// complete event — the shape the reasoning visibility rules are decided on.
func emitReasoningThenAnswer(events chan *iop.RunEvent, _ chan *iop.EdgeNodeEvent) {
events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "thinking hard"}
events <- &iop.RunEvent{Type: "delta", Delta: "answer"}
events <- &iop.RunEvent{Type: "complete"}
}
// streamSessionTerminalCases covers how a session ends.
func streamSessionTerminalCases() []streamSessionCase {
return []streamSessionCase{
{
name: "complete emits content then finish",
req: chatCompletionRequest{Model: "llama3"},
waitTimeout: time.Second,
emit: func(events chan *iop.RunEvent, _ chan *iop.EdgeNodeEvent) {
events <- &iop.RunEvent{Type: "delta", Delta: "hello"}
events <- &iop.RunEvent{Type: "complete", Metadata: map[string]string{"finish_reason": "stop"}}
},
wantBody: []string{`"role":"assistant"`, `"content":"hello"`, `"finish_reason":"stop"`},
},
{
name: "error event surfaces the run error",
req: chatCompletionRequest{Model: "llama3"},
waitTimeout: time.Second,
emit: func(events chan *iop.RunEvent, _ chan *iop.EdgeNodeEvent) {
events <- &iop.RunEvent{Type: "error", Error: "boom"}
},
wantBody: []string{"boom", "run_error"},
wantMissing: []string{`"finish_reason":"stop"`},
},
{
name: "cancelled event surfaces its message",
req: chatCompletionRequest{Model: "llama3"},
waitTimeout: time.Second,
emit: func(events chan *iop.RunEvent, _ chan *iop.EdgeNodeEvent) {
events <- &iop.RunEvent{Type: "cancelled", Message: "stopped by user"}
},
wantBody: []string{"stopped by user"},
},
{
name: "closed stream terminates once",
req: chatCompletionRequest{Model: "llama3"},
waitTimeout: time.Second,
emit: func(events chan *iop.RunEvent, _ chan *iop.EdgeNodeEvent) {
close(events)
},
wantBody: []string{"run stream closed"},
},
{
name: "node disconnect terminates once",
req: chatCompletionRequest{Model: "llama3"},
waitTimeout: time.Second,
emit: func(_ chan *iop.RunEvent, nodeEvents chan *iop.EdgeNodeEvent) {
nodeEvents <- &iop.EdgeNodeEvent{Type: eventpkg.TypeNodeDisconnected, NodeId: "node-1"}
},
wantBody: []string{"node disconnected"},
},
{
name: "timeout terminates once",
req: chatCompletionRequest{Model: "llama3"},
waitTimeout: 30 * time.Millisecond,
emit: func(_ chan *iop.RunEvent, _ chan *iop.EdgeNodeEvent) {},
wantBody: []string{"run timed out"},
},
}
}
// streamSessionReasoningCases covers what a session exposes of the model's
// reasoning.
func streamSessionReasoningCases() []streamSessionCase {
strictTrue := true
return []streamSessionCase{
{
name: "reasoning is exposed by default",
req: chatCompletionRequest{Model: "llama3"},
waitTimeout: time.Second,
emit: emitReasoningThenAnswer,
wantBody: []string{`"reasoning_content":"thinking hard"`, `"content":"answer"`},
},
{
name: "strict output hides reasoning the caller did not ask for",
req: chatCompletionRequest{Model: "llama3"},
outputPolicy: strictOutputPolicy{Strict: true},
waitTimeout: time.Second,
emit: emitReasoningThenAnswer,
wantBody: []string{`"content":"answer"`},
wantMissing: []string{"thinking hard"},
},
{
name: "strict output exposes reasoning the caller asked for",
req: chatCompletionRequest{Model: "llama3", IncludeReasoning: &strictTrue},
outputPolicy: strictOutputPolicy{Strict: true},
waitTimeout: time.Second,
emit: emitReasoningThenAnswer,
wantBody: []string{`"reasoning_content":"thinking hard"`},
},
}
}
// TestChatStreamSessionTerminalOutcomes pins the terminal contract of the live
// SSE session: each outcome emits exactly one terminal and the accumulated
// content it was supposed to expose.
func TestChatStreamSessionTerminalOutcomes(t *testing.T) {
cases := append(streamSessionTerminalCases(), streamSessionReasoningCases()...)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
body := runStreamSession(t, tc.req, tc.outputPolicy, tc.waitTimeout, context.Background(), tc.emit)
if got := countTerminals(body); got != 1 {
t.Fatalf("expected exactly 1 terminal, got %d: %s", got, body)
}
for _, want := range tc.wantBody {
if !strings.Contains(body, want) {
t.Fatalf("expected body to contain %q, got %s", want, body)
}
}
for _, missing := range tc.wantMissing {
if strings.Contains(body, missing) {
t.Fatalf("expected body to omit %q, got %s", missing, body)
}
}
})
}
}
// TestChatStreamSessionCallerCancelWritesNoTerminal pins that a caller who
// disconnects gets no SSE body: the run is cancelled node-side instead.
func TestChatStreamSessionCallerCancelWritesNoTerminal(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
body := runStreamSession(t, chatCompletionRequest{Model: "llama3"}, strictOutputPolicy{}, time.Second, ctx,
func(_ chan *iop.RunEvent, _ chan *iop.EdgeNodeEvent) {})
if got := countTerminals(body); got != 0 {
t.Fatalf("expected no terminal for a cancelled caller, got %d: %s", got, body)
}
}