Skip to content

Commit 933e977

Browse files
committed
feat: add user prompt checkpointing and notify on manual file changes
1 parent 084c84e commit 933e977

3 files changed

Lines changed: 250 additions & 9 deletions

File tree

src/common/file-history.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ type FileHistoryManifest = {
1818
files: Record<string, FileHistoryEntry>;
1919
};
2020

21+
export type FileHistoryCheckpointResult = {
22+
checkpointHash: string | undefined;
23+
changedFilePaths: string[];
24+
};
25+
2126
export class GitFileHistory {
2227
constructor(
2328
_projectRoot: string,
@@ -114,21 +119,33 @@ export class GitFileHistory {
114119
}
115120
}
116121

117-
recordTrackedFilesCheckpoint(sessionId: string, message: string): string | undefined {
122+
recordTrackedFilesCheckpoint(sessionId: string, message: string): FileHistoryCheckpointResult {
118123
const currentHash = this.ensureSession(sessionId);
119124
if (!currentHash) {
120-
return undefined;
125+
return { checkpointHash: undefined, changedFilePaths: [] };
121126
}
122127

123128
try {
124129
const manifest = this.readManifest(currentHash);
125-
const trackedPaths = Object.values(manifest.files).map((entry) => entry.path);
130+
const trackedPaths = Object.values(manifest.files)
131+
.map((entry) => entry.path)
132+
.sort((left, right) => left.localeCompare(right));
126133
if (trackedPaths.length === 0) {
127-
return currentHash;
134+
return { checkpointHash: currentHash, changedFilePaths: [] };
128135
}
129-
return this.recordCheckpoint(sessionId, trackedPaths, message);
136+
const nextHash = this.recordCheckpoint(sessionId, trackedPaths, message);
137+
if (!nextHash) {
138+
return { checkpointHash: undefined, changedFilePaths: [] };
139+
}
140+
141+
const nextManifest = this.readManifest(nextHash);
142+
const changedFilePaths = Object.entries(manifest.files)
143+
.filter(([key, entry]) => !isSameFileHistoryEntry(entry, nextManifest.files[key]))
144+
.map(([key, entry]) => nextManifest.files[key]?.path ?? entry.path)
145+
.sort((left, right) => left.localeCompare(right));
146+
return { checkpointHash: nextHash, changedFilePaths };
130147
} catch {
131-
return undefined;
148+
return { checkpointHash: undefined, changedFilePaths: [] };
132149
}
133150
}
134151

@@ -340,6 +357,13 @@ function normalizeManifest(manifest: FileHistoryManifest): FileHistoryManifest {
340357
return { version: 2, files };
341358
}
342359

360+
function isSameFileHistoryEntry(left: FileHistoryEntry, right: FileHistoryEntry | undefined): boolean {
361+
if (!right) {
362+
return false;
363+
}
364+
return left.path === right.path && left.blob === right.blob && left.mode === right.mode;
365+
}
366+
343367
function uniqueAbsolutePaths(filePaths: string[]): string[] {
344368
return Array.from(new Set(filePaths.map((filePath) => path.resolve(filePath))));
345369
}

src/session.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import type { McpServerConfig, PermissionScope, PermissionSettings } from "./set
3030
import { logApiError } from "./common/error-logger";
3131
import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./common/debug-logger";
3232
import { killProcessTree } from "./common/process-tree";
33-
import { GitFileHistory } from "./common/file-history";
33+
import { GitFileHistory, type FileHistoryCheckpointResult } from "./common/file-history";
3434
import { clearSessionState, getSnippet, rebuildSessionStateFromHistory } from "./common/state";
3535
import {
3636
appendProjectPermissionAllows,
@@ -1088,7 +1088,11 @@ ${skillMd}
10881088
this.reportNewPrompt();
10891089

10901090
this.ensureFileHistorySession(sessionId);
1091-
this.recordUserPromptCheckpoint(sessionId);
1091+
const checkpoint = this.recordUserPromptCheckpoint(sessionId);
1092+
if (checkpoint.changedFilePaths.length) {
1093+
const content = `Note that the user manually modified these files:\n${checkpoint.changedFilePaths.join("\n")}`;
1094+
this.appendSessionMessage(sessionId, this.buildSystemMessage(sessionId, content));
1095+
}
10921096
const userMessage = this.buildUserMessage(sessionId, userPrompt);
10931097
this.appendSessionMessage(sessionId, userMessage);
10941098

@@ -1754,7 +1758,7 @@ ${skillMd}
17541758
return this.getFileHistory().getCurrentCheckpointHash(sessionId);
17551759
}
17561760

1757-
private recordUserPromptCheckpoint(sessionId: string): string | undefined {
1761+
private recordUserPromptCheckpoint(sessionId: string): FileHistoryCheckpointResult {
17581762
return this.getFileHistory().recordTrackedFilesCheckpoint(sessionId, "User prompt checkpoint");
17591763
}
17601764

src/tests/session.test.ts

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1061,6 +1061,219 @@ test("replySession snapshots manual edits to tracked files before appending the
10611061
assert.equal(fs.readFileSync(filePath, "utf8"), manualEdit);
10621062
});
10631063

1064+
test("replySession inserts hidden system notice for manually changed tracked files", async (t) => {
1065+
if (!hasGit()) {
1066+
t.skip("git is not available");
1067+
return;
1068+
}
1069+
1070+
const workspace = createTempDir("deepcode-manual-change-notice-workspace-");
1071+
const home = createTempDir("deepcode-manual-change-notice-home-");
1072+
setHomeDir(home);
1073+
1074+
const firstPath = path.join(workspace, "a.txt");
1075+
const secondPath = path.join(workspace, "b.txt");
1076+
const manager = createSessionManager(workspace, "machine-id-manual-change-notice");
1077+
(manager as any).activateSession = async () => {};
1078+
1079+
const sessionId = await manager.createSession({ text: "first prompt" });
1080+
const fileHistory = new GitFileHistory(workspace, getFileHistoryGitDir(home, workspace));
1081+
fs.writeFileSync(firstPath, "one\n", "utf8");
1082+
fs.writeFileSync(secondPath, "two\n", "utf8");
1083+
assert.ok(fileHistory.recordCheckpoint(sessionId, [secondPath, firstPath], "track files"));
1084+
1085+
fs.writeFileSync(secondPath, "two changed\n", "utf8");
1086+
fs.writeFileSync(firstPath, "one changed\n", "utf8");
1087+
await manager.replySession(sessionId, { text: "check manual changes" });
1088+
1089+
const messages = manager.listSessionMessages(sessionId);
1090+
const userIndex = messages.findIndex(
1091+
(message) => message.role === "user" && message.content === "check manual changes"
1092+
);
1093+
assert.ok(userIndex > 0);
1094+
const notice = messages[userIndex - 1];
1095+
assert.equal(notice?.role, "system");
1096+
assert.equal(notice?.visible, false);
1097+
assert.equal(notice?.content, `Note that the user manually modified these files:\n${firstPath}\n${secondPath}`);
1098+
});
1099+
1100+
test("replySession does not insert manual-change notice when tracked files are unchanged", async (t) => {
1101+
if (!hasGit()) {
1102+
t.skip("git is not available");
1103+
return;
1104+
}
1105+
1106+
const workspace = createTempDir("deepcode-no-manual-change-notice-workspace-");
1107+
const home = createTempDir("deepcode-no-manual-change-notice-home-");
1108+
setHomeDir(home);
1109+
1110+
const filePath = path.join(workspace, "tracked.txt");
1111+
const manager = createSessionManager(workspace, "machine-id-no-manual-change-notice");
1112+
(manager as any).activateSession = async () => {};
1113+
1114+
const sessionId = await manager.createSession({ text: "first prompt" });
1115+
const fileHistory = new GitFileHistory(workspace, getFileHistoryGitDir(home, workspace));
1116+
fs.writeFileSync(filePath, "same\n", "utf8");
1117+
assert.ok(fileHistory.recordCheckpoint(sessionId, [filePath], "track file"));
1118+
1119+
await manager.replySession(sessionId, { text: "second prompt" });
1120+
1121+
const notices = manager
1122+
.listSessionMessages(sessionId)
1123+
.filter(
1124+
(message) =>
1125+
message.role === "system" &&
1126+
typeof message.content === "string" &&
1127+
message.content.startsWith("Note that the user manually modified these files:")
1128+
);
1129+
assert.equal(notices.length, 0);
1130+
});
1131+
1132+
test("replySession reports manual deletion of a tracked file", async (t) => {
1133+
if (!hasGit()) {
1134+
t.skip("git is not available");
1135+
return;
1136+
}
1137+
1138+
const workspace = createTempDir("deepcode-manual-delete-notice-workspace-");
1139+
const home = createTempDir("deepcode-manual-delete-notice-home-");
1140+
setHomeDir(home);
1141+
1142+
const filePath = path.join(workspace, "deleted.txt");
1143+
const manager = createSessionManager(workspace, "machine-id-manual-delete-notice");
1144+
(manager as any).activateSession = async () => {};
1145+
1146+
const sessionId = await manager.createSession({ text: "first prompt" });
1147+
const fileHistory = new GitFileHistory(workspace, getFileHistoryGitDir(home, workspace));
1148+
fs.writeFileSync(filePath, "delete me\n", "utf8");
1149+
assert.ok(fileHistory.recordCheckpoint(sessionId, [filePath], "track file"));
1150+
1151+
fs.unlinkSync(filePath);
1152+
await manager.replySession(sessionId, { text: "check deletion" });
1153+
1154+
const notice = manager
1155+
.listSessionMessages(sessionId)
1156+
.find(
1157+
(message) =>
1158+
message.role === "system" &&
1159+
message.content === `Note that the user manually modified these files:\n${filePath}`
1160+
);
1161+
assert.ok(notice);
1162+
});
1163+
1164+
test("replySession ignores manually created untracked files", async (t) => {
1165+
if (!hasGit()) {
1166+
t.skip("git is not available");
1167+
return;
1168+
}
1169+
1170+
const workspace = createTempDir("deepcode-untracked-manual-file-workspace-");
1171+
const home = createTempDir("deepcode-untracked-manual-file-home-");
1172+
setHomeDir(home);
1173+
1174+
const trackedPath = path.join(workspace, "tracked.txt");
1175+
const untrackedPath = path.join(workspace, "untracked.txt");
1176+
const manager = createSessionManager(workspace, "machine-id-untracked-manual-file");
1177+
(manager as any).activateSession = async () => {};
1178+
1179+
const sessionId = await manager.createSession({ text: "first prompt" });
1180+
const fileHistory = new GitFileHistory(workspace, getFileHistoryGitDir(home, workspace));
1181+
fs.writeFileSync(trackedPath, "tracked\n", "utf8");
1182+
assert.ok(fileHistory.recordCheckpoint(sessionId, [trackedPath], "track file"));
1183+
1184+
fs.writeFileSync(untrackedPath, "new manual file\n", "utf8");
1185+
await manager.replySession(sessionId, { text: "second prompt" });
1186+
1187+
const notices = manager
1188+
.listSessionMessages(sessionId)
1189+
.filter(
1190+
(message) =>
1191+
message.role === "system" &&
1192+
typeof message.content === "string" &&
1193+
message.content.startsWith("Note that the user manually modified these files:")
1194+
);
1195+
assert.equal(notices.length, 0);
1196+
});
1197+
1198+
test("replySession does not insert manual-change notice for /continue", async (t) => {
1199+
if (!hasGit()) {
1200+
t.skip("git is not available");
1201+
return;
1202+
}
1203+
1204+
const workspace = createTempDir("deepcode-continue-no-manual-change-notice-workspace-");
1205+
const home = createTempDir("deepcode-continue-no-manual-change-notice-home-");
1206+
setHomeDir(home);
1207+
1208+
const filePath = path.join(workspace, "tracked.txt");
1209+
const manager = createSessionManager(workspace, "machine-id-continue-no-manual-change-notice");
1210+
(manager as any).activateSession = async () => {};
1211+
1212+
const sessionId = await manager.createSession({ text: "first prompt" });
1213+
const fileHistory = new GitFileHistory(workspace, getFileHistoryGitDir(home, workspace));
1214+
fs.writeFileSync(filePath, "before\n", "utf8");
1215+
assert.ok(fileHistory.recordCheckpoint(sessionId, [filePath], "track file"));
1216+
1217+
fs.writeFileSync(filePath, "manual change\n", "utf8");
1218+
await manager.replySession(sessionId, { text: "/continue" });
1219+
1220+
const notices = manager
1221+
.listSessionMessages(sessionId)
1222+
.filter(
1223+
(message) =>
1224+
message.role === "system" &&
1225+
typeof message.content === "string" &&
1226+
message.content.startsWith("Note that the user manually modified these files:")
1227+
);
1228+
assert.equal(notices.length, 0);
1229+
});
1230+
1231+
test("replySession does not insert manual-change notice for permission-only replies", async (t) => {
1232+
if (!hasGit()) {
1233+
t.skip("git is not available");
1234+
return;
1235+
}
1236+
1237+
const workspace = createTempDir("deepcode-permission-no-manual-change-notice-workspace-");
1238+
const home = createTempDir("deepcode-permission-no-manual-change-notice-home-");
1239+
setHomeDir(home);
1240+
1241+
const filePath = path.join(workspace, "tracked.txt");
1242+
const manager = createSessionManager(workspace, "machine-id-permission-no-manual-change-notice");
1243+
(manager as any).activateSession = async () => {};
1244+
1245+
const sessionId = await manager.createSession({ text: "first prompt" });
1246+
const fileHistory = new GitFileHistory(workspace, getFileHistoryGitDir(home, workspace));
1247+
fs.writeFileSync(filePath, "before\n", "utf8");
1248+
assert.ok(fileHistory.recordCheckpoint(sessionId, [filePath], "track file"));
1249+
const assistant = (manager as any).buildAssistantMessage(
1250+
sessionId,
1251+
"Need permission",
1252+
[
1253+
{
1254+
id: "call-read",
1255+
type: "function",
1256+
function: { name: "read", arguments: JSON.stringify({ file_path: filePath }) },
1257+
},
1258+
],
1259+
null
1260+
) as SessionMessage;
1261+
(manager as any).appendSessionMessage(sessionId, assistant);
1262+
1263+
fs.writeFileSync(filePath, "manual change\n", "utf8");
1264+
await manager.replySession(sessionId, { permissions: [{ toolCallId: "call-read", permission: "allow" }] });
1265+
1266+
const notices = manager
1267+
.listSessionMessages(sessionId)
1268+
.filter(
1269+
(message) =>
1270+
message.role === "system" &&
1271+
typeof message.content === "string" &&
1272+
message.content.startsWith("Note that the user manually modified these files:")
1273+
);
1274+
assert.equal(notices.length, 0);
1275+
});
1276+
10641277
test("Write tool advances file-history while preserving the user prompt checkpoint", async (t) => {
10651278
if (!hasGit()) {
10661279
t.skip("git is not available");

0 commit comments

Comments
 (0)