Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

1902 lines
58 KiB
Go

package server_test
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.kenn.io/agentsview/internal/config"
"go.kenn.io/agentsview/internal/db"
"go.kenn.io/agentsview/internal/dbtest"
"go.kenn.io/agentsview/internal/insight"
"go.kenn.io/agentsview/internal/server"
)
type listInsightsResponse struct {
Insights []db.Insight `json:"insights"`
}
type failFirstWriteRecorder struct {
header http.Header
writes int
status int
flushed bool
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(
req *http.Request,
) (*http.Response, error) {
return f(req)
}
type readOnlyInsightPersistStore struct {
db.Store
insertCalls int
}
func fastInsightLogDrainTimeouts() server.Option {
return server.WithInsightLogDrainTimeouts(
20*time.Millisecond,
50*time.Millisecond,
)
}
func (s *readOnlyInsightPersistStore) ReadOnly() bool { return true }
func (s *readOnlyInsightPersistStore) InsightGenerationAvailable() bool {
return true
}
func (s *readOnlyInsightPersistStore) InsertInsight(
insight db.Insight,
) (int64, error) {
s.insertCalls++
return s.Store.InsertInsight(insight)
}
func newFailFirstWriteRecorder() *failFirstWriteRecorder {
return &failFirstWriteRecorder{
header: make(http.Header),
status: http.StatusOK,
}
}
func (f *failFirstWriteRecorder) Header() http.Header {
return f.header
}
func (f *failFirstWriteRecorder) WriteHeader(statusCode int) {
f.status = statusCode
}
func (f *failFirstWriteRecorder) Write(b []byte) (int, error) {
f.writes++
if f.writes == 1 {
return 0, io.ErrClosedPipe
}
return len(b), nil
}
func (f *failFirstWriteRecorder) Flush() {
f.flushed = true
}
func TestListInsights(t *testing.T) {
assertList := func(
t *testing.T,
te *testEnv,
path string,
wantStatus int,
wantCount int,
wantBody string,
) {
t.Helper()
w := te.get(t, path)
assertStatus(t, w, wantStatus)
if wantBody != "" {
assertBodyContains(t, w, wantBody)
}
if wantStatus == http.StatusOK {
r := decode[listInsightsResponse](t, w)
require.Len(t, r.Insights, wantCount)
}
}
t.Run("Empty", func(t *testing.T) {
te := setup(t)
assertList(t, te, "/api/v1/insights", http.StatusOK, 0, "")
})
t.Run("FiltersAndInvalidRequests", func(t *testing.T) {
te := setup(t)
te.seedInsight(t, "daily_activity", "2025-01-15", new("my-app"))
te.seedInsight(t, "agent_analysis", "2025-01-15", nil)
t.Run("TypeFilter", func(t *testing.T) {
assertList(t, te, "/api/v1/insights?type=daily_activity",
http.StatusOK, 1, "")
})
te.seedInsight(t, "daily_activity", "2025-01-15", new("other-app"))
t.Run("WithData", func(t *testing.T) {
assertList(t, te, "/api/v1/insights", http.StatusOK, 3, "")
})
t.Run("InvalidType", func(t *testing.T) {
assertList(t, te, "/api/v1/insights?type=invalid",
http.StatusBadRequest, 0, "invalid type")
})
t.Run("ReversedDateRange", func(t *testing.T) {
assertList(t, te,
"/api/v1/insights?date_from=2026-06-17&date_to=2026-06-16",
http.StatusBadRequest, 0,
"date_from must not be after date_to")
})
t.Run("InvalidDateFrom", func(t *testing.T) {
// A non-date value is rejected with 400 before the handler
// runs (huma's format:"date" query validation), so only the
// status is asserted; the body message is owned by the
// framework.
assertList(t, te, "/api/v1/insights?date_from=not-a-date",
http.StatusBadRequest, 0, "")
})
})
t.Run("ReturnsAll", func(t *testing.T) {
te := setup(t)
te.seedInsight(t, "daily_activity", "2025-01-15", new("my-app"))
te.seedInsight(t, "daily_activity", "2025-01-16", new("my-app"))
assertList(t, te, "/api/v1/insights", http.StatusOK, 2, "")
})
}
func TestGetInsight_Found(t *testing.T) {
te := setup(t)
id := te.seedInsight(t, "daily_activity", "2025-01-15",
new("my-app"))
w := te.get(t, fmt.Sprintf("/api/v1/insights/%d", id))
assertStatus(t, w, http.StatusOK)
r := decode[db.Insight](t, w)
require.Equal(t, id, r.ID)
assert.Equal(t, "daily_activity", r.Type)
}
func TestInsightExportHTML(t *testing.T) {
te := setup(t)
id := te.seedInsight(t, "daily_activity", "2025-01-15", new("my-app"),
func(insight *db.Insight) {
insight.Content = "# Insight\n\n- published finding"
},
)
w := te.get(t, fmt.Sprintf("/api/v1/insights/%d/export", id))
assertStatus(t, w, http.StatusOK)
assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
assert.Contains(t, w.Header().Get("Content-Disposition"), "attachment")
assert.Contains(t, w.Header().Get("Content-Disposition"), ".html")
assertBodyContains(t, w, "Daily Activity Insight")
assertBodyContains(t, w, "# Insight")
assertBodyContains(t, w, "my-app")
}
func TestInsightMarkdownExport(t *testing.T) {
te := setup(t)
content := "# Insight\n\n- stored markdown"
id := te.seedInsight(t, "daily_activity", "2025-01-15", new("my-app"),
func(insight *db.Insight) {
insight.Content = content
},
)
w := te.get(t, fmt.Sprintf("/api/v1/insights/%d/md", id))
assertStatus(t, w, http.StatusOK)
assert.Contains(t, w.Header().Get("Content-Type"), "text/markdown")
assert.Contains(t, w.Header().Get("Content-Disposition"), "inline")
assert.Contains(t, w.Header().Get("Content-Disposition"), ".md")
assert.Equal(t, content, w.Body.String())
}
func TestInsightPublish(t *testing.T) {
t.Run("Success", func(t *testing.T) {
te := setup(t)
te.srv.SetGithubToken("fake-token")
id := te.seedInsight(t, "daily_activity", "2025-01-15", new("my-app"),
func(insight *db.Insight) {
insight.Content = "# Insight\n\n- publish me"
},
)
originalTransport := http.DefaultTransport
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
require.Equal(t, http.MethodPost, req.Method)
require.Equal(t, "https://api.github.com/gists", req.URL.String())
require.Equal(t, "token fake-token", req.Header.Get("Authorization"))
var payload struct {
Description string `json:"description"`
Public bool `json:"public"`
Files map[string]struct {
Content string `json:"content"`
} `json:"files"`
}
body, err := io.ReadAll(req.Body)
require.NoError(t, err)
require.NoError(t, json.Unmarshal(body, &payload))
assert.Equal(t, "Insight: Daily Activity - my-app - 2025-01-15", payload.Description)
assert.False(t, payload.Public)
require.Contains(t, payload.Files, "insight-daily_activity-my-app-20250115.html")
assert.Contains(t,
payload.Files["insight-daily_activity-my-app-20250115.html"].Content,
"Daily Activity Insight",
)
assert.Contains(t,
payload.Files["insight-daily_activity-my-app-20250115.html"].Content,
"# Insight",
)
return &http.Response{
StatusCode: http.StatusCreated,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(
`{"id":"gist123","html_url":"https://gist.github.com/octocat/gist123","owner":{"login":"octocat"}}`,
)),
}, nil
})
t.Cleanup(func() {
http.DefaultTransport = originalTransport
})
w := te.post(t, fmt.Sprintf("/api/v1/insights/%d/publish?secret=true", id), "{}")
assertStatus(t, w, http.StatusOK)
resp := decode[map[string]string](t, w)
assert.Equal(t, "gist123", resp["gist_id"])
assert.Equal(t, "https://gist.github.com/octocat/gist123", resp["gist_url"])
assert.Equal(t,
"https://gist.githubusercontent.com/octocat/gist123/raw/insight-daily_activity-my-app-20250115.html",
resp["raw_url"],
)
assert.Equal(t,
"https://htmlpreview.github.io/?https://gist.githubusercontent.com/octocat/gist123/raw/insight-daily_activity-my-app-20250115.html",
resp["view_url"],
)
})
t.Run("NoToken", func(t *testing.T) {
t.Setenv("AGENTSVIEW_GITHUB_TOKEN", "")
t.Setenv("PATH", t.TempDir())
te := setup(t)
id := te.seedInsight(t, "daily_activity", "2025-01-15", new("my-app"))
w := te.post(t, fmt.Sprintf("/api/v1/insights/%d/publish", id), "{}")
assertStatus(t, w, http.StatusUnauthorized)
})
t.Run("ForwardedRequestDoesNotUseGitHubCLIAuthTokenFallback", func(t *testing.T) {
useGitHubCLIAuthTokenStub(t)
te := setup(t)
id := te.seedInsight(t, "daily_activity", "2025-01-15", new("my-app"))
req := httptest.NewRequest(http.MethodPost,
fmt.Sprintf("/api/v1/insights/%d/publish", id),
strings.NewReader("{}"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", "http://127.0.0.1:0")
req.Header.Set("X-Forwarded-For", "203.0.113.10")
w := httptest.NewRecorder()
te.handler.ServeHTTP(w, req)
assertStatus(t, w, http.StatusUnauthorized)
})
t.Run("NotFound", func(t *testing.T) {
te := setup(t)
te.srv.SetGithubToken("fake-token")
w := te.post(t, "/api/v1/insights/99999/publish", "{}")
assertStatus(t, w, http.StatusNotFound)
})
}
func TestGenerateInsight_Validation(t *testing.T) {
te := setup(t)
tests := []struct {
name string
payload string
wantBody string
}{
{"InvalidType", `{"type":"bad","date_from":"2025-01-15","date_to":"2025-01-15"}`, ""},
{"InvalidDateFrom", `{"type":"daily_activity","date_from":"bad","date_to":"2025-01-15"}`, "date_from"},
{"InvalidDateTo", `{"type":"daily_activity","date_from":"2025-01-15","date_to":"bad"}`, "date_to"},
{"DateToBeforeDateFrom", `{"type":"daily_activity","date_from":"2025-01-16","date_to":"2025-01-15"}`, "date_to must be"},
{"InvalidJSON", `{bad json`, ""},
{"InvalidAgent", `{"type":"daily_activity","date_from":"2025-01-15","date_to":"2025-01-15","agent":"gpt"}`, "invalid agent"},
{"InvalidAutomatedScope", `{"type":"daily_activity","date_from":"2025-01-15","date_to":"2025-01-15","automated_scope":"robots"}`, "automated_scope"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := te.post(t, "/api/v1/insights/generate", tt.payload)
assertStatus(t, w, http.StatusBadRequest)
if tt.wantBody != "" {
assertBodyContains(t, w, tt.wantBody)
}
})
}
}
func TestGenerateInsight_DefaultAgent(t *testing.T) {
stubGen := func(
_ context.Context, agent, _ string,
) (insight.Result, error) {
assert.Equal(t, "claude", agent, "expected default agent claude")
return insight.Result{}, fmt.Errorf("stub: no CLI")
}
te := setupWithServerOpts(t, []server.Option{
server.WithGenerateFunc(stubGen),
})
w := te.post(t, "/api/v1/insights/generate",
`{"type":"daily_activity","date_from":"2025-01-15","date_to":"2025-01-15"}`)
assertStatus(t, w, http.StatusOK)
assertBodyContains(t, w, "event: error")
assertBodyContains(t, w, "stub: no CLI")
}
func TestGenerateInsight_SessionValidation(t *testing.T) {
te := setup(t)
for _, body := range []string{
`{"type":"daily_activity","session_id":"missing","date_from":"2025-01-15","date_to":"2025-01-15"}`,
`{"type":"llm_canned","kind":"prompt_maturity_review","llm_opt_in":true,"session_id":"missing","date_from":"2025-01-15","date_to":"2025-01-15"}`,
} {
w := te.post(t, "/api/v1/insights/generate", body)
assertStatus(t, w, http.StatusBadRequest)
assertBodyContains(t, w, "session_id is only supported for agent_analysis")
}
}
func TestGenerateInsight_SingleSessionUsesSessionPrompt(t *testing.T) {
te := setupWithServerOpts(t, []server.Option{
server.WithGenerateFunc(func(
_ context.Context, agent, prompt string,
) (insight.Result, error) {
assert.Equal(t, "claude", agent)
assert.Contains(t, prompt, "## Session: session-1")
return insight.Result{
Agent: "claude",
Content: "single-session analysis",
}, nil
}),
})
te.seedSession(t, "session-1", "my-app", 2)
w := te.post(t, "/api/v1/insights/generate",
`{"type":"agent_analysis","session_id":"session-1","date_from":"","date_to":""}`)
assertStatus(t, w, http.StatusOK)
events := parseSSE(w.Body.String())
require.NotEmpty(t, events)
require.Equal(t, "done", events[len(events)-1].Event)
var saved db.Insight
require.NoError(t, json.Unmarshal([]byte(events[len(events)-1].Data), &saved))
assert.Equal(t, "2025-01-15", saved.DateFrom)
assert.Equal(t, "2025-01-15", saved.DateTo)
assert.Equal(t, "my-app", *saved.Project)
}
func TestGenerateInsight_SingleSessionMissingSession(t *testing.T) {
te := setup(t)
w := te.post(t, "/api/v1/insights/generate",
`{"type":"agent_analysis","session_id":"missing","date_from":"","date_to":""}`)
assertStatus(t, w, http.StatusNotFound)
assertBodyContains(t, w, "session not found")
}
func TestGenerateInsight_PersistsWithReadOnlyStore(t *testing.T) {
dir := tempDirWithRetryCleanup(t)
dbPath := filepath.Join(dir, "test.db")
database := dbtest.OpenTestDBAt(t, dbPath)
store := &readOnlyInsightPersistStore{Store: database}
cfg := config.Config{
Host: "127.0.0.1",
Port: 0,
DataDir: dir,
DBPath: dbPath,
WriteTimeout: 30 * time.Second,
}
srv := server.New(cfg, store, nil, server.WithGenerateFunc(func(
_ context.Context, agent, _ string,
) (insight.Result, error) {
assert.Equal(t, "claude", agent)
return insight.Result{
Agent: "claude",
Content: "persisted from read-only wrapper",
}, nil
}))
te := &testEnv{
srv: srv,
handler: wrapTestHandler(cfg, srv.Handler()),
db: database,
engine: nil,
broadcaster: nil,
dataDir: dir,
}
assert.True(t, store.ReadOnly())
w := te.post(t, "/api/v1/insights/generate",
`{"type":"daily_activity","date_from":"2025-01-15","date_to":"2025-01-15"}`)
assertStatus(t, w, http.StatusOK)
events := parseSSE(w.Body.String())
require.NotEmpty(t, events)
require.Equal(t, "done", events[len(events)-1].Event)
assert.Equal(t, 1, store.insertCalls)
var saved db.Insight
require.NoError(t, json.Unmarshal([]byte(events[len(events)-1].Data), &saved))
assert.Equal(t, "persisted from read-only wrapper", saved.Content)
assert.Equal(t, "claude", saved.Agent)
stored, err := database.GetInsight(context.Background(), saved.ID)
require.NoError(t, err, "GetInsight after generate")
require.NotNil(t, stored)
assert.Equal(t, saved.ID, stored.ID)
assert.Equal(t, saved.Content, stored.Content)
}
func TestGenerateInsight_StaysBlockedForReadOnlyStoreWithoutInsightWrites(t *testing.T) {
dir := tempDirWithRetryCleanup(t)
dbPath := filepath.Join(dir, "test.db")
database := dbtest.OpenTestDBAt(t, dbPath)
var called bool
cfg := config.Config{
Host: "127.0.0.1",
Port: 0,
DataDir: dir,
DBPath: dbPath,
WriteTimeout: 30 * time.Second,
}
srv := server.New(cfg, readOnlyTestStore{Store: database}, nil, server.WithGenerateFunc(func(
_ context.Context, _ string, _ string,
) (insight.Result, error) {
called = true
return insight.Result{Agent: "claude", Content: "should not run"}, nil
}))
te := &testEnv{
srv: srv,
handler: wrapTestHandler(cfg, srv.Handler()),
db: database,
engine: nil,
broadcaster: nil,
dataDir: dir,
}
w := te.post(t, "/api/v1/insights/generate",
`{"type":"daily_activity","date_from":"2025-01-15","date_to":"2025-01-15"}`)
assertStatus(t, w, http.StatusNotImplemented)
assertBodyContains(t, w, "read-only mode")
assert.False(t, called)
}
func TestGenerateCannedInsight_RequiresExplicitOptIn(t *testing.T) {
te := setup(t)
w := te.post(t, "/api/v1/insights/generate",
`{"type":"llm_canned","kind":"prompt_maturity_review","date_from":"2025-01-15","date_to":"2025-01-15","agent":"claude"}`)
assertStatus(t, w, http.StatusBadRequest)
assertBodyContains(t, w, "llm_opt_in")
}
func TestGenerateCannedInsight_RejectsInvalidFilterTimezone(t *testing.T) {
te := setup(t)
w := te.post(t, "/api/v1/insights/generate",
`{"type":"llm_canned","kind":"prompt_maturity_review","date_from":"2025-01-15","date_to":"2025-01-15","agent":"claude","llm_opt_in":true,"filters":{"timezone":"Fake/Zone","include_one_shot":false,"automated_scope":"human"}}`)
assertStatus(t, w, http.StatusBadRequest)
assertBodyContains(t, w, "invalid timezone: Fake/Zone")
}
func TestGenerateCannedInsight_ReturnsValidationDetail(t *testing.T) {
stubGen := func(
_ context.Context, _, _ string, _ insight.LogFunc,
) (insight.Result, error) {
return insight.Result{
Agent: "claude",
Model: "test-model",
Content: `{
"schema_version":"llm_insight.v1",
"kind":"model_cost_review",
"summary":"Cache behavior needs a closer look.",
"confidence":"medium",
"recommendations":[{
"title":"Review cache misses",
"rationale":"The usage aggregates suggest cache misses.",
"actions":["Review expensive sessions"],
"evidence_refs":["usage:cache_behavior"],
"impact":"medium",
"effort":"low"
}],
"risks":[],
"evidence_refs":["usage:cache_behavior"]
}`,
}, nil
}
te := setupWithServerOpts(t, []server.Option{
server.WithGenerateStreamFunc(stubGen),
})
w := te.post(t, "/api/v1/insights/generate",
`{"type":"llm_canned","kind":"model_cost_review","date_from":"2025-01-15","date_to":"2025-01-15","agent":"claude","llm_opt_in":true}`)
assertStatus(t, w, http.StatusOK)
assertBodyContains(t, w, "event: error")
assertBodyContains(t, w,
"generated insight failed validation: unknown envelope evidence_ref: usage:cache_behavior")
}
func TestGenerateCannedInsight_SaveCacheAndPreserveSignals(t *testing.T) {
var calls atomic.Int32
stubGen := func(
_ context.Context, agent, prompt string, _ insight.LogFunc,
) (insight.Result, error) {
calls.Add(1)
if agent != "claude" {
t.Fatalf("agent = %q, want claude", agent)
}
if !strings.Contains(prompt, "Do not recalculate, override") {
t.Fatalf("prompt missing score boundary: %s", prompt)
}
return insight.Result{
Agent: "claude",
Model: "test-model",
Content: `{
"schema_version":"llm_insight.v1",
"kind":"prompt_maturity_review",
"summary":"Prompt starts are mostly healthy, with a few places to tighten acceptance criteria.",
"confidence":"medium",
"recommendations":[{
"title":"Add explicit verification asks",
"rationale":"The selected aggregate has scored sessions and outcome data, so verification wording can be improved without changing scores.",
"actions":["Add acceptance criteria to implementation prompts","Ask for validation commands in task handoffs"],
"evidence_refs":["signals:score_distribution","signals:outcomes"],
"impact":"medium",
"effort":"low"
}],
"risks":[{
"title":"Evidence is aggregate-only",
"explanation":"This recommendation does not inspect raw transcript text.",
"evidence_refs":["signals:score_distribution"]
}],
"evidence_refs":["signals:score_distribution","signals:outcomes"]
}`,
}, nil
}
te := setupWithServerOpts(t, []server.Option{
server.WithGenerateStreamFunc(stubGen),
})
te.seedSession(t, "quality-1", "my-app", 6)
score := 86
grade := "B"
if err := te.db.UpdateSessionSignals("quality-1", db.SessionSignalUpdate{
ToolFailureSignalCount: 2,
ToolRetryCount: 1,
Outcome: "completed",
OutcomeConfidence: "high",
EndedWithRole: "assistant",
HealthScore: &score,
HealthGrade: &grade,
HasToolCalls: true,
HasContextData: true,
}); err != nil {
t.Fatalf("UpdateSessionSignals: %v", err)
}
before, err := te.db.GetSession(context.Background(), "quality-1")
if err != nil {
t.Fatalf("GetSession before: %v", err)
}
payload := `{"type":"llm_canned","kind":"prompt_maturity_review","date_from":"2025-01-15","date_to":"2025-01-15","project":"my-app","agent":"claude","llm_opt_in":true}`
w := te.post(t, "/api/v1/insights/generate", payload)
assertStatus(t, w, http.StatusOK)
events := parseSSE(w.Body.String())
if len(events) == 0 || events[len(events)-1].Event != "done" {
t.Fatalf("expected done event, got %s", w.Body.String())
}
var saved db.Insight
if err := json.Unmarshal([]byte(events[len(events)-1].Data), &saved); err != nil {
t.Fatalf("decode saved insight: %v", err)
}
if saved.Type != insight.CannedType {
t.Fatalf("Type = %q, want %q", saved.Type, insight.CannedType)
}
if saved.Kind != "prompt_maturity_review" {
t.Fatalf("Kind = %q", saved.Kind)
}
if saved.SchemaVersion != insight.CannedSchemaVersion ||
saved.TemplateID == "" || saved.AggregateHash == "" ||
saved.CacheKey == "" || saved.CacheStatus != "fresh" ||
saved.ProvenanceJSON == "" || saved.StructuredJSON == "" {
t.Fatalf("missing canned metadata: %+v", saved)
}
if !strings.Contains(saved.Content, "AI-generated recommendation") {
t.Fatalf("content missing generated label: %s", saved.Content)
}
after, err := te.db.GetSession(context.Background(), "quality-1")
if err != nil {
t.Fatalf("GetSession after: %v", err)
}
if *after.HealthScore != *before.HealthScore ||
*after.HealthGrade != *before.HealthGrade ||
after.ToolFailureSignalCount != before.ToolFailureSignalCount ||
after.ToolRetryCount != before.ToolRetryCount {
t.Fatalf("canonical signals changed: before=%+v after=%+v", before, after)
}
w = te.post(t, "/api/v1/insights/generate", payload)
assertStatus(t, w, http.StatusOK)
if calls.Load() != 1 {
t.Fatalf("generator calls = %d, want 1 after cache hit", calls.Load())
}
events = parseSSE(w.Body.String())
foundCacheHit := false
var cached db.Insight
for _, ev := range events {
if ev.Event == "status" && strings.Contains(ev.Data, "cache_hit") {
foundCacheHit = true
}
if ev.Event == "done" {
if err := json.Unmarshal([]byte(ev.Data), &cached); err != nil {
t.Fatalf("decode cached insight: %v", err)
}
}
}
if !foundCacheHit {
t.Fatalf("expected cache_hit status, got %s", w.Body.String())
}
if cached.CacheStatus != "hit" {
t.Fatalf("cached CacheStatus = %q, want hit", cached.CacheStatus)
}
if !strings.Contains(cached.ProvenanceJSON, `"cache_status":"hit"`) {
t.Fatalf("cached provenance missing hit status: %s", cached.ProvenanceJSON)
}
stored, err := te.db.GetInsight(context.Background(), saved.ID)
if err != nil {
t.Fatalf("GetInsight stored: %v", err)
}
if stored == nil || stored.CacheStatus != "fresh" ||
!strings.Contains(stored.ProvenanceJSON, `"cache_status":"fresh"`) {
t.Fatalf("stored insight should keep original provenance: %+v", stored)
}
}
func TestGenerateCannedInsight_ModelCostPromptIncludesModelBreakdown(t *testing.T) {
var capturedPrompt string
stubGen := func(
_ context.Context, _, prompt string, _ insight.LogFunc,
) (insight.Result, error) {
capturedPrompt = prompt
return insight.Result{
Agent: "claude",
Model: "test-model",
Content: `{
"schema_version":"llm_insight.v1",
"kind":"model_cost_review",
"summary":"Model costs are concentrated in the supplied breakdown.",
"confidence":"high",
"recommendations":[{
"title":"Review the highest cost model",
"rationale":"The deterministic model breakdown identifies the cost concentration.",
"actions":["Compare the top model against lower-cost alternatives for routine tasks"],
"evidence_refs":["usage:model_breakdown"],
"impact":"medium",
"effort":"low"
}],
"risks":[],
"evidence_refs":["usage:model_breakdown"]
}`,
}, nil
}
te := setupWithServerOpts(t, []server.Option{
server.WithGenerateStreamFunc(stubGen),
})
if err := te.db.UpsertModelPricing([]db.ModelPricing{
{
ModelPattern: "claude-opus-4-7",
InputPerMTok: 15,
OutputPerMTok: 75,
CacheCreationPerMTok: 18.75,
CacheReadPerMTok: 1.5,
},
{
ModelPattern: "claude-sonnet-4-6",
InputPerMTok: 3,
OutputPerMTok: 15,
CacheCreationPerMTok: 3.75,
CacheReadPerMTok: 0.3,
},
}); err != nil {
t.Fatalf("UpsertModelPricing: %v", err)
}
te.seedSession(t, "usage-1", "my-app", 4)
te.seedMessages(t, "usage-1", 4, func(i int, m *db.Message) {
switch i {
case 1:
m.Model = "claude-opus-4-7"
m.TokenUsage = json.RawMessage(
`{"input_tokens":1000,"output_tokens":200,"cache_creation_input_tokens":100,"cache_read_input_tokens":50}`)
case 3:
m.Model = "claude-sonnet-4-6"
m.TokenUsage = json.RawMessage(
`{"input_tokens":2000,"output_tokens":300,"cache_creation_input_tokens":0,"cache_read_input_tokens":400}`)
}
})
payload := `{"type":"llm_canned","kind":"model_cost_review","date_from":"2025-01-15","date_to":"2025-01-15","project":"my-app","agent":"claude","llm_opt_in":true}`
w := te.post(t, "/api/v1/insights/generate", payload)
assertStatus(t, w, http.StatusOK)
assertBodyContains(t, w, "event: done")
for _, want := range []string{
`"model_breakdowns"`,
`"model_name":"claude-opus-4-7"`,
`"model_name":"claude-sonnet-4-6"`,
`usage:model_breakdown`,
} {
if !strings.Contains(capturedPrompt, want) {
t.Fatalf("prompt missing %q: %s", want, capturedPrompt)
}
}
if strings.Contains(capturedPrompt, "Model mix is not directly observable") {
t.Fatalf("prompt should include observable model mix: %s", capturedPrompt)
}
}
func TestGenerateCannedInsight_CoachSummaryUsesAllPages(t *testing.T) {
var generatedPrompt string
stubGen := func(
_ context.Context, _ string, prompt string, _ insight.LogFunc,
) (insight.Result, error) {
generatedPrompt = prompt
return insight.Result{
Agent: "claude",
Model: "test-model",
Content: `{
"schema_version":"llm_insight.v1",
"kind":"prompt_maturity_review",
"summary":"Coach-derived prompt maturity evidence is available for the selected scope.",
"confidence":"medium",
"recommendations":[{
"title":"Tighten repeated implementation prompts",
"rationale":"The Coach prompt maturity aggregate covers the selected sessions without changing canonical scores.",
"actions":["Add explicit verification steps to repeated prompts"],
"evidence_refs":["coach:prompt_maturity"],
"impact":"medium",
"effort":"low"
}],
"risks":[],
"evidence_refs":["coach:prompt_maturity"]
}`,
}, nil
}
te := setupWithServerOpts(t, []server.Option{
server.WithGenerateStreamFunc(stubGen),
})
prompt := "Implement paged workflow review with acceptance criteria and verify output"
for i := range db.MaxSessionLimit + 1 {
te.seedSession(t, fmt.Sprintf("coach-%03d", i), "my-app", 3,
func(s *db.Session) {
s.FirstMessage = &prompt
s.HasToolCalls = true
})
}
w := te.post(t, "/api/v1/insights/generate",
`{"type":"llm_canned","kind":"prompt_maturity_review","date_from":"2025-01-15","date_to":"2025-01-15","project":"my-app","agent":"claude","llm_opt_in":true}`)
assertStatus(t, w, http.StatusOK)
if !strings.Contains(generatedPrompt, `"session_count":501`) {
t.Fatalf("generated prompt missing all-page Coach session count: %s",
generatedPrompt)
}
}
func TestGenerateCannedInsight_AutomatedScopeOnlyAutomated(t *testing.T) {
var generatedPrompt string
stubGen := func(
_ context.Context, _ string, prompt string, _ insight.LogFunc,
) (insight.Result, error) {
generatedPrompt = prompt
return insight.Result{
Agent: "claude",
Model: "test-model",
Content: `{
"schema_version":"llm_insight.v1",
"kind":"prompt_maturity_review",
"summary":"Automated review prompts are isolated for this recommendation.",
"confidence":"medium",
"recommendations":[{
"title":"Separate review automation from human work",
"rationale":"The Coach prompt maturity aggregate covers the selected automated scope.",
"actions":["Review automated sessions separately from human sessions"],
"evidence_refs":["coach:prompt_maturity"],
"impact":"medium",
"effort":"low"
}],
"risks":[],
"evidence_refs":["coach:prompt_maturity"]
}`,
}, nil
}
te := setupWithServerOpts(t, []server.Option{
server.WithGenerateStreamFunc(stubGen),
})
humanPrompt := "Implement the checkout fix with tests and verification"
te.seedSession(t, "human-session", "my-app", 4, func(s *db.Session) {
s.FirstMessage = &humanPrompt
s.UserMessageCount = 2
})
autoPrompt := "You are a code reviewer. Review the diff."
te.seedSession(t, "auto-session", "my-app", 2, func(s *db.Session) {
s.FirstMessage = &autoPrompt
s.UserMessageCount = 1
s.IsAutomated = true
})
w := te.post(t, "/api/v1/insights/generate",
`{"type":"llm_canned","kind":"prompt_maturity_review","date_from":"2025-01-15","date_to":"2025-01-15","project":"my-app","agent":"claude","llm_opt_in":true,"automated_scope":"automated"}`)
assertStatus(t, w, http.StatusOK)
if !strings.Contains(generatedPrompt, `"automated_scope":"automated"`) {
t.Fatalf("generated prompt missing automated scope: %s", generatedPrompt)
}
if !strings.Contains(generatedPrompt, `"session_count":1`) {
t.Fatalf("generated prompt should include only automated session: %s", generatedPrompt)
}
if strings.Contains(generatedPrompt, "human-session") {
t.Fatalf("generated prompt included human session: %s", generatedPrompt)
}
}
func TestGenerateCannedInsight_UsesSessionFilterPayload(t *testing.T) {
var calls atomic.Int32
var generatedPrompts []string
stubGen := func(
_ context.Context, _ string, prompt string, _ insight.LogFunc,
) (insight.Result, error) {
calls.Add(1)
generatedPrompts = append(generatedPrompts, prompt)
return insight.Result{
Agent: "claude",
Model: "test-model",
Content: `{
"schema_version":"llm_insight.v1",
"kind":"prompt_maturity_review",
"summary":"Prompt maturity evidence is scoped to the active dashboard filters.",
"confidence":"medium",
"recommendations":[{
"title":"Keep filtered recommendations scoped",
"rationale":"The Coach prompt maturity aggregate covers only the selected session cohort.",
"actions":["Generate recommendations from the same filters used by the dashboard"],
"evidence_refs":["coach:prompt_maturity"],
"impact":"medium",
"effort":"low"
}],
"risks":[],
"evidence_refs":["coach:prompt_maturity"]
}`,
}, nil
}
te := setupWithServerOpts(t, []server.Option{
server.WithGenerateStreamFunc(stubGen),
})
clean := "clean"
codexPrompt := "Implement filtered recommendations with acceptance criteria and verification"
claudePrompt := "Implement agent-specific recommendations with acceptance criteria and verification"
wrongMachinePrompt := "Implement workstation filter bypass with acceptance criteria"
oneShotPrompt := "Fix it"
te.seedSession(t, "codex-match", "my-app", 4, func(s *db.Session) {
s.Agent = "codex"
s.Machine = "workstation"
s.UserMessageCount = 3
s.FirstMessage = &codexPrompt
s.HasToolCalls = true
s.TerminationStatus = &clean
})
te.seedSession(t, "claude-match", "my-app", 4, func(s *db.Session) {
s.Agent = "claude"
s.Machine = "workstation"
s.UserMessageCount = 3
s.FirstMessage = &claudePrompt
s.HasToolCalls = true
s.TerminationStatus = &clean
})
te.seedSession(t, "wrong-machine", "my-app", 4, func(s *db.Session) {
s.Agent = "codex"
s.Machine = "other-host"
s.UserMessageCount = 3
s.FirstMessage = &wrongMachinePrompt
s.HasToolCalls = true
s.TerminationStatus = &clean
})
te.seedSession(t, "one-shot", "my-app", 1, func(s *db.Session) {
s.Agent = "codex"
s.Machine = "workstation"
s.UserMessageCount = 1
s.FirstMessage = &oneShotPrompt
s.HasToolCalls = true
s.TerminationStatus = &clean
})
firstPayload := `{"type":"llm_canned","kind":"prompt_maturity_review","date_from":"2025-01-15","date_to":"2025-01-15","project":"my-app","agent":"claude","llm_opt_in":true,"filters":{"timezone":"America/New_York","agent":"codex","machine":"workstation","termination":"clean","min_user_messages":2,"include_one_shot":false,"automated_scope":"human"}}`
w := te.post(t, "/api/v1/insights/generate", firstPayload)
assertStatus(t, w, http.StatusOK)
require.Equal(t, int32(1), calls.Load())
require.Len(t, generatedPrompts, 1)
assert.Contains(t, generatedPrompts[0], `"timezone":"America/New_York"`)
assert.Contains(t, generatedPrompts[0], `"agent":"codex"`)
assert.Contains(t, generatedPrompts[0], `"session_count":1`)
assert.Contains(t, generatedPrompts[0], codexPrompt)
assert.NotContains(t, generatedPrompts[0], claudePrompt)
assert.NotContains(t, generatedPrompts[0], wrongMachinePrompt)
assert.NotContains(t, generatedPrompts[0], oneShotPrompt)
secondPayload := `{"type":"llm_canned","kind":"prompt_maturity_review","date_from":"2025-01-15","date_to":"2025-01-15","project":"my-app","agent":"claude","llm_opt_in":true,"filters":{"timezone":"America/New_York","agent":"claude","machine":"workstation","termination":"clean","min_user_messages":2,"include_one_shot":false,"automated_scope":"human"}}`
w = te.post(t, "/api/v1/insights/generate", secondPayload)
assertStatus(t, w, http.StatusOK)
require.Equal(t, int32(2), calls.Load())
require.Len(t, generatedPrompts, 2)
assert.Contains(t, generatedPrompts[1], `"agent":"claude"`)
assert.Contains(t, generatedPrompts[1], `"session_count":1`)
assert.Contains(t, generatedPrompts[1], claudePrompt)
assert.NotContains(t, generatedPrompts[1], codexPrompt)
}
func TestGenerateCannedInsight_CoachSummaryUsesFilterTimezone(t *testing.T) {
var generatedPrompt string
stubGen := func(
_ context.Context, _ string, prompt string, _ insight.LogFunc,
) (insight.Result, error) {
generatedPrompt = prompt
return insight.Result{
Agent: "claude",
Model: "test-model",
Content: `{
"schema_version":"llm_insight.v1",
"kind":"prompt_maturity_review",
"summary":"Prompt maturity evidence is scoped to the requested local day.",
"confidence":"medium",
"recommendations":[{
"title":"Keep local-day Coach scope aligned",
"rationale":"Coach prompt maturity uses the same local-date filter as the canned payload.",
"actions":["Generate recommendations from the selected local day"],
"evidence_refs":["coach:prompt_maturity"],
"impact":"medium",
"effort":"low"
}],
"risks":[],
"evidence_refs":["coach:prompt_maturity"]
}`,
}, nil
}
te := setupWithServerOpts(t, []server.Option{
server.WithGenerateStreamFunc(stubGen),
})
localDayPrompt := "Implement local-day filtering with acceptance criteria and verification steps"
previousLocalDayPrompt := "Implement previous-day filtering with acceptance criteria and verification steps"
te.seedSession(t, "local-day-match", "my-app", 4, func(s *db.Session) {
started := "2025-01-16T07:30:00Z"
ended := "2025-01-16T07:45:00Z"
s.StartedAt = &started
s.EndedAt = &ended
s.FirstMessage = &localDayPrompt
})
te.seedSession(t, "previous-local-day", "my-app", 4, func(s *db.Session) {
started := "2025-01-15T01:00:00Z"
ended := "2025-01-15T01:15:00Z"
s.StartedAt = &started
s.EndedAt = &ended
s.FirstMessage = &previousLocalDayPrompt
})
payload := `{"type":"llm_canned","kind":"prompt_maturity_review","date_from":"2025-01-15","date_to":"2025-01-15","project":"my-app","agent":"claude","llm_opt_in":true,"filters":{"timezone":"America/Los_Angeles","include_one_shot":false,"automated_scope":"human"}}`
w := te.post(t, "/api/v1/insights/generate", payload)
assertStatus(t, w, http.StatusOK)
assert.Contains(t, generatedPrompt, `"timezone":"America/Los_Angeles"`)
assert.Contains(t, generatedPrompt, `"session_count":1`)
assert.Contains(t, generatedPrompt, localDayPrompt)
assert.NotContains(t, generatedPrompt, previousLocalDayPrompt)
}
func TestGenerateCannedInsight_CoachSummaryUsesTopLevelTimezone(t *testing.T) {
var generatedPrompt string
stubGen := func(
_ context.Context, _ string, prompt string, _ insight.LogFunc,
) (insight.Result, error) {
generatedPrompt = prompt
return insight.Result{
Agent: "claude",
Model: "test-model",
Content: `{
"schema_version":"llm_insight.v1",
"kind":"prompt_maturity_review",
"summary":"Prompt maturity evidence is scoped to the requested local day.",
"confidence":"medium",
"recommendations":[{
"title":"Keep local-day Coach scope aligned",
"rationale":"Coach prompt maturity uses the same local-date filter as the canned payload.",
"actions":["Generate recommendations from the selected local day"],
"evidence_refs":["coach:prompt_maturity"],
"impact":"medium",
"effort":"low"
}],
"risks":[],
"evidence_refs":["coach:prompt_maturity"]
}`,
}, nil
}
te := setupWithServerOpts(t, []server.Option{
server.WithGenerateStreamFunc(stubGen),
})
localDayPrompt := "Implement local-day filtering with acceptance criteria and verification steps"
previousLocalDayPrompt := "Implement previous-day filtering with acceptance criteria and verification steps"
te.seedSession(t, "local-day-match", "my-app", 4, func(s *db.Session) {
started := "2025-01-16T07:30:00Z"
ended := "2025-01-16T07:45:00Z"
s.StartedAt = &started
s.EndedAt = &ended
s.FirstMessage = &localDayPrompt
})
te.seedSession(t, "previous-local-day", "my-app", 4, func(s *db.Session) {
started := "2025-01-15T01:00:00Z"
ended := "2025-01-15T01:15:00Z"
s.StartedAt = &started
s.EndedAt = &ended
s.FirstMessage = &previousLocalDayPrompt
})
payload := `{"type":"llm_canned","kind":"prompt_maturity_review","date_from":"2025-01-15","date_to":"2025-01-15","project":"my-app","agent":"claude","llm_opt_in":true,"timezone":"America/Los_Angeles"}`
w := te.post(t, "/api/v1/insights/generate", payload)
assertStatus(t, w, http.StatusOK)
assert.Contains(t, generatedPrompt, `"timezone":"America/Los_Angeles"`)
assert.Contains(t, generatedPrompt, `"session_count":1`)
assert.Contains(t, generatedPrompt, localDayPrompt)
assert.NotContains(t, generatedPrompt, previousLocalDayPrompt)
}
func TestGenerateCannedInsight_RejectsOversizedFocus(t *testing.T) {
te := setup(t)
longFocus := strings.Repeat("x", insight.MaxCannedFocusRunes+1)
body, err := json.Marshal(map[string]any{
"type": "llm_canned",
"kind": "prompt_maturity_review",
"date_from": "2025-01-15",
"date_to": "2025-01-15",
"agent": "claude",
"llm_opt_in": true,
"prompt": longFocus,
})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
w := te.post(t, "/api/v1/insights/generate", string(body))
assertStatus(t, w, http.StatusBadRequest)
assertBodyContains(t, w, "prompt is too long")
}
func TestGenerateCannedInsight_NormalizesFocusBeforeCaching(t *testing.T) {
var calls atomic.Int32
var generatedPrompt string
stubGen := func(
_ context.Context, _ string, prompt string, _ insight.LogFunc,
) (insight.Result, error) {
calls.Add(1)
generatedPrompt = prompt
return insight.Result{
Agent: "claude",
Model: "test-model",
Content: `{
"schema_version":"llm_insight.v1",
"kind":"prompt_maturity_review",
"summary":"Prompt starts are mostly healthy, with a few places to tighten acceptance criteria.",
"confidence":"medium",
"recommendations":[{
"title":"Add explicit verification asks",
"rationale":"The selected aggregate has scored sessions and outcome data, so verification wording can be improved without changing scores.",
"actions":["Add acceptance criteria to implementation prompts","Ask for validation commands in task handoffs"],
"evidence_refs":["aggregate:empty"],
"impact":"medium",
"effort":"low"
}],
"risks":[{
"title":"Evidence is aggregate-only",
"explanation":"This recommendation does not inspect raw transcript text.",
"evidence_refs":["aggregate:empty"]
}],
"evidence_refs":["aggregate:empty"]
}`,
}, nil
}
te := setupWithServerOpts(t, []server.Option{
server.WithGenerateStreamFunc(stubGen),
})
padded := strings.Repeat(" ", 5) + "Focus on retries" + strings.Repeat("\n", 4)
body, err := json.Marshal(map[string]any{
"type": "llm_canned",
"kind": "prompt_maturity_review",
"date_from": "2025-01-15",
"date_to": "2025-01-15",
"agent": "claude",
"llm_opt_in": true,
"prompt": padded,
})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
w := te.post(t, "/api/v1/insights/generate", string(body))
assertStatus(t, w, http.StatusOK)
events := parseSSE(w.Body.String())
if len(events) == 0 || events[len(events)-1].Event != "done" {
t.Fatalf("expected done event, got %s", w.Body.String())
}
var saved db.Insight
if err := json.Unmarshal([]byte(events[len(events)-1].Data), &saved); err != nil {
t.Fatalf("decode saved insight: %v", err)
}
if saved.Prompt == nil || *saved.Prompt != "Focus on retries" {
t.Fatalf("saved Prompt = %v, want trimmed focus", saved.Prompt)
}
if strings.Contains(generatedPrompt, padded) ||
!strings.Contains(generatedPrompt, "Focus on retries") {
t.Fatalf("generated prompt did not normalize focus: %q", generatedPrompt)
}
trimmedPayload := `{"type":"llm_canned","kind":"prompt_maturity_review","date_from":"2025-01-15","date_to":"2025-01-15","agent":"claude","llm_opt_in":true,"prompt":"Focus on retries"}`
w = te.post(t, "/api/v1/insights/generate", trimmedPayload)
assertStatus(t, w, http.StatusOK)
if calls.Load() != 1 {
t.Fatalf("generator calls = %d, want 1 after normalized cache hit", calls.Load())
}
assertBodyContains(t, w, "cache_hit")
}
func TestGenerateInsight_ErrorMessageStripsStderr(t *testing.T) {
stubGen := func(
_ context.Context, _, _ string,
) (insight.Result, error) {
return insight.Result{}, fmt.Errorf(
"claude CLI failed: exit status 1\nstderr: some debug output",
)
}
te := setupWithServerOpts(t, []server.Option{
server.WithGenerateFunc(stubGen),
})
w := te.post(t, "/api/v1/insights/generate",
`{"type":"daily_activity","date_from":"2025-01-15","date_to":"2025-01-15"}`)
assertStatus(t, w, http.StatusOK)
body := w.Body.String()
require.Contains(t, body, "claude CLI failed: exit status 1")
require.NotContains(t, body, "some debug output",
"expected stderr to be stripped from client message")
}
func TestGenerateInsight_ErrorMessageStripsRaw(t *testing.T) {
stubGen := func(
_ context.Context, _, _ string,
) (insight.Result, error) {
return insight.Result{}, fmt.Errorf(
"claude returned empty result\nraw: {\"type\":\"result\",\"result\":\"\"}",
)
}
te := setupWithServerOpts(t, []server.Option{
server.WithGenerateFunc(stubGen),
})
w := te.post(t, "/api/v1/insights/generate",
`{"type":"daily_activity","date_from":"2025-01-15","date_to":"2025-01-15"}`)
assertStatus(t, w, http.StatusOK)
body := w.Body.String()
require.Contains(t, body, "claude returned empty result")
require.NotContains(t, body, `"type":"result"`,
"expected raw payload to be stripped from client message")
}
func TestGenerateInsight_InitialStatusWriteFailureSkipsGeneration(t *testing.T) {
var called atomic.Bool
te := setupWithServerOpts(t, []server.Option{
server.WithGenerateStreamFunc(func(
_ context.Context, _ string, _ string, _ insight.LogFunc,
) (insight.Result, error) {
called.Store(true)
return insight.Result{Content: "should not run"}, nil
}),
})
req := httptest.NewRequest(
http.MethodPost, "/api/v1/insights/generate",
strings.NewReader(
`{"type":"daily_activity","date_from":"2025-01-15","date_to":"2025-01-15","agent":"claude"}`,
),
)
req.Header.Set("Content-Type", "application/json")
w := newFailFirstWriteRecorder()
te.handler.ServeHTTP(w, req)
require.False(t, called.Load(),
"generation should not run when initial SSE status write fails")
}
func TestGenerateInsight_StreamsLogs(t *testing.T) {
stubGen := func(
_ context.Context, _ string, _ string, onLog insight.LogFunc,
) (insight.Result, error) {
onLog(insight.LogEvent{
Stream: "stdout",
Line: `{"type":"system","status":"ready"}`,
})
onLog(insight.LogEvent{
Stream: "stderr",
Line: "rate limit warning",
})
return insight.Result{
Content: "# Insight",
Agent: "claude",
Model: "test-model",
}, nil
}
te := setupWithServerOpts(t, []server.Option{
server.WithGenerateStreamFunc(stubGen),
})
w := te.post(t, "/api/v1/insights/generate",
`{"type":"daily_activity","date_from":"2025-01-15","date_to":"2025-01-15","agent":"claude"}`)
assertStatus(t, w, http.StatusOK)
events := parseSSE(w.Body.String())
require.GreaterOrEqual(t, len(events), 4, "expected >=4 SSE events: %s", w.Body.String())
require.Equal(t, "status", events[0].Event, "first event")
require.Equal(t, "log", events[1].Event, "events: %#v", events)
require.Equal(t, "log", events[2].Event, "events: %#v", events)
require.Equal(t, "done", events[len(events)-1].Event, "last event")
var log1 insight.LogEvent
require.NoError(t, json.Unmarshal([]byte(events[1].Data), &log1))
require.Equal(t, "stdout", log1.Stream)
var log2 insight.LogEvent
require.NoError(t, json.Unmarshal([]byte(events[2].Data), &log2))
require.Equal(t, "stderr", log2.Stream)
}
type slowFlushRecorder struct {
*httptest.ResponseRecorder
delay time.Duration
mu sync.Mutex
}
func (f *slowFlushRecorder) Write(
b []byte,
) (int, error) {
time.Sleep(f.delay)
f.mu.Lock()
defer f.mu.Unlock()
return f.ResponseRecorder.Write(b)
}
func (f *slowFlushRecorder) Flush() {
f.mu.Lock()
defer f.mu.Unlock()
f.ResponseRecorder.Flush()
}
func (f *slowFlushRecorder) BodyString() string {
f.mu.Lock()
defer f.mu.Unlock()
return f.Body.String()
}
type slowLogRecorder struct {
*httptest.ResponseRecorder
delay time.Duration
mu sync.Mutex
}
func (f *slowLogRecorder) Write(
b []byte,
) (int, error) {
if strings.HasPrefix(string(b), "event: log\n") {
time.Sleep(f.delay)
}
f.mu.Lock()
defer f.mu.Unlock()
return f.ResponseRecorder.Write(b)
}
func (f *slowLogRecorder) Flush() {
f.mu.Lock()
defer f.mu.Unlock()
f.ResponseRecorder.Flush()
}
func (f *slowLogRecorder) BodyString() string {
f.mu.Lock()
defer f.mu.Unlock()
return f.Body.String()
}
type blockingLogRecorder struct {
*httptest.ResponseRecorder
release <-chan struct{}
mu sync.Mutex
}
func (f *blockingLogRecorder) Write(
b []byte,
) (int, error) {
if strings.HasPrefix(string(b), "event: log\n") {
<-f.release
}
f.mu.Lock()
defer f.mu.Unlock()
return f.ResponseRecorder.Write(b)
}
func (f *blockingLogRecorder) Flush() {
f.mu.Lock()
defer f.mu.Unlock()
f.ResponseRecorder.Flush()
}
func (f *blockingLogRecorder) BodyString() string {
f.mu.Lock()
defer f.mu.Unlock()
return f.Body.String()
}
type firstLogDelayRecorder struct {
*httptest.ResponseRecorder
delay time.Duration
once sync.Once
mu sync.Mutex
}
func (f *firstLogDelayRecorder) Write(
b []byte,
) (int, error) {
if strings.HasPrefix(string(b), "event: log\n") {
f.once.Do(func() {
time.Sleep(f.delay)
})
}
f.mu.Lock()
defer f.mu.Unlock()
return f.ResponseRecorder.Write(b)
}
func (f *firstLogDelayRecorder) Flush() {
f.mu.Lock()
defer f.mu.Unlock()
f.ResponseRecorder.Flush()
}
func (f *firstLogDelayRecorder) BodyString() string {
f.mu.Lock()
defer f.mu.Unlock()
return f.Body.String()
}
type deadlineAwareBlockingLogRecorder struct {
*httptest.ResponseRecorder
handlerReturned <-chan struct{}
postReturnWrites atomic.Int32
postReturnAttempted chan struct{}
deadlineUpdates chan struct{}
mu sync.Mutex
writeDeadline time.Time
}
func newDeadlineAwareBlockingLogRecorder(
handlerReturned <-chan struct{},
) *deadlineAwareBlockingLogRecorder {
return &deadlineAwareBlockingLogRecorder{
ResponseRecorder: httptest.NewRecorder(),
handlerReturned: handlerReturned,
postReturnAttempted: make(chan struct{}, 1),
deadlineUpdates: make(chan struct{}, 1),
}
}
func (f *deadlineAwareBlockingLogRecorder) SetWriteDeadline(t time.Time) error {
f.mu.Lock()
f.writeDeadline = t
f.mu.Unlock()
select {
case f.deadlineUpdates <- struct{}{}:
default:
}
return nil
}
func (f *deadlineAwareBlockingLogRecorder) Write(
b []byte,
) (int, error) {
if f.handlerReturned != nil {
select {
case <-f.handlerReturned:
f.postReturnWrites.Add(1)
select {
case f.postReturnAttempted <- struct{}{}:
default:
}
default:
}
}
if strings.HasPrefix(string(b), "event: log\n") {
for {
f.mu.Lock()
deadline := f.writeDeadline
f.mu.Unlock()
if !deadline.IsZero() && !deadline.After(time.Now()) {
return 0, os.ErrDeadlineExceeded
}
<-f.deadlineUpdates
}
}
f.mu.Lock()
defer f.mu.Unlock()
return f.ResponseRecorder.Write(b)
}
func (f *deadlineAwareBlockingLogRecorder) Flush() {
f.mu.Lock()
defer f.mu.Unlock()
f.ResponseRecorder.Flush()
}
func (f *deadlineAwareBlockingLogRecorder) PostReturnWrites() int32 {
return f.postReturnWrites.Load()
}
func (f *deadlineAwareBlockingLogRecorder) PostReturnAttempted() <-chan struct{} {
return f.postReturnAttempted
}
func (f *deadlineAwareBlockingLogRecorder) BodyString() string {
f.mu.Lock()
defer f.mu.Unlock()
return f.Body.String()
}
func TestGenerateInsight_LogDropSummaryAndCompletion(t *testing.T) {
stubGen := func(
_ context.Context, _ string, _ string, onLog insight.LogFunc,
) (insight.Result, error) {
for i := range 1000 {
onLog(insight.LogEvent{
Stream: "stdout",
Line: fmt.Sprintf("line-%d", i),
})
}
return insight.Result{
Content: "# Insight",
Agent: "claude",
}, nil
}
// The 1ms-per-write client drains the buffered log backlog far
// slower on Windows, where time.Sleep rounds up to ~15ms timer
// granularity. This test protects drop reporting plus completion,
// not drain-deadline behavior (covered by the LogDrainTimeout
// tests), so give the drain a generous ceiling to keep the done
// event deterministic across platforms.
te := setupWithServerOpts(t, []server.Option{
server.WithGenerateStreamFunc(stubGen),
server.WithInsightLogDrainTimeouts(30*time.Second, time.Second),
})
req := httptest.NewRequest(
http.MethodPost, "/api/v1/insights/generate",
strings.NewReader(
`{"type":"daily_activity","date_from":"2025-01-15","date_to":"2025-01-15","agent":"claude"}`,
),
)
req.Header.Set("Content-Type", "application/json")
w := &slowFlushRecorder{
ResponseRecorder: httptest.NewRecorder(),
delay: time.Millisecond,
}
done := make(chan struct{})
go func() {
defer close(done)
te.handler.ServeHTTP(w, req)
}()
select {
case <-done:
case <-time.After(40 * time.Second):
require.Fail(t, "timed out waiting for generate handler")
}
assertStatus(t, w.ResponseRecorder, http.StatusOK)
events := parseSSE(w.BodyString())
foundDone := false
foundDropSummary := false
for _, ev := range events {
if ev.Event == "done" {
foundDone = true
}
if ev.Event != "log" {
continue
}
var line insight.LogEvent
if json.Unmarshal([]byte(ev.Data), &line) != nil {
continue
}
if line.Stream == "stderr" &&
strings.Contains(line.Line, "dropped ") &&
strings.Contains(line.Line, "slow client") {
foundDropSummary = true
}
}
require.True(t, foundDropSummary,
"expected dropped-log summary event, got %d events", len(events))
require.True(t, foundDone, "expected done event")
}
func TestGenerateInsight_LogDrainTimeoutReturnsWithoutHang(t *testing.T) {
stubGen := func(
_ context.Context, _ string, _ string, onLog insight.LogFunc,
) (insight.Result, error) {
for i := range 10 {
onLog(insight.LogEvent{
Stream: "stdout",
Line: fmt.Sprintf("slow-line-%d", i),
})
}
return insight.Result{
Content: "# Insight",
Agent: "claude",
}, nil
}
te := setupWithServerOpts(t, []server.Option{
server.WithGenerateStreamFunc(stubGen),
fastInsightLogDrainTimeouts(),
})
req := httptest.NewRequest(
http.MethodPost, "/api/v1/insights/generate",
strings.NewReader(
`{"type":"daily_activity","date_from":"2025-01-15","date_to":"2025-01-15","agent":"claude"}`,
),
)
req.Header.Set("Content-Type", "application/json")
w := &slowLogRecorder{
ResponseRecorder: httptest.NewRecorder(),
delay: 35 * time.Millisecond,
}
started := time.Now()
done := make(chan struct{})
go func() {
defer close(done)
te.handler.ServeHTTP(w, req)
}()
select {
case <-done:
case <-time.After(12 * time.Second):
require.Fail(t, "timed out waiting for generate handler completion")
}
require.LessOrEqual(t, time.Since(started), 7*time.Second,
"handler should return within bounded timeout handling")
assertStatus(t, w.ResponseRecorder, http.StatusOK)
events := parseSSE(w.BodyString())
for _, ev := range events {
require.NotEqual(t, "done", ev.Event,
"did not expect done event when timeout path is triggered")
}
}
func TestGenerateInsight_LogDrainTimeoutReportsBufferedDrops(t *testing.T) {
stubGen := func(
_ context.Context, _ string, _ string, onLog insight.LogFunc,
) (insight.Result, error) {
for i := range 300 {
onLog(insight.LogEvent{
Stream: "stdout",
Line: fmt.Sprintf("slow-line-%d", i),
})
}
return insight.Result{
Content: "# Insight",
Agent: "claude",
}, nil
}
te := setupWithServerOpts(t, []server.Option{
server.WithGenerateStreamFunc(stubGen),
fastInsightLogDrainTimeouts(),
})
req := httptest.NewRequest(
http.MethodPost, "/api/v1/insights/generate",
strings.NewReader(
`{"type":"daily_activity","date_from":"2025-01-15","date_to":"2025-01-15","agent":"claude"}`,
),
)
req.Header.Set("Content-Type", "application/json")
w := &firstLogDelayRecorder{
ResponseRecorder: httptest.NewRecorder(),
delay: 35 * time.Millisecond,
}
done := make(chan struct{})
go func() {
defer close(done)
te.handler.ServeHTTP(w, req)
}()
select {
case <-done:
case <-time.After(10 * time.Second):
require.Fail(t, "timed out waiting for generate handler completion")
}
assertStatus(t, w.ResponseRecorder, http.StatusOK)
events := parseSSE(w.BodyString())
foundTimeoutError := false
foundDropSummary := false
for _, ev := range events {
require.NotEqual(t, "done", ev.Event,
"did not expect done event when timeout path is triggered")
if ev.Event == "error" &&
strings.Contains(ev.Data, "timed out before completion") {
foundTimeoutError = true
}
if ev.Event != "log" {
continue
}
var line insight.LogEvent
if json.Unmarshal([]byte(ev.Data), &line) != nil {
continue
}
if line.Stream != "stderr" ||
!strings.HasPrefix(line.Line, "dropped ") ||
!strings.Contains(line.Line, "log stream timeout") {
continue
}
parts := strings.SplitN(line.Line, " ", 3)
if len(parts) < 3 {
continue
}
dropped, err := strconv.Atoi(parts[1])
if err != nil {
continue
}
require.Positive(t, dropped,
"expected timeout drop summary to report at least one dropped log line (%q)", line.Line)
foundDropSummary = true
}
require.True(t, foundTimeoutError,
"expected timeout error event, got %d events", len(events))
require.True(t, foundDropSummary,
"expected timeout-aware drop summary, got %d events", len(events))
}
func TestGenerateInsight_LogDrainTimeoutBoundedWhenWriterStuck(t *testing.T) {
stubGen := func(
_ context.Context, _ string, _ string, onLog insight.LogFunc,
) (insight.Result, error) {
onLog(insight.LogEvent{Stream: "stdout", Line: "stuck-line"})
return insight.Result{Content: "# Insight", Agent: "claude"}, nil
}
te := setupWithServerOpts(t, []server.Option{
server.WithGenerateStreamFunc(stubGen),
fastInsightLogDrainTimeouts(),
})
req := httptest.NewRequest(
http.MethodPost, "/api/v1/insights/generate",
strings.NewReader(
`{"type":"daily_activity","date_from":"2025-01-15","date_to":"2025-01-15","agent":"claude"}`,
),
)
req.Header.Set("Content-Type", "application/json")
release := make(chan struct{})
w := &blockingLogRecorder{
ResponseRecorder: httptest.NewRecorder(),
release: release,
}
started := time.Now()
done := make(chan struct{})
go func() {
defer close(done)
te.handler.ServeHTTP(w, req)
}()
select {
case <-done:
case <-time.After(7 * time.Second):
require.Fail(t, "timed out waiting for bounded timeout behavior")
}
elapsed := time.Since(started)
require.LessOrEqual(t, elapsed, 6*time.Second,
"handler returned too slowly for stuck writer path")
close(release)
assertStatus(t, w.ResponseRecorder, http.StatusOK)
events := parseSSE(w.BodyString())
for _, ev := range events {
require.NotEqual(t, "done", ev.Event,
"did not expect done event on stuck writer timeout path")
}
}
func TestGenerateInsight_LogDrainTimeoutForceUnblocksAndNoPostReturnWrites(t *testing.T) {
stubGen := func(
_ context.Context, _ string, _ string, onLog insight.LogFunc,
) (insight.Result, error) {
onLog(insight.LogEvent{Stream: "stdout", Line: "force-unblock-line"})
return insight.Result{Content: "# Insight", Agent: "claude"}, nil
}
te := setupWithServerOpts(t, []server.Option{
server.WithGenerateStreamFunc(stubGen),
fastInsightLogDrainTimeouts(),
})
req := httptest.NewRequest(
http.MethodPost, "/api/v1/insights/generate",
strings.NewReader(
`{"type":"daily_activity","date_from":"2025-01-15","date_to":"2025-01-15","agent":"claude"}`,
),
)
req.Header.Set("Content-Type", "application/json")
handlerReturned := make(chan struct{})
w := newDeadlineAwareBlockingLogRecorder(handlerReturned)
done := make(chan struct{})
go func() {
defer close(done)
te.handler.ServeHTTP(w, req)
close(handlerReturned)
}()
select {
case <-done:
case <-time.After(8 * time.Second):
require.Fail(t, "timed out waiting for forced-unblock completion")
}
select {
case <-w.PostReturnAttempted():
require.Fail(t, "expected no writes after handler return")
case <-time.After(100 * time.Millisecond):
}
require.Zero(t, w.PostReturnWrites(), "expected no writes after handler return")
assertStatus(t, w.ResponseRecorder, http.StatusOK)
events := parseSSE(w.BodyString())
foundTimeoutError := false
for _, ev := range events {
require.NotEqual(t, "done", ev.Event,
"did not expect done event on forced-unblock timeout path")
if ev.Event == "error" &&
strings.Contains(ev.Data, "timed out before completion") {
foundTimeoutError = true
}
}
require.True(t, foundTimeoutError, "expected timeout error event")
}
func TestDeleteInsight_Found(t *testing.T) {
te := setup(t)
id := te.seedInsight(t, "daily_activity", "2025-01-15",
new("my-app"))
w := te.del(t, fmt.Sprintf("/api/v1/insights/%d", id))
assertStatus(t, w, http.StatusNoContent)
// Verify it's gone.
w = te.get(t, fmt.Sprintf("/api/v1/insights/%d", id))
assertStatus(t, w, http.StatusNotFound)
}
func TestInsight_ResourceErrors(t *testing.T) {
tests := []struct {
name string
method string
path string
status int
}{
{"Get_NotFound", http.MethodGet, "/api/v1/insights/99999", http.StatusNotFound},
{"Get_InvalidID", http.MethodGet, "/api/v1/insights/abc", http.StatusBadRequest},
{"Delete_NotFound", http.MethodDelete, "/api/v1/insights/99999", http.StatusNotFound},
{"Delete_InvalidID", http.MethodDelete, "/api/v1/insights/abc", http.StatusBadRequest},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
te := setup(t)
if tt.method == http.MethodGet {
w := te.get(t, tt.path)
assertStatus(t, w, tt.status)
} else {
w := te.del(t, tt.path)
assertStatus(t, w, tt.status)
}
})
}
}
// --- helpers ---
func (te *testEnv) seedInsight(
t *testing.T,
typ, date string,
project *string,
opts ...func(*db.Insight),
) int64 {
t.Helper()
insight := db.Insight{
Type: typ,
DateFrom: date,
DateTo: date,
Project: project,
Agent: "claude",
Content: "Test insight content",
}
for _, opt := range opts {
opt(&insight)
}
id, err := te.db.InsertInsight(insight)
require.NoError(t, err)
return id
}