diff --git a/README.md b/README.md index 4d3726b..0e7367f 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). + Chats run on Claude models through the Claude subscription, at no per-token cost. The model pickers — the composer (its model pill and the `/model` command), the default model and each agent's model — offer only models that can actually run here: the current Claude models Claude Code knows, plus, when the operator configures an Anthropic-compatible gateway such as OpenRouter's, the gateway's models, labelled **Gateway · paid** and priced per token in the usage ledger. The gateway is off by default. See [docs/llm/llm.md](docs/llm/llm.md). The message box has two shortcuts. Typing `@` lists the files in the chat's workspace (a bound project's folder, or an agent's persistent workspace) and inserts the chosen file's relative path. Typing `/` at the start opens a command palette over the app's own actions: `/compact`, `/model`, `/agent`, `/research`, `/plan`, `/effort`, `/attach` and `/voice`. Both work from the keyboard and by tapping on a phone. See "Composer shortcuts" in [docs/chat/spec.md](docs/chat/spec.md). @@ -213,6 +215,8 @@ Notes: - Tools (what agents can call, approvals, code execution): `docs/tools/tools.md` - Chat plan: `docs/chat/plan.md` - Chat spec (composer shortcuts, attachments, safe rendering): `docs/chat/spec.md` +- Chat: editing, regenerating, restoring files and compacting: `docs/chat/chat.md` +- Models, the subscription and the optional gateway: `docs/llm/llm.md` - Memory spec: `docs/memory/spec.md` - Automations: `docs/automations/automations.md` - Monitors: `docs/monitors/monitors.md` diff --git a/docs/chat/chat.md b/docs/chat/chat.md new file mode 100644 index 0000000..183ab23 --- /dev/null +++ b/docs/chat/chat.md @@ -0,0 +1,143 @@ +# 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: three 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. | +| Your message | `sdkCutPending` | Set when an edit or regenerate cuts the conversation back to this message. It means the SDK session still holds the replies that were dropped. It is cleared as soon as a new turn starts on a session that has been cut to match (or on a fresh one). | + +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. + +If step 5 never starts — the connection drops, the server fails or restarts before the reply begins — the edit is not lost. The message is marked as cut (`sdkCutPending`). Whatever you do next, **Retry** or simply sending a new message, cuts the session back the same way first. The model then sees the conversation as the page shows it, never 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. + +After a restore, the page warns you if some of the listed files were **not** put back. The SDK leaves a file alone when a link is in the way: a symlink or hard link at that path, or a folder that has moved since the message. The warning says how many files were restored and how many were left alone, and it stays until you dismiss it. The SDK can only find this out during the real restore, not the preview, so the dialog may have listed files that end up skipped. + +### Compacting a conversation + +**Compact Conversation** (in the context ring's popover in the chat header), or typing `/compact` in the message box, 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. | +| A new message sent after an edit or regenerate whose reply never started | Treated like the edit: the session is cut back to the reply before the edited message. Any of your messages after that reply that never got an answer (the edited one, for example) are put in front of the new message as text, because the cut session never saw them. | +| 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. | +| A slash command such as `/compact` sent while a cut is pending | The session is still cut, but nothing is put in front of the command, because the SDK only recognises a command at the very start of the message. Unanswered messages before it stay on the page but are not in the model's context. | + +**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, and the pending-cut mark | `src/lib/chat/message-branch.server.ts` | +| The `/compact` prompt and the model-switch notice | `src/lib/chat/compact-command.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 10ebc4f..65ec47c 100644 --- a/docs/chat/spec.md +++ b/docs/chat/spec.md @@ -145,7 +145,7 @@ Typing `/` as the very first character of the message opens the command palette. | Command | What it does | Available | | --- | --- | --- | -| `/compact` | Asks the agent to summarise the conversation so far, to free up context. The same as Compact on the context meter | In a chat, when no reply is running | +| `/compact` | Compacts the conversation to free up context: runs the Agent SDK's own `/compact`, which summarises the conversation and starts the session again from that summary (see [chat.md](chat.md#compacting-a-conversation)). The same as Compact on the context meter | In a chat, when no reply is running | | `/model ` | Switches the model for the next message. It lists the same models as the model picker: only models that can run here, with gateway models marked "Gateway · paid" (see [docs/llm/llm.md](../llm/llm.md)) | Everywhere | | `/agent ` | Hands the conversation to another agent | Everywhere, when there are agents to pick | | `/research ` | Starts a deep research run on the question and opens its page | In a chat | @@ -316,10 +316,14 @@ The context meter above the composer, and the context ring in the chat's header | 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 rail is a drawer opened from the chat header. The workbench preserves the same actions, but prioritizes the thread and current blocker state. @@ -362,6 +366,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. - A path offered by `@` is one the next turn can open as written: it is relative to the folder that turn starts in, and it never reaches through a symbolic link. - A palette command calls the same handler as the button it stands in for. The palette has no copy of its own of any action. diff --git a/docs/context/spec.md b/docs/context/spec.md index ba1171e..acae104 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 a802104..fbebd3d 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 4cbe409..0b15214 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,41 @@ 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. +- **A cut that has to survive a failed request.** An edit or regenerate marks the row it cut + back to (`metadata.sdkCutPending`). If the reply never starts, the next turn of any kind + (Retry, or just a new message) still resumes at the previous reply's tail. Any unanswered + user rows in between go in front of the new message as text. The mark is cleared when a + turn records its join. +- **Partial restores are reported.** A real `rewindFiles` can leave files alone because a link + is in the way (`RewindFilesResult.skippedLinks`; the dry run never reports these). The edit + or regenerate result carries the count, and the page shows how many listed files were not + put back. + +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 @@ -515,9 +550,11 @@ over the app's existing actions (option B of the triage). can be merged in later without changing the palette. With #9, `/model` lists the same runnable engine models as the model pill, and `/effort` is switched off on a gateway model, as the reasoning pill is. +- `/compact` already reaches the SDK's real compaction: it calls Compact Conversation, which + #24 switched to sending the CLI's own `/compact` (see the #24 section). - Still open for the SDK half: caching the command list on the conversation (control - requests only work mid-turn), routing `/compact` to the SDK's real compaction, and handling - `local_command_output` and `conversation_reset` in the stream loop. + requests only work mid-turn), and handling `local_command_output` and `conversation_reset` + in the stream loop. ### #38 — usage digest @@ -641,8 +678,9 @@ Claude and a gateway model, and each non-Claude model's multi-step tool use. See 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` diff --git a/src/lib/chat/MessageBubble.svelte b/src/lib/chat/MessageBubble.svelte index b62663d..ba32558 100644 --- a/src/lib/chat/MessageBubble.svelte +++ b/src/lib/chat/MessageBubble.svelte @@ -40,12 +40,16 @@ onEdit, onRegenerate, canRegenerate = false, + canEdit = true, modelChanged = true, } = $props<{ message: MessageRow; - onEdit?: ((messageId: string, content: string) => Promise | void) | undefined; + /** Resolves `false` when nothing was done (the user cancelled), which keeps the editor open. */ + onEdit?: ((messageId: string, content: string) => Promise | boolean | 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 @@ -116,6 +120,9 @@ if (!editing) return; const onPointerDown = (event: PointerEvent) => { + // While a save is pending the page may be asking about restoring files (#24); a click + // in that dialog is not a click away from the editor. + if (editingBusy) return; const target = event.target; if (editorRoot && target instanceof Node && !editorRoot.contains(target)) { cancelEditing(); @@ -123,7 +130,7 @@ }; const onEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') { + if (event.key === 'Escape' && !editingBusy) { event.preventDefault(); cancelEditing(); } @@ -154,8 +161,8 @@ if (!trimmed || editingBusy) return; editingBusy = true; try { - await onEdit?.(message.id, trimmed); - editing = false; + const done = await onEdit?.(message.id, trimmed); + if (done !== false) editing = false; } finally { editingBusy = false; } @@ -228,9 +235,11 @@

{message.content}

- + {#if canEdit} + + {/if} {#if canRegenerate} +
+ +{/if} + +{#if pending && preview} + +{/if} diff --git a/src/lib/chat/chat-page-commands.ts b/src/lib/chat/chat-page-commands.ts index 9118f9b..14fd44c 100644 --- a/src/lib/chat/chat-page-commands.ts +++ b/src/lib/chat/chat-page-commands.ts @@ -11,7 +11,8 @@ import { * #22 — the `/` commands only a conversation page can offer, wired to the handlers its own * buttons use. Kept out of the page so the page passes one prop. * - * - `/compact` is the context meter's Compact button (a summarise-this-conversation turn). + * - `/compact` is the context meter's Compact button, which sends the CLI's own `/compact` + * (`$lib/chat/compact-command`, #24): the SDK summarises and restarts the session from it. * - `/research ` is the page's Deep Research trigger. * - `/plan` flips the permission-mode chip between Plan only and Ask, through the same * command the chip calls. It never switches to Bypass, which needs the chip's confirm. diff --git a/src/lib/chat/chat.remote.ts b/src/lib/chat/chat.remote.ts index d37d478..0fb127d 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/compact-command.ts b/src/lib/chat/compact-command.ts new file mode 100644 index 0000000..fdf50e3 --- /dev/null +++ b/src/lib/chat/compact-command.ts @@ -0,0 +1,31 @@ +/** + * Finding 80 — "Compact Conversation", and the compaction before a switch to a model with a + * smaller context window, run the CLI's own `/compact`. + * + * Both used to send 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 instead of shrinking. `/compact` summarises and starts the session again + * from the summary; the turn shows a "Context compacted" notice. + * + * The command has to be the whole prompt, as plain text: the CLI reads a slash command only + * from the very start of it (`userTurnMessages` keeps text-only prompts a string for this). + */ + +/** What `/compact` is told to keep. */ +export const COMPACT_INSTRUCTIONS = + 'Preserve all requirements, decisions, open tasks, constraints, and the latest user intent.' + +/** The prompt for a compaction; `handoff` when it runs before a switch to a smaller model. */ +export function compactCommand(options: { handoff?: boolean } = {}): string { + const handoff = options.handoff ? ' This is a handoff to a model with a smaller context window.' : '' + return `/compact ${COMPACT_INSTRUCTIONS}${handoff}` +} + +const shortModelName = (model: string) => model.split('/').at(-1) ?? model + +/** The notice after compacting before a model switch: honest about a compaction that failed. */ +export function compactSwitchNotice(input: { failed: boolean; from: string; to: string }): string { + return input.failed + ? `Compacting before the switch to ${shortModelName(input.to)} failed; the full conversation is still in context.` + : `Compacted the conversation on ${shortModelName(input.from)} before switching to ${shortModelName(input.to)}.` +} diff --git a/src/lib/chat/message-branch.server.ts b/src/lib/chat/message-branch.server.ts new file mode 100644 index 0000000..d698ba6 --- /dev/null +++ b/src/lib/chat/message-branch.server.ts @@ -0,0 +1,133 @@ +/** + * Edit and regenerate: cutting a conversation back to one of its user messages. + * + * Both change `messages` only, and mark the row they cut back to (`SDK_CUT_PENDING_KEY`). + * The next turn cuts the SDK session to match (`./turn-plan`) — the regenerate that follows, + * or, if that never starts, whatever message the user sends next. 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' +import { markCutPending } from './turn-plan.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 + /** Files put back as they were. */ + filesRestored: number + /** Files the restore left alone because a link was in the way (`RewindFilesResult.skippedLinks`). */ + skippedLinks: number + } + | { success: false; error: string; rewindFailed?: true } + +type Restored = { filesRestored: number; skippedLinks: number } +const NOTHING_RESTORED: Restored = { filesRestored: 0, skippedLinks: 0 } + +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 } | Restored> { + 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 NOTHING_RESTORED + 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, skippedLinks: rewind.skippedLinks } +} + +type Tx = Parameters[0]>[0] + +/** Every row after `sequence` — the turns the cut drops. Sequence, not time: two rows can share a millisecond. */ +async function deleteAfter(tx: Tx, conversationId: string, sequence: number) { + await tx.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, metadata: markCutPending() }).where(eq(messages.id, target.id)) + await deleteAfter(tx, target.conversationId, target.sequence) + }) + return { success: true, conversationId: target.conversationId, ...prepared } +} + +/** 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 db.transaction(async (tx) => { + await tx.update(messages).set({ metadata: markCutPending() }).where(eq(messages.id, target.id)) + await deleteAfter(tx, target.conversationId, target.sequence) + }) + return { success: true, conversationId: target.conversationId, ...prepared } +} diff --git a/src/lib/chat/rewind-dialog.svelte.ts b/src/lib/chat/rewind-dialog.svelte.ts new file mode 100644 index 0000000..be7115f --- /dev/null +++ b/src/lib/chat/rewind-dialog.svelte.ts @@ -0,0 +1,90 @@ +/** + * #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 { restoreOutcomeNotice, 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 + /** Said after a restore that did not put every listed file back. Stays until dismissed. */ + notice: string | null +}>({ + pending: null, + checking: false, + notice: null, +}) + +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 + + rewindDialogState.notice = null + 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) +} + +/** + * Tell the user how an edit or regenerate's restore went, when it did not put back every + * file the dialog listed. Called with the edit or regenerate's result; says nothing otherwise. + */ +export function reportFileRestore(result: { success?: boolean; filesRestored?: number; skippedLinks?: number } | null | undefined): void { + if (!result || result.success !== true) return + rewindDialogState.notice = restoreOutcomeNotice(result) +} + +export function dismissFileRestoreNotice(): void { + rewindDialogState.notice = null +} diff --git a/src/lib/chat/rewind-plan.ts b/src/lib/chat/rewind-plan.ts new file mode 100644 index 0000000..be296b7 --- /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 0000000..fbc4799 --- /dev/null +++ b/src/lib/chat/rewind-preview.ts @@ -0,0 +1,113 @@ +/** + * #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 +} + +/** + * What to tell the user after a restore, or null when there is nothing to add. The CLI + * leaves a tracked file alone when a link is in the way — a symlink or hard link at the + * path, or a parent directory that moved — and only a real restore finds that out, so the + * dialog may have listed files that were not put back. + */ +export function restoreOutcomeNotice(result: { filesRestored?: number; skippedLinks?: number }): string | null { + const skipped = Math.max(0, result.skippedLinks ?? 0) + if (skipped === 0) return null + const restored = Math.max(0, result.filesRestored ?? 0) + const total = restored + skipped + const which = + skipped === 1 + ? 'One was not restored: it is a link, or its folder moved after this message.' + : `${skipped} were not restored: they are links, or their folders moved after this message.` + return `Restored ${restored} of ${total} ${total === 1 ? 'file' : 'files'}. ${which}` +} diff --git a/src/lib/chat/rewind.server.ts b/src/lib/chat/rewind.server.ts new file mode 100644 index 0000000..9111ed7 --- /dev/null +++ b/src/lib/chat/rewind.server.ts @@ -0,0 +1,261 @@ +/** + * #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 + /** The previewed files, less the ones the CLI refused. */ + filesRestored: number + /** Files left as they are because a link was in the way. The user is told. */ + 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.' } + /* + * `skippedLinks` counts tracked files the CLI refused to touch — a symlink or hard + * link at the path, or a parent directory that moved. Only a real rewind reports + * it (the dry run never does), so the preview's count includes them. + */ + const skippedLinks = Math.min(preview.files.length, Math.max(0, result.skippedLinks ?? 0)) + logger.info('[chat/rewind] files restored', { + conversationId: target.conversationId, + messageId: input.messageId, + files: preview.files.length, + skippedLinks, + }) + return { ok: true, filesRestored: preview.files.length - skippedLinks, skippedLinks } + }, + ) + }) + } 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/chat/turn-plan.server.ts b/src/lib/chat/turn-plan.server.ts new file mode 100644 index 0000000..b1d5588 --- /dev/null +++ b/src/lib/chat/turn-plan.server.ts @@ -0,0 +1,196 @@ +/** + * The database half of `./turn-plan`: what a turn sends and how it starts its session, + * and the join it leaves behind on the user row. + */ + +import { randomUUID } from 'node:crypto' +import { and, desc, eq, inArray, lt, lte, sql } from 'drizzle-orm' +import { db } from '$lib/db.server' +import { messages } from '$lib/sessions/sessions.schema' +import { logger } from '$lib/observability/logger' +import type { ChatAttachment } from '$lib/engine/attachments.server' +import type { TurnAttemptPlan } from '$lib/engine/turn-input' +import { + isSlashCommand, + readTailUuid, + resolveTurnResume, + SDK_CUT_PENDING_KEY, + SDK_TURN_KEY, + unansweredTail, + type TurnJoin, +} from './turn-plan' + +/** How much kept history a fallback preamble may draw on. `buildHistoryPreamble` trims further. */ +const KEPT_HISTORY_ROWS = 200 + +export type PlannedTurn = { + /** The text of the turn: the new message, or the edited / regenerated row's own content. */ + text: string + /** Its attachments, from the request or from the row. */ + attachments: ChatAttachment[] + attempts: TurnAttemptPlan +} + +/** Whether an edit or regenerate cut this conversation's rows back and no turn has cut the session to match. */ +const cutPending = sql`(${messages.metadata} ->> ${SDK_CUT_PENDING_KEY}::text) = 'true'` + +async function conversationHasPendingCut(conversationId: string): Promise { + const [row] = await db + .select({ id: messages.id }) + .from(messages) + .where(and(eq(messages.conversationId, conversationId), eq(messages.role, 'user'), cutPending)) + .limit(1) + return Boolean(row) +} + +/** + * Plan one turn. + * + * A regenerate never trusts the request body. The client used to send the placeholder + * `'regenerate'` as the content, and the server used it as the prompt; the prompt is now + * the pivot row's own text and attachments — after an edit, the edited text. + * + * A regenerate always cuts the session back to the kept history. So does an ordinary new + * message while an edit or regenerate's cut is still pending (`SDK_CUT_PENDING_KEY`): its + * reply never started — the request failed, or the server restarted — and the session + * still holds the turns the page no longer shows. + */ +export async function planTurn(input: { + conversationId: string + regenerate: boolean + sdkSessionId: string | null + /** + * The user row this turn answers (`resolveParentMessage`'s answer): for a regenerate the + * row being answered again, otherwise the one just inserted for the new message. + */ + pivotMessageId: string | null + body: { content?: string; attachments?: ChatAttachment[] } +}): Promise<{ ok: true; turn: PlannedTurn } | { ok: false; error: string }> { + const cut = input.regenerate || (input.pivotMessageId !== null && (await conversationHasPendingCut(input.conversationId))) + if (!cut) { + return { + ok: true, + turn: { + text: input.body.content ?? '', + attachments: input.body.attachments ?? [], + attempts: resolveTurnResume({ + cut: false, + sdkSessionId: input.sdkSessionId, + previousAssistant: null, + keptHistory: [], + mintUuid: randomUUID, + }), + }, + } + } + + const noPivot = input.regenerate ? 'There is no message to regenerate a reply to.' : 'The message to answer was not found.' + if (!input.pivotMessageId) return { ok: false, error: noPivot } + const [pivot] = await db + .select() + .from(messages) + .where(and(eq(messages.id, input.pivotMessageId), eq(messages.conversationId, input.conversationId))) + .limit(1) + if (!pivot || pivot.role !== 'user') return { ok: false, error: noPivot } + + const [previousAssistant] = await db + .select({ metadata: messages.metadata }) + .from(messages) + .where( + and( + eq(messages.conversationId, input.conversationId), + eq(messages.role, 'assistant'), + lt(messages.sequence, pivot.sequence), + ), + ) + .orderBy(desc(messages.sequence)) + .limit(1) + + const kept = await db + .select({ role: messages.role, content: messages.content }) + .from(messages) + .where( + and( + eq(messages.conversationId, input.conversationId), + inArray(messages.role, ['user', 'assistant']), + lt(messages.sequence, pivot.sequence), + ), + ) + .orderBy(desc(messages.sequence)) + .limit(KEPT_HISTORY_ROWS) + kept.reverse() + + // A new message sends what the request says, as it always has; a regenerate, the row's own. + const text = input.regenerate ? pivot.content : (input.body.content ?? '') + const attachments = input.regenerate ? (pivot.attachments ?? []) : (input.body.attachments ?? []) + const assistantMeta = previousAssistant?.metadata as Record | undefined + return { + ok: true, + turn: { + text, + attachments, + attempts: resolveTurnResume({ + cut: true, + sdkSessionId: input.sdkSessionId, + previousAssistant: previousAssistant + ? { + sdkSessionId: typeof assistantMeta?.sdkSessionId === 'string' ? assistantMeta.sdkSessionId : null, + sdkTailUuid: readTailUuid(assistantMeta), + } + : null, + keptHistory: kept, + // Nothing may go in front of a slash command; those rows stay on the page only. + unanswered: isSlashCommand(text) ? [] : unansweredTail(kept), + mintUuid: randomUUID, + }), + }, + } +} + +/** + * Stamp the user row with where its prompt landed in the transcript. Merged into the + * metadata rather than replacing it; a regenerate overwrites the old join, whose uuid now + * sits on a branch the session no longer follows. + * + * Also clears any pending cut up to that row: the turn's session now starts from the cut, + * or afresh, so the next message can resume it as it is. + */ +export async function recordTurnJoin(userMessageId: string, join: TurnJoin): Promise { + const [row] = await db + .update(messages) + .set({ metadata: sql`${messages.metadata} || ${JSON.stringify({ [SDK_TURN_KEY]: join })}::jsonb` }) + .where(and(eq(messages.id, userMessageId), eq(messages.role, 'user'))) + .returning({ conversationId: messages.conversationId, sequence: messages.sequence }) + if (!row) return + await db + .update(messages) + .set({ metadata: sql`${messages.metadata} - ${SDK_CUT_PENDING_KEY}::text` }) + .where( + and( + eq(messages.conversationId, row.conversationId), + eq(messages.role, 'user'), + lte(messages.sequence, row.sequence), + cutPending, + ), + ) +} + +/** + * Mark the user row an edit or regenerate cut the conversation back to. Written with the + * cut itself (`./message-branch.server`), in the same transaction. + */ +export function markCutPending() { + return sql`${messages.metadata} || ${JSON.stringify({ [SDK_CUT_PENDING_KEY]: true })}::jsonb` +} + +/** Fire-and-forget `recordTurnJoin`, for the stream's `onSessionId`. A failed write must not fail a turn. */ +export function recordTurnJoinInBackground(userMessageId: string | null, join: TurnJoin): void { + if (!userMessageId) return + void recordTurnJoin(userMessageId, join).catch((error) => + logger.warn('[chat/stream] failed to record the transcript join', { + messageId: userMessageId, + error: String(error), + }), + ) +} + diff --git a/src/lib/chat/turn-plan.ts b/src/lib/chat/turn-plan.ts new file mode 100644 index 0000000..a665cf6 --- /dev/null +++ b/src/lib/chat/turn-plan.ts @@ -0,0 +1,268 @@ +/** + * The join between AgentStudio's `messages` rows and the SDK transcript, and what a turn + * does with it (#24, and the edit/regenerate fix). + * + * The conversation lives in the SDK session (`conversations.sdk_session_id`); the rows are + * our copy of it. Edit and regenerate change the rows, and used to leave the session alone: + * the next turn resumed the full, unedited transcript and sent the literal word + * "regenerate" into it. The model never saw the edited text, and the reply the user threw + * away stayed in its context. + * + * Three keys on `messages.metadata` fix that, with no schema change: + * + * | Row | Key | What it holds | + * | --------- | --------------- | --------------------------------------------------------------- | + * | user | `sdkTurn` | the uuid its prompt carried into the transcript, the session, the working directory the run used, and whether files were checkpointed | + * | assistant | `sdkTailUuid` | the last transcript entry its turn wrote | + * | user | `sdkCutPending` | an edit or regenerate cut the rows back to this one, and no turn has cut the session to match yet | + * + * A regenerate of a user row resumes the session cut after the previous assistant row's + * `sdkTailUuid` — exactly the kept history — and sends the row's own text. So does the next + * ordinary message while a cut is pending: an edit whose reply never started (the request + * failed, the server restarted) must not leave the next message talking to the unedited + * session. A rewind restores files to the user row's `sdkTurn.uuid` (`./rewind.server`). + * + * Pure, so a spec can pin every branch without a database or a CLI. + */ + +import type { TurnAttempt, TurnAttemptPlan } from '$lib/engine/turn-input' + +/** `messages.metadata` key on a user row. */ +export const SDK_TURN_KEY = 'sdkTurn' +/** `messages.metadata` key on an assistant row. */ +export const SDK_TAIL_KEY = 'sdkTailUuid' +/** + * `messages.metadata` key on a user row: an edit or regenerate cut the rows back to it, and + * the SDK session still holds the turns it dropped. Set with the cut; cleared when a turn + * records its join, because by then that turn's session starts from the cut (or afresh). + */ +export const SDK_CUT_PENDING_KEY = 'sdkCutPending' + +/** Where one user row sits in the SDK transcript. */ +export type TurnJoin = { + /** The transcript uuid of the prompt — and the id its file checkpoint is keyed by. */ + uuid: string + /** The SDK session the prompt went into. */ + sessionId: string + /** The working directory the run used. A rewind must use the same one: the CLI stores paths under it relative to it. */ + cwd: string + /** Whether the run backed up files before changing them, so there is anything to rewind to. */ + checkpointed: boolean +} + +const isNonEmptyString = (value: unknown): value is string => typeof value === 'string' && value.length > 0 + +/** The user row's join, or null when it has none (it predates this, or its turn never started). */ +export function readTurnJoin(metadata: unknown): TurnJoin | null { + if (!metadata || typeof metadata !== 'object') return null + const raw = (metadata as Record)[SDK_TURN_KEY] + if (!raw || typeof raw !== 'object') return null + const join = raw as Record + if (!isNonEmptyString(join.uuid) || !isNonEmptyString(join.sessionId) || !isNonEmptyString(join.cwd)) return null + return { uuid: join.uuid, sessionId: join.sessionId, cwd: join.cwd, checkpointed: join.checkpointed === true } +} + +/** Whether an edit or regenerate cut the rows back to this user row and the session has not followed yet. */ +export function hasPendingCut(metadata: unknown): boolean { + if (!metadata || typeof metadata !== 'object') return false + return (metadata as Record)[SDK_CUT_PENDING_KEY] === true +} + +/** The assistant row's transcript tail, or null. */ +export function readTailUuid(metadata: unknown): string | null { + if (!metadata || typeof metadata !== 'object') return null + const tail = (metadata as Record)[SDK_TAIL_KEY] + return isNonEmptyString(tail) ? tail : null +} + +/** + * Whether a run's workspace outlives it, which is when checkpointing its files is worth + * anything. Mirrors `resolveWorkspaceRoot`'s priority: an agent's persistent directory + * wins; a worktree is per run; a project's checkout is stable; a bare chat gets a fresh + * `runs/` directory every turn, so there is never anything to go back to. + */ +export function supportsFileCheckpoints(ctx: { + persistentKey?: string | null + worktree?: unknown + projectId?: string | null +}): boolean { + if (ctx.persistentKey) return true + if (ctx.worktree) return false + return Boolean(ctx.projectId) +} + +/** A kept row, as the preamble reads it. */ +export type HistoryRow = { role: string; content: string } + +/** The preamble's size budget. A fallback, not a replay: the most recent turns matter most. */ +export const PREAMBLE_MAX_CHARS = 24_000 +const PREAMBLE_ROW_MAX_CHARS = 4_000 + +/** The rows as "User: …" / "Assistant: …" turns, the most recent kept within `maxChars`. Null when there are none. */ +function formatTurns(rows: HistoryRow[], maxChars: number): string | null { + const turns = rows + .filter((row) => (row.role === 'user' || row.role === 'assistant') && row.content.trim().length > 0) + .map((row) => { + const text = row.content.trim() + const clipped = text.length > PREAMBLE_ROW_MAX_CHARS ? `${text.slice(0, PREAMBLE_ROW_MAX_CHARS)} […]` : text + return `${row.role === 'user' ? 'User' : 'Assistant'}: ${clipped}` + }) + if (turns.length === 0) return null + + const kept: string[] = [] + let used = 0 + for (let i = turns.length - 1; i >= 0; i--) { + if (used + turns[i].length > maxChars && kept.length > 0) break + kept.unshift(turns[i]) + used += turns[i].length + } + const omitted = kept.length < turns.length ? '(Earlier messages omitted.)\n\n' : '' + return `${omitted}${kept.join('\n\n')}` +} + +/** + * The kept history as text, for a fresh session that stands in for one we could not cut — + * a conversation older than the join, or a cut the CLI refused. Most recent turns first to + * survive the budget; tool calls are not in `messages.content`, so they are not here. + * Null when there is nothing to keep. + */ +export function buildHistoryPreamble(rows: HistoryRow[], maxChars = PREAMBLE_MAX_CHARS): string | null { + const turns = formatTurns(rows, maxChars) + if (!turns) return null + return [ + '[Earlier in this conversation — restored as text because the session could not be rewound to this point.]', + '', + turns, + '', + '[End of the earlier conversation. The message to answer follows.]', + ].join('\n') +} + +/** + * The user rows after the last reply: sent, but never answered in the session a cut goes + * back to. Usually there are none. There are some when an edit's reply never started and the + * user wrote again, or when a turn failed before it saved a reply. + */ +export function unansweredTail(rows: HistoryRow[]): HistoryRow[] { + let lastReply = -1 + for (let i = rows.length - 1; i >= 0; i--) { + if (rows[i].role === 'assistant') { + lastReply = i + break + } + } + return rows.slice(lastReply + 1).filter((row) => row.role === 'user') +} + +/** + * The unanswered user rows as text, for a turn resumed at the last reply. The cut session + * holds everything up to that reply; these are what the page shows between it and the turn. + */ +export function buildUnansweredPreamble(rows: HistoryRow[], maxChars = PREAMBLE_MAX_CHARS): string | null { + const turns = formatTurns( + rows.filter((row) => row.role === 'user'), + maxChars, + ) + if (!turns) return null + return [ + '[Earlier messages in this conversation that did not get a reply:]', + '', + turns, + '', + '[End of the earlier messages. The message to answer follows.]', + ].join('\n') +} + +/** + * Whether a turn's text is a CLI slash command such as `/compact`. The CLI only reads a + * command from the very start of the prompt, so nothing may go in front of one. + */ +export function isSlashCommand(text: string): boolean { + return /^\/[A-Za-z]/.test(text.trimStart()) +} + +/** The previous assistant row, as far as the fork decision cares. */ +export type ForkPoint = { sdkSessionId: string | null; sdkTailUuid: string | null } + +/** + * How a turn starts its SDK session. + * + * - A new message resumes the session as it is. + * - A cut — an edit or regenerate, or the next message after an edit whose reply never + * started — resumes the session cut after the previous assistant row's tail, when that row + * belongs to the current session and recorded one. User rows between that reply and the + * turn (`unanswered`) go in front of the text, since the cut session never saw them. The + * fallback, used only if the CLI refuses the cut, is a fresh session primed with the kept + * history. + * - Otherwise — the first message, a conversation older than the join, a turn that ended + * in a compaction — the cut starts a fresh session with that preamble straight away. + * + * `forkSession` is deliberately never used: a forked session starts without the file + * history (`forkSession()`'s own doc says so), and keeping the same session id is what lets + * a later rewind still find every checkpoint. + */ +export function resolveTurnResume(input: { + /** The session must be cut back to the kept history first. */ + cut: boolean + sdkSessionId: string | null + previousAssistant: ForkPoint | null + /** Every kept user and assistant row before the turn, oldest first. */ + keptHistory: HistoryRow[] + /** The kept user rows after `previousAssistant` (`unansweredTail`). Empty for a slash command. */ + unanswered?: HistoryRow[] + mintUuid: () => string +}): TurnAttemptPlan { + if (!input.cut) { + return { + first: { + kind: 'continue', + ...(input.sdkSessionId ? { resumeSessionId: input.sdkSessionId } : {}), + preamble: null, + sdkUserUuid: input.mintUuid(), + }, + fallback: null, + } + } + + const fresh = (): TurnAttempt => ({ + kind: 'fresh', + preamble: buildHistoryPreamble(input.keptHistory), + sdkUserUuid: input.mintUuid(), + }) + + const point = input.previousAssistant + if (input.sdkSessionId && point?.sdkTailUuid && point.sdkSessionId === input.sdkSessionId) { + return { + first: { + kind: 'fork', + resumeSessionId: input.sdkSessionId, + resumeSessionAt: point.sdkTailUuid, + preamble: buildUnansweredPreamble(input.unanswered ?? []), + sdkUserUuid: input.mintUuid(), + }, + fallback: fresh(), + } + } + return { first: fresh(), fallback: null } +} + +/** A prompt as `prepareAttachmentPrompt` returns it. */ +type PreparedContent = { text: string; content: Block[] | null } + +/** + * The content an attempt sends: the prepared prompt with the preamble, if any, in front. + * Content blocks (images) stay blocks; the preamble joins the first text block. + */ +export function turnPromptContent( + prepared: PreparedContent, + preamble: string | null, +): string | Block[] { + if (!prepared.content) return preamble ? `${preamble}\n\n${prepared.text}` : prepared.text + if (!preamble) return prepared.content + const blocks = [...prepared.content] + const first = blocks.findIndex((block) => block.type === 'text') + if (first === -1) return [{ type: 'text', text: preamble } as unknown as Block, ...blocks] + const block = blocks[first] as unknown as { type: 'text'; text: string } + blocks[first] = { ...block, text: `${preamble}\n\n${block.text}` } as unknown as Block + return blocks +} diff --git a/src/lib/engine/options.server.ts b/src/lib/engine/options.server.ts index cdbc8cb..3d8ef1b 100644 --- a/src/lib/engine/options.server.ts +++ b/src/lib/engine/options.server.ts @@ -98,6 +98,18 @@ export type EngineOptionsInput = { cwd?: string /** Resume a prior SDK session instead of starting a new one. */ resumeSessionId?: string + /** + * With `resumeSessionId`: resume only up to and including this transcript entry — how an + * edited or regenerated turn drops the turns after it (`./turn-input`). Ignored without + * `resumeSessionId`, as the SDK ignores it. + */ + resumeSessionAt?: string + /** + * #24 — back up files before the SDK's file tools change them, so `Query.rewindFiles()` + * can restore them to any user message later (`./rewind.server`). Only worth it for a + * workspace that outlives the turn; see `supportsFileCheckpoints`. + */ + fileCheckpointing?: boolean /** * Whether this run's project has its committed settings marked trusted * (`projects.settings_trusted`). Decides whether the repo's `CLAUDE.md`, commands and @@ -245,6 +257,8 @@ export function buildEngineOptions(input: EngineOptionsInput): Options { maxTurns: input.maxTurns ?? 64, ...(input.cwd ? { cwd: input.cwd } : {}), ...(input.resumeSessionId ? { resume: input.resumeSessionId } : {}), + ...(input.resumeSessionId && input.resumeSessionAt ? { resumeSessionAt: input.resumeSessionAt } : {}), + ...(input.fileCheckpointing ? { enableFileCheckpointing: true } : {}), // Needed for token-level `delta` frames; without it text only arrives in // whole-message chunks and the UI loses its typing effect. includePartialMessages: true, diff --git a/src/lib/engine/rewind.server.ts b/src/lib/engine/rewind.server.ts new file mode 100644 index 0000000..5cbc7a4 --- /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/lib/engine/stream.server.ts b/src/lib/engine/stream.server.ts index b382689..b10b915 100644 --- a/src/lib/engine/stream.server.ts +++ b/src/lib/engine/stream.server.ts @@ -45,6 +45,7 @@ import { toolResultDetails, type ToolResultDetails } from './tool-result-details import { toolResultText } from './tool-result-content' import { interpretSdkMessage } from './sdk-notices' import { readTurnUsage, resultErrorMessage, type EngineUsage, type SessionUsage } from './run-result' +import { nextTranscriptTail } from './turn-input' import type { EngineQueryHandle } from './run-registry.server' import type { StreamBlock } from '$lib/runs/runs.schema' import type { Options } from '@anthropic-ai/claude-agent-sdk' @@ -220,6 +221,12 @@ export type EngineRunSummary = { * frames. The SDK calls these estimates, so treat them as such. */ reasoningTokens: number + /** + * The uuid of the last main-thread chain entry this turn wrote to the transcript, or null + * when there is none to cut after (see `./turn-input`). What a later edit or regenerate + * of the NEXT message passes as `resumeSessionAt`. + */ + sdkTailUuid: string | null } /** @@ -235,6 +242,8 @@ export async function runEngineStream(input: EngineRunInput): Promise { + yield { + type: 'user', + message: { role: 'user', content } as unknown as SDKUserMessage['message'], + parent_tool_use_id: null, + uuid: uuid as SDKUserMessage['uuid'], + } +} + +/** + * The transcript's last top-level chain entry after `msg`, given the one before it. + * + * Counts the main thread's assistant and user messages — the entries a later + * `resumeSessionAt` can cut after. Everything else is left alone: + * + * - a subagent's messages (`parent_tool_use_id` set) live in the child's sidechain; + * - a replay (`isReplay`) echoes an entry that is already in the chain; + * - `stream_event`, `result` and the system notices are not chain entries at all. + * + * A compaction boundary clears the tail: nothing before it is a place a later resume can + * cut to. The bundled CLI (0.3.278, checked against a stub Messages API) then streams the + * summary it starts the session again from as main-thread `user` entries, and those set + * the tail again — so an edit of the message after a `/compact` cuts right after the + * summary and keeps it. A cut at an entry from before the boundary is refused with + * "No message found with message.uuid", which `runWithResumeFallback` answers with a fresh + * session; if a compaction ever ends a turn with nothing after it, the tail stays null and + * the edit starts a fresh session straight away. + */ +export function nextTranscriptTail(current: string | null, msg: Record): string | null { + if (typeof msg.parent_tool_use_id === 'string' && msg.parent_tool_use_id.length > 0) return current + if (msg.type === 'system' && msg.subtype === 'compact_boundary') return null + if (msg.type !== 'assistant' && msg.type !== 'user') return current + if (msg.isReplay === true) return current + return typeof msg.uuid === 'string' && msg.uuid.length > 0 ? msg.uuid : current +} + +/** How one attempt at a turn starts its SDK session. */ +export type TurnAttempt = { + /** + * - `continue`: an ordinary new message — resume the session where it is. + * - `fork`: an edit or regenerate — resume, cut after `resumeSessionAt`. + * - `fresh`: a new session, optionally primed with the kept history as text. + */ + kind: 'continue' | 'fork' | 'fresh' + resumeSessionId?: string + resumeSessionAt?: string + /** Kept history, as text, for a fresh session that replaces one it could not cut. */ + preamble: string | null + /** The uuid this attempt's user message carries into the transcript. */ + sdkUserUuid: string +} + +/** The first attempt, and what to run instead if the CLI refuses its truncating resume. */ +export type TurnAttemptPlan = { first: TurnAttempt; fallback: TurnAttempt | null } + +/** + * `options` with the session start `attempt` asks for, and nothing left over from another. + * + * `buildEngineOptions` already set `resume` for the first attempt; a fallback must drop it, + * or the "fresh" session would resume the one it is replacing. + */ +export function withTurnResume(options: Options, attempt: TurnAttempt): Options { + const { resume: _resume, resumeSessionAt: _at, ...rest } = options + return { + ...rest, + ...(attempt.resumeSessionId ? { resume: attempt.resumeSessionId } : {}), + ...(attempt.resumeSessionId && attempt.resumeSessionAt ? { resumeSessionAt: attempt.resumeSessionAt } : {}), + } +} + +/** + * Whether an error is the CLI refusing to resume — which happens at boot, before any + * model call. The texts are the CLI's own (print-mode resume path, checked against the + * bundled `claude` 0.3.278): a `resumeSessionAt` uuid that is not in the loaded chain, the + * `resumeDropsTurn` guard, and a transcript that could not be loaded. + */ +export function isResumeRefusal(error: string | null | undefined): boolean { + if (!error) return false + return /No message found with message\.uuid|Resume rejected by --resume-drops-turn|Failed to resume session|No conversation found with session ID/i.test( + error, + ) +} + +/** The fields of a run summary the fallback decision reads. */ +type AttemptOutcome = { error: string | null; text: string; blocks: unknown[] } + +/** + * Run a turn, and when its truncating resume is refused, run it once more on the fallback. + * + * Only a refusal that produced nothing is retried: text or a tool call means the turn really + * ran, and running it again would repeat its side effects. Only once: the fallback does not + * cut anything, so it cannot be refused the same way. + */ +export async function runWithResumeFallback( + plan: TurnAttemptPlan, + run: (attempt: TurnAttempt) => Promise, + onFallback?: (reason: string) => void, +): Promise { + const fallback = plan.fallback + let outcome: T + try { + outcome = await run(plan.first) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (!fallback || !isResumeRefusal(message)) throw error + onFallback?.(message) + return run(fallback) + } + // A notice is the CLI talking about the session, not the turn doing anything. + const producedNothing = + outcome.text.trim().length === 0 && + outcome.blocks.every((block) => (block as { kind?: unknown } | null)?.kind === 'notice') + if (fallback && producedNothing && isResumeRefusal(outcome.error)) { + onFallback?.(outcome.error ?? '') + return run(fallback) + } + return outcome +} diff --git a/src/routes/chat/[id]/+page.svelte b/src/routes/chat/[id]/+page.svelte index fdd168e..8b41c0e 100644 --- a/src/routes/chat/[id]/+page.svelte +++ b/src/routes/chat/[id]/+page.svelte @@ -30,6 +30,9 @@ 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, reportFileRestore } from '$lib/chat/rewind-dialog.svelte'; + import { compactCommand, compactSwitchNotice } from '$lib/chat/compact-command'; import ChatErrorNotice from '$lib/chat/ChatErrorNotice.svelte'; import { shouldShowModelTag } from '$lib/chat/message-bubble-helpers'; import ToolCallCard from '$lib/chat/ToolCallCard.svelte'; @@ -1258,15 +1261,21 @@ if (runId && !streaming) attachToRun(runId); }); - async function handleEdit(messageId: string, content: string) { + /** 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(); + reportFileRestore(result); // Editing creates a new branch point. Clear optimistic remnants so // old assistant drafts cannot be re-shown after the server truncates history. pendingAssistantDrafts = []; @@ -1279,7 +1288,8 @@ stopThinkingInterpolation(); await refreshAll(); - await streamMessage('regenerate', true); + // The server answers the edited row itself; the content sent here is never the prompt. + await streamMessage('', true); } catch (error) { setRecoverableError( error instanceof Error ? error.message : 'Unable to edit message', @@ -1287,14 +1297,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', @@ -1304,6 +1317,7 @@ return; } clearRecoverableError(); + reportFileRestore(result); pendingAssistantDrafts = []; pendingMessageId = null; streamingBlocks = []; @@ -1312,7 +1326,7 @@ stopDraftInterpolation(); stopThinkingInterpolation(); await refreshAll(); - await streamMessage('regenerate', true); + await streamMessage('', true); } catch (error) { setRecoverableError( error instanceof Error ? error.message : 'Unable to regenerate response', @@ -1342,9 +1356,9 @@ 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(compactCommand({ handoff: true }), false); + modelSwitchNotice = compactSwitchNotice({ failed: Boolean(streamError), from: currentModel, to: nextModel }); setTimeout(() => { modelSwitchNotice = null; }, 5000); @@ -1353,10 +1367,10 @@ model = nextModel; } + /** Finding 80 — the CLI's own `/compact`, which really shrinks the session (`$lib/chat/compact-command`). */ 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(compactCommand(), false); } /* @@ -1572,6 +1586,7 @@ onEdit={handleEdit} onRegenerate={handleRegenerate} canRegenerate={!streaming && message.id === lastUserMessageId} + canEdit={!streaming} modelChanged={shouldShowModelTag(displayedMessages, i)} /> {/each} @@ -1736,6 +1751,7 @@ + diff --git a/src/routes/chat/[id]/stream/+server.ts b/src/routes/chat/[id]/stream/+server.ts index d5acd64..b4505ff 100644 --- a/src/routes/chat/[id]/stream/+server.ts +++ b/src/routes/chat/[id]/stream/+server.ts @@ -76,9 +76,12 @@ import { resolveToolScope } from '$lib/engine/tool-scope' import { formatAttachmentWarnings, prepareAttachmentPrompt, - singleUserMessageStream, type ChatAttachment, } from '$lib/engine/attachments.server' +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' @@ -113,6 +116,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 { @@ -149,6 +156,21 @@ export const POST: RequestHandler = async ({ request, locals }) => { if (!parentResult.ok) return json({ error: parentResult.error }, { status: 400 }) const parentMessageId = parentResult.parentMessageId + /* + * What this turn sends, and how it starts its SDK session. A regenerate — after an edit + * too — sends the user row's own text and attachments and cuts the session back to the + * turn before it, so the model sees exactly the kept history. See `$lib/chat/turn-plan`. + */ + const planned = await planTurn({ + conversationId: body.conversationId, + regenerate: Boolean(body.regenerate), + sdkSessionId: conversation.sdkSessionId, + pivotMessageId: parentMessageId, + body, + }) + if (!planned.ok) return json({ error: planned.error }, { status: 400 }) + const turn = planned.turn + // A conversation with no SDK session yet is on its first exchange as far as the // engine is concerned, which is also when the title gets generated. const isFirstExchange = !conversation.sdkSessionId && !body.regenerate @@ -194,7 +216,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { const skillSummariesText = await buildSkillSummariesText({ userId: user.id, - userQuery: body.content, + userQuery: turn.text, skillTopK: resolveSkillTopK(currentSettings), }) if (skillSummariesText) { @@ -209,7 +231,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { const memorySlot = await buildMemoryRecallSlot({ settings: currentSettings, userId: user.id, - userQuery: body.content, + userQuery: turn.text, }) if (memorySlot) contextSlots.push(memorySlot) @@ -391,8 +413,8 @@ export const POST: RequestHandler = async ({ request, locals }) => { * warning the user actually sees instead of a silent drop. */ const preparedPrompt = await prepareAttachmentPrompt({ - text: body.content ?? '', - attachments: body.attachments, + text: turn.text, + attachments: turn.attachments, availableTools: scopedTools ? new Set(scopedTools) : null, io: createAttachmentIo(workspace.context), }).catch(abandonSetup) @@ -431,6 +453,8 @@ export const POST: RequestHandler = async ({ request, locals }) => { parentIsClaude: isClaudeModel(routedModel), }).catch(abandonSetup) const toolScope = resolveToolScope(scopedTools, { delegation: Object.keys(subagents).length > 0 }) + // #24 — back files up before the agent changes them, where the workspace outlives the turn. + const fileCheckpointing = supportsFileCheckpoints(workspace.context) let engineOptions try { @@ -444,7 +468,9 @@ export const POST: RequestHandler = async ({ request, locals }) => { cwd: workspace.root, permissionMode: permission.mode, runSource: RUN_SOURCE, - resumeSessionId: conversation.sdkSessionId ?? undefined, + resumeSessionId: turn.attempts.first.resumeSessionId, + resumeSessionAt: turn.attempts.first.resumeSessionAt, + fileCheckpointing, tools: { userId: user.id, runId: run.id, @@ -555,12 +581,11 @@ export const POST: RequestHandler = async ({ request, locals }) => { */ if (attachmentNotice) await emit('delta', { content: attachmentNotice }) - const summary = await runEngineStream( + const summary = await runWithResumeFallback(turn.attempts, async (attempt) => runEngineStream( { - prompt: preparedPrompt.content - ? singleUserMessageStream(preparedPrompt.content) - : preparedPrompt.text, - options: engineOptions, + // One user message with a uuid we chose: the row's join to the transcript. + prompt: userTurnMessages(turnPromptContent(preparedPrompt, attempt.preamble), attempt.sdkUserUuid), + options: withTurnResume(engineOptions, attempt), emit, /* * Every completed call gets a ledger row. Before this, only `web_search` @@ -625,9 +650,15 @@ export const POST: RequestHandler = async ({ request, locals }) => { .catch((error) => logger.warn('[chat/stream] failed to persist sdkSessionId', { error: String(error) }), ) + recordTurnJoinInBackground(parentMessageId, { + uuid: attempt.sdkUserUuid, + sessionId, + cwd: workspace.root, + checkpointed: fileCheckpointing, + }) }, // What the resumed session had already spent, so this turn logs its own share. - usageBaseline: await loadSessionUsageBaseline(body.conversationId, conversation.sdkSessionId), + usageBaseline: await loadSessionUsageBaseline(body.conversationId, attempt.resumeSessionId), // Settings-only view; `permissionMode` composes with it inside the engine's // `resolveToolGate`, which is what decides allow / ask / deny. requiresApproval: (name) => @@ -657,7 +688,12 @@ export const POST: RequestHandler = async ({ request, locals }) => { return approved ? { allow: true } : { allow: false, reason: 'Denied by user' } } : undefined, - }) + }), (reason) => + logger.warn('[chat/stream] the session could not be cut back; starting a fresh one', { + runId: run.id, + reason, + }), + ) // Fold the attachment notice into the persisted turn so the reloaded // message matches what the user watched stream in. @@ -718,6 +754,8 @@ export const POST: RequestHandler = async ({ request, locals }) => { tokensCacheRead: summary.usage.cacheReadTokens, runId: run.id, sdkSessionId: summary.sessionId, + // Where a later edit of the next message cuts the session back to. + sdkTailUuid: summary.sdkTailUuid ?? undefined, // The next turn's usage baseline — see `loadSessionUsageBaseline`. sessionUsage: summary.sessionUsage ?? undefined, numTurns: summary.numTurns, @@ -735,7 +773,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { maybeGenerateTitle({ isFirstExchange, - userContent: body.content ?? '', + userContent: turn.text, assistantContent: summary.text, conversationId: body.conversationId, }) @@ -760,7 +798,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { runId: run.id, userId: user.id, conversationId: body.conversationId, - userContent: body.content, + userContent: turn.text, assistantContent: summary.text, toolCalls: [], }) diff --git a/tests/chat.compact.spec.ts b/tests/chat.compact.spec.ts new file mode 100644 index 0000000..c4c4120 --- /dev/null +++ b/tests/chat.compact.spec.ts @@ -0,0 +1,84 @@ +import { expect, test } from '@playwright/test' +import { + authenticateContext, + cleanupPrefixedRecords, + getActiveUserId, + seedConversation, + uniquePrefix, + waitForHydration, +} from './helpers' +import { sse } from './chat-stream-script' +import { COMPACT_INSTRUCTIONS, compactCommand, compactSwitchNotice } from '../src/lib/chat/compact-command' + +/** + * Finding 80: "Compact Conversation" runs the CLI's own `/compact`, which really replaces the + * session's history with a summary. It used to post an ordinary message asking the model for + * a summary, which the session then carried on top of the history it was meant to replace. + * + * The run is scripted in the browser, so no model is involved; what matters is what the page + * sends. + */ + +test.describe('the /compact prompt', () => { + test('is the command itself, first thing in the prompt, with what to keep', () => { + expect(compactCommand()).toBe(`/compact ${COMPACT_INSTRUCTIONS}`) + const handoff = compactCommand({ handoff: true }) + expect(handoff.startsWith('/compact ')).toBe(true) + expect(handoff).toContain('smaller context window') + }) + + test('the notice before a model switch says whether the compaction happened', () => { + expect(compactSwitchNotice({ failed: false, from: 'anthropic/claude-opus-4', to: 'anthropic/claude-haiku-4' })).toBe( + 'Compacted the conversation on claude-opus-4 before switching to claude-haiku-4.', + ) + expect(compactSwitchNotice({ failed: true, from: 'anthropic/claude-opus-4', to: 'anthropic/claude-haiku-4' })).toBe( + 'Compacting before the switch to claude-haiku-4 failed; the full conversation is still in context.', + ) + }) +}) + +test('Compact Conversation sends /compact as a new turn, not a request for a summary', async ({ page }) => { + test.setTimeout(90_000) + const prefix = uniquePrefix('chat-compact') + 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: Array<{ content?: string; regenerate?: boolean }> = [] + await page.route( + (url) => url.pathname === `/chat/${conversation.id}/stream`, + (route) => { + sends.push(route.request().postDataJSON()) + return route.fulfill({ + status: 200, + headers: { 'content-type': 'text/event-stream' }, + body: sse([{ id: 1, event: 'done', data: { error: 'scripted end' } }]), + }) + }, + ) + + try { + // The context ring with its Compact button sits in the phone and tablet header. + const viewport = page.viewportSize()! + if (viewport.width >= 1280) await page.setViewportSize({ width: 1024, height: viewport.height }) + await page.goto(`/chat/${conversation.id}`, { waitUntil: 'domcontentloaded' }) + await waitForHydration(page) + + await page.getByRole('button', { name: /Context window usage/ }).filter({ visible: true }).first().click() + const compact = page.getByRole('button', { name: 'Compact Conversation' }).filter({ visible: true }).first() + await expect(compact).toBeVisible() + await compact.click() + + await expect.poll(() => sends.length, { timeout: 30_000 }).toBe(1) + expect(sends[0].regenerate).toBe(false) + expect(sends[0].content).toBe(compactCommand()) + expect(sends[0].content?.startsWith('/compact ')).toBe(true) + expect(sends[0].content).not.toContain('Please compact this conversation') + } finally { + await page.unrouteAll({ behavior: 'ignoreErrors' }) + await cleanupPrefixedRecords(prefix) + } +}) diff --git a/tests/chat.composer-mentions.spec.ts b/tests/chat.composer-mentions.spec.ts index becca0d..d944139 100644 --- a/tests/chat.composer-mentions.spec.ts +++ b/tests/chat.composer-mentions.spec.ts @@ -16,6 +16,8 @@ import { import { sse } from './chat-stream-script' import { SUBSCRIPTION_MODEL_IDS } from '../src/lib/engine/model-backend' import { claudeDisplayName } from '../src/lib/llm/engine-models' +// The Compact button's prompt (#24): the CLI's own `/compact`, not a request for a summary. +import { compactCommand as sdkCompactPrompt } from '../src/lib/chat/compact-command' /** * #22 — `@` file mentions and the `/` command palette, driven through the chat page on both @@ -375,7 +377,8 @@ test.describe('chat composer — / commands', () => { await composer.press('Enter') await expect.poll(() => sends.length, { timeout: 15_000 }).toBe(1) - expect(sends[0].content).toMatch(/^Please compact this conversation/) + // The button's handler sends the CLI's own `/compact` (#24), so the palette does too. + expect(sends[0].content).toBe(sdkCompactPrompt()) await expect(composer).toHaveValue('') } finally { await cleanup(prefix, seeded) diff --git a/tests/chat.edit-regenerate.spec.ts b/tests/chat.edit-regenerate.spec.ts new file mode 100644 index 0000000..51b8705 --- /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 0000000..b595945 --- /dev/null +++ b/tests/chat.regenerate-fork.spec.ts @@ -0,0 +1,667 @@ +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 { hasPendingCut, 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 } +} + +/** A new user row at the end of the thread, as `resolveParentMessage` inserts one for a new message. */ +async function appendUserRow(conversationId: string, content: string) { + const sql = getSql() + const [row] = await sql<{ id: string }[]>` + insert into messages (conversation_id, role, content, metadata, tool_calls, attachments, sequence) + select ${conversationId}, 'user', ${content}, '{}'::jsonb, '[]'::jsonb, '[]'::jsonb, coalesce(max(sequence), 0) + 1 + from messages where conversation_id = ${conversationId} + returning id + ` + return row.id +} + +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('clears a pending cut on its row and the rows before it, in its own conversation only', async () => { + const prefix = uniquePrefix('regen-fork-join-clears') + await cleanupPrefixedRecords(prefix) + try { + const pending = { sdkCutPending: true, keep: 'me' } + const mine = await seedThread(prefix, [ + { role: 'user', content: 'edited', metadata: pending }, + { role: 'user', content: 'sent after it' }, + ]) + const other = await seedThread(prefix, [{ role: 'user', content: 'elsewhere', metadata: pending }]) + await recordTurnJoin(mine.ids[1], { uuid: randomUUID(), sessionId: SESSION, cwd: '/w', checkpointed: false }) + + const [edited] = await rows(mine.conversationId) + expect(hasPendingCut(edited.metadata)).toBe(false) + expect(edited.metadata.keep).toBe('me') + expect(hasPendingCut((await rows(other.conversationId))[0].metadata)).toBe(true) + } 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, skippedLinks: 0 }) + expect((await rows(conversationId)).map((row) => row.content)).toEqual(['first', 'reply one', 'change them differently']) + // The session still holds the dropped turn until a turn cuts it back. + expect(hasPendingCut((await rows(conversationId))[2].metadata)).toBe(true) + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test('an edit whose reply never started: the next new message still cuts the session back', async () => { + const prefix = uniquePrefix('regen-fork-edit-then-send') + await cleanupPrefixedRecords(prefix) + try { + const { userId, conversationId, ids } = await checkpointedThread(prefix) + // Edit U2 to U2'. The regenerate that should follow never reaches the server. + expect((await editUserMessage({ userId, messageId: ids[2], content: 'change them differently' })).success).toBe(true) + + // The user ignores Retry and writes U3. + const u3 = await appendUserRow(conversationId, 'and add a test') + const planned = await planTurn({ + conversationId, + regenerate: false, + sdkSessionId: SESSION, + pivotMessageId: u3, + body: { content: 'and add a test' }, + }) + expect(planned.ok).toBe(true) + if (!planned.ok) return + expect(planned.turn.text).toBe('and add a test') + // Cut after "reply one": the original U2 and the discarded "changed them" are gone. + expect(planned.turn.attempts.first).toMatchObject({ kind: 'fork', resumeSessionId: SESSION, resumeSessionAt: 'tail-1' }) + // U2' was never answered in that session, so it goes in front of U3. + expect(planned.turn.attempts.first.preamble).toContain('User: change them differently') + expect(planned.turn.attempts.first.preamble).not.toContain('change the files') + expect(planned.turn.attempts.fallback?.preamble).toContain('User: change them differently') + expect(planned.turn.attempts.fallback?.preamble).not.toContain('changed them') + + // Once the turn records its join, the cut is done: the next message resumes as it is. + await recordTurnJoin(u3, { uuid: randomUUID(), sessionId: SESSION, cwd: '/w', checkpointed: false }) + expect((await rows(conversationId)).some((row) => hasPendingCut(row.metadata))).toBe(false) + const u4 = await appendUserRow(conversationId, 'thanks') + const next = await planTurn({ conversationId, regenerate: false, sdkSessionId: SESSION, pivotMessageId: u4, body: { content: 'thanks' } }) + expect(next.ok && next.turn.attempts.first).toMatchObject({ kind: 'continue', resumeSessionId: SESSION, preamble: null }) + expect(next.ok && next.turn.attempts.first).not.toHaveProperty('resumeSessionAt') + } finally { + await cleanupPrefixedRecords(prefix) + } + }) + + test('a regenerate marks its row too, and a slash command sent after it gets nothing in front', async () => { + const prefix = uniquePrefix('regen-fork-regen-then-compact') + await cleanupPrefixedRecords(prefix) + try { + const { userId, conversationId, ids } = await checkpointedThread(prefix) + expect((await truncateAfterMessage({ userId, conversationId, messageId: ids[2] })).success).toBe(true) + const after = await rows(conversationId) + expect(after.map((row) => row.content)).toEqual(['first', 'reply one', 'change the files']) + expect(hasPendingCut(after[2].metadata)).toBe(true) + + // "Compact Conversation" before the regenerate ran: cut, but `/compact` must stay a command. + const compact = await appendUserRow(conversationId, '/compact Preserve everything.') + const planned = await planTurn({ + conversationId, + regenerate: false, + sdkSessionId: SESSION, + pivotMessageId: compact, + body: { content: '/compact Preserve everything.' }, + }) + expect(planned.ok && planned.turn.attempts.first).toMatchObject({ kind: 'fork', resumeSessionAt: 'tail-1', preamble: null }) + } 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, skippedLinks: 0 }) + 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('files the CLI would not restore because of a link are reported, not counted as restored', async () => { + const prefix = uniquePrefix('regen-fork-skipped-links') + await cleanupPrefixedRecords(prefix) + try { + const { userId, conversationId, ids, cwd } = await checkpointedThread(prefix) + const files = [join(cwd, 'a.txt'), join(cwd, 'b.txt'), join(cwd, 'c.txt')] + // Only a real rewind reports `skippedLinks`; the dry run never does. + const control = stubControl((_id, dryRun) => + dryRun ? { canRewind: true, filesChanged: files } : { canRewind: true, filesChanged: files, skippedLinks: 2 }, + ) + const result = await truncateAfterMessage( + { userId, conversationId, messageId: ids[2], restoreFiles: true }, + { createQuery: control.createQuery, sandboxRoot }, + ) + expect(result).toEqual({ success: true, conversationId, filesRestored: 1, skippedLinks: 2 }) + } 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/chat.turn-plan.spec.ts b/tests/chat.turn-plan.spec.ts new file mode 100644 index 0000000..31937ae --- /dev/null +++ b/tests/chat.turn-plan.spec.ts @@ -0,0 +1,254 @@ +import { expect, test } from '@playwright/test' +import { + buildHistoryPreamble, + buildUnansweredPreamble, + hasPendingCut, + isSlashCommand, + readTailUuid, + readTurnJoin, + resolveTurnResume, + supportsFileCheckpoints, + turnPromptContent, + unansweredTail, +} from '../src/lib/chat/turn-plan' + +/** + * The join between `messages` rows and the SDK transcript, and the decision it feeds: how an + * edited or regenerated turn starts its session so the model sees exactly the kept history + * plus the row's own text. Pure; the database half is `chat.regenerate-fork.spec.ts`. + */ + +function uuids() { + let n = 0 + return () => `uuid-${++n}` +} + +test.describe('resolveTurnResume', () => { + test('a new message resumes the session as it is', () => { + const plan = resolveTurnResume({ + cut: false, + sdkSessionId: 's1', + previousAssistant: null, + keptHistory: [], + mintUuid: uuids(), + }) + expect(plan.first).toEqual({ kind: 'continue', resumeSessionId: 's1', preamble: null, sdkUserUuid: 'uuid-1' }) + expect(plan.fallback).toBeNull() + }) + + test('the first message of a conversation starts a session', () => { + const plan = resolveTurnResume({ + cut: false, + sdkSessionId: null, + previousAssistant: null, + keptHistory: [], + mintUuid: uuids(), + }) + expect(plan.first.kind).toBe('continue') + expect(plan.first).not.toHaveProperty('resumeSessionId') + }) + + test('a regenerate cuts the session after the previous reply, with a fresh session as the fallback', () => { + const plan = resolveTurnResume({ + cut: true, + sdkSessionId: 's1', + previousAssistant: { sdkSessionId: 's1', sdkTailUuid: 'tail-1' }, + keptHistory: [ + { role: 'user', content: 'What is 2+2?' }, + { role: 'assistant', content: '4' }, + ], + mintUuid: uuids(), + }) + expect(plan.first).toEqual({ + kind: 'fork', + resumeSessionId: 's1', + resumeSessionAt: 'tail-1', + preamble: null, + sdkUserUuid: 'uuid-1', + }) + expect(plan.fallback?.kind).toBe('fresh') + expect(plan.fallback?.preamble).toContain('User: What is 2+2?') + expect(plan.fallback?.preamble).toContain('Assistant: 4') + // Never the same uuid twice: the refused attempt may have left its own in the transcript. + expect(plan.fallback?.sdkUserUuid).not.toBe(plan.first.sdkUserUuid) + }) + + test('regenerating the first message starts a fresh session with nothing to carry', () => { + const plan = resolveTurnResume({ + cut: true, + sdkSessionId: 's1', + previousAssistant: null, + keptHistory: [], + mintUuid: uuids(), + }) + expect(plan.first).toEqual({ kind: 'fresh', preamble: null, sdkUserUuid: 'uuid-1' }) + expect(plan.fallback).toBeNull() + }) + + test('a previous reply without a tail — older than the join — gets the history as text', () => { + const plan = resolveTurnResume({ + cut: true, + sdkSessionId: 's1', + previousAssistant: { sdkSessionId: 's1', sdkTailUuid: null }, + keptHistory: [ + { role: 'user', content: 'first' }, + { role: 'assistant', content: 'reply' }, + ], + mintUuid: uuids(), + }) + expect(plan.first.kind).toBe('fresh') + expect(plan.first.preamble).toContain('User: first') + expect(plan.fallback).toBeNull() + }) + + test('a cut puts the user rows the kept session never answered in front of the text', () => { + const keptHistory = [ + { role: 'user', content: 'What is 2+2?' }, + { role: 'assistant', content: '4' }, + { role: 'user', content: 'What is 3+3?' }, + ] + const plan = resolveTurnResume({ + cut: true, + sdkSessionId: 's1', + previousAssistant: { sdkSessionId: 's1', sdkTailUuid: 'tail-1' }, + keptHistory, + unanswered: unansweredTail(keptHistory), + mintUuid: uuids(), + }) + expect(plan.first).toMatchObject({ kind: 'fork', resumeSessionId: 's1', resumeSessionAt: 'tail-1' }) + expect(plan.first.preamble).toContain('User: What is 3+3?') + expect(plan.first.preamble).not.toContain('2+2') + // The fresh fallback carries the whole kept history, the unanswered row included. + expect(plan.fallback?.preamble).toContain('User: What is 2+2?') + expect(plan.fallback?.preamble).toContain('User: What is 3+3?') + }) + + test('a tail from an earlier session is not a place to cut this one', () => { + const plan = resolveTurnResume({ + cut: true, + sdkSessionId: 's2', + previousAssistant: { sdkSessionId: 's1', sdkTailUuid: 'tail-in-s1' }, + keptHistory: [{ role: 'user', content: 'first' }], + mintUuid: uuids(), + }) + expect(plan.first.kind).toBe('fresh') + }) +}) + +test.describe('buildHistoryPreamble', () => { + test('is null when nothing is kept', () => { + expect(buildHistoryPreamble([])).toBeNull() + expect(buildHistoryPreamble([{ role: 'system', content: 'anchor' }, { role: 'user', content: ' ' }])).toBeNull() + }) + + test('keeps the most recent turns within the budget and says when it dropped some', () => { + const rows = Array.from({ length: 10 }, (_, i) => ({ role: i % 2 ? 'assistant' : 'user', content: `message ${i} ${'x'.repeat(50)}` })) + const preamble = buildHistoryPreamble(rows, 200)! + expect(preamble).toContain('message 9') + expect(preamble).not.toContain('message 0 ') + expect(preamble).toContain('(Earlier messages omitted.)') + }) + + test('skips rows that are not the conversation', () => { + const preamble = buildHistoryPreamble([ + { role: 'system', content: 'agent switched' }, + { role: 'user', content: 'hello' }, + ])! + expect(preamble).not.toContain('agent switched') + expect(preamble).toContain('User: hello') + }) +}) + +test.describe('unanswered rows', () => { + test('unansweredTail is the user rows after the last reply', () => { + expect(unansweredTail([])).toEqual([]) + expect( + unansweredTail([ + { role: 'user', content: 'a' }, + { role: 'assistant', content: 'b' }, + ]), + ).toEqual([]) + expect( + unansweredTail([ + { role: 'user', content: 'a' }, + { role: 'assistant', content: 'b' }, + { role: 'user', content: 'c' }, + { role: 'user', content: 'd' }, + ]), + ).toEqual([ + { role: 'user', content: 'c' }, + { role: 'user', content: 'd' }, + ]) + // No reply at all: every user row is unanswered. + expect(unansweredTail([{ role: 'user', content: 'a' }])).toEqual([{ role: 'user', content: 'a' }]) + }) + + test('buildUnansweredPreamble frames them as unanswered, and is null for none', () => { + expect(buildUnansweredPreamble([])).toBeNull() + const preamble = buildUnansweredPreamble([{ role: 'user', content: 'What is 3+3?' }])! + expect(preamble).toContain('did not get a reply') + expect(preamble).toContain('User: What is 3+3?') + expect(preamble).toContain('The message to answer follows.') + }) + + test('isSlashCommand: nothing may go in front of a CLI command', () => { + expect(isSlashCommand('/compact Preserve all requirements.')).toBe(true) + expect(isSlashCommand(' /compact')).toBe(true) + expect(isSlashCommand('a/b')).toBe(false) + expect(isSlashCommand('/ not a command')).toBe(false) + expect(isSlashCommand('')).toBe(false) + }) + + test('hasPendingCut reads the stamp an edit or regenerate leaves', () => { + expect(hasPendingCut({ sdkCutPending: true })).toBe(true) + expect(hasPendingCut({ sdkCutPending: 'yes' })).toBe(false) + expect(hasPendingCut({})).toBe(false) + expect(hasPendingCut(null)).toBe(false) + }) +}) + +test.describe('turnPromptContent', () => { + test('text goes out as it is, or behind the preamble', () => { + expect(turnPromptContent({ text: 'What is 3+3?', content: null }, null)).toBe('What is 3+3?') + expect(turnPromptContent({ text: 'What is 3+3?', content: null }, 'HISTORY')).toBe('HISTORY\n\nWhat is 3+3?') + }) + + test('blocks stay blocks; the preamble joins the first text block', () => { + const image = { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AA==' } } + const blocks = [{ type: 'text', text: 'look' }, image] + expect(turnPromptContent({ text: 'look', content: blocks }, null)).toBe(blocks) + const primed = turnPromptContent({ text: 'look', content: blocks }, 'HISTORY') as Array> + expect(primed[0]).toEqual({ type: 'text', text: 'HISTORY\n\nlook' }) + expect(primed[1]).toBe(image) + // The prepared prompt itself is not mutated. + expect(blocks[0]).toEqual({ type: 'text', text: 'look' }) + }) +}) + +test.describe('the join on metadata', () => { + test('readTurnJoin reads a complete join and refuses a partial one', () => { + expect( + readTurnJoin({ sdkTurn: { uuid: 'u', sessionId: 's', cwd: '/w', checkpointed: true }, other: 1 }), + ).toEqual({ uuid: 'u', sessionId: 's', cwd: '/w', checkpointed: true }) + expect(readTurnJoin({ sdkTurn: { uuid: 'u', sessionId: 's', cwd: '/w' } })?.checkpointed).toBe(false) + expect(readTurnJoin({ sdkTurn: { uuid: 'u', cwd: '/w' } })).toBeNull() + expect(readTurnJoin({})).toBeNull() + expect(readTurnJoin(null)).toBeNull() + }) + + test('readTailUuid reads the assistant row’s tail', () => { + expect(readTailUuid({ sdkTailUuid: 't1' })).toBe('t1') + expect(readTailUuid({ sdkTailUuid: '' })).toBeNull() + expect(readTailUuid({})).toBeNull() + }) +}) + +test.describe('supportsFileCheckpoints', () => { + test('only a workspace that outlives the turn is checkpointed', () => { + expect(supportsFileCheckpoints({ projectId: 'p1' })).toBe(true) + expect(supportsFileCheckpoints({ persistentKey: 'k' })).toBe(true) + expect(supportsFileCheckpoints({ persistentKey: 'k', worktree: { repoPath: '/r' } })).toBe(true) + expect(supportsFileCheckpoints({ projectId: 'p1', worktree: { repoPath: '/r' } })).toBe(false) + expect(supportsFileCheckpoints({})).toBe(false) + }) +}) diff --git a/tests/engine.rewind.spec.ts b/tests/engine.rewind.spec.ts new file mode 100644 index 0000000..70778f9 --- /dev/null +++ b/tests/engine.rewind.spec.ts @@ -0,0 +1,435 @@ +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, + restoreOutcomeNotice, + 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('after a restore, files a link kept the CLI from restoring are named, and a full restore says nothing', () => { + expect(restoreOutcomeNotice({ filesRestored: 3, skippedLinks: 0 })).toBeNull() + expect(restoreOutcomeNotice({ filesRestored: 0 })).toBeNull() + expect(restoreOutcomeNotice({ filesRestored: 4, skippedLinks: 1 })).toBe( + 'Restored 4 of 5 files. One was not restored: it is a link, or its folder moved after this message.', + ) + expect(restoreOutcomeNotice({ filesRestored: 1, skippedLinks: 2 })).toBe( + 'Restored 1 of 3 files. 2 were not restored: they are links, or their folders moved after this message.', + ) + expect(restoreOutcomeNotice({ filesRestored: 0, skippedLinks: 1 })).toContain('Restored 0 of 1 file.') + }) +}) + +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/engine.turn-input.spec.ts b/tests/engine.turn-input.spec.ts new file mode 100644 index 0000000..0e92ef8 --- /dev/null +++ b/tests/engine.turn-input.spec.ts @@ -0,0 +1,268 @@ +import { expect, test } from '@playwright/test' +import { + isResumeRefusal, + nextTranscriptTail, + runWithResumeFallback, + userTurnMessages, + withTurnResume, + type TurnAttempt, + type TurnAttemptPlan, +} from '../src/lib/engine/turn-input' +import { runEngineStream, type EngineQuerySource } from '../src/lib/engine/stream.server' + +/** + * How a chat turn enters its SDK session (#24 and the edit/regenerate fix): the prompt's + * own uuid, the transcript tail a later edit cuts back to, and the fallback when the CLI + * refuses the cut. Pure, apart from the last group, which drives the real engine loop over + * a scripted stream the way `engine.stream-routing.spec.ts` does. + */ + +async function collect(iterable: AsyncIterable): Promise { + const out: T[] = [] + for await (const item of iterable) out.push(item) + return out +} + +test.describe('userTurnMessages', () => { + test('yields exactly one user message carrying the uuid we chose', async () => { + const messages = await collect(userTurnMessages('hello', '11111111-1111-4111-8111-111111111111')) + expect(messages).toHaveLength(1) + expect(messages[0]).toMatchObject({ + type: 'user', + parent_tool_use_id: null, + uuid: '11111111-1111-4111-8111-111111111111', + message: { role: 'user', content: 'hello' }, + }) + }) + + test('keeps text as a plain string, so a slash command like /compact still reads as one', async () => { + const [message] = await collect(userTurnMessages('/compact keep the plan', 'u')) + expect(message.message.content).toBe('/compact keep the plan') + }) + + test('passes content blocks through untouched', async () => { + const blocks = [ + { type: 'text', text: 'look' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AA==' } }, + ] + const [message] = await collect(userTurnMessages(blocks, 'u')) + expect(message.message.content).toEqual(blocks) + }) +}) + +test.describe('nextTranscriptTail', () => { + test('follows the main thread’s assistant and user messages', () => { + let tail: string | null = null + tail = nextTranscriptTail(tail, { type: 'assistant', uuid: 'a1', parent_tool_use_id: null }) + expect(tail).toBe('a1') + tail = nextTranscriptTail(tail, { type: 'user', uuid: 'tool-result-1', parent_tool_use_id: null }) + expect(tail).toBe('tool-result-1') + tail = nextTranscriptTail(tail, { type: 'assistant', uuid: 'a2', parent_tool_use_id: null }) + expect(tail).toBe('a2') + }) + + test('ignores what is not a main-thread chain entry', () => { + const tail = 'a1' + expect(nextTranscriptTail(tail, { type: 'stream_event', uuid: 'se', parent_tool_use_id: null })).toBe('a1') + expect(nextTranscriptTail(tail, { type: 'assistant', uuid: 'child', parent_tool_use_id: 'task-1' })).toBe('a1') + expect(nextTranscriptTail(tail, { type: 'user', uuid: 'replay', isReplay: true, parent_tool_use_id: null })).toBe('a1') + expect(nextTranscriptTail(tail, { type: 'result', uuid: 'r', subtype: 'success' })).toBe('a1') + expect(nextTranscriptTail(tail, { type: 'system', subtype: 'init', uuid: 's' })).toBe('a1') + expect(nextTranscriptTail(tail, { type: 'assistant', parent_tool_use_id: null })).toBe('a1') + }) + + test('a compaction boundary clears it, and the summary the CLI writes after it sets it again', () => { + let tail: string | null = 'a1' + tail = nextTranscriptTail(tail, { type: 'system', subtype: 'compact_boundary', uuid: 'b' }) + expect(tail).toBeNull() + // What `/compact` streams after the boundary: the summary, as main-thread user entries. + tail = nextTranscriptTail(tail, { type: 'user', uuid: 'summary', parent_tool_use_id: null }) + expect(tail).toBe('summary') + tail = nextTranscriptTail(tail, { type: 'assistant', uuid: 'a2', parent_tool_use_id: null }) + expect(tail).toBe('a2') + }) +}) + +test.describe('withTurnResume', () => { + const base = { model: 'claude-sonnet-5', resume: 'old', resumeSessionAt: 'old-tail', cwd: '/w' } + + test('a fork resumes and cuts', () => { + const options = withTurnResume(base, { + kind: 'fork', + resumeSessionId: 's1', + resumeSessionAt: 't1', + preamble: null, + sdkUserUuid: 'u', + }) + expect(options).toMatchObject({ model: 'claude-sonnet-5', cwd: '/w', resume: 's1', resumeSessionAt: 't1' }) + }) + + test('a fresh session drops both, so it cannot resume the session it replaces', () => { + const options = withTurnResume(base, { kind: 'fresh', preamble: 'x', sdkUserUuid: 'u' }) + expect(options).not.toHaveProperty('resume') + expect(options).not.toHaveProperty('resumeSessionAt') + expect(options.cwd).toBe('/w') + }) + + test('a plain continue resumes without cutting', () => { + const options = withTurnResume(base, { kind: 'continue', resumeSessionId: 's1', preamble: null, sdkUserUuid: 'u' }) + expect(options.resume).toBe('s1') + expect(options).not.toHaveProperty('resumeSessionAt') + }) +}) + +test.describe('isResumeRefusal', () => { + test('recognises the CLI’s own refusals', () => { + expect(isResumeRefusal('No message found with message.uuid of: 3522cd6d-abd3')).toBe(true) + expect(isResumeRefusal('Resume rejected by --resume-drops-turn: resuming at x would discard')).toBe(true) + expect(isResumeRefusal('Failed to resume session: ENOENT')).toBe(true) + expect(isResumeRefusal('No conversation found with session ID: abc')).toBe(true) + }) + + test('leaves every other failure alone', () => { + expect(isResumeRefusal(null)).toBe(false) + expect(isResumeRefusal('Reached maximum number of turns')).toBe(false) + expect(isResumeRefusal('Overloaded')).toBe(false) + }) +}) + +test.describe('runWithResumeFallback', () => { + const fork: TurnAttempt = { kind: 'fork', resumeSessionId: 's', resumeSessionAt: 't', preamble: null, sdkUserUuid: 'u1' } + const fresh: TurnAttempt = { kind: 'fresh', preamble: 'history', sdkUserUuid: 'u2' } + const plan: TurnAttemptPlan = { first: fork, fallback: fresh } + const refused = { error: 'No message found with message.uuid of: t', text: '', blocks: [] as unknown[] } + const ok = { error: null, text: 'answer', blocks: [{ kind: 'text' }] as unknown[] } + + test('a refused cut that produced nothing runs the fallback once', async () => { + const attempts: TurnAttempt[] = [] + const reasons: string[] = [] + const outcome = await runWithResumeFallback( + plan, + async (attempt) => { + attempts.push(attempt) + return attempt.kind === 'fork' ? refused : ok + }, + (reason) => reasons.push(reason), + ) + expect(attempts.map((a) => a.kind)).toEqual(['fork', 'fresh']) + expect(outcome).toBe(ok) + expect(reasons[0]).toContain('No message found') + }) + + test('a refusal thrown by the SDK falls back the same way', async () => { + const attempts: string[] = [] + const outcome = await runWithResumeFallback(plan, async (attempt) => { + attempts.push(attempt.kind) + if (attempt.kind === 'fork') throw new Error('Failed to resume session: gone') + return ok + }) + expect(attempts).toEqual(['fork', 'fresh']) + expect(outcome).toBe(ok) + }) + + test('a notice-only first attempt still counts as having produced nothing', async () => { + const attempts: string[] = [] + await runWithResumeFallback(plan, async (attempt) => { + attempts.push(attempt.kind) + return attempt.kind === 'fork' ? { ...refused, blocks: [{ kind: 'notice' }] } : ok + }) + expect(attempts).toEqual(['fork', 'fresh']) + }) + + test('never repeats a turn that already did something', async () => { + const attempts: string[] = [] + const partial = { error: 'No message found with message.uuid of: t', text: 'half an answer', blocks: [] } + const outcome = await runWithResumeFallback(plan, async (attempt) => { + attempts.push(attempt.kind) + return partial + }) + expect(attempts).toEqual(['fork']) + expect(outcome).toBe(partial) + }) + + test('other failures and plans without a fallback are returned as they are', async () => { + const failed = { error: 'Overloaded', text: '', blocks: [] } + const attempts: string[] = [] + expect(await runWithResumeFallback(plan, async (a) => (attempts.push(a.kind), failed))).toBe(failed) + expect(await runWithResumeFallback({ first: fork, fallback: null }, async (a) => (attempts.push(a.kind), refused))).toBe( + refused, + ) + await expect( + runWithResumeFallback(plan, async () => { + throw new Error('socket hang up') + }), + ).rejects.toThrow('socket hang up') + expect(attempts).toEqual(['fork', 'fork']) + }) +}) + +test.describe('runEngineStream reports the transcript tail', () => { + function scripted(messages: unknown[]): EngineQuerySource { + return { + async *[Symbol.asyncIterator]() { + for (const message of messages) yield message as never + }, + } + } + + test('sdkTailUuid is the last main-thread entry, whatever the subagents and stream events did', async () => { + const summary = await runEngineStream({ + prompt: 'go', + options: {}, + createQuery: () => + scripted([ + { type: 'system', subtype: 'init', session_id: 's1', uuid: 'init' }, + { type: 'assistant', uuid: 'a1', parent_tool_use_id: null, session_id: 's1', message: { content: [] } }, + { type: 'user', uuid: 'r1', parent_tool_use_id: null, session_id: 's1', message: { content: [] } }, + { type: 'assistant', uuid: 'child', parent_tool_use_id: 'task-1', session_id: 's1', message: { content: [] } }, + { + type: 'stream_event', + uuid: 'se', + parent_tool_use_id: null, + session_id: 's1', + event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'hi' } }, + }, + { type: 'assistant', uuid: 'a2', parent_tool_use_id: null, session_id: 's1', message: { content: [] } }, + { type: 'result', uuid: 'res', session_id: 's1', usage: {}, duration_ms: 1, num_turns: 1 }, + ]), + requiresApproval: () => false, + emit: async () => {}, + }) + expect(summary.sdkTailUuid).toBe('a2') + expect(summary.sessionId).toBe('s1') + }) + + test('a /compact turn reports the summary after the boundary as its tail', async () => { + const summary = await runEngineStream({ + prompt: '/compact', + options: {}, + createQuery: () => + scripted([ + { type: 'system', subtype: 'init', session_id: 's1', uuid: 'init' }, + { type: 'system', subtype: 'compact_boundary', uuid: 'b1', compact_metadata: { trigger: 'manual', pre_tokens: 10 } }, + { type: 'user', uuid: 'summary-1', parent_tool_use_id: null, message: { role: 'user', content: 'summary' } }, + { type: 'user', uuid: 'summary-2', parent_tool_use_id: null, message: { role: 'user', content: 'output' } }, + { type: 'result', usage: {}, duration_ms: 1, num_turns: 1 }, + ]), + requiresApproval: () => false, + emit: async () => {}, + }) + expect(summary.sdkTailUuid).toBe('summary-2') + }) + + test('a turn that ends in a compaction with nothing after it reports no tail', async () => { + const summary = await runEngineStream({ + prompt: '/compact', + options: {}, + createQuery: () => + scripted([ + { type: 'assistant', uuid: 'a1', parent_tool_use_id: null, message: { content: [] } }, + { type: 'system', subtype: 'compact_boundary', uuid: 'b1', compact_metadata: { trigger: 'manual', pre_tokens: 10 } }, + { type: 'result', usage: {}, duration_ms: 1, num_turns: 1 }, + ]), + requiresApproval: () => false, + emit: async () => {}, + }) + expect(summary.sdkTailUuid).toBeNull() + }) +}) diff --git a/tests/hooks.chat-run.spec.ts b/tests/hooks.chat-run.spec.ts index c9437f2..460712c 100644 --- a/tests/hooks.chat-run.spec.ts +++ b/tests/hooks.chat-run.spec.ts @@ -192,7 +192,12 @@ test.describe('hooks/chat-run — the stream route uses it', () => { // Inside `emit`, which every frame passes through — the engine's and the route's own. const emitBody = /const emit = async \(event: string, payload: unknown\) => \{([\s\S]*?)\n\t\t\t\}/.exec(source) expect(emitBody?.[1]).toContain('hooks.frame(event, payload)') - expect(source.indexOf('hooks.runStarted()')).toBeLessThan(source.indexOf('await runEngineStream(')) + // The engine call sits inside `runWithResumeFallback` (#24), which may make it twice; the + // turn is opened once, before either attempt. + const engineCall = source.search(/runEngineStream\(/) + expect(engineCall).toBeGreaterThan(-1) + expect(source.indexOf('hooks.runStarted()')).toBeGreaterThan(-1) + expect(source.indexOf('hooks.runStarted()')).toBeLessThan(engineCall) expect(source.match(/hooks\.runFinished\(/g)?.length).toBe(2) // The agent the turn runs as, so the default Chat agent's bindings apply too. expect(source).toMatch(/createChatRunHooks\(\{[\s\S]*?agentId: agent\.id/) diff --git a/tests/sdk-transcript-fixture.ts b/tests/sdk-transcript-fixture.ts new file mode 100644 index 0000000..8807ed5 --- /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`, + ) +}