From 49eaabc6729af529eb012b1d1ecda017e0b9e5c8 Mon Sep 17 00:00:00 2001 From: Hongyi Shen Date: Mon, 24 Aug 2026 21:17:55 -0700 Subject: [PATCH 1/3] Read and fork GitHub Copilot CLI sessions Copilot keeps one directory per session under $COPILOT_HOME/session-state: workspace.yaml for the metadata, events.jsonl for the append-only log. Resuming appends to the same log, so a session is always one file. On the timeline: user.message content (the raw text, not the transformedContent copy that carries injected datetime and system-reminder context) and assistant.message content, which is empty on turns that only request tools. Sub-agent traffic arrives under subagent.* types and stays off the timeline. Recency comes from the log's mtime, not workspace.yaml's updated_at, which lags a session that is still appending. Both handoff halves are native: `copilot --resume=` forks, `copilot -i ` seeds. The id goes inline because a bare --resume opens the session picker and would swallow a separated argument. Copilot also exports COPILOT_AGENT_SESSION_ID into every shell it spawns, so a catchup run from inside a session resolves that session instead of guessing by recency. Fixes #8 --- README.md | 4 +- SKILL.md | 4 +- internal/cli/cli.go | 20 +- internal/copilot/copilot.go | 326 +++++++++++++++++++++++++++++++ internal/copilot/copilot_test.go | 244 +++++++++++++++++++++++ internal/session/roots.go | 24 ++- internal/session/roots_test.go | 8 +- internal/session/session.go | 2 + 8 files changed, 620 insertions(+), 12 deletions(-) create mode 100644 internal/copilot/copilot.go create mode 100644 internal/copilot/copilot_test.go diff --git a/README.md b/README.md index 990065c..483748d 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Use it whenever you don't want to explain the whole job again — you hit an agent's usage limit, switch tools mid-task, pick up older work, or want a clean record of what happened. -Works with **Claude Code**, **Codex**, **Cursor**, **Cline**, **Kimi**, **Antigravity**, **OpenCode**, **Pi Agent**, **ZCode**, and **DeepSeek Harness**. +Works with **Claude Code**, **Codex**, **Copilot CLI**, **Cursor**, **Cline**, **Kimi**, **Antigravity**, **OpenCode**, **Pi Agent**, **ZCode**, and **DeepSeek Harness**.
@@ -54,7 +54,7 @@ herdr plugin install wilbeibi/herdr-catchup ## Usage -Agents: `claude` · `codex` · `cursor` · `cline` · `kimi` · `agy` (Antigravity) · `opencode` · `pi-agent` +Agents: `claude` · `codex` · `copilot` · `cursor` · `cline` · `kimi` · `agy` (Antigravity) · `opencode` · `pi-agent` · `zcode` · `deepseek` (dsh) 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 211971c..33530fd 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: catchup -description: Recovers prior coding-agent session context by running `catchup --since-compact`, which extracts a clean summary of a previous Codex, Claude Code, Antigravity, Cline, 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", or asks to recover/summarize a previous session before continuing. Do NOT use for the current conversation, git history, or any non-agent log. +description: Recovers prior coding-agent session context by running `catchup --since-compact`, which extracts a clean summary of a previous Codex, 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", or asks to recover/summarize a previous session before continuing. Do NOT use for the current conversation, git history, or any non-agent log. --- # catchup @@ -24,7 +24,7 @@ catchup fork # native resume, full state catchup fork --into # seed a different agent with the transcript ``` -Agents: `codex`, `claude`, `agy` (Antigravity), `cline`, `cursor`, `deepseek` (dsh), `kimi`, `opencode`, `pi-agent`, `zcode`. +Agents: `codex`, `claude`, `agy` (Antigravity), `cline`, `copilot`, `cursor`, `deepseek` (dsh), `kimi`, `opencode`, `pi-agent`, `zcode`. ## Operation diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 136106a..c1d306b 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -25,6 +25,7 @@ import ( "github.com/wilbeibi/catchup/internal/claude" "github.com/wilbeibi/catchup/internal/cline" "github.com/wilbeibi/catchup/internal/codex" + "github.com/wilbeibi/catchup/internal/copilot" "github.com/wilbeibi/catchup/internal/cursor" "github.com/wilbeibi/catchup/internal/deepseek" "github.com/wilbeibi/catchup/internal/kimi" @@ -40,7 +41,8 @@ const helpText = `Usage: catchup [agent[/]] [flags] read a past ses catchup fork --into --from catchup install-skill [agent] -Agents: codex, claude, agy (Antigravity), cline, cursor, deepseek (dsh), kimi, opencode, pi-agent, zcode +Agents: codex, claude, agy (Antigravity), cline, copilot, cursor, deepseek (dsh), +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. @@ -317,6 +319,7 @@ func providerNames() []string { session.ProviderClaude, session.ProviderAgy, session.ProviderCline, + session.ProviderCopilot, session.ProviderCursor, session.ProviderDeepSeek, session.ProviderKimi, @@ -338,6 +341,8 @@ func selectProvider(name string) (session.Provider, error) { return agy.New(), nil case session.ProviderCline: return cline.New(), nil + case session.ProviderCopilot: + return copilot.New(), nil case session.ProviderCursor: return cursor.New(), nil case session.ProviderDeepSeek: @@ -363,7 +368,7 @@ func selectProvider(name string) (session.Provider, error) { if name == "dsh" { return nil, fmt.Errorf(`unknown agent "dsh"; DeepSeek Harness's agent name is deepseek`) } - return nil, fmt.Errorf("unknown agent %q (want codex, claude, agy, cline, cursor, deepseek, kimi, opencode, pi-agent, or zcode); run catchup --help", name) + return nil, fmt.Errorf("unknown agent %q (want codex, claude, agy, cline, copilot, cursor, deepseek, kimi, opencode, pi-agent, or zcode); run catchup --help", name) } } @@ -805,6 +810,10 @@ func intoCommand(target, prompt, model string) (string, []string, error) { return "cline", append(modelArgs("--model", model), "-i", prompt), nil case session.ProviderCursor: return "cursor-agent", append(modelArgs("--model", model), prompt), nil + case session.ProviderCopilot: + // -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.ProviderKimi: // Kimi rejects positional arguments and its -p flag is // non-interactive print mode, so there is no way to start an @@ -896,6 +905,13 @@ func forkCommand(src session.Source, model string) (string, []string, error) { // Cline has no fork, and a bare --id only prints a session summary // and exits; -i opens the TUI resumed on the session. return "cline", append([]string{"-i", "--id", src.Ref.SessionID}, modelArgs("--model", model)...), nil + case session.ProviderCopilot: + if src.Ref.SessionID == "" { + return "", nil, fmt.Errorf("fork copilot: missing session id") + } + // 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.ProviderCursor: if src.Ref.SessionID == "" { return "", nil, fmt.Errorf("fork cursor: missing session id") diff --git a/internal/copilot/copilot.go b/internal/copilot/copilot.go new file mode 100644 index 0000000..bf3a795 --- /dev/null +++ b/internal/copilot/copilot.go @@ -0,0 +1,326 @@ +// Package copilot implements session.Provider over GitHub Copilot CLI +// history: one directory per session under $COPILOT_HOME/session-state +// (default ~/.copilot/session-state). +// +// Format reference, derived from a live install (@github/copilot 1.0.80): +// +// session-state//workspace.yaml holds the session's metadata as a +// flat key/value map (id, cwd, name, created_at, updated_at, plus git_root, +// repository and branch when the session started inside a repository), and +// session-state//events.jsonl is the append-only event log. Resuming +// a session appends to the same log rather than starting a new directory, +// so one session is always one file. +// +// Every event is {type, id, parentId, timestamp, data} with an RFC 3339 +// timestamp. Visible on the timeline: user/message content (data.content is +// what the human typed; data.transformedContent is the same text wrapped in +// injected datetime and system-reminder context, and is not conversation) +// and assistant/message content, which is empty on the turns that only carry +// tool requests. Everything else — system.message, tool.*, assistant.turn_*, +// session.usage_checkpoint, and the session lifecycle events — is +// bookkeeping. Sub-agent traffic is emitted under "subagent."-prefixed types, +// so matching the exact type keeps it off the timeline. +// +// The model is read from each assistant message's data.model, last writer +// wins: sessions run with --model auto are routed per turn, so the last +// answer's model is the one that produced the tail of the transcript. +// +// Session recency comes from the event log's mtime, not workspace.yaml's +// updated_at: the yaml is rewritten when the session's metadata changes +// (naming, resume), so it lags a session that is still appending events. +// +// Unverified: session.compaction_complete is mapped to a compaction marker +// from the shipped bundle's event set alone — no local session had compacted. +// Its payload carries success and token counts, not summary text, so the +// marker is bare by construction. Legacy sessions under +// history-session-state/ (pre-migration format) are not read; Copilot +// migrates one into session-state/ the first time it is resumed. +package copilot + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/wilbeibi/catchup/internal/session" +) + +// Provider reads Copilot CLI session state. It is stateless; every call +// re-reads the files, so a concurrently writing copilot is never blocked. +type Provider struct{} + +// New returns a GitHub Copilot CLI provider. +func New() *Provider { return &Provider{} } + +var _ session.Provider = (*Provider)(nil) + +const ( + eventsFile = "events.jsonl" + workspaceFile = "workspace.yaml" +) + +func (p *Provider) Resolve(ctx context.Context, roots session.Roots, id string) (session.Source, error) { + dirs, err := sessionDirs(roots.Copilot) + if err != nil { + return session.Source{}, err + } + if len(dirs) == 0 { + return session.Source{}, fmt.Errorf("copilot: no sessions found under %s", roots.Copilot) + } + if id != "" { + for _, d := range dirs { + if d.id == id { + return readMeta(d) + } + } + return session.Source{}, fmt.Errorf("copilot: no session with id %q", id) + } + return readMeta(dirs[0]) +} + +func (p *Provider) Read(ctx context.Context, src session.Source) (session.Thread, error) { + if src.Path == "" { + return session.Thread{}, errors.New("copilot: source has no path") + } + info, err := os.Stat(src.Path) + if err != nil { + return session.Thread{}, err + } + d := dirInfo{path: filepath.Dir(src.Path), mod: info.ModTime(), id: src.Ref.SessionID} + return readThread(d, readWorkspace(d.path)) +} + +func (p *Provider) List(ctx context.Context, roots session.Roots, opts session.ListOptions) ([]session.Summary, error) { + dirs, err := sessionDirs(roots.Copilot) + if err != nil { + return nil, err + } + q := strings.ToLower(opts.Query) + limit := opts.EffectiveLimit() + out := make([]session.Summary, 0, limit) + for _, d := range dirs { + if len(out) >= limit { + break + } + // The directory filter is answered from workspace.yaml alone, so a + // session in another directory never costs an event-log parse. The + // parsed map is handed on, so the file is read once either way. + meta := readWorkspace(d.path) + if opts.Cwd != "" && meta["cwd"] != opts.Cwd { + continue + } + t, err := readThread(d, meta) + if err != nil || len(t.Entries) == 0 { + continue + } + if q != "" && !strings.Contains(strings.ToLower(t.VisibleText()), q) { + continue + } + out = append(out, t.Summary()) + } + for i := range out { + out[i].Rank = i + 1 + } + return out, nil +} + +// --- directory enumeration -------------------------------------------------- + +type dirInfo struct { + path string + mod time.Time + id string +} + +// sessionDirs returns every session directory under /session-state that +// holds an event log, newest first. The directory's base name is the session +// id; workspace.yaml confirms it, but a directory whose yaml is missing or +// truncated (a session killed mid-write) is still readable from its log. +func sessionDirs(root string) ([]dirInfo, error) { + base := filepath.Join(root, "session-state") + entries, err := os.ReadDir(base) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var dirs []dirInfo + for _, e := range entries { + if !e.IsDir() { + continue + } + dir := filepath.Join(base, e.Name()) + info, err := os.Stat(filepath.Join(dir, eventsFile)) + if err != nil { + continue + } + dirs = append(dirs, dirInfo{path: dir, mod: info.ModTime(), id: e.Name()}) + } + sort.Slice(dirs, func(i, j int) bool { return dirs[i].mod.After(dirs[j].mod) }) + return dirs, nil +} + +// readMeta delegates instead of describing the session from workspace.yaml +// alone: the yaml has no model, and a metadata-only view that omits the model +// would be poorer than every other provider's. Event logs are small (a long +// session stays under a few MB). +func readMeta(d dirInfo) (session.Source, error) { + t, err := readThread(d, readWorkspace(d.path)) + return t.Source, err +} + +// readSource describes a session from its already-parsed workspace.yaml — +// everything a listing row needs except the timeline itself. +func readSource(d dirInfo, meta map[string]string) session.Source { + src := session.Source{ + Ref: session.Ref{Provider: session.ProviderCopilot, SessionID: d.id}, + Path: filepath.Join(d.path, eventsFile), + UpdatedAt: d.mod, + Metadata: map[string]string{}, + } + if id := meta["id"]; id != "" { + src.Ref.SessionID = id + } + if cwd := meta["cwd"]; cwd != "" { + src.Metadata["cwd"] = cwd + } + if name := meta["name"]; name != "" { + src.Metadata["title"] = name + } else if cwd := meta["cwd"]; cwd != "" { + src.Metadata["title"] = filepath.Base(cwd) + } + return src +} + +// readWorkspace parses workspace.yaml. The file is a flat map of scalars +// written by Copilot itself — no nesting, no lists — so a full YAML parser +// would be a dependency bought for a dozen lines of text. It is read whole +// because it is small by construction and a session's whole metadata is +// wanted at once; a missing or unreadable file yields an empty map, leaving +// the session readable from its event log alone. +func readWorkspace(dir string) map[string]string { + raw, err := os.ReadFile(filepath.Join(dir, workspaceFile)) + if err != nil { + return map[string]string{} + } + meta := map[string]string{} + for _, line := range strings.Split(string(raw), "\n") { + // Indented lines would be nested values; Copilot writes none, and + // guessing at one's meaning is worse than ignoring it. + if line == "" || strings.HasPrefix(line, " ") || strings.HasPrefix(line, "#") { + continue + } + key, val, ok := strings.Cut(line, ":") + if !ok { + continue + } + meta[strings.TrimSpace(key)] = unquote(strings.TrimSpace(val)) + } + return meta +} + +// unquote strips the one quoting form Copilot's writer emits: a single-quoted +// scalar, which escapes its own quote by doubling it. Bare scalars pass +// through untouched. +func unquote(v string) string { + if len(v) >= 2 && v[0] == '\'' && v[len(v)-1] == '\'' { + return strings.ReplaceAll(v[1:len(v)-1], "''", "'") + } + return v +} + +// --- parsing ---------------------------------------------------------------- + +// The envelope decodes only the fields every event carries; data is kept raw +// and handed to the per-type shapes in applyEvent, because unrelated events +// reuse field names with different shapes and one mismatch would fail the +// whole event's decode. +type cpEvent struct { + Type string `json:"type"` + Timestamp string `json:"timestamp"` + Data json.RawMessage `json:"data"` +} + +type cpUserMessage struct { + Content string `json:"content"` +} + +type cpAssistantMessage struct { + Content string `json:"content"` + Model string `json:"model"` +} + +func readThread(d dirInfo, meta map[string]string) (session.Thread, error) { + src := readSource(d, meta) + f, err := os.Open(src.Path) + if err != nil { + return session.Thread{}, err + } + defer f.Close() + + var entries []session.Entry + var warnings []string + dec := json.NewDecoder(f) + for dec.More() { + var ev cpEvent + if err := dec.Decode(&ev); err != nil { + // A killed writer can leave a torn final line; keep the prefix. + warnings = append(warnings, "stopped reading at a malformed record") + break + } + applyEvent(&src, &entries, ev) + } + return session.Thread{Source: src, Entries: entries, Warnings: warnings}, nil +} + +// applyEvent folds one event into the source metadata or the timeline. An +// unparseable or empty payload makes the event contribute nothing rather than +// fail the read. +func applyEvent(src *session.Source, entries *[]session.Entry, ev cpEvent) { + switch ev.Type { + case "user.message": + var d cpUserMessage + if json.Unmarshal(ev.Data, &d) != nil || d.Content == "" { + return + } + *entries = append(*entries, session.Entry{ + Kind: session.KindMessage, Role: session.RoleUser, + Text: d.Content, Time: parseTime(ev.Timestamp), + }) + case "assistant.message": + var d cpAssistantMessage + if json.Unmarshal(ev.Data, &d) != nil { + return + } + if d.Model != "" { + src.Metadata["model"] = d.Model + } + if d.Content == "" { + return // a turn that only requested tools + } + *entries = append(*entries, session.Entry{ + Kind: session.KindMessage, Role: session.RoleAssistant, + Text: d.Content, Time: parseTime(ev.Timestamp), + }) + case "session.compaction_complete": + *entries = append(*entries, session.Entry{ + Kind: session.KindCompact, Time: parseTime(ev.Timestamp), + }) + } +} + +func parseTime(s string) time.Time { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return time.Time{} + } + return t +} diff --git a/internal/copilot/copilot_test.go b/internal/copilot/copilot_test.go new file mode 100644 index 0000000..59418ce --- /dev/null +++ b/internal/copilot/copilot_test.go @@ -0,0 +1,244 @@ +package copilot + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/wilbeibi/catchup/internal/session" +) + +// events mirrors a real Copilot CLI log (@github/copilot 1.0.80), trimmed to +// one event of each kind the provider must handle: the lifecycle rows, the +// system prompt, a tool-only assistant turn, the tool plumbing around it, and +// a sub-agent's messages — all of which are noise — plus the two human turns +// and the two answers that are not. +const events = `{"type":"session.start","id":"e1","timestamp":"2026-08-24T15:07:45.905Z","data":{"sessionId":"816a9dd1","version":1,"producer":"copilot-agent","context":{"cwd":"/home/u/src/catchup"}}} +{"type":"session.model_change","id":"e2","timestamp":"2026-08-24T15:07:47.149Z","data":{"newModel":"auto","reasoningEffort":null}} +{"type":"session.auto_mode_resolved","id":"e3","timestamp":"2026-08-24T15:07:47.958Z","data":{"chosenModel":"claude-haiku-4.5","routingMethod":"hydra"}} +{"type":"system.message","id":"e4","timestamp":"2026-08-24T15:07:47.999Z","data":{"role":"system","content":"You are the GitHub Copilot CLI."}} +{"type":"user.message","id":"e5","timestamp":"2026-08-24T15:07:48.023Z","data":{"content":"support copilot","transformedContent":"2026-08-24T08:07:48.022-07:00\n\nsupport copilot\n\ninjected","attachments":[],"delivery":"idle"}} +{"type":"assistant.turn_start","id":"e6","timestamp":"2026-08-24T15:07:48.070Z","data":{"turnId":"0"}} +{"type":"assistant.message","id":"e7","timestamp":"2026-08-24T15:07:49.558Z","data":{"messageId":"m1","model":"claude-haiku-4.5","content":"I will read the log.","toolRequests":[],"turnId":"0"}} +{"type":"assistant.message","id":"e8","timestamp":"2026-08-24T15:07:50.100Z","data":{"messageId":"m2","model":"claude-haiku-4.5","content":"","toolRequests":[{"toolCallId":"t1","name":"bash","arguments":{"command":"ls"}}],"turnId":"0"}} +{"type":"tool.execution_start","id":"e9","timestamp":"2026-08-24T15:07:50.200Z","data":{"toolCallId":"t1","name":"bash"}} +{"type":"tool.execution_complete","id":"e10","timestamp":"2026-08-24T15:07:50.900Z","data":{"toolCallId":"t1","result":"a.txt"}} +{"type":"subagent.user.message","id":"e11","timestamp":"2026-08-24T15:07:51.000Z","data":{"content":"sub-agent instructions"}} +{"type":"subagent.assistant.message","id":"e12","timestamp":"2026-08-24T15:07:51.500Z","data":{"model":"gpt-5-mini","content":"sub-agent answer"}} +{"type":"assistant.turn_end","id":"e13","timestamp":"2026-08-24T15:07:52.000Z","data":{"turnId":"0"}} +{"type":"session.compaction_complete","id":"e14","timestamp":"2026-08-24T15:08:00.000Z","data":{"success":true,"compactionTokensUsed":1234}} +{"type":"session.shutdown","id":"e15","timestamp":"2026-08-24T15:08:10.000Z","data":{}} +{"type":"session.resume","id":"e16","timestamp":"2026-08-24T15:08:35.352Z","data":{"eventCount":15,"context":{"cwd":"/home/u/src/catchup"}}} +{"type":"user.message","id":"e17","timestamp":"2026-08-24T15:08:37.037Z","data":{"content":"finish it","transformedContent":"wrapped"}} +{"type":"assistant.message","id":"e18","timestamp":"2026-08-24T15:08:40.397Z","data":{"messageId":"m3","model":"gpt-5.4","content":"done","toolRequests":[]}} +` + +const workspace = `id: 816a9dd1-c2ef-4b36-99a8-b503320856cc +cwd: /home/u/src/catchup +git_root: /home/u/src/catchup +repository: wilbeibi/catchup +branch: main +client_name: github/cli +name: 'Reply with exactly: hello' +user_named: false +summary_count: 0 +created_at: 2026-08-24T15:07:45.818Z +updated_at: 2026-08-24T15:07:45.850Z +` + +func ts(t *testing.T, s string) time.Time { + t.Helper() + v, err := time.Parse(time.RFC3339, s) + if err != nil { + t.Fatal(err) + } + return v +} + +func wantEntries(t *testing.T) []session.Entry { + return []session.Entry{ + {Kind: session.KindMessage, Role: session.RoleUser, Text: "support copilot", Time: ts(t, "2026-08-24T15:07:48.023Z")}, + {Kind: session.KindMessage, Role: session.RoleAssistant, Text: "I will read the log.", Time: ts(t, "2026-08-24T15:07:49.558Z")}, + {Kind: session.KindCompact, Time: ts(t, "2026-08-24T15:08:00.000Z")}, + {Kind: session.KindMessage, Role: session.RoleUser, Text: "finish it", Time: ts(t, "2026-08-24T15:08:37.037Z")}, + {Kind: session.KindMessage, Role: session.RoleAssistant, Text: "done", Time: ts(t, "2026-08-24T15:08:40.397Z")}, + } +} + +// writeSession lays out one session directory. A session whose workspace.yaml +// is empty stands for one killed before its metadata was written. +func writeSession(t *testing.T, root, id, ws, log string, mod time.Time) string { + t.Helper() + dir := filepath.Join(root, "session-state", id) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if ws != "" { + if err := os.WriteFile(filepath.Join(dir, workspaceFile), []byte(ws), 0o644); err != nil { + t.Fatal(err) + } + } + path := filepath.Join(dir, eventsFile) + if err := os.WriteFile(path, []byte(log), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, mod, mod); err != nil { + t.Fatal(err) + } + return dir +} + +func rootsAt(dir string) session.Roots { return session.Roots{Copilot: dir} } + +func TestReadSkipsEverythingButConversation(t *testing.T) { + root := t.TempDir() + writeSession(t, root, "816a9dd1", workspace, events, time.Now()) + + p := New() + src, err := p.Resolve(context.Background(), rootsAt(root), "") + if err != nil { + t.Fatal(err) + } + if src.Ref.Provider != session.ProviderCopilot { + t.Errorf("provider = %q", src.Ref.Provider) + } + // The id comes from workspace.yaml, which spells it in full; the + // directory name is only the fallback. + if src.Ref.SessionID != "816a9dd1-c2ef-4b36-99a8-b503320856cc" { + t.Errorf("session id = %q", src.Ref.SessionID) + } + if src.Metadata["cwd"] != "/home/u/src/catchup" { + t.Errorf("cwd = %q", src.Metadata["cwd"]) + } + // A single-quoted scalar keeps the colon inside the title. + if src.Metadata["title"] != "Reply with exactly: hello" { + t.Errorf("title = %q", src.Metadata["title"]) + } + // Last writer wins: the session was routed to another model mid-way. + if src.Metadata["model"] != "gpt-5.4" { + t.Errorf("model = %q", src.Metadata["model"]) + } + + th, err := p.Read(context.Background(), src) + if err != nil { + t.Fatal(err) + } + got, want := th.Entries, wantEntries(t) + if len(got) != len(want) { + t.Fatalf("entries = %d, want %d: %+v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("entry %d =\n%+v\nwant\n%+v", i, got[i], want[i]) + } + } + if len(th.Warnings) != 0 { + t.Errorf("warnings = %v, want none", th.Warnings) + } +} + +// A session killed mid-write leaves a torn final line. The prefix stays +// readable and the reader says what it dropped. +func TestReadTornFinalLine(t *testing.T) { + root := t.TempDir() + writeSession(t, root, "torn", workspace, events+`{"type":"user.message","data":{"cont`, time.Now()) + + p := New() + src, err := p.Resolve(context.Background(), rootsAt(root), "") + if err != nil { + t.Fatal(err) + } + th, err := p.Read(context.Background(), src) + if err != nil { + t.Fatal(err) + } + if len(th.Entries) != len(wantEntries(t)) { + t.Errorf("entries = %d, want %d", len(th.Entries), len(wantEntries(t))) + } + if len(th.Warnings) == 0 { + t.Error("no warning for the torn record") + } +} + +// Without workspace.yaml the directory name is the id and the timeline is +// still whole. +func TestReadWithoutWorkspaceFile(t *testing.T) { + root := t.TempDir() + writeSession(t, root, "no-yaml", "", events, time.Now()) + + p := New() + src, err := p.Resolve(context.Background(), rootsAt(root), "") + if err != nil { + t.Fatal(err) + } + if src.Ref.SessionID != "no-yaml" { + t.Errorf("session id = %q, want the directory name", src.Ref.SessionID) + } + th, err := p.Read(context.Background(), src) + if err != nil { + t.Fatal(err) + } + if len(th.Entries) != len(wantEntries(t)) { + t.Errorf("entries = %d, want %d", len(th.Entries), len(wantEntries(t))) + } +} + +// Recency is the event log's mtime, not workspace.yaml's updated_at: the yaml +// is written when metadata changes and lags a session that is still appending. +func TestListOrdersByEventLogMtime(t *testing.T) { + root := t.TempDir() + now := time.Now() + writeSession(t, root, "older", workspace, events, now.Add(-time.Hour)) + writeSession(t, root, "newer", strings.Replace(workspace, "816a9dd1-c2ef-4b36-99a8-b503320856cc", "newer-id", 1), events, now) + + got, err := New().List(context.Background(), rootsAt(root), session.ListOptions{}) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("rows = %d, want 2", len(got)) + } + if got[0].Ref.SessionID != "newer-id" || got[0].Rank != 1 { + t.Errorf("first row = %+v", got[0]) + } +} + +func TestListFiltersByCwdAndQuery(t *testing.T) { + root := t.TempDir() + now := time.Now() + writeSession(t, root, "here", workspace, events, now) + elsewhere := strings.Replace(workspace, "cwd: /home/u/src/catchup", "cwd: /home/u/src/other", 1) + writeSession(t, root, "there", elsewhere, events, now.Add(-time.Minute)) + + p := New() + rows, err := p.List(context.Background(), rootsAt(root), session.ListOptions{Cwd: "/home/u/src/other"}) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].Cwd != "/home/u/src/other" { + t.Fatalf("cwd filter kept %+v", rows) + } + + rows, err = p.List(context.Background(), rootsAt(root), session.ListOptions{Query: "sub-agent"}) + if err != nil { + t.Fatal(err) + } + if len(rows) != 0 { + t.Errorf("query matched sub-agent text that is not on the timeline: %+v", rows) + } +} + +func TestResolveErrors(t *testing.T) { + root := t.TempDir() + p := New() + if _, err := p.Resolve(context.Background(), rootsAt(root), ""); err == nil { + t.Error("empty root resolved without error") + } + writeSession(t, root, "816a9dd1", workspace, events, time.Now()) + if _, err := p.Resolve(context.Background(), rootsAt(root), "nope"); err == nil { + t.Error("unknown id resolved without error") + } +} diff --git a/internal/session/roots.go b/internal/session/roots.go index dda4221..7d84d70 100644 --- a/internal/session/roots.go +++ b/internal/session/roots.go @@ -16,6 +16,7 @@ import "path/filepath" // Cursor : $CURSOR_CONFIG_DIR else $XDG_CONFIG_HOME/cursor else /.cursor // ZCode : $ZCODE_HOME else /.zcode/cli/db (the dir holding db.sqlite) // DeepSeek : $DSH_HOME else /.dsh +// Copilot : $COPILOT_HOME else /.copilot // // 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 @@ -78,7 +79,13 @@ func ResolveRoots(getenv func(string) string, home string) Roots { deepseek = filepath.Join(home, ".dsh") } - return Roots{Codex: codex, Claude: claude, Agy: agy, OpenCode: opencode, PiAgent: piAgent, Kimi: kimi, Cline: cline, Cursor: cursor, ZCode: zcode, DeepSeek: deepseek} + // Copilot CLI keeps one directory per session under /session-state. + copilot := getenv("COPILOT_HOME") + if copilot == "" { + copilot = filepath.Join(home, ".copilot") + } + + return Roots{Codex: codex, Claude: claude, Agy: agy, OpenCode: opencode, PiAgent: piAgent, Kimi: kimi, Cline: cline, Cursor: cursor, ZCode: zcode, DeepSeek: deepseek, Copilot: copilot} } // ResolveSkillDirs returns each provider's global Agent Skills directory, @@ -103,6 +110,8 @@ func ResolveRoots(getenv func(string) string, home string) Roots { // Cursor : roots.Cursor/skills (respects $CURSOR_CONFIG_DIR) // ZCode : /.agents/skills (shares Codex's entry: ZCode // discovers ~/.agents/skills, so the same SKILL.md serves both) +// Copilot : roots.Copilot/skills (respects $COPILOT_HOME; Copilot +// 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) func ResolveSkillDirs(roots Roots, home string) map[string]string { @@ -117,14 +126,16 @@ func ResolveSkillDirs(roots Roots, home string) map[string]string { ProviderCursor: filepath.Join(roots.Cursor, "skills"), ProviderZCode: filepath.Join(home, ".agents", "skills"), ProviderDeepSeek: filepath.Join(roots.DeepSeek, "skills"), + ProviderCopilot: filepath.Join(roots.Copilot, "skills"), } } // ResolveCurrent reports the session each provider says we are running inside, -// keyed by provider name. Only Claude Code injects such a signal today -// ($CLAUDE_CODE_SESSION_ID, set in every shell it spawns); Codex and OpenCode -// spawn shells indistinguishable from a plain terminal, so they contribute -// nothing. A provider absent from the map (or mapped to "") has no in-band +// keyed by provider name. Two agents inject such a signal into every shell +// they spawn: Claude Code ($CLAUDE_CODE_SESSION_ID) and Copilot CLI +// ($COPILOT_AGENT_SESSION_ID, whose value is the id --resume takes); Codex and +// OpenCode spawn shells indistinguishable from a plain terminal, so they +// contribute nothing. A provider absent from the map (or mapped to "") has no in-band // current session, and the caller falls back to the newest session in the // working directory. // @@ -135,5 +146,8 @@ func ResolveCurrent(getenv func(string) string) map[string]string { if id := getenv("CLAUDE_CODE_SESSION_ID"); id != "" { current[ProviderClaude] = id } + if id := getenv("COPILOT_AGENT_SESSION_ID"); id != "" { + current[ProviderCopilot] = id + } return current } diff --git a/internal/session/roots_test.go b/internal/session/roots_test.go index 0a39502..bc49776 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, + ProviderZCode, ProviderDeepSeek, ProviderCopilot, } // noEnv is the environment of a machine that overrides nothing. @@ -41,6 +41,7 @@ func TestResolveRootsDefaults(t *testing.T) { Cursor: filepath.Join(home, ".cursor"), ZCode: filepath.Join(home, ".zcode", "cli", "db"), DeepSeek: filepath.Join(home, ".dsh"), + Copilot: filepath.Join(home, ".copilot"), } if got != want { t.Fatalf("roots =\n%+v\nwant\n%+v", got, want) @@ -58,6 +59,7 @@ func TestResolveRootsOverrides(t *testing.T) { {"CODEX_HOME", map[string]string{"CODEX_HOME": dir}, func(r Roots) string { return r.Codex }, dir}, {"CLAUDE_CONFIG_DIR", map[string]string{"CLAUDE_CONFIG_DIR": dir}, func(r Roots) string { return r.Claude }, dir}, {"PI_CODING_AGENT_DIR", map[string]string{"PI_CODING_AGENT_DIR": dir}, func(r Roots) string { return r.PiAgent }, dir}, + {"COPILOT_HOME", map[string]string{"COPILOT_HOME": dir}, func(r Roots) string { return r.Copilot }, dir}, {"KIMI_CODE_HOME", map[string]string{"KIMI_CODE_HOME": dir}, func(r Roots) string { return r.Kimi }, dir}, {"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}, @@ -151,4 +153,8 @@ func TestResolveCurrent(t *testing.T) { if len(got) != 1 || got[ProviderClaude] != "sess-1" { t.Fatalf("current = %+v", got) } + got = ResolveCurrent(envFrom(map[string]string{"COPILOT_AGENT_SESSION_ID": "sess-2"})) + if len(got) != 1 || got[ProviderCopilot] != "sess-2" { + t.Fatalf("current = %+v", got) + } } diff --git a/internal/session/session.go b/internal/session/session.go index 8cf55bc..8799b46 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -34,6 +34,7 @@ const ( ProviderCursor = "cursor" // Cursor CLI (cursor-agent) ProviderZCode = "zcode" // ZCode (Z.ai) desktop agent ProviderDeepSeek = "deepseek" // DeepSeek Harness (dsh) + ProviderCopilot = "copilot" // GitHub Copilot CLI ) // Entry kinds and message roles. Providers normalize their own wire formats @@ -84,6 +85,7 @@ type Roots struct { Cursor string ZCode string DeepSeek string + Copilot string } // Source is a located session: enough to read it and to describe it in a From 386ead435137df30f6e1cad7444ece6e78315ff9 Mon Sep 17 00:00:00 2001 From: Hongyi Shen Date: Tue, 25 Aug 2026 02:30:30 -0700 Subject: [PATCH 2/3] Follow the shipped Copilot event schema for sub-agents and compaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI ships schemas/session-events.schema.json, which settles three things the first pass guessed at. Sub-agent turns are not a separate event type. They reuse user.message and assistant.message and are marked by the envelope's agentId, "absent for events from the root/main agent" — so a sub-agent's prompts and answers were landing in the transcript, and its model could overwrite the session's. Events carrying an agentId are now skipped. A session.compaction_complete carries success, and on success the summaryContent that replaced the history. A failed compaction removed nothing and is not a seam, so it no longer produces a marker that --since-compact would cut on; a successful one now carries its summary instead of being bare. The session id is the directory name alone. It is what --resume takes and what workspace.yaml repeats, and reading it from the yaml meant a listed id was not guaranteed to resolve. Tests follow the schema rather than the earlier guess, and cover the failed compaction, the sub-agent turns, the listed-id round trip, and the Copilot argv in the CLI command-contract tests. --- internal/cli/cli_test.go | 5 ++ internal/copilot/copilot.go | 68 ++++++++++++++++-------- internal/copilot/copilot_test.go | 90 ++++++++++++++++++++++++++------ 3 files changed, 124 insertions(+), 39 deletions(-) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index cc4b180..96aba10 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -490,6 +490,10 @@ func TestForkCommand(t *testing.T) { {"opencode", session.Source{Ref: session.Ref{Provider: session.ProviderOpenCode, SessionID: "o1"}}, "", "opencode --session o1 --fork"}, {"pi path", session.Source{Ref: session.Ref{Provider: session.ProviderPiAgent, SessionID: "p1"}, Path: "/tmp/pi.jsonl"}, "", "pi --fork /tmp/pi.jsonl"}, {"codex with model", session.Source{Ref: session.Ref{Provider: session.ProviderCodex, SessionID: "c1"}}, "gpt-5.6", "codex fork c1 -m gpt-5.6"}, + // The id is inline: a bare --resume opens Copilot's session picker, + // 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"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -715,6 +719,7 @@ func TestIntoCommandModelPlacement(t *testing.T) { {session.ProviderClaude, "--model M PROMPT"}, {session.ProviderAgy, "--model M -i PROMPT"}, {session.ProviderOpenCode, "--model M --prompt PROMPT"}, + {session.ProviderCopilot, "--model M -i PROMPT"}, } for _, tt := range tests { t.Run(tt.target, func(t *testing.T) { diff --git a/internal/copilot/copilot.go b/internal/copilot/copilot.go index bf3a795..8e70f4d 100644 --- a/internal/copilot/copilot.go +++ b/internal/copilot/copilot.go @@ -11,30 +11,41 @@ // a session appends to the same log rather than starting a new directory, // so one session is always one file. // -// Every event is {type, id, parentId, timestamp, data} with an RFC 3339 -// timestamp. Visible on the timeline: user/message content (data.content is -// what the human typed; data.transformedContent is the same text wrapped in -// injected datetime and system-reminder context, and is not conversation) -// and assistant/message content, which is empty on the turns that only carry -// tool requests. Everything else — system.message, tool.*, assistant.turn_*, -// session.usage_checkpoint, and the session lifecycle events — is -// bookkeeping. Sub-agent traffic is emitted under "subagent."-prefixed types, -// so matching the exact type keeps it off the timeline. +// Every event is {type, id, parentId, timestamp, agentId, data} with an +// RFC 3339 timestamp. Visible on the timeline: user/message content +// (data.content is what the human typed; data.transformedContent is the same +// text wrapped in injected datetime and system-reminder context, and is not +// conversation) and assistant/message content, which is empty on the turns +// that only carry tool requests. Everything else — system.message, tool.*, +// assistant.turn_*, session.usage_checkpoint, and the session lifecycle +// events — is bookkeeping. // -// The model is read from each assistant message's data.model, last writer -// wins: sessions run with --model auto are routed per turn, so the last +// Sub-agent traffic reuses those same two types and is told apart by the +// envelope's agentId, which the shipped schemas/session-events.schema.json +// documents as "absent for events from the root/main agent". A sub-agent's +// prompts and answers are the parent turn's tool plumbing, not conversation, +// so any event carrying an agentId is skipped — including for the model, +// which a sub-agent routed to another model would otherwise overwrite. +// +// A session.compaction_complete carries success and, when the compaction +// succeeded, summaryContent: the LLM-written summary that replaced the +// history. It becomes the compaction marker's text, so --since-compact opens +// on what survived rather than on a bare seam. A failed compaction removed +// nothing and is not a seam, so it produces no marker. +// +// The model is read from each root assistant message's data.model, last +// writer wins: sessions run with --model auto are routed per turn, so the last // answer's model is the one that produced the tail of the transcript. // // Session recency comes from the event log's mtime, not workspace.yaml's // updated_at: the yaml is rewritten when the session's metadata changes // (naming, resume), so it lags a session that is still appending events. // -// Unverified: session.compaction_complete is mapped to a compaction marker -// from the shipped bundle's event set alone — no local session had compacted. -// Its payload carries success and token counts, not summary text, so the -// marker is bare by construction. Legacy sessions under -// history-session-state/ (pre-migration format) are not read; Copilot -// migrates one into session-state/ the first time it is resumed. +// Unverified: no local session compacted or spawned a sub-agent, so the +// compaction and agentId handling follows the shipped schema rather than an +// observed log. Legacy sessions under history-session-state/ (pre-migration +// format) are not read; Copilot migrates one into session-state/ the first +// time it is resumed. package copilot import ( @@ -141,8 +152,9 @@ type dirInfo struct { // sessionDirs returns every session directory under /session-state that // holds an event log, newest first. The directory's base name is the session -// id; workspace.yaml confirms it, but a directory whose yaml is missing or -// truncated (a session killed mid-write) is still readable from its log. +// id — the value --resume takes, and the id workspace.yaml repeats — so it is +// the one Resolve matches and List reports, and a session whose yaml is +// missing or truncated is still selectable. func sessionDirs(root string) ([]dirInfo, error) { base := filepath.Join(root, "session-state") entries, err := os.ReadDir(base) @@ -186,9 +198,6 @@ func readSource(d dirInfo, meta map[string]string) session.Source { UpdatedAt: d.mod, Metadata: map[string]string{}, } - if id := meta["id"]; id != "" { - src.Ref.SessionID = id - } if cwd := meta["cwd"]; cwd != "" { src.Metadata["cwd"] = cwd } @@ -246,6 +255,7 @@ func unquote(v string) string { type cpEvent struct { Type string `json:"type"` Timestamp string `json:"timestamp"` + AgentID string `json:"agentId"` Data json.RawMessage `json:"data"` } @@ -258,6 +268,11 @@ type cpAssistantMessage struct { Model string `json:"model"` } +type cpCompaction struct { + Success bool `json:"success"` + Summary string `json:"summaryContent"` +} + func readThread(d dirInfo, meta map[string]string) (session.Thread, error) { src := readSource(d, meta) f, err := os.Open(src.Path) @@ -285,6 +300,9 @@ func readThread(d dirInfo, meta map[string]string) (session.Thread, error) { // unparseable or empty payload makes the event contribute nothing rather than // fail the read. func applyEvent(src *session.Source, entries *[]session.Entry, ev cpEvent) { + if ev.AgentID != "" { + return // a sub-agent's own turns: the parent's tool plumbing + } switch ev.Type { case "user.message": var d cpUserMessage @@ -311,8 +329,12 @@ func applyEvent(src *session.Source, entries *[]session.Entry, ev cpEvent) { Text: d.Content, Time: parseTime(ev.Timestamp), }) case "session.compaction_complete": + var d cpCompaction + if json.Unmarshal(ev.Data, &d) != nil || !d.Success { + return // a failed compaction removed nothing: not a seam + } *entries = append(*entries, session.Entry{ - Kind: session.KindCompact, Time: parseTime(ev.Timestamp), + Kind: session.KindCompact, Text: d.Summary, Time: parseTime(ev.Timestamp), }) } } diff --git a/internal/copilot/copilot_test.go b/internal/copilot/copilot_test.go index 59418ce..74fca3c 100644 --- a/internal/copilot/copilot_test.go +++ b/internal/copilot/copilot_test.go @@ -11,11 +11,14 @@ import ( "github.com/wilbeibi/catchup/internal/session" ) -// events mirrors a real Copilot CLI log (@github/copilot 1.0.80), trimmed to -// one event of each kind the provider must handle: the lifecycle rows, the -// system prompt, a tool-only assistant turn, the tool plumbing around it, and -// a sub-agent's messages — all of which are noise — plus the two human turns -// and the two answers that are not. +// events mirrors a real Copilot CLI log (@github/copilot 1.0.80), with the +// event shapes taken from the schemas/session-events.schema.json that ships +// with the CLI. It carries one of each kind the provider must handle: the +// lifecycle rows, the system prompt, a tool-only assistant turn, the tool +// plumbing around it, and a sub-agent's own messages — which reuse the +// ordinary message types and are marked by the envelope's agentId — plus a +// failed compaction, a successful one, and the human turns and answers that +// are the actual conversation. const events = `{"type":"session.start","id":"e1","timestamp":"2026-08-24T15:07:45.905Z","data":{"sessionId":"816a9dd1","version":1,"producer":"copilot-agent","context":{"cwd":"/home/u/src/catchup"}}} {"type":"session.model_change","id":"e2","timestamp":"2026-08-24T15:07:47.149Z","data":{"newModel":"auto","reasoningEffort":null}} {"type":"session.auto_mode_resolved","id":"e3","timestamp":"2026-08-24T15:07:47.958Z","data":{"chosenModel":"claude-haiku-4.5","routingMethod":"hydra"}} @@ -26,17 +29,21 @@ const events = `{"type":"session.start","id":"e1","timestamp":"2026-08-24T15:07: {"type":"assistant.message","id":"e8","timestamp":"2026-08-24T15:07:50.100Z","data":{"messageId":"m2","model":"claude-haiku-4.5","content":"","toolRequests":[{"toolCallId":"t1","name":"bash","arguments":{"command":"ls"}}],"turnId":"0"}} {"type":"tool.execution_start","id":"e9","timestamp":"2026-08-24T15:07:50.200Z","data":{"toolCallId":"t1","name":"bash"}} {"type":"tool.execution_complete","id":"e10","timestamp":"2026-08-24T15:07:50.900Z","data":{"toolCallId":"t1","result":"a.txt"}} -{"type":"subagent.user.message","id":"e11","timestamp":"2026-08-24T15:07:51.000Z","data":{"content":"sub-agent instructions"}} -{"type":"subagent.assistant.message","id":"e12","timestamp":"2026-08-24T15:07:51.500Z","data":{"model":"gpt-5-mini","content":"sub-agent answer"}} +{"type":"subagent.started","id":"e11","timestamp":"2026-08-24T15:07:50.950Z","data":{"toolCallId":"t1","agentName":"explore"}} +{"type":"user.message","id":"e12","agentId":"agent-7","timestamp":"2026-08-24T15:07:51.000Z","data":{"content":"sub-agent instructions"}} +{"type":"assistant.message","id":"e13","agentId":"agent-7","timestamp":"2026-08-24T15:07:51.500Z","data":{"messageId":"m9","model":"gpt-5-mini","content":"sub-agent answer"}} {"type":"assistant.turn_end","id":"e13","timestamp":"2026-08-24T15:07:52.000Z","data":{"turnId":"0"}} -{"type":"session.compaction_complete","id":"e14","timestamp":"2026-08-24T15:08:00.000Z","data":{"success":true,"compactionTokensUsed":1234}} +{"type":"session.compaction_complete","id":"e15","timestamp":"2026-08-24T15:07:59.000Z","data":{"success":false,"error":"model unavailable","statusCode":503}} +{"type":"session.compaction_complete","id":"e16","timestamp":"2026-08-24T15:08:00.000Z","data":{"success":true,"summaryContent":"The user asked for Copilot support; the log format is settled.","preCompactionTokens":180000,"messagesRemoved":42,"compactionTokensUsed":{"inputTokens":1234,"outputTokens":56}}} {"type":"session.shutdown","id":"e15","timestamp":"2026-08-24T15:08:10.000Z","data":{}} {"type":"session.resume","id":"e16","timestamp":"2026-08-24T15:08:35.352Z","data":{"eventCount":15,"context":{"cwd":"/home/u/src/catchup"}}} {"type":"user.message","id":"e17","timestamp":"2026-08-24T15:08:37.037Z","data":{"content":"finish it","transformedContent":"wrapped"}} {"type":"assistant.message","id":"e18","timestamp":"2026-08-24T15:08:40.397Z","data":{"messageId":"m3","model":"gpt-5.4","content":"done","toolRequests":[]}} ` -const workspace = `id: 816a9dd1-c2ef-4b36-99a8-b503320856cc +const sessionID = "816a9dd1-c2ef-4b36-99a8-b503320856cc" + +const workspace = `id: ` + sessionID + ` cwd: /home/u/src/catchup git_root: /home/u/src/catchup repository: wilbeibi/catchup @@ -62,7 +69,7 @@ func wantEntries(t *testing.T) []session.Entry { return []session.Entry{ {Kind: session.KindMessage, Role: session.RoleUser, Text: "support copilot", Time: ts(t, "2026-08-24T15:07:48.023Z")}, {Kind: session.KindMessage, Role: session.RoleAssistant, Text: "I will read the log.", Time: ts(t, "2026-08-24T15:07:49.558Z")}, - {Kind: session.KindCompact, Time: ts(t, "2026-08-24T15:08:00.000Z")}, + {Kind: session.KindCompact, Text: "The user asked for Copilot support; the log format is settled.", Time: ts(t, "2026-08-24T15:08:00.000Z")}, {Kind: session.KindMessage, Role: session.RoleUser, Text: "finish it", Time: ts(t, "2026-08-24T15:08:37.037Z")}, {Kind: session.KindMessage, Role: session.RoleAssistant, Text: "done", Time: ts(t, "2026-08-24T15:08:40.397Z")}, } @@ -95,7 +102,7 @@ func rootsAt(dir string) session.Roots { return session.Roots{Copilot: dir} } func TestReadSkipsEverythingButConversation(t *testing.T) { root := t.TempDir() - writeSession(t, root, "816a9dd1", workspace, events, time.Now()) + writeSession(t, root, sessionID, workspace, events, time.Now()) p := New() src, err := p.Resolve(context.Background(), rootsAt(root), "") @@ -105,9 +112,7 @@ func TestReadSkipsEverythingButConversation(t *testing.T) { if src.Ref.Provider != session.ProviderCopilot { t.Errorf("provider = %q", src.Ref.Provider) } - // The id comes from workspace.yaml, which spells it in full; the - // directory name is only the fallback. - if src.Ref.SessionID != "816a9dd1-c2ef-4b36-99a8-b503320856cc" { + if src.Ref.SessionID != sessionID { t.Errorf("session id = %q", src.Ref.SessionID) } if src.Metadata["cwd"] != "/home/u/src/catchup" { @@ -192,7 +197,7 @@ func TestListOrdersByEventLogMtime(t *testing.T) { root := t.TempDir() now := time.Now() writeSession(t, root, "older", workspace, events, now.Add(-time.Hour)) - writeSession(t, root, "newer", strings.Replace(workspace, "816a9dd1-c2ef-4b36-99a8-b503320856cc", "newer-id", 1), events, now) + writeSession(t, root, "newer-id", strings.Replace(workspace, sessionID, "newer-id", 1), events, now) got, err := New().List(context.Background(), rootsAt(root), session.ListOptions{}) if err != nil { @@ -231,13 +236,66 @@ func TestListFiltersByCwdAndQuery(t *testing.T) { } } +// Every id a listing reports must select that session on the next run: the +// rank a user retypes resolves through this round trip. +func TestListedIDsResolve(t *testing.T) { + root := t.TempDir() + writeSession(t, root, sessionID, workspace, events, time.Now()) + + p := New() + rows, err := p.List(context.Background(), rootsAt(root), session.ListOptions{}) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 { + t.Fatalf("rows = %d, want 1", len(rows)) + } + src, err := p.Resolve(context.Background(), rootsAt(root), rows[0].Ref.SessionID) + if err != nil { + t.Fatalf("resolving the listed id %q: %v", rows[0].Ref.SessionID, err) + } + if src.Ref.SessionID != rows[0].Ref.SessionID { + t.Errorf("resolved %q, want %q", src.Ref.SessionID, rows[0].Ref.SessionID) + } +} + +// A compaction that failed removed nothing, so it is not a seam --since-compact +// may cut on; only the successful one is a marker, and it carries the summary +// that replaced the history. +func TestCompactionMarkers(t *testing.T) { + root := t.TempDir() + writeSession(t, root, sessionID, workspace, events, time.Now()) + + p := New() + src, err := p.Resolve(context.Background(), rootsAt(root), "") + if err != nil { + t.Fatal(err) + } + th, err := p.Read(context.Background(), src) + if err != nil { + t.Fatal(err) + } + var marks []session.Entry + for _, e := range th.Entries { + if e.Kind == session.KindCompact { + marks = append(marks, e) + } + } + if len(marks) != 1 { + t.Fatalf("compaction markers = %d, want 1 (the failed one is not a seam): %+v", len(marks), marks) + } + if !strings.Contains(marks[0].Text, "the log format is settled") { + t.Errorf("marker text = %q, want the summaryContent", marks[0].Text) + } +} + func TestResolveErrors(t *testing.T) { root := t.TempDir() p := New() if _, err := p.Resolve(context.Background(), rootsAt(root), ""); err == nil { t.Error("empty root resolved without error") } - writeSession(t, root, "816a9dd1", workspace, events, time.Now()) + writeSession(t, root, sessionID, workspace, events, time.Now()) if _, err := p.Resolve(context.Background(), rootsAt(root), "nope"); err == nil { t.Error("unknown id resolved without error") } From 84ecd06004b078289b594510b7b74c9b06c59eea Mon Sep 17 00:00:00 2001 From: Hongyi Shen Date: Tue, 25 Aug 2026 02:34:23 -0700 Subject: [PATCH 3/3] Drop the copilot session id that duplicated its directory name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once the directory name became the canonical session id, dirInfo's id field was a second copy of filepath.Base(path) — state that can only ever disagree with the path it came from, and Read was seeding it from a caller-supplied Ref. The two uses derive it instead. Also names the shipped event schema, not just the live install, as where the format reference comes from, and says in the package doc that the directory name is the id --resume takes. --- internal/copilot/copilot.go | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/internal/copilot/copilot.go b/internal/copilot/copilot.go index 8e70f4d..aa8fc6f 100644 --- a/internal/copilot/copilot.go +++ b/internal/copilot/copilot.go @@ -2,14 +2,16 @@ // history: one directory per session under $COPILOT_HOME/session-state // (default ~/.copilot/session-state). // -// Format reference, derived from a live install (@github/copilot 1.0.80): +// Format reference, from a live install (@github/copilot 1.0.80) and the +// schemas/session-events.schema.json it ships: // // session-state//workspace.yaml holds the session's metadata as a // flat key/value map (id, cwd, name, created_at, updated_at, plus git_root, // repository and branch when the session started inside a repository), and // session-state//events.jsonl is the append-only event log. Resuming // a session appends to the same log rather than starting a new directory, -// so one session is always one file. +// so one session is always one file. The directory name is the session id: +// the value --resume takes, and the id every listing reports. // // Every event is {type, id, parentId, timestamp, agentId, data} with an // RFC 3339 timestamp. Visible on the timeline: user/message content @@ -21,8 +23,8 @@ // events — is bookkeeping. // // Sub-agent traffic reuses those same two types and is told apart by the -// envelope's agentId, which the shipped schemas/session-events.schema.json -// documents as "absent for events from the root/main agent". A sub-agent's +// envelope's agentId, which the schema documents as "absent for events from +// the root/main agent". A sub-agent's // prompts and answers are the parent turn's tool plumbing, not conversation, // so any event carrying an agentId is skipped — including for the model, // which a sub-agent routed to another model would otherwise overwrite. @@ -87,7 +89,7 @@ func (p *Provider) Resolve(ctx context.Context, roots session.Roots, id string) } if id != "" { for _, d := range dirs { - if d.id == id { + if filepath.Base(d.path) == id { return readMeta(d) } } @@ -104,7 +106,7 @@ func (p *Provider) Read(ctx context.Context, src session.Source) (session.Thread if err != nil { return session.Thread{}, err } - d := dirInfo{path: filepath.Dir(src.Path), mod: info.ModTime(), id: src.Ref.SessionID} + d := dirInfo{path: filepath.Dir(src.Path), mod: info.ModTime()} return readThread(d, readWorkspace(d.path)) } @@ -144,10 +146,12 @@ func (p *Provider) List(ctx context.Context, roots session.Roots, opts session.L // --- directory enumeration -------------------------------------------------- +// dirInfo is one session directory. The id is not carried alongside it: the +// directory's base name is the session id, so a second copy could only ever +// disagree with the path it came from. type dirInfo struct { path string mod time.Time - id string } // sessionDirs returns every session directory under /session-state that @@ -174,7 +178,7 @@ func sessionDirs(root string) ([]dirInfo, error) { if err != nil { continue } - dirs = append(dirs, dirInfo{path: dir, mod: info.ModTime(), id: e.Name()}) + dirs = append(dirs, dirInfo{path: dir, mod: info.ModTime()}) } sort.Slice(dirs, func(i, j int) bool { return dirs[i].mod.After(dirs[j].mod) }) return dirs, nil @@ -193,7 +197,7 @@ func readMeta(d dirInfo) (session.Source, error) { // everything a listing row needs except the timeline itself. func readSource(d dirInfo, meta map[string]string) session.Source { src := session.Source{ - Ref: session.Ref{Provider: session.ProviderCopilot, SessionID: d.id}, + Ref: session.Ref{Provider: session.ProviderCopilot, SessionID: filepath.Base(d.path)}, Path: filepath.Join(d.path, eventsFile), UpdatedAt: d.mod, Metadata: map[string]string{},