feat(import): collapse+truncate imported session LastPrompt · Entire

feat(import): collapse+truncate imported session LastPrompt

518880f→main·

computermode·1w ago·4 files·+129 added/-131 removed

Addresses the PR review point that imported sessions stored an un-collapsed, un-truncated prompt in the LastPrompt display field (bloating state JSON and diverging from every other session kind). The full prompt remains in the transcript, which is unchanged and still stored complete per checkpoint.

Moves the shared formatter to session.TruncatePromptForStorage (colocated with State.LastPrompt) so imports and live sessions format it identically, avoid an agentimport->strategy dependency. Simplifies the import session-state tests with shared helpers and readable session IDs.

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

Sessions

01KX4D9VZ10V46ER1P01XHNS0VView transcript

Changes

4

255 unmodified lines

256
257
258
259
259
260
261
262

255 unmodified lines

LastInteractionTime: &endedAt,
    StepCount:           len(turns),
    TokenUsage:          tokens,
    LastPrompt:          turns[len(turns)-1].Prompt,
    LastPrompt:          session.TruncatePromptForStorage(turns[len(turns)-1].Prompt),
    LastCheckpointID:    DeriveCheckpointID(sf.SessionID, turns[len(turns)-1].UUID),
}
if err := store.Save(ctx, state); err != nil {

Mcmd/entire/cli/agentimport/agentimport.go+1/-1

3 unmodified lines

4
5
6
7
8
9
10
11
12
13
28 unmodified lines

42
43
44
43
44
45
46
47
48
49
50
51
47
48
49
50
51
52
53
52
53
54
55
56
57
58
59
57
58
59
60
61
62
63
64
65
66
67
68
69
70
60
61
62
63
64
76
77
65
66
67
79
80
81
68
69
70
84
85
86
87
88
89
90
91
92
93
94
95
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
97
98
99
100
101
94
95
96
97
98
99
100
101
102
103
103
104
105
106
104
105
106
107
108
109
110
111
112
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
113
114
115
134
135
136
137
138
116
117
140
141
142
118
119
120
121
144
145
122
123
124
125
126
127
128
129
152
153
154
155
156
157
158
130
131
132
133
134
135
4 unmodified lines

140
141
142
169
170
171
172
173
143
144
145
146
147
148
178
149
150
151
181
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
187
188
189
190
191
180
181
193
182
183
184
185
186
187
198
199
188
189
190
191
203
204
205
192
193
194
195
196
209
210
211
212
197
198
199
200
201
202
218
219
220
221
222
223
203
204
205
206
207
226
227
228
208
209
210
211
212
1 unmodified line

214
215
216
236
217
218
239
219
220
221
242
243
222
223
224
225
226
227
228
229
230
231

3 unmodified lines

"context"
    "os"
    "path/filepath"
    "strings"
    "testing"
    "time"
    "unicode/utf8"

"github.com/go-git/go-git/v6"

28 unmodified lines

}
func (f runFakeImporter) SplitTurns(_ SessionFile, _ []byte) ([]Turn, error) { return f.turns, nil }

func TestRun_WritesSessionStateForImportedSession(t *testing.T) {
    // Not parallel: t.Chdir.
// importRepo creates an isolated git repo and chdirs into it so session-state
// resolution (git common dir from cwd) targets it. Callers must not use
// t.Parallel (t.Chdir).
func importRepo(t *testing.T) string {
    t.Helper()
    dir := t.TempDir()
    testutil.InitRepo(t, dir)
    testutil.WriteFile(t, dir, "f.txt", "x")
    testutil.GitAdd(t, dir, "f.txt")
    testutil.GitCommit(t, dir, "init")
tp := filepath.Join(dir, "session.jsonl")
if err := os.WriteFile(tp, []byte("{\"type\":\"user\"}\n"), 0o600); err != nil {
    t.Fatalf("write transcript: %v", err)
    }
    t.Chdir(dir)
    return dir
}

// loadState reads a session state by id from the current repo's store.
func loadState(t *testing.T, sid string) *session.State {
    t.Helper()
    ctx := context.Background()
    repo, err := git.PlainOpen(dir)
    if err != nil {
        t.Fatalf("open repo: %v", err)
    }

sid := "44444444-4444-4444-4444-444444444444"
    imp := runFakeImporter{
        files: []SessionFile{{Path: tp, SessionID: sid}},
        turns: []Turn{{UUID: "a", Prompt: "hello", CreatedAt: time.Now().Add(-time.Hour)}},
    }
    if _, err := Run(ctx, repo, imp, Options{RepoRoot: dir, Now: time.Now()}); err != nil {
        t.Fatalf("Run: %v", err)
    }

store, err := session.NewStateStore(ctx)
    if err != nil {
        t.Fatalf("NewStateStore: %v", err)
    }
    st, err := store.Load(ctx, sid)
    if err != nil || st == nil {
        t.Fatalf("Load returned (%v, %v); want an imported state", st, err)
    if err != nil {
        t.Fatalf("Load %s: %v", sid, err)
    }
    if st.Kind != session.KindImported {
        t.Errorf("Kind = %q, want %q", st.Kind, session.KindImported)
    }
    return st
}

func TestRun_DryRunWritesNoSessionState(t *testing.T) {
    // Not parallel: t.Chdir.
    dir := t.TempDir()
    testutil.InitRepo(t, dir)
    testutil.WriteFile(t, dir, "f.txt", "x")
    testutil.GitAdd(t, dir, "f.txt")
    testutil.GitCommit(t, dir, "init")
tp := filepath.Join(dir, "session.jsonl")
if err := os.WriteFile(tp, []byte("{\"type\":\"user\"}\n"), 0o600); err != nil {
    t.Fatalf("write transcript: %v", err)
    }
    t.Chdir(dir)
func TestRun_WritesSessionStateExceptDryRun(t *testing.T) {
for _, tc := range []struct {
    name      string
    dryRun    bool
    wantState bool
} {
    {"writes imported session state", false, true},
    {"dry-run writes nothing", true, false},
} {
    t.Run(tc.name, func(t *testing.T) {
        dir := importRepo(t)
        testutil.WriteFile(t, dir, "f.txt", "x")
        testutil.GitAdd(t, dir, "f.txt")
        testutil.GitCommit(t, dir, "init")
        transcript := filepath.Join(dir, "session.jsonl")
        if err := os.WriteFile(transcript, []byte(`{"type":"user"}`+"\n"), 0o600); err != nil {
            t.Fatalf("write transcript: %v", err)
        }
        repo, err := git.PlainOpen(dir)
        if err != nil {
            t.Fatalf("open repo: %v", err)
        }

ctx := context.Background()
        repo, err := git.PlainOpen(dir)
        if err != nil {
            t.Fatalf("open repo: %v", err)
        }
        ctx := context.Background()
        const sid = "claude-run-session"
        imp := runFakeImporter{
            files: []SessionFile{{Path: transcript, SessionID: sid}},
            turns: []Turn{{UUID: "a", Prompt: "hello", CreatedAt: time.Now().Add(-time.Hour)}},
        }
        if _, err := Run(ctx, repo, imp, Options{RepoRoot: dir, Now: time.Now(), DryRun: tc.dryRun}); err != nil {
            t.Fatalf("Run: %v", err)
        }

sid := "55555555-5555-5555-5555-555555555555"
        imp := runFakeImporter{
            files: []SessionFile{{Path: tp, SessionID: sid}},
            turns: []Turn{{UUID: "a", Prompt: "hello", CreatedAt: time.Now().Add(-time.Hour)}},
        }
        st := loadState(t, sid)
        switch {
        case tc.wantState && (st == nil || st.Kind != session.KindImported):
            t.Fatalf("want imported session state, got %+v", st)
        case !tc.wantState && st != nil:
            t.Fatalf("dry-run must not write session state, got %+v", st)
        }
    })
}
    if _, err := Run(ctx, repo, imp, Options{RepoRoot: dir, Now: time.Now(), DryRun: true}); err != nil {
        t.Fatalf("Run: %v", err)
    }

store, err := session.NewStateStore(ctx)
    if err != nil {
        t.Fatalf("NewStateStore: %v", err)
    }
    st, err := store.Load(ctx, sid)
    if err != nil {
        t.Fatalf("Load: %v", err)
    }
    if st != nil {
        t.Fatalf("dry-run must not write a session state, got %+v", st)
    }
}

func totalImported(s *session.State) int {
    if s.TokenUsage == nil {
        return 0
    }
    return s.TokenUsage.InputTokens + s.TokenUsage.OutputTokens +
        s.TokenUsage.CacheCreationTokens + s.TokenUsage.CacheReadTokens
}

func TestWriteSessionState_CreatesListableImportedState(t *testing.T) {
    // Not parallel: t.Chdir + git-cwd resolution.
    dir := t.TempDir()
    testutil.InitRepo(t, dir)
    t.Chdir(dir)

importRepo(t)
    ctx := context.Background()
    t0 := time.Now().Add(-48 * time.Hour)
    t1 := t0.Add(30 * time.Minute)
     sf := SessionFile{Path: "/does/not/matter.jsonl", SessionID: "11111111-1111-1111-1111-111111111111"}
    started := time.Now().Add(-48 * time.Hour)
    ended := started.Add(30 * time.Minute)
     sf := SessionFile{Path: "session.jsonl", SessionID: "claude-basic-session"}
    turns := []Turn{
        {UUID: "a", Prompt: "first prompt", Model: "claude-x", CreatedAt: t0, Tokens: &types.TokenUsage{InputTokens: 10, OutputTokens: 5}},
        {UUID: "b", Prompt: "second prompt", Model: "claude-x", CreatedAt: t1, Tokens: &types.TokenUsage{InputTokens: 3, OutputTokens: 2}},
        {UUID: "a", Prompt: "opening prompt", Model: "claude-x", CreatedAt: started, Tokens: &types.TokenUsage{InputTokens: 10, OutputTokens: 5}},
        {UUID: "b", Prompt: "latest prompt", Model: "claude-x", CreatedAt: ended, Tokens: &types.TokenUsage{InputTokens: 3, OutputTokens: 2}},
    }

if err := writeSessionState(ctx, fakeImporter{}, sf, turns); err != nil {
        t.Fatalf("writeSessionState: %v", err)
    }

store, err := session.NewStateStore(ctx)
    if err != nil {
        t.Fatalf("NewStateStore: %v", err)
    }
    st, err := store.Load(ctx, sf.SessionID)
    if err != nil || st == nil {
        t.Fatalf("Load returned (%v, %v); want a state", st, err)
    st := loadState(t, sf.SessionID)
    if st == nil {
        t.Fatal("no session state written")
    }
    if st.Kind != session.KindImported {
        t.Errorf("Kind = %q, want %q", st.Kind, session.KindImported)
4 unmodified lines

if st.AgentType != agent.AgentTypeClaudeCode {
        t.Errorf("AgentType = %q, want %q", st.AgentType, agent.AgentTypeClaudeCode)
    }
    if !st.StartedAt.Equal(t0) {
        t.Errorf("StartedAt = %v, want %v (earliest turn)", st.StartedAt, t0)
    }
    if st.EndedAt == nil || !st.EndedAt.Equal(t1) {
        t.Errorf("EndedAt = %v, want %v (latest turn)", st.EndedAt, t1)
    if !st.StartedAt.Equal(started) || st.EndedAt == nil || !st.EndedAt.Equal(ended) {
        t.Errorf("timestamps = [%v, %v], want [%v, %v] (earliest/latest turn)", st.StartedAt, st.EndedAt, started, ended)
    }
    if st.StepCount != 2 {
        t.Errorf("StepCount = %d, want 2", st.StepCount)
    }
    if got := totalImported(st); got != 20 {
    if got := sessionTokenTotal(st); got != 20 {
        t.Errorf("token total = %d, want 20", got)
    }
    if st.LastPrompt != "second prompt" {
    if st.LastPrompt != "latest prompt" {
        t.Errorf("LastPrompt = %q, want the most recent turn's prompt", st.LastPrompt)
    }
}

func TestWriteSessionState_CollapsesAndTruncatesLastPrompt(t *testing.T) {
    importRepo(t)
    ctx := context.Background()

longPrompt := "please   fix\n\n\tthe   login   bug " + strings.Repeat("x", 300)
    sf := SessionFile{Path: "session.jsonl", SessionID: "claude-long-prompt-session"}
    if err := writeSessionState(ctx, fakeImporter{}, sf, []Turn{{UUID: "a", Prompt: longPrompt, CreatedAt: time.Now()}}); err != nil {
        t.Fatalf("writeSessionState: %v", err)
    }

got := loadState(t, sf.SessionID).LastPrompt
    if n := utf8.RuneCountInString(got); n > session.MaxLastPromptRunes {
        t.Errorf("LastPrompt rune count = %d, want <= %d", n, session.MaxLastPromptRunes)
    }
    if strings.ContainsAny(got, "\n\t") || strings.Contains(got, "  ") {
        t.Errorf("LastPrompt not whitespace-collapsed: %q", got)
    }
    if !strings.HasSuffix(got, "...") {
        t.Errorf("LastPrompt should be truncated with ellipsis: %q", got)
    }
}

func TestWriteSessionState_DoesNotClobberLiveSession(t *testing.T) {
    // Not parallel: t.Chdir.
    dir := t.TempDir()
    testutil.InitRepo(t, dir)
    t.Chdir(dir)

importRepo(t)
    ctx := context.Background()
    sid := "22222222-2222-2222-2222-222222222222"

const sid = "claude-live-session"
    store, err := session.NewStateStore(ctx)
    if err != nil {
        t.Fatalf("NewStateStore: %v", err)
    }
    live := &session.State{SessionID: sid, Phase: session.PhaseActive, StartedAt: time.Now()}
    if err := store.Save(ctx, live); err != nil {
    if err := store.Save(ctx, &session.State{SessionID: sid, Phase: session.PhaseActive, StartedAt: time.Now()}); err != nil {
        t.Fatalf("seed live state: %v", err)
    }

sf := SessionFile{Path: "/x.jsonl", SessionID: sid}
    turns := []Turn{{UUID: "a", Prompt: "p", CreatedAt: time.Now()}}
    if err := writeSessionState(ctx, fakeImporter{}, sf, turns); err != nil {
    sf := SessionFile{Path: "session.jsonl", SessionID: sid}
    if err := writeSessionState(ctx, fakeImporter{}, sf, []Turn{{UUID: "a", Prompt: "p", CreatedAt: time.Now()}}); err != nil {
        t.Fatalf("writeSessionState: %v", err)
    }

got, err := store.Load(ctx, sid)
    if err != nil {
        t.Fatalf("Load: %v", err)
    }
    got := loadState(t, sid)
    if got == nil || got.Kind == session.KindImported || got.Phase != session.PhaseActive {
        t.Fatalf("import clobbered a live session: %+v", got)
    }
}

func TestImportedSessionSurvivesListing(t *testing.T) {
    // Not parallel: t.Chdir.
    dir := t.TempDir()
    testutil.InitRepo(t, dir)
    t.Chdir(dir)

func TestWriteSessionState_SurvivesListingWhenOld(t *testing.T) {
    importRepo(t)
    ctx := context.Background()

old := time.Now().Add(-30 * 24 * time.Hour) // 30 days > 7-day stale threshold
    sf := SessionFile{Path: "/x.jsonl", SessionID: "33333333-3333-3333-3333-333333333333"}
    turns := []Turn{{UUID: "a", Prompt: "p", CreatedAt: old}}
    if err := writeSessionState(ctx, fakeImporter{}, sf, turns); err != nil {
    sf := SessionFile{Path: "session.jsonl", SessionID: "claude-old-session"}
    if err := writeSessionState(ctx, fakeImporter{}, sf, []Turn{{UUID: "a", Prompt: "p", CreatedAt: old}}); err != nil {
        t.Fatalf("writeSessionState: %v", err)
    }

1 unmodified line

if err != nil {
        t.Fatalf("ListSessionStates: %v", err)
    }
    found := false
    for _, s := range states {
        if s.SessionID == sf.SessionID {
            found = true
            return
        }
    }
    if !found {
        t.Fatal("imported session (30 days old) was not returned by ListSessionStates")
    }
        t.Fatal("30-day-old imported session was not returned by ListSessionStates")
}

func sessionTokenTotal(s *session.State) int {
    if s.TokenUsage == nil {
        return 0
    }
    return s.TokenUsage.InputTokens + s.TokenUsage.OutputTokens +
        s.TokenUsage.CacheCreationTokens + s.TokenUsage.CacheReadTokens
}