From 2c3818074177d372da2d6879a425bcecb0a133eb Mon Sep 17 00:00:00 2001 From: Rudimar Ronsoni Date: Sat, 26 Sep 2026 10:53:25 +0200 Subject: [PATCH] feat(grok): read Grok Build sessions Read the authoritative ACP timeline under GROK_HOME through the existing provider, CLI, and rendering interfaces. Preserve real user turns, failures, stops, timestamps, and every compaction summary record. Warn about losses when falling back to chat history and honor explicit session visibility. Use sanitized real-record fixtures with focused provider and CLI tests. Keep shared conformance infrastructure out of this provider addition. --- README.md | 4 +- SKILL.md | 4 +- internal/cli/agentlist_test.go | 1 + internal/cli/cli.go | 16 +- internal/cli/cli_test.go | 43 +- internal/grok/grok.go | 816 ++++++++++++++++++++++ internal/grok/grok_test.go | 292 ++++++++ internal/grok/testdata/chat_history.jsonl | 7 + internal/grok/testdata/checkpoint.json | 1 + internal/grok/testdata/updates.jsonl | 16 + internal/session/roots.go | 11 +- internal/session/roots_test.go | 10 +- internal/session/session.go | 5 +- 13 files changed, 1215 insertions(+), 11 deletions(-) create mode 100644 internal/grok/grok.go create mode 100644 internal/grok/grok_test.go create mode 100644 internal/grok/testdata/chat_history.jsonl create mode 100644 internal/grok/testdata/checkpoint.json create mode 100644 internal/grok/testdata/updates.jsonl diff --git a/README.md b/README.md index 25286f4..0c2d43e 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Reach for it whenever you don't want to explain the whole job again: an agent hits its usage limit, you switch tools mid-task, you pick up older work, or you want a clean record of what happened. -Works with **Amp**, **Claude Code**, **Codex**, **Copilot CLI**, **Cursor**, **Cline**, **Kimi**, **Antigravity**, **OpenCode**, **Pi Agent**, **ZCode**, and **DeepSeek Harness**. +Works with **Amp**, **Claude Code**, **Codex**, **Copilot CLI**, **Cursor**, **Cline**, **Kimi**, **Antigravity**, **OpenCode**, **Pi Agent**, **ZCode**, **Grok Build**, and **DeepSeek Harness**.
@@ -56,7 +56,7 @@ herdr plugin install wilbeibi/herdr-catchup ## Usage -Agents: amp · `claude` · `codex` · `copilot` · `cursor` · `cline` · `kimi` · `agy` (Antigravity) · `opencode` · `pi-agent` · `zcode` · `deepseek` (dsh) +Agents: amp · `claude` · `codex` · `copilot` · `cursor` · `cline` · `kimi` · `agy` (Antigravity) · `opencode` · `pi-agent` · `zcode` · `deepseek` (dsh) · `grok` Omit `` and catchup uses whichever agent has the newest session in this directory. Inside a live session, that's usually the session you're in. diff --git a/SKILL.md b/SKILL.md index 4a0e4a9..8deeec9 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: catchup -description: Recovers the conversation and failed tool calls of a previous Codex, Amp, Claude Code, Antigravity, Cline, Copilot CLI, Cursor, DeepSeek Harness, Kimi, OpenCode, Pi Agent, or ZCode session. Use when the user says "catch up", "what did the last session do", "get me up to speed", "I switched agents", asks to recover/summarize a previous session before continuing, or asks to diagnose or report a catchup failure. Do NOT use for the current conversation, git history, or any non-agent log. +description: Recovers the conversation and failed tool calls of a previous Codex, Amp, Claude Code, Antigravity, Cline, Copilot CLI, Cursor, DeepSeek Harness, Grok Build, Kimi, OpenCode, Pi Agent, or ZCode session. Use when the user says "catch up", "what did the last session do", "get me up to speed", "I switched agents", asks to recover/summarize a previous session before continuing, or asks to diagnose or report a catchup failure. Do NOT use for the current conversation, git history, or any non-agent log. --- # catchup @@ -27,7 +27,7 @@ catchup fork # native resume, full state catchup fork --into # seed a different agent with the transcript ``` -Agents: `amp`, `codex`, `claude`, `agy` (Antigravity), `cline`, `copilot`, `cursor`, `deepseek` (dsh), `kimi`, `opencode`, `pi-agent`, `zcode`. +Agents: `amp`, `codex`, `claude`, `agy` (Antigravity), `cline`, `copilot`, `cursor`, `deepseek` (dsh), `grok`, `kimi`, `opencode`, `pi-agent`, `zcode`. ## Before loading a transcript diff --git a/internal/cli/agentlist_test.go b/internal/cli/agentlist_test.go index 5e96def..400cc3c 100644 --- a/internal/cli/agentlist_test.go +++ b/internal/cli/agentlist_test.go @@ -127,6 +127,7 @@ func TestProseNamesEveryAgent(t *testing.T) { session.ProviderCopilot: "Copilot CLI", session.ProviderCursor: "Cursor", session.ProviderDeepSeek: "DeepSeek Harness", + session.ProviderGrok: "Grok Build", session.ProviderKimi: "Kimi", session.ProviderOpenCode: "OpenCode", session.ProviderPiAgent: "Pi Agent", diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 0816b77..5756762 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -30,6 +30,7 @@ import ( "github.com/wilbeibi/catchup/internal/copilot" "github.com/wilbeibi/catchup/internal/cursor" "github.com/wilbeibi/catchup/internal/deepseek" + "github.com/wilbeibi/catchup/internal/grok" "github.com/wilbeibi/catchup/internal/kimi" "github.com/wilbeibi/catchup/internal/opencode" "github.com/wilbeibi/catchup/internal/piagent" @@ -44,7 +45,7 @@ const helpText = `Usage: catchup [agent[/]] [flags] read a past ses catchup install-skill [agent] Agents: amp, codex, claude, agy (Antigravity), cline, copilot, cursor, deepseek (dsh), -kimi, opencode, pi-agent, zcode +grok, kimi, opencode, pi-agent, zcode Omit the agent to use whichever has the newest session here. Bare ` + "`catchup`" + ` prints that session in full, as Markdown. The flags refine three things: which session, how much of it, and as what. @@ -385,6 +386,8 @@ func selectProvider(name string) (session.Provider, error) { return cursor.New(), nil case session.ProviderDeepSeek: return deepseek.New(), nil + case session.ProviderGrok: + return grok.New(), nil case session.ProviderKimi: return kimi.New(), nil case session.ProviderOpenCode: @@ -935,6 +938,10 @@ func intoCommand(target, prompt, model string) (string, []string, error) { // -i starts interactive and auto-executes the prompt; a bare -p is // non-interactive and exits when the answer lands. return "copilot", append(modelArgs("--model", model), "-i", prompt), nil + case session.ProviderGrok: + // The TUI takes its opening prompt as a positional argument (a bare -p + // runs headless and exits); options must precede it. + return "grok", append(modelArgs("-m", model), prompt), nil case session.ProviderKimi: // Kimi rejects positional arguments and its -p flag is // non-interactive print mode, so there is no way to start an @@ -1035,6 +1042,13 @@ func forkCommand(src session.Source, model string) (string, []string, error) { // Copilot has no fork; --resume is its native resume, and it takes // the id inline (a bare --resume opens the session picker). return "copilot", append([]string{"--resume=" + src.Ref.SessionID}, modelArgs("--model", model)...), nil + case session.ProviderGrok: + if src.Ref.SessionID == "" { + return "", nil, fmt.Errorf("fork grok: missing session id") + } + // --fork-session branches into a new id instead of appending to the + // original, which is what catchup's native fork means. + return "grok", append([]string{"--resume", src.Ref.SessionID, "--fork-session"}, modelArgs("-m", model)...), nil case session.ProviderCursor: if src.Ref.SessionID == "" { return "", nil, fmt.Errorf("fork cursor: missing session id") diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index bbc44bf..78f797d 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -146,6 +146,43 @@ func piAgentRoot(t *testing.T) session.Roots { return session.Roots{PiAgent: root} } +// grokRoot writes one Grok session under a percent-encoded cwd group and +// returns a Roots pointing at it. summary.json is the index entry; the +// transcript is chat_history.jsonl. +func grokRoot(t *testing.T) session.Roots { + t.Helper() + root := t.TempDir() + dir := filepath.Join(root, "sessions", "%2Fhome%2Fu%2Fsrc%2Fproj", "gr-1") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + summary := `{"info":{"id":"gr-1","cwd":"/home/u/src/proj"},"generated_title":"grok session","last_active_at":"2026-07-18T15:20:00Z"}` + chat, err := os.ReadFile("../grok/testdata/chat_history.jsonl") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "summary.json"), []byte(summary), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "chat_history.jsonl"), []byte(chat), 0o644); err != nil { + t.Fatal(err) + } + return session.Roots{Grok: root} +} + +func TestRunReadsGrokSession(t *testing.T) { + roots := grokRoot(t) + out := run(t, roots, "grok", "--id", "gr-1", "--since-compact") + for _, want := range []string{"agent: grok", "session: gr-1", "title: grok session", "support grok", "the compaction summary", "I will read the log."} { + if !strings.Contains(out, want) { + t.Errorf("grok read missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "injected context") { + t.Error("--since-compact kept an obsolete compaction marker") + } +} + func runWithCwd(t *testing.T, roots session.Roots, cwd string, args ...string) string { t.Helper() var out, errOut bytes.Buffer @@ -732,6 +769,8 @@ func TestForkCommand(t *testing.T) { // which would swallow a separated id as an unrelated argument. {"copilot", session.Source{Ref: session.Ref{Provider: session.ProviderCopilot, SessionID: "gh1"}}, "", "copilot --resume=gh1"}, {"copilot with model", session.Source{Ref: session.Ref{Provider: session.ProviderCopilot, SessionID: "gh1"}}, "gpt-5.4", "copilot --resume=gh1 --model gpt-5.4"}, + {"grok", session.Source{Ref: session.Ref{Provider: session.ProviderGrok, SessionID: "gr1"}}, "", "grok --resume gr1 --fork-session"}, + {"grok with model", session.Source{Ref: session.Ref{Provider: session.ProviderGrok, SessionID: "gr1"}}, "grok-4.7", "grok --resume gr1 --fork-session -m grok-4.7"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -811,9 +850,10 @@ func runBoth(t *testing.T, roots session.Roots, cwd string, args ...string) (str func TestRunListAcrossAgents(t *testing.T) { roots := codexRoot(t) roots.Claude = claudeRoot(t).Claude + roots.Grok = grokRoot(t).Grok out, _ := runBoth(t, roots, "", "--list") - for _, want := range []string{"codex/1", "claude/1", "claude/2"} { + for _, want := range []string{"codex/1", "claude/1", "claude/2", "grok/1"} { if !strings.Contains(out, want) { t.Errorf("cross-agent listing missing %q:\n%s", want, out) } @@ -1004,6 +1044,7 @@ func TestIntoCommandModelPlacement(t *testing.T) { {session.ProviderAgy, "--model M -i PROMPT"}, {session.ProviderOpenCode, "--model M --prompt PROMPT"}, {session.ProviderCopilot, "--model M -i PROMPT"}, + {session.ProviderGrok, "-m M PROMPT"}, } for _, tt := range tests { t.Run(tt.target, func(t *testing.T) { diff --git a/internal/grok/grok.go b/internal/grok/grok.go new file mode 100644 index 0000000..658c067 --- /dev/null +++ b/internal/grok/grok.go @@ -0,0 +1,816 @@ +// Package grok reads Grok Build history under $GROK_HOME (default ~/.grok): +// sessions///{summary.json,updates.jsonl,chat_history.jsonl}. +// IDs and cwd come from summary.json, not the percent-encoded or hashed group. +// +// Format evidence: real sessions inspected with grok 1.0.41 (4220f3b224a6), +// 2026-09-27; sanitized excerpts and their provenance are in grok_test.go. +// Listing predicates follow xai-org/grok-build at +// f0e3be1100ef5252488e3be8bb0e91cf68d8c305, crates/codegen/xai-grok-shell/src/ +// session/persistence.rs (Summary::is_hidden/is_unused_optimistic_husk). +// Compatibility with other versions has not been established. +// +// updates.jsonl is the authoritative ACP timeline, preserving timestamps, +// failures, stop reasons and history across compactions. Assistant chunks join +// until a user turn, tool call or turn completion. hideFromScrollback excludes +// injected user chunks; promptIndex alone does not. Unknown kinds warn, while +// known reasoning/tooling/UI updates are explicitly ignored below. +// +// Each compaction_checkpoint references a file under compaction_checkpoints/; +// all its compaction_meta records form the recap, including injected context +// and the summary. chat_history.jsonl is the lossy fallback, not a second copy +// of the timeline: compaction replaces it, losing older history. Surviving +// messages before its final compaction marker are Retained. Fallback reads warn +// about missing timestamps, failures, stops and possible lost history. +// Hidden sessions and unused husks stay out of listings; --id still reads them. +package grok + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/wilbeibi/catchup/internal/session" +) + +// Provider reads Grok session directories. It is stateless; each call re-reads +// the files, so a session Grok is still appending to is never blocked. +type Provider struct{} + +// New returns a Grok provider. +func New() *Provider { return &Provider{} } + +var _ session.Provider = (*Provider)(nil) + +const ( + updatesFile = "updates.jsonl" + chatFile = "chat_history.jsonl" +) + +// updatesMaxBytes bounds the authoritative-transcript read. A very long +// session's updates.jsonl can run to hundreds of MB (every streamed chunk and +// hook run); past this the provider falls back to the smaller chat_history.jsonl +// and says so, rather than streaming an unbounded file on every read. +const updatesMaxBytes int64 = 512 << 20 + +// scanLine bounds one updates.jsonl line. +const scanLine = 64 << 20 + +func (p *Provider) Resolve(ctx context.Context, roots session.Roots, id string) (session.Source, error) { + infos, err := enumerate(roots.Grok) + if err != nil { + return session.Source{}, err + } + if id == "" { + for _, in := range infos { + if in.visible() { + return in.source(), nil + } + } + return session.Source{}, fmt.Errorf("grok: no sessions found under %s", roots.Grok) + } + for _, in := range infos { + if in.sum.Info.ID == id || filepath.Base(in.dir) == id { + return in.source(), nil + } + } + return session.Source{}, fmt.Errorf("grok: no session with id %q", id) +} + +// readThread builds a session's timeline from its authoritative source: +// updates.jsonl when present and within updatesMaxBytes, else chat_history.jsonl. +// stopAfterUser asks for the cheap preview read — stop once the first real user +// turn is seen — which a plain listing uses; a full read, and a queried listing, +// see the whole session. +func readThread(src session.Source, stopAfterUser bool) (session.Thread, error) { + updates := filepath.Join(src.Path, updatesFile) + info, err := os.Stat(updates) + var warnings []string + reason := "updates.jsonl is missing" + switch { + case err != nil && !errors.Is(err, fs.ErrNotExist): + return session.Thread{}, err + case err == nil && info.Size() > updatesMaxBytes: + reason = fmt.Sprintf("updates.jsonl is over the %d MB limit", updatesMaxBytes>>20) + case err == nil: + t, err := readUpdates(src, updates, stopAfterUser) + if err != nil || len(t.Entries) > 0 { + return t, err + } + warnings = t.Warnings + reason = "updates.jsonl has no readable conversation" + } + t, err := readChat(src, filepath.Join(src.Path, chatFile)) + t.Warnings = append(warnings, t.Warnings...) + if err == nil && (info != nil || len(t.Entries) > 0) { + t.Warnings = append(t.Warnings, reason+"; using chat_history.jsonl without timestamps, tool failures, or stop reasons; pre-compaction history may be missing") + } + return t, err +} + +func (p *Provider) Read(ctx context.Context, src session.Source) (session.Thread, error) { + if src.Path == "" { + return session.Thread{}, errors.New("grok: source has no path") + } + info, err := os.Stat(src.Path) + if err != nil { + return session.Thread{}, err + } + if !info.IsDir() { + return session.Thread{}, errors.New("grok: source is not a session directory") + } + return readThread(src, false) +} + +func (p *Provider) List(ctx context.Context, roots session.Roots, opts session.ListOptions) ([]session.Summary, error) { + infos, err := enumerate(roots.Grok) + if err != nil { + return nil, err + } + limit := opts.EffectiveLimit() + out := make([]session.Summary, 0, limit) + for _, in := range infos { + if len(out) >= limit { + break + } + if !in.visible() { + continue + } + src := in.source() + if !opts.MatchesCwd(src.Metadata["cwd"]) { + continue + } + // A query must see the whole session, exactly as a read does — a term + // that only exists before a compaction is still in updates.jsonl. A + // plain listing needs only a preview, so it stops at the first turn. + t, err := readThread(src, opts.Query == "") + if err != nil || len(t.Entries) == 0 { + continue + } + if !opts.Matches(t) { + continue + } + out = append(out, opts.Summarize(t)) + } + for i := range out { + out[i].Rank = i + 1 + } + return out, nil +} + +// --- session index ---------------------------------------------------------- + +// grokSummary mirrors the fields of summary.json this provider uses. It is +// deliberately partial: Grok's summary carries many more keys and grows, and an +// unknown key must not fail a parse. +type grokSummary struct { + Info struct { + ID string `json:"id"` + Cwd string `json:"cwd"` + } `json:"info"` + SessionSummary string `json:"session_summary"` + GeneratedTitle string `json:"generated_title"` + CurrentModelID string `json:"current_model_id"` + UpdatedAt string `json:"updated_at"` + LastActiveAt string `json:"last_active_at"` + SessionKind string `json:"session_kind"` + ParentSessionID string `json:"parent_session_id"` + ForkedAt string `json:"forked_at"` + NumMessages int `json:"num_messages"` + Hidden *bool `json:"hidden"` +} + +// sessionInfo is one located session: its directory, parsed summary, and +// recency. +type sessionInfo struct { + dir string + sum grokSummary + when time.Time +} + +// visible reports whether the agent's own listing would show this session. It +// mirrors Summary::is_hidden (explicit hidden overrides the subagent default) +// and is_unused_optimistic_husk (no fork provenance, messages or title). +func (in sessionInfo) visible() bool { + hidden := strings.HasPrefix(in.sum.SessionKind, "subagent") + if in.sum.Hidden != nil { + hidden = *in.sum.Hidden + } + return !hidden && !in.isHusk() +} + +// isHusk mirrors Grok's is_unused_optimistic_husk: a TUI session opened and +// abandoned before it had a title or a message. Fork provenance exempts one. +func (in sessionInfo) isHusk() bool { + if in.sum.SessionKind == "fork" || in.sum.ParentSessionID != "" || in.sum.ForkedAt != "" { + return false + } + return in.sum.NumMessages == 0 && in.displayTitle() == "" +} + +func (in sessionInfo) source() session.Source { + id := in.sum.Info.ID + if id == "" { + id = filepath.Base(in.dir) + } + md := map[string]string{} + if cwd := in.sum.Info.Cwd; cwd != "" { + md["cwd"] = cwd + } + if title := in.title(); title != "" { + md["title"] = title + } + if in.sum.CurrentModelID != "" { + md["model"] = in.sum.CurrentModelID + } + return session.Source{ + Ref: session.Ref{Provider: session.ProviderGrok, SessionID: id}, + Path: in.dir, + UpdatedAt: in.when, + Metadata: md, + } +} + +// title prefers the model-generated title, falls back to the session summary, +// then to the directory name — the same fallback every provider's listing uses +// for a session its agent never named. +func (in sessionInfo) title() string { + if t := in.displayTitle(); t != "" { + return t + } + if cwd := in.sum.Info.Cwd; cwd != "" { + return filepath.Base(cwd) + } + return "" +} + +// displayTitle is Grok's Summary::display_title: the generated title, else the +// session summary, with no directory-name fallback. +func (in sessionInfo) displayTitle() string { + if t := strings.TrimSpace(in.sum.GeneratedTitle); t != "" { + return t + } + return strings.TrimSpace(in.sum.SessionSummary) +} + +// enumerate walks every summary.json under /sessions and returns the +// sessions newest-first. A session directory without a readable summary.json is +// skipped: summary.json is the index entry, and Grok itself treats a directory +// without one as an images-only stub rather than a session. +func enumerate(root string) ([]sessionInfo, error) { + dir := filepath.Join(root, "sessions") + if _, err := os.Stat(dir); errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + var out []sessionInfo + err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || d.Name() != "summary.json" { + return nil + } + b, e := os.ReadFile(p) + if e != nil { + return nil + } + var s grokSummary + if json.Unmarshal(b, &s) != nil { + return nil + } + out = append(out, sessionInfo{dir: filepath.Dir(p), sum: s, when: summaryTime(s, p)}) + return nil + }) + if err != nil { + return nil, err + } + // Newest first. last_active_at is Grok's own recency field (it advances only + // when content is added), so it outranks updated_at, which metadata-only + // writes also touch; the file mtime is the last resort. The id breaks ties so + // the order is deterministic. + sort.SliceStable(out, func(i, j int) bool { + if !out[i].when.Equal(out[j].when) { + return out[i].when.After(out[j].when) + } + return out[i].sum.Info.ID < out[j].sum.Info.ID + }) + return out, nil +} + +// summaryTime reads a session's recency, falling back from last_active_at to +// updated_at to the summary file's own mtime. +func summaryTime(s grokSummary, summaryPath string) time.Time { + if t := session.ParseTime(s.LastActiveAt); !t.IsZero() { + return t + } + if t := session.ParseTime(s.UpdatedAt); !t.IsZero() { + return t + } + if info, err := os.Stat(summaryPath); err == nil { + return info.ModTime() + } + return time.Time{} +} + +// --- authoritative timeline (updates.jsonl) --------------------------------- + +// acpLine is one updates.jsonl record: a JSON-RPC notification whose params +// carry the discriminated update (kept raw and decoded per kind below) and an +// outer _meta with the millisecond wall-clock. +type acpLine struct { + Timestamp int64 `json:"timestamp"` + Params struct { + Update json.RawMessage `json:"update"` + Meta struct { + AgentTimestampMs int64 `json:"agentTimestampMs"` + } `json:"_meta"` + } `json:"params"` +} + +// acpKind decodes just the discriminator of an update. +type acpKind struct { + SessionUpdate string `json:"sessionUpdate"` +} + +// contentObject is a single content block, the shape a message chunk carries. +type contentObject struct { + Type string `json:"type"` + Text string `json:"text"` +} + +// The per-kind update shapes. Each is decoded independently from the raw +// update because one field name carries different shapes across kinds (content +// is an object for a message chunk and an array for a tool result), and one +// shared struct would fail a whole line on the first mismatch. +type acpUserChunk struct { + Content contentObject `json:"content"` + Meta struct { + HideFromScrollback json.RawMessage `json:"hideFromScrollback"` + } `json:"_meta"` +} + +type acpAssistantChunk struct { + Content contentObject `json:"content"` +} + +type acpToolCall struct { + ToolCallID string `json:"toolCallId"` + Title string `json:"title"` + RawInput json.RawMessage `json:"rawInput"` + Meta struct { + Tool struct { + Name string `json:"name"` + } `json:"x.ai/tool"` + } `json:"_meta"` +} + +type acpToolUpdate struct { + ToolCallID string `json:"toolCallId"` + Status string `json:"status"` + Title string `json:"title"` + Content json.RawMessage `json:"content"` + RawOutput json.RawMessage `json:"rawOutput"` +} + +type acpTaskCompleted struct { + TaskSnapshot struct { + TaskID string `json:"task_id"` + Output string `json:"output"` + ExitCode *int `json:"exit_code"` + } `json:"task_snapshot"` +} + +type acpTurnCompleted struct { + StopReason string `json:"stop_reason"` +} + +type acpCompaction struct { + CheckpointFile string `json:"checkpoint_file"` +} + +// acpIgnored names every updates.jsonl kind that is tooling or UI state rather +// than conversation, so a kind Grok adds later lands in the unknown warning +// instead of being silently dropped. +var acpIgnored = map[string]bool{ + "agent_thought_chunk": true, "hook_execution": true, "hook_annotation": true, + "plan": true, "goal_updated": true, "retry_state": true, + "background_tasks": true, "task_backgrounded": true, + "subagent_spawned": true, "subagent_finished": true, + "session_recap": true, "auto_compact_started": true, "auto_compact_completed": true, + "current_mode_update": true, "memory_dream_queued": true, "memory_dream_started": true, + "memory_dream_completed": true, "image_compressed": true, "model_changed": true, + "available_commands_update": true, "pending_interaction": true, "interaction_resolved": true, + "session_summary_generated": true, "tool_call_delta_chunk": true, +} + +// readUpdates parses updates.jsonl into the visible timeline, carrying a +// timestamp on every entry. stopAfterUser returns as soon as the first real user +// turn is read, which is all a listing preview needs; a full read sees the whole +// session. +func readUpdates(src session.Source, path string, stopAfterUser bool) (session.Thread, error) { + f, err := os.Open(path) + if err != nil { + return session.Thread{}, err + } + defer f.Close() + + var entries []session.Entry + var warnings []string + var unknown session.UnknownTypes + calls := map[string]toolCall{} + answered := map[string]bool{} + var abuf strings.Builder + var atime time.Time + gotUser := false + + flush := func() { + if abuf.Len() == 0 { + return + } + entries = append(entries, session.Entry{Kind: session.KindMessage, Role: session.RoleAssistant, Text: abuf.String(), Time: atime}) + abuf.Reset() + } + + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 64*1024), scanLine) + for sc.Scan() { + line := bytes.TrimSpace(sc.Bytes()) + if len(line) == 0 { + continue + } + var rec acpLine + if err := json.Unmarshal(line, &rec); err != nil { + warnings = append(warnings, session.ReadStopWarning(err)) + break + } + var kind acpKind + if json.Unmarshal(rec.Params.Update, &kind) != nil { + continue + } + ts := lineTime(rec.Params.Meta.AgentTimestampMs, rec.Timestamp) + raw := rec.Params.Update + + switch kind.SessionUpdate { + case "user_message_chunk": + var u acpUserChunk + if json.Unmarshal(raw, &u) != nil || u.Content.Type != "text" { + continue + } + // Grok marks injected context it hides from the user's own + // scrollback (monitor events, system reminders) with + // hideFromScrollback. Everything else is a real turn: a typed + // prompt, or a mid-turn interjection whose text is wrapped in + // , which extractUserQuery unwraps. + if hideBool(u.Meta.HideFromScrollback) { + continue + } + q := extractUserQuery(u.Content.Text) + if q == "" { + continue + } + flush() + entries = append(entries, session.Entry{Kind: session.KindMessage, Role: session.RoleUser, Text: q, Time: ts}) + gotUser = true + case "agent_message_chunk": + var a acpAssistantChunk + if json.Unmarshal(raw, &a) != nil || a.Content.Type != "text" || a.Content.Text == "" { + continue + } + if abuf.Len() == 0 { + atime = ts + } + abuf.WriteString(a.Content.Text) + case "tool_call": + var tc acpToolCall + if json.Unmarshal(raw, &tc) != nil || tc.ToolCallID == "" { + continue + } + flush() + name := tc.Title + if name == "" { + name = tc.Meta.Tool.Name + } + calls[tc.ToolCallID] = toolCall{name: name, input: tc.RawInput} + case "tool_call_update": + var tu acpToolUpdate + if json.Unmarshal(raw, &tu) != nil || tu.Status != "failed" || tu.ToolCallID == "" || answered[tu.ToolCallID] { + continue + } + answered[tu.ToolCallID] = true + flush() + c := calls[tu.ToolCallID] + name := c.name + if name == "" { + name = tu.Title + } + entries = append(entries, session.Failure(name, c.input, toolFailureText(tu.Content, tu.RawOutput), ts)) + case "task_completed": + var t acpTaskCompleted + if json.Unmarshal(raw, &t) != nil { + continue + } + snap := t.TaskSnapshot + if snap.ExitCode == nil || *snap.ExitCode == 0 || answered[snap.TaskID] { + continue + } + answered[snap.TaskID] = true + flush() + c := calls[snap.TaskID] + entries = append(entries, session.Failure(c.name, c.input, snap.Output, ts)) + case "turn_completed": + var t acpTurnCompleted + if json.Unmarshal(raw, &t) != nil { + continue + } + flush() + if t.StopReason == "error" { + entries = append(entries, session.Entry{Kind: session.KindStop, Reason: "error", Time: ts}) + } + case "compaction_checkpoint": + var c acpCompaction + if json.Unmarshal(raw, &c) != nil { + continue + } + flush() + entries = append(entries, session.Entry{Kind: session.KindCompact, Text: checkpointSummary(src.Path, c.CheckpointFile), Time: ts}) + default: + if acpIgnored[kind.SessionUpdate] { + continue + } + unknown.Add(kind.SessionUpdate) + } + if stopAfterUser && gotUser { + break + } + } + if err := sc.Err(); err != nil { + warnings = append(warnings, session.ReadStopWarning(err)) + } + flush() + return session.Thread{Source: src, Entries: entries, Warnings: unknown.AppendTo(warnings)}, nil +} + +// lineTime picks the millisecond wall-clock when present, else the enclosing +// line's epoch-second timestamp. +func lineTime(ms, secs int64) time.Time { + if ms != 0 { + return time.UnixMilli(ms) + } + if secs != 0 { + return time.Unix(secs, 0) + } + return time.Time{} +} + +// hideBool reads hideFromScrollback, which Grok has written both as a JSON +// boolean and as the string "True". +func hideBool(raw json.RawMessage) bool { + s := strings.TrimSpace(string(raw)) + return s == "true" || strings.EqualFold(s, `"true"`) +} + +// textArray joins the text of a content block array, the shape tool_call_update +// carries: blocks are {"type":"text","text"} or a {"type":"content", +// "content":{"type":"text","text"}} wrapper. +func textArray(raw json.RawMessage) string { + if len(raw) == 0 || raw[0] != '[' { + return "" + } + var blocks []struct { + Type string `json:"type"` + Text string `json:"text"` + Content *struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } + if json.Unmarshal(raw, &blocks) != nil { + return "" + } + var parts []string + for _, b := range blocks { + if b.Content != nil && b.Content.Text != "" { + parts = append(parts, b.Content.Text) + } else if b.Text != "" { + parts = append(parts, b.Text) + } + } + return strings.Join(parts, "\n") +} + +// toolFailureText reads a failed call's output: the content blocks first, then +// rawOutput, which is a plain string or an object with a nested text field. +func toolFailureText(content, rawOutput json.RawMessage) string { + if s := textArray(content); s != "" { + return s + } + if s := rawOutputText(rawOutput); s != "" { + return s + } + return "tool call failed" +} + +func rawOutputText(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var s string + if json.Unmarshal(raw, &s) == nil { + return s + } + var obj map[string]json.RawMessage + if json.Unmarshal(raw, &obj) != nil { + return string(raw) + } + for _, key := range []string{"content", "output", "text", "stdout", "error", "message"} { + if v, ok := obj[key]; ok { + var inner string + if json.Unmarshal(v, &inner) == nil && strings.TrimSpace(inner) != "" { + return inner + } + } + } + return string(raw) +} + +// checkpointSummary reads the compaction summary Grok saved for a seam, so +// --since-compact and a bare read show what replaced the context rather than an +// empty marker. It is a best effort: a missing or reshaped file leaves the +// marker bare. +func checkpointSummary(sessionDir, file string) string { + if file == "" { + return "" + } + b, err := os.ReadFile(filepath.Join(sessionDir, file)) + if err != nil { + return "" + } + var c struct { + CompactedHistory []struct { + Type string `json:"type"` + Content json.RawMessage `json:"content"` + SyntheticReason *string `json:"synthetic_reason"` + } `json:"compacted_history"` + } + if json.Unmarshal(b, &c) != nil { + return "" + } + var parts []string + for _, it := range c.CompactedHistory { + if it.Type == "user" && it.SyntheticReason != nil && *it.SyntheticReason == "compaction_meta" { + parts = append(parts, textArray(it.Content)) + } + } + return strings.Join(parts, "\n") +} + +// --- fallback timeline (chat_history.jsonl) --------------------------------- + +// grokUser decodes a user record's content blocks and its synthetic marker. +type grokUser struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + SyntheticReason *string `json:"synthetic_reason"` +} + +// grokAssistant decodes an assistant record. content is a plain string, empty +// on a tool-only row. +type grokAssistant struct { + Content string `json:"content"` + ModelID string `json:"model_id"` +} + +// toolCall is the name and structured input of an assistant tool call. +type toolCall struct { + name string + input json.RawMessage +} + +// readChat parses chat_history.jsonl. It is the fallback for a session with no +// (or an oversized) updates.jsonl. It has no timestamps or error flags. +// A missing transcript is not an error. +func readChat(src session.Source, path string) (session.Thread, error) { + f, err := os.Open(path) + if errors.Is(err, fs.ErrNotExist) { + return session.Thread{Source: src}, nil + } + if err != nil { + return session.Thread{}, err + } + defer f.Close() + + var entries []session.Entry + var warnings []string + var unknown session.UnknownTypes + model := "" + + dec := json.NewDecoder(f) + for dec.More() { + var raw json.RawMessage + if err := dec.Decode(&raw); err != nil { + warnings = append(warnings, session.ReadStopWarning(err)) + break + } + var rec struct { + Type string `json:"type"` + } + if json.Unmarshal(raw, &rec) != nil { + continue + } + switch rec.Type { + case "user": + var u grokUser + if json.Unmarshal(raw, &u) != nil { + continue + } + text := userText(u) + if u.SyntheticReason != nil { + if *u.SyntheticReason == "compaction_meta" { + markRetained(entries) + entries = append(entries, session.Entry{Kind: session.KindCompact, Text: text}) + } + continue + } + if q := extractUserQuery(text); q != "" { + entries = append(entries, session.Entry{Kind: session.KindMessage, Role: session.RoleUser, Text: q}) + } + case "assistant": + var a grokAssistant + if json.Unmarshal(raw, &a) != nil { + continue + } + if a.ModelID != "" { + model = a.ModelID + } + if strings.TrimSpace(a.Content) != "" { + entries = append(entries, session.Entry{Kind: session.KindMessage, Role: session.RoleAssistant, Text: a.Content}) + } + case "system", "reasoning", "tool_result", "backend_tool_call", "custom_tool_output": + // The system prompt, model scratch work, and tool plumbing. + default: + unknown.Add(rec.Type) + } + } + + if model != "" { + if src.Metadata == nil { + src.Metadata = map[string]string{} + } + src.Metadata["model"] = model + } + return session.Thread{Source: src, Entries: entries, Warnings: unknown.AppendTo(warnings)}, nil +} + +// userText joins a user record's text blocks with newlines; image blocks carry +// no text. +func userText(u grokUser) string { + var parts []string + for _, b := range u.Content { + if b.Type == "text" && b.Text != "" { + parts = append(parts, b.Text) + } + } + return strings.Join(parts, "\n") +} + +const ( + userQueryOpen = "" + userQueryClose = "" +) + +// extractUserQuery returns a real user turn's words. Grok wraps a prompt with +// injected context, the actual request inside …; when +// the tags are absent the whole text is returned (so this also serves the +// fallback reader, whose real user record may be the bare prompt). +func extractUserQuery(text string) string { + i := strings.Index(text, userQueryOpen) + if i < 0 { + return strings.TrimSpace(text) + } + rest := text[i+len(userQueryOpen):] + if j := strings.Index(rest, userQueryClose); j >= 0 { + return strings.TrimSpace(rest[:j]) + } + return strings.TrimSpace(rest) +} + +// markRetained flags the messages already read as having survived the +// compaction about to be marked. Only the fallback reader needs it: chat_history +// is replaced at a compaction, so everything still in the file was handed back +// to the model, and --since-compact must keep it rather than cut it away. The +// updates.jsonl reader keeps the whole history, so it needs no marks. +func markRetained(entries []session.Entry) { + for i := range entries { + switch entries[i].Kind { + case session.KindMessage, session.KindFailure: + entries[i].Retained = true + } + } +} diff --git a/internal/grok/grok_test.go b/internal/grok/grok_test.go new file mode 100644 index 0000000..1480510 --- /dev/null +++ b/internal/grok/grok_test.go @@ -0,0 +1,292 @@ +package grok + +import ( + "context" + _ "embed" + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/wilbeibi/catchup/internal/session" +) + +// Sanitized excerpts from session 01a0be48-8eca-7633-ba5c-f2f58d58a384, +// inspected with grok 1.0.41 (4220f3b224a6) on 2026-09-27. Updates source +// lines: 4,5,6,8,14,20,27,29,33,957,2027,7164,7890,14971,34313,34314. +// Chat lines: 1,2,3,4,5,7,8. The checkpoint is the one referenced on line 2027. +// Text, paths and IDs are replaced; timestamps are renumbered and telemetry +// omitted. Update/content shapes and relative ordering are preserved, including +// promptIndex on real turns and both compaction_meta records around a retained +// user turn. Rows added below for corrupt/unknown input are deliberate mutations. +// These excerpts establish the observed format, not compatibility with every +// Grok version. Listing visibility also follows upstream persistence.rs at +// xai-org/grok-build@f0e3be1100ef5252488e3be8bb0e91cf68d8c305. + +//go:embed testdata/updates.jsonl +var acpLog string + +//go:embed testdata/chat_history.jsonl +var chatLog string + +//go:embed testdata/checkpoint.json +var checkpoint string + +const cwd = "/home/u/src/proj" + +func writeSession(t *testing.T, root, group, id string, summary map[string]any, chat, updates string, mod time.Time) string { + t.Helper() + dir := filepath.Join(root, "sessions", group, id) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + files := map[string]string{chatFile: chat, updatesFile: updates} + if summary != nil { + b, err := json.Marshal(summary) + if err != nil { + t.Fatal(err) + } + files["summary.json"] = string(b) + } + for name, body := range files { + if body == "" { + continue + } + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, mod, mod); err != nil { + t.Fatal(err) + } + } + return dir +} + +func TestRead(t *testing.T) { + at := func(n int64) time.Time { return time.UnixMilli(n * 1000) } + // Literal expectations are independent of session.Failure and the parser. + updates := []session.Entry{ + {Kind: "message", Role: "user", Text: "support grok", Time: at(1)}, + {Kind: "message", Role: "assistant", Text: "I will read the log.", Time: at(3)}, + {Kind: "failure", Role: "tool", Tool: "list_dir", Input: `{"target_directory":"redacted"}`, Text: `{"type":"ListDir","NotFound":"redacted"}`, Time: at(7)}, + {Kind: "failure", Role: "tool", Tool: "read_file", Input: `{"target_file":"a.txt","limit":80}`, Text: "read failed", Time: at(8)}, + {Kind: "failure", Role: "tool", Text: "background failed", Time: at(10)}, + {Kind: "compact", Text: "injected context\nthe compaction summary", Time: at(11)}, + {Kind: "message", Role: "user", Text: "finish it", Time: at(13)}, + {Kind: "stop", Reason: "error", Time: at(14)}, + {Kind: "message", Role: "assistant", Text: "all done", Time: at(15)}, + } + chat := []session.Entry{ + {Kind: "compact", Text: "injected context"}, + {Kind: "message", Role: "user", Text: "support grok", Retained: true}, + {Kind: "compact", Text: "the compaction summary"}, + {Kind: "message", Role: "assistant", Text: "I will read the log."}, + } + loss := "without timestamps, tool failures, or stop reasons; pre-compaction history may be missing" + unknown := `{"params":{"update":{"sessionUpdate":"future_kind"}}}` + "\n" + for _, tc := range []struct { + name, updates, chat string + want []session.Entry + warnings []string + }{ + {"updates", acpLog, chatLog, updates, nil}, + {"string visibility", strings.Replace(acpLog, `"hideFromScrollback":true`, `"hideFromScrollback":"True"`, 1), "", updates, nil}, + {"timestamp fallback", strings.Replace(acpLog, `"agentTimestampMs":1000`, `"unused":1000`, 1), "", updates, nil}, + {"duplicate failure", acpLog + strings.Split(acpLog, "\n")[7] + "\n", "", updates, nil}, + {"torn updates", acpLog + `{"params":`, "", updates, []string{"malformed record"}}, + {"unknown update", acpLog + unknown, "", updates, []string{"future_kind"}}, + {"missing updates", "", chatLog, chat, []string{"updates.jsonl is missing", loss}}, + {"empty updates", "\n", chatLog, chat, []string{"no readable conversation", loss}}, + {"malformed updates", `{"params":`, chatLog, chat, []string{"malformed record", loss}}, + {"unknown only", unknown, chatLog, chat, []string{"future_kind", loss}}, + {"oversized updates", acpLog, chatLog, chat, []string{"over the 512 MB limit", loss}}, + {"oversized without fallback", acpLog, "", nil, []string{"over the 512 MB limit", loss}}, + {"torn chat", "", chatLog + `{"type":`, chat, []string{"last record is incomplete", loss}}, + {"unknown chat", "", chatLog + `{"type":"future_kind"}`, chat, []string{"future_kind", loss}}, + {"no transcript", "", "", nil, nil}, + {"malformed without fallback", `{"params":`, "", nil, []string{"malformed record"}}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := writeSession(t, t.TempDir(), "group", "id", nil, tc.chat, tc.updates, time.Now()) + cp := filepath.Join(dir, "compaction_checkpoints") + if err := os.Mkdir(cp, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cp, "cp1.json"), []byte(checkpoint), 0o644); err != nil { + t.Fatal(err) + } + if strings.HasPrefix(tc.name, "oversized") { + if err := os.Truncate(filepath.Join(dir, updatesFile), updatesMaxBytes+1); err != nil { + t.Fatal(err) + } + } + th, err := New().Read(context.Background(), session.Source{Path: dir}) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(th.Entries, tc.want) { + t.Fatalf("entries =\n%+v\nwant\n%+v", th.Entries, tc.want) + } + if len(tc.warnings) == 0 && len(th.Warnings) != 0 { + t.Fatalf("unexpected warnings: %v", th.Warnings) + } + for _, want := range tc.warnings { + if !strings.Contains(strings.Join(th.Warnings, "\n"), want) { + t.Errorf("warnings = %v, want %q", th.Warnings, want) + } + } + if len(th.Entries) > 0 && th.Entries[0].Kind == "compact" && th.Source.Metadata["model"] != "grok-build" { + t.Errorf("fallback model = %q", th.Source.Metadata["model"]) + } + }) + } +} + +func TestReadSourceErrors(t *testing.T) { + file := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(file, nil, 0o644); err != nil { + t.Fatal(err) + } + for _, path := range []string{"", file} { + if _, err := New().Read(context.Background(), session.Source{Path: path}); err == nil { + t.Errorf("read %q: expected an error", path) + } + } +} + +func TestListAndResolve(t *testing.T) { + root := t.TempDir() + roots := session.Roots{Grok: root} + old, fresh := time.Unix(100, 0), time.Unix(200, 0) + for _, tc := range []struct { + id, group, dir, title string + active time.Time + mtime time.Time + }{ + {"new", "%2Fhome%2Fu%2Fsrc%2Fproj", cwd, "grok support", fresh, old}, + {"old", "other-abc123", "/other", "other session", old, fresh}, + } { + summary := map[string]any{ + "info": map[string]string{"id": tc.id, "cwd": tc.dir}, + "generated_title": tc.title, "session_summary": "longer summary", + "current_model_id": "grok-build", "last_active_at": tc.active.Format(time.RFC3339), + } + writeSession(t, root, tc.group, tc.id, summary, chatLog, acpLog, tc.mtime) + } + p := New() + for _, tc := range []struct { + name string + opts session.ListOptions + ids []string + }{ + {"all", session.ListOptions{}, []string{"new", "old"}}, + {"cwd", session.ListOptions{Cwd: "/other"}, []string{"old"}}, + {"limit", session.ListOptions{Limit: 1}, []string{"new"}}, + {"pre-compaction query", session.ListOptions{Query: "read failed"}, []string{"new", "old"}}, + {"post-compaction query", session.ListOptions{Query: "finish it"}, []string{"new", "old"}}, + {"hidden context", session.ListOptions{Query: "reminder_token"}, nil}, + {"reasoning", session.ListOptions{Query: "reasoning-only-token"}, nil}, + {"successful tool", session.ListOptions{Query: "tool-only-token"}, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + rows, err := p.List(context.Background(), roots, tc.opts) + if err != nil || len(rows) != len(tc.ids) { + t.Fatalf("rows = %+v, %v; want ids %v", rows, err, tc.ids) + } + for i, row := range rows { + if row.Ref.SessionID != tc.ids[i] || row.Rank != i+1 || row.Preview != "support grok" { + t.Errorf("row %d = %+v", i, row) + } + src, err := p.Resolve(context.Background(), roots, row.Ref.SessionID) + if err != nil || src.Ref != row.Ref || src.Metadata["cwd"] != row.Cwd { + t.Errorf("resolve listed id = %+v, %v", src, err) + } + } + }) + } + src, err := p.Resolve(context.Background(), roots, "") + if err != nil || src.Ref.SessionID != "new" || src.Metadata["title"] != "grok support" || src.Metadata["model"] != "grok-build" || !src.UpdatedAt.Equal(fresh) { + t.Fatalf("newest source = %+v, %v", src, err) + } + if _, err := p.Resolve(context.Background(), roots, "missing"); err == nil { + t.Fatal("unknown id resolved") + } +} + +func TestVisibility(t *testing.T) { + for _, tc := range []struct { + name string + fields map[string]any + listed bool + }{ + {"normal", map[string]any{"num_messages": 1}, true}, + {"hidden", map[string]any{"hidden": true, "num_messages": 1}, false}, + {"subagent", map[string]any{"session_kind": "subagent", "num_messages": 1}, false}, + {"subagent resume", map[string]any{"session_kind": "subagent_resume", "num_messages": 1}, false}, + {"explicit visible", map[string]any{"session_kind": "subagent", "hidden": false, "num_messages": 1}, true}, + {"husk", map[string]any{}, false}, + {"titled", map[string]any{"generated_title": "title"}, true}, + {"fork", map[string]any{"session_kind": "fork"}, true}, + {"parent", map[string]any{"parent_session_id": "parent"}, true}, + {"fork timestamp", map[string]any{"forked_at": "2026-07-18T00:00:00Z"}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + writeSession(t, root, "group", "id", tc.fields, chatLog, "", time.Now()) + p, roots := New(), session.Roots{Grok: root} + rows, err := p.List(context.Background(), roots, session.ListOptions{}) + if err != nil || (len(rows) == 1) != tc.listed { + t.Fatalf("rows = %+v, %v; listed = %v", rows, err, tc.listed) + } + if _, err := p.Resolve(context.Background(), roots, "id"); err != nil { + t.Fatalf("explicit id: %v", err) + } + _, err = p.Resolve(context.Background(), roots, "") + if (err == nil) != tc.listed { + t.Errorf("newest error = %v; listed = %v", err, tc.listed) + } + }) + } +} + +func TestIndexFallbacks(t *testing.T) { + for _, tc := range []struct { + name, title string + summary map[string]any + chat string + resolves bool + }{ + {"summary title", "summary", map[string]any{"session_summary": "summary", "updated_at": "1970-01-01T00:01:40Z"}, chatLog, true}, + {"cwd title", "proj", map[string]any{"info": map[string]string{"cwd": cwd}, "num_messages": 1}, chatLog, true}, + {"no transcript", "", map[string]any{"num_messages": 1}, "", true}, + {"no summary", "", nil, chatLog, false}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + writeSession(t, root, "slug-hash", "id", tc.summary, tc.chat, "", time.Unix(100, 0)) + p, roots := New(), session.Roots{Grok: root} + src, err := p.Resolve(context.Background(), roots, "id") + if (err == nil) != tc.resolves { + t.Fatalf("resolve = %+v, %v", src, err) + } + if tc.resolves && (src.Ref.SessionID != "id" || src.Metadata["title"] != tc.title || !src.UpdatedAt.Equal(time.Unix(100, 0))) { + t.Errorf("source = %+v", src) + } + rows, err := p.List(context.Background(), roots, session.ListOptions{}) + if err != nil || (len(rows) == 1) != (tc.resolves && tc.chat != "") { + t.Errorf("list = %+v, %v", rows, err) + } + }) + } + p, roots := New(), session.Roots{Grok: t.TempDir()} + if _, err := p.Resolve(context.Background(), roots, ""); err == nil { + t.Fatal("empty root resolved") + } + if rows, err := p.List(context.Background(), roots, session.ListOptions{}); err != nil || len(rows) != 0 { + t.Fatalf("empty root = %+v, %v", rows, err) + } +} diff --git a/internal/grok/testdata/chat_history.jsonl b/internal/grok/testdata/chat_history.jsonl new file mode 100644 index 0000000..2daefab --- /dev/null +++ b/internal/grok/testdata/chat_history.jsonl @@ -0,0 +1,7 @@ +{"type":"system","content":"system-only-token"} +{"type":"user","content":[{"type":"text","text":"injected context"}],"synthetic_reason":"compaction_meta"} +{"type":"user","content":[{"type":"text","text":"support grok"}]} +{"type":"user","content":[{"type":"text","text":"the compaction summary"}],"synthetic_reason":"compaction_meta"} +{"type":"user","content":[{"type":"text","text":"reminder_token"}],"synthetic_reason":"system_reminder"} +{"type":"reasoning","id":"redacted","summary":[{"type":"summary_text","text":"redacted"}],"encrypted_content":"redacted","status":"completed"} +{"type":"assistant","content":"I will read the log.","tool_calls":[{"id":"redacted","name":"todo_write","arguments":"redacted"},{"id":"redacted","name":"read_file","arguments":"redacted"},{"id":"redacted","name":"run_terminal_command","arguments":"redacted"}],"model_id":"grok-build","model_fingerprint":"redacted","reasoning_effort":"redacted"} diff --git a/internal/grok/testdata/checkpoint.json b/internal/grok/testdata/checkpoint.json new file mode 100644 index 0000000..2977b8a --- /dev/null +++ b/internal/grok/testdata/checkpoint.json @@ -0,0 +1 @@ +{"checkpoint_id":"cp1","prompt_index_at_compaction":6,"compacted_history":[{"type":"system","content":"system-only-token"},{"type":"user","content":[{"type":"text","text":"injected context"}],"synthetic_reason":"compaction_meta"},{"type":"user","content":[{"type":"text","text":"support grok"}]},{"type":"user","content":[{"type":"text","text":"the compaction summary"}],"synthetic_reason":"compaction_meta"},{"type":"user","content":[{"type":"text","text":"reminder_token"}],"synthetic_reason":"system_reminder"}],"schema_version":1,"created_at":"redacted","original_user_info":"redacted","reread_file_paths":[]} diff --git a/internal/grok/testdata/updates.jsonl b/internal/grok/testdata/updates.jsonl new file mode 100644 index 0000000..92a7b99 --- /dev/null +++ b/internal/grok/testdata/updates.jsonl @@ -0,0 +1,16 @@ +{"timestamp":1,"method":"session/update","params":{"sessionId":"fixture-session","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"support grok"},"_meta":{"modelId":"grok-build","promptIndex":0}},"_meta":{"agentTimestampMs":1000}}} +{"timestamp":2,"method":"session/update","params":{"sessionId":"fixture-session","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"reasoning-only-token"}},"_meta":{"agentTimestampMs":2000}}} +{"timestamp":3,"method":"session/update","params":{"sessionId":"fixture-session","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I will read the log."}},"_meta":{"agentTimestampMs":3000}}} +{"timestamp":4,"method":"session/update","params":{"sessionId":"fixture-session","update":{"sessionUpdate":"tool_call","toolCallId":"tool-1","title":"read_file","rawInput":{"target_file":"a.txt","limit":80},"_meta":{"x.ai/tool":{"version":1,"name":"read_file","kind":"read","namespace":"grok_build","label":"redacted","read_only":true}}},"_meta":{"agentTimestampMs":4000}}} +{"timestamp":5,"method":"session/update","params":{"sessionId":"fixture-session","update":{"sessionUpdate":"tool_call","toolCallId":"tool-2","title":"list_dir","rawInput":{"target_directory":"redacted"},"_meta":{"x.ai/tool":{"version":1,"name":"list_dir","kind":"list","namespace":"grok_build","label":"redacted","read_only":true}}},"_meta":{"agentTimestampMs":5000}}} +{"timestamp":6,"method":"session/update","params":{"sessionId":"fixture-session","update":{"sessionUpdate":"tool_call","toolCallId":"tool-3","title":"read_file","rawInput":{"target_file":"a.txt","limit":150},"_meta":{"x.ai/tool":{"version":1,"name":"read_file","kind":"read","namespace":"grok_build","label":"redacted","read_only":true}}},"_meta":{"agentTimestampMs":6000}}} +{"timestamp":7,"method":"session/update","params":{"sessionId":"fixture-session","update":{"sessionUpdate":"tool_call_update","toolCallId":"tool-2","status":"failed","rawOutput":{"type":"ListDir","NotFound":"redacted"}},"_meta":{"agentTimestampMs":7000}}} +{"timestamp":8,"method":"session/update","params":{"sessionId":"fixture-session","update":{"sessionUpdate":"tool_call_update","toolCallId":"tool-1","status":"failed","content":[{"type":"content","content":{"type":"text","text":"read failed"}}],"rawOutput":{"type":"ReadFile","FileNotFound":"redacted"}},"_meta":{"agentTimestampMs":8000}}} +{"timestamp":9,"method":"session/update","params":{"sessionId":"fixture-session","update":{"sessionUpdate":"tool_call_update","toolCallId":"tool-3","status":"completed","content":[{"type":"content","content":{"type":"text","text":"tool-only-token"}}],"rawOutput":{"type":"ReadFile","FileContent":{"content":"redacted","content_concise":"redacted","absolute_path":"redacted","offset":null,"raw_output":"redacted","total_lines":10}}},"_meta":{"agentTimestampMs":9000}}} +{"timestamp":10,"method":"_x.ai/session/update","params":{"sessionId":"fixture-session","update":{"sessionUpdate":"task_completed","task_snapshot":{"task_id":"background-1","command":"redacted","cwd":"redacted","start_time":{"secs_since_epoch":1,"nanos_since_epoch":0},"end_time":{"secs_since_epoch":1,"nanos_since_epoch":0},"output":"background failed","output_file":"redacted","truncated":false,"output_total_bytes":17,"exit_code":1,"signal":null,"completed":true,"kind":"bash","block_waited":false,"explicitly_killed":false,"kill_result_delivered":false,"owner_session_id":"redacted","description":"redacted","is_backgrounded":true},"will_wake":true},"_meta":{"agentTimestampMs":10000}}} +{"timestamp":11,"method":"_x.ai/session/update","params":{"sessionId":"fixture-session","update":{"sessionUpdate":"compaction_checkpoint","checkpoint_id":"cp1","prompt_index_at_compaction":6,"checkpoint_file":"compaction_checkpoints/cp1.json","schema_version":1,"created_at":"redacted"},"_meta":{"agentTimestampMs":11000}}} +{"timestamp":12,"method":"session/update","params":{"sessionId":"fixture-session","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"reminder_token"},"_meta":{"modelId":"grok-build","promptIndex":11,"hideFromScrollback":true}},"_meta":{"agentTimestampMs":12000}}} +{"timestamp":13,"method":"session/update","params":{"sessionId":"fixture-session","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"finish it"},"_meta":{"modelId":"grok-build","promptIndex":17}},"_meta":{"agentTimestampMs":13000}}} +{"timestamp":14,"method":"_x.ai/session/update","params":{"sessionId":"fixture-session","update":{"sessionUpdate":"turn_completed","prompt_id":"redacted","stop_reason":"error","agent_result":"redacted"},"_meta":{"agentTimestampMs":14000}}} +{"timestamp":15,"method":"session/update","params":{"sessionId":"fixture-session","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"all "}},"_meta":{"agentTimestampMs":15000}}} +{"timestamp":16,"method":"session/update","params":{"sessionId":"fixture-session","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"done"}},"_meta":{"agentTimestampMs":16000}}} diff --git a/internal/session/roots.go b/internal/session/roots.go index f809ffc..b9ff590 100644 --- a/internal/session/roots.go +++ b/internal/session/roots.go @@ -17,6 +17,7 @@ import "path/filepath" // ZCode : $ZCODE_HOME else /.zcode/cli/db (the dir holding db.sqlite) // DeepSeek : $DSH_HOME else /.dsh // Copilot : $COPILOT_HOME else /.copilot +// Grok : $GROK_HOME else /.grok // // getenv and home are passed in rather than read from the os package so that // root resolution is a pure function and can be tested without touching the @@ -89,7 +90,13 @@ func ResolveRoots(getenv func(string) string, home string) Roots { } amp = filepath.Join(base, "amp") } - return Roots{Amp: amp, Codex: codex, Claude: claude, Agy: agy, OpenCode: opencode, PiAgent: piAgent, Kimi: kimi, Cline: cline, Cursor: cursor, ZCode: zcode, DeepSeek: deepseek, Copilot: copilot} + + grok := getenv("GROK_HOME") + if grok == "" { + grok = filepath.Join(home, ".grok") + } + + return Roots{Amp: amp, Codex: codex, Claude: claude, Agy: agy, OpenCode: opencode, PiAgent: piAgent, Kimi: kimi, Cline: cline, Cursor: cursor, ZCode: zcode, DeepSeek: deepseek, Copilot: copilot, Grok: grok} } // ResolveSkillDirs returns each provider's global Agent Skills directory, @@ -118,6 +125,7 @@ func ResolveRoots(getenv func(string) string, home string) Roots { // also discovers ~/.agents/skills — Codex's entry, same reasoning) // DeepSeek : roots.DeepSeek/skills (respects $DSH_HOME; dsh also // discovers ~/.agents/skills — Codex's entry, same reasoning) +// Grok : roots.Grok/skills (respects $GROK_HOME) func ResolveSkillDirs(roots Roots, home string) map[string]string { return map[string]string{ ProviderAmp: filepath.Join(home, ".config", "amp", "skills"), @@ -132,6 +140,7 @@ func ResolveSkillDirs(roots Roots, home string) map[string]string { ProviderZCode: filepath.Join(home, ".agents", "skills"), ProviderDeepSeek: filepath.Join(roots.DeepSeek, "skills"), ProviderCopilot: filepath.Join(roots.Copilot, "skills"), + ProviderGrok: filepath.Join(roots.Grok, "skills"), } } diff --git a/internal/session/roots_test.go b/internal/session/roots_test.go index b8cadef..dec272e 100644 --- a/internal/session/roots_test.go +++ b/internal/session/roots_test.go @@ -12,7 +12,7 @@ import ( var allProviders = []string{ ProviderCodex, ProviderClaude, ProviderAgy, ProviderOpenCode, ProviderPiAgent, ProviderKimi, ProviderCline, ProviderCursor, - ProviderZCode, ProviderDeepSeek, ProviderCopilot, ProviderAmp, + ProviderZCode, ProviderDeepSeek, ProviderCopilot, ProviderAmp, ProviderGrok, } // noEnv is the environment of a machine that overrides nothing. @@ -43,6 +43,7 @@ func TestResolveRootsDefaults(t *testing.T) { ZCode: filepath.Join(home, ".zcode", "cli", "db"), DeepSeek: filepath.Join(home, ".dsh"), Copilot: filepath.Join(home, ".copilot"), + Grok: filepath.Join(home, ".grok"), } if got != want { t.Fatalf("roots =\n%+v\nwant\n%+v", got, want) @@ -65,6 +66,7 @@ func TestResolveRootsOverrides(t *testing.T) { {"CLINE_DIR", map[string]string{"CLINE_DIR": dir}, func(r Roots) string { return r.Cline }, dir}, {"ZCODE_HOME", map[string]string{"ZCODE_HOME": dir}, func(r Roots) string { return r.ZCode }, dir}, {"DSH_HOME", map[string]string{"DSH_HOME": dir}, func(r Roots) string { return r.DeepSeek }, dir}, + {"GROK_HOME", map[string]string{"GROK_HOME": dir}, func(r Roots) string { return r.Grok }, dir}, // OpenCode hangs off the XDG data dir, not the variable's bare value. {"XDG_DATA_HOME", map[string]string{"XDG_DATA_HOME": dir}, func(r Roots) string { return r.OpenCode }, filepath.Join(dir, "opencode")}, // Cursor prefers its own variable, then XDG, then home. @@ -87,7 +89,7 @@ func TestResolveRootsAreAbsolute(t *testing.T) { for name, path := range map[string]string{ "Codex": r.Codex, "Claude": r.Claude, "Agy": r.Agy, "OpenCode": r.OpenCode, "PiAgent": r.PiAgent, "Kimi": r.Kimi, "Cline": r.Cline, "Cursor": r.Cursor, - "ZCode": r.ZCode, "DeepSeek": r.DeepSeek, + "ZCode": r.ZCode, "DeepSeek": r.DeepSeek, "Grok": r.Grok, } { if path == "" { t.Errorf("%s root is empty", name) @@ -122,16 +124,18 @@ func TestResolveSkillDirsCoverEveryProvider(t *testing.T) { // The skill dirs that follow a provider's history root must track an override // of that root; the ones pinned to a fixed convention must not. func TestResolveSkillDirsFollowOverrides(t *testing.T) { - home, claude, dsh, data := t.TempDir(), t.TempDir(), t.TempDir(), t.TempDir() + home, claude, dsh, grok, data := t.TempDir(), t.TempDir(), t.TempDir(), t.TempDir(), t.TempDir() env := map[string]string{ "CLAUDE_CONFIG_DIR": claude, "DSH_HOME": dsh, + "GROK_HOME": grok, "XDG_DATA_HOME": data, } dirs := ResolveSkillDirs(ResolveRoots(envFrom(env), home), home) for p, want := range map[string]string{ ProviderClaude: filepath.Join(claude, "skills"), ProviderDeepSeek: filepath.Join(dsh, "skills"), + ProviderGrok: filepath.Join(grok, "skills"), // OpenCode discovers skills under ~/.config, not $XDG_DATA_HOME, so // its history override must leave the skill dir alone — and the dir // must still be rooted at home. diff --git a/internal/session/session.go b/internal/session/session.go index daaa820..8b9ec3b 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -33,6 +33,7 @@ const ( ProviderZCode = "zcode" // ZCode (Z.ai) desktop agent ProviderDeepSeek = "deepseek" // DeepSeek Harness (dsh) ProviderCopilot = "copilot" // GitHub Copilot CLI + ProviderGrok = "grok" // Grok Build (xAI's grok CLI) ) // Providers lists every provider name, in the order catchup documents them. @@ -40,7 +41,8 @@ const ( // by hand and checked against this; machine-read lists build from it. var Providers = []string{ ProviderAmp, ProviderCodex, ProviderClaude, ProviderAgy, ProviderCline, ProviderCopilot, - ProviderCursor, ProviderDeepSeek, ProviderKimi, ProviderOpenCode, ProviderPiAgent, ProviderZCode, + ProviderCursor, ProviderDeepSeek, ProviderGrok, ProviderKimi, ProviderOpenCode, + ProviderPiAgent, ProviderZCode, } // Entry kinds and message roles. Providers normalize their own wire formats @@ -106,6 +108,7 @@ type Roots struct { ZCode string DeepSeek string Copilot string + Grok string } // Source is a located session: enough to read it and to describe it in a