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/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000000..a11f2d3ac849 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,54 @@ +# 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. + +**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**. +- 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, 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. +- **"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/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. diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 837c32fc4fd9..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 { @@ -59,6 +62,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 { @@ -354,16 +358,36 @@ 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, 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/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/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index 6155af8858a9..29388c61a371 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -8,8 +8,10 @@ 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"; import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; describe("OrchestrationReactor", () => { @@ -54,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: () => { @@ -63,6 +74,14 @@ describe("OrchestrationReactor", () => { drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(ResumeSeedReactor, { + start: () => { + started.push("resume-seed-reactor"); + return Effect.void; + }, + }), + ), ), ); @@ -74,7 +93,9 @@ describe("OrchestrationReactor", () => { "provider-runtime-ingestion", "provider-command-reactor", "checkpoint-reactor", + "rewind-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..222a4aecb2d1 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -8,19 +8,25 @@ 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"; 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; const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { yield* providerRuntimeIngestion.start(); yield* providerCommandReactor.start(); yield* checkpointReactor.start(); + yield* rewindReactor.start(); yield* threadDeletionReactor.start(); + yield* resumeSeedReactor.start(); }); return { 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..f19e524859fb --- /dev/null +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.rewind.test.ts @@ -0,0 +1,501 @@ +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"], + ); + }), + ); + + // 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 c201e1d9f9eb..98f706956289 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, @@ -857,6 +863,45 @@ 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; + } + + // 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; } @@ -1234,6 +1279,40 @@ 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; + } + + // 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/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/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 3b2411cba2a3..d5a39004b8c1 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,165 @@ 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("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"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 59787e0b5451..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, @@ -905,6 +925,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 +959,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 +1534,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,9 +1558,38 @@ 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); + + // 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); 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..b9817107fafe --- /dev/null +++ b/apps/server/src/orchestration/Layers/RewindReactor.test.ts @@ -0,0 +1,591 @@ +// @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; +} + +// 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(), + sendTurn: () => unsupported(), + interruptTurn: () => unsupported(), + respondToRequest: () => unsupported(), + respondToUserInput: () => unsupported(), + stopSession: (input) => + Effect.sync(() => { + stopRecorder.calls.push({ threadId: (input as { threadId: ThreadId }).threadId }); + }) as ReturnType, + listSessions: () => + 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({ + 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); + + 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 + // 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, stopRecorder)), + ), + 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, stopRecorder }; + } + + // 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); + + // 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", + 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"); + + // 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("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); + + // 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..be34a3e4672d --- /dev/null +++ b/apps/server/src/orchestration/Layers/RewindReactor.ts @@ -0,0 +1,513 @@ +/** + * 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, + }); + }); + + // 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, + ) { + 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 }); + + // 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. + 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.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) => + 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.conversation-rewind-cancel-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/orchestration/decider.resumeReplay.test.ts b/apps/server/src/orchestration/decider.resumeReplay.test.ts new file mode 100644 index 000000000000..8e9da14ff236 --- /dev/null +++ b/apps/server/src/orchestration/decider.resumeReplay.test.ts @@ -0,0 +1,187 @@ +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}`); + } + }), + ); + + // 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/orchestration/decider.rewind.test.ts b/apps/server/src/orchestration/decider.rewind.test.ts new file mode 100644 index 000000000000..57c170746d50 --- /dev/null +++ b/apps/server/src/orchestration/decider.rewind.test.ts @@ -0,0 +1,198 @@ +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); + } + }), + ); + + // 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", () => + 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 0d4af771ca8a..fa85672aa710 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, }, @@ -557,6 +560,81 @@ 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, + }, + }; + } + + // 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. + 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, @@ -619,6 +697,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, @@ -646,6 +727,39 @@ 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, + updatedAt: command.createdAt, + }, + }; + } + + 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, + ...(command.providerMessageUuid !== undefined + ? { providerMessageUuid: command.providerMessageUuid } + : {}), turnId: command.turnId ?? null, streaming: false, createdAt: command.createdAt, @@ -723,6 +837,61 @@ 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, + }, + }; + } + + // 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 b1f394a9e577..c3872b21c0a3 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -111,4 +111,179 @@ 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"); + } + }), + ); + + // 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); + }), + ); + + // 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 719191668869..1b0c4fb5b941 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, @@ -21,9 +22,13 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( Struct.assign({ 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 { @@ -34,9 +39,13 @@ 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 } : {}), + ...(row.providerMessageUuid !== null + ? { providerMessageUuid: row.providerMessageUuid } + : {}), }; } @@ -48,6 +57,18 @@ 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; + // 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, @@ -56,6 +77,8 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { role, text, attachments_json, + provider_message_uuid, + abandoned, is_streaming, created_at, updated_at @@ -74,6 +97,23 @@ 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} + ) + ), + COALESCE( + ${nextAbandoned}, + ( + SELECT abandoned + FROM projection_thread_messages + WHERE message_id = ${row.messageId} + ), + 0 + ), ${row.isStreaming ? 1 : 0}, ${row.createdAt}, ${row.updatedAt} @@ -88,6 +128,14 @@ 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 + ), + abandoned = COALESCE( + ${nextAbandoned}, + projection_thread_messages.abandoned + ), is_streaming = excluded.is_streaming, created_at = excluded.created_at, updated_at = excluded.updated_at @@ -107,6 +155,8 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { role, text, attachments_json AS "attachments", + provider_message_uuid AS "providerMessageUuid", + abandoned, is_streaming AS "isStreaming", created_at AS "createdAt", updated_at AS "updatedAt" @@ -128,6 +178,8 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { role, text, attachments_json AS "attachments", + provider_message_uuid AS "providerMessageUuid", + abandoned, is_streaming AS "isStreaming", created_at AS "createdAt", updated_at AS "updatedAt" @@ -146,6 +198,39 @@ 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" + `, + }); + + // 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")), @@ -174,11 +259,35 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { ), ); + const markAbandonedFromCreatedAt: ProjectionThreadMessageRepositoryShape["markAbandonedFromCreatedAt"] = + (input) => + markProjectionThreadMessageRowsAbandoned(input).pipe( + Effect.mapError( + toPersistenceSqlError( + "ProjectionThreadMessageRepository.markAbandonedFromCreatedAt:query", + ), + ), + 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.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..d7292eb71f19 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,37 @@ 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" + `, + }); + + // 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( @@ -304,7 +340,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 +380,24 @@ 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), + ); + + 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, @@ -346,6 +407,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () { getByTurnId, clearCheckpointTurnConflict, deleteByThreadId, + markAbandonedFromRequestedAt, + unmarkAbandonedFromRequestedAt, } satisfies ProjectionTurnRepositoryShape; }); diff --git a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts index 9ee5c82bb53d..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 }) => @@ -182,7 +208,31 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { toPersistenceDecodeError("ProviderSessionRuntimeRepository.list:rowToRuntime"), ), ), - { concurrency: "unbounded" }, + { concurrency: 16 }, + ), + ), + ); + + 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 }, ), ), ); @@ -198,6 +248,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { upsert, getByThreadId, list, + listByProjectId, deleteByThreadId, } satisfies ProviderSessionRuntimeRepositoryShape; }); 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..05f3b4227e68 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -28,12 +28,29 @@ 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)), + // 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, }); @@ -84,6 +101,26 @@ 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; + + /** + * 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 f3d5d5e47061..ab2326d753cf 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,26 @@ 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; + + /** + * 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/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/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 ff9bbfe0231d..33de1df14d70 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 { @@ -180,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; } @@ -412,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; @@ -429,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 } : {}), @@ -437,6 +456,7 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef ...(turnCountValue !== undefined && Number.isInteger(turnCountValue) && turnCountValue >= 0 ? { turnCount: turnCountValue } : {}), + ...(rewindPending ? { rewindPending: true } : {}), }; } @@ -1113,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, }; @@ -1598,6 +1633,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), }); @@ -2058,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); }); @@ -2928,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 } : {}), @@ -2947,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, @@ -3032,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); 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 0508f6c8cb34..13aa7f147c33 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -178,7 +178,23 @@ const makeProviderSessionDirectory = Effect.gen(function* () { Effect.forEach( rows, (row) => toRuntimeBinding(row, "ProviderSessionDirectory.listBindings"), - { concurrency: "unbounded" }, + { concurrency: 16 }, + ), + ), + ); + + 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 }, ), ), ); @@ -189,6 +205,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () { 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/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..d4b27c7b3f4b --- /dev/null +++ b/apps/server/src/resume/ResumeSeedReactor.ts @@ -0,0 +1,213 @@ +/** + * 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 { getSessionMessages } from "@anthropic-ai/claude-agent-sdk"; +import { + type ProjectId, + ProviderDriverKind, + type ProviderInstanceId, + type RuntimeMode, + type ThreadId, +} 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 { DEFAULT_REPLAY_MESSAGE_LIMIT, 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"); + +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 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 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, + }, + DEFAULT_REPLAY_MESSAGE_LIMIT, + totalMessages, + ); + yield* Effect.forEach(commands, (command) => orchestrationEngine.dispatch(command), { + discard: true, + }); + }); + + const start: ResumeSeedReactorShape["start"] = Effect.fn("start")(function* () { + yield* Effect.forkScoped( + Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { + if (event.type !== "thread.created") { + return Effect.void; + } + 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", { + threadId: payload.threadId, + cause: Cause.pretty(cause), + }), + ), + ); + }), + ); + }); + + return { start } satisfies ResumeSeedReactorShape; +}); + +export const ResumeSeedReactorLive = Layer.effect(ResumeSeedReactor, make); diff --git a/apps/server/src/resume/importableSessions.test.ts b/apps/server/src/resume/importableSessions.test.ts new file mode 100644 index 000000000000..43d1e8de5f46 --- /dev/null +++ b/apps/server/src/resume/importableSessions.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; + +import { + buildImportedSessionMap, + selectImportableSessions, + type ImportedBinding, + 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(), + importedSessions: new Map(), + }, + ); + + 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(), importedSessions: new Map() }, + ); + + 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"]), importedSessions: new Map() }, + ); + + expect(result.map((r) => r.sessionId)).toEqual(["t1"]); + }); + + it("flags an already-imported session and carries its existing thread id for rejoin", () => { + const result = selectImportableSessions([session({ sessionId: "t1" })], { + t3OriginSessionIds: new Set(), + 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 new file mode 100644 index 000000000000..b11462bcf9e8 --- /dev/null +++ b/apps/server/src/resume/importableSessions.ts @@ -0,0 +1,125 @@ +/** + * 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; + /** + * 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 field is branded `ThreadId` + * (see packages/contracts/src/resume.ts). + */ + 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, 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 (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) => 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.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 new file mode 100644 index 000000000000..ec957f6388fc --- /dev/null +++ b/apps/server/src/resume/importableSessionsService.ts @@ -0,0 +1,178 @@ +/** + * 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 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"; +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"; +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"; + +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, + }; +} + +/** + * 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; + + 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* () { + const sdkSessions = yield* Effect.tryPromise({ + try: () => sdkListSessions({ dir: projectCwd }), + catch: (cause) => + new ImportableSessionsError({ detail: "Claude listSessions failed", cause }), + }); + + const importedSessions = yield* buildImportedSessions(importedSessionsDeps, projectCwd); + + return selectImportableSessions(sdkSessions.map(toSessionInfo), { + // 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(), + importedSessions, + }); + }); + + return { listForProject } satisfies ImportableSessionsServiceShape; +}); + +export const ImportableSessionsServiceLive = Layer.effect(ImportableSessionsService, make); diff --git a/apps/server/src/resume/transcriptReplay.test.ts b/apps/server/src/resume/transcriptReplay.test.ts new file mode 100644 index 000000000000..35609bc5c991 --- /dev/null +++ b/apps/server/src/resume/transcriptReplay.test.ts @@ -0,0 +1,197 @@ +import { ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { buildReplayCommands, planReplayCommands } 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"); + // ADR-0002: the Claude transcript uuid is the rewind anchor. + expect(command.providerMessageUuid).toBe("u1"); + } + }); + + 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"); + // 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 + 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"); + } + }); + + 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"]); + }); + + 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", () => { + 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 new file mode 100644 index 000000000000..0b4e03d5d154 --- /dev/null +++ b/apps/server/src/resume/transcriptReplay.ts @@ -0,0 +1,205 @@ +/** + * 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 ""; +} + +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). 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. + * + * 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; + +/** + * 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, + totalMessageCount?: number, +): ReadonlyArray { + const total = totalMessageCount ?? messages.length; + if (total <= 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 ${total} 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, +): 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: 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") { + // 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, + // The rewind anchor uuid for this assistant message (ADR-0002). + providerMessageUuid: entry.uuid, + 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; +} 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/server/src/server.ts b/apps/server/src/server.ts index 5c0bbb8425af..e8efcbf4c883 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -43,7 +43,9 @@ 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"; import { ServerSettingsLive } from "./serverSettings.ts"; import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; @@ -133,7 +135,9 @@ 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), ); 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 da8e3f694db2..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, @@ -42,6 +44,7 @@ import { type TerminalError, type TerminalEvent, type TerminalMetadataStreamEvent, + ResumeError, WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; @@ -74,6 +77,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"; @@ -110,6 +117,8 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< | "thread.activity-appended" | "thread.turn-diff-completed" | "thread.reverted" + | "thread.conversation-rewound" + | "thread.conversation-rewind-cancelled" | "thread.session-set"; } > { @@ -119,10 +128,49 @@ 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.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([ @@ -152,6 +200,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 +291,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; @@ -961,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( @@ -984,6 +1040,25 @@ 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: 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" }, + ), [WS_METHODS.serverRefreshProviders]: (input) => observeRpcEffect( WS_METHODS.serverRefreshProviders, @@ -1433,6 +1508,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/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 7fc7669fe60d..477ef6f8ce79 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,8 +1,10 @@ import { type ApprovalRequestId, DEFAULT_MODEL, + DEFAULT_MODEL_BY_PROVIDER, defaultInstanceIdForDriver, type EnvironmentId, + type ImportableSession, type MessageId, type ModelSelection, type ProjectScript, @@ -47,8 +49,12 @@ 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 { RewindPicker } from "./chat/RewindMenu"; import { deriveCompletionDividerBeforeEntryId, derivePendingApprovals, @@ -105,7 +111,13 @@ 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, + Undo2Icon, + WifiOffIcon, +} from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; @@ -153,6 +165,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 { @@ -855,6 +869,24 @@ 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>(() => {}); + // 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[] @@ -1430,6 +1462,44 @@ 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}`, + 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}`, @@ -1453,8 +1523,11 @@ export default function ChatView(props: ChatViewProps) { }, [ activeEnvironmentUnavailableState, handleReconnectActiveEnvironment, + isRevertingCheckpoint, navigate, + pendingRewind, reconnectingEnvironmentId, + rewindFilesAhead, showVersionMismatchBanner, versionMismatch, versionMismatchDismissKey, @@ -1810,6 +1883,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; @@ -1838,24 +1956,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. Falls back to first-match-by-kind when no - // saved instance id is available or the instance no longer exists. - const activeProviderInstanceId = - activeThread?.session?.providerInstanceId ?? - activeThread?.modelSelection.instanceId ?? - activeProject?.defaultModelSelection?.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; @@ -2773,6 +2914,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); @@ -2832,6 +3012,254 @@ 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 localApi = readLocalApi(); + 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, + `Reconnect ${activeEnvironmentUnavailableLabel} before rewinding this thread.`, + ); + 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; + } + 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); + // 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); + 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; + + // 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); + 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); @@ -2884,6 +3312,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) @@ -3096,6 +3535,10 @@ export default function ChatView(props: ChatViewProps) { createdAt: messageCreatedAt, }); turnStartSucceeded = true; + // 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 && @@ -3701,18 +4144,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 @@ -3792,7 +4234,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} @@ -3830,6 +4273,74 @@ export default function ChatView(props: ChatViewProps) {
+ { + const project = activeProject; + if (!project) { + return; + } + const api = readEnvironmentApi(environmentId); + 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, + }, + }); + 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 }, + }), + ); + }} + /> + 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 +613,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, @@ -862,6 +821,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) label: "/model", description: "Switch response model for this thread", }, + { + id: "slash:resume", + type: "slash-command", + command: "resume", + label: "/resume", + description: "Resume a past terminal Claude chat in this project", + }, { id: "slash:plan", type: "slash-command", @@ -1500,6 +1466,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return; } + if (item.command === "resume") { + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + focusEditorAfterReplace: false, + }); + if (applied) { + setComposerHighlightedItemId(null); + useResumePickerStore.getState().requestOpen(); + } + return; + } void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.tsx index e59938b49585..c1712a303989 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.tsx @@ -61,7 +61,8 @@ function buildProps() { routeThreadKey: "environment-local:thread-1", onOpenTurnDiff: vi.fn(), revertTurnCountByUserMessageId: new Map(), - onRevertUserMessage: vi.fn(), + onRewindConversation: vi.fn(), + onRewindConversationAndFiles: vi.fn(), isRevertingCheckpoint: false, onImageExpand: vi.fn(), activeThreadEnvironmentId: EnvironmentId.make("environment-local"), diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index cbafa09ac484..b00bc6b3a427 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -103,7 +103,8 @@ function buildProps() { routeThreadKey: "environment-local:thread-1", onOpenTurnDiff: () => {}, 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/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; } diff --git a/apps/web/src/components/chat/ResumePicker.tsx b/apps/web/src/components/chat/ResumePicker.tsx new file mode 100644 index 000000000000..a2ac75c5656e --- /dev/null +++ b/apps/web/src/components/chat/ResumePicker.tsx @@ -0,0 +1,122 @@ +import { useEffect, useState } from "react"; + +import type { EnvironmentId, ImportableSession } from "@t3tools/contracts"; + +import { + Dialog, + DialogDescription, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "~/components/ui/dialog"; +import { readEnvironmentApi } from "~/environmentApi"; +import { useResumePickerStore } from "~/resumePickerStore"; + +interface ResumePickerProps { + environmentId: EnvironmentId; + /** The project's root working directory; the dir whose terminal sessions we list. */ + cwd: string | null; + /** Resume the chosen session into a new thread. The picker closes after. */ + onSelect: (session: ImportableSession) => void; +} + +type LoadStatus = "loading" | "ready" | "error"; + +/** + * The `/resume` picker: lists past terminal Claude sessions for the current + * 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, onSelect }: ResumePickerProps) { + const open = useResumePickerStore((store) => 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) => ( + + ))} +
+
+
+ ); +} 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} + + } + /> + ))} +
+
+
+ ); +} 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 }; +} 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/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/docs/adr/0001-cli-t3-conversation-continuity.md b/docs/adr/0001-cli-t3-conversation-continuity.md new file mode 100644 index 000000000000..5918892777b6 --- /dev/null +++ b/docs/adr/0001-cli-t3-conversation-continuity.md @@ -0,0 +1,38 @@ +# 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. + +## 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. 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). diff --git a/package.json b/package.json index 9b2aedf10005..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", @@ -41,6 +41,10 @@ "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", + "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/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( 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/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/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; + }; } diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 401928171c8b..b98c2c2acee3 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, }); @@ -638,6 +641,49 @@ 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, +}); + +// 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 +// 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, @@ -661,6 +707,9 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadApprovalRespondCommand, ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, + ThreadConversationRewindCommand, + ThreadConversationRewindCancelCommand, + ThreadFilesRestoreCommand, ThreadSessionStopCommand, ]); export type DispatchableClientOrchestrationCommand = @@ -682,6 +731,9 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadApprovalRespondCommand, ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, + ThreadConversationRewindCommand, + ThreadConversationRewindCancelCommand, + ThreadFilesRestoreCommand, ThreadSessionStopCommand, ]); export type ClientOrchestrationCommand = typeof ClientOrchestrationCommand.Type; @@ -701,6 +753,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, }); @@ -710,6 +765,24 @@ 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, +}); + +// 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), + // Claude transcript `uuid` for this replayed user message (rewind anchor). + providerMessageUuid: Schema.optional(Schema.String), createdAt: IsoDateTime, }); @@ -751,14 +824,43 @@ 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, +}); + +// 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, ThreadMessageAssistantCompleteCommand, + ThreadMessageUserRecordCommand, ThreadProposedPlanUpsertCommand, ThreadTurnDiffCompleteCommand, ThreadActivityAppendCommand, ThreadRevertCompleteCommand, + ThreadConversationRewindCompleteCommand, + ThreadConversationRewindCancelCompleteCommand, ]); export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; @@ -786,6 +888,11 @@ export const OrchestrationEventType = Schema.Literals([ "thread.user-input-response-requested", "thread.checkpoint-revert-requested", "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", "thread.proposed-plan-upserted", @@ -835,6 +942,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, }); @@ -884,6 +994,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, @@ -934,6 +1048,55 @@ 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, +}); + +// 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 +// CheckpointStore.restoreCheckpoint only. +export const ThreadFilesRestoreRequestedPayload = Schema.Struct({ + threadId: ThreadId, + turnCount: NonNegativeInt, + createdAt: IsoDateTime, +}); + export const ThreadSessionStopRequestedPayload = Schema.Struct({ threadId: ThreadId, createdAt: IsoDateTime, @@ -1072,6 +1235,31 @@ 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.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"), + 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; diff --git a/packages/contracts/src/resume.ts b/packages/contracts/src/resume.ts new file mode 100644 index 000000000000..c10585e6f3b8 --- /dev/null +++ b/packages/contracts/src/resume.ts @@ -0,0 +1,45 @@ +/** + * 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"; +import { ThreadId } from "./baseSchemas.ts"; + +/** 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, + /** + * 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(ThreadId), +}); +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, diff --git a/scripts/deploy-vps.sh b/scripts/deploy-vps.sh new file mode 100755 index 000000000000..a08a238d662c --- /dev/null +++ b/scripts/deploy-vps.sh @@ -0,0 +1,204 @@ +#!/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 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" # 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' INCOMING='$INCOMING' 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 + +# 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 "$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 + +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" + +[[ -d "$INSTALL_DIR" ]] || rfail "install dir not found: $INSTALL_DIR" +rm -f "$INCOMING/bin.smoke.mjs" # don't ship the smoke copy + +# 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" + +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] 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 '?')" +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, rm -rf $INSTALL_DIR && mv ${INSTALL_DIR}.bak-$TS $INSTALL_DIR, then restart $SERVICE." 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) { 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" 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); +}