From 452a5c6a8070001c019a428789a1d3286c717fc7 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Wed, 3 Jun 2026 15:39:45 -0400 Subject: [PATCH 01/40] docs: add FORK.md capturing fork workflow + conflict map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the shared understanding of how this fork stays in sync with upstream pingdotgg/t3code: branch model (main mirror / custom work), the new-files-first rule, rerere, the three-bucket conflict map, and the verified hot files. New file only — does not touch upstream files. Co-Authored-By: Claude Opus 4.8 (1M context) --- FORK.md | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 FORK.md diff --git a/FORK.md b/FORK.md new file mode 100644 index 000000000000..8fcbc558af39 --- /dev/null +++ b/FORK.md @@ -0,0 +1,108 @@ +# FORK.md — how this fork works + +> This is a **fork** of [`pingdotgg/t3code`](https://github.com/pingdotgg/t3code) (Theo's "T3 Code"). +> The whole point of the setup below is to **keep pulling Theo's updates cleanly** while our own +> work stays separate and doesn't fight his. +> +> This file is the shared understanding. If you (or a new Claude session) are picking this up, +> **read this first.** It is the source of truth; do not relearn it from scratch. +> +> _Last updated: 2026-06-03. Status: fork is bootstrapped, nothing custom built yet._ + +--- + +## The plain-English version + +- **Theo's repo moves fast.** His own `AGENTS.md` says it's a "VERY EARLY WIP," so he ships big, + frequent changes. Our job is to ride along without constant merge pain. +- **Two branches, kept apart:** + - `main` is a **clean mirror** of Theo's repo. We never put our own work here. + - `custom` is **where all our work lives.** +- **The golden rule: add new files, don't edit his.** When our changes live in new files (or whole + new sub-projects), Theo's updates and ours touch different files and never collide. Editing his + existing files is what causes painful conflicts — so we only do it when a feature truly has no + other way, and then we keep the edit as small as possible. +- **Conflict auto-memory is on.** Git is set to remember how we resolve any conflict and replay + that fix automatically next time the same one shows up (`rerere`). So a conflict, once solved, + generally stays solved. +- **Pull his updates often, in small steps.** Small, frequent catch-ups are far easier than one + giant one months later. + +## How easy a given feature is to keep separate — the three buckets + +This is the heart of what we worked out. How clean things stay depends on *what kind* of feature it is: + +1. **New screens / views / a whole new tool — nearly conflict-free.** + The web app discovers screens just from which files exist in a folder, and the project picks up + brand-new sub-projects automatically. New files only, nothing of Theo's touched. + +2. **Features where the server has to do something new — manageable.** + These have to register themselves in a few central "switchboard" files (see the reference below). + Adding a background service is basically a one-line addition. Adding a new browser↔server command + is a real edit, not a clean plug-in, so expect small conflicts there — `rerere` softens the repeats. + +3. **Changing how the core agent conversation itself works — genuinely conflict-prone.** + The full list of agent events/commands lives in one big central file the whole app keys off of. + It can't be sidestepped, and it's exactly the kind of file Theo reshapes often. Avoid extending + the core protocol if you can; if a feature truly needs it, treat that file as a known battleground. + +**Cleanest option of all:** when a feature allows it, build it as its **own separate project that +talks to t3code**, rather than living inside his code. Then there is nothing of ours in his files. + +We deliberately did **not** pre-build "plug your stuff in here" scaffolding — empty hooks would just +be guesses about his structure that rot as he changes things. We apply the small "seam" edit *when* a +feature actually needs a central file, not before. + +--- + +## Reference (for precise, repeatable steps) + +### Remotes & branches +- `origin` → `ziyadakl/t3code` (our fork — safe to push to) +- `upstream` → `pingdotgg/t3code` (push URL is blocked on purpose, so we can't accidentally PR to Theo) +- `main` — clean mirror of `upstream/main`; **never commit here** +- `custom` — all our work; branch off `custom` for individual features + +### Pulling Theo's updates +```sh +git fetch upstream +git checkout main +git merge --ff-only upstream/main # main stays a pure mirror +git push origin main +git checkout custom +git rebase main # rerere auto-replays past conflict fixes +``` +Do this frequently. Resolve any conflict once; `rerere` remembers it. + +### Local git config already set (lives in `.git/config`, not committed) +- `rerere.enabled = true` +- `rerere.autoupdate = true` + +### The conflict-prone "hot files" (verified 2026-06-03) +Touch these only when a feature genuinely requires it; keep edits minimal. + +| File | What it is | Bucket | Conflict risk | +|---|---|---|---| +| `packages/contracts/src/orchestration.ts` | Core agent event/command unions (`OrchestrationCommand`, `OrchestrationEvent`); ~1,300 lines | 3 | **High** — central, heavily reshaped upstream | +| `apps/server/src/ws.ts` | Browser↔server command registry (`WsRpcGroup.of({…})`) + auth-scope map | 2 | Medium — real edits, handlers close over lots of local scope | +| `packages/contracts/src/rpc.ts` | Method-name constants (`WS_METHODS`) every command starts from | 2 | Medium | +| `apps/server/src/server.ts` | Effect service graph (`.pipe(Layer.provideMerge(…))` chain) | 2 | **Low** — new service = one line appended after `AuthLayerLive` | + +### The low-conflict seams (prefer these) +- **New web screen:** add a file under `apps/web/src/routes/`. `routeTree.gen.ts` is autogenerated — + don't hand-edit it. No edit to Theo's files. +- **New package/sub-project:** create a folder under `apps/` or `packages/`. Root `package.json` + uses globs (`apps/*`, `packages/*`), so it's picked up with no edit. +- **New server service:** append one `Layer.provideMerge(YourLayerLive)` at the end of the chain in + `server.ts`; keep `YourLayerLive` in your own new file. + +### Required checks before declaring work done (from Theo's `AGENTS.md`) +- `bun fmt`, `bun lint`, `bun typecheck` must all pass +- Use `bun run test` (Vitest) — **never** `bun test` +- If touching native mobile code, `bun lint:mobile` must also pass + +### Toolchain notes +- `mise` pins Node 24.13.1 + Bun 1.3.9 (see `.mise.toml`). +- `bun` is **not** on the PATH directly — run everything via `mise exec -- bun …` + (e.g. `mise exec -- bun install`, `mise exec -- bun run test`). +- There is **no plugin / settings / config layer** — additions must be real code changes. From 994ef4b180cbe96108d0234488e90339f1c934b6 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Wed, 3 Jun 2026 20:00:30 -0400 Subject: [PATCH 02/40] fix(web): suppress provider-status banner for disabled/unselected providers A red "Codex CLI not installed" banner appeared on new threads even when Codex was disabled and Claude was the selected model. The banner only hid providers whose status was literally "disabled", and resolved to the project default instead of the composer selection. - ProviderStatusBanner: extract a shouldShowProviderStatusBanner type guard that also hides switched-off (!enabled) providers; add a unit test. - ChatView: for a draft thread, fall through to the composer-selected provider rather than the project default. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/components/ChatView.tsx | 11 ++-- .../chat/ProviderStatusBanner.test.ts | 53 +++++++++++++++++++ .../components/chat/ProviderStatusBanner.tsx | 15 +++++- 3 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/components/chat/ProviderStatusBanner.test.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7fc7669fe60d..f29bea63adc7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1840,13 +1840,12 @@ export default function ChatView(props: ChatViewProps) { const availableEditors = useServerAvailableEditors(); // Prefer an instance-id match so a custom Codex instance (e.g. // `codex_personal`) surfaces its own status/message in the banner rather - // than the default Codex's. Falls back to first-match-by-kind when no - // saved instance id is available or the instance no longer exists. + // than the default Codex's. For a draft with no thread yet, fall through to + // the composer-selected provider (the `selectedProvider` branch below) + // instead of the project default, so the banner reflects the provider the + // user actually picked — not a disabled project-default they aren't using. const activeProviderInstanceId = - activeThread?.session?.providerInstanceId ?? - activeThread?.modelSelection.instanceId ?? - activeProject?.defaultModelSelection?.instanceId ?? - null; + activeThread?.session?.providerInstanceId ?? activeThread?.modelSelection.instanceId ?? null; const activeProviderStatus = useMemo(() => { if (activeProviderInstanceId) { return ( diff --git a/apps/web/src/components/chat/ProviderStatusBanner.test.ts b/apps/web/src/components/chat/ProviderStatusBanner.test.ts new file mode 100644 index 000000000000..8a9f7c28361e --- /dev/null +++ b/apps/web/src/components/chat/ProviderStatusBanner.test.ts @@ -0,0 +1,53 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { shouldShowProviderStatusBanner } from "./ProviderStatusBanner"; + +function provider(overrides: Partial = {}): ServerProvider { + return { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + ...overrides, + }; +} + +describe("shouldShowProviderStatusBanner", () => { + it("hides when there is no status", () => { + expect(shouldShowProviderStatusBanner(null)).toBe(false); + }); + + it("hides a disabled provider even when its probe reports an error", () => { + // The exact case from the bug: Codex disabled in settings, but its probe + // still reports "CLI not installed". It must not surface a red banner. + expect( + shouldShowProviderStatusBanner( + provider({ enabled: false, installed: false, status: "error" }), + ), + ).toBe(false); + }); + + it("hides a healthy (ready) provider", () => { + expect(shouldShowProviderStatusBanner(provider({ status: "ready" }))).toBe(false); + }); + + it("hides a provider the server already marks disabled", () => { + expect(shouldShowProviderStatusBanner(provider({ status: "disabled" }))).toBe(false); + }); + + it("shows an enabled provider that is genuinely in error", () => { + expect( + shouldShowProviderStatusBanner( + provider({ enabled: true, installed: false, status: "error" }), + ), + ).toBe(true); + }); +}); diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx index a882942585f5..7e3c9700eee9 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.tsx @@ -4,12 +4,25 @@ import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; import { CircleAlertIcon } from "lucide-react"; import { formatProviderDriverKindLabel } from "../../providerModels"; +/** + * The banner is shown only for a provider that is enabled/selected and in a + * non-healthy state worth surfacing. A disabled or unselected provider must + * never raise a red "CLI not installed" error — that was the spurious Codex + * banner shown while using Claude with Codex switched off. Written as a type + * guard so the component body can treat `status` as non-null. + */ +export function shouldShowProviderStatusBanner( + status: ServerProvider | null, +): status is ServerProvider { + return !!status && status.enabled && status.status !== "ready" && status.status !== "disabled"; +} + export const ProviderStatusBanner = memo(function ProviderStatusBanner({ status, }: { status: ServerProvider | null; }) { - if (!status || status.status === "ready" || status.status === "disabled") { + if (!shouldShowProviderStatusBanner(status)) { return null; } From 17422a52e1fec74c9f03c060af12eef1ce2ddcd5 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Wed, 3 Jun 2026 20:00:30 -0400 Subject: [PATCH 03/40] chore(scripts): add pack-server tool to build a deployable fork server tarball Reuses resolveCatalogDependencies to produce an npm pack tarball of the t3 server with catalog deps resolved, so the fork's own server build can be installed on a self-hosted VPS without npm publish. Fork-owned new file. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/pack-server.ts | 63 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 scripts/pack-server.ts diff --git a/scripts/pack-server.ts b/scripts/pack-server.ts new file mode 100644 index 000000000000..5df0e5405106 --- /dev/null +++ b/scripts/pack-server.ts @@ -0,0 +1,63 @@ +#!/usr/bin/env node +/** + * Build a self-installable npm tarball of the `t3` server package from this + * fork, with `catalog:` dependencies resolved to concrete versions. + * + * This mirrors the dependency resolution that `apps/server/scripts/cli.ts + * publish` performs, but produces a local `.tgz` via `npm pack` instead of + * publishing to npm — so the fork's own server build can be deployed to a VPS. + * + * Prereq: build the server first so the assets exist: + * bun --filter=@t3tools/web run build + * bun --filter=t3 run build + * + * Output: `apps/server/t3-.tgz`. Install it on the target with: + * npm install --prefix apps/server/t3-.tgz + */ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const serverDir = join(repoRoot, "apps/server"); +const pkgPath = join(serverDir, "package.json"); +const backupPath = `${pkgPath}.pack-bak`; + +for (const rel of ["dist/bin.mjs", "dist/client/index.html"]) { + if (!existsSync(join(serverDir, rel))) { + throw new Error( + `Missing build asset: apps/server/${rel}. Run \`bun --filter=t3 run build\` first.`, + ); + } +} + +const rootPkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")); +const serverPkg = JSON.parse(readFileSync(pkgPath, "utf8")); +const catalog: Record = rootPkg.workspaces.catalog; + +const resolved = { + name: serverPkg.name, + repository: serverPkg.repository, + bin: serverPkg.bin, + type: serverPkg.type, + version: serverPkg.version, + engines: serverPkg.engines, + files: serverPkg.files, + dependencies: resolveCatalogDependencies(serverPkg.dependencies, catalog, "apps/server"), + overrides: resolveCatalogDependencies(rootPkg.overrides ?? {}, catalog, "apps/server"), +}; + +const original = readFileSync(pkgPath, "utf8"); +writeFileSync(backupPath, original); +try { + writeFileSync(pkgPath, `${JSON.stringify(resolved, null, 2)}\n`); + console.log(`[pack-server] Resolved package.json for t3@${resolved.version}; running npm pack…`); + execFileSync("npm", ["pack"], { cwd: serverDir, stdio: "inherit" }); + console.log(`[pack-server] Done → apps/server/t3-${resolved.version}.tgz`); +} finally { + // Always restore the original (catalog:-based) package.json. + renameSync(backupPath, pkgPath); +} From 82018bcfc59305b88b9194f82d74a129220f95eb Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Wed, 3 Jun 2026 20:00:30 -0400 Subject: [PATCH 04/40] docs: capture CLI<->t3 conversation-continuity design (CONTEXT.md + ADR 0001) Vocabulary + decisions for resuming the same Claude conversation across the Claude Code CLI and t3 (terminal->t3 first; shared ~/.claude session pool; conversation-rewind in v1, file-rewind deferred). Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTEXT.md | 45 +++++++++++++++++++ .../0001-cli-t3-conversation-continuity.md | 29 ++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 CONTEXT.md create mode 100644 docs/adr/0001-cli-t3-conversation-continuity.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000000..8519c8f5fed8 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,45 @@ +# CONTEXT — Conversations & resume + +The shared language for how a back-and-forth agent conversation is stored, identified, and +resumed across t3code and an agent's own standalone CLI. Seeded 2026-06-03 while designing +CLI ↔ t3 conversation continuity (Claude first). This is a fork — see [FORK.md](./FORK.md). + +## Language + +**Thread**: +t3code's term for one back-and-forth agent conversation (id = UUID, stored in t3's own SQLite at `~/.t3code/userdata/state.sqlite`). +_Avoid_: conversation, session, chat — at the t3 domain level it is always a Thread. + +**Provider session**: +The underlying agent's own conversation instance that actually holds the model context (for Claude, a Claude session id). A Thread is backed by a Provider session. +_Avoid_: calling this a "thread" — that collides with t3's Thread. + +**Resume cursor**: +The pointer t3 stores per Thread so it can reconnect to that Thread's Provider session. For Claude it is `{ resume: , resumeSessionAt: }` — the exact data `claude --resume` needs. + +**Provider / agent backend**: +The engine that runs a Thread: Claude, Codex, Cursor, or OpenCode. The first target is Claude. + +**Project bucket**: +Claude's on-disk grouping of sessions by working directory: `~/.claude/projects//.jsonl`. Both t3 and the standalone CLI share `~/.claude` by default (no `homePath` override). + +**Worktree**: +An isolated git working copy t3 may create for a Thread. When present, the Thread runs there instead of the repo root — which puts its Provider session in a *different* Project bucket than a terminal session run from the repo. + +## Relationships + +- A **Thread** is backed by exactly one **Provider session** at a time, via a **Resume cursor**. +- A **Provider session** physically lives in one **Project bucket**, keyed by the **working directory** it ran in. +- Resuming a **Provider session** only works from *its own* **Project bucket** — i.e. the same working directory. Different directory ⇒ "No conversation found." (verified 2026-06-03) +- A **Thread** runs in its **Worktree** if it has one, otherwise the project's repo root. + +## Flagged ambiguities + +- **"the CLI"** — initially ambiguous. Resolved: the *agent's own* standalone CLI (Claude Code first), **not** a new t3 CLI, and **not** running one agent's chat inside a different agent. Same agent, two places. +- **Target experience (resolved 2026-06-03):** a t3 **Thread** should appear in Claude's native `/resume` picker like any normal Claude Code session — no copy-paste command, no special button. This works **iff** the Thread ran in the project's repo root (so its Provider session lands in the repo's **Project bucket**, which is what `/resume` lists for that folder). +- **Priority direction (resolved 2026-06-03):** the more important half is **terminal → t3** ("resume a CLI chat *inside* t3"), built first. The reverse (t3 → terminal) is nearly free and comes second. +- **VERIFIED with real data (2026-06-03):** t3 already stores Claude **Provider sessions** in the *shared* `~/.claude` store and runs Claude **Threads** in the repo root (no Worktree by default — real threads had `worktree_path: null`, branch `custom`/`main`). A real t3 thread's session file (`c55dc749…`, 26 msgs) physically sits in the **same Project bucket** as terminal sessions. macOS's case-insensitive filesystem merges the `Dev` vs `dev` path-casing difference into one bucket, so the two share a single session pool. t3 state lives at `~/.t3/userdata/state.sqlite`; resume pointer is `provider_session_runtime.resume_cursor_json` = `{resume, resumeSessionAt}`. +- **Display of an imported chat (resolved 2026-06-03):** **show** the earlier messages (readable text), so the user can see the chat and pick a point — not "show nothing". Full byte-perfect reproduction of every tool call/sidechain is *not* required for v1. +- **Rewind (resolved 2026-06-03):** two kinds. **Conversation-rewind** (jump to an earlier message and continue a new path) is in v1 — it uses the stable `resumeSessionAt` anchor. **File-rewind** (restore the working tree to an earlier point of the *imported* chat) is **deferred**: the data exists (`~/.claude/file-history//` blobs + `file-history-snapshot` transcript entries with `trackedFileBackups`), so it's *possible*, but it means reading Claude's private, undocumented snapshot format and translating it to t3's git-checkpoint model — fragile against Claude updates, against the fork's "don't depend on churny internals" principle. Revisit only if it proves essential. +- **"conversation"** — maps to both a t3 **Thread** and a **Provider session**; they are linked, not the same thing. +- **"resume / pick up the context"** — means continue the *same* conversation on the other side. **Resolved (2026-06-03):** carry the *chat*; run **both sides in the same folder** (the real repo, no private Worktree for bridged Threads) so the files line up on their own. Trade-off accepted: bridged Threads edit the real working tree, giving up worktree isolation. diff --git a/docs/adr/0001-cli-t3-conversation-continuity.md b/docs/adr/0001-cli-t3-conversation-continuity.md new file mode 100644 index 000000000000..262948147f67 --- /dev/null +++ b/docs/adr/0001-cli-t3-conversation-continuity.md @@ -0,0 +1,29 @@ +# CLI ↔ t3 conversation continuity (Claude first) + +**Status:** accepted (2026-06-03) — design agreed, not yet implemented. Fork-local; see [FORK.md](../../FORK.md) and [CONTEXT.md](../../CONTEXT.md). + +## Context & decision + +We want the *same* agent conversation to be pick-up-able in both an agent's own standalone CLI (Claude Code first) and the t3 app, same agent on both sides. The priority direction is **terminal → t3** ("resume a CLI chat inside t3"); the reverse is nearly free and follows. + +Verified that this is feasible with almost no new mechanism: t3 already drives Claude via the same `claude` binary against the shared `~/.claude` store, already records each thread's Claude session id as a resume cursor (`{resume, resumeSessionAt}`), and already runs threads in the project's repo root when no worktree is set. So a t3 Claude thread and a terminal Claude session land in the **same on-disk session pool** for that folder (confirmed with real data: a t3 thread's `.jsonl` sat in the same bucket as terminal sessions). + +We therefore decided: + +1. **Bridged conversations run in the project repo root, not an isolated worktree.** This puts both sides in the same Claude "project bucket" (Claude keys sessions by working directory) and makes the working files line up automatically, so resume "just works" both ways. +2. **Surface = a `/resume`-style affordance inside t3** that lists the project's past Claude sessions to pick from — matching what the user already reached for (they had typed `/resume` into t3's chat box and it did nothing). +3. **Generalize per-provider later.** t3's existing `listSessions` adapter method enumerates only *active* sessions, so a new "list resumable sessions" capability is added at the provider seam — Claude now, Codex/OpenCode later — rather than hard-coding a Claude-only disk reader into the orchestration layer. +4. **Imported chats show their prior messages** (readable text), and support **conversation-rewind** (continue from any earlier message, via the stable `resumeSessionAt` anchor). + +## Considered options / rejected + +- **Worktree isolation for bridged threads** — rejected: it splits the conversation into a different project bucket than the terminal and desyncs the files, defeating the whole point. Accepted trade-off: bridged threads edit the real working tree (no sandbox). +- **A new standalone t3 CLI** — rejected: the user means the agent's *own* CLI (Claude Code), not a new t3 terminal client. +- **Cross-agent resume** (Claude chat continued by a different agent) — out of scope; continuity is same-agent only. +- **File-rewind into imported history** — deferred, not impossible. The data exists (`~/.claude/file-history//` blobs + `file-history-snapshot` transcript entries), but using it means reading Claude's private, undocumented snapshot format and translating it to t3's git-checkpoint model — fragile against Claude updates, which the fork explicitly avoids. Revisit only if it proves essential. + +## Consequences + +- Fork exposure is mostly new files (a resumable-session service, a `/resume` UI) plus the known Bucket-2 seams: one new RPC method (`ws.ts` + `rpc.ts`). The provider-adapter interface edit is deferred until generalization. +- Bridged threads have no worktree isolation by design. +- Correct behavior depends on both sides using the same working-directory string; macOS's case-insensitive filesystem currently merges `Dev`/`dev` casing into one bucket, but exact-path alignment is the underlying requirement. From 0841fa1e1fbadd3740756a1dfa502ba01ad11b67 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Wed, 3 Jun 2026 22:14:39 -0400 Subject: [PATCH 05/40] fix(web): resolve draft provider banner/picker to a ready provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh draft inherited the project's default provider instance for both its status banner and its model-picker identity, even when that provider was disabled or enabled-but-not-installed. On a remote (VPS) project whose default is Codex — enabled but with no Codex CLI installed — this raised a spurious "Codex not installed" banner and a Codex icon while the draft actually runs on the ready Claude provider. - Add resolveComposerProviderTarget: resolves a draft's provider instance, preferring an enabled + ready instance, then any enabled one (so a genuinely all-broken setup still warns), then a concrete fallback identity. - ChatComposer's model picker and ChatView's status banner both resolve through it, so the picker icon/tab and the banner can never disagree. - The banner only pins to a provider when a real session is running; an unstarted draft routes through the resolver instead of short-circuiting on its project-default modelSelection instanceId. Verified live on a VPS project (draft resolves to claudeAgent:ready, no Codex banner). 1003 web tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/components/ChatView.tsx | 49 ++- apps/web/src/components/chat/ChatComposer.tsx | 114 ++----- .../resolveComposerProviderTarget.test.ts | 315 ++++++++++++++++++ .../chat/resolveComposerProviderTarget.ts | 173 ++++++++++ 4 files changed, 561 insertions(+), 90 deletions(-) create mode 100644 apps/web/src/components/chat/resolveComposerProviderTarget.test.ts create mode 100644 apps/web/src/components/chat/resolveComposerProviderTarget.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f29bea63adc7..b8621c1ab8f5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,7 +1,6 @@ import { type ApprovalRequestId, DEFAULT_MODEL, - defaultInstanceIdForDriver, type EnvironmentId, type MessageId, type ModelSelection, @@ -153,6 +152,8 @@ import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; +import { resolveComposerProviderTarget } from "./chat/resolveComposerProviderTarget"; +import { deriveProviderInstanceEntries, sortProviderInstanceEntries } from "../providerInstances"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { @@ -1838,23 +1839,47 @@ export default function ChatView(props: ChatViewProps) { const gitStatusQuery = useVcsStatus({ environmentId, cwd: gitCwd }); const keybindings = useServerKeybindings(); const availableEditors = useServerAvailableEditors(); - // Prefer an instance-id match so a custom Codex instance (e.g. - // `codex_personal`) surfaces its own status/message in the banner rather - // than the default Codex's. For a draft with no thread yet, fall through to - // the composer-selected provider (the `selectedProvider` branch below) - // instead of the project default, so the banner reflects the provider the - // user actually picked — not a disabled project-default they aren't using. - const activeProviderInstanceId = - activeThread?.session?.providerInstanceId ?? activeThread?.modelSelection.instanceId ?? null; + // Only a genuinely-running session pins the banner to a specific provider + // (so a live Codex thread keeps surfacing its own status, even custom + // instances like `codex_personal`). A draft — or any not-yet-started thread — + // carries the project-default `modelSelection` instance id, which must NOT + // short-circuit here: that's how a disabled / uninstalled default (e.g. a + // remote Codex) used to raise a banner for a provider the draft never runs. + // Unstarted threads fall through to the composer-style resolution below. + const activeProviderInstanceId = activeThread?.session?.providerInstanceId ?? null; const activeProviderStatus = useMemo(() => { if (activeProviderInstanceId) { return ( providerStatuses.find((status) => status.instanceId === activeProviderInstanceId) ?? null ); } - const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); - return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; - }, [activeProviderInstanceId, providerStatuses, selectedProvider]); + // No running session (draft / unstarted thread): resolve the banner + // provider the same way the composer picker does. This prefers a ready + // provider over an enabled-but-broken default (e.g. a remote Codex that + // isn't installed), so the banner reflects the provider the draft will + // actually run on instead of warning about one the user isn't using. + const entries = sortProviderInstanceEntries(deriveProviderInstanceEntries(providerStatuses)); + const target = resolveComposerProviderTarget({ + entries, + candidates: [ + selectedProviderByThreadId, + activeThread?.session?.providerInstanceId, + activeThread?.modelSelection.instanceId, + activeProject?.defaultModelSelection?.instanceId, + ], + lockedProvider, + lockedContinuationGroupKey: null, + }); + return providerStatuses.find((status) => status.instanceId === target.instanceId) ?? null; + }, [ + activeProviderInstanceId, + providerStatuses, + selectedProviderByThreadId, + activeThread?.session?.providerInstanceId, + activeThread?.modelSelection.instanceId, + activeProject?.defaultModelSelection?.instanceId, + lockedProvider, + ]); const activeProjectCwd = activeProject?.cwd ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d6677fa172ad..bc8aac398b9b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -95,10 +95,13 @@ import { proposedPlanTitle } from "../../proposedPlan"; import { getProviderInteractionModeToggle } from "../../providerModels"; import { deriveProviderInstanceEntries, - resolveProviderDriverKindForInstanceSelection, sortProviderInstanceEntries, type ProviderInstanceEntry, } from "../../providerInstances"; +import { + resolveComposerProviderTarget, + type ComposerProviderTarget, +} from "./resolveComposerProviderTarget"; import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection"; import type { UnifiedSettings } from "@t3tools/contracts/settings"; import type { SessionPhase, Thread } from "../../types"; @@ -593,21 +596,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => sortProviderInstanceEntries(deriveProviderInstanceEntries(providerStatuses)), [providerStatuses], ); - const selectedProviderByThreadId = composerDraft.activeProvider ?? null; - const threadProvider = - activeThread?.session?.providerInstanceId ?? - activeThreadModelSelection?.instanceId ?? - activeProjectDefaultModelSelection?.instanceId ?? - null; - const explicitSelectedInstanceId = selectedProviderByThreadId ?? threadProvider; - - const unlockedSelectedProvider = - resolveProviderDriverKindForInstanceSelection( - providerInstanceEntries, - providerStatuses, - explicitSelectedInstanceId, - ) ?? ProviderDriverKind.make("codex"); - const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const lockedContinuationGroupKey = useMemo((): string | null => { if (!lockedProvider || !activeThread) return null; const lockedInstanceId = @@ -624,70 +612,40 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) providerInstanceEntries, ]); - // Resolve which configured instance the composer is currently targeting. - // Priority: - // 1. The composer draft's `activeProvider` — the user's unsaved pick - // from the model picker (must win, otherwise the UI appears to - // ignore picker selections). - // 2. Thread's persisted instance id (server-side saved selection). - // 3. Project default's instance id. - // 4. First enabled entry matching the current driver kind. - // 5. First enabled entry overall / default instance for the kind. - // - const selectedInstanceId = useMemo(() => { - const candidates: Array = [ - composerDraft.activeProvider, + // Resolve the configured instance + driver kind the composer is targeting as + // a single matched pair. Candidate priority: the draft's unsaved pick, the + // thread's session/persisted instance, then the project default. A DISABLED + // candidate (e.g. a Codex project default while only Claude is enabled) never + // wins over an enabled instance, so the picker's provider identity (icon + + // auto-opened tab) stays in lockstep with the model that actually resolves + // (which already falls back to the first enabled provider). Without this, a + // fresh draft showed a Claude model under a Codex icon that opened an empty + // Codex tab. See `resolveComposerProviderTarget`. + const providerTarget = useMemo( + () => + resolveComposerProviderTarget({ + entries: providerInstanceEntries, + candidates: [ + composerDraft.activeProvider, + activeThread?.session?.providerInstanceId, + activeThreadModelSelection?.instanceId, + activeProjectDefaultModelSelection?.instanceId, + ], + lockedProvider, + lockedContinuationGroupKey, + }), + [ + activeProjectDefaultModelSelection?.instanceId, activeThread?.session?.providerInstanceId, activeThreadModelSelection?.instanceId, - activeProjectDefaultModelSelection?.instanceId, - ]; - for (const candidate of candidates) { - if (!candidate) continue; - const match = providerInstanceEntries.find( - (entry) => entry.instanceId === candidate && entry.enabled, - ); - if (match) { - // When locked to a specific driver kind, ignore persisted instance - // ids from a different kind or continuation group. - if (lockedProvider && match.driverKind !== lockedProvider) continue; - if ( - lockedContinuationGroupKey && - match.continuationGroupKey !== lockedContinuationGroupKey - ) { - continue; - } - return match.instanceId; - } - } - if (explicitSelectedInstanceId) { - return ProviderInstanceId.make(explicitSelectedInstanceId); - } - const byKind = providerInstanceEntries.find( - (entry) => - entry.enabled && - entry.driverKind === selectedProvider && - (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey), - ); - if (byKind) return byKind.instanceId; - const anyEnabled = providerInstanceEntries.find((entry) => entry.enabled); - return ( - anyEnabled?.instanceId ?? - providerInstanceEntries[0]?.instanceId ?? - activeThreadModelSelection?.instanceId ?? - activeProjectDefaultModelSelection?.instanceId ?? - ProviderInstanceId.make("codex") - ); - }, [ - activeProjectDefaultModelSelection?.instanceId, - activeThread?.session?.providerInstanceId, - activeThreadModelSelection?.instanceId, - composerDraft.activeProvider, - explicitSelectedInstanceId, - lockedContinuationGroupKey, - lockedProvider, - providerInstanceEntries, - selectedProvider, - ]); + composerDraft.activeProvider, + lockedContinuationGroupKey, + lockedProvider, + providerInstanceEntries, + ], + ); + const selectedInstanceId: ProviderInstanceId = providerTarget.instanceId; + const selectedProvider: ProviderDriverKind = providerTarget.driverKind; const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ threadRef: composerDraftTarget, diff --git a/apps/web/src/components/chat/resolveComposerProviderTarget.test.ts b/apps/web/src/components/chat/resolveComposerProviderTarget.test.ts new file mode 100644 index 000000000000..29a2b3ef5367 --- /dev/null +++ b/apps/web/src/components/chat/resolveComposerProviderTarget.test.ts @@ -0,0 +1,315 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings"; +import { describe, expect, it } from "vitest"; + +import { + deriveProviderInstanceEntries, + resolveProviderDriverKindForInstanceSelection, + sortProviderInstanceEntries, +} from "../../providerInstances"; +import { resolveSelectableProvider } from "../../providerModels"; +import { deriveEffectiveComposerModelState } from "../../composerDraftStore"; +import { resolveComposerProviderTarget } from "./resolveComposerProviderTarget"; +import { shouldShowProviderStatusBanner } from "./ProviderStatusBanner"; + +const CODEX = ProviderInstanceId.make("codex"); +const CLAUDE = ProviderInstanceId.make("claudeAgent"); + +function provider(input: { + instanceId: ProviderInstanceId; + driver: ProviderDriverKind; + enabled: boolean; + status: ServerProvider["status"]; + models?: ReadonlyArray<{ slug: string; name: string }>; +}): ServerProvider { + return { + instanceId: input.instanceId, + driver: input.driver, + enabled: input.enabled, + installed: input.status === "ready", + version: null, + status: input.status, + auth: { status: input.enabled ? "authenticated" : "unknown" }, + checkedAt: "2026-06-04T00:21:51.119Z", + models: (input.models ?? []).map((m) => ({ + slug: m.slug, + name: m.name, + isCustom: false, + capabilities: {}, + })), + slashCommands: [], + skills: [], + }; +} + +// Mirrors the user's real machine state (from ~/.t3/caches): Codex disabled, +// only Claude enabled and ready. The project default is Codex. +function disabledCodexOnlyClaudeEnabled(): ReadonlyArray { + return [ + provider({ + instanceId: CODEX, + driver: ProviderDriverKind.make("codex"), + enabled: false, + status: "disabled", + models: [], + }), + provider({ + instanceId: CLAUDE, + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + status: "ready", + models: [{ slug: "claude-opus-4-8", name: "Claude Opus 4.8" }], + }), + ]; +} + +const settings: UnifiedSettings = DEFAULT_UNIFIED_SETTINGS; + +describe("draft provider-identity divergence (the bug)", () => { + // These assertions document the two composer resolution paths that + // disagreed: the displayed model NAME fell back to the enabled Claude, but + // the picker IDENTITY (icon + auto-opened tab) stayed on the disabled Codex + // project default. That mismatch is BUG 2 — a Claude model under a Codex + // icon opening an empty Codex tab. + const providers = disabledCodexOnlyClaudeEnabled(); + + it("the model resolution swaps a disabled Codex default to the enabled Claude", () => { + // This is the path that produces the displayed model NAME. + const { selectedModel } = deriveEffectiveComposerModelState({ + draft: null, + providers, + selectedProvider: ProviderDriverKind.make("codex"), + selectedInstanceId: CODEX, + threadModelSelection: null, + projectModelSelection: { instanceId: CODEX, model: "gpt-5.4" }, + settings, + }); + expect(selectedModel).toBe("claude-opus-4-8"); + // …and `resolveSelectableProvider` agrees: disabled Codex → enabled Claude. + expect(resolveSelectableProvider(providers, ProviderDriverKind.make("codex"))).toBe( + "claudeAgent", + ); + }); + + it("but the identity resolution keeps the disabled Codex (the divergence)", () => { + // This is the path that produced the picker ICON / TAB and the banner + // provider — it does NOT check `enabled`, so it stays on Codex while the + // model above went to Claude. + const entries = sortProviderInstanceEntries(deriveProviderInstanceEntries(providers)); + expect(resolveProviderDriverKindForInstanceSelection(entries, providers, CODEX)).toBe("codex"); + }); +}); + +describe("resolveComposerProviderTarget (the fix)", () => { + it("falls back to the enabled provider when the project default is disabled", () => { + const providers = disabledCodexOnlyClaudeEnabled(); + const entries = sortProviderInstanceEntries(deriveProviderInstanceEntries(providers)); + const target = resolveComposerProviderTarget({ + entries, + // Fresh draft: no pick, no thread, project default = codex. + candidates: [null, null, null, CODEX], + lockedProvider: null, + lockedContinuationGroupKey: null, + }); + // Identity now matches the model that actually resolves: Claude. + expect(target.instanceId).toBe("claudeAgent"); + expect(target.driverKind).toBe("claudeAgent"); + + // And the model resolved under that identity is still claude-opus-4-8, + // so icon + tab + model agree. + const { selectedModel } = deriveEffectiveComposerModelState({ + draft: null, + providers, + selectedProvider: target.driverKind, + selectedInstanceId: target.instanceId, + threadModelSelection: null, + projectModelSelection: { instanceId: CODEX, model: "gpt-5.4" }, + settings, + }); + expect(selectedModel).toBe("claude-opus-4-8"); + }); + + it("keeps an ENABLED project default unchanged (no regression)", () => { + const providers = [ + provider({ + instanceId: CODEX, + driver: ProviderDriverKind.make("codex"), + enabled: true, + status: "ready", + models: [{ slug: "gpt-5.4", name: "GPT-5.4" }], + }), + provider({ + instanceId: CLAUDE, + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + status: "ready", + models: [{ slug: "claude-opus-4-8", name: "Claude Opus 4.8" }], + }), + ]; + const entries = sortProviderInstanceEntries(deriveProviderInstanceEntries(providers)); + const target = resolveComposerProviderTarget({ + entries, + candidates: [null, null, null, CODEX], + lockedProvider: null, + lockedContinuationGroupKey: null, + }); + expect(target.instanceId).toBe("codex"); + expect(target.driverKind).toBe("codex"); + }); + + it("honours an explicit enabled draft pick over the project default", () => { + const providers = disabledCodexOnlyClaudeEnabled(); + const entries = sortProviderInstanceEntries(deriveProviderInstanceEntries(providers)); + const target = resolveComposerProviderTarget({ + entries, + // Draft pick = claudeAgent (enabled); project default = codex. + candidates: [CLAUDE, null, null, CODEX], + lockedProvider: null, + lockedContinuationGroupKey: null, + }); + expect(target.instanceId).toBe("claudeAgent"); + }); + + it("respects a thread lock to a specific driver kind", () => { + const providers = [ + provider({ + instanceId: CODEX, + driver: ProviderDriverKind.make("codex"), + enabled: true, + status: "ready", + models: [{ slug: "gpt-5.4", name: "GPT-5.4" }], + }), + provider({ + instanceId: CLAUDE, + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + status: "ready", + models: [{ slug: "claude-opus-4-8", name: "Claude Opus 4.8" }], + }), + ]; + const entries = sortProviderInstanceEntries(deriveProviderInstanceEntries(providers)); + const target = resolveComposerProviderTarget({ + entries, + // Project default codex, but the thread is locked to claudeAgent. + candidates: [null, CLAUDE, null, CODEX], + lockedProvider: ProviderDriverKind.make("claudeAgent"), + lockedContinuationGroupKey: null, + }); + expect(target.instanceId).toBe("claudeAgent"); + expect(target.driverKind).toBe("claudeAgent"); + }); + + it("preserves a concrete identity when nothing is enabled", () => { + const providers = [ + provider({ + instanceId: CODEX, + driver: ProviderDriverKind.make("codex"), + enabled: false, + status: "disabled", + models: [], + }), + ]; + const entries = sortProviderInstanceEntries(deriveProviderInstanceEntries(providers)); + const target = resolveComposerProviderTarget({ + entries, + candidates: [null, null, null, CODEX], + lockedProvider: null, + lockedContinuationGroupKey: null, + }); + expect(target.instanceId).toBe("codex"); + expect(target.driverKind).toBe("codex"); + }); +}); + +// ChatView resolves the draft-status banner through this SAME helper, so the +// picker icon/tab and the banner always land on one provider. This mirrors +// ChatView's draft branch: resolve the target instance, then read its snapshot. +function resolveDraftBannerStatus( + providers: ReadonlyArray, + projectDefaultInstanceId: ProviderInstanceId, +): ServerProvider | null { + const entries = sortProviderInstanceEntries(deriveProviderInstanceEntries(providers)); + const target = resolveComposerProviderTarget({ + entries, + candidates: [null, null, null, projectDefaultInstanceId], + lockedProvider: null, + lockedContinuationGroupKey: null, + }); + return providers.find((p) => p.instanceId === target.instanceId) ?? null; +} + +describe("picker identity and draft banner stay in lockstep", () => { + it("disabled Codex default: both resolve to Claude and no banner shows", () => { + const providers = disabledCodexOnlyClaudeEnabled(); + const entries = sortProviderInstanceEntries(deriveProviderInstanceEntries(providers)); + const target = resolveComposerProviderTarget({ + entries, + candidates: [null, null, null, CODEX], + lockedProvider: null, + lockedContinuationGroupKey: null, + }); + const bannerStatus = resolveDraftBannerStatus(providers, CODEX); + expect(target.driverKind).toBe("claudeAgent"); + expect(bannerStatus?.instanceId).toBe("claudeAgent"); + expect(shouldShowProviderStatusBanner(bannerStatus)).toBe(false); + }); + + it("enabled-but-NOT-INSTALLED Codex default moves to Claude and hides the banner", () => { + // The exact VPS case: Codex is enabled on the remote server but its CLI + // isn't installed (status "error"), while Claude is enabled and ready. A + // draft must follow the ready provider, not raise Codex's "not installed" + // banner for a provider it isn't running. + const providers = [ + provider({ + instanceId: CODEX, + driver: ProviderDriverKind.make("codex"), + enabled: true, + status: "error", + models: [], + }), + provider({ + instanceId: CLAUDE, + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + status: "ready", + models: [{ slug: "claude-opus-4-8", name: "Claude Opus 4.8" }], + }), + ]; + const entries = sortProviderInstanceEntries(deriveProviderInstanceEntries(providers)); + const target = resolveComposerProviderTarget({ + entries, + candidates: [null, null, null, CODEX], + lockedProvider: null, + lockedContinuationGroupKey: null, + }); + const bannerStatus = resolveDraftBannerStatus(providers, CODEX); + expect(target.driverKind).toBe("claudeAgent"); + expect(bannerStatus?.instanceId).toBe("claudeAgent"); + expect(shouldShowProviderStatusBanner(bannerStatus)).toBe(false); + }); + + it("Codex enabled-but-broken as the ONLY provider still warns", () => { + // No ready alternative exists, so the broken provider is surfaced and its + // banner shows — a genuinely useful warning, not noise. + const providers = [ + provider({ + instanceId: CODEX, + driver: ProviderDriverKind.make("codex"), + enabled: true, + status: "error", + models: [], + }), + ]; + const entries = sortProviderInstanceEntries(deriveProviderInstanceEntries(providers)); + const target = resolveComposerProviderTarget({ + entries, + candidates: [null, null, null, CODEX], + lockedProvider: null, + lockedContinuationGroupKey: null, + }); + const bannerStatus = resolveDraftBannerStatus(providers, CODEX); + expect(target.driverKind).toBe("codex"); + expect(bannerStatus?.instanceId).toBe("codex"); + expect(shouldShowProviderStatusBanner(bannerStatus)).toBe(true); + }); +}); diff --git a/apps/web/src/components/chat/resolveComposerProviderTarget.ts b/apps/web/src/components/chat/resolveComposerProviderTarget.ts new file mode 100644 index 000000000000..3f13e6bed0d3 --- /dev/null +++ b/apps/web/src/components/chat/resolveComposerProviderTarget.ts @@ -0,0 +1,173 @@ +import { + defaultInstanceIdForDriver, + ProviderDriverKind, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import type { ProviderInstanceEntry } from "../../providerInstances"; + +/** + * The provider instance + driver kind a composer draft is currently + * targeting. Both fields are derived from the SAME entry so the model + * picker icon/tab (keyed on `instanceId`) and everything keyed on + * `driverKind` (capabilities, banner, dispatch metadata) can never disagree. + */ +export interface ComposerProviderTarget { + readonly instanceId: ProviderInstanceId; + readonly driverKind: ProviderDriverKind; +} + +export interface ResolveComposerProviderTargetInput { + /** + * Instance entries, default-first per kind (i.e. the output of + * `sortProviderInstanceEntries(deriveProviderInstanceEntries(providers))`). + */ + readonly entries: ReadonlyArray; + /** + * Candidate instance ids in priority order. Conventionally: + * 1. the composer draft's `activeProvider` (the user's unsaved pick) + * 2. the active thread's session `providerInstanceId` + * 3. the active thread's persisted model-selection `instanceId` + * 4. the project default's `instanceId` + * Nullish entries are skipped. + */ + readonly candidates: ReadonlyArray; + /** Driver kind the thread is locked to (server threads), else null. */ + readonly lockedProvider: ProviderDriverKind | null; + /** Continuation group the thread is locked to, else null. */ + readonly lockedContinuationGroupKey: string | null; +} + +function matchesLock( + entry: ProviderInstanceEntry, + lockedProvider: ProviderDriverKind | null, + lockedContinuationGroupKey: string | null, +): boolean { + if (lockedProvider && entry.driverKind !== lockedProvider) return false; + if (lockedContinuationGroupKey && entry.continuationGroupKey !== lockedContinuationGroupKey) { + return false; + } + return true; +} + +/** Enabled and probed healthy — usable without raising a provider-status banner. */ +function isReady(entry: ProviderInstanceEntry): boolean { + return entry.enabled && entry.status === "ready"; +} + +/** + * Run one resolution pass against a usability predicate, in candidate priority + * order, then by-kind, then any-match. Returns null when nothing satisfies the + * predicate so the caller can try a looser one. + */ +function pick( + input: ResolveComposerProviderTargetInput, + explicit: ProviderInstanceId | null, + requestedKind: ProviderDriverKind | null, + predicate: (entry: ProviderInstanceEntry) => boolean, +): ComposerProviderTarget | null { + const { entries, candidates, lockedProvider, lockedContinuationGroupKey } = input; + + // Candidate priority (the draft pick / thread / project default). + for (const candidate of candidates) { + if (!candidate) continue; + const match = entries.find((entry) => entry.instanceId === candidate && predicate(entry)); + if (match && matchesLock(match, lockedProvider, lockedContinuationGroupKey)) { + return { instanceId: match.instanceId, driverKind: match.driverKind }; + } + } + + // First matching instance of the requested driver kind. + if (requestedKind) { + const byKind = entries.find( + (entry) => + predicate(entry) && + entry.driverKind === requestedKind && + (!lockedContinuationGroupKey || + entry.continuationGroupKey === lockedContinuationGroupKey), + ); + if (byKind) { + return { instanceId: byKind.instanceId, driverKind: byKind.driverKind }; + } + } + + // Any matching instance overall — but never leave a locked kind. + if (!lockedProvider) { + const any = entries.find( + (entry) => + predicate(entry) && + (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey), + ); + if (any) { + return { instanceId: any.instanceId, driverKind: any.driverKind }; + } + } + + return null; +} + +/** + * Resolve which configured instance the composer is targeting, returning the + * instance id AND its driver kind as a matched pair. + * + * Resolution prefers, in order: + * 1. A READY instance (enabled + probed healthy) — candidate first, then by + * the requested driver kind, then any ready instance. + * 2. Failing that, any ENABLED instance (same candidate → kind → any order), + * so an enabled-but-broken provider is still surfaced when nothing works. + * 3. Last resort when nothing is enabled: the first candidate's own entry, + * else the first entry, else the default instance for the locked/Codex + * kind — so the composer always has a concrete identity to show. + * + * Two properties matter: + * + * - A DISABLED candidate (e.g. a `codex` project default while only Claude is + * enabled) never wins over an enabled instance — keeping the picker identity + * in lockstep with the model that resolves via `resolveSelectableProvider`. + * + * - An ENABLED-BUT-NOT-READY candidate (e.g. a `codex` project default on a + * remote server where the Codex CLI isn't installed) never wins over a ready + * provider. Without this, a fresh draft you're composing on Claude still + * inherited the broken Codex default and raised its "not installed" banner. + * The broken provider is only chosen when there is no ready alternative, so a + * genuinely all-broken setup still warns; the provider's real status remains + * visible in Settings either way. + * + * `ChatView` resolves the draft-status banner through this same helper, so the + * picker icon/tab and the banner always agree on one provider. + */ +export function resolveComposerProviderTarget( + input: ResolveComposerProviderTargetInput, +): ComposerProviderTarget { + const { entries, candidates, lockedProvider } = input; + const explicit = candidates.find((candidate) => !!candidate) ?? null; + const requestedKind = + lockedProvider ?? entries.find((entry) => entry.instanceId === explicit)?.driverKind ?? null; + + // Prefer a ready provider; fall back to any enabled one (so a broken-but- + // enabled provider is still surfaced when nothing is ready). + return ( + pick(input, explicit, requestedKind, isReady) ?? + pick(input, explicit, requestedKind, (entry) => entry.enabled) ?? + lastResort(input, explicit) + ); +} + +/** Nothing is enabled — keep a concrete identity to show. */ +function lastResort( + input: ResolveComposerProviderTargetInput, + explicit: ProviderInstanceId | null, +): ComposerProviderTarget { + const { entries, lockedProvider } = input; + const withinLock = (entry: ProviderInstanceEntry): boolean => + !lockedProvider || entry.driverKind === lockedProvider; + const explicitEntry = entries.find((entry) => entry.instanceId === explicit); + if (explicitEntry && withinLock(explicitEntry)) { + return { instanceId: explicitEntry.instanceId, driverKind: explicitEntry.driverKind }; + } + const first = entries.find(withinLock); + if (first) { + return { instanceId: first.instanceId, driverKind: first.driverKind }; + } + const fallbackKind = lockedProvider ?? ProviderDriverKind.make("codex"); + return { instanceId: defaultInstanceIdForDriver(fallbackKind), driverKind: fallbackKind }; +} From efbab7dbb9f6a227d1c5a9d0e814166dcc18a4f5 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Thu, 4 Jun 2026 08:53:11 -0400 Subject: [PATCH 06/40] docs: record rewind feasibility + picker scope for /resume Capture the cap-4 rewind feasibility finding (SDK supports resumeSessionAt + forkSession) and the picker-scope decision (terminal-origin sessions only, t3-origin hidden, already-imported flagged) in CONTEXT.md and ADR 0001 so a future session does not re-investigate. Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTEXT.md | 11 ++++++++++- docs/adr/0001-cli-t3-conversation-continuity.md | 9 +++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CONTEXT.md b/CONTEXT.md index 8519c8f5fed8..fc3ed2d98170 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -26,6 +26,13 @@ Claude's on-disk grouping of sessions by working directory: `~/.claude/projects/ **Worktree**: An isolated git working copy t3 may create for a Thread. When present, the Thread runs there instead of the repo root — which puts its Provider session in a *different* Project bucket than a terminal session run from the repo. +**Importable session**: +A past Claude Provider session in this project's Project bucket that t3's `/resume` picker offers to pull into a new Thread. Defined as: a top-level chat (not a background sub-agent / sidechain session), with real content (not an empty or abandoned stub), that originated in the **terminal** (not one t3 itself created), in this project's working directory. A terminal session already pulled into a Thread is still listed but flagged "already in t3". +_Avoid_: listing t3-originated or sub-agent sessions in the picker by default. + +**Session origin**: +Where a Provider session was started: the **terminal** (the user's own Claude CLI) or **t3** (the app drove Claude itself). The picker shows terminal-origin sessions; t3-origin ones are already Threads and are hidden by default. The two are distinguishable on disk. + ## Relationships - A **Thread** is backed by exactly one **Provider session** at a time, via a **Resume cursor**. @@ -40,6 +47,8 @@ An isolated git working copy t3 may create for a Thread. When present, the Threa - **Priority direction (resolved 2026-06-03):** the more important half is **terminal → t3** ("resume a CLI chat *inside* t3"), built first. The reverse (t3 → terminal) is nearly free and comes second. - **VERIFIED with real data (2026-06-03):** t3 already stores Claude **Provider sessions** in the *shared* `~/.claude` store and runs Claude **Threads** in the repo root (no Worktree by default — real threads had `worktree_path: null`, branch `custom`/`main`). A real t3 thread's session file (`c55dc749…`, 26 msgs) physically sits in the **same Project bucket** as terminal sessions. macOS's case-insensitive filesystem merges the `Dev` vs `dev` path-casing difference into one bucket, so the two share a single session pool. t3 state lives at `~/.t3/userdata/state.sqlite`; resume pointer is `provider_session_runtime.resume_cursor_json` = `{resume, resumeSessionAt}`. - **Display of an imported chat (resolved 2026-06-03):** **show** the earlier messages (readable text), so the user can see the chat and pick a point — not "show nothing". Full byte-perfect reproduction of every tool call/sidechain is *not* required for v1. -- **Rewind (resolved 2026-06-03):** two kinds. **Conversation-rewind** (jump to an earlier message and continue a new path) is in v1 — it uses the stable `resumeSessionAt` anchor. **File-rewind** (restore the working tree to an earlier point of the *imported* chat) is **deferred**: the data exists (`~/.claude/file-history//` blobs + `file-history-snapshot` transcript entries with `trackedFileBackups`), so it's *possible*, but it means reading Claude's private, undocumented snapshot format and translating it to t3's git-checkpoint model — fragile against Claude updates, against the fork's "don't depend on churny internals" principle. Revisit only if it proves essential. +- **Rewind (resolved 2026-06-03):** two kinds, mirroring Claude Code's *native* rewind, which deliberately separates them (native menu offers "Restore conversation" vs "Restore code" as distinct actions — verified against Claude Code rewind docs 2026-06-03). **Conversation-rewind** (jump to an earlier message and continue) is in v1 — it uses the stable `resumeSessionAt` anchor. It mirrors native "Restore conversation": it stays in the **same Thread / Provider session** (not a new branch Thread), it is **non-destructive** (the skipped-past messages are retained in the on-disk transcript tree, as native keeps them), and it **leaves the working tree untouched**. Mechanically it just moves `resumeSessionAt` back to an earlier message uuid and continues the same session. **File-rewind** (native "Restore code" — restore the working tree to an earlier point of the *imported* chat) is **deferred**: the data exists (`~/.claude/file-history//` blobs + `file-history-snapshot` transcript entries with `trackedFileBackups`), so it's *possible*, but it means reading Claude's private, undocumented snapshot format and translating it to t3's git-checkpoint model — fragile against Claude updates, against the fork's "don't depend on churny internals" principle. Because native treats the two as separate actions, doing conversation-rewind without file-rewind is **faithful to native**, not a compromise. Revisit file-rewind only if it proves essential. +- **Picker contents (resolved 2026-06-04):** the `/resume` picker lists **Importable sessions** only — terminal-origin chats for this project. t3-origin chats are hidden by default (they are already Threads; a future toggle can reveal them); background sub-agent / sidechain sessions and empty stubs are always hidden; an already-imported terminal chat is shown but flagged "already in t3". Distinguishing these on disk was verified against the real bucket on 2026-06-04 (origin marker, sidechain flag, content size, and a stored title for display all present). +- **Surface (resolved 2026-06-04):** two entry points, one picker — a `/resume` command in the t3 chat box (caught by t3 before it reaches Claude) and a button near "new thread" in the sidebar. - **"conversation"** — maps to both a t3 **Thread** and a **Provider session**; they are linked, not the same thing. - **"resume / pick up the context"** — means continue the *same* conversation on the other side. **Resolved (2026-06-03):** carry the *chat*; run **both sides in the same folder** (the real repo, no private Worktree for bridged Threads) so the files line up on their own. Trade-off accepted: bridged Threads edit the real working tree, giving up worktree isolation. diff --git a/docs/adr/0001-cli-t3-conversation-continuity.md b/docs/adr/0001-cli-t3-conversation-continuity.md index 262948147f67..5918892777b6 100644 --- a/docs/adr/0001-cli-t3-conversation-continuity.md +++ b/docs/adr/0001-cli-t3-conversation-continuity.md @@ -27,3 +27,12 @@ We therefore decided: - Fork exposure is mostly new files (a resumable-session service, a `/resume` UI) plus the known Bucket-2 seams: one new RPC method (`ws.ts` + `rpc.ts`). The provider-adapter interface edit is deferred until generalization. - Bridged threads have no worktree isolation by design. - Correct behavior depends on both sides using the same working-directory string; macOS's case-insensitive filesystem currently merges `Dev`/`dev` casing into one bucket, but exact-path alignment is the underlying requirement. + +## Feasibility verification (2026-06-04) + +A pre-build spike — static, against the installed SDK and the real on-disk session data, no live run — checked the two load-bearing unknowns. Both cleared, favorably: + +- **Cap 4 (conversation-rewind) is feasible.** The Claude Agent SDK's query `Options` exposes `resumeSessionAt?: string` — *"when resuming, only resume messages up to and including the message with this UUID … resume from a specific point in the conversation"* (`sdk.d.ts:1703-1707`, SDK 0.3.154). That is exactly the anchor cap 4 needs. **The gap is on t3's side, not the SDK's:** t3 already *stores* `resumeSessionAt` in its resume cursor but never passes it into the query — `startSession` builds `queryOptions` with only `resume` + `sessionId` (`ClaudeAdapter.ts:2949-2950`). Wiring `resumeSessionAt` through — only for an *intentional* rewind, not every normal continue (every continue currently sets the cursor's `resumeSessionAt` to the latest assistant uuid) — is the cap-4 change. It edits `ClaudeAdapter.ts`, an upstream file (not in the FORK.md hot-list, but still Theo's) — keep the edit minimal. +- **Resume does not fork by default.** `forkSession` is opt-in (`sdk.d.ts:1426-1429`; CLI `--fork-session`). t3 assigns a fresh id only for brand-new sessions and resumes with `resume` alone — the two are mutually exclusive (`ClaudeAdapter.ts:2584-2586`) — so resuming a terminal session continues the **same** session id, non-destructively (matches native "Restore conversation"). Consequence for the "already in t3" flag: matching on the cursor's `resume` id is sound today, but to stay robust against any future fork behavior, also persist an explicit *imported-from* session id on the bridged Thread rather than relying only on the live cursor. +- **Project-membership filter:** the finder should decide which sessions belong to this project by reading the `cwd` field *inside* each `.jsonl` (present on every turn line), not by reconstructing Claude's directory-bucket hash — robust against whatever path encoding Claude uses. +- **Not yet run live.** End-to-end confirmation (t3 actually resuming a real terminal session, and rewinding to an earlier uuid) is deferred to the build of those slices, where it is verified empirically rather than from types alone. From 3596620a1a6d6904759e44643df7660aca804d1b Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Thu, 4 Jun 2026 08:53:20 -0400 Subject: [PATCH 07/40] feat(resume): server-side finder + resume.listImportableSessions RPC Add the server half of the CLI<->t3 /resume feature: list a project's past terminal Claude sessions so they can be resumed inside t3. - importableSessions.ts: pure selection logic (filter t3-origin, flag already-imported, prefer custom title) with 4 unit tests. - importableSessionsService.ts: I/O shell over the SDK's documented listSessions({ dir }) (no transcript parsing). Origin/imported filtering is deferred (needs provider_session_runtime, which would churn upstream tests); ships dependency-free for now, listing every session. - contracts/resume.ts: ResumeListImportableSessions{Input,Result}, ResumeError. - Wire resume.listImportableSessions through rpc.ts + ws.ts with the service provided locally at the ws layer, leaving server.ts untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/resume/importableSessions.test.ts | 64 +++++++++++++ apps/server/src/resume/importableSessions.ts | 59 ++++++++++++ .../src/resume/importableSessionsService.ts | 89 +++++++++++++++++++ apps/server/src/ws.ts | 17 ++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/resume.ts | 38 ++++++++ packages/contracts/src/rpc.ts | 15 ++++ 7 files changed, 283 insertions(+) create mode 100644 apps/server/src/resume/importableSessions.test.ts create mode 100644 apps/server/src/resume/importableSessions.ts create mode 100644 apps/server/src/resume/importableSessionsService.ts create mode 100644 packages/contracts/src/resume.ts diff --git a/apps/server/src/resume/importableSessions.test.ts b/apps/server/src/resume/importableSessions.test.ts new file mode 100644 index 000000000000..1315e51343aa --- /dev/null +++ b/apps/server/src/resume/importableSessions.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { selectImportableSessions, type SessionInfo } from "./importableSessions.ts"; + +/** + * Inputs mirror the SDK's `listSessions({ dir })` result (`SDKSessionInfo`), + * verified against real data 2026-06-04: `summary` is the auto title, + * `customTitle` the user-set name (shown in Claude's own picker), `lastModified` + * is ms since epoch. Origin is supplied separately from t3's DB. + */ +const session = (over: Partial): SessionInfo => ({ + sessionId: "s", + summary: "Auto title", + lastModified: 1780548498563, + ...over, +}); + +describe("selectImportableSessions", () => { + it("offers a terminal session, titled and not-yet-imported", () => { + const result = selectImportableSessions( + [session({ sessionId: "t1", summary: "Fix the bug" })], + { + t3OriginSessionIds: new Set(), + importedSessionIds: new Set(), + }, + ); + + expect(result).toEqual([ + { + sessionId: "t1", + title: "Fix the bug", + lastActivityAt: 1780548498563, + alreadyImported: false, + }, + ]); + }); + + it("prefers the user-set custom title over the auto summary", () => { + const result = selectImportableSessions( + [session({ sessionId: "t1", summary: "Auto", customTitle: "fork-polish" })], + { t3OriginSessionIds: new Set(), importedSessionIds: new Set() }, + ); + + expect(result[0]?.title).toBe("fork-polish"); + }); + + it("hides sessions t3 created itself", () => { + const result = selectImportableSessions( + [session({ sessionId: "t1" }), session({ sessionId: "fromT3" })], + { t3OriginSessionIds: new Set(["fromT3"]), importedSessionIds: new Set() }, + ); + + expect(result.map((r) => r.sessionId)).toEqual(["t1"]); + }); + + it("flags a terminal session already imported into a Thread", () => { + const result = selectImportableSessions([session({ sessionId: "t1" })], { + t3OriginSessionIds: new Set(), + importedSessionIds: new Set(["t1"]), + }); + + expect(result[0]?.alreadyImported).toBe(true); + }); +}); diff --git a/apps/server/src/resume/importableSessions.ts b/apps/server/src/resume/importableSessions.ts new file mode 100644 index 000000000000..0a01ac1d85ae --- /dev/null +++ b/apps/server/src/resume/importableSessions.ts @@ -0,0 +1,59 @@ +/** + * Pure selection logic for the "/resume" finder. + * + * The session list + metadata come from the Claude Agent SDK's documented + * `listSessions({ dir })` (returns `SDKSessionInfo[]`) — NOT from hand-parsing + * Claude's private transcript format (that would be the same fragile dependency + * the fork avoids; see docs/adr/0001 "Feasibility verification"). Origin + * (terminal vs t3) is NOT part of that metadata; it comes from t3's own DB — the + * Claude session ids t3 created, and the terminal sessions it has imported. See + * CONTEXT.md ("Importable session", "Session origin"). + * + * This module is pure: the SDK call and the DB queries live in a thin shell + * elsewhere, so the filter/flag rules stay unit-testable from plain objects. + */ + +/** The subset of the SDK's `SDKSessionInfo` the finder relies on. */ +export interface SessionInfo { + readonly sessionId: string; + /** Auto-generated title or first prompt (SDK `summary`). */ + readonly summary: string; + /** User-set title via /rename or `-n` (SDK `customTitle`); preferred when present. */ + readonly customTitle?: string; + /** Last-modified time, milliseconds since epoch (SDK `lastModified`). */ + readonly lastModified: number; +} + +/** A session the `/resume` picker offers, after filtering and flagging. */ +export interface ImportableSession { + readonly sessionId: string; + readonly title: string; + readonly lastActivityAt: number; + readonly alreadyImported: boolean; +} + +export interface SelectImportableOptions { + /** Claude session ids t3 created itself (native t3 Threads) — hidden from the picker. */ + readonly t3OriginSessionIds: ReadonlySet; + /** Terminal session ids already imported into a Thread — shown, but flagged. */ + readonly importedSessionIds: ReadonlySet; +} + +/** + * Reduce the SDK's project session list to the Importable sessions: the + * terminal-origin ones (everything t3 did not create itself), each titled and + * flagged with whether it has already been imported into a Thread. + */ +export function selectImportableSessions( + sessions: ReadonlyArray, + options: SelectImportableOptions, +): ReadonlyArray { + return sessions + .filter((session) => !options.t3OriginSessionIds.has(session.sessionId)) + .map((session) => ({ + sessionId: session.sessionId, + title: session.customTitle ?? session.summary, + lastActivityAt: session.lastModified, + alreadyImported: options.importedSessionIds.has(session.sessionId), + })); +} diff --git a/apps/server/src/resume/importableSessionsService.ts b/apps/server/src/resume/importableSessionsService.ts new file mode 100644 index 000000000000..7ffbdfa6b50e --- /dev/null +++ b/apps/server/src/resume/importableSessionsService.ts @@ -0,0 +1,89 @@ +/** + * The "/resume" finder's I/O shell: a thin server service that lists a + * project's importable Claude sessions via the SDK and delegates the rules to + * the pure `selectImportableSessions`. + * + * Source: the Claude Agent SDK's documented `listSessions({ dir })` — the same + * on-disk metadata Claude's own `/resume` picker uses (no transcript parsing). + * + * DEFERRED — origin/imported filtering: the pure rules can hide t3-origin + * sessions and flag already-imported ones, but both need t3's + * `provider_session_runtime` rows (the Claude session ids t3 created). Injecting + * that persistence repo into the ws layer pulls `SqlClient` into the ws-layer + * requirements, which the upstream `server.test.ts` ws-route tests do not + * provide — so wiring it would force edits to that large upstream test file, + * against the fork strategy. For this slice the service passes empty id sets + * (lists every session, none flagged). Re-enabling the filter is the next task: + * either provide an in-memory persistence layer to those tests, or derive the + * t3-origin id set from a service already present in the ws-layer scope. + * See CONTEXT.md ("Importable session") and docs/adr/0001. + */ +import { listSessions as sdkListSessions } from "@anthropic-ai/claude-agent-sdk"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { + selectImportableSessions, + type ImportableSession, + type SessionInfo, +} from "./importableSessions.ts"; + +export class ImportableSessionsError extends Data.TaggedError("ImportableSessionsError")<{ + readonly detail: string; + readonly cause?: unknown; +}> {} + +export interface ListImportableSessionsInput { + /** The project's working directory; sessions are listed for this folder. */ + readonly projectCwd: string; +} + +export interface ImportableSessionsServiceShape { + readonly listForProject: ( + input: ListImportableSessionsInput, + ) => Effect.Effect, ImportableSessionsError>; +} + +export class ImportableSessionsService extends Context.Service< + ImportableSessionsService, + ImportableSessionsServiceShape +>()("t3/resume/importableSessionsService") {} + +/** Project the SDK's `SDKSessionInfo` onto the subset the finder consumes. */ +function toSessionInfo(info: { + readonly sessionId: string; + readonly summary: string; + readonly customTitle?: string; + readonly lastModified: number; +}): SessionInfo { + return { + sessionId: info.sessionId, + summary: info.summary, + ...(info.customTitle !== undefined ? { customTitle: info.customTitle } : {}), + lastModified: info.lastModified, + }; +} + +const make = Effect.sync(() => { + const listForProject: ImportableSessionsServiceShape["listForProject"] = ({ projectCwd }) => + Effect.gen(function* () { + const sdkSessions = yield* Effect.tryPromise({ + try: () => sdkListSessions({ dir: projectCwd }), + catch: (cause) => + new ImportableSessionsError({ detail: "Claude listSessions failed", cause }), + }); + + return selectImportableSessions(sdkSessions.map(toSessionInfo), { + // Deferred (see file header): needs provider_session_runtime, which can't + // reach the ws-layer test env without upstream-test churn. + t3OriginSessionIds: new Set(), + importedSessionIds: new Set(), + }); + }); + + return { listForProject } satisfies ImportableSessionsServiceShape; +}); + +export const ImportableSessionsServiceLive = Layer.effect(ImportableSessionsService, make); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index da8e3f694db2..e035974a498e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -42,6 +42,7 @@ import { type TerminalError, type TerminalEvent, type TerminalMetadataStreamEvent, + ResumeError, WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; @@ -74,6 +75,10 @@ import { VcsStatusBroadcaster } from "./vcs/VcsStatusBroadcaster.ts"; import { VcsProvisioningService } from "./vcs/VcsProvisioningService.ts"; import { GitWorkflowService } from "./git/GitWorkflowService.ts"; import { ReviewService } from "./review/ReviewService.ts"; +import { + ImportableSessionsService, + ImportableSessionsServiceLive, +} from "./resume/importableSessionsService.ts"; import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts"; import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; @@ -152,6 +157,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope], [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope], [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope], + [WS_METHODS.resumeListImportableSessions, AuthOrchestrationReadScope], [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope], [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope], [WS_METHODS.vcsPull, AuthOrchestrationOperateScope], @@ -242,6 +248,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const startup = yield* ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem; + const importableSessions = yield* ImportableSessionsService; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; const repositoryIdentityResolver = yield* RepositoryIdentityResolver; const serverEnvironment = yield* ServerEnvironment; @@ -984,6 +991,15 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { "rpc.aggregate": "server", }), + [WS_METHODS.resumeListImportableSessions]: (input) => + observeRpcEffect( + WS_METHODS.resumeListImportableSessions, + importableSessions.listForProject({ projectCwd: input.cwd }).pipe( + Effect.map((sessions) => ({ sessions })), + Effect.mapError((cause) => new ResumeError({ detail: cause.detail, cause })), + ), + { "rpc.aggregate": "resume" }, + ), [WS_METHODS.serverRefreshProviders]: (input) => observeRpcEffect( WS_METHODS.serverRefreshProviders, @@ -1433,6 +1449,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( Effect.provide( makeWsRpcLayer(session).pipe( Layer.provideMerge(RpcSerialization.layerJson), + Layer.provide(ImportableSessionsServiceLive), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide( SourceControlDiscoveryLayer.layer.pipe( diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index bf26014c46e0..2c3b74e53209 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -21,4 +21,5 @@ export * from "./editor.ts"; export * from "./project.ts"; export * from "./filesystem.ts"; export * from "./review.ts"; +export * from "./resume.ts"; export * from "./rpc.ts"; diff --git a/packages/contracts/src/resume.ts b/packages/contracts/src/resume.ts new file mode 100644 index 000000000000..2782556aff0c --- /dev/null +++ b/packages/contracts/src/resume.ts @@ -0,0 +1,38 @@ +/** + * Contracts for the CLI ↔ t3 conversation-continuity feature ("/resume"). + * See repo CONTEXT.md and docs/adr/0001-cli-t3-conversation-continuity.md. + */ +import * as Schema from "effect/Schema"; + +/** Input: list the importable Claude sessions for a project directory. */ +export const ResumeListImportableSessionsInput = Schema.Struct({ + /** The project's working directory (workspace root) whose sessions to list. */ + cwd: Schema.String, +}); +export type ResumeListImportableSessionsInput = typeof ResumeListImportableSessionsInput.Type; + +/** One past terminal Claude session the /resume picker can offer. */ +export const ImportableSession = Schema.Struct({ + sessionId: Schema.String, + title: Schema.String, + /** Last-activity time, milliseconds since epoch. */ + lastActivityAt: Schema.Number, + /** True when this session has already been imported into a Thread. */ + alreadyImported: Schema.Boolean, +}); +export type ImportableSession = typeof ImportableSession.Type; + +export const ResumeListImportableSessionsResult = Schema.Struct({ + sessions: Schema.Array(ImportableSession), +}); +export type ResumeListImportableSessionsResult = typeof ResumeListImportableSessionsResult.Type; + +/** Error raised by the /resume finder (listing past sessions). */ +export class ResumeError extends Schema.TaggedErrorClass()("ResumeError", { + detail: Schema.String, + cause: Schema.optional(Schema.Defect), +}) { + override get message(): string { + return `Resume finder error: ${this.detail}`; + } +} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 814e403b64c9..c9790c86a54d 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -110,6 +110,11 @@ import { SourceControlRepositoryLookupInput, } from "./sourceControl.ts"; import { VcsError } from "./vcs.ts"; +import { + ResumeError, + ResumeListImportableSessionsInput, + ResumeListImportableSessionsResult, +} from "./resume.ts"; export const WS_METHODS = { // Project registry methods @@ -143,6 +148,9 @@ export const WS_METHODS = { // Review methods reviewGetDiffPreview: "review.getDiffPreview", + // Resume methods (CLI <-> t3 conversation continuity) + resumeListImportableSessions: "resume.listImportableSessions", + // Terminal methods terminalOpen: "terminal.open", terminalAttach: "terminal.attach", @@ -523,6 +531,12 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, stream: true, }); +export const WsResumeListImportableSessionsRpc = Rpc.make(WS_METHODS.resumeListImportableSessions, { + payload: ResumeListImportableSessionsInput, + success: ResumeListImportableSessionsResult, + error: Schema.Union([ResumeError, EnvironmentAuthorizationError]), +}); + export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, WsServerRefreshProvidersRpc, @@ -556,6 +570,7 @@ export const WsRpcGroup = RpcGroup.make( WsVcsSwitchRefRpc, WsVcsInitRpc, WsReviewGetDiffPreviewRpc, + WsResumeListImportableSessionsRpc, WsTerminalOpenRpc, WsTerminalAttachRpc, WsTerminalWriteRpc, From faaad3f92a81041bacaac7a529a381709b146ec1 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Thu, 4 Jun 2026 09:09:17 -0400 Subject: [PATCH 08/40] feat(resume): /resume picker UI (list-only slice) Wire the web client to resume.listImportableSessions and add a minimal popup that lists a project's past terminal Claude sessions. Selecting/importing a session is the next slice. - client-runtime/wsRpcClient + contracts/ipc EnvironmentApi + web environmentApi: add the resume namespace mirroring the orchestration/vcs binding pattern. - ResumePicker.tsx (new): a base-ui Dialog that fetches sessions on open and renders them; cwd is the project root (where terminal claude runs), not the per-thread worktree. - resumePickerStore.ts (new): tiny zustand open/close store so both triggers reach the single picker without prop-drilling (mirrors commandPaletteStore). - Intercept /resume as a t3-native command: composer menu-select branch (ChatComposer, like /model) and the typed-and-Enter path (ChatView.onSend via isStandaloneResumeCommand) so it opens the picker instead of reaching Claude. - Add resume to two web mock fixtures for the now-required contract member. Verified: typecheck/lint clean, unit tests green, and a listSessions probe returns this project's 7 terminal sessions for the project-root cwd. Live end-to-end (web transport -> auth -> serialize) not yet exercised. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/components/ChatView.browser.tsx | 1 + apps/web/src/components/ChatView.tsx | 15 +++ apps/web/src/components/chat/ChatComposer.tsx | 19 +++ apps/web/src/components/chat/ResumePicker.tsx | 113 ++++++++++++++++++ apps/web/src/composer-logic.ts | 14 ++- apps/web/src/environmentApi.ts | 3 + .../service.threadSubscriptions.test.ts | 3 + apps/web/src/resumePickerStore.ts | 23 ++++ packages/client-runtime/src/wsRpcClient.ts | 9 ++ packages/contracts/src/ipc.ts | 9 ++ 10 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/chat/ResumePicker.tsx create mode 100644 apps/web/src/resumePickerStore.ts diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index e490ff986dcc..d5e353f12307 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -244,6 +244,7 @@ function createMockEnvironmentApi(input: { vcs: {} as EnvironmentApi["vcs"], git: {} as EnvironmentApi["git"], review: {} as EnvironmentApi["review"], + resume: {} as EnvironmentApi["resume"], orchestration: { dispatchCommand: input.dispatchCommand, getTurnDiff: (() => { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b8621c1ab8f5..b1c448a718ec 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -46,8 +46,11 @@ import { readLocalApi } from "../localApi"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { collapseExpandedComposerCursor, + isStandaloneResumeCommand, parseStandaloneComposerSlashCommand, } from "../composer-logic"; +import { useResumePickerStore } from "../resumePickerStore"; +import { ResumePicker } from "./chat/ResumePicker"; import { deriveCompletionDividerBeforeEntryId, derivePendingApprovals, @@ -2908,6 +2911,17 @@ export default function ChatView(props: ChatViewProps) { }); return; } + if ( + composerImages.length === 0 && + sendableComposerTerminalContexts.length === 0 && + isStandaloneResumeCommand(trimmed) + ) { + useResumePickerStore.getState().requestOpen(); + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + return; + } const standaloneSlashCommand = composerImages.length === 0 && sendableComposerTerminalContexts.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) @@ -3854,6 +3868,7 @@ export default function ChatView(props: ChatViewProps) {
+ store.open); + const setOpen = useResumePickerStore((store) => store.setOpen); + + const [sessions, setSessions] = useState>([]); + const [status, setStatus] = useState("loading"); + const [errorDetail, setErrorDetail] = useState(null); + + useEffect(() => { + if (!open) { + return; + } + if (!cwd) { + setStatus("error"); + setErrorDetail("No project directory is available for this thread."); + return; + } + const api = readEnvironmentApi(environmentId); + if (!api) { + setStatus("error"); + setErrorDetail("This environment is not connected."); + return; + } + + let cancelled = false; + setStatus("loading"); + setErrorDetail(null); + api.resume + .listImportableSessions({ cwd }) + .then((result) => { + if (cancelled) { + return; + } + setSessions(result.sessions); + setStatus("ready"); + }) + .catch((error: unknown) => { + if (cancelled) { + return; + } + setStatus("error"); + setErrorDetail(error instanceof Error ? error.message : String(error)); + }); + + return () => { + cancelled = true; + }; + }, [open, cwd, environmentId]); + + return ( + + + + Resume a terminal chat + + Past Claude sessions run in this project from the terminal. + + + + {status === "loading" ? ( +
Loading sessions…
+ ) : null} + {status === "error" ? ( +
{errorDetail}
+ ) : null} + {status === "ready" && sessions.length === 0 ? ( +
+ No terminal sessions found for this project. +
+ ) : null} + {sessions.map((session) => ( +
+
{session.title}
+
+ {session.sessionId} + {session.alreadyImported ? " · already imported" : ""} +
+
+ ))} +
+
+
+ ); +} diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index fb63d2581c7a..7b238e309986 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -2,7 +2,7 @@ import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; export type ComposerTriggerKind = "path" | "slash-command" | "skill"; -export type ComposerSlashCommand = "model" | "plan" | "default"; +export type ComposerSlashCommand = "model" | "plan" | "default" | "resume"; export interface ComposerTrigger { kind: ComposerTriggerKind; @@ -257,7 +257,7 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos export function parseStandaloneComposerSlashCommand( text: string, -): Exclude | null { +): Exclude | null { const match = /^\/(plan|default)\s*$/i.exec(text.trim()); if (!match) { return null; @@ -267,6 +267,16 @@ export function parseStandaloneComposerSlashCommand( return "default"; } +/** + * True when the composer holds exactly `/resume` (optionally with trailing + * whitespace). t3 intercepts this as its own command — opening the resume + * picker — instead of forwarding it to the provider, which has its own + * unrelated `/resume`. + */ +export function isStandaloneResumeCommand(text: string): boolean { + return /^\/resume\s*$/i.test(text.trim()); +} + export function replaceTextRange( text: string, rangeStart: number, diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index bdb2e793069b..caf2da4a6873 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -58,6 +58,9 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { subscribeThread: (input, callback, options) => rpcClient.orchestration.subscribeThread(input, callback, options), }, + resume: { + listImportableSessions: rpcClient.resume.listImportableSessions, + }, }; } diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts index 003334458792..a43edf370807 100644 --- a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +++ b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts @@ -86,6 +86,9 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { subscribeShell: vi.fn(() => () => undefined), subscribeThread: mockSubscribeThread, }, + resume: { + listImportableSessions: vi.fn(), + }, terminal: { open: vi.fn(), attach: vi.fn(() => () => undefined), diff --git a/apps/web/src/resumePickerStore.ts b/apps/web/src/resumePickerStore.ts new file mode 100644 index 000000000000..592f7345e3ba --- /dev/null +++ b/apps/web/src/resumePickerStore.ts @@ -0,0 +1,23 @@ +import { create } from "zustand"; + +/** + * Open/close state for the `/resume` picker (CLI <-> t3 conversation continuity). + * + * The picker is rendered once inside ChatView (which owns the project cwd and + * environment id), but it can be opened from two places: the composer's + * `/resume` slash-command menu-select, and the typed-and-Enter path in + * ChatView.onSend. A tiny shared store lets both trigger it without threading a + * new callback prop through ChatComposer. Mirrors `commandPaletteStore`. + */ +interface ResumePickerStore { + open: boolean; + /** Request the picker to open (from a slash-command trigger). */ + requestOpen: () => void; + setOpen: (open: boolean) => void; +} + +export const useResumePickerStore = create((set) => ({ + open: false, + requestOpen: () => set({ open: true }), + setOpen: (open) => set({ open }), +})); diff --git a/packages/client-runtime/src/wsRpcClient.ts b/packages/client-runtime/src/wsRpcClient.ts index 407f840b46f4..90102e2eaab6 100644 --- a/packages/client-runtime/src/wsRpcClient.ts +++ b/packages/client-runtime/src/wsRpcClient.ts @@ -160,6 +160,11 @@ export interface WsRpcClient { readonly subscribeShell: RpcStreamMethod; readonly subscribeThread: RpcInputStreamMethod; }; + readonly resume: { + readonly listImportableSessions: RpcUnaryMethod< + typeof WS_METHODS.resumeListImportableSessions + >; + }; } export interface CreateWsRpcClientOptions { @@ -344,5 +349,9 @@ export function createWsRpcClient( subscriptionOptions(options, ORCHESTRATION_WS_METHODS.subscribeThread), ), }, + resume: { + listImportableSessions: (input) => + transport.request((client) => client[WS_METHODS.resumeListImportableSessions](input)), + }, }; } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 684974fcac51..cf9c43774e12 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -70,6 +70,10 @@ import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } fr import { AdvertisedEndpoint } from "./remoteAccess.ts"; import { EditorId } from "./editor.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; +import type { + ResumeListImportableSessionsInput, + ResumeListImportableSessionsResult, +} from "./resume.ts"; import type { ClientSettings, ServerSettings, ServerSettingsPatch } from "./settings.ts"; import type { SourceControlCloneRepositoryInput, @@ -583,4 +587,9 @@ export interface EnvironmentApi { }, ) => () => void; }; + resume: { + listImportableSessions: ( + input: ResumeListImportableSessionsInput, + ) => Promise; + }; } From c998a4d5eeb7b435c47dfbf0963a342a2b83292d Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Thu, 4 Jun 2026 12:29:18 -0400 Subject: [PATCH 09/40] feat(resume): seed resume cursor on thread create (continue half) A thread created with the new optional `resumeSessionId` now seeds a `stopped` Claude provider binding whose `resumeCursor` carries `{ resume: }`. ProviderService already merges a persisted cursor (gated on matching providerInstanceId), so the thread's first turn resumes the prior Claude SDK session. New ResumeSeedReactor reacts to `thread.created` off `streamDomainEvents`; `stopped` status both survives the reaper and leaves no active session so the fresh-start path runs. Fork-rule notes: logic lives in new apps/server/src/resume/; the only hot-file edits are two optional contract fields, a one-line decider passthrough, and one-line reactor registration in OrchestrationReactor + server.ts. Pre-existing OrchestrationReactor test + integration harness gain a ResumeSeedReactor stub. Tests use `it.effect` deliberately: a bare `it("...", () => Effect.gen)` under @effect/vitest passes WITHOUT running the effect (latent in ProviderSessionDirectory.test.ts). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../OrchestrationEngineHarness.integration.ts | 6 + .../Layers/OrchestrationReactor.test.ts | 10 ++ .../Layers/OrchestrationReactor.ts | 3 + apps/server/src/orchestration/decider.ts | 3 + .../src/resume/ResumeSeedReactor.test.ts | 101 ++++++++++++++ apps/server/src/resume/ResumeSeedReactor.ts | 129 ++++++++++++++++++ apps/server/src/server.ts | 2 + packages/contracts/src/orchestration.ts | 6 + 8 files changed, 260 insertions(+) create mode 100644 apps/server/src/resume/ResumeSeedReactor.test.ts create mode 100644 apps/server/src/resume/ResumeSeedReactor.ts diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 837c32fc4fd9..c1c246283637 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -59,6 +59,7 @@ import { type OrchestrationEngineShape, } from "../src/orchestration/Services/OrchestrationEngine.ts"; import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts"; +import { ResumeSeedReactor } from "../src/resume/ResumeSeedReactor.ts"; import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; import { @@ -364,6 +365,11 @@ export const makeOrchestrationIntegrationHarness = ( drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(ResumeSeedReactor, { + start: () => Effect.void, + }), + ), ); const layer = Layer.empty.pipe( Layer.provideMerge(runtimeServicesLayer), diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index 6155af8858a9..21f5f1f1ee4c 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; +import { ResumeSeedReactor } from "../../resume/ResumeSeedReactor.ts"; import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; describe("OrchestrationReactor", () => { @@ -63,6 +64,14 @@ describe("OrchestrationReactor", () => { drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(ResumeSeedReactor, { + start: () => { + started.push("resume-seed-reactor"); + return Effect.void; + }, + }), + ), ), ); @@ -75,6 +84,7 @@ describe("OrchestrationReactor", () => { "provider-command-reactor", "checkpoint-reactor", "thread-deletion-reactor", + "resume-seed-reactor", ]); await Effect.runPromise(Scope.close(scope, Exit.void)); diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index 5e432d9884fe..fc07ffe27ceb 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -9,18 +9,21 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; +import { ResumeSeedReactor } from "../../resume/ResumeSeedReactor.ts"; export const makeOrchestrationReactor = Effect.gen(function* () { const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService; const providerCommandReactor = yield* ProviderCommandReactor; const checkpointReactor = yield* CheckpointReactor; const threadDeletionReactor = yield* ThreadDeletionReactor; + const resumeSeedReactor = yield* ResumeSeedReactor; const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { yield* providerRuntimeIngestion.start(); yield* providerCommandReactor.start(); yield* checkpointReactor.start(); yield* threadDeletionReactor.start(); + yield* resumeSeedReactor.start(); }); return { diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 0d4af771ca8a..2b83af725599 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -239,6 +239,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" interactionMode: command.interactionMode, branch: command.branch, worktreePath: command.worktreePath, + ...(command.resumeSessionId != null + ? { resumeSessionId: command.resumeSessionId } + : {}), createdAt: command.createdAt, updatedAt: command.createdAt, }, diff --git a/apps/server/src/resume/ResumeSeedReactor.test.ts b/apps/server/src/resume/ResumeSeedReactor.test.ts new file mode 100644 index 000000000000..282b20a6f935 --- /dev/null +++ b/apps/server/src/resume/ResumeSeedReactor.test.ts @@ -0,0 +1,101 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { it, assert } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import type * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { ProviderSessionRuntimeRepositoryLive } from "../persistence/Layers/ProviderSessionRuntime.ts"; +import { ProviderSessionDirectory } from "../provider/Services/ProviderSessionDirectory.ts"; +import { ProviderSessionDirectoryLive } from "../provider/Layers/ProviderSessionDirectory.ts"; +import { handleCreatedThread, seedResumeBinding } from "./ResumeSeedReactor.ts"; + +function makeDirectoryLayer(persistenceLayer: Layer.Layer) { + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(persistenceLayer), + ); + return Layer.mergeAll( + runtimeRepositoryLayer, + ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)), + NodeServices.layer, + ); +} + +it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ResumeSeedReactor", (it) => { + it.effect("seeds a stopped Claude resume binding carrying the picked session id", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + + const threadId = ThreadId.make("thread-resume-1"); + const sessionId = "00a5c392-f7f3-4e01-ad14-1bba7c69d789"; + const instanceId = ProviderInstanceId.make("claude-default"); + + yield* seedResumeBinding({ + threadId, + resumeSessionId: sessionId, + providerInstanceId: instanceId, + runtimeMode: "full-access", + }); + + const binding = yield* directory.getBinding(threadId); + assert.equal(Option.isSome(binding), true); + if (Option.isSome(binding)) { + // Survives the reaper (which skips "stopped") AND leaves no active + // session, so the first turn takes the fresh-start path and picks up + // the cursor below. + assert.equal(binding.value.status, "stopped"); + // Must match the first turn's instance or ProviderService silently + // drops the resume cursor. + assert.equal(binding.value.providerInstanceId, instanceId); + assert.equal(binding.value.provider, "claudeAgent"); + // The shape ClaudeAdapter.readClaudeResumeState reads: { resume } is + // passed to the SDK query() as the resume session id. + assert.deepEqual(binding.value.resumeCursor, { + resume: sessionId, + threadId, + }); + } + })); + + it.effect("seeds from a thread.created carrying a resumeSessionId, using the model's instance", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + + const threadId = ThreadId.make("thread-created-resume"); + const sessionId = "0ed4e913-e0cf-4256-bc48-9ab382ddfc64"; + const instanceId = ProviderInstanceId.make("claude-default"); + + yield* handleCreatedThread({ + threadId, + resumeSessionId: sessionId, + modelSelection: { instanceId }, + runtimeMode: "full-access", + }); + + const binding = yield* directory.getBinding(threadId); + assert.equal(Option.isSome(binding), true); + if (Option.isSome(binding)) { + assert.equal(binding.value.providerInstanceId, instanceId); + assert.deepEqual(binding.value.resumeCursor, { resume: sessionId, threadId }); + } + })); + + it.effect("does not seed a binding for a thread created without a resumeSessionId", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + + const threadId = ThreadId.make("thread-created-plain"); + + yield* handleCreatedThread({ + threadId, + resumeSessionId: null, + modelSelection: { instanceId: ProviderInstanceId.make("claude-default") }, + runtimeMode: "full-access", + }); + + const binding = yield* directory.getBinding(threadId); + assert.equal(Option.isNone(binding), true); + })); +}); diff --git a/apps/server/src/resume/ResumeSeedReactor.ts b/apps/server/src/resume/ResumeSeedReactor.ts new file mode 100644 index 000000000000..62d9c9b26ac5 --- /dev/null +++ b/apps/server/src/resume/ResumeSeedReactor.ts @@ -0,0 +1,129 @@ +/** + * ResumeSeedReactor — seeds a provider runtime binding so a freshly-created + * thread resumes a previously-recorded Claude SDK session on its first turn. + * + * When a thread is created from the /resume picker it carries a + * `resumeSessionId`. We write a `stopped` binding whose `resumeCursor` holds + * `{ resume: }`. The first turn then takes the fresh-start path + * (no active session), and `ProviderService.startSession` merges this persisted + * cursor — gated only on the provider instance matching — into the adapter + * call, where `ClaudeAdapter.readClaudeResumeState` hands `resume` to the SDK. + * + * `stopped` is load-bearing: the reaper skips stopped bindings (so the seed + * survives until the first turn) and it leaves no active session (so the turn + * actually starts a new query rather than attaching to nothing). + * + * @module ResumeSeedReactor + */ +import { + ProviderDriverKind, + type ProviderInstanceId, + type RuntimeMode, + type ThreadId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import { ProviderSessionDirectory } from "../provider/Services/ProviderSessionDirectory.ts"; + +const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); + +export interface ResumeSeedInput { + readonly threadId: ThreadId; + readonly resumeSessionId: string; + readonly providerInstanceId: ProviderInstanceId; + readonly runtimeMode: RuntimeMode; +} + +export const seedResumeBinding = (input: ResumeSeedInput) => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + yield* directory.upsert({ + threadId: input.threadId, + provider: CLAUDE_DRIVER_KIND, + providerInstanceId: input.providerInstanceId, + status: "stopped", + runtimeMode: input.runtimeMode, + resumeCursor: { resume: input.resumeSessionId, threadId: input.threadId }, + }); + }); + +/** + * The slice of a `thread.created` payload the reactor needs. Declared narrow so + * the reactor can pass `event.payload` directly (structural supertype) without + * coupling to every ThreadCreatedPayload field. + */ +export interface CreatedThreadForResume { + readonly threadId: ThreadId; + readonly resumeSessionId?: string | null | undefined; + readonly modelSelection: { readonly instanceId: ProviderInstanceId }; + readonly runtimeMode: RuntimeMode; +} + +/** + * React to a `thread.created`: seed the resume binding only when the thread was + * created from the /resume picker (i.e. it carries a `resumeSessionId`). The + * binding's instance is taken from the thread's model selection so it matches + * the first turn's instance — the equality `ProviderService` gates the merge on. + */ +export const handleCreatedThread = (payload: CreatedThreadForResume) => + Effect.gen(function* () { + if (payload.resumeSessionId == null) { + return; + } + yield* seedResumeBinding({ + threadId: payload.threadId, + resumeSessionId: payload.resumeSessionId, + providerInstanceId: payload.modelSelection.instanceId, + runtimeMode: payload.runtimeMode, + }); + }); + +export interface ResumeSeedReactorShape { + /** + * Start the reactor. Must run in a scope so the subscription fiber is + * finalized on shutdown. Mirrors CheckpointReactor's forkScoped pattern. + */ + readonly start: () => Effect.Effect; +} + +export class ResumeSeedReactor extends Context.Service< + ResumeSeedReactor, + ResumeSeedReactorShape +>()("t3/resume/ResumeSeedReactor") {} + +const make = Effect.gen(function* () { + const orchestrationEngine = yield* OrchestrationEngineService; + // Captured here so the forked stream effect does not leak + // ProviderSessionDirectory into `start`'s scope-only requirement. + const directory = yield* ProviderSessionDirectory; + + const start: ResumeSeedReactorShape["start"] = Effect.fn("start")(function* () { + yield* Effect.forkScoped( + Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { + if (event.type !== "thread.created") { + return Effect.void; + } + // One failing event must not tear down the subscription. + return handleCreatedThread(event.payload).pipe( + Effect.provideService(ProviderSessionDirectory, directory), + Effect.catchCause((cause) => + Effect.logWarning("resume seed reactor failed to seed binding", { + threadId: event.payload.threadId, + cause: Cause.pretty(cause), + }), + ), + ); + }), + ); + }); + + return { start } satisfies ResumeSeedReactorShape; +}); + +export const ResumeSeedReactorLive = Layer.effect(ResumeSeedReactor, make); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 5c0bbb8425af..4911ee2cd15e 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -44,6 +44,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; +import { ResumeSeedReactorLive } from "./resume/ResumeSeedReactor.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; import { ServerSettingsLive } from "./serverSettings.ts"; import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; @@ -134,6 +135,7 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(ProviderCommandReactorLive), Layer.provideMerge(CheckpointReactorLive), Layer.provideMerge(ThreadDeletionReactorLive), + Layer.provideMerge(ResumeSeedReactorLive), Layer.provideMerge(RuntimeReceiptBusLive), ); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 401928171c8b..1dcb51cc1b11 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -492,6 +492,9 @@ const ThreadCreateCommand = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + // Set by the /resume picker: the Claude SDK session id to resume into the + // new thread. Optional so every existing producer decodes unchanged. + resumeSessionId: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), createdAt: IsoDateTime, }); @@ -835,6 +838,9 @@ export const ThreadCreatedPayload = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + // Carried from ThreadCreateCommand so the ResumeSeedReactor (and replay) can + // act on a thread created from the /resume picker. + resumeSessionId: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); From f43fb442168469e2c144d730caa8fada2b7ade45 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Thu, 4 Jun 2026 12:34:54 -0400 Subject: [PATCH 10/40] feat(resume): add thread.message.user.record command (replay groundwork) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records a historical user message into a thread for DISPLAY only, without firing a live turn. Replaying an old transcript needs to re-show user messages, but the only existing user message-sent path is thread.turn.start — which also emits thread.turn-start-requested and kicks off a real Claude turn. This new internal command emits just the thread.message-sent event (role user, streaming false), mirroring the assistant delta/complete cases. Decider test asserts it emits exactly one message-sent event and NO turn-start-requested. Assistant text + tool activities will reuse the existing assistant.delta/complete and activity.append commands. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../decider.resumeReplay.test.ts | 108 ++++++++++++++++++ apps/server/src/orchestration/decider.ts | 27 +++++ packages/contracts/src/orchestration.ts | 14 +++ 3 files changed, 149 insertions(+) create mode 100644 apps/server/src/orchestration/decider.resumeReplay.test.ts diff --git a/apps/server/src/orchestration/decider.resumeReplay.test.ts b/apps/server/src/orchestration/decider.resumeReplay.test.ts new file mode 100644 index 000000000000..e360a855e584 --- /dev/null +++ b/apps/server/src/orchestration/decider.resumeReplay.test.ts @@ -0,0 +1,108 @@ +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +const now = "2026-01-01T00:00:00.000Z"; +const asCommandId = (value: string): CommandId => CommandId.make(value); +const asEventId = (value: string): EventId => EventId.make(value); + +const seedThread = Effect.gen(function* () { + const initial = createEmptyReadModel(now); + const withProject = yield* projectEvent(initial, { + sequence: 1, + eventId: asEventId("evt-project"), + aggregateKind: "project", + aggregateId: ProjectId.make("project-replay"), + type: "project.created", + occurredAt: now, + commandId: asCommandId("cmd-project"), + causationEventId: null, + correlationId: asCommandId("cmd-project"), + metadata: {}, + payload: { + projectId: ProjectId.make("project-replay"), + title: "Replay", + workspaceRoot: "/tmp/project-replay", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + return yield* projectEvent(withProject, { + sequence: 2, + eventId: asEventId("evt-thread"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-replay"), + type: "thread.created", + occurredAt: now, + commandId: asCommandId("cmd-thread"), + causationEventId: null, + correlationId: asCommandId("cmd-thread"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-replay"), + projectId: ProjectId.make("project-replay"), + title: "Replay Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("claude-default"), + model: "claude-opus", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); +}); + +it.layer(NodeServices.layer)("decider resume-replay", (it) => { + it.effect("records a historical user message WITHOUT requesting a turn", () => + Effect.gen(function* () { + const readModel = yield* seedThread; + + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.message.user.record", + commandId: asCommandId("cmd-user-record"), + threadId: ThreadId.make("thread-replay"), + messageId: MessageId.make("msg-replay-1"), + text: "fix the budget calc", + createdAt: now, + }, + readModel, + }); + + const events = Array.isArray(result) ? result : [result]; + + // The whole point of the new command: a user message is recorded for + // display, but NO turn is fired (contrast thread.turn.start, which emits + // both message-sent AND turn-start-requested). + expect(events.map((event) => event.type)).toEqual(["thread.message-sent"]); + + const event = events[0]; + if (event?.type === "thread.message-sent") { + expect(event.payload.role).toBe("user"); + expect(event.payload.text).toBe("fix the budget calc"); + expect(event.payload.streaming).toBe(false); + expect(event.payload.turnId).toBe(null); + } else { + throw new Error(`expected a thread.message-sent event, got ${event?.type}`); + } + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 2b83af725599..0ccdd7b21ffe 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -657,6 +657,33 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.message.user.record": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.message-sent", + payload: { + threadId: command.threadId, + messageId: command.messageId, + role: "user", + text: command.text, + turnId: command.turnId ?? null, + streaming: false, + createdAt: command.createdAt, + updatedAt: command.createdAt, + }, + }; + } + case "thread.proposed-plan.upsert": { yield* requireThread({ readModel, diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 1dcb51cc1b11..d0b3bf982537 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -716,6 +716,19 @@ const ThreadMessageAssistantCompleteCommand = Schema.Struct({ createdAt: IsoDateTime, }); +// Records a historical user message into a thread for DISPLAY only — used by +// /resume transcript replay. Unlike thread.turn.start it does NOT emit +// thread.turn-start-requested, so no live turn is fired. +const ThreadMessageUserRecordCommand = Schema.Struct({ + type: Schema.Literal("thread.message.user.record"), + commandId: CommandId, + threadId: ThreadId, + messageId: MessageId, + text: Schema.String, + turnId: Schema.optional(TurnId), + createdAt: IsoDateTime, +}); + const ThreadProposedPlanUpsertCommand = Schema.Struct({ type: Schema.Literal("thread.proposed-plan.upsert"), commandId: CommandId, @@ -758,6 +771,7 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadSessionSetCommand, ThreadMessageAssistantDeltaCommand, ThreadMessageAssistantCompleteCommand, + ThreadMessageUserRecordCommand, ThreadProposedPlanUpsertCommand, ThreadTurnDiffCompleteCommand, ThreadActivityAppendCommand, From 2ecb59efb506541486b0bc8d495371eb9bbd75f2 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Thu, 4 Jun 2026 12:39:08 -0400 Subject: [PATCH 11/40] feat(resume): transcript->commands mapper for replay display (text v1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildReplayCommands maps a Claude SDK session transcript (getSessionMessages output) to orchestration commands that re-render the conversation: user messages -> thread.message.user.record; assistant messages -> assistant delta(full text)+complete on one messageId (projector appends on streaming delta, preserves on empty complete). Deterministic server:resume-replay commandIds (idempotent re-run) and monotonic createdAt (transcript order + sorts before later live messages). v1 is TEXT only; tool_use/tool_result blocks are not yet rendered as activities (follow-up) — the conversation text still displays, and the model gets full context via the resume cursor regardless. Pure function, plain-vitest tested (4 cases: user string, assistant pair, ordering, array/empty/system). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/resume/transcriptReplay.test.ts | 99 ++++++++++++++ apps/server/src/resume/transcriptReplay.ts | 127 ++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 apps/server/src/resume/transcriptReplay.test.ts create mode 100644 apps/server/src/resume/transcriptReplay.ts diff --git a/apps/server/src/resume/transcriptReplay.test.ts b/apps/server/src/resume/transcriptReplay.test.ts new file mode 100644 index 000000000000..6fd948a67580 --- /dev/null +++ b/apps/server/src/resume/transcriptReplay.test.ts @@ -0,0 +1,99 @@ +import { ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { buildReplayCommands } from "./transcriptReplay.ts"; + +const ctx = { + threadId: ThreadId.make("thread-replay"), + sessionId: "0ed4e913-e0cf-4256-bc48-9ab382ddfc64", + baseTimeMs: Date.parse("2026-01-01T00:00:00.000Z"), +}; + +describe("buildReplayCommands", () => { + it("maps a plain-string user message to a single user.record command", () => { + const commands = buildReplayCommands( + [{ type: "user", uuid: "u1", message: { role: "user", content: "fix the budget calc" } }], + ctx, + ); + + expect(commands).toHaveLength(1); + const [command] = commands; + expect(command?.type).toBe("thread.message.user.record"); + if (command?.type === "thread.message.user.record") { + expect(command.threadId).toBe(ctx.threadId); + expect(command.text).toBe("fix the budget calc"); + // deterministic id + monotonic timestamp for idempotent, ordered replay + expect(command.commandId).toBe(`server:resume-replay:${ctx.sessionId}:0`); + expect(command.createdAt).toBe("2026-01-01T00:00:00.000Z"); + } + }); + + it("maps an assistant message to a delta(full text) + complete pair on one messageId", () => { + const commands = buildReplayCommands( + [ + { + type: "assistant", + uuid: "a1", + message: { role: "assistant", content: [{ type: "text", text: "done, added it" }] }, + }, + ], + ctx, + ); + + expect(commands.map((c) => c.type)).toEqual([ + "thread.message.assistant.delta", + "thread.message.assistant.complete", + ]); + const [delta, complete] = commands; + if (delta?.type === "thread.message.assistant.delta") { + expect(delta.delta).toBe("done, added it"); + expect(delta.messageId).toBe("resume:a1"); + } + if (complete?.type === "thread.message.assistant.complete") { + // same messageId so complete finalizes the message delta created + expect(complete.messageId).toBe("resume:a1"); + } + }); + + it("preserves order with strictly monotonic timestamps across messages", () => { + const commands = buildReplayCommands( + [ + { type: "user", uuid: "u1", message: { role: "user", content: "first" } }, + { + type: "assistant", + uuid: "a1", + message: { role: "assistant", content: [{ type: "text", text: "reply" }] }, + }, + ], + ctx, + ); + + expect(commands.map((c) => c.type)).toEqual([ + "thread.message.user.record", + "thread.message.assistant.delta", + "thread.message.assistant.complete", + ]); + const times = commands.map((c) => Date.parse(c.createdAt)); + expect(times).toEqual([...times].sort((a, b) => a - b)); + expect(new Set(times).size).toBe(times.length); // all distinct + }); + + it("extracts text from array content and skips empty / non-text messages", () => { + const commands = buildReplayCommands( + [ + { type: "user", uuid: "u1", message: { role: "user", content: [{ type: "text", text: "hi" }] } }, + { type: "system", uuid: "s1", message: { role: "system", content: "ignored" } } as never, + { type: "assistant", uuid: "a1", message: { role: "assistant", content: [] } }, + ], + ctx, + ); + + // only the array-text user message survives; system is not user/assistant, + // and the empty-content assistant produces nothing. + expect(commands).toHaveLength(1); + expect(commands[0]?.type).toBe("thread.message.user.record"); + if (commands[0]?.type === "thread.message.user.record") { + expect(commands[0].text).toBe("hi"); + } + }); +}); diff --git a/apps/server/src/resume/transcriptReplay.ts b/apps/server/src/resume/transcriptReplay.ts new file mode 100644 index 000000000000..143860621c0e --- /dev/null +++ b/apps/server/src/resume/transcriptReplay.ts @@ -0,0 +1,127 @@ +/** + * transcriptReplay — maps a Claude SDK session transcript (as returned by the + * SDK's `getSessionMessages`) into the t3 orchestration commands that re-render + * the conversation in a thread, for the /resume "show the old messages" path. + * + * v1 maps TEXT only (user + assistant). Tool-call / tool-result blocks are not + * yet rendered as activities — tracked as a follow-up; the conversation text + * still displays. The model gets full prior context via the resume cursor + * regardless of how much is displayed (see ResumeSeedReactor). + * + * Determinism: each command gets a `server:resume-replay::` + * commandId (so a re-run dedupes via command receipts) and a monotonic + * `createdAt` (so the SQL projection, which orders by created_at, renders them + * in transcript order and strictly before any later live message). + * + * @module transcriptReplay + */ +import { CommandId, MessageId, type OrchestrationCommand, type ThreadId } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; + +/** The commands buildReplayCommands emits (all carry `createdAt`). */ +export type ReplayCommand = Extract< + OrchestrationCommand, + { + readonly type: + | "thread.message.user.record" + | "thread.message.assistant.delta" + | "thread.message.assistant.complete"; + } +>; + +/** Narrow view of the SDK `SessionMessage` (its `message` field is `unknown`). */ +export interface ReplaySessionMessage { + readonly type: string; // "user" | "assistant" | "system" + readonly uuid: string; + readonly message: unknown; // raw Anthropic message: { role, content } +} + +export interface ReplayContext { + readonly threadId: ThreadId; + readonly sessionId: string; + /** Anchor for minted timestamps; the reactor passes a wall-clock now. */ + readonly baseTimeMs: number; +} + +/** Concatenate the text of an Anthropic message whose content is a string or block array. */ +function extractText(message: unknown): string { + if (!message || typeof message !== "object") { + return ""; + } + const content = (message as { content?: unknown }).content; + if (typeof content === "string") { + return content; + } + if (Array.isArray(content)) { + return content + .filter( + (block): block is { type: "text"; text: string } => + !!block && + typeof block === "object" && + (block as { type?: unknown }).type === "text" && + typeof (block as { text?: unknown }).text === "string", + ) + .map((block) => block.text) + .join(""); + } + return ""; +} + +export function buildReplayCommands( + messages: ReadonlyArray, + ctx: ReplayContext, +): ReadonlyArray { + const commands: ReplayCommand[] = []; + let seq = 0; + + const take = () => { + const current = seq; + seq += 1; + return { + commandId: CommandId.make(`server:resume-replay:${ctx.sessionId}:${current}`), + createdAt: DateTime.formatIso(DateTime.makeUnsafe(ctx.baseTimeMs + current)), + }; + }; + + for (const entry of messages) { + const text = extractText(entry.message); + if (text.length === 0) { + continue; + } + const messageId = MessageId.make(`resume:${entry.uuid}`); + + if (entry.type === "user") { + const { commandId, createdAt } = take(); + commands.push({ + type: "thread.message.user.record", + commandId, + threadId: ctx.threadId, + messageId, + text, + createdAt, + }); + } else if (entry.type === "assistant") { + // delta(full text) creates+fills the message (streaming append from + // empty), complete finalizes it (streaming:false, empty text preserves). + const delta = take(); + commands.push({ + type: "thread.message.assistant.delta", + commandId: delta.commandId, + threadId: ctx.threadId, + messageId, + delta: text, + createdAt: delta.createdAt, + }); + const complete = take(); + commands.push({ + type: "thread.message.assistant.complete", + commandId: complete.commandId, + threadId: ctx.threadId, + messageId, + createdAt: complete.createdAt, + }); + } + } + + return commands; +} From a3c907e1e1a9cdfbb43f58f72b4aa18893423959 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Thu, 4 Jun 2026 12:43:48 -0400 Subject: [PATCH 12/40] feat(resume): replay session transcript into the thread on create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResumeSeedReactor now does both halves on thread.created+resumeSessionId: - continue: seed the resume cursor (inline, fast) so the first turn resumes the prior Claude SDK session; - display: fork a transcript replay — resolve the project cwd via getProjectShellById->workspaceRoot, load the transcript with the SDK's getSessionMessages({dir}), then dispatch planReplayCommands (capped to the last 100 messages, monotonic createdAt anchored at now). Replay is forked so a large transcript doesn't block the domain-event subscription; the seed stays inline so continuity is set promptly. Failures in either half log and never tear down the stream. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/resume/ResumeSeedReactor.ts | 90 +++++++++++++++++++-- 1 file changed, 83 insertions(+), 7 deletions(-) diff --git a/apps/server/src/resume/ResumeSeedReactor.ts b/apps/server/src/resume/ResumeSeedReactor.ts index 62d9c9b26ac5..c92a418ea6ce 100644 --- a/apps/server/src/resume/ResumeSeedReactor.ts +++ b/apps/server/src/resume/ResumeSeedReactor.ts @@ -15,7 +15,9 @@ * * @module ResumeSeedReactor */ +import { getSessionMessages } from "@anthropic-ai/claude-agent-sdk"; import { + type ProjectId, ProviderDriverKind, type ProviderInstanceId, type RuntimeMode, @@ -23,13 +25,23 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { ProviderSessionDirectory } from "../provider/Services/ProviderSessionDirectory.ts"; +import { planReplayCommands, type ReplaySessionMessage } from "./transcriptReplay.ts"; + +class ResumeReplayError extends Data.TaggedError("ResumeReplayError")<{ + readonly detail: string; + readonly cause?: unknown; +}> {} const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); @@ -99,9 +111,49 @@ export class ResumeSeedReactor extends Context.Service< const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; - // Captured here so the forked stream effect does not leak - // ProviderSessionDirectory into `start`'s scope-only requirement. + // Captured here so the forked stream effect does not leak service + // requirements into `start`'s scope-only signature. const directory = yield* ProviderSessionDirectory; + const snapshotQuery = yield* ProjectionSnapshotQuery; + + // Display half: re-render the picked session's transcript into the new + // thread. Reads the project cwd (the same dir listSessions used), loads the + // transcript via the SDK, and dispatches the (capped) replay commands. + const replayTranscript = (input: { + readonly threadId: ThreadId; + readonly projectId: ProjectId; + readonly resumeSessionId: string; + }) => + Effect.gen(function* () { + const project = yield* snapshotQuery + .getProjectShellById(input.projectId) + .pipe(Effect.map(Option.getOrUndefined)); + const cwd = project?.workspaceRoot; + if (!cwd) { + yield* Effect.logWarning("resume replay skipped: no project cwd", { + threadId: input.threadId, + }); + return; + } + const sdkMessages = yield* Effect.tryPromise({ + try: () => getSessionMessages(input.resumeSessionId, { dir: cwd }), + catch: (cause) => new ResumeReplayError({ detail: "getSessionMessages failed", cause }), + }); + const messages: ReadonlyArray = sdkMessages.map((message) => ({ + type: message.type, + uuid: message.uuid, + message: message.message, + })); + const baseTimeMs = DateTime.toEpochMillis(yield* DateTime.now); + const commands = planReplayCommands(messages, { + threadId: input.threadId, + sessionId: input.resumeSessionId, + baseTimeMs, + }); + yield* Effect.forEach(commands, (command) => orchestrationEngine.dispatch(command), { + discard: true, + }); + }); const start: ResumeSeedReactorShape["start"] = Effect.fn("start")(function* () { yield* Effect.forkScoped( @@ -109,12 +161,36 @@ const make = Effect.gen(function* () { if (event.type !== "thread.created") { return Effect.void; } - // One failing event must not tear down the subscription. - return handleCreatedThread(event.payload).pipe( - Effect.provideService(ProviderSessionDirectory, directory), + const payload = event.payload; + const resumeSessionId = payload.resumeSessionId; + if (resumeSessionId == null) { + return Effect.void; + } + // Seed inline (one fast write, so the resume cursor is set promptly), + // then fork the transcript replay so a large replay does not block the + // event subscription. One failure must not tear down the stream. + return Effect.gen(function* () { + yield* handleCreatedThread(payload).pipe( + Effect.provideService(ProviderSessionDirectory, directory), + ); + yield* Effect.forkScoped( + replayTranscript({ + threadId: payload.threadId, + projectId: payload.projectId, + resumeSessionId, + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("resume replay failed", { + threadId: payload.threadId, + cause: Cause.pretty(cause), + }), + ), + ), + ); + }).pipe( Effect.catchCause((cause) => - Effect.logWarning("resume seed reactor failed to seed binding", { - threadId: event.payload.threadId, + Effect.logWarning("resume seed reactor failed", { + threadId: payload.threadId, cause: Cause.pretty(cause), }), ), From f1b2ff5932bf5744f128c57cf97fd792867da037 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Thu, 4 Jun 2026 12:49:46 -0400 Subject: [PATCH 13/40] feat(resume): pick a session to resume it into a new thread (UI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResumePicker rows are now clickable; selecting one closes the picker and calls onSelect. ChatView wires onSelect to create a new thread carrying resumeSessionId (which triggers ResumeSeedReactor to seed the resume cursor and replay the transcript) and navigates to it. No turn.start is dispatched — the user types the first message to continue. The resumed thread is created with a Claude model selection: resume targets a Claude SDK session, and the seed binds the cursor to this instance, so the first turn must resolve the same Claude instance or the resume is dropped. Completes the local feature (server continue+display + UI). Remaining: deploy the fork server to the VPS so affinity-tracker picks it up. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/components/ChatView.tsx | 52 ++++++++++++++++++- apps/web/src/components/chat/ResumePicker.tsx | 21 +++++--- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b1c448a718ec..ce8794777737 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,7 +1,10 @@ import { type ApprovalRequestId, DEFAULT_MODEL, + DEFAULT_MODEL_BY_PROVIDER, + defaultInstanceIdForDriver, type EnvironmentId, + type ImportableSession, type MessageId, type ModelSelection, type ProjectScript, @@ -3868,7 +3871,54 @@ export default function ChatView(props: ChatViewProps) {
- + { + const project = activeProject; + if (!project) { + return; + } + const api = readEnvironmentApi(environmentId); + if (!api) { + return; + } + const nextThreadId = newThreadId(); + // Resume targets a Claude SDK session, so the thread must + // run on Claude — the seed reactor binds the resume cursor + // to this instance, and the first turn must resolve the same + // (Claude) instance or the resume is silently dropped. + const claudeDriver = ProviderDriverKind.make("claudeAgent"); + const resumeModelSelection: ModelSelection = { + instanceId: defaultInstanceIdForDriver(claudeDriver), + model: DEFAULT_MODEL_BY_PROVIDER[claudeDriver] ?? "claude-sonnet-4-6", + }; + void api.orchestration + .dispatchCommand({ + type: "thread.create", + commandId: newCommandId(), + threadId: nextThreadId, + projectId: project.id, + title: session.title, + modelSelection: resumeModelSelection, + runtimeMode, + interactionMode: "default", + branch: null, + worktreePath: null, + // Triggers ResumeSeedReactor: seed the resume cursor + + // replay the transcript. No turn.start — the user types + // the first message. + resumeSessionId: session.sessionId, + createdAt: new Date().toISOString(), + }) + .then(() => + navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId: nextThreadId }, + }), + ); + }} + /> void; } type LoadStatus = "loading" | "ready" | "error"; /** * The `/resume` picker: lists past terminal Claude sessions for the current - * project so they can be resumed inside t3. This is the deliberately minimal - * first slice — it lists sessions; selecting one (the "import" action) is the - * next slice. See repo CONTEXT.md + docs/adr/0001. + * project and resumes the chosen one into a new t3 thread (continue + display). + * See repo CONTEXT.md + docs/adr/0001. */ -export function ResumePicker({ environmentId, cwd }: ResumePickerProps) { +export function ResumePicker({ environmentId, cwd, onSelect }: ResumePickerProps) { const open = useResumePickerStore((store) => store.open); const setOpen = useResumePickerStore((store) => store.setOpen); @@ -98,13 +99,21 @@ export function ResumePicker({ environmentId, cwd }: ResumePickerProps) {
) : null} {sessions.map((session) => ( -
+
+ ))} From a77dc9318eb7ad1d082e4f099eae1a57167f3a36 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Thu, 4 Jun 2026 13:13:18 -0400 Subject: [PATCH 14/40] feat(resume): cap replayed history to last N with a truncation notice planReplayCommands wraps buildReplayCommands: slices to the last DEFAULT_REPLAY_MESSAGE_LIMIT (100) messages and, when truncated, prepends a synthetic notice message so the user sees that earlier history is hidden (the model still has full context via the resume cursor). Keeps replay dispatch volume bounded on large sessions. The replay reactor (a3c907e1) already calls this; this commit makes that import resolvable standalone. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/resume/transcriptReplay.test.ts | 38 ++++++++++++++++++- apps/server/src/resume/transcriptReplay.ts | 36 ++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/apps/server/src/resume/transcriptReplay.test.ts b/apps/server/src/resume/transcriptReplay.test.ts index 6fd948a67580..add77a5f80cd 100644 --- a/apps/server/src/resume/transcriptReplay.test.ts +++ b/apps/server/src/resume/transcriptReplay.test.ts @@ -1,7 +1,7 @@ import { ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { buildReplayCommands } from "./transcriptReplay.ts"; +import { buildReplayCommands, planReplayCommands } from "./transcriptReplay.ts"; const ctx = { threadId: ThreadId.make("thread-replay"), @@ -96,4 +96,40 @@ describe("buildReplayCommands", () => { expect(commands[0].text).toBe("hi"); } }); + + it("does not cap or add a notice when within the limit", () => { + const messages = [ + { type: "user" as const, uuid: "u1", message: { role: "user", content: "a" } }, + { type: "user" as const, uuid: "u2", message: { role: "user", content: "b" } }, + ]; + const commands = planReplayCommands(messages, ctx, 2); + expect(commands).toHaveLength(2); + expect(commands.every((c) => c.type === "thread.message.user.record")).toBe(true); + }); + + it("caps to the last N messages and prepends a truncation notice", () => { + const messages = [ + { type: "user" as const, uuid: "u1", message: { role: "user", content: "oldest" } }, + { type: "user" as const, uuid: "u2", message: { role: "user", content: "middle" } }, + { type: "user" as const, uuid: "u3", message: { role: "user", content: "newest" } }, + ]; + const commands = planReplayCommands(messages, ctx, 2); + + // notice (assistant delta+complete) + last 2 user messages = 4 commands + expect(commands.map((c) => c.type)).toEqual([ + "thread.message.assistant.delta", + "thread.message.assistant.complete", + "thread.message.user.record", + "thread.message.user.record", + ]); + const notice = commands[0]; + if (notice?.type === "thread.message.assistant.delta") { + expect(notice.delta).toContain("last 2 of 3"); + } + // the dropped "oldest" message is not present + const texts = commands.flatMap((c) => + c.type === "thread.message.user.record" ? [c.text] : [], + ); + expect(texts).toEqual(["middle", "newest"]); + }); }); diff --git a/apps/server/src/resume/transcriptReplay.ts b/apps/server/src/resume/transcriptReplay.ts index 143860621c0e..8d573a9347d1 100644 --- a/apps/server/src/resume/transcriptReplay.ts +++ b/apps/server/src/resume/transcriptReplay.ts @@ -67,6 +67,42 @@ function extractText(message: unknown): string { return ""; } +/** Default cap on how many trailing messages are re-rendered (see planReplayCommands). */ +export const DEFAULT_REPLAY_MESSAGE_LIMIT = 100; + +/** + * Cap the displayed history to the last `maxMessages` and, when truncated, + * prepend a notice so the user understands earlier messages are hidden. The + * model still has full prior context via the resume cursor, so capping only + * affects what is RENDERED — keeping replay dispatch volume bounded (real + * sessions can be thousands of messages). The notice is just a synthetic + * leading assistant message, so it reuses the tested mapper unchanged. + */ +export function planReplayCommands( + messages: ReadonlyArray, + ctx: ReplayContext, + maxMessages: number = DEFAULT_REPLAY_MESSAGE_LIMIT, +): ReadonlyArray { + if (messages.length <= maxMessages) { + return buildReplayCommands(messages, ctx); + } + const sliced = messages.slice(-maxMessages); + const notice: ReplaySessionMessage = { + type: "assistant", + uuid: "resume-truncation-notice", + message: { + role: "assistant", + content: [ + { + type: "text", + text: `_(Resumed session — showing the last ${maxMessages} of ${messages.length} messages. Earlier history is hidden here, but the assistant still has full context.)_`, + }, + ], + }, + }; + return buildReplayCommands([notice, ...sliced], ctx); +} + export function buildReplayCommands( messages: ReadonlyArray, ctx: ReplayContext, From da6147ba671c010d659a3ee485a6f2d6a05e1b15 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Thu, 4 Jun 2026 15:19:28 -0400 Subject: [PATCH 15/40] fix(resume): render replayed slash-command invocations as clean text Terminal Claude stores a slash-command/skill invocation as a user message whose text is an XML-ish // wrapper. The replay mapper recorded it verbatim, so resumed transcripts showed the raw tags. Collapse the wrapper to the command the user typed ("/name" plus any args); ordinary prompts pass through unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/resume/transcriptReplay.test.ts | 36 +++++++++++++++++++ apps/server/src/resume/transcriptReplay.ts | 26 +++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/apps/server/src/resume/transcriptReplay.test.ts b/apps/server/src/resume/transcriptReplay.test.ts index add77a5f80cd..3596e391d9d8 100644 --- a/apps/server/src/resume/transcriptReplay.test.ts +++ b/apps/server/src/resume/transcriptReplay.test.ts @@ -133,3 +133,39 @@ describe("buildReplayCommands", () => { expect(texts).toEqual(["middle", "newest"]); }); }); + +describe("slash-command / skill invocations", () => { + const userText = (content: string) => + buildReplayCommands([{ type: "user", uuid: "u", message: { role: "user", content } }], ctx).find( + (c) => c.type === "thread.message.user.record", + ); + + it("renders a command invocation as the plain command, not raw XML tags", () => { + // The exact shape terminal Claude writes to the transcript for a slash command. + const command = userText( + "/sandcastle-status\n sandcastle-status\n ", + ); + expect(command?.type).toBe("thread.message.user.record"); + if (command?.type === "thread.message.user.record") { + expect(command.text).toBe("/sandcastle-status"); + expect(command.text).not.toContain(""); + expect(command.text).not.toContain(""); + } + }); + + it("appends args and tolerates message-before-name tag order", () => { + const command = userText( + "review\n/review\n--fix src/foo.ts", + ); + if (command?.type === "thread.message.user.record") { + expect(command.text).toBe("/review --fix src/foo.ts"); + } + }); + + it("leaves ordinary prompts (no command wrapper) unchanged", () => { + const command = userText("just a normal message"); + if (command?.type === "thread.message.user.record") { + expect(command.text).toBe("just a normal message"); + } + }); +}); diff --git a/apps/server/src/resume/transcriptReplay.ts b/apps/server/src/resume/transcriptReplay.ts index 8d573a9347d1..aef249630080 100644 --- a/apps/server/src/resume/transcriptReplay.ts +++ b/apps/server/src/resume/transcriptReplay.ts @@ -67,6 +67,30 @@ function extractText(message: unknown): string { return ""; } +const COMMAND_NAME_RE = /([\s\S]*?)<\/command-name>/; +const COMMAND_ARGS_RE = /([\s\S]*?)<\/command-args>/; + +/** + * Terminal Claude records a slash-command / skill invocation as a user message + * whose text is an XML-ish wrapper, e.g. + * /sandcastle-status + * sandcastle-status + * + * t3 renders user text as plain text, so replayed verbatim those tags are noise. + * Collapse them to the command the user actually typed — "/name" plus any args — + * so a resumed transcript reads like the original prompt. Tag order varies, so + * each tag is matched independently. Returns null when the text is not a command + * wrapper, leaving ordinary prompts untouched. + */ +function formatCommandInvocation(text: string): string | null { + const name = COMMAND_NAME_RE.exec(text)?.[1]?.trim(); + if (!name) { + return null; + } + const args = COMMAND_ARGS_RE.exec(text)?.[1]?.trim() ?? ""; + return args.length > 0 ? `${name} ${args}` : name; +} + /** Default cap on how many trailing messages are re-rendered (see planReplayCommands). */ export const DEFAULT_REPLAY_MESSAGE_LIMIT = 100; @@ -133,7 +157,7 @@ export function buildReplayCommands( commandId, threadId: ctx.threadId, messageId, - text, + text: formatCommandInvocation(text) ?? text, createdAt, }); } else if (entry.type === "assistant") { From 3e28388656789346ea4980b7e6bf1de190a70f69 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Thu, 4 Jun 2026 15:45:43 -0400 Subject: [PATCH 16/40] fix(dev): stop auto-bootstrapping a junk 'server' project on every dev restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev server runs from apps/server, so the server's mode==='web' cwd-bootstrap created a project named after the cwd basename ('server') — plus an empty 'New thread' that made it undeletable via the UI (project.delete needs force=true for non-empty projects). The dev-runner now defaults the bootstrap OFF (overridable via --auto-bootstrap-project-from-cwd or the env =1), and 'project remove' gains a --force flag mirroring the contract's existing force option. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/cli/project.ts | 5 +++++ scripts/dev-runner.ts | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index 95e9ed7f3830..f952bea6581c 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -359,6 +359,10 @@ const projectRemoveCommand = Command.make("remove", { project: Argument.string("project").pipe( Argument.withDescription("Project id or workspace root to remove."), ), + force: Flag.boolean("force").pipe( + Flag.withDescription("Delete even when the project still has threads."), + Flag.withDefault(false), + ), }).pipe( Command.withDescription("Remove a project."), Command.withHandler((flags) => @@ -381,6 +385,7 @@ const projectRemoveCommand = Command.make("remove", { type: "project.delete", commandId: CommandId.make(yield* projectCommandUuid), projectId: project.id, + force: flags.force, }); return `Removed project ${project.id} (${project.title}).`; }), diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index cce408c827cd..e1e87ecf576a 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -192,7 +192,11 @@ export function createDevRunnerEnv({ if (autoBootstrapProjectFromCwd !== undefined) { output.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD = autoBootstrapProjectFromCwd ? "1" : "0"; } else { - delete output.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD; + // Default the dev server OFF: it runs from apps/server, so the server's + // mode==="web" cwd-bootstrap would otherwise recreate a junk "server" + // project (with an empty thread that blocks deletion) on every restart. + // Pass --auto-bootstrap-project-from-cwd or set the env =1 to opt back in. + output.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD = "0"; } if (logWebSocketEvents !== undefined) { From 3ea0d1a5e488063e6deb7f7db6c4904cfeeb4937 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Wed, 10 Jun 2026 16:24:05 -0400 Subject: [PATCH 17/40] fix(resume): rejoin the existing thread instead of forking a duplicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resuming a chat that was already imported into t3 created a brand-new thread every time (the picker had no dedup; ResumeSeedReactor fires only on thread.created), so re-resuming spawned a second thread onto the same Claude session — read as "it forked and showed history again". And the replay was capped at 100 messages, so a re-resume of a long chat appeared to "lose" earlier history (often noticed right after archiving). Make /resume behave like the Claude CLI — one conversation, full history: - Picker now carries `existingThreadId` for already-imported sessions, built from provider bindings (resumeCursor.resume -> threadId), filtered to the ACTIVE thread snapshot so archived/deleted threads are never rejoin targets (navigating to one bounces the UI home). Latest-active binding wins so a rejoin lands on the freshest thread when pre-fix duplicates exist. - ChatView rejoins that thread on pick instead of dispatching thread.create. Resuming an archived/deleted chat falls through to a fresh import. - Raise replay cap 100 -> 1000 so the full history renders (the model always had full context via the resume cursor; this is display only). The SDK does NOT fork on resume (forkSession defaults false, verified) — the duplication was purely t3's always-new-thread path, not the SDK. Regression tests cover the rejoin map (active-only, latest-wins, skips non-resume bindings, excludes archived/deleted) and existingThreadId on the picker. server.test.ts gains a ProviderSessionDirectory mock. Co-Authored-By: Claude Opus 4.8 --- .../src/resume/importableSessions.test.ts | 87 +++++++++++++++++-- apps/server/src/resume/importableSessions.ts | 83 ++++++++++++++++-- .../src/resume/importableSessionsService.ts | 44 +++++++++- apps/server/src/resume/transcriptReplay.ts | 11 ++- apps/server/src/server.test.ts | 6 ++ apps/web/src/components/ChatView.tsx | 13 +++ packages/contracts/src/resume.ts | 6 ++ 7 files changed, 229 insertions(+), 21 deletions(-) diff --git a/apps/server/src/resume/importableSessions.test.ts b/apps/server/src/resume/importableSessions.test.ts index 1315e51343aa..43d1e8de5f46 100644 --- a/apps/server/src/resume/importableSessions.test.ts +++ b/apps/server/src/resume/importableSessions.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { selectImportableSessions, type SessionInfo } from "./importableSessions.ts"; +import { + buildImportedSessionMap, + selectImportableSessions, + type ImportedBinding, + type SessionInfo, +} from "./importableSessions.ts"; /** * Inputs mirror the SDK's `listSessions({ dir })` result (`SDKSessionInfo`), @@ -21,7 +26,7 @@ describe("selectImportableSessions", () => { [session({ sessionId: "t1", summary: "Fix the bug" })], { t3OriginSessionIds: new Set(), - importedSessionIds: new Set(), + importedSessions: new Map(), }, ); @@ -38,7 +43,7 @@ describe("selectImportableSessions", () => { it("prefers the user-set custom title over the auto summary", () => { const result = selectImportableSessions( [session({ sessionId: "t1", summary: "Auto", customTitle: "fork-polish" })], - { t3OriginSessionIds: new Set(), importedSessionIds: new Set() }, + { t3OriginSessionIds: new Set(), importedSessions: new Map() }, ); expect(result[0]?.title).toBe("fork-polish"); @@ -47,18 +52,88 @@ describe("selectImportableSessions", () => { it("hides sessions t3 created itself", () => { const result = selectImportableSessions( [session({ sessionId: "t1" }), session({ sessionId: "fromT3" })], - { t3OriginSessionIds: new Set(["fromT3"]), importedSessionIds: new Set() }, + { t3OriginSessionIds: new Set(["fromT3"]), importedSessions: new Map() }, ); expect(result.map((r) => r.sessionId)).toEqual(["t1"]); }); - it("flags a terminal session already imported into a Thread", () => { + it("flags an already-imported session and carries its existing thread id for rejoin", () => { const result = selectImportableSessions([session({ sessionId: "t1" })], { t3OriginSessionIds: new Set(), - importedSessionIds: new Set(["t1"]), + importedSessions: new Map([["t1", "thread-42"]]), }); expect(result[0]?.alreadyImported).toBe(true); + expect(result[0]?.existingThreadId).toBe("thread-42"); + }); + + it("leaves existingThreadId undefined for a not-yet-imported session", () => { + const result = selectImportableSessions([session({ sessionId: "t1" })], { + t3OriginSessionIds: new Set(), + importedSessions: new Map(), + }); + + expect(result[0]?.existingThreadId).toBeUndefined(); + }); +}); + +const binding = (over: Partial): ImportedBinding => ({ + threadId: "thread-1", + resumeCursor: { resume: "sess-1" }, + lastSeenAt: "2026-06-01T00:00:00.000Z", + ...over, +}); + +describe("buildImportedSessionMap", () => { + it("maps a resumed session id to the thread that imported it", () => { + const map = buildImportedSessionMap( + [binding({ threadId: "thread-A", resumeCursor: { resume: "sess-A" } })], + new Set(["thread-A"]), + ); + + expect(map.get("sess-A")).toBe("thread-A"); + }); + + it("skips bindings with no string resume cursor (native t3 / other providers)", () => { + const map = buildImportedSessionMap( + [ + binding({ threadId: "native", resumeCursor: null }), + binding({ threadId: "noResume", resumeCursor: { threadId: "x" } }), + binding({ threadId: "blank", resumeCursor: { resume: "" } }), + ], + new Set(["native", "noResume", "blank"]), + ); + + expect(map.size).toBe(0); + }); + + it("lets the most-recently-active thread win when duplicates target one session", () => { + const map = buildImportedSessionMap( + [ + binding({ + threadId: "older", + resumeCursor: { resume: "dup" }, + lastSeenAt: "2026-06-01T00:00:00.000Z", + }), + binding({ + threadId: "newer", + resumeCursor: { resume: "dup" }, + lastSeenAt: "2026-06-09T00:00:00.000Z", + }), + ], + new Set(["older", "newer"]), + ); + + expect(map.get("dup")).toBe("newer"); + }); + + it("excludes a binding whose thread is no longer active (archived or deleted)", () => { + const map = buildImportedSessionMap( + [binding({ threadId: "archived-or-gone", resumeCursor: { resume: "sess-X" } })], + new Set(), + ); + + expect(map.size).toBe(0); }); }); diff --git a/apps/server/src/resume/importableSessions.ts b/apps/server/src/resume/importableSessions.ts index 0a01ac1d85ae..d73d729cb009 100644 --- a/apps/server/src/resume/importableSessions.ts +++ b/apps/server/src/resume/importableSessions.ts @@ -30,30 +30,95 @@ export interface ImportableSession { readonly title: string; readonly lastActivityAt: number; readonly alreadyImported: boolean; + /** + * When this session has already been imported, the id of the Thread it lives + * in. Lets the picker REJOIN that existing Thread instead of creating a + * duplicate (CLI-parity: one conversation, not copies). Plain `string` keeps + * this module dependency-free; the contract layer brands it as `ThreadId`. + */ + readonly existingThreadId?: string; } export interface SelectImportableOptions { /** Claude session ids t3 created itself (native t3 Threads) — hidden from the picker. */ readonly t3OriginSessionIds: ReadonlySet; - /** Terminal session ids already imported into a Thread — shown, but flagged. */ - readonly importedSessionIds: ReadonlySet; + /** + * Terminal session ids already imported into a Thread, mapped to that + * Thread's id. Shown in the picker, but flagged and made rejoin-able. + */ + readonly importedSessions: ReadonlyMap; } /** * Reduce the SDK's project session list to the Importable sessions: the * terminal-origin ones (everything t3 did not create itself), each titled and - * flagged with whether it has already been imported into a Thread. + * flagged with whether it has already been imported into a Thread (and, if so, + * which Thread, so the picker can rejoin rather than duplicate). */ +/** Title and flag one session, attaching its existing Thread id when imported. */ +function toImportableSession( + session: SessionInfo, + importedSessions: ReadonlyMap, +): ImportableSession { + const existingThreadId = importedSessions.get(session.sessionId); + return { + sessionId: session.sessionId, + title: session.customTitle ?? session.summary, + lastActivityAt: session.lastModified, + alreadyImported: existingThreadId !== undefined, + ...(existingThreadId !== undefined ? { existingThreadId } : {}), + }; +} + export function selectImportableSessions( sessions: ReadonlyArray, options: SelectImportableOptions, ): ReadonlyArray { return sessions .filter((session) => !options.t3OriginSessionIds.has(session.sessionId)) - .map((session) => ({ - sessionId: session.sessionId, - title: session.customTitle ?? session.summary, - lastActivityAt: session.lastModified, - alreadyImported: options.importedSessionIds.has(session.sessionId), - })); + .map((session) => toImportableSession(session, options.importedSessions)); +} + +/** The slice of a provider runtime binding the imported-map builder needs. */ +export interface ImportedBinding { + readonly threadId: string; + /** Resume state; `{ resume: }` when set. */ + readonly resumeCursor?: unknown | null; + /** ISO timestamp of last activity; used to break ties (latest wins). */ + readonly lastSeenAt: string; +} + +/** + * Map each already-imported Claude session id → the t3 Thread that imported it, + * derived from provider bindings (`resumeCursor.resume` holds the original + * session id). Bindings are applied oldest-first so the most-recently-active + * thread wins when pre-fix duplicates already point at the same session — a + * rejoin then lands on the freshest thread. Bindings without a string `resume` + * cursor (native t3 threads, other providers) are skipped. + * + * Only threads still in `activeThreadIds` (the active snapshot — archived and + * deleted threads excluded) are mapped. Rejoin must never target an archived or + * deleted thread: navigating to one bounces the UI home. Resuming such a + * session instead falls through to a fresh import (with full-history replay), + * which is the CLI-like outcome. + */ +export function buildImportedSessionMap( + bindings: ReadonlyArray, + activeThreadIds: ReadonlySet, +): ReadonlyMap { + const map = new Map(); + const ordered = [...bindings].sort((a, b) => a.lastSeenAt.localeCompare(b.lastSeenAt)); + for (const binding of ordered) { + if (!activeThreadIds.has(binding.threadId)) { + continue; + } + const cursor = binding.resumeCursor; + if (cursor && typeof cursor === "object" && "resume" in cursor) { + const resume = (cursor as { readonly resume?: unknown }).resume; + if (typeof resume === "string" && resume.length > 0) { + map.set(resume, binding.threadId); + } + } + } + return map; } diff --git a/apps/server/src/resume/importableSessionsService.ts b/apps/server/src/resume/importableSessionsService.ts index 7ffbdfa6b50e..5e8fe3782fc9 100644 --- a/apps/server/src/resume/importableSessionsService.ts +++ b/apps/server/src/resume/importableSessionsService.ts @@ -24,7 +24,10 @@ import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProviderSessionDirectory } from "../provider/Services/ProviderSessionDirectory.ts"; import { + buildImportedSessionMap, selectImportableSessions, type ImportableSession, type SessionInfo, @@ -66,7 +69,38 @@ function toSessionInfo(info: { }; } -const make = Effect.sync(() => { +const make = Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const projection = yield* ProjectionSnapshotQuery; + + /** + * Build the "already imported" map — Claude session id → the t3 Thread that + * imported it — from the provider session bindings. Each thread created from + * /resume carries a binding whose `resumeCursor.resume` is the original Claude + * session id (seeded by ResumeSeedReactor, kept in step by the adapter). The + * latest-active binding wins, so a rejoin lands on the most recent thread when + * pre-fix duplicates already exist. Only threads still in the active snapshot + * are eligible — archived/deleted threads fall through to a fresh import + * rather than a broken rejoin. Best-effort: on any read failure the picker + * still lists every session, just without rejoin targets. + */ + const buildImportedSessions: Effect.Effect> = Effect.gen( + function* () { + const activeThreadIds = yield* projection.getShellSnapshot().pipe( + Effect.map((snapshot) => new Set(snapshot.threads.map((thread) => thread.id))), + Effect.catch(() => Effect.succeed(new Set())), + ); + const bindings = yield* directory.listBindings(); + return buildImportedSessionMap(bindings, activeThreadIds); + }, + ).pipe( + Effect.catch((cause) => + Effect.logWarning("resume picker: failed to build rejoin map", { cause }).pipe( + Effect.as(new Map() as ReadonlyMap), + ), + ), + ); + const listForProject: ImportableSessionsServiceShape["listForProject"] = ({ projectCwd }) => Effect.gen(function* () { const sdkSessions = yield* Effect.tryPromise({ @@ -75,11 +109,13 @@ const make = Effect.sync(() => { new ImportableSessionsError({ detail: "Claude listSessions failed", cause }), }); + const importedSessions = yield* buildImportedSessions; + return selectImportableSessions(sdkSessions.map(toSessionInfo), { - // Deferred (see file header): needs provider_session_runtime, which can't - // reach the ws-layer test env without upstream-test churn. + // t3-origin hiding is still deferred (needs the t3-created id set); the + // already-imported flag + rejoin target now come from the bindings. t3OriginSessionIds: new Set(), - importedSessionIds: new Set(), + importedSessions, }); }); diff --git a/apps/server/src/resume/transcriptReplay.ts b/apps/server/src/resume/transcriptReplay.ts index aef249630080..1d0b8574e687 100644 --- a/apps/server/src/resume/transcriptReplay.ts +++ b/apps/server/src/resume/transcriptReplay.ts @@ -91,8 +91,15 @@ function formatCommandInvocation(text: string): string | null { return args.length > 0 ? `${name} ${args}` : name; } -/** Default cap on how many trailing messages are re-rendered (see planReplayCommands). */ -export const DEFAULT_REPLAY_MESSAGE_LIMIT = 100; +/** + * Default cap on how many trailing messages are re-rendered (see + * planReplayCommands). Set high enough to show the FULL history of essentially + * every real session (CLI-parity), while still bounding a pathological + * many-thousand-message session so its one-time import replay can't hang the + * UI. With rejoin in place the replay runs only once per imported session, so + * this generous cap costs nothing on re-resume. + */ +export const DEFAULT_REPLAY_MESSAGE_LIMIT = 1000; /** * Cap the displayed history to the last `maxMessages` and, when truncated, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 7cd9efe5fd7c..b4aabefe10c2 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -84,6 +84,7 @@ import { ProviderRegistry, type ProviderRegistryShape, } from "./provider/Services/ProviderRegistry.ts"; +import { ProviderSessionDirectory } from "./provider/Services/ProviderSessionDirectory.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts"; @@ -655,6 +656,11 @@ const buildAppUnderTest = (options?: { ...options?.layers?.orchestrationEngine, }), ), + Layer.provide( + Layer.mock(ProviderSessionDirectory)({ + listBindings: () => Effect.succeed([]), + }), + ), Layer.provide( Layer.mock(ProjectionSnapshotQuery)({ getCommandReadModel: () => Effect.succeed(makeDefaultOrchestrationReadModel()), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ce8794777737..2a7e0e3f57a2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3883,6 +3883,19 @@ export default function ChatView(props: ChatViewProps) { if (!api) { return; } + // Already in t3? Rejoin that thread instead of creating a + // duplicate — CLI-parity: resuming the same chat returns to + // the same conversation, it does not fork a copy. + if (session.existingThreadId) { + navigate({ + to: "/$environmentId/$threadId", + params: { + environmentId, + threadId: session.existingThreadId as ReturnType, + }, + }); + return; + } const nextThreadId = newThreadId(); // Resume targets a Claude SDK session, so the thread must // run on Claude — the seed reactor binds the resume cursor diff --git a/packages/contracts/src/resume.ts b/packages/contracts/src/resume.ts index 2782556aff0c..8bc41f44b041 100644 --- a/packages/contracts/src/resume.ts +++ b/packages/contracts/src/resume.ts @@ -19,6 +19,12 @@ export const ImportableSession = Schema.Struct({ lastActivityAt: Schema.Number, /** True when this session has already been imported into a Thread. */ alreadyImported: Schema.Boolean, + /** + * When already imported, the id of the Thread this session lives in, so the + * picker can REJOIN that Thread instead of creating a duplicate (CLI-parity: + * one conversation, not copies). Absent for not-yet-imported sessions. + */ + existingThreadId: Schema.optional(Schema.String), }); export type ImportableSession = typeof ImportableSession.Type; From 8c1b8c883eabbd0d949e0c3a373d931b2621ad56 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Wed, 10 Jun 2026 17:48:40 -0400 Subject: [PATCH 18/40] refactor(resume): brand existingThreadId as ThreadId in the contract The picker contract typed `existingThreadId` as a plain string, then the web rejoin branch cast it back to a branded `ThreadId` and a comment falsely claimed the contract did the branding. Brand the field with the canonical `ThreadId` schema so the value arrives typed: the ChatView cast disappears, and the ws RPC boundary brands via `ThreadId.make()` rather than a structural pass-through. No behavioral change. Co-Authored-By: Claude Opus 4.8 --- apps/server/src/resume/importableSessions.ts | 3 ++- apps/server/src/ws.ts | 12 +++++++++++- apps/web/src/components/ChatView.tsx | 2 +- packages/contracts/src/resume.ts | 3 ++- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/server/src/resume/importableSessions.ts b/apps/server/src/resume/importableSessions.ts index d73d729cb009..b11462bcf9e8 100644 --- a/apps/server/src/resume/importableSessions.ts +++ b/apps/server/src/resume/importableSessions.ts @@ -34,7 +34,8 @@ export interface ImportableSession { * When this session has already been imported, the id of the Thread it lives * in. Lets the picker REJOIN that existing Thread instead of creating a * duplicate (CLI-parity: one conversation, not copies). Plain `string` keeps - * this module dependency-free; the contract layer brands it as `ThreadId`. + * this module dependency-free; the contract field is branded `ThreadId` + * (see packages/contracts/src/resume.ts). */ readonly existingThreadId?: string; } diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e035974a498e..5171732da642 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -995,7 +995,17 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => observeRpcEffect( WS_METHODS.resumeListImportableSessions, importableSessions.listForProject({ projectCwd: input.cwd }).pipe( - Effect.map((sessions) => ({ sessions })), + Effect.map((sessions) => ({ + sessions: sessions.map((s) => ({ + sessionId: s.sessionId, + title: s.title, + lastActivityAt: s.lastActivityAt, + alreadyImported: s.alreadyImported, + ...(s.existingThreadId !== undefined + ? { existingThreadId: ThreadId.make(s.existingThreadId) } + : {}), + })), + })), Effect.mapError((cause) => new ResumeError({ detail: cause.detail, cause })), ), { "rpc.aggregate": "resume" }, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2a7e0e3f57a2..252e425117f7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3891,7 +3891,7 @@ export default function ChatView(props: ChatViewProps) { to: "/$environmentId/$threadId", params: { environmentId, - threadId: session.existingThreadId as ReturnType, + threadId: session.existingThreadId, }, }); return; diff --git a/packages/contracts/src/resume.ts b/packages/contracts/src/resume.ts index 8bc41f44b041..c10585e6f3b8 100644 --- a/packages/contracts/src/resume.ts +++ b/packages/contracts/src/resume.ts @@ -3,6 +3,7 @@ * See repo CONTEXT.md and docs/adr/0001-cli-t3-conversation-continuity.md. */ import * as Schema from "effect/Schema"; +import { ThreadId } from "./baseSchemas.ts"; /** Input: list the importable Claude sessions for a project directory. */ export const ResumeListImportableSessionsInput = Schema.Struct({ @@ -24,7 +25,7 @@ export const ImportableSession = Schema.Struct({ * picker can REJOIN that Thread instead of creating a duplicate (CLI-parity: * one conversation, not copies). Absent for not-yet-imported sessions. */ - existingThreadId: Schema.optional(Schema.String), + existingThreadId: Schema.optional(ThreadId), }); export type ImportableSession = typeof ImportableSession.Type; From 394c331ac84fa3e81c918411cc62ee1cac735043 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Wed, 10 Jun 2026 17:48:49 -0400 Subject: [PATCH 19/40] perf(resume): slice the transcript to the replay cap before mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResumeSeedReactor fetched the whole session, mapped every message into a second full array, then sliced to the last 1000 for UI replay — two full copies on a large session. Slice sdkMessages to DEFAULT_REPLAY_MESSAGE_LIMIT before the .map, and thread the true total into planReplayCommands so the "last N of M" truncation notice stays accurate when the caller pre-slices. Display-only: getSessionMessages still fetches the full session and the canonical transcript stays uncapped, so a future rewind-to-message feature is unaffected (the slice never becomes a source of truth). The SDK still parses the whole file internally, so this bounds t3's own materialization, not the SDK's. Co-Authored-By: Claude Opus 4.8 --- apps/server/src/resume/ResumeSeedReactor.ts | 30 ++++++++++++------- .../src/resume/transcriptReplay.test.ts | 22 ++++++++++++++ apps/server/src/resume/transcriptReplay.ts | 6 ++-- 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/apps/server/src/resume/ResumeSeedReactor.ts b/apps/server/src/resume/ResumeSeedReactor.ts index c92a418ea6ce..d4b27c7b3f4b 100644 --- a/apps/server/src/resume/ResumeSeedReactor.ts +++ b/apps/server/src/resume/ResumeSeedReactor.ts @@ -36,7 +36,7 @@ import * as Stream from "effect/Stream"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { ProviderSessionDirectory } from "../provider/Services/ProviderSessionDirectory.ts"; -import { planReplayCommands, type ReplaySessionMessage } from "./transcriptReplay.ts"; +import { DEFAULT_REPLAY_MESSAGE_LIMIT, planReplayCommands, type ReplaySessionMessage } from "./transcriptReplay.ts"; class ResumeReplayError extends Data.TaggedError("ResumeReplayError")<{ readonly detail: string; @@ -139,17 +139,25 @@ const make = Effect.gen(function* () { try: () => getSessionMessages(input.resumeSessionId, { dir: cwd }), catch: (cause) => new ResumeReplayError({ detail: "getSessionMessages failed", cause }), }); - const messages: ReadonlyArray = sdkMessages.map((message) => ({ - type: message.type, - uuid: message.uuid, - message: message.message, - })); + const totalMessages = sdkMessages.length; + const messages: ReadonlyArray = sdkMessages + .slice(-DEFAULT_REPLAY_MESSAGE_LIMIT) + .map((message) => ({ + type: message.type, + uuid: message.uuid, + message: message.message, + })); const baseTimeMs = DateTime.toEpochMillis(yield* DateTime.now); - const commands = planReplayCommands(messages, { - threadId: input.threadId, - sessionId: input.resumeSessionId, - baseTimeMs, - }); + const commands = planReplayCommands( + messages, + { + threadId: input.threadId, + sessionId: input.resumeSessionId, + baseTimeMs, + }, + DEFAULT_REPLAY_MESSAGE_LIMIT, + totalMessages, + ); yield* Effect.forEach(commands, (command) => orchestrationEngine.dispatch(command), { discard: true, }); diff --git a/apps/server/src/resume/transcriptReplay.test.ts b/apps/server/src/resume/transcriptReplay.test.ts index 3596e391d9d8..700fe292583a 100644 --- a/apps/server/src/resume/transcriptReplay.test.ts +++ b/apps/server/src/resume/transcriptReplay.test.ts @@ -132,6 +132,28 @@ describe("buildReplayCommands", () => { ); expect(texts).toEqual(["middle", "newest"]); }); + + it("uses totalMessageCount for the notice when the caller pre-slices the array", () => { + // Simulate the ResumeSeedReactor pre-slice path: caller already sliced to + // last 2 messages but passes totalMessageCount=3 so the notice is accurate. + const messages = [ + { type: "user" as const, uuid: "u2", message: { role: "user", content: "middle" } }, + { type: "user" as const, uuid: "u3", message: { role: "user", content: "newest" } }, + ]; + const commands = planReplayCommands(messages, ctx, 2, 3); + + // notice (assistant delta+complete) + 2 user messages = 4 commands + expect(commands.map((c) => c.type)).toEqual([ + "thread.message.assistant.delta", + "thread.message.assistant.complete", + "thread.message.user.record", + "thread.message.user.record", + ]); + const notice = commands[0]; + if (notice?.type === "thread.message.assistant.delta") { + expect(notice.delta).toContain("last 2 of 3"); + } + }); }); describe("slash-command / skill invocations", () => { diff --git a/apps/server/src/resume/transcriptReplay.ts b/apps/server/src/resume/transcriptReplay.ts index 1d0b8574e687..1267827f6880 100644 --- a/apps/server/src/resume/transcriptReplay.ts +++ b/apps/server/src/resume/transcriptReplay.ts @@ -113,8 +113,10 @@ export function planReplayCommands( messages: ReadonlyArray, ctx: ReplayContext, maxMessages: number = DEFAULT_REPLAY_MESSAGE_LIMIT, + totalMessageCount?: number, ): ReadonlyArray { - if (messages.length <= maxMessages) { + const total = totalMessageCount ?? messages.length; + if (total <= maxMessages) { return buildReplayCommands(messages, ctx); } const sliced = messages.slice(-maxMessages); @@ -126,7 +128,7 @@ export function planReplayCommands( content: [ { type: "text", - text: `_(Resumed session — showing the last ${maxMessages} of ${messages.length} messages. Earlier history is hidden here, but the assistant still has full context.)_`, + text: `_(Resumed session — showing the last ${maxMessages} of ${total} messages. Earlier history is hidden here, but the assistant still has full context.)_`, }, ], }, From 40958d22e8e8930fffe6d0ea10a153a66053cd74 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Wed, 10 Jun 2026 17:48:57 -0400 Subject: [PATCH 20/40] perf(resume): bound provider-binding parse concurrency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening the resume picker listed all provider bindings and parsed every row with concurrency: "unbounded" — an unbounded fiber spawn that grows with the binding count. Cap at 16 in both ProviderSessionDirectory.listBindings and the ProviderSessionRuntime repository list. Result collection is input-order stable, so the oldest-first ordering guarantee is unchanged. Follow-up (deferred, not in this change): add a project_id column + listByProjectId so the picker scans only the current project's bindings instead of the full table — the real scalability fix, left out because it changes the live DB schema. Co-Authored-By: Claude Opus 4.8 --- apps/server/src/persistence/Layers/ProviderSessionRuntime.ts | 2 +- apps/server/src/provider/Layers/ProviderSessionDirectory.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts index 9ee5c82bb53d..21f555db08a4 100644 --- a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts @@ -182,7 +182,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { toPersistenceDecodeError("ProviderSessionRuntimeRepository.list:rowToRuntime"), ), ), - { concurrency: "unbounded" }, + { concurrency: 16 }, ), ), ); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 0508f6c8cb34..c3a1c8e51466 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -178,7 +178,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () { Effect.forEach( rows, (row) => toRuntimeBinding(row, "ProviderSessionDirectory.listBindings"), - { concurrency: "unbounded" }, + { concurrency: 16 }, ), ), ); From 50dc300c22608dd4054d08d56d9172cead6b6f21 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Wed, 10 Jun 2026 17:56:03 -0400 Subject: [PATCH 21/40] chore(deploy): one-command VPS deploy script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploying the fork server was a manual file-swap runbook (build web, build server, scp, back up, smoke-test, swap, chown, restart, verify) that had to be done by hand every time. Codify it as `bun run deploy`. The script does the whole flow with a hard stop on any failure so it can never leave the live server half-swapped: it boot-smoke-tests the new bundle on a scratch port BEFORE touching live files, backs up the current bin.mjs + client, swaps, restarts the user service, and verifies HTTP on :4101 — keeping timestamped backups for rollback. Works whether you SSH in as root or as the deploy service user. Refuses to run (and points at the tarball path) if server dependencies changed, since file-swap does not run npm install on the VPS. Co-Authored-By: Claude Opus 4.8 --- package.json | 1 + scripts/deploy-vps.sh | 189 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100755 scripts/deploy-vps.sh diff --git a/package.json b/package.json index 9b2aedf10005..76d66e28ad7a 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "start:marketing": "turbo run preview --filter=@t3tools/marketing", "start:mock-update-server": "node scripts/mock-update-server.ts", "build": "turbo run build", + "deploy": "bash scripts/deploy-vps.sh", "build:marketing": "turbo run build --filter=@t3tools/marketing", "build:desktop": "turbo run build --filter=@t3tools/desktop --filter=t3", "typecheck": "turbo run typecheck", diff --git a/scripts/deploy-vps.sh b/scripts/deploy-vps.sh new file mode 100755 index 000000000000..50fb38f01360 --- /dev/null +++ b/scripts/deploy-vps.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# +# One-command deploy of the t3 fork server (server bundle + web client) to the VPS. +# +# bun run deploy # or: bash scripts/deploy-vps.sh +# +# Does the whole validated file-swap runbook with a hard stop on any failure, so +# it can never leave the live server half-swapped: +# build web -> build server -> stage to VPS /tmp -> boot smoke-test on a scratch +# port -> back up live files -> swap + chown -> restart the user service -> +# verify HTTP on :4101. +# +# This is the FILE-SWAP path: it only ships dist/ (pure-JS) and restarts. It does +# NOT run `npm install` on the VPS, so if server dependencies changed it will +# refuse and tell you to use the tarball path instead. +# +# Overridable via env: +# T3_DEPLOY_HOST ssh target (default: hub) +# T3_LIVE_PORT live server port (default: 4101) +# T3_SMOKE_PORT scratch smoke port (default: 4199) +# T3_SKIP_BUILD=1 reuse existing apps/server/dist (skip the build step) +# +set -euo pipefail + +HOST="${T3_DEPLOY_HOST:-hub}" +LIVE_PORT="${T3_LIVE_PORT:-4101}" +SMOKE_PORT="${T3_SMOKE_PORT:-4199}" +INSTALL_DIR="/home/deploy/.t3-server/node_modules/t3/dist" +SERVICE="t3-code.service" +BASE_DIR="/home/deploy/.t3" + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" +DIST="$REPO_ROOT/apps/server/dist" + +say() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } +die() { printf '\n\033[1;31mDEPLOY ABORTED: %s\033[0m\n' "$*" >&2; exit 1; } + +# --- 0. Preflight: file-swap is only safe when server deps are unchanged -------- +# Only DEPENDENCY declarations matter (file-swap skips `npm install`); a scripts +# or unrelated package.json edit must NOT block the deploy. Compare just the +# dependency-bearing fields (root deps + workspaces.catalog/overrides, and the +# server package's own deps) between main and the working tree. +say "Preflight: checking that server dependencies are unchanged vs main" +if ! git rev-parse --verify --quiet main >/dev/null; then + echo " (no local 'main' to compare against; skipping dep check)" +elif ! command -v python3 >/dev/null 2>&1; then + echo " (python3 not found; falling back to coarse check on apps/server/package.json)" + git diff --quiet main...HEAD -- apps/server/package.json \ + || die "apps/server/package.json changed vs main — use the tarball path (npm install) instead." + echo " OK (coarse) — server package unchanged." +elif python3 - <<'PY' +import json, subprocess, sys +DEP_KEYS = ["dependencies","devDependencies","optionalDependencies","peerDependencies", + "overrides","resolutions","patchedDependencies","trustedDependencies"] +def git_show(ref, path): + try: + return json.loads(subprocess.check_output(["git","show",f"{ref}:{path}"], text=True)) + except subprocess.CalledProcessError: + return None +def working(path): + try: + with open(path) as f: return json.load(f) + except FileNotFoundError: + return None +def root_slice(d): + if d is None: return None + s = {k: d.get(k) for k in DEP_KEYS} + s["catalog"] = (d.get("workspaces") or {}).get("catalog") + return s +def srv_slice(d): + return None if d is None else {k: d.get(k) for k in DEP_KEYS} +changed = (root_slice(git_show("main","package.json")) != root_slice(working("package.json")) + or srv_slice(git_show("main","apps/server/package.json")) != srv_slice(working("apps/server/package.json"))) +sys.exit(1 if changed else 0) +PY +then + echo " OK — dependency declarations unchanged, file-swap is safe." +else + die "server dependency declarations changed vs main (deps/catalog/overrides). + The file-swap deploy does NOT run 'npm install' on the VPS, so a dependency + change would ship a bundle the VPS can't satisfy. Use the tarball path + (scripts/pack-server.ts + npm install) for this one instead." +fi + +# --- 1. Build (web FIRST: the server build copies apps/web/dist into dist/client) +if [[ "${T3_SKIP_BUILD:-}" == "1" ]]; then + say "Skipping build (T3_SKIP_BUILD=1) — reusing $DIST" +else + say "Building web bundle" + CI=1 mise exec -- bun --filter=@t3tools/web run build + say "Building server bundle (copies web dist into dist/client)" + CI=1 mise exec -- bun --filter=t3 run build +fi + +[[ -f "$DIST/bin.mjs" ]] || die "missing build artifact: $DIST/bin.mjs" +[[ -d "$DIST/client" ]] || die "missing build artifact: $DIST/client/ (web build did not land beside the server bundle)" +echo " Artifacts present: bin.mjs + client/" + +# --- 2. Stage artifacts to the VPS --------------------------------------------- +TS="$(date +%Y%m%d-%H%M%S)" +STAGING="/tmp/t3-deploy-$TS" +say "Staging to $HOST:$STAGING" +ssh "$HOST" "mkdir -p '$STAGING'" +scp -q "$DIST/bin.mjs" "$HOST:$STAGING/bin.mjs" +[[ -f "$DIST/bin.mjs.map" ]] && scp -q "$DIST/bin.mjs.map" "$HOST:$STAGING/bin.mjs.map" +scp -qr "$DIST/client" "$HOST:$STAGING/client" +echo " Uploaded." + +# --- 3-7. Remote: smoke-test -> backup -> swap -> chown -> restart -> verify ---- +say "Remote: smoke-test, swap, restart, verify" +ssh "$HOST" "STAGING='$STAGING' TS='$TS' LIVE_PORT='$LIVE_PORT' SMOKE_PORT='$SMOKE_PORT' INSTALL_DIR='$INSTALL_DIR' SERVICE='$SERVICE' BASE_DIR='$BASE_DIR' bash -s" <<'REMOTE' +set -euo pipefail +rfail() { printf '\n[remote] FAILED: %s\n' "$*" >&2; exit 1; } + +DEPLOY_UID="$(id -u deploy)" || rfail "could not resolve 'deploy' uid" + +# Work whether we logged in as root (need sudo to act as deploy) or as deploy +# itself (act directly). as_deploy runs a command in deploy's user-systemd context. +if [[ "$(id -u)" == "0" ]]; then + as_deploy() { sudo -u deploy XDG_RUNTIME_DIR="/run/user/$DEPLOY_UID" "$@"; } + NEED_CHOWN=1 +else + as_deploy() { XDG_RUNTIME_DIR="/run/user/$DEPLOY_UID" "$@"; } + NEED_CHOWN=0 # files we write are already deploy-owned +fi + +# node refuses any extension but .mjs (ERR_UNKNOWN_FILE_EXTENSION) — smoke copy must end in .mjs +cp "$STAGING/bin.mjs" "$STAGING/bin.smoke.mjs" +mkdir -p "$STAGING/scratch-basedir" + +echo "[remote] boot smoke-test on :$SMOKE_PORT (VPS node $(node -v)) before touching live files" +node "$STAGING/bin.smoke.mjs" serve --host 127.0.0.1 --port "$SMOKE_PORT" --base-dir "$STAGING/scratch-basedir" >"$STAGING/smoke.log" 2>&1 & +SMOKE_PID=$! +trap 'kill "$SMOKE_PID" 2>/dev/null || true' EXIT + +code="000" +for _ in $(seq 1 30); do + if ! kill -0 "$SMOKE_PID" 2>/dev/null; then + echo "----- smoke.log -----"; tail -n 40 "$STAGING/smoke.log" || true + rfail "smoke server exited before becoming ready (new bundle does not boot on VPS node)" + fi + # curl -w always prints the code (000 on no-response); || true keeps set -e happy. + code="$(curl -s -o /dev/null -m 5 -w '%{http_code}' "http://127.0.0.1:$SMOKE_PORT/" 2>/dev/null || true)" + code="${code:-000}" + [[ "$code" != "000" ]] && break + sleep 1 +done +kill "$SMOKE_PID" 2>/dev/null || true; trap - EXIT +[[ "$code" == "000" ]] && { echo "----- smoke.log -----"; tail -n 40 "$STAGING/smoke.log" || true; rfail "smoke server never answered on :$SMOKE_PORT"; } +echo "[remote] smoke OK (HTTP $code) — bundle boots on the VPS" + +[[ -f "$INSTALL_DIR/bin.mjs" ]] || rfail "install dir not found: $INSTALL_DIR/bin.mjs" +echo "[remote] backing up live files (suffix .bak-$TS)" +cp "$INSTALL_DIR/bin.mjs" "$INSTALL_DIR/bin.mjs.bak-$TS" +cp -r "$INSTALL_DIR/client" "$INSTALL_DIR/client.bak-$TS" + +echo "[remote] swapping in new bundle + client" +cp "$STAGING/bin.mjs" "$INSTALL_DIR/bin.mjs" +[[ -f "$STAGING/bin.mjs.map" ]] && cp "$STAGING/bin.mjs.map" "$INSTALL_DIR/bin.mjs.map" +rm -rf "$INSTALL_DIR/client" +cp -r "$STAGING/client" "$INSTALL_DIR/client" +[[ "$NEED_CHOWN" == "1" ]] && chown -R deploy:deploy "$INSTALL_DIR" + +echo "[remote] restarting $SERVICE" +as_deploy systemctl --user restart "$SERVICE" + +echo "[remote] verifying live server on :$LIVE_PORT" +live="000" +for _ in $(seq 1 30); do + live="$(curl -s -o /dev/null -m 5 -w '%{http_code}' "http://127.0.0.1:$LIVE_PORT/" 2>/dev/null || true)" + live="${live:-000}" + [[ "$live" =~ ^[23] ]] && break # require a real 2xx/3xx, not just "responding" + sleep 1 +done +if [[ ! "$live" =~ ^[23] ]]; then + echo "[remote] live server not answering — recent service log:" + as_deploy journalctl --user -u "$SERVICE" -n 40 --no-pager || true + echo "[remote] backups kept at $INSTALL_DIR/bin.mjs.bak-$TS and client.bak-$TS — restore with cp if needed" + rfail "live server did not come back up on :$LIVE_PORT" +fi +NEWPID="$(as_deploy systemctl --user show -p MainPID --value "$SERVICE" 2>/dev/null || echo '?')" +echo "[remote] LIVE OK (HTTP $live), service PID $NEWPID" +echo "[remote] cleaning up staging" +cd /tmp && rm -rf "t3-deploy-$TS" +REMOTE + +say "Deploy complete — live on $HOST:$LIVE_PORT" +echo " Rollback if needed: on $HOST, cp $INSTALL_DIR/bin.mjs.bak-$TS -> bin.mjs (and client.bak-$TS), chown deploy, restart $SERVICE." From a67d11b2a47a990943e082ae567ecaf9fd34d07a Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Wed, 10 Jun 2026 18:13:54 -0400 Subject: [PATCH 22/40] chore(deploy): auto-deploy on commit via tracked git hook Make deploys zero-friction: a post-commit hook (wired through core.hooksPath=.githooks) runs `bun run deploy` in the background whenever a commit touches bundle code (apps/server, apps/web, packages/). Commits that only touch docs/memory/scripts/config don't trigger a deploy. The hook never fails the commit (always exits 0), backgrounds the ~2-min deploy to a log (.git/t3-deploy.log), guards against concurrent deploys with a lock, and resolves mise explicitly since bun isn't on the bare hook PATH. Toggle with `bun run deploy:auto:off` / `:on` (git config t3.autoDeploy). Installed automatically via the root `prepare` script on `bun install`. Co-Authored-By: Claude Opus 4.8 --- .githooks/post-commit | 74 ++++++++++++++++++++++++++++++++++++ package.json | 5 ++- scripts/install-git-hooks.sh | 18 +++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100755 .githooks/post-commit create mode 100644 scripts/install-git-hooks.sh diff --git a/.githooks/post-commit b/.githooks/post-commit new file mode 100755 index 000000000000..64e411b42677 --- /dev/null +++ b/.githooks/post-commit @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# +# Auto-deploy the fork server to the VPS after a commit that changed app code. +# +# Wired via `git config core.hooksPath .githooks` (see scripts/install-git-hooks.sh). +# Design goals: +# - NEVER fail the commit (always exit 0; a broken deploy must not block git). +# - Only deploy when the commit actually touched the bundle (apps/server, apps/web, +# packages/) — doc/memory/plan commits are silent no-ops. +# - Run detached so `git commit` returns instantly; the ~2-min build+deploy runs +# in the background and logs to .git/t3-deploy.log. +# - Be toggleable: `git config t3.autoDeploy false` disables it. +# +# Set T3_AUTO_DEPLOY_DRYRUN=1 to print the decision (and the command it would run) +# without launching anything. + +# Resolve repo root; bail quietly if we somehow can't. +ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0 +cd "$ROOT" || exit 0 + +DRY="${T3_AUTO_DEPLOY_DRYRUN:-}" +note() { printf '%s\n' "$*"; } + +# 1. Toggle: default ON; only "false" disables. +if [[ "$(git config --bool t3.autoDeploy 2>/dev/null)" == "false" ]]; then + [[ -n "$DRY" ]] && note "[auto-deploy] skip (disabled via git config t3.autoDeploy=false)" + exit 0 +fi + +# 2. Path gate: did this commit touch bundle-affecting code? +if ! git diff-tree --no-commit-id --name-only -r HEAD 2>/dev/null \ + | grep -Eq '^(apps/server/|apps/web/|packages/)'; then + [[ -n "$DRY" ]] && note "[auto-deploy] skip (commit touched no bundle paths: apps/server, apps/web, packages)" + exit 0 +fi + +# 3. Locate mise (bun is not on the bare hook PATH). +MISE="$(command -v mise 2>/dev/null || true)" +[[ -z "$MISE" && -x /opt/homebrew/bin/mise ]] && MISE=/opt/homebrew/bin/mise +[[ -z "$MISE" && -x /usr/local/bin/mise ]] && MISE=/usr/local/bin/mise +if [[ -z "$MISE" ]]; then + note "[auto-deploy] mise not found on PATH — skipping. Run 'bun run deploy' manually." + exit 0 +fi + +DEPLOY_CMD="$MISE exec -- bun run deploy" + +if [[ -n "$DRY" ]]; then + note "[auto-deploy] would deploy — bundle paths changed." + note "[auto-deploy] command: $DEPLOY_CMD (logs -> .git/t3-deploy.log)" + exit 0 +fi + +# 4. Concurrency guard: non-blocking lock (atomic mkdir). If a deploy is already +# running, skip — the user can re-run `bun run deploy` or the next commit re-fires. +LOCK="$ROOT/.git/t3-deploy.lock" +LOG="$ROOT/.git/t3-deploy.log" +if ! mkdir "$LOCK" 2>/dev/null; then + note "🚀 auto-deploy: a deploy is already running — this commit will be picked up by re-running 'bun run deploy' if needed." + exit 0 +fi + +# 5. Launch detached. The subshell releases the lock when the deploy finishes. +# SSH key is inherited from the committing shell's SSH_AUTH_SOCK. +{ + printf '\n===== auto-deploy %s (commit %s) =====\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$(git rev-parse --short HEAD)" + $DEPLOY_CMD + printf '===== auto-deploy exit %s =====\n' "$?" + rmdir "$LOCK" 2>/dev/null || true +} >>"$LOG" 2>&1 & +disown 2>/dev/null || true + +note "🚀 auto-deploy started in background → tail -f .git/t3-deploy.log (disable: bun run deploy:auto:off)" +exit 0 diff --git a/package.json b/package.json index 76d66e28ad7a..eee831aa5ea7 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ }, "type": "module", "scripts": { - "prepare": "effect-tsgo patch", + "prepare": "effect-tsgo patch && (bash scripts/install-git-hooks.sh || true)", "dev": "node scripts/dev-runner.ts dev", "dev:server": "node scripts/dev-runner.ts dev:server", "dev:web": "node scripts/dev-runner.ts dev:web", @@ -42,6 +42,9 @@ "start:mock-update-server": "node scripts/mock-update-server.ts", "build": "turbo run build", "deploy": "bash scripts/deploy-vps.sh", + "hooks:install": "bash scripts/install-git-hooks.sh", + "deploy:auto:on": "git config t3.autoDeploy true && echo 'auto-deploy on commit: ON'", + "deploy:auto:off": "git config t3.autoDeploy false && echo 'auto-deploy on commit: OFF'", "build:marketing": "turbo run build --filter=@t3tools/marketing", "build:desktop": "turbo run build --filter=@t3tools/desktop --filter=t3", "typecheck": "turbo run typecheck", diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh new file mode 100644 index 000000000000..742e1551181f --- /dev/null +++ b/scripts/install-git-hooks.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# +# Idempotent installer for the repo's tracked git hooks. Points git at .githooks/ +# (which holds post-commit auto-deploy) and makes the hooks executable. +# Safe to run repeatedly; runs automatically via the root `prepare` script. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +# Only meaningful inside a git work tree. +git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo "(not a git repo; skipping hook install)"; exit 0; } + +git config core.hooksPath .githooks +chmod +x .githooks/* 2>/dev/null || true + +echo "git hooks installed: core.hooksPath -> .githooks (auto-deploy on commit)." +echo " toggle off: bun run deploy:auto:off | on: bun run deploy:auto:on" From 4a0d9c57a1bd0e521e6e6667ee762ebe02d70fa4 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Wed, 10 Jun 2026 18:14:21 -0400 Subject: [PATCH 23/40] docs(resume): note the replay cap is display-only (rewind-safe) Clarify that DEFAULT_REPLAY_MESSAGE_LIMIT bounds only what is rendered; the canonical transcript stays uncapped in the SDK store, so a future rewind-to-message feature is unaffected. Co-Authored-By: Claude Opus 4.8 --- apps/server/src/resume/transcriptReplay.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/server/src/resume/transcriptReplay.ts b/apps/server/src/resume/transcriptReplay.ts index 1267827f6880..646ebd986b8b 100644 --- a/apps/server/src/resume/transcriptReplay.ts +++ b/apps/server/src/resume/transcriptReplay.ts @@ -98,6 +98,10 @@ function formatCommandInvocation(text: string): string | null { * many-thousand-message session so its one-time import replay can't hang the * UI. With rejoin in place the replay runs only once per imported session, so * this generous cap costs nothing on re-resume. + * + * This cap is DISPLAY-ONLY: the canonical transcript stays uncapped in the SDK + * session store, so a future rewind-to-message feature reads full history + * independently of this limit. */ export const DEFAULT_REPLAY_MESSAGE_LIMIT = 1000; From 390bea6465d1469d75af0a902a498d80f4aa85a9 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Wed, 10 Jun 2026 18:33:17 -0400 Subject: [PATCH 24/40] fix(deploy): ship the full dist, smoke-test in the install tree, swap by rename The first deploy script only copied bin.mjs, but the server build code-splits (bin.mjs imports PTY-*/NodePTY-*/BunPTY-*/NodeSqliteClient-* chunks) and externalizes some node_modules deps (@effect/platform-node) resolved by walking up the install tree. Three fixes so the deploy is actually correct and safe: - Stage the WHOLE dist/ (all chunks + maps + client), not just bin.mjs. - Stage into a sibling of the live dist (dist.incoming) and smoke-test FROM there, so both the relative chunk imports and the externalized node_modules deps resolve exactly as the live server does (a /tmp smoke test could not see node_modules). - Swap by RENAME, never `rm -rf` the live dir: renames are atomic, never recurse, and can't partially delete the install or choke on root-owned cruft left inside by older deploys (which is exactly what broke a live deploy once). The renamed old dir is the timestamped backup; keep the 3 newest. Also: fix the curl health check (was concatenating "000"+echo, a false-positive that reported success on a dead server), require a real 2xx/3xx for the live check, and disable macOS AppleDouble (._*) files in the tar. Co-Authored-By: Claude Opus 4.8 --- scripts/deploy-vps.sh | 61 +++++++++++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/scripts/deploy-vps.sh b/scripts/deploy-vps.sh index 50fb38f01360..a08a238d662c 100755 --- a/scripts/deploy-vps.sh +++ b/scripts/deploy-vps.sh @@ -97,19 +97,29 @@ fi [[ -d "$DIST/client" ]] || die "missing build artifact: $DIST/client/ (web build did not land beside the server bundle)" echo " Artifacts present: bin.mjs + client/" -# --- 2. Stage artifacts to the VPS --------------------------------------------- +# --- 2. Stage the FULL dist INTO THE INSTALL TREE ------------------------------ +# Two things make naive staging fail: +# (a) the build CODE-SPLITS: bin.mjs imports sibling chunks (PTY-*.mjs, NodePTY-*, +# BunPTY-*, NodeSqliteClient-*, .map) — ship the whole dist/, not just bin.mjs. +# (b) the bundle EXTERNALIZES some node_modules deps (e.g. @effect/platform-node), +# resolved by walking up to the install tree's node_modules. So we stage into a +# sibling of the live dist (${INSTALL_DIR}.incoming, same depth) — that way the +# smoke-test resolves those externals exactly as the live server does, and the +# final swap is a cheap in-place mv. (This is the file-swap path; deps must be +# unchanged, which the preflight guarantees.) TS="$(date +%Y%m%d-%H%M%S)" -STAGING="/tmp/t3-deploy-$TS" -say "Staging to $HOST:$STAGING" -ssh "$HOST" "mkdir -p '$STAGING'" -scp -q "$DIST/bin.mjs" "$HOST:$STAGING/bin.mjs" -[[ -f "$DIST/bin.mjs.map" ]] && scp -q "$DIST/bin.mjs.map" "$HOST:$STAGING/bin.mjs.map" -scp -qr "$DIST/client" "$HOST:$STAGING/client" +STAGING="/tmp/t3-deploy-$TS" # scratch base-dir + smoke log only +INCOMING="${INSTALL_DIR}.incoming" +MJS_COUNT="$(find "$DIST" -maxdepth 1 -name '*.mjs' | wc -l | tr -d ' ')" +say "Staging full dist ($MJS_COUNT mjs files + client/) into $HOST:$INCOMING" +ssh "$HOST" "rm -rf '$INCOMING' && mkdir -p '$INCOMING' '$STAGING/scratch-basedir'" +# COPYFILE_DISABLE stops macOS bsdtar from emitting AppleDouble ._* sidecar files. +COPYFILE_DISABLE=1 tar -C "$DIST" -cf - . | ssh "$HOST" "tar -C '$INCOMING' -xf -" echo " Uploaded." # --- 3-7. Remote: smoke-test -> backup -> swap -> chown -> restart -> verify ---- say "Remote: smoke-test, swap, restart, verify" -ssh "$HOST" "STAGING='$STAGING' TS='$TS' LIVE_PORT='$LIVE_PORT' SMOKE_PORT='$SMOKE_PORT' INSTALL_DIR='$INSTALL_DIR' SERVICE='$SERVICE' BASE_DIR='$BASE_DIR' bash -s" <<'REMOTE' +ssh "$HOST" "STAGING='$STAGING' TS='$TS' LIVE_PORT='$LIVE_PORT' SMOKE_PORT='$SMOKE_PORT' INSTALL_DIR='$INSTALL_DIR' INCOMING='$INCOMING' SERVICE='$SERVICE' BASE_DIR='$BASE_DIR' bash -s" <<'REMOTE' set -euo pipefail rfail() { printf '\n[remote] FAILED: %s\n' "$*" >&2; exit 1; } @@ -125,12 +135,13 @@ else NEED_CHOWN=0 # files we write are already deploy-owned fi -# node refuses any extension but .mjs (ERR_UNKNOWN_FILE_EXTENSION) — smoke copy must end in .mjs -cp "$STAGING/bin.mjs" "$STAGING/bin.smoke.mjs" -mkdir -p "$STAGING/scratch-basedir" +# Smoke copy must end in .mjs (node ERR_UNKNOWN_FILE_EXTENSION) and sits in $INCOMING +# beside its sibling chunks AND within the install tree, so both the relative chunk +# imports and the externalized node_modules deps resolve like the live server. +cp "$INCOMING/bin.mjs" "$INCOMING/bin.smoke.mjs" echo "[remote] boot smoke-test on :$SMOKE_PORT (VPS node $(node -v)) before touching live files" -node "$STAGING/bin.smoke.mjs" serve --host 127.0.0.1 --port "$SMOKE_PORT" --base-dir "$STAGING/scratch-basedir" >"$STAGING/smoke.log" 2>&1 & +node "$INCOMING/bin.smoke.mjs" serve --host 127.0.0.1 --port "$SMOKE_PORT" --base-dir "$STAGING/scratch-basedir" >"$STAGING/smoke.log" 2>&1 & SMOKE_PID=$! trap 'kill "$SMOKE_PID" 2>/dev/null || true' EXIT @@ -150,18 +161,22 @@ kill "$SMOKE_PID" 2>/dev/null || true; trap - EXIT [[ "$code" == "000" ]] && { echo "----- smoke.log -----"; tail -n 40 "$STAGING/smoke.log" || true; rfail "smoke server never answered on :$SMOKE_PORT"; } echo "[remote] smoke OK (HTTP $code) — bundle boots on the VPS" -[[ -f "$INSTALL_DIR/bin.mjs" ]] || rfail "install dir not found: $INSTALL_DIR/bin.mjs" -echo "[remote] backing up live files (suffix .bak-$TS)" -cp "$INSTALL_DIR/bin.mjs" "$INSTALL_DIR/bin.mjs.bak-$TS" -cp -r "$INSTALL_DIR/client" "$INSTALL_DIR/client.bak-$TS" +[[ -d "$INSTALL_DIR" ]] || rfail "install dir not found: $INSTALL_DIR" +rm -f "$INCOMING/bin.smoke.mjs" # don't ship the smoke copy -echo "[remote] swapping in new bundle + client" -cp "$STAGING/bin.mjs" "$INSTALL_DIR/bin.mjs" -[[ -f "$STAGING/bin.mjs.map" ]] && cp "$STAGING/bin.mjs.map" "$INSTALL_DIR/bin.mjs.map" -rm -rf "$INSTALL_DIR/client" -cp -r "$STAGING/client" "$INSTALL_DIR/client" +# Swap by RENAME, never rm -rf the live dir. Two renames within the (deploy-owned) +# parent: the old dist becomes the timestamped backup, the validated $INCOMING takes +# its place. Renames are atomic, never recurse, and — critically — can't partially +# delete the live dir or choke on root-owned cruft left inside it by old deploys. +echo "[remote] swapping: rename live dist -> ${INSTALL_DIR}.bak-$TS, move validated dist into place" +mv "$INSTALL_DIR" "${INSTALL_DIR}.bak-$TS" +mv "$INCOMING" "$INSTALL_DIR" [[ "$NEED_CHOWN" == "1" ]] && chown -R deploy:deploy "$INSTALL_DIR" +# Best-effort: keep only the 3 newest backups (older ones may hold root-owned cruft +# that we can't delete as deploy — ignore failures). +ls -dt "${INSTALL_DIR}".bak-* 2>/dev/null | tail -n +4 | while read -r old; do rm -rf "$old" 2>/dev/null || true; done + echo "[remote] restarting $SERVICE" as_deploy systemctl --user restart "$SERVICE" @@ -176,7 +191,7 @@ done if [[ ! "$live" =~ ^[23] ]]; then echo "[remote] live server not answering — recent service log:" as_deploy journalctl --user -u "$SERVICE" -n 40 --no-pager || true - echo "[remote] backups kept at $INSTALL_DIR/bin.mjs.bak-$TS and client.bak-$TS — restore with cp if needed" + echo "[remote] previous dist kept at ${INSTALL_DIR}.bak-$TS — restore: rm -rf $INSTALL_DIR && mv ${INSTALL_DIR}.bak-$TS $INSTALL_DIR && restart" rfail "live server did not come back up on :$LIVE_PORT" fi NEWPID="$(as_deploy systemctl --user show -p MainPID --value "$SERVICE" 2>/dev/null || echo '?')" @@ -186,4 +201,4 @@ cd /tmp && rm -rf "t3-deploy-$TS" REMOTE say "Deploy complete — live on $HOST:$LIVE_PORT" -echo " Rollback if needed: on $HOST, cp $INSTALL_DIR/bin.mjs.bak-$TS -> bin.mjs (and client.bak-$TS), chown deploy, restart $SERVICE." +echo " Rollback if needed: on $HOST, rm -rf $INSTALL_DIR && mv ${INSTALL_DIR}.bak-$TS $INSTALL_DIR, then restart $SERVICE." From fcd75ccf8061e74f6a7a0cda576028ef2bbc158f Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Wed, 10 Jun 2026 19:17:54 -0400 Subject: [PATCH 25/40] perf(resume): scope the resume picker to the current project The picker full-scanned every project's provider bindings on each open. Add a project-scoped query that joins provider_session_runtime to projection_threads (project_id already indexed), resolve the project from the picker's cwd, and read only that project's bindings. No DB migration: the project link is derived by join, not denormalized onto the binding row. Pure performance bound, not a behavior change: the imported-session map is keyed by Claude session id and only probed with the current folder's SDK session ids, so cross-project bindings never matched. When the cwd does not resolve to a known project, fall back to the unscoped scan so rejoin targets are never silently dropped. Co-Authored-By: Claude Opus 4.8 --- .../Layers/ProviderSessionRuntime.ts | 53 +++++++++++++- .../Services/ProviderSessionRuntime.ts | 19 +++++ .../src/provider/Layers/CodexAdapter.test.ts | 1 + .../provider/Layers/OpenCodeAdapter.test.ts | 1 + .../Layers/ProviderSessionDirectory.test.ts | 69 ++++++++++++++++++- .../Layers/ProviderSessionDirectory.ts | 17 +++++ .../Services/ProviderSessionDirectory.ts | 15 ++++ .../src/resume/importableSessionsService.ts | 38 +++++++--- 8 files changed, 200 insertions(+), 13 deletions(-) diff --git a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts index 21f555db08a4..503e62e3b3fb 100644 --- a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts @@ -1,4 +1,4 @@ -import { ThreadId } from "@t3tools/contracts"; +import { ProjectId, ThreadId } from "@t3tools/contracts"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import * as Effect from "effect/Effect"; @@ -31,6 +31,10 @@ const GetRuntimeRequestSchema = Schema.Struct({ threadId: ThreadId, }); +const ListByProjectIdRequestSchema = Schema.Struct({ + projectId: ProjectId, +}); + const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema; function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { @@ -122,6 +126,28 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { `, }); + const listRuntimeRowsByProjectId = SqlSchema.findAll({ + Request: ListByProjectIdRequestSchema, + Result: ProviderSessionRuntimeDbRowSchema, + execute: ({ projectId }) => + sql` + SELECT + psr.thread_id AS "threadId", + psr.provider_name AS "providerName", + psr.provider_instance_id AS "providerInstanceId", + psr.adapter_key AS "adapterKey", + psr.runtime_mode AS "runtimeMode", + psr.status, + psr.last_seen_at AS "lastSeenAt", + psr.resume_cursor_json AS "resumeCursor", + psr.runtime_payload_json AS "runtimePayload" + FROM provider_session_runtime psr + JOIN projection_threads pt ON pt.thread_id = psr.thread_id + WHERE pt.project_id = ${projectId} + ORDER BY psr.last_seen_at ASC, psr.thread_id ASC + `, + }); + const deleteRuntimeByThreadId = SqlSchema.void({ Request: DeleteRuntimeRequestSchema, execute: ({ threadId }) => @@ -187,6 +213,30 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { ), ); + const listByProjectId: ProviderSessionRuntimeRepositoryShape["listByProjectId"] = (input) => + listRuntimeRowsByProjectId(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProviderSessionRuntimeRepository.listByProjectId:query", + "ProviderSessionRuntimeRepository.listByProjectId:decodeRows", + ), + ), + Effect.flatMap((rows) => + Effect.forEach( + rows, + (row) => + decodeRuntime(row).pipe( + Effect.mapError( + toPersistenceDecodeError( + "ProviderSessionRuntimeRepository.listByProjectId:rowToRuntime", + ), + ), + ), + { concurrency: 16 }, + ), + ), + ); + const deleteByThreadId: ProviderSessionRuntimeRepositoryShape["deleteByThreadId"] = (input) => deleteRuntimeByThreadId(input).pipe( Effect.mapError( @@ -198,6 +248,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { upsert, getByThreadId, list, + listByProjectId, deleteByThreadId, } satisfies ProviderSessionRuntimeRepositoryShape; }); diff --git a/apps/server/src/persistence/Services/ProviderSessionRuntime.ts b/apps/server/src/persistence/Services/ProviderSessionRuntime.ts index 125f4fa5bbf2..b81f6363ece8 100644 --- a/apps/server/src/persistence/Services/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/Services/ProviderSessionRuntime.ts @@ -7,6 +7,7 @@ */ import { IsoDateTime, + ProjectId, ProviderInstanceId, ProviderSessionRuntimeStatus, RuntimeMode, @@ -42,6 +43,10 @@ export type ProviderSessionRuntime = typeof ProviderSessionRuntime.Type; export const GetProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId }); export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInput.Type; +export const ListProviderSessionRuntimeByProjectInput = Schema.Struct({ projectId: ProjectId }); +export type ListProviderSessionRuntimeByProjectInput = + typeof ListProviderSessionRuntimeByProjectInput.Type; + export const DeleteProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId }); export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type; @@ -75,6 +80,20 @@ export interface ProviderSessionRuntimeRepositoryShape { ProviderSessionRuntimeRepositoryError >; + /** + * List provider runtime rows scoped to a single project. + * + * Joins through `projection_threads` (thread_id → project_id) so the resume + * picker reads only the current project's bindings instead of full-scanning + * every project. Returned in ascending last-seen order, like `list`. + */ + readonly listByProjectId: ( + input: ListProviderSessionRuntimeByProjectInput, + ) => Effect.Effect< + ReadonlyArray, + ProviderSessionRuntimeRepositoryError + >; + /** * Delete provider runtime state by canonical thread id. */ diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 3ae98ec248c7..577600c8e6fe 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -218,6 +218,7 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory getBinding: () => Effect.succeed(Option.none()), listThreadIds: () => Effect.succeed([]), listBindings: () => Effect.succeed([]), + listBindingsByProjectId: () => Effect.succeed([]), }); const validationRuntimeFactory = makeRuntimeFactory(); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 4849272035c9..35aaa3358891 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -185,6 +185,7 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory getBinding: () => Effect.succeed(Option.none()), listThreadIds: () => Effect.succeed([]), listBindings: () => Effect.succeed([]), + listBindingsByProjectId: () => Effect.succeed([]), }); // The adapter now receives its settings as a plain argument (the old design diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index f9793ca9d1fe..2231d0ff8173 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; +import { ProjectId, ProviderDriverKind, ThreadId } from "@t3tools/contracts"; import { it, assert } from "@effect/vitest"; import { assertSome } from "@effect/vitest/utils"; import * as Effect from "effect/Effect"; @@ -199,6 +199,73 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL ]); })); + it("scopes listBindingsByProjectId to the requested project, oldest-first", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const sql = yield* SqlClient.SqlClient; + + const projectAlpha = ProjectId.make("project-alpha"); + const projectBeta = ProjectId.make("project-beta"); + + const alphaOlder = ThreadId.make("thread-alpha-older"); + const alphaNewer = ThreadId.make("thread-alpha-newer"); + const betaThread = ThreadId.make("thread-beta"); + + // Seed the projection rows the JOIN reads through. `model` was dropped in + // migration 016, so only thread_id/project_id/title/created_at/updated_at + // are NOT NULL without a default. + const seedThread = (threadId: ThreadId, projectId: ProjectId, title: string) => + sql` + INSERT INTO projection_threads (thread_id, project_id, title, created_at, updated_at) + VALUES ( + ${threadId}, + ${projectId}, + ${title}, + ${"2026-01-01T00:00:00.000Z"}, + ${"2026-01-01T00:00:00.000Z"} + ) + `; + yield* seedThread(alphaOlder, projectAlpha, "Alpha older"); + yield* seedThread(alphaNewer, projectAlpha, "Alpha newer"); + yield* seedThread(betaThread, projectBeta, "Beta"); + + const bind = (threadId: ThreadId, lastSeenAt: string) => + runtimeRepository.upsert({ + threadId, + providerName: "claudeAgent", + providerInstanceId: null, + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "stopped", + lastSeenAt, + resumeCursor: null, + runtimePayload: null, + }); + yield* bind(alphaNewer, "2026-04-14T12:05:00.000Z"); + yield* bind(alphaOlder, "2026-04-14T12:00:00.000Z"); + yield* bind(betaThread, "2026-04-14T12:10:00.000Z"); + + const alphaBindings = yield* directory.listBindingsByProjectId(projectAlpha); + // Only the alpha project's bindings, oldest-first — beta is excluded. + assert.deepEqual( + alphaBindings.map((binding) => binding.threadId), + [alphaOlder, alphaNewer], + ); + + const betaBindings = yield* directory.listBindingsByProjectId(projectBeta); + assert.deepEqual( + betaBindings.map((binding) => binding.threadId), + [betaThread], + ); + + // A project with no threads returns nothing (no full-scan leakage). + const emptyBindings = yield* directory.listBindingsByProjectId( + ProjectId.make("project-unknown"), + ); + assert.deepEqual(emptyBindings, []); + })); + it("resets adapterKey to the new provider when provider changes without an explicit adapter key", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index c3a1c8e51466..13aa7f147c33 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -183,12 +183,29 @@ const makeProviderSessionDirectory = Effect.gen(function* () { ), ); + const listBindingsByProjectId: ProviderSessionDirectoryShape["listBindingsByProjectId"] = ( + projectId, + ) => + repository.listByProjectId({ projectId }).pipe( + Effect.mapError( + toPersistenceError("ProviderSessionDirectory.listBindingsByProjectId:listByProjectId"), + ), + Effect.flatMap((rows) => + Effect.forEach( + rows, + (row) => toRuntimeBinding(row, "ProviderSessionDirectory.listBindingsByProjectId"), + { concurrency: 16 }, + ), + ), + ); + return { upsert, getProvider, getBinding, listThreadIds, listBindings, + listBindingsByProjectId, } satisfies ProviderSessionDirectoryShape; }); diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index f2dd4323f7a3..f5e6b88774aa 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -1,4 +1,5 @@ import type { + ProjectId, ProviderInstanceId, ProviderDriverKind, ProviderSessionRuntimeStatus, @@ -62,6 +63,20 @@ export interface ProviderSessionDirectoryShape { ReadonlyArray, ProviderSessionDirectoryPersistenceError >; + + /** + * List bindings scoped to a single project (the resume picker's path). + * + * Same shape as `listBindings`, but the persistence query joins through + * `projection_threads` so only the current project's bindings are read and + * parsed — not every project's. + */ + readonly listBindingsByProjectId: ( + projectId: ProjectId, + ) => Effect.Effect< + ReadonlyArray, + ProviderSessionDirectoryPersistenceError + >; } export class ProviderSessionDirectory extends Context.Service< diff --git a/apps/server/src/resume/importableSessionsService.ts b/apps/server/src/resume/importableSessionsService.ts index 5e8fe3782fc9..80789572e80f 100644 --- a/apps/server/src/resume/importableSessionsService.ts +++ b/apps/server/src/resume/importableSessionsService.ts @@ -23,6 +23,7 @@ import * as Context from "effect/Context"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { ProviderSessionDirectory } from "../provider/Services/ProviderSessionDirectory.ts"; @@ -83,23 +84,38 @@ const make = Effect.gen(function* () { * are eligible — archived/deleted threads fall through to a fresh import * rather than a broken rejoin. Best-effort: on any read failure the picker * still lists every session, just without rejoin targets. + * + * Bindings are scoped to the current project (resolved from `projectCwd`) so + * the picker never full-scans every project's bindings. This is a pure + * performance bound, not a behavior change: cross-project bindings carry other + * projects' Claude session ids, which never appear in this folder's + * `sdkListSessions`, so they could never have matched `buildImportedSessionMap` + * (keyed by Claude session id). When the cwd does NOT resolve to a known + * project — a genuinely new folder, or a path-representation mismatch against + * the stored workspace_root — fall back to the unscoped scan so rejoin targets + * are never silently lost (which would resurrect the duplicate-on-rejoin bug). + * The scan cost is paid only in that rare unresolved case, not per picker-open. */ - const buildImportedSessions: Effect.Effect> = Effect.gen( - function* () { + const buildImportedSessions = ( + projectCwd: string, + ): Effect.Effect> => + Effect.gen(function* () { const activeThreadIds = yield* projection.getShellSnapshot().pipe( Effect.map((snapshot) => new Set(snapshot.threads.map((thread) => thread.id))), Effect.catch(() => Effect.succeed(new Set())), ); - const bindings = yield* directory.listBindings(); + const project = yield* projection.getActiveProjectByWorkspaceRoot(projectCwd); + const bindings = Option.isSome(project) + ? yield* directory.listBindingsByProjectId(project.value.id) + : yield* directory.listBindings(); return buildImportedSessionMap(bindings, activeThreadIds); - }, - ).pipe( - Effect.catch((cause) => - Effect.logWarning("resume picker: failed to build rejoin map", { cause }).pipe( - Effect.as(new Map() as ReadonlyMap), + }).pipe( + Effect.catch((cause) => + Effect.logWarning("resume picker: failed to build rejoin map", { cause }).pipe( + Effect.as(new Map() as ReadonlyMap), + ), ), - ), - ); + ); const listForProject: ImportableSessionsServiceShape["listForProject"] = ({ projectCwd }) => Effect.gen(function* () { @@ -109,7 +125,7 @@ const make = Effect.gen(function* () { new ImportableSessionsError({ detail: "Claude listSessions failed", cause }), }); - const importedSessions = yield* buildImportedSessions; + const importedSessions = yield* buildImportedSessions(projectCwd); return selectImportableSessions(sdkSessions.map(toSessionInfo), { // t3-origin hiding is still deferred (needs the t3-created id set); the From 1e0f7f1fa2335556a28d9c027c316dae7b014437 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Wed, 10 Jun 2026 20:07:31 -0400 Subject: [PATCH 26/40] test(resume): cover the unscoped-scan fallback in the resume picker Follow-up to fcd75ccf. The cwd->no-project fallback (which keeps rejoin targets alive when a folder doesn't resolve to a stored project, so the duplicate-on-rejoin bug can't resurface) had no test of its own. Lift buildImportedSessions out of the service closure into an exported function taking a narrow ImportedSessionsDeps interface, so the scoped-vs-unscoped branch is unit-testable without the Claude SDK or a DB. Pure extraction: identical runtime behavior. Add 3 it.effect tests pinning the fallback path, the scoped path, and the active-snapshot filter. Co-Authored-By: Claude Opus 4.8 --- .../resume/importableSessionsService.test.ts | 128 ++++++++++++++++++ .../src/resume/importableSessionsService.ts | 123 +++++++++++------ 2 files changed, 208 insertions(+), 43 deletions(-) create mode 100644 apps/server/src/resume/importableSessionsService.test.ts diff --git a/apps/server/src/resume/importableSessionsService.test.ts b/apps/server/src/resume/importableSessionsService.test.ts new file mode 100644 index 000000000000..c143236caa16 --- /dev/null +++ b/apps/server/src/resume/importableSessionsService.test.ts @@ -0,0 +1,128 @@ +import { ProjectId } from "@t3tools/contracts"; +import { it, assert } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import { buildImportedSessions, type ImportedSessionsDeps } from "./importableSessionsService.ts"; +import type { ImportedBinding } from "./importableSessions.ts"; + +/** + * `buildImportedSessions` chooses between the project-scoped binding query and + * the unscoped fallback. The fallback (cwd → no project) is the safety-critical + * branch: it exists so a path-representation mismatch can't silently drop rejoin + * targets and resurrect the duplicate-on-rejoin bug. These tests pin both + * branches directly with stub deps — no Claude SDK, no database. + */ + +const binding = (threadId: string, resume: string, lastSeenAt: string): ImportedBinding => ({ + threadId, + resumeCursor: { resume }, + lastSeenAt, +}); + +/** Base deps; each test overrides the fields it exercises. Records which list path ran. */ +function makeDeps( + overrides: Partial, + calls: { listBindings: boolean; listBindingsByProjectId: ProjectId | null }, +): ImportedSessionsDeps { + return { + activeThreadIds: Effect.succeed(new Set()), + resolveProject: () => Effect.succeed(Option.none()), + listBindings: () => + Effect.sync(() => { + calls.listBindings = true; + return []; + }), + listBindingsByProjectId: (projectId) => + Effect.sync(() => { + calls.listBindingsByProjectId = projectId; + return []; + }), + ...overrides, + }; +} + +it.effect("falls back to the unscoped scan when the cwd resolves to no project", () => + Effect.gen(function* () { + const calls = { listBindings: false, listBindingsByProjectId: null as ProjectId | null }; + const deps = makeDeps( + { + activeThreadIds: Effect.succeed(new Set(["t-A"])), + resolveProject: () => Effect.succeed(Option.none()), + listBindings: () => + Effect.sync(() => { + calls.listBindings = true; + return [binding("t-A", "sess-A", "2026-01-01T00:00:00.000Z")]; + }), + }, + calls, + ); + + const map = yield* buildImportedSessions(deps, "/some/unknown/cwd"); + + // Rejoin target survived via the unscoped path. + assert.equal(map.get("sess-A"), "t-A"); + assert.equal(calls.listBindings, true); + // The scoped query must NOT run when there's no project. + assert.equal(calls.listBindingsByProjectId, null); + }), +); + +it.effect("uses the project-scoped query when the cwd resolves to a project", () => + Effect.gen(function* () { + const projectId = ProjectId.make("proj-1"); + const calls = { listBindings: false, listBindingsByProjectId: null as ProjectId | null }; + const deps = makeDeps( + { + activeThreadIds: Effect.succeed(new Set(["t-B"])), + resolveProject: () => Effect.succeed(Option.some(projectId)), + listBindingsByProjectId: (id) => + Effect.sync(() => { + calls.listBindingsByProjectId = id; + return [binding("t-B", "sess-B", "2026-01-01T00:00:00.000Z")]; + }), + // If the scoped path were skipped and this ran, "sess-X" would leak in. + listBindings: () => + Effect.sync(() => { + calls.listBindings = true; + return [binding("t-X", "sess-X", "2026-01-01T00:00:00.000Z")]; + }), + }, + calls, + ); + + const map = yield* buildImportedSessions(deps, "/known/project/cwd"); + + assert.equal(map.get("sess-B"), "t-B"); + assert.equal(map.has("sess-X"), false); + assert.deepEqual(calls.listBindingsByProjectId, projectId); + assert.equal(calls.listBindings, false); + }), +); + +it.effect("ignores bindings whose thread is not in the active snapshot", () => + Effect.gen(function* () { + const calls = { listBindings: false, listBindingsByProjectId: null as ProjectId | null }; + const deps = makeDeps( + { + // "t-archived" is NOT in the active set, so its rejoin target is dropped. + activeThreadIds: Effect.succeed(new Set(["t-A"])), + resolveProject: () => Effect.succeed(Option.none()), + listBindings: () => + Effect.sync(() => { + calls.listBindings = true; + return [ + binding("t-A", "sess-A", "2026-01-01T00:00:00.000Z"), + binding("t-archived", "sess-archived", "2026-01-02T00:00:00.000Z"), + ]; + }), + }, + calls, + ); + + const map = yield* buildImportedSessions(deps, "/some/cwd"); + + assert.equal(map.get("sess-A"), "t-A"); + assert.equal(map.has("sess-archived"), false); + }), +); diff --git a/apps/server/src/resume/importableSessionsService.ts b/apps/server/src/resume/importableSessionsService.ts index 80789572e80f..ec957f6388fc 100644 --- a/apps/server/src/resume/importableSessionsService.ts +++ b/apps/server/src/resume/importableSessionsService.ts @@ -18,6 +18,7 @@ * t3-origin id set from a service already present in the ws-layer scope. * See CONTEXT.md ("Importable session") and docs/adr/0001. */ +import type { ProjectId } from "@t3tools/contracts"; import { listSessions as sdkListSessions } from "@anthropic-ai/claude-agent-sdk"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; @@ -27,10 +28,13 @@ import * as Option from "effect/Option"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { ProviderSessionDirectory } from "../provider/Services/ProviderSessionDirectory.ts"; +import type { ProviderSessionDirectoryPersistenceError } from "../provider/Errors.ts"; +import type { ProjectionRepositoryError } from "../persistence/Errors.ts"; import { buildImportedSessionMap, selectImportableSessions, type ImportableSession, + type ImportedBinding, type SessionInfo, } from "./importableSessions.ts"; @@ -70,52 +74,85 @@ function toSessionInfo(info: { }; } +/** + * The narrow slice of services the rejoin-map builder needs, injected so the + * project-scoping branch is unit-testable without the Claude SDK or a database. + */ +export interface ImportedSessionsDeps { + /** Active (non-archived/deleted) thread ids; best-effort, empty on read failure. */ + readonly activeThreadIds: Effect.Effect>; + /** The project owning `projectCwd`, if the cwd resolves to one. */ + readonly resolveProject: ( + projectCwd: string, + ) => Effect.Effect, ProjectionRepositoryError>; + /** Every binding (unscoped) — the fallback path when the cwd doesn't resolve. */ + readonly listBindings: () => Effect.Effect< + ReadonlyArray, + ProviderSessionDirectoryPersistenceError + >; + /** Bindings scoped to one project — the fast path. */ + readonly listBindingsByProjectId: ( + projectId: ProjectId, + ) => Effect.Effect, ProviderSessionDirectoryPersistenceError>; +} + +/** + * Build the "already imported" map — Claude session id → the t3 Thread that + * imported it — from the provider session bindings. Each thread created from + * /resume carries a binding whose `resumeCursor.resume` is the original Claude + * session id (seeded by ResumeSeedReactor, kept in step by the adapter). The + * latest-active binding wins, so a rejoin lands on the most recent thread when + * pre-fix duplicates already exist. Only threads still in the active snapshot + * are eligible — archived/deleted threads fall through to a fresh import + * rather than a broken rejoin. Best-effort: on any read failure the picker + * still lists every session, just without rejoin targets. + * + * Bindings are scoped to the current project (resolved from `projectCwd`) so + * the picker never full-scans every project's bindings. This is a pure + * performance bound, not a behavior change: cross-project bindings carry other + * projects' Claude session ids, which never appear in this folder's + * `sdkListSessions`, so they could never have matched `buildImportedSessionMap` + * (keyed by Claude session id). When the cwd does NOT resolve to a known + * project — a genuinely new folder, or a path-representation mismatch against + * the stored workspace_root — fall back to the unscoped scan so rejoin targets + * are never silently lost (which would resurrect the duplicate-on-rejoin bug). + * The scan cost is paid only in that rare unresolved case, not per picker-open. + */ +export const buildImportedSessions = ( + deps: ImportedSessionsDeps, + projectCwd: string, +): Effect.Effect> => + Effect.gen(function* () { + const activeThreadIds = yield* deps.activeThreadIds; + const project = yield* deps.resolveProject(projectCwd); + const bindings = Option.isSome(project) + ? yield* deps.listBindingsByProjectId(project.value) + : yield* deps.listBindings(); + return buildImportedSessionMap(bindings, activeThreadIds); + }).pipe( + Effect.catch((cause) => + Effect.logWarning("resume picker: failed to build rejoin map", { cause }).pipe( + Effect.as(new Map() as ReadonlyMap), + ), + ), + ); + const make = Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; const projection = yield* ProjectionSnapshotQuery; - /** - * Build the "already imported" map — Claude session id → the t3 Thread that - * imported it — from the provider session bindings. Each thread created from - * /resume carries a binding whose `resumeCursor.resume` is the original Claude - * session id (seeded by ResumeSeedReactor, kept in step by the adapter). The - * latest-active binding wins, so a rejoin lands on the most recent thread when - * pre-fix duplicates already exist. Only threads still in the active snapshot - * are eligible — archived/deleted threads fall through to a fresh import - * rather than a broken rejoin. Best-effort: on any read failure the picker - * still lists every session, just without rejoin targets. - * - * Bindings are scoped to the current project (resolved from `projectCwd`) so - * the picker never full-scans every project's bindings. This is a pure - * performance bound, not a behavior change: cross-project bindings carry other - * projects' Claude session ids, which never appear in this folder's - * `sdkListSessions`, so they could never have matched `buildImportedSessionMap` - * (keyed by Claude session id). When the cwd does NOT resolve to a known - * project — a genuinely new folder, or a path-representation mismatch against - * the stored workspace_root — fall back to the unscoped scan so rejoin targets - * are never silently lost (which would resurrect the duplicate-on-rejoin bug). - * The scan cost is paid only in that rare unresolved case, not per picker-open. - */ - const buildImportedSessions = ( - projectCwd: string, - ): Effect.Effect> => - Effect.gen(function* () { - const activeThreadIds = yield* projection.getShellSnapshot().pipe( - Effect.map((snapshot) => new Set(snapshot.threads.map((thread) => thread.id))), - Effect.catch(() => Effect.succeed(new Set())), - ); - const project = yield* projection.getActiveProjectByWorkspaceRoot(projectCwd); - const bindings = Option.isSome(project) - ? yield* directory.listBindingsByProjectId(project.value.id) - : yield* directory.listBindings(); - return buildImportedSessionMap(bindings, activeThreadIds); - }).pipe( - Effect.catch((cause) => - Effect.logWarning("resume picker: failed to build rejoin map", { cause }).pipe( - Effect.as(new Map() as ReadonlyMap), - ), - ), - ); + const importedSessionsDeps: ImportedSessionsDeps = { + activeThreadIds: projection.getShellSnapshot().pipe( + Effect.map((snapshot) => new Set(snapshot.threads.map((thread) => thread.id))), + Effect.catch(() => Effect.succeed(new Set())), + ), + resolveProject: (projectCwd) => + projection + .getActiveProjectByWorkspaceRoot(projectCwd) + .pipe(Effect.map(Option.map((project) => project.id))), + listBindings: directory.listBindings, + listBindingsByProjectId: directory.listBindingsByProjectId, + }; const listForProject: ImportableSessionsServiceShape["listForProject"] = ({ projectCwd }) => Effect.gen(function* () { @@ -125,7 +162,7 @@ const make = Effect.gen(function* () { new ImportableSessionsError({ detail: "Claude listSessions failed", cause }), }); - const importedSessions = yield* buildImportedSessions(projectCwd); + const importedSessions = yield* buildImportedSessions(importedSessionsDeps, projectCwd); return selectImportableSessions(sdkSessions.map(toSessionInfo), { // t3-origin hiding is still deferred (needs the t3-created id set); the From ca36a403b38f2ae7a7157a030f9a3313d9ac5ef5 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Wed, 10 Jun 2026 21:44:52 -0400 Subject: [PATCH 27/40] docs(rewind): add ADR-0002 conversation-rewind design + link from CONTEXT Captures the agreed non-destructive conversation-rewind design: one Claude-CLI-style menu (restore conversation only / also restore files), hidden-but-retained forward messages, pre-filled rewound prompt, and decoupling the existing destructive revert from code-restore. Co-Authored-By: Claude Opus 4.8 --- CONTEXT.md | 2 +- docs/adr/0002-conversation-rewind.md | 97 ++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0002-conversation-rewind.md diff --git a/CONTEXT.md b/CONTEXT.md index fc3ed2d98170..a11f2d3ac849 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -47,7 +47,7 @@ Where a Provider session was started: the **terminal** (the user's own Claude CL - **Priority direction (resolved 2026-06-03):** the more important half is **terminal → t3** ("resume a CLI chat *inside* t3"), built first. The reverse (t3 → terminal) is nearly free and comes second. - **VERIFIED with real data (2026-06-03):** t3 already stores Claude **Provider sessions** in the *shared* `~/.claude` store and runs Claude **Threads** in the repo root (no Worktree by default — real threads had `worktree_path: null`, branch `custom`/`main`). A real t3 thread's session file (`c55dc749…`, 26 msgs) physically sits in the **same Project bucket** as terminal sessions. macOS's case-insensitive filesystem merges the `Dev` vs `dev` path-casing difference into one bucket, so the two share a single session pool. t3 state lives at `~/.t3/userdata/state.sqlite`; resume pointer is `provider_session_runtime.resume_cursor_json` = `{resume, resumeSessionAt}`. - **Display of an imported chat (resolved 2026-06-03):** **show** the earlier messages (readable text), so the user can see the chat and pick a point — not "show nothing". Full byte-perfect reproduction of every tool call/sidechain is *not* required for v1. -- **Rewind (resolved 2026-06-03):** two kinds, mirroring Claude Code's *native* rewind, which deliberately separates them (native menu offers "Restore conversation" vs "Restore code" as distinct actions — verified against Claude Code rewind docs 2026-06-03). **Conversation-rewind** (jump to an earlier message and continue) is in v1 — it uses the stable `resumeSessionAt` anchor. It mirrors native "Restore conversation": it stays in the **same Thread / Provider session** (not a new branch Thread), it is **non-destructive** (the skipped-past messages are retained in the on-disk transcript tree, as native keeps them), and it **leaves the working tree untouched**. Mechanically it just moves `resumeSessionAt` back to an earlier message uuid and continues the same session. **File-rewind** (native "Restore code" — restore the working tree to an earlier point of the *imported* chat) is **deferred**: the data exists (`~/.claude/file-history//` blobs + `file-history-snapshot` transcript entries with `trackedFileBackups`), so it's *possible*, but it means reading Claude's private, undocumented snapshot format and translating it to t3's git-checkpoint model — fragile against Claude updates, against the fork's "don't depend on churny internals" principle. Because native treats the two as separate actions, doing conversation-rewind without file-rewind is **faithful to native**, not a compromise. Revisit file-rewind only if it proves essential. +- **Rewind (resolved 2026-06-03):** two kinds, mirroring Claude Code's *native* rewind, which deliberately separates them (native menu offers "Restore conversation" vs "Restore code" as distinct actions — verified against Claude Code rewind docs 2026-06-03). **Conversation-rewind** (jump to an earlier message and continue) is in v1 — it uses the stable `resumeSessionAt` anchor. It mirrors native "Restore conversation": it stays in the **same Thread / Provider session** (not a new branch Thread), it is **non-destructive** (the skipped-past messages are retained in the on-disk transcript tree, as native keeps them), and it **leaves the working tree untouched**. Mechanically it just moves `resumeSessionAt` back to an earlier message uuid and continues the same session. **File-rewind** (native "Restore code" — restore the working tree to an earlier point of the *imported* chat) is **deferred**: the data exists (`~/.claude/file-history//` blobs + `file-history-snapshot` transcript entries with `trackedFileBackups`), so it's *possible*, but it means reading Claude's private, undocumented snapshot format and translating it to t3's git-checkpoint model — fragile against Claude updates, against the fork's "don't depend on churny internals" principle. Because native treats the two as separate actions, doing conversation-rewind without file-rewind is **faithful to native**, not a compromise. Revisit file-rewind only if it proves essential. **Refined 2026-06-11 → see [ADR-0002](docs/adr/0002-conversation-rewind.md):** v1 applies to **all** Threads (native + imported), behind **one Claude-CLI-style menu** ("restore conversation only" vs "also restore files"); abandoned forward messages are hidden-but-retained; the rewound prompt is pre-filled for editing; the existing destructive `thread.checkpoint.revert` is **decoupled** so code-restore no longer force-deletes the conversation; and a post-rewind "restore files to this point too" affordance covers a change of mind (git-checkpoint chats only). - **Picker contents (resolved 2026-06-04):** the `/resume` picker lists **Importable sessions** only — terminal-origin chats for this project. t3-origin chats are hidden by default (they are already Threads; a future toggle can reveal them); background sub-agent / sidechain sessions and empty stubs are always hidden; an already-imported terminal chat is shown but flagged "already in t3". Distinguishing these on disk was verified against the real bucket on 2026-06-04 (origin marker, sidechain flag, content size, and a stored title for display all present). - **Surface (resolved 2026-06-04):** two entry points, one picker — a `/resume` command in the t3 chat box (caught by t3 before it reaches Claude) and a button near "new thread" in the sidebar. - **"conversation"** — maps to both a t3 **Thread** and a **Provider session**; they are linked, not the same thing. diff --git a/docs/adr/0002-conversation-rewind.md b/docs/adr/0002-conversation-rewind.md new file mode 100644 index 000000000000..49daf1ad177a --- /dev/null +++ b/docs/adr/0002-conversation-rewind.md @@ -0,0 +1,97 @@ +# Conversation rewind (non-destructive, unified with code-restore) + +**Status:** design agreed (2026-06-11) — not yet implemented. Fork-local; see [FORK.md](../../FORK.md) and [CONTEXT.md](../../CONTEXT.md). Refines Cap 4 of [ADR-0001](./0001-cli-t3-conversation-continuity.md). + +## Context + +Users want Claude-CLI-style "rewind" (ESC-ESC → jump back to an earlier prompt and continue) inside t3, for **every** Claude-backed Thread — both terminal/CLI-imported chats and t3-native ones. + +Exploration surfaced that t3 already ships a *different* rewind: the `thread.checkpoint.revert` command + the per-message undo button. That existing revert is **destructive and code-coupled** — it restores the working tree from a git checkpoint, rolls the provider session back, **and deletes** every message/turn after the target point from the projection (`CheckpointReactor.handleRevertRequested` → `thread.reverted` → `ProjectionPipeline` row deletion). That is not what we want as the primary rewind. + +The Claude Agent SDK already supports the mechanism we need: `query` option `resumeSessionAt: ` ("resume only up to and including this message"). t3 **stores** an anchor (`resumeCursor.resumeSessionAt`) but **never passes it to the query** — `ClaudeAdapter.ts:2949-2950` builds query options with only `resume` + `sessionId`. Wiring that anchor through, *only for an intentional rewind*, is the core of this feature. + +## Decisions (resolved via grill, 2026-06-11) + +1. **Non-destructive conversation rewind.** Jump back to an earlier prompt and continue in the **same Thread / same Provider session**; the skipped-forward messages are **kept** (never deleted); the working tree is **not** touched. Mechanically: move `resumeSessionAt` back to an earlier message uuid and continue. (Matches CONTEXT.md "Rewind (resolved 2026-06-03)" and Claude Code native "Restore conversation".) +2. **Applies to all Threads** — native and imported alike (both run through `ClaudeAdapter`). +3. **One unified, Claude-CLI-style menu.** A single rewind entry point lets the user choose **"restore conversation only"** (non-destructive, default) or **"also restore my files"** (uses the existing git checkpoint). This requires **decoupling** the existing revert so file-restore no longer force-deletes the forward conversation. +4. **Abandoned forward messages disappear from the active view but are retained** (in the Claude transcript for imported chats, in t3's event log for native chats). No branch-browser in v1 — the chat stays a single readable line. +5. **Two entry points, one action.** ESC-ESC opens a rewind picker; the existing per-message undo button is **repointed** from the destructive revert to this unified menu. **Rewind targets are user prompts** (not mid-assistant messages). +6. **Pre-fill the rewound prompt for editing.** Landing back at a prompt drops its text into the composer to edit and re-send (the rewind anchors *just before* that prompt). +7. **Confirmations:** conversation-only rewind = **no** confirmation (safe, reversible, instant). "Also restore files" = **confirmation** (it overwrites the working tree). +8. **Mind-change affordance.** After a conversation-only rewind, an inline note ("your files are still at the newer state") carries an action button **"Restore files to this point too"** — runs the file-restore to the same anchor (with the file confirm). Shown **only when a git checkpoint exists** for that point (native chats; never for imported chats, which have none). File-restore is itself reversible (t3 snapshots files per turn → restore forward again), so there are **no dead ends** in either direction. + +## Out of scope (v1) + +- **File-rewind for imported chats** — deferred in ADR-0001 (needs Claude's private snapshot format). Imported chats only ever get conversation-only rewind. +- **Browsing abandoned branches** — they're retained but not surfaced as a tree. +- **"Redo forward"** after a rewind (rewind is backward-only in v1). +- **Editing an arbitrary mid-conversation message** — targets are prompts only. + +## Design + +### Data model — persist the provider message uuid (prerequisite) + +Conversation-rewind anchors on a **Claude message uuid**, but t3 only stores its own `MessageId`; the Claude `uuid` lives **in memory** (`ClaudeAdapter.ts:2060` `context.lastAssistantUuid = message.uuid`) and is lost on restart. `projection_thread_messages` (Migration 005) has no provider-uuid column. + +- **Migration:** add a nullable `provider_message_uuid TEXT` column to `projection_thread_messages` (+ index). Backward-safe/idempotent (follow the Migration 027 `PRAGMA table_info` pattern). +- **Write path:** thread the Claude `message.uuid` from the adapter through the message-created event into the projection, for both live turns and **imported transcript replay** (the replay already reads each `.jsonl` line, which carries `uuid`). +- The UI then maps "rewind to this prompt" → the provider uuid of the **assistant message immediately before that prompt** (so resuming "up to and including" it leaves the prompt itself re-askable). + +### The rewind flow + +New command **`thread.conversation.rewind`** (distinct from `thread.checkpoint.revert`), carrying the target t3 `messageId` (resolved server-side to the anchor uuid + turn count). Its reactor: + +1. Sets the Thread's `resumeCursor.resumeSessionAt` to the anchor uuid (and stops auto-advancing it on this turn — today `updateResumeCursor`, `ClaudeAdapter.ts:1110-1128`, sets it to the latest assistant uuid on every continue; rewind must override that for the next start). +2. Marks the forward turns/messages **abandoned** in the projection (a flag, **not** a delete) so the active timeline hides them while the event log / transcript retain them. Emits an event the `ProjectionPipeline` applies as a hide, not a row-deletion. +3. Does **not** capture or restore any checkpoint; the working tree is untouched. + +On the next turn, **`ClaudeAdapter` passes `resumeSessionAt` into the query options** (`ClaudeAdapter.ts:2949-2950`) — *only* when the cursor was set by an intentional rewind, not on ordinary continues. Claude then resumes from the anchor; the new (edited) prompt becomes the next turn in the same session. + +### "Also restore files" + the decoupling + +Split the existing bundled revert into two independent capabilities: + +- **Conversation truncation** → replaced by the non-destructive rewind above (hide, don't delete). +- **File restore** → a standalone "restore working tree to turn N" that calls `CheckpointStore.restoreCheckpoint` **only** (no message deletion, no provider rollback of the *displayed* history beyond what the rewind already did). + +The unified menu's **"also restore files"** = `thread.conversation.rewind` + file-restore to the same turn, in one confirmed action. The post-rewind **"Restore files to this point too"** button = the file-restore alone, applied to the earlier rewind anchor. + +`CheckpointReactor.handleRevertRequested` (`apps/server/.../CheckpointReactor.ts:610-738`) and the `thread.reverted` projection deletion (`ProjectionPipeline.ts:743`) are refactored so the destructive path is no longer the only way to restore code. Keep edits to these upstream files minimal (FORK.md). + +### UI (`apps/web`) + +- **ESC-ESC** in `ChatView` opens a rewind picker listing the Thread's prior **user prompts**. +- The per-user-message undo button (`MessagesTimeline.tsx:97,410-413`) is **repointed** from `onRevertUserMessage`→destructive revert to the unified rewind menu. +- Menu: **Restore conversation only** (default, no confirm) · **Also restore files** (confirm; shown only when a checkpoint exists). +- On selecting a point: pre-fill that prompt's text into the composer; the abandoned forward messages drop out of the timeline. +- After a conversation-only rewind that left files ahead: a small inline note with the **"Restore files to this point too"** action (native + checkpoint present only). + +### Imported vs native — source of truth + +- **Imported chats:** the Claude transcript `.jsonl` is the source of truth; the projection is a display cache (already re-derived via the replay path, display-capped per the S1 work). "Retained" is automatic — the transcript is never written by a rewind. +- **Native chats:** t3's event log is the source of truth; "retained" means the abandoned turns' events stay; only the projection hides them. + +Both share the single "mark abandoned, don't delete" projection mechanism. + +## Edge cases + +- **Archived/deleted Thread:** rewind only applies to active Threads (consistent with the resume picker, which already excludes archived/deleted — see `importableSessions.ts`). +- **Rewind, then rewind again / forward:** anchors are message uuids; a later rewind just moves the anchor again. No "redo forward" UI in v1, but file-restore can still move the tree forward (checkpoints per turn). +- **Imported chat, no checkpoints:** "also restore files" / the follow-up button are never offered (guarded on checkpoint existence). +- **Files-out-of-step after conversation-only rewind:** expected and surfaced via the inline note (decision 8); never blocked. + +## Implementation outline (touchpoints) + +- **Migration:** `0NN_ProjectionThreadMessageProviderUuid.ts` — add `provider_message_uuid` + index (Migration 027 pattern). +- **Adapter (`ClaudeAdapter.ts`, upstream — minimal):** pass `resumeSessionAt` into query options for intentional rewind (`:2949-2950`); ensure the message uuid reaches the projection; gate auto-advance of the cursor on rewind. +- **Contracts:** new `thread.conversation.rewind` command + a standalone file-restore command; extend the message type with the provider uuid; new RPC entries via the existing dispatch union (`rpc.ts`, `ws.ts:763`). +- **Orchestration:** decider case + reactor for the non-destructive rewind (mark-abandoned, set anchor); decouple `CheckpointReactor`/`ProjectionPipeline` so file-restore ≠ message-deletion. +- **Web (`ChatView.tsx`, `MessagesTimeline.tsx`):** ESC-ESC picker, repoint the per-message button to the unified menu, prompt pre-fill, the post-rewind inline note + "restore files too" action. + +## Verification + +- **Unit (`vitest`/`it.effect`):** rewind sets the anchor and hides (not deletes) forward turns; `resumeSessionAt` flows into query options only on intentional rewind; file-restore is independent of conversation truncation; the provider uuid is persisted on both live and replayed messages. +- **Migration test:** column added idempotently; existing rows get `NULL`. +- **End-to-end (dev runtime, `CI=1 mise exec -- bun dev`):** on a native chat — rewind a prompt, confirm files untouched + forward messages hidden + edited prompt continues the same session; then "restore files to this point" and confirm the tree moves and is reversible. On an imported/CLI chat — rewind works with no file options offered. +- **Live click-through** before declaring done (auto-deploy is currently OFF; deploy manually). From 2c0c76f4bb1bbc2a155755b06e1460b6bf9dd38e Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Wed, 10 Jun 2026 22:07:49 -0400 Subject: [PATCH 28/40] feat(rewind): freeze conversation-rewind contracts + decider scaffolding (Phase 0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0002 Phase 0 — the shared contract surface every later stream builds on. - Extend ThreadMessageSentPayload + the three message commands (assistant.delta/complete, user.record) with optional providerMessageUuid: the Claude message uuid that the non-destructive rewind anchors on. - New client commands: thread.conversation.rewind (carries the target t3 messageId) and thread.files.restore (carries turnCount) — the decoupled conversation-only vs. file-restore halves of the unified rewind menu. Added to both dispatchable + client command unions. - New internal events: thread.conversation-rewind-requested, thread.conversation-rewound, thread.files-restore-requested — added to both the OrchestrationEventType literals and the OrchestrationEvent union. - Decider: trivial command->event mappings for the two new commands (behavior is WS-2); thread providerMessageUuid through the three message cases into the message-sent payload. The decider's `satisfies never` keeps it exhaustive. - Carry the turn-final assistant uuid on TurnCompletedPayload (assistantMessageUuid) and add the shared WS-1<->WS-2 marker rewindPending?: boolean to the adapter-local ClaudeResumeState (round-trips opaquely via resume_cursor_json, which is Schema.Unknown). Co-Authored-By: Claude Opus 4.8 --- apps/server/src/orchestration/decider.ts | 59 ++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 11 +++ packages/contracts/src/orchestration.ts | 94 +++++++++++++++++++ packages/contracts/src/providerRuntime.ts | 4 + 4 files changed, 168 insertions(+) diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 0ccdd7b21ffe..9895a1cfc95a 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -560,6 +560,56 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + // Non-destructive conversation rewind (ADR-0002). Scaffold only: the decider + // records the request; the rewind reactor (WS-2) resolves the anchor, marks + // forward rows abandoned, and emits `thread.conversation-rewound`. + case "thread.conversation.rewind": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.conversation-rewind-requested", + payload: { + threadId: command.threadId, + messageId: command.messageId, + createdAt: command.createdAt, + }, + }; + } + + // Standalone file-restore (ADR-0002). Scaffold only: the file-restore reactor + // (WS-2) consumes this and calls CheckpointStore.restoreCheckpoint, with no + // conversation truncation. + case "thread.files.restore": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.files-restore-requested", + payload: { + threadId: command.threadId, + turnCount: command.turnCount, + createdAt: command.createdAt, + }, + }; + } + case "thread.session.stop": { yield* requireThread({ readModel, @@ -622,6 +672,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" messageId: command.messageId, role: "assistant", text: command.delta, + ...(command.providerMessageUuid !== undefined + ? { providerMessageUuid: command.providerMessageUuid } + : {}), turnId: command.turnId ?? null, streaming: true, createdAt: command.createdAt, @@ -649,6 +702,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" messageId: command.messageId, role: "assistant", text: "", + ...(command.providerMessageUuid !== undefined + ? { providerMessageUuid: command.providerMessageUuid } + : {}), turnId: command.turnId ?? null, streaming: false, createdAt: command.createdAt, @@ -676,6 +732,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" messageId: command.messageId, role: "user", text: command.text, + ...(command.providerMessageUuid !== undefined + ? { providerMessageUuid: command.providerMessageUuid } + : {}), turnId: command.turnId ?? null, streaming: false, createdAt: command.createdAt, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index ff9bbfe0231d..6837f0f5aa71 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -115,6 +115,14 @@ interface ClaudeResumeState { readonly resume?: string; readonly resumeSessionAt?: string; readonly turnCount?: number; + // Shared WS-1 <-> WS-2 marker for the conversation-rewind feature (ADR-0002). + // An orchestration reactor (WS-2) sets this true when it sets the rewind + // anchor; the adapter (WS-1) reads it to decide whether to pass + // `resumeSessionAt` into the query and to skip auto-advancing the cursor, + // then clears it. Persisted opaquely inside `resume_cursor_json` + // (Schema.Unknown, raw JSON round-trip) so unknown fields survive — DEFINED + // here only; the read/set behavior is WS-1/WS-2. + readonly rewindPending?: boolean; } interface ClaudeTurnState { @@ -1598,6 +1606,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ? { totalCostUsd: result.total_cost_usd } : {}), ...(errorMessage ? { errorMessage } : {}), + // Carry the turn-final assistant uuid (the rewind anchor) so ingestion + // can stamp it onto the final assistant message (ADR-0002). + ...(context.lastAssistantUuid ? { assistantMessageUuid: context.lastAssistantUuid } : {}), }, providerRefs: nativeProviderRefs(context), }); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index d0b3bf982537..e754084430df 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -641,6 +641,34 @@ const ThreadCheckpointRevertCommand = Schema.Struct({ createdAt: IsoDateTime, }); +// Non-destructive conversation rewind (ADR-0002). Jump the session back to an +// earlier prompt and continue in the SAME thread/provider session; forward +// messages are HIDDEN (marked abandoned), never deleted, and the working tree +// is untouched. Distinct from `thread.checkpoint.revert`, which is destructive +// and also restores files. The reactor resolves `messageId` to the anchor uuid +// + turn count server-side. REACTOR BEHAVIOR is a later stream (WS-2); this +// only freezes the command shape. +const ThreadConversationRewindCommand = Schema.Struct({ + type: Schema.Literal("thread.conversation.rewind"), + commandId: CommandId, + threadId: ThreadId, + messageId: MessageId, + createdAt: IsoDateTime, +}); + +// Standalone "restore the working tree to turn N" — the file half of the old +// bundled revert, decoupled (ADR-0002) so "also restore files" / the post-rewind +// "restore files to this point too" action can move the tree WITHOUT truncating +// the conversation. Calls CheckpointStore.restoreCheckpoint only. REACTOR +// BEHAVIOR is a later stream (WS-2). +const ThreadFilesRestoreCommand = Schema.Struct({ + type: Schema.Literal("thread.files.restore"), + commandId: CommandId, + threadId: ThreadId, + turnCount: NonNegativeInt, + createdAt: IsoDateTime, +}); + const ThreadSessionStopCommand = Schema.Struct({ type: Schema.Literal("thread.session.stop"), commandId: CommandId, @@ -664,6 +692,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadApprovalRespondCommand, ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, + ThreadConversationRewindCommand, + ThreadFilesRestoreCommand, ThreadSessionStopCommand, ]); export type DispatchableClientOrchestrationCommand = @@ -685,6 +715,8 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadApprovalRespondCommand, ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, + ThreadConversationRewindCommand, + ThreadFilesRestoreCommand, ThreadSessionStopCommand, ]); export type ClientOrchestrationCommand = typeof ClientOrchestrationCommand.Type; @@ -704,6 +736,9 @@ const ThreadMessageAssistantDeltaCommand = Schema.Struct({ messageId: MessageId, delta: Schema.String, turnId: Schema.optional(TurnId), + // Claude provider message uuid — the conversation-rewind anchor. Optional: + // live streaming deltas don't yet know the turn-final uuid; replay carries it. + providerMessageUuid: Schema.optional(Schema.String), createdAt: IsoDateTime, }); @@ -713,6 +748,9 @@ const ThreadMessageAssistantCompleteCommand = Schema.Struct({ threadId: ThreadId, messageId: MessageId, turnId: Schema.optional(TurnId), + // Turn-final Claude message uuid (the rewind anchor) — stamped here for live + // turns, where the uuid is only known once the turn completes. + providerMessageUuid: Schema.optional(Schema.String), createdAt: IsoDateTime, }); @@ -726,6 +764,8 @@ const ThreadMessageUserRecordCommand = Schema.Struct({ messageId: MessageId, text: Schema.String, turnId: Schema.optional(TurnId), + // Claude transcript `uuid` for this replayed user message (rewind anchor). + providerMessageUuid: Schema.optional(Schema.String), createdAt: IsoDateTime, }); @@ -803,6 +843,9 @@ export const OrchestrationEventType = Schema.Literals([ "thread.user-input-response-requested", "thread.checkpoint-revert-requested", "thread.reverted", + "thread.conversation-rewind-requested", + "thread.conversation-rewound", + "thread.files-restore-requested", "thread.session-stop-requested", "thread.session-set", "thread.proposed-plan-upserted", @@ -904,6 +947,10 @@ export const ThreadMessageSentPayload = Schema.Struct({ role: OrchestrationMessageRole, text: Schema.String, attachments: Schema.optional(Schema.Array(ChatAttachment)), + // Claude provider message uuid — the conversation-rewind anchor (ADR-0002). + // Optional: present for assistant messages (stamped on completion) and + // replayed messages; absent for live streaming deltas and legacy events. + providerMessageUuid: Schema.optional(Schema.String), turnId: Schema.NullOr(TurnId), streaming: Schema.Boolean, createdAt: IsoDateTime, @@ -954,6 +1001,38 @@ export const ThreadRevertedPayload = Schema.Struct({ turnCount: NonNegativeInt, }); +// Non-destructive conversation rewind (ADR-0002). The decider emits the +// `-requested` event from the client command; the rewind reactor (WS-2) +// consumes it, sets the resume anchor + marks forward rows abandoned, and emits +// the `-rewound` event the ProjectionPipeline applies as a hide (not a delete). +export const ThreadConversationRewindRequestedPayload = Schema.Struct({ + threadId: ThreadId, + messageId: MessageId, + createdAt: IsoDateTime, +}); + +export const ThreadConversationRewoundPayload = Schema.Struct({ + threadId: ThreadId, + // The user-selected target message (the prompt being rewound to). + messageId: MessageId, + // The Claude message uuid the next turn resumes "up to and including". + // Optional: a native thread without a persisted anchor uuid may rewind by + // turn count alone. + anchorProviderMessageUuid: Schema.optional(Schema.String), + // Number of turns at/after the anchor that were marked abandoned. + turnCount: NonNegativeInt, +}); + +// Standalone file-restore (ADR-0002): restore the working tree to turn N +// WITHOUT truncating the conversation. The decider emits this `-requested` +// event; the file-restore reactor (WS-2) consumes it and calls +// CheckpointStore.restoreCheckpoint only. +export const ThreadFilesRestoreRequestedPayload = Schema.Struct({ + threadId: ThreadId, + turnCount: NonNegativeInt, + createdAt: IsoDateTime, +}); + export const ThreadSessionStopRequestedPayload = Schema.Struct({ threadId: ThreadId, createdAt: IsoDateTime, @@ -1092,6 +1171,21 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.reverted"), payload: ThreadRevertedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.conversation-rewind-requested"), + payload: ThreadConversationRewindRequestedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.conversation-rewound"), + payload: ThreadConversationRewoundPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.files-restore-requested"), + payload: ThreadFilesRestoreRequestedPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.session-stop-requested"), diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 5032dc4eb415..4a1ae5b61fb6 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -365,6 +365,10 @@ const TurnCompletedPayload = Schema.Struct({ modelUsage: Schema.optional(UnknownRecordSchema), totalCostUsd: Schema.optional(Schema.Number), errorMessage: Schema.optional(TrimmedNonEmptyStringSchema), + // Turn-final Claude assistant message uuid (the conversation-rewind anchor, + // ADR-0002). Only known once the turn completes, so it rides the completion + // event; ingestion stamps it onto the final `assistant.complete` command. + assistantMessageUuid: Schema.optional(TrimmedNonEmptyStringSchema), }); export type TurnCompletedPayload = typeof TurnCompletedPayload.Type; From 25581ee5df33339aaeb069e430d749d4d78c524f Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Wed, 10 Jun 2026 22:08:02 -0400 Subject: [PATCH 29/40] feat(rewind): persist the provider message uuid through the write path (Phase 0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0002 Phase 0 data layer — the conversation-rewind anchor must survive a restart, so persist the Claude message uuid into the projection. - Migration 032: nullable provider_message_uuid TEXT (+index) on projection_thread_messages, and abandoned INTEGER NOT NULL DEFAULT 0 (+index) on BOTH projection_thread_messages AND projection_turns. Idempotent PRAGMA table_info guards (the Migration 027 pattern); registered in Migrations.ts. The abandoned column is added with a safe default only — the read-path filter and the write that sets it are WS-2. Non-destructive: no row is ever deleted. - Repo (Services + Layers): providerMessageUuid on the schema + upsert, persisted with COALESCE(excluded, existing) so the row born uuid-null on a streaming assistant.delta gets the real uuid on assistant.complete and a later uuid-less upsert never nulls it (mirrors attachments_json). - Live write path: ClaudeAdapter carries context.lastAssistantUuid on the turn.completed event; ProviderRuntimeIngestion stamps it onto the final assistant.complete command — only the LAST assistant message of the turn (the turn-final = the only row a rewind anchors to). - Replay write path: transcriptReplay stamps each .jsonl line's uuid onto the user.record + assistant.delta commands. - ProjectionPipeline persists providerMessageUuid from the message-sent event. Tests: migration idempotency + legacy-row NULL/0; repo COALESCE non-null-wins/never-nulled; decider uuid flow-through + the two new command mappings; replay carries the transcript uuid. Co-Authored-By: Claude Opus 4.8 --- .../Layers/ProjectionPipeline.ts | 6 + .../Layers/ProviderRuntimeIngestion.ts | 24 +++- .../decider.resumeReplay.test.ts | 79 ++++++++++++ .../Layers/ProjectionThreadMessages.test.ts | 69 ++++++++++ .../Layers/ProjectionThreadMessages.ts | 26 ++++ apps/server/src/persistence/Migrations.ts | 2 + ...rojectionThreadMessageProviderUuid.test.ts | 118 ++++++++++++++++++ ...032_ProjectionThreadMessageProviderUuid.ts | 66 ++++++++++ .../Services/ProjectionThreadMessages.ts | 3 + .../src/resume/transcriptReplay.test.ts | 4 + apps/server/src/resume/transcriptReplay.ts | 5 + 11 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/persistence/Migrations/032_ProjectionThreadMessageProviderUuid.test.ts create mode 100644 apps/server/src/persistence/Migrations/032_ProjectionThreadMessageProviderUuid.ts diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index c201e1d9f9eb..5da060c5bf04 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -817,6 +817,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti role: event.payload.role, text: nextText, ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}), + // Conversation-rewind anchor (ADR-0002). Persisted via COALESCE in the + // repo, so a later uuid-bearing event (assistant.complete) sets it and + // an earlier uuid-less streaming delta never nulls it out. + ...(event.payload.providerMessageUuid !== undefined + ? { providerMessageUuid: event.payload.providerMessageUuid } + : {}), isStreaming: event.payload.streaming, createdAt: previousMessage?.createdAt ?? event.payload.createdAt, updatedAt: event.payload.updatedAt, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 59787e0b5451..9821f9dc6321 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -905,6 +905,10 @@ const make = Effect.gen(function* () { finalDeltaCommandTag: string; fallbackText?: string; hasProjectedMessage?: boolean; + // Turn-final Claude assistant message uuid (the conversation-rewind anchor, + // ADR-0002). Stamped onto the completion command; the projection persists it + // via COALESCE so the row born uuid-null on the streaming delta gets it now. + providerMessageUuid?: string; }) => Effect.gen(function* () { const bufferedText = yield* takeBufferedAssistantText(input.messageId); @@ -935,6 +939,9 @@ const make = Effect.gen(function* () { threadId: input.threadId, messageId: input.messageId, ...(input.turnId ? { turnId: input.turnId } : {}), + ...(input.providerMessageUuid !== undefined + ? { providerMessageUuid: input.providerMessageUuid } + : {}), createdAt: input.createdAt, }); } @@ -1507,7 +1514,18 @@ const make = Effect.gen(function* () { const proposedPlans = detailedThread?.proposedPlans ?? []; const turnId = toTurnId(event.turnId); if (turnId) { - const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId); + const assistantMessageIdSet = yield* getAssistantMessageIdsForTurn(thread.id, turnId); + const assistantMessageIds = [...assistantMessageIdSet]; + // The turn-final assistant uuid (the conversation-rewind anchor, + // ADR-0002) is singular; stamp it only on the LAST assistant message of + // the turn (insertion order) — that is the row a rewind ever anchors + // to. Mid-turn tool-split segments correctly stay uuid-null. + const assistantMessageUuid = + event.type === "turn.completed" ? event.payload.assistantMessageUuid : undefined; + const lastAssistantMessageId = + assistantMessageIds.length > 0 + ? assistantMessageIds[assistantMessageIds.length - 1] + : undefined; yield* Effect.forEach( assistantMessageIds, (assistantMessageId) => @@ -1520,6 +1538,10 @@ const make = Effect.gen(function* () { commandTag: "assistant-complete-finalize", finalDeltaCommandTag: "assistant-delta-finalize-fallback", hasProjectedMessage: findMessageById(messages, assistantMessageId) !== undefined, + ...(assistantMessageUuid !== undefined && + assistantMessageId === lastAssistantMessageId + ? { providerMessageUuid: assistantMessageUuid } + : {}), }), { concurrency: 1 }, ).pipe(Effect.asVoid); diff --git a/apps/server/src/orchestration/decider.resumeReplay.test.ts b/apps/server/src/orchestration/decider.resumeReplay.test.ts index e360a855e584..8e9da14ff236 100644 --- a/apps/server/src/orchestration/decider.resumeReplay.test.ts +++ b/apps/server/src/orchestration/decider.resumeReplay.test.ts @@ -105,4 +105,83 @@ it.layer(NodeServices.layer)("decider resume-replay", (it) => { } }), ); + + // ADR-0002: the conversation-rewind anchor uuid must flow command -> payload + // for the assistant completion (the live write path stamps it here). + it.effect("threads providerMessageUuid through assistant.complete to message-sent", () => + Effect.gen(function* () { + const readModel = yield* seedThread; + + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.message.assistant.complete", + commandId: asCommandId("cmd-assistant-complete"), + threadId: ThreadId.make("thread-replay"), + messageId: MessageId.make("msg-replay-asst"), + providerMessageUuid: "claude-uuid-abc", + createdAt: now, + }, + readModel, + }); + + const events = Array.isArray(result) ? result : [result]; + const event = events[0]; + if (event?.type === "thread.message-sent") { + expect(event.payload.role).toBe("assistant"); + expect(event.payload.providerMessageUuid).toBe("claude-uuid-abc"); + } else { + throw new Error(`expected a thread.message-sent event, got ${event?.type}`); + } + }), + ); + + // ADR-0002 scaffolding: the two new client commands decode to their requested + // events so the union stays exhaustive and Phase 1 builds on frozen types. + it.effect("maps thread.conversation.rewind to a conversation-rewind-requested event", () => + Effect.gen(function* () { + const readModel = yield* seedThread; + + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.conversation.rewind", + commandId: asCommandId("cmd-rewind"), + threadId: ThreadId.make("thread-replay"), + messageId: MessageId.make("msg-replay-target"), + createdAt: now, + }, + readModel, + }); + + const events = Array.isArray(result) ? result : [result]; + const event = events[0]; + expect(event?.type).toBe("thread.conversation-rewind-requested"); + if (event?.type === "thread.conversation-rewind-requested") { + expect(event.payload.messageId).toBe("msg-replay-target"); + } + }), + ); + + it.effect("maps thread.files.restore to a files-restore-requested event", () => + Effect.gen(function* () { + const readModel = yield* seedThread; + + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.files.restore", + commandId: asCommandId("cmd-files-restore"), + threadId: ThreadId.make("thread-replay"), + turnCount: 3, + createdAt: now, + }, + readModel, + }); + + const events = Array.isArray(result) ? result : [result]; + const event = events[0]; + expect(event?.type).toBe("thread.files-restore-requested"); + if (event?.type === "thread.files-restore-requested") { + expect(event.payload.turnCount).toBe(3); + } + }), + ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index b1f394a9e577..4595ced1066e 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -111,4 +111,73 @@ layer("ProjectionThreadMessageRepository", (it) => { assert.deepEqual(rows[0]?.attachments, []); }), ); + + // ADR-0002 conversation-rewind anchor. The live write path creates the row on + // a streaming `assistant.delta` (uuid-null), then stamps the turn-final uuid on + // `assistant.complete`. COALESCE must let the non-null uuid win and never let a + // subsequent uuid-less upsert null it back out. + it.effect("persists providerMessageUuid via COALESCE (non-null wins, never nulled)", () => + Effect.gen(function* () { + const repository = yield* ProjectionThreadMessageRepository; + const threadId = ThreadId.make("thread-uuid-anchor"); + const messageId = MessageId.make("message-uuid-anchor"); + const createdAt = "2026-02-28T20:00:00.000Z"; + + // Streaming delta: row born with no anchor uuid. + yield* repository.upsert({ + messageId, + threadId, + turnId: null, + role: "assistant", + text: "partial", + isStreaming: true, + createdAt, + updatedAt: "2026-02-28T20:00:01.000Z", + }); + + let row = yield* repository.getByMessageId({ messageId }); + assert.equal(row._tag, "Some"); + if (row._tag === "Some") { + assert.equal(row.value.providerMessageUuid, undefined); + } + + // Completion stamps the turn-final uuid. + yield* repository.upsert({ + messageId, + threadId, + turnId: null, + role: "assistant", + text: "", + providerMessageUuid: "claude-uuid-123", + isStreaming: false, + createdAt, + updatedAt: "2026-02-28T20:00:02.000Z", + }); + + row = yield* repository.getByMessageId({ messageId }); + assert.equal(row._tag, "Some"); + if (row._tag === "Some") { + assert.equal(row.value.providerMessageUuid, "claude-uuid-123"); + } + + // A later uuid-less upsert must NOT clear the persisted anchor. + yield* repository.upsert({ + messageId, + threadId, + turnId: null, + role: "assistant", + text: "edited", + isStreaming: false, + createdAt, + updatedAt: "2026-02-28T20:00:03.000Z", + }); + + row = yield* repository.getByMessageId({ messageId }); + assert.equal(row._tag, "Some"); + if (row._tag === "Some") { + assert.equal(row.value.providerMessageUuid, "claude-uuid-123"); + assert.equal(row.value.text, "edited"); + } + }), + ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 719191668869..7d81346ca40c 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -21,6 +21,7 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( Struct.assign({ isStreaming: Schema.Number, attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), + providerMessageUuid: Schema.NullOr(Schema.String), }), ); @@ -37,6 +38,9 @@ function toProjectionThreadMessage( createdAt: row.createdAt, updatedAt: row.updatedAt, ...(row.attachments !== null ? { attachments: row.attachments } : {}), + ...(row.providerMessageUuid !== null + ? { providerMessageUuid: row.providerMessageUuid } + : {}), }; } @@ -48,6 +52,13 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { execute: (row) => { const nextAttachmentsJson = row.attachments !== undefined ? JSON.stringify(row.attachments) : null; + // Nullable anchor uuid. The streaming `assistant.delta` writes this row + // first with a null uuid; the later `assistant.complete` carries the real + // (turn-final) uuid. COALESCE makes a non-null value win and never lets a + // subsequent null overwrite it — mirroring attachments_json above so the + // last non-null = the rewind anchor. + const nextProviderMessageUuid = + row.providerMessageUuid !== undefined ? row.providerMessageUuid : null; return sql` INSERT INTO projection_thread_messages ( message_id, @@ -56,6 +67,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { role, text, attachments_json, + provider_message_uuid, is_streaming, created_at, updated_at @@ -74,6 +86,14 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { WHERE message_id = ${row.messageId} ) ), + COALESCE( + ${nextProviderMessageUuid}, + ( + SELECT provider_message_uuid + FROM projection_thread_messages + WHERE message_id = ${row.messageId} + ) + ), ${row.isStreaming ? 1 : 0}, ${row.createdAt}, ${row.updatedAt} @@ -88,6 +108,10 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { excluded.attachments_json, projection_thread_messages.attachments_json ), + provider_message_uuid = COALESCE( + excluded.provider_message_uuid, + projection_thread_messages.provider_message_uuid + ), is_streaming = excluded.is_streaming, created_at = excluded.created_at, updated_at = excluded.updated_at @@ -107,6 +131,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { role, text, attachments_json AS "attachments", + provider_message_uuid AS "providerMessageUuid", is_streaming AS "isStreaming", created_at AS "createdAt", updated_at AS "updatedAt" @@ -128,6 +153,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { role, text, attachments_json AS "attachments", + provider_message_uuid AS "providerMessageUuid", is_streaming AS "isStreaming", created_at AS "createdAt", updated_at AS "updatedAt" diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 9524deae8d8e..3d5c32a6557e 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -44,6 +44,7 @@ import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts"; import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; +import Migration0032 from "./Migrations/032_ProjectionThreadMessageProviderUuid.ts"; /** * Migration loader with all migrations defined inline. @@ -87,6 +88,7 @@ export const migrationEntries = [ [29, "ProjectionThreadDetailOrderingIndexes", Migration0029], [30, "ProjectionThreadShellArchiveIndexes", Migration0030], [31, "AuthAuthorizationScopes", Migration0031], + [32, "ProjectionThreadMessageProviderUuid", Migration0032], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/032_ProjectionThreadMessageProviderUuid.test.ts b/apps/server/src/persistence/Migrations/032_ProjectionThreadMessageProviderUuid.test.ts new file mode 100644 index 000000000000..6efadfcda831 --- /dev/null +++ b/apps/server/src/persistence/Migrations/032_ProjectionThreadMessageProviderUuid.test.ts @@ -0,0 +1,118 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("032_ProjectionThreadMessageProviderUuid", (it) => { + it.effect( + "adds provider_message_uuid + abandoned columns; existing rows get NULL / 0; re-run is idempotent", + () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Migrate up to just before 032, then seed pre-existing rows. + yield* runMigrations({ toMigrationInclusive: 31 }); + + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + is_streaming, + created_at, + updated_at + ) + VALUES ( + 'msg-legacy', + 'thread-legacy', + NULL, + 'assistant', + 'legacy text', + 0, + '2026-05-29T00:00:00.000Z', + '2026-05-29T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + state, + requested_at, + checkpoint_files_json + ) + VALUES ( + 'thread-legacy', + 'turn-legacy', + 'completed', + '2026-05-29T00:00:00.000Z', + '[]' + ) + `; + + yield* runMigrations({ toMigrationInclusive: 32 }); + + const messageColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_thread_messages) + `; + const turnColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_turns) + `; + + assert.isTrue(messageColumns.some((c) => c.name === "provider_message_uuid")); + assert.isTrue(messageColumns.some((c) => c.name === "abandoned")); + assert.isTrue(turnColumns.some((c) => c.name === "abandoned")); + + // Existing rows: uuid NULL, abandoned defaulted to 0. + const messageRow = yield* sql<{ + readonly providerMessageUuid: string | null; + readonly abandoned: number; + }>` + SELECT + provider_message_uuid AS "providerMessageUuid", + abandoned + FROM projection_thread_messages + WHERE message_id = 'msg-legacy' + `; + assert.equal(messageRow[0]?.providerMessageUuid, null); + assert.equal(messageRow[0]?.abandoned, 0); + + const turnRow = yield* sql<{ readonly abandoned: number }>` + SELECT abandoned FROM projection_turns WHERE turn_id = 'turn-legacy' + `; + assert.equal(turnRow[0]?.abandoned, 0); + + // Re-running the migration is a no-op (idempotent guards): schema and + // existing rows are unchanged. + yield* runMigrations({ toMigrationInclusive: 32 }); + + const messageColumnsAfter = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_thread_messages) + `; + assert.deepStrictEqual( + messageColumnsAfter.map((c) => c.name), + messageColumns.map((c) => c.name), + ); + + const messageRowAfter = yield* sql<{ + readonly providerMessageUuid: string | null; + readonly abandoned: number; + }>` + SELECT + provider_message_uuid AS "providerMessageUuid", + abandoned + FROM projection_thread_messages + WHERE message_id = 'msg-legacy' + `; + assert.equal(messageRowAfter[0]?.providerMessageUuid, null); + assert.equal(messageRowAfter[0]?.abandoned, 0); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/032_ProjectionThreadMessageProviderUuid.ts b/apps/server/src/persistence/Migrations/032_ProjectionThreadMessageProviderUuid.ts new file mode 100644 index 000000000000..7867f4eb2eb7 --- /dev/null +++ b/apps/server/src/persistence/Migrations/032_ProjectionThreadMessageProviderUuid.ts @@ -0,0 +1,66 @@ +/** + * Conversation-rewind data layer (Phase 0). + * + * Adds the columns the non-destructive rewind feature anchors on, all + * backward-safe and idempotent (the Migration 027 `PRAGMA table_info` pattern): + * + * - `projection_thread_messages.provider_message_uuid TEXT` (nullable, +index): + * the Claude message `uuid` for an assistant message. Rewind resolves "jump + * back to this prompt" to the provider uuid of the assistant message just + * before it, then resumes the SDK session "up to and including" that uuid. + * Nullable on purpose: legacy rows, user messages, and mid-turn assistant + * segments have no anchor uuid and decode as NULL. + * + * - `projection_thread_messages.abandoned INTEGER NOT NULL DEFAULT 0` and + * `projection_turns.abandoned INTEGER NOT NULL DEFAULT 0` (+indexes): the + * "hide, don't delete" flag a later stream (WS-2) flips so the active + * timeline drops the skipped-forward rows while the event log / transcript + * retain them. Phase 0 only ADDS the column with a safe default; the + * read-path filter and the write that sets it are WS-2's. + */ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const messageColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_thread_messages) + `; + if (!messageColumns.some((column) => column.name === "provider_message_uuid")) { + yield* sql` + ALTER TABLE projection_thread_messages + ADD COLUMN provider_message_uuid TEXT + `; + } + if (!messageColumns.some((column) => column.name === "abandoned")) { + yield* sql` + ALTER TABLE projection_thread_messages + ADD COLUMN abandoned INTEGER NOT NULL DEFAULT 0 + `; + } + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_thread_messages_provider_uuid + ON projection_thread_messages(provider_message_uuid) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_thread_messages_thread_abandoned + ON projection_thread_messages(thread_id, abandoned) + `; + + const turnColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_turns) + `; + if (!turnColumns.some((column) => column.name === "abandoned")) { + yield* sql` + ALTER TABLE projection_turns + ADD COLUMN abandoned INTEGER NOT NULL DEFAULT 0 + `; + } + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_turns_thread_abandoned + ON projection_turns(thread_id, abandoned) + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index d50ff3202563..faa601e59539 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -28,6 +28,9 @@ export const ProjectionThreadMessage = Schema.Struct({ role: OrchestrationMessageRole, text: Schema.String, attachments: Schema.optional(Schema.Array(ChatAttachment)), + // Claude provider message uuid — the conversation-rewind anchor. Nullable: + // user messages, mid-turn assistant segments, and legacy rows have none. + providerMessageUuid: Schema.optional(Schema.NullOr(Schema.String)), isStreaming: Schema.Boolean, createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/server/src/resume/transcriptReplay.test.ts b/apps/server/src/resume/transcriptReplay.test.ts index 700fe292583a..35609bc5c991 100644 --- a/apps/server/src/resume/transcriptReplay.test.ts +++ b/apps/server/src/resume/transcriptReplay.test.ts @@ -25,6 +25,8 @@ describe("buildReplayCommands", () => { // deterministic id + monotonic timestamp for idempotent, ordered replay expect(command.commandId).toBe(`server:resume-replay:${ctx.sessionId}:0`); expect(command.createdAt).toBe("2026-01-01T00:00:00.000Z"); + // ADR-0002: the Claude transcript uuid is the rewind anchor. + expect(command.providerMessageUuid).toBe("u1"); } }); @@ -48,6 +50,8 @@ describe("buildReplayCommands", () => { if (delta?.type === "thread.message.assistant.delta") { expect(delta.delta).toBe("done, added it"); expect(delta.messageId).toBe("resume:a1"); + // ADR-0002: the assistant message carries its rewind-anchor uuid. + expect(delta.providerMessageUuid).toBe("a1"); } if (complete?.type === "thread.message.assistant.complete") { // same messageId so complete finalizes the message delta created diff --git a/apps/server/src/resume/transcriptReplay.ts b/apps/server/src/resume/transcriptReplay.ts index 646ebd986b8b..0b4e03d5d154 100644 --- a/apps/server/src/resume/transcriptReplay.ts +++ b/apps/server/src/resume/transcriptReplay.ts @@ -171,6 +171,9 @@ export function buildReplayCommands( threadId: ctx.threadId, messageId, text: formatCommandInvocation(text) ?? text, + // Persist the Claude transcript uuid so an imported chat can be rewound + // to this prompt (ADR-0002). + providerMessageUuid: entry.uuid, createdAt, }); } else if (entry.type === "assistant") { @@ -183,6 +186,8 @@ export function buildReplayCommands( threadId: ctx.threadId, messageId, delta: text, + // The rewind anchor uuid for this assistant message (ADR-0002). + providerMessageUuid: entry.uuid, createdAt: delta.createdAt, }); const complete = take(); From 58def9963dc4263fb70bf479c06276b974f41442 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Wed, 10 Jun 2026 22:25:05 -0400 Subject: [PATCH 30/40] feat(rewind): wire resumeSessionAt into the Claude query on intentional rewind (WS-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read side of ADR-0002 conversation-rewind. The orchestration reactor sets `rewindPending: true` + a `resumeSessionAt` anchor on the persisted cursor; the adapter now consumes it: - Parse `rewindPending` in `readClaudeResumeState` so the marker survives the cursor load (was previously dropped). - Pass the SDK `resumeSessionAt` option into the query ONLY when `rewindPending` is set AND a durable `resume` session id exists (SDK requires the pairing). Ordinary continues never pass it — unchanged behavior. - Gate `updateResumeCursor`: while the rewind is pending, hold the loaded anchor instead of auto-advancing to the latest assistant uuid; the marker is cleared once a fresh assistant message lands, restoring normal auto-advance. - The marker is consumed at session start and never re-persisted into `session.resumeCursor`. SDK option name verified against @anthropic-ai/claude-agent-sdk@0.3.154 sdk.d.ts:1703-1707 (`resumeSessionAt?: string`, "resume up to and including this message uuid; use with resume"). Tests (it.effect): resumeSessionAt flows into query options only on a rewind, omitted on ordinary continue and when no resume id is present, marker not re-persisted, and auto-advance gated then restored after the next assistant message. Co-Authored-By: Claude Opus 4.8 --- .../src/provider/Layers/ClaudeAdapter.test.ts | 180 ++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 50 ++++- 2 files changed, 229 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index aad1654ecd9e..c73efe4001b2 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -2646,6 +2646,186 @@ describe("ClaudeAdapterLive", () => { ); }); + // ADR-0002 conversation-rewind (WS-1): the adapter passes the SDK + // `resumeSessionAt` option ONLY when an intentional rewind set + // `rewindPending` on the cursor. + it.effect("passes resumeSessionAt into the query only on an intentional rewind", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + yield* adapter.startSession({ + threadId: RESUME_THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + resumeCursor: { + threadId: "resume-thread-1", + resume: "550e8400-e29b-41d4-a716-446655440000", + resumeSessionAt: "assistant-99", + turnCount: 3, + // The orchestration reactor (WS-2) sets this when a rewind happens. + rewindPending: true, + }, + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.resume, "550e8400-e29b-41d4-a716-446655440000"); + assert.equal(createInput?.options.resumeSessionAt, "assistant-99"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + // The SDK requires `resumeSessionAt` be paired with `resume`; a rewind marker + // with no durable resume session id must NOT smuggle the anchor through. + it.effect("omits resumeSessionAt on a rewind when there is no resume session id", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + yield* adapter.startSession({ + threadId: RESUME_THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + resumeCursor: { + threadId: "resume-thread-1", + resumeSessionAt: "assistant-99", + turnCount: 3, + rewindPending: true, + }, + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.resume, undefined); + assert.equal(createInput?.options.resumeSessionAt, undefined); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + // The marker is consumed at session start: it never round-trips back into the + // persisted cursor, so the next ordinary continue behaves normally. + it.effect("does not re-persist the rewind marker after consuming it", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const session = yield* adapter.startSession({ + threadId: RESUME_THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + resumeCursor: { + threadId: "resume-thread-1", + resume: "550e8400-e29b-41d4-a716-446655440000", + resumeSessionAt: "assistant-99", + turnCount: 3, + rewindPending: true, + }, + runtimeMode: "full-access", + }); + + // The anchor is preserved on the cursor for the rewind turn, but the + // marker itself is gone (consumed). + assert.deepEqual(session.resumeCursor, { + threadId: RESUME_THREAD_ID, + resume: "550e8400-e29b-41d4-a716-446655440000", + resumeSessionAt: "assistant-99", + turnCount: 3, + }); + assert.equal( + (session.resumeCursor as { rewindPending?: unknown }).rewindPending, + undefined, + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + // While the rewind is pending, `updateResumeCursor` must hold the anchor + // instead of auto-advancing it; once a fresh assistant message lands, the + // marker is cleared and the cursor advances to that new uuid. + it.effect("gates auto-advance on a rewind, then advances after the next assistant message", () => { + const harness = makeHarness(); + const durableSessionId = "550e8400-e29b-41d4-a716-446655440000"; + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const drainFiber = yield* Stream.runDrain(adapter.streamEvents).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId: RESUME_THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + resumeCursor: { + threadId: RESUME_THREAD_ID, + resume: durableSessionId, + resumeSessionAt: "assistant-anchor", + turnCount: 3, + rewindPending: true, + }, + runtimeMode: "full-access", + }); + + // A durable init message fires `updateResumeCursor` before any assistant + // message; the anchor must survive (not be clobbered). + harness.query.emit({ + type: "system", + subtype: "init", + apiKeySource: "none", + claude_code_version: "test", + cwd: "/tmp/claude-adapter-test", + tools: [], + mcp_servers: [], + model: "claude-sonnet-4-5", + permissionMode: "bypassPermissions", + slash_commands: [], + output_style: "default", + skills: [], + plugins: [], + session_id: durableSessionId, + uuid: "resume-init", + } as unknown as SDKMessage); + + yield* Effect.yieldNow; + yield* Effect.yieldNow; + yield* Effect.yieldNow; + + const afterInit = yield* adapter.listSessions(); + const afterInitCursor = afterInit[0]?.resumeCursor as + | { resumeSessionAt?: string } + | undefined; + assert.equal(afterInitCursor?.resumeSessionAt, "assistant-anchor"); + + // A fresh assistant message consumes the rewind: the cursor advances. + harness.query.emit({ + type: "assistant", + session_id: durableSessionId, + uuid: "assistant-after-rewind", + parent_tool_use_id: null, + message: { + id: "assistant-message-after-rewind", + content: [{ type: "text", text: "rewound" }], + }, + } as unknown as SDKMessage); + + yield* Effect.yieldNow; + yield* Effect.yieldNow; + yield* Effect.yieldNow; + + const afterAssistant = yield* adapter.listSessions(); + const afterAssistantCursor = afterAssistant[0]?.resumeCursor as + | { resumeSessionAt?: string } + | undefined; + assert.equal(afterAssistantCursor?.resumeSessionAt, "assistant-after-rewind"); + + yield* Fiber.interrupt(drainFiber); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("preserves durable resume ids across Claude resume hooks", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 6837f0f5aa71..33de1df14d70 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -188,6 +188,11 @@ interface ClaudeSessionContext { lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined; lastAssistantUuid: string | undefined; lastThreadStartedId: string | undefined; + // ADR-0002 conversation-rewind: true only while an intentional rewind anchor + // is in flight. The query at session start consumes the anchor; until it is + // cleared, `updateResumeCursor` must NOT auto-advance the anchor to the + // latest assistant uuid (which would overwrite the rewind target). + rewindPending: boolean; stopped: boolean; } @@ -420,6 +425,7 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef sessionId?: unknown; resumeSessionAt?: unknown; turnCount?: unknown; + rewindPending?: unknown; }; const threadIdCandidate = typeof cursor.threadId === "string" ? cursor.threadId : undefined; @@ -437,6 +443,11 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef const resumeSessionAt = typeof cursor.resumeSessionAt === "string" ? cursor.resumeSessionAt : undefined; const turnCountValue = typeof cursor.turnCount === "number" ? cursor.turnCount : undefined; + // ADR-0002 conversation-rewind: the orchestration reactor (WS-2) sets this + // true alongside an anchor `resumeSessionAt` for an intentional rewind. It is + // the ONLY signal that distinguishes a rewind from an ordinary continue, so + // it must survive the cursor parse on the read side (WS-1). + const rewindPending = cursor.rewindPending === true; return { ...(threadId ? { threadId } : {}), @@ -445,6 +456,7 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef ...(turnCountValue !== undefined && Number.isInteger(turnCountValue) && turnCountValue >= 0 ? { turnCount: turnCountValue } : {}), + ...(rewindPending ? { rewindPending: true } : {}), }; } @@ -1121,10 +1133,25 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const threadId = context.session.threadId; if (!threadId) return; + // ADR-0002 conversation-rewind: while a rewind is pending, hold the anchor + // that was loaded at session start instead of auto-advancing to the latest + // assistant uuid — otherwise the next cursor update would clobber the + // rewind target before the start query has resumed from it. The query at + // session start has already consumed the anchor; this just keeps the + // persisted cursor stable for the duration of the rewind turn. + const existingResumeSessionAt = + typeof (context.session.resumeCursor as { resumeSessionAt?: unknown } | undefined) + ?.resumeSessionAt === "string" + ? (context.session.resumeCursor as { resumeSessionAt: string }).resumeSessionAt + : undefined; + const resumeSessionAt = context.rewindPending + ? existingResumeSessionAt + : context.lastAssistantUuid; + const resumeCursor = { threadId, ...(context.resumeSessionId ? { resume: context.resumeSessionId } : {}), - ...(context.lastAssistantUuid ? { resumeSessionAt: context.lastAssistantUuid } : {}), + ...(resumeSessionAt ? { resumeSessionAt } : {}), turnCount: context.turns.length, }; @@ -2069,6 +2096,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } context.lastAssistantUuid = message.uuid; + // ADR-0002 conversation-rewind: a fresh assistant message means the rewind + // turn produced new content, so the anchor is fully consumed — let the + // cursor advance to this uuid and resume normal auto-advance from here on. + context.rewindPending = false; yield* updateResumeCursor(context); }); @@ -2939,6 +2970,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(fastMode ? { fastMode: true } : {}), ...(ultracode ? { ultracode: true } : {}), }; + // ADR-0002 conversation-rewind: pass the SDK `resumeSessionAt` option + // ("resume only up to and including this message uuid") ONLY for an + // intentional rewind — i.e. when the orchestration reactor (WS-2) set + // `rewindPending` on the cursor. On an ordinary continue we never pass it, + // so resume picks up at the latest message as before. The SDK requires it + // be paired with `resume`, so we also gate on a durable resume session id. + const rewindAnchorUuid = + resumeState?.rewindPending === true && existingResumeSessionId !== undefined + ? resumeState.resumeSessionAt + : undefined; const queryOptions: ClaudeQueryOptions = { ...(input.cwd ? { cwd: input.cwd } : {}), ...(apiModelId ? { model: apiModelId } : {}), @@ -2958,6 +2999,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( : {}), ...(Object.keys(settings).length > 0 ? { settings } : {}), ...(existingResumeSessionId ? { resume: existingResumeSessionId } : {}), + ...(rewindAnchorUuid ? { resumeSessionAt: rewindAnchorUuid } : {}), ...(newSessionId ? { sessionId: newSessionId } : {}), includePartialMessages: true, canUseTool, @@ -3043,6 +3085,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( lastKnownTokenUsage: undefined, lastAssistantUuid: resumeState?.resumeSessionAt, lastThreadStartedId: undefined, + // ADR-0002 conversation-rewind: carry the rewind marker into the live + // context so `updateResumeCursor` holds the anchor for this turn. The + // persisted `session.resumeCursor` above intentionally OMITS + // `rewindPending` — the anchor was consumed by the query just built, so + // the next ordinary continue must behave normally. + rewindPending: resumeState?.rewindPending === true, stopped: false, }; yield* Ref.set(contextRef, context); From 0905e835390f230c4f93834d92e83b82d1e67aad Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Wed, 10 Jun 2026 22:26:25 -0400 Subject: [PATCH 31/40] feat(rewind): unified Claude-CLI-style conversation rewind UI (WS-3) Wire the web UI for the non-destructive conversation-rewind feature (ADR-0002), built against the frozen Phase-0 contracts. - New RewindMenu.tsx: a single unified menu (Restore conversation only / Also restore files) plus the ESC-ESC RewindPicker listing prior user prompts. Both entry points route through one action path. - Repoint the per-message undo button from the destructive revert to the unified rewind menu. The button now renders on EVERY user prompt (conversation-only rewind is universal); "Also restore files" is gated inside the menu on checkpoint existence. - Checkpoint gating reuses revertTurnCountByUserMessageId: presence == a checkpoint exists, and the value is the exact turnCount passed to thread.files.restore (identical to the old revert). - ESC-ESC opens the picker via a bubble-phase double-press detector that never preventDefaults a single ESC, so dialogs/model-picker/command- palette still close on a lone ESC. - Conversation-only rewind dispatches thread.conversation.rewind, pre-fills the rewound prompt into the composer, and surfaces a "files are still ahead" banner with a "Restore files to this point too" action (native + checkpoint only). Forward messages drop out server-side (abandoned hide). - The destructive onRevertToTurnCount path stays callable (orchestration keeps thread.checkpoint.revert) but is no longer wired to any button. Co-Authored-By: Claude Opus 4.8 --- apps/web/src/components/ChatView.tsx | 341 +++++++++++++++++- .../chat/MessagesTimeline.browser.tsx | 3 +- .../components/chat/MessagesTimeline.test.tsx | 3 +- .../src/components/chat/MessagesTimeline.tsx | 51 ++- apps/web/src/components/chat/RewindMenu.tsx | 185 ++++++++++ 5 files changed, 549 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/components/chat/RewindMenu.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 252e425117f7..6b21b6989d63 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -54,6 +54,7 @@ import { } from "../composer-logic"; import { useResumePickerStore } from "../resumePickerStore"; import { ResumePicker } from "./chat/ResumePicker"; +import { RewindPicker } from "./chat/RewindMenu"; import { deriveCompletionDividerBeforeEntryId, derivePendingApprovals, @@ -110,7 +111,7 @@ import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; -import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; +import { ChevronDownIcon, FileClockIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; @@ -862,6 +863,18 @@ export default function ChatView(props: ChatViewProps) { >({}); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); + // ESC-ESC rewind picker (ADR-0002). + const [rewindPickerOpen, setRewindPickerOpen] = useState(false); + // After a conversation-only rewind that left a git checkpoint behind, surface + // a "files are still ahead" note offering to restore the working tree too. + // Cleared when the user restores files, rewinds again, or sends a new turn. + const [rewindFilesAhead, setRewindFilesAhead] = useState<{ + messageId: MessageId; + turnCount: number; + } | null>(null); + // The post-rewind "restore files" handler is defined far below; the banner + // (built earlier in render) calls it through this ref to avoid TDZ ordering. + const rewindRestoreFilesRef = useRef<(messageId: MessageId) => void>(() => {}); const [respondingRequestIds, setRespondingRequestIds] = useState([]); const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< ApprovalRequestId[] @@ -1437,6 +1450,27 @@ export default function ChatView(props: ChatViewProps) { ), }); } + if (rewindFilesAhead) { + items.push({ + id: `rewind-files-ahead:${rewindFilesAhead.messageId}`, + variant: "info", + icon: , + title: "Your files are still at the newer state", + description: + "The conversation was rewound, but the working tree was left untouched. Restore your files to this point too?", + actions: ( + + ), + dismissLabel: "Dismiss files-out-of-step note", + onDismiss: () => setRewindFilesAhead(null), + }); + } if (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey) { items.push({ id: `version-mismatch:${versionMismatchDismissKey}`, @@ -1460,8 +1494,10 @@ export default function ChatView(props: ChatViewProps) { }, [ activeEnvironmentUnavailableState, handleReconnectActiveEnvironment, + isRevertingCheckpoint, navigate, reconnectingEnvironmentId, + rewindFilesAhead, showVersionMismatchBanner, versionMismatch, versionMismatchDismissKey, @@ -1817,6 +1853,51 @@ export default function ChatView(props: ChatViewProps) { return byUserMessageId; }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); + // Prior user prompts for the ESC-ESC rewind picker, most-recent-first. A + // prompt is checkpoint-gated for "also restore files" iff a revert turn count + // resolved for it (presence in revertTurnCountByUserMessageId == checkpoint + // exists; imported chats never populate it, so they only ever get + // conversation-only rewind). + const userPromptsForRewind = useMemo(() => { + const prompts: Array<{ + messageId: MessageId; + text: string; + createdAt: string; + hasCheckpoint: boolean; + }> = []; + for (const entry of timelineEntries) { + if (entry.kind !== "message" || entry.message.role !== "user") { + continue; + } + prompts.push({ + messageId: entry.message.id, + text: entry.message.text ?? "", + createdAt: entry.message.createdAt, + hasCheckpoint: revertTurnCountByUserMessageId.has(entry.message.id), + }); + } + return prompts.reverse(); + }, [timelineEntries, revertTurnCountByUserMessageId]); + + // Raw prompt text by message id — used to pre-fill the composer when a rewind + // lands back on a prompt (the user edits and re-sends the full prompt). + const promptTextByUserMessageId = useMemo(() => { + const byId = new Map(); + for (const entry of timelineEntries) { + if (entry.kind === "message" && entry.message.role === "user") { + byId.set(entry.message.id, entry.message.text ?? ""); + } + } + return byId; + }, [timelineEntries]); + + // Reset the rewind picker + "files ahead" note when switching threads so they + // never leak across conversations. + useEffect(() => { + setRewindPickerOpen(false); + setRewindFilesAhead(null); + }, [activeThreadId]); + const completionSummary = useMemo(() => { if (!latestTurnSettled) return null; if (!activeLatestTurn?.startedAt) return null; @@ -2803,6 +2884,45 @@ export default function ChatView(props: ChatViewProps) { toggleTerminalVisibility, ]); + // ESC-ESC opens the rewind picker (ADR-0002). A single ESC is left untouched + // so it still closes dialogs / the model picker / the command palette — we + // only act on the SECOND ESC within the window, and only when nothing else + // is open to consume it. This runs in the bubble phase, after dialogs have + // had their capture-phase chance to preventDefault on a closing ESC. + const lastEscapeAtRef = useRef(0); + useEffect(() => { + if (!activeThreadId) return; + const ESC_ESC_WINDOW_MS = 500; + const handler = (event: globalThis.KeyboardEvent) => { + if (event.key !== "Escape" || event.repeat) { + return; + } + // Something already handled this ESC (a dialog/menu closing), or another + // overlay is open — let it consume the keystroke, don't start a chord. + if ( + event.defaultPrevented || + useCommandPaletteStore.getState().open || + (composerRef.current?.isModelPickerOpen() ?? false) + ) { + lastEscapeAtRef.current = 0; + return; + } + const now = Date.now(); + const previous = lastEscapeAtRef.current; + lastEscapeAtRef.current = now; + if (previous !== 0 && now - previous <= ESC_ESC_WINDOW_MS) { + lastEscapeAtRef.current = 0; + if (userPromptsForRewind.length === 0) { + return; + } + event.preventDefault(); + setRewindPickerOpen(true); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [activeThreadId, composerRef, userPromptsForRewind.length]); + const onRevertToTurnCount = useCallback( async (turnCount: number) => { const api = readEnvironmentApi(environmentId); @@ -2862,6 +2982,190 @@ export default function ChatView(props: ChatViewProps) { ], ); + // ---------------------------------------------------------------------- + // Conversation rewind (ADR-0002) — non-destructive. Distinct from the + // destructive `onRevertToTurnCount` above, which stays callable but is no + // longer wired to any UI button. + // ---------------------------------------------------------------------- + + // Drop a prior prompt's text back into the composer for editing + re-send. + const prefillComposerWithPrompt = useCallback( + (messageId: MessageId) => { + const promptText = promptTextByUserMessageId.get(messageId) ?? ""; + promptRef.current = promptText; + setComposerDraftPrompt(composerDraftTarget, promptText); + composerRef.current?.resetCursorState({ + cursor: collapseExpandedComposerCursor(promptText, promptText.length), + prompt: promptText, + detectTrigger: true, + }); + window.requestAnimationFrame(() => { + composerRef.current?.focusAtEnd(); + }); + }, + [composerDraftTarget, composerRef, promptTextByUserMessageId, setComposerDraftPrompt], + ); + + // Guard shared by every rewind action. Returns null when blocked. + const resolveRewindContext = useCallback(() => { + const api = readEnvironmentApi(environmentId); + const localApi = readLocalApi(); + if (!api || !localApi || !activeThread || isRevertingCheckpoint) { + return null; + } + if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) { + setThreadError( + activeThread.id, + `Reconnect ${activeEnvironmentUnavailableLabel} before rewinding this thread.`, + ); + return null; + } + if (phase === "running" || isSendBusy || isConnecting) { + setThreadError(activeThread.id, "Interrupt the current turn before rewinding."); + return null; + } + return { api, localApi, thread: activeThread }; + }, [ + activeEnvironmentUnavailable, + activeEnvironmentUnavailableLabel, + activeThread, + environmentId, + isConnecting, + isRevertingCheckpoint, + isSendBusy, + phase, + setThreadError, + ]); + + const onRewindConversation = useCallback( + async (messageId: MessageId) => { + const ctx = resolveRewindContext(); + if (!ctx) return; + setThreadError(ctx.thread.id, null); + try { + await ctx.api.orchestration.dispatchCommand({ + type: "thread.conversation.rewind", + commandId: newCommandId(), + threadId: ctx.thread.id, + messageId, + createdAt: new Date().toISOString(), + }); + } catch (err) { + setThreadError( + ctx.thread.id, + err instanceof Error ? err.message : "Failed to rewind the conversation.", + ); + return; + } + prefillComposerWithPrompt(messageId); + // If a checkpoint exists for this point, the working tree is now ahead of + // the restored conversation — offer to restore it too via the inline note. + const turnCount = revertTurnCountByUserMessageId.get(messageId); + setRewindFilesAhead(typeof turnCount === "number" ? { messageId, turnCount } : null); + }, + [ + prefillComposerWithPrompt, + resolveRewindContext, + revertTurnCountByUserMessageId, + setThreadError, + ], + ); + + const onRestoreFilesForMessage = useCallback( + async (messageId: MessageId) => { + const turnCount = revertTurnCountByUserMessageId.get(messageId); + if (typeof turnCount !== "number") return; + const ctx = resolveRewindContext(); + if (!ctx) return; + const confirmed = await ctx.localApi.dialogs.confirm( + [ + "Restore your files to this point?", + "This overwrites the working tree with the checkpoint from this turn.", + "You can move your files forward again from a later turn.", + ].join("\n"), + ); + if (!confirmed) return; + + setIsRevertingCheckpoint(true); + setThreadError(ctx.thread.id, null); + try { + await ctx.api.orchestration.dispatchCommand({ + type: "thread.files.restore", + commandId: newCommandId(), + threadId: ctx.thread.id, + turnCount, + createdAt: new Date().toISOString(), + }); + setRewindFilesAhead(null); + } catch (err) { + setThreadError( + ctx.thread.id, + err instanceof Error ? err.message : "Failed to restore files.", + ); + } + setIsRevertingCheckpoint(false); + }, + [resolveRewindContext, revertTurnCountByUserMessageId, setThreadError], + ); + rewindRestoreFilesRef.current = onRestoreFilesForMessage; + + const onRewindConversationAndFiles = useCallback( + async (messageId: MessageId) => { + const turnCount = revertTurnCountByUserMessageId.get(messageId); + if (typeof turnCount !== "number") { + // No checkpoint — fall back to conversation-only (menu shouldn't offer + // this, but stay safe). + void onRewindConversation(messageId); + return; + } + const ctx = resolveRewindContext(); + if (!ctx) return; + const confirmed = await ctx.localApi.dialogs.confirm( + [ + "Rewind the conversation and restore your files to this point?", + "This overwrites the working tree with the checkpoint from this turn.", + "You can move your files forward again from a later turn.", + ].join("\n"), + ); + if (!confirmed) return; + + setIsRevertingCheckpoint(true); + setThreadError(ctx.thread.id, null); + try { + await ctx.api.orchestration.dispatchCommand({ + type: "thread.conversation.rewind", + commandId: newCommandId(), + threadId: ctx.thread.id, + messageId, + createdAt: new Date().toISOString(), + }); + await ctx.api.orchestration.dispatchCommand({ + type: "thread.files.restore", + commandId: newCommandId(), + threadId: ctx.thread.id, + turnCount, + createdAt: new Date().toISOString(), + }); + prefillComposerWithPrompt(messageId); + // Files are now at this point too — no "files ahead" note needed. + setRewindFilesAhead(null); + } catch (err) { + setThreadError( + ctx.thread.id, + err instanceof Error ? err.message : "Failed to rewind and restore files.", + ); + } + setIsRevertingCheckpoint(false); + }, + [ + onRewindConversation, + prefillComposerWithPrompt, + resolveRewindContext, + revertTurnCountByUserMessageId, + setThreadError, + ], + ); + const onSend = async (e?: { preventDefault: () => void }) => { e?.preventDefault(); const api = readEnvironmentApi(environmentId); @@ -3137,6 +3441,8 @@ export default function ChatView(props: ChatViewProps) { createdAt: messageCreatedAt, }); turnStartSucceeded = true; + // A fresh turn supersedes any pending "files are still ahead" note. + setRewindFilesAhead(null); })().catch(async (err: unknown) => { if ( !turnStartSucceeded && @@ -3742,18 +4048,17 @@ export default function ChatView(props: ChatViewProps) { }, [environmentId, isServerThread, navigate, onDiffPanelOpen, threadId], ); - // Both the Map and the revert handler are read from refs at call-time so - // the callback reference is fully stable and never busts context identity. - const revertTurnCountRef = useRef(revertTurnCountByUserMessageId); - revertTurnCountRef.current = revertTurnCountByUserMessageId; - const onRevertToTurnCountRef = useRef(onRevertToTurnCount); - onRevertToTurnCountRef.current = onRevertToTurnCount; - const onRevertUserMessage = useCallback((messageId: MessageId) => { - const targetTurnCount = revertTurnCountRef.current.get(messageId); - if (typeof targetTurnCount !== "number") { - return; - } - void onRevertToTurnCountRef.current(targetTurnCount); + // The rewind handlers are read from refs at call-time so the callbacks passed + // to the timeline are fully stable and never bust the LegendList row context. + const onRewindConversationRef = useRef(onRewindConversation); + onRewindConversationRef.current = onRewindConversation; + const onRewindConversationAndFilesRef = useRef(onRewindConversationAndFiles); + onRewindConversationAndFilesRef.current = onRewindConversationAndFiles; + const onRewindConversationStable = useCallback((messageId: MessageId) => { + void onRewindConversationRef.current(messageId); + }, []); + const onRewindConversationAndFilesStable = useCallback((messageId: MessageId) => { + void onRewindConversationAndFilesRef.current(messageId); }, []); // Empty state: no active thread @@ -3833,7 +4138,8 @@ export default function ChatView(props: ChatViewProps) { routeThreadKey={routeThreadKey} onOpenTurnDiff={onOpenTurnDiff} revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} - onRevertUserMessage={onRevertUserMessage} + onRewindConversation={onRewindConversationStable} + onRewindConversationAndFiles={onRewindConversationAndFilesStable} isRevertingCheckpoint={isRevertingCheckpoint} onImageExpand={onExpandTimelineImage} markdownCwd={gitCwd ?? undefined} @@ -3932,6 +4238,13 @@ export default function ChatView(props: ChatViewProps) { ); }} /> + {}, revertTurnCountByUserMessageId: new Map(), - onRevertUserMessage: () => {}, + onRewindConversation: () => {}, + onRewindConversationAndFiles: () => {}, isRevertingCheckpoint: false, onImageExpand: () => {}, activeThreadEnvironmentId: ACTIVE_THREAD_ENVIRONMENT_ID, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c2fb44c80a97..ec3f09c22ff1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -36,11 +36,11 @@ import { type LucideIcon, SquarePenIcon, TerminalIcon, - Undo2Icon, WrenchIcon, ZapIcon, } from "lucide-react"; import { Button } from "../ui/button"; +import { RewindMenu } from "./RewindMenu"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesTree } from "./ChangedFilesTree"; @@ -94,7 +94,8 @@ interface TimelineRowSharedState { workspaceRoot: string | undefined; skills: ReadonlyArray>; activeThreadEnvironmentId: EnvironmentId; - onRevertUserMessage: (messageId: MessageId) => void; + onRewindConversation: (messageId: MessageId) => void; + onRewindConversationAndFiles: (messageId: MessageId) => void; onImageExpand: (preview: ExpandedImagePreview) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; } @@ -127,7 +128,8 @@ interface MessagesTimelineProps { routeThreadKey: string; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; revertTurnCountByUserMessageId: Map; - onRevertUserMessage: (messageId: MessageId) => void; + onRewindConversation: (messageId: MessageId) => void; + onRewindConversationAndFiles: (messageId: MessageId) => void; isRevertingCheckpoint: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; activeThreadEnvironmentId: EnvironmentId; @@ -156,7 +158,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ routeThreadKey, onOpenTurnDiff, revertTurnCountByUserMessageId, - onRevertUserMessage, + onRewindConversation, + onRewindConversationAndFiles, isRevertingCheckpoint, onImageExpand, activeThreadEnvironmentId, @@ -228,7 +231,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ workspaceRoot, skills, activeThreadEnvironmentId, - onRevertUserMessage, + onRewindConversation, + onRewindConversationAndFiles, onImageExpand, onOpenTurnDiff, }), @@ -240,7 +244,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ workspaceRoot, skills, activeThreadEnvironmentId, - onRevertUserMessage, + onRewindConversation, + onRewindConversationAndFiles, onImageExpand, onOpenTurnDiff, ], @@ -338,7 +343,11 @@ function UserTimelineRow({ row }: { row: Extract @@ -386,7 +395,10 @@ function UserTimelineRow({ row }: { row: Extract )} - {canRevertAgentWork && } +

{formatTimestamp(row.message.createdAt, ctx.timestampFormat)} @@ -399,21 +411,24 @@ function UserTimelineRow({ row }: { row: Extract ctx.onRevertUserMessage(messageId)} - title="Revert to this message" - > - - + onRestoreConversation={ctx.onRewindConversation} + onRestoreConversationAndFiles={ctx.onRewindConversationAndFiles} + /> ); } diff --git a/apps/web/src/components/chat/RewindMenu.tsx b/apps/web/src/components/chat/RewindMenu.tsx new file mode 100644 index 000000000000..8004b78c0f5f --- /dev/null +++ b/apps/web/src/components/chat/RewindMenu.tsx @@ -0,0 +1,185 @@ +import { type MessageId } from "@t3tools/contracts"; +import { type ReactElement } from "react"; +import { FileClockIcon, Undo2Icon } from "lucide-react"; + +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu"; + +// --------------------------------------------------------------------------- +// Unified Claude-CLI-style rewind menu (ADR-0002). Targets are USER prompts. +// +// * "Restore conversation only" — default, NO confirmation. Non-destructive: +// jump the session back to that prompt and continue in the same thread. +// * "Also restore files" — confirmation-gated (it overwrites the working +// tree). Shown ONLY when a git checkpoint exists for that point. +// +// Both the per-message undo button and the ESC-ESC picker route through this +// single menu so the two entry points share one action path. +// --------------------------------------------------------------------------- + +export interface RewindMenuActions { + /** Conversation-only rewind. Default item; never confirms. */ + readonly onRestoreConversation: (messageId: MessageId) => void; + /** Rewind conversation + restore the working tree. Confirms before running. */ + readonly onRestoreConversationAndFiles: (messageId: MessageId) => void; +} + +interface RewindMenuProps extends RewindMenuActions { + readonly messageId: MessageId; + /** Whether a git checkpoint exists for this point (gates "Also restore files"). */ + readonly hasCheckpoint: boolean; + readonly disabled?: boolean; + /** Custom trigger element. Defaults to the small undo icon button. */ + readonly trigger?: ReactElement; + /** Called after either action is chosen (e.g. to close a host picker dialog). */ + readonly onChosen?: () => void; +} + +export function RewindMenu({ + messageId, + hasCheckpoint, + disabled, + trigger, + onRestoreConversation, + onRestoreConversationAndFiles, + onChosen, +}: RewindMenuProps) { + return ( +

+ {trigger ? ( + + ) : ( + + } + > + + + )} + + { + onRestoreConversation(messageId); + onChosen?.(); + }} + > + + Restore conversation only + + {hasCheckpoint ? ( + <> + + { + onRestoreConversationAndFiles(messageId); + onChosen?.(); + }} + > + + Also restore files + + + ) : null} + + + ); +} + +// --------------------------------------------------------------------------- +// ESC-ESC rewind picker — lists prior user prompts (most-recent-first). Each +// row is a RewindMenu trigger, so selecting one opens the same unified menu. +// --------------------------------------------------------------------------- + +export interface RewindPickerPrompt { + readonly messageId: MessageId; + readonly text: string; + readonly createdAt: string; + /** Whether a git checkpoint exists for this prompt's turn. */ + readonly hasCheckpoint: boolean; +} + +interface RewindPickerProps extends RewindMenuActions { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + /** Prior user prompts, already ordered most-recent-first. */ + readonly prompts: ReadonlyArray; +} + +const REWIND_PICKER_PREVIEW_LENGTH = 140; + +function previewPromptText(text: string): string { + const collapsed = text.replace(/\s+/g, " ").trim(); + if (collapsed.length === 0) { + return "(empty prompt)"; + } + return collapsed.length > REWIND_PICKER_PREVIEW_LENGTH + ? `${collapsed.slice(0, REWIND_PICKER_PREVIEW_LENGTH)}…` + : collapsed; +} + +export function RewindPicker({ + open, + onOpenChange, + prompts, + onRestoreConversation, + onRestoreConversationAndFiles, +}: RewindPickerProps) { + const close = () => onOpenChange(false); + + return ( + + + + Rewind to an earlier prompt + + Jump back to a previous prompt and continue from there. Restoring the conversation is + non-destructive — your files stay put unless you choose to restore them too. + + + + {prompts.length === 0 ? ( +
No earlier prompts to rewind to.
+ ) : null} + {prompts.map((prompt) => ( + +
{previewPromptText(prompt.text)}
+ {prompt.hasCheckpoint ? ( +
checkpoint available
+ ) : null} + + } + /> + ))} +
+
+
+ ); +} From 8b97936f3472c7f48d4d71e9f5af248275131fb5 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Wed, 10 Jun 2026 22:42:51 -0400 Subject: [PATCH 32/40] feat(rewind): non-destructive abandoned-flip projection + read-path filter (WS-2) ADR-0002 conversation rewind, server side. Wire the migration-032 `abandoned` columns into the repo Structs/SELECTs and add in-place UPDATE mark methods (messages by created_at, turns by requested_at) that FLIP the flag and RETURN the flipped ids for an accurate count -- they never delete a row. New ProjectionPipeline case on `thread.conversation-rewound` marks the rewound prompt and everything forward abandoned across messages and turns, mirroring the `thread.reverted` cut logic but non-destructively. Add the `WHERE abandoned = 0` read-path filter to the active-timeline message queries (getThreadDetailById + the full snapshot) and the latest-turn query, so the web stream receives already-filtered rows while the event log / transcript retain everything. Add the server-only `thread.conversation-rewind.complete` bridge command + decider case that emits the terminal `thread.conversation-rewound` event (mirrors `thread.revert.complete` -> `thread.reverted`). Co-Authored-By: Claude Opus 4.8 --- .../Layers/ProjectionPipeline.rewind.test.ts | 360 ++++++++++++++++++ .../Layers/ProjectionPipeline.ts | 37 ++ .../Layers/ProjectionSnapshotQuery.test.ts | 67 ++++ .../Layers/ProjectionSnapshotQuery.ts | 10 + .../src/orchestration/decider.rewind.test.ts | 148 +++++++ apps/server/src/orchestration/decider.ts | 29 ++ .../Layers/ProjectionThreadMessages.test.ts | 55 +++ .../Layers/ProjectionThreadMessages.ts | 55 +++ .../Layers/ProjectionTurns.abandoned.test.ts | 64 ++++ .../src/persistence/Layers/ProjectionTurns.ts | 42 +- .../Services/ProjectionThreadMessages.ts | 24 ++ .../persistence/Services/ProjectionTurns.ts | 21 + packages/contracts/src/orchestration.ts | 15 + 13 files changed, 925 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/orchestration/Layers/ProjectionPipeline.rewind.test.ts create mode 100644 apps/server/src/orchestration/decider.rewind.test.ts create mode 100644 apps/server/src/persistence/Layers/ProjectionTurns.abandoned.test.ts diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.rewind.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.rewind.test.ts new file mode 100644 index 000000000000..1dbc69881547 --- /dev/null +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.rewind.test.ts @@ -0,0 +1,360 @@ +import { + CheckpointRef, + CommandId, + CorrelationId, + EventId, + MessageId, + ProjectId, + ThreadId, + TurnId, + ProviderInstanceId, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; +import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; +import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; +import { ServerConfig } from "../../config.ts"; + +const TestLayer = OrchestrationProjectionPipelineLive.pipe( + Layer.provideMerge(OrchestrationEventStoreLive), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-projection-rewind-test-" })), + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(NodeServices.layer), +); + +it.layer(TestLayer)("OrchestrationProjectionPipeline (conversation-rewind)", (it) => { + // ADR-0002: `thread.conversation-rewound` must FLIP `abandoned` on the rewound + // prompt and everything forward of it — never delete — across messages and + // turns. Mirrors the `thread.reverted` shape but is non-destructive. + it.effect("marks forward messages abandoned without deleting any rows", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + const projectId = ProjectId.make("project-rw"); + const threadId = ThreadId.make("thread-rw"); + const t0 = "2026-05-01T00:00:00.000Z"; + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.make("evt-rw-1"), + aggregateKind: "project", + aggregateId: projectId, + occurredAt: t0, + commandId: CommandId.make("cmd-rw-1"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-rw-1"), + metadata: {}, + payload: { + projectId, + title: "Project RW", + workspaceRoot: "/tmp/project-rw", + defaultModelSelection: null, + scripts: [], + createdAt: t0, + updatedAt: t0, + }, + }); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.make("evt-rw-2"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: t0, + commandId: CommandId.make("cmd-rw-2"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-rw-2"), + metadata: {}, + payload: { + threadId, + projectId, + title: "Thread RW", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: t0, + updatedAt: t0, + }, + }); + + const sendMessage = (suffix: string, role: "user" | "assistant", createdAt: string) => + appendAndProject({ + type: "thread.message-sent", + eventId: EventId.make(`evt-rw-msg-${suffix}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: createdAt, + commandId: CommandId.make(`cmd-rw-msg-${suffix}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-rw-msg-${suffix}`), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make(`message-${suffix}`), + role, + text: suffix, + turnId: null, + streaming: false, + createdAt, + updatedAt: createdAt, + }, + }); + + yield* sendMessage("a-user", "user", "2026-05-01T00:01:00.000Z"); + yield* sendMessage("a-assistant", "assistant", "2026-05-01T00:01:01.000Z"); + yield* sendMessage("b-user", "user", "2026-05-01T00:01:02.000Z"); // rewind target + yield* sendMessage("b-assistant", "assistant", "2026-05-01T00:01:03.000Z"); + + // Rewind to the b-user prompt. + yield* appendAndProject({ + type: "thread.conversation-rewound", + eventId: EventId.make("evt-rw-rewound"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: "2026-05-01T00:02:00.000Z", + commandId: CommandId.make("cmd-rw-rewound"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-rw-rewound"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make("message-b-user"), + anchorProviderMessageUuid: "claude-uuid-a-assistant", + turnCount: 0, + }, + }); + + // Non-destructive: all four message rows still exist. + const total = yield* sql<{ + readonly count: number; + }>`SELECT COUNT(*) AS count FROM projection_thread_messages WHERE thread_id = ${threadId}`; + assert.equal(total[0]?.count, 4); + + // The rewound prompt and everything forward are flagged abandoned. + const abandoned = yield* sql<{ + readonly message_id: string; + }>` + SELECT message_id FROM projection_thread_messages + WHERE thread_id = ${threadId} AND abandoned = 1 + ORDER BY message_id ASC + `; + assert.deepEqual( + abandoned.map((row) => row.message_id), + ["message-b-assistant", "message-b-user"], + ); + + // Earlier messages stay visible. + const kept = yield* sql<{ + readonly message_id: string; + }>` + SELECT message_id FROM projection_thread_messages + WHERE thread_id = ${threadId} AND abandoned = 0 + ORDER BY message_id ASC + `; + assert.deepEqual( + kept.map((row) => row.message_id), + ["message-a-assistant", "message-a-user"], + ); + }), + ); + + // The turn-cut must align with the prompt timestamp: a turn's `requested_at` + // (the user-prompt time, carried from the pending start) at or after the + // rewound prompt flips abandoned, while the earlier turn is retained. + it.effect("marks forward turns abandoned without deleting them", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + const projectId = ProjectId.make("project-rwt"); + const threadId = ThreadId.make("thread-rwt"); + const t0 = "2026-06-01T00:00:00.000Z"; + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.make("evt-rwt-1"), + aggregateKind: "project", + aggregateId: projectId, + occurredAt: t0, + commandId: CommandId.make("cmd-rwt-1"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-rwt-1"), + metadata: {}, + payload: { + projectId, + title: "Project RWT", + workspaceRoot: "/tmp/project-rwt", + defaultModelSelection: null, + scripts: [], + createdAt: t0, + updatedAt: t0, + }, + }); + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.make("evt-rwt-2"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: t0, + commandId: CommandId.make("cmd-rwt-2"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-rwt-2"), + metadata: {}, + payload: { + threadId, + projectId, + title: "Thread RWT", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: t0, + updatedAt: t0, + }, + }); + + // Seed two turns. Each: a user prompt + a turn-start-request (same time) + + // a turn-diff-completed promoting the pending start to a concrete turn, + // which preserves `requested_at` = the prompt time. + const seedTurn = ( + suffix: string, + promptAt: string, + turnCount: number, + ) => + Effect.gen(function* () { + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.make(`evt-rwt-msg-${suffix}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: promptAt, + commandId: CommandId.make(`cmd-rwt-msg-${suffix}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-rwt-msg-${suffix}`), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make(`message-${suffix}`), + role: "user", + text: suffix, + turnId: null, + streaming: false, + createdAt: promptAt, + updatedAt: promptAt, + }, + }); + yield* appendAndProject({ + type: "thread.turn-start-requested", + eventId: EventId.make(`evt-rwt-start-${suffix}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: promptAt, + commandId: CommandId.make(`cmd-rwt-start-${suffix}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-rwt-start-${suffix}`), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make(`message-${suffix}`), + runtimeMode: "full-access", + createdAt: promptAt, + }, + }); + yield* appendAndProject({ + type: "thread.turn-diff-completed", + eventId: EventId.make(`evt-rwt-diff-${suffix}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: promptAt, + commandId: CommandId.make(`cmd-rwt-diff-${suffix}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-rwt-diff-${suffix}`), + metadata: {}, + payload: { + threadId, + turnId: TurnId.make(`turn-${suffix}`), + checkpointTurnCount: turnCount, + checkpointRef: CheckpointRef.make(`refs/t3/checkpoints/thread-rwt/turn/${turnCount}`), + status: "ready", + files: [], + assistantMessageId: MessageId.make(`assistant-${suffix}`), + completedAt: promptAt, + }, + }); + }); + + yield* seedTurn("t1", "2026-06-01T00:01:00.000Z", 1); + yield* seedTurn("t2", "2026-06-01T00:02:00.000Z", 2); // rewind target turn + + // Confirm the concrete turns carry the prompt time as requested_at. + const seeded = yield* sql<{ + readonly turn_id: string; + readonly requested_at: string; + }>` + SELECT turn_id, requested_at FROM projection_turns + WHERE thread_id = ${threadId} AND turn_id IS NOT NULL + ORDER BY turn_id ASC + `; + assert.equal(seeded.length, 2); + + yield* appendAndProject({ + type: "thread.conversation-rewound", + eventId: EventId.make("evt-rwt-rewound"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: "2026-06-01T00:03:00.000Z", + commandId: CommandId.make("cmd-rwt-rewound"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-rwt-rewound"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make("message-t2"), + turnCount: 1, + }, + }); + + // Non-destructive: both turns survive. + const all = yield* sql<{ + readonly count: number; + }>`SELECT COUNT(*) AS count FROM projection_turns WHERE thread_id = ${threadId} AND turn_id IS NOT NULL`; + assert.equal(all[0]?.count, 2); + + const abandonedTurns = yield* sql<{ + readonly turn_id: string; + }>` + SELECT turn_id FROM projection_turns + WHERE thread_id = ${threadId} AND abandoned = 1 AND turn_id IS NOT NULL + ORDER BY turn_id ASC + `; + assert.deepEqual( + abandonedTurns.map((row) => row.turn_id), + ["turn-t2"], + ); + }), + ); +}); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 5da060c5bf04..60a14d903721 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -863,6 +863,26 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + // Non-destructive conversation rewind (ADR-0002). Unlike `thread.reverted` + // (which deletes forward rows above), this flips `abandoned = 1` in place + // on the rewound prompt and every message after it, so the active timeline + // hides them while the rows are retained. The reactor (WS-2) resolves the + // anchor and emits this event; here we resolve the cut timestamp from the + // target prompt and mark forward rows. + case "thread.conversation-rewound": { + const targetMessage = yield* projectionThreadMessageRepository.getByMessageId({ + messageId: event.payload.messageId, + }); + if (Option.isNone(targetMessage)) { + return; + } + yield* projectionThreadMessageRepository.markAbandonedFromCreatedAt({ + threadId: event.payload.threadId, + fromCreatedAt: targetMessage.value.createdAt, + }); + return; + } + default: return; } @@ -1240,6 +1260,23 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + // Non-destructive conversation rewind (ADR-0002): flip `abandoned = 1` on + // every turn requested at or after the rewound prompt, mirroring the + // message flip above. Turns are never deleted here. + case "thread.conversation-rewound": { + const targetMessage = yield* projectionThreadMessageRepository.getByMessageId({ + messageId: event.payload.messageId, + }); + if (Option.isNone(targetMessage)) { + return; + } + yield* projectionTurnRepository.markAbandonedFromRequestedAt({ + threadId: event.payload.threadId, + fromRequestedAt: targetMessage.value.createdAt, + }); + return; + } + default: return; } diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 7db2a23e5ec3..ab583866836e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -1433,6 +1433,73 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { assert.equal(shellSnapshot.threads.length, 0); }), ); + + // ADR-0002 conversation-rewind read-path filter. Messages flagged + // `abandoned = 1` are retained in the table but excluded from the active + // timeline returned by getThreadDetailById. + it.effect("getThreadDetailById excludes abandoned (rewound) messages", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_thread_messages`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, title, workspace_root, default_model_selection_json, + scripts_json, created_at, updated_at, deleted_at + ) + VALUES ( + 'project-1', 'Project 1', '/tmp/project-1', + '{"provider":"codex","model":"gpt-5-codex"}', '[]', + '2026-04-01T00:00:00.000Z', '2026-04-01T00:00:01.000Z', NULL + ) + `; + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, + interaction_mode, branch, worktree_path, latest_turn_id, + latest_user_message_at, pending_approval_count, pending_user_input_count, + has_actionable_proposed_plan, created_at, updated_at, deleted_at + ) + VALUES ( + 'thread-1', 'project-1', 'Thread 1', + '{"provider":"codex","model":"gpt-5-codex"}', 'full-access', 'default', + NULL, NULL, NULL, NULL, 0, 0, 0, + '2026-04-01T00:00:02.000Z', '2026-04-01T00:00:03.000Z', NULL + ) + `; + // Two visible messages + one abandoned (rewound) message. + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, abandoned, + is_streaming, created_at, updated_at + ) + VALUES + ('m-keep-1', 'thread-1', NULL, 'user', 'kept prompt', 0, 0, + '2026-04-01T00:00:04.000Z', '2026-04-01T00:00:04.000Z'), + ('m-keep-2', 'thread-1', NULL, 'assistant', 'kept reply', 0, 0, + '2026-04-01T00:00:05.000Z', '2026-04-01T00:00:05.000Z'), + ('m-abandoned', 'thread-1', NULL, 'user', 'rewound prompt', 1, 0, + '2026-04-01T00:00:06.000Z', '2026-04-01T00:00:06.000Z') + `; + + const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1")); + assert.equal(threadDetail._tag, "Some"); + if (threadDetail._tag === "Some") { + const ids = threadDetail.value.messages.map((message) => message.id); + assert.deepEqual(ids, [asMessageId("m-keep-1"), asMessageId("m-keep-2")]); + } + + // The abandoned row is retained in the table (non-destructive). + const allRows = yield* sql<{ + readonly count: number; + }>`SELECT COUNT(*) AS count FROM projection_thread_messages WHERE thread_id = 'thread-1'`; + assert.equal(allRows[0]?.count, 3); + }), + ); }); it.effect( diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index e629d1604b3e..5c00db1f1318 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -419,6 +419,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_messages + -- Conversation-rewind (ADR-0002): the full read-model snapshot is an + -- active-view path too; hide forward-of-anchor rows here as well. + WHERE abandoned = 0 ORDER BY thread_id ASC, created_at ASC, message_id ASC `, }); @@ -783,6 +786,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { updated_at AS "updatedAt" FROM projection_thread_messages WHERE thread_id = ${threadId} + -- Conversation-rewind (ADR-0002): hide forward-of-anchor rows from the + -- active timeline. The rows are retained (event log / transcript); only + -- this read path filters them. + AND abandoned = 0 ORDER BY created_at ASC, message_id ASC `, }); @@ -873,6 +880,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { WHERE threads.thread_id = ${threadId} AND threads.deleted_at IS NULL AND threads.archived_at IS NULL + -- Conversation-rewind (ADR-0002): the active timeline never surfaces a + -- latest turn that was abandoned by a rewind. + AND turns.abandoned = 0 LIMIT 1 `, }); diff --git a/apps/server/src/orchestration/decider.rewind.test.ts b/apps/server/src/orchestration/decider.rewind.test.ts new file mode 100644 index 000000000000..48ecd2780bdd --- /dev/null +++ b/apps/server/src/orchestration/decider.rewind.test.ts @@ -0,0 +1,148 @@ +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + EventId, + MessageId, + NonNegativeInt, + ProjectId, + ThreadId, + ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +const now = "2026-01-01T00:00:00.000Z"; + +const seedReadModel = Effect.gen(function* () { + const initial = createEmptyReadModel(now); + const withProject = yield* projectEvent(initial, { + sequence: 1, + eventId: EventId.make("evt-project"), + aggregateKind: "project", + aggregateId: ProjectId.make("project-rw"), + type: "project.created", + occurredAt: now, + commandId: CommandId.make("cmd-project"), + causationEventId: null, + correlationId: CommandId.make("cmd-project"), + metadata: {}, + payload: { + projectId: ProjectId.make("project-rw"), + title: "Project RW", + workspaceRoot: "/tmp/project-rw", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + return yield* projectEvent(withProject, { + sequence: 2, + eventId: EventId.make("evt-thread"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-rw"), + type: "thread.created", + occurredAt: now, + commandId: CommandId.make("cmd-thread"), + causationEventId: null, + correlationId: CommandId.make("cmd-thread"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-rw"), + projectId: ProjectId.make("project-rw"), + title: "Thread RW", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); +}); + +it.layer(NodeServices.layer)("decider conversation-rewind", (it) => { + // ADR-0002: the client `thread.conversation.rewind` command records the + // request the rewind reactor (WS-2) consumes. + it.effect("emits thread.conversation-rewind-requested for the rewind command", () => + Effect.gen(function* () { + const readModel = yield* seedReadModel; + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.conversation.rewind", + commandId: CommandId.make("cmd-rewind"), + threadId: ThreadId.make("thread-rw"), + messageId: MessageId.make("message-target"), + createdAt: now, + }, + readModel, + }); + const event = Array.isArray(result) ? result[0] : result; + assert.equal(event?.type, "thread.conversation-rewind-requested"); + if (event?.type === "thread.conversation-rewind-requested") { + assert.equal(event.payload.threadId, "thread-rw"); + assert.equal(event.payload.messageId, "message-target"); + } + }), + ); + + // The server-only bridge command turns into the terminal rewound event the + // ProjectionPipeline applies as a non-destructive "mark abandoned". + it.effect("turns thread.conversation-rewind.complete into thread.conversation-rewound", () => + Effect.gen(function* () { + const readModel = yield* seedReadModel; + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.conversation-rewind.complete", + commandId: CommandId.make("cmd-rewind-complete"), + threadId: ThreadId.make("thread-rw"), + messageId: MessageId.make("message-target"), + anchorProviderMessageUuid: "claude-uuid-anchor", + turnCount: NonNegativeInt.make(2), + createdAt: now, + }, + readModel, + }); + const event = Array.isArray(result) ? result[0] : result; + assert.equal(event?.type, "thread.conversation-rewound"); + if (event?.type === "thread.conversation-rewound") { + assert.equal(event.payload.threadId, "thread-rw"); + assert.equal(event.payload.messageId, "message-target"); + assert.equal(event.payload.anchorProviderMessageUuid, "claude-uuid-anchor"); + assert.equal(event.payload.turnCount, 2); + } + }), + ); + + // The decoupled file-restore command records its own request, distinct from + // the destructive checkpoint revert. + it.effect("emits thread.files-restore-requested for the file-restore command", () => + Effect.gen(function* () { + const readModel = yield* seedReadModel; + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.files.restore", + commandId: CommandId.make("cmd-files-restore"), + threadId: ThreadId.make("thread-rw"), + turnCount: NonNegativeInt.make(1), + createdAt: now, + }, + readModel, + }); + const event = Array.isArray(result) ? result[0] : result; + assert.equal(event?.type, "thread.files-restore-requested"); + if (event?.type === "thread.files-restore-requested") { + assert.equal(event.payload.threadId, "thread-rw"); + assert.equal(event.payload.turnCount, 1); + } + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 9895a1cfc95a..8c804d38c1a9 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -812,6 +812,35 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + // Server-only bridge (ADR-0002): the rewind reactor (WS-2) dispatches + // `thread.conversation-rewind.complete` after it has resolved the anchor and + // set the cursor marker; this turns it into the terminal event the + // ProjectionPipeline applies as a non-destructive "mark abandoned". + case "thread.conversation-rewind.complete": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.conversation-rewound", + payload: { + threadId: command.threadId, + messageId: command.messageId, + ...(command.anchorProviderMessageUuid !== undefined + ? { anchorProviderMessageUuid: command.anchorProviderMessageUuid } + : {}), + turnCount: command.turnCount, + }, + }; + } + case "thread.activity.append": { yield* requireThread({ readModel, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index 4595ced1066e..1dec7140d172 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -180,4 +180,59 @@ layer("ProjectionThreadMessageRepository", (it) => { } }), ); + + // ADR-0002 non-destructive conversation rewind. The mark path must FLIP the + // `abandoned` flag on the rewound prompt and everything forward of it — never + // delete rows — and report the count of newly hidden rows. + it.effect("markAbandonedFromCreatedAt flips forward rows without deleting them", () => + Effect.gen(function* () { + const repository = yield* ProjectionThreadMessageRepository; + const threadId = ThreadId.make("thread-rewind-mark"); + + const seed = (suffix: string, role: "user" | "assistant", createdAt: string) => + repository.upsert({ + messageId: MessageId.make(`message-${suffix}`), + threadId, + turnId: null, + role, + text: suffix, + isStreaming: false, + createdAt, + updatedAt: createdAt, + }); + + yield* seed("a-user", "user", "2026-03-01T10:00:00.000Z"); + yield* seed("a-assistant", "assistant", "2026-03-01T10:00:01.000Z"); + yield* seed("b-user", "user", "2026-03-01T10:00:02.000Z"); // rewind target + yield* seed("b-assistant", "assistant", "2026-03-01T10:00:03.000Z"); + yield* seed("c-user", "user", "2026-03-01T10:00:04.000Z"); + + const flipped = yield* repository.markAbandonedFromCreatedAt({ + threadId, + fromCreatedAt: "2026-03-01T10:00:02.000Z", + }); + // b-user (the target prompt), b-assistant, c-user. + assert.equal(flipped, 3); + + // Non-destructive: every row is still present in the unfiltered repo read. + const rows = yield* repository.listByThreadId({ threadId }); + assert.equal(rows.length, 5); + + const abandonedIds = rows + .filter((row) => row.abandoned === true) + .map((row) => String(row.messageId)); + assert.deepEqual(abandonedIds.toSorted(), [ + "message-b-assistant", + "message-b-user", + "message-c-user", + ]); + + // A repeated rewind to the same point flips nothing new (idempotent count). + const flippedAgain = yield* repository.markAbandonedFromCreatedAt({ + threadId, + fromCreatedAt: "2026-03-01T10:00:02.000Z", + }); + assert.equal(flippedAgain, 0); + }), + ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 7d81346ca40c..1846ef21f332 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -10,6 +10,7 @@ import { ChatAttachment } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; import { GetProjectionThreadMessageInput, + MarkProjectionThreadMessagesAbandonedInput, ProjectionThreadMessageRepository, type ProjectionThreadMessageRepositoryShape, DeleteProjectionThreadMessagesInput, @@ -22,9 +23,12 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( isStreaming: Schema.Number, attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), providerMessageUuid: Schema.NullOr(Schema.String), + abandoned: Schema.Number, }), ); +const MarkedMessageIdRowSchema = Schema.Struct({ messageId: Schema.String }); + function toProjectionThreadMessage( row: Schema.Schema.Type, ): ProjectionThreadMessage { @@ -35,6 +39,7 @@ function toProjectionThreadMessage( role: row.role, text: row.text, isStreaming: row.isStreaming === 1, + abandoned: row.abandoned === 1, createdAt: row.createdAt, updatedAt: row.updatedAt, ...(row.attachments !== null ? { attachments: row.attachments } : {}), @@ -59,6 +64,11 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { // last non-null = the rewind anchor. const nextProviderMessageUuid = row.providerMessageUuid !== undefined ? row.providerMessageUuid : null; + // Conversation-rewind flag is owned by the dedicated mark/UPDATE path, not + // by upserts. A normal upsert must never clear an already-set abandoned + // flag (e.g. a late streaming delta on a rewound row), so preserve the + // existing value on conflict and default new rows to 0. + const nextAbandoned = row.abandoned === true ? 1 : null; return sql` INSERT INTO projection_thread_messages ( message_id, @@ -68,6 +78,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { text, attachments_json, provider_message_uuid, + abandoned, is_streaming, created_at, updated_at @@ -94,6 +105,15 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { WHERE message_id = ${row.messageId} ) ), + COALESCE( + ${nextAbandoned}, + ( + SELECT abandoned + FROM projection_thread_messages + WHERE message_id = ${row.messageId} + ), + 0 + ), ${row.isStreaming ? 1 : 0}, ${row.createdAt}, ${row.updatedAt} @@ -112,6 +132,10 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { excluded.provider_message_uuid, projection_thread_messages.provider_message_uuid ), + abandoned = COALESCE( + ${nextAbandoned}, + projection_thread_messages.abandoned + ), is_streaming = excluded.is_streaming, created_at = excluded.created_at, updated_at = excluded.updated_at @@ -132,6 +156,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { text, attachments_json AS "attachments", provider_message_uuid AS "providerMessageUuid", + abandoned, is_streaming AS "isStreaming", created_at AS "createdAt", updated_at AS "updatedAt" @@ -154,6 +179,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { text, attachments_json AS "attachments", provider_message_uuid AS "providerMessageUuid", + abandoned, is_streaming AS "isStreaming", created_at AS "createdAt", updated_at AS "updatedAt" @@ -172,6 +198,23 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { `, }); + // Non-destructive rewind: flip the flag in place and RETURN the flipped ids so + // callers get an accurate count. Already-abandoned rows are excluded so a + // repeated rewind to the same point reports 0 newly hidden rows. + const markProjectionThreadMessageRowsAbandoned = SqlSchema.findAll({ + Request: MarkProjectionThreadMessagesAbandonedInput, + Result: MarkedMessageIdRowSchema, + execute: ({ threadId, fromCreatedAt }) => + sql` + UPDATE projection_thread_messages + SET abandoned = 1 + WHERE thread_id = ${threadId} + AND abandoned = 0 + AND created_at >= ${fromCreatedAt} + RETURNING message_id AS "messageId" + `, + }); + const upsert: ProjectionThreadMessageRepositoryShape["upsert"] = (row) => upsertProjectionThreadMessageRow(row).pipe( Effect.mapError(toPersistenceSqlError("ProjectionThreadMessageRepository.upsert:query")), @@ -200,11 +243,23 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { ), ); + const markAbandonedFromCreatedAt: ProjectionThreadMessageRepositoryShape["markAbandonedFromCreatedAt"] = + (input) => + markProjectionThreadMessageRowsAbandoned(input).pipe( + Effect.mapError( + toPersistenceSqlError( + "ProjectionThreadMessageRepository.markAbandonedFromCreatedAt:query", + ), + ), + Effect.map((rows) => rows.length), + ); + return { upsert, getByMessageId, listByThreadId, deleteByThreadId, + markAbandonedFromCreatedAt, } satisfies ProjectionThreadMessageRepositoryShape; }); diff --git a/apps/server/src/persistence/Layers/ProjectionTurns.abandoned.test.ts b/apps/server/src/persistence/Layers/ProjectionTurns.abandoned.test.ts new file mode 100644 index 000000000000..d16bc7fb2b33 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionTurns.abandoned.test.ts @@ -0,0 +1,64 @@ +import { CheckpointRef, MessageId, NonNegativeInt, ThreadId, TurnId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { ProjectionTurnRepository } from "../Services/ProjectionTurns.ts"; +import { ProjectionTurnRepositoryLive } from "./ProjectionTurns.ts"; +import { SqlitePersistenceMemory } from "./Sqlite.ts"; + +const layer = it.layer( + ProjectionTurnRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), +); + +layer("ProjectionTurnRepository (conversation-rewind)", (it) => { + // ADR-0002 non-destructive rewind: flip `abandoned` on turns requested at or + // after the rewound prompt, never delete, and report the concrete-turn count. + it.effect("markAbandonedFromRequestedAt flips forward turns without deleting them", () => + Effect.gen(function* () { + const repository = yield* ProjectionTurnRepository; + const threadId = ThreadId.make("thread-rewind-turns"); + + const seed = (suffix: string, requestedAt: string, checkpointTurnCount: number) => + repository.upsertByTurnId({ + turnId: TurnId.make(`turn-${suffix}`), + threadId, + pendingMessageId: null, + sourceProposedPlanThreadId: null, + sourceProposedPlanId: null, + assistantMessageId: MessageId.make(`assistant-${suffix}`), + state: "completed", + requestedAt, + startedAt: requestedAt, + completedAt: requestedAt, + checkpointTurnCount: NonNegativeInt.make(checkpointTurnCount), + checkpointRef: CheckpointRef.make(`ref-${suffix}`), + checkpointStatus: "ready", + checkpointFiles: [], + }); + + yield* seed("a", "2026-03-01T10:00:01.000Z", 1); + yield* seed("b", "2026-03-01T10:00:03.000Z", 2); // at/after the cut + yield* seed("c", "2026-03-01T10:00:05.000Z", 3); + + const flipped = yield* repository.markAbandonedFromRequestedAt({ + threadId, + fromRequestedAt: "2026-03-01T10:00:03.000Z", + }); + assert.equal(flipped, 2); // turn-b + turn-c + + // Non-destructive: all three turns survive the unfiltered repo read. + const turns = yield* repository.listByThreadId({ threadId }); + assert.equal(turns.length, 3); + + const abandoned = turns + .filter((turn) => turn.abandoned === true) + .map((turn) => String(turn.turnId)) + .toSorted(); + assert.deepEqual(abandoned, ["turn-b", "turn-c"]); + + const kept = turns.find((turn) => turn.turnId === "turn-a"); + assert.equal(kept?.abandoned, false); + }), + ); +}); diff --git a/apps/server/src/persistence/Layers/ProjectionTurns.ts b/apps/server/src/persistence/Layers/ProjectionTurns.ts index bd57a4eaa30a..dc667c8ffb50 100644 --- a/apps/server/src/persistence/Layers/ProjectionTurns.ts +++ b/apps/server/src/persistence/Layers/ProjectionTurns.ts @@ -14,6 +14,7 @@ import { GetProjectionPendingTurnStartInput, GetProjectionTurnByTurnIdInput, ListProjectionTurnsByThreadInput, + MarkProjectionTurnsAbandonedInput, ProjectionPendingTurnStart, ProjectionTurn, ProjectionTurnById, @@ -24,9 +25,12 @@ import { const ProjectionTurnDbRowSchema = ProjectionTurn.mapFields( Struct.assign({ checkpointFiles: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), + abandoned: Schema.Number, }), ); +const MarkedTurnRowSchema = Schema.Struct({ turnId: Schema.NullOr(Schema.String) }); + const ProjectionTurnByIdDbRowSchema = ProjectionTurnById.mapFields( Struct.assign({ checkpointFiles: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), @@ -188,7 +192,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () { checkpoint_turn_count AS "checkpointTurnCount", checkpoint_ref AS "checkpointRef", checkpoint_status AS "checkpointStatus", - checkpoint_files_json AS "checkpointFiles" + checkpoint_files_json AS "checkpointFiles", + abandoned FROM projection_turns WHERE thread_id = ${threadId} ORDER BY @@ -254,6 +259,22 @@ const makeProjectionTurnRepository = Effect.gen(function* () { `, }); + // Non-destructive rewind: flip the flag in place and RETURN the flipped + // concrete turn ids so the caller's count excludes pending placeholders. + const markProjectionTurnsAbandoned = SqlSchema.findAll({ + Request: MarkProjectionTurnsAbandonedInput, + Result: MarkedTurnRowSchema, + execute: ({ threadId, fromRequestedAt }) => + sql` + UPDATE projection_turns + SET abandoned = 1 + WHERE thread_id = ${threadId} + AND abandoned = 0 + AND requested_at >= ${fromRequestedAt} + RETURNING turn_id AS "turnId" + `, + }); + const upsertByTurnId: ProjectionTurnRepositoryShape["upsertByTurnId"] = (row) => upsertProjectionTurnById(row).pipe( Effect.mapError( @@ -304,7 +325,14 @@ const makeProjectionTurnRepository = Effect.gen(function* () { "ProjectionTurnRepository.listByThreadId:decodeRows", ), ), - Effect.map((rows) => rows as ReadonlyArray>), + Effect.map((rows) => + rows.map( + (row): Schema.Schema.Type => ({ + ...row, + abandoned: row.abandoned === 1, + }), + ), + ), ); const getByTurnId: ProjectionTurnRepositoryShape["getByTurnId"] = (input) => @@ -337,6 +365,15 @@ const makeProjectionTurnRepository = Effect.gen(function* () { Effect.mapError(toPersistenceSqlError("ProjectionTurnRepository.deleteByThreadId:query")), ); + const markAbandonedFromRequestedAt: ProjectionTurnRepositoryShape["markAbandonedFromRequestedAt"] = + (input) => + markProjectionTurnsAbandoned(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionTurnRepository.markAbandonedFromRequestedAt:query"), + ), + Effect.map((rows) => rows.filter((row) => row.turnId !== null).length), + ); + return { upsertByTurnId, replacePendingTurnStart, @@ -346,6 +383,7 @@ const makeProjectionTurnRepository = Effect.gen(function* () { getByTurnId, clearCheckpointTurnConflict, deleteByThreadId, + markAbandonedFromRequestedAt, } satisfies ProjectionTurnRepositoryShape; }); diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index faa601e59539..907cc75c94cb 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -31,12 +31,26 @@ export const ProjectionThreadMessage = Schema.Struct({ // Claude provider message uuid — the conversation-rewind anchor. Nullable: // user messages, mid-turn assistant segments, and legacy rows have none. providerMessageUuid: Schema.optional(Schema.NullOr(Schema.String)), + // Conversation-rewind "hide, don't delete" flag (ADR-0002). True once a + // rewind marks this row as forward-of-the-anchor; the active timeline read + // path filters these out while the row itself is retained. Optional/defaults + // false for callers that never touch it. + abandoned: Schema.optional(Schema.Boolean), isStreaming: Schema.Boolean, createdAt: IsoDateTime, updatedAt: IsoDateTime, }); export type ProjectionThreadMessage = typeof ProjectionThreadMessage.Type; +export const MarkProjectionThreadMessagesAbandonedInput = Schema.Struct({ + threadId: ThreadId, + // Inclusive lower bound: every message at or after this creation timestamp is + // marked abandoned (the rewound prompt and everything forward of it). + fromCreatedAt: IsoDateTime, +}); +export type MarkProjectionThreadMessagesAbandonedInput = + typeof MarkProjectionThreadMessagesAbandonedInput.Type; + export const ListProjectionThreadMessagesInput = Schema.Struct({ threadId: ThreadId, }); @@ -87,6 +101,16 @@ export interface ProjectionThreadMessageRepositoryShape { readonly deleteByThreadId: ( input: DeleteProjectionThreadMessagesInput, ) => Effect.Effect; + + /** + * Non-destructive conversation rewind (ADR-0002): flip `abandoned = 1` on + * every message row at or after `fromCreatedAt` for a thread. Rows are never + * deleted — the active timeline read path filters them, the event log / + * transcript retain them. Returns the number of rows flipped. + */ + readonly markAbandonedFromCreatedAt: ( + input: MarkProjectionThreadMessagesAbandonedInput, + ) => Effect.Effect; } /** diff --git a/apps/server/src/persistence/Services/ProjectionTurns.ts b/apps/server/src/persistence/Services/ProjectionTurns.ts index f3d5d5e47061..ae407e11f53b 100644 --- a/apps/server/src/persistence/Services/ProjectionTurns.ts +++ b/apps/server/src/persistence/Services/ProjectionTurns.ts @@ -48,6 +48,9 @@ export const ProjectionTurn = Schema.Struct({ checkpointRef: Schema.NullOr(CheckpointRef), checkpointStatus: Schema.NullOr(OrchestrationCheckpointStatus), checkpointFiles: Schema.Array(OrchestrationCheckpointFile), + // Conversation-rewind "hide, don't delete" flag (ADR-0002): true once a + // rewind marks this turn as forward-of-the-anchor. Optional/defaults false. + abandoned: Schema.optional(Schema.Boolean), }); export type ProjectionTurn = typeof ProjectionTurn.Type; @@ -99,6 +102,14 @@ export const DeleteProjectionTurnsByThreadInput = Schema.Struct({ }); export type DeleteProjectionTurnsByThreadInput = typeof DeleteProjectionTurnsByThreadInput.Type; +export const MarkProjectionTurnsAbandonedInput = Schema.Struct({ + threadId: ThreadId, + // Inclusive lower bound on `requested_at`: every turn requested at or after + // the rewound prompt is marked abandoned. + fromRequestedAt: IsoDateTime, +}); +export type MarkProjectionTurnsAbandonedInput = typeof MarkProjectionTurnsAbandonedInput.Type; + export const ClearCheckpointTurnConflictInput = Schema.Struct({ threadId: ThreadId, turnId: TurnId, @@ -162,6 +173,16 @@ export interface ProjectionTurnRepositoryShape { readonly deleteByThreadId: ( input: DeleteProjectionTurnsByThreadInput, ) => Effect.Effect; + + /** + * Non-destructive conversation rewind (ADR-0002): flip `abandoned = 1` on + * every turn row requested at or after `fromRequestedAt`. Rows are never + * deleted. Returns the number of concrete (`turn_id IS NOT NULL`) turns + * flipped — the rewind's reported turn count. + */ + readonly markAbandonedFromRequestedAt: ( + input: MarkProjectionTurnsAbandonedInput, + ) => Effect.Effect; } export class ProjectionTurnRepository extends Context.Service< diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index e754084430df..2f2c002f1421 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -807,6 +807,20 @@ const ThreadRevertCompleteCommand = Schema.Struct({ createdAt: IsoDateTime, }); +// Server-only bridge command (ADR-0002): the rewind reactor (WS-2) dispatches +// this once it has resolved the anchor + set the cursor marker; the decider +// turns it into the terminal `thread.conversation-rewound` event (mirrors the +// `thread.revert.complete` → `thread.reverted` pattern). Never sent by clients. +const ThreadConversationRewindCompleteCommand = Schema.Struct({ + type: Schema.Literal("thread.conversation-rewind.complete"), + commandId: CommandId, + threadId: ThreadId, + messageId: MessageId, + anchorProviderMessageUuid: Schema.optional(Schema.String), + turnCount: NonNegativeInt, + createdAt: IsoDateTime, +}); + const InternalOrchestrationCommand = Schema.Union([ ThreadSessionSetCommand, ThreadMessageAssistantDeltaCommand, @@ -816,6 +830,7 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadTurnDiffCompleteCommand, ThreadActivityAppendCommand, ThreadRevertCompleteCommand, + ThreadConversationRewindCompleteCommand, ]); export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; From 6de8ed15d04f344a8a6d7175fb625a5aee43cd1c Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Wed, 10 Jun 2026 22:43:03 -0400 Subject: [PATCH 33/40] feat(rewind): rewind reactor + decoupled file-restore reactor (WS-2) ADR-0002. New RewindReactor consumes the two non-destructive rewind events: - `thread.conversation-rewind-requested`: resolves the pre-prompt anchor (the provider_message_uuid of the assistant message immediately before the target prompt), writes the cursor marker into provider_sessions.resume_cursor_json (resumeSessionAt = anchor, rewindPending = true) preserving the adapter's unknown blob fields, and dispatches the bridge command so the projection marks forward rows abandoned. The working tree is untouched. - `thread.files-restore-requested`: restores the working tree to turn N via CheckpointStore.restoreCheckpoint ONLY -- the decoupled file half of the old bundled revert, with no provider rollback and no message/turn deletion. The destructive `thread.checkpoint.revert` -> `thread.reverted` path in CheckpointReactor is left fully intact for backward-compat. The marker write strips the adapter's forward `resumeSessionAt` before re-adding the anchor: the adapter auto-advances it to the latest assistant uuid on every continue, so preserving it would resume to the wrong (forward) point. Wired into OrchestrationReactor + server ReactorLayerLive + the integration harness. Tests (it.effect / vitest): anchor resolves to the pre-prompt uuid and the cursor blob carries the marker + overrides the stale forward value while preserving unknown fields; file-restore moves the tree while every message row survives; reactor start ordering. Co-Authored-By: Claude Opus 4.8 --- .../OrchestrationEngineHarness.integration.ts | 18 + .../Layers/OrchestrationReactor.test.ts | 11 + .../Layers/OrchestrationReactor.ts | 3 + .../Layers/RewindReactor.test.ts | 400 ++++++++++++++++++ .../src/orchestration/Layers/RewindReactor.ts | 378 +++++++++++++++++ .../orchestration/Services/RewindReactor.ts | 48 +++ apps/server/src/server.ts | 2 + 7 files changed, 860 insertions(+) create mode 100644 apps/server/src/orchestration/Layers/RewindReactor.test.ts create mode 100644 apps/server/src/orchestration/Layers/RewindReactor.ts create mode 100644 apps/server/src/orchestration/Services/RewindReactor.ts diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index c1c246283637..bcd4e9744930 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -29,6 +29,8 @@ import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/La import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts"; import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts"; +import { ProjectionThreadMessageRepositoryLive } from "../src/persistence/Layers/ProjectionThreadMessages.ts"; +import { ProjectionTurnRepositoryLive } from "../src/persistence/Layers/ProjectionTurns.ts"; import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts"; import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts"; import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts"; @@ -52,6 +54,7 @@ import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts"; import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts"; +import { RewindReactorLive } from "../src/orchestration/Layers/RewindReactor.ts"; import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionLive } from "../src/orchestration/Layers/ProviderRuntimeIngestion.ts"; import { @@ -355,10 +358,25 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(WorkspacePathsLive), Layer.provideMerge(VcsProcess.layer), ); + const rewindReactorLayer = RewindReactorLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + Layer.provideMerge(providerSessionDirectoryLayer), + Layer.provideMerge(ProjectionThreadMessageRepositoryLive), + Layer.provideMerge(ProjectionTurnRepositoryLive), + Layer.provideMerge( + WorkspaceEntriesLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provideMerge(VcsDriverRegistry.layer), + Layer.provide(NodeServices.layer), + ), + ), + Layer.provideMerge(VcsProcess.layer), + ); const orchestrationReactorLayer = OrchestrationReactorLive.pipe( Layer.provideMerge(runtimeIngestionLayer), Layer.provideMerge(providerCommandReactorLayer), Layer.provideMerge(checkpointReactorLayer), + Layer.provideMerge(rewindReactorLayer), Layer.provideMerge( Layer.succeed(ThreadDeletionReactor, { start: () => Effect.void, diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index 21f5f1f1ee4c..29388c61a371 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -8,6 +8,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; +import { RewindReactor } from "../Services/RewindReactor.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; import { ResumeSeedReactor } from "../../resume/ResumeSeedReactor.ts"; @@ -55,6 +56,15 @@ describe("OrchestrationReactor", () => { drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(RewindReactor, { + start: () => { + started.push("rewind-reactor"); + return Effect.void; + }, + drain: Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(ThreadDeletionReactor, { start: () => { @@ -83,6 +93,7 @@ describe("OrchestrationReactor", () => { "provider-runtime-ingestion", "provider-command-reactor", "checkpoint-reactor", + "rewind-reactor", "thread-deletion-reactor", "resume-seed-reactor", ]); diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index fc07ffe27ceb..222a4aecb2d1 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -8,6 +8,7 @@ import { import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; +import { RewindReactor } from "../Services/RewindReactor.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import { ResumeSeedReactor } from "../../resume/ResumeSeedReactor.ts"; @@ -15,6 +16,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService; const providerCommandReactor = yield* ProviderCommandReactor; const checkpointReactor = yield* CheckpointReactor; + const rewindReactor = yield* RewindReactor; const threadDeletionReactor = yield* ThreadDeletionReactor; const resumeSeedReactor = yield* ResumeSeedReactor; @@ -22,6 +24,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { yield* providerRuntimeIngestion.start(); yield* providerCommandReactor.start(); yield* checkpointReactor.start(); + yield* rewindReactor.start(); yield* threadDeletionReactor.start(); yield* resumeSeedReactor.start(); }); diff --git a/apps/server/src/orchestration/Layers/RewindReactor.test.ts b/apps/server/src/orchestration/Layers/RewindReactor.test.ts new file mode 100644 index 000000000000..7349def61749 --- /dev/null +++ b/apps/server/src/orchestration/Layers/RewindReactor.test.ts @@ -0,0 +1,400 @@ +// @effect-diagnostics nodeBuiltinImport:off +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; + +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + MessageId, + NonNegativeInt, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ProviderSession, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as Option from "effect/Option"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import { afterEach, describe, expect, it } from "vitest"; + +import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; +import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; +import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; +import { RewindReactorLive } from "./RewindReactor.ts"; +import { RuntimeReceiptBusLive } from "./RuntimeReceiptBus.ts"; +import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; +import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; +import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts"; +import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; +import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "../Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { RewindReactor } from "../Services/RewindReactor.ts"; +import { ProviderSessionDirectory } from "../../provider/Services/ProviderSessionDirectory.ts"; +import { ProviderSessionDirectoryLive } from "../../provider/Layers/ProviderSessionDirectory.ts"; +import { ProviderService, type ProviderServiceShape } from "../../provider/Services/ProviderService.ts"; +import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { WorkspaceEntriesLive } from "../../workspace/Layers/WorkspaceEntries.ts"; +import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts"; + +const threadId = ThreadId.make("thread-1"); + +function unsupported() { + return Effect.die(new Error("Unsupported provider call in test")) as Effect.Effect; +} + +function makeProviderServiceMock(cwd: string): ProviderServiceShape { + const now = "2026-01-01T00:00:00.000Z"; + return { + startSession: () => unsupported(), + sendTurn: () => unsupported(), + interruptTurn: () => unsupported(), + respondToRequest: () => unsupported(), + respondToUserInput: () => unsupported(), + stopSession: () => unsupported(), + listSessions: () => + Effect.succeed([ + { + provider: ProviderDriverKind.make("codex"), + status: "ready", + runtimeMode: "full-access", + threadId, + cwd, + createdAt: now, + updatedAt: now, + }, + ] satisfies ReadonlyArray), + getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), + getInstanceInfo: (instanceId) => + Effect.succeed({ + instanceId, + driverKind: ProviderDriverKind.make("codex"), + displayName: undefined, + enabled: true, + continuationIdentity: { + driverKind: ProviderDriverKind.make("codex"), + continuationKey: `codex:instance:${instanceId}`, + }, + }), + rollbackConversation: () => unsupported(), + get streamEvents() { + return Stream.empty; + }, + }; +} + +function runGit(cwd: string, args: ReadonlyArray) { + return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" }); +} + +function createGitRepository() { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "t3-rewind-reactor-")); + runGit(cwd, ["init", "--initial-branch=main"]); + runGit(cwd, ["config", "user.email", "test@example.com"]); + runGit(cwd, ["config", "user.name", "Test User"]); + fs.writeFileSync(path.join(cwd, "README.md"), "v1\n", "utf8"); + runGit(cwd, ["add", "."]); + runGit(cwd, ["commit", "-m", "Initial"]); + return cwd; +} + +async function waitFor(predicate: () => Promise, timeoutMs = 15_000) { + const deadline = (await Effect.runPromise(Clock.currentTimeMillis)) + timeoutMs; + const poll = async (): Promise => { + if (await predicate()) { + return; + } + if ((await Effect.runPromise(Clock.currentTimeMillis)) >= deadline) { + throw new Error("Timed out waiting for reactor state."); + } + await Effect.runPromise(Effect.sleep("10 millis")); + return poll(); + }; + return poll(); +} + +describe("RewindReactor", () => { + let runtime: ManagedRuntime.ManagedRuntime< + | OrchestrationEngineService + | RewindReactor + | ProjectionSnapshotQuery + | ProviderSessionDirectory + | CheckpointStore, + unknown + > | null = null; + let scope: Scope.Closeable | null = null; + const tempDirs: string[] = []; + + afterEach(async () => { + if (scope) { + await Effect.runPromise(Scope.close(scope, Exit.void)); + } + scope = null; + if (runtime) { + await runtime.dispose(); + } + runtime = null; + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + }); + + async function createHarness() { + const cwd = createGitRepository(); + tempDirs.push(cwd); + + // One shared in-memory DB + one shared projection snapshot query so the + // engine's projected rows, the reactor's reads, the test's reads, and the + // session directory all see the same data. Everything is layered off a + // single `dataLayer` so Effect memoizes the SqlitePersistenceMemory instance + // across all consumers in this runtime. + const dataLayer = Layer.mergeAll( + OrchestrationProjectionSnapshotQueryLive, + OrchestrationProjectionPipelineLive, + ProjectionThreadMessageRepositoryLive, + ProjectionTurnRepositoryLive, + ProviderSessionDirectoryLive, + ).pipe( + Layer.provideMerge(OrchestrationEventStoreLive), + Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive), + Layer.provideMerge(ProviderSessionRuntimeRepositoryLive), + Layer.provideMerge(RepositoryIdentityResolverLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + const layer = RewindReactorLive.pipe( + Layer.provideMerge(OrchestrationEngineLive), + Layer.provideMerge(dataLayer), + Layer.provideMerge(RuntimeReceiptBusLive), + Layer.provideMerge(Layer.succeed(ProviderService, makeProviderServiceMock(cwd))), + Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer))), + Layer.provideMerge( + WorkspaceEntriesLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provideMerge(VcsDriverRegistry.layer), + ), + ), + Layer.provideMerge(WorkspacePathsLive), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-rewind-reactor-" })), + Layer.provideMerge(NodeServices.layer), + ); + + runtime = ManagedRuntime.make(layer); + const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); + const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); + const reactor = await runtime.runPromise(Effect.service(RewindReactor)); + const directory = await runtime.runPromise(Effect.service(ProviderSessionDirectory)); + const checkpointStore = await runtime.runPromise(Effect.service(CheckpointStore)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reactor.start().pipe(Scope.provide(scope))); + + const now = "2026-01-01T00:00:00.000Z"; + await Effect.runPromise( + engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-project"), + projectId: ProjectId.make("project-1"), + title: "Project", + workspaceRoot: cwd, + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt: now, + }), + ); + await Effect.runPromise( + engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread"), + threadId, + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: cwd, + createdAt: now, + }), + ); + + return { engine, snapshotQuery, reactor, directory, checkpointStore, cwd }; + } + + // Seed a small conversation with an anchor uuid on the first assistant reply. + async function seedConversation(engine: OrchestrationEngineShape) { + const send = ( + suffix: string, + role: "user" | "assistant", + createdAt: string, + providerMessageUuid?: string, + ) => + Effect.runPromise( + engine.dispatch({ + type: "thread.message.user.record", + commandId: CommandId.make(`cmd-msg-${suffix}`), + threadId, + messageId: MessageId.make(`message-${suffix}`), + text: suffix, + ...(providerMessageUuid ? { providerMessageUuid } : {}), + createdAt, + } as never), + ).then(() => undefined); + + // user.record only records user rows; for the assistant anchor we use the + // assistant.complete command which carries the turn-final uuid. + await send("a-user", "user", "2026-01-01T00:01:00.000Z"); + await Effect.runPromise( + engine.dispatch({ + type: "thread.message.assistant.complete", + commandId: CommandId.make("cmd-msg-a-assistant"), + threadId, + messageId: MessageId.make("message-a-assistant"), + providerMessageUuid: "claude-uuid-a-assistant", + createdAt: "2026-01-01T00:01:01.000Z", + } as never), + ); + await send("b-user", "user", "2026-01-01T00:01:02.000Z"); + } + + it("resolves the pre-prompt anchor uuid and writes the cursor marker", async () => { + const harness = await createHarness(); + + // Seed a provider-session binding carrying a stale forward resumeSessionAt so + // we can prove the rewind overrides it (not merely preserves it). + await Effect.runPromise( + harness.directory.upsert({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + resumeCursor: { + resume: "session-abc", + resumeSessionAt: "claude-uuid-FORWARD", + turnCount: 5, + custom: "keep-me", + }, + }), + ); + + await seedConversation(harness.engine); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.conversation.rewind", + commandId: CommandId.make("cmd-rewind"), + threadId, + messageId: MessageId.make("message-b-user"), + createdAt: "2026-01-01T00:02:00.000Z", + }), + ); + + // Wait until the cursor blob carries the marker. + let cursor: Record = {}; + await waitFor(async () => { + const binding = await Effect.runPromise(harness.directory.getBinding(threadId)); + if (Option.isNone(binding)) { + return false; + } + const raw = binding.value.resumeCursor; + cursor = raw && typeof raw === "object" ? (raw as Record) : {}; + return cursor.rewindPending === true; + }); + + // Anchor = the assistant uuid immediately before the b-user prompt. + expect(cursor.resumeSessionAt).toBe("claude-uuid-a-assistant"); + expect(cursor.rewindPending).toBe(true); + // The stale forward value was overridden, unknown fields preserved. + expect(cursor.resume).toBe("session-abc"); + expect(cursor.custom).toBe("keep-me"); + }); + + it("file-restore restores the tree without touching message rows", async () => { + const harness = await createHarness(); + await seedConversation(harness.engine); + + // Capture checkpoints for turn 0 (v1) and turn 1 (v2). + await Effect.runPromise( + harness.checkpointStore.captureCheckpoint({ + cwd: harness.cwd, + checkpointRef: checkpointRefForThreadTurn(threadId, 0), + }), + ); + fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); + + // Register the turn-1 checkpoint in the projection via a turn-diff-complete so + // the reactor can resolve its ref. + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.make("cmd-turn-diff-1"), + threadId, + turnId: TurnId.make("turn-1"), + completedAt: "2026-01-01T00:01:30.000Z", + checkpointRef: checkpointRefForThreadTurn(threadId, 1), + status: "ready", + files: [], + checkpointTurnCount: NonNegativeInt.make(1), + createdAt: "2026-01-01T00:01:30.000Z", + } as never), + ); + await Effect.runPromise( + harness.checkpointStore.captureCheckpoint({ + cwd: harness.cwd, + checkpointRef: checkpointRefForThreadTurn(threadId, 1), + }), + ); + fs.writeFileSync(path.join(harness.cwd, "README.md"), "v3\n", "utf8"); + + const messagesBefore = await Effect.runPromise( + harness.snapshotQuery.getThreadDetailById(threadId), + ); + const countBefore = Option.isSome(messagesBefore) ? messagesBefore.value.messages.length : 0; + expect(countBefore).toBeGreaterThan(0); + + // Restore files to turn 1 (v2). + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.files.restore", + commandId: CommandId.make("cmd-files-restore"), + threadId, + turnCount: NonNegativeInt.make(1), + createdAt: "2026-01-01T00:03:00.000Z", + }), + ); + + await waitFor(async () => fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8") === "v2\n"); + + // The working tree moved back, but the conversation is untouched. + expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n"); + const messagesAfter = await Effect.runPromise( + harness.snapshotQuery.getThreadDetailById(threadId), + ); + const countAfter = Option.isSome(messagesAfter) ? messagesAfter.value.messages.length : 0; + expect(countAfter).toBe(countBefore); + }); +}); diff --git a/apps/server/src/orchestration/Layers/RewindReactor.ts b/apps/server/src/orchestration/Layers/RewindReactor.ts new file mode 100644 index 000000000000..76e8262704db --- /dev/null +++ b/apps/server/src/orchestration/Layers/RewindReactor.ts @@ -0,0 +1,378 @@ +/** + * RewindReactor (ADR-0002) — non-destructive conversation rewind + decoupled + * file-restore. See `Services/RewindReactor.ts` for the contract. + * + * NON-DESTRUCTIVE INVARIANT: this reactor never deletes a message or turn row. + * The conversation rewind only flips `abandoned` (via the projection) and moves + * the provider-session cursor; the file-restore only touches the working tree. + */ +import { + CommandId, + EventId, + type MessageId, + type ThreadId, + type OrchestrationEvent, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; + +import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; +import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { ProviderSessionDirectory } from "../../provider/Services/ProviderSessionDirectory.ts"; +import { ProjectionThreadMessageRepository } from "../../persistence/Services/ProjectionThreadMessages.ts"; +import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; +import { RewindReactor, type RewindReactorShape } from "../Services/RewindReactor.ts"; +import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { isGitRepository } from "../../git/Utils.ts"; +import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts"; + +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + +// The shared marker (WS-1 reads it) lives opaquely inside the provider session's +// `resume_cursor_json` blob. We merge into the existing blob so unknown fields +// the adapter writes (resume id, turnCount, etc.) survive the rewind write. +interface RewindCursorBlob { + readonly resumeSessionAt?: string; + readonly rewindPending: boolean; + readonly [key: string]: unknown; +} + +const make = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const randomUUID = crypto.randomUUIDv4; + const serverEventId = randomUUID.pipe(Effect.map(EventId.make)); + const serverCommandId = (tag: string) => + randomUUID.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); + const orchestrationEngine = yield* OrchestrationEngineService; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const providerService = yield* ProviderService; + const providerSessionDirectory = yield* ProviderSessionDirectory; + const projectionThreadMessageRepository = yield* ProjectionThreadMessageRepository; + const projectionTurnRepository = yield* ProjectionTurnRepository; + const checkpointStore = yield* CheckpointStore; + const workspaceEntries = yield* WorkspaceEntries; + + const appendFailureActivity = (input: { + readonly threadId: ThreadId; + readonly kind: "rewind.failed" | "files.restore.failed"; + readonly summary: string; + readonly detail: string; + readonly createdAt: string; + }) => + Effect.all({ + commandId: serverCommandId(input.kind), + activityId: serverEventId, + }).pipe( + Effect.flatMap(({ commandId, activityId }) => + orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId, + threadId: input.threadId, + activity: { + id: activityId, + tone: "error", + kind: input.kind, + summary: input.summary, + payload: { detail: input.detail }, + turnId: null, + createdAt: input.createdAt, + }, + createdAt: input.createdAt, + }), + ), + Effect.catch(() => Effect.void), + ); + + // Resolve the rewind anchor: from the target prompt's `messageId`, find the + // provider uuid of the assistant message immediately BEFORE that prompt, so + // the next turn resumes "up to and including" it and the prompt itself stays + // re-askable. Reads the UNFILTERED projection (a re-rewind may target rows + // already marked abandoned by a prior rewind). + const resolveAnchorUuid = Effect.fn("resolveAnchorUuid")(function* (input: { + readonly threadId: ThreadId; + readonly messageId: MessageId; + }) { + const target = yield* projectionThreadMessageRepository.getByMessageId({ + messageId: input.messageId, + }); + if (Option.isNone(target)) { + return undefined; + } + const messages = yield* projectionThreadMessageRepository.listByThreadId({ + threadId: input.threadId, + }); + const cutAt = target.value.createdAt; + // The assistant message with a non-null anchor uuid and the greatest + // createdAt strictly before the target prompt. + let anchor: string | undefined; + let anchorCreatedAt: string | undefined; + for (const message of messages) { + if (message.role !== "assistant") { + continue; + } + const uuid = message.providerMessageUuid ?? undefined; + if (uuid === undefined) { + continue; + } + if (message.createdAt >= cutAt) { + continue; + } + if (anchorCreatedAt === undefined || message.createdAt > anchorCreatedAt) { + anchorCreatedAt = message.createdAt; + anchor = uuid; + } + } + return anchor; + }); + + // Write the rewind marker into the provider session cursor blob, preserving + // the adapter's unknown fields. WS-1's adapter reads `rewindPending` to pass + // `resumeSessionAt` into the next query and to skip auto-advancing the cursor. + // + // `resumeSessionAt` is OWNED by the rewind, not an unknown field to preserve: + // the adapter auto-advances it to the latest (forward) assistant uuid on every + // continue, so we MUST strip the old value first and re-set it only to our + // anchor. Leaving the forward value in place with rewindPending=true would make + // WS-1 resume to the wrong point — a no-op rewind. When the anchor is undefined + // (rewind to the first prompt, or a preceding row with no persisted uuid), we + // drop `resumeSessionAt` entirely so the adapter resumes from the session start. + const setRewindCursor = Effect.fn("setRewindCursor")(function* (input: { + readonly threadId: ThreadId; + readonly anchorUuid: string | undefined; + }) { + const binding = yield* providerSessionDirectory.getBinding(input.threadId); + if (Option.isNone(binding)) { + yield* Effect.logWarning("rewind: no provider session binding to carry the cursor marker", { + threadId: input.threadId, + }); + return; + } + const existingCursor = + binding.value.resumeCursor && typeof binding.value.resumeCursor === "object" + ? (binding.value.resumeCursor as Record) + : {}; + // Strip the adapter's forward `resumeSessionAt`; we re-set it only to the + // rewind anchor below. + const { resumeSessionAt: _staleResumeSessionAt, ...preservedCursor } = existingCursor; + const nextCursor: RewindCursorBlob = { + ...preservedCursor, + ...(input.anchorUuid !== undefined ? { resumeSessionAt: input.anchorUuid } : {}), + rewindPending: true, + }; + yield* providerSessionDirectory.upsert({ + threadId: binding.value.threadId, + provider: binding.value.provider, + ...(binding.value.providerInstanceId !== undefined + ? { providerInstanceId: binding.value.providerInstanceId } + : {}), + resumeCursor: nextCursor, + }); + }); + + const handleRewindRequested = Effect.fn("handleRewindRequested")(function* ( + event: Extract, + ) { + const now = yield* nowIso; + const threadId = event.payload.threadId; + const messageId = event.payload.messageId; + + // 1. Resolve the pre-prompt anchor uuid from the projection. + const anchorUuid = yield* resolveAnchorUuid({ threadId, messageId }); + + // 2. Set the provider-session cursor: resumeSessionAt = anchor, rewindPending. + yield* setRewindCursor({ threadId, anchorUuid }); + + // Count the turns that will be marked abandoned (those requested at/after the + // target prompt). The projection applies the actual flip on the emitted + // event; we report the same cut here for the event payload. + const turns = yield* projectionTurnRepository.listByThreadId({ threadId }); + const target = yield* projectionThreadMessageRepository.getByMessageId({ messageId }); + const cutAt = Option.match(target, { + onNone: () => undefined, + onSome: (message) => message.createdAt, + }); + const turnCount = + cutAt === undefined + ? 0 + : turns.filter((turn) => turn.turnId !== null && turn.requestedAt >= cutAt).length; + + // 3 + 4. Dispatch the bridge command; the decider emits + // `thread.conversation-rewound`, which the ProjectionPipeline applies as a + // non-destructive "mark abandoned" on forward messages/turns. No checkpoint + // is captured or restored — the working tree is untouched. + yield* orchestrationEngine.dispatch({ + type: "thread.conversation-rewind.complete", + commandId: yield* serverCommandId("conversation-rewind-complete"), + threadId, + messageId, + ...(anchorUuid !== undefined ? { anchorProviderMessageUuid: anchorUuid } : {}), + turnCount, + createdAt: now, + }); + }); + + // Decoupled file-restore (ADR-0002): restore the working tree to turn N via + // CheckpointStore.restoreCheckpoint ONLY. Reuses the restore phase of + // CheckpointReactor.handleRevertRequested WITHOUT the provider rollback, stale + // checkpoint deletion, or `thread.revert.complete` → row-deletion tail. + const handleFilesRestoreRequested = Effect.fn("handleFilesRestoreRequested")(function* ( + event: Extract, + ) { + const now = yield* nowIso; + const threadId = event.payload.threadId; + const turnCount = event.payload.turnCount; + + const sessions = yield* providerService.listSessions(); + const session = sessions.find((entry) => entry.threadId === threadId); + if (!session?.cwd) { + yield* appendFailureActivity({ + threadId, + kind: "files.restore.failed", + summary: "File restore failed", + detail: "No active provider session with workspace cwd is bound to this thread.", + createdAt: now, + }); + return; + } + if (!isGitRepository(session.cwd)) { + yield* appendFailureActivity({ + threadId, + kind: "files.restore.failed", + summary: "File restore failed", + detail: "Checkpoints are unavailable because this project is not a git repository.", + createdAt: now, + }); + return; + } + + const thread = yield* projectionSnapshotQuery + .getThreadDetailById(threadId) + .pipe(Effect.map(Option.getOrUndefined)); + if (!thread) { + yield* appendFailureActivity({ + threadId, + kind: "files.restore.failed", + summary: "File restore failed", + detail: "Thread was not found in read model.", + createdAt: now, + }); + return; + } + + const targetCheckpointRef = + turnCount === 0 + ? checkpointRefForThreadTurn(threadId, 0) + : thread.checkpoints.find((checkpoint) => checkpoint.checkpointTurnCount === turnCount) + ?.checkpointRef; + if (!targetCheckpointRef) { + yield* appendFailureActivity({ + threadId, + kind: "files.restore.failed", + summary: "File restore failed", + detail: `Checkpoint ref for turn ${turnCount} is unavailable in read model.`, + createdAt: now, + }); + return; + } + + const restored = yield* checkpointStore.restoreCheckpoint({ + cwd: session.cwd, + checkpointRef: targetCheckpointRef, + fallbackToHead: turnCount === 0, + }); + if (!restored) { + yield* appendFailureActivity({ + threadId, + kind: "files.restore.failed", + summary: "File restore failed", + detail: `Filesystem checkpoint is unavailable for turn ${turnCount}.`, + createdAt: now, + }); + return; + } + + // Invalidate the workspace entry cache so the @-mention file picker reflects + // the restored filesystem state. + yield* workspaceEntries.invalidate(session.cwd); + }); + + const processDomainEvent = Effect.fn("processDomainEvent")(function* (event: OrchestrationEvent) { + if (event.type === "thread.conversation-rewind-requested") { + yield* handleRewindRequested(event).pipe( + Effect.catch((error) => + Effect.flatMap(nowIso, (createdAt) => + appendFailureActivity({ + threadId: event.payload.threadId, + kind: "rewind.failed", + summary: "Conversation rewind failed", + detail: error.message, + createdAt, + }), + ), + ), + ); + return; + } + + if (event.type === "thread.files-restore-requested") { + yield* handleFilesRestoreRequested(event).pipe( + Effect.catch((error) => + Effect.flatMap(nowIso, (createdAt) => + appendFailureActivity({ + threadId: event.payload.threadId, + kind: "files.restore.failed", + summary: "File restore failed", + detail: error.message, + createdAt, + }), + ), + ), + ); + return; + } + }); + + const processInputSafely = (event: OrchestrationEvent) => + processDomainEvent(event).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + return Effect.logWarning("rewind reactor failed to process input", { + eventType: event.type, + cause: Cause.pretty(cause), + }); + }), + ); + + const worker = yield* makeDrainableWorker(processInputSafely); + + const start: RewindReactorShape["start"] = Effect.fn("start")(function* () { + yield* Effect.forkScoped( + Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { + if ( + event.type !== "thread.conversation-rewind-requested" && + event.type !== "thread.files-restore-requested" + ) { + return Effect.void; + } + return worker.enqueue(event); + }), + ); + }); + + return { + start, + drain: worker.drain, + } satisfies RewindReactorShape; +}); + +export const RewindReactorLive = Layer.effect(RewindReactor, make); diff --git a/apps/server/src/orchestration/Services/RewindReactor.ts b/apps/server/src/orchestration/Services/RewindReactor.ts new file mode 100644 index 000000000000..5e5528691324 --- /dev/null +++ b/apps/server/src/orchestration/Services/RewindReactor.ts @@ -0,0 +1,48 @@ +/** + * RewindReactor - Conversation-rewind reaction service interface (ADR-0002). + * + * Owns the background worker that reacts to the two non-destructive + * conversation-rewind orchestration events: + * + * - `thread.conversation-rewind-requested`: resolve the pre-prompt anchor uuid, + * set the provider-session cursor marker (`resumeSessionAt` + `rewindPending`), + * and dispatch the bridge command so the projection marks forward rows + * abandoned (never deletes). NON-DESTRUCTIVE: the working tree is untouched. + * - `thread.files-restore-requested`: restore the working tree to a turn via + * `CheckpointStore.restoreCheckpoint` ONLY — the decoupled file half of the + * old bundled revert, with no message/turn deletion. + * + * Distinct from `CheckpointReactor`, which still owns the destructive + * `thread.checkpoint-revert-requested` → `thread.reverted` path (backward-compat). + * + * @module RewindReactor + */ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; + +/** + * RewindReactorShape - Service API for the conversation-rewind reactor lifecycle. + */ +export interface RewindReactorShape { + /** + * Start the rewind reactor. + * + * The returned effect must be run in a scope so the worker fiber is finalized + * on shutdown. + */ + readonly start: () => Effect.Effect; + + /** + * Resolves when the internal processing queue is empty and idle. + * Intended for test use to replace timing-sensitive sleeps. + */ + readonly drain: Effect.Effect; +} + +/** + * RewindReactor - Service tag for conversation-rewind reactor workers. + */ +export class RewindReactor extends Context.Service()( + "t3/orchestration/Services/RewindReactor", +) {} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 4911ee2cd15e..e8efcbf4c883 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -43,6 +43,7 @@ import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus. import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion.ts"; import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; +import { RewindReactorLive } from "./orchestration/Layers/RewindReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; import { ResumeSeedReactorLive } from "./resume/ResumeSeedReactor.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; @@ -134,6 +135,7 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(ProviderRuntimeIngestionLive), Layer.provideMerge(ProviderCommandReactorLive), Layer.provideMerge(CheckpointReactorLive), + Layer.provideMerge(RewindReactorLive), Layer.provideMerge(ThreadDeletionReactorLive), Layer.provideMerge(ResumeSeedReactorLive), Layer.provideMerge(RuntimeReceiptBusLive), From 1cd761be924952814a56a734e61d7e527605f957 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Wed, 10 Jun 2026 23:09:52 -0400 Subject: [PATCH 34/40] fix(rewind): stop the provider session so the rewind marker cold-starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web rewind flow only dispatched thread.conversation.rewind (+ optional file restore) and pre-filled the composer; it never stopped the provider session. When the user re-sent the edited prompt, ProviderCommandReactor found the session still LIVE and reused it, so ClaudeAdapter.sendTurn went to the live promptQueue and never read the DB rewind marker — resumeSessionAt was ignored and the first live sendTurn auto-advanced the in-memory cursor, clobbering the persisted anchor. The RewindReactor now issues a full providerService.stopSession after writing the marker. listSessions() derives the reuse check's activeSession from the adapters' live in-memory sessions; a real stop deletes the Claude session from that map, so the next prompt cold-starts through startSession, which backfills from the persisted binding and consumes the marker via Path B. The stop's directory upsert omits resumeCursor and ProviderSessionDirectory.upsert preserves the existing blob when the field is absent, so the marker (and the durable resume id for same-session continuation) survives stop → cold-start. Guarded on a live session (mirrors processSessionStopRequested) so a missing session is a clean no-op rather than a spurious "rewind failed". Co-Authored-By: Claude Opus 4.8 --- .../Layers/RewindReactor.test.ts | 137 ++++++++++++++++-- .../src/orchestration/Layers/RewindReactor.ts | 35 +++++ 2 files changed, 157 insertions(+), 15 deletions(-) diff --git a/apps/server/src/orchestration/Layers/RewindReactor.test.ts b/apps/server/src/orchestration/Layers/RewindReactor.test.ts index 7349def61749..6ccba644b0dc 100644 --- a/apps/server/src/orchestration/Layers/RewindReactor.test.ts +++ b/apps/server/src/orchestration/Layers/RewindReactor.test.ts @@ -63,7 +63,19 @@ function unsupported() { return Effect.die(new Error("Unsupported provider call in test")) as Effect.Effect; } -function makeProviderServiceMock(cwd: string): ProviderServiceShape { +// Records `stopSession` calls so tests can assert the rewind handler issues a +// full provider-session stop (the cold-start trigger for the marker — ADR-0002). +// `hasLiveSession` lets a test simulate the no-live-session branch where the +// handler must skip the stop (clean no-op) but still emit the abandoned event. +interface StopRecorder { + readonly calls: Array<{ readonly threadId: ThreadId }>; + hasLiveSession: boolean; +} + +function makeProviderServiceMock( + cwd: string, + stopRecorder: StopRecorder, +): ProviderServiceShape { const now = "2026-01-01T00:00:00.000Z"; return { startSession: () => unsupported(), @@ -71,19 +83,26 @@ function makeProviderServiceMock(cwd: string): ProviderServiceShape { interruptTurn: () => unsupported(), respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), - stopSession: () => unsupported(), + stopSession: (input) => + Effect.sync(() => { + stopRecorder.calls.push({ threadId: (input as { threadId: ThreadId }).threadId }); + }) as ReturnType, listSessions: () => - Effect.succeed([ - { - provider: ProviderDriverKind.make("codex"), - status: "ready", - runtimeMode: "full-access", - threadId, - cwd, - createdAt: now, - updatedAt: now, - }, - ] satisfies ReadonlyArray), + Effect.succeed( + stopRecorder.hasLiveSession + ? ([ + { + provider: ProviderDriverKind.make("codex"), + status: "ready", + runtimeMode: "full-access", + threadId, + cwd, + createdAt: now, + updatedAt: now, + }, + ] satisfies ReadonlyArray) + : ([] satisfies ReadonlyArray), + ), getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), getInstanceInfo: (instanceId) => Effect.succeed({ @@ -166,6 +185,8 @@ describe("RewindReactor", () => { const cwd = createGitRepository(); tempDirs.push(cwd); + const stopRecorder: StopRecorder = { calls: [], hasLiveSession: true }; + // One shared in-memory DB + one shared projection snapshot query so the // engine's projected rows, the reactor's reads, the test's reads, and the // session directory all see the same data. Everything is layered off a @@ -189,7 +210,9 @@ describe("RewindReactor", () => { Layer.provideMerge(OrchestrationEngineLive), Layer.provideMerge(dataLayer), Layer.provideMerge(RuntimeReceiptBusLive), - Layer.provideMerge(Layer.succeed(ProviderService, makeProviderServiceMock(cwd))), + Layer.provideMerge( + Layer.succeed(ProviderService, makeProviderServiceMock(cwd, stopRecorder)), + ), Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer))), Layer.provideMerge( WorkspaceEntriesLive.pipe( @@ -243,7 +266,7 @@ describe("RewindReactor", () => { }), ); - return { engine, snapshotQuery, reactor, directory, checkpointStore, cwd }; + return { engine, snapshotQuery, reactor, directory, checkpointStore, cwd, stopRecorder }; } // Seed a small conversation with an anchor uuid on the first assistant reply. @@ -303,6 +326,15 @@ describe("RewindReactor", () => { await seedConversation(harness.engine); + // Sanity: the forward prompt is in the active timeline BEFORE the rewind, so + // the post-rewind "hidden" assertion below is non-vacuous. + const before = await Effect.runPromise(harness.snapshotQuery.getThreadDetailById(threadId)); + expect( + Option.isSome(before) + ? before.value.messages.map((message) => message.id) + : [], + ).toContain(MessageId.make("message-b-user")); + await Effect.runPromise( harness.engine.dispatch({ type: "thread.conversation.rewind", @@ -331,6 +363,81 @@ describe("RewindReactor", () => { // The stale forward value was overridden, unknown fields preserved. expect(cursor.resume).toBe("session-abc"); expect(cursor.custom).toBe("keep-me"); + + // The handler MUST stop the live provider session so the next prompt + // cold-starts and the adapter consumes the marker (ADR-0002 integration fix). + // Stop is awaited before the rewind completes, so by now it has fired exactly + // once for this thread. + expect(harness.stopRecorder.calls).toEqual([{ threadId }]); + + // The stop's directory upsert omits `resumeCursor`, so the marker blob we set + // above survives the stop (it does not clobber the anchor). + expect(cursor.resumeSessionAt).toBe("claude-uuid-a-assistant"); + expect(cursor.rewindPending).toBe(true); + + // The abandoned event still fired: the rewound forward prompt (b-user) drops + // out of the active timeline (marked abandoned, not deleted). + await waitFor(async () => { + const detail = await Effect.runPromise(harness.snapshotQuery.getThreadDetailById(threadId)); + if (Option.isNone(detail)) { + return false; + } + return !detail.value.messages.some( + (message) => message.id === MessageId.make("message-b-user"), + ); + }); + const detail = await Effect.runPromise(harness.snapshotQuery.getThreadDetailById(threadId)); + const activeMessageIds = Option.isSome(detail) + ? detail.value.messages.map((message) => message.id) + : []; + expect(activeMessageIds).not.toContain(MessageId.make("message-b-user")); + }); + + it("skips the stop (clean no-op) when no live provider session is bound", async () => { + const harness = await createHarness(); + await seedConversation(harness.engine); + + // No live provider session for this thread: the handler must SKIP the stop + // (a stop with `allowRecovery: false` would error and abort the handler + // before the abandoned event), yet still mark the forward turns abandoned. + harness.stopRecorder.hasLiveSession = false; + harness.stopRecorder.calls.length = 0; + + // Sanity: the forward prompt is present before the rewind. + const before = await Effect.runPromise(harness.snapshotQuery.getThreadDetailById(threadId)); + expect( + Option.isSome(before) + ? before.value.messages.map((message) => message.id) + : [], + ).toContain(MessageId.make("message-b-user")); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.conversation.rewind", + commandId: CommandId.make("cmd-rewind-noop"), + threadId, + messageId: MessageId.make("message-b-user"), + createdAt: "2026-01-01T00:02:00.000Z", + }), + ); + + await waitFor(async () => { + const detail = await Effect.runPromise(harness.snapshotQuery.getThreadDetailById(threadId)); + if (Option.isNone(detail)) { + return false; + } + return !detail.value.messages.some( + (message) => message.id === MessageId.make("message-b-user"), + ); + }); + const detail = await Effect.runPromise(harness.snapshotQuery.getThreadDetailById(threadId)); + const activeMessageIds = Option.isSome(detail) + ? detail.value.messages.map((message) => message.id) + : []; + expect(activeMessageIds).not.toContain(MessageId.make("message-b-user")); + + // No live session → the stop was skipped, not attempted. + expect(harness.stopRecorder.calls).toEqual([]); }); it("file-restore restores the tree without touching message rows", async () => { diff --git a/apps/server/src/orchestration/Layers/RewindReactor.ts b/apps/server/src/orchestration/Layers/RewindReactor.ts index 76e8262704db..1610a6bb2bda 100644 --- a/apps/server/src/orchestration/Layers/RewindReactor.ts +++ b/apps/server/src/orchestration/Layers/RewindReactor.ts @@ -190,6 +190,41 @@ const make = Effect.gen(function* () { // 2. Set the provider-session cursor: resumeSessionAt = anchor, rewindPending. yield* setRewindCursor({ threadId, anchorUuid }); + // 2b. STOP the provider session (full stop → status "stopped"), so the next + // prompt cold-starts through `ProviderService.startSession` and consumes the + // marker via the adapter's `readClaudeResumeState` (Path B). The web rewind + // flow never stops the session itself; without this, the still-LIVE session + // is reused (ProviderCommandReactor reuse check at :456-457) and the marker + // is ignored — and the first live `sendTurn` auto-advances the in-memory + // cursor, clobbering the persisted anchor. + // + // A direct `providerService.stopSession` (not a restart-in-place) is required: + // - `listSessions()` derives `activeSession` from the adapters' LIVE + // in-memory sessions; the Claude adapter's stop deletes the session from + // its map (ClaudeAdapter.ts:2566) so it drops out of `listSessions()` → + // `activeSession` becomes undefined → the reuse check yields null → cold + // start. A restart-in-place would instead pass the marker-less in-memory + // `activeSession.resumeCursor` (ProviderCommandReactor.ts:486-488). + // - The stop's directory upsert OMITS `resumeCursor` (ProviderService.ts: + // 830-838) and `ProviderSessionDirectory.upsert` preserves the existing + // blob when the field is absent (ProviderSessionDirectory.ts:140-143), so + // the marker survives stop → cold-start. The durable `resume` id in that + // blob carries forward, so the cold-start is a CONTINUATION of the same + // Claude session, not a new one. + // - The adapter's idle stop does NOT auto-advance the cursor: it only runs + // `completeTurn` (which calls `updateResumeCursor`) when a turn is in + // flight (ClaudeAdapter.ts:2515-2517), and the rewind flow is idle. + // + // Guard on a live session (mirrors ProviderCommandReactor.ts:925) so a missing + // session is a clean no-op, not a spurious "rewind failed": `stopSession` + // resolves with `allowRecovery: false` and would error otherwise, aborting the + // handler before the abandoned event is emitted. + const liveSessions = yield* providerService.listSessions(); + const hasLiveSession = liveSessions.some((entry) => entry.threadId === threadId); + if (hasLiveSession) { + yield* providerService.stopSession({ threadId }); + } + // Count the turns that will be marked abandoned (those requested at/after the // target prompt). The projection applies the actual flip on the emitted // event; we report the same cut here for the event payload. From 73787b3408de3a60fd375bf577ace5e7b4ac98e3 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Thu, 11 Jun 2026 00:01:14 -0400 Subject: [PATCH 35/40] fix(rewind): surface a reconnect error instead of silently no-op'ing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveRewindContext returned null silently when readEnvironmentApi was undefined — which is exactly the case for threads on a saved environment that isn't currently connected. The rewind button appeared dead (no command, no error). Reorder so the actionable 'reconnect' message shows first, and never return null without surfacing why. Co-Authored-By: Claude Opus 4.8 --- apps/web/src/components/ChatView.tsx | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6b21b6989d63..fab3586d12d9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3008,11 +3008,17 @@ export default function ChatView(props: ChatViewProps) { // Guard shared by every rewind action. Returns null when blocked. const resolveRewindContext = useCallback(() => { - const api = readEnvironmentApi(environmentId); const localApi = readLocalApi(); - if (!api || !localApi || !activeThread || isRevertingCheckpoint) { + if (!localApi || !activeThread || isRevertingCheckpoint) { return null; } + // A saved (non-primary) environment that isn't currently connected has no + // entry in the connection map, so `readEnvironmentApi` returns undefined for + // its threads. Surface the actionable reconnect message *before* the bare + // api guard below — otherwise the missing api silently swallows the click + // (no command, no error) on exactly the imported/cloud threads this feature + // must support (ADR-0002 §2). Once reconnected, the rewind dispatches as + // normal through that environment's adapter. if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) { setThreadError( activeThread.id, @@ -3020,6 +3026,17 @@ export default function ChatView(props: ChatViewProps) { ); return null; } + const api = readEnvironmentApi(environmentId); + if (!api) { + // No live connection for this thread's environment and it isn't a known + // saved-environment we can name a reconnect for (e.g. its registry entry + // is gone). Never fail silently — that is the bug this guard replaces. + setThreadError( + activeThread.id, + "Can't reach this thread's environment to rewind. Reconnect it and try again.", + ); + return null; + } if (phase === "running" || isSendBusy || isConnecting) { setThreadError(activeThread.id, "Interrupt the current turn before rewinding."); return null; From 23fb86364431449181b0d1006015261ed2fba59e Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Thu, 11 Jun 2026 00:46:06 -0400 Subject: [PATCH 36/40] fix(rewind): refresh the timeline when forward messages are abandoned The rewind hid forward messages server-side but the web UI never updated: thread.conversation-rewound was missing from the isThreadDetailEvent allowlist (so it never streamed to the client) and from the store reducer (so the client never dropped the messages). Wire both, mirroring the existing thread.reverted path, cutting on the target message's createdAt. Co-Authored-By: Claude Opus 4.8 --- apps/server/src/ws.ts | 2 + apps/web/src/store.test.ts | 154 ++++++++++++++++++ apps/web/src/store.ts | 66 ++++++++ .../client-runtime/src/threadDetailReducer.ts | 67 ++++++++ 4 files changed, 289 insertions(+) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5171732da642..9fbdbc4f9d0c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -115,6 +115,7 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< | "thread.activity-appended" | "thread.turn-diff-completed" | "thread.reverted" + | "thread.conversation-rewound" | "thread.session-set"; } > { @@ -124,6 +125,7 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< event.type === "thread.activity-appended" || event.type === "thread.turn-diff-completed" || event.type === "thread.reverted" || + event.type === "thread.conversation-rewound" || event.type === "thread.session-set" ); } diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 59ebd0cea0c6..f4ebee0fdb55 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -1050,3 +1050,157 @@ describe("incremental orchestration updates", () => { expect(threadsOf(next)[0]?.latestTurn?.sourceProposedPlan).toBeUndefined(); }); }); + +describe("thread.conversation-rewound", () => { + function makeRewindThread(): Thread { + return makeThread({ + messages: [ + { + id: MessageId.make("message-0"), + role: "user", + text: "first prompt", + turnId: TurnId.make("turn-1"), + createdAt: "2026-02-13T00:01:00.000Z", + streaming: false, + }, + { + id: MessageId.make("message-1"), + role: "assistant", + text: "first reply", + turnId: TurnId.make("turn-1"), + createdAt: "2026-02-13T00:01:30.000Z", + streaming: false, + }, + { + // Rewind target — this prompt and everything after it must drop. + id: MessageId.make("message-2"), + role: "user", + text: "second prompt", + turnId: TurnId.make("turn-2"), + createdAt: "2026-02-13T00:02:00.000Z", + streaming: false, + }, + { + id: MessageId.make("message-3"), + role: "assistant", + text: "second reply", + turnId: TurnId.make("turn-2"), + createdAt: "2026-02-13T00:02:30.000Z", + streaming: false, + }, + ], + activities: [ + { + id: EventId.make("activity-before"), + tone: "info", + kind: "step", + summary: "before cut", + payload: {}, + turnId: TurnId.make("turn-1"), + createdAt: "2026-02-13T00:01:45.000Z", + }, + { + id: EventId.make("activity-after"), + tone: "info", + kind: "step", + summary: "after cut", + payload: {}, + turnId: TurnId.make("turn-2"), + createdAt: "2026-02-13T00:02:15.000Z", + }, + ], + proposedPlans: [ + { + id: "plan-before", + turnId: TurnId.make("turn-1"), + planMarkdown: "before", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-02-13T00:01:50.000Z", + updatedAt: "2026-02-13T00:01:50.000Z", + }, + { + id: "plan-after", + turnId: TurnId.make("turn-2"), + planMarkdown: "after", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-02-13T00:02:20.000Z", + updatedAt: "2026-02-13T00:02:20.000Z", + }, + ], + turnDiffSummaries: [ + { + turnId: TurnId.make("turn-1"), + completedAt: "2026-02-13T00:01:55.000Z", + status: "ready", + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("checkpoint-1"), + assistantMessageId: MessageId.make("message-1"), + files: [], + }, + { + turnId: TurnId.make("turn-2"), + completedAt: "2026-02-13T00:02:55.000Z", + status: "ready", + checkpointTurnCount: 2, + checkpointRef: CheckpointRef.make("checkpoint-2"), + assistantMessageId: MessageId.make("message-3"), + files: [], + }, + ], + }); + } + + it("drops the target prompt and every forward row, keeping earlier ones", () => { + const state = makeState(makeRewindThread()); + + const next = applyOrchestrationEvent( + state, + makeEvent("thread.conversation-rewound", { + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("message-2"), + turnCount: 1, + }), + localEnvironmentId, + ); + + const thread = threadsOf(next)[0]; + // Messages strictly before the target's createdAt are retained; the target + // prompt and the forward assistant reply are gone. + expect(thread?.messages.map((message) => message.id)).toEqual([ + MessageId.make("message-0"), + MessageId.make("message-1"), + ]); + // Forward activity and proposed plan dropped; earlier ones retained. + expect(thread?.activities.map((activity) => activity.id)).toEqual([ + EventId.make("activity-before"), + ]); + expect(thread?.proposedPlans.map((plan) => plan.id)).toEqual(["plan-before"]); + // Forward checkpoint dropped; latestTurn now points at the last retained one. + expect(thread?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([ + TurnId.make("turn-1"), + ]); + expect(thread?.latestTurn?.turnId).toBe(TurnId.make("turn-1")); + }); + + it("is a no-op when the target message is not in the local list", () => { + const state = makeState(makeRewindThread()); + + const next = applyOrchestrationEvent( + state, + makeEvent("thread.conversation-rewound", { + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("message-missing"), + turnCount: 1, + }), + localEnvironmentId, + ); + + const thread = threadsOf(next)[0]; + expect(thread?.messages).toHaveLength(4); + expect(thread?.turnDiffSummaries).toHaveLength(2); + expect(thread?.activities).toHaveLength(2); + expect(thread?.proposedPlans).toHaveLength(2); + }); +}); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 7d995b5ea751..22545f4f8d4f 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1624,6 +1624,72 @@ function applyEnvironmentOrchestrationEvent( }; }); + case "thread.conversation-rewound": + return updateThreadState(state, event.payload.threadId, (thread) => { + // Non-destructive rewind (ADR-0002). The server marks the rewound prompt + // and everything after it `abandoned`; mirror that hide on the client by + // cutting at the target message's `createdAt`. The rewound prompt itself + // leaves the timeline (it is pre-filled into the composer), so we keep + // strictly-earlier rows: `createdAt < cut`. The payload carries no + // checkpoint list, so this is a createdAt cut, not a turn-retain like + // `thread.reverted`. + const target = thread.messages.find( + (message) => message.id === event.payload.messageId, + ); + if (target === undefined) { + // Defensive no-op: target not in the local list (e.g. trimmed by the + // MAX_THREAD_MESSAGES cap). Leave the thread untouched. + return thread; + } + const cut = target.createdAt; + const messages = thread.messages + .filter((message) => message.createdAt < cut) + .slice(-MAX_THREAD_MESSAGES); + // Checkpoints/turns carry only `completedAt`; under the linear-conversation + // invariant a turn that completed before the cut was also requested before + // it, so `completedAt < cut` mirrors the server's `requested_at < cut`. + const turnDiffSummaries = thread.turnDiffSummaries + .filter((entry) => entry.completedAt < cut) + .toSorted( + (left, right) => + (left.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER) - + (right.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER), + ) + .slice(-MAX_THREAD_CHECKPOINTS); + // The server projection only flips `abandoned` on messages + turns for a + // rewind; it has no rewound case for plans/activities. We still cut them + // by `createdAt < cut` here (spec-directed) so the forward timeline UI is + // consistent — forward plans/activities belong to now-abandoned turns. + const proposedPlans = thread.proposedPlans + .filter((plan) => plan.createdAt < cut) + .slice(-MAX_THREAD_PROPOSED_PLANS); + const activities = thread.activities.filter((activity) => activity.createdAt < cut); + const latestCheckpoint = turnDiffSummaries.at(-1) ?? null; + + return { + ...thread, + turnDiffSummaries, + messages, + proposedPlans, + activities, + pendingSourceProposedPlan: undefined, + latestTurn: + latestCheckpoint === null + ? null + : { + turnId: latestCheckpoint.turnId, + state: checkpointStatusToLatestTurnState( + (latestCheckpoint.status ?? "ready") as "ready" | "missing" | "error", + ), + requestedAt: latestCheckpoint.completedAt, + startedAt: latestCheckpoint.completedAt, + completedAt: latestCheckpoint.completedAt, + assistantMessageId: latestCheckpoint.assistantMessageId ?? null, + }, + updatedAt: event.occurredAt, + }; + }); + case "thread.activity-appended": return updateThreadState(state, event.payload.threadId, (thread) => { const activities = [ diff --git a/packages/client-runtime/src/threadDetailReducer.ts b/packages/client-runtime/src/threadDetailReducer.ts index 125f39428a62..a334bbf9c2c2 100644 --- a/packages/client-runtime/src/threadDetailReducer.ts +++ b/packages/client-runtime/src/threadDetailReducer.ts @@ -453,6 +453,73 @@ export function applyThreadDetailEvent( }; } + // ── Conversation rewind (ADR-0002, non-destructive) ───────────── + case "thread.conversation-rewound": { + // The server marks the rewound prompt and everything after it + // `abandoned`. Mirror that hide here by cutting at the target message's + // `createdAt`: keep strictly-earlier rows (`createdAt < cut`). The rewound + // prompt itself leaves the timeline (pre-filled into the composer). The + // payload carries no checkpoint list, so this is a createdAt cut rather + // than a turn-retain like `thread.reverted`. + const target = thread.messages.find((message) => message.id === event.payload.messageId); + if (target === undefined) { + // Defensive no-op: target not in the local list. + return { kind: "unchanged" }; + } + const cut = target.createdAt; + const messages = pipe( + thread.messages, + Arr.filter((message) => message.createdAt < cut), + Arr.takeRight(limits.maxMessages), + ); + // Checkpoints carry only `completedAt`; under the linear-conversation + // invariant `completedAt < cut` mirrors the server's `requested_at < cut`. + const checkpoints = pipe( + thread.checkpoints, + Arr.filter((entry) => entry.completedAt < cut), + Arr.sort(checkpointOrder), + Arr.takeRight(limits.maxCheckpoints), + ); + // The server projection only flips `abandoned` on messages + turns for a + // rewind (no rewound case for plans/activities). We still cut them by + // `createdAt < cut` (spec-directed) so the forward timeline stays consistent. + const proposedPlans = pipe( + thread.proposedPlans, + Arr.filter((plan) => plan.createdAt < cut), + Arr.takeRight(limits.maxProposedPlans), + ); + const activities = pipe( + thread.activities, + Arr.filter((activity) => activity.createdAt < cut), + ); + const latestCheckpoint = checkpoints.at(-1) ?? null; + + return { + kind: "updated", + thread: { + ...thread, + checkpoints, + messages, + proposedPlans, + activities, + latestTurn: + latestCheckpoint === null + ? null + : { + turnId: latestCheckpoint.turnId, + state: checkpointStatusToTurnState( + latestCheckpoint.status as "ready" | "missing" | "error", + ), + requestedAt: latestCheckpoint.completedAt, + startedAt: latestCheckpoint.completedAt, + completedAt: latestCheckpoint.completedAt, + assistantMessageId: latestCheckpoint.assistantMessageId ?? null, + }, + updatedAt: event.occurredAt, + }, + }; + } + // ── Activities ────────────────────────────────────────────────── case "thread.activity-appended": { const activities = pipe( From 3142b0e8382b1eaafb983e07acfe346628900090 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Thu, 11 Jun 2026 09:58:49 -0400 Subject: [PATCH 37/40] feat(rewind): cancel an un-sent conversation rewind (server) Inverse of the conversation rewind: after a rewind hides forward rows + sets the resume anchor but BEFORE the user re-sends, "Cancel rewind" un-hides the rows, clears the pending cursor, and restores the timeline. Race-free restore: the reactor un-abandons the message/turn rows DIRECTLY (committed) then dispatches the cancel bridge; ws.ts, on the resulting thread.conversation-rewind-cancelled event, re-queries getThreadDetailById and streams a FRESH restored snapshot the client applies by overwrite (the rewind dropped those rows from the client store, so a bare event can't bring them back). The ProjectionPipeline re-applies the un-abandon on replay/rebuild (reactors don't run there), idempotent with the reactor. New command/event strings: thread.conversation.rewind.cancel (client) thread.conversation-rewind.cancel.complete (server bridge) thread.conversation-rewind-cancel-requested (event) thread.conversation-rewind-cancelled (event) Repo inverses: unmarkAbandonedFromCreatedAt / unmarkAbandonedFromRequestedAt. No session stop/start (the rewind was never sent). v1 accepts the stacked- rewind over-restore edge per ADR-0002. Co-Authored-By: Claude Opus 4.8 --- .../Layers/ProjectionPipeline.rewind.test.ts | 141 ++++++++++++++++++ .../Layers/ProjectionPipeline.ts | 36 +++++ .../Layers/RewindReactor.test.ts | 84 +++++++++++ .../src/orchestration/Layers/RewindReactor.ts | 100 +++++++++++++ .../src/orchestration/decider.rewind.test.ts | 50 +++++++ apps/server/src/orchestration/decider.ts | 51 +++++++ .../Layers/ProjectionThreadMessages.test.ts | 51 +++++++ .../Layers/ProjectionThreadMessages.ts | 28 ++++ .../src/persistence/Layers/ProjectionTurns.ts | 25 ++++ .../Services/ProjectionThreadMessages.ts | 10 ++ .../persistence/Services/ProjectionTurns.ts | 10 ++ apps/server/src/ws.rewindCancel.test.ts | 98 ++++++++++++ apps/server/src/ws.ts | 55 ++++++- packages/contracts/src/orchestration.ts | 59 ++++++++ 14 files changed, 794 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/ws.rewindCancel.test.ts diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.rewind.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.rewind.test.ts index 1dbc69881547..f19e524859fb 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.rewind.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.rewind.test.ts @@ -357,4 +357,145 @@ it.layer(TestLayer)("OrchestrationProjectionPipeline (conversation-rewind)", (it ); }), ); + + // Cancel an un-sent rewind (ADR-0002): on replay/rebuild (where reactors don't + // run), `thread.conversation-rewind-cancelled` must RE-APPLY the un-abandon so + // the timeline restores. Idempotent with the reactor's direct un-mark. + it.effect("un-marks abandoned messages + turns for the cancelled event", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + const projectId = ProjectId.make("project-rwc"); + const threadId = ThreadId.make("thread-rwc"); + const t0 = "2026-07-01T00:00:00.000Z"; + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.make("evt-rwc-1"), + aggregateKind: "project", + aggregateId: projectId, + occurredAt: t0, + commandId: CommandId.make("cmd-rwc-1"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-rwc-1"), + metadata: {}, + payload: { + projectId, + title: "Project RWC", + workspaceRoot: "/tmp/project-rwc", + defaultModelSelection: null, + scripts: [], + createdAt: t0, + updatedAt: t0, + }, + }); + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.make("evt-rwc-2"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: t0, + commandId: CommandId.make("cmd-rwc-2"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-rwc-2"), + metadata: {}, + payload: { + threadId, + projectId, + title: "Thread RWC", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: t0, + updatedAt: t0, + }, + }); + + const sendMessage = (suffix: string, role: "user" | "assistant", createdAt: string) => + appendAndProject({ + type: "thread.message-sent", + eventId: EventId.make(`evt-rwc-msg-${suffix}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: createdAt, + commandId: CommandId.make(`cmd-rwc-msg-${suffix}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-rwc-msg-${suffix}`), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make(`message-${suffix}`), + role, + text: suffix, + turnId: null, + streaming: false, + createdAt, + updatedAt: createdAt, + }, + }); + + yield* sendMessage("a-user", "user", "2026-07-01T00:01:00.000Z"); + yield* sendMessage("a-assistant", "assistant", "2026-07-01T00:01:01.000Z"); + yield* sendMessage("b-user", "user", "2026-07-01T00:01:02.000Z"); // rewind target + yield* sendMessage("b-assistant", "assistant", "2026-07-01T00:01:03.000Z"); + + yield* appendAndProject({ + type: "thread.conversation-rewound", + eventId: EventId.make("evt-rwc-rewound"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: "2026-07-01T00:02:00.000Z", + commandId: CommandId.make("cmd-rwc-rewound"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-rwc-rewound"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make("message-b-user"), + turnCount: 0, + }, + }); + + // Sanity: two rows are hidden by the rewind. + const hidden = yield* sql<{ + readonly count: number; + }>`SELECT COUNT(*) AS count FROM projection_thread_messages WHERE thread_id = ${threadId} AND abandoned = 1`; + assert.equal(hidden[0]?.count, 2); + + // Cancel: re-applying the un-abandon restores every row. + yield* appendAndProject({ + type: "thread.conversation-rewind-cancelled", + eventId: EventId.make("evt-rwc-cancelled"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: "2026-07-01T00:03:00.000Z", + commandId: CommandId.make("cmd-rwc-cancelled"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-rwc-cancelled"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make("message-b-user"), + }, + }); + + const stillHidden = yield* sql<{ + readonly count: number; + }>`SELECT COUNT(*) AS count FROM projection_thread_messages WHERE thread_id = ${threadId} AND abandoned = 1`; + assert.equal(stillHidden[0]?.count, 0); + + const totalAfter = yield* sql<{ + readonly count: number; + }>`SELECT COUNT(*) AS count FROM projection_thread_messages WHERE thread_id = ${threadId}`; + assert.equal(totalAfter[0]?.count, 4); + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 60a14d903721..98f706956289 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -883,6 +883,25 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + // Cancel an un-sent conversation rewind (ADR-0002): the inverse of + // `thread.conversation-rewound` above. Un-hide the message rows the rewind + // had marked abandoned. The reactor already applied this directly (for the + // race-free live restore); this case re-applies it on a projection + // rebuild/replay, where reactors don't run. Idempotent. + case "thread.conversation-rewind-cancelled": { + const targetMessage = yield* projectionThreadMessageRepository.getByMessageId({ + messageId: event.payload.messageId, + }); + if (Option.isNone(targetMessage)) { + return; + } + yield* projectionThreadMessageRepository.unmarkAbandonedFromCreatedAt({ + threadId: event.payload.threadId, + fromCreatedAt: targetMessage.value.createdAt, + }); + return; + } + default: return; } @@ -1277,6 +1296,23 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + // Cancel an un-sent conversation rewind (ADR-0002): the inverse of the + // turn flip above. Re-applied on a projection rebuild/replay; idempotent + // with the reactor's direct un-abandon. + case "thread.conversation-rewind-cancelled": { + const targetMessage = yield* projectionThreadMessageRepository.getByMessageId({ + messageId: event.payload.messageId, + }); + if (Option.isNone(targetMessage)) { + return; + } + yield* projectionTurnRepository.unmarkAbandonedFromRequestedAt({ + threadId: event.payload.threadId, + fromRequestedAt: targetMessage.value.createdAt, + }); + return; + } + default: return; } diff --git a/apps/server/src/orchestration/Layers/RewindReactor.test.ts b/apps/server/src/orchestration/Layers/RewindReactor.test.ts index 6ccba644b0dc..b9817107fafe 100644 --- a/apps/server/src/orchestration/Layers/RewindReactor.test.ts +++ b/apps/server/src/orchestration/Layers/RewindReactor.test.ts @@ -440,6 +440,90 @@ describe("RewindReactor", () => { expect(harness.stopRecorder.calls).toEqual([]); }); + it("cancel un-abandons the hidden rows, clears the cursor, restores the timeline", async () => { + const harness = await createHarness(); + + // Seed a binding with the rewind marker already present (as a rewind would + // have left it) plus the durable resume id + an unknown field to preserve. + await Effect.runPromise( + harness.directory.upsert({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + resumeCursor: { + resume: "session-abc", + resumeSessionAt: "claude-uuid-a-assistant", + rewindPending: true, + custom: "keep-me", + }, + }), + ); + + await seedConversation(harness.engine); + + // Rewind to the b-user prompt (hides b-user forward). + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.conversation.rewind", + commandId: CommandId.make("cmd-rewind"), + threadId, + messageId: MessageId.make("message-b-user"), + createdAt: "2026-01-01T00:02:00.000Z", + }), + ); + + // Wait until b-user is hidden (the rewind landed). + await waitFor(async () => { + const detail = await Effect.runPromise(harness.snapshotQuery.getThreadDetailById(threadId)); + return ( + Option.isSome(detail) && + !detail.value.messages.some((message) => message.id === MessageId.make("message-b-user")) + ); + }); + + // Cancel the un-sent rewind. + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.conversation.rewind.cancel", + commandId: CommandId.make("cmd-rewind-cancel"), + threadId, + messageId: MessageId.make("message-b-user"), + createdAt: "2026-01-01T00:02:30.000Z", + }), + ); + + // The hidden prompt comes back into the active timeline. + await waitFor(async () => { + const detail = await Effect.runPromise(harness.snapshotQuery.getThreadDetailById(threadId)); + return ( + Option.isSome(detail) && + detail.value.messages.some((message) => message.id === MessageId.make("message-b-user")) + ); + }); + const detail = await Effect.runPromise(harness.snapshotQuery.getThreadDetailById(threadId)); + const activeMessageIds = Option.isSome(detail) + ? detail.value.messages.map((message) => message.id) + : []; + expect(activeMessageIds).toContain(MessageId.make("message-b-user")); + + // The pending cursor was cleared: rewindPending false, the anchor stripped, + // durable + unknown fields preserved. + let cursor: Record = {}; + await waitFor(async () => { + const binding = await Effect.runPromise(harness.directory.getBinding(threadId)); + if (Option.isNone(binding)) { + return false; + } + const raw = binding.value.resumeCursor; + cursor = raw && typeof raw === "object" ? (raw as Record) : {}; + return cursor.rewindPending === false; + }); + expect(cursor.rewindPending).toBe(false); + expect(cursor.resumeSessionAt).toBeUndefined(); + expect(cursor.resume).toBe("session-abc"); + expect(cursor.custom).toBe("keep-me"); + }); + it("file-restore restores the tree without touching message rows", async () => { const harness = await createHarness(); await seedConversation(harness.engine); diff --git a/apps/server/src/orchestration/Layers/RewindReactor.ts b/apps/server/src/orchestration/Layers/RewindReactor.ts index 1610a6bb2bda..be34a3e4672d 100644 --- a/apps/server/src/orchestration/Layers/RewindReactor.ts +++ b/apps/server/src/orchestration/Layers/RewindReactor.ts @@ -177,6 +177,88 @@ const make = Effect.gen(function* () { }); }); + // Cancel an un-sent rewind: the inverse of `setRewindCursor`. Strip the rewind + // anchor (`resumeSessionAt`) and clear `rewindPending`, preserving the durable + // `resume` id and any other adapter-owned blob fields so the session continues + // normally on the next turn (no rewind in flight). + const clearRewindCursor = Effect.fn("clearRewindCursor")(function* (input: { + readonly threadId: ThreadId; + }) { + const binding = yield* providerSessionDirectory.getBinding(input.threadId); + if (Option.isNone(binding)) { + yield* Effect.logWarning("rewind-cancel: no provider session binding to clear the marker", { + threadId: input.threadId, + }); + return; + } + const existingCursor = + binding.value.resumeCursor && typeof binding.value.resumeCursor === "object" + ? (binding.value.resumeCursor as Record) + : {}; + const { resumeSessionAt: _staleResumeSessionAt, ...preservedCursor } = existingCursor; + const nextCursor: RewindCursorBlob = { + ...preservedCursor, + rewindPending: false, + }; + yield* providerSessionDirectory.upsert({ + threadId: binding.value.threadId, + provider: binding.value.provider, + ...(binding.value.providerInstanceId !== undefined + ? { providerInstanceId: binding.value.providerInstanceId } + : {}), + resumeCursor: nextCursor, + }); + }); + + // Cancel an un-sent conversation rewind (ADR-0002). The inverse of + // `handleRewindRequested`. RACE-FREE RESTORE: the rewind REMOVED the forward + // rows from clients' stores, so the cancel must make them come back from the + // server. We un-abandon the rows DIRECTLY here (committed before any client + // re-read) and THEN dispatch the bridge command; ws.ts, on the resulting + // `-cancelled` event, re-queries and streams a fresh restored snapshot. We do + // NOT rely on the projector/stream ordering for the live restore. No session + // stop/start — the rewind was never sent, so the live session is unchanged. + const handleRewindCancelRequested = Effect.fn("handleRewindCancelRequested")(function* ( + event: Extract, + ) { + const now = yield* nowIso; + const threadId = event.payload.threadId; + const messageId = event.payload.messageId; + + // 1. Resolve the rewind anchor prompt's createdAt (unfiltered PK lookup — the + // anchor row is itself hidden by the rewind it is cancelling). + const target = yield* projectionThreadMessageRepository.getByMessageId({ messageId }); + if (Option.isNone(target)) { + return; + } + const cutAt = target.value.createdAt; + + // 2. Un-abandon the hidden message + turn rows DIRECTLY (committed now, so the + // ws.ts snapshot re-read below returns them restored). + yield* projectionThreadMessageRepository.unmarkAbandonedFromCreatedAt({ + threadId, + fromCreatedAt: cutAt, + }); + yield* projectionTurnRepository.unmarkAbandonedFromRequestedAt({ + threadId, + fromRequestedAt: cutAt, + }); + + // 3. Clear the pending cursor anchor so the next turn continues normally. + yield* clearRewindCursor({ threadId }); + + // 4. Dispatch the bridge; the decider emits `thread.conversation-rewind-cancelled`, + // which ws.ts uses to stream a fresh (now-restored) snapshot to clients and the + // ProjectionPipeline re-applies on replay (idempotent with step 2). + yield* orchestrationEngine.dispatch({ + type: "thread.conversation-rewind.cancel.complete", + commandId: yield* serverCommandId("conversation-rewind-cancel-complete"), + threadId, + messageId, + createdAt: now, + }); + }); + const handleRewindRequested = Effect.fn("handleRewindRequested")(function* ( event: Extract, ) { @@ -357,6 +439,23 @@ const make = Effect.gen(function* () { return; } + if (event.type === "thread.conversation-rewind-cancel-requested") { + yield* handleRewindCancelRequested(event).pipe( + Effect.catch((error) => + Effect.flatMap(nowIso, (createdAt) => + appendFailureActivity({ + threadId: event.payload.threadId, + kind: "rewind.failed", + summary: "Cancel rewind failed", + detail: error.message, + createdAt, + }), + ), + ), + ); + return; + } + if (event.type === "thread.files-restore-requested") { yield* handleFilesRestoreRequested(event).pipe( Effect.catch((error) => @@ -395,6 +494,7 @@ const make = Effect.gen(function* () { Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { if ( event.type !== "thread.conversation-rewind-requested" && + event.type !== "thread.conversation-rewind-cancel-requested" && event.type !== "thread.files-restore-requested" ) { return Effect.void; diff --git a/apps/server/src/orchestration/decider.rewind.test.ts b/apps/server/src/orchestration/decider.rewind.test.ts index 48ecd2780bdd..57c170746d50 100644 --- a/apps/server/src/orchestration/decider.rewind.test.ts +++ b/apps/server/src/orchestration/decider.rewind.test.ts @@ -122,6 +122,56 @@ it.layer(NodeServices.layer)("decider conversation-rewind", (it) => { }), ); + // Cancel an un-sent rewind (ADR-0002): the client cancel command records the + // request the rewind reactor consumes to un-abandon the hidden rows. + it.effect("emits thread.conversation-rewind-cancel-requested for the cancel command", () => + Effect.gen(function* () { + const readModel = yield* seedReadModel; + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.conversation.rewind.cancel", + commandId: CommandId.make("cmd-rewind-cancel"), + threadId: ThreadId.make("thread-rw"), + messageId: MessageId.make("message-target"), + createdAt: now, + }, + readModel, + }); + const event = Array.isArray(result) ? result[0] : result; + assert.equal(event?.type, "thread.conversation-rewind-cancel-requested"); + if (event?.type === "thread.conversation-rewind-cancel-requested") { + assert.equal(event.payload.threadId, "thread-rw"); + assert.equal(event.payload.messageId, "message-target"); + } + }), + ); + + // The server-only cancel bridge turns into the terminal cancelled event that + // ws.ts uses to stream a fresh restored snapshot. + it.effect( + "turns thread.conversation-rewind.cancel.complete into thread.conversation-rewind-cancelled", + () => + Effect.gen(function* () { + const readModel = yield* seedReadModel; + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.conversation-rewind.cancel.complete", + commandId: CommandId.make("cmd-rewind-cancel-complete"), + threadId: ThreadId.make("thread-rw"), + messageId: MessageId.make("message-target"), + createdAt: now, + }, + readModel, + }); + const event = Array.isArray(result) ? result[0] : result; + assert.equal(event?.type, "thread.conversation-rewind-cancelled"); + if (event?.type === "thread.conversation-rewind-cancelled") { + assert.equal(event.payload.threadId, "thread-rw"); + assert.equal(event.payload.messageId, "message-target"); + } + }), + ); + // The decoupled file-restore command records its own request, distinct from // the destructive checkpoint revert. it.effect("emits thread.files-restore-requested for the file-restore command", () => diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 8c804d38c1a9..fa85672aa710 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -585,6 +585,31 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + // Cancel an un-sent conversation rewind (ADR-0002). The decider records the + // request; the rewind reactor un-abandons the hidden rows + clears the + // pending cursor, then emits `thread.conversation-rewind-cancelled`. + case "thread.conversation.rewind.cancel": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.conversation-rewind-cancel-requested", + payload: { + threadId: command.threadId, + messageId: command.messageId, + createdAt: command.createdAt, + }, + }; + } + // Standalone file-restore (ADR-0002). Scaffold only: the file-restore reactor // (WS-2) consumes this and calls CheckpointStore.restoreCheckpoint, with no // conversation truncation. @@ -841,6 +866,32 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + // Server-only bridge (ADR-0002): the rewind reactor dispatches + // `thread.conversation-rewind.cancel.complete` after it has un-abandoned the + // hidden rows and cleared the pending cursor; this turns it into the terminal + // event the ProjectionPipeline re-applies on replay and ws.ts uses to stream + // a fresh restored snapshot. + case "thread.conversation-rewind.cancel.complete": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.conversation-rewind-cancelled", + payload: { + threadId: command.threadId, + messageId: command.messageId, + }, + }; + } + case "thread.activity.append": { yield* requireThread({ readModel, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index 1dec7140d172..c3872b21c0a3 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -235,4 +235,55 @@ layer("ProjectionThreadMessageRepository", (it) => { assert.equal(flippedAgain, 0); }), ); + + // Cancel an un-sent rewind (ADR-0002): the exact inverse of the mark — un-hide + // the rows a rewind had marked abandoned so the active timeline restores. + it.effect("unmarkAbandonedFromCreatedAt restores rows a rewind had hidden", () => + Effect.gen(function* () { + const repository = yield* ProjectionThreadMessageRepository; + const threadId = ThreadId.make("thread-rewind-unmark"); + + const seed = (suffix: string, role: "user" | "assistant", createdAt: string) => + repository.upsert({ + messageId: MessageId.make(`message-${suffix}`), + threadId, + turnId: null, + role, + text: suffix, + isStreaming: false, + createdAt, + updatedAt: createdAt, + }); + + yield* seed("a-user", "user", "2026-03-01T10:00:00.000Z"); + yield* seed("a-assistant", "assistant", "2026-03-01T10:00:01.000Z"); + yield* seed("b-user", "user", "2026-03-01T10:00:02.000Z"); // rewind target + yield* seed("b-assistant", "assistant", "2026-03-01T10:00:03.000Z"); + + // Rewind: hide the target prompt and everything forward of it. + yield* repository.markAbandonedFromCreatedAt({ + threadId, + fromCreatedAt: "2026-03-01T10:00:02.000Z", + }); + + // Cancel: un-hide exactly those rows. + const restored = yield* repository.unmarkAbandonedFromCreatedAt({ + threadId, + fromCreatedAt: "2026-03-01T10:00:02.000Z", + }); + assert.equal(restored, 2); // b-user, b-assistant. + + const rows = yield* repository.listByThreadId({ threadId }); + assert.equal(rows.length, 4); + const abandoned = rows.filter((row) => row.abandoned === true); + assert.equal(abandoned.length, 0); + + // Idempotent: a second cancel un-hides nothing new. + const restoredAgain = yield* repository.unmarkAbandonedFromCreatedAt({ + threadId, + fromCreatedAt: "2026-03-01T10:00:02.000Z", + }); + assert.equal(restoredAgain, 0); + }), + ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 1846ef21f332..1b0c4fb5b941 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -215,6 +215,22 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { `, }); + // Cancel an un-sent rewind: the exact inverse of the abandon flip above. + // Un-hide rows that a rewind had marked abandoned at/after the anchor. + const unmarkProjectionThreadMessageRowsAbandoned = SqlSchema.findAll({ + Request: MarkProjectionThreadMessagesAbandonedInput, + Result: MarkedMessageIdRowSchema, + execute: ({ threadId, fromCreatedAt }) => + sql` + UPDATE projection_thread_messages + SET abandoned = 0 + WHERE thread_id = ${threadId} + AND abandoned = 1 + AND created_at >= ${fromCreatedAt} + RETURNING message_id AS "messageId" + `, + }); + const upsert: ProjectionThreadMessageRepositoryShape["upsert"] = (row) => upsertProjectionThreadMessageRow(row).pipe( Effect.mapError(toPersistenceSqlError("ProjectionThreadMessageRepository.upsert:query")), @@ -254,12 +270,24 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { Effect.map((rows) => rows.length), ); + const unmarkAbandonedFromCreatedAt: ProjectionThreadMessageRepositoryShape["unmarkAbandonedFromCreatedAt"] = + (input) => + unmarkProjectionThreadMessageRowsAbandoned(input).pipe( + Effect.mapError( + toPersistenceSqlError( + "ProjectionThreadMessageRepository.unmarkAbandonedFromCreatedAt:query", + ), + ), + Effect.map((rows) => rows.length), + ); + return { upsert, getByMessageId, listByThreadId, deleteByThreadId, markAbandonedFromCreatedAt, + unmarkAbandonedFromCreatedAt, } satisfies ProjectionThreadMessageRepositoryShape; }); diff --git a/apps/server/src/persistence/Layers/ProjectionTurns.ts b/apps/server/src/persistence/Layers/ProjectionTurns.ts index dc667c8ffb50..d7292eb71f19 100644 --- a/apps/server/src/persistence/Layers/ProjectionTurns.ts +++ b/apps/server/src/persistence/Layers/ProjectionTurns.ts @@ -275,6 +275,21 @@ const makeProjectionTurnRepository = Effect.gen(function* () { `, }); + // Cancel an un-sent rewind: the exact inverse of the abandon flip above. + const unmarkProjectionTurnsAbandoned = SqlSchema.findAll({ + Request: MarkProjectionTurnsAbandonedInput, + Result: MarkedTurnRowSchema, + execute: ({ threadId, fromRequestedAt }) => + sql` + UPDATE projection_turns + SET abandoned = 0 + WHERE thread_id = ${threadId} + AND abandoned = 1 + AND requested_at >= ${fromRequestedAt} + RETURNING turn_id AS "turnId" + `, + }); + const upsertByTurnId: ProjectionTurnRepositoryShape["upsertByTurnId"] = (row) => upsertProjectionTurnById(row).pipe( Effect.mapError( @@ -374,6 +389,15 @@ const makeProjectionTurnRepository = Effect.gen(function* () { Effect.map((rows) => rows.filter((row) => row.turnId !== null).length), ); + const unmarkAbandonedFromRequestedAt: ProjectionTurnRepositoryShape["unmarkAbandonedFromRequestedAt"] = + (input) => + unmarkProjectionTurnsAbandoned(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionTurnRepository.unmarkAbandonedFromRequestedAt:query"), + ), + Effect.map((rows) => rows.filter((row) => row.turnId !== null).length), + ); + return { upsertByTurnId, replacePendingTurnStart, @@ -384,6 +408,7 @@ const makeProjectionTurnRepository = Effect.gen(function* () { clearCheckpointTurnConflict, deleteByThreadId, markAbandonedFromRequestedAt, + unmarkAbandonedFromRequestedAt, } satisfies ProjectionTurnRepositoryShape; }); diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index 907cc75c94cb..05f3b4227e68 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -111,6 +111,16 @@ export interface ProjectionThreadMessageRepositoryShape { readonly markAbandonedFromCreatedAt: ( input: MarkProjectionThreadMessagesAbandonedInput, ) => Effect.Effect; + + /** + * Cancel an un-sent conversation rewind (ADR-0002): the exact inverse of + * `markAbandonedFromCreatedAt` — flip `abandoned = 0` on every row at or after + * `fromCreatedAt` that a prior rewind had hidden, so the timeline restores. + * Returns the number of rows un-hidden. + */ + readonly unmarkAbandonedFromCreatedAt: ( + input: MarkProjectionThreadMessagesAbandonedInput, + ) => Effect.Effect; } /** diff --git a/apps/server/src/persistence/Services/ProjectionTurns.ts b/apps/server/src/persistence/Services/ProjectionTurns.ts index ae407e11f53b..ab2326d753cf 100644 --- a/apps/server/src/persistence/Services/ProjectionTurns.ts +++ b/apps/server/src/persistence/Services/ProjectionTurns.ts @@ -183,6 +183,16 @@ export interface ProjectionTurnRepositoryShape { readonly markAbandonedFromRequestedAt: ( input: MarkProjectionTurnsAbandonedInput, ) => Effect.Effect; + + /** + * Cancel an un-sent conversation rewind (ADR-0002): the exact inverse of + * `markAbandonedFromRequestedAt` — flip `abandoned = 0` on every turn requested + * at or after `fromRequestedAt` that a prior rewind had hidden. Returns the + * number of concrete (`turn_id IS NOT NULL`) turns un-hidden. + */ + readonly unmarkAbandonedFromRequestedAt: ( + input: MarkProjectionTurnsAbandonedInput, + ) => Effect.Effect; } export class ProjectionTurnRepository extends Context.Service< diff --git a/apps/server/src/ws.rewindCancel.test.ts b/apps/server/src/ws.rewindCancel.test.ts new file mode 100644 index 000000000000..d4d023089873 --- /dev/null +++ b/apps/server/src/ws.rewindCancel.test.ts @@ -0,0 +1,98 @@ +import { + CommandId, + CorrelationId, + EventId, + type OrchestrationEvent, + type OrchestrationThread, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import { mapThreadDetailStreamItem } from "./ws.ts"; + +const threadId = ThreadId.make("thread-rwc-ws"); + +const baseEventFields = { + sequence: 1, + eventId: EventId.make("evt-1"), + aggregateKind: "thread" as const, + aggregateId: threadId, + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: CommandId.make("cmd-1"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-1"), + metadata: {}, +}; + +const cancelledEvent: OrchestrationEvent = { + ...baseEventFields, + type: "thread.conversation-rewind-cancelled", + payload: { threadId, messageId: "message-target" as never }, +}; + +const sessionSetEvent: OrchestrationEvent = { + ...baseEventFields, + type: "thread.session-set", + payload: { + threadId, + session: { + provider: "codex" as never, + providerInstanceId: ProviderInstanceId.make("codex"), + status: "ready", + } as never, + } as never, +}; + +const restoredThread = { + id: threadId, + messages: [{ id: "message-target" }], +} as unknown as OrchestrationThread; + +it.effect("emits a fresh snapshot for the cancelled event (race-free restore)", () => + Effect.gen(function* () { + const item = yield* mapThreadDetailStreamItem({ + event: cancelledEvent, + threadId, + snapshotSequence: 42, + getThreadDetailById: () => Effect.succeed(Option.some(restoredThread)), + }); + assert.equal(item.kind, "snapshot"); + if (item.kind === "snapshot") { + assert.equal(item.snapshot.snapshotSequence, 42); + assert.equal(item.snapshot.thread, restoredThread); + } + }), +); + +it.effect("forwards a bare event for non-cancel events", () => + Effect.gen(function* () { + const item = yield* mapThreadDetailStreamItem({ + event: sessionSetEvent, + threadId, + snapshotSequence: 7, + getThreadDetailById: () => Effect.die(new Error("must not query for non-cancel events")), + }); + assert.equal(item.kind, "event"); + if (item.kind === "event") { + assert.equal(item.event.type, "thread.session-set"); + } + }), +); + +it.effect("falls back to forwarding the event when the thread detail is missing", () => + Effect.gen(function* () { + const item = yield* mapThreadDetailStreamItem({ + event: cancelledEvent, + threadId, + snapshotSequence: 5, + getThreadDetailById: () => Effect.succeed(Option.none()), + }); + assert.equal(item.kind, "event"); + if (item.kind === "event") { + assert.equal(item.event.type, "thread.conversation-rewind-cancelled"); + } + }), +); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9fbdbc4f9d0c..29aa8251cbd7 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -28,6 +28,8 @@ import { OrchestrationDispatchCommandError, type OrchestrationEvent, type OrchestrationShellStreamEvent, + type OrchestrationThread, + type OrchestrationThreadStreamItem, OrchestrationGetFullThreadDiffError, OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, @@ -116,6 +118,7 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< | "thread.turn-diff-completed" | "thread.reverted" | "thread.conversation-rewound" + | "thread.conversation-rewind-cancelled" | "thread.session-set"; } > { @@ -126,10 +129,48 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< event.type === "thread.turn-diff-completed" || event.type === "thread.reverted" || event.type === "thread.conversation-rewound" || + event.type === "thread.conversation-rewind-cancelled" || event.type === "thread.session-set" ); } +/** + * Map a single live thread-detail event into a subscribeThread stream item + * (ADR-0002). Cancel-rewind un-abandons rows the rewind had REMOVED from the + * client's store, so a bare event can't restore them: re-query the (now-restored) + * thread detail and emit a fresh SNAPSHOT the client applies by overwrite. The + * reactor commits the un-abandon BEFORE emitting the event, so this re-read is + * guaranteed restored. All other events forward as a bare `{ kind: "event" }`. + * Extracted so the cancel-rewind snapshot path is unit-testable. + */ +export function mapThreadDetailStreamItem(input: { + readonly event: OrchestrationEvent; + readonly threadId: ThreadId; + readonly snapshotSequence: number; + readonly getThreadDetailById: ( + threadId: ThreadId, + ) => Effect.Effect, E>; +}): Effect.Effect { + const { event, threadId, snapshotSequence, getThreadDetailById } = input; + if (event.type !== "thread.conversation-rewind-cancelled") { + return Effect.succeed({ kind: "event", event }); + } + return getThreadDetailById(threadId).pipe( + Effect.map( + (threadDetailOption): OrchestrationThreadStreamItem => + Option.isNone(threadDetailOption) + ? { kind: "event", event } + : { + kind: "snapshot", + snapshot: { snapshotSequence, thread: threadDetailOption.value }, + }, + ), + Effect.catch(() => + Effect.succeed({ kind: "event", event }), + ), + ); +} + const PROVIDER_STATUS_DEBOUNCE_MS = 200; const RPC_REQUIRED_SCOPE = new Map([ @@ -970,10 +1011,16 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => event.aggregateId === input.threadId && isThreadDetailEvent(event), ), - Stream.map((event) => ({ - kind: "event" as const, - event, - })), + // Cancel-rewind streams a fresh restored snapshot; all other + // events forward as bare events. See `mapThreadDetailStreamItem`. + Stream.mapEffect((event) => + mapThreadDetailStreamItem({ + event, + threadId: input.threadId, + snapshotSequence, + getThreadDetailById: projectionSnapshotQuery.getThreadDetailById, + }), + ), ); return Stream.concat( diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 2f2c002f1421..b98c2c2acee3 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -656,6 +656,21 @@ const ThreadConversationRewindCommand = Schema.Struct({ createdAt: IsoDateTime, }); +// Cancel an UN-SENT conversation rewind (ADR-0002). The inverse of +// `thread.conversation.rewind`: after a rewind hid forward rows + set the +// resume anchor but BEFORE the user re-sent, this un-hides the rows, clears the +// pending cursor anchor, and resets the composer. No session stop/start — the +// reactor un-abandons the rows directly then emits the cancelled event, which +// streams a fresh restored snapshot to clients (ws.ts). Distinct from a full +// rewind; carries the same target `messageId` (the rewind anchor prompt). +const ThreadConversationRewindCancelCommand = Schema.Struct({ + type: Schema.Literal("thread.conversation.rewind.cancel"), + commandId: CommandId, + threadId: ThreadId, + messageId: MessageId, + createdAt: IsoDateTime, +}); + // Standalone "restore the working tree to turn N" — the file half of the old // bundled revert, decoupled (ADR-0002) so "also restore files" / the post-rewind // "restore files to this point too" action can move the tree WITHOUT truncating @@ -693,6 +708,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, ThreadConversationRewindCommand, + ThreadConversationRewindCancelCommand, ThreadFilesRestoreCommand, ThreadSessionStopCommand, ]); @@ -716,6 +732,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, ThreadConversationRewindCommand, + ThreadConversationRewindCancelCommand, ThreadFilesRestoreCommand, ThreadSessionStopCommand, ]); @@ -821,6 +838,18 @@ const ThreadConversationRewindCompleteCommand = Schema.Struct({ createdAt: IsoDateTime, }); +// Server-only bridge command (ADR-0002): the rewind reactor dispatches this +// once it has un-abandoned the hidden rows and cleared the pending cursor; the +// decider turns it into the terminal `thread.conversation-rewind-cancelled` +// event (mirrors `thread.conversation-rewind.complete`). Never sent by clients. +const ThreadConversationRewindCancelCompleteCommand = Schema.Struct({ + type: Schema.Literal("thread.conversation-rewind.cancel.complete"), + commandId: CommandId, + threadId: ThreadId, + messageId: MessageId, + createdAt: IsoDateTime, +}); + const InternalOrchestrationCommand = Schema.Union([ ThreadSessionSetCommand, ThreadMessageAssistantDeltaCommand, @@ -831,6 +860,7 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadActivityAppendCommand, ThreadRevertCompleteCommand, ThreadConversationRewindCompleteCommand, + ThreadConversationRewindCancelCompleteCommand, ]); export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; @@ -860,6 +890,8 @@ export const OrchestrationEventType = Schema.Literals([ "thread.reverted", "thread.conversation-rewind-requested", "thread.conversation-rewound", + "thread.conversation-rewind-cancel-requested", + "thread.conversation-rewind-cancelled", "thread.files-restore-requested", "thread.session-stop-requested", "thread.session-set", @@ -1038,6 +1070,23 @@ export const ThreadConversationRewoundPayload = Schema.Struct({ turnCount: NonNegativeInt, }); +// Cancel an un-sent conversation rewind (ADR-0002). The decider emits the +// `-requested` event from the client cancel command; the rewind reactor +// consumes it, un-abandons the hidden rows + clears the pending cursor, then +// emits the terminal `-cancelled` event the ProjectionPipeline applies (for +// replay idempotency) and ws.ts uses to stream a fresh restored snapshot. +export const ThreadConversationRewindCancelRequestedPayload = Schema.Struct({ + threadId: ThreadId, + messageId: MessageId, + createdAt: IsoDateTime, +}); + +export const ThreadConversationRewindCancelledPayload = Schema.Struct({ + threadId: ThreadId, + // The rewind anchor prompt whose forward rows are being un-hidden. + messageId: MessageId, +}); + // Standalone file-restore (ADR-0002): restore the working tree to turn N // WITHOUT truncating the conversation. The decider emits this `-requested` // event; the file-restore reactor (WS-2) consumes it and calls @@ -1196,6 +1245,16 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.conversation-rewound"), payload: ThreadConversationRewoundPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.conversation-rewind-cancel-requested"), + payload: ThreadConversationRewindCancelRequestedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.conversation-rewind-cancelled"), + payload: ThreadConversationRewindCancelledPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.files-restore-requested"), From f6c0d9a22a2f003872df60ff61f6a4ee57e90fe3 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Thu, 11 Jun 2026 09:58:57 -0400 Subject: [PATCH 38/40] feat(rewind): "Cancel rewind" composer banner (web) After any conversation rewind, surface a "Cancel rewind" banner until the user re-sends. onCancelRewind dispatches thread.conversation.rewind.cancel; the restored messages arrive via the server snapshot (no client message manipulation) and the composer (pre-filled with the rewound prompt) is reset. pendingRewind is cleared on send, on cancel, or overwritten by a re-rewind. TDZ-safe ref pattern (rewindCancelRef) mirrors the existing files-ahead banner. Co-Authored-By: Claude Opus 4.8 --- apps/web/src/components/ChatView.tsx | 83 +++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fab3586d12d9..477ef6f8ce79 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -111,7 +111,13 @@ import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; -import { ChevronDownIcon, FileClockIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; +import { + ChevronDownIcon, + FileClockIcon, + TriangleAlertIcon, + Undo2Icon, + WifiOffIcon, +} from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; @@ -875,6 +881,12 @@ export default function ChatView(props: ChatViewProps) { // The post-rewind "restore files" handler is defined far below; the banner // (built earlier in render) calls it through this ref to avoid TDZ ordering. const rewindRestoreFilesRef = useRef<(messageId: MessageId) => void>(() => {}); + // After ANY rewind (conversation-only or +files) but BEFORE the user re-sends, + // they may cancel it: un-hide the forward messages, clear the pending cursor, + // reset the composer. Cleared on send, on cancel, or overwritten by a re-rewind. + const [pendingRewind, setPendingRewind] = useState<{ messageId: MessageId } | null>(null); + // Same TDZ pattern as rewindRestoreFilesRef for the "cancel rewind" handler. + const rewindCancelRef = useRef<() => void>(() => {}); const [respondingRequestIds, setRespondingRequestIds] = useState([]); const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< ApprovalRequestId[] @@ -1450,6 +1462,23 @@ export default function ChatView(props: ChatViewProps) { ), }); } + if (pendingRewind) { + items.push({ + id: `rewind-cancel:${pendingRewind.messageId}`, + variant: "info", + icon: , + title: "Conversation rewound — not sent yet", + description: + "Edit and send the prompt to continue from here, or cancel the rewind to restore the hidden messages.", + actions: ( + + ), + dismissLabel: "Cancel rewind", + onDismiss: () => rewindCancelRef.current(), + }); + } if (rewindFilesAhead) { items.push({ id: `rewind-files-ahead:${rewindFilesAhead.messageId}`, @@ -1496,6 +1525,7 @@ export default function ChatView(props: ChatViewProps) { handleReconnectActiveEnvironment, isRevertingCheckpoint, navigate, + pendingRewind, reconnectingEnvironmentId, rewindFilesAhead, showVersionMismatchBanner, @@ -3075,6 +3105,8 @@ export default function ChatView(props: ChatViewProps) { return; } prefillComposerWithPrompt(messageId); + // Offer "Cancel rewind" until the user re-sends (ADR-0002). + setPendingRewind({ messageId }); // If a checkpoint exists for this point, the working tree is now ahead of // the restored conversation — offer to restore it too via the inline note. const turnCount = revertTurnCountByUserMessageId.get(messageId); @@ -3126,6 +3158,51 @@ export default function ChatView(props: ChatViewProps) { ); rewindRestoreFilesRef.current = onRestoreFilesForMessage; + // Cancel an un-sent rewind (ADR-0002): the inverse of onRewindConversation. + // Dispatch the cancel command; the server un-abandons the hidden rows and + // streams a fresh restored snapshot back (no client message manipulation + // needed). On success, reset the composer and clear the pending banners. + const onCancelRewind = useCallback(async () => { + if (!pendingRewind) return; + const ctx = resolveRewindContext(); + if (!ctx) return; + setThreadError(ctx.thread.id, null); + try { + await ctx.api.orchestration.dispatchCommand({ + type: "thread.conversation.rewind.cancel", + commandId: newCommandId(), + threadId: ctx.thread.id, + messageId: pendingRewind.messageId, + createdAt: new Date().toISOString(), + }); + } catch (err) { + setThreadError( + ctx.thread.id, + err instanceof Error ? err.message : "Failed to cancel the rewind.", + ); + return; + } + // The restored messages arrive via the server snapshot. Reset the composer + // (it was pre-filled with the rewound prompt) and drop the pending banners. + setComposerDraftPrompt(composerDraftTarget, ""); + promptRef.current = ""; + composerRef.current?.resetCursorState({ + cursor: collapseExpandedComposerCursor("", 0), + prompt: "", + detectTrigger: true, + }); + setPendingRewind(null); + setRewindFilesAhead(null); + }, [ + composerDraftTarget, + composerRef, + pendingRewind, + resolveRewindContext, + setComposerDraftPrompt, + setThreadError, + ]); + rewindCancelRef.current = () => void onCancelRewind(); + const onRewindConversationAndFiles = useCallback( async (messageId: MessageId) => { const turnCount = revertTurnCountByUserMessageId.get(messageId); @@ -3458,8 +3535,10 @@ export default function ChatView(props: ChatViewProps) { createdAt: messageCreatedAt, }); turnStartSucceeded = true; - // A fresh turn supersedes any pending "files are still ahead" note. + // A fresh turn supersedes any pending "files are still ahead" note and the + // "cancel rewind" affordance (the rewind has now been sent). setRewindFilesAhead(null); + setPendingRewind(null); })().catch(async (err: unknown) => { if ( !turnStartSucceeded && From 2d23a6b47712261545053ef93b9c63e361c16096 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Thu, 11 Jun 2026 11:19:41 -0400 Subject: [PATCH 39/40] fix(rewind): persist the turn-final assistant uuid so rewind can anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conversation-rewind anchor reads `provider_message_uuid`, but on a live streaming turn that column was never populated: the assistant message is finalized AND forgotten at `item.completed`, which fires before the `turn.completed` event that carries the real `assistantMessageUuid`. By the time the turn-completed uuid-stamping loop runs, the per-turn message-id set is already empty, so it iterates nothing and writes no uuid. (Transcript replay was unaffected because it stamps the uuid on the always-projected delta command.) With no persisted uuid, `resolveAnchorUuid` returned undefined, the rewind cursor got no `resumeSessionAt`, and every rewind silently degraded to a full session resume — the model kept remembering the rewound-away turns. Fix: at `turn.completed`, derive the turn-final assistant message from the projection (which survives the `item.completed` forget) and stamp the uuid onto it directly. The projection COALESCEs the uuid onto the existing row, leaving its text untouched. Verified end to end on a fresh local chat: rewinding past a fact now makes Claude genuinely forget it (binary honors `resumeSessionAt`), and the assistant rows carry their provider uuids in the DB. Co-Authored-By: Claude Opus 4.8 --- .../Layers/ProviderRuntimeIngestion.test.ts | 82 ++++++++++++++++++- .../Layers/ProviderRuntimeIngestion.ts | 45 ++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 3b2411cba2a3..ceb8046a420d 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -35,6 +35,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts"; import { ProviderService, type ProviderServiceShape, @@ -47,6 +48,7 @@ import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { ProjectionThreadMessageRepository } from "../../persistence/Services/ProjectionThreadMessages.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -191,7 +193,10 @@ async function waitForThread( describe("ProviderRuntimeIngestion", () => { let runtime: ManagedRuntime.ManagedRuntime< - OrchestrationEngineService | ProviderRuntimeIngestionService | ProjectionSnapshotQuery, + | OrchestrationEngineService + | ProviderRuntimeIngestionService + | ProjectionSnapshotQuery + | ProjectionThreadMessageRepository, unknown > | null = null; let scope: Scope.Closeable | null = null; @@ -236,6 +241,7 @@ describe("ProviderRuntimeIngestion", () => { const layer = ProviderRuntimeIngestionLive.pipe( Layer.provideMerge(orchestrationLayer), Layer.provideMerge(projectionSnapshotLayer), + Layer.provideMerge(ProjectionThreadMessageRepositoryLive), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)), @@ -309,12 +315,18 @@ describe("ProviderRuntimeIngestion", () => { updatedAt: createdAt, }); + const messageRepo = await runtime.runPromise( + Effect.service(ProjectionThreadMessageRepository), + ); + return { engine, readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), emit: provider.emit, setProviderSession: provider.setSession, drain, + listMessages: (threadId: ThreadId = asThreadId("thread-1")) => + Effect.runPromise(messageRepo.listByThreadId({ threadId })), }; } @@ -719,6 +731,74 @@ describe("ProviderRuntimeIngestion", () => { expect(message?.streaming).toBe(false); }); + it("stamps the turn-final assistant provider uuid (rewind anchor) onto the persisted message", async () => { + // Reproduces the conversation-rewind anchor bug (ADR-0002): on a live + // streaming turn the assistant message is finalized AND forgotten at + // `item.completed`, which fires before `turn.completed` arrives carrying the + // real `assistantMessageUuid`. The uuid must still land on the persisted row + // (the rewind anchor reads `provider_message_uuid`); the read model omits the + // field, so we assert against the projection repository directly. + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const anchorUuid = "11111111-2222-3333-4444-555555555555"; + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-uuid-turn-started"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-uuid"), + }); + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-uuid-delta"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-uuid"), + itemId: asItemId("item-uuid"), + payload: { streamKind: "assistant_text", delta: "Got it" }, + }); + // Finalizes AND forgets the assistant message id — before the uuid arrives. + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-uuid-item"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-uuid"), + itemId: asItemId("item-uuid"), + payload: { itemType: "assistant_message", status: "completed" }, + }); + // Canonical turn.completed carrying the uuid (payload defined → bypasses the + // legacy normalizer, exactly like the real ClaudeAdapter emit). + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-uuid-turn-completed"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-uuid"), + payload: { state: "completed", assistantMessageUuid: anchorUuid }, + }); + + await waitForThread( + harness.readModel, + (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-uuid" && !message.streaming, + ) && entry.session?.status === "ready", + ); + await harness.drain(); + + const messages = await harness.listMessages(); + const assistant = messages.find((row) => row.messageId === "assistant:item-uuid"); + expect(assistant).toBeDefined(); + expect(assistant?.providerMessageUuid).toBe(anchorUuid); + }); + it("uses assistant item completion detail when no assistant deltas were streamed", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 9821f9dc6321..a9c1964976a5 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -107,6 +107,26 @@ function hasAssistantMessageForTurn( return false; } +// The turn-final assistant message id, derived from the projection (ascending +// creation order). Used at `turn.completed` to stamp the rewind-anchor uuid: +// the streaming `item.completed` finalizes AND forgets the in-memory message id +// before `turn.completed` arrives with the uuid, so the projection — which +// survives the forget — is the only reliable source for the anchor row. +function findLastAssistantMessageIdForTurn( + messages: ReadonlyArray, + turnId: TurnId, +): MessageId | undefined { + let lastId: MessageId | undefined; + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]; + if (!message || message.role !== "assistant" || message.turnId !== turnId) { + continue; + } + lastId = message.id; + } + return lastId; +} + function findMessageById( messages: ReadonlyArray, messageId: MessageId, @@ -1545,6 +1565,31 @@ const make = Effect.gen(function* () { }), { concurrency: 1 }, ).pipe(Effect.asVoid); + + // On a live streaming turn the assistant message is finalized AND + // forgotten at `item.completed` (which fires before this + // `turn.completed`), so `assistantMessageIds` above is empty and the + // loop never stamps the rewind-anchor uuid. Derive the turn-final + // assistant row from the projection — which survives the forget — and + // stamp the uuid onto it directly. The projection COALESCEs the uuid + // onto the existing row, leaving its text untouched (ADR-0002). + if (assistantMessageUuid !== undefined) { + const turnFinalAssistantMessageId = findLastAssistantMessageIdForTurn(messages, turnId); + if ( + turnFinalAssistantMessageId !== undefined && + !assistantMessageIds.includes(turnFinalAssistantMessageId) + ) { + yield* orchestrationEngine.dispatch({ + type: "thread.message.assistant.complete", + commandId: yield* providerCommandId(event, "assistant-uuid-stamp"), + threadId: thread.id, + messageId: turnFinalAssistantMessageId, + turnId, + providerMessageUuid: assistantMessageUuid, + createdAt: now, + }); + } + } yield* clearAssistantMessageIdsForTurn(thread.id, turnId); yield* clearAssistantSegmentStateForTurn(thread.id, turnId); From a93019fec34aa845267fd71963edb17c39c7d9c2 Mon Sep 17 00:00:00 2001 From: Ziyad Akl Date: Thu, 11 Jun 2026 11:35:02 -0400 Subject: [PATCH 40/40] test(rewind): verify the anchor lands on the turn-final segment of a multi-segment turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up from the thermo-review of 2d23a6b4: the original fix was only covered for single-segment turns. A tool-using turn produces multiple assistant segments, and the rewind anchor must be the turn-FINAL one. This drives a two-segment turn (distinct created_at per segment, as in production) and asserts the uuid lands on the second segment and the earlier segment stays uuid-null. Passes against the deployed fix — confirms multi-segment anchoring is correct. Co-Authored-By: Claude Opus 4.8 --- .../Layers/ProviderRuntimeIngestion.test.ts | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index ceb8046a420d..d5a39004b8c1 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -799,6 +799,97 @@ describe("ProviderRuntimeIngestion", () => { expect(assistant?.providerMessageUuid).toBe(anchorUuid); }); + it("stamps the rewind-anchor uuid on the turn-FINAL assistant segment of a multi-segment turn", async () => { + // A tool-using turn produces two assistant segments (text, tool call, more + // text). The rewind anchor is the turn-FINAL assistant message, so the uuid + // must land on the second segment and NOT on the earlier one. Segments carry + // distinct created_at (each pinned to its own first delta), as in production. + const harness = await createHarness(); + const t1 = "2026-01-01T00:00:01.000Z"; + const t2 = "2026-01-01T00:00:05.000Z"; + const finalUuid = "99999999-8888-7777-6666-555555555555"; + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-multi-turn-started"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-multi"), + }); + // First assistant segment (before the tool call). + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-multi-delta-a"), + provider: ProviderDriverKind.make("codex"), + createdAt: t1, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-multi"), + itemId: asItemId("item-multi-a"), + payload: { streamKind: "assistant_text", delta: "Let me check. " }, + }); + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-multi-item-a"), + provider: ProviderDriverKind.make("codex"), + createdAt: t1, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-multi"), + itemId: asItemId("item-multi-a"), + payload: { itemType: "assistant_message", status: "completed" }, + }); + // Second (turn-final) assistant segment — this is the rewind anchor. + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-multi-delta-b"), + provider: ProviderDriverKind.make("codex"), + createdAt: t2, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-multi"), + itemId: asItemId("item-multi-b"), + payload: { streamKind: "assistant_text", delta: "The answer is 42." }, + }); + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-multi-item-b"), + provider: ProviderDriverKind.make("codex"), + createdAt: t2, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-multi"), + itemId: asItemId("item-multi-b"), + payload: { itemType: "assistant_message", status: "completed" }, + }); + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-multi-turn-completed"), + provider: ProviderDriverKind.make("codex"), + createdAt: t2, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-multi"), + payload: { state: "completed", assistantMessageUuid: finalUuid }, + }); + + await waitForThread( + harness.readModel, + (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-multi-b" && !message.streaming, + ) && entry.session?.status === "ready", + ); + await harness.drain(); + + const messages = await harness.listMessages(); + const firstSegment = messages.find((row) => row.messageId === "assistant:item-multi-a"); + const finalSegment = messages.find((row) => row.messageId === "assistant:item-multi-b"); + expect(firstSegment).toBeDefined(); + expect(finalSegment).toBeDefined(); + // Anchor lands on the turn-final segment... + expect(finalSegment?.providerMessageUuid).toBe(finalUuid); + // ...and NOT on the earlier mid-turn segment. + expect(firstSegment?.providerMessageUuid ?? null).toBeNull(); + }); + it("uses assistant item completion detail when no assistant deltas were streamed", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z";