Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 30 additions & 21 deletions internal/agent/grouping.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,14 @@ type FileGroupInfo struct {
Files []string `json:"files"`
}

// groupingResponse is one group as returned by the LLM. Files holds the integer
// indices printed beside each file in buildFileList, not paths: an index costs a
// few output tokens where a path costs its full length, so the response stays
// well inside the completion limit even for large change sets and no longer
// truncates into a whole-set per-file fallback.
type groupingResponse struct {
Label string `json:"label"`
Files []string `json:"files"`
Label string `json:"label"`
Files []int `json:"files"`
}

// groupDiffsResult holds the grouping output and any LLM usage to record.
Expand Down Expand Up @@ -241,11 +246,16 @@ func callGroupingLLM(ctx context.Context, diffs []model.Diff, client llm.LLMClie
return groups, usage, err
}

// buildFileList renders the change set for the grouping prompt, one file per
// line, each prefixed with its zero-based index. The index is what the model
// groups by (see groupingResponse): it maps back to diffs[i] in
// parseGroupingResponse, so the two must agree on ordering — both walk diffs in
// slice order. formatDiffEntry is left untouched because it is shared with the
// other-changed-files block, which has no index to show.
func buildFileList(diffs []model.Diff) string {
var sb strings.Builder
for _, d := range diffs {
sb.WriteString(formatDiffEntry(d))
sb.WriteString("\n")
for i, d := range diffs {
fmt.Fprintf(&sb, "[%d] %s\n", i, formatDiffEntry(d))
}
return sb.String()
}
Expand All @@ -266,40 +276,39 @@ func parseGroupingResponse(content string, diffs []model.Diff) ([]FileGroup, err

var resp []groupingResponse
if err := json.Unmarshal([]byte(content), &resp); err != nil {
// A parse failure (including a response truncated by the completion limit)
// returns an error; the caller falls back to per-file dispatch. Indices
// keep this output an order of magnitude smaller than paths did, so a
// truncation that reaches this point is far rarer than before.
return nil, fmt.Errorf("parse grouping JSON: %w", err)
}

diffByPath := make(map[string]model.Diff, len(diffs))
for _, d := range diffs {
diffByPath[d.NewPath] = d
}

seen := make(map[string]bool, len(diffs))
seen := make([]bool, len(diffs))
var groups []FileGroup

for _, g := range resp {
var gDiffs []model.Diff
for _, f := range g.Files {
if seen[f] {
// Skip duplicate — file already assigned to an earlier group
for _, idx := range g.Files {
if idx < 0 || idx >= len(diffs) {
// Skip an index that names no file — the index equivalent of the
// unknown-path case the path-based version skipped.
continue
}
d, ok := diffByPath[f]
if !ok {
// Skip unknown file path
if seen[idx] {
// Skip duplicate — file already assigned to an earlier group.
continue
}
seen[f] = true
gDiffs = append(gDiffs, d)
seen[idx] = true
gDiffs = append(gDiffs, diffs[idx])
}
if len(gDiffs) > 0 {
groups = append(groups, FileGroup{Label: g.Label, Diffs: gDiffs})
}
}

// Files not covered by any group get their own single-file group
for _, d := range diffs {
if !seen[d.NewPath] {
for i, d := range diffs {
if !seen[i] {
groups = append(groups, FileGroup{Label: d.NewPath, Diffs: []model.Diff{d}})
}
}
Expand Down
48 changes: 31 additions & 17 deletions internal/agent/grouping_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ func TestParseGroupingResponse_Valid(t *testing.T) {
{NewPath: "docs/README.md"},
}
content := `[
{"label": "auth handler", "files": ["internal/auth/handler.go", "internal/auth/handler_test.go"]},
{"label": "docs", "files": ["docs/README.md"]}
{"label": "auth handler", "files": [0, 1]},
{"label": "docs", "files": [2]}
]`
groups, err := parseGroupingResponse(content, diffs)
if err != nil {
Expand All @@ -87,7 +87,7 @@ func TestParseGroupingResponse_MarkdownFenced(t *testing.T) {
{NewPath: "a.go"},
{NewPath: "b.go"},
}
content := "```json\n" + `[{"label":"all","files":["a.go","b.go"]}]` + "\n```"
content := "```json\n" + `[{"label":"all","files":[0,1]}]` + "\n```"
groups, err := parseGroupingResponse(content, diffs)
if err != nil {
t.Fatal(err)
Expand All @@ -101,7 +101,7 @@ func TestParseGroupingResponse_DuplicateFile(t *testing.T) {
diffs := []model.Diff{
{NewPath: "a.go"},
}
content := `[{"label":"g1","files":["a.go"]},{"label":"g2","files":["a.go"]}]`
content := `[{"label":"g1","files":[0]},{"label":"g2","files":[0]}]`
groups, err := parseGroupingResponse(content, diffs)
if err != nil {
t.Fatal(err)
Expand All @@ -120,7 +120,7 @@ func TestParseGroupingResponse_MissingFile(t *testing.T) {
{NewPath: "a.go"},
{NewPath: "b.go"},
}
content := `[{"label":"g1","files":["a.go"]}]`
content := `[{"label":"g1","files":[0]}]`
groups, err := parseGroupingResponse(content, diffs)
if err != nil {
t.Fatal(err)
Expand All @@ -138,12 +138,12 @@ func TestParseGroupingResponse_UnknownFile(t *testing.T) {
diffs := []model.Diff{
{NewPath: "a.go"},
}
content := `[{"label":"g1","files":["a.go","unknown.go"]}]`
content := `[{"label":"g1","files":[0,99]}]`
groups, err := parseGroupingResponse(content, diffs)
if err != nil {
t.Fatal(err)
}
// unknown.go is skipped; a.go still forms the group
// index 99 is out of range and skipped; a.go still forms the group
if len(groups) != 1 {
t.Fatalf("got %d groups, want 1", len(groups))
}
Expand All @@ -160,6 +160,18 @@ func TestParseGroupingResponse_InvalidJSON(t *testing.T) {
}
}

func TestParseGroupingResponse_Truncated(t *testing.T) {
// A response cut off by the completion limit is no longer partially salvaged:
// json.Unmarshal fails, parseGroupingResponse returns an error, and the caller
// falls back to per-file dispatch — the same behavior the path-based version
// had. Indices make this case far rarer (smaller output), but not special.
diffs := []model.Diff{{NewPath: "a.go"}, {NewPath: "b.go"}}
content := `[{"label":"g1","files":[0,1]},{"label":"g2","fil`
if _, err := parseGroupingResponse(content, diffs); err == nil {
t.Fatal("expected an error for a truncated response")
}
}

func TestEnforceGroupTokenBudget_NoSplit(t *testing.T) {
groups := []FileGroup{
{Label: "small", Diffs: []model.Diff{{NewPath: "a.go", Diff: "short"}}},
Expand Down Expand Up @@ -259,7 +271,7 @@ func TestGroupDiffs_LLMError_Fallback(t *testing.T) {
func TestGroupDiffs_LLMSuccess(t *testing.T) {
diffs := []model.Diff{{NewPath: "a.go"}, {NewPath: "b.go"}, {NewPath: "c.go"}}
client := &fakeGroupingClient{
response: `[{"label":"ab","files":["a.go","b.go"]},{"label":"c","files":["c.go"]}]`,
response: `[{"label":"ab","files":[0,1]},{"label":"c","files":[2]}]`,
}
tpl := template.Template{
GroupingTask: &template.LlmConversation{
Expand Down Expand Up @@ -403,7 +415,7 @@ func TestGroupDiffs_AtFileThresholdCallsLLM(t *testing.T) {
{NewPath: "d.go", Insertions: 1},
}
client := &fakeGroupingClient{
response: `[{"label":"ab","files":["a.go","b.go"]},{"label":"cd","files":["c.go","d.go"]}]`,
response: `[{"label":"ab","files":[0,1]},{"label":"cd","files":[2,3]}]`,
}
result := groupDiffs(context.Background(), diffs, client, "fake", groupingSkipTemplate(4, 200), 0, nil)
if !client.called {
Expand Down Expand Up @@ -523,15 +535,15 @@ func TestCallGroupingLLM_UsesTemplateMaxTokens(t *testing.T) {
Messages: []template.ChatMessage{{Role: "user", Content: "{{file_list}}"}},
}

client := &fakeGroupingClient{response: `[{"label":"a","files":["a.go"]}]`}
client := &fakeGroupingClient{response: `[{"label":"a","files":[0]}]`}
if _, _, err := callGroupingLLM(context.Background(), diffs, client, "fake", task, 32000, nil); err != nil {
t.Fatalf("callGroupingLLM: %v", err)
}
if client.gotReq.MaxTokens != 32000 {
t.Errorf("MaxTokens = %d, want 32000 (the template's own limit)", client.gotReq.MaxTokens)
}

client = &fakeGroupingClient{response: `[{"label":"a","files":["a.go"]}]`}
client = &fakeGroupingClient{response: `[{"label":"a","files":[0]}]`}
if _, _, err := callGroupingLLM(context.Background(), diffs, client, "fake", task, 0, nil); err != nil {
t.Fatalf("callGroupingLLM: %v", err)
}
Expand All @@ -548,12 +560,14 @@ func TestBuildFileMetadataTable(t *testing.T) {
{NewPath: "d.go", OldPath: "d.go", Insertions: 3, Deletions: 4},
}
// The grouping file list shares formatDiffEntry with the other-changed-files
// block, so both prompts enumerate files the same way. Pin the exact shape,
// including the per-entry trailing newline the grouping template relies on.
want := "ADDED a.go (+10/-0)\n" +
"DELETED b.go (+0/-5)\n" +
"RENAMED c.go (+2/-1)\n" +
"MODIFIED d.go (+3/-4)\n"
// block, so both prompts enumerate files the same way. buildFileList adds a
// zero-based index prefix on top, which the model groups by. Pin the exact
// shape, including the per-entry trailing newline the grouping template
// relies on.
want := "[0] ADDED a.go (+10/-0)\n" +
"[1] DELETED b.go (+0/-5)\n" +
"[2] RENAMED c.go (+2/-1)\n" +
"[3] MODIFIED d.go (+3/-4)\n"
if got := buildFileList(diffs); got != want {
t.Errorf("got %q, want %q", got, want)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/agent/manifest_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ type groupedBudgetPartialClient struct {

func (c *groupedBudgetPartialClient) CompletionsWithCtx(_ context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) {
if len(req.Tools) == 0 {
content := `[{"label":"pair","files":["a.go","b.go"]}]`
content := `[{"label":"pair","files":[0,1]}]`
return &llm.ChatResponse{
Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &content}}},
Model: "fake",
Expand Down
5 changes: 4 additions & 1 deletion internal/config/template/prompts/grouping_task_system.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ Files in the same group typically:
- Are i18n/config variants of the same resource (e.g. message_en.properties and message_zh.properties)
- Share the same directory and work together on a single concern

Each file in the list is prefixed with a zero-based index in brackets, e.g. `[0] MODIFIED path/to/file (+12/-3)`. Refer to files by that integer index, never by path.

Rules:
- Every file must appear in exactly one group.
- Every file index must appear in exactly one group.
- A group may contain 1 file if it is unrelated to others.
- Maximum 10 files per group.
- The "files" field of each group is an array of the integer indices shown in brackets.
- Output ONLY a JSON array, no other text.
4 changes: 2 additions & 2 deletions internal/config/template/prompts/grouping_task_user.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ Group the following changed files:

{{file_list}}

Respond with a JSON array:
[{"label": "short theme description", "files": ["path1", "path2"]}]
Respond with a JSON array, where "files" holds the integer indices shown in brackets beside each file:
[{"label": "short theme description", "files": [0, 1]}]
53 changes: 53 additions & 0 deletions internal/viewer/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package viewer

import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
Expand Down Expand Up @@ -179,6 +180,58 @@ func TestHandleSession_Success(t *testing.T) {
}
}

func TestHandleSession_GroupingRendersPaths(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
if err := os.MkdirAll(repoDir, 0755); err != nil {
t.Fatal(err)
}

// json.Marshal each record so the embedded newlines in the file list and the
// quotes in the response JSON are escaped correctly, instead of hand-writing
// the escapes into a raw JSONL literal.
mustJSON := func(v any) string {
b, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return string(b)
}
fileList := "[0] MODIFIED internal/agent/grouping.go (+66/-24)\n" +
"[1] ADDED internal/viewer/store.go (+120/-0)\n"
writeJSONL(t, filepath.Join(repoDir, "grp.jsonl"),
`{"type":"session_start","timestamp":"2025-06-01T10:00:00Z","cwd":"/my/proj","model":"claude"}`,
mustJSON(map[string]any{
"type": "llm_request", "filePath": "__grouping__", "taskType": "grouping_task", "request_no": 1,
"messages": []any{map[string]any{"role": "user", "content": fileList}},
}),
mustJSON(map[string]any{
"type": "llm_response", "filePath": "__grouping__", "taskType": "grouping_task",
"content": `[{"label":"grouping index switch","files":[0,1]}]`,
}),
`{"type":"session_end","duration_seconds":30}`)

req := httptest.NewRequest("GET", "/r/repo/grp", nil)
rr := httptest.NewRecorder()
handleSession(rr, req, root, "repo", "grp")

if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
body := rr.Body.String()
// The resolved paths and label must appear in the grouping-view markup.
for _, want := range []string{"grouping-view", "internal/agent/grouping.go", "internal/viewer/store.go", "grouping index switch"} {
if !strings.Contains(body, want) {
t.Errorf("rendered session missing %q", want)
}
}
// The raw index JSON is still available (collapsed) for audit, but the
// primary view is the path list, not a bare "files":[0,1].
if !strings.Contains(body, "Raw LLM response") {
t.Error("raw LLM response fallback should still be present for audit")
}
}

func TestHandleSession_NotFound(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
Expand Down
2 changes: 2 additions & 0 deletions internal/viewer/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,8 @@ func parseTemplate(name string) (*template.Template, error) {
return fp
}
},
"isGrouping": func(tt TaskType) bool { return tt == GroupingTask },
"groupingView": groupingView,
"orderedTasks": func(tasks map[TaskType][]*TaskCard) []struct {
Type TaskType
Cards []*TaskCard
Expand Down
32 changes: 32 additions & 0 deletions internal/viewer/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,38 @@ h3 {
word-break: break-word;
}

/* Grouping view: model-proposed file groups, indices resolved to paths */
.grouping-view {
padding: 0.75rem 1.25rem;
background: var(--response-bg);
display: flex;
flex-direction: column;
gap: 0.75rem;
max-height: 450px;
overflow-y: auto;
}
.grouping-group {
border-left: 3px solid var(--task-grouping);
padding-left: 0.75rem;
}
.grouping-label {
display: block;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-strong);
margin-bottom: 0.25rem;
}
.grouping-files {
margin: 0;
padding-left: 1.25rem;
font-size: 0.82rem;
line-height: 1.6;
color: var(--text-strong);
font-family: var(--mono);
word-break: break-word;
}
.grouping-unresolved { color: var(--text-faint); }

/* Scrollbar styling */
.response-text::-webkit-scrollbar { width: 6px; }
.response-text::-webkit-scrollbar-track { background: transparent; }
Expand Down
Loading