Растущий в один text-парт стрим (text-delta) и reasoning больше не выглядят как зависшая нейронка: idle-таймер сбрасывается по росту числа партов и суммарной длины text/reasoning.
301 lines
9.4 KiB
Go
301 lines
9.4 KiB
Go
package opencode
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// fakeAPIServer — минимальный фейк opencode serve v2 HTTP API (пути /api/*).
|
||
//
|
||
// Сценарии:
|
||
// - нормальный: Prompt ставит active=false и в messages кладётся финальное
|
||
// assistant-сообщение (verdictText) → Runner собирает вердикт;
|
||
// - blockPrompt: «агент завис» — active=true всегда, сообщений нет → idle abort;
|
||
// - failCreate / failMessages — имитация ошибок;
|
||
// - growStream: стрим одного растущего парта — текст/reasoning растёт с
|
||
// каждым опросом GET /message (streamPolls раз), active=true, затем
|
||
// active=false + финальное завершённое сообщение.
|
||
type fakeAPIServer struct {
|
||
sessionID string
|
||
created bool
|
||
active bool
|
||
blockPrompt bool
|
||
messages []v2Message
|
||
verdictText string
|
||
failCreate bool
|
||
failMessages bool
|
||
createdModel *ModelRef // модель, полученная на POST /api/session
|
||
promptCalls int
|
||
|
||
// streamGrow: стрим одного растущего парта — текст/reasoning растёт с
|
||
// каждым опросом GET /message, active=true, пока messageCalls не дойдёт до
|
||
// streamPolls; затем active=false + финальное завершённое сообщение.
|
||
streamGrow bool
|
||
streamReasoning bool // растущий парт — reasoning вместо text
|
||
streamPolls int // сколько опросов длится «стрим» до завершения
|
||
messageCalls int
|
||
}
|
||
|
||
func (f *fakeAPIServer) handler() http.Handler {
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/session", func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
if f.failCreate {
|
||
http.Error(w, "boom", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
var in struct {
|
||
Model *ModelRef `json:"model"`
|
||
}
|
||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||
f.createdModel = in.Model
|
||
f.sessionID = "sess-fake"
|
||
f.created = true
|
||
writeJSON(w, map[string]any{"data": map[string]any{"id": "sess-fake"}})
|
||
})
|
||
mux.HandleFunc("/api/session/active", func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodGet {
|
||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
data := map[string]any{}
|
||
if f.active && f.sessionID != "" {
|
||
data[f.sessionID] = map[string]any{"type": "running"}
|
||
}
|
||
writeJSON(w, map[string]any{"data": data})
|
||
})
|
||
mux.HandleFunc("/api/session/{id}/prompt", func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
f.promptCalls++
|
||
if f.blockPrompt {
|
||
// «зависший» агент: активен, но сообщений не появляется.
|
||
f.active = true
|
||
} else {
|
||
f.active = false
|
||
}
|
||
writeJSON(w, map[string]any{"data": map[string]any{
|
||
"id": "msg_1",
|
||
"sessionID": f.sessionID,
|
||
"timeCreated": time.Now().UnixMilli(),
|
||
}})
|
||
})
|
||
mux.HandleFunc("/api/session/{id}/interrupt", func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
f.active = false
|
||
w.WriteHeader(http.StatusNoContent)
|
||
})
|
||
mux.HandleFunc("/api/session/{id}/message", func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodGet {
|
||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
if f.failMessages {
|
||
http.Error(w, "db error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if f.streamGrow {
|
||
f.messageCalls++
|
||
now := time.Now().UnixMilli()
|
||
done := f.messageCalls >= f.streamPolls
|
||
msg := v2Message{ID: "msg_stream", Type: "assistant", Time: v2Time{Created: &now}}
|
||
switch {
|
||
case done:
|
||
msg.Content = []v2Part{{Type: "text", Text: "done-stream"}}
|
||
msg.Finish = "end_turn"
|
||
msg.Time.Completed = &now
|
||
f.active = false
|
||
case f.streamReasoning:
|
||
msg.Content = []v2Part{{Type: "reasoning", Text: strings.Repeat("r", f.messageCalls)}}
|
||
f.active = true
|
||
default:
|
||
msg.Content = []v2Part{{Type: "text", Text: strings.Repeat("x", f.messageCalls)}}
|
||
f.active = true
|
||
}
|
||
writeJSON(w, map[string]any{"data": []v2Message{msg}})
|
||
return
|
||
}
|
||
msgs := f.messages
|
||
if msgs == nil && f.verdictText != "" && !f.blockPrompt {
|
||
msgs = []v2Message{f.assistantMsg(f.verdictText)}
|
||
}
|
||
if msgs == nil {
|
||
msgs = []v2Message{}
|
||
}
|
||
writeJSON(w, map[string]any{"data": msgs})
|
||
})
|
||
return mux
|
||
}
|
||
|
||
// assistantMsg строит завершённое assistant-сообщение с text-партом.
|
||
func (f *fakeAPIServer) assistantMsg(text string) v2Message {
|
||
now := time.Now().UnixMilli()
|
||
return v2Message{
|
||
ID: "msg_a",
|
||
Type: "assistant",
|
||
Content: []v2Part{{Type: "text", Text: text}},
|
||
Finish: "end_turn",
|
||
Time: v2Time{Created: &now, Completed: &now},
|
||
}
|
||
}
|
||
|
||
func writeJSON(w http.ResponseWriter, v any) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_ = json.NewEncoder(w).Encode(v)
|
||
}
|
||
|
||
// fakeClient — клиент к фейк-серверу.
|
||
func fakeClient(t *testing.T, f *fakeAPIServer) *Client {
|
||
t.Helper()
|
||
ts := httptest.NewServer(f.handler())
|
||
t.Cleanup(ts.Close)
|
||
return &Client{BaseURL: ts.URL}
|
||
}
|
||
|
||
func TestClient_CreateSession(t *testing.T) {
|
||
f := &fakeAPIServer{}
|
||
c := fakeClient(t, f)
|
||
id, err := c.CreateSession(context.Background(), nil)
|
||
if err != nil {
|
||
t.Fatalf("CreateSession err: %v", err)
|
||
}
|
||
if id != "sess-fake" {
|
||
t.Errorf("id = %q, want sess-fake", id)
|
||
}
|
||
if f.createdModel != nil {
|
||
t.Errorf("createdModel = %+v, want nil", f.createdModel)
|
||
}
|
||
}
|
||
|
||
func TestClient_CreateSessionHardpinsModel(t *testing.T) {
|
||
want := &ModelRef{ProviderID: "tokentool", ID: "deepseek/deepseek-v4-flash-0731"}
|
||
f := &fakeAPIServer{}
|
||
c := fakeClient(t, f)
|
||
if _, err := c.CreateSession(context.Background(), want); err != nil {
|
||
t.Fatalf("CreateSession err: %v", err)
|
||
}
|
||
if f.createdModel == nil || f.createdModel.ProviderID != want.ProviderID || f.createdModel.ID != want.ID {
|
||
t.Errorf("createdModel = %+v, want %+v", f.createdModel, want)
|
||
}
|
||
}
|
||
|
||
func TestClient_CreateSessionFail(t *testing.T) {
|
||
c := fakeClient(t, &fakeAPIServer{failCreate: true})
|
||
if _, err := c.CreateSession(context.Background(), nil); err == nil {
|
||
t.Fatal("CreateSession должен упасть при 500, а не nil")
|
||
}
|
||
}
|
||
|
||
func TestClient_Prompt(t *testing.T) {
|
||
c := fakeClient(t, &fakeAPIServer{})
|
||
adm, err := c.Prompt(context.Background(), "sess-fake", "почини x")
|
||
if err != nil {
|
||
t.Fatalf("Prompt err: %v", err)
|
||
}
|
||
if adm.ID != "msg_1" {
|
||
t.Errorf("adm.ID = %q, want msg_1", adm.ID)
|
||
}
|
||
if adm.TimeCreated == 0 {
|
||
t.Error("adm.TimeCreated = 0, want epoch ms")
|
||
}
|
||
}
|
||
|
||
func TestClient_Messages(t *testing.T) {
|
||
now := time.Now().UnixMilli()
|
||
f := &fakeAPIServer{messages: []v2Message{{
|
||
ID: "msg_a", Type: "assistant",
|
||
Content: []v2Part{{Type: "text", Text: "a"}, {Type: "reasoning", Text: "x"}},
|
||
Finish: "end_turn",
|
||
Time: v2Time{Created: &now, Completed: &now},
|
||
}}}
|
||
c := fakeClient(t, f)
|
||
msgs, err := c.Messages(context.Background(), "sess-fake")
|
||
if err != nil {
|
||
t.Fatalf("Messages err: %v", err)
|
||
}
|
||
if len(msgs) != 1 {
|
||
t.Fatalf("len(msgs) = %d, want 1", len(msgs))
|
||
}
|
||
if !msgs[0].finished() {
|
||
t.Error("сообщение должно быть finished (Finish задан)")
|
||
}
|
||
}
|
||
|
||
func TestClient_Active(t *testing.T) {
|
||
f := &fakeAPIServer{active: true, sessionID: "sess-fake"}
|
||
c := fakeClient(t, f)
|
||
ok, err := c.Active(context.Background(), "sess-fake")
|
||
if err != nil {
|
||
t.Fatalf("Active err: %v", err)
|
||
}
|
||
if !ok {
|
||
t.Error("Active = false, want true")
|
||
}
|
||
ok, _ = c.Active(context.Background(), "sess-other")
|
||
if ok {
|
||
t.Error("Active(чужой) = true, want false")
|
||
}
|
||
}
|
||
|
||
func TestClient_Interrupt(t *testing.T) {
|
||
c := fakeClient(t, &fakeAPIServer{})
|
||
if err := c.Interrupt(context.Background(), "sess-fake"); err != nil {
|
||
t.Fatalf("Interrupt err: %v", err)
|
||
}
|
||
}
|
||
|
||
func Test_newestAssistant(t *testing.T) {
|
||
older := time.Now().Add(-time.Minute).UnixMilli()
|
||
newer := time.Now().UnixMilli()
|
||
msgs := []v2Message{
|
||
{ID: "a", Type: "assistant", Content: []v2Part{{Type: "text", Text: "x"}}, Time: v2Time{Created: &newer}},
|
||
{ID: "b", Type: "user", Time: v2Time{Created: &newer}},
|
||
{ID: "c", Type: "assistant", Content: []v2Part{{Type: "text", Text: "y"}}, Time: v2Time{Created: &older}},
|
||
}
|
||
cur, count := newestAssistant(msgs, older)
|
||
if cur == nil || cur.ID != "a" {
|
||
t.Errorf("newest = %v, want a", cur)
|
||
}
|
||
if count != 2 {
|
||
t.Errorf("count = %d, want 2", count)
|
||
}
|
||
texts := assistantText(msgs, older)
|
||
if len(texts) != 2 || texts[0] != "y" || texts[1] != "x" {
|
||
t.Errorf("assistantText order = %v, want [y x]", texts)
|
||
}
|
||
}
|
||
|
||
func Test_parseModelString(t *testing.T) {
|
||
m := parseModelString("tokentool/deepseek/deepseek-v4-flash-0731")
|
||
if m == nil || m.ProviderID != "tokentool" || m.ID != "deepseek/deepseek-v4-flash-0731" {
|
||
t.Errorf("parse = %+v, want tokentool/deepseek-v4-flash-0731", m)
|
||
}
|
||
if parseModelString("onlyprovider") != nil {
|
||
t.Error("parse без '/' должен вернуть nil")
|
||
}
|
||
if parseModelString("") != nil {
|
||
t.Error("parse пустой должен вернуть nil")
|
||
}
|
||
}
|
||
|
||
func TestClientErr_Unwrap(t *testing.T) {
|
||
ce := &ClientErr{Op: "prompt", Err: errors.New("boom")}
|
||
var target *ClientErr
|
||
if !errors.As(ce, &target) {
|
||
t.Fatal("expected *ClientErr")
|
||
}
|
||
} |