diff --git a/README.md b/README.md index a09c083ee..f5185397f 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,7 @@ agentsview auto-discovers sessions from all of these: | Pi | `~/.pi/agent/sessions/` | | Qwen Code | `~/.qwen/projects/` | | OpenClaw | `~/.openclaw/agents/` | +| QClaw | `~/.qclaw/agents/` | | Kimi | `~/.kimi/sessions/` | | Kiro CLI | `~/.kiro/sessions/cli/`, `~/.local/share/kiro-cli/` | | Kiro IDE | `~/Library/Application Support/Kiro/` (macOS) | diff --git a/cmd/agentsview/cli.go b/cmd/agentsview/cli.go index d3145c640..c0897404b 100644 --- a/cmd/agentsview/cli.go +++ b/cmd/agentsview/cli.go @@ -432,6 +432,7 @@ func writeRootHelp(w io.Writer, root *cobra.Command) { fmt.Fprintln(w, " IFLOW_DIR iFlow projects directory") fmt.Fprintln(w, " AMP_DIR Amp threads directory") fmt.Fprintln(w, " QWEN_PROJECTS_DIR Qwen Code projects directory") + fmt.Fprintln(w, " QCLAW_DIR QClaw agents directory") fmt.Fprintln(w, " PIEBALD_DIR Piebald data directory") fmt.Fprintln(w, " AGENTSVIEW_DATA_DIR Data directory (database, config)") fmt.Fprintln(w, " AGENTSVIEW_PG_URL PostgreSQL connection URL for sync") diff --git a/frontend/src/lib/components/settings/AgentDirSettings.svelte b/frontend/src/lib/components/settings/AgentDirSettings.svelte index 32d3b74de..22cf60da8 100644 --- a/frontend/src/lib/components/settings/AgentDirSettings.svelte +++ b/frontend/src/lib/components/settings/AgentDirSettings.svelte @@ -16,6 +16,7 @@ pi: "Pi", qwen: "Qwen Code", openclaw: "OpenClaw", + qclaw: "QClaw", kimi: "Kimi", piebald: "Piebald", antigravity: "Antigravity", diff --git a/frontend/src/lib/utils/agents.test.ts b/frontend/src/lib/utils/agents.test.ts index 870e463a1..9b393a339 100644 --- a/frontend/src/lib/utils/agents.test.ts +++ b/frontend/src/lib/utils/agents.test.ts @@ -22,6 +22,7 @@ describe("KNOWN_AGENTS", () => { "pi", "qwen", "openclaw", + "qclaw", "iflow", "kimi", "claude-ai", @@ -80,6 +81,9 @@ describe("agentColor", () => { expect(agentColor("vscode-copilot")).toBe( "var(--accent-teal)", ); + expect(agentColor("qclaw")).toBe( + "var(--accent-orange)", + ); expect(agentColor("piebald")).toBe( "var(--accent-orange)", ); @@ -100,6 +104,7 @@ describe("agentLabel", () => { ); expect(agentLabel("openhands")).toBe("OpenHands"); expect(agentLabel("openclaw")).toBe("OpenClaw"); + expect(agentLabel("qclaw")).toBe("QClaw"); expect(agentLabel("iflow")).toBe("iFlow"); expect(agentLabel("piebald")).toBe("Piebald"); expect(agentLabel("qwen")).toBe("Qwen Code"); diff --git a/frontend/src/lib/utils/agents.ts b/frontend/src/lib/utils/agents.ts index fca150844..418868b63 100644 --- a/frontend/src/lib/utils/agents.ts +++ b/frontend/src/lib/utils/agents.ts @@ -26,6 +26,11 @@ export const KNOWN_AGENTS: readonly AgentMeta[] = [ color: "var(--accent-orange)", label: "OpenClaw", }, + { + name: "qclaw", + color: "var(--accent-orange)", + label: "QClaw", + }, { name: "iflow", color: "var(--accent-sky)", label: "iFlow" }, { name: "kimi", color: "var(--accent-pink)", label: "Kimi" }, { name: "claude-ai", color: "var(--accent-violet)", label: "Claude.ai" }, diff --git a/internal/parser/discovery.go b/internal/parser/discovery.go index 62cb18cba..1f558a2a0 100644 --- a/internal/parser/discovery.go +++ b/internal/parser/discovery.go @@ -1797,6 +1797,210 @@ func FindOpenClawSourceFile(agentsDir, rawID string) string { return "" } +// DiscoverQClawSessions finds all JSONL session files under the +// QClaw agents directory. The directory structure is: +// //sessions/.jsonl +// +// When both active (.jsonl) and archived (.jsonl.deleted.*, +// .jsonl.full.bak, .jsonl.reset.*) files exist for the same +// logical session ID, only one file is returned per session: +// the active .jsonl file is preferred; if absent, the newest +// archived file (by filename, which embeds a timestamp, or by +// file mtime as a fallback) is chosen. +func DiscoverQClawSessions(agentsDir string) []DiscoveredFile { + if agentsDir == "" { + return nil + } + + // Each agent has its own subdirectory. + agentEntries, err := os.ReadDir(agentsDir) + if err != nil { + return nil + } + + var files []DiscoveredFile + for _, agentEntry := range agentEntries { + if !isDirOrSymlink(agentEntry, agentsDir) { + continue + } + if !IsValidSessionID(agentEntry.Name()) { + continue + } + + sessionsDir := filepath.Join( + agentsDir, agentEntry.Name(), "sessions", + ) + entries, err := os.ReadDir(sessionsDir) + if err != nil { + continue + } + + // Deduplicate by logical session ID within each + // agent's sessions directory. + best := make(map[string]os.DirEntry) // sessionID -> best entry + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !IsQClawSessionFile(name) { + continue + } + sid := QClawSessionID(name) + prev, exists := best[sid] + if !exists { + best[sid] = entry + continue + } + best[sid] = bestQClawEntry(prev, entry) + } + + for _, entry := range best { + files = append(files, DiscoveredFile{ + Path: filepath.Join( + sessionsDir, entry.Name(), + ), + Agent: AgentQClaw, + }) + } + } + + sort.Slice(files, func(i, j int) bool { + return files[i].Path < files[j].Path + }) + return files +} + +// bestQClawEntry returns the preferred entry when two files +// share the same logical session ID. Active .jsonl files always +// win. Among archived files, the one with the newest embedded +// timestamp wins; when no timestamp is parseable, mtime is used. +func bestQClawEntry(a, b os.DirEntry) os.DirEntry { + aActive := strings.HasSuffix(a.Name(), ".jsonl") + bActive := strings.HasSuffix(b.Name(), ".jsonl") + if aActive && !bActive { + return a + } + if bActive && !aActive { + return b + } + aTime := qClawArchiveTime(a) + bTime := qClawArchiveTime(b) + if !aTime.IsZero() && !bTime.IsZero() { + if bTime.After(aTime) { + return b + } + return a + } + if !aTime.IsZero() { + return a + } + if !bTime.IsZero() { + return b + } + ai, errA := a.Info() + bi, errB := b.Info() + if errA == nil && errB == nil && + bi.ModTime().After(ai.ModTime()) { + return b + } + return a +} + +// qClawArchiveTime extracts the timestamp embedded in an +// QClaw archive filename suffix (e.g. ".deleted.2026-02-19T08-59-24.951Z"). +func qClawArchiveTime(e os.DirEntry) time.Time { + name := e.Name() + idx := strings.Index(name, ".jsonl.") + if idx <= 0 { + return time.Time{} + } + suffix := name[idx+len(".jsonl."):] + // suffix is e.g. "deleted.2026-02-19T08-59-24.951Z" or "full.bak" + _, tsStr, ok := strings.Cut(suffix, ".") + if !ok { + return time.Time{} + } + // Convert dash-separated time back to colons: 08-59-24 → 08:59:24 + if tIdx := strings.IndexByte(tsStr, 'T'); tIdx >= 0 { + datePart := tsStr[:tIdx+1] + timePart := tsStr[tIdx+1:] + // Only replace first two dashes in time portion (hh-mm-ss) + timePart = strings.Replace(timePart, "-", ":", 1) + timePart = strings.Replace(timePart, "-", ":", 1) + tsStr = datePart + timePart + } + t, err := time.Parse("2006-01-02T15:04:05.000Z", tsStr) + if err != nil { + t, err = time.Parse("2006-01-02T15:04:05Z", tsStr) + } + if err != nil { + return time.Time{} + } + return t +} + +// FindQClawSourceFile locates a QClaw session file by its +// raw ID (without the "qclaw:" prefix). The raw ID has the +// format ":", which directly maps to the +// file at //sessions/.jsonl. +// +// If the active .jsonl file does not exist (archive-only session), +// the sessions directory is scanned for any archived file whose +// logical session ID matches. When multiple archived files match, +// the best candidate (newest by filename timestamp) is returned. +func FindQClawSourceFile(agentsDir, rawID string) string { + if agentsDir == "" { + return "" + } + + // Split "agentId:sessionId" into its two parts. + agentID, sessionID, ok := strings.Cut(rawID, ":") + if !ok || !IsValidSessionID(agentID) || + !IsValidSessionID(sessionID) { + return "" + } + + sessionsDir := filepath.Join( + agentsDir, agentID, "sessions", + ) + + // Fast path: the active .jsonl file exists. + active := filepath.Join(sessionsDir, sessionID+".jsonl") + if _, err := os.Stat(active); err == nil { + return active + } + + // Slow path: scan for archived files matching this session. + entries, err := os.ReadDir(sessionsDir) + if err != nil { + return "" + } + + var best os.DirEntry + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !IsQClawSessionFile(name) { + continue + } + if QClawSessionID(name) != sessionID { + continue + } + if best == nil { + best = entry + continue + } + best = bestQClawEntry(best, entry) + } + if best != nil { + return filepath.Join(sessionsDir, best.Name()) + } + return "" +} + // DiscoverIflowProjects finds all project directories under the // iFlow projects dir and returns their JSONL session files. // iFlow stores sessions in .iflow/projects//session-.jsonl diff --git a/internal/parser/qclaw.go b/internal/parser/qclaw.go new file mode 100644 index 000000000..350bd9f06 --- /dev/null +++ b/internal/parser/qclaw.go @@ -0,0 +1,396 @@ +package parser + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/tidwall/gjson" +) + +// ParseQClawSession parses a QClaw JSONL session file. +// QClaw stores messages in a JSONL format with a session header +// line, message entries, compaction summaries, and metadata events. +func ParseQClawSession( + path, project, machine string, +) (*ParsedSession, []ParsedMessage, error) { + info, err := os.Stat(path) + if err != nil { + return nil, nil, fmt.Errorf("stat %s: %w", path, err) + } + + f, err := os.Open(path) + if err != nil { + return nil, nil, fmt.Errorf("open %s: %w", path, err) + } + defer f.Close() + + lr := newLineReader(f, maxLineSize) + var ( + messages []ParsedMessage + startedAt time.Time + endedAt time.Time + ordinal int + realUserCount int + firstMsg string + sessionID string + cwd string + ) + + for { + line, ok := lr.next() + if !ok { + break + } + if !gjson.Valid(line) { + continue + } + + entryType := gjson.Get(line, "type").Str + + if ts := parseQClawTimestamp(line); !ts.IsZero() { + if startedAt.IsZero() || ts.Before(startedAt) { + startedAt = ts + } + if ts.After(endedAt) { + endedAt = ts + } + } + + switch entryType { + case "session": + if sessionID == "" { + sessionID = gjson.Get(line, "id").Str + } + if cwd == "" { + cwd = gjson.Get(line, "cwd").Str + } + continue + + case "model_change", "thinking_level_change", "custom", + "compaction": + continue + + case "message": + default: + continue + } + + msg := gjson.Get(line, "message") + if !msg.Exists() { + continue + } + + role := msg.Get("role").Str + ts := parseTimestamp(msg.Get("timestamp").Str) + if ts.IsZero() { + ts = parseTimestamp(gjson.Get(line, "timestamp").Str) + } + + switch role { + case "user": + content := msg.Get("content") + text, thinkingText, hasThinking, hasToolUse, tcs, trs := + ExtractTextContent(content) + text = strings.TrimSpace(text) + if text == "" && len(tcs) == 0 && len(trs) == 0 { + continue + } + + if firstMsg == "" && text != "" { + firstMsg = truncate( + strings.ReplaceAll( + stripQClawDatePrefix(text), + "\n", " ", + ), 300, + ) + } + + messages = append(messages, ParsedMessage{ + Ordinal: ordinal, + Role: RoleUser, + Content: text, + Timestamp: ts, + HasThinking: hasThinking, + ThinkingText: thinkingText, + HasToolUse: hasToolUse, + ContentLength: len(text), + ToolCalls: tcs, + ToolResults: trs, + }) + ordinal++ + realUserCount++ + + case "assistant": + content := msg.Get("content") + text, thinkingText, hasThinking, hasToolUse, tcs, trs := + ExtractTextContent(content) + text = strings.TrimSpace(text) + if text == "" && len(tcs) == 0 && len(trs) == 0 { + continue + } + + pm := ParsedMessage{ + Ordinal: ordinal, + Role: RoleAssistant, + Content: text, + Timestamp: ts, + HasThinking: hasThinking, + ThinkingText: thinkingText, + HasToolUse: hasToolUse, + ContentLength: len(text), + ToolCalls: tcs, + ToolResults: trs, + tokenPresenceKnown: true, + } + applyQClawAssistantUsage(&pm, msg) + messages = append(messages, pm) + ordinal++ + + case "toolResult": + // Tool results in QClaw are separate messages. + // Emit as a user message with empty Content so + // pairAndFilter removes it after pairToolResults + // copies ResultContentLength to the matching call. + toolCallID := msg.Get("toolCallId").Str + if toolCallID == "" { + continue + } + + content := msg.Get("content") + resultText := extractQClawToolResultText(content) + contentLen := len(resultText) + + messages = append(messages, ParsedMessage{ + Ordinal: ordinal, + Role: RoleUser, + Content: "", + Timestamp: ts, + HasThinking: false, + HasToolUse: false, + ContentLength: contentLen, + ToolResults: []ParsedToolResult{{ + ToolUseID: toolCallID, + ContentLength: contentLen, + ContentRaw: content.Raw, + }}, + }) + ordinal++ + } + } + + if err := lr.Err(); err != nil { + return nil, nil, fmt.Errorf("reading %s: %w", path, err) + } + + if len(messages) == 0 { + return nil, nil, nil + } + + // Build session ID with prefix, including the agent + // subdirectory to avoid collisions across agents. + if sessionID == "" { + sessionID = QClawSessionID(filepath.Base(path)) + } + agentID := qClawAgentIDFromPath(path) + fullID := "qclaw:" + agentID + ":" + sessionID + + if project == "" && cwd != "" { + project = ExtractProjectFromCwd(cwd) + } + if project == "" { + project = "qclaw" + } + + sess := &ParsedSession{ + ID: fullID, + Project: project, + Machine: machine, + Agent: AgentQClaw, + FirstMessage: firstMsg, + StartedAt: startedAt, + EndedAt: endedAt, + MessageCount: len(messages), + UserMessageCount: realUserCount, + File: FileInfo{ + Path: path, + Size: info.Size(), + Mtime: info.ModTime().UnixNano(), + }, + } + + accumulateMessageTokenUsage(sess, messages) + + return sess, messages, nil +} + +// applyQClawAssistantUsage copies the assistant turn's model id +// and per-message token counts into pm so the usage dashboard can +// attribute cost. QClaw uses its own usage shape — short field +// names (input, output, cacheRead, cacheWrite) under message.usage, +// with provider/model on message itself. We map the token fields +// onto the agentsview-native input_tokens/output_tokens/ +// cache_creation_input_tokens/cache_read_input_tokens keys that +// internal/db/usage.go reads. +// +// Cost (message.usage.cost.total) is intentionally not propagated: +// agentsview re-prices via the model_pricing table (loaded from +// LiteLLM), so trusting the gateway's at-request cost would skew +// totals against the canonical pricing source. The model name is +// the load-bearing field for accurate pricing lookup. +// +// Defensive about missing fields — older sessions may carry a model +// without a usage block, or a usage block without cost; either is +// fine. +func applyQClawAssistantUsage( + pm *ParsedMessage, msg gjson.Result, +) { + if model := msg.Get("model").Str; model != "" { + pm.Model = model + } + + usage := msg.Get("usage") + if !usage.Exists() { + return + } + + var ( + input int + output int + cacheRead int + cacheWrite int + + hasInput bool + hasOutput bool + hasCacheRead bool + hasCacheWrite bool + ) + if f := usage.Get("input"); f.Exists() { + input = int(f.Int()) + hasInput = true + } + if f := usage.Get("output"); f.Exists() { + output = int(f.Int()) + hasOutput = true + } + if f := usage.Get("cacheRead"); f.Exists() { + cacheRead = int(f.Int()) + hasCacheRead = true + } + if f := usage.Get("cacheWrite"); f.Exists() { + cacheWrite = int(f.Int()) + hasCacheWrite = true + } + + if !hasInput && !hasOutput && !hasCacheRead && !hasCacheWrite { + return + } + + normalized := map[string]int{ + "input_tokens": input, + "output_tokens": output, + "cache_read_input_tokens": cacheRead, + "cache_creation_input_tokens": cacheWrite, + } + j, err := json.Marshal(normalized) + if err != nil { + return + } + pm.TokenUsage = j + pm.OutputTokens = output + pm.HasOutputTokens = hasOutput + pm.ContextTokens = input + cacheRead + cacheWrite + pm.HasContextTokens = hasInput || hasCacheRead || hasCacheWrite +} + +// extractQClawToolResultText extracts plain text from a QClaw +// tool result content field (which is an array of blocks). +func extractQClawToolResultText(content gjson.Result) string { + if content.Type == gjson.String { + return content.Str + } + if !content.IsArray() { + return "" + } + + var parts []string + content.ForEach(func(_, block gjson.Result) bool { + if block.Get("type").Str == "text" { + if t := block.Get("text").Str; t != "" { + parts = append(parts, t) + } + } + return true + }) + return strings.Join(parts, "\n") +} + +// IsQClawSessionFile reports whether a filename is a QClaw +// session file. It matches active files (*.jsonl) and the known +// archive suffixes: .jsonl.deleted., .jsonl.reset., and +// .jsonl.full.bak. +func IsQClawSessionFile(name string) bool { + if strings.HasSuffix(name, ".jsonl") { + return true + } + idx := strings.Index(name, ".jsonl.") + if idx <= 0 { + return false + } + suffix := name[idx+len(".jsonl."):] + return strings.HasPrefix(suffix, "deleted.") || + strings.HasPrefix(suffix, "reset.") || + suffix == "full.bak" +} + +// QClawSessionID extracts the session UUID from a QClaw +// session filename, stripping any archive suffix. +// "abc.jsonl" → "abc" +// "abc.jsonl.deleted.2026-02-19T08-59-24.951Z" → "abc" +// "abc.jsonl.full.bak" → "abc" +func QClawSessionID(name string) string { + if idx := strings.Index(name, ".jsonl"); idx > 0 { + return name[:idx] + } + return strings.TrimSuffix(name, ".jsonl") +} + +// qClawAgentIDFromPath extracts the agent subdirectory name +// from a QClaw session file path. The expected layout is +// //sessions/.jsonl, so the +// agent ID is the grandparent directory of the file. +func qClawAgentIDFromPath(path string) string { + // path = .../agents//sessions/.jsonl + sessionsDir := filepath.Dir(path) // .../agents//sessions + agentDir := filepath.Dir(sessionsDir) // .../agents/ + name := filepath.Base(agentDir) + if name == "" || name == "." || name == "/" { + return "unknown" + } + return name +} + +// stripQClawDatePrefix removes the gateway-injected date +// prefix from user messages. QClaw prepends timestamps like +// "[Wed 2026-02-18 11:21 GMT+1] " to messages received via +// Telegram/channels. We strip this so session titles are clean. +func stripQClawDatePrefix(s string) string { + if len(s) < 2 || s[0] != '[' { + return s + } + idx := strings.Index(s, "] ") + if idx < 0 || idx > 40 { + return s + } + return strings.TrimSpace(s[idx+2:]) +} + +// parseQClawTimestamp extracts and parses the timestamp from +// any QClaw JSONL entry. +func parseQClawTimestamp(line string) time.Time { + tsStr := gjson.Get(line, "timestamp").Str + return parseTimestamp(tsStr) +} diff --git a/internal/parser/qclaw_test.go b/internal/parser/qclaw_test.go new file mode 100644 index 000000000..e34ce90a5 --- /dev/null +++ b/internal/parser/qclaw_test.go @@ -0,0 +1,720 @@ +package parser + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// writeQClawTestFile creates a test JSONL file inside an +// agent directory structure: //sessions/.jsonl. +// Returns the full path to the file and the root agents directory. +func writeQClawTestFile( + t *testing.T, agentID string, lines ...string, +) (path, agentsDir string) { + t.Helper() + root := t.TempDir() + sessDir := filepath.Join(root, agentID, "sessions") + if err := os.MkdirAll(sessDir, 0755); err != nil { + t.Fatal(err) + } + path = filepath.Join(sessDir, "test-session.jsonl") + var b strings.Builder + for _, line := range lines { + b.WriteString(line) + b.WriteByte('\n') + } + content := b.String() + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + return path, root +} + +func TestParseQClawSession_Basic(t *testing.T) { + path, _ := writeQClawTestFile(t, "main", + `{"type":"session","version":3,"id":"abc-123","timestamp":"2026-02-25T10:00:00Z","cwd":"/home/user/project"}`, + `{"type":"model_change","id":"mc1","timestamp":"2026-02-25T10:00:00Z","provider":"anthropic","modelId":"claude-sonnet-4-6"}`, + `{"type":"message","id":"m1","timestamp":"2026-02-25T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"Hello, how are you?"}],"timestamp":"2026-02-25T10:00:01Z"}}`, + `{"type":"message","id":"m2","timestamp":"2026-02-25T10:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"I'm doing well, thanks!"}],"timestamp":"2026-02-25T10:00:02Z"}}`, + ) + + sess, msgs, err := ParseQClawSession(path, "", "test-machine") + if err != nil { + t.Fatal(err) + } + if sess == nil { + t.Fatal("expected session, got nil") + return + } + + if sess.ID != "qclaw:main:abc-123" { + t.Errorf("expected ID qclaw:main:abc-123, got %s", sess.ID) + } + if sess.Agent != AgentQClaw { + t.Errorf("expected agent qclaw, got %s", sess.Agent) + } + if sess.Machine != "test-machine" { + t.Errorf("expected machine test-machine, got %s", sess.Machine) + } + if sess.Project != "project" { + t.Errorf("expected project 'project', got %s", sess.Project) + } + if sess.FirstMessage != "Hello, how are you?" { + t.Errorf("expected first message 'Hello, how are you?', got %s", sess.FirstMessage) + } + if len(msgs) != 2 { + t.Fatalf("expected 2 messages, got %d", len(msgs)) + } + if msgs[0].Role != RoleUser { + t.Errorf("expected first role user, got %s", msgs[0].Role) + } + if msgs[1].Role != RoleAssistant { + t.Errorf("expected second role assistant, got %s", msgs[1].Role) + } + if sess.UserMessageCount != 1 { + t.Errorf("expected 1 user message, got %d", sess.UserMessageCount) + } +} + +func TestParseQClawSession_Thinking(t *testing.T) { + path, _ := writeQClawTestFile(t, "main", + `{"type":"session","version":3,"id":"think-123","timestamp":"2026-02-25T10:00:00Z","cwd":"/tmp"}`, + `{"type":"message","id":"m1","timestamp":"2026-02-25T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"Think about this"}],"timestamp":"2026-02-25T10:00:01Z"}}`, + `{"type":"message","id":"m2","timestamp":"2026-02-25T10:00:02Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Let me consider..."},{"type":"text","text":"Here is my response."}],"timestamp":"2026-02-25T10:00:02Z"}}`, + ) + + _, msgs, err := ParseQClawSession(path, "", "test") + if err != nil { + t.Fatal(err) + } + if len(msgs) != 2 { + t.Fatalf("expected 2 messages, got %d", len(msgs)) + } + if !msgs[1].HasThinking { + t.Error("expected HasThinking=true for assistant message") + } +} + +func TestParseQClawSession_ToolResult(t *testing.T) { + path, _ := writeQClawTestFile(t, "main", + `{"type":"session","version":3,"id":"tool-123","timestamp":"2026-02-25T10:00:00Z","cwd":"/tmp"}`, + `{"type":"message","id":"m1","timestamp":"2026-02-25T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"Read a file"}],"timestamp":"2026-02-25T10:00:01Z"}}`, + `{"type":"message","id":"m2","timestamp":"2026-02-25T10:00:02Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"tu1","name":"read","input":{"path":"/etc/hosts"}}],"timestamp":"2026-02-25T10:00:02Z"}}`, + `{"type":"message","id":"m3","timestamp":"2026-02-25T10:00:03Z","message":{"role":"toolResult","toolCallId":"tu1","toolName":"read","content":[{"type":"text","text":"127.0.0.1 localhost"}],"isError":false,"timestamp":"2026-02-25T10:00:03Z"}}`, + `{"type":"message","id":"m4","timestamp":"2026-02-25T10:00:04Z","message":{"role":"assistant","content":[{"type":"text","text":"The hosts file contains localhost."}],"timestamp":"2026-02-25T10:00:04Z"}}`, + ) + + sess, msgs, err := ParseQClawSession(path, "", "test") + if err != nil { + t.Fatal(err) + } + if len(msgs) != 4 { + t.Fatalf("expected 4 messages, got %d", len(msgs)) + } + // Assistant with tool_use + if !msgs[1].HasToolUse { + t.Error("expected HasToolUse=true for tool-use message") + } + if len(msgs[1].ToolCalls) != 1 { + t.Fatalf("expected 1 tool call, got %d", len(msgs[1].ToolCalls)) + } + if msgs[1].ToolCalls[0].ToolName != "read" { + t.Errorf("expected tool name 'read', got %s", msgs[1].ToolCalls[0].ToolName) + } + if msgs[1].ToolCalls[0].Category != "Read" { + t.Errorf("expected category 'Read', got %s", msgs[1].ToolCalls[0].Category) + } + + // Tool result mapped to user role + if msgs[2].Role != RoleUser { + t.Errorf("expected tool result as user role, got %s", msgs[2].Role) + } + if len(msgs[2].ToolResults) != 1 { + t.Fatalf("expected 1 tool result, got %d", len(msgs[2].ToolResults)) + } + if msgs[2].ToolResults[0].ToolUseID != "tu1" { + t.Errorf("expected tool use ID 'tu1', got %s", msgs[2].ToolResults[0].ToolUseID) + } + resultContent := DecodeContent(msgs[2].ToolResults[0].ContentRaw) + if resultContent != "127.0.0.1 localhost" { + t.Errorf("expected decoded tool result content, got %q", resultContent) + } + if sess.MessageCount != 4 { + t.Errorf("expected 4 messages, got %d", sess.MessageCount) + } + + // UserMessageCount should only count the real user message, + // not the synthetic tool-result message. + if sess.UserMessageCount != 1 { + t.Errorf("expected UserMessageCount 1 (tool results excluded), got %d", sess.UserMessageCount) + } +} + +func TestParseQClawSession_OrphanToolResult(t *testing.T) { + path, _ := writeQClawTestFile(t, "main", + `{"type":"session","version":3,"id":"orphan-tr","timestamp":"2026-02-25T10:00:00Z","cwd":"/tmp"}`, + `{"type":"message","id":"m1","timestamp":"2026-02-25T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"hello"}],"timestamp":"2026-02-25T10:00:01Z"}}`, + `{"type":"message","id":"m2","timestamp":"2026-02-25T10:00:02Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"tu1","name":"read","input":{}}],"timestamp":"2026-02-25T10:00:02Z"}}`, + // toolResult with empty toolCallId — should be dropped + `{"type":"message","id":"m3","timestamp":"2026-02-25T10:00:03Z","message":{"role":"toolResult","toolCallId":"","toolName":"read","content":[{"type":"text","text":"orphan result"}],"isError":false,"timestamp":"2026-02-25T10:00:03Z"}}`, + `{"type":"message","id":"m4","timestamp":"2026-02-25T10:00:04Z","message":{"role":"assistant","content":[{"type":"text","text":"done"}],"timestamp":"2026-02-25T10:00:04Z"}}`, + ) + + sess, msgs, err := ParseQClawSession(path, "", "test") + if err != nil { + t.Fatal(err) + } + // 3 messages: user, assistant (tool_use), assistant (text). + // The orphan toolResult is skipped entirely. + if len(msgs) != 3 { + t.Fatalf("expected 3 messages, got %d", len(msgs)) + } + if sess.MessageCount != 3 { + t.Errorf("MessageCount = %d, want 3", sess.MessageCount) + } + if sess.UserMessageCount != 1 { + t.Errorf("UserMessageCount = %d, want 1", sess.UserMessageCount) + } + for _, m := range msgs { + if m.Role == RoleUser && m.Content == "" { + t.Error("blank user message leaked through") + } + } +} + +func TestParseQClawSession_EmptyFile(t *testing.T) { + path, _ := writeQClawTestFile(t, "main", + `{"type":"session","version":3,"id":"empty","timestamp":"2026-02-25T10:00:00Z","cwd":"/tmp"}`, + ) + + sess, _, err := ParseQClawSession(path, "", "test") + if err != nil { + t.Fatal(err) + } + if sess != nil { + t.Error("expected nil session for file with no messages") + } +} + +func TestParseQClawSession_AssistantUsage(t *testing.T) { + // Synthetic fixture covering the QClaw assistant-turn usage + // shape: per-message provider/model and a usage block with + // short-name token counts plus a nested cost object. + path, _ := writeQClawTestFile(t, "main", + `{"type":"session","version":3,"id":"usage-1","timestamp":"2026-04-30T12:00:00Z","cwd":"/home/user/proj"}`, + `{"type":"model_change","id":"mc1","timestamp":"2026-04-30T12:00:00Z","provider":"anthropic","modelId":"claude-sonnet-4-6"}`, + `{"type":"message","id":"u1","timestamp":"2026-04-30T12:00:01Z","message":{"role":"user","content":[{"type":"text","text":"do a thing"}],"timestamp":"2026-04-30T12:00:01Z"}}`, + `{"type":"message","id":"a1","timestamp":"2026-04-30T12:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"done"}],"timestamp":"2026-04-30T12:00:02Z","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":91,"cacheRead":0,"cacheWrite":9612,"totalTokens":9706,"cost":{"input":0.000009,"output":0.001365,"cacheRead":0,"cacheWrite":0.036045,"total":0.037419}}}}`, + ) + + sess, msgs, err := ParseQClawSession(path, "", "test") + if err != nil { + t.Fatal(err) + } + if len(msgs) != 2 { + t.Fatalf("expected 2 messages, got %d", len(msgs)) + } + + a := msgs[1] + if a.Role != RoleAssistant { + t.Fatalf("expected assistant role, got %s", a.Role) + } + if a.Model != "claude-sonnet-4-6" { + t.Errorf("Model = %q, want claude-sonnet-4-6", a.Model) + } + if a.OutputTokens != 91 { + t.Errorf("OutputTokens = %d, want 91", a.OutputTokens) + } + if !a.HasOutputTokens { + t.Error("HasOutputTokens = false, want true") + } + // ContextTokens = input + cacheRead + cacheWrite. + if a.ContextTokens != 9615 { + t.Errorf("ContextTokens = %d, want 9615", a.ContextTokens) + } + if !a.HasContextTokens { + t.Error("HasContextTokens = false, want true") + } + // TokenUsage must be normalized to Anthropic-style keys so + // downstream usage aggregation (internal/db/usage.go) can + // read input_tokens/output_tokens/cache_*_input_tokens. + if len(a.TokenUsage) == 0 { + t.Fatal("TokenUsage empty, want normalized JSON") + } + tu := string(a.TokenUsage) + for _, want := range []string{ + `"input_tokens":3`, + `"output_tokens":91`, + `"cache_read_input_tokens":0`, + `"cache_creation_input_tokens":9612`, + } { + if !strings.Contains(tu, want) { + t.Errorf("TokenUsage %q missing %q", tu, want) + } + } + + // Session-level rollup must reflect the per-message totals. + if !sess.HasTotalOutputTokens { + t.Error("sess.HasTotalOutputTokens = false, want true") + } + if sess.TotalOutputTokens != 91 { + t.Errorf("TotalOutputTokens = %d, want 91", + sess.TotalOutputTokens) + } + if !sess.HasPeakContextTokens { + t.Error("sess.HasPeakContextTokens = false, want true") + } + if sess.PeakContextTokens != 9615 { + t.Errorf("PeakContextTokens = %d, want 9615", + sess.PeakContextTokens) + } +} + +func TestParseQClawSession_AssistantUsageWithoutCost(t *testing.T) { + // Older sessions may carry a usage block without the nested + // cost object. Token extraction must still succeed and not + // crash on the missing field. + path, _ := writeQClawTestFile(t, "main", + `{"type":"session","version":3,"id":"usage-2","timestamp":"2026-04-30T12:00:00Z","cwd":"/tmp"}`, + `{"type":"message","id":"u1","timestamp":"2026-04-30T12:00:01Z","message":{"role":"user","content":[{"type":"text","text":"hi"}],"timestamp":"2026-04-30T12:00:01Z"}}`, + `{"type":"message","id":"a1","timestamp":"2026-04-30T12:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"hi back"}],"timestamp":"2026-04-30T12:00:02Z","provider":"anthropic","model":"claude-haiku-4-5","usage":{"input":42,"output":17,"cacheRead":0,"cacheWrite":0,"totalTokens":59}}}`, + ) + + sess, msgs, err := ParseQClawSession(path, "", "test") + if err != nil { + t.Fatal(err) + } + if len(msgs) != 2 { + t.Fatalf("expected 2 messages, got %d", len(msgs)) + } + + a := msgs[1] + if a.Model != "claude-haiku-4-5" { + t.Errorf("Model = %q, want claude-haiku-4-5", a.Model) + } + if a.OutputTokens != 17 { + t.Errorf("OutputTokens = %d, want 17", a.OutputTokens) + } + if a.ContextTokens != 42 { + t.Errorf("ContextTokens = %d, want 42", a.ContextTokens) + } + if len(a.TokenUsage) == 0 { + t.Error("TokenUsage empty, want normalized JSON") + } + if sess.TotalOutputTokens != 17 { + t.Errorf("TotalOutputTokens = %d, want 17", + sess.TotalOutputTokens) + } +} + +func TestParseQClawSession_PartialUsage(t *testing.T) { + // Partial usage block: only output is present in the source. + // applyQClawAssistantUsage normalizes to a 4-key JSON, but + // HasContextTokens must still be false. TokenPresence() must + // trust the parser's explicit flags rather than inferring from + // the always-populated normalized keys. + path, _ := writeQClawTestFile(t, "main", + `{"type":"session","version":3,"id":"partial","timestamp":"2026-04-30T12:00:00Z","cwd":"/tmp"}`, + `{"type":"message","id":"u1","timestamp":"2026-04-30T12:00:01Z","message":{"role":"user","content":[{"type":"text","text":"hi"}],"timestamp":"2026-04-30T12:00:01Z"}}`, + `{"type":"message","id":"a1","timestamp":"2026-04-30T12:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"reply"}],"timestamp":"2026-04-30T12:00:02Z","provider":"anthropic","model":"claude-haiku-4-5","usage":{"output":17}}}`, + ) + + _, msgs, err := ParseQClawSession(path, "", "test") + if err != nil { + t.Fatal(err) + } + if len(msgs) != 2 { + t.Fatalf("expected 2 messages, got %d", len(msgs)) + } + + a := msgs[1] + if a.HasContextTokens { + t.Error("HasContextTokens = true, want false") + } + if !a.HasOutputTokens { + t.Error("HasOutputTokens = false, want true") + } + + hasCtx, hasOut := a.TokenPresence() + if hasCtx { + t.Error("TokenPresence ctx = true, want false " + + "(parser flags must take precedence over JSON keys)") + } + if !hasOut { + t.Error("TokenPresence out = false, want true") + } +} + +func TestParseQClawSession_NoUsage(t *testing.T) { + // Assistant turn without any usage block: the parser is still + // authoritative — both presence flags must be false and stick. + path, _ := writeQClawTestFile(t, "main", + `{"type":"session","version":3,"id":"nousage","timestamp":"2026-04-30T12:00:00Z","cwd":"/tmp"}`, + `{"type":"message","id":"u1","timestamp":"2026-04-30T12:00:01Z","message":{"role":"user","content":[{"type":"text","text":"hi"}],"timestamp":"2026-04-30T12:00:01Z"}}`, + `{"type":"message","id":"a1","timestamp":"2026-04-30T12:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"reply"}],"timestamp":"2026-04-30T12:00:02Z"}}`, + ) + + _, msgs, err := ParseQClawSession(path, "", "test") + if err != nil { + t.Fatal(err) + } + if len(msgs) != 2 { + t.Fatalf("expected 2 messages, got %d", len(msgs)) + } + + hasCtx, hasOut := msgs[1].TokenPresence() + if hasCtx || hasOut { + t.Errorf("TokenPresence = (%v, %v), want (false, false)", + hasCtx, hasOut) + } +} + +func TestParseQClawSession_Compaction(t *testing.T) { + path, _ := writeQClawTestFile(t, "main", + `{"type":"session","version":3,"id":"compact","timestamp":"2026-02-25T10:00:00Z","cwd":"/tmp"}`, + `{"type":"compaction","id":"c1","timestamp":"2026-02-25T10:00:01Z","summary":"Previous work summary"}`, + `{"type":"message","id":"m1","timestamp":"2026-02-25T10:00:02Z","message":{"role":"user","content":[{"type":"text","text":"Continue from here"}],"timestamp":"2026-02-25T10:00:02Z"}}`, + `{"type":"message","id":"m2","timestamp":"2026-02-25T10:00:03Z","message":{"role":"assistant","content":[{"type":"text","text":"Continuing..."}],"timestamp":"2026-02-25T10:00:03Z"}}`, + ) + + sess, msgs, err := ParseQClawSession(path, "", "test") + if err != nil { + t.Fatal(err) + } + if sess == nil { + t.Fatal("expected session, got nil") + } + // Compaction should be skipped, only messages remain. + if len(msgs) != 2 { + t.Errorf("expected 2 messages (compaction skipped), got %d", len(msgs)) + } +} + +func TestParseQClawSession_AgentIDInSessionID(t *testing.T) { + // Verify different agent subdirectories produce distinct + // session IDs even when the raw session ID is the same. + pathA, _ := writeQClawTestFile(t, "alpha", + `{"type":"session","version":3,"id":"same-id","timestamp":"2026-02-25T10:00:00Z","cwd":"/tmp"}`, + `{"type":"message","id":"m1","timestamp":"2026-02-25T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"Hello"}],"timestamp":"2026-02-25T10:00:01Z"}}`, + ) + pathB, _ := writeQClawTestFile(t, "beta", + `{"type":"session","version":3,"id":"same-id","timestamp":"2026-02-25T10:00:00Z","cwd":"/tmp"}`, + `{"type":"message","id":"m1","timestamp":"2026-02-25T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"Hello"}],"timestamp":"2026-02-25T10:00:01Z"}}`, + ) + + sessA, _, err := ParseQClawSession(pathA, "", "test") + if err != nil { + t.Fatal(err) + } + sessB, _, err := ParseQClawSession(pathB, "", "test") + if err != nil { + t.Fatal(err) + } + + if sessA.ID == sessB.ID { + t.Errorf("expected different session IDs for different agents, both got %s", sessA.ID) + } + if sessA.ID != "qclaw:alpha:same-id" { + t.Errorf("expected qclaw:alpha:same-id, got %s", sessA.ID) + } + if sessB.ID != "qclaw:beta:same-id" { + t.Errorf("expected qclaw:beta:same-id, got %s", sessB.ID) + } +} + +func TestIsQClawSessionFile(t *testing.T) { + accepted := []string{ + "abc.jsonl", + "abc.jsonl.deleted.2026-02-19T08-59-24.951Z", + "abc.jsonl.reset.2026-02-17T09-39-39.691Z", + "abc.jsonl.full.bak", + } + rejected := []string{ + "abc.jsonl.tmp", + "abc.jsonl.lock", + "abc.jsonl.partial", + "abc.json", + "sessions.json", + } + for _, name := range accepted { + if !IsQClawSessionFile(name) { + t.Errorf("expected %q to be accepted", name) + } + } + for _, name := range rejected { + if IsQClawSessionFile(name) { + t.Errorf("expected %q to be rejected", name) + } + } +} + +func TestBestQClawEntry_CrossSuffix(t *testing.T) { + root := t.TempDir() + sessDir := filepath.Join(root, "main", "sessions") + if err := os.MkdirAll(sessDir, 0755); err != nil { + t.Fatal(err) + } + + // reset is newer (March) than deleted (January), even though + // "deleted" > "reset" would be wrong lexicographically within + // the suffix family. + older := "abc.jsonl.deleted.2026-01-15T00-00-00.000Z" + newer := "abc.jsonl.reset.2026-03-01T00-00-00.000Z" + for _, name := range []string{older, newer} { + if err := os.WriteFile( + filepath.Join(sessDir, name), []byte("{}"), 0644, + ); err != nil { + t.Fatal(err) + } + } + + files := DiscoverQClawSessions(root) + if len(files) != 1 { + t.Fatalf("expected 1 (deduplicated), got %d", len(files)) + } + if filepath.Base(files[0].Path) != newer { + t.Errorf("expected %q, got %q", newer, filepath.Base(files[0].Path)) + } +} + +func TestDiscoverQClawSessions(t *testing.T) { + // Build a mock directory structure: + // /main/sessions/sess1.jsonl + // /main/sessions/sessions.json + // /claude/sessions/sess2.jsonl + root := t.TempDir() + + mainSessions := filepath.Join(root, "main", "sessions") + if err := os.MkdirAll(mainSessions, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(mainSessions, "sess1.jsonl"), []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(mainSessions, "sessions.json"), []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + + claudeSessions := filepath.Join(root, "claude", "sessions") + if err := os.MkdirAll(claudeSessions, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(claudeSessions, "sess2.jsonl"), []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + + files := DiscoverQClawSessions(root) + if len(files) != 2 { + t.Fatalf("expected 2 session files, got %d", len(files)) + } + for _, f := range files { + if f.Agent != AgentQClaw { + t.Errorf("expected agent qclaw, got %s", f.Agent) + } + } +} + +func TestDiscoverQClawSessions_DeduplicatesArchived(t *testing.T) { + root := t.TempDir() + sessDir := filepath.Join(root, "main", "sessions") + if err := os.MkdirAll(sessDir, 0755); err != nil { + t.Fatal(err) + } + + // Active file and two archived files for the same session. + for _, name := range []string{ + "abc.jsonl", + "abc.jsonl.deleted.2026-02-19T08-59-24.951Z", + "abc.jsonl.reset.2026-02-17T09-39-39.691Z", + } { + if err := os.WriteFile( + filepath.Join(sessDir, name), + []byte("{}"), 0644, + ); err != nil { + t.Fatal(err) + } + } + + files := DiscoverQClawSessions(root) + if len(files) != 1 { + t.Fatalf("expected 1 file (deduplicated), got %d", len(files)) + } + // Active file should win. + if !strings.HasSuffix(files[0].Path, "abc.jsonl") { + t.Errorf( + "expected active .jsonl to win, got %s", + filepath.Base(files[0].Path), + ) + } +} + +func TestDiscoverQClawSessions_ArchiveOnlyPicksNewest(t *testing.T) { + root := t.TempDir() + sessDir := filepath.Join(root, "main", "sessions") + if err := os.MkdirAll(sessDir, 0755); err != nil { + t.Fatal(err) + } + + // Two archived files, no active — newest filename wins. + for _, name := range []string{ + "xyz.jsonl.deleted.2026-01-01T00-00-00.000Z", + "xyz.jsonl.deleted.2026-03-01T00-00-00.000Z", + } { + if err := os.WriteFile( + filepath.Join(sessDir, name), + []byte("{}"), 0644, + ); err != nil { + t.Fatal(err) + } + } + + files := DiscoverQClawSessions(root) + if len(files) != 1 { + t.Fatalf("expected 1 file (deduplicated), got %d", len(files)) + } + want := "xyz.jsonl.deleted.2026-03-01T00-00-00.000Z" + if filepath.Base(files[0].Path) != want { + t.Errorf("expected newest archive %q, got %q", + want, filepath.Base(files[0].Path)) + } +} + +func TestDiscoverQClawSessions_DifferentSessionsNotDeduped(t *testing.T) { + root := t.TempDir() + sessDir := filepath.Join(root, "main", "sessions") + if err := os.MkdirAll(sessDir, 0755); err != nil { + t.Fatal(err) + } + + // Two different session IDs — should not be deduplicated. + for _, name := range []string{ + "aaa.jsonl", + "bbb.jsonl.deleted.2026-01-01T00-00-00.000Z", + } { + if err := os.WriteFile( + filepath.Join(sessDir, name), + []byte("{}"), 0644, + ); err != nil { + t.Fatal(err) + } + } + + files := DiscoverQClawSessions(root) + if len(files) != 2 { + t.Fatalf("expected 2 files (different sessions), got %d", + len(files)) + } +} + +func TestFindQClawSourceFile(t *testing.T) { + root := t.TempDir() + sessDir := filepath.Join(root, "main", "sessions") + if err := os.MkdirAll(sessDir, 0755); err != nil { + t.Fatal(err) + } + target := filepath.Join(sessDir, "abc-123.jsonl") + if err := os.WriteFile(target, []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + + // Raw ID is now "agentId:sessionId". + found := FindQClawSourceFile(root, "main:abc-123") + if found != target { + t.Errorf("expected %s, got %s", target, found) + } + + // Non-existent session. + notFound := FindQClawSourceFile(root, "main:nonexistent") + if notFound != "" { + t.Errorf("expected empty string, got %s", notFound) + } + + // Non-existent agent. + notFound2 := FindQClawSourceFile(root, "other:abc-123") + if notFound2 != "" { + t.Errorf("expected empty string, got %s", notFound2) + } + + // Invalid format (no colon separator). + notFound3 := FindQClawSourceFile(root, "abc-123") + if notFound3 != "" { + t.Errorf("expected empty string for bare ID, got %s", notFound3) + } +} + +func TestFindQClawSourceFile_ArchiveOnly(t *testing.T) { + root := t.TempDir() + sessDir := filepath.Join(root, "main", "sessions") + if err := os.MkdirAll(sessDir, 0755); err != nil { + t.Fatal(err) + } + + // Only archived files exist — no active .jsonl. + archived := "def-456.jsonl.deleted.2026-02-19T08-59-24.951Z" + if err := os.WriteFile( + filepath.Join(sessDir, archived), + []byte("{}"), 0644, + ); err != nil { + t.Fatal(err) + } + + found := FindQClawSourceFile(root, "main:def-456") + want := filepath.Join(sessDir, archived) + if found != want { + t.Errorf("expected %s, got %s", want, found) + } +} + +func TestFindQClawSourceFile_PrefersActiveOverArchive(t *testing.T) { + root := t.TempDir() + sessDir := filepath.Join(root, "main", "sessions") + if err := os.MkdirAll(sessDir, 0755); err != nil { + t.Fatal(err) + } + + // Both active and archived files exist. + active := filepath.Join(sessDir, "ghi-789.jsonl") + if err := os.WriteFile(active, []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + archived := "ghi-789.jsonl.deleted.2026-02-19T00-00-00.000Z" + if err := os.WriteFile( + filepath.Join(sessDir, archived), + []byte("{}"), 0644, + ); err != nil { + t.Fatal(err) + } + + found := FindQClawSourceFile(root, "main:ghi-789") + if found != active { + t.Errorf("expected active file %s, got %s", active, found) + } +} + +func TestFindQClawSourceFile_ArchiveOnlyNewest(t *testing.T) { + root := t.TempDir() + sessDir := filepath.Join(root, "main", "sessions") + if err := os.MkdirAll(sessDir, 0755); err != nil { + t.Fatal(err) + } + + // Two archived files — newest should be chosen. + old := "jkl.jsonl.deleted.2026-01-01T00-00-00.000Z" + newest := "jkl.jsonl.deleted.2026-03-01T00-00-00.000Z" + for _, name := range []string{old, newest} { + if err := os.WriteFile( + filepath.Join(sessDir, name), + []byte("{}"), 0644, + ); err != nil { + t.Fatal(err) + } + } + + found := FindQClawSourceFile(root, "main:jkl") + want := filepath.Join(sessDir, newest) + if found != want { + t.Errorf("expected newest archive %s, got %s", want, found) + } +} diff --git a/internal/parser/types.go b/internal/parser/types.go index e02692561..83ee60f78 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -24,6 +24,7 @@ const ( AgentPi AgentType = "pi" AgentQwen AgentType = "qwen" AgentOpenClaw AgentType = "openclaw" + AgentQClaw AgentType = "qclaw" AgentKimi AgentType = "kimi" AgentClaudeAI AgentType = "claude-ai" AgentChatGPT AgentType = "chatgpt" @@ -250,6 +251,17 @@ var Registry = []AgentDef{ DiscoverFunc: DiscoverOpenClawSessions, FindSourceFunc: FindOpenClawSourceFile, }, + { + Type: AgentQClaw, + DisplayName: "QClaw", + EnvVar: "QCLAW_DIR", + ConfigKey: "qclaw_dirs", + DefaultDirs: []string{".qclaw/agents"}, + IDPrefix: "qclaw:", + FileBased: true, + DiscoverFunc: DiscoverQClawSessions, + FindSourceFunc: FindQClawSourceFile, + }, { Type: AgentKimi, DisplayName: "Kimi", diff --git a/internal/parser/types_test.go b/internal/parser/types_test.go index fe825c7ba..af48779cc 100644 --- a/internal/parser/types_test.go +++ b/internal/parser/types_test.go @@ -276,6 +276,7 @@ func TestRegistryCompleteness(t *testing.T) { AgentVSCodeCopilot, AgentPi, AgentOpenClaw, + AgentQClaw, AgentKimi, AgentClaudeAI, AgentChatGPT, diff --git a/internal/sync/engine.go b/internal/sync/engine.go index 82cfa49c3..0f5502e8e 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -874,6 +874,47 @@ func (e *Engine) classifyOnePath( } } + // QClaw: //sessions/.jsonl + // or: //sessions/.jsonl. + for _, qcDir := range e.agentDirs[parser.AgentQClaw] { + if qcDir == "" { + continue + } + if rel, ok := isUnder(qcDir, path); ok { + parts := strings.Split(rel, sep) + // Expect: /sessions/ + if len(parts) != 3 || parts[1] != "sessions" { + continue + } + if !parser.IsValidSessionID(parts[0]) { + continue + } + if !parser.IsQClawSessionFile(parts[2]) { + continue + } + if !strings.HasSuffix(parts[2], ".jsonl") { + sid := parser.QClawSessionID(parts[2]) + active := filepath.Join( + qcDir, parts[0], "sessions", + sid+".jsonl", + ) + if _, err := os.Stat(active); err == nil { + continue + } + best := parser.FindQClawSourceFile( + qcDir, parts[0]+":"+sid, + ) + if best != path { + continue + } + } + return parser.DiscoveredFile{ + Path: path, + Agent: parser.AgentQClaw, + }, true + } + } + // Cortex: /.json // or: /.history.jsonl → remap to .json for _, cortexDir := range e.agentDirs[parser.AgentCortex] { @@ -2732,6 +2773,8 @@ func (e *Engine) processFile( res = e.processQwen(file, info) case parser.AgentOpenClaw: res = e.processOpenClaw(file, info) + case parser.AgentQClaw: + res = e.processQClaw(file, info) case parser.AgentKimi: res = e.processKimi(file, info) case parser.AgentKiro: @@ -3585,6 +3628,35 @@ func (e *Engine) processOpenClaw( } } +func (e *Engine) processQClaw( + file parser.DiscoveredFile, info os.FileInfo, +) processResult { + if e.shouldSkipByPath(file.Path, info) { + return processResult{skip: true} + } + + sess, msgs, err := parser.ParseQClawSession( + file.Path, file.Project, e.machine, + ) + if err != nil { + return processResult{err: err} + } + if sess == nil { + return processResult{} + } + + hash, err := ComputeFileHash(file.Path) + if err == nil { + sess.File.Hash = hash + } + + return processResult{ + results: []parser.ParseResult{ + {Session: *sess, Messages: msgs}, + }, + } +} + func (e *Engine) processKimi( file parser.DiscoveredFile, info os.FileInfo, ) processResult { diff --git a/internal/sync/engine_test.go b/internal/sync/engine_test.go index 57d7900cd..262679bb0 100644 --- a/internal/sync/engine_test.go +++ b/internal/sync/engine_test.go @@ -14,6 +14,7 @@ import ( "github.com/google/go-cmp/cmp" "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/dbtest" "go.kenn.io/agentsview/internal/parser" "go.kenn.io/agentsview/internal/testjsonl" ) @@ -1619,6 +1620,89 @@ func TestEngine_ClassifyPathsQwenSession(t *testing.T) { } } +func TestEngine_ClassifyPathsQClawSession(t *testing.T) { + db := openTestDB(t) + qclawDir := t.TempDir() + engine := NewEngine(db, EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentQClaw: {qclawDir}, + }, + Machine: "local", + }) + + agentID := "main" + sessionID := "adc026b4-c620-43e4-8cc4-295593889d18" + sessionsDir := filepath.Join(qclawDir, agentID, "sessions") + sessionPath := filepath.Join(sessionsDir, sessionID+".jsonl") + dbtest.WriteTestFile(t, sessionPath, []byte("{}\n")) + + files := engine.classifyPaths([]string{sessionPath}) + if len(files) != 1 { + t.Fatalf("len(files) = %d, want 1 (%v)", len(files), files) + } + if files[0].Path != sessionPath { + t.Errorf("Path = %q, want %q", files[0].Path, sessionPath) + } + if files[0].Agent != parser.AgentQClaw { + t.Errorf("Agent = %q, want %q", files[0].Agent, parser.AgentQClaw) + } + + bogus := []string{ + filepath.Join(qclawDir, "stray.jsonl"), + filepath.Join(qclawDir, agentID, "notes", sessionID+".jsonl"), + filepath.Join(sessionsDir, "notes.txt"), + filepath.Join(qclawDir, "not a session id", "sessions", sessionID+".jsonl"), + } + for _, p := range bogus { + dbtest.WriteTestFile(t, p, []byte("{}")) + } + if got := engine.classifyPaths(bogus); len(got) != 0 { + t.Fatalf("expected no QClaw classifications for %v, got %v", + bogus, got) + } +} + +func TestEngine_ClassifyPathsQClawArchivedSession(t *testing.T) { + db := openTestDB(t) + qclawDir := t.TempDir() + engine := NewEngine(db, EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentQClaw: {qclawDir}, + }, + Machine: "local", + }) + + agentID := "main" + sessionID := "adc026b4-c620-43e4-8cc4-295593889d18" + sessionsDir := filepath.Join(qclawDir, agentID, "sessions") + + active := filepath.Join(sessionsDir, sessionID+".jsonl") + archived := filepath.Join( + sessionsDir, + sessionID+".jsonl.deleted.2026-02-19T08-59-24.951Z", + ) + dbtest.WriteTestFile(t, active, []byte("{}\n")) + dbtest.WriteTestFile(t, archived, []byte("{}\n")) + + if got := engine.classifyPaths([]string{archived}); len(got) != 0 { + t.Fatalf("expected archived file shadowed by active to be ignored, got %v", got) + } + + if err := os.Remove(active); err != nil { + t.Fatalf("Remove(%q): %v", active, err) + } + files := engine.classifyPaths([]string{archived}) + if len(files) != 1 { + t.Fatalf("len(files) = %d, want 1 (%v)", len(files), files) + } + if files[0].Path != archived { + t.Errorf("Path = %q, want %q", files[0].Path, archived) + } + if files[0].Agent != parser.AgentQClaw { + t.Errorf("Agent = %q, want %q", files[0].Agent, parser.AgentQClaw) + } +} + func TestEngine_SyncSingleSessionEmitsOnSuccess(t *testing.T) { fx := newEngineFixture(t) em := &fakeEmitter{} diff --git a/scripts/e2e-server.sh b/scripts/e2e-server.sh index 84ab57b54..26c794095 100755 --- a/scripts/e2e-server.sh +++ b/scripts/e2e-server.sh @@ -55,6 +55,7 @@ IFLOW_DIR="$EMPTY_DIR" \ VSCODE_COPILOT_DIR="$EMPTY_DIR" \ PI_DIR="$EMPTY_DIR" \ OPENCLAW_DIR="$EMPTY_DIR" \ +QCLAW_DIR="$EMPTY_DIR" \ exec "$SERVER" serve \ --port 8090 \ --no-browser