package suggestions import ( "context" "encoding/json" "net/http" "strings" "testing" "gitea.parodia.dev/drwily/petal/internal/db" "gitea.parodia.dev/drwily/petal/internal/llm" ) // streamClient serves a canned checkpoint (so a suggestion can be seeded through // the public /check route) and a fixed token stream for chat. It records the // last streamed request so a test can assert on the injected system context. type streamClient struct { checkpoint string tokens []string lastStream llm.CompletionRequest } func (c *streamClient) Complete(_ context.Context, _ llm.CompletionRequest) (string, error) { return c.checkpoint, nil } func (c *streamClient) Stream(_ context.Context, req llm.CompletionRequest) (<-chan string, error) { c.lastStream = req ch := make(chan string) go func() { defer close(ch) for _, t := range c.tokens { ch <- t } }() return ch, nil } func TestAskPetalChat(t *testing.T) { client := &streamClient{ checkpoint: `{"suggestions":[{"original":"I has","replacement":"I have","explanation":"subject-verb agreement","type":"grammar"}]}`, tokens: []string{"Because ", "\"I\" ", "takes ", "\"have\"."}, } srv, docID, _ := newTestServer(t, client) // Seed one suggestion via the checkpoint route, then grab its id. do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "") listed := do(t, srv, http.MethodGet, "/docs/"+docID+"/suggestions", "") var sugs []db.Suggestion if err := json.Unmarshal(listed.Body.Bytes(), &sugs); err != nil { t.Fatalf("decode suggestions: %v", err) } if len(sugs) != 1 { t.Fatalf("seed: want 1 suggestion, got %d", len(sugs)) } sugID := sugs[0].ID rec := do(t, srv, http.MethodPost, "/suggestions/"+sugID+"/chat", `{"messages":[{"role":"user","content":"why is this wrong?"}]}`) if rec.Code != http.StatusOK { t.Fatalf("chat: code=%d body=%s", rec.Code, rec.Body) } if ct := rec.Header().Get("Content-Type"); ct != "text/event-stream" { t.Fatalf("chat: content-type=%q, want text/event-stream", ct) } // The streamed tokens should arrive concatenated across data: events, and the // stream should terminate with a done event. body := rec.Body.String() got := collectSSEText(body) if want := "Because \"I\" takes \"have\"."; got != want { t.Fatalf("streamed text = %q, want %q", got, want) } if !strings.Contains(body, "event: done") { t.Fatalf("chat stream missing done event:\n%s", body) } // Context injection: the system prompt (first message) must carry the // suggestion's original text and the surrounding paragraph — loaded // server-side, never from the client. msgs := client.lastStream.Messages if len(msgs) < 2 || msgs[0].Role != "system" { t.Fatalf("expected a leading system message, got %+v", msgs) } sys := msgs[0].Content if !strings.Contains(sys, "I has") { t.Fatalf("system prompt missing original text:\n%s", sys) } if !strings.Contains(sys, "I has two apple.") { t.Fatalf("system prompt missing surrounding paragraph:\n%s", sys) } // The client's user message rides after the system turn. if last := msgs[len(msgs)-1]; last.Role != "user" || last.Content != "why is this wrong?" { t.Fatalf("user message not forwarded: %+v", last) } // Chat sampling parameters from the spec. if client.lastStream.MaxTokens != 512 || client.lastStream.Temperature != 0.7 { t.Fatalf("unexpected chat params: %+v", client.lastStream) } } func TestAskPetalChatNotFound(t *testing.T) { client := &streamClient{checkpoint: `{"suggestions":[]}`} srv, _, _ := newTestServer(t, client) rec := do(t, srv, http.MethodPost, "/suggestions/does-not-exist/chat", `{"messages":[{"role":"user","content":"hi"}]}`) if rec.Code != http.StatusNotFound { t.Fatalf("want 404 for unknown suggestion, got %d", rec.Code) } } func TestSurroundingParagraph(t *testing.T) { text := "First paragraph here.\n\nThe target sentence lives here.\n\nThird paragraph." from := strings.Index(text, "target") got := surroundingParagraph(text, from) if got != "The target sentence lives here." { t.Fatalf("paragraph = %q", got) } // Unknown offset falls back to the (trimmed) document. if got := surroundingParagraph(text, -1); !strings.Contains(got, "First paragraph") { t.Fatalf("fallback paragraph = %q", got) } } // collectSSEText concatenates the JSON text fields from every `event: token` // frame in an SSE body. func collectSSEText(body string) string { var b strings.Builder for _, frame := range strings.Split(body, "\n\n") { if !strings.Contains(frame, "event: token") { continue } for _, line := range strings.Split(frame, "\n") { data, ok := strings.CutPrefix(line, "data: ") if !ok { continue } var payload struct { Text string `json:"text"` } _ = json.Unmarshal([]byte(data), &payload) b.WriteString(payload.Text) } } return b.String() }