diff --git a/UserGuide.md b/UserGuide.md index 35da4767..df8ee801 100644 --- a/UserGuide.md +++ b/UserGuide.md @@ -4,7 +4,18 @@ ## Jay - X -## Cathy - X +## Cathy - Repository Learning Map (`/group`) + +Open an OpenCode session in a repository, select **Learn** mode, and enter `/group`. The map lists functionality groups. Select one to browse subgroups and files; use **Next page** or **Previous page** on long file lists. Choose **EXPLAIN THIS FUNCTIONALITY** or **EXPLAIN THIS SUBGROUP** to ask for a focused, read-only guide. The answer should cite files, explain relationships to other areas, suggest a reading order, and ask a check-your-understanding question. + +To see session-aware ordering, ask Learn to read a file, then run `/group` in the same session. Groups containing files read in this session are prioritized. If model-generated names are unavailable, directory-based names remain. The file inventory is capped at 10,000 paths, so very large repositories can show a partial map. Naming and explanations use model tokens. + +**End-to-end test (manual):** In Learn mode, run `/group` in a repository containing `src/`, `test/`, or `packages/` files. Open a group and subgroup, page through files, and request both explanations. The answers should name real files, describe evidence-backed relationships, give a reading order, and ask a learning question. Ask Learn to read a file, reopen `/group`, and confirm its group moves toward the top. In an empty repository, `/group` should show an informational message without crashing. Learn should remain selected throughout. This manual test covers the complete TUI and model workflow; the automated tests below do not launch the full application. + +**Unit tests (automated):** [`packages/tui/test/util/repository-functionality.test.ts`](packages/tui/test/util/repository-functionality.test.ts) covers repository layouts, empty inventories, 30-group and 20-subgroup limits, overflow preservation, priority, prompt bounds, and naming fallback. [`packages/tui/test/util/referenced-file.test.ts`](packages/tui/test/util/referenced-file.test.ts) covers completed reads, excluded results, absolute and relative paths, de-duplication, and `/group` registration. + +**Integration test (automated):** [`packages/tui/test/integration/repository-map.test.ts`](packages/tui/test/integration/repository-map.test.ts) passes completed read records through path collection, repository grouping, and both naming and explanation prompts. It verifies that a real session path remains prioritized and appears as evidence in the resulting guides. From `packages/tui`, run `bun test test/util/repository-functionality.test.ts test/util/referenced-file.test.ts test/integration/repository-map.test.ts` and `bun typecheck`. + ## Janna - X diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 13cc77e7..ef046410 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -478,6 +478,7 @@ export function Session() { run: async () => { const referencedFiles = collectReferencedFiles( messages().flatMap((message) => sync.data.part[message.id] ?? []), + project.instance.directory(), ) dialog.replace(() => ) const result = await sdk.client.find.files({ @@ -503,6 +504,7 @@ export function Session() { const naming = await sdk.client.session.prompt({ sessionID: namingSession.data.id, workspace: project.workspace.current(), + agent: "learn", parts: [{ type: "text", text: buildGroupNamingPrompt(groups) }], }) const text = naming.data?.parts diff --git a/packages/tui/src/util/referenced-file.ts b/packages/tui/src/util/referenced-file.ts index d97c65fc..b0e40bd4 100644 --- a/packages/tui/src/util/referenced-file.ts +++ b/packages/tui/src/util/referenced-file.ts @@ -18,14 +18,16 @@ export const referencedFileCommand = { }, } as const -export function collectReferencedFiles(parts: readonly SessionPart[]) { +export function collectReferencedFiles(parts: readonly SessionPart[], directory = "") { const seen = new Set() + const root = directory.replaceAll("\\", "/").replace(/\/$/, "") return parts.flatMap((part) => { const filePath = referencedFilePath(part) if (!filePath) return [] - const normalized = filePath.replaceAll("\\", "/") + const path = filePath.replaceAll("\\", "/") + const normalized = root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path if (seen.has(normalized)) return [] seen.add(normalized) return [normalized] diff --git a/packages/tui/src/util/repository-functionality.ts b/packages/tui/src/util/repository-functionality.ts index 47d30aae..bbb13ef5 100644 --- a/packages/tui/src/util/repository-functionality.ts +++ b/packages/tui/src/util/repository-functionality.ts @@ -107,7 +107,13 @@ export function analyzeRepositoryGroups(input: readonly string[], referencedFile root: ".", files: rest.flatMap((item) => item.files), referencedFiles: rest.flatMap((item) => item.referencedFiles), - subgroups: rest.flatMap((item) => item.subgroups), + subgroups: limit(rest.flatMap((item) => item.subgroups), 20, (subgroups) => ({ + id: "other-repository-groups/other", + title: "Other areas", + root: ".", + files: subgroups.flatMap((item) => item.files), + referencedFiles: subgroups.flatMap((item) => item.referencedFiles), + })), })) } @@ -115,7 +121,10 @@ export function buildGroupNamingPrompt(groups: readonly FunctionalGroup[]) { return [ "Give each repository group a concise, beginner-friendly functionality name.", "Return only a JSON object mapping each exact group id to a name of at most four words.", - ...groups.flatMap((group) => [`Group: ${group.id}`, ...group.files.slice(0, 5).map((file) => `- ${file}`)]), + ...groups.flatMap((group) => [ + `Group: ${group.id}`, + ...[...new Set([...group.referencedFiles, ...group.files])].slice(0, 5).map((file) => `- ${file}`), + ]), ].join("\n") } diff --git a/packages/tui/test/integration/repository-map.test.ts b/packages/tui/test/integration/repository-map.test.ts new file mode 100644 index 00000000..4ae6db34 --- /dev/null +++ b/packages/tui/test/integration/repository-map.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "bun:test" +import { collectReferencedFiles } from "../../src/util/referenced-file" +import { analyzeRepositoryGroups, buildFunctionalGroupPrompt, buildGroupNamingPrompt } from "../../src/util/repository-functionality" + +test("integration: completed session reads prioritize map groups and ground their guides", () => { + const referenced = collectReferencedFiles( + [ + { type: "tool", tool: "read", state: { status: "completed", input: { filePath: "/repo/apps/web/src/home.tsx" } } }, + { type: "tool", tool: "read", state: { status: "error", input: { filePath: "/repo/packages/api/src/user.ts" } } }, + ], + "/repo", + ) + const groups = analyzeRepositoryGroups( + ["packages/api/src/user.ts", "apps/web/src/home.tsx", "apps/web/test/home.test.ts"], + referenced, + ) + const web = groups[0] + + expect(referenced).toEqual(["apps/web/src/home.tsx"]) + expect(web.id).toBe("apps/web") + expect(web.referencedFiles).toEqual(referenced) + expect(buildGroupNamingPrompt(groups)).toContain("- apps/web/src/home.tsx") + expect(buildFunctionalGroupPrompt(web, groups)).toContain("- apps/web/src/home.tsx") + expect(buildFunctionalGroupPrompt(web.subgroups[0], groups)).toContain("- apps/web/src/home.tsx") +}) + +test("integration: repeated session reads count once in the map and guide", () => { + const reads = [ + { type: "tool", tool: "read", state: { status: "completed", input: { filePath: "/repo/apps/web/src/home.tsx" } } }, + { type: "tool", tool: "read", state: { status: "completed", input: { filePath: "apps/web/src/home.tsx" } } }, + ] + const referenced = collectReferencedFiles(reads, "/repo") + const [web] = analyzeRepositoryGroups(["apps/web/src/home.tsx", "apps/web/src/about.tsx"], referenced) + const prompt = buildFunctionalGroupPrompt(web, [web]) + + expect(referenced).toEqual(["apps/web/src/home.tsx"]) + expect(web.referencedFiles).toEqual(["apps/web/src/home.tsx"]) + expect(prompt.match(/- apps\/web\/src\/home\.tsx/g)).toHaveLength(1) +}) diff --git a/packages/tui/test/util/referenced-file.test.ts b/packages/tui/test/util/referenced-file.test.ts index e24111d7..f9ef5c21 100644 --- a/packages/tui/test/util/referenced-file.test.ts +++ b/packages/tui/test/util/referenced-file.test.ts @@ -14,6 +14,61 @@ describe("referenced files", () => { ).toEqual(["src/index.ts", "test/index.test.ts"]) }) + test("equivalence partitioning: metadata file paths override input paths", () => { + expect( + collectReferencedFiles([ + { + type: "tool", + tool: "read", + state: { + status: "completed", + input: { filePath: "outdated.ts" }, + metadata: { display: { type: "file", path: "src/current.ts" } }, + }, + }, + { + type: "tool", + tool: "read", + state: { + status: "completed", + input: { filePath: "directory.ts" }, + metadata: { display: { type: "directory", path: "src" } }, + }, + }, + completedRead("src/fallback.ts"), + ]), + ).toEqual(["src/current.ts", "src/fallback.ts"]) + }) + + test("equivalence partitioning: ignores unreadable or unfinished tool results", () => { + expect( + collectReferencedFiles([ + { type: "tool", tool: "read", state: { status: "completed", input: { filePath: " " } } }, + { type: "tool", tool: "read", state: { status: "completed", input: { filePath: 42 } } }, + { type: "tool", tool: "read", state: { status: "error", input: { filePath: "failed.ts" } } }, + { type: "tool", tool: "write", state: { status: "completed", input: { filePath: "written.ts" } } }, + { type: "text" }, + ]), + ).toEqual([]) + }) + + test("matches absolute read paths to relative repository files", () => { + expect( + collectReferencedFiles( + [ + completedRead("/home/student/project/src/index.ts"), + completedRead("src/index.ts"), + completedRead("/home/student/project-copy/src/other.ts"), + ], + "/home/student/project", + ), + ).toEqual(["src/index.ts", "/home/student/project-copy/src/other.ts"]) + + expect( + collectReferencedFiles([completedRead("C:\\repo\\src\\index.ts")], "C:\\repo"), + ).toEqual(["src/index.ts"]) + }) + test("registers /group", () => { expect(referencedFileCommand.slash.name).toBe("group") }) diff --git a/packages/tui/test/util/repository-functionality.test.ts b/packages/tui/test/util/repository-functionality.test.ts index 05dbd777..7e8d8740 100644 --- a/packages/tui/test/util/repository-functionality.test.ts +++ b/packages/tui/test/util/repository-functionality.test.ts @@ -49,6 +49,16 @@ describe("repository areas", () => { expect(prompt).not.toContain("src/session/file-8.ts") }) + test("includes session-referenced files in the naming sample", () => { + const files = Array.from({ length: 12 }, (_, index) => `src/session/file-${index}.ts`) + const referenced = files.at(-1)! + const [group] = analyzeRepositoryGroups(files, [referenced]) + const prompt = buildGroupNamingPrompt([group]) + + expect(prompt).toContain(`- ${referenced}`) + expect(prompt.split("\n- ")).toHaveLength(6) + }) + test("limits the map and applies model-generated names", () => { const groups = analyzeRepositoryGroups( Array.from({ length: 35 }, (_, index) => `packages/area-${index}/src/index.ts`), @@ -61,4 +71,130 @@ describe("repository areas", () => { expect(renamed[0].title).toBe("Session History") expect(buildGroupNamingPrompt(groups)).toContain("Group: packages/area-34") }) + + test("caps subgroups in the overflow group without losing files", () => { + const files = Array.from({ length: 55 }, (_, index) => `packages/area-${index}/src/index.ts`) + const groups = analyzeRepositoryGroups(files, files) + const overflow = groups.at(-1)! + + expect(groups).toHaveLength(30) + expect(overflow.title).toBe("Other repository groups") + expect(overflow.subgroups).toHaveLength(20) + expect(overflow.subgroups.flatMap((subgroup) => subgroup.files).sort()).toEqual([...overflow.files].sort()) + expect(overflow.subgroups.flatMap((subgroup) => subgroup.referencedFiles).sort()).toEqual( + [...overflow.referencedFiles].sort(), + ) + }) + + test("handles empty repositories and ignores generated-only inventories", () => { + expect(analyzeRepositoryGroups([])).toEqual([]) + expect(analyzeRepositoryGroups(["node_modules/pkg/index.js", "dist/app.js", ".git/config"])).toEqual([]) + }) + + test("keeps root files and unfamiliar layouts without losing paths", () => { + const files = [ + "README.md", + "mystery/component.custom", + "services/api/lib/start.ts", + "modules/parser/spec/check.txt", + ] + const groups = analyzeRepositoryGroups(files) + + expect(groups.flatMap((group) => group.files).sort()).toEqual([...files].sort()) + expect(groups.find((group) => group.id === ".")?.files).toEqual(["README.md"]) + expect(groups.map((group) => group.id)).toEqual( + expect.arrayContaining(["mystery", "services/api", "modules/parser"]), + ) + }) + + test.each([19, 20, 21, 25])("boundary-value analysis: preserves files with %i subgroups", (count) => { + const files = Array.from({ length: count }, (_, index) => `packages/api/src/feature-${index}/index.ts`) + const [group] = analyzeRepositoryGroups(files, files) + + expect(group.subgroups).toHaveLength(Math.min(count, 20)) + expect(group.subgroups.flatMap((subgroup) => subgroup.files).sort()).toEqual([...files].sort()) + expect(group.subgroups.flatMap((subgroup) => subgroup.referencedFiles).sort()).toEqual([...files].sort()) + if (count > 20) { + expect(group.subgroups.at(-1)?.title).toBe("Other areas") + expect(group.subgroups.at(-1)?.files).toHaveLength(count - 19) + } + }) + + test.each([29, 30, 31, 35])("boundary-value analysis: preserves files with %i groups", (count) => { + const files = Array.from({ length: count }, (_, index) => `packages/area-${index}/src/index.ts`) + const groups = analyzeRepositoryGroups(files, files) + + expect(groups).toHaveLength(Math.min(count, 30)) + expect(groups.flatMap((group) => group.files).sort()).toEqual([...files].sort()) + expect(groups.flatMap((group) => group.referencedFiles).sort()).toEqual([...files].sort()) + expect(groups.flatMap((group) => group.subgroups.flatMap((subgroup) => subgroup.files)).sort()).toEqual( + [...files].sort(), + ) + if (count > 30) { + expect(groups.at(-1)?.title).toBe("Other repository groups") + expect(groups.at(-1)?.files).toHaveLength(count - 29) + } + }) + + test.each(["", "No names available", '{"src":', '{"src": "Source",}'])( + "retains structural names for malformed naming response %j", + (response) => { + const groups = analyzeRepositoryGroups(["src/main.ts", "docs/guide.md"]) + expect(applyGroupNames(groups, response)).toEqual(groups) + }, + ) + + test("metamorphic testing: duplicate and reordered path formats preserve the map", () => { + const files = ["packages/api/src/login.ts", "packages/api/test/login.test.ts", "apps/web/src/home.tsx"] + const expected = analyzeRepositoryGroups(files, ["apps/web/src/home.tsx"]) + const changed = analyzeRepositoryGroups( + ["./apps/web/src/home.tsx", "packages\\api\\test\\login.test.ts", ...files.toReversed(), files[0]], + ["apps\\web\\src\\home.tsx"], + ) + + expect(changed).toEqual(expected) + }) + + test("generated-input property: every accepted file belongs to one group and subgroup", () => { + for (const count of [1, 2, 17, 18, 19, 30, 31, 75]) { + const files = Array.from({ length: count }, (_, index) => + index % 2 ? `apps/web/test/case-${index}.test.ts` : `packages/api/src/feature-${index}/index.ts`, + ) + const groups = analyzeRepositoryGroups( + files.flatMap((file, index) => (index % 2 ? [file, file] : [`./${file}`, file])), + [files[0], files.at(-1)!], + ) + const references = [...new Set([files[0], files.at(-1)!])].sort() + + expect(groups.flatMap((group) => group.files).sort()).toEqual([...files].sort()) + expect(groups.flatMap((group) => group.subgroups.flatMap((subgroup) => subgroup.files)).sort()).toEqual( + [...files].sort(), + ) + expect(groups.flatMap((group) => group.referencedFiles).sort()).toEqual(references) + } + }) + + test("pairwise testing: a late referenced file survives prompt length limits", () => { + const files = Array.from({ length: 35 }, (_, index) => `src/session/file-${index}.ts`) + const referenced = files.at(-1)! + const [group] = analyzeRepositoryGroups(files, [referenced]) + const prompt = buildFunctionalGroupPrompt(group, [group]) + const listed = prompt.split("Representative files:\n")[1].split("\n- (", 1)[0].split("\n") + + expect(listed).toHaveLength(15) + expect(listed[0]).toBe(`- ${referenced}`) + expect(prompt).toContain(`(${files.length - 15} additional files omitted)`) + }) + + test("retains names for missing and non-string values without changing the original map", () => { + const groups = analyzeRepositoryGroups(["src/main.ts", "docs/guide.md", "tests/main.test.ts"]) + const original = structuredClone(groups) + const renamed = applyGroupNames(groups, '{"src": "Application", "docs": 42, "unrelated": "Unused"}') + + expect(renamed.find((group) => group.id === "src")?.title).toBe("Application") + expect(renamed.find((group) => group.id === "docs")).toEqual(groups.find((group) => group.id === "docs")) + expect(renamed.find((group) => group.id === "tests")).toEqual(groups.find((group) => group.id === "tests")) + expect(renamed).toHaveLength(groups.length) + expect(groups).toEqual(original) + }) })