f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
721 lines
26 KiB
Go
721 lines
26 KiB
Go
package parser
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// fixtureAider is the path to the golden fixture derived from a real
|
|
// .aider.chat.history.md file. It holds three runs: two with content and
|
|
// a trailing header-only run with no body.
|
|
func fixtureAider() string {
|
|
return filepath.Join(
|
|
"testdata", "aider", "myrepo", ".aider.chat.history.md",
|
|
)
|
|
}
|
|
|
|
// TestParseAiderRunsPerRun asserts the per-run fan-out: the multi-run
|
|
// fixture yields one session per run that has parseable turns, each with
|
|
// its own StartedAt and FirstMessage. The header-only trailing run
|
|
// contributes no session.
|
|
func TestParseAiderRunsPerRun(t *testing.T) {
|
|
results, err := parseAiderRuns(fixtureAider(), "testmachine")
|
|
require.NoError(t, err)
|
|
// Three runs in the file, but the trailing header-only run has no
|
|
// turns, so only two sessions are emitted.
|
|
require.Len(t, results, 2, "one session per content-bearing run")
|
|
|
|
r0, r1 := results[0], results[1]
|
|
|
|
// Both runs share project/machine/agent but are distinct sessions.
|
|
for _, r := range results {
|
|
assert.Equal(t, AgentAider, r.Session.Agent)
|
|
assert.Equal(t, "testmachine", r.Session.Machine)
|
|
assert.Equal(t, "myrepo", r.Session.Project)
|
|
assert.Contains(t, r.Session.ID, "aider:")
|
|
}
|
|
assert.NotEqual(t, r0.Session.ID, r1.Session.ID,
|
|
"distinct runs get distinct session IDs")
|
|
|
|
// Run 0: header 14:01:00, first prompt "add a retry to the webhook",
|
|
// two user prompts ("add a retry..." and "Step 1").
|
|
assert.Equal(t,
|
|
time.Date(2026, 6, 9, 14, 1, 0, 0, time.UTC), r0.Session.StartedAt)
|
|
assert.Equal(t, r0.Session.StartedAt, r0.Session.EndedAt,
|
|
"a run has no separate end time")
|
|
assert.Contains(t, r0.Session.FirstMessage, "add a retry to the webhook")
|
|
assert.Equal(t, 2, r0.Session.UserMessageCount)
|
|
|
|
// Run 1: header 15:30:00, its own first prompt and message stream.
|
|
assert.Equal(t,
|
|
time.Date(2026, 6, 9, 15, 30, 0, 0, time.UTC), r1.Session.StartedAt)
|
|
assert.Contains(t,
|
|
r1.Session.FirstMessage, "make the timeout configurable")
|
|
assert.Equal(t, 1, r1.Session.UserMessageCount)
|
|
|
|
// The message streams are per-run, not flattened: run 1 must not carry
|
|
// run 0's prompt.
|
|
require.NotEmpty(t, r1.Messages)
|
|
for _, m := range r1.Messages {
|
|
assert.NotContains(t, m.Content, "add a retry to the webhook")
|
|
}
|
|
|
|
// Run 0 roles, in order. "#### Step 1" is an aider USER prompt (a fresh
|
|
// user channel), not an assistant heading: aider only ever writes
|
|
// "#### " for user input. The "> " lines are tool output, surfaced as
|
|
// assistant transcript content (agentsview has no tool role).
|
|
roles := make([]RoleType, len(r0.Messages))
|
|
for i, m := range r0.Messages {
|
|
roles[i] = m.Role
|
|
}
|
|
assert.Equal(t, []RoleType{
|
|
RoleUser, // add a retry to the webhook
|
|
RoleAssistant, // I'll add exponential backoff ... Here is the plan:
|
|
RoleUser, // Step 1
|
|
RoleAssistant, // Wrap the call in a loop.
|
|
RoleAssistant, // > Applied edit ... (tool block, surfaced as assistant)
|
|
}, roles)
|
|
assert.Equal(t, "add a retry to the webhook", r0.Messages[0].Content)
|
|
assert.Contains(t, r0.Messages[1].Content, "exponential backoff")
|
|
assert.Contains(t, r0.Messages[1].Content, "Here is the plan:")
|
|
|
|
// No per-message timestamps in aider's markdown format.
|
|
for _, r := range results {
|
|
for _, m := range r.Messages {
|
|
assert.True(t, m.Timestamp.IsZero())
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestParseAiderRunSingle parses one run out of a file by index.
|
|
func TestParseAiderRunSingle(t *testing.T) {
|
|
sess, msgs, err := parseAiderRun(fixtureAider(), 1, "m")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
require.NotEmpty(t, msgs)
|
|
assert.Contains(t, sess.FirstMessage, "make the timeout configurable")
|
|
assert.Equal(t,
|
|
time.Date(2026, 6, 9, 15, 30, 0, 0, time.UTC), sess.StartedAt)
|
|
|
|
// The trailing header-only run (index 2) yields no session.
|
|
sess2, msgs2, err := parseAiderRun(fixtureAider(), 2, "m")
|
|
require.NoError(t, err)
|
|
assert.Nil(t, sess2)
|
|
assert.Empty(t, msgs2)
|
|
|
|
// Out-of-range indices are tolerated, not errors.
|
|
sess3, _, err := parseAiderRun(fixtureAider(), 99, "m")
|
|
require.NoError(t, err)
|
|
assert.Nil(t, sess3)
|
|
}
|
|
|
|
// TestAiderSessionIDStableOnAppend is the core regression test for
|
|
// MUST-FIX 1's ID scheme: appending a new run to a file must not change
|
|
// any earlier run's session ID. A bare positional index would re-key
|
|
// later runs when an early run is removed; hashing the header plus an
|
|
// equal-header ordinal keeps each ID pinned to its own run.
|
|
func TestAiderSessionIDStableOnAppend(t *testing.T) {
|
|
dir := t.TempDir()
|
|
repo := filepath.Join(dir, "myrepo")
|
|
require.NoError(t, os.MkdirAll(repo, 0o755))
|
|
path := filepath.Join(repo, ".aider.chat.history.md")
|
|
|
|
base := "# aider chat started at 2026-06-09 14:01:00\n" +
|
|
"#### first prompt\nanswer one\n" +
|
|
"# aider chat started at 2026-06-09 15:30:00\n" +
|
|
"#### second prompt\nanswer two\n"
|
|
require.NoError(t, os.WriteFile(path, []byte(base), 0o644))
|
|
|
|
before, err := parseAiderRuns(path, "m")
|
|
require.NoError(t, err)
|
|
require.Len(t, before, 2)
|
|
id0, id1 := before[0].Session.ID, before[1].Session.ID
|
|
|
|
// Append a third run with a fresh timestamp.
|
|
appended := base +
|
|
"# aider chat started at 2026-06-09 16:45:00\n" +
|
|
"#### third prompt\nanswer three\n"
|
|
require.NoError(t, os.WriteFile(path, []byte(appended), 0o644))
|
|
|
|
after, err := parseAiderRuns(path, "m")
|
|
require.NoError(t, err)
|
|
require.Len(t, after, 3)
|
|
|
|
assert.Equal(t, id0, after[0].Session.ID,
|
|
"appending a run must not re-key the first run")
|
|
assert.Equal(t, id1, after[1].Session.ID,
|
|
"appending a run must not re-key the second run")
|
|
assert.NotEqual(t, id0, after[2].Session.ID)
|
|
assert.NotEqual(t, id1, after[2].Session.ID)
|
|
}
|
|
|
|
// TestAiderSessionIDStableOnEarlyRemoval asserts that removing an early
|
|
// run does not re-key the runs that follow it (the bare-positional-index
|
|
// failure mode).
|
|
func TestAiderSessionIDStableOnEarlyRemoval(t *testing.T) {
|
|
dir := t.TempDir()
|
|
repo := filepath.Join(dir, "myrepo")
|
|
require.NoError(t, os.MkdirAll(repo, 0o755))
|
|
path := filepath.Join(repo, ".aider.chat.history.md")
|
|
|
|
run0 := "# aider chat started at 2026-06-09 14:01:00\n" +
|
|
"#### first prompt\nanswer one\n"
|
|
run1 := "# aider chat started at 2026-06-09 15:30:00\n" +
|
|
"#### second prompt\nanswer two\n"
|
|
require.NoError(t, os.WriteFile(path, []byte(run0+run1), 0o644))
|
|
|
|
before, err := parseAiderRuns(path, "m")
|
|
require.NoError(t, err)
|
|
require.Len(t, before, 2)
|
|
secondID := before[1].Session.ID
|
|
|
|
// Remove the first run; the second run is now positionally index 0 but
|
|
// must keep its original ID.
|
|
require.NoError(t, os.WriteFile(path, []byte(run1), 0o644))
|
|
after, err := parseAiderRuns(path, "m")
|
|
require.NoError(t, err)
|
|
require.Len(t, after, 1)
|
|
assert.Equal(t, secondID, after[0].Session.ID,
|
|
"removing an earlier run must not re-key a later run")
|
|
}
|
|
|
|
// TestAiderEqualHeaderRunsGetStableDistinctIDs covers the rare case of two
|
|
// runs with identical header timestamps: they must get distinct, stable
|
|
// IDs disambiguated by their ordinal among equal-header runs.
|
|
func TestAiderEqualHeaderRunsGetStableDistinctIDs(t *testing.T) {
|
|
dir := t.TempDir()
|
|
repo := filepath.Join(dir, "myrepo")
|
|
require.NoError(t, os.MkdirAll(repo, 0o755))
|
|
path := filepath.Join(repo, ".aider.chat.history.md")
|
|
|
|
content := "# aider chat started at 2026-06-09 14:01:00\n" +
|
|
"#### prompt a\nanswer a\n" +
|
|
"# aider chat started at 2026-06-09 14:01:00\n" +
|
|
"#### prompt b\nanswer b\n"
|
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
|
|
|
|
r1, err := parseAiderRuns(path, "m")
|
|
require.NoError(t, err)
|
|
require.Len(t, r1, 2)
|
|
assert.NotEqual(t, r1[0].Session.ID, r1[1].Session.ID,
|
|
"equal-header runs disambiguate by ordinal")
|
|
|
|
// Stable across re-parse.
|
|
r2, err := parseAiderRuns(path, "m")
|
|
require.NoError(t, err)
|
|
require.Len(t, r2, 2)
|
|
assert.Equal(t, r1[0].Session.ID, r2[0].Session.ID)
|
|
assert.Equal(t, r1[1].Session.ID, r2[1].Session.ID)
|
|
}
|
|
|
|
// TestAiderSessionIDStableAcrossExtractionDirs is the MEDIUM-1 regression
|
|
// test for SSH sync. During remote sync the history file is extracted to a
|
|
// RANDOM local temp dir, so hashing the on-disk path would re-key the run on
|
|
// every sync. Passing a canonical identity path (the remote physical path)
|
|
// to parseAiderRunsWithID must produce the SAME ID regardless of where the
|
|
// file physically lives, while the plain on-disk parse (local behavior)
|
|
// produces DIFFERENT IDs for the two locations.
|
|
func TestAiderSessionIDStableAcrossExtractionDirs(t *testing.T) {
|
|
content := "# aider chat started at 2026-06-09 14:01:00\n" +
|
|
"#### first prompt\nanswer one\n" +
|
|
"# aider chat started at 2026-06-09 15:30:00\n" +
|
|
"#### second prompt\nanswer two\n"
|
|
|
|
// Two distinct on-disk locations standing in for two sync runs that
|
|
// extract the same remote file under different random temp dirs.
|
|
writeAt := func(t *testing.T) string {
|
|
t.Helper()
|
|
repo := filepath.Join(t.TempDir(), "myrepo")
|
|
require.NoError(t, os.MkdirAll(repo, 0o755))
|
|
p := filepath.Join(repo, ".aider.chat.history.md")
|
|
require.NoError(t, os.WriteFile(p, []byte(content), 0o644))
|
|
return p
|
|
}
|
|
pathA := writeAt(t)
|
|
pathB := writeAt(t)
|
|
require.NotEqual(t, pathA, pathB, "the two extraction paths must differ")
|
|
|
|
// The canonical identity is the remote physical path, the same for both
|
|
// syncs regardless of the temp extraction dir.
|
|
const identity = "host:/home/wes/myrepo/.aider.chat.history.md"
|
|
|
|
withIDa, err := parseAiderRunsWithID(pathA, identity, "m")
|
|
require.NoError(t, err)
|
|
require.Len(t, withIDa, 2)
|
|
withIDb, err := parseAiderRunsWithID(pathB, identity, "m")
|
|
require.NoError(t, err)
|
|
require.Len(t, withIDb, 2)
|
|
|
|
assert.Equal(t, withIDa[0].Session.ID, withIDb[0].Session.ID,
|
|
"identical identity path must yield a stable ID across temp dirs")
|
|
assert.Equal(t, withIDa[1].Session.ID, withIDb[1].Session.ID,
|
|
"identical identity path must yield a stable ID across temp dirs")
|
|
|
|
// Sanity: the ID is derived from the identity path, not the temp path.
|
|
// Without an identity path (local behavior), the two extraction paths
|
|
// produce DIFFERENT IDs -- exactly the instability the identity path
|
|
// fixes. parseAiderRuns is the empty-identity passthrough.
|
|
localA, err := parseAiderRuns(pathA, "m")
|
|
require.NoError(t, err)
|
|
require.Len(t, localA, 2)
|
|
localB, err := parseAiderRuns(pathB, "m")
|
|
require.NoError(t, err)
|
|
require.Len(t, localB, 2)
|
|
assert.NotEqual(t, localA[0].Session.ID, localB[0].Session.ID,
|
|
"without an identity path the temp path leaks into the ID")
|
|
assert.NotEqual(t, withIDa[0].Session.ID, localA[0].Session.ID,
|
|
"identity-path ID differs from on-disk-path ID")
|
|
|
|
// parseAiderRunWithID (single-run) must agree with the fan-out variant.
|
|
single, _, err := parseAiderRunWithID(pathB, identity, 0, "m")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, single)
|
|
assert.Equal(t, withIDa[0].Session.ID, single.ID,
|
|
"single-run identity ID must match the fan-out identity ID")
|
|
}
|
|
|
|
// TestAiderSameHeaderEarlyRemovalRekeysSiblings pins the documented residual
|
|
// limitation of the ordinal disambiguator: when multiple runs share a
|
|
// byte-identical header timestamp, removing an earlier same-header run shifts
|
|
// the ordinals of the later same-header siblings and therefore re-keys their
|
|
// IDs. A same-second collision in one repo is rare; this test exists so the
|
|
// behavior is explicit and any future change to the scheme is a conscious one.
|
|
func TestAiderSameHeaderEarlyRemovalRekeysSiblings(t *testing.T) {
|
|
dir := t.TempDir()
|
|
repo := filepath.Join(dir, "myrepo")
|
|
require.NoError(t, os.MkdirAll(repo, 0o755))
|
|
path := filepath.Join(repo, ".aider.chat.history.md")
|
|
|
|
hdr := "# aider chat started at 2026-06-09 14:01:00\n"
|
|
runA := hdr + "#### prompt a\nanswer a\n"
|
|
runB := hdr + "#### prompt b\nanswer b\n"
|
|
runC := hdr + "#### prompt c\nanswer c\n"
|
|
require.NoError(t, os.WriteFile(path, []byte(runA+runB+runC), 0o644))
|
|
|
|
before, err := parseAiderRuns(path, "m")
|
|
require.NoError(t, err)
|
|
require.Len(t, before, 3)
|
|
idB := before[1].Session.ID
|
|
|
|
// Remove the first same-header run; runs b and c each shift down one
|
|
// equal-header ordinal.
|
|
require.NoError(t, os.WriteFile(path, []byte(runB+runC), 0o644))
|
|
after, err := parseAiderRuns(path, "m")
|
|
require.NoError(t, err)
|
|
require.Len(t, after, 2)
|
|
|
|
// Documented residual: run b, now ordinal 0, takes the former ordinal-0
|
|
// ID, so its ID changes. A unique-header run keeps its ID instead
|
|
// (see TestAiderSessionIDStableOnEarlyRemoval).
|
|
assert.NotEqual(t, idB, after[0].Session.ID,
|
|
"same-header early removal re-keys later siblings (accepted residual)")
|
|
}
|
|
|
|
// TestDiscoverAiderFindsFilesAtMaxDepth pins the depth-cap fix: a history
|
|
// file whose parent directory is exactly aiderMaxWalkDepth (4) levels under
|
|
// the root must be discovered, while one a level deeper must not. A `>=`
|
|
// test skipped the max-depth directory before its files were seen.
|
|
func TestDiscoverAiderFindsFilesAtMaxDepth(t *testing.T) {
|
|
root := t.TempDir()
|
|
atCap := filepath.Join(root, "a", "b", "c", "d") // parent depth 4
|
|
tooDeep := filepath.Join(root, "a", "b", "c", "d", "e") // parent depth 5
|
|
require.NoError(t, os.MkdirAll(atCap, 0o755))
|
|
require.NoError(t, os.MkdirAll(tooDeep, 0o755))
|
|
hist := "# aider chat started at 2026-06-09 14:01:00\n#### p\nans\n"
|
|
atCapFile := filepath.Join(atCap, ".aider.chat.history.md")
|
|
tooDeepFile := filepath.Join(tooDeep, ".aider.chat.history.md")
|
|
require.NoError(t, os.WriteFile(atCapFile, []byte(hist), 0o644))
|
|
require.NoError(t, os.WriteFile(tooDeepFile, []byte(hist), 0o644))
|
|
|
|
var paths []string
|
|
for _, f := range discoverAiderSessions(root) {
|
|
paths = append(paths, f.Path)
|
|
}
|
|
assert.Contains(t, paths, atCapFile,
|
|
"a history file at the max walk depth must be discovered")
|
|
assert.NotContains(t, paths, tooDeepFile,
|
|
"a history file below the max walk depth must not be discovered")
|
|
}
|
|
|
|
// TestAiderRawIDAtDetectsShiftedIndex pins that a stored positional
|
|
// "<history>#<idx>" path is validated by recomputed ID: after an earlier run
|
|
// is removed, the stale index no longer recomputes to a run, and the session
|
|
// re-resolves to the run's new index by raw ID (the engine fast-path
|
|
// correctness fix).
|
|
func TestAiderRawIDAtDetectsShiftedIndex(t *testing.T) {
|
|
dir := t.TempDir()
|
|
repo := filepath.Join(dir, "myrepo")
|
|
require.NoError(t, os.MkdirAll(repo, 0o755))
|
|
path := filepath.Join(repo, ".aider.chat.history.md")
|
|
|
|
run0 := "# aider chat started at 2026-06-09 14:01:00\n#### first\nans1\n"
|
|
run1 := "# aider chat started at 2026-06-09 15:30:00\n#### second\nans2\n"
|
|
require.NoError(t, os.WriteFile(path, []byte(run0+run1), 0o644))
|
|
|
|
// run1's raw ID, as stored under the virtual path "<path>#1".
|
|
id1, ok := AiderRawIDAt(path, 1)
|
|
require.True(t, ok)
|
|
_, ok = AiderRawIDAt(path, 5)
|
|
assert.False(t, ok, "out-of-range index returns false")
|
|
|
|
// Remove the first run; index 1 is now out of range, so the stored
|
|
// positional path can no longer be trusted by recomputed ID.
|
|
require.NoError(t, os.WriteFile(path, []byte(run1), 0o644))
|
|
_, ok = AiderRawIDAt(path, 1)
|
|
assert.False(t, ok, "stale index 1 no longer recomputes to a run")
|
|
|
|
// Re-resolution by raw ID finds run1 at its new index 0.
|
|
resolved := findAiderSourceFile(dir, id1)
|
|
assert.Equal(t, AiderVirtualPath(path, 0), resolved,
|
|
"re-resolving by raw ID locates the run at its shifted index")
|
|
}
|
|
|
|
func TestParseAiderTimestamp(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want time.Time
|
|
ok bool
|
|
}{
|
|
{
|
|
name: "valid naive local assumed UTC",
|
|
in: "2026-06-09 14:01:00",
|
|
want: time.Date(2026, 6, 9, 14, 1, 0, 0, time.UTC),
|
|
ok: true,
|
|
},
|
|
{"trailing space tolerated", "2026-06-09 14:01:00 ",
|
|
time.Date(2026, 6, 9, 14, 1, 0, 0, time.UTC), true},
|
|
{"garbage rejected", "not a date", time.Time{}, false},
|
|
{"empty rejected", "", time.Time{}, false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got, ok := parseAiderTimestamp(c.in)
|
|
assert.Equal(t, c.ok, ok)
|
|
assert.Equal(t, c.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSplitAiderRuns(t *testing.T) {
|
|
content := "junk before any header\n" +
|
|
"# aider chat started at 2026-06-09 14:01:00\n" +
|
|
"#### hi\n" +
|
|
"answer\n" +
|
|
"# aider chat started at 2026-06-09 15:00:00\n" +
|
|
"# aider chat started at 2026-06-09 16:00:00\n" +
|
|
"#### again\n"
|
|
|
|
runs := splitAiderRuns(content)
|
|
require.Len(t, runs, 3, "empty middle run keeps its slot")
|
|
|
|
first, ok := parseAiderTimestamp("2026-06-09 14:01:00")
|
|
require.True(t, ok)
|
|
assert.Equal(t, first, runs[0].started)
|
|
assert.Equal(t, "2026-06-09 14:01:00", runs[0].rawHeader)
|
|
assert.Contains(t, runs[0].body, "#### hi")
|
|
assert.Empty(t, runs[1].body, "header-only run has empty body")
|
|
|
|
// Bytes before the first header are dropped.
|
|
for _, r := range runs {
|
|
assert.NotContains(t, r.body, "junk before any header")
|
|
}
|
|
}
|
|
|
|
func TestParseAiderTurnsToolAndEditedFiles(t *testing.T) {
|
|
body := "#### fix the bug\n" +
|
|
"Here is the fix.\n" +
|
|
"Some prose.\n" +
|
|
"> Applied edit to src/a.py\n" +
|
|
"> Applied edit to src/a.py\n" +
|
|
"> Creating empty file src/b.py\n" +
|
|
"> Did not apply edit to src/c.py (--dry-run)\n" +
|
|
"> Skipping edits to src/d.py\n"
|
|
|
|
msgs, touched := parseAiderTurns(body)
|
|
|
|
roles := make([]RoleType, len(msgs))
|
|
for i, m := range msgs {
|
|
roles[i] = m.Role
|
|
}
|
|
// user, assistant prose, then the tool block surfaced as assistant.
|
|
assert.Equal(t,
|
|
[]RoleType{RoleUser, RoleAssistant, RoleAssistant}, roles)
|
|
assert.Equal(t, "fix the bug", msgs[0].Content)
|
|
assert.Contains(t, msgs[1].Content, "Here is the fix.")
|
|
assert.Contains(t, msgs[2].Content, "Applied edit to src/a.py")
|
|
|
|
// Dedup; dry-run and skip lines contribute nothing.
|
|
assert.Equal(t, []string{"src/a.py", "src/b.py"}, touched)
|
|
|
|
for _, m := range msgs {
|
|
assert.True(t, m.Timestamp.IsZero())
|
|
}
|
|
}
|
|
|
|
func TestParseAiderTurnsBlankLinesDoNotSplit(t *testing.T) {
|
|
body := "#### q\n" +
|
|
"para one\n" +
|
|
"\n" +
|
|
"para two\n"
|
|
msgs, _ := parseAiderTurns(body)
|
|
require.Len(t, msgs, 2)
|
|
assert.Equal(t, RoleUser, msgs[0].Role)
|
|
assert.Equal(t, RoleAssistant, msgs[1].Role)
|
|
assert.Contains(t, msgs[1].Content, "para one")
|
|
assert.Contains(t, msgs[1].Content, "para two")
|
|
}
|
|
|
|
func TestParseAiderRunsEmptyAndGarbage(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
cases := []struct {
|
|
name string
|
|
content string
|
|
wantCount int
|
|
}{
|
|
{"empty file", "", 0},
|
|
{"only preamble, no header", "some text\nwith no header\n", 0},
|
|
{"header-only run, no body", "# aider chat started at 2026-06-09 14:01:00\n", 0},
|
|
{
|
|
name: "garbage header timestamp still indexed",
|
|
content: "# aider chat started at not-a-real-date\n#### hello\nhi\n",
|
|
wantCount: 1,
|
|
},
|
|
{
|
|
name: "crlf line endings",
|
|
content: "# aider chat started at 2026-06-09 14:01:00\r\n" +
|
|
"#### crlf prompt\r\nanswer\r\n",
|
|
wantCount: 1,
|
|
},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
repo := filepath.Join(dir, c.name)
|
|
require.NoError(t, os.MkdirAll(repo, 0o755))
|
|
path := filepath.Join(repo, ".aider.chat.history.md")
|
|
require.NoError(t,
|
|
os.WriteFile(path, []byte(c.content), 0o644))
|
|
|
|
results, err := parseAiderRuns(path, "m")
|
|
require.NoError(t, err) // never panics, never hard-errors
|
|
assert.Len(t, results, c.wantCount)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAiderVirtualPathRoundTrip(t *testing.T) {
|
|
hist := filepath.Join("repo", ".aider.chat.history.md")
|
|
vp := AiderVirtualPath(hist, 3)
|
|
assert.Equal(t, hist+"#3", vp)
|
|
|
|
gotPath, gotIdx, ok := ParseAiderVirtualPath(vp)
|
|
require.True(t, ok)
|
|
assert.Equal(t, hist, gotPath)
|
|
assert.Equal(t, 3, gotIdx)
|
|
|
|
// Non-virtual / invalid inputs are rejected.
|
|
cases := []string{
|
|
hist, // real path, no "#"
|
|
"repo/other.md#0", // wrong base name
|
|
hist + "#", // empty index
|
|
hist + "#-1", // negative index
|
|
hist + "#notanumber", // non-numeric index
|
|
"plain/path", // no separator at all
|
|
}
|
|
for _, in := range cases {
|
|
_, _, ok := ParseAiderVirtualPath(in)
|
|
assert.False(t, ok, "should reject %q", in)
|
|
}
|
|
}
|
|
|
|
// TestAiderRegistryOptInDiscovery pins that Aider is not discovered by
|
|
// default. Aider has no central store, and a rootless $HOME scan can trigger
|
|
// macOS privacy prompts during passive background refreshes, so users must opt
|
|
// in with AIDER_DIR or aider_dirs. ShallowWatch must stay true so a configured
|
|
// broad root is watched only at the root, relying on the periodic sync.
|
|
func TestAiderRegistryOptInDiscovery(t *testing.T) {
|
|
def, ok := AgentByType(AgentAider)
|
|
require.True(t, ok, "AgentAider missing from Registry")
|
|
assert.Empty(t, def.DefaultDirs,
|
|
"aider must not be discovered by default; opt in via AIDER_DIR/aider_dirs")
|
|
assert.True(t, def.ShallowWatch,
|
|
"aider must watch an opt-in broad root shallowly, not recurse all of it")
|
|
// The shallow-watch contract relies on no static subdir or custom
|
|
// watch-roots wiring overriding it.
|
|
assert.Empty(t, def.WatchSubdirs)
|
|
assert.Nil(t, def.WatchRootsFunc)
|
|
}
|
|
|
|
func TestDiscoverAiderSessions(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
// A repo with a history file at its root.
|
|
repo := filepath.Join(root, "proj")
|
|
require.NoError(t, os.MkdirAll(repo, 0o755))
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(repo, ".aider.chat.history.md"),
|
|
[]byte("# aider chat started at 2026-06-09 14:01:00\n"), 0o644))
|
|
// A sibling non-matching file must never be picked up.
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(repo, "README.md"), []byte("x\n"), 0o644))
|
|
|
|
// A history file buried in a skipped dir must be ignored.
|
|
skip := filepath.Join(repo, "node_modules", "dep")
|
|
require.NoError(t, os.MkdirAll(skip, 0o755))
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(skip, ".aider.chat.history.md"),
|
|
[]byte("# aider chat started at 2026-06-09 14:01:00\n"), 0o644))
|
|
|
|
files := discoverAiderSessions(root)
|
|
require.Len(t, files, 1, "found repo file, skipped node_modules")
|
|
assert.Equal(t, AgentAider, files[0].Agent)
|
|
assert.Equal(t, aiderHistoryFile, filepath.Base(files[0].Path))
|
|
|
|
// Empty root is tolerated.
|
|
assert.Empty(t, discoverAiderSessions(""))
|
|
}
|
|
|
|
func TestAiderShouldSkipProtectedHomeDirsOnlyOnDarwinHomeRoot(t *testing.T) {
|
|
home := filepath.Join(string(os.PathSeparator), "home", "user")
|
|
|
|
assert.True(t, aiderShouldSkipProtectedHomeDirs(home, home, "darwin"))
|
|
assert.False(t, aiderShouldSkipProtectedHomeDirs(home, home, "linux"))
|
|
assert.False(t, aiderShouldSkipProtectedHomeDirs(home, home, "windows"))
|
|
assert.False(t,
|
|
aiderShouldSkipProtectedHomeDirs(filepath.Join(home, "Documents"), home, "darwin"),
|
|
"explicit protected roots are user-scoped opt-ins")
|
|
}
|
|
|
|
func TestAiderProtectedHomeDirsCoversMacOSTCCPrompts(t *testing.T) {
|
|
for _, name := range []string{
|
|
"Desktop",
|
|
"Documents",
|
|
"Downloads",
|
|
"Movies",
|
|
"Music",
|
|
"Photos",
|
|
"Pictures",
|
|
} {
|
|
_, ok := aiderProtectedHomeDirs[name]
|
|
assert.True(t, ok, "%s should be pruned from broad home discovery", name)
|
|
}
|
|
}
|
|
|
|
func TestAiderBroadHomeWalkRootsExcludeMacOSProtectedDirs(t *testing.T) {
|
|
home := t.TempDir()
|
|
for _, name := range []string{"Code", "Documents", "Downloads"} {
|
|
require.NoError(t, os.Mkdir(filepath.Join(home, name), 0o755))
|
|
}
|
|
|
|
roots := aiderDiscoveryWalkRoots(home, home, "darwin")
|
|
|
|
assert.Contains(t, roots, filepath.Join(home, "Code"))
|
|
assert.NotContains(t, roots, home,
|
|
"broad Aider home discovery must not recursively walk $HOME on macOS")
|
|
assert.NotContains(t, roots, filepath.Join(home, "Documents"))
|
|
assert.NotContains(t, roots, filepath.Join(home, "Downloads"))
|
|
}
|
|
|
|
func TestDiscoverAiderSessionsSkipsMacOSProtectedDirs(t *testing.T) {
|
|
if runtime.GOOS != "darwin" {
|
|
t.Skip("macOS TCC protected-directory pruning is Darwin-only")
|
|
}
|
|
root := t.TempDir()
|
|
t.Setenv("HOME", root)
|
|
for _, name := range []string{
|
|
"Desktop",
|
|
"Documents",
|
|
"Downloads",
|
|
"Movies",
|
|
"Music",
|
|
"Photos",
|
|
"Pictures",
|
|
} {
|
|
protectedRepo := filepath.Join(root, name, "proj")
|
|
require.NoError(t, os.MkdirAll(protectedRepo, 0o755))
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(protectedRepo, ".aider.chat.history.md"),
|
|
[]byte("# aider chat started at 2026-06-09 14:01:00\n"), 0o644))
|
|
}
|
|
|
|
files := discoverAiderSessions(root)
|
|
assert.Empty(t, files, "default home discovery must not enter macOS TCC-protected folders")
|
|
}
|
|
|
|
func TestDiscoverAiderSessionsAllowsExplicitProtectedRoot(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
documentsRoot := filepath.Join(home, "Documents")
|
|
repo := filepath.Join(documentsRoot, "proj")
|
|
require.NoError(t, os.MkdirAll(repo, 0o755))
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(repo, ".aider.chat.history.md"),
|
|
[]byte("# aider chat started at 2026-06-09 14:01:00\n"), 0o644))
|
|
|
|
files := discoverAiderSessions(documentsRoot)
|
|
require.Len(t, files, 1, "explicit Aider roots should still be scanned")
|
|
assert.Equal(t, filepath.Join(repo, ".aider.chat.history.md"), files[0].Path)
|
|
}
|
|
|
|
// TestAiderWalkBudget documents the wall-clock budget (MUST-FIX 3,
|
|
// ported from the Rust adapter's WALK_BUDGET_SECS) and confirms a normal
|
|
// discovery walk completes well within it. The budget is checked inside
|
|
// the WalkDir callback and returns filepath.SkipAll once exceeded.
|
|
func TestAiderWalkBudget(t *testing.T) {
|
|
assert.Equal(t, 2*time.Second, aiderWalkBudget,
|
|
"budget mirrors the Rust adapter's WALK_BUDGET_SECS")
|
|
|
|
root := t.TempDir()
|
|
// A small but multi-level tree with one history file.
|
|
deep := filepath.Join(root, "a", "b", "c")
|
|
require.NoError(t, os.MkdirAll(deep, 0o755))
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(deep, ".aider.chat.history.md"),
|
|
[]byte("# aider chat started at 2026-06-09 14:01:00\n#### hi\nok\n"),
|
|
0o644))
|
|
|
|
start := time.Now()
|
|
files := discoverAiderSessions(root)
|
|
elapsed := time.Since(start)
|
|
assert.Less(t, elapsed, aiderWalkBudget,
|
|
"a normal walk finishes well under budget")
|
|
require.Len(t, files, 1)
|
|
}
|
|
|
|
func TestFindAiderSourceFile(t *testing.T) {
|
|
root := t.TempDir()
|
|
repo := filepath.Join(root, "myrepo")
|
|
require.NoError(t, os.MkdirAll(repo, 0o755))
|
|
hist := filepath.Join(repo, ".aider.chat.history.md")
|
|
content := "# aider chat started at 2026-06-09 14:01:00\n" +
|
|
"#### first\nanswer one\n" +
|
|
"# aider chat started at 2026-06-09 15:30:00\n" +
|
|
"#### second\nanswer two\n"
|
|
require.NoError(t, os.WriteFile(hist, []byte(content), 0o644))
|
|
|
|
// Parse the runs to learn the real per-run raw IDs.
|
|
results, err := parseAiderRuns(hist, "m")
|
|
require.NoError(t, err)
|
|
require.Len(t, results, 2)
|
|
|
|
for i, r := range results {
|
|
rawID := r.Session.ID[len(aiderIDPrefix):]
|
|
found := findAiderSourceFile(root, rawID)
|
|
require.NotEmpty(t, found, "run %d should resolve", i)
|
|
gotPath, gotIdx, ok := ParseAiderVirtualPath(found)
|
|
require.True(t, ok)
|
|
assert.Equal(t, aiderHistoryFile, filepath.Base(gotPath))
|
|
assert.Equal(t, i, gotIdx, "run %d resolves to run index %d", i, i)
|
|
}
|
|
|
|
assert.Empty(t, findAiderSourceFile(root, "nonexistent-id"))
|
|
assert.Empty(t, findAiderSourceFile("", "anything"))
|
|
}
|