2026-02-13 15:03:02 +00:00
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
import (
|
2026-10-30 17:21:58 +00:00
|
|
|
"context"
|
|
|
|
|
"encoding/json/v2"
|
|
|
|
|
"fmt"
|
|
|
|
|
"log/slog"
|
2026-02-13 15:03:02 +00:00
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
2026-10-30 17:21:58 +00:00
|
|
|
"path/filepath"
|
2026-06-27 00:13:16 +00:00
|
|
|
"strings"
|
2026-02-13 15:03:02 +00:00
|
|
|
"testing"
|
2026-10-30 17:21:58 +00:00
|
|
|
"time"
|
2026-06-27 00:13:16 +00:00
|
|
|
|
2026-10-30 17:21:58 +00:00
|
|
|
"code.chimeric.al/chimerical/odidere/internal/config"
|
|
|
|
|
"code.chimeric.al/chimerical/odidere/internal/conversation"
|
|
|
|
|
"code.chimeric.al/chimerical/odidere/internal/db"
|
2026-06-27 00:13:16 +00:00
|
|
|
"code.chimeric.al/chimerical/odidere/internal/llm"
|
2026-02-13 15:03:02 +00:00
|
|
|
)
|
|
|
|
|
|
2026-10-30 17:21:58 +00:00
|
|
|
func newTestService(t *testing.T, handler http.HandlerFunc) *Service {
|
|
|
|
|
t.Helper()
|
|
|
|
|
if handler == nil {
|
|
|
|
|
handler = func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
fmt.Fprint(w, `{
|
|
|
|
|
"choices":[{
|
|
|
|
|
"message":{"role":"assistant","content":"ok"},
|
|
|
|
|
"finish_reason":"stop"
|
|
|
|
|
}]
|
|
|
|
|
}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
srv := httptest.NewServer(handler)
|
|
|
|
|
t.Cleanup(srv.Close)
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
ctx = t.Context()
|
|
|
|
|
log = slog.New(slog.DiscardHandler)
|
|
|
|
|
)
|
|
|
|
|
database, err := db.Connect(ctx, db.Config{
|
|
|
|
|
Path: filepath.Join(t.TempDir(), "service.db"),
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Connect: %v", err)
|
|
|
|
|
}
|
|
|
|
|
t.Cleanup(func() { database.Close() })
|
|
|
|
|
if err := database.ApplySchema(ctx, log); err != nil {
|
|
|
|
|
t.Fatalf("ApplySchema: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cfg := &config.Config{
|
|
|
|
|
Address: ":0",
|
|
|
|
|
Database: config.Database{
|
|
|
|
|
Path: filepath.Join(t.TempDir(), "service.db"),
|
|
|
|
|
},
|
|
|
|
|
DefaultModel: config.ModelConfig{
|
|
|
|
|
Provider: "test",
|
|
|
|
|
Model: "test-model",
|
|
|
|
|
},
|
|
|
|
|
Providers: []config.ProviderConfig{{
|
|
|
|
|
Name: "test",
|
|
|
|
|
URL: srv.URL,
|
|
|
|
|
Models: []string{"test-model"},
|
|
|
|
|
}},
|
|
|
|
|
STT: config.STT{URL: "http://localhost:8178"},
|
|
|
|
|
TTS: config.TTS{URL: "http://localhost:8880", Voice: "af"},
|
|
|
|
|
SystemMessage: "Default system.",
|
|
|
|
|
}
|
|
|
|
|
cfg.ApplyDefaults()
|
|
|
|
|
|
|
|
|
|
svc, err := New(cfg, log, database)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("New: %v", err)
|
|
|
|
|
}
|
|
|
|
|
return svc
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 15:03:02 +00:00
|
|
|
func TestStatusHandler(t *testing.T) {
|
|
|
|
|
svc := &Service{}
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/status", nil)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
svc.status(w, req)
|
|
|
|
|
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Errorf(
|
|
|
|
|
"status handler returned %d, want %d",
|
|
|
|
|
w.Code,
|
|
|
|
|
http.StatusOK,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-27 00:13:16 +00:00
|
|
|
|
2026-10-30 17:21:58 +00:00
|
|
|
func TestConversationHandlers(t *testing.T) {
|
|
|
|
|
svc := newTestService(t, nil)
|
|
|
|
|
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
svc.ServeHTTP(w, httptest.NewRequest(
|
|
|
|
|
http.MethodPost, "/conversations",
|
|
|
|
|
strings.NewReader(`{"title":"Chat"}`),
|
|
|
|
|
))
|
|
|
|
|
if w.Code != http.StatusCreated {
|
|
|
|
|
t.Fatalf("create status = %d, want 201: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var created db.Conversation
|
|
|
|
|
if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil {
|
|
|
|
|
t.Fatalf("Unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if created.Title == nil || *created.Title != "Chat" {
|
|
|
|
|
t.Fatalf("Title = %v, want Chat", created.Title)
|
2026-06-27 00:13:16 +00:00
|
|
|
}
|
|
|
|
|
|
2026-10-30 17:21:58 +00:00
|
|
|
w = httptest.NewRecorder()
|
|
|
|
|
svc.ServeHTTP(w, httptest.NewRequest(
|
|
|
|
|
http.MethodGet, "/conversations", nil,
|
|
|
|
|
))
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("list status = %d, want 200", w.Code)
|
|
|
|
|
}
|
|
|
|
|
var listed []db.Conversation
|
|
|
|
|
if err := json.Unmarshal(w.Body.Bytes(), &listed); err != nil {
|
|
|
|
|
t.Fatalf("Unmarshal list: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(listed) != 1 || listed[0].Id != created.Id {
|
|
|
|
|
t.Fatalf("listed = %+v, want [%s]", listed, created.Id)
|
2026-06-27 00:13:16 +00:00
|
|
|
}
|
|
|
|
|
|
2026-10-30 17:21:58 +00:00
|
|
|
w = httptest.NewRecorder()
|
|
|
|
|
svc.ServeHTTP(w, httptest.NewRequest(
|
|
|
|
|
http.MethodGet, "/conversations/"+created.Id, nil,
|
|
|
|
|
))
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("get status = %d, want 200", w.Code)
|
2026-06-27 00:13:16 +00:00
|
|
|
}
|
2026-10-30 17:21:58 +00:00
|
|
|
|
2026-10-31 12:13:36 +00:00
|
|
|
// Rename the conversation, then try renames the server must
|
|
|
|
|
// reject.
|
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
|
svc.ServeHTTP(w, httptest.NewRequest(
|
|
|
|
|
http.MethodPatch, "/conversations/"+created.Id,
|
|
|
|
|
strings.NewReader(`{"title":"Renamed"}`),
|
|
|
|
|
))
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("rename status = %d, want 200: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var renamed db.Conversation
|
|
|
|
|
if err := json.Unmarshal(w.Body.Bytes(), &renamed); err != nil {
|
|
|
|
|
t.Fatalf("Unmarshal renamed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if renamed.Title == nil || *renamed.Title != "Renamed" {
|
|
|
|
|
t.Fatalf("Title = %v, want Renamed", renamed.Title)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
|
svc.ServeHTTP(w, httptest.NewRequest(
|
|
|
|
|
http.MethodPatch, "/conversations/missing",
|
|
|
|
|
strings.NewReader(`{"title":"Renamed"}`),
|
|
|
|
|
))
|
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
|
|
|
t.Fatalf("rename missing status = %d, want 404", w.Code)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
|
svc.ServeHTTP(w, httptest.NewRequest(
|
|
|
|
|
http.MethodPatch, "/conversations/"+created.Id,
|
|
|
|
|
strings.NewReader(`{"title":" "}`),
|
|
|
|
|
))
|
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
|
|
|
t.Fatalf("rename blank status = %d, want 400", w.Code)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
|
svc.ServeHTTP(w, httptest.NewRequest(
|
|
|
|
|
http.MethodPatch, "/conversations/"+created.Id,
|
|
|
|
|
strings.NewReader(fmt.Sprintf(
|
|
|
|
|
`{"title":%q}`, strings.Repeat("x", 257),
|
|
|
|
|
)),
|
|
|
|
|
))
|
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
|
|
|
t.Fatalf("rename long status = %d, want 400", w.Code)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-30 17:21:58 +00:00
|
|
|
w = httptest.NewRecorder()
|
|
|
|
|
svc.ServeHTTP(w, httptest.NewRequest(
|
|
|
|
|
http.MethodDelete, "/conversations/"+created.Id, nil,
|
|
|
|
|
))
|
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
|
|
|
t.Fatalf("delete status = %d, want 204", w.Code)
|
2026-06-27 00:13:16 +00:00
|
|
|
}
|
2026-10-30 17:21:58 +00:00
|
|
|
if _, ok := svc.conversations[created.Id]; ok {
|
|
|
|
|
t.Fatal("deleted conversation handle remained in map")
|
2026-06-27 00:13:16 +00:00
|
|
|
}
|
2026-10-30 17:21:58 +00:00
|
|
|
|
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
|
svc.ServeHTTP(w, httptest.NewRequest(
|
|
|
|
|
http.MethodGet, "/conversations/"+created.Id, nil,
|
|
|
|
|
))
|
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
|
|
|
t.Fatalf("get deleted status = %d, want 404", w.Code)
|
2026-06-27 00:13:16 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-30 17:21:58 +00:00
|
|
|
func TestMessageAndToolCallHandlers(t *testing.T) {
|
|
|
|
|
svc := newTestService(t, nil)
|
|
|
|
|
saved, _, err := svc.newConversation(
|
|
|
|
|
t.Context(), "default", nil, "System",
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("newConversation: %v", err)
|
|
|
|
|
}
|
|
|
|
|
base := "/conversations/" + saved.Id + "/messages"
|
|
|
|
|
|
|
|
|
|
// Insert and update a user message.
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
svc.ServeHTTP(w, httptest.NewRequest(
|
|
|
|
|
http.MethodPost, base,
|
|
|
|
|
strings.NewReader(`{"message":{"role":"user","content":"hi"}}`),
|
|
|
|
|
))
|
|
|
|
|
if w.Code != http.StatusCreated {
|
|
|
|
|
t.Fatalf("insert user = %d: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var user db.Message
|
|
|
|
|
if err := json.Unmarshal(w.Body.Bytes(), &user); err != nil {
|
|
|
|
|
t.Fatalf("Unmarshal: %v", err)
|
2026-06-27 00:13:16 +00:00
|
|
|
}
|
|
|
|
|
|
2026-10-30 17:21:58 +00:00
|
|
|
w = httptest.NewRecorder()
|
|
|
|
|
svc.ServeHTTP(w, httptest.NewRequest(
|
|
|
|
|
http.MethodPut, base+"/"+user.Id,
|
|
|
|
|
strings.NewReader(fmt.Sprintf(
|
|
|
|
|
`{"message":{"role":"user","content":"hello"},"updated_at":%q}`,
|
|
|
|
|
user.UpdatedAt.Format(time.RFC3339Nano),
|
|
|
|
|
)),
|
|
|
|
|
))
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("update user = %d: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(w.Body.Bytes(), &user); err != nil {
|
|
|
|
|
t.Fatalf("Unmarshal: %v", err)
|
2026-06-27 00:13:16 +00:00
|
|
|
}
|
|
|
|
|
|
2026-10-30 17:21:58 +00:00
|
|
|
// Insert an assistant message, edit its tool call, and edit its result.
|
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
|
svc.ServeHTTP(w, httptest.NewRequest(
|
|
|
|
|
http.MethodPost, base,
|
|
|
|
|
strings.NewReader(`{"message":{"role":"assistant","content":"run"}}`),
|
|
|
|
|
))
|
|
|
|
|
if w.Code != http.StatusCreated {
|
|
|
|
|
t.Fatalf("insert assistant = %d: %s", w.Code, w.Body.String())
|
2026-06-27 00:13:16 +00:00
|
|
|
}
|
2026-10-30 17:21:58 +00:00
|
|
|
var asst db.Message
|
|
|
|
|
if err := json.Unmarshal(w.Body.Bytes(), &asst); err != nil {
|
|
|
|
|
t.Fatalf("Unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
asstPath := base + "/" + asst.Id
|
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
|
svc.ServeHTTP(w, httptest.NewRequest(
|
|
|
|
|
http.MethodPost, asstPath+"/calls",
|
|
|
|
|
strings.NewReader(fmt.Sprintf(
|
|
|
|
|
`{"call":{"id":"c1","type":"function","function":{"name":"fn","arguments":"{}"}},"updated_at":%q}`,
|
|
|
|
|
asst.UpdatedAt.Format(time.RFC3339Nano),
|
|
|
|
|
)),
|
|
|
|
|
))
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("add call = %d: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(w.Body.Bytes(), &asst); err != nil {
|
|
|
|
|
t.Fatalf("Unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
|
svc.ServeHTTP(w, httptest.NewRequest(
|
|
|
|
|
http.MethodPut, asstPath+"/calls/c1",
|
|
|
|
|
strings.NewReader(fmt.Sprintf(
|
|
|
|
|
`{"call":{"id":"c1","type":"function","function":{"name":"fn2","arguments":"{}"}},"updated_at":%q}`,
|
|
|
|
|
asst.UpdatedAt.Format(time.RFC3339Nano),
|
|
|
|
|
)),
|
|
|
|
|
))
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("update call = %d: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(w.Body.Bytes(), &asst); err != nil {
|
|
|
|
|
t.Fatalf("Unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
|
svc.ServeHTTP(w, httptest.NewRequest(
|
|
|
|
|
http.MethodPost, base,
|
|
|
|
|
strings.NewReader(fmt.Sprintf(
|
|
|
|
|
`{"message":{"role":"tool","tool_call_id":"c1","content":"out"},"message_id":%q,"updated_at":%q}`,
|
|
|
|
|
asst.Id, asst.UpdatedAt.Format(time.RFC3339Nano),
|
|
|
|
|
)),
|
|
|
|
|
))
|
|
|
|
|
if w.Code != http.StatusCreated {
|
|
|
|
|
t.Fatalf("add tool result = %d: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var result db.Message
|
|
|
|
|
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
|
|
|
|
t.Fatalf("Unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
|
svc.ServeHTTP(w, httptest.NewRequest(
|
|
|
|
|
http.MethodPut, asstPath,
|
|
|
|
|
strings.NewReader(fmt.Sprintf(
|
|
|
|
|
`{"message":{"role":"tool","tool_call_id":"c1","content":"new"},"updated_at":%q}`,
|
|
|
|
|
result.UpdatedAt.Format(time.RFC3339Nano),
|
|
|
|
|
)),
|
|
|
|
|
))
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("update tool result = %d: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
|
svc.ServeHTTP(w, httptest.NewRequest(
|
|
|
|
|
http.MethodDelete, asstPath+"/calls/c1",
|
|
|
|
|
strings.NewReader(fmt.Sprintf(
|
|
|
|
|
`{"updated_at":%q}`,
|
|
|
|
|
asst.UpdatedAt.Format(time.RFC3339Nano),
|
|
|
|
|
)),
|
|
|
|
|
))
|
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
|
|
|
t.Fatalf("delete call = %d: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
|
svc.ServeHTTP(w, httptest.NewRequest(
|
|
|
|
|
http.MethodDelete, base+"/"+user.Id,
|
|
|
|
|
strings.NewReader(fmt.Sprintf(
|
|
|
|
|
`{"updated_at":%q}`,
|
|
|
|
|
user.UpdatedAt.Format(time.RFC3339Nano),
|
|
|
|
|
)),
|
|
|
|
|
))
|
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
|
|
|
t.Fatalf("delete user = %d: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestExecutionHandlers(t *testing.T) {
|
|
|
|
|
svc := newTestService(t, nil)
|
|
|
|
|
_, c, err := svc.newConversation(
|
|
|
|
|
t.Context(), "default", nil, "System",
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("newConversation: %v", err)
|
|
|
|
|
}
|
|
|
|
|
asst, err := c.InsertMessage(t.Context(), llm.Message{
|
|
|
|
|
Role: llm.RoleAssistant,
|
|
|
|
|
ToolCalls: []llm.ToolCall{{
|
|
|
|
|
ID: "c1",
|
|
|
|
|
Type: llm.ToolTypeFunction,
|
|
|
|
|
Function: llm.Function{Name: "fn", Arguments: "{}"},
|
|
|
|
|
}},
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("InsertMessage: %v", err)
|
|
|
|
|
}
|
|
|
|
|
exec, err := svc.dbw.InsertExecution(
|
|
|
|
|
t.Context(), db.InsertExecutionParams{
|
|
|
|
|
MessageId: asst.Id,
|
|
|
|
|
ToolCallId: "c1",
|
|
|
|
|
Status: db.ExecutionStatusPending,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("InsertExecution: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
svc.ServeHTTP(w, httptest.NewRequest(
|
|
|
|
|
http.MethodGet, "/executions", nil,
|
|
|
|
|
))
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("list status = %d", w.Code)
|
|
|
|
|
}
|
|
|
|
|
var pending []db.Execution
|
|
|
|
|
if err := json.Unmarshal(w.Body.Bytes(), &pending); err != nil {
|
|
|
|
|
t.Fatalf("Unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(pending) != 1 || pending[0].Id != exec.Id {
|
|
|
|
|
t.Fatalf("pending = %+v, want [%s]", pending, exec.Id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
|
svc.ServeHTTP(w, httptest.NewRequest(
|
|
|
|
|
http.MethodPut, "/executions/"+exec.Id,
|
|
|
|
|
strings.NewReader(`{"status":"approved"}`),
|
|
|
|
|
))
|
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
|
|
|
t.Fatalf("approve status = %d: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
|
svc.ServeHTTP(w, httptest.NewRequest(
|
|
|
|
|
http.MethodPut, "/executions/"+exec.Id,
|
|
|
|
|
strings.NewReader(`{"status":"denied"}`),
|
|
|
|
|
))
|
|
|
|
|
if w.Code != http.StatusConflict {
|
|
|
|
|
t.Fatalf("second decision status = %d, want 409", w.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestInferenceSubscribeAndStopHandlers(t *testing.T) {
|
|
|
|
|
started := make(chan struct{})
|
|
|
|
|
release := make(chan struct{})
|
|
|
|
|
svc := newTestService(t, func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
close(started)
|
|
|
|
|
select {
|
|
|
|
|
case <-release:
|
|
|
|
|
case <-r.Context().Done():
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
fmt.Fprint(w, `{
|
|
|
|
|
"choices":[{
|
|
|
|
|
"message":{"role":"assistant","content":"reply"},
|
|
|
|
|
"finish_reason":"stop"
|
|
|
|
|
}]
|
|
|
|
|
}`)
|
|
|
|
|
})
|
|
|
|
|
saved, _, err := svc.newConversation(
|
|
|
|
|
t.Context(), "default", nil, "System",
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("newConversation: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Disconnect the inference client while the background run continues.
|
|
|
|
|
reqCtx, cancelReq := context.WithCancel(t.Context())
|
|
|
|
|
doneReq := make(chan *httptest.ResponseRecorder, 1)
|
|
|
|
|
go func() {
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
|
|
|
reqCtx,
|
|
|
|
|
http.MethodPost,
|
|
|
|
|
"/conversations/"+saved.Id+"/inference",
|
|
|
|
|
strings.NewReader(`{"message":{"role":"user","content":"hi"},"stream":false}`),
|
|
|
|
|
)
|
|
|
|
|
svc.ServeHTTP(w, req)
|
|
|
|
|
doneReq <- w
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
<-started
|
|
|
|
|
cancelReq()
|
|
|
|
|
<-doneReq
|
|
|
|
|
|
|
|
|
|
// A second inference while reserved returns 409 Conflict.
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
svc.ServeHTTP(w, httptest.NewRequest(
|
|
|
|
|
http.MethodPost,
|
|
|
|
|
"/conversations/"+saved.Id+"/inference",
|
|
|
|
|
strings.NewReader(`{"stream":false}`),
|
|
|
|
|
))
|
|
|
|
|
if w.Code != http.StatusConflict {
|
|
|
|
|
t.Fatalf("concurrent inference = %d, want 409", w.Code)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
close(release)
|
|
|
|
|
svc.wg.Wait()
|
|
|
|
|
|
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
|
svc.ServeHTTP(w, httptest.NewRequest(
|
|
|
|
|
http.MethodPost,
|
|
|
|
|
"/conversations/"+saved.Id+"/subscribe",
|
|
|
|
|
strings.NewReader(`{}`),
|
|
|
|
|
))
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("subscribe status = %d: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
body := w.Body.String()
|
|
|
|
|
if strings.Count(body, "event: "+string(conversation.EventMessageSaved)) != 3 {
|
|
|
|
|
t.Fatalf("subscribe events = %s", body)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
|
svc.ServeHTTP(w, httptest.NewRequest(
|
|
|
|
|
http.MethodPost,
|
|
|
|
|
"/conversations/"+saved.Id+"/stop",
|
|
|
|
|
nil,
|
|
|
|
|
))
|
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
|
|
|
t.Fatalf("stop status = %d, want 204", w.Code)
|
2026-06-27 00:13:16 +00:00
|
|
|
}
|
|
|
|
|
}
|