468 lines
13 KiB
Go
468 lines
13 KiB
Go
package debug
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/agentctl/server/adapter/transport/acp"
|
|
"github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared"
|
|
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
|
v1 "github.com/kandev/kandev/pkg/api/v1"
|
|
)
|
|
|
|
// Protocol and file type constants.
|
|
const (
|
|
protocolACP = "acp"
|
|
filetypeUnknown = "unknown"
|
|
)
|
|
|
|
// DiscoveredFile represents a discovered fixture file.
|
|
type DiscoveredFile struct {
|
|
Path string `json:"path"`
|
|
Protocol string `json:"protocol"`
|
|
Agent string `json:"agent,omitempty"`
|
|
MessageCount int `json:"message_count"`
|
|
FileType string `json:"file_type"` // "raw", "normalized", or "testdata"
|
|
}
|
|
|
|
// NormalizedFixture represents a test fixture with its normalized payload.
|
|
type NormalizedFixture struct {
|
|
Protocol string `json:"protocol"`
|
|
ToolName string `json:"tool_name"`
|
|
ToolType string `json:"tool_type"`
|
|
Input map[string]any `json:"input"`
|
|
Payload *streams.NormalizedPayload `json:"payload"`
|
|
}
|
|
|
|
// normalizedEventEntry represents a single entry in a normalized events file.
|
|
type normalizedEventEntry struct {
|
|
Ts int64 `json:"ts"`
|
|
Event *streams.AgentEvent `json:"event"`
|
|
}
|
|
|
|
// fixtureInput represents a raw fixture from JSONL files.
|
|
type fixtureInput struct {
|
|
Input map[string]any `json:"input"`
|
|
Expected map[string]any `json:"expected"`
|
|
}
|
|
|
|
// normalizer defines the interface for protocol-specific normalizers.
|
|
type normalizer interface {
|
|
NormalizeToolCall(toolName string, args map[string]any) *streams.NormalizedPayload
|
|
}
|
|
|
|
// discoverFixtureFiles finds all fixture JSONL files in standard and custom locations.
|
|
func discoverFixtureFiles(baseDir string) ([]DiscoveredFile, error) {
|
|
var allMatches []string
|
|
|
|
// Pattern 1: Standard location - transport/*/testdata/*-messages.jsonl
|
|
pattern1 := filepath.Join(baseDir, "transport", "*", "testdata", "*-messages.jsonl")
|
|
if matches, err := filepath.Glob(pattern1); err == nil {
|
|
allMatches = append(allMatches, matches...)
|
|
}
|
|
|
|
// Pattern 2: Backend root - any *.jsonl files (for debug files)
|
|
backendRoot := filepath.Join(baseDir, "..", "..", "..", "..")
|
|
pattern2 := filepath.Join(backendRoot, "*.jsonl")
|
|
if matches, err := filepath.Glob(pattern2); err == nil {
|
|
allMatches = append(allMatches, matches...)
|
|
}
|
|
|
|
var files []DiscoveredFile
|
|
seen := make(map[string]bool)
|
|
|
|
for _, fullPath := range allMatches {
|
|
absPath, _ := filepath.Abs(fullPath)
|
|
if seen[absPath] {
|
|
continue
|
|
}
|
|
seen[absPath] = true
|
|
|
|
filename := filepath.Base(fullPath)
|
|
fileType, protocol, agent := parseDebugFilename(filename)
|
|
count := countLines(fullPath)
|
|
relPath, err := filepath.Rel(baseDir, fullPath)
|
|
if err != nil {
|
|
relPath = fullPath
|
|
}
|
|
files = append(files, DiscoveredFile{
|
|
Path: relPath,
|
|
Protocol: protocol,
|
|
Agent: agent,
|
|
MessageCount: count,
|
|
FileType: fileType,
|
|
})
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
// discoverNormalizedFiles finds all normalized event files (normalized-*.jsonl).
|
|
// It looks in two places: the legacy backend-root location (where older runs
|
|
// wrote files into the process CWD) and the per-session log dir used by the
|
|
// managed ACP writer (~/.kandev/logs/acp by default).
|
|
func discoverNormalizedFiles(baseDir string) ([]DiscoveredFile, error) {
|
|
dirs := []string{
|
|
filepath.Join(baseDir, "..", "..", "..", ".."), // backend root (legacy CWD)
|
|
shared.ACPLogDir(), // per-session managed writer dir
|
|
}
|
|
|
|
seen := make(map[string]bool)
|
|
var files []DiscoveredFile
|
|
for _, dir := range dirs {
|
|
matches, err := filepath.Glob(filepath.Join(dir, "normalized-*.jsonl"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, fullPath := range matches {
|
|
if seen[fullPath] {
|
|
continue
|
|
}
|
|
seen[fullPath] = true
|
|
files = append(files, normalizedFileEntry(baseDir, fullPath))
|
|
}
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
// normalizedFileEntry builds a DiscoveredFile for a normalized log file, with a
|
|
// path expressed relative to baseDir so handleReadNormalizedEvents can re-join
|
|
// it.
|
|
func normalizedFileEntry(baseDir, fullPath string) DiscoveredFile {
|
|
filename := filepath.Base(fullPath)
|
|
_, protocol, agent := parseDebugFilename(filename)
|
|
relPath, err := filepath.Rel(baseDir, fullPath)
|
|
if err != nil {
|
|
relPath = fullPath
|
|
}
|
|
return DiscoveredFile{
|
|
Path: relPath,
|
|
Protocol: protocol,
|
|
Agent: agent,
|
|
MessageCount: countLines(fullPath),
|
|
FileType: "normalized",
|
|
}
|
|
}
|
|
|
|
// readNormalizedEventsAsMessages reads a normalized events file and returns v1.Message objects.
|
|
func readNormalizedEventsAsMessages(filePath string) ([]*v1.Message, error) {
|
|
file, err := os.Open(filePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open %s: %w", filePath, err)
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
|
|
var messages []*v1.Message
|
|
scanner := bufio.NewScanner(file)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
|
|
index := 0
|
|
for scanner.Scan() {
|
|
var entry normalizedEventEntry
|
|
if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
|
|
continue // Skip malformed lines
|
|
}
|
|
if entry.Event != nil {
|
|
msg := agentEventToMessage(entry.Event, entry.Ts, index)
|
|
if msg != nil {
|
|
messages = append(messages, msg)
|
|
}
|
|
index++
|
|
}
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, fmt.Errorf("error reading %s: %w", filePath, err)
|
|
}
|
|
|
|
return messages, nil
|
|
}
|
|
|
|
// agentEventToMessage converts an AgentEvent to a v1.Message.
|
|
func agentEventToMessage(event *streams.AgentEvent, ts int64, index int) *v1.Message {
|
|
if event == nil {
|
|
return nil
|
|
}
|
|
|
|
// Map AgentEvent type to Message type
|
|
msgType := mapEventTypeToMessageType(event.Type)
|
|
if msgType == "" {
|
|
return nil // Skip events we don't want to display
|
|
}
|
|
|
|
// Build content based on event type
|
|
content := buildMessageContent(event)
|
|
|
|
// Build metadata
|
|
metadata := buildMessageMetadata(event)
|
|
|
|
// Use timestamp from entry, or current time as fallback
|
|
createdAt := time.Now()
|
|
if ts > 0 {
|
|
createdAt = time.UnixMilli(ts)
|
|
}
|
|
|
|
// Generate a unique ID for the message using index to ensure uniqueness
|
|
msgID := fmt.Sprintf("msg-%d-%d", ts, index)
|
|
|
|
return &v1.Message{
|
|
ID: msgID,
|
|
TaskSessionID: event.SessionID,
|
|
AuthorType: "agent",
|
|
Type: msgType,
|
|
Content: content,
|
|
Metadata: metadata,
|
|
CreatedAt: createdAt,
|
|
}
|
|
}
|
|
|
|
// mapEventTypeToMessageType converts AgentEvent type to Message type.
|
|
func mapEventTypeToMessageType(eventType string) string {
|
|
switch eventType {
|
|
case streams.EventTypeMessageChunk:
|
|
return "message"
|
|
case streams.EventTypeReasoning:
|
|
return "thinking"
|
|
case streams.EventTypeToolCall, streams.EventTypeToolUpdate:
|
|
return "tool_call"
|
|
case streams.EventTypePlan:
|
|
return "todo"
|
|
case streams.EventTypeError:
|
|
return "error"
|
|
case streams.EventTypePermissionRequest:
|
|
return "permission_request"
|
|
case streams.EventTypeComplete:
|
|
return "status"
|
|
case streams.EventTypeSessionStatus, streams.EventTypeContextWindow:
|
|
return "" // Skip these events
|
|
default:
|
|
return "message"
|
|
}
|
|
}
|
|
|
|
// buildMessageContent extracts the appropriate content from an AgentEvent.
|
|
func buildMessageContent(event *streams.AgentEvent) string {
|
|
switch event.Type {
|
|
case streams.EventTypeMessageChunk:
|
|
return event.Text
|
|
case streams.EventTypeReasoning:
|
|
return event.ReasoningText
|
|
case streams.EventTypeToolCall, streams.EventTypeToolUpdate:
|
|
if event.ToolTitle != "" {
|
|
return event.ToolTitle
|
|
}
|
|
return event.ToolName
|
|
case streams.EventTypeError:
|
|
return event.Error
|
|
case streams.EventTypeComplete:
|
|
return "Turn completed"
|
|
case streams.EventTypePermissionRequest:
|
|
return event.PermissionTitle
|
|
default:
|
|
return event.Text
|
|
}
|
|
}
|
|
|
|
// buildMessageMetadata builds the metadata map for a Message from an AgentEvent.
|
|
func buildMessageMetadata(event *streams.AgentEvent) map[string]any {
|
|
metadata := make(map[string]any)
|
|
|
|
// Add tool-related metadata
|
|
if event.ToolCallID != "" {
|
|
metadata["tool_call_id"] = event.ToolCallID
|
|
}
|
|
if event.ToolName != "" {
|
|
metadata["tool_name"] = event.ToolName
|
|
}
|
|
if event.ToolStatus != "" {
|
|
metadata["status"] = event.ToolStatus
|
|
}
|
|
if event.Diff != "" {
|
|
metadata["diff"] = event.Diff
|
|
}
|
|
|
|
// Add normalized payload if present
|
|
if event.NormalizedPayload != nil {
|
|
metadata["normalized"] = event.NormalizedPayload
|
|
}
|
|
|
|
// Add plan entries if present
|
|
if len(event.PlanEntries) > 0 {
|
|
metadata["plan_entries"] = event.PlanEntries
|
|
}
|
|
|
|
// Add permission request details
|
|
if event.PendingID != "" {
|
|
metadata["pending_id"] = event.PendingID
|
|
}
|
|
if len(event.PermissionOptions) > 0 {
|
|
metadata["permission_options"] = event.PermissionOptions
|
|
}
|
|
if event.ActionType != "" {
|
|
metadata["action_type"] = event.ActionType
|
|
}
|
|
if event.ActionDetails != nil {
|
|
metadata["action_details"] = event.ActionDetails
|
|
}
|
|
|
|
// Add context window info if present
|
|
if event.ContextWindowSize > 0 {
|
|
metadata["context_window_size"] = event.ContextWindowSize
|
|
metadata["context_window_used"] = event.ContextWindowUsed
|
|
metadata["context_window_remaining"] = event.ContextWindowRemaining
|
|
}
|
|
|
|
return metadata
|
|
}
|
|
|
|
// parseDebugFilename extracts file type, protocol, and agent from debug filenames.
|
|
// Patterns:
|
|
// - "raw-{protocol}-{agent}.jsonl" → ("raw", protocol, agent)
|
|
// - "normalized-{protocol}-{agent}.jsonl" → ("normalized", protocol, agent)
|
|
// - "{protocol}-messages.jsonl" → ("testdata", protocol, "")
|
|
// - "{protocol}-{agent}.jsonl" → ("unknown", protocol, agent) [legacy]
|
|
func parseDebugFilename(filename string) (fileType, protocol, agent string) {
|
|
name := strings.TrimSuffix(filename, ".jsonl")
|
|
|
|
// Check for raw- prefix
|
|
if rest, found := strings.CutPrefix(name, "raw-"); found {
|
|
protocol, agent = splitProtocolAgent(rest)
|
|
return "raw", protocol, agent
|
|
}
|
|
|
|
// Check for normalized- prefix
|
|
if rest, found := strings.CutPrefix(name, "normalized-"); found {
|
|
protocol, agent = splitProtocolAgent(rest)
|
|
return "normalized", protocol, agent
|
|
}
|
|
|
|
// Check for testdata pattern: {protocol}-messages
|
|
if protocol, found := strings.CutSuffix(name, "-messages"); found {
|
|
return "testdata", protocol, ""
|
|
}
|
|
|
|
// Legacy pattern: {protocol}-{agent}
|
|
protocol, agent = splitProtocolAgent(name)
|
|
if isKnownProtocol(protocol) {
|
|
return filetypeUnknown, protocol, agent
|
|
}
|
|
|
|
return filetypeUnknown, filetypeUnknown, ""
|
|
}
|
|
|
|
// splitProtocolAgent splits "{protocol}-{agent}" into protocol and agent.
|
|
// Per-session ACP debug files append "-{sessionID}" after the agent ID. ACP
|
|
// registry IDs consistently end in "-acp", so trim that session tail for
|
|
// display while leaving unknown legacy shapes unchanged.
|
|
func splitProtocolAgent(s string) (protocol, agent string) {
|
|
idx := strings.Index(s, "-")
|
|
if idx <= 0 {
|
|
return s, ""
|
|
}
|
|
protocol, agent = s[:idx], s[idx+1:]
|
|
if protocol == protocolACP {
|
|
agent = trimACPSessionTail(agent)
|
|
}
|
|
return protocol, agent
|
|
}
|
|
|
|
func trimACPSessionTail(agent string) string {
|
|
const acpMarker = "-acp-"
|
|
idx := strings.Index(agent, acpMarker)
|
|
if idx < 0 {
|
|
return agent
|
|
}
|
|
return agent[:idx+len("-acp")]
|
|
}
|
|
|
|
// parseProtocolFromFilename extracts protocol from filename patterns (legacy).
|
|
func parseProtocolFromFilename(filename string) string {
|
|
_, protocol, _ := parseDebugFilename(filename)
|
|
return protocol
|
|
}
|
|
|
|
// isKnownProtocol checks if the protocol is one of the known protocols.
|
|
func isKnownProtocol(protocol string) bool {
|
|
return protocol == protocolACP
|
|
}
|
|
|
|
// countLines counts the number of lines in a file.
|
|
func countLines(path string) int {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
|
|
count := 0
|
|
scanner := bufio.NewScanner(file)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
for scanner.Scan() {
|
|
count++
|
|
}
|
|
return count
|
|
}
|
|
|
|
// getNormalizerForProtocol returns the appropriate normalizer for the protocol.
|
|
func getNormalizerForProtocol(protocol, agentID string) normalizer {
|
|
switch protocol {
|
|
case protocolACP:
|
|
return acp.NewNormalizer(agentID)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// normalizeFixtureFile normalizes a specific fixture file.
|
|
func normalizeFixtureFile(filePath string) ([]NormalizedFixture, error) {
|
|
protocol := parseProtocolFromFilename(filepath.Base(filePath))
|
|
_, _, agent := parseDebugFilename(filepath.Base(filePath))
|
|
norm := getNormalizerForProtocol(protocol, agent)
|
|
if norm == nil {
|
|
return nil, fmt.Errorf("unknown protocol: %s", protocol)
|
|
}
|
|
|
|
file, err := os.Open(filePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open %s: %w", filePath, err)
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
|
|
var fixtures []NormalizedFixture
|
|
scanner := bufio.NewScanner(file)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
|
|
for scanner.Scan() {
|
|
var input fixtureInput
|
|
if err := json.Unmarshal(scanner.Bytes(), &input); err != nil {
|
|
continue // Skip malformed lines
|
|
}
|
|
|
|
fixture := NormalizedFixture{
|
|
Protocol: protocol,
|
|
Input: input.Input,
|
|
}
|
|
|
|
// Extract tool info and normalize based on protocol
|
|
if protocol == protocolACP {
|
|
args, _ := input.Input["args"].(map[string]any)
|
|
kind, _ := args["kind"].(string)
|
|
fixture.ToolName = kind
|
|
fixture.ToolType = acp.DetectToolOperationType(kind, args)
|
|
fixture.Payload = norm.NormalizeToolCall(kind, args)
|
|
}
|
|
|
|
fixtures = append(fixtures, fixture)
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, fmt.Errorf("error reading %s: %w", filePath, err)
|
|
}
|
|
|
|
return fixtures, nil
|
|
}
|