import: fix cursor per-turn IDs and copilot numeric timestamps · Entire

import: fix cursor per-turn IDs and copilot numeric timestamps

b61250a→main·

computermode·2w ago·5 files·+87 added/-19 removed

Cursor transcript lines carry only role + message — no per-turn uuid or timestamp — so DeriveCheckpointID collided on an empty UUID and every turn after the first was skipped as a duplicate, with CreatedAt always zero. Key each turn by its line index (the Codex pattern) and fall back to the file modtime for CreatedAt (the Factory/Gemini pattern). Fixtures now match the real on-disk format.

Copilot timestamps may be numeric epoch-millis, not only RFC3339 strings. Decoding into a string field made json.Unmarshal fail on numeric values, hitting the defensive skip and importing zero turns for the session. Decode the timestamp as json.RawMessage and parse it with the agent's dual-format parser (exported as copilotcli.ParseTimestamp).

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com

Sessions

217723121355View transcript

Changes

5

79 unmodified lines

80
81
82
83
83
84
85
86
39 unmodified lines

126
127
128
129
129
130
131
132
133
134
135
136

79 unmodified lines

Reason:         firstString(raw, "reason"),
}

ts, err := parseTimestamp(raw["timestamp"])
ts, err := ParseTimestamp(raw["timestamp"])
if err != nil {
    return nil, fmt.Errorf("failed to parse hook input: %w", err)
}
39 unmodified lines

return ""
}

func parseTimestamp(raw json.RawMessage) (time.Time, error) {
// ParseTimestamp decodes a Copilot event timestamp, which may be either numeric
// epoch-millis or an RFC3339(Nano) string. A null/zero value returns the zero
// time (callers treat that as "missing"). Exported so transcript importers can
// decode the same dual-format field without re-implementing the logic.
func ParseTimestamp(raw json.RawMessage) (time.Time, error) {
if len(raw) == 0 || string(raw) == "null" {
    return time.Time{}, nil
}

Mcmd/entire/cli/agent/copilotcli/compat.go+6/-2

96 unmodified lines

97
98
99
100
101
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
108
117
118
119
120

96 unmodified lines

return nil, fmt.Errorf("token usage: %w", err)
}
var evt struct {
    ID        string `json:"id"`
    Timestamp string `json:"timestamp"`
    ID        string          `json:"id"`
    Timestamp json.RawMessage `json:"timestamp"`
}
if err := json.Unmarshal(rawLines[start], &evt); err != nil {
    //nolint:nilerr // skip defensively; the line already parsed in copilotPromptText
    return nil, nil
}
// Copilot timestamps may be numeric epoch-millis or an RFC3339
// string; decode via the agent's dual-format parser so a numeric
timestamp doesn't fail the turn.
createdAt, tsErr := copilotcli.ParseTimestamp(evt.Timestamp)
if tsErr != nil {
    // A malformed timestamp degrades to the zero time rather than
dropping the turn.
    createdAt = time.Time{}
}
prompt, _ := copilotPromptText(rawLines[start])
return &Turn{UUID: evt.ID, Prompt: prompt, Model: model, CreatedAt: parseTimestamp(evt.Timestamp), Tokens: tokens}, nil
return &Turn{UUID: evt.ID, Prompt: prompt, Model: model, CreatedAt: createdAt, Tokens: tokens}, nil
})
}

Mcmd/entire/cli/agentimport/copilot.go+12/-3

97 unmodified lines

98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133

97 unmodified lines

}

// TestCopilotSplitTurns_NumericTimestamp covers the dual-format timestamp:
// Copilot may emit a numeric epoch-millis timestamp instead of an RFC3339
// string. Decoding it as a plain string would fail json.Unmarshal and silently
drop the turn, importing zero turns for the session.
func TestCopilotSplitTurns_NumericTimestamp(t *testing.T) {

t.Parallel()

dir := t.TempDir()
    p := filepath.Join(dir, "events.jsonl")
    const epochMillis = 1750377601000 // 2025-06-20T00:00:01Z
    full := []byte(strings.Join([]string{
    `{"type":"session.start","id":"s0","timestamp":1750377600000,"data":{"context":{"gitRoot":"/work/myrepo"}}}`,
    `{"type":"user.message","id":"u1","timestamp":1750377601000,"data":{"content":"first"}}`,
    `{"type":"assistant.message","id":"a1","data":{"content":"ok","outputTokens":5}}`,
    }, "\n") + "\n")
    if err := os.WriteFile(p, full, 0o644); err != nil {
        t.Fatal(err)
    }

turns, err := copilotImporter{}.SplitTurns(SessionFile{Path: p, SessionID: "sess"}, full)
    if err != nil {
        t.Fatal(err)
    }
    if len(turns) != 1 {
        t.Fatalf("numeric timestamp must not drop the turn; want 1, got %d", len(turns))
    }
    if want := time.UnixMilli(epochMillis); !turns[0].CreatedAt.Equal(want) {
        t.Errorf("CreatedAt = %v, want %v (decoded from epoch-millis)", turns[0].CreatedAt, want)
    }
}

func TestCopilotSplitTurns_NonUserEventIsNotATurn(t *testing.T) {

t.Parallel()
dir := t.TempDir()

Mcmd/entire/cli/agentimport/copilot_test.go+30

3 unmodified lines

4
5
6
7
8
9
10
47 unmodified lines

58
59
60
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
64
65
66
76
77
78
79
80
81
82
73
83
84
75
85
86
87
88

3 unmodified lines

"encoding/json"
    "os"
    "path/filepath"
    "strconv"
    "strings"
    "time"

47 unmodified lines

// SplitTurns produces one Turn per user-prompt line, bounded by the next. It
// reuses the package's shared JSONL helpers; Cursor carries no token usage or
// model, so those fields are left zero.
func (cursorImporter) SplitTurns(_ SessionFile, full []byte) ([]Turn, error) {
//
// Real Cursor lines carry only role + message — there is no per-turn uuid or
// timestamp (see cursor/AGENT.md). The append-only line index is the stable
// turn key (as the Codex importer does), so each prompt yields a distinct
// checkpoint ID instead of colliding on an empty UUID and dropping every turn
after the first. The timestamp falls back to the transcript file's modtime
// (as the Factory/Gemini importers do).
func (cursorImporter) SplitTurns(sf SessionFile, full []byte) ([]Turn, error) {
var createdAt time.Time
if info, statErr := os.Stat(sf.Path); statErr == nil {
createdAt = info.ModTime()
}
return splitLineTurns(splitRawLines(full), isUserPromptLine,
    func(rawLines [][]byte, start, _ int, _ []byte) (*Turn, error) {
    var rec struct {
        UUID      string          `json:"uuid"`
        Message   json.RawMessage `json:"message"`
        Timestamp string          `json:"timestamp"`
        Message json.RawMessage `json:"message"`
    }
    if err := json.Unmarshal(rawLines[start], &rec); err != nil {
        //nolint:nilerr // skip defensively; the line already parsed in isUserPromptLine
        return nil, nil
    }
    return &Turn{
        UUID:      rec.UUID,
        UUID:      strconv.Itoa(start),
        Prompt:    transcript.ExtractUserContent(rec.Message),
        CreatedAt: parseTimestamp(rec.Timestamp),
        CreatedAt: createdAt,
    }, nil
    })
}

Mcmd/entire/cli/agentimport/cursor.go+16/-6

89 unmodified lines

90
91
92
93
93
94
95
95
96
97
96
97
98
99
100
101
102
103
104
105
106
100
107
108
109
110
6 unmodified lines

117
118
119
120
121
122
123
124
125
126
127
128
129
130
5 unmodified lines

136
137
138
124
125
126
139
140
141
142
143
144

89 unmodified lines

func TestCursorSplitTurns_TwoPromptsNoTokensNoModel(t *testing.T) {
    .
    \n})