Files
petal/internal/suggestions/chat_test.go
prosolis 3c5f3ecb96 Phase 4: Ask Petal SSE chat
Conversational follow-up on a suggestion, streamed token-by-token.

Backend (interface-only; handlers never touch a concrete LLM client):
- internal/llm/chat.go: StreamAskPetal with conversational sampling
  (max_tokens 512, temp 0.7, rep 1.15, top_p 0.92, stop "\n\n\n"),
  reusing AskPetalSystemPrompt + TrimHistory.
- internal/suggestions/chat.go: POST /api/suggestions/:id/chat. One
  user-scoped join loads the suggestion + parent content_text;
  surroundingParagraph extracts the \n\n-bounded paragraph at from_pos
  (whole-doc fallback when unlocated) and injects it server-side.
  Streams event: token / event: done SSE frames with JSON-encoded data
  so token newlines can't break framing; real http.Flusher per chunk.
  LLM-unreachable -> 502 before SSE headers; unknown suggestion -> 404.

Frontend:
- streamSuggestionChat: fetch + ReadableStream SSE parser (not
  EventSource, needs POST), abortable.
- AskPetal.tsx: whole conversation in component state (no persistence,
  cleared on close), Petal's first bubble pre-seeded with the
  explanation, rose/lavender bubbles, CJK font stack on the bubbles
  only (Note #17), streaming caret.
- SuggestionCard "Ask Petal" pill pins the card open while chatting
  (hover-close suppressed, click-away closes) and widens it to 340px.

Tests: chat_test.go covers streamed-text concat + done event,
server-side context injection on the system message, sampling params,
404, and surroundingParagraph. go build/vet/test clean, tsc clean,
vite build OK. Live SSE smoke-tested against a fake streaming vLLM:
tokens flushed individually through the chi middleware stack, done
terminator, 502 on LLM-down, 404 on unknown suggestion.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-25 21:05:39 -07:00

149 lines
4.7 KiB
Go

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()
}