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
876 lines
22 KiB
Go
876 lines
22 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"time"
|
|
|
|
"go.kenn.io/agentsview/internal/db"
|
|
duckdbsync "go.kenn.io/agentsview/internal/duckdb"
|
|
)
|
|
|
|
type sessionSpec struct {
|
|
project string
|
|
suffix string
|
|
msgCount int
|
|
userMsgCount int
|
|
parentSessionID string
|
|
relationshipType string
|
|
terminationStatus string
|
|
}
|
|
|
|
var specs = []sessionSpec{
|
|
{"project-alpha", "small-2", 2, 2, "", "", ""},
|
|
{"project-alpha", "small-5", 5, 3, "", "", ""},
|
|
// One unclean session for e2e termination tests.
|
|
{"project-beta", "mixed-content-7", 7, 3, "", "",
|
|
"tool_call_pending"},
|
|
{"project-beta", "medium-8", 8, 4, "", "", ""},
|
|
{"project-beta", "medium-100", 100, 50, "", "", ""},
|
|
{"project-gamma", "large-200", 200, 100, "", "", ""},
|
|
{"project-gamma", "large-1500", 1500, 750, "", "", ""},
|
|
{"project-delta", "xlarge-5500", 5500, 2750, "", "", ""},
|
|
|
|
// Sub-agent and fork sessions: must NOT appear in session
|
|
// list, stats, or analytics summary counts.
|
|
{"project-alpha", "subagent-1", 12, 6,
|
|
"test-session-small-5", "subagent", ""},
|
|
{"project-alpha", "subagent-2", 8, 4,
|
|
"test-session-small-5", "subagent", ""},
|
|
{"project-beta", "fork-1", 15, 7,
|
|
"test-session-medium-8", "fork", ""},
|
|
|
|
// Empty session (0 messages): must also be excluded.
|
|
{"project-gamma", "empty-0", 0, 0, "", "", ""},
|
|
}
|
|
|
|
func main() {
|
|
out := flag.String("out", "", "output database path")
|
|
duckDBOut := flag.String("duckdb-out", "", "optional output DuckDB mirror path")
|
|
flag.Parse()
|
|
if *out == "" {
|
|
fmt.Fprintln(os.Stderr, "usage: testfixture -out <path>")
|
|
os.Exit(1)
|
|
}
|
|
|
|
if err := os.Remove(*out); err != nil &&
|
|
!errors.Is(err, os.ErrNotExist) {
|
|
log.Fatalf("removing existing db: %v", err)
|
|
}
|
|
|
|
database, err := db.Open(*out)
|
|
if err != nil {
|
|
log.Fatalf("opening db: %v", err)
|
|
}
|
|
defer database.Close()
|
|
|
|
// Seed model pricing for usage page e2e tests.
|
|
if err := database.UpsertModelPricing([]db.ModelPricing{
|
|
{
|
|
ModelPattern: "claude-sonnet-4-20250514",
|
|
InputPerMTok: 3.0,
|
|
OutputPerMTok: 15.0,
|
|
CacheCreationPerMTok: 3.75,
|
|
CacheReadPerMTok: 0.30,
|
|
},
|
|
{
|
|
ModelPattern: "claude-opus-4-20250514",
|
|
InputPerMTok: 15.0,
|
|
OutputPerMTok: 75.0,
|
|
CacheCreationPerMTok: 18.75,
|
|
CacheReadPerMTok: 1.50,
|
|
},
|
|
}); err != nil {
|
|
log.Fatalf("seeding model pricing: %v", err)
|
|
}
|
|
|
|
// Use a recent base date so fixture data stays within the
|
|
// default 1-year analytics window.
|
|
base := time.Now().UTC().AddDate(0, 0, -30).
|
|
Truncate(24 * time.Hour).Add(10 * time.Hour)
|
|
|
|
for i, spec := range specs {
|
|
if err := createSessionFixture(
|
|
database, spec, i, base,
|
|
); err != nil {
|
|
log.Fatalf("creating fixture %s: %v", spec.suffix, err)
|
|
}
|
|
fmt.Printf(
|
|
" test-session-%s: %d messages\n",
|
|
spec.suffix, spec.msgCount,
|
|
)
|
|
}
|
|
|
|
if err := createDurationShowcaseFixture(
|
|
database, base.Add(72*time.Hour),
|
|
); err != nil {
|
|
log.Fatalf("creating duration showcase: %v", err)
|
|
}
|
|
|
|
if err := createRecentEditsFixture(
|
|
database, base.Add(96*time.Hour),
|
|
); err != nil {
|
|
log.Fatalf("creating recent-edits fixture: %v", err)
|
|
}
|
|
|
|
fmt.Printf("Fixture DB written to %s\n", *out)
|
|
if *duckDBOut != "" {
|
|
if err := writeDuckDBMirror(database, *duckDBOut); err != nil {
|
|
log.Fatalf("writing DuckDB mirror: %v", err)
|
|
}
|
|
fmt.Printf("Fixture DuckDB mirror written to %s\n", *duckDBOut)
|
|
}
|
|
}
|
|
|
|
func writeDuckDBMirror(database *db.DB, path string) error {
|
|
if err := os.Remove(path); err != nil &&
|
|
!errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("removing existing DuckDB mirror: %w", err)
|
|
}
|
|
syncer, err := duckdbsync.New(path, database, "test-machine", duckdbsync.SyncOptions{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer syncer.Close()
|
|
|
|
ctx := context.Background()
|
|
if err := syncer.EnsureSchema(ctx); err != nil {
|
|
return err
|
|
}
|
|
result, err := syncer.Push(ctx, true, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if result.Errors > 0 {
|
|
return fmt.Errorf("DuckDB push had %d session error(s)", result.Errors)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func createSessionFixture(
|
|
database *db.DB, spec sessionSpec,
|
|
index int, base time.Time,
|
|
) error {
|
|
sessionID := fmt.Sprintf("test-session-%s", spec.suffix)
|
|
startedAt := base.Add(
|
|
time.Duration(index) * 24 * time.Hour,
|
|
)
|
|
endedAt := startedAt.Add(
|
|
time.Duration(spec.msgCount) * time.Minute,
|
|
)
|
|
|
|
sess := db.Session{
|
|
ID: sessionID,
|
|
Project: spec.project,
|
|
Machine: "test-machine",
|
|
Agent: "claude",
|
|
StartedAt: new(startedAt.Format(time.RFC3339Nano)),
|
|
EndedAt: new(endedAt.Format(time.RFC3339Nano)),
|
|
MessageCount: spec.msgCount,
|
|
UserMessageCount: spec.userMsgCount,
|
|
RelationshipType: spec.relationshipType,
|
|
}
|
|
if spec.parentSessionID != "" {
|
|
sess.ParentSessionID = new(spec.parentSessionID)
|
|
}
|
|
if spec.terminationStatus != "" {
|
|
sess.TerminationStatus = new(spec.terminationStatus)
|
|
}
|
|
if spec.msgCount > 0 {
|
|
sess.FirstMessage = new(
|
|
fmt.Sprintf("First message for %s", spec.project),
|
|
)
|
|
}
|
|
if err := database.UpsertSession(sess); err != nil {
|
|
return fmt.Errorf("upserting session: %w", err)
|
|
}
|
|
|
|
if spec.msgCount == 0 {
|
|
return nil
|
|
}
|
|
|
|
model := "claude-sonnet-4-20250514"
|
|
if index%3 == 1 {
|
|
model = "claude-opus-4-20250514"
|
|
}
|
|
|
|
var msgs []db.Message
|
|
if spec.suffix == "mixed-content-7" {
|
|
msgs = generateMixedContentMessages(
|
|
sessionID, startedAt, model,
|
|
)
|
|
} else {
|
|
msgs = generateMessages(
|
|
sessionID, spec.msgCount, startedAt, model,
|
|
)
|
|
}
|
|
if err := database.InsertMessages(msgs); err != nil {
|
|
return fmt.Errorf("inserting messages: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func generateMessages(
|
|
sessionID string, count int,
|
|
start time.Time, model string,
|
|
) []db.Message {
|
|
msgs := make([]db.Message, 0, count)
|
|
for i := range count {
|
|
role := "user"
|
|
if i%2 == 1 {
|
|
role = "assistant"
|
|
}
|
|
|
|
ts := start.Add(time.Duration(i) * time.Minute)
|
|
content := generateContent(role, i, count)
|
|
|
|
msg := db.Message{
|
|
SessionID: sessionID,
|
|
Ordinal: i,
|
|
Role: role,
|
|
Content: content,
|
|
Timestamp: ts.Format(time.RFC3339Nano),
|
|
HasThinking: role == "assistant" && i%5 == 0,
|
|
HasToolUse: role == "assistant" && i%3 == 0,
|
|
ContentLength: len(content),
|
|
}
|
|
|
|
if role == "assistant" && model != "" {
|
|
msg.Model = model
|
|
inputTok := 500 + (i*137)%2000
|
|
outputTok := 200 + (i*89)%800
|
|
cacheCr := 50 + (i*31)%200
|
|
cacheRd := 1000 + (i*53)%4000
|
|
msg.TokenUsage = json.RawMessage(
|
|
fmt.Sprintf(
|
|
`{"input_tokens":%d,`+
|
|
`"output_tokens":%d,`+
|
|
`"cache_creation_input_tokens":%d,`+
|
|
`"cache_read_input_tokens":%d}`,
|
|
inputTok, outputTok,
|
|
cacheCr, cacheRd,
|
|
),
|
|
)
|
|
}
|
|
|
|
msgs = append(msgs, msg)
|
|
}
|
|
return msgs
|
|
}
|
|
|
|
func generateMixedContentMessages(
|
|
sessionID string, start time.Time, model string,
|
|
) []db.Message {
|
|
type spec struct {
|
|
role string
|
|
content string
|
|
hasThinking bool
|
|
hasToolUse bool
|
|
}
|
|
|
|
specs := []spec{
|
|
{
|
|
role: "user",
|
|
content: "Help me read a file",
|
|
},
|
|
{
|
|
role: "assistant",
|
|
content: "[Thinking]\nLet me analyze..." +
|
|
"\n\nHere is my analysis.",
|
|
hasThinking: true,
|
|
},
|
|
{
|
|
role: "user",
|
|
content: "Now check the directory",
|
|
},
|
|
{
|
|
role: "assistant",
|
|
content: "[Read /src/main.ts]\nconst app = express();",
|
|
hasToolUse: true,
|
|
},
|
|
{
|
|
role: "assistant",
|
|
content: "[Bash]\nls -la /src",
|
|
hasToolUse: true,
|
|
},
|
|
{
|
|
role: "assistant",
|
|
content: "[Thinking]\nGemini-style reasoning\n" +
|
|
"[/Thinking]\n\n" +
|
|
"This is the visible response after thinking.",
|
|
hasThinking: true,
|
|
},
|
|
{
|
|
role: "user",
|
|
content: "Thanks",
|
|
},
|
|
}
|
|
|
|
msgs := make([]db.Message, 0, len(specs))
|
|
for i, s := range specs {
|
|
ts := start.Add(time.Duration(i) * time.Minute)
|
|
msg := db.Message{
|
|
SessionID: sessionID,
|
|
Ordinal: i,
|
|
Role: s.role,
|
|
Content: s.content,
|
|
Timestamp: ts.Format(time.RFC3339Nano),
|
|
HasThinking: s.hasThinking,
|
|
HasToolUse: s.hasToolUse,
|
|
ContentLength: len(s.content),
|
|
}
|
|
if s.role == "assistant" && model != "" {
|
|
msg.Model = model
|
|
inputTok := 400 + (i*113)%1500
|
|
outputTok := 150 + (i*67)%600
|
|
cacheCr := 30 + (i*23)%150
|
|
cacheRd := 800 + (i*41)%3000
|
|
msg.TokenUsage = json.RawMessage(
|
|
fmt.Sprintf(
|
|
`{"input_tokens":%d,`+
|
|
`"output_tokens":%d,`+
|
|
`"cache_creation_input_tokens":%d,`+
|
|
`"cache_read_input_tokens":%d}`,
|
|
inputTok, outputTok,
|
|
cacheCr, cacheRd,
|
|
),
|
|
)
|
|
}
|
|
msgs = append(msgs, msg)
|
|
}
|
|
return msgs
|
|
}
|
|
|
|
func generateContent(role string, idx, total int) string {
|
|
if role == "user" {
|
|
return fmt.Sprintf(
|
|
"User message %d of %d. "+
|
|
"Please help me with this task. "+
|
|
"I need to understand how the code works.",
|
|
idx, total,
|
|
)
|
|
}
|
|
return fmt.Sprintf(
|
|
"Assistant response %d of %d. "+
|
|
"Here is my analysis of the code. "+
|
|
"The implementation follows standard patterns "+
|
|
"and uses well-known libraries. "+
|
|
"Let me explain the key components.",
|
|
idx, total,
|
|
)
|
|
}
|
|
|
|
// createDurationShowcaseFixture builds a parent session that
|
|
// exercises every shape the Session Vital Signs UX renders:
|
|
// solo tool call, parallel turn with a sub-agent, and a slow
|
|
// solo Bash. Together with the linked sub-agent session it
|
|
// gives the right-panel timing query stable data to display.
|
|
//
|
|
// Timeline (relative to start):
|
|
//
|
|
// T+0:00 msg 0 user "investigate auth"
|
|
// T+0:02 msg 1 assistant solo Read (tool_use)
|
|
// T+0:04 msg 2 user tool_result for Read
|
|
// T+0:14 msg 3 assistant parallel: 2 Reads + 1 Task
|
|
// T+2:14 msg 4 user tool_results for all 3
|
|
// T+2:24 msg 5 assistant solo Bash
|
|
// T+2:52 msg 6 user tool_result for Bash
|
|
// T+2:55 session ends
|
|
//
|
|
// Per the timing spec, turn durations come from the gap to
|
|
// the next message; sub-agent calls take their duration from
|
|
// the linked child session's started_at/ended_at.
|
|
func createDurationShowcaseFixture(
|
|
database *db.DB, start time.Time,
|
|
) error {
|
|
const (
|
|
parentID = "test-session-duration-showcase"
|
|
subagentID = "test-session-duration-subagent-1"
|
|
project = "project-duration"
|
|
model = "claude-sonnet-4-20250514"
|
|
)
|
|
|
|
// Per-message timestamps anchored on `start`.
|
|
t0 := start
|
|
t1 := start.Add(2 * time.Second)
|
|
t2 := start.Add(4 * time.Second)
|
|
t3 := start.Add(14 * time.Second)
|
|
t4 := start.Add(2*time.Minute + 14*time.Second)
|
|
t5 := start.Add(2*time.Minute + 24*time.Second)
|
|
t6 := start.Add(2*time.Minute + 52*time.Second)
|
|
endParent := start.Add(2*time.Minute + 55*time.Second)
|
|
|
|
// Sub-agent runs alongside the parallel turn so its
|
|
// duration covers the full ~2 minutes of that turn.
|
|
subStart := t3
|
|
subEnd := t4
|
|
subAgentMessages := buildDurationSubagentMessages(
|
|
subagentID, subStart,
|
|
)
|
|
|
|
subSess := db.Session{
|
|
ID: subagentID,
|
|
Project: project,
|
|
Machine: "test-machine",
|
|
Agent: "claude",
|
|
StartedAt: new(subStart.Format(time.RFC3339Nano)),
|
|
EndedAt: new(subEnd.Format(time.RFC3339Nano)),
|
|
MessageCount: len(subAgentMessages),
|
|
UserMessageCount: countUserMessages(subAgentMessages),
|
|
ParentSessionID: new(parentID),
|
|
RelationshipType: "subagent",
|
|
FirstMessage: new(
|
|
"Inspect middleware request flow",
|
|
),
|
|
}
|
|
if err := database.UpsertSession(subSess); err != nil {
|
|
return fmt.Errorf(
|
|
"upserting subagent session: %w", err,
|
|
)
|
|
}
|
|
if err := database.InsertMessages(
|
|
subAgentMessages,
|
|
); err != nil {
|
|
return fmt.Errorf(
|
|
"inserting subagent messages: %w", err,
|
|
)
|
|
}
|
|
fmt.Printf(
|
|
" %s: %d messages (subagent)\n",
|
|
subagentID, len(subAgentMessages),
|
|
)
|
|
|
|
parentMessages := buildDurationShowcaseMessages(
|
|
parentID, subagentID, model,
|
|
t0, t1, t2, t3, t4, t5, t6,
|
|
)
|
|
|
|
parentSess := db.Session{
|
|
ID: parentID,
|
|
Project: project,
|
|
Machine: "test-machine",
|
|
Agent: "claude",
|
|
StartedAt: new(t0.Format(time.RFC3339Nano)),
|
|
EndedAt: new(endParent.Format(time.RFC3339Nano)),
|
|
MessageCount: len(parentMessages),
|
|
UserMessageCount: countUserMessages(parentMessages),
|
|
FirstMessage: new(
|
|
"Investigate auth middleware performance",
|
|
),
|
|
}
|
|
if err := database.UpsertSession(parentSess); err != nil {
|
|
return fmt.Errorf(
|
|
"upserting showcase session: %w", err,
|
|
)
|
|
}
|
|
if err := database.InsertMessages(
|
|
parentMessages,
|
|
); err != nil {
|
|
return fmt.Errorf(
|
|
"inserting showcase messages: %w", err,
|
|
)
|
|
}
|
|
fmt.Printf(
|
|
" %s: %d messages (duration showcase)\n",
|
|
parentID, len(parentMessages),
|
|
)
|
|
return nil
|
|
}
|
|
|
|
func countUserMessages(msgs []db.Message) int {
|
|
n := 0
|
|
for _, m := range msgs {
|
|
if m.Role == "user" {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// buildDurationShowcaseMessages assembles the parent session's
|
|
// messages and tool calls. ToolUseIDs are stable strings so the
|
|
// IDs surface intact in the tool_calls table; matching results
|
|
// would arrive in the next user message in a real transcript,
|
|
// but the result_content_length lives on the originating call
|
|
// row in this DB schema, so it's set there directly.
|
|
func buildDurationShowcaseMessages(
|
|
sessionID, subagentID, model string,
|
|
t0, t1, t2, t3, t4, t5, t6 time.Time,
|
|
) []db.Message {
|
|
const (
|
|
readSoloID = "tu_read_solo"
|
|
readPar1ID = "tu_read_par1"
|
|
readPar2ID = "tu_read_par2"
|
|
taskID = "tu_task_subagent"
|
|
bashSlowID = "tu_bash_slow"
|
|
)
|
|
|
|
tokenUsage := func(seed int) json.RawMessage {
|
|
input := 600 + seed*150
|
|
output := 220 + seed*80
|
|
cacheCr := 60 + seed*15
|
|
cacheRd := 1100 + seed*40
|
|
return json.RawMessage(fmt.Sprintf(
|
|
`{"input_tokens":%d,`+
|
|
`"output_tokens":%d,`+
|
|
`"cache_creation_input_tokens":%d,`+
|
|
`"cache_read_input_tokens":%d}`,
|
|
input, output, cacheCr, cacheRd,
|
|
))
|
|
}
|
|
|
|
msg0Content := "Take a look at the auth middleware " +
|
|
"and figure out where time is going."
|
|
msg1Content := "Reading the middleware so I can map " +
|
|
"the request flow."
|
|
msg2Content := "[tool_result]"
|
|
msg3Content := "Fanning out: two reads plus a sub-agent " +
|
|
"to dig into the session helpers."
|
|
msg4Content := "[tool_results]"
|
|
msg5Content := "Running the auth test suite to confirm " +
|
|
"the slow path matches what I read."
|
|
msg6Content := "[tool_result]"
|
|
|
|
return []db.Message{
|
|
{
|
|
SessionID: sessionID,
|
|
Ordinal: 0,
|
|
Role: "user",
|
|
Content: msg0Content,
|
|
Timestamp: t0.Format(time.RFC3339Nano),
|
|
ContentLength: len(msg0Content),
|
|
},
|
|
{
|
|
SessionID: sessionID,
|
|
Ordinal: 1,
|
|
Role: "assistant",
|
|
Content: msg1Content,
|
|
Timestamp: t1.Format(time.RFC3339Nano),
|
|
HasToolUse: true,
|
|
ContentLength: len(msg1Content),
|
|
Model: model,
|
|
TokenUsage: tokenUsage(1),
|
|
ToolCalls: []db.ToolCall{
|
|
{
|
|
ToolName: "Read",
|
|
Category: "Read",
|
|
ToolUseID: readSoloID,
|
|
InputJSON: `{"file_path":` +
|
|
`"/src/auth/middleware.go"}`,
|
|
ResultContentLength: 412,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
SessionID: sessionID,
|
|
Ordinal: 2,
|
|
Role: "user",
|
|
Content: msg2Content,
|
|
Timestamp: t2.Format(time.RFC3339Nano),
|
|
ContentLength: len(msg2Content),
|
|
},
|
|
{
|
|
SessionID: sessionID,
|
|
Ordinal: 3,
|
|
Role: "assistant",
|
|
Content: msg3Content,
|
|
Timestamp: t3.Format(time.RFC3339Nano),
|
|
HasToolUse: true,
|
|
ContentLength: len(msg3Content),
|
|
Model: model,
|
|
TokenUsage: tokenUsage(2),
|
|
ToolCalls: []db.ToolCall{
|
|
{
|
|
ToolName: "Read",
|
|
Category: "Read",
|
|
ToolUseID: readPar1ID,
|
|
InputJSON: `{"file_path":` +
|
|
`"/src/auth/session.go"}`,
|
|
ResultContentLength: 510,
|
|
},
|
|
{
|
|
ToolName: "Read",
|
|
Category: "Read",
|
|
ToolUseID: readPar2ID,
|
|
InputJSON: `{"file_path":` +
|
|
`"/src/auth/tokens.go"}`,
|
|
ResultContentLength: 388,
|
|
},
|
|
{
|
|
ToolName: "Task",
|
|
Category: "Task",
|
|
ToolUseID: taskID,
|
|
SubagentSessionID: subagentID,
|
|
InputJSON: `{"description":` +
|
|
`"audit session helpers",` +
|
|
`"prompt":"Walk through ` +
|
|
`session helpers and report ` +
|
|
`anything that touches the DB ` +
|
|
`on the hot path."}`,
|
|
ResultContentLength: 1280,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
SessionID: sessionID,
|
|
Ordinal: 4,
|
|
Role: "user",
|
|
Content: msg4Content,
|
|
Timestamp: t4.Format(time.RFC3339Nano),
|
|
ContentLength: len(msg4Content),
|
|
},
|
|
{
|
|
SessionID: sessionID,
|
|
Ordinal: 5,
|
|
Role: "assistant",
|
|
Content: msg5Content,
|
|
Timestamp: t5.Format(time.RFC3339Nano),
|
|
HasToolUse: true,
|
|
ContentLength: len(msg5Content),
|
|
Model: model,
|
|
TokenUsage: tokenUsage(3),
|
|
ToolCalls: []db.ToolCall{
|
|
{
|
|
ToolName: "Bash",
|
|
Category: "Bash",
|
|
ToolUseID: bashSlowID,
|
|
InputJSON: `{"command":` +
|
|
`"go test ./... -count=10",` +
|
|
`"description":"rerun ` +
|
|
`auth tests"}`,
|
|
ResultContentLength: 940,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
SessionID: sessionID,
|
|
Ordinal: 6,
|
|
Role: "user",
|
|
Content: msg6Content,
|
|
Timestamp: t6.Format(time.RFC3339Nano),
|
|
ContentLength: len(msg6Content),
|
|
},
|
|
}
|
|
}
|
|
|
|
// buildDurationSubagentMessages builds a small but realistic
|
|
// sub-agent transcript: a Read followed by a Grep, then a
|
|
// final report. The exact gaps don't drive the parent timing
|
|
// UI (that uses the child session's start/end window), so we
|
|
// keep the messages evenly spaced for readability.
|
|
func buildDurationSubagentMessages(
|
|
sessionID string, start time.Time,
|
|
) []db.Message {
|
|
const model = "claude-sonnet-4-20250514"
|
|
|
|
tokenUsage := func(seed int) json.RawMessage {
|
|
input := 350 + seed*90
|
|
output := 180 + seed*55
|
|
cacheCr := 40 + seed*12
|
|
cacheRd := 700 + seed*30
|
|
return json.RawMessage(fmt.Sprintf(
|
|
`{"input_tokens":%d,`+
|
|
`"output_tokens":%d,`+
|
|
`"cache_creation_input_tokens":%d,`+
|
|
`"cache_read_input_tokens":%d}`,
|
|
input, output, cacheCr, cacheRd,
|
|
))
|
|
}
|
|
|
|
t0 := start
|
|
t1 := start.Add(20 * time.Second)
|
|
t2 := start.Add(40 * time.Second)
|
|
t3 := start.Add(70 * time.Second)
|
|
t4 := start.Add(95 * time.Second)
|
|
t5 := start.Add(115 * time.Second)
|
|
|
|
msg0 := "Audit the session helpers and report any " +
|
|
"hot-path DB calls."
|
|
msg1 := "Reading the helper module first."
|
|
msg2 := "[tool_result]"
|
|
msg3 := "Now scanning the cache layer for sync calls."
|
|
msg4 := "[tool_result]"
|
|
msg5 := "Two helpers issue a synchronous DB read on " +
|
|
"every request. Report attached."
|
|
|
|
return []db.Message{
|
|
{
|
|
SessionID: sessionID,
|
|
Ordinal: 0,
|
|
Role: "user",
|
|
Content: msg0,
|
|
Timestamp: t0.Format(time.RFC3339Nano),
|
|
ContentLength: len(msg0),
|
|
},
|
|
{
|
|
SessionID: sessionID,
|
|
Ordinal: 1,
|
|
Role: "assistant",
|
|
Content: msg1,
|
|
Timestamp: t1.Format(time.RFC3339Nano),
|
|
HasToolUse: true,
|
|
ContentLength: len(msg1),
|
|
Model: model,
|
|
TokenUsage: tokenUsage(1),
|
|
ToolCalls: []db.ToolCall{
|
|
{
|
|
ToolName: "Read",
|
|
Category: "Read",
|
|
ToolUseID: "tu_sub_read1",
|
|
InputJSON: `{"file_path":` +
|
|
`"/src/auth/helpers.go"}`,
|
|
ResultContentLength: 320,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
SessionID: sessionID,
|
|
Ordinal: 2,
|
|
Role: "user",
|
|
Content: msg2,
|
|
Timestamp: t2.Format(time.RFC3339Nano),
|
|
ContentLength: len(msg2),
|
|
},
|
|
{
|
|
SessionID: sessionID,
|
|
Ordinal: 3,
|
|
Role: "assistant",
|
|
Content: msg3,
|
|
Timestamp: t3.Format(time.RFC3339Nano),
|
|
HasToolUse: true,
|
|
ContentLength: len(msg3),
|
|
Model: model,
|
|
TokenUsage: tokenUsage(2),
|
|
ToolCalls: []db.ToolCall{
|
|
{
|
|
ToolName: "Grep",
|
|
Category: "Grep",
|
|
ToolUseID: "tu_sub_grep1",
|
|
InputJSON: `{"pattern":` +
|
|
`"db.Query","path":` +
|
|
`"/src/auth"}`,
|
|
ResultContentLength: 210,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
SessionID: sessionID,
|
|
Ordinal: 4,
|
|
Role: "user",
|
|
Content: msg4,
|
|
Timestamp: t4.Format(time.RFC3339Nano),
|
|
ContentLength: len(msg4),
|
|
},
|
|
{
|
|
SessionID: sessionID,
|
|
Ordinal: 5,
|
|
Role: "assistant",
|
|
Content: msg5,
|
|
Timestamp: t5.Format(time.RFC3339Nano),
|
|
ContentLength: len(msg5),
|
|
Model: model,
|
|
TokenUsage: tokenUsage(3),
|
|
},
|
|
}
|
|
}
|
|
|
|
// createRecentEditsFixture seeds one session that carries a real
|
|
// Edit tool call with FilePath set. The feed at GET /api/v1/recent-edits
|
|
// filters on category IN ('Edit','Write') AND file_path IS NOT NULL, so
|
|
// FilePath must be set directly on the ToolCall — InputJSON alone does
|
|
// not propagate to the file_path column.
|
|
func createRecentEditsFixture(
|
|
database *db.DB, start time.Time,
|
|
) error {
|
|
const (
|
|
sessionID = "test-session-recent-edits"
|
|
project = "project-edits"
|
|
model = "claude-sonnet-4-20250514"
|
|
editPath = "/src/server/handler.go"
|
|
)
|
|
|
|
endedAt := start.Add(5 * time.Minute)
|
|
firstMsg := "Add request logging to the HTTP handler."
|
|
|
|
sess := db.Session{
|
|
ID: sessionID,
|
|
Project: project,
|
|
Machine: "test-machine",
|
|
Agent: "claude",
|
|
StartedAt: new(start.Format(time.RFC3339Nano)),
|
|
EndedAt: new(endedAt.Format(time.RFC3339Nano)),
|
|
MessageCount: 3,
|
|
UserMessageCount: 1,
|
|
FirstMessage: new(firstMsg),
|
|
}
|
|
if err := database.UpsertSession(sess); err != nil {
|
|
return fmt.Errorf("upserting recent-edits session: %w", err)
|
|
}
|
|
|
|
msgs := []db.Message{
|
|
{
|
|
SessionID: sessionID,
|
|
Ordinal: 0,
|
|
Role: "user",
|
|
Content: firstMsg,
|
|
Timestamp: start.Format(time.RFC3339Nano),
|
|
ContentLength: len(firstMsg),
|
|
},
|
|
{
|
|
SessionID: sessionID,
|
|
Ordinal: 1,
|
|
Role: "assistant",
|
|
HasToolUse: true,
|
|
Content: "[Edit /src/server/handler.go]",
|
|
Timestamp: start.Add(1 * time.Minute).
|
|
Format(time.RFC3339Nano),
|
|
ContentLength: 29,
|
|
Model: model,
|
|
TokenUsage: json.RawMessage(
|
|
`{"input_tokens":800,` +
|
|
`"output_tokens":320,` +
|
|
`"cache_creation_input_tokens":80,` +
|
|
`"cache_read_input_tokens":1500}`,
|
|
),
|
|
ToolCalls: []db.ToolCall{
|
|
{
|
|
ToolName: "Edit",
|
|
Category: "Edit",
|
|
ToolUseID: "tu_edit_handler",
|
|
// FilePath must be set directly; InputJSON
|
|
// alone is not propagated to the DB column.
|
|
FilePath: editPath,
|
|
InputJSON: `{"file_path":"` + editPath + `",` +
|
|
`"old_string":"func handler(",` +
|
|
`"new_string":"func handler(// + logging\n"}`,
|
|
ResultContentLength: 0,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
SessionID: sessionID,
|
|
Ordinal: 2,
|
|
Role: "user",
|
|
Content: "[tool_result]",
|
|
Timestamp: start.Add(2 * time.Minute).
|
|
Format(time.RFC3339Nano),
|
|
ContentLength: 13,
|
|
},
|
|
}
|
|
if err := database.InsertMessages(msgs); err != nil {
|
|
return fmt.Errorf(
|
|
"inserting recent-edits messages: %w", err,
|
|
)
|
|
}
|
|
fmt.Printf(
|
|
" %s: %d messages (recent-edits fixture)\n",
|
|
sessionID, len(msgs),
|
|
)
|
|
return nil
|
|
}
|