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
57 changes: 53 additions & 4 deletions src/browser/features/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,15 @@ const MAX_PERSISTED_ATTACHMENT_DRAFT_CHARS = 4_000_000;

export type { ChatInputProps, ChatInputAPI };

interface SendOverrides {
queueDispatchMode?: QueueDispatchMode;
goalInterventionPolicy?: GoalInterventionPolicy;
}

interface InternalSendOverrides extends SendOverrides {
skipBoundaryEditConfirmation?: boolean;
}

const ChatInputInner: React.FC<ChatInputProps> = (props) => {
const { api } = useAPI();
const policyState = usePolicy();
Expand Down Expand Up @@ -263,6 +272,8 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
// Extract workspace-specific props with defaults
const disabled = props.disabled ?? false;
const editingMessage = variant === "workspace" ? props.editingMessage : undefined;
const [pendingBoundaryEditConfirmation, setPendingBoundaryEditConfirmation] =
useState<SendOverrides | null>(null);
// Hide edit-mode chrome as soon as an edit send starts so the input doesn't sit blank
// while the backend acknowledges the edit and begins the replacement stream.
const [optimisticallyDismissedEditId, setOptimisticallyDismissedEditId] = useState<string | null>(
Expand Down Expand Up @@ -2134,10 +2145,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
[setInput]
);

const handleSend = async (overrides?: {
queueDispatchMode?: QueueDispatchMode;
goalInterventionPolicy?: GoalInterventionPolicy;
}) => {
const handleSend = async (overrides?: InternalSendOverrides) => {
if (!canSend) {
return;
}
Expand Down Expand Up @@ -2246,6 +2254,21 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
// Workspace variant: full command handling + message send
if (variant !== "workspace") return; // Type guard

if (
editingMessageForUi?.isBeforeLatestContextBoundary === true &&
overrides?.skipBoundaryEditConfirmation !== true
) {
// Re-enable the old pre-compaction edit flow, but confirm at send time because
// the backend truncates through the context boundary and discards its summary.
setPendingBoundaryEditConfirmation({
...(overrides?.queueDispatchMode ? { queueDispatchMode: overrides.queueDispatchMode } : {}),
...(overrides?.goalInterventionPolicy
? { goalInterventionPolicy: overrides.goalInterventionPolicy }
: {}),
});
return;
}

try {
const modelOneShot = parsed?.type === "model-oneshot" ? parsed : null;
const commandHandled = modelOneShot
Expand Down Expand Up @@ -2546,6 +2569,22 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
}
};

const handleBoundaryEditConfirm = async () => {
const pendingOverrides = pendingBoundaryEditConfirmation;
const pendingEdit = editingMessageForUi;
if (!pendingOverrides || variant !== "workspace" || !pendingEdit) {
setPendingBoundaryEditConfirmation(null);
return;
}

setPendingBoundaryEditConfirmation(null);
await handleSend({ ...pendingOverrides, skipBoundaryEditConfirmation: true });
};

const handleBoundaryEditCancel = () => {
setPendingBoundaryEditConfirmation(null);
};

// Keep the imperative API pointing at the latest send handler.
handleSendRef.current = handleSend;

Expand Down Expand Up @@ -3100,6 +3139,16 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
</div>
</div>

<ConfirmationModal
isOpen={pendingBoundaryEditConfirmation !== null}
title="Edit Message Before Context Boundary?"
description="Sending this edit will discard the latest compaction or reset summary and every message after the edited one, then continue from the rewritten history."
warning="This action cannot be undone."
confirmLabel="Edit and Send"
onConfirm={handleBoundaryEditConfirm}
onCancel={handleBoundaryEditCancel}
/>

{/* Confirmation modal for destructive commands (currently only /clear). */}
<ConfirmationModal
isOpen={pendingDestructiveCommand}
Expand Down
13 changes: 10 additions & 3 deletions src/browser/utils/chatEditing.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import type { DisplayedUserMessage } from "@/common/types/message";
import { canEditDisplayedUserMessage } from "./chatEditing";
import { buildEditingStateFromDisplayed, canEditDisplayedUserMessage } from "./chatEditing";

function userMessage(overrides: Partial<DisplayedUserMessage> = {}): DisplayedUserMessage {
return {
Expand All @@ -27,12 +27,19 @@ describe("canEditDisplayedUserMessage", () => {
).toBe(false);
});

test("excludes messages before the latest context boundary", () => {
test("allows messages before the latest context boundary", () => {
expect(canEditDisplayedUserMessage(userMessage({ isBeforeLatestContextBoundary: true }))).toBe(
false
true
);
});

test("marks pre-boundary edits so the send flow can confirm destructive rewind", () => {
expect(
buildEditingStateFromDisplayed(userMessage({ isBeforeLatestContextBoundary: true }))
.isBeforeLatestContextBoundary
).toBe(true);
});

test("excludes side-question rows from edit paths", () => {
expect(canEditDisplayedUserMessage(userMessage({ isSideQuestion: true }))).toBe(false);
});
Expand Down
9 changes: 8 additions & 1 deletion src/browser/utils/chatEditing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ export interface PendingUserMessage extends Omit<
export interface EditingMessageState {
id: string;
pending: PendingUserMessage;
/**
* Sending this edit will truncate across the latest context boundary, so the
* composer must confirm before discarding the compaction/reset summary.
*/
isBeforeLatestContextBoundary?: boolean;
}

export const normalizeQueuedMessage = (queued: QueuedMessage): PendingUserMessage => ({
Expand All @@ -31,7 +36,6 @@ const LOCAL_COMMAND_STDOUT_OPEN_TAG = "<local-command-stdout>";
const LOCAL_COMMAND_STDOUT_CLOSE_TAG = "</local-command-stdout>";

export const canEditDisplayedUserMessage = (message: DisplayedUserMessage): boolean => {
Comment thread
ethanndickson marked this conversation as resolved.
if (message.isBeforeLatestContextBoundary === true) return false;
// /btw rows are persisted read-only side branches. Editing one would route the
// edited text through the normal main-thread send path and truncate history
// from the aside instead of re-running the side-question flow.
Expand All @@ -54,6 +58,9 @@ export const buildEditingStateFromDisplayed = (
): EditingMessageState => ({
id: message.historyId,
pending: buildPendingFromDisplayed(message),
...(message.isBeforeLatestContextBoundary === true
? { isBeforeLatestContextBoundary: true }
: {}),
});

/**
Expand Down
62 changes: 62 additions & 0 deletions src/node/services/agentSession.editMessageId.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,68 @@ describe("AgentSession.sendMessage (editMessageId)", () => {
expect(appendedFileParts[0].mediaType).toBe("image/png");
});

it("removes snapshots when editing before the latest context boundary", async () => {
const workspaceId = "ws-edit-before-boundary-snapshot";
const { session, historyService } = await createSessionHarness(workspaceId);

await historyService.appendToHistory(
workspaceId,
createMuxMessage("snapshot-original", "user", "snapshot", {
historySequence: 0,
synthetic: true,
fileAtMentionSnapshot: ["@src/foo.ts"],
})
);
await historyService.appendToHistory(
workspaceId,
createMuxMessage("user-original", "user", "original @src/foo.ts", {
historySequence: 1,
})
);
await historyService.appendToHistory(
workspaceId,
createMuxMessage("assistant-original", "assistant", "reply", { historySequence: 2 })
);
await historyService.appendToHistory(
workspaceId,
createMuxMessage("boundary", "assistant", "summary", {
historySequence: 3,
compacted: "user",
compactionBoundary: true,
Comment thread
ethanndickson marked this conversation as resolved.
compactionEpoch: 1,
})
);
await historyService.appendToHistory(
workspaceId,
createMuxMessage("assistant-after-boundary", "assistant", "after", { historySequence: 4 })
);
const activeWindow = await historyService.getHistoryFromLatestBoundary(workspaceId);
expect(activeWindow.success).toBe(true);
if (activeWindow.success) {
expect(activeWindow.data.map((message) => message.id)).toEqual([
"boundary",
"assistant-after-boundary",
]);
}
const truncateAfterMessage = spyOn(historyService, "truncateAfterMessage");

const result = await session.sendMessage("edited without mention", {
model: TEST_MODEL,
agentId: "exec",
editMessageId: "user-original",
});

expect(result.success).toBe(true);
expect(truncateAfterMessage.mock.calls[0]?.[1]).toBe("snapshot-original");

const history = await historyService.getHistoryFromLatestBoundary(workspaceId);
expect(history.success).toBe(true);
if (history.success) {
expect(history.data.map((message) => message.id)).not.toContain("snapshot-original");
expect(history.data.map((message) => message.id)).toHaveLength(1);
}
});

it("preempts a still-preparing turn when editing its last user message", async () => {
const workspaceId = "ws-edit-preparing";
const streamResolves: Array<() => void> = [];
Expand Down
72 changes: 51 additions & 21 deletions src/node/services/agentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,54 @@ export class AgentSession {
);
}

private getEditTruncateTargetFromMessages(
messages: readonly MuxMessage[],
editMessageId: string
): string | undefined {
const editIndex = messages.findIndex((message) => message.id === editMessageId);
if (editIndex === -1) {
return undefined;
}

let truncateTargetId = editMessageId;
for (let i = editIndex - 1; i >= 0; i -= 1) {
const message = messages[i];
if (!this.isSyntheticSnapshotUserMessage(message)) {
break;
}
truncateTargetId = message.id;
}

return truncateTargetId;
}

private async getEditTruncateTargetId(editMessageId: string): Promise<string> {
const historyResult = await this.historyService.getHistoryFromLatestBoundary(this.workspaceId);
if (historyResult.success) {
const truncateTargetId = this.getEditTruncateTargetFromMessages(
historyResult.data,
editMessageId
);
if (truncateTargetId !== undefined) {
return truncateTargetId;
}
}

const fullHistory: MuxMessage[] = [];
const fullHistoryResult = await this.historyService.iterateFullHistory(
this.workspaceId,
"forward",
(messages) => {
fullHistory.push(...messages);
}
);
if (!fullHistoryResult.success) {
return editMessageId;
}

return this.getEditTruncateTargetFromMessages(fullHistory, editMessageId) ?? editMessageId;
}

private getLastNonSystemHistoryMessage(historyTail: MuxMessage[]): MuxMessage | undefined {
for (let index = historyTail.length - 1; index >= 0; index -= 1) {
const candidate = historyTail[index];
Expand Down Expand Up @@ -2414,27 +2462,9 @@ export class AgentSession {

// Find the truncation target: the edited message or any immediately-preceding snapshots.
// (snapshots are persisted immediately before their corresponding user message)
// Only search the current compaction epoch — truncating past a compaction boundary
// would destroy the summary. The frontend only shows post-boundary messages.
let truncateTargetId = editMessageId;
const historyResult = await this.historyService.getHistoryFromLatestBoundary(
this.workspaceId
);
if (historyResult.success) {
const messages = historyResult.data;
const editIndex = messages.findIndex((m) => m.id === editMessageId);
if (editIndex > 0) {
// Walk backwards over contiguous synthetic snapshots so we don't orphan them.
for (let i = editIndex - 1; i >= 0; i--) {
const msg = messages[i];
const isSnapshot =
msg.metadata?.synthetic &&
(msg.metadata?.fileAtMentionSnapshot ?? msg.metadata?.agentSkillSnapshot);
if (!isSnapshot) break;
truncateTargetId = msg.id;
}
}
}
// Pre-boundary edits are user-confirmed by the composer, so fall back to full-history lookup
// when the edit target is outside the active context window.
const truncateTargetId = await this.getEditTruncateTargetId(editMessageId);

const truncateResult = await this.historyService.truncateAfterMessage(
this.workspaceId,
Expand Down
Loading