366 lines
12 KiB
Go
366 lines
12 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
|
||
verdictReasoning string // завершённый ответ только с reasoning-партом (без text)
|
||
failCreate bool
|
||
failMessages bool
|
||
createdAgent string // агент, полученный на 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 {
|
||
Agent string `json:"agent"`
|
||
}
|
||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||
f.createdAgent = in.Agent
|
||
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 && f.verdictReasoning != "" && !f.blockPrompt {
|
||
msgs = []v2Message{f.assistantReasoningMsg(f.verdictReasoning)}
|
||
}
|
||
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},
|
||
}
|
||
}
|
||
|
||
// assistantReasoningMsg строит завершённое assistant-сообщение только с
|
||
// reasoning-партом (без text) — для проверки fallback-сценария.
|
||
func (f *fakeAPIServer) assistantReasoningMsg(text string) v2Message {
|
||
now := time.Now().UnixMilli()
|
||
return v2Message{
|
||
ID: "msg_r",
|
||
Type: "assistant",
|
||
Content: []v2Part{{Type: "reasoning", 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(), "dev")
|
||
if err != nil {
|
||
t.Fatalf("CreateSession err: %v", err)
|
||
}
|
||
if id != "sess-fake" {
|
||
t.Errorf("id = %q, want sess-fake", id)
|
||
}
|
||
if f.createdAgent != "dev" {
|
||
t.Errorf("createdAgent = %q, want dev", f.createdAgent)
|
||
}
|
||
}
|
||
|
||
func TestClient_CreateSessionPassesAgent(t *testing.T) {
|
||
f := &fakeAPIServer{}
|
||
c := fakeClient(t, f)
|
||
if _, err := c.CreateSession(context.Background(), "postmortem"); err != nil {
|
||
t.Fatalf("CreateSession err: %v", err)
|
||
}
|
||
if f.createdAgent != "postmortem" {
|
||
t.Errorf("createdAgent = %q, want postmortem", f.createdAgent)
|
||
}
|
||
}
|
||
|
||
func TestClient_CreateSessionNoAgent(t *testing.T) {
|
||
f := &fakeAPIServer{}
|
||
c := fakeClient(t, f)
|
||
if _, err := c.CreateSession(context.Background(), ""); err != nil {
|
||
t.Fatalf("CreateSession err: %v", err)
|
||
}
|
||
if f.createdAgent != "" {
|
||
t.Errorf("createdAgent = %q, want пусто", f.createdAgent)
|
||
}
|
||
}
|
||
|
||
func TestClient_CreateSessionFail(t *testing.T) {
|
||
c := fakeClient(t, &fakeAPIServer{failCreate: true})
|
||
if _, err := c.CreateSession(context.Background(), "dev"); 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_assistantVerdict(t *testing.T) {
|
||
older := time.Now().Add(-time.Minute).UnixMilli()
|
||
newer := time.Now().UnixMilli()
|
||
reasoningOf := func(text string, at *int64) v2Message {
|
||
return v2Message{ID: "r", Type: "assistant", Content: []v2Part{{Type: "reasoning", Text: text}}, Time: v2Time{Created: at}}
|
||
}
|
||
|
||
// reasoning-only: text-партов нет → fallback на reasoning, usedReasoning=true.
|
||
// Сообщения приходят новейшими первыми (как из API) → размышление 2 новее.
|
||
reasoningOnlyMsgs := []v2Message{
|
||
reasoningOf("размышление 2", &newer),
|
||
reasoningOf("размышление 1", &older),
|
||
}
|
||
texts, used := assistantVerdict(reasoningOnlyMsgs, older)
|
||
if !used {
|
||
t.Error("usedReasoning = false, want true для reasoning-only")
|
||
}
|
||
if len(texts) != 2 || texts[0] != "размышление 1" || texts[1] != "размышление 2" {
|
||
t.Errorf("verdict = %v, want [размышление 1 размышление 2] (хронологически)", texts)
|
||
}
|
||
|
||
// text + reasoning → берётся text, reasoning игнорируется.
|
||
mixed := []v2Message{
|
||
{ID: "a", Type: "assistant",
|
||
Content: []v2Part{{Type: "reasoning", Text: "thinking"}, {Type: "text", Text: "ответ"}},
|
||
Time: v2Time{Created: &newer}},
|
||
}
|
||
texts, used = assistantVerdict(mixed, older)
|
||
if used {
|
||
t.Error("usedReasoning = true, want false (есть text)")
|
||
}
|
||
if len(texts) != 1 || texts[0] != "ответ" {
|
||
t.Errorf("verdict = %v, want [ответ]", texts)
|
||
}
|
||
|
||
// пусто → пусто и usedReasoning=false.
|
||
empty := []v2Message{{ID: "u", Type: "user", Time: v2Time{Created: &newer}}}
|
||
if texts, used := assistantVerdict(empty, older); used || len(texts) != 0 {
|
||
t.Errorf("пусто: texts=%v usedReasoning=%v, want пусто/false", texts, used)
|
||
}
|
||
}
|
||
|
||
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")
|
||
}
|
||
}
|
||
|
||
// TestSessionMessagesURL — ссылка на сообщения сессии, сортировка по времени (новые сверху).
|
||
func TestSessionMessagesURL(t *testing.T) {
|
||
got := SessionMessagesURL("http://127.0.0.1:4101", "sess-abc")
|
||
want := "http://127.0.0.1:4101/api/session/sess-abc/message?order=desc"
|
||
if got != want {
|
||
t.Errorf("SessionMessagesURL() = %q, want %q", got, want)
|
||
}
|
||
} |