From a9f74badc1b04a319316103dd77fed61ba7d0c64 Mon Sep 17 00:00:00 2001 From: Derek Hearst Date: Wed, 23 Sep 2026 20:12:00 -0600 Subject: [PATCH 1/9] fix(chat): Compact Conversation runs the SDK's /compact Compact Conversation and the compaction before a switch to a smaller model sent an ordinary message asking the model for a summary. The SDK session then carried that summary on top of the full history it was meant to replace, so the context grew, and the model-switch notice claimed a compaction that never happened. Both now send `/compact` with the same keep-list. The CLI summarises and starts the session again from the summary (checked against the bundled CLI: a compact_boundary comes back and the next turn sends only the summary), and the turn shows its own "Context compacted" notice. The switch notice says whether it worked. Co-Authored-By: Claude Opus 5.5 --- src/routes/chat/[id]/+page.svelte | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/routes/chat/[id]/+page.svelte b/src/routes/chat/[id]/+page.svelte index c665158b..90362242 100644 --- a/src/routes/chat/[id]/+page.svelte +++ b/src/routes/chat/[id]/+page.svelte @@ -1336,9 +1336,11 @@ const projectedPct = nextLimit > 0 ? (contextMetrics.used / nextLimit) * 100 : 0; if (nextLimit < currentLimit && projectedPct >= autoCompactThresholdPct) { - const compactionPrompt = `Please compact this conversation for handoff to a model with a smaller context window. Preserve all requirements, decisions, open tasks, constraints, and the latest user intent in a concise structured summary.`; - await streamMessage(compactionPrompt, false); - modelSwitchNotice = `Auto-compact ran on ${currentModel.split('/').at(-1)} before switching to ${nextModel.split('/').at(-1)}.`; + // The SDK's own `/compact`: it really replaces the session's history with a summary. + await streamMessage(`/compact ${COMPACT_INSTRUCTIONS} This is a handoff to a model with a smaller context window.`, false); + modelSwitchNotice = streamError + ? `Compacting before the switch to ${nextModel.split('/').at(-1)} failed; the full conversation is still in context.` + : `Compacted the conversation on ${currentModel.split('/').at(-1)} before switching to ${nextModel.split('/').at(-1)}.`; setTimeout(() => { modelSwitchNotice = null; }, 5000); @@ -1347,10 +1349,18 @@ model = nextModel; } + /** + * What `/compact` is told to keep. It used to be sent as an ordinary message asking the model + * for a summary, which the SDK session then carried on top of the full history it was meant + * to replace — so the context grew. `/compact` is the CLI's own command: it summarises and + * starts the session again from the summary, and the turn shows a "Context compacted" notice. + */ + const COMPACT_INSTRUCTIONS = + 'Preserve all requirements, decisions, open tasks, constraints, and the latest user intent.'; + async function compactContext() { if (!conversationId || streaming) return; - const compactionPrompt = `Please compact this conversation. Preserve all requirements, decisions, open tasks, constraints, and the latest user intent in a concise structured summary so we can continue from a smaller context.`; - await streamMessage(compactionPrompt, false); + await streamMessage(`/compact ${COMPACT_INSTRUCTIONS}`, false); } // Console-redesign — surface streaming/context data to the right rail. From 101b54f0eb31cb36b0fc8380f30e56970815ab95 Mon Sep 17 00:00:00 2001 From: Derek Hearst Date: Wed, 23 Sep 2026 20:12:36 -0600 Subject: [PATCH 2/9] fix(chat): edit and regenerate answer the edited message on a cut-back SDK session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edit and Regenerate sent the literal prompt "regenerate" into a resumed SDK session that still held the original, unedited history. The edited text never reached the model, the reply the user threw away stayed in its context, and memory recall, skill relevance, the title and the evaluator all ran on the word "regenerate". Every turn now goes in as one streamed SDKUserMessage carrying a uuid we mint, which the CLI keeps as the transcript uuid (verified against the bundled CLI). The user row records it with the session and the run's cwd (`metadata.sdkTurn`), and the reply records the last main-thread chain entry its turn wrote (`metadata.sdkTailUuid`) — no migration. A regenerate, after an edit too, sends the row's own text and attachments and resumes with `resumeSessionAt` at the previous reply's tail: the same session id, exactly the kept history. The first message, and replies from before the join or from another session, start a fresh session primed with the kept rows as text; a cut the CLI refuses (one from before a compaction) is recognised before any model call and retried once that way. forkSession is not used — a fork starts without the file history. File checkpointing is turned on for runs whose workspace outlives the turn (a project, or an agent's persistent key), so the join can name a checkpoint; restoring comes next. The edit button is hidden while a reply streams, and the page sends '' rather than a placeholder. Co-Authored-By: Claude Opus 5.5 --- src/lib/chat/MessageBubble.svelte | 11 +- src/lib/chat/turn-plan.server.ts | 137 +++++++++++++ src/lib/chat/turn-plan.ts | 196 ++++++++++++++++++ src/lib/engine/options.server.ts | 14 ++ src/lib/engine/stream.server.ts | 12 ++ src/lib/engine/turn-input.ts | 156 ++++++++++++++ src/routes/chat/[id]/+page.svelte | 7 +- src/routes/chat/[id]/stream/+server.ts | 63 ++++-- tests/chat.turn-plan.spec.ts | 180 +++++++++++++++++ tests/engine.turn-input.spec.ts | 268 +++++++++++++++++++++++++ 10 files changed, 1024 insertions(+), 20 deletions(-) create mode 100644 src/lib/chat/turn-plan.server.ts create mode 100644 src/lib/chat/turn-plan.ts create mode 100644 src/lib/engine/turn-input.ts create mode 100644 tests/chat.turn-plan.spec.ts create mode 100644 tests/engine.turn-input.spec.ts diff --git a/src/lib/chat/MessageBubble.svelte b/src/lib/chat/MessageBubble.svelte index 156800d3..db1e842f 100644 --- a/src/lib/chat/MessageBubble.svelte +++ b/src/lib/chat/MessageBubble.svelte @@ -40,12 +40,15 @@ onEdit, onRegenerate, canRegenerate = false, + canEdit = true, modelChanged = true, } = $props<{ message: MessageRow; onEdit?: ((messageId: string, content: string) => Promise | void) | undefined; onRegenerate?: ((messageId: string) => Promise | void) | undefined; canRegenerate?: boolean; + /** False while a reply is streaming: an edit would cut the conversation out from under it. */ + canEdit?: boolean; /** * Whether this message's model differs from the previous assistant message's. * The tag is noise when it repeats down a whole conversation; it earns its place @@ -225,9 +228,11 @@

{message.content}

- + {#if canEdit} + + {/if} {#if canRegenerate} + +
+ + + +{/if} diff --git a/src/lib/chat/chat.remote.ts b/src/lib/chat/chat.remote.ts index d37d478c..0fb127d1 100644 --- a/src/lib/chat/chat.remote.ts +++ b/src/lib/chat/chat.remote.ts @@ -1,5 +1,5 @@ import { command, query } from '$app/server' -import { and, asc, desc, eq, gt, isNull, ne, or, sql } from 'drizzle-orm' +import { and, asc, desc, eq, isNull, sql } from 'drizzle-orm' import { z } from 'zod' import { db } from '$lib/db.server' import { conversations, messages } from '$lib/sessions/sessions.schema' @@ -16,6 +16,8 @@ import { } from '$lib/chat/agent-switch.server' import { BUILTIN_AGENT_KEYS } from '$lib/agents/builtin-agents.server' import { insertMessageWithSequence } from '$lib/chat/insert-message.server' +import { editUserMessage, truncateAfterMessage } from '$lib/chat/message-branch.server' +import { previewMessageRewind } from '$lib/chat/rewind.server' import { listRecentConversations } from '$lib/chat/conversation-list.server' import { findLiveChatRun } from '$lib/runs/live-chat-run.server' import { @@ -38,16 +40,30 @@ const createConversationSchema = z.object({ const conversationIdSchema = z.string().uuid() +/** + * #24 — `restoreFiles` also restores the files the dropped turns changed, before any row + * changes; `acknowledgeUncommitted` is the user's explicit "overwrite them" for an imported + * repository with uncommitted changes in those files. See `./message-branch.server`. + */ +const branchOptions = { + restoreFiles: z.boolean().optional(), + acknowledgeUncommitted: z.boolean().optional(), +} + const editMessageSchema = z.object({ messageId: z.string().uuid(), content: z.string().trim().min(1), + ...branchOptions, }) const deleteMessagesAfterSchema = z.object({ conversationId: z.string().uuid(), messageId: z.string().uuid(), + ...branchOptions, }) +const previewRewindSchema = z.object({ messageId: z.string().uuid() }) + const savePartialAssistantSchema = z.object({ conversationId: z.string().uuid(), content: z.string().trim().min(1), @@ -145,64 +161,22 @@ export const deleteConversation = command(conversationIdSchema, async (conversat export const editMessage = command(editMessageSchema, async (input) => { const user = requireAuthenticatedRequestUser() - const [target] = await db.select().from(messages).where(eq(messages.id, input.messageId)).limit(1) - if (!target || target.role !== 'user') { - return { success: false, error: 'Message not found or not editable' as const } - } - - const [conversation] = await db - .select({ id: conversations.id }) - .from(conversations) - .where(and(eq(conversations.id, target.conversationId), eq(conversations.userId, user.id))) - .limit(1) - if (!conversation) { - return { success: false, error: 'Message not found or not editable' as const } - } - - await db.update(messages).set({ content: input.content }).where(eq(messages.id, input.messageId)) - - await db - .delete(messages) - .where( - and( - eq(messages.conversationId, target.conversationId), - or( - gt(messages.createdAt, target.createdAt), - and(eq(messages.createdAt, target.createdAt), ne(messages.id, target.id)), - ), - ), - ) - - return { success: true as const, conversationId: target.conversationId } + return editUserMessage({ ...input, userId: user.id }) }) export const deleteMessagesAfter = command(deleteMessagesAfterSchema, async (input) => { const user = requireAuthenticatedRequestUser() - const [conversation] = await db - .select({ id: conversations.id }) - .from(conversations) - .where(and(eq(conversations.id, input.conversationId), eq(conversations.userId, user.id))) - .limit(1) - - if (!conversation) { - return { success: false, error: 'Message not found' as const } - } - - const [pivot] = await db - .select() - .from(messages) - .where(and(eq(messages.id, input.messageId), eq(messages.conversationId, input.conversationId))) - .limit(1) - - if (!pivot) { - return { success: false, error: 'Message not found' as const } - } - - await db - .delete(messages) - .where(and(eq(messages.conversationId, input.conversationId), gt(messages.createdAt, pivot.createdAt))) + return truncateAfterMessage({ ...input, userId: user.id }) +}) - return { success: true as const } +/** + * #24 — what "also restore files" would do for an edit or regenerate at `messageId`: the + * files, their line counts and which have uncommitted changes. Changes nothing. A command + * rather than a query: it starts a CLI process, and must never be cached or refreshed. + */ +export const previewRewind = command(previewRewindSchema, async ({ messageId }) => { + const user = requireAuthenticatedRequestUser() + return previewMessageRewind({ userId: user.id, messageId }) }) export const savePartialAssistant = command(savePartialAssistantSchema, async (input) => { diff --git a/src/lib/chat/message-branch.server.ts b/src/lib/chat/message-branch.server.ts new file mode 100644 index 00000000..88f4320c --- /dev/null +++ b/src/lib/chat/message-branch.server.ts @@ -0,0 +1,118 @@ +/** + * Edit and regenerate: cutting a conversation back to one of its user messages. + * + * Both change `messages` only; the next turn (a regenerate) cuts the SDK session to match + * (`./turn-plan`). With `restoreFiles`, the files the dropped turns changed are restored + * first (#24), and a restore that fails leaves the conversation exactly as it was — the + * user can then choose to go on without it. + * + * Refused while a turn is running: the turn would save its reply under a message that no + * longer says what it answered, and its session would be cut out from under it. + */ + +import { and, eq, gt } from 'drizzle-orm' +import { db } from '$lib/db.server' +import { conversations, messages } from '$lib/sessions/sessions.schema' +import { findLiveChatRun } from '$lib/runs/live-chat-run.server' +import { applyMessageRewind, isConversationRewinding, type RewindDeps } from './rewind.server' + +export type BranchInput = { + userId: string + /** The user message the conversation is cut back to. */ + messageId: string + /** Also restore the files the dropped turns changed. */ + restoreFiles?: boolean + /** The user confirmed that uncommitted changes in an imported repository may be overwritten. */ + acknowledgeUncommitted?: boolean +} + +export type BranchResult = + | { success: true; conversationId: string; filesRestored: number } + | { success: false; error: string; rewindFailed?: true } + +const NOT_FOUND = 'Message not found or not editable' +const TURN_RUNNING = 'A reply is still being written. Wait for it to finish, or stop it, first.' + +/** The caller's own user message, with the conversation it belongs to. */ +async function loadOwnUserMessage(userId: string, messageId: string, conversationId?: string) { + const [row] = await db + .select({ id: messages.id, role: messages.role, sequence: messages.sequence, conversationId: messages.conversationId }) + .from(messages) + .innerJoin(conversations, eq(conversations.id, messages.conversationId)) + .where( + and( + eq(messages.id, messageId), + eq(conversations.userId, userId), + ...(conversationId ? [eq(messages.conversationId, conversationId)] : []), + ), + ) + .limit(1) + return row && row.role === 'user' ? row : null +} + +/** Refuse while a turn or a rewind is running, then restore the files when asked. */ +async function prepareBranch( + input: BranchInput, + conversationId: string, + deps: RewindDeps, +): Promise<{ error: BranchResult } | { filesRestored: number }> { + if (await findLiveChatRun(conversationId, input.userId)) return { error: { success: false, error: TURN_RUNNING } } + if (isConversationRewinding(conversationId)) { + return { error: { success: false, error: 'Files are already being restored in this conversation.' } } + } + if (!input.restoreFiles) return { filesRestored: 0 } + const rewind = await applyMessageRewind( + { userId: input.userId, messageId: input.messageId, acknowledgeUncommitted: input.acknowledgeUncommitted }, + deps, + ) + if (!rewind.ok) { + return { + error: { + success: false, + error: `${rewind.error} The conversation was not changed: try again, or go on without restoring the files.`, + rewindFailed: true, + }, + } + } + return { filesRestored: rewind.filesRestored } +} + +/** Every row after `sequence` — the turns the cut drops. Sequence, not time: two rows can share a millisecond. */ +async function deleteAfter(conversationId: string, sequence: number) { + await db.delete(messages).where(and(eq(messages.conversationId, conversationId), gt(messages.sequence, sequence))) +} + +/** Replace a user message's text and drop everything after it. */ +export async function editUserMessage( + input: BranchInput & { content: string }, + deps: RewindDeps = {}, +): Promise { + const target = await loadOwnUserMessage(input.userId, input.messageId) + if (!target) return { success: false, error: NOT_FOUND } + + const prepared = await prepareBranch(input, target.conversationId, deps) + if ('error' in prepared) return prepared.error + + await db.transaction(async (tx) => { + await tx.update(messages).set({ content: input.content }).where(eq(messages.id, target.id)) + await tx + .delete(messages) + .where(and(eq(messages.conversationId, target.conversationId), gt(messages.sequence, target.sequence))) + }) + return { success: true, conversationId: target.conversationId, filesRestored: prepared.filesRestored } +} + +/** Drop everything after a user message, so its reply can be written again. */ +export async function truncateAfterMessage( + input: BranchInput & { conversationId: string }, + deps: RewindDeps = {}, +): Promise { + const target = await loadOwnUserMessage(input.userId, input.messageId, input.conversationId) + if (!target) return { success: false, error: 'Message not found' } + + const prepared = await prepareBranch(input, target.conversationId, deps) + if ('error' in prepared) return prepared.error + + await deleteAfter(target.conversationId, target.sequence) + return { success: true, conversationId: target.conversationId, filesRestored: prepared.filesRestored } +} diff --git a/src/lib/chat/rewind-dialog.svelte.ts b/src/lib/chat/rewind-dialog.svelte.ts new file mode 100644 index 00000000..1f725afe --- /dev/null +++ b/src/lib/chat/rewind-dialog.svelte.ts @@ -0,0 +1,70 @@ +/** + * #24 — the "also restore files" question an edit or regenerate asks first. + * + * Same shape as `confirmDialog`: an awaitable call, and one host component + * (`RewindPreviewDialog.svelte`) that reads the pending request from module state. The chat + * page's handlers stay one `await` longer and nothing else. + * + * The question is only asked when there is something to answer. A message with no file + * checkpoint (a chat with no project, an older message), or one whose turn changed no + * files, goes straight on without restoring — the option is simply not there. + */ + +import { previewRewind } from './chat.remote' +import { shouldOfferRestore, type RewindPreview } from './rewind-preview' + +export type RestoreChoice = { + restoreFiles: boolean + /** The user ticked "overwrite the uncommitted changes" for an imported repository. */ + acknowledgeUncommitted: boolean +} + +export type RewindAction = 'edit' | 'regenerate' + +type PendingRestore = { + action: RewindAction + preview: RewindPreview + resolve: (choice: RestoreChoice | null) => void +} + +export const rewindDialogState = $state<{ pending: PendingRestore | null; checking: boolean }>({ + pending: null, + checking: false, +}) + +const NO_RESTORE: RestoreChoice = { restoreFiles: false, acknowledgeUncommitted: false } + +/** + * Ask whether to restore files along with an edit or regenerate at `messageId`. + * + * Resolves to the choice, or null when the user cancels — in which case nothing at all + * should happen. A preview that cannot be fetched goes on without restoring: leaving the + * files as they are never loses anything. + */ +export async function chooseFileRestore(messageId: string, action: RewindAction): Promise { + let preview: RewindPreview + rewindDialogState.checking = true + try { + preview = await previewRewind({ messageId }) + } catch (error) { + console.warn('[chat/ui] file restore preview failed', error) + return NO_RESTORE + } finally { + rewindDialogState.checking = false + } + if (!shouldOfferRestore(preview)) return NO_RESTORE + + return new Promise((resolve) => { + // A second request while one is open would strand the first. Cancelling it is the safe answer. + rewindDialogState.pending?.resolve(null) + rewindDialogState.pending = { action, preview, resolve } + }) +} + +/** Called by the host component with the user's answer; null cancels. */ +export function settleFileRestore(choice: RestoreChoice | null): void { + const pending = rewindDialogState.pending + if (!pending) return + rewindDialogState.pending = null + pending.resolve(choice) +} diff --git a/src/lib/chat/rewind-plan.ts b/src/lib/chat/rewind-plan.ts new file mode 100644 index 00000000..be296b71 --- /dev/null +++ b/src/lib/chat/rewind-plan.ts @@ -0,0 +1,107 @@ +/** + * #24 — mapping the CLI's dry-run answer onto the preview the edit/regenerate dialog shows + * (`./rewind-preview`). Server side only, for `node:path`; pure otherwise, so a spec can pin + * it. The project's `git status` goes in alongside, as a set of paths. + */ + +import { isAbsolute, relative, resolve, sep } from 'node:path' +import type { RepoKind } from '$lib/projects/projects.schema' +import { blockedPreview, type RewindFile, type RewindPreview, type RewindResultLike } from './rewind-preview' + +const toSlashes = (path: string) => path.split(sep).join('/') + +/** + * The workspace-relative path of `file`, or null when it is outside the workspace. The CLI + * reports absolute paths; a relative one is taken against the workspace, as the CLI would. + */ +export function workspaceRelative(workspaceRoot: string, file: string): string | null { + const root = resolve(workspaceRoot) + const absolute = isAbsolute(file) ? resolve(file) : resolve(root, file) + const rel = relative(root, absolute) + if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null + return toSlashes(rel) +} + +/** + * The arguments `uncommittedPaths` runs git with. `-z` so a path with spaces or quotes comes + * back as it is, `--untracked-files=all` so a new file inside a new directory is listed by + * name rather than as the directory, `--no-renames` so every entry is exactly one path. + */ +export function uncommittedStatusArgs(repoPath: string): string[] { + return ['-C', repoPath, 'status', '--porcelain=v1', '-z', '--untracked-files=all', '--no-renames'] +} + +/** + * The paths `git status --porcelain=v1 -z` lists: changed, staged, deleted and untracked + * files alike, relative to the repository root. An untracked file counts — a restore that + * deletes it deletes something git never had a copy of. + */ +export function parseUncommittedPaths(stdout: string): Set { + const paths = new Set() + for (const entry of stdout.split('\0')) { + // "XY path": two status letters, a space, the path. + if (entry.length > 3) paths.add(entry.slice(3)) + } + return paths +} + +/** Whether git lists `path`, or a directory it sits in (`dir/`, as git lists a whole new directory). */ +function listedAsUncommitted(path: string, uncommitted: ReadonlySet): boolean { + if (uncommitted.has(path)) return true + const parts = path.split('/') + for (let i = 1; i < parts.length; i++) { + if (uncommitted.has(`${parts.slice(0, i).join('/')}/`)) return true + } + return false +} + +/** + * The CLI's refusals, in the words the dialog uses. Its own text is kept for anything else: + * an unfamiliar reason is still better than none. + */ +export function describeRewindRefusal(error: string | undefined): string { + const text = error?.trim() ?? '' + if (/no file checkpoint found/i.test(text)) { + return 'There is no saved copy of the files from before this message. The conversation may have been compacted since, or the copy has expired.' + } + if (/file rewinding is not enabled/i.test(text)) return 'File checkpoints were not turned on for this message.' + return text || 'These files cannot be restored.' +} + +/** + * Map a dry run onto the preview. + * + * `uncommittedPaths` is the set of workspace-relative paths `git status` lists, or null when + * the workspace is not a git checkout (nothing to compare against). + */ +export function mapRewindPreview(input: { + result: RewindResultLike + workspaceRoot: string + uncommittedPaths: ReadonlySet | null + repoKind: RepoKind | null +}): RewindPreview { + const { result } = input + if (!result.canRewind) return blockedPreview(describeRewindRefusal(result.error), input.repoKind) + + const files: RewindFile[] = [] + const outsideWorkspace: string[] = [] + for (const file of result.filesChanged ?? []) { + const rel = workspaceRelative(input.workspaceRoot, file) + if (rel === null) outsideWorkspace.push(file) + else files.push({ path: rel, uncommitted: input.uncommittedPaths ? listedAsUncommitted(rel, input.uncommittedPaths) : false }) + } + files.sort((a, b) => a.path.localeCompare(b.path)) + + const outside = outsideWorkspace.length > 0 + return { + available: true, + reason: outside ? 'Some of these files are outside the workspace, so nothing will be restored.' : null, + canRewind: !outside && files.length > 0, + files, + insertions: result.insertions ?? 0, + deletions: result.deletions ?? 0, + outsideWorkspace, + repoKind: input.repoKind, + requiresAcknowledge: input.repoKind === 'imported' && files.some((file) => file.uncommitted), + } +} diff --git a/src/lib/chat/rewind-preview.ts b/src/lib/chat/rewind-preview.ts new file mode 100644 index 00000000..15e93677 --- /dev/null +++ b/src/lib/chat/rewind-preview.ts @@ -0,0 +1,95 @@ +/** + * #24 — the file-restore preview, as the server returns it and the edit/regenerate dialog + * reads it. No imports with a runtime, so the browser can load it as well as a spec. + */ + +import type { RepoKind } from '$lib/projects/projects.schema' + +/** The CLI's answer to `rewindFiles`, as far as the preview reads it. */ +export type RewindResultLike = { + canRewind: boolean + error?: string + filesChanged?: string[] + insertions?: number + deletions?: number + skippedLinks?: number +} + +export type RewindFile = { + /** Relative to the workspace, forward slashes. */ + path: string + /** Git reports uncommitted changes in it — which may be the agent's, or the user's own. */ + uncommitted: boolean +} + +export type RewindPreview = { + /** + * Whether this message has a file checkpoint at all. False hides the option: a chat with + * no project, a message from before checkpointing, one from an earlier session. + */ + available: boolean + /** Why the files cannot be restored, in plain words, when that is the case. */ + reason: string | null + /** Whether restoring would do anything and is allowed to. */ + canRewind: boolean + /** The files a restore would change back, or delete when the turn created them. */ + files: RewindFile[] + insertions: number + deletions: number + /** Paths outside the workspace. Any at all and the restore is refused. */ + outsideWorkspace: string[] + /** The project's repository kind, when the run stood in its checkout. */ + repoKind: RepoKind | null + /** + * An imported repository with uncommitted changes in files the restore would overwrite. + * Restoring then needs an explicit "overwrite them" from the user, checked again on the + * server; it is never implied by the default. + */ + requiresAcknowledge: boolean +} + +/** A preview for a message there is nothing to restore for. */ +export function unavailablePreview(reason: string | null = null): RewindPreview { + return { + available: false, + reason, + canRewind: false, + files: [], + insertions: 0, + deletions: 0, + outsideWorkspace: [], + repoKind: null, + requiresAcknowledge: false, + } +} + +/** A checkpoint exists, but it cannot be used right now. The dialog explains; the restore is off. */ +export function blockedPreview(reason: string, repoKind: RepoKind | null = null): RewindPreview { + return { ...unavailablePreview(reason), available: true, repoKind } +} + +/** + * Whether the dialog is worth showing. Hidden when there is no checkpoint, and when the + * checkpoint would restore nothing — a turn that changed no files. Shown when a restore is + * possible, and when a checkpoint exists but cannot be used, so the user learns why. + */ +export function shouldOfferRestore(preview: RewindPreview): boolean { + if (!preview.available) return false + return preview.canRewind || preview.reason !== null +} + +/** "Also restore files" starts ticked whenever there is something to restore. */ +export function restoreByDefault(preview: RewindPreview): boolean { + return preview.available && preview.canRewind +} + +/** + * Whether the dialog's Continue may be pressed. Restoring over uncommitted changes in an + * imported repository needs the explicit "overwrite them" box as well; not restoring, or + * restoring anywhere else, needs nothing more. + */ +export function canContinueRestore(preview: RewindPreview, choice: { restore: boolean; acknowledge: boolean }): boolean { + if (!choice.restore) return true + if (!preview.canRewind) return false + return !preview.requiresAcknowledge || choice.acknowledge +} diff --git a/src/lib/chat/rewind.server.ts b/src/lib/chat/rewind.server.ts new file mode 100644 index 00000000..6c56c38f --- /dev/null +++ b/src/lib/chat/rewind.server.ts @@ -0,0 +1,247 @@ +/** + * #24 — restore a conversation's files to how they were at one of its user messages. + * + * The edit/regenerate flow calls these: `previewMessageRewind` for the dialog, then + * `applyMessageRewind` when the user confirms, before any row changes. Both go through a + * short-lived control session (`$lib/engine/rewind.server`), so they work after a restart + * and cost no model call. + * + * The guards, all checked on every call rather than trusted from the preview: + * + * - the message is the caller's own, a user row, with a checkpointed join (`./turn-plan`); + * - its checkpoint belongs to the conversation's current SDK session — an older session's + * file history knows nothing about what later sessions changed; + * - the working directory it recorded is inside the caller's own sandbox, and still there; + * - no turn is running in the conversation, and no other rewind is (one per conversation, + * in this process — the session file and the workspace are shared); + * - nothing outside the workspace is touched; + * - an imported repository with uncommitted changes in the files being restored needs + * `acknowledgeUncommitted`, because git cannot bring those back. + */ + +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' +import { and, eq } from 'drizzle-orm' +import { db } from '$lib/db.server' +import { conversations, messages } from '$lib/sessions/sessions.schema' +import { projects, type RepoKind } from '$lib/projects/projects.schema' +import { getProjectPath } from '$lib/projects/project-fs.server' +import { defaultGitRunner } from '$lib/workspace/worktree.server' +import { findLiveChatRun } from '$lib/runs/live-chat-run.server' +import { resolveWorkspaceRoot } from '$lib/workspace/workspace.server' +import { isPathWithin } from '$lib/workspace/containment.server' +import { withControlSession, type CreateRewindQuery, type RewindControl } from '$lib/engine/rewind.server' +import { logger } from '$lib/observability/logger' +import { readTurnJoin, type TurnJoin } from './turn-plan' +import { mapRewindPreview, parseUncommittedPaths, uncommittedStatusArgs } from './rewind-plan' +import { blockedPreview, unavailablePreview, type RewindPreview } from './rewind-preview' + +/** Conversations with a rewind in flight in this process. */ +const rewinding = new Set() + +/** Whether a rewind is restoring this conversation's files right now. A new turn must wait. */ +export function isConversationRewinding(conversationId: string): boolean { + return rewinding.has(conversationId) +} + +export type RewindDeps = { + /** The SDK seam, for specs. */ + createQuery?: CreateRewindQuery + /** Workspace-relative paths with uncommitted changes, or null when `repoPath` is not a checkout. */ + uncommittedPaths?: (repoPath: string) => Promise | null> + /** The per-user sandbox root. Defaults to `SANDBOX_WORKSPACE`, as runs use. */ + sandboxRoot?: string + timeoutMs?: number +} + +type RewindTarget = { + conversationId: string + join: TurnJoin + repoKind: RepoKind | null + /** The project checkout, when the run stood in it: where `git status` is asked. */ + gitRoot: string | null +} + +async function defaultUncommittedPaths(repoPath: string): Promise | null> { + try { + const result = await defaultGitRunner(uncommittedStatusArgs(repoPath)) + // Not a checkout, or git failed: say nothing rather than guess. + return result.code === 0 ? parseUncommittedPaths(result.stdout) : null + } catch { + return null + } +} + +/** Resolve the message to rewind to, or the preview that explains why there is nothing to do. */ +async function loadTarget( + userId: string, + messageId: string, + deps: RewindDeps, +): Promise<{ ok: true; target: RewindTarget } | { ok: false; preview: RewindPreview }> { + const [row] = await db + .select({ + role: messages.role, + metadata: messages.metadata, + conversationId: messages.conversationId, + sdkSessionId: conversations.sdkSessionId, + projectId: conversations.projectId, + }) + .from(messages) + .innerJoin(conversations, eq(conversations.id, messages.conversationId)) + .where(and(eq(messages.id, messageId), eq(conversations.userId, userId))) + .limit(1) + if (!row || row.role !== 'user') return { ok: false, preview: unavailablePreview() } + + const join = readTurnJoin(row.metadata) + if (!join || !join.checkpointed) return { ok: false, preview: unavailablePreview() } + if (join.sessionId !== row.sdkSessionId) { + return { + ok: false, + preview: unavailablePreview('This message is from an earlier session of this conversation, so its files cannot be restored.'), + } + } + + const sandboxRoot = deps.sandboxRoot ?? process.env.SANDBOX_WORKSPACE + if (!isPathWithin(resolveWorkspaceRoot({ userId, sandboxRoot }), resolve(join.cwd))) { + return { ok: false, preview: unavailablePreview() } + } + // A workspace that is gone has nothing left to restore into — and the CLI cannot start in it. + if (!existsSync(join.cwd)) return { ok: false, preview: unavailablePreview() } + + let repoKind: RepoKind | null = null + let gitRoot: string | null = null + if (row.projectId) { + const [project] = await db + .select({ repoKind: projects.repoKind, repoLocalPath: projects.repoLocalPath }) + .from(projects) + .where(and(eq(projects.id, row.projectId), eq(projects.userId, userId))) + .limit(1) + if (project) { + const checkout = project.repoLocalPath ?? getProjectPath(userId, row.projectId) + // Only when the run stood in the checkout: an agent's persistent directory is not the repo. + if (resolve(checkout) === resolve(join.cwd)) { + repoKind = project.repoKind + gitRoot = project.repoKind === 'none' ? null : checkout + } + } + } + + return { ok: true, target: { conversationId: row.conversationId, join, repoKind, gitRoot } } +} + +/** A turn running in the conversation owns its session and its workspace. */ +async function liveTurnReason(userId: string, target: RewindTarget): Promise { + if (await findLiveChatRun(target.conversationId, userId)) { + return 'A reply is still being written. Wait for it to finish, or stop it, first.' + } + return null +} + +/** The dry run, mapped. Needs an open control session. */ +async function dryRun(control: RewindControl, target: RewindTarget, deps: RewindDeps): Promise { + const result = await control.rewindFiles(target.join.uuid, { dryRun: true }) + const uncommitted = target.gitRoot ? await (deps.uncommittedPaths ?? defaultUncommittedPaths)(target.gitRoot) : null + return mapRewindPreview({ result, workspaceRoot: target.join.cwd, uncommittedPaths: uncommitted, repoKind: target.repoKind }) +} + +class RewindBusyError extends Error { + constructor() { + super('Files are already being restored in this conversation.') + this.name = 'RewindBusyError' + } +} + +/** + * One rewind per conversation at a time. Claimed synchronously, before the first await, so + * two requests cannot both see the conversation free. + */ +async function exclusively(conversationId: string, work: () => Promise): Promise { + if (rewinding.has(conversationId)) throw new RewindBusyError() + rewinding.add(conversationId) + try { + return await work() + } finally { + rewinding.delete(conversationId) + } +} + +const describeError = (error: unknown) => (error instanceof Error ? error.message : String(error)) + +/** What restoring files to `messageId` would do. Changes nothing. */ +export async function previewMessageRewind( + input: { userId: string; messageId: string }, + deps: RewindDeps = {}, +): Promise { + const loaded = await loadTarget(input.userId, input.messageId, deps) + if (!loaded.ok) return loaded.preview + const { target } = loaded + + try { + return await exclusively(target.conversationId, async () => { + const busy = await liveTurnReason(input.userId, target) + if (busy) return blockedPreview(busy, target.repoKind) + return withControlSession( + { sessionId: target.join.sessionId, cwd: target.join.cwd, createQuery: deps.createQuery, timeoutMs: deps.timeoutMs }, + (control) => dryRun(control, target, deps), + ) + }) + } catch (error) { + if (error instanceof RewindBusyError) return blockedPreview(error.message, target.repoKind) + logger.warn('[chat/rewind] preview failed', { messageId: input.messageId, error: describeError(error) }) + return blockedPreview(`Could not check the files: ${describeError(error)}`, target.repoKind) + } +} + +export type ApplyRewindResult = { ok: true; filesRestored: number; skippedLinks: number } | { ok: false; error: string } + +/** + * Restore the files to how they were at `messageId`. + * + * Runs the dry run again inside the same session first: the preview the user saw may be + * minutes old, and every refusal is judged on what is on disk now. + */ +export async function applyMessageRewind( + input: { userId: string; messageId: string; acknowledgeUncommitted?: boolean }, + deps: RewindDeps = {}, +): Promise { + const loaded = await loadTarget(input.userId, input.messageId, deps) + if (!loaded.ok) return { ok: false, error: loaded.preview.reason ?? 'This message has no saved files to restore.' } + const { target } = loaded + + try { + return await exclusively(target.conversationId, async (): Promise => { + const busy = await liveTurnReason(input.userId, target) + if (busy) return { ok: false, error: busy } + return withControlSession( + { sessionId: target.join.sessionId, cwd: target.join.cwd, createQuery: deps.createQuery, timeoutMs: deps.timeoutMs }, + async (control): Promise => { + const preview = await dryRun(control, target, deps) + if (preview.outsideWorkspace.length > 0 || (!preview.canRewind && preview.reason)) { + return { ok: false, error: preview.reason ?? 'These files cannot be restored.' } + } + if (preview.files.length === 0) return { ok: true, filesRestored: 0, skippedLinks: 0 } + if (preview.requiresAcknowledge && input.acknowledgeUncommitted !== true) { + return { + ok: false, + error: 'Some of these files have uncommitted changes. Confirm that they may be overwritten first.', + } + } + + const result = await control.rewindFiles(target.join.uuid) + if (!result.canRewind) return { ok: false, error: result.error ?? 'The files could not be restored.' } + logger.info('[chat/rewind] files restored', { + conversationId: target.conversationId, + messageId: input.messageId, + files: preview.files.length, + skippedLinks: result.skippedLinks ?? 0, + }) + return { ok: true, filesRestored: preview.files.length, skippedLinks: result.skippedLinks ?? 0 } + }, + ) + }) + } catch (error) { + if (error instanceof RewindBusyError) return { ok: false, error: error.message } + logger.warn('[chat/rewind] restore failed', { messageId: input.messageId, error: describeError(error) }) + return { ok: false, error: `The files could not be restored: ${describeError(error)}` } + } +} diff --git a/src/lib/engine/rewind.server.ts b/src/lib/engine/rewind.server.ts new file mode 100644 index 00000000..5cbc7a48 --- /dev/null +++ b/src/lib/engine/rewind.server.ts @@ -0,0 +1,191 @@ +/** + * #24 — a short-lived SDK session that exists only to rewind files. + * + * `Query.rewindFiles(userMessageId, { dryRun })` restores the files a session's tools + * changed to how they were at one of its user messages, and with `dryRun` reports what it + * would change without touching anything. It is a control request, so it needs a live + * `Query` — and a rewind is asked for between turns, when the turn's own `Query` is gone. + * The run registry cannot help either: it is per process, and a restart empties it. + * + * So a rewind opens its own session: `query()` resuming the conversation's SDK session, in + * the same working directory the runs used, with file checkpointing on, and an input that + * never yields. No user message ever arrives, so the model is never called. The request is + * answered from the file history the CLI loads from the transcript at start-up, and the + * session is closed straight after — on success, on failure and on timeout alike. + * + * What was checked against the SDK and the bundled CLI (`@anthropic-ai/claude-agent-sdk` + * 0.3.278), rather than assumed: + * + * - `enableFileCheckpointing` reaches the CLI as `CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING`, + * which the SDK sets on the child's environment itself, after `env` — so the engine's env + * allow-list does not have to name it. + * - In print mode the CLI loads a resumed session, file-history snapshots included, before + * it reads any input, and answers `rewind_files` from that state. With checkpointing off + * it answers "File rewinding is not enabled."; for a message it holds no snapshot of, + * "No file checkpoint found for this message." + * - A dry run reports `filesChanged` as absolute paths: tracked paths are stored relative + * to the CLI's working directory when they sit under it, and made absolute against it + * again. That is why the working directory has to be the run's own. + * - A real rewind that fails comes back as a control error, which the SDK throws; a dry run + * that cannot rewind resolves with `canRewind: false` and the reason. + * - `forkSession()` copies no file history, which is one reason edits never fork. + */ + +import { + query, + type Options, + type PermissionResult, + type RewindFilesResult, + type SDKUserMessage, +} from '@anthropic-ai/claude-agent-sdk' +import { buildEngineEnv } from './engine-env' +import { resultErrorMessage } from './run-result' + +export type { RewindFilesResult } + +/** The slice of the SDK's `Query` a control session uses. A spec supplies a stub. */ +export type RewindControlSource = AsyncIterable & { + rewindFiles: (userMessageId: string, options?: { dryRun?: boolean }) => Promise + close: () => void +} + +export type CreateRewindQuery = (params: { + prompt: AsyncIterable + options: Options +}) => RewindControlSource + +/** What the work inside a control session may do. */ +export type RewindControl = { + rewindFiles: (userMessageId: string, options?: { dryRun?: boolean }) => Promise +} + +/** + * Spawning the CLI and loading a long transcript takes a few seconds; a rewind that has not + * answered in this long is not going to, and the user is waiting on a dialog. + */ +export const CONTROL_SESSION_TIMEOUT_MS = 30_000 + +export class RewindTimeoutError extends Error { + constructor(ms: number) { + super(`Restoring files did not answer within ${Math.round(ms / 1000)} seconds.`) + this.name = 'RewindTimeoutError' + } +} + +export type ControlSessionInput = { + /** The SDK session to resume — the conversation's `sdk_session_id`. */ + sessionId: string + /** The working directory the checkpointed run used. Nothing else resolves its paths right. */ + cwd: string + /** The CLI's environment. Defaults to the engine's allow-list, the same one runs get. */ + env?: Record + createQuery?: CreateRewindQuery + timeoutMs?: number +} + +/** + * Options for a session that can do nothing but answer control requests: no tools, no MCP + * servers, no settings from disk, and a permission callback that refuses anything that asks. + * `maxTurns` is belt and braces — no prompt is ever sent. + */ +export function controlSessionOptions(input: Pick): Options { + return { + resume: input.sessionId, + cwd: input.cwd, + enableFileCheckpointing: true, + settingSources: [], + tools: [], + mcpServers: {}, + strictMcpConfig: true, + permissionMode: 'default', + canUseTool: async (): Promise => ({ + behavior: 'deny', + message: 'This session only restores files.', + }), + maxTurns: 1, + env: input.env ?? buildEngineEnv(process.env), + } +} + +/** + * An input stream that never yields a message and ends when `stop` is called. + * + * The SDK keeps the CLI's stdin open until its input ends, and control requests travel on + * stdin — so this is what keeps the session answerable for exactly as long as it is needed. + */ +export function idleInput(): { input: AsyncIterable; stop: () => void } { + let stop: () => void = () => {} + const stopped = new Promise((resolve) => (stop = resolve)) + async function* never(): AsyncGenerator { + await stopped + } + return { input: never(), stop: () => stop() } +} + +/** + * Open a control session, hand it to `work`, and close it whatever happens. + * + * The session's messages are drained in the background — nothing here needs them, but the + * CLI's start-up failure (a session it cannot find, say) arrives as an error result, and it + * is a far better message than the SDK's "process exited". + */ +export async function withControlSession( + input: ControlSessionInput, + work: (control: RewindControl) => Promise, +): Promise { + const timeoutMs = input.timeoutMs ?? CONTROL_SESSION_TIMEOUT_MS + const idle = idleInput() + const create: CreateRewindQuery = + input.createQuery ?? ((params) => query(params) as unknown as RewindControlSource) + let session: RewindControlSource + try { + session = create({ prompt: idle.input, options: controlSessionOptions(input) }) + } catch (error) { + idle.stop() + throw error + } + + let startupError: string | null = null + const drained = (async () => { + try { + for await (const message of session) { + const msg = message as Record | null + if (msg?.type === 'result') startupError = resultErrorMessage(msg) ?? startupError + } + } catch { + // The session ending badly shows up as a failed request below. + } + })() + + let closed = false + const closeSession = () => { + if (closed) return + closed = true + idle.stop() + try { + session.close() + } catch { + // Already gone. + } + } + + let timer: ReturnType | undefined + try { + return await Promise.race([ + work({ rewindFiles: (id, options) => session.rewindFiles(id, options) }), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new RewindTimeoutError(timeoutMs)), timeoutMs) + }), + ]) + } catch (error) { + if (error instanceof RewindTimeoutError) throw error + // A CLI that could not start writes its reason as an error result just before it exits, + // which can land a moment after the request failed. Give the drain a moment to read it. + closeSession() + await Promise.race([drained, new Promise((resolve) => setTimeout(resolve, 1_000))]) + throw startupError ? new Error(startupError) : error + } finally { + clearTimeout(timer) + closeSession() + } +} diff --git a/src/routes/chat/[id]/+page.svelte b/src/routes/chat/[id]/+page.svelte index aba91264..dcc2d4f0 100644 --- a/src/routes/chat/[id]/+page.svelte +++ b/src/routes/chat/[id]/+page.svelte @@ -25,6 +25,8 @@ import PinnedTodoPanel from '$lib/chat/PinnedTodoPanel.svelte'; import type { TodoItem } from '$lib/engine/tool-result-details'; import MessageBubble from '$lib/chat/MessageBubble.svelte'; + import RewindPreviewDialog from '$lib/chat/RewindPreviewDialog.svelte'; + import { chooseFileRestore } from '$lib/chat/rewind-dialog.svelte'; import ChatErrorNotice from '$lib/chat/ChatErrorNotice.svelte'; import { shouldShowModelTag } from '$lib/chat/message-bubble-helpers'; import ToolCallCard from '$lib/chat/ToolCallCard.svelte'; @@ -1251,13 +1253,17 @@ if (runId && !streaming) attachToRun(runId); }); - async function handleEdit(messageId: string, content: string) { - if (streaming) return; + /** Resolves false when nothing was done — the user cancelled — so the editor stays open. */ + async function handleEdit(messageId: string, content: string): Promise { + if (streaming) return false; + // #24 — whether to restore the files the dropped replies changed. Null: cancelled. + const restore = await chooseFileRestore(messageId, 'edit'); + if (!restore) return false; try { - const result = await editMessage({ messageId, content }); + const result = await editMessage({ messageId, content, ...restore }); if (!result || result.success !== true) { setRecoverableError(result?.error ?? 'Unable to edit message', { kind: 'edit', messageId, content }, { action: 'handleEdit' }); - return; + return true; } clearRecoverableError(); @@ -1282,14 +1288,17 @@ { action: 'handleEdit', messageId } ); } + return true; } async function handleRegenerate() { if (!conversationId || streaming) return; const pivotId = lastUserMessageId; if (!pivotId) return; + const restore = await chooseFileRestore(pivotId, 'regenerate'); + if (!restore) return; try { - const result = await deleteMessagesAfter({ conversationId, messageId: pivotId }); + const result = await deleteMessagesAfter({ conversationId, messageId: pivotId, ...restore }); if (!result || result.success !== true) { setRecoverableError( result?.error ?? 'Unable to regenerate response', @@ -1778,6 +1787,7 @@ + diff --git a/src/routes/chat/[id]/stream/+server.ts b/src/routes/chat/[id]/stream/+server.ts index 8791875a..dee83b61 100644 --- a/src/routes/chat/[id]/stream/+server.ts +++ b/src/routes/chat/[id]/stream/+server.ts @@ -80,6 +80,7 @@ import { import { runWithResumeFallback, userTurnMessages, withTurnResume } from '$lib/engine/turn-input' import { supportsFileCheckpoints, turnPromptContent } from '$lib/chat/turn-plan' import { planTurn, recordTurnJoinInBackground } from '$lib/chat/turn-plan.server' +import { isConversationRewinding } from '$lib/chat/rewind.server' import { createAttachmentIo } from '$lib/engine/attachment-io.server' import { createChatRunHooks } from '$lib/hooks/chat-run-hooks.server' import { logger } from '$lib/observability/logger' @@ -114,6 +115,10 @@ export const POST: RequestHandler = async ({ request, locals }) => { if (liveRunId) { return json({ error: 'This conversation already has a turn in progress.', runId: liveRunId }, { status: 409 }) } + // #24 — a rewind restoring this conversation's files owns its session and workspace for a moment. + if (isConversationRewinding(body.conversationId)) { + return json({ error: 'Files are being restored in this conversation. Try again in a moment.' }, { status: 409 }) + } const currentSettings = await getOrCreateSettings(user.id) const { routedModel, reasoningEffort, modelSelection } = resolveModelConfig({ diff --git a/tests/chat.edit-regenerate.spec.ts b/tests/chat.edit-regenerate.spec.ts new file mode 100644 index 00000000..51b8705c --- /dev/null +++ b/tests/chat.edit-regenerate.spec.ts @@ -0,0 +1,156 @@ +import { randomUUID } from 'node:crypto' +import { mkdirSync, rmSync } from 'node:fs' +import { expect, test, type Page } from '@playwright/test' +import { + authenticateContext, + cleanupPrefixedRecords, + getActiveUserId, + getSql, + seedConversation, + uniquePrefix, + waitForHydration, +} from './helpers' +import { sse } from './chat-stream-script' +import { resolveWorkspaceRoot } from '../src/lib/workspace/workspace.server' + +/** + * Edit and Regenerate on the chat page, and the "also restore files" question they ask + * first (#24). + * + * The run itself is scripted in the browser, so no model is involved. What matters is what + * the page sends: never the placeholder word "regenerate" as a prompt — the server answers + * the (edited) row itself — and nothing at all when the user cancels. + * + * The restore question is only asked when the message has a file checkpoint. A restorable + * preview needs a real session on disk, which is `engine.rewind.spec.ts`'s job; here the + * checkpoint points at a session the CLI does not have, so the preview comes back blocked + * with the CLI's reason, which is the dialog's other face — it says why, and lets the user go + * on without restoring. + */ + +type Send = { content?: string; regenerate?: boolean } + +async function scriptStream(page: Page, conversationId: string) { + const sends: Send[] = [] + await page.route( + (url) => url.pathname === `/chat/${conversationId}/stream`, + (route) => { + sends.push(route.request().postDataJSON() as Send) + return route.fulfill({ + status: 200, + headers: { 'content-type': 'text/event-stream' }, + body: sse([{ id: 1, event: 'done', data: { error: 'scripted end' } }]), + }) + }, + ) + return sends +} + +async function rows(conversationId: string) { + return getSql()<{ role: string; content: string }[]>` + select role, content from messages where conversation_id = ${conversationId} order by sequence + ` +} + +async function open(page: Page, conversationId: string) { + await page.goto(`/chat/${conversationId}`, { waitUntil: 'domcontentloaded' }) + await waitForHydration(page) +} + +const visible = (page: Page, name: string) => page.getByRole('button', { name }).filter({ visible: true }).first() + +test('with no file checkpoint, Regenerate goes straight on and sends no prompt of its own', async ({ page }) => { + test.setTimeout(90_000) + const prefix = uniquePrefix('chat-regen-plain') + await cleanupPrefixedRecords(prefix) + await authenticateContext(page.context()) + const conversation = await seedConversation(prefix, { + userId: await getActiveUserId(), + userMessage: `${prefix} What is 2+2?`, + assistantMessage: `${prefix} 4`, + }) + const sends = await scriptStream(page, conversation.id) + + try { + await open(page, conversation.id) + await visible(page, 'Regenerate response').click() + + await expect.poll(() => sends.length, { timeout: 30_000 }).toBe(1) + expect(sends[0]).toMatchObject({ regenerate: true, content: '' }) + await expect(page.getByTestId('rewind-dialog')).toHaveCount(0) + expect(await rows(conversation.id)).toEqual([{ role: 'user', content: `${prefix} What is 2+2?` }]) + } finally { + await page.unrouteAll({ behavior: 'ignoreErrors' }) + await cleanupPrefixedRecords(prefix) + } +}) + +test('a checkpoint that cannot be used: the dialog says why, Cancel keeps the edit, Continue goes on without restoring', async ({ + page, +}) => { + test.setTimeout(120_000) + const prefix = uniquePrefix('chat-edit-restore') + await cleanupPrefixedRecords(prefix) + await authenticateContext(page.context()) + const userId = await getActiveUserId() + const conversation = await seedConversation(prefix, { + userId, + userMessage: `${prefix} What is 2+2?`, + assistantMessage: `${prefix} 4`, + }) + // A workspace inside this user's sandbox, resolved the way the server resolves it. + const cwd = resolveWorkspaceRoot({ userId, projectId: randomUUID(), sandboxRoot: process.env.SANDBOX_WORKSPACE }) + mkdirSync(cwd, { recursive: true }) + const sessionId = randomUUID() + const sql = getSql() + await sql`update conversations set sdk_session_id = ${sessionId} where id = ${conversation.id}` + await sql` + update messages set metadata = ${sql.json({ sdkTurn: { uuid: randomUUID(), sessionId, cwd, checkpointed: true } } as never)} + where conversation_id = ${conversation.id} and role = 'user' + ` + const sends = await scriptStream(page, conversation.id) + const before = await rows(conversation.id) + + try { + await open(page, conversation.id) + await visible(page, 'Edit message').click() + const editor = page.locator('textarea.console-msg__edit-ta').filter({ visible: true }) + await editor.fill(`${prefix} What is 3+3?`) + await visible(page, 'Save & regenerate').click() + + // The CLI has no such session, so the preview comes back with its reason. + const dialog = page.getByTestId('rewind-dialog') + await expect(dialog).toBeVisible({ timeout: 60_000 }) + await expect(dialog).toContainText("The files can't be restored for this message") + await expect(dialog.getByTestId('rewind-restore')).toHaveCount(0) + // Fits a phone: the whole box and its buttons stay on screen. + const box = await dialog.locator('.modal-box').boundingBox() + const viewport = page.viewportSize()! + expect(box!.x).toBeGreaterThanOrEqual(0) + expect(box!.x + box!.width).toBeLessThanOrEqual(viewport.width + 1) + await expect(dialog.getByTestId('rewind-continue')).toBeInViewport() + + // Cancel: nothing happens, and the edit is still there to try again. + await dialog.getByRole('button', { name: 'Cancel' }).click() + await expect(dialog).toHaveCount(0) + await expect(editor).toHaveValue(`${prefix} What is 3+3?`) + expect(sends).toEqual([]) + expect(await rows(conversation.id)).toEqual(before) + + await visible(page, 'Save & regenerate').click() + await expect(dialog).toBeVisible({ timeout: 60_000 }) + await dialog.getByTestId('rewind-continue').click() + + await expect.poll(() => sends.length, { timeout: 30_000 }).toBe(1) + expect(sends[0]).toMatchObject({ regenerate: true, content: '' }) + expect(await rows(conversation.id)).toEqual([{ role: 'user', content: `${prefix} What is 3+3?` }]) + } finally { + await page.unrouteAll({ behavior: 'ignoreErrors' }) + await cleanupPrefixedRecords(prefix) + try { + rmSync(cwd, { recursive: true, force: true }) + } catch { + // Windows keeps it while the CLI the preview started is still exiting; it is empty. + } + } +}) diff --git a/tests/chat.regenerate-fork.spec.ts b/tests/chat.regenerate-fork.spec.ts new file mode 100644 index 00000000..0ec18353 --- /dev/null +++ b/tests/chat.regenerate-fork.spec.ts @@ -0,0 +1,547 @@ +import { randomUUID } from 'node:crypto' +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, test } from '@playwright/test' +import { cleanupPrefixedRecords, getActiveUserId, getSql, uniquePrefix } from './helpers' +import { planTurn, recordTurnJoin } from '../src/lib/chat/turn-plan.server' +import { readTurnJoin } from '../src/lib/chat/turn-plan' +import { editUserMessage, truncateAfterMessage } from '../src/lib/chat/message-branch.server' +import { applyMessageRewind, previewMessageRewind, type RewindDeps } from '../src/lib/chat/rewind.server' +import { claimRun } from '../src/lib/engine/run-registry.server' +import type { CreateRewindQuery, RewindFilesResult } from '../src/lib/engine/rewind.server' + +/** + * Edit, regenerate and "also restore files" against the database (#24, and the + * edit/regenerate fix). The pure halves are `chat.turn-plan.spec.ts` and + * `engine.rewind.spec.ts`; these pin what the rows say and what the modules do with them. + * + * No model and no CLI: the rewind's control session is a stub (`createQuery`), and git is + * a stub (`uncommittedPaths`). + */ + +type Row = { + role: 'user' | 'assistant' | 'system' + content: string + metadata?: Record + attachments?: unknown[] +} + +async function seedThread(prefix: string, rows: Row[], opts: { sdkSessionId?: string | null; projectId?: string } = {}) { + const sql = getSql() + const userId = await getActiveUserId() + const [conversation] = await sql<{ id: string }[]>` + insert into conversations (user_id, title, model, total_tokens, total_cost, sdk_session_id, project_id) + values (${userId}, ${`${prefix} thread`}, ${'anthropic/claude-sonnet-4'}, 0, '0', ${opts.sdkSessionId ?? null}, ${opts.projectId ?? null}) + returning id + ` + const ids: string[] = [] + for (const [i, row] of rows.entries()) { + const [inserted] = await sql<{ id: string }[]>` + insert into messages (conversation_id, role, content, metadata, tool_calls, attachments, sequence) + values ( + ${conversation.id}, ${row.role}, ${row.content}, + ${sql.json((row.metadata ?? {}) as never)}, '[]'::jsonb, ${sql.json((row.attachments ?? []) as never)}, + ${i + 1} + ) + returning id + ` + ids.push(inserted.id) + } + return { userId, conversationId: conversation.id, ids } +} + +async function rows(conversationId: string) { + return getSql()<{ id: string; role: string; content: string; metadata: Record }[]>` + select id, role, content, metadata from messages where conversation_id = ${conversationId} order by sequence + ` +} + +/** A control session that answers from a script and records what it was asked. */ +function stubControl(answer: (id: string, dryRun: boolean) => Promise | RewindFilesResult) { + const calls: Array<{ id: string; dryRun: boolean }> = [] + let closed = 0 + const createQuery: CreateRewindQuery = () => ({ + async *[Symbol.asyncIterator]() {}, + rewindFiles: async (id, options) => { + calls.push({ id, dryRun: options?.dryRun === true }) + return answer(id, options?.dryRun === true) + }, + close: () => { + closed += 1 + }, + }) + return { createQuery, calls, closed: () => closed } +} + +const SESSION = 's-current' + +test.describe('planTurn: what an edited or regenerated turn sends', () => { + test('the edited row’s own text and attachments, cut back to the reply before it', async () => { + const prefix = uniquePrefix('regen-fork-plan') + await cleanupPrefixedRecords(prefix) + try { + const attachment = { id: randomUUID(), filename: 'plan.png', mimeType: 'image/png', size: 10, url: '/api/upload/x.png' } + const { conversationId, ids } = await seedThread( + prefix, + [ + { role: 'user', content: 'What is 2+2?' }, + { role: 'assistant', content: '4', metadata: { sdkSessionId: SESSION, sdkTailUuid: 'tail-1' } }, + { role: 'user', content: 'What is 3+3?', attachments: [attachment] }, + ], + { sdkSessionId: SESSION }, + ) + const planned = await planTurn({ + conversationId, + regenerate: true, + sdkSessionId: SESSION, + pivotMessageId: ids[2], + // What the page used to send. Never the prompt. + body: { content: 'regenerate' }, + }) + expect(planned.ok).toBe(true) + if (!planned.ok) return + expect(planned.turn.text).toBe('What is 3+3?') + expect(planned.turn.attachments).toEqual([attachment]) + expect(planned.turn.attempts.first).toMatchObject({ kind: 'fork', resumeSessionId: SESSION, resumeSessionAt: 'tail-1' }) + // If the CLI refuses the cut, the fallback carries exactly the kept history. + const preamble = planned.turn.attempts.fallback?.preamble ?? '' + expect(preamble).toContain('User: What is 2+2?') + expect(preamble).toContain('Assistant: 4') + expect(preamble).not.toContain('3+3') + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test('regenerating the first message starts a fresh session with nothing to carry', async () => { + const prefix = uniquePrefix('regen-fork-first') + await cleanupPrefixedRecords(prefix) + try { + const { conversationId, ids } = await seedThread(prefix, [{ role: 'user', content: 'hello there' }], { sdkSessionId: SESSION }) + const planned = await planTurn({ conversationId, regenerate: true, sdkSessionId: SESSION, pivotMessageId: ids[0], body: {} }) + expect(planned.ok && planned.turn.text).toBe('hello there') + expect(planned.ok && planned.turn.attempts.first).toMatchObject({ kind: 'fresh', preamble: null }) + expect(planned.ok && planned.turn.attempts.first).not.toHaveProperty('resumeSessionId') + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test('a reply from before the join, or from another session, gets the history as text instead', async () => { + const prefix = uniquePrefix('regen-fork-legacy') + await cleanupPrefixedRecords(prefix) + try { + const legacy = await seedThread( + prefix, + [ + { role: 'user', content: 'first' }, + { role: 'assistant', content: 'old reply', metadata: { sdkSessionId: SESSION } }, + { role: 'user', content: 'second' }, + ], + { sdkSessionId: SESSION }, + ) + const planned = await planTurn({ + conversationId: legacy.conversationId, + regenerate: true, + sdkSessionId: SESSION, + pivotMessageId: legacy.ids[2], + body: {}, + }) + expect(planned.ok && planned.turn.attempts.first.kind).toBe('fresh') + expect(planned.ok && planned.turn.attempts.first.preamble).toContain('Assistant: old reply') + expect(planned.ok && planned.turn.attempts.fallback).toBeNull() + + const moved = await seedThread( + prefix, + [ + { role: 'user', content: 'first' }, + { role: 'assistant', content: 'reply', metadata: { sdkSessionId: 's-earlier', sdkTailUuid: 't' } }, + { role: 'user', content: 'second' }, + ], + { sdkSessionId: SESSION }, + ) + const other = await planTurn({ + conversationId: moved.conversationId, + regenerate: true, + sdkSessionId: SESSION, + pivotMessageId: moved.ids[2], + body: {}, + }) + expect(other.ok && other.turn.attempts.first.kind).toBe('fresh') + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test('a new message sends the request’s text and resumes the session as it is', async () => { + const planned = await planTurn({ + conversationId: randomUUID(), + regenerate: false, + sdkSessionId: SESSION, + pivotMessageId: randomUUID(), + body: { content: 'next question' }, + }) + expect(planned.ok && planned.turn.text).toBe('next question') + expect(planned.ok && planned.turn.attempts.first).toMatchObject({ kind: 'continue', resumeSessionId: SESSION }) + }) + + test('refuses a regenerate with no user message to answer, or one from another conversation', async () => { + const prefix = uniquePrefix('regen-fork-refuse') + await cleanupPrefixedRecords(prefix) + try { + const a = await seedThread(prefix, [{ role: 'user', content: 'a' }]) + const b = await seedThread(prefix, [{ role: 'user', content: 'b' }]) + expect((await planTurn({ conversationId: a.conversationId, regenerate: true, sdkSessionId: null, pivotMessageId: null, body: {} })).ok).toBe(false) + expect( + (await planTurn({ conversationId: a.conversationId, regenerate: true, sdkSessionId: null, pivotMessageId: b.ids[0], body: {} })).ok, + ).toBe(false) + } finally { + await cleanupPrefixedRecords(prefix) + } + }) +}) + +test.describe('recordTurnJoin', () => { + test('stamps the user row, keeps its other metadata, and leaves assistant rows alone', async () => { + const prefix = uniquePrefix('regen-fork-join') + await cleanupPrefixedRecords(prefix) + try { + const { conversationId, ids } = await seedThread(prefix, [ + { role: 'user', content: 'q', metadata: { keep: 'me' } }, + { role: 'assistant', content: 'a' }, + ]) + const join = { uuid: randomUUID(), sessionId: SESSION, cwd: '/w', checkpointed: true } + await recordTurnJoin(ids[0], join) + await recordTurnJoin(ids[1], join) + const [user, assistant] = await rows(conversationId) + expect(user.metadata.keep).toBe('me') + expect(readTurnJoin(user.metadata)).toEqual(join) + expect(assistant.metadata).toEqual({}) + + // A regenerate replaces the join: the old uuid is on a branch the session left. + const again = { ...join, uuid: randomUUID(), checkpointed: false } + await recordTurnJoin(ids[0], again) + expect(readTurnJoin((await rows(conversationId))[0].metadata)).toEqual(again) + } finally { + await cleanupPrefixedRecords(prefix) + } + }) +}) + +test.describe('edit and regenerate, with and without restoring files', () => { + let sandboxRoot: string + test.beforeEach(() => { + sandboxRoot = mkdtempSync(join(tmpdir(), 'agentstudio-regen-fork-')) + }) + test.afterEach(() => { + rmSync(sandboxRoot, { recursive: true, force: true }) + }) + + /** A checkpointed thread whose last user message ran in `cwd`, inside the sandbox. */ + async function checkpointedThread(prefix: string, opts: { projectId?: string; cwd?: string } = {}) { + const userId = await getActiveUserId() + const cwd = opts.cwd ?? join(sandboxRoot, userId, 'projects', randomUUID()) + mkdirSync(cwd, { recursive: true }) + const uuid = randomUUID() + const seeded = await seedThread( + prefix, + [ + { role: 'user', content: 'first' }, + { role: 'assistant', content: 'reply one', metadata: { sdkSessionId: SESSION, sdkTailUuid: 'tail-1' } }, + { role: 'user', content: 'change the files', metadata: { sdkTurn: { uuid, sessionId: SESSION, cwd, checkpointed: true } } }, + { role: 'assistant', content: 'changed them', metadata: { sdkSessionId: SESSION, sdkTailUuid: 'tail-2' } }, + ], + { sdkSessionId: SESSION, projectId: opts.projectId }, + ) + return { ...seeded, cwd, uuid } + } + + test('an edit without restoring rewrites the row and drops what came after it', async () => { + const prefix = uniquePrefix('regen-fork-edit') + await cleanupPrefixedRecords(prefix) + try { + const { userId, conversationId, ids } = await checkpointedThread(prefix) + const result = await editUserMessage({ userId, messageId: ids[2], content: 'change them differently' }) + expect(result).toEqual({ success: true, conversationId, filesRestored: 0 }) + expect((await rows(conversationId)).map((row) => row.content)).toEqual(['first', 'reply one', 'change them differently']) + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test('someone else’s message, or an assistant row, is not editable', async () => { + const prefix = uniquePrefix('regen-fork-owner') + await cleanupPrefixedRecords(prefix) + try { + const { userId, conversationId, ids } = await checkpointedThread(prefix) + expect((await editUserMessage({ userId: randomUUID(), messageId: ids[2], content: 'x' })).success).toBe(false) + expect((await editUserMessage({ userId, messageId: ids[1], content: 'x' })).success).toBe(false) + expect( + (await truncateAfterMessage({ userId: randomUUID(), conversationId, messageId: ids[2] })).success, + ).toBe(false) + expect((await rows(conversationId))).toHaveLength(4) + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test('restoring files runs the dry run, then the restore, and only then changes the rows', async () => { + const prefix = uniquePrefix('regen-fork-restore') + await cleanupPrefixedRecords(prefix) + try { + const { userId, conversationId, ids, cwd, uuid } = await checkpointedThread(prefix) + let rowsAtRestore = -1 + const control = stubControl(async (_id, dryRun) => { + if (!dryRun) rowsAtRestore = (await rows(conversationId)).length + return { canRewind: true, filesChanged: [join(cwd, 'notes.md')], insertions: 1, deletions: 4 } + }) + const deps: RewindDeps = { createQuery: control.createQuery, sandboxRoot } + + const result = await editUserMessage({ userId, messageId: ids[2], content: 'edited', restoreFiles: true }, deps) + expect(result).toEqual({ success: true, conversationId, filesRestored: 1 }) + expect(control.calls).toEqual([ + { id: uuid, dryRun: true }, + { id: uuid, dryRun: false }, + ]) + expect(rowsAtRestore).toBe(4) + expect(control.closed()).toBeGreaterThanOrEqual(1) + expect((await rows(conversationId)).map((row) => row.content)).toEqual(['first', 'reply one', 'edited']) + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test('a restore that fails leaves the conversation exactly as it was', async () => { + const prefix = uniquePrefix('regen-fork-restore-fails') + await cleanupPrefixedRecords(prefix) + try { + const { userId, conversationId, ids, cwd } = await checkpointedThread(prefix) + const control = stubControl((_id, dryRun) => { + if (dryRun) return { canRewind: true, filesChanged: [join(cwd, 'a.txt')] } + throw new Error('control request failed') + }) + const before = await rows(conversationId) + + const regenerate = await truncateAfterMessage( + { userId, conversationId, messageId: ids[2], restoreFiles: true }, + { createQuery: control.createQuery, sandboxRoot }, + ) + expect(regenerate).toMatchObject({ success: false, rewindFailed: true }) + expect(regenerate.success === false && regenerate.error).toContain('The conversation was not changed') + expect(await rows(conversationId)).toEqual(before) + + // No checkpoint at all (the first message has no join): refused the same way. + const edit = await editUserMessage( + { userId, messageId: ids[0], content: 'x', restoreFiles: true }, + { createQuery: control.createQuery, sandboxRoot }, + ) + expect(edit).toMatchObject({ success: false, rewindFailed: true }) + expect(await rows(conversationId)).toEqual(before) + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test('nothing changes while a reply is still being written', async () => { + const prefix = uniquePrefix('regen-fork-live') + await cleanupPrefixedRecords(prefix) + const sql = getSql() + let release = () => {} + try { + const { userId, conversationId, ids } = await checkpointedThread(prefix) + const [run] = await sql<{ id: string }[]>` + insert into chat_runs (conversation_id, user_id, state, source, label, started_at) + values (${conversationId}, ${userId}, 'running', 'chat_stream', ${`${prefix} run`}, now()) + returning id + ` + release = claimRun(run.id) + const control = stubControl(() => ({ canRewind: true, filesChanged: [] })) + + const edit = await editUserMessage({ userId, messageId: ids[2], content: 'x' }, { createQuery: control.createQuery, sandboxRoot }) + expect(edit).toMatchObject({ success: false, error: expect.stringContaining('still being written') }) + const preview = await previewMessageRewind({ userId, messageId: ids[2] }, { createQuery: control.createQuery, sandboxRoot }) + expect(preview).toMatchObject({ available: true, canRewind: false, reason: expect.stringContaining('still being written') }) + expect(control.calls).toEqual([]) + expect(await rows(conversationId)).toHaveLength(4) + } finally { + release() + await sql`delete from chat_runs where label like ${`${prefix}%`}` + await cleanupPrefixedRecords(prefix) + } + }) +}) + +test.describe('previewMessageRewind and applyMessageRewind', () => { + let sandboxRoot: string + test.beforeEach(() => { + sandboxRoot = mkdtempSync(join(tmpdir(), 'agentstudio-rewind-preview-')) + }) + test.afterEach(() => { + rmSync(sandboxRoot, { recursive: true, force: true }) + }) + + async function threadWithJoin(prefix: string, join: Record | null, opts: { projectId?: string } = {}) { + return seedThread( + prefix, + [ + { role: 'user', content: 'change the files', metadata: join ? { sdkTurn: join } : {} }, + { role: 'assistant', content: 'done', metadata: { sdkSessionId: SESSION, sdkTailUuid: 't' } }, + ], + { sdkSessionId: SESSION, projectId: opts.projectId }, + ) + } + + test('the option is hidden when there is no checkpoint to go back to', async () => { + const prefix = uniquePrefix('rewind-preview-hidden') + await cleanupPrefixedRecords(prefix) + try { + const userId = await getActiveUserId() + const inside = join(sandboxRoot, userId, 'projects', randomUUID()) + mkdirSync(inside, { recursive: true }) + const control = stubControl(() => ({ canRewind: true, filesChanged: [join(inside, 'a.txt')] })) + const deps = { createQuery: control.createQuery, sandboxRoot } + const base = { uuid: randomUUID(), sessionId: SESSION, cwd: inside, checkpointed: true } + + const cases = [ + // A chat with no project, or a message from before this shipped. + null, + { ...base, checkpointed: false }, + // A checkpoint from an earlier session of the conversation. + { ...base, sessionId: 's-earlier' }, + // A working directory outside the caller's sandbox, or one that is gone. + { ...base, cwd: join(tmpdir(), 'elsewhere') }, + { ...base, cwd: join(sandboxRoot, userId, 'projects', randomUUID()) }, + ] + for (const join_ of cases) { + const { ids } = await threadWithJoin(prefix, join_) + const preview = await previewMessageRewind({ userId, messageId: ids[0] }, deps) + expect(preview.available).toBe(false) + expect((await applyMessageRewind({ userId, messageId: ids[0] }, deps)).ok).toBe(false) + } + // Someone else's message looks exactly like one with nothing to restore. + const { ids } = await threadWithJoin(prefix, base) + expect((await previewMessageRewind({ userId: randomUUID(), messageId: ids[0] }, deps)).available).toBe(false) + expect(control.calls).toEqual([]) + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test('an imported repository with uncommitted changes needs an explicit yes before anything is overwritten', async () => { + const prefix = uniquePrefix('rewind-preview-imported') + await cleanupPrefixedRecords(prefix) + const sql = getSql() + let projectId = '' + try { + const userId = await getActiveUserId() + const [project] = await sql<{ id: string }[]>` + insert into projects (user_id, name, slug, kind, repo_kind) + values (${userId}, ${`${prefix} repo`}, ${`regen-fork-${randomUUID().slice(0, 8)}`}, 'code'::project_kind, 'imported') + returning id + ` + projectId = project.id + const checkout = join(sandboxRoot, userId, 'projects', projectId) + mkdirSync(checkout, { recursive: true }) + await sql`update projects set repo_local_path = ${checkout} where id = ${projectId}` + + const uuid = randomUUID() + const { ids } = await threadWithJoin(prefix, { uuid, sessionId: SESSION, cwd: checkout, checkpointed: true }, { projectId }) + const control = stubControl(() => ({ + canRewind: true, + filesChanged: [join(checkout, 'src', 'app.ts'), join(checkout, 'new', 'file.ts')], + insertions: 2, + deletions: 9, + })) + const gitAsked: string[] = [] + const deps: RewindDeps = { + createQuery: control.createQuery, + sandboxRoot, + uncommittedPaths: async (repo) => { + gitAsked.push(repo) + // `new/` is how git lists a whole new directory. + return new Set(['src/app.ts', 'new/']) + }, + } + + const preview = await previewMessageRewind({ userId, messageId: ids[0] }, deps) + expect(preview).toMatchObject({ + available: true, + canRewind: true, + repoKind: 'imported', + requiresAcknowledge: true, + insertions: 2, + deletions: 9, + }) + expect(preview.files).toEqual([ + { path: 'new/file.ts', uncommitted: true }, + { path: 'src/app.ts', uncommitted: true }, + ]) + expect(gitAsked).toEqual([checkout]) + + // The server checks again: no acknowledgement, no restore. + const refused = await applyMessageRewind({ userId, messageId: ids[0] }, deps) + expect(refused).toMatchObject({ ok: false, error: expect.stringContaining('uncommitted changes') }) + expect(control.calls.filter((call) => !call.dryRun)).toEqual([]) + + const done = await applyMessageRewind({ userId, messageId: ids[0], acknowledgeUncommitted: true }, deps) + expect(done).toEqual({ ok: true, filesRestored: 2, skippedLinks: 0 }) + expect(control.calls.at(-1)).toEqual({ id: uuid, dryRun: false }) + } finally { + await cleanupPrefixedRecords(prefix) + if (projectId) await sql`delete from projects where id = ${projectId}` + } + }) + + test('a file outside the workspace blocks the restore, and the CLI’s refusal reads as plain words', async () => { + const prefix = uniquePrefix('rewind-preview-refusals') + await cleanupPrefixedRecords(prefix) + try { + const userId = await getActiveUserId() + const cwd = join(sandboxRoot, userId, 'persistent', 'agent-key') + mkdirSync(cwd, { recursive: true }) + const { ids } = await threadWithJoin(prefix, { uuid: randomUUID(), sessionId: SESSION, cwd, checkpointed: true }) + + const outside = stubControl(() => ({ canRewind: true, filesChanged: [join(cwd, 'a.txt'), join(sandboxRoot, 'secret.txt')] })) + const preview = await previewMessageRewind({ userId, messageId: ids[0] }, { createQuery: outside.createQuery, sandboxRoot }) + expect(preview).toMatchObject({ canRewind: false, outsideWorkspace: [join(sandboxRoot, 'secret.txt')] }) + const applied = await applyMessageRewind({ userId, messageId: ids[0] }, { createQuery: outside.createQuery, sandboxRoot }) + expect(applied.ok).toBe(false) + expect(outside.calls.every((call) => call.dryRun)).toBe(true) + + const expired = stubControl(() => ({ canRewind: false, error: 'No file checkpoint found for this message.' })) + const gone = await previewMessageRewind({ userId, messageId: ids[0] }, { createQuery: expired.createQuery, sandboxRoot }) + expect(gone).toMatchObject({ available: true, canRewind: false }) + expect(gone.reason).toContain('no saved copy of the files') + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test('one rewind per conversation at a time', async () => { + const prefix = uniquePrefix('rewind-preview-busy') + await cleanupPrefixedRecords(prefix) + try { + const userId = await getActiveUserId() + const cwd = join(sandboxRoot, userId, 'projects', randomUUID()) + mkdirSync(cwd, { recursive: true }) + const { ids } = await threadWithJoin(prefix, { uuid: randomUUID(), sessionId: SESSION, cwd, checkpointed: true }) + let letGo: () => void = () => {} + const held = new Promise((resolve) => (letGo = resolve)) + const slow = stubControl(async () => { + await held + return { canRewind: true, filesChanged: [join(cwd, 'a.txt')] } + }) + const deps = { createQuery: slow.createQuery, sandboxRoot } + + const first = previewMessageRewind({ userId, messageId: ids[0] }, deps) + await expect.poll(() => slow.calls.length).toBe(1) + const second = await previewMessageRewind({ userId, messageId: ids[0] }, deps) + expect(second).toMatchObject({ canRewind: false, reason: expect.stringContaining('already being restored') }) + letGo() + expect((await first).canRewind).toBe(true) + } finally { + await cleanupPrefixedRecords(prefix) + } + }) +}) diff --git a/tests/engine.rewind.spec.ts b/tests/engine.rewind.spec.ts new file mode 100644 index 00000000..2188475b --- /dev/null +++ b/tests/engine.rewind.spec.ts @@ -0,0 +1,422 @@ +import { expect, test } from '@playwright/test' +import { randomUUID } from 'node:crypto' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + controlSessionOptions, + RewindTimeoutError, + withControlSession, + type CreateRewindQuery, + type RewindControlSource, + type RewindFilesResult, +} from '../src/lib/engine/rewind.server' +import { buildEngineEnv } from '../src/lib/engine/engine-env' +import { + describeRewindRefusal, + mapRewindPreview, + parseUncommittedPaths, + uncommittedStatusArgs, + workspaceRelative, +} from '../src/lib/chat/rewind-plan' +import { + blockedPreview, + canContinueRestore, + restoreByDefault, + shouldOfferRestore, + unavailablePreview, +} from '../src/lib/chat/rewind-preview' +import { syntheticTranscript, writeSyntheticSession } from './sdk-transcript-fixture' + +/** + * #24 — the rewind control session and the preview it feeds. + * + * The first groups drive `withControlSession` through its SDK seam with a stub, and pin the + * pure mapping. The last group runs the REAL bundled CLI against a synthetic session on disk + * (`./sdk-transcript-fixture`): no credentials and no model call, because a control session + * never sends a prompt. That group is what proves the design rather than assuming it — an + * idle resumed session really answers `rewindFiles`, from the file history it loaded at + * start-up. + */ + +type StubCalls = { rewind: Array<{ id: string; dryRun: boolean }>; closed: number; prompts: AsyncIterable[] } + +function stubQuery( + answer: (id: string, dryRun: boolean) => Promise, + messages: unknown[] = [], +): { create: CreateRewindQuery; calls: StubCalls } { + const calls: StubCalls = { rewind: [], closed: 0, prompts: [] } + const create: CreateRewindQuery = ({ prompt }) => { + calls.prompts.push(prompt) + const source: RewindControlSource = { + async *[Symbol.asyncIterator]() { + for (const message of messages) yield message + }, + rewindFiles: (id, options) => { + calls.rewind.push({ id, dryRun: options?.dryRun === true }) + return answer(id, options?.dryRun === true) + }, + close: () => { + calls.closed += 1 + }, + } + return source + } + return { create, calls } +} + +test.describe('controlSessionOptions', () => { + test('a session that can only answer control requests', async () => { + const options = controlSessionOptions({ sessionId: 's1', cwd: '/sandbox/u/projects/p' }) + expect(options).toMatchObject({ + resume: 's1', + cwd: '/sandbox/u/projects/p', + enableFileCheckpointing: true, + settingSources: [], + tools: [], + mcpServers: {}, + strictMcpConfig: true, + maxTurns: 1, + }) + const decision = await options.canUseTool!('Write', {}, { signal: new AbortController().signal, toolUseID: 't' } as never) + expect(decision?.behavior).toBe('deny') + }) + + test('gets the engine’s allow-listed environment, never the server’s', () => { + const previous = process.env.DATABASE_URL + process.env.DATABASE_URL = 'postgres://secret' + try { + const options = controlSessionOptions({ sessionId: 's1', cwd: '/w' }) + expect(options.env).not.toHaveProperty('DATABASE_URL') + } finally { + if (previous === undefined) delete process.env.DATABASE_URL + else process.env.DATABASE_URL = previous + } + }) +}) + +test.describe('withControlSession', () => { + test('never sends a prompt, and closes the session after the work', async () => { + const { create, calls } = stubQuery(async () => ({ canRewind: true, filesChanged: [] })) + const result = await withControlSession({ sessionId: 's', cwd: '/w', createQuery: create }, (control) => + control.rewindFiles('u1', { dryRun: true }), + ) + expect(result.canRewind).toBe(true) + expect(calls.rewind).toEqual([{ id: 'u1', dryRun: true }]) + expect(calls.closed).toBeGreaterThanOrEqual(1) + + // The input yields nothing — no user message, so no model call — and ends once closed. + const iterator = calls.prompts[0][Symbol.asyncIterator]() + expect(await iterator.next()).toEqual({ value: undefined, done: true }) + }) + + test('closes on failure', async () => { + const { create, calls } = stubQuery(async () => { + throw new Error('control request failed') + }) + await expect( + withControlSession({ sessionId: 's', cwd: '/w', createQuery: create }, (control) => control.rewindFiles('u1')), + ).rejects.toThrow('control request failed') + expect(calls.closed).toBeGreaterThanOrEqual(1) + }) + + test('gives up and closes when the CLI does not answer', async () => { + const { create, calls } = stubQuery(() => new Promise(() => {})) + await expect( + withControlSession({ sessionId: 's', cwd: '/w', createQuery: create, timeoutMs: 50 }, (control) => + control.rewindFiles('u1', { dryRun: true }), + ), + ).rejects.toBeInstanceOf(RewindTimeoutError) + expect(calls.closed).toBeGreaterThanOrEqual(1) + }) + + test('a CLI that could not start reports its own reason, not the transport’s', async () => { + const { create } = stubQuery( + async () => { + throw new Error('Claude Code process exited with code 1') + }, + [{ type: 'result', subtype: 'error_during_execution', is_error: true, errors: ['No conversation found with session ID: s'] }], + ) + await expect( + withControlSession({ sessionId: 's', cwd: '/w', createQuery: create }, (control) => control.rewindFiles('u1')), + ).rejects.toThrow('No conversation found with session ID: s') + }) +}) + +test.describe('mapRewindPreview', () => { + const root = join(tmpdir(), 'ws-root') + + test('lists files relative to the workspace, sorted, with their uncommitted flag', () => { + const preview = mapRewindPreview({ + result: { + canRewind: true, + filesChanged: [join(root, 'src', 'b.ts'), join(root, 'a.txt')], + insertions: 3, + deletions: 7, + }, + workspaceRoot: root, + uncommittedPaths: new Set(['src/b.ts']), + repoKind: 'local', + }) + expect(preview).toMatchObject({ + available: true, + canRewind: true, + reason: null, + insertions: 3, + deletions: 7, + outsideWorkspace: [], + repoKind: 'local', + // Only an imported repository needs the extra confirmation. + requiresAcknowledge: false, + }) + expect(preview.files).toEqual([ + { path: 'a.txt', uncommitted: false }, + { path: 'src/b.ts', uncommitted: true }, + ]) + }) + + test('an imported repository with uncommitted changes in a restored file needs an explicit yes', () => { + const result = { canRewind: true, filesChanged: [join(root, 'a.txt')] } + expect( + mapRewindPreview({ result, workspaceRoot: root, uncommittedPaths: new Set(['a.txt']), repoKind: 'imported' }) + .requiresAcknowledge, + ).toBe(true) + expect( + mapRewindPreview({ result, workspaceRoot: root, uncommittedPaths: new Set(), repoKind: 'imported' }).requiresAcknowledge, + ).toBe(false) + expect( + mapRewindPreview({ result, workspaceRoot: root, uncommittedPaths: null, repoKind: null }).requiresAcknowledge, + ).toBe(false) + }) + + test('a path outside the workspace blocks the whole restore', () => { + const outside = join(tmpdir(), 'elsewhere', 'secret.txt') + const preview = mapRewindPreview({ + result: { canRewind: true, filesChanged: [join(root, 'a.txt'), outside] }, + workspaceRoot: root, + uncommittedPaths: null, + repoKind: null, + }) + expect(preview.canRewind).toBe(false) + expect(preview.outsideWorkspace).toEqual([outside]) + expect(preview.reason).toContain('outside the workspace') + }) + + test('the CLI’s refusal is passed on, and nothing to restore is not a restore', () => { + const refused = mapRewindPreview({ + result: { canRewind: false, error: 'No file checkpoint found for this message.' }, + workspaceRoot: root, + uncommittedPaths: null, + repoKind: null, + }) + // In the dialog's words, not the CLI's: this is what a compaction or an expired copy looks like. + expect(refused).toMatchObject({ available: true, canRewind: false }) + expect(refused.reason).toMatch(/^There is no saved copy of the files from before this message/) + + const nothing = mapRewindPreview({ + result: { canRewind: true, filesChanged: [] }, + workspaceRoot: root, + uncommittedPaths: null, + repoKind: null, + }) + expect(nothing).toMatchObject({ available: true, canRewind: false, reason: null, files: [] }) + }) + + test('a file in a directory git lists as new counts as uncommitted', () => { + const preview = mapRewindPreview({ + result: { canRewind: true, filesChanged: [join(root, 'fresh', 'deep', 'x.ts'), join(root, 'freshly.ts')] }, + workspaceRoot: root, + uncommittedPaths: new Set(['fresh/']), + repoKind: 'imported', + }) + expect(preview.files).toEqual([ + { path: 'fresh/deep/x.ts', uncommitted: true }, + { path: 'freshly.ts', uncommitted: false }, + ]) + }) + + test('workspaceRelative refuses a sibling that merely shares the prefix', () => { + expect(workspaceRelative(root, join(root, 'x', 'y.txt'))).toBe('x/y.txt') + expect(workspaceRelative(root, `${root}-other${join('/', 'y.txt')}`)).toBeNull() + expect(workspaceRelative(root, join(root, '..', 'y.txt'))).toBeNull() + expect(workspaceRelative(root, root)).toBeNull() + }) +}) + +test.describe('what git says is uncommitted', () => { + test('asks for every file by name, one path per entry, NUL-separated', () => { + expect(uncommittedStatusArgs('/repo')).toEqual([ + '-C', + '/repo', + 'status', + '--porcelain=v1', + '-z', + '--untracked-files=all', + '--no-renames', + ]) + }) + + test('reads modified, staged, deleted and untracked files, spaces and all', () => { + const stdout = [' M src/app.ts', 'M staged.ts', ' D gone.ts', '?? notes/new file.md', 'A added.ts', ''].join('\0') + expect([...parseUncommittedPaths(stdout)].sort()).toEqual(['added.ts', 'gone.ts', 'notes/new file.md', 'src/app.ts', 'staged.ts']) + expect(parseUncommittedPaths('').size).toBe(0) + }) +}) + +test.describe('describeRewindRefusal', () => { + test('puts the CLI’s refusals in plain words and passes anything else through', () => { + expect(describeRewindRefusal('No file checkpoint found for this message.')).toContain('no saved copy of the files') + expect(describeRewindRefusal('File rewinding is not enabled.')).toBe('File checkpoints were not turned on for this message.') + expect(describeRewindRefusal('Something new')).toBe('Something new') + expect(describeRewindRefusal(undefined)).toBe('These files cannot be restored.') + }) +}) + +test.describe('the dialog’s decisions', () => { + const restorable = mapRewindPreview({ + result: { canRewind: true, filesChanged: [join(tmpdir(), 'w', 'a.txt')] }, + workspaceRoot: join(tmpdir(), 'w'), + uncommittedPaths: new Set(['a.txt']), + repoKind: 'imported', + }) + + test('the option is hidden when there is nothing to restore', () => { + expect(shouldOfferRestore(unavailablePreview())).toBe(false) + expect(shouldOfferRestore(unavailablePreview('from an earlier session'))).toBe(false) + expect( + shouldOfferRestore( + mapRewindPreview({ result: { canRewind: true, filesChanged: [] }, workspaceRoot: '/w', uncommittedPaths: null, repoKind: null }), + ), + ).toBe(false) + }) + + test('shown when a restore is possible, or when a checkpoint exists but cannot be used', () => { + expect(shouldOfferRestore(restorable)).toBe(true) + expect(shouldOfferRestore(blockedPreview('A reply is still being written.'))).toBe(true) + }) + + test('"also restore files" defaults on exactly when there is something to restore', () => { + expect(restoreByDefault(restorable)).toBe(true) + expect(restoreByDefault(blockedPreview('no'))).toBe(false) + }) + + test('Continue waits for the explicit overwrite when an imported repo has uncommitted changes', () => { + expect(canContinueRestore(restorable, { restore: true, acknowledge: false })).toBe(false) + expect(canContinueRestore(restorable, { restore: true, acknowledge: true })).toBe(true) + expect(canContinueRestore(restorable, { restore: false, acknowledge: false })).toBe(true) + expect(canContinueRestore(blockedPreview('no'), { restore: true, acknowledge: true })).toBe(false) + }) +}) + +test.describe('against the bundled CLI (no credentials, no model call)', () => { + test.setTimeout(120_000) + + let base: string + let configDir: string + let cwd: string + let env: Record + + test.beforeEach(() => { + base = mkdtempSync(join(tmpdir(), 'agentstudio-rewind-')) + configDir = join(base, 'config') + cwd = join(base, 'workspace') + mkdirSync(cwd, { recursive: true }) + env = buildEngineEnv(process.env, { CLAUDE_CONFIG_DIR: configDir }) + delete env.CLAUDE_CODE_OAUTH_TOKEN + }) + + test.afterEach(async () => { + // `close()` does not wait for the CLI to exit, and Windows will not delete a directory + // that is still some process's working directory. Give it a moment; a stray temp + // directory is not worth failing a test over. + for (let attempt = 0; attempt < 20; attempt++) { + try { + rmSync(base, { recursive: true, force: true }) + return + } catch { + await new Promise((resolve) => setTimeout(resolve, 250)) + } + } + }) + + test('an idle resumed session previews and restores: edits undone, created files removed', async () => { + const sessionId = randomUUID() + const [u1, a1, u2, a2] = [randomUUID(), randomUUID(), randomUUID(), randomUUID()] + const t = syntheticTranscript({ sessionId, cwd }) + writeSyntheticSession({ + configDir, + cwd, + sessionId, + backups: { '0123456789abcdef@v1': 'original\n' }, + entries: [ + t.user(u1, null), + t.snapshot(u1, { 'notes.txt': '0123456789abcdef@v1' }), + t.assistant(a1, u1), + t.user(u2, a1), + t.snapshot(u2, { 'notes.txt': '0123456789abcdef@v1', 'created.txt': null }), + t.assistant(a2, u2), + ], + }) + // What the dropped turns left on disk. + writeFileSync(join(cwd, 'notes.txt'), 'edited by the agent\nand again\n') + writeFileSync(join(cwd, 'created.txt'), 'new\n') + + const outcome = await withControlSession({ sessionId, cwd, env }, async (control) => { + const dry = await control.rewindFiles(u2, { dryRun: true }) + // A dry run changes nothing. + expect(readFileSync(join(cwd, 'notes.txt'), 'utf8')).toBe('edited by the agent\nand again\n') + const missing = await control.rewindFiles(randomUUID(), { dryRun: true }) + const real = await control.rewindFiles(u2) + return { dry, missing, real } + }) + + const preview = mapRewindPreview({ result: outcome.dry, workspaceRoot: cwd, uncommittedPaths: null, repoKind: null }) + expect(preview.canRewind).toBe(true) + expect(preview.files.map((file) => file.path)).toEqual(['created.txt', 'notes.txt']) + expect(outcome.missing).toEqual({ canRewind: false, error: 'No file checkpoint found for this message.' }) + expect(outcome.real.canRewind).toBe(true) + expect(outcome.real.skippedLinks ?? 0).toBe(0) + + expect(readFileSync(join(cwd, 'notes.txt'), 'utf8')).toBe('original\n') + expect(existsSync(join(cwd, 'created.txt'))).toBe(false) + }) + + test('after an edit branches the session, a plain resume follows the new branch', async () => { + const sessionId = randomUUID() + const [u1, a1, u2, a2, u2b, a2b] = Array.from({ length: 6 }, () => randomUUID()) + const t = syntheticTranscript({ sessionId, cwd }) + const tracked = { 'notes.txt': '0123456789abcdef@v1' } + writeSyntheticSession({ + configDir, + cwd, + sessionId, + backups: { '0123456789abcdef@v1': 'original\n' }, + entries: [ + t.user(u1, null), + t.snapshot(u1, {}), + t.assistant(a1, u1), + t.user(u2, a1), + t.snapshot(u2, tracked), + t.assistant(a2, u2), + // What `resumeSessionAt: a1` plus a new prompt appends. + t.user(u2b, a1), + t.snapshot(u2b, tracked), + t.assistant(a2b, u2b), + ], + }) + writeFileSync(join(cwd, 'notes.txt'), 'changed\n') + + const result = await withControlSession({ sessionId, cwd, env }, async (control) => ({ + abandoned: await control.rewindFiles(u2, { dryRun: true }), + current: await control.rewindFiles(u2b, { dryRun: true }), + })) + expect(result.abandoned.canRewind).toBe(false) + expect(result.current.canRewind).toBe(true) + }) + + test('an unknown session fails with the CLI’s reason', async () => { + await expect( + withControlSession({ sessionId: randomUUID(), cwd, env }, (control) => control.rewindFiles(randomUUID(), { dryRun: true })), + ).rejects.toThrow(/No conversation found with session ID/) + }) +}) diff --git a/tests/sdk-transcript-fixture.ts b/tests/sdk-transcript-fixture.ts new file mode 100644 index 00000000..8807ed59 --- /dev/null +++ b/tests/sdk-transcript-fixture.ts @@ -0,0 +1,95 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +/** + * A synthetic Claude Code session on disk: a transcript and the file-history backups its + * checkpoints point at. Enough for the bundled CLI to resume it and answer `rewindFiles` + * with no model call and no credentials — which is exactly what a rewind control session + * does (`src/lib/engine/rewind.server.ts`), so the real CLI can be tested against it. + * + * The layout is the CLI's own, read from the bundled `claude` (SDK 0.3.278): + * + * /projects//.jsonl + * /file-history//<16 hex>@v + * + * A `file-history-snapshot` entry is keyed by the user message's uuid; a tracked path maps + * to the backup of that file as it was at that message, or `null` when it did not exist + * yet (a rewind then deletes it). This is internal to the CLI. If a CLI upgrade changes it, + * the specs that use this fixture fail — which is the point: they are the canary for the + * rewind path still working against the CLI we ship. + */ + +export const BACKUP_NAME = /^[0-9a-f]{16}@v\d+$/ + +export function syntheticTranscript(input: { sessionId: string; cwd: string }) { + const common = { + sessionId: input.sessionId, + cwd: input.cwd, + isSidechain: false, + userType: 'external', + version: '2.0.0', + } + let clock = Date.parse('2026-09-01T12:00:00Z') + const stamp = () => new Date((clock += 1000)).toISOString() + + return { + user(uuid: string, parentUuid: string | null, content = 'change the files') { + return { ...common, type: 'user', uuid, parentUuid, timestamp: stamp(), message: { role: 'user', content } } + }, + assistant(uuid: string, parentUuid: string, text = 'done') { + return { + ...common, + type: 'assistant', + uuid, + parentUuid, + timestamp: stamp(), + message: { + id: `msg_${uuid.slice(0, 8)}`, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [{ type: 'text', text }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 1 }, + }, + } + }, + /** Workspace-relative path → backup file name, or null for "did not exist at this message". */ + snapshot(messageId: string, tracked: Record) { + const at = stamp() + const trackedFileBackups = Object.fromEntries( + Object.entries(tracked).map(([path, backupFileName]) => [path, { backupFileName, version: 1, backupTime: at }]), + ) + return { + type: 'file-history-snapshot', + messageId, + isSnapshotUpdate: false, + snapshot: { messageId, timestamp: at, trackedFileBackups }, + } + }, + } +} + +/** Write the transcript and the backups where the CLI looks for them. */ +export function writeSyntheticSession(input: { + configDir: string + cwd: string + sessionId: string + entries: unknown[] + /** Backup file name → the file's content at the checkpoint. */ + backups: Record +}): void { + const historyDir = join(input.configDir, 'file-history', input.sessionId) + mkdirSync(historyDir, { recursive: true }) + for (const [name, content] of Object.entries(input.backups)) { + if (!BACKUP_NAME.test(name)) throw new Error(`Not a backup name the CLI accepts: ${name}`) + writeFileSync(join(historyDir, name), content) + } + const projectDir = join(input.configDir, 'projects', input.cwd.replace(/[^a-zA-Z0-9]/g, '-')) + mkdirSync(projectDir, { recursive: true }) + writeFileSync( + join(projectDir, `${input.sessionId}.jsonl`), + `${input.entries.map((entry) => JSON.stringify(entry)).join('\n')}\n`, + ) +} From 13c8d6684f273f5efe2cb9f7f1154828a63d1616 Mon Sep 17 00:00:00 2001 From: Derek Hearst Date: Wed, 23 Sep 2026 20:12:51 -0600 Subject: [PATCH 4/9] docs(chat): editing, regenerating, restoring files and compacting New docs/chat/chat.md covers what the model sees after an edit or regenerate, when files are checkpointed, the restore dialog and its guards, compaction, and exactly which SDK behaviours were verified against the bundled CLI and how. The chat and context specs, README, the desktop parity table and the #24 section of the re-audit are updated; the re-audit's two open questions for #24 are answered there. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 + docs/chat/chat.md | 135 ++++++++++++++++++++ docs/chat/spec.md | 8 +- docs/context/spec.md | 2 +- docs/structure/claude-desktop-parity.md | 2 +- docs/structure/parity-reaudit-2026-09-22.md | 33 ++++- 6 files changed, 176 insertions(+), 6 deletions(-) create mode 100644 docs/chat/chat.md diff --git a/README.md b/README.md index b9947a7b..ba509e67 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Self-hosted autonomous AI agent platform for a single owner, with a sandboxed wo AgentStudio provides a streaming chat interface where the assistant can call tools such as web search and sandboxed code execution. The filesystem toolset supports ranged file reads, full writes, unified-diff patch apply, deterministic string replace, recursive directory listing, search, move/rename, delete, and file metadata lookups. Chat supports editing and branching, interleaved tool and thinking blocks, per-message performance and cost metrics, model selection, and per-prompt reasoning effort selection. +Editing a message or regenerating a reply cuts the conversation back so the model sees only what was kept plus the message it is answering. In a project (or an agent with a persistent workspace) the agent's file changes are checkpointed every turn, and Edit and Regenerate offer to restore the files the dropped replies changed — showing the files and line counts first, and asking again before overwriting uncommitted changes in an imported repository. **Compact Conversation** runs the SDK's own `/compact`. See [docs/chat/chat.md](docs/chat/chat.md). + Replies can be read aloud: a speaker button on each reply plays it through an OpenRouter text-to-speech model (code blocks are skipped), and an opt-in, per-device **Auto-read** switch above the message box reads each new reply when its turn finishes, for hands-free use. The model and voice are set in Settings → Model & AI; spend is recorded in the usage ledger under "Read Aloud" and counts toward budget limits. See [docs/speech/speech.md](docs/speech/speech.md). Creation workflows are chat-led: New Agent and New Skill actions launch a fresh conversation with a seeded creation prompt. The assistant gathers missing requirements (optionally with ask_user), then executes directly with tool-level approvals where configured. diff --git a/docs/chat/chat.md b/docs/chat/chat.md new file mode 100644 index 00000000..a706b53a --- /dev/null +++ b/docs/chat/chat.md @@ -0,0 +1,135 @@ +# Chat + +## Overview + +Chat is where you talk to an agent. Each conversation is a list of messages in AgentStudio's database, and behind it sits a Claude Agent SDK session: the SDK's own record of the conversation, which is what the model actually reads on every turn. The full feature spec (composer, attachments, tool cards, approvals, the right rail and so on) is [spec.md](spec.md). This page covers the parts of chat that change a conversation after the fact: + +- **Edit** a message you sent, and get a new reply to the edited text. +- **Regenerate** the last reply. +- **Also restore files**: when you edit or regenerate, put the files the dropped replies changed back the way they were (#24). +- **Compact** a long conversation so it takes less of the model's context. + +## Key concepts + +| Concept | What it means | +| --- | --- | +| Message row | One message in the `messages` table: your text, or the agent's reply. What the page shows. | +| SDK session | The Claude Agent SDK's transcript of the conversation (`conversations.sdk_session_id`). What the model sees. Each turn resumes it. | +| Transcript join | Where a message row sits in the SDK transcript, recorded on the row's `metadata` (see below). How an edit knows where to cut, and how a restore knows which checkpoint to go back to. | +| File checkpoint | A backup the SDK takes of a file just before the agent's file tools (Write, Edit, MultiEdit, NotebookEdit) change it, keyed by the message that started the turn. | +| Restore | Putting files back to how they were at one of your messages, from those backups. Files the agent created after that point are deleted. | + +### The transcript join + +No schema change: two keys on `messages.metadata`. + +| Row | Key | What it holds | +| --- | --- | --- | +| Your message | `sdkTurn` | The id the message carried into the SDK transcript, the SDK session it went into, the working directory the run used, and whether files were checkpointed on that turn. | +| The agent's reply | `sdkTailUuid` | The last transcript entry that turn wrote — the point an edit of the *next* message cuts back to. | + +AgentStudio chooses the id each message carries into the transcript, rather than letting the SDK make one up, so it knows it in advance. Messages from before this change have no join; they still work, as described under business rules. + +## User flows + +### Editing a message + +1. You click the pencil on one of your messages, change the text, and click **Save & regenerate**. (The pencil is hidden while a reply is being written.) +2. If the dropped replies changed files that can be restored, the **restore dialog** opens (below). Otherwise the edit goes straight on. +3. If you chose to restore files, the files are restored first. If that fails, nothing else happens: the conversation is left exactly as it was and the error says so, so you can try again or go on without restoring. +4. Your message is updated and everything after it is deleted. +5. A new reply is written. The model sees the conversation up to the reply before your message, followed by your edited text — nothing from the replies you dropped. + +### Regenerating a reply + +The same as editing the last message without changing it: the last reply is deleted and the model answers your message again, without seeing the reply it is replacing. + +### The restore dialog + +It shows: + +- the files that would be restored, with the total lines added and removed; +- **Also restore these files to how they were before this message**, ticked by default; +- a warning on any file git reports as having uncommitted changes, because restoring overwrites them — including edits you made yourself; +- for a project imported from a remote repository, a second box, **Overwrite the uncommitted changes in these files**, which must be ticked before Continue works when any of the files has uncommitted changes; +- a reminder that only changes made with the agent's file tools are restored: changes made by shell commands, and commits or pushes, are not undone. + +**Cancel** does nothing at all, and an edit stays open so you can try again. When a checkpoint exists but cannot be used right now (a reply is still being written, the conversation was compacted since, the backup has expired), the dialog says why and offers to continue without restoring. + +The dialog does not appear when there is nothing to restore: a chat with no project, a message from before this feature, or a turn that did not change any files. + +### Compacting a conversation + +**Compact Conversation** (in the context panel) sends the SDK's own `/compact` command. The SDK summarises the conversation and starts the session again from that summary, so the next turns really do use less context; the reply shows a "Context compacted" notice. + +When you switch to a model with a smaller context window and the conversation would fill more of it than the auto-compact threshold in settings, the page runs the same `/compact` first, then switches. The notice after the switch says whether compaction ran or failed. + +Before this change both sent an ordinary message asking the model for a summary. The session then carried that summary on top of the full history, so the context grew instead of shrinking, and the notice claimed a compaction that had not happened. + +## Roles & permissions + +| Action | Who can do it | +| --- | --- | +| Edit or regenerate a message | The conversation's owner | +| Preview or restore files | The conversation's owner, for a message in their own conversation, in a working directory inside their own sandbox | +| Restore over uncommitted changes in an imported repository | The owner, after ticking the explicit overwrite box; the server checks it again | + +## Integrations + +**Claude Agent SDK** (`@anthropic-ai/claude-agent-sdk` 0.3.278). What each piece relies on, and how it was verified rather than assumed: + +| Behaviour | How it was checked | +| --- | --- | +| A message sent with our own id keeps that id in the transcript, and file checkpoints are keyed by it | Ran the bundled CLI against a stand-in Messages API: the transcript's user entries and `file-history-snapshot` entries carried exactly the ids we sent. | +| `enableFileCheckpointing` backs up files the Write tool changes, and `rewindFiles(id, { dryRun: true })` lists them (absolute paths) with line counts, without touching them | Same run: a file created in turn 2 was listed by a dry run to turn 2, and both turns' files by a dry run to turn 1. | +| `resume` + `resumeSessionAt` keeps the same session id and drops everything after the cut from what the model is sent | Same run: the model received turn 1 and the edited text, and nothing from turn 2. | +| After such a cut, the dropped message's checkpoint is gone and the kept ones still work | Same run. This is why files are restored *before* the new reply, never after. | +| A session with no prompt answers `rewindFiles` from the history it loads at start-up, with no model call | Automated: `tests/engine.rewind.spec.ts` runs the real CLI against a synthetic session on disk. | +| `/compact` sent as a plain-text message runs the SDK's compaction; the summary it writes after the boundary is a valid place to cut an edit | Stand-in API run: a `compact_boundary` came back, the next turn sent only the summary, and an edit cut after the summary kept it. | +| After a compaction, messages from before it have no checkpoint ("No file checkpoint found"), and a cut before it is refused ("No message found with message.uuid") before any model call | Stand-in API run. The refusal is recognised and the turn falls back to a fresh session. | +| `forkSession` copies no file history | The SDK's own documentation of `forkSession()`. Edits therefore never fork. | + +**Git.** For a project with a repository, the restore preview runs `git status` in the project's checkout to mark files with uncommitted changes. A restore never runs git: it rewrites file contents only, so it cannot remove a commit. A commit the agent made after the checkpoint stays in history (and on the remote if it was pushed), and the restored files then show as uncommitted changes. + +## Business rules + +**What the model sees after an edit or regenerate.** The page no longer sends the word "regenerate" as a prompt; the server answers the edited or regenerated message itself, using its stored text and attachments. How the SDK session is started: + +| Situation | What happens | +| --- | --- | +| A new message | The session is resumed as it is. | +| Edit or regenerate, and the reply before it recorded where it ended | The session is resumed and cut right after that reply. Same session id. | +| Edit or regenerate of the first message | A fresh session; there is nothing before it to keep. | +| The reply before it predates this feature, belongs to an earlier session, or has no record of where it ended (a reply the page saved itself after a dropped connection) | A fresh session, primed with the kept conversation as text (up to about 24,000 characters, most recent first). Tool calls are not in that text. | +| The SDK refuses the cut (for example, it is from before a compaction) | Detected before the model is called; the turn runs once more on a fresh session primed as above. A turn that already produced anything is never re-run. | + +**When files are checkpointed.** Only when the run's working directory outlives the turn: a conversation in a project (any repository kind, including none), or an agent with a persistent workspace. A chat with no project gets a new, throwaway directory every turn, so there is never anything to go back to; the same goes for per-run worktrees. + +**Restore guards,** all checked again on the server when you confirm, not taken from the preview: + +- the message is yours, and its checkpoint belongs to the conversation's current SDK session; +- the working directory it recorded is inside your sandbox and still exists; +- no reply is being written in the conversation, and no other restore is running in it (a new message is refused while one is); +- every file the restore would touch is inside that working directory — if any is not, nothing is restored; +- the dry run is repeated just before the real restore, so the decision is made on what is on disk now; +- for an imported repository, uncommitted changes in any of the files need the explicit overwrite. + +**Limits.** + +- Changes made by shell commands, by AgentStudio's own tools (knowledge uploads, attachments), and by anything outside the agent's file tools are not checkpointed and are not restored. +- The Claude CLI removes old session files after 30 days by default; older messages then have no backup and the dialog says so. +- Compacting a conversation removes the backups for messages before the compaction. +- A restore overwrites the files as they are now. Git can show you which files have uncommitted changes, but not who made them. + +## Where it lives + +| Piece | File | +| --- | --- | +| Turn planning: text, attachments and how the session starts | `src/lib/chat/turn-plan.ts`, `src/lib/chat/turn-plan.server.ts` | +| The prompt's own id, the transcript tail, the refused-cut fallback | `src/lib/engine/turn-input.ts` | +| Edit and regenerate on the rows | `src/lib/chat/message-branch.server.ts` | +| Restore preview and restore, with the guards | `src/lib/chat/rewind.server.ts`, `src/lib/chat/rewind-plan.ts`, `src/lib/chat/rewind-preview.ts` | +| The short-lived SDK session a restore runs in | `src/lib/engine/rewind.server.ts` | +| The dialog | `src/lib/chat/RewindPreviewDialog.svelte`, `src/lib/chat/rewind-dialog.svelte.ts` | + +Follow-up, not done here: deleting a conversation does not yet delete its SDK transcript or file backups. diff --git a/docs/chat/spec.md b/docs/chat/spec.md index 61d9f37a..0c2925e0 100644 --- a/docs/chat/spec.md +++ b/docs/chat/spec.md @@ -258,10 +258,14 @@ The context meter above the composer, and the same figure in the right rail, est | Messages | Estimated from the text of every message in the conversation | | Tool results | Estimated from the saved output of every tool call. Each reply's output is counted once, from its saved steps when it has them | -Everything except the system prompt is an estimate (about four characters per token), so treat it as a guide. It used to show only the system prompt once a turn had run, so a long conversation looked nearly empty. That also mattered for model switching: when you switch to a model with a smaller window, the page asks the agent to summarise the conversation first if it would fill more of the new window than the auto-compact threshold in settings (72% by default), and that check uses this figure. +Everything except the system prompt is an estimate (about four characters per token), so treat it as a guide. It used to show only the system prompt once a turn had run, so a long conversation looked nearly empty. That also mattered for model switching: when you switch to a model with a smaller window, the page compacts the conversation first (the SDK's own `/compact`) if it would fill more of the new window than the auto-compact threshold in settings (72% by default), and that check uses this figure. **Compact Conversation** runs the same command; see [chat.md](chat.md#compacting-a-conversation). A reply saved after Stop or an error keeps its tool output in two places. The meter counts it once, so a stopped turn with a large command output does not read as twice its size or set off that summary early. +### Editing, regenerating and restoring files + +Editing one of your messages, or regenerating the last reply, cuts the SDK session back to the reply before that message and answers the message's own stored text, so the model never sees the replies you dropped. In a project, the same action can also restore the files those replies changed, after showing what it would restore. How it works, and what it cannot undo: [chat.md](chat.md). + ### Mobile and compact layout On mobile, the right panel collapses into a bottom sheet or tab drawer. The workbench preserves the same actions, but prioritizes the thread and current blocker state. @@ -304,6 +308,8 @@ The renderer checks itself when the app starts by running a set of known attack - Messages keep their order when two writers add to a conversation at the same moment (a Stop saving what was written so far while the turn saves its final reply, or a background run). The one that loses the race takes the next position and is still saved. - A staged attachment always lands in the same sandbox workspace the run's own tools resolve, so the path quoted to the agent is a path the agent can open. - Nothing the model writes can run script in the app, and displaying a reply never loads any image except an uploaded attachment. +- After an edit or regenerate the model sees exactly the kept conversation plus the message being answered — never the dropped replies, and never a placeholder prompt. +- Restoring files happens before any message changes, and a restore that fails leaves the conversation as it was. Uncommitted changes in an imported repository are never overwritten without an explicit confirmation. ## Roles & Permissions diff --git a/docs/context/spec.md b/docs/context/spec.md index ba1171e3..acae1040 100644 --- a/docs/context/spec.md +++ b/docs/context/spec.md @@ -32,7 +32,7 @@ The **auto-compact threshold** is the point at which conversation history is com Compaction is the process of replacing older conversation turns with a summary to free up window space. The summary becomes a `system` message injected before the recent turns. -> **Today:** chats run on the Claude Agent SDK, which compacts a conversation itself. AgentStudio's own compaction (`compactMessages`, `shouldCompact`) had no caller after the move to the SDK and was deleted (#8). The rules below describe that earlier design. The auto-compact threshold setting now does one thing: when you switch a chat to a model with a smaller context window and the conversation would fill more than that share of it, the chat first asks the current model to summarize the conversation. +> **Today:** chats run on the Claude Agent SDK, which compacts a conversation itself. AgentStudio's own compaction (`compactMessages`, `shouldCompact`) had no caller after the move to the SDK and was deleted (#8). The rules below describe that earlier design. The auto-compact threshold setting now does one thing: when you switch a chat to a model with a smaller context window and the conversation would fill more than that share of it, the chat first compacts the conversation with the SDK's own `/compact` command, on the current model. (Until 2026-09 it sent an ordinary message asking for a summary, which the SDK session then carried on top of the full history — the context grew instead of shrinking. See [../chat/chat.md](../chat/chat.md#compacting-a-conversation).) Compaction rules: diff --git a/docs/structure/claude-desktop-parity.md b/docs/structure/claude-desktop-parity.md index dd4c58de..579f3c31 100644 --- a/docs/structure/claude-desktop-parity.md +++ b/docs/structure/claude-desktop-parity.md @@ -51,7 +51,7 @@ This is the Cowork comparison, and it is the one I got wrong in the first draft: | Tool approval | **behind** | per-tool settings + mandatory-approval list, checked for every call (subagents' included) ahead of the SDK's own allow rules; inline Allow/Deny cards, which never rendered on the engine path until 2026-09-22 (the frame carried no approval token) | per-session modes, path-scoped deny rules, per-model effort caps, auto-mode classifier | | Permission modes (plan / acceptEdits / bypass) | **absent** (#19) | hardcoded `default`; the Plan agent is a persona, not a mode | four modes, switchable mid-session | | Diff rendering | **far behind** (#16) | raw JSON in a tool card | inline diffs with per-hunk accept/reject | -| Checkpoints and rewind | **far behind** (#24) | rewinds the transcript only; files stay written | auto-checkpoint per turn, Esc-Esc or `/rewind`, restore code / conversation / both | +| Checkpoints and rewind | **behind** (#24 landed) | auto-checkpoint per turn in a project or persistent workspace; Edit and Regenerate cut the SDK session back to the kept history and offer to restore the files the dropped turns changed, with a dry-run preview and an explicit confirmation over uncommitted changes in an imported repo | auto-checkpoint per turn, Esc-Esc or `/rewind`, restore code / conversation / both — including a code-only restore we do not offer yet | | Todo list | **absent** (#21) | nothing consumes it | pinned, updated in place | | Terminal / command output | **far behind** (#26) | JSON-escaped blob in a card, nothing streams | streamed terminal output | | Git worktrees | **absent** | one working directory per project | worktree-per-agent with cleanup safety | diff --git a/docs/structure/parity-reaudit-2026-09-22.md b/docs/structure/parity-reaudit-2026-09-22.md index cfb214b1..21ba2b19 100644 --- a/docs/structure/parity-reaudit-2026-09-22.md +++ b/docs/structure/parity-reaudit-2026-09-22.md @@ -248,7 +248,7 @@ plan), **fold** (belongs inside another issue), **delete** (close it). | #26 | Shell output as a terminal | **rebuild** | same adapter; "stream it live" means background + poll, not a new transport | | #21 | Render the todo list | **rebuild** — shipped | same adapter; pinned above the composer, kept on the conversation | | #35 | Background work in a turn | **rebuild** — mostly shipped | chips, notices and a stop control land; the live output card is left, with #26 | -| #24 | Filesystem checkpoints | **rebuild** | `enableFileCheckpointing` + `rewindFiles()`, not hand-rolled git stashes | +| #24 | Filesystem checkpoints | **rebuild** — shipped | `enableFileCheckpointing` + `rewindFiles()` through a short-lived control session; edit/regenerate now cut the SDK session too | | #23 | Per-project instructions | **rebuild** — shipped | `settingSources` was 90% of it; instructions and the knowledge directory close the rest | | #17 | Connect external MCP servers | **as filed** | plumbing confirmed trivial; the policy layer is the actual work | | #32 | Multi-agent orchestration | **rebuild** | use SDK `agents` + the Task tool instead of a bespoke fan-out tool | @@ -349,6 +349,32 @@ The remaining AgentStudio-side work is joining a `messages` row to the SDK user- so "rewind to this message" has something to pass in — which is worth doing anyway, because it is the same join `compact_boundary` and per-message context accounting want. +**Shipped (2026-09-23).** Both open questions were answered by running the bundled CLI +against a stand-in Messages API, and the answers are in [../chat/chat.md](../chat/chat.md#integrations): +the sandbox layout is tracked (paths come back absolute), but only for the SDK's file tools — +Bash and our own tools are not checkpointed; and a rewind never runs git, so it cannot drop a +commit. Imported repos with uncommitted changes in the files being restored need an explicit +overwrite, checked again on the server. + +- **The join**, with no migration: every prompt is sent as a streamed `SDKUserMessage` with a + uuid we mint, which the CLI keeps as the transcript uuid and keys its checkpoints by. + `messages.metadata.sdkTurn` on the user row holds it with the session id and the run's cwd; + `metadata.sdkTailUuid` on the reply holds the turn's last chain entry. +- **The rewind** is option A from the triage: `query()` resuming the session in the run's cwd + with checkpointing on and an input that never yields, `rewindFiles(uuid, { dryRun: true })` + for the preview, the real call on confirm, then `close()` — no model call, and it works after + a restart, which finding 2's live handle would not have. Checkpointing is on only where the + workspace outlives the turn (a project, or an agent's persistent key). +- **Worse than the issue said, and fixed with it:** edit and regenerate sent the literal prompt + "regenerate" into the unedited session. They now send the row's own text and resume with + `resumeSessionAt` at the previous reply's tail (same session id — `forkSession()` would lose + the file history), falling back to a fresh session primed with the kept history when the cut + is refused. "Compact Conversation" now runs the SDK's `/compact` instead of asking for a + summary the session then carried on top of everything. + +Left for later: a "restore files only" action on a message, and deleting a conversation's +SDK transcript and file backups when the conversation is deleted. + ### #23 — project instructions and knowledge **Done, and not as scoped here — see the correction in finding 3.** Project settings were @@ -579,8 +605,9 @@ unless one of those is the actual goal. placement rather than data — the pinned todo list above the composer (#21), live output while a command runs (#26, which is the background path in #35). The ledger gap was closed off the same field (e0c6234; see the "Fixed" note under the ledger finding). -2. **The handle** (finding 2) — keep `Query` alive per conversation. Unblocks #24, the rest - of #35, and real context accounting. +2. **The handle** (finding 2) — keep `Query` alive per conversation. Unblocks the rest + of #35, and real context accounting. (#24 turned out not to need it: a rewind opens its + own short-lived control session, which also survives a restart — see the #24 section.) 3. **The two defects** — delete `search_tools` and its prompt text; account built-in tool calls. **Done**: both fixed, and `search_tools` has since been deleted outright (#8). 4. **#5, then #32 reshaped** — the orchestration keystone, which also deletes `$lib/runtime` From 1700819a24e9f58b062322b1ac5d5cd758b923e1 Mon Sep 17 00:00:00 2001 From: Derek Hearst Date: Wed, 23 Sep 2026 20:39:40 -0600 Subject: [PATCH 5/9] fix(chat): tell the user when a restore left linked files alone A real `rewindFiles` can refuse tracked files because a symlink, hard link or moved parent directory is in the way (`RewindFilesResult.skippedLinks`); the dry run never reports these. The count was logged and dropped, and `filesRestored` was the previewed count, so a partial restore read as a full one. `applyMessageRewind` now reports the previewed files less the skipped ones, the edit and regenerate results carry `skippedLinks`, and the chat page shows a dismissable notice ("Restored 3 of 5 files. 2 were not restored: ...") when any were skipped. Co-Authored-By: Claude Opus 5.5 --- src/lib/chat/RewindPreviewDialog.svelte | 13 ++++++++++++- src/lib/chat/message-branch.server.ts | 22 ++++++++++++++++------ src/lib/chat/rewind-dialog.svelte.ts | 24 ++++++++++++++++++++++-- src/lib/chat/rewind-preview.ts | 18 ++++++++++++++++++ src/lib/chat/rewind.server.ts | 20 +++++++++++++++++--- src/routes/chat/[id]/+page.svelte | 4 +++- tests/chat.regenerate-fork.spec.ts | 24 ++++++++++++++++++++++-- tests/engine.rewind.spec.ts | 13 +++++++++++++ 8 files changed, 123 insertions(+), 15 deletions(-) diff --git a/src/lib/chat/RewindPreviewDialog.svelte b/src/lib/chat/RewindPreviewDialog.svelte index 7f8a6ecf..1bd909af 100644 --- a/src/lib/chat/RewindPreviewDialog.svelte +++ b/src/lib/chat/RewindPreviewDialog.svelte @@ -8,9 +8,11 @@ * the files the dropped reply changed with their line counts, "also restore files" * (ticked by default), and — for an imported repository with uncommitted changes in those * files — a second, explicit box before anything is overwritten. + * + * Afterwards, `reportFileRestore()`'s notice when the restore left some listed files alone. */ import { untrack } from 'svelte'; - import { rewindDialogState, settleFileRestore } from './rewind-dialog.svelte'; + import { dismissFileRestoreNotice, rewindDialogState, settleFileRestore } from './rewind-dialog.svelte'; import { canContinueRestore, restoreByDefault } from './rewind-preview'; let restore = $state(false); @@ -59,6 +61,15 @@ {/if} +{#if rewindDialogState.notice && !pending} +
+ +
+{/if} + {#if pending && preview}