fix: simplify walkthrough prompt requests (#1660)
* fix: simplify walkthrough prompt requests * fix: address walkthrough review feedback
This commit is contained in:
@@ -26,7 +26,10 @@ const overloaded529Message = "Internal error: API Error: 529 Overloaded. This is
|
||||
// before recovering (default 1, so the turn self-heals on the first retry).
|
||||
// Use a large N (e.g. `/overloaded:9`) to exhaust the retry budget and fall
|
||||
// through to the red recovery banner.
|
||||
var overloadedCmdRe = regexp.MustCompile(`(?i)^/(?:e2e:)?overloaded(?::(\d+))?$`)
|
||||
var (
|
||||
overloadedCmdRe = regexp.MustCompile(`(?i)^/(?:e2e:)?overloaded(?::(\d+))?$`)
|
||||
changesWalkthroughPromptRefRe = regexp.MustCompile(`^@changes-walkthrough(?:\s|$)`)
|
||||
)
|
||||
|
||||
const changesWalkthroughPromptMarker = "Please create an agent-authored walkthrough of the current changes"
|
||||
|
||||
@@ -35,8 +38,7 @@ func isChangesWalkthroughRequest(prompt string) bool {
|
||||
legacyPrompt := strings.Contains(cmd, changesWalkthroughPromptMarker) &&
|
||||
strings.Contains(cmd, "show_walkthrough_kandev") &&
|
||||
strings.Contains(cmd, "Available changed files:")
|
||||
promptReference := strings.Contains(cmd, "@changes-walkthrough") &&
|
||||
strings.Contains(cmd, "Changed files:")
|
||||
promptReference := changesWalkthroughPromptRefRe.MatchString(cmd)
|
||||
return legacyPrompt || promptReference
|
||||
}
|
||||
|
||||
|
||||
@@ -124,9 +124,6 @@ func TestIsChangesWalkthroughRequest(t *testing.T) {
|
||||
promptReference := strings.Join([]string{
|
||||
"@changes-walkthrough",
|
||||
"",
|
||||
"Changed files:",
|
||||
"- src/app.ts [uncommitted]",
|
||||
"",
|
||||
"<kandev-system>",
|
||||
"EXPANDED PROMPT REFERENCES",
|
||||
"### @changes-walkthrough",
|
||||
@@ -142,6 +139,9 @@ func TestIsChangesWalkthroughRequest(t *testing.T) {
|
||||
if isChangesWalkthroughRequest("show_walkthrough_kandev without the generated prompt shape") {
|
||||
t.Fatal("expected unrelated prompt not to be detected")
|
||||
}
|
||||
if isChangesWalkthroughRequest("what does @changes-walkthrough do?") {
|
||||
t.Fatal("expected incidental prompt reference not to be detected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelayRange(t *testing.T) {
|
||||
|
||||
@@ -851,8 +851,8 @@ func walkthroughDemoArgs() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
wtKeyTitle: "Tour of the change",
|
||||
wtKeySteps: []map[string]interface{}{
|
||||
wtStep("Entry point", "walkthrough_a.txt",
|
||||
"Execution starts here. **Step 1** explains the entry point in file A.", 1, 0),
|
||||
wtStep("Overview", "walkthrough_a.txt",
|
||||
"Big picture: this tour explains how the sample changes connect across the touched files.\n\nELI5: first we show the map, then we explain each changed line.", 1, 0),
|
||||
wtStep("The change in A", "walkthrough_a.txt",
|
||||
"Step 2: this is WALKTHROUGH_CHANGE_A the tour narrates.", 2, 3),
|
||||
wtStep("File B", "walkthrough_b.txt",
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
Please create an agent-authored walkthrough of the current changes using `show_walkthrough_kandev`.
|
||||
|
||||
Walkthrough requirements:
|
||||
- Use only files listed below or files you verify exist in this task's local worktree/review diff.
|
||||
- Inspect the changed files yourself instead of relying on UI-provided paths or diff context.
|
||||
- If this is a PR task or PR review task, compare the PR head against the PR base branch.
|
||||
- Otherwise, compare against the task/repository base using the local task state (`git status`, `git diff`, `git diff --cached`, and cumulative diff context when available).
|
||||
- For PR-only files, do not assume the PR head is checked out locally; anchor to the review diff when available, and avoid editor-only/current-worktree claims.
|
||||
- The first walkthrough step must contextualize the whole change before explaining individual hunks.
|
||||
- Anchor the first walkthrough step to the most representative changed line or changed line range available.
|
||||
- Include an `ELI5:` line in the first walkthrough step text that explains the change in the simplest terms possible.
|
||||
- Anchor steps to changed lines or changed line ranges whenever possible.
|
||||
- Use `line_end` whenever a logical explanation spans multiple lines; prefer one range step over adjacent single-line steps.
|
||||
- Keep each step concise and direct. Do not include a `Justification:` preamble.
|
||||
- If a good local/review anchor is unavailable, omit that step instead of referencing a remote-only path.
|
||||
|
||||
Available changed files:
|
||||
{{changed_files}}
|
||||
|
||||
@@ -164,9 +164,13 @@ func TestSQLiteRepository_BuiltinPrompts(t *testing.T) {
|
||||
}
|
||||
for _, want := range []string{
|
||||
"show_walkthrough_kandev",
|
||||
"{{changed_files}}",
|
||||
"Inspect the changed files yourself",
|
||||
"compare the PR head against the PR base branch",
|
||||
"compare against the task/repository base",
|
||||
"Use `line_end`",
|
||||
"do not assume the PR head is checked out locally",
|
||||
"first walkthrough step",
|
||||
"ELI5:",
|
||||
} {
|
||||
if !strings.Contains(changesWalkthroughContent, want) {
|
||||
t.Fatalf("expected changes-walkthrough prompt to contain %q, got:\n%s", want, changesWalkthroughContent)
|
||||
|
||||
@@ -468,14 +468,27 @@ function useWalkthroughFileSelection(
|
||||
}, [open, step, allFiles, filter, setFilter, handleSelectFile]);
|
||||
}
|
||||
|
||||
function useReviewDialogWalkthroughRequest({
|
||||
sessionId,
|
||||
allFiles,
|
||||
}: {
|
||||
sessionId: string;
|
||||
allFiles: ReviewFile[];
|
||||
}) {
|
||||
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
||||
return useRequestChangesWalkthrough({
|
||||
taskId: activeTaskId,
|
||||
sessionId,
|
||||
ready: allFiles.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export const ReviewDialog = memo(function ReviewDialog(props: ReviewDialogProps) {
|
||||
const { open, onOpenChange, sessionId, baseBranch, onOpenFile } = props;
|
||||
const s = useReviewDialogState(props);
|
||||
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
||||
const handleRequestWalkthrough = useRequestChangesWalkthrough({
|
||||
taskId: activeTaskId,
|
||||
const handleRequestWalkthrough = useReviewDialogWalkthroughRequest({
|
||||
sessionId,
|
||||
files: s.allFiles,
|
||||
allFiles: s.allFiles,
|
||||
});
|
||||
const splitRowRef = useRef<HTMLDivElement>(null);
|
||||
const sidebar = useReviewSidebarResize(splitRowRef, open);
|
||||
|
||||
@@ -225,7 +225,13 @@ export const ReviewTopBar = memo(function ReviewTopBar({
|
||||
disabled={requestWalkthroughDisabled}
|
||||
/>
|
||||
<VcsSplitButton sessionId={sessionId} baseBranch={baseBranch} />
|
||||
<Button size="sm" variant="ghost" className="px-2 cursor-pointer" onClick={onClose}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="px-2 cursor-pointer"
|
||||
onClick={onClose}
|
||||
aria-label="Close review"
|
||||
>
|
||||
<IconX className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -27,8 +27,6 @@ import {
|
||||
buildRepoNameById,
|
||||
} from "./changes-panel-helpers";
|
||||
import type { OpenDiffOptions } from "./changes-diff-target";
|
||||
import type { WalkthroughPromptFile } from "@/lib/walkthrough-request";
|
||||
import { buildWalkthroughPromptFiles } from "@/lib/walkthrough-prompt-files";
|
||||
|
||||
function useChangesPanelStoreData() {
|
||||
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
||||
@@ -211,6 +209,10 @@ function useChangesPanelPRData() {
|
||||
return { prDiffFiles, prCommitsList, hasPRFiles, hasPRCommits, prFiles };
|
||||
}
|
||||
|
||||
function hasCumulativeFiles(files: Record<string, unknown> | null | undefined): boolean {
|
||||
return Object.keys(files ?? {}).length > 0;
|
||||
}
|
||||
|
||||
export function useChangesPanelData() {
|
||||
const { activeTaskId, activeSessionId, baseBranch, existingPrUrl } = useChangesPanelStoreData();
|
||||
const baseBranchByRepo = useBaseBranchByRepo(activeTaskId);
|
||||
@@ -244,16 +246,11 @@ export function useChangesPanelData() {
|
||||
() => buildPrByRepoMap(taskPRsForMap, repoNameById, pendingByRepo),
|
||||
[taskPRsForMap, repoNameById, pendingByRepo],
|
||||
);
|
||||
const walkthroughPromptFiles = useMemo<WalkthroughPromptFile[]>(
|
||||
() =>
|
||||
buildWalkthroughPromptFiles({
|
||||
unstagedFiles,
|
||||
stagedFiles,
|
||||
committedFiles: git.statusLoaded ? git.cumulativeDiff?.files : null,
|
||||
prFiles: prData.prFiles,
|
||||
}),
|
||||
[git.cumulativeDiff?.files, git.statusLoaded, prData.prFiles, stagedFiles, unstagedFiles],
|
||||
);
|
||||
const walkthroughRequestReady =
|
||||
unstagedFiles.length > 0 ||
|
||||
stagedFiles.length > 0 ||
|
||||
(git.statusLoaded && hasCumulativeFiles(git.cumulativeDiff?.files)) ||
|
||||
prData.prFiles.length > 0;
|
||||
return {
|
||||
activeTaskId,
|
||||
activeSessionId,
|
||||
@@ -272,7 +269,7 @@ export function useChangesPanelData() {
|
||||
repoDisplayName,
|
||||
prByRepo,
|
||||
existingPrUrl,
|
||||
walkthroughPromptFiles,
|
||||
walkthroughRequestReady,
|
||||
...prData,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const ChangesPanel = memo(function ChangesPanel(props: ChangesPanelProps) {
|
||||
const requestWalkthrough = useRequestChangesWalkthrough({
|
||||
taskId: data.activeTaskId,
|
||||
sessionId: data.activeSessionId,
|
||||
files: data.walkthroughPromptFiles,
|
||||
ready: data.walkthroughRequestReady,
|
||||
});
|
||||
if (isArchived) return <ArchivedPanelPlaceholder />;
|
||||
return (
|
||||
@@ -44,7 +44,7 @@ const ChangesPanel = memo(function ChangesPanel(props: ChangesPanelProps) {
|
||||
onOpenDiffAll={props.onOpenDiffAll}
|
||||
onOpenReview={props.onOpenReview}
|
||||
onRequestWalkthrough={requestWalkthrough}
|
||||
requestWalkthroughDisabled={data.walkthroughPromptFiles.length === 0}
|
||||
requestWalkthroughDisabled={!data.walkthroughRequestReady}
|
||||
repoNames={data.git.repoNames}
|
||||
perRepoStatus={data.git.perRepoStatus}
|
||||
onRepoPull={data.repoCallbacks.onRepoPull}
|
||||
|
||||
@@ -38,7 +38,7 @@ export const MobileChangesPanel = memo(function MobileChangesPanel({
|
||||
const requestWalkthrough = useRequestChangesWalkthrough({
|
||||
taskId: data.activeTaskId,
|
||||
sessionId: data.activeSessionId,
|
||||
files: data.walkthroughPromptFiles,
|
||||
ready: data.walkthroughRequestReady,
|
||||
});
|
||||
|
||||
// Track the previous selectedDiff to detect changes
|
||||
@@ -107,7 +107,7 @@ export const MobileChangesPanel = memo(function MobileChangesPanel({
|
||||
onOpenDiffAll={handleOpenDiffAll}
|
||||
onOpenReview={handleOpenReview}
|
||||
onRequestWalkthrough={requestWalkthrough}
|
||||
requestWalkthroughDisabled={data.walkthroughPromptFiles.length === 0}
|
||||
requestWalkthroughDisabled={!data.walkthroughRequestReady}
|
||||
repoNames={data.git.repoNames}
|
||||
perRepoStatus={data.git.perRepoStatus}
|
||||
onRepoPull={data.repoCallbacks.onRepoPull}
|
||||
|
||||
@@ -169,15 +169,12 @@ function persistAutoMarkSetting(checked: boolean) {
|
||||
updateUserSettings(payload, { cache: "no-store" }).catch(() => {});
|
||||
}
|
||||
|
||||
function useReviewWalkthroughRequest(
|
||||
activeSessionId: string | null | undefined,
|
||||
allFiles: ReviewFile[],
|
||||
) {
|
||||
function useWalkthroughRequest(activeSessionId: string | null | undefined, allFiles: ReviewFile[]) {
|
||||
const activeTaskId = useAppStore((s) => s.tasks.activeTaskId);
|
||||
return useRequestChangesWalkthrough({
|
||||
taskId: activeTaskId,
|
||||
sessionId: activeSessionId,
|
||||
files: allFiles,
|
||||
ready: allFiles.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -485,7 +482,7 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
|
||||
handleToggleAutoMark,
|
||||
handleFixComments,
|
||||
} = useChangesActions(activeSessionId, allFiles, wordWrapProp);
|
||||
const handleRequestWalkthrough = useReviewWalkthroughRequest(activeSessionId, allFiles);
|
||||
const handleRequestWalkthrough = useWalkthroughRequest(activeSessionId, allFiles);
|
||||
const { visibleFiles, visibleFileRefs, reviewedCount, totalCount, progressPercent } =
|
||||
useVisibleDiffState({
|
||||
allFiles,
|
||||
|
||||
@@ -88,6 +88,8 @@ test.describe("Mobile code walkthrough", () => {
|
||||
const card = session.walkthroughFloating();
|
||||
await expect(card).toBeVisible({ timeout: 30_000 });
|
||||
await expect(card).toHaveAttribute("data-mobile-variant", "bottom-sheet");
|
||||
await expect(card.getByTestId("walkthrough-step-title")).toContainText("Overview");
|
||||
await expect(card.getByTestId("walkthrough-step-body")).toContainText("ELI5:");
|
||||
await expect(session.walkthroughEditorRange()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
|
||||
@@ -52,12 +52,15 @@ async function customizeChangesWalkthroughPrompt(
|
||||
"Please create an agent-authored walkthrough of the current changes using `show_walkthrough_kandev`.",
|
||||
"",
|
||||
"Walkthrough requirements:",
|
||||
"- Inspect the changed files yourself instead of relying on UI-provided paths.",
|
||||
"- For PR tasks, compare the PR head against the PR base branch.",
|
||||
"- For non-PR tasks, compare against the task or repository base.",
|
||||
"- The first walkthrough step must contextualize the whole change.",
|
||||
"- Anchor the first step to the most representative changed line or range.",
|
||||
"- Include an `ELI5:` line in the first step text.",
|
||||
"- Use `line_end` whenever a logical explanation spans multiple lines.",
|
||||
"- For PR-only files, do not assume the PR head is checked out locally.",
|
||||
"- Keep each step concise and direct. Do not include a `Justification:` preamble.",
|
||||
"",
|
||||
"Available changed files:",
|
||||
"{{changed_files}}",
|
||||
].join("\n"),
|
||||
});
|
||||
return () => apiClient.updatePrompt(prompt!.id, { content: originalContent });
|
||||
@@ -97,8 +100,10 @@ test.describe("Code walkthrough", () => {
|
||||
await expect(session.activeChat()).toContainText("@changes-walkthrough", {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(session.activeChat()).toContainText("Changed files:");
|
||||
await expect(session.activeChat()).toContainText("walkthrough_a.txt [");
|
||||
await expect(session.activeChat()).not.toContainText("Diff context:");
|
||||
await expect(session.activeChat()).not.toContainText("Base branch:");
|
||||
await expect(session.activeChat()).not.toContainText("Changed files:");
|
||||
await expect(session.activeChat()).not.toContainText("walkthrough_a.txt [");
|
||||
await expect(session.activeChat()).not.toContainText("E2E_CUSTOM_CHANGES_WALKTHROUGH");
|
||||
await expect(session.activeChat()).toContainText("Walkthrough: Tour of the change", {
|
||||
timeout: 45_000,
|
||||
@@ -110,7 +115,8 @@ test.describe("Code walkthrough", () => {
|
||||
|
||||
const card = await openWalkthrough(testPage);
|
||||
await expect(card.getByTestId("walkthrough-step-header")).toContainText("Step 1 / 5");
|
||||
await expect(card.getByTestId("walkthrough-step-body")).toContainText("Step 1");
|
||||
await expect(card.getByTestId("walkthrough-step-title")).toContainText("Overview");
|
||||
await expect(card.getByTestId("walkthrough-step-body")).toContainText("ELI5:");
|
||||
await expect(session.walkthroughEditorRange()).toBeVisible({ timeout: 15_000 });
|
||||
} finally {
|
||||
await restorePrompt();
|
||||
@@ -137,15 +143,17 @@ test.describe("Code walkthrough", () => {
|
||||
await expect(request).toHaveAttribute("aria-label", "Walk me through these review changes");
|
||||
await request.click();
|
||||
|
||||
await testPage.keyboard.press("Escape");
|
||||
await dialog.getByRole("button", { name: "Close review" }).click();
|
||||
await expect(dialog).toBeHidden({ timeout: 15_000 });
|
||||
await session.showSessionContext();
|
||||
|
||||
await expect(session.activeChat()).toContainText("@changes-walkthrough", {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(session.activeChat()).toContainText("Changed files:");
|
||||
await expect(session.activeChat()).toContainText("walkthrough_a.txt [");
|
||||
await expect(session.activeChat()).not.toContainText("Diff context:");
|
||||
await expect(session.activeChat()).not.toContainText("Base branch:");
|
||||
await expect(session.activeChat()).not.toContainText("Changed files:");
|
||||
await expect(session.activeChat()).not.toContainText("walkthrough_a.txt [");
|
||||
await expect(session.activeChat()).not.toContainText("E2E_CUSTOM_CHANGES_WALKTHROUGH");
|
||||
await expect(session.activeChat()).toContainText("walkthrough-request complete", {
|
||||
timeout: 45_000,
|
||||
@@ -166,7 +174,8 @@ test.describe("Code walkthrough", () => {
|
||||
const body = card.getByTestId("walkthrough-step-body");
|
||||
|
||||
await expect(header).toContainText("Step 1 / 5");
|
||||
await expect(body).toContainText("Step 1");
|
||||
await expect(card.getByTestId("walkthrough-step-title")).toContainText("Overview");
|
||||
await expect(body).toContainText("ELI5:");
|
||||
await expect(card.getByTestId("walkthrough-step-file")).toContainText("walkthrough_a.txt");
|
||||
await expect(card.getByTestId("walkthrough-prev")).toBeDisabled();
|
||||
|
||||
|
||||
@@ -40,12 +40,12 @@ function storeState(sessionState: string, planMode = false) {
|
||||
};
|
||||
}
|
||||
|
||||
function renderRequestHook(files = [{ path: "src/app.ts", source: "uncommitted" }]) {
|
||||
function renderRequestHook(ready = true) {
|
||||
return renderHook(() =>
|
||||
useRequestChangesWalkthrough({
|
||||
taskId: "task-1",
|
||||
sessionId: "session-1",
|
||||
files,
|
||||
ready,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -59,7 +59,7 @@ describe("useRequestChangesWalkthrough", () => {
|
||||
{
|
||||
id: "builtin-changes-walkthrough",
|
||||
name: "changes-walkthrough",
|
||||
content: "CUSTOM_WALKTHROUGH_PROMPT\n{{changed_files}}\nshow_walkthrough_kandev",
|
||||
content: "CUSTOM_WALKTHROUGH_PROMPT\nshow_walkthrough_kandev",
|
||||
builtin: true,
|
||||
},
|
||||
],
|
||||
@@ -87,13 +87,16 @@ describe("useRequestChangesWalkthrough", () => {
|
||||
expect.objectContaining({
|
||||
task_id: "task-1",
|
||||
session_id: "session-1",
|
||||
content: expect.stringMatching(/^@changes-walkthrough\n\nChanged files:/),
|
||||
content: expect.stringMatching(/^@changes-walkthrough\n\n<kandev-system>/),
|
||||
}),
|
||||
10000,
|
||||
);
|
||||
const sentContent = mockRequest.mock.calls[0]?.[1]?.content as string;
|
||||
expect(sentContent).toContain("<kandev-system>");
|
||||
expect(sentContent).toContain("CUSTOM_WALKTHROUGH_PROMPT");
|
||||
expect(sentContent).not.toContain("Diff context:");
|
||||
expect(sentContent).not.toContain("Base branch:");
|
||||
expect(sentContent).not.toContain("Base commit:");
|
||||
expect(mockListPrompts).toHaveBeenCalledWith({ cache: "no-store" });
|
||||
expect(mockAddMessage).toHaveBeenCalledWith(expect.objectContaining({ id: "msg-1" }));
|
||||
expect(mockQueueMessage).not.toHaveBeenCalled();
|
||||
@@ -115,7 +118,7 @@ describe("useRequestChangesWalkthrough", () => {
|
||||
session_id: "session-1",
|
||||
task_id: "task-1",
|
||||
plan_mode: true,
|
||||
content: expect.stringMatching(/^@changes-walkthrough\n\nChanged files:/),
|
||||
content: expect.stringMatching(/^@changes-walkthrough\n\n<kandev-system>/),
|
||||
}),
|
||||
);
|
||||
const queuedContent = mockQueueMessage.mock.calls[0]?.[0]?.content as string;
|
||||
@@ -127,8 +130,8 @@ describe("useRequestChangesWalkthrough", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not send a low-context prompt before changed files are loaded", async () => {
|
||||
const { result } = renderRequestHook([]);
|
||||
it("does not send a low-context prompt before diff context is ready", async () => {
|
||||
const { result } = renderRequestHook(false);
|
||||
|
||||
await act(async () => {
|
||||
await result.current();
|
||||
|
||||
@@ -7,7 +7,6 @@ import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
import {
|
||||
buildChangesWalkthroughPrompt,
|
||||
CHANGES_WALKTHROUGH_PROMPT_NAME,
|
||||
type WalkthroughPromptFile,
|
||||
} from "@/lib/walkthrough-request";
|
||||
import type { Message } from "@/lib/types/http";
|
||||
import type { AppState } from "@/lib/state/store";
|
||||
@@ -15,7 +14,7 @@ import type { AppState } from "@/lib/state/store";
|
||||
type UseRequestChangesWalkthroughParams = {
|
||||
taskId: string | null | undefined;
|
||||
sessionId: string | null | undefined;
|
||||
files: WalkthroughPromptFile[];
|
||||
ready?: boolean;
|
||||
};
|
||||
|
||||
function isAgentBusy(state: string | undefined): boolean {
|
||||
@@ -75,7 +74,7 @@ async function sendWalkthroughRequest(params: {
|
||||
export function useRequestChangesWalkthrough({
|
||||
taskId,
|
||||
sessionId,
|
||||
files,
|
||||
ready = true,
|
||||
}: UseRequestChangesWalkthroughParams) {
|
||||
const storeApi = useAppStoreApi();
|
||||
const { toast } = useToast();
|
||||
@@ -87,13 +86,13 @@ export function useRequestChangesWalkthrough({
|
||||
const activeSession = state.taskSessions.items[sessionId] ?? null;
|
||||
const shouldQueue = isAgentBusy(activeSession?.state);
|
||||
const planModeEnabled = state.chatInput.planModeBySessionId[sessionId] ?? false;
|
||||
if (files.length === 0) {
|
||||
if (!ready) {
|
||||
toast({ title: "Changes are still loading", variant: "error" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const template = await loadChangesWalkthroughPromptTemplate();
|
||||
const content = buildChangesWalkthroughPrompt(template, files);
|
||||
const content = buildChangesWalkthroughPrompt(template);
|
||||
if (shouldQueue) {
|
||||
await queueWalkthroughRequest({
|
||||
taskId,
|
||||
@@ -111,5 +110,5 @@ export function useRequestChangesWalkthrough({
|
||||
console.error("Failed to request walkthrough:", error);
|
||||
toast({ title: "Failed to request walkthrough", variant: "error" });
|
||||
}
|
||||
}, [files, sessionId, storeApi, taskId, toast]);
|
||||
}, [ready, sessionId, storeApi, taskId, toast]);
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildWalkthroughPromptFiles } from "./walkthrough-prompt-files";
|
||||
|
||||
const APP_PATH = "src/app.ts";
|
||||
|
||||
describe("buildWalkthroughPromptFiles", () => {
|
||||
it("includes working tree, staged, committed, and PR files in review order", () => {
|
||||
const files = buildWalkthroughPromptFiles({
|
||||
unstagedFiles: [{ path: APP_PATH }],
|
||||
stagedFiles: [{ path: "src/staged.ts", repositoryName: "web" }],
|
||||
committedFiles: {
|
||||
"src/committed.ts": { path: "src/committed.ts" },
|
||||
},
|
||||
prFiles: [{ path: "src/pr.ts", repository_name: "backend" }],
|
||||
});
|
||||
|
||||
expect(files).toEqual([
|
||||
{ path: APP_PATH, source: "uncommitted" },
|
||||
{ path: "src/staged.ts", repository_name: "web", source: "staged" },
|
||||
{ path: "src/committed.ts", source: "committed" },
|
||||
{ path: "src/pr.ts", repository_name: "backend", source: "pr" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the freshest local source when cumulative diff repeats a file", () => {
|
||||
const files = buildWalkthroughPromptFiles({
|
||||
unstagedFiles: [{ path: APP_PATH }],
|
||||
stagedFiles: [],
|
||||
committedFiles: {
|
||||
[APP_PATH]: { path: APP_PATH },
|
||||
"src/other.ts": { path: "src/other.ts" },
|
||||
},
|
||||
prFiles: [],
|
||||
});
|
||||
|
||||
expect(files).toEqual([
|
||||
{ path: APP_PATH, source: "uncommitted" },
|
||||
{ path: "src/other.ts", source: "committed" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to composite cumulative-diff keys when the payload lacks path fields", () => {
|
||||
const files = buildWalkthroughPromptFiles({
|
||||
unstagedFiles: [],
|
||||
stagedFiles: [],
|
||||
committedFiles: {
|
||||
"backend\u0000src/server.go": {},
|
||||
},
|
||||
prFiles: [],
|
||||
});
|
||||
|
||||
expect(files).toEqual([
|
||||
{ path: "src/server.go", repository_name: "backend", source: "committed" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
import type { WalkthroughPromptFile } from "./walkthrough-request";
|
||||
|
||||
type PromptFileInput = {
|
||||
path?: string;
|
||||
repositoryName?: string;
|
||||
repository_name?: string;
|
||||
};
|
||||
|
||||
type CumulativePromptFileInput = PromptFileInput & {
|
||||
diff?: string;
|
||||
};
|
||||
|
||||
type PromptFileSource = "uncommitted" | "staged" | "committed" | "pr";
|
||||
|
||||
const FILE_KEY_SEP = "\u0000";
|
||||
|
||||
function splitCompositeKey(key: string): { repo: string; path: string } {
|
||||
const sep = key.indexOf(FILE_KEY_SEP);
|
||||
if (sep < 0) return { repo: "", path: key };
|
||||
return { repo: key.slice(0, sep), path: key.slice(sep + 1) };
|
||||
}
|
||||
|
||||
function normalizePromptFile(
|
||||
source: PromptFileSource,
|
||||
file: PromptFileInput,
|
||||
fallbackKey?: string,
|
||||
): WalkthroughPromptFile | null {
|
||||
const fallback = fallbackKey ? splitCompositeKey(fallbackKey) : { repo: "", path: "" };
|
||||
const path = file.path || fallback.path;
|
||||
if (!path) return null;
|
||||
const repo = file.repository_name ?? file.repositoryName ?? fallback.repo;
|
||||
return {
|
||||
path,
|
||||
...(repo ? { repository_name: repo } : {}),
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
function promptKey(file: WalkthroughPromptFile): string {
|
||||
return `${file.repository_name ?? file.repositoryName ?? ""}\0${file.path}`;
|
||||
}
|
||||
|
||||
function addPromptFile(
|
||||
out: WalkthroughPromptFile[],
|
||||
seen: Set<string>,
|
||||
source: PromptFileSource,
|
||||
file: PromptFileInput,
|
||||
fallbackKey?: string,
|
||||
) {
|
||||
const promptFile = normalizePromptFile(source, file, fallbackKey);
|
||||
if (!promptFile) return;
|
||||
const key = promptKey(promptFile);
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
out.push(promptFile);
|
||||
}
|
||||
|
||||
export function buildWalkthroughPromptFiles({
|
||||
unstagedFiles,
|
||||
stagedFiles,
|
||||
committedFiles,
|
||||
prFiles,
|
||||
}: {
|
||||
unstagedFiles: PromptFileInput[];
|
||||
stagedFiles: PromptFileInput[];
|
||||
committedFiles?: Record<string, CumulativePromptFileInput> | null;
|
||||
prFiles: PromptFileInput[];
|
||||
}): WalkthroughPromptFile[] {
|
||||
const out: WalkthroughPromptFile[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const file of unstagedFiles) addPromptFile(out, seen, "uncommitted", file);
|
||||
for (const file of stagedFiles) addPromptFile(out, seen, "staged", file);
|
||||
for (const [key, file] of Object.entries(committedFiles ?? {})) {
|
||||
addPromptFile(out, seen, "committed", file, key);
|
||||
}
|
||||
for (const file of prFiles) addPromptFile(out, seen, "pr", file);
|
||||
return out;
|
||||
}
|
||||
@@ -1,66 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildChangesWalkthroughPrompt,
|
||||
formatChangedFilesForWalkthroughPrompt,
|
||||
} from "./walkthrough-request";
|
||||
import { buildChangesWalkthroughPrompt } from "./walkthrough-request";
|
||||
|
||||
describe("buildChangesWalkthroughPrompt", () => {
|
||||
it("sends the saved prompt reference visibly and hides the expanded prompt content", () => {
|
||||
const prompt = buildChangesWalkthroughPrompt("CUSTOM_PROMPT\n{{changed_files}}", [
|
||||
{ path: "src/app.ts", source: "uncommitted" },
|
||||
{ path: "src/review.ts", repository_name: "web", source: "pr" },
|
||||
]);
|
||||
it("sends only the prompt reference visibly and hides expanded prompt content", () => {
|
||||
const prompt = buildChangesWalkthroughPrompt("CUSTOM_PROMPT\nshow_walkthrough_kandev");
|
||||
|
||||
expect(prompt).toMatch(/^@changes-walkthrough\n\nChanged files:/);
|
||||
expect(prompt).toMatch(/^@changes-walkthrough\n\n<kandev-system>/);
|
||||
expect(prompt).toContain("<kandev-system>");
|
||||
expect(prompt).toContain("EXPANDED PROMPT REFERENCES");
|
||||
expect(prompt).toContain("### @changes-walkthrough");
|
||||
expect(prompt).toContain("CUSTOM_PROMPT");
|
||||
expect(prompt).toContain("- src/app.ts [uncommitted]");
|
||||
expect(prompt).toContain("- web:src/review.ts [pr]");
|
||||
expect(prompt).not.toContain("Diff context:");
|
||||
expect(prompt).not.toContain("Base branch:");
|
||||
expect(prompt).not.toContain("Base commit:");
|
||||
expect(prompt).not.toContain("src/app.ts");
|
||||
});
|
||||
|
||||
it("maps diff context placeholders to static instructions instead of dynamic context", () => {
|
||||
const prompt = buildChangesWalkthroughPrompt("CUSTOM\n{{diff_context}}");
|
||||
|
||||
expect(prompt).toContain("CUSTOM");
|
||||
expect(prompt).toContain("Inspect the task changes and compare against the correct base.");
|
||||
expect(prompt).not.toContain("PR: kdlbs/kandev#42");
|
||||
expect(prompt).not.toContain("Head branch:");
|
||||
expect(prompt).not.toContain("Base branch:");
|
||||
expect(prompt).not.toContain("Changed files:");
|
||||
expect(prompt).not.toContain("{{diff_context}}");
|
||||
});
|
||||
|
||||
it("maps legacy changed_files placeholders to static instructions instead of file paths", () => {
|
||||
const prompt = buildChangesWalkthroughPrompt("CUSTOM\n{{changed_files}}");
|
||||
|
||||
expect(prompt).toContain("CUSTOM");
|
||||
expect(prompt).toContain("Inspect the task changes and compare against the correct base.");
|
||||
expect(prompt).not.toContain("Changed files:");
|
||||
expect(prompt).not.toContain("{{changed_files}}");
|
||||
});
|
||||
|
||||
it("deduplicates repeated files and caps the file list", () => {
|
||||
const many = Array.from({ length: 85 }, (_, index) => ({
|
||||
path: `src/file-${index}.ts`,
|
||||
source: "committed",
|
||||
}));
|
||||
const prompt = buildChangesWalkthroughPrompt("{{changed_files}}", [many[0], ...many]);
|
||||
it("does not append extra fallback instructions when a customized prompt omits placeholders", () => {
|
||||
const prompt = buildChangesWalkthroughPrompt("CUSTOM_WITHOUT_PLACEHOLDER");
|
||||
|
||||
expect(prompt.match(/src\/file-0\.ts/g)).toHaveLength(1);
|
||||
expect(prompt).toContain("5 more file(s) omitted");
|
||||
});
|
||||
|
||||
it("escapes file metadata line breaks before prompt interpolation", () => {
|
||||
const files = [
|
||||
{
|
||||
path: "src/app.ts\n- injected",
|
||||
repository_name: "web\nrepo",
|
||||
source: "pr\rsource",
|
||||
},
|
||||
];
|
||||
|
||||
expect(formatChangedFilesForWalkthroughPrompt(files)).toContain(
|
||||
"- web\\nrepo:src/app.ts\\n- injected [pr\\rsource]",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the changed file list visible when a customized prompt omits the placeholder", () => {
|
||||
const prompt = buildChangesWalkthroughPrompt("CUSTOM_WITHOUT_PLACEHOLDER", [
|
||||
{ path: "src/app.ts", source: "committed" },
|
||||
]);
|
||||
|
||||
expect(prompt).toMatch(/^@changes-walkthrough\n\nChanged files:/);
|
||||
expect(prompt).toMatch(/^@changes-walkthrough\n\n<kandev-system>/);
|
||||
expect(prompt).toContain("CUSTOM_WITHOUT_PLACEHOLDER");
|
||||
expect(prompt).toContain("- src/app.ts [committed]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatChangedFilesForWalkthroughPrompt", () => {
|
||||
it("renders an explicit loading fallback when no files are available", () => {
|
||||
expect(formatChangedFilesForWalkthroughPrompt([])).toContain(
|
||||
"No changed files were listed by the UI",
|
||||
);
|
||||
expect(prompt).not.toContain("Diff context:");
|
||||
expect(prompt).not.toContain("No precomputed changed-file list is supplied.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,69 +1,26 @@
|
||||
import { formatPromptReferenceExpansions } from "@/lib/prompts/expand-prompt-references";
|
||||
|
||||
export type WalkthroughPromptFile = {
|
||||
path: string;
|
||||
repository_name?: string;
|
||||
repositoryName?: string;
|
||||
source?: "uncommitted" | "committed" | "pr" | string;
|
||||
};
|
||||
|
||||
const MAX_PROMPT_FILES = 80;
|
||||
export const CHANGES_WALKTHROUGH_PROMPT_NAME = "changes-walkthrough";
|
||||
|
||||
// Custom prompts may still contain placeholders from earlier walkthrough prompt iterations.
|
||||
const DIFF_CONTEXT_PLACEHOLDER = "{{diff_context}}";
|
||||
const CHANGED_FILES_PLACEHOLDER = "{{changed_files}}";
|
||||
const CHANGED_FILES_REFERENCE = "The changed files are listed in the user-visible message above.";
|
||||
const STATIC_CONTEXT_INSTRUCTION = "Inspect the task changes and compare against the correct base.";
|
||||
|
||||
function fileKey(file: WalkthroughPromptFile): string {
|
||||
return `${file.repository_name ?? file.repositoryName ?? ""}\0${file.path}\0${file.source ?? ""}`;
|
||||
function buildExpansionContent(template: string): string {
|
||||
return template
|
||||
.trim()
|
||||
.replaceAll(DIFF_CONTEXT_PLACEHOLDER, STATIC_CONTEXT_INSTRUCTION)
|
||||
.replaceAll(CHANGED_FILES_PLACEHOLDER, STATIC_CONTEXT_INSTRUCTION);
|
||||
}
|
||||
|
||||
function escapePromptFilePart(value: string): string {
|
||||
return value.replaceAll("\\", "\\\\").replaceAll("\r", "\\r").replaceAll("\n", "\\n");
|
||||
}
|
||||
|
||||
function formatPromptFile(file: WalkthroughPromptFile): string {
|
||||
const repo = escapePromptFilePart(file.repository_name ?? file.repositoryName ?? "");
|
||||
const path = escapePromptFilePart(file.path);
|
||||
const source = file.source ? ` [${escapePromptFilePart(file.source)}]` : "";
|
||||
return repo ? `${repo}:${path}${source}` : `${path}${source}`;
|
||||
}
|
||||
|
||||
export function formatChangedFilesForWalkthroughPrompt(files: WalkthroughPromptFile[]): string {
|
||||
const uniqueFiles: WalkthroughPromptFile[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const file of files) {
|
||||
if (!file.path) continue;
|
||||
const key = fileKey(file);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
uniqueFiles.push(file);
|
||||
}
|
||||
|
||||
const shown = uniqueFiles.slice(0, MAX_PROMPT_FILES);
|
||||
const omitted = uniqueFiles.length - shown.length;
|
||||
return shown.length > 0
|
||||
? shown.map((file) => `- ${formatPromptFile(file)}`).join("\n") +
|
||||
(omitted > 0 ? `\n- ... ${omitted} more file(s) omitted from this prompt` : "")
|
||||
: "- No changed files were listed by the UI; inspect the local task state before anchoring.";
|
||||
}
|
||||
|
||||
export function buildChangesWalkthroughPrompt(
|
||||
template: string,
|
||||
files: WalkthroughPromptFile[],
|
||||
): string {
|
||||
const changedFiles = formatChangedFilesForWalkthroughPrompt(files);
|
||||
const trimmedTemplate = template.trim();
|
||||
const expansionContent = trimmedTemplate.includes(CHANGED_FILES_PLACEHOLDER)
|
||||
? trimmedTemplate.replaceAll(CHANGED_FILES_PLACEHOLDER, CHANGED_FILES_REFERENCE)
|
||||
: trimmedTemplate;
|
||||
export function buildChangesWalkthroughPrompt(template: string): string {
|
||||
const expansionContext = formatPromptReferenceExpansions([
|
||||
{ name: CHANGES_WALKTHROUGH_PROMPT_NAME, content: expansionContent },
|
||||
{ name: CHANGES_WALKTHROUGH_PROMPT_NAME, content: buildExpansionContent(template) },
|
||||
]);
|
||||
return [
|
||||
`@${CHANGES_WALKTHROUGH_PROMPT_NAME}`,
|
||||
"",
|
||||
"Changed files:",
|
||||
changedFiles,
|
||||
"",
|
||||
"<kandev-system>",
|
||||
expansionContext,
|
||||
"</kandev-system>",
|
||||
|
||||
Reference in New Issue
Block a user