feat(checkpoints): create checkpoint when user sends a message (#7713)

* feat(checkpoints): create checkpoint on user message send

* fix(checkpoints): suppress implicit user-message checkpoint row; keep current checkpoint updated without a chat row

* Fix checkpoint suppression for user messages

- Propagate suppressMessage flag through event chain properly
- Update ChatView to check checkpoint metadata for suppressMessage flag
- Ensure checkpoint messages are created but not rendered when suppressed
- Fix bug where checkpointSave(false) should have been checkpointSave(true)

* fix: only create checkpoint on user message when files have changed

- Changed allowEmpty from true to false in checkpointSave call
- Checkpoints will now only be created when there are actual file changes
- This avoids creating empty commits in the shadow git repository

* test: update checkpoint test to include suppressMessage parameter

- Fixed test expectation to match the new function signature
- saveCheckpoint now expects both allowEmpty and suppressMessage parameters

---------

Co-authored-by: Roo Code <roomote@roocode.com>
Co-authored-by: Hannes Rudolph <hrudolph@gmail.com>
Co-authored-by: Daniel Riccio <ricciodaniel98@gmail.com>
This commit is contained in:
roomote[bot] 2025-09-05 18:07:59 -04:00 committed by GitHub
parent ae01a90151
commit ed765a3e7d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 57 additions and 19 deletions

View file

@ -111,7 +111,7 @@ describe("Checkpoint functionality", () => {
// saveCheckpoint should have been called
expect(mockCheckpointService.saveCheckpoint).toHaveBeenCalledWith(
expect.stringContaining("Task: test-task-id"),
{ allowEmpty: true },
{ allowEmpty: true, suppressMessage: false },
)
// Result should contain the commit hash

View file

@ -131,13 +131,26 @@ async function checkGitInstallation(
task.checkpointServiceInitializing = false
})
service.on("checkpoint", ({ fromHash: from, toHash: to }) => {
service.on("checkpoint", ({ fromHash: from, toHash: to, suppressMessage }) => {
try {
provider?.postMessageToWebview({ type: "currentCheckpointUpdated", text: to })
// Always update the current checkpoint hash in the webview, including the suppress flag
provider?.postMessageToWebview({
type: "currentCheckpointUpdated",
text: to,
suppressMessage: !!suppressMessage,
})
task.say("checkpoint_saved", to, undefined, undefined, { from, to }, undefined, {
isNonInteractive: true,
}).catch((err) => {
// Always create the chat message but include the suppress flag in the payload
// so the chatview can choose not to render it while keeping it in history.
task.say(
"checkpoint_saved",
to,
undefined,
undefined,
{ from, to, suppressMessage: !!suppressMessage },
undefined,
{ isNonInteractive: true },
).catch((err) => {
log("[Task#getCheckpointService] caught unexpected error in say('checkpoint_saved')")
console.error(err)
})
@ -164,7 +177,7 @@ async function checkGitInstallation(
}
}
export async function checkpointSave(task: Task, force = false) {
export async function checkpointSave(task: Task, force = false, suppressMessage = false) {
const service = await getCheckpointService(task)
if (!service) {
@ -174,10 +187,12 @@ export async function checkpointSave(task: Task, force = false) {
TelemetryService.instance.captureCheckpointCreated(task.taskId)
// Start the checkpoint process in the background.
return service.saveCheckpoint(`Task: ${task.taskId}, Time: ${Date.now()}`, { allowEmpty: force }).catch((err) => {
console.error("[Task#checkpointSave] caught unexpected error, disabling checkpoints", err)
task.enableCheckpoints = false
})
return service
.saveCheckpoint(`Task: ${task.taskId}, Time: ${Date.now()}`, { allowEmpty: force, suppressMessage })
.catch((err) => {
console.error("[Task#checkpointSave] caught unexpected error, disabling checkpoints", err)
task.enableCheckpoints = false
})
}
export type CheckpointRestoreOptions = {

View file

@ -890,6 +890,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.askResponseText = text
this.askResponseImages = images
// Create a checkpoint whenever the user sends a message.
// Use allowEmpty=true to ensure a checkpoint is recorded even if there are no file changes.
// Suppress the checkpoint_saved chat row for this particular checkpoint to keep the timeline clean.
if (askResponse === "messageResponse") {
void this.checkpointSave(false, true)
}
// Mark the last follow-up question as answered
if (askResponse === "messageResponse" || askResponse === "yesButtonClicked") {
// Find the last unanswered follow-up message using findLastIndex
@ -2774,8 +2781,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Checkpoints
public async checkpointSave(force: boolean = false) {
return checkpointSave(this, force)
public async checkpointSave(force: boolean = false, suppressMessage: boolean = false) {
return checkpointSave(this, force, suppressMessage)
}
public async checkpointRestore(options: CheckpointRestoreOptions) {

View file

@ -200,7 +200,7 @@ export abstract class ShadowCheckpointService extends EventEmitter {
public async saveCheckpoint(
message: string,
options?: { allowEmpty?: boolean },
options?: { allowEmpty?: boolean; suppressMessage?: boolean },
): Promise<CheckpointResult | undefined> {
try {
this.log(
@ -221,7 +221,13 @@ export abstract class ShadowCheckpointService extends EventEmitter {
const duration = Date.now() - startTime
if (result.commit) {
this.emit("checkpoint", { type: "checkpoint", fromHash, toHash, duration })
this.emit("checkpoint", {
type: "checkpoint",
fromHash,
toHash,
duration,
suppressMessage: options?.suppressMessage ?? false,
})
}
if (result.commit) {

View file

@ -28,6 +28,7 @@ export interface CheckpointEventMap {
fromHash: string
toHash: string
duration: number
suppressMessage?: boolean
}
restore: { type: "restore"; commitHash: string; duration: number }
error: { type: "error"; error: Error }

View file

@ -859,10 +859,19 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
// Remove the 500-message limit to prevent array index shifting
// Virtuoso is designed to efficiently handle large lists through virtualization
const newVisibleMessages = modifiedMessages.filter((message) => {
// Filter out checkpoint_saved messages that are associated with user messages
if (message.say === "checkpoint_saved" && message.text) {
// Use O(1) Set lookup instead of O(n) array search
if (userMessageCheckpointHashes.has(message.text)) {
// Filter out checkpoint_saved messages that should be suppressed
if (message.say === "checkpoint_saved") {
// Check if this checkpoint has the suppressMessage flag set
if (
message.checkpoint &&
typeof message.checkpoint === "object" &&
"suppressMessage" in message.checkpoint &&
message.checkpoint.suppressMessage
) {
return false
}
// Also filter out checkpoint messages associated with user messages (legacy behavior)
if (message.text && userMessageCheckpointHashes.has(message.text)) {
return false
}
}