From af80d78152d77dd4fc9468875a6b004c7849b2d2 Mon Sep 17 00:00:00 2001 From: Manas Raghuwanshi Date: Wed, 5 Aug 2026 19:15:09 +0530 Subject: [PATCH] feat(runtime): give each project its own conversations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Woopcode kept one conversation for the whole machine. `getConversationPath` returned a single global `conversation.json`, and `getExecutionLogPath` a single `execution-log.json` beside it. Three things followed, all of them live: `/new` destroyed history — it called `saveConversation([])`, and the guide said outright that there was no undo. History leaked between repositories, which the guide also documented, as a warning rather than a bug: a session started in one project restored into a turn taken in another, execution log included, which the model reads as a description of what has already been done *here*. And there was no way back to anything: no ids, no listing, no resume, no branching. A session is now one record under `sessions//.json`, keyed by the repository root. The slug carries a hash of the full path because slugifying alone maps `/a/b` and `/a-b` to the same name, which would silently merge two unrelated projects. `index.json` beside them is a derived cache: delete it, or let it fall behind the files, and it is rebuilt by scanning. A bare launch continues the newest session in the project, which is what the documentation has always promised, now correctly scoped. `-p` starts its own instead of inheriting whatever was open interactively — a surprise a scripted caller could not see or opt out of. Nothing is written until a turn has run, so starting Woopcode and quitting leaves nothing behind. Titles come from the first prompt rather than a model. A generated title costs a provider request per session on whichever of the three is configured, and adds a failure path at the moment a session is created; `/rename` is there for anyone who wants better. History from before sessions existed is imported once into a `legacy` bucket rather than attributed to whichever project is opened first — it was shared by every repository, so no project can honestly claim it. Take a turn in it and it is adopted by the project you are working in; open it to read and it stays put. Two windows on one session used to lose a turn, each writing the whole record so the later save discarded the earlier one. The single conversation file behaved the same way, so this is inherited rather than new, but it is silent loss of someone's work: it is now detected and answered by branching, leaving the other window untouched. --- CLAUDE.md | 70 +- README.md | 14 +- cli.ts | 8 +- commands/agent.tsx | 125 ++- commands/agentController.ts | 318 ++++++- commands/sessions.ts | 137 +++ commands/slash/commands.ts | 164 +++- config/config.ts | 121 +-- config/paths.ts | 53 +- config/sessions.ts | 841 ++++++++++++++++++ docs/guides/sessions-and-history.md | 132 ++- docs/reference/cli.md | 59 ++ docs/reference/configuration.md | 16 +- docs/reference/keyboard.md | 16 +- onboarding/test-reset.ts | 7 +- packages/tests/config/configRecovery.test.ts | 130 +-- packages/tests/config/sessionFlags.test.ts | 75 ++ packages/tests/config/sessions.test.ts | 595 +++++++++++++ packages/tests/e2e/chat.e2e.test.ts | 17 +- packages/tests/e2e/persistence.e2e.test.ts | 124 ++- .../tests/runtime/agentController.test.ts | 150 +++- packages/tests/slash/sessions.test.ts | 308 +++++++ site/src/docs/surface.json | 38 +- tui/src/app.overlay.test.tsx | 14 + tui/src/app.tsx | 4 + tui/src/components/SessionPicker.tsx | 237 +++++ tui/src/relative-time.ts | 23 + tui/src/store/ui-store.test.ts | 110 +++ tui/src/store/ui-store.ts | 64 ++ tui/src/types.ts | 1 + 30 files changed, 3696 insertions(+), 275 deletions(-) create mode 100644 commands/sessions.ts create mode 100644 config/sessions.ts create mode 100644 packages/tests/config/sessionFlags.test.ts create mode 100644 packages/tests/config/sessions.test.ts create mode 100644 packages/tests/slash/sessions.test.ts create mode 100644 tui/src/components/SessionPicker.tsx create mode 100644 tui/src/relative-time.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5c8518a..f19e186 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,5 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - Woopcode is a terminal-native coding agent (React Ink TUI + streaming agent loop) published to npm as `woopcode`. TypeScript throughout, running on Bun. ## Commands @@ -52,8 +50,6 @@ tools/ the tool registry The agent loop is `runtime/loop.ts`. It knows nothing about the interface, which is what lets the same loop drive both the TUI and the headless `--prompt` path; everything flows back out through `AgentCallbacks` (text, tool start, tool finish, error). -One structural fact to know before editing: - - **Approval is split in two.** `runtime/approval/classifier.ts` decides how risky a shell command is; `runtime/approval/policy.ts` decides whether that risk needs asking. Adding an approval mode is one entry in a table. A turn: `cli.ts` → `AgentController` (owns client, model, cancellation) → `buildRepositoryContext` in `config/config.ts` (package metadata, README, agent instruction files, structure — each capped, the whole capped again) → `agentLoop` in `runtime/loop.ts` (stream, collect tool calls, execute, feed results back; 40 iterations per stretch, then it asks via `onBudgetExhausted` — absent handler means nobody to ask, and exhaustion throws as before) → tools resolved via `toolRegistry` in `tools/index.ts`. @@ -139,35 +135,59 @@ matching one before starting that kind of work. Before you do any work, mention how you could verify that work — the test, command, or observation that would show it actually worked. If a change can't be verified, say so before making it. -### Never call work finished without a green run in the tree as it stands now +### Never call work finished without checking the tree as it stands now "Done", "shipped" and "verified" are claims about the working tree at the moment -you say them, not about a run from earlier in the session. Before any of those -words, run the suite and quote what it actually printed: +you say them, not a run from earlier. Before any of those words, run this and +quote what it printed: ```bash bun install # if package.json or bun.lock moved since your last install bun run verify --all # tsc + bun test + docs; the whole gate whatever changed ``` -Three ways a green run goes stale underneath a claim, all of which have happened -here: - -- **`node_modules` is stale.** A dependency landed on `main` and was never - installed locally, so every file that imports it fails with - `Cannot find package ''`. Twenty-four tests went red this way and it reads - exactly like a code break. `bun install` first when `package.json` has moved. -- **`main` moved after your branch went green.** Two branches that each pass CI - can merge into a red `main`: git merges them without a textual conflict while - one silently fails to honour a parameter the other added. CI tested each side, - never the merge. After a fetch, merge or rebase — or when `origin/main` is - ahead — re-run the gate against the merged tree before saying anything. -- **A bare `bun run verify` on a fully staged tree** prints "nothing to check" - and exits 0. That is not a pass; see the note under Commands. - -If a check was skipped or could not run, say which one and why, rather than a -sentence that implies a green run. A verification that is reported but not run is -worse than none, because it stops anyone else from looking. +Three ways a green run goes stale underneath a claim, all of which happened here: + +- **`node_modules` is stale** — a dependency landed on `main`, never installed + locally, and every import of it fails with `Cannot find package ''`. It went + red across twenty-four tests, reading exactly like a code break. +- **`main` moved after your branch went green** — two branches that each pass CI + can merge into a red `main`, git finding no textual conflict while one silently + fails to honour a parameter the other added. After any fetch, merge or rebase, + or whenever `origin/main` is ahead, re-run the gate on the merged tree. +- **A bare `bun run verify` on a staged tree** reports a pass it did not run; see Commands. + +**But a green suite is not a review.** It says the cases you thought of hold and +nothing about the rest — and "I checked everything" is a claim about the rest. +The sessions work went green on every gate and sweep; reading the diff afterwards +found seven defects, one of which wrote into the session `--fork-session` exists +to protect. Walk the diff and ask, per changed file: + +- **Every default argument** is an assumption about the caller (`forkSession` + defaulted to the current project; sessions elsewhere silently failed to fork). +- **Every `?? fallback` on a failure path** — what does it *do* when it fires? + (`fork() ?? original` turned a failed copy into a write to the original.) +- **Every read-modify-write** — two windows, two processes (the index lost a row + and the session stopped being listed at all). +- **Every counter or flag in a loop** — per-iteration or cumulative? (Prune's was + cumulative across projects.) +- **Every write recording that something happened** — does it create state where + the feature promises none? (`pruneIfDue` created the directory lazy creation + exists to avoid.) +- **Every optional CLI value** — what does the bare flag do? (`--resume` was + indistinguishable from `--continue`.) +- **Every empty string, empty array and zero** reaching a renderer. +- **Every caller you did not write** — a new signature is only as sound as the + stubs standing in for it elsewhere. + +Then about the checking itself: + +- **A regression test that has never failed proves nothing.** Revert the fix, + watch it go red, restore it — and confirm the revert actually applied. Twice a + mutation silently did not match, the suite stayed green, and the claim was void. +- **Name what you did not check.** Interactive input, live providers and other + processes are outside the suite's reach. Say so rather than letting a green run + imply them: a verification reported but not run stops anyone else looking. Conventional commits (`feat(tools):`, `fix(runtime):`, …), TypeScript strict mode, small focused functions. diff --git a/README.md b/README.md index d1d049b..3e84a91 100644 --- a/README.md +++ b/README.md @@ -89,10 +89,14 @@ The conversation, provider configuration, and local state are stored in: ### Session history -Conversation history is written after every turn using an atomic write, so an interrupted session does not leave a half-written transcript behind. Restarting Woopcode in the same repository resumes from that history; `/new` clears it. +A session is one saved conversation, belonging to the project it happened in. It is written after every turn using an atomic write, so an interrupted session does not leave a half-written transcript behind. Restarting Woopcode in the same repository resumes the newest one; starting it somewhere else does not, because sessions live under `sessions//` rather than in one file shared by every repository. + +`/new` starts a fresh session and keeps the old one — `/resume` goes back to it, `/rename` gives it a name, `/branch` copies it to try a second approach, and `woopcode --continue` / `--resume` do the same from the command line. Sessions are deleted 30 days after their last turn; `retentionDays` changes that and `0` keeps them forever. Only user and assistant messages are persisted, capped at the most recent messages. Tool calls and their results are dropped: they are the bulk of a long transcript, they only mean something to the turn that produced them, and persisting half of a call/result pair would make the restored history invalid for the provider. +History written by a version before sessions existed is imported once into a `legacy` bucket, reachable from the `/resume` picker with Ctrl+A. Resume it and take a turn and it becomes that project's session; open it only to read and it stays put. + ## Built-in tools Woopcode ships with a fixed set of tools, grouped by what they touch. @@ -146,7 +150,11 @@ Type `/` in the prompt to browse and autocomplete commands. | Command | Description | | ----------------------------- | ------------------------------------------------------------------------ | | `/help` | Show all available commands. | -| `/new` | Start a new conversation. | +| `/new` | Start a new conversation, keeping the current one. | +| `/resume [name-or-id]` | Switch to a previous conversation, or pick one from a list. | +| `/sessions` | List saved conversations for this project. | +| `/rename ` | Name the current conversation so it can be resumed by name. | +| `/branch [name]` | Copy this conversation and continue in the copy. | | `/provider [name]` | View or switch the configured provider. | | `/login ` | Authenticate from inside the app. | | `/logout [provider]` | Remove a saved provider key. | @@ -157,7 +165,7 @@ Type `/` in the prompt to browse and autocomplete commands. | `/version` | Show the Woopcode version. | | `/exit` | Quit Woopcode. | -Most commands have short aliases: `/h` or `/?` for help, `/clear` or `/reset` for `/new`, `/p` for provider, `/m` or `/model` for models, `/v` for version, `/q` or `/quit` for exit. +Most commands have short aliases: `/h` or `/?` for help, `/clear` or `/reset` for `/new`, `/r` for resume, `/ls` for sessions, `/fork` for branch, `/p` for provider, `/m` or `/model` for models, `/v` for version, `/q` or `/quit` for exit. | Key | Action | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | diff --git a/cli.ts b/cli.ts index aaf8c64..e0c2746 100755 --- a/cli.ts +++ b/cli.ts @@ -1,11 +1,12 @@ #!/usr/bin/env bun import { program } from "commander"; import { modelsCommand } from "./commands/models"; -import { agentCommand, runAgent } from "./commands/agent"; +import { addSessionOptions, agentCommand, runAgent } from "./commands/agent"; import { providerCommand } from "./commands/providers"; +import { sessionsCommand } from "./commands/sessions"; import { VERSION } from "./config/version"; -program +addSessionOptions(program) .name("woopcode") .description("Coding agent cli") .version(VERSION) @@ -20,7 +21,8 @@ program .action(runAgent) .addCommand(modelsCommand) .addCommand(agentCommand) - .addCommand(providerCommand); + .addCommand(providerCommand) + .addCommand(sessionsCommand); // A configuration failure (no provider, unusable key) is a normal outcome for // an automated caller, not a crash. Reporting it as a one-line message with a diff --git a/commands/agent.tsx b/commands/agent.tsx index 07424a7..c723c06 100644 --- a/commands/agent.tsx +++ b/commands/agent.tsx @@ -3,7 +3,7 @@ import { getConfig } from "../config/config"; import type { AgentCallbacks, TurnSummary } from "../config/types"; import { App, store } from "../tui/src"; import { render } from "ink"; -import { AgentController } from "./agentController"; +import { AgentController, type InitializeOptions } from "./agentController"; import { DEFAULT_MODEL_ID } from "../providers/client"; import type { HomeScreenData } from "../tui/src/components/HomeScreen"; import { ensureProviderConfigured } from "../onboarding"; @@ -28,15 +28,77 @@ export interface RunAgentOptions { model?: string; /** Headless only: path to write a JSONL record of the run to. */ events?: string; + /** Reopen the newest session in this project. */ + continue?: boolean; + /** A session name, id or id prefix to resume. `true` opens the picker. */ + resume?: string | boolean; + /** Start a fresh session even where one would otherwise be continued. */ + new?: boolean; + /** Branch whatever was resumed instead of writing into it. */ + forkSession?: boolean; + /** Name a new session, so it can be resumed by name later. */ + name?: string; + /** Headless only: run without ever writing a session file. */ + sessionPersistence?: boolean; } -export const agentCommand = new Command("agent") - .description("Runs the agent") - .option("-p, --prompt ", "run a single prompt headlessly and exit", "") - .option("--no-auto-approve", "with --prompt, reject tool edits and commands instead of approving them") - .option("-m, --model ", "model id to use for this run") - .option("--events ", "with --prompt, write a JSONL record of the run to this path") - .action(runAgent); +/** + * Turns the session flags into what `AgentController.initialize` takes. + * + * The two entry points differ in one default and it matters: the TUI continues + * where you left off, because that is what Woopcode has always done and what + * the documentation promises, while `-p` starts clean. A headless run used to + * inherit whatever the interactive session had been doing, which is a surprise + * for a scripted caller and impossible to opt out of. + */ +export function sessionOptionsFrom( + options: RunAgentOptions, + mode: "interactive" | "headless", +): InitializeOptions { + const resumeRef = typeof options.resume === "string" ? options.resume.trim() : ""; + // `--resume` with no value. Interactively that opens the picker; headlessly + // there is nobody to pick, so the caller has to say which session. + const wantsPicker = options.resume === true; + + if (wantsPicker && mode === "headless") { + throw new Error("--resume needs a session id when used with --prompt."); + } + + return { + ...(resumeRef ? { sessionRef: resumeRef } : {}), + continueLatest: + !options.new && + !resumeRef && + (options.continue === true || mode === "interactive"), + fork: options.forkSession === true, + ...(options.name ? { name: options.name } : {}), + persist: options.sessionPersistence !== false, + openPicker: wantsPicker, + }; +} + +/** + * The session flags, declared on both the root program and `agent` because + * either can be the one commander parses. + */ +export function addSessionOptions(command: Command): Command { + return command + .option("-c, --continue", "resume the newest session in this project") + .option("--resume [session]", "resume a session by name or id") + .option("--new", "start a fresh session instead of continuing") + .option("--fork-session", "with --continue or --resume, branch instead of writing into it") + .option("-n, --name ", "name a new session so it can be resumed by name") + .option("--no-session-persistence", "with --prompt, do not save the session"); +} + +export const agentCommand = addSessionOptions( + new Command("agent") + .description("Runs the agent") + .option("-p, --prompt ", "run a single prompt headlessly and exit", "") + .option("--no-auto-approve", "with --prompt, reject tool edits and commands instead of approving them") + .option("-m, --model ", "model id to use for this run") + .option("--events ", "with --prompt, write a JSONL record of the run to this path"), +).action(runAgent); /** * Entry point for both `woopcode` and `woopcode agent`. With `--prompt` the @@ -51,12 +113,19 @@ export async function runAgent(options: RunAgentOptions = {}, command?: Command) options.autoApprove !== false && globals?.autoApprove !== false; const model = options.model || globals?.model; const events = options.events || globals?.events; + // Session flags can land on either the root program or the subcommand, the + // same way --prompt does. + const merged: RunAgentOptions = { ...globals, ...options }; if (prompt) { - return runHeadless(prompt, autoApprove, { model, events }); + return runHeadless(prompt, autoApprove, { + model, + events, + session: sessionOptionsFrom(merged, "headless"), + }); } - return runInteractive(model); + return runInteractive(model, sessionOptionsFrom(merged, "interactive")); } /** @@ -75,7 +144,7 @@ async function resolveModel(override: string | undefined): Promise { async function runHeadless( prompt: string, autoApprove: boolean, - options: { model?: string; events?: string } = {}, + options: { model?: string; events?: string; session?: InitializeOptions } = {}, ) { registerCommands(); const { provider, apiKey } = await ensureProviderConfigured(); @@ -181,7 +250,12 @@ async function runHeadless( }; const controller = new AgentController(provider, apiKey, selectedModel, callbacks); - await controller.initialize(); + await controller.initialize(options.session); + + // On stderr, not stdout: stdout is the agent's answer and a caller pipes it. + // Printed so a script can follow up with `--resume ` on the same session. + const session = controller.currentSession(); + if (session) process.stderr.write(`session ${session.id}\n`); const onSigint = () => { controller.cancel(); @@ -215,7 +289,10 @@ async function runHeadless( export const EXIT_BUDGET_EXHAUSTED = 2; /** Runs the interactive TUI agent. */ -async function runInteractive(modelOverride?: string) { +async function runInteractive( + modelOverride?: string, + session: InitializeOptions = { continueLatest: true }, +) { // Register slash commands registerCommands(); @@ -316,7 +393,27 @@ async function runInteractive(modelOverride?: string) { }, }; const controller = new AgentController(provider, apiKey, selectedModel, callbacks); - await controller.initialize(); + try { + await controller.initialize(session); + } catch (error) { + // A --resume that names nothing is a usage error, not a crash: report it + // and stop rather than dropping the user into a session they did not ask + // for and might overwrite. + process.stderr.write(`✖ ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + + // Draws the resumed conversation. Without this the transcript is blank over a + // history the model can see, which reads as the history having been lost. + const resumed = controller.currentSession(); + if (resumed && resumed.messages.length > 0) { + store.hydrateTimeline(resumed.messages); + } + + // A bare `--resume` asks to choose. The newest session is loaded above so + // there is something behind the dialog and something to fall back to on Esc. + if (session.openPicker) store.openSessionPicker(); + const homeScreen = await buildHomeScreen(provider); const customStdin = new PassThrough() as any; diff --git a/commands/agentController.ts b/commands/agentController.ts index 6474fb8..49b5043 100644 --- a/commands/agentController.ts +++ b/commands/agentController.ts @@ -2,11 +2,25 @@ import { createProviderClient, DEFAULT_MODEL_ID } from "../providers/client"; import { MAX_REPO_CONTEXT_CHARS, buildRepositoryContext, - getConversation, - getExecutionLog, - saveConversation, - saveExecutionLog, + getConfig, + parseRetentionDays, } from "../config/config"; +import { + LEGACY_SLUG, + adoptSession, + createSession, + forkSession, + latestSession, + listSessions, + loadSession, + pruneIfDue, + reloadSession, + resolveSessionRef, + saveSession, + titleFromPrompt, + UNTITLED, + type SessionRecord, +} from "../config/sessions"; import { agentLoop } from "../runtime/loop"; import { PLAN_MODE_PROMPT } from "../config/systemPrompt"; import { @@ -38,6 +52,35 @@ export function isConversationalPrompt(prompt: string) { || /^(how are you|who are you|what can you do|what do you do|can you help me)[!?. ]*$/.test(text); } +/** + * Which session a run attaches to. + * + * Every field is optional and the empty object is the common case: the TUI + * continues where it left off, and `-p` starts clean. Kept optional rather than + * required because `initialize()` is called bare from both entry points and + * from every controller test. + */ +export interface InitializeOptions { + /** A name, id or id prefix to resume. */ + sessionRef?: string; + /** + * Reopen the newest session in this project rather than starting one. + * Defaults to true; the headless path sets it false. + */ + continueLatest?: boolean; + /** Branch whatever was resolved instead of writing into it. */ + fork?: boolean; + /** Name a new session at creation, as `--name` does. */ + name?: string | null; + /** False runs the turn without ever writing a session file. */ + persist?: boolean; + /** + * Open the session picker once the interface is up, for a bare `--resume`. + * Read by the launcher, not the controller. + */ + openPicker?: boolean; +} + export class AgentController { private conversation: Message[] = []; private repoContext = ""; @@ -57,6 +100,19 @@ export class AgentController { * first edit of the next session. */ private sessionMode: SessionMode = "build"; + /** + * The session this turn belongs to. Null until `initialize`, and null for the + * lifetime of a run started with persistence off. + */ + private session: SessionRecord | null = null; + private persistSession = true; + /** + * Whether a turn has been taken since this session was loaded. + * + * Only used to decide when migrated history is adopted into the current + * project: opening it to read it must not move it, but working in it should. + */ + private sessionTouched = false; private readonly callbacks: AgentCallbacks; constructor( @@ -150,6 +206,8 @@ export class AgentController { this.conversation.push(userMessage); this.pendingUserMessage = userMessage; + // The signal that this session is being worked in rather than just read. + this.sessionTouched = true; const conversation = [...this.conversation]; const conversational = isConversationalPrompt(prompt); @@ -250,10 +308,112 @@ export class AgentController { return response; } - async initialize() { - this.conversation = await getConversation(); - this.executionRecords = await getExecutionLog(); + /** + * Resolves which session this run belongs to and loads it. + * + * The options are optional and their defaults reproduce what Woopcode has + * always done interactively — reopen what you were last working on — now + * scoped to the repository you are in rather than shared by every repository + * on the machine. + */ + async initialize(options: InitializeOptions = {}) { + this.persistSession = options.persist !== false; + this.session = await this.resolveSession(options); + this.conversation = this.session ? [...this.session.messages] : []; + this.executionRecords = this.session ? [...this.session.executionLog] : []; this.repoContext = await buildRepositoryContext(); + + if (this.persistSession) { + // Retention runs at most once a day; see pruneIfDue. + try { + const config = await getConfig(); + await pruneIfDue(parseRetentionDays(config.retentionDays)); + } catch { + // Housekeeping must never stop a session from starting. + } + } + } + + private async resolveSession( + options: InitializeOptions, + ): Promise { + if (!this.persistSession) return null; + + try { + if (options.sessionRef) { + const found = await this.findSession(options.sessionRef); + // A ref that names nothing is the caller's error to report, not ours to + // paper over by silently starting somewhere else. + if (!found) throw new Error(`No session found matching "${options.sessionRef}"`); + return options.fork ? this.mustFork(found, options.name) : found; + } + + // Defaulted to true rather than false: continuing where you left off is + // what Woopcode has always done and what the documentation promises. The + // headless path opts out explicitly, because a scripted caller inheriting + // an interactive session is a surprise it cannot see coming. + if (options.continueLatest ?? true) { + const latest = await latestSession(); + if (!latest) return createSession({ name: options.name }); + const record = await loadSession(latest.id, latest.slug); + if (!record) return createSession({ name: options.name }); + return options.fork ? this.mustFork(record, options.name) : record; + } + + return createSession({ name: options.name }); + } catch (error) { + if (options.sessionRef) throw error; + // Anything else — an unreadable store, a permissions problem — degrades to + // a session that runs now and may not persist, rather than a launch that + // fails. + return createSession({ name: options.name }); + } + } + + /** + * Branches a session, or fails loudly. + * + * Never falls back to the original: `--fork-session` is a request *not* to + * write into it, so quietly continuing there when the copy could not be made + * would do the one thing the flag exists to prevent. + */ + private async mustFork( + source: SessionRecord, + name?: string | null, + ): Promise { + const copy = await forkSession(source.id, { + name: name ?? null, + // Migrated history has no project of its own, so say where it lives + // rather than letting the lookup fall back to a scan. + slug: source.cwd ? undefined : LEGACY_SLUG, + }); + if (!copy) { + throw new Error( + `Could not branch session ${source.id.slice(0, 8)}; it was left untouched.`, + ); + } + return copy; + } + + /** Loads whichever session a user-supplied reference names. */ + private async findSession(ref: string): Promise { + const scoped = await listSessions(); + let resolution = resolveSessionRef(ref, scoped); + + // Widened only when the current project has no answer, so a name used in + // two repositories still resolves to the local one. + if (resolution.status === "none") { + resolution = resolveSessionRef(ref, await listSessions({ scope: "all" })); + } + + if (resolution.status === "ambiguous") { + throw new Error( + `"${ref}" matches ${resolution.matches.length} sessions. Use the id, or /sessions to list them.`, + ); + } + if (resolution.status === "none") return null; + + return loadSession(resolution.session.id, resolution.session.slug); } /** @@ -318,14 +478,52 @@ export class AgentController { } /** - * Writes the conversation to disk. Failures are reported but never thrown: - * losing history is bad, but it must not take down a turn that otherwise - * succeeded. + * Writes the session to disk. Failures are reported but never thrown: losing + * history is bad, but it must not take down a turn that otherwise succeeded. + * + * This is also where a session first appears on disk. Nothing is written + * until a turn has run, so opening the TUI and quitting leaves no empty + * session behind for the picker to offer. */ private async persist() { + if (!this.persistSession || !this.session) return; + try { - await saveConversation(this.conversation); - await saveExecutionLog(this.executionRecords); + let pending: SessionRecord = { + ...this.session, + title: this.sessionTitle(), + messages: this.conversation, + executionLog: this.executionRecords, + }; + + // Two windows open on one session — two terminals in the same repository + // both continuing the newest one — each write the whole record, so the + // second silently discarded the first's turn. (The single conversation + // file behaved the same way; sessions inherited it rather than caused + // it.) Detected by the timestamp moving underneath us, and answered by + // branching: this turn is kept under a new id and the other window's work + // is left exactly as it was. + const onDisk = await reloadSession(this.session); + if (onDisk && onDisk.updated > this.session.updated) { + pending = { + ...pending, + id: crypto.randomUUID(), + name: null, + forkedFrom: this.session.id, + }; + this.callbacks.onStatus?.( + `⚠️ This conversation was changed by another Woopcode window. Continuing in a branch (${pending.id.slice(0, 8)}); nothing was overwritten.`, + ); + } + + // Migrated history belongs to no project. Once a turn has been taken in + // it, it belongs to this one — otherwise an hour's work would stay in the + // `legacy` bucket, absent from the list of the very repository it + // happened in. + this.session = + pending.cwd === null && this.sessionTouched + ? await adoptSession(pending) + : await saveSession(pending); } catch (error) { this.callbacks.onError?.( new Error( @@ -335,6 +533,102 @@ export class AgentController { } } + /** + * The title to store. A name set with /rename is authoritative; otherwise the + * first prompt names the session, and only until one exists. + */ + private sessionTitle(): string { + if (!this.session) return UNTITLED; + if (this.session.title && this.session.title !== UNTITLED) { + return this.session.title; + } + + const firstPrompt = this.conversation.find( + (message): message is Extract => + message.role === "user", + ); + return firstPrompt ? titleFromPrompt(firstPrompt.content) : UNTITLED; + } + + /** The session in flight, for the status line and the pickers. */ + currentSession(): SessionRecord | null { + return this.session; + } + + /** + * Messages in the live conversation, which is ahead of the session record + * between turns: the record only catches up when `persist` trims and writes. + */ + messageCount(): number { + return this.conversation.length; + } + + /** + * Points the controller at a different session and returns its messages so + * the caller can redraw the transcript. + * + * Every one of these refuses while a turn is running: swapping the + * conversation underneath a request in flight would attribute its reply to + * the wrong session. + */ + private async adopt(session: SessionRecord): Promise { + this.session = session; + this.conversation = [...session.messages]; + this.executionRecords = [...session.executionLog]; + this.pendingAssistantText = null; + this.pendingUserMessage = null; + // Reset with the session, or resuming migrated history after a turn in + // another session would move it on sight rather than on use. + this.sessionTouched = false; + return session; + } + + /** Starts an empty session. The current one stays on disk. */ + async newSession(): Promise { + if (this.isRunning) return null; + await this.persist(); + return this.adopt(await createSession()); + } + + /** Switches to an existing session. Throws if the reference is unusable. */ + async switchSession(ref: string): Promise { + if (this.isRunning) return null; + + const found = await this.findSession(ref); + if (!found) return null; + + await this.persist(); + return this.adopt(found); + } + + /** + * Copies this session and continues in the copy, leaving the original where + * it was. Persists first, so the branch starts from what is actually on disk. + */ + async branchSession(name?: string): Promise { + if (this.isRunning || !this.session) return null; + + await this.persist(); + const copy = await forkSession(this.session.id, { + name: name ?? null, + // Same hint mustFork gives: migrated history has no project of its own, + // so name where it lives rather than paying for a scan to find it. + slug: this.session.cwd ? undefined : LEGACY_SLUG, + }); + if (!copy) return null; + + return this.adopt(copy); + } + + /** Gives the session a resume handle. */ + async renameSession(name: string): Promise { + if (this.isRunning || !this.session) return null; + + this.session = { ...this.session, name: name.trim() || null }; + await this.persist(); + return this.session; + } + cancel() { if (!this.isRunning) { return; diff --git a/commands/sessions.ts b/commands/sessions.ts new file mode 100644 index 0000000..97df23c --- /dev/null +++ b/commands/sessions.ts @@ -0,0 +1,137 @@ +/** + * `woopcode sessions` — the session store from outside a session. + * + * The picker covers the interactive case. This is for the rest: seeing what is + * on disk before starting, reading a transcript without resuming it, and + * running retention on demand rather than waiting for the daily sweep. + */ + +import { Command } from "commander"; +import { + latestSession, + listSessions, + loadSession, + pruneSessions, + resolveSessionRef, + UNTITLED, + type SessionSummary, +} from "../config/sessions"; +import { getConfig, parseRetentionDays } from "../config/config"; +import { relativeTime } from "../tui/src/relative-time"; +import { renderTable } from "./table"; + +function label(session: SessionSummary): string { + return session.name ?? session.title ?? UNTITLED; +} + +const listCommand = new Command("list") + .description("List saved sessions") + .option("-a, --all", "every project on this machine, not just this one") + .action(async (options: { all?: boolean }) => { + const sessions = await listSessions({ scope: options.all ? "all" : "project" }); + + if (sessions.length === 0) { + process.stdout.write( + options.all + ? "No saved sessions.\n" + : "No saved sessions in this project. Try --all.\n", + ); + return; + } + + const current = await latestSession(); + + process.stdout.write( + renderTable(sessions, [ + { header: "", value: (session) => (session.id === current?.id ? "●" : " ") }, + { header: "ID", value: (session) => session.id.slice(0, 8) }, + { header: "SESSION", value: label }, + { header: "MSGS", value: (session) => `${session.messageCount}`, align: "right" }, + { header: "UPDATED", value: (session) => relativeTime(session.updated) }, + { header: "BRANCH", value: (session) => session.branch ?? "—" }, + ]) + "\n", + ); + }); + +const showCommand = new Command("show") + .description("Print a session's transcript") + .argument("", "name, id or id prefix") + .action(async (ref: string) => { + const sessions = await listSessions({ scope: "all" }); + const resolution = resolveSessionRef(ref, sessions); + + if (resolution.status === "none") { + process.stderr.write(`✖ No session found matching "${ref}"\n`); + process.exit(1); + } + if (resolution.status === "ambiguous") { + process.stderr.write( + `✖ "${ref}" matches ${resolution.matches.length} sessions:\n` + + resolution.matches + .map((session) => ` ${session.id.slice(0, 8)} ${label(session)}\n`) + .join("") + + " Use a longer id.\n", + ); + process.exit(1); + } + + const record = await loadSession(resolution.session.id, resolution.session.slug); + if (!record) { + process.stderr.write(`✖ Could not read session ${resolution.session.id}\n`); + process.exit(1); + } + + process.stdout.write( + `${label(resolution.session)} (${record.id})\n` + + `${record.cwd ?? "no project"}${record.branch ? ` · ${record.branch}` : ""}\n\n`, + ); + + // Only user and assistant messages are ever persisted, but the Message + // union is wider than that, so narrow rather than assume. + for (const message of record.messages) { + if (message.role !== "user" && message.role !== "assistant") continue; + const who = message.role === "user" ? "you" : "woopcode"; + process.stdout.write(`── ${who} ──\n${message.content}\n\n`); + } + }); + +const pruneCommand = new Command("prune") + .description("Delete sessions older than the retention period") + .option("--days ", "override the configured retention period") + .action(async (options: { days?: string }) => { + const configured = parseRetentionDays((await getConfig()).retentionDays); + + // A value that is not a number is a typo, and reporting it as "retention is + // off" would describe the configuration rather than the mistake. + if (options.days !== undefined && !Number.isFinite(Number(options.days))) { + process.stderr.write(`✖ --days needs a number, not "${options.days}"\n`); + process.exit(1); + } + + const days = options.days !== undefined ? Number(options.days) : configured; + + if (days <= 0) { + process.stdout.write( + "Retention is off, so nothing was removed. Pass --days to override.\n", + ); + return; + } + + const removed = await pruneSessions(days); + process.stdout.write( + removed === 0 + ? `No sessions older than ${days} days.\n` + : `Removed ${removed} session${removed === 1 ? "" : "s"} older than ${days} days.\n`, + ); + }); + +export const sessionsCommand = new Command("sessions") + .description("List, inspect and prune saved sessions") + .addCommand(listCommand) + .addCommand(showCommand) + .addCommand(pruneCommand) + // A bare `woopcode sessions` is a listing, which is what anyone typing it + // wants; the subcommands are for the less common cases. + .action(async () => { + await listCommand.parseAsync([], { from: "user" }); + }); diff --git a/commands/slash/commands.ts b/commands/slash/commands.ts index 79cd62a..4a7ce42 100644 --- a/commands/slash/commands.ts +++ b/commands/slash/commands.ts @@ -1,12 +1,9 @@ import type { SlashCommand, SlashCommandContext } from "./types"; import { registry } from "./registry"; import { APPROVAL_MODES, describeApprovalMode, parseApprovalMode } from "../../runtime/approval"; -import { - getConfig, - saveConfig, - getConversation, - saveConversation, -} from "../../config/config"; +import { getConfig, saveConfig } from "../../config/config"; +import { listSessions, UNTITLED, type SessionSummary } from "../../config/sessions"; +import { relativeTime } from "../../tui/src/relative-time"; import { isProviderEnabled, unsupportedProviderMessage } from "../../providers/providerRegistry"; import { DEFAULT_MODEL_ID, getModelDisplayName } from "../../providers/client"; import { toolRegistry } from "../../tools"; @@ -62,20 +59,151 @@ const helpCommand: SlashCommand = { }, }; +/** A picker row rendered as one line of text, for /sessions and errors. */ +function describeSession(session: SessionSummary, active = false): string { + const label = session.name ?? session.title ?? UNTITLED; + const marker = active ? "●" : " "; + const parts = [ + `${marker} ${session.id.slice(0, 8)}`, + label, + relativeTime(session.updated), + `${session.messageCount} msg`, + ]; + if (session.branch) parts.push(session.branch); + return parts.join(" · "); +} + const newCommand: SlashCommand = { name: "new", aliases: ["clear", "reset"], - description: "Start a new conversation", + description: "Start a new conversation, keeping the current one", category: "session", async execute(context, args) { - await saveConversation([]); - - // Clear UI timeline as well + if (context.controller.isBusy()) { + return "Cannot start a new session while the agent is running. Press Esc to cancel first."; + } + + const previous = context.controller.currentSession(); + const session = await context.controller.newSession(); + if (!session) return "Could not start a new session."; + const { store } = await import("../../tui/src/store/ui-store"); store.clearTimeline(); - - return "Started new conversation"; + + // Naming the way back is the whole difference from what /new used to do. + return previous && previous.messages.length > 0 + ? `Started a new session. The previous one is saved as ${previous.id.slice(0, 8)} — /resume ${previous.id.slice(0, 8)} to return.` + : "Started a new session"; + }, +}; + +const resumeCommand: SlashCommand = { + name: "resume", + aliases: ["r"], + description: "Switch to a previous conversation", + category: "session", + usage: "/resume [name-or-id]", + + async execute(context, args) { + if (context.controller.isBusy()) { + return "Cannot switch sessions while the agent is running. Press Esc to cancel first."; + } + + const { store } = await import("../../tui/src/store/ui-store"); + + if (args.length === 0) { + store.openSessionPicker(); + return ""; + } + + const ref = args.join(" "); + let session; + try { + session = await context.controller.switchSession(ref); + } catch (error) { + // Ambiguity is reported rather than guessed at; the message names the way + // to disambiguate. + return error instanceof Error ? error.message : String(error); + } + + if (!session) { + return `No session found matching "${ref}". Use /resume with no argument to pick from a list.`; + } + + store.hydrateTimeline(session.messages); + return `Resumed ${session.name ?? session.title}`; + }, +}; + +const listSessionsCommand: SlashCommand = { + name: "sessions", + aliases: ["ls"], + description: "List saved conversations for this project", + category: "session", + + async execute(context, args) { + const sessions = await listSessions(); + if (sessions.length === 0) return "No saved sessions in this project yet."; + + const activeId = context.controller.currentSession()?.id; + const rows = sessions + .slice(0, 20) + .map((session) => describeSession(session, session.id === activeId)); + + return [ + `Sessions in this project (${sessions.length}):`, + "", + ...rows, + "", + "Switch with /resume ", + ].join("\n"); + }, +}; + +const renameCommand: SlashCommand = { + name: "rename", + description: "Name the current conversation so it can be resumed by name", + category: "session", + usage: "/rename ", + + async execute(context, args) { + if (args.length === 0) return "Usage: /rename "; + if (context.controller.isBusy()) { + return "Cannot rename while the agent is running. Press Esc to cancel first."; + } + + const session = await context.controller.renameSession(args.join(" ")); + if (!session) return "No session to rename yet — run a turn first."; + + return `Renamed to ${session.name}`; + }, +}; + +const branchCommand: SlashCommand = { + name: "branch", + aliases: ["fork"], + description: "Copy this conversation and continue in the copy", + category: "session", + usage: "/branch [name]", + + async execute(context, args) { + if (context.controller.isBusy()) { + return "Cannot branch while the agent is running. Press Esc to cancel first."; + } + + const original = context.controller.currentSession(); + if (!original || original.messages.length === 0) { + return "Nothing to branch yet — run a turn first."; + } + + const session = await context.controller.branchSession(args.join(" ") || undefined); + if (!session) return "Could not branch this session."; + + return [ + `Branched into ${session.name ?? session.title} (${session.id.slice(0, 8)}).`, + `The original is unchanged — /resume ${original.id.slice(0, 8)} to return to it.`, + ].join("\n"); }, }; @@ -396,7 +524,10 @@ const statusCommand: SlashCommand = { async execute(context, args) { const config = await getConfig(); - const conversation = await getConversation(); + // Optional the same way getModel is below: /status is the command someone + // runs when something is wrong, so it reports what it can rather than + // throwing on a controller that is not fully wired. + const session = context.controller?.currentSession?.() ?? null; const cwd = process.cwd(); const parts = cwd.split("/").filter(Boolean); const repoName = parts[parts.length - 1] ?? "workspace"; @@ -418,7 +549,8 @@ const statusCommand: SlashCommand = { `Provider: ${providerLabel(provider)}`, `Model: ${getModelDisplayName(model)} (${model})`, ``, - `Conversation: ${conversation.length} messages`, + `Session: ${session ? `${session.name ?? session.title} (${session.id.slice(0, 8)})` : "none yet"}`, + `Conversation: ${context.controller?.messageCount?.() ?? 0} messages`, `Tools: ${toolRegistry.length} registered`, `Version: ${VERSION}`, ].join("\n"); @@ -443,6 +575,10 @@ const versionCommand: SlashCommand = { export function registerCommands() { registry.register(helpCommand); registry.register(newCommand); + registry.register(resumeCommand); + registry.register(listSessionsCommand); + registry.register(renameCommand); + registry.register(branchCommand); registry.register(exitCommand); registry.register(providerCommand); registry.register(loginCommand); diff --git a/config/config.ts b/config/config.ts index 13d8d8d..85273b6 100644 --- a/config/config.ts +++ b/config/config.ts @@ -1,12 +1,6 @@ import { renameSync } from "fs"; import type { Message } from "./types"; -import { - getProvidersConfigPath, - getConversationPath, - getExecutionLogPath, - initializeConfig, -} from "./paths"; -import type { ExecutionRecord } from "../runtime/executionLog"; +import { getProvidersConfigPath, initializeConfig } from "./paths"; import { type ApprovalMode, parseApprovalMode } from "../runtime/approval"; export interface ProviderEntry { @@ -19,16 +13,39 @@ export interface ProvidersConfig { selectedModel?: string; /** How much the agent may run without asking; see runtime/approval. */ approvalMode?: ApprovalMode; + /** Days a session survives after its last turn; 0 keeps them forever. */ + retentionDays?: number; providers: Record; } +/** + * How long a session lives after its last turn. + * + * Thirty days is what Claude Code defaults to and it is the right shape of + * number: long enough that coming back to last month's work still finds it, + * short enough that the store does not grow without bound. + */ +export const DEFAULT_RETENTION_DAYS = 30; + +/** + * Reads the retention setting, falling back to the default for anything that is + * not a usable number. Zero and negatives are honoured as "keep forever" rather + * than corrected — that is how retention is turned off. + */ +export function parseRetentionDays(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return DEFAULT_RETENTION_DAYS; + } + return value < 0 ? 0 : value; +} + /** * Reads and parses a JSON file. A file that is corrupt (truncated write, hand * edit, disk error) is moved aside rather than crashing every command that * touches config — the user keeps the broken copy, and startup continues from * a clean default. */ -async function readJsonFile(path: string, label: string): Promise { +export async function readJsonFile(path: string, label: string): Promise { const file = Bun.file(path); if (!(await file.exists())) { @@ -84,6 +101,10 @@ export function normalizeConfig(raw: unknown): ProvidersConfig { // Always normalised, so an absent or misspelt setting cannot leave the // approval mode undefined at the point a command is about to run. approvalMode: parseApprovalMode(source.approvalMode), + // The spread above preserves unrecognised keys, which would leave a + // hand-written `retentionDays: "30"` intact and unusable. Normalise it for + // the same reason the approval mode is normalised. + retentionDays: parseRetentionDays(source.retentionDays), providers, }; } @@ -129,23 +150,18 @@ export async function saveConfig(config: ProvidersConfig) { await Bun.write(configPath, JSON.stringify(config, null, 2)); } -// for storing and apending the conversation history -export async function getConversation(): Promise { - await initializeConfig(); - const conversationPath = getConversationPath(); - - const parsed = await readJsonFile(conversationPath, "conversation history"); - - // A conversation that is not a list of messages is not worth recovering - // partially; starting a fresh transcript beats crashing on every launch. - if (!Array.isArray(parsed)) { - return []; - } - - return parsed.filter( - (message): message is Message => - !!message && typeof message === "object" && typeof (message as Message).role === "string", - ); +/** + * Writes JSON to a temporary sibling and renames it over the target. + * + * The rename is atomic on the same filesystem, so a crash mid-write leaves + * either the old file or the new one, never half of either. Everything that + * persists user state goes through here — a session, its index, the config — + * because all of them are written often enough to hit that window. + */ +export async function writeJsonAtomic(path: string, value: unknown): Promise { + const temporaryPath = `${path}.tmp`; + await Bun.write(temporaryPath, JSON.stringify(value, null, 2)); + renameSync(temporaryPath, path); } /** @@ -171,19 +187,6 @@ export function prepareConversationForDisk(messages: Message[]): Message[] { return conversational.slice(-MAX_PERSISTED_MESSAGES); } -export async function saveConversation(messages: Message[]) { - await initializeConfig(); - const conversationPath = getConversationPath(); - const payload = JSON.stringify(prepareConversationForDisk(messages), null, 2); - - // Saving now happens after every turn, so a crash mid-write would be much - // easier to hit. Write to a sibling file and rename, which is atomic on the - // same filesystem: readers see either the old file or the new one. - const temporaryPath = `${conversationPath}.tmp`; - await Bun.write(temporaryPath, payload); - renameSync(temporaryPath, conversationPath); -} - /** * How many execution records are kept on disk. * @@ -193,48 +196,6 @@ export async function saveConversation(messages: Message[]) { */ export const MAX_PERSISTED_RECORDS = 200; -/** - * Loads what previous sessions did. - * - * Without this the execution log would survive a turn but not a restart, which - * is the same forgetting one level up — reopening Woopcode in a repository - * would discard everything it had learned there. - */ -export async function getExecutionLog(): Promise { - await initializeConfig(); - - const parsed = await readJsonFile(getExecutionLogPath(), "execution log"); - if (!Array.isArray(parsed)) return []; - - return parsed.filter( - (record): record is ExecutionRecord => - !!record && - typeof record === "object" && - typeof (record as ExecutionRecord).tool === "string" && - typeof (record as ExecutionRecord).outcome === "string", - ); -} - -export async function saveExecutionLog(records: ExecutionRecord[]) { - await initializeConfig(); - const path = getExecutionLogPath(); - const payload = JSON.stringify(records.slice(-MAX_PERSISTED_RECORDS), null, 2); - - // Same write-then-rename as the conversation: a crash mid-write must leave - // either the old file or the new one, never half of either. - const temporaryPath = `${path}.tmp`; - await Bun.write(temporaryPath, payload); - renameSync(temporaryPath, path); -} - -export async function appendMessage(message: any) { - const conversation = await getConversation(); - - conversation.push(message); - - await saveConversation(conversation); -} - // ==================== REPOSITORY CONTEXT ==================== // // This context is prepended to every model request, so it is budgeted rather diff --git a/config/paths.ts b/config/paths.ts index 87f038a..9766297 100644 --- a/config/paths.ts +++ b/config/paths.ts @@ -48,18 +48,51 @@ export function getProvidersConfigPath(): string { return join(getConfigDir(), "providers.json"); } -export function getConversationPath(): string { +/** + * The pre-sessions conversation file. + * + * Retained only so `migrateLegacyConversation` in config/sessions.ts can find + * what an older version wrote. Nothing reads or writes it as live history any + * more; sessions live under `sessions//`. + */ +export function getLegacyConversationPath(): string { return join(getConfigDir(), "conversation.json"); } +/** Root of the per-project session store. */ +export function getSessionsDir(): string { + return join(getConfigDir(), "sessions"); +} + /** - * Get the path to execution-log.json + * Where one project's sessions live. * - * Kept beside the conversation rather than inside it: conversation.json is an - * array of messages that older versions read directly, so widening it would - * make a downgrade fail on its own history. + * Takes the slug rather than computing it, so the directory layout stays a pure + * function of its argument and the slug rules live in one place + * (`projectSlug`). */ -export function getExecutionLogPath(): string { +export function getProjectSessionsDir(slug: string): string { + return join(getSessionsDir(), slug); +} + +export function getSessionPath(slug: string, id: string): string { + return join(getProjectSessionsDir(slug), `${id}.json`); +} + +export function getSessionIndexPath(slug: string): string { + return join(getProjectSessionsDir(slug), "index.json"); +} + +/** + * The pre-sessions execution log. + * + * It was kept beside the conversation because conversation.json was an array of + * messages that older versions read directly, so widening it would have made a + * downgrade fail on its own history. A session record has a version field and a + * place to put it, so the log now lives inside the session and this path exists + * only for the migration to drain. + */ +export function getLegacyExecutionLogPath(): string { return join(getConfigDir(), "execution-log.json"); } @@ -72,7 +105,6 @@ export function getModelsPath(): string { */ export async function initializeConfig(): Promise { const providersPath = getProvidersConfigPath(); - const conversationPath = getConversationPath(); // Create default providers.json if it doesn't exist if (!existsSync(providersPath)) { @@ -99,10 +131,9 @@ export async function initializeConfig(): Promise { await removeRetiredProviders(providersPath); } - // Create empty conversation.json if it doesn't exist - if (!existsSync(conversationPath)) { - await Bun.write(conversationPath, JSON.stringify([], null, 2)); - } + // No conversation file is seeded any more. History lives in + // sessions//, and a session file is written only once a turn has + // actually run — an empty one would be a resume target with nothing in it. } /** Providers that were offered by an earlier version and have since been dropped. */ diff --git a/config/sessions.ts b/config/sessions.ts new file mode 100644 index 0000000..5d42296 --- /dev/null +++ b/config/sessions.ts @@ -0,0 +1,841 @@ +/** + * Sessions: conversations that survive, keyed by the project they happened in. + * + * Woopcode used to keep one `conversation.json` for the whole machine. That + * made `/new` destructive (there was nowhere for the old transcript to go) and + * it fed history from one repository into turns taken in another — including + * the execution log, which the model reads as a description of what has already + * been done *here*. + * + * A session is one JSON file under `sessions//.json`, and + * `index.json` beside them is a derived cache so the picker does not have to + * open every one. The index is never the source of truth: delete it and it is + * rebuilt by scanning the directory. + */ + +import { + existsSync, + mkdirSync, + readdirSync, + realpathSync, + renameSync, + rmSync, + statSync, +} from "fs"; +import { basename, join } from "path"; +import type { Message } from "./types"; +import type { ExecutionRecord } from "../runtime/executionLog"; +import { + getLegacyConversationPath, + getLegacyExecutionLogPath, + getProjectSessionsDir, + getSessionIndexPath, + getSessionPath, + getSessionsDir, + initializeConfig, +} from "./paths"; +import { + MAX_PERSISTED_RECORDS, + prepareConversationForDisk, + readJsonFile, + writeJsonAtomic, +} from "./config"; + +/** Bumped when the on-disk shape changes in a way a reader must notice. */ +const SESSION_VERSION = 1; + +/** The bucket migrated pre-sessions history lands in. */ +export const LEGACY_SLUG = "legacy"; + +export interface SessionRecord { + version: number; + id: string; + /** Set by /rename. The resume handle; a title is not. */ + name: string | null; + /** Derived from the first prompt, so the picker has something to show. */ + title: string; + /** Absolute path the session was started in; null for migrated history. */ + cwd: string | null; + branch: string | null; + created: number; + updated: number; + /** Id of the session this was branched from, if any. */ + forkedFrom: string | null; + messages: Message[]; + executionLog: ExecutionRecord[]; +} + +/** One row of the picker. Everything here comes from the index. */ +export interface SessionSummary { + id: string; + name: string | null; + title: string; + cwd: string | null; + branch: string | null; + created: number; + updated: number; + forkedFrom: string | null; + messageCount: number; + /** Which project directory the session was read from. */ + slug: string; +} + +interface SessionIndex { + version: number; + /** When sessions here were last aged out; see pruneSessions. */ + lastPrunedAt?: number; + sessions: SessionSummary[]; +} + +// --------------------------------------------------------------------------- +// pure helpers +// --------------------------------------------------------------------------- + +/** + * Characters of the first prompt kept as a title. Long enough to tell two + * tasks apart in a narrow picker, short enough not to wrap it. + */ +export const MAX_TITLE_CHARS = 60; + +/** A session with no first prompt yet. */ +export const UNTITLED = "Untitled session"; + +/** + * A one-line title from the first thing the user said. + * + * Deliberately mechanical rather than model-written: a generated title costs a + * provider request per session, on whichever of the three providers happens to + * be configured, and has a failure path to handle at the exact moment a session + * is being created. `/rename` is there for anyone who wants better. + */ +export function titleFromPrompt(prompt: string): string { + const flattened = prompt.replace(/\s+/g, " ").trim(); + if (!flattened) return UNTITLED; + if (flattened.length <= MAX_TITLE_CHARS) return flattened; + return `${flattened.slice(0, MAX_TITLE_CHARS - 1).trimEnd()}…`; +} + +/** + * A short, stable hash of a path. Not cryptographic — it exists to keep two + * different directories out of each other's session list. + */ +function pathHash(path: string): string { + // FNV-1a. Bun has crypto, but a sync 8-char digest with no allocation is all + // this needs and it keeps projectSlug a pure synchronous function. + let hash = 0x811c9dc5; + for (let index = 0; index < path.length; index++) { + hash ^= path.charCodeAt(index); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash.toString(16).padStart(8, "0"); +} + +/** + * The directory name one project's sessions live under. + * + * The readable part is for whoever opens the config directory; the hash is what + * actually keys it. Both are needed: slugifying alone maps `/a/b` and `/a-b` to + * the same string, which would silently merge two unrelated projects' history. + */ +export function projectSlug(path: string): string { + const readable = + basename(path).replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "") || + "project"; + return `${readable}-${pathHash(path)}`; +} + +/** + * Resolves what the user typed to exactly one session. + * + * Ordered most to least specific, and ambiguity is reported rather than + * guessed: resuming the wrong conversation is worse than being asked again. + */ +export type SessionRefResolution = + | { status: "found"; session: SessionSummary } + | { status: "none" } + | { status: "ambiguous"; matches: SessionSummary[] }; + +export function resolveSessionRef( + ref: string, + summaries: readonly SessionSummary[], +): SessionRefResolution { + const needle = ref.trim(); + if (!needle) return { status: "none" }; + + const exactName = summaries.filter((session) => session.name === needle); + if (exactName.length === 1) return { status: "found", session: exactName[0]! }; + if (exactName.length > 1) return { status: "ambiguous", matches: exactName }; + + const exactId = summaries.find((session) => session.id === needle); + if (exactId) return { status: "found", session: exactId }; + + const byPrefix = summaries.filter((session) => session.id.startsWith(needle)); + if (byPrefix.length === 1) return { status: "found", session: byPrefix[0]! }; + if (byPrefix.length > 1) return { status: "ambiguous", matches: byPrefix }; + + const lowered = needle.toLowerCase(); + const byTitle = summaries.filter( + (session) => + session.title.toLowerCase().includes(lowered) || + (session.name?.toLowerCase().includes(lowered) ?? false), + ); + if (byTitle.length === 1) return { status: "found", session: byTitle[0]! }; + if (byTitle.length > 1) return { status: "ambiguous", matches: byTitle }; + + return { status: "none" }; +} + +/** Newest first — the order both the picker and `latestSession` want. */ +function byRecency(a: SessionSummary, b: SessionSummary): number { + return b.updated - a.updated; +} + +function summarize(record: SessionRecord, slug: string): SessionSummary { + return { + id: record.id, + name: record.name, + title: record.title, + cwd: record.cwd, + branch: record.branch, + created: record.created, + updated: record.updated, + forkedFrom: record.forkedFrom, + messageCount: record.messages.length, + slug, + }; +} + +// --------------------------------------------------------------------------- +// project resolution +// --------------------------------------------------------------------------- + +/** + * The directory a session belongs to: the repository root when there is one, + * so `cd packages/x` shares history with the root rather than starting a + * second store, and the working directory otherwise. + * + * Symlinks are resolved first for the same reason `resolveWorkspacePath` does + * it — two paths that reach the same directory must not produce two slugs. + */ +export function projectRoot(cwd: string = process.cwd()): string { + let resolved = cwd; + try { + resolved = realpathSync(cwd); + } catch { + // A cwd that cannot be resolved is still usable as a key. + } + + let directory = resolved; + while (true) { + if (existsSync(join(directory, ".git"))) return directory; + const parent = join(directory, ".."); + const parentResolved = (() => { + try { + return realpathSync(parent); + } catch { + return directory; + } + })(); + if (parentResolved === directory) return resolved; + directory = parentResolved; + } +} + +/** The current git branch, or null outside a repository. */ +async function currentBranch(cwd: string): Promise { + try { + const text = await Bun.$`git -C ${cwd} branch --show-current`.quiet().text(); + return text.trim() || null; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// reading +// --------------------------------------------------------------------------- + +function isSessionRecord(value: unknown): value is SessionRecord { + if (!value || typeof value !== "object") return false; + const record = value as SessionRecord; + return ( + typeof record.id === "string" && + Array.isArray(record.messages) && + Array.isArray(record.executionLog) + ); +} + +/** Fills in anything an older or hand-edited file is missing. */ +function normalizeRecord(raw: SessionRecord): SessionRecord { + return { + ...raw, + version: typeof raw.version === "number" ? raw.version : SESSION_VERSION, + name: typeof raw.name === "string" ? raw.name : null, + title: typeof raw.title === "string" && raw.title ? raw.title : UNTITLED, + cwd: typeof raw.cwd === "string" ? raw.cwd : null, + branch: typeof raw.branch === "string" ? raw.branch : null, + created: typeof raw.created === "number" ? raw.created : Date.now(), + updated: typeof raw.updated === "number" ? raw.updated : Date.now(), + forkedFrom: typeof raw.forkedFrom === "string" ? raw.forkedFrom : null, + messages: raw.messages.filter( + (message): message is Message => + !!message && typeof message === "object" && typeof message.role === "string", + ), + executionLog: raw.executionLog.filter( + (record): record is ExecutionRecord => + !!record && + typeof record === "object" && + typeof record.tool === "string" && + typeof record.outcome === "string", + ), + }; +} + +/** + * Reads one session. A file that is not a session — corrupt, hand-edited, + * written by something else — returns null rather than throwing: one bad + * session must not make the picker unopenable. + */ +export async function loadSession( + id: string, + slug: string = projectSlug(projectRoot()), +): Promise { + const raw = await readJsonFile(getSessionPath(slug, id), `session ${id}`); + if (!isSessionRecord(raw)) return null; + return normalizeRecord(raw); +} + +/** Every session directory currently on disk. */ +function projectSlugs(): string[] { + const root = getSessionsDir(); + if (!existsSync(root)) return []; + try { + return readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + } catch { + return []; + } +} + +/** + * Rebuilds a project's index by opening every session in it. + * + * The slow path, taken when the index is missing or unreadable. It is what + * makes the index safe to treat as a cache: losing it costs one directory scan, + * never a session. + */ +async function rebuildIndex(slug: string): Promise { + const directory = getProjectSessionsDir(slug); + if (!existsSync(directory)) return []; + + let entries: string[]; + try { + entries = readdirSync(directory); + } catch { + return []; + } + + const summaries: SessionSummary[] = []; + for (const entry of entries) { + if (!entry.endsWith(".json") || entry === "index.json") continue; + const record = await loadSession(entry.slice(0, -".json".length), slug); + if (record) summaries.push(summarize(record, slug)); + } + + summaries.sort(byRecency); + await writeIndex(slug, { version: SESSION_VERSION, sessions: summaries }); + return summaries; +} + +async function readIndex(slug: string): Promise { + const raw = await readJsonFile(getSessionIndexPath(slug), "session index"); + if (!raw || typeof raw !== "object") return null; + const index = raw as SessionIndex; + if (!Array.isArray(index.sessions)) return null; + return index; +} + +async function writeIndex(slug: string, index: SessionIndex): Promise { + mkdirSync(getProjectSessionsDir(slug), { recursive: true }); + await writeJsonAtomic(getSessionIndexPath(slug), index); +} + +/** Session files on disk for a project, ignoring the index entirely. */ +function sessionFileCount(slug: string): number { + const directory = getProjectSessionsDir(slug); + if (!existsSync(directory)) return 0; + try { + return readdirSync(directory).filter( + (entry) => entry.endsWith(".json") && entry !== "index.json", + ).length; + } catch { + return 0; + } +} + +async function summariesFor(slug: string): Promise { + const index = await readIndex(slug); + if (!index) return rebuildIndex(slug); + + // The index is a read-modify-write, so two Woopcode windows in one repository + // can each save a session and leave only the later row behind. The file is + // still on disk, and without this check it would never be listed again — + // a session that silently disappears from the picker. Comparing counts costs + // one directory read and heals it. + if (sessionFileCount(slug) !== index.sessions.length) { + return rebuildIndex(slug); + } + + return index.sessions.map((session) => ({ ...session, slug })).sort(byRecency); +} + +export interface ListOptions { + /** "project" is the current repository; "all" is every project on disk. */ + scope?: "project" | "all"; + cwd?: string; +} + +export async function listSessions( + options: ListOptions = {}, +): Promise { + await ensureSessionStore(); + + if (options.scope === "all") { + const all: SessionSummary[] = []; + for (const slug of projectSlugs()) all.push(...(await summariesFor(slug))); + return all.sort(byRecency); + } + + return summariesFor(projectSlug(projectRoot(options.cwd))); +} + +/** The session `--continue` and a bare launch reopen. */ +export async function latestSession( + cwd?: string, +): Promise { + const sessions = await listSessions({ cwd }); + return sessions[0] ?? null; +} + +// --------------------------------------------------------------------------- +// writing +// --------------------------------------------------------------------------- + +/** + * Creates a session in memory. Nothing is written until `saveSession` — a + * session file that exists before any turn has run is a resume target with + * nothing in it, and every launch would leave one behind. + */ +export async function createSession( + options: { cwd?: string; name?: string | null; title?: string } = {}, +): Promise { + const root = projectRoot(options.cwd); + const now = Date.now(); + + return { + version: SESSION_VERSION, + id: crypto.randomUUID(), + name: options.name ?? null, + title: options.title ?? UNTITLED, + cwd: root, + branch: await currentBranch(root), + created: now, + updated: now, + forkedFrom: null, + messages: [], + executionLog: [], + }; +} + +/** + * Writes a session and updates the index to match. + * + * The same trim the single conversation file always had is applied here: + * `prepareConversationForDisk` drops tool traffic and caps the message count. + * Persisting half of a call/result pair would make the restored history invalid + * for the provider. + */ +export async function saveSession(record: SessionRecord): Promise { + await ensureSessionStore(); + return writeSessionRecord(record); +} + +/** + * The write itself, without the store-setup guard. + * + * Separate because migration runs *inside* that guard: routing it through + * `saveSession` would have it await the very promise it is part of, and the + * first launch after an upgrade would hang instead of importing. + */ +async function writeSessionRecord(record: SessionRecord): Promise { + const slug = record.cwd ? projectSlug(record.cwd) : LEGACY_SLUG; + const trimmed: SessionRecord = { + ...record, + version: SESSION_VERSION, + updated: Date.now(), + messages: prepareConversationForDisk(record.messages), + executionLog: record.executionLog.slice(-MAX_PERSISTED_RECORDS), + }; + + mkdirSync(getProjectSessionsDir(slug), { recursive: true }); + await writeJsonAtomic(getSessionPath(slug, trimmed.id), trimmed); + + const index = (await readIndex(slug)) ?? { + version: SESSION_VERSION, + sessions: await rebuildIndex(slug), + }; + const summary = summarize(trimmed, slug); + const sessions = [ + summary, + ...index.sessions.filter((session) => session.id !== trimmed.id), + ].sort(byRecency); + await writeIndex(slug, { ...index, version: SESSION_VERSION, sessions }); + + return trimmed; +} + +/** + * Loads a session without needing to be told which project it is in. + * + * The current project is tried first, so the common case costs one read. The + * scan matters because a session reached through the picker's all-projects view + * — or migrated history, which belongs to no project — is not in the current + * project's directory, and assuming it was made `/branch` fail on exactly the + * sessions a user is most likely to want a copy of. + */ +async function loadSessionAnywhere( + id: string, + slug?: string, +): Promise { + const local = await loadSession(id, slug ?? projectSlug(projectRoot())); + if (local) return local; + + for (const candidate of projectSlugs()) { + const found = await loadSession(id, candidate); + if (found) return found; + } + + return null; +} + +/** + * Re-reads a session from wherever it lives, for checking whether anything else + * has written it since. Takes the record rather than an id so the project is + * known — migrated history has none, and a scan would be wasted here. + */ +export async function reloadSession( + record: SessionRecord, +): Promise { + return loadSession(record.id, record.cwd ? projectSlug(record.cwd) : LEGACY_SLUG); +} + +/** + * Drops a session from a project: its file, and its row in that project's + * index. Best effort — a failure leaves the session listed, which is recoverable + * in a way a half-removed one is not. + */ +async function removeFromProject(slug: string, id: string): Promise { + try { + const path = getSessionPath(slug, id); + if (existsSync(path)) rmSync(path, { force: true }); + + const index = await readIndex(slug); + if (!index) return; + + await writeIndex(slug, { + ...index, + sessions: index.sessions.filter((session) => session.id !== id), + }); + } catch { + // See above: leaving it listed is the safer failure. + } +} + +/** + * Moves a session into the project it is now being worked in. + * + * This exists for migrated history, which has no project of its own: resuming + * it in a repository and working for an hour used to leave it in the `legacy` + * bucket, invisible in that repository's own list, which is the same "where did + * my conversation go" the session store was built to end. + * + * The order is deliberate — the new copy is written before the old one is + * removed, so an interruption leaves the session listed twice rather than not + * at all. + */ +export async function adoptSession( + record: SessionRecord, + cwd: string = projectRoot(), +): Promise { + const previousSlug = record.cwd ? projectSlug(record.cwd) : LEGACY_SLUG; + const nextSlug = projectSlug(cwd); + if (previousSlug === nextSlug) return saveSession(record); + + const adopted = await saveSession({ + ...record, + cwd, + branch: await currentBranch(cwd), + }); + + await removeFromProject(previousSlug, record.id); + + return adopted; +} + +/** + * Copies a session under a new id and returns the copy. The original is not + * touched — that is the whole point of branching. + */ +export async function forkSession( + id: string, + options: { name?: string | null; slug?: string } = {}, +): Promise { + const source = await loadSessionAnywhere(id, options.slug); + if (!source) return null; + + const now = Date.now(); + const copy: SessionRecord = { + ...source, + id: crypto.randomUUID(), + name: options.name ?? null, + title: source.title, + created: now, + updated: now, + forkedFrom: source.id, + // A fork taken from another project's session belongs to the project it was + // taken in, so it is findable where the work continues. + cwd: projectRoot(), + branch: await currentBranch(projectRoot()), + }; + + return saveSession(copy); +} + +export async function renameSession( + id: string, + name: string, + slug?: string, +): Promise { + const record = await loadSessionAnywhere(id, slug); + if (!record) return null; + return saveSession({ ...record, name: name.trim() || null }); +} + +/** + * Deletes sessions older than `maxAgeDays`, measured from their last update. + * A non-positive age keeps everything — the way to turn retention off. + */ +export async function pruneSessions( + maxAgeDays: number, + now: number = Date.now(), +): Promise { + if (!Number.isFinite(maxAgeDays) || maxAgeDays <= 0) return 0; + + const cutoff = now - maxAgeDays * 24 * 60 * 60 * 1000; + let removed = 0; + + for (const slug of projectSlugs()) { + const summaries = await summariesFor(slug); + const kept: SessionSummary[] = []; + // Counted per project, not across them. A shared counter rewrote every + // later project's index — and stamped it as pruned — because an earlier + // one happened to have something expired in it. + let removedHere = 0; + + for (const session of summaries) { + if (session.updated >= cutoff) { + kept.push(session); + continue; + } + try { + const path = getSessionPath(slug, session.id); + if (existsSync(path)) { + renameSync(path, `${path}.pruned`); + // Renamed then removed, so a crash between the two leaves a file that + // is out of the index rather than a half-deleted session in it. + rmSync(`${path}.pruned`, { force: true }); + } + removed++; + removedHere++; + } catch { + // A session that cannot be removed stays listed rather than vanishing + // from the index while its file remains on disk. + kept.push(session); + } + } + + if (removedHere > 0) { + const index = (await readIndex(slug)) ?? { version: SESSION_VERSION, sessions: [] }; + await writeIndex(slug, { + ...index, + version: SESSION_VERSION, + lastPrunedAt: now, + sessions: kept, + }); + } + } + + return removed; +} + +/** How often retention runs on its own, so startup is not scanning constantly. */ +const PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000; + +/** + * Runs retention at most once a day. Called at startup, where the cost has to + * be near zero on the overwhelmingly common path of having pruned recently. + */ +export async function pruneIfDue( + maxAgeDays: number, + now: number = Date.now(), +): Promise { + if (!Number.isFinite(maxAgeDays) || maxAgeDays <= 0) return 0; + + const slug = projectSlug(projectRoot()); + + // A project with no session directory has nothing to age out, and writing an + // index to record that would create the very directory the lazy-creation + // rule exists to avoid: starting Woopcode here and quitting must leave + // nothing behind. + if (!existsSync(getProjectSessionsDir(slug))) return 0; + + const index = await readIndex(slug); + if (index?.lastPrunedAt && now - index.lastPrunedAt < PRUNE_INTERVAL_MS) { + return 0; + } + + const removed = await pruneSessions(maxAgeDays, now); + + // Stamped even when nothing was removed, or a store with no expired sessions + // would rescan every launch. + const current = (await readIndex(slug)) ?? { version: SESSION_VERSION, sessions: [] }; + await writeIndex(slug, { ...current, lastPrunedAt: now }); + + return removed; +} + +// --------------------------------------------------------------------------- +// migration +// --------------------------------------------------------------------------- + +/** + * Moves the pre-sessions `conversation.json` into the session store. + * + * It lands in a `legacy` bucket rather than the current project: that file was + * shared by every repository on the machine, so there is no honest answer to + * which project it belongs to, and attributing it to whichever one happens to + * be opened first would be a guess presented as a fact. It is reachable from + * the picker's all-projects view. + * + * Idempotent by the rename: once the sources are `.migrated`, there is nothing + * left to find. + */ +export async function migrateLegacyConversation(): Promise { + const conversationPath = getLegacyConversationPath(); + if (!existsSync(conversationPath)) return null; + + const raw = await readJsonFile(conversationPath, "legacy conversation"); + const messages = Array.isArray(raw) + ? raw.filter( + (message): message is Message => + !!message && typeof message === "object" && typeof message.role === "string", + ) + : []; + + const logPath = getLegacyExecutionLogPath(); + const rawLog = existsSync(logPath) + ? await readJsonFile(logPath, "legacy execution log") + : undefined; + const executionLog = Array.isArray(rawLog) + ? rawLog.filter( + (record): record is ExecutionRecord => + !!record && + typeof record === "object" && + typeof record.tool === "string" && + typeof record.outcome === "string", + ) + : []; + + // Retire the sources whether or not there was anything worth keeping, so an + // empty file is not re-read on every launch forever. + const retire = (path: string) => { + try { + if (existsSync(path)) renameSync(path, `${path}.migrated`); + } catch { + // Leaving the source in place costs a re-read next launch, which is + // harmless — the import is keyed on the file existing, not on a flag. + } + }; + + if (messages.length === 0) { + retire(conversationPath); + retire(logPath); + return null; + } + + const firstPrompt = messages.find((message) => message.role === "user"); + const now = Date.now(); + const record: SessionRecord = { + version: SESSION_VERSION, + id: crypto.randomUUID(), + name: null, + title: titleFromPrompt( + firstPrompt && typeof (firstPrompt as { content?: unknown }).content === "string" + ? ((firstPrompt as { content: string }).content) + : "", + ), + // Null rather than a guess: this history predates any notion of which + // project it came from. + cwd: null, + branch: null, + created: fileCreatedAt(conversationPath, now), + updated: now, + forkedFrom: null, + messages, + executionLog, + }; + + const saved = await writeSessionRecord(record); + retire(conversationPath); + retire(logPath); + return saved; +} + +function fileCreatedAt(path: string, fallback: number): number { + try { + return statSync(path).birthtimeMs || fallback; + } catch { + return fallback; + } +} + +/** + * One-time setup for the session store. + * + * Guarded by a module flag rather than hooked into `initializeConfig`, which + * `getConfig` calls on every read — migration is a once-per-process concern and + * does not belong on that path. + */ +let storeReady: Promise | null = null; + +export function ensureSessionStore(): Promise { + storeReady ??= (async () => { + try { + await initializeConfig(); + mkdirSync(getSessionsDir(), { recursive: true }); + await migrateLegacyConversation(); + } catch { + // Config failures never block startup. A store that could not be prepared + // degrades to a session that will not persist, which is reported when the + // write itself fails. + } + })(); + + return storeReady; +} + +/** Test seam: forget that setup already ran. */ +export function resetSessionStoreForTests(): void { + storeReady = null; +} diff --git a/docs/guides/sessions-and-history.md b/docs/guides/sessions-and-history.md index c56eb4f..936078a 100644 --- a/docs/guides/sessions-and-history.md +++ b/docs/guides/sessions-and-history.md @@ -1,7 +1,7 @@ --- title: Sessions & history type: guide -summary: What Woopcode remembers between runs, what it deliberately forgets, and how to clear it. +summary: How Woopcode saves a conversation per project, and how to resume, name, branch and prune them. prerequisites: - /docs/getting-started/first-session related: @@ -12,16 +12,55 @@ since: 0.6.0 # Sessions & history -Your conversation is saved after every turn. Quit, come back, and Woopcode -picks up where you left off. +A session is one saved conversation, belonging to the project it happened in. +It is written after every turn, so quitting and coming back picks up where you +left off — in that repository, and not in any other. -## Clearing it +## Resuming + +Starting Woopcode in a project reopens the newest session there. The rest: + +| Command | What it does | +| --- | --- | +| `woopcode --continue` | Reopen the newest session in this project | +| `woopcode --resume ` | Reopen a particular one | +| `woopcode --new` | Start fresh instead of continuing | +| `/resume` | Pick from a list, without leaving the session you are in | +| `/resume ` | Switch straight to one | +| `/sessions` | List what is saved in this project | + +An id prefix is enough — the first eight characters, as `/sessions` prints +them. A reference that matches more than one session is reported rather than +guessed at. + +## Naming + +```text +/rename auth-refactor +``` + +Until you name it, a session is titled from its first prompt, which is what the +picker shows. A name is also a resume handle: `woopcode --resume auth-refactor`. + +## Starting a new one ```text /new ``` -That drops the saved history and starts fresh. There is no undo. +The conversation you were in is saved, not deleted, and `/new` prints the id to +get back to it. This is the difference from older versions, where `/new` dropped +the transcript with no undo. + +## Branching + +```text +/branch try-streaming +``` + +Copies the conversation so far into a new session and continues there, leaving +the original untouched — for trying a second approach without losing the first. +From the command line, `woopcode --continue --fork-session` does the same. ## What is saved @@ -35,6 +74,10 @@ of what it returned. The most recent 100 messages are kept. Older ones fall off the end. +Each session also carries its own execution log — the one-line record of what +it did — so a resumed session knows what it already tried, and a new one starts +without inheriting another project's work. + ## What is sent to the model Saved and sent are different numbers. Only the most recent **six turns** go to @@ -46,17 +89,53 @@ context. If something matters, restate it. ## Where it lives -`conversation.json` in your config directory: +Under `sessions/` in your config directory, one directory per project: | Platform | Path | | --- | --- | -| macOS, Linux | `~/.config/woopcode/conversation.json` | +| macOS, Linux | `~/.config/woopcode/sessions//.json` | + +The project directory is named after the repository root, with a hash of its +full path appended so two projects with similar names cannot share a store. +`index.json` beside the sessions is a cache the picker reads; deleting it costs +a directory scan and nothing else. -:::warning -There is one history file, not one per repository. Starting Woopcode in a -different project resumes the same conversation. Use `/new` when you switch -projects, or the agent begins with context from somewhere else entirely. -::: +History saved by a version before sessions existed is imported once, into a +`legacy` bucket. It was one file shared by every repository, so no project can +honestly claim it — find it with CtrlA in `/resume`. + +Resuming it and taking a turn moves it into the project you are working in, and +it appears in that project's list from then on. Opening it to read does not: +only a turn moves it, so browsing old history leaves it where it is. + +## Retention + +Sessions are deleted 30 days after their last turn. Change it in +`providers.json`: + +```json +{ "retentionDays": 90 } +``` + +`0` keeps them forever. `woopcode sessions prune` runs it on demand. + +## Two windows on one conversation + +Open Woopcode twice in the same repository and both continue the newest session. +Each turn writes the whole record, so the second window would overwrite the +first's work. + +It does not: a session that changed underneath a window is detected, and that +window's turn is kept as a branch with its own id, leaving the other window's +conversation exactly as it was. You are told when it happens. + +```text +⚠️ This conversation was changed by another Woopcode window. + Continuing in a branch (3f9c1a2b); nothing was overwritten. +``` + +To work in two windows deliberately, `/branch` in one of them first and skip the +notice. ## How it is written @@ -64,20 +143,33 @@ After every turn, to a temporary file that is then renamed over the real one. The rename is atomic on the same filesystem, so a crash mid-write leaves you with either the old file or the new one — never half a transcript. -A `conversation.json` that is not valid JSON is moved aside to -`conversation.json.corrupt-` and history starts empty. Your broken -copy is kept. +A session file that is not valid JSON is moved aside to +`.json.corrupt-` and skipped. Your broken copy is kept, and the +rest of your sessions still open. + +Nothing is written until a turn has run, so starting Woopcode and quitting +leaves no empty session behind. + +## Non-interactive runs + +`woopcode -p` starts its own session rather than continuing the one you have +open, and prints its id to stderr. Pass `--resume ` to continue it, or +`--no-session-persistence` to leave nothing behind. ## Privacy -The file is plain JSON in your home directory. Everything you typed and -everything the agent replied is in it, in the clear. If you paste a secret into -a prompt, it is on disk until you run `/new`. +Sessions are plain JSON in your home directory. Everything you typed and +everything the agent replied is in them, in the clear. If you paste a secret +into a prompt, it is on disk until that session is deleted or aged out. ## When it does not work -**It resumed a conversation from another project** — Expected; history is -global. `/new`. +**It did not resume what I expected** — Sessions belong to a project. Run +Woopcode from the same repository, or use `/resume` and widen with +CtrlA. + +**`--resume` says no session found** — The reference did not match anything in +this project. `woopcode sessions list --all` shows every one on the machine. **History looks truncated** — Only the last 100 messages are kept. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 7f922b3..2a96446 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -24,6 +24,14 @@ directory. | --- | --- | | `-p, --prompt ` | Run a single prompt without the interface and exit | | `--no-auto-approve` | With `--prompt`, reject tool edits and commands instead of approving them | +| `-m, --model ` | Model id for this run only; the saved selection is left alone | +| `-c, --continue` | Reopen the newest session in this project | +| `--resume ` | Reopen a session by name, id or id prefix | +| `--new` | Start a fresh session instead of continuing | +| `--fork-session` | With `--continue` or `--resume`, branch instead of writing into it | +| `-n, --name ` | Name a new session so it can be resumed by name | +| `--no-session-persistence` | With `--prompt`, do not save the session | +| `--events ` | With `--prompt`, write a JSONL record of the run | | `-V, --version` | Print the version | | `-h, --help` | Print usage | @@ -35,6 +43,7 @@ directory. | `woopcode agent` | The same thing, named explicitly | | `woopcode models` | List the models Woopcode knows about | | `woopcode providers` | Inspect and configure providers | +| `woopcode sessions` | List, inspect and prune saved conversations | ## `woopcode` @@ -46,6 +55,23 @@ cd path/to/your-project woopcode ``` +### Sessions + +A bare launch reopens the newest session in that project. The flags pick a +different one: + +```bash +woopcode --continue # explicit about the default +woopcode --resume auth-refactor # by name +woopcode --resume 3f9c1a2b # by id prefix +woopcode --new # start fresh, keeping the old one +woopcode --continue --fork-session # branch rather than continue in place +woopcode -n auth-refactor # name the session as it starts +``` + +A reference that matches nothing exits with status 1 rather than dropping you +into a session you did not ask for. + ### Headless `--prompt` runs one turn without the interface and exits. There is nobody to @@ -70,6 +96,15 @@ the approval mode saved in your config. Use `--no-auto-approve` anywhere the checkout matters — CI in particular. ::: +A headless run starts its own session rather than continuing the one you have +open interactively, and prints the id to stderr: + +```bash +woopcode -p "summarise the auth flow" 2> >(grep '^session ') +woopcode -p --resume 3f9c1a2b "now write the tests" +woopcode -p --no-session-persistence "one-off question" +``` + ## `woopcode models` ```bash @@ -111,6 +146,30 @@ Switching provider also moves the model: a selection belonging to another provider is replaced with that provider's default, because the two are stored independently and a Gemini model id sent to Anthropic fails on the first turn. +## `woopcode sessions` + +| Subcommand | Description | +| --- | --- | +| `list` | Saved sessions in this project, newest first | +| `show` | Print one session's transcript | +| `prune` | Delete sessions past the retention period | + +A bare `woopcode sessions` is the listing. + +```bash +woopcode sessions +woopcode sessions list --all # every project on this machine +woopcode sessions show auth-refactor +woopcode sessions prune --days 7 +``` + +`show` and `prune` take a name, id or id prefix. An ambiguous reference lists +the candidates and exits 1 rather than picking one. + +`prune` without `--days` uses `retentionDays` from +[`providers.json`](/docs/reference/configuration), which defaults to 30. It also +runs on its own at most once a day when a session starts. + ## Exit codes Woopcode exits `0` on success and non-zero when a command fails to parse. A diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 8c3f399..2a97772 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -21,12 +21,14 @@ The directory is created on first run. | File | Contents | | --- | --- | -| `providers.json` | Provider keys, the default provider, the selected model, the approval mode | -| `conversation.json` | Saved conversation history | +| `providers.json` | Provider keys, the default provider, the selected model, the approval mode, session retention | +| `sessions/` | Saved conversations, one directory per project | | `models.json` | The model list | Configuration is global, not per-repository. Conversation history is the one -thing scoped to where you are working. +thing scoped to where you are working: it lives under +`sessions//.json`, keyed by the repository root. See +[Sessions & history](/docs/guides/sessions-and-history). ## `providers.json` @@ -35,6 +37,7 @@ thing scoped to where you are working. "defaultProvider": "google", "selectedModel": "gemini-3.6-flash", "approvalMode": "auto-read-only", + "retentionDays": 30, "providers": { "google": { "type": "api", "apiKey": "..." } } @@ -46,6 +49,7 @@ thing scoped to where you are working. | `defaultProvider` | `string` | `"google"` | Which provider a session starts with | | `selectedModel` | `string` | first model of the provider | Model id, as listed by `woopcode models` | | `approvalMode` | `string` | `"auto-read-only"` | One of the four [approval modes](/docs/guides/approval-modes) | +| `retentionDays` | `number` | `30` | Days a session survives after its last turn; `0` keeps them forever | | `providers` | `object` | three entries | Keyed by provider id | | `providers..type` | `string` | `"api"` | How the provider authenticates | | `providers..apiKey` | `string` | `""` | The stored key | @@ -71,7 +75,7 @@ widen what runs without asking. ## When the file is corrupt -A `providers.json` or `conversation.json` that is not valid JSON — a truncated +A `providers.json` or a session file that is not valid JSON — a truncated write, a bad hand edit — is moved aside rather than crashing every command that touches it: @@ -168,5 +172,5 @@ rather than honoured. - [Configuring providers](/docs/guides/configuring-providers) — the task, not the schema -- [Sessions & history](/docs/guides/sessions-and-history) — what - `conversation.json` holds +- [Sessions & history](/docs/guides/sessions-and-history) — what a session + holds, and how to resume one diff --git a/docs/reference/keyboard.md b/docs/reference/keyboard.md index 38e19e4..1ec2a5a 100644 --- a/docs/reference/keyboard.md +++ b/docs/reference/keyboard.md @@ -83,18 +83,24 @@ answered one at a time. ## Pickers -`/models` and `/approval` open a picker. `/provider` does not — it prints the -configured providers as text. +`/models`, `/approval` and `/resume` open a picker. `/provider` does not — it +prints the configured providers as text. | Key | Action | | --- | --- | | | Move the selection | | Enter | Choose it, and save | | Esc | Close without changing anything | +| CtrlA | Session picker only: widen to every project on this machine | -The two differ in one way: the model picker has a search field, so typing -narrows the list. The approval picker has a short fixed list and no search, so -typing does nothing there. +They differ in one way: the model and session pickers have a search field, so +typing narrows the list. The approval picker has a short fixed list and no +search, so typing does nothing there. + +The session picker starts scoped to the current project, which is what you +almost always want. CtrlA is also the only way to reach +history migrated from a version before sessions existed, which belongs to no +project. ## Ctrl+C diff --git a/onboarding/test-reset.ts b/onboarding/test-reset.ts index d7b8098..ed9b188 100755 --- a/onboarding/test-reset.ts +++ b/onboarding/test-reset.ts @@ -8,9 +8,10 @@ * * The whole directory moves, rather than the keys being blanked in place. * `ensureProviderConfigured` treats a keyless provider and a missing config the - * same way, but the rest of a first run does not: conversation.json and - * execution-log.json are what separate "no key yet" from "never run before", - * and blanking a key leaves both behind. + * same way, but the rest of a first run does not: the saved sessions under + * `sessions/` are what separate "no key yet" from "never run before", and + * blanking a key leaves them behind. Moving the directory is also what lets the + * one-time import of a pre-sessions `conversation.json` be exercised twice. * * This previously pointed at ./config/providers.json — a path inside the * repository, where no configuration has ever lived. It raised ENOENT on every diff --git a/packages/tests/config/configRecovery.test.ts b/packages/tests/config/configRecovery.test.ts index 166b91b..66c40e2 100644 --- a/packages/tests/config/configRecovery.test.ts +++ b/packages/tests/config/configRecovery.test.ts @@ -12,20 +12,31 @@ process.env.XDG_CONFIG_HOME = configHome; const { MAX_PERSISTED_MESSAGES, getConfig, - getConversation, normalizeConfig, prepareConversationForDisk, saveConfig, - saveConversation, - getExecutionLog, - saveExecutionLog, MAX_PERSISTED_RECORDS, } = await import("../../../config/config"); +const { + createSession, + loadSession, + saveSession, + projectRoot, + projectSlug, + resetSessionStoreForTests, +} = await import("../../../config/sessions"); + const configDir = join(configHome, "woopcode"); const providersPath = join(configDir, "providers.json"); -const conversationPath = join(configDir, "conversation.json"); -const executionLogPath = join(configDir, "execution-log.json"); +const sessionsDir = join(configDir, "sessions"); + +/** Round-trips a conversation through a session, the way a turn does. */ +async function storeConversation(messages: Parameters[0]) { + const session = await createSession(); + const saved = await saveSession({ ...session, messages }); + return (await loadSession(saved.id))!; +} afterAll(() => { if (previousConfigHome === undefined) { @@ -106,41 +117,37 @@ describe("corrupt file recovery", () => { expect(config.providers.google?.apiKey).toBeUndefined(); }); - test("a corrupt conversation is quarantined and the transcript starts fresh", async () => { - await getConfig(); // ensures the config dir exists - writeFileSync(conversationPath, '[{"role":"user"'); - - await expect(getConversation()).resolves.toEqual([]); - expect(corruptFiles("conversation.json")).toHaveLength(1); - }); - - test("a conversation that is not a list starts fresh", async () => { - await getConfig(); - writeFileSync(conversationPath, '{"role":"user"}'); + test("a corrupt session is skipped rather than crashing the store", async () => { + const stored = await storeConversation([{ role: "user", content: "hi" }]); + const sessionPath = join(sessionsDir, projectSlug(projectRoot()), `${stored.id}.json`); + writeFileSync(sessionPath, '[{"role":"user"'); - await expect(getConversation()).resolves.toEqual([]); + // Null, not a throw: one unreadable session must not make the picker + // unopenable or stop a launch. + await expect(loadSession(stored.id)).resolves.toBeNull(); }); test("malformed messages are dropped, valid ones kept", async () => { - await getConfig(); - writeFileSync( - conversationPath, - JSON.stringify([{ role: "user", content: "hi" }, null, "junk", { content: "no role" }]), - ); + const stored = await storeConversation([{ role: "user", content: "hi" }]); + const sessionPath = join(sessionsDir, projectSlug(projectRoot()), `${stored.id}.json`); + const raw = JSON.parse(await Bun.file(sessionPath).text()); + raw.messages = [{ role: "user", content: "hi" }, null, "junk", { content: "no role" }]; + writeFileSync(sessionPath, JSON.stringify(raw)); - await expect(getConversation()).resolves.toEqual([ - { role: "user", content: "hi" }, - ]); + const loaded = await loadSession(stored.id); + + expect(loaded!.messages).toEqual([{ role: "user", content: "hi" }]); }); }); describe("conversation persistence", () => { beforeEach(() => { rmSync(configDir, { recursive: true, force: true }); + resetSessionStoreForTests(); }); test("does not persist tool calls or their results", async () => { - await saveConversation([ + const stored = await storeConversation([ { role: "user", content: "hi" }, { role: "assistant_tool_call", @@ -152,9 +159,7 @@ describe("conversation persistence", () => { { role: "assistant", content: "done" }, ]); - const stored = await getConversation(); - - expect(stored.map((message) => message.role)).toEqual(["user", "assistant"]); + expect(stored.messages.map((message) => message.role)).toEqual(["user", "assistant"]); }); test("keeps only the most recent messages", async () => { @@ -163,11 +168,10 @@ describe("conversation persistence", () => { content: `message ${index}`, })); - await saveConversation(messages); - const stored = await getConversation(); + const stored = await storeConversation(messages); - expect(stored).toHaveLength(MAX_PERSISTED_MESSAGES); - expect(stored.at(-1)).toEqual(messages.at(-1)!); + expect(stored.messages).toHaveLength(MAX_PERSISTED_MESSAGES); + expect(stored.messages.at(-1)).toEqual(messages.at(-1)!); }); test("prepareConversationForDisk leaves a short conversation alone", () => { @@ -180,32 +184,41 @@ describe("conversation persistence", () => { }); test("writes atomically, leaving no partial file behind", async () => { - await saveConversation([{ role: "user", content: "hi" }]); + const stored = await storeConversation([{ role: "user", content: "hi" }]); + const projectDir = join(sessionsDir, projectSlug(projectRoot())); - expect(readdirSync(configDir).filter((name) => name.endsWith(".tmp"))).toHaveLength(0); - expect(await getConversation()).toEqual([{ role: "user", content: "hi" }]); + expect(readdirSync(projectDir).filter((name) => name.endsWith(".tmp"))).toHaveLength(0); + expect(stored.messages).toEqual([{ role: "user", content: "hi" }]); }); }); describe("execution log persistence", () => { beforeEach(() => { - rmSync(executionLogPath, { force: true }); + rmSync(configDir, { recursive: true, force: true }); + resetSessionStoreForTests(); }); test("records survive a restart", async () => { // Without this the log would survive a turn but not a restart, which is // the same forgetting one level up. - await saveExecutionLog([ - { iteration: 1, tool: "read_file", subject: "a.ts", outcome: "12 lines" }, - ]); + const session = await createSession(); + const saved = await saveSession({ + ...session, + executionLog: [ + { iteration: 1, tool: "read_file", subject: "a.ts", outcome: "12 lines" }, + ], + }); - expect(await getExecutionLog()).toEqual([ + const reopened = await loadSession(saved.id); + + expect(reopened!.executionLog).toEqual([ { iteration: 1, tool: "read_file", subject: "a.ts", outcome: "12 lines" }, ]); }); - test("a fresh install starts with an empty log", async () => { - expect(await getExecutionLog()).toEqual([]); + test("a fresh session starts with an empty log", async () => { + const session = await createSession(); + expect(session.executionLog).toEqual([]); }); test("the log is capped so a long-lived session cannot grow it forever", async () => { @@ -215,27 +228,22 @@ describe("execution log persistence", () => { subject: `f${i}.ts`, outcome: "1 line", })); - await saveExecutionLog(many); + const session = await createSession(); + const saved = await saveSession({ ...session, executionLog: many }); - const loaded = await getExecutionLog(); - expect(loaded).toHaveLength(MAX_PERSISTED_RECORDS); + expect(saved.executionLog).toHaveLength(MAX_PERSISTED_RECORDS); // The most recent survive: what was done last is what must not be redone. - expect(loaded.at(-1)!.subject).toBe(`f${many.length - 1}.ts`); - }); - - test("a corrupt log is quarantined rather than crashing startup", async () => { - writeFileSync(executionLogPath, "{not json"); - - expect(await getExecutionLog()).toEqual([]); - expect(corruptFiles("execution-log.json.corrupt-").length).toBeGreaterThan(0); + expect(saved.executionLog.at(-1)!.subject).toBe(`f${many.length - 1}.ts`); }); test("entries that are not records are discarded", async () => { - writeFileSync( - executionLogPath, - JSON.stringify([null, 42, { tool: "read_file", outcome: "3 lines" }]), - ); - - expect(await getExecutionLog()).toHaveLength(1); + const session = await createSession(); + const saved = await saveSession({ ...session, messages: [{ role: "user", content: "x" }] }); + const sessionPath = join(sessionsDir, projectSlug(projectRoot()), `${saved.id}.json`); + const raw = JSON.parse(await Bun.file(sessionPath).text()); + raw.executionLog = [null, 42, { tool: "read_file", outcome: "3 lines" }]; + writeFileSync(sessionPath, JSON.stringify(raw)); + + expect((await loadSession(saved.id))!.executionLog).toHaveLength(1); }); }); diff --git a/packages/tests/config/sessionFlags.test.ts b/packages/tests/config/sessionFlags.test.ts new file mode 100644 index 0000000..0cd217d --- /dev/null +++ b/packages/tests/config/sessionFlags.test.ts @@ -0,0 +1,75 @@ +import { describe, test, expect } from "bun:test"; +import { sessionOptionsFrom } from "../../../commands/agent"; + +/** + * The mapping from command-line flags to what the controller resolves. + * + * Pure and worth testing on its own: the two entry points differ in one default + * and getting it backwards is invisible until someone loses a conversation. + */ +describe("sessionOptionsFrom", () => { + test("a bare interactive launch continues where you left off", () => { + // The documented promise, now scoped to the project you are in. + expect(sessionOptionsFrom({}, "interactive").continueLatest).toBe(true); + }); + + test("a bare headless run starts its own session", () => { + // -p used to inherit whatever the interactive session was doing, which a + // scripted caller cannot see coming and could not opt out of. + expect(sessionOptionsFrom({}, "headless").continueLatest).toBe(false); + }); + + test("--continue asks for it explicitly, headlessly too", () => { + expect(sessionOptionsFrom({ continue: true }, "headless").continueLatest).toBe(true); + }); + + test("--new overrides the interactive default", () => { + expect(sessionOptionsFrom({ new: true }, "interactive").continueLatest).toBe(false); + }); + + test("--resume resolves a reference instead of continuing", () => { + const options = sessionOptionsFrom({ resume: "auth-work" }, "interactive"); + + expect(options.sessionRef).toBe("auth-work"); + expect(options.continueLatest).toBe(false); + expect(options.openPicker).toBe(false); + }); + + test("surrounding whitespace on a reference is ignored", () => { + expect(sessionOptionsFrom({ resume: " auth-work " }, "interactive").sessionRef).toBe( + "auth-work", + ); + }); + + test("a bare --resume opens the picker rather than quietly continuing", () => { + // The option takes an optional value, and with none it used to be + // indistinguishable from --continue — advertising a choice it never gave. + const options = sessionOptionsFrom({ resume: true }, "interactive"); + + expect(options.openPicker).toBe(true); + expect(options.sessionRef).toBeUndefined(); + }); + + test("a bare --resume is refused headlessly, where nobody can pick", () => { + expect(() => sessionOptionsFrom({ resume: true }, "headless")).toThrow( + "--resume needs a session id", + ); + }); + + test("--fork-session is carried through", () => { + expect(sessionOptionsFrom({ forkSession: true }, "interactive").fork).toBe(true); + }); + + test("--no-session-persistence turns persistence off", () => { + expect(sessionOptionsFrom({ sessionPersistence: false }, "headless").persist).toBe(false); + }); + + test("persistence is on by default", () => { + expect(sessionOptionsFrom({}, "headless").persist).toBe(true); + }); + + test("--name is passed on, and an empty one is not", () => { + expect(sessionOptionsFrom({ name: "auth-work" }, "interactive").name).toBe("auth-work"); + expect(sessionOptionsFrom({ name: "" }, "interactive").name).toBeUndefined(); + }); +}); diff --git a/packages/tests/config/sessions.test.ts b/packages/tests/config/sessions.test.ts new file mode 100644 index 0000000..0c01d75 --- /dev/null +++ b/packages/tests/config/sessions.test.ts @@ -0,0 +1,595 @@ +import { describe, test, expect, beforeEach, afterAll } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +// Redirected for the whole file, never per test: restoring it in afterEach +// would point the rest of the file at the developer's real ~/.config/woopcode. +// Named with a UUID because Date.now() has millisecond resolution, so two runs +// can build the same fixture path and delete each other's files. +const previousConfigHome = process.env.XDG_CONFIG_HOME; +const configHome = mkdtempSync(join(tmpdir(), `woopcode-sessions-${crypto.randomUUID()}-`)); +process.env.XDG_CONFIG_HOME = configHome; + +const { + LEGACY_SLUG, + adoptSession, + MAX_TITLE_CHARS, + UNTITLED, + createSession, + forkSession, + latestSession, + listSessions, + loadSession, + migrateLegacyConversation, + projectRoot, + projectSlug, + pruneIfDue, + pruneSessions, + renameSession, + resetSessionStoreForTests, + resolveSessionRef, + saveSession, + titleFromPrompt, +} = await import("../../../config/sessions"); + +const configDir = join(configHome, "woopcode"); +const sessionsDir = join(configDir, "sessions"); + +function projectDir() { + return join(sessionsDir, projectSlug(projectRoot())); +} + +afterAll(() => { + if (previousConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previousConfigHome; + } + rmSync(configHome, { recursive: true, force: true }); +}); + +beforeEach(() => { + rmSync(configDir, { recursive: true, force: true }); + resetSessionStoreForTests(); +}); + +/** A session with one exchange in it, saved. */ +async function seed(overrides: Record = {}) { + const session = await createSession(); + return saveSession({ + ...session, + messages: [ + { role: "user", content: "first prompt" }, + { role: "assistant", content: "reply" }, + ], + ...overrides, + }); +} + +describe("titleFromPrompt", () => { + test("uses the prompt when it is short enough", () => { + expect(titleFromPrompt("add session resume")).toBe("add session resume"); + }); + + test("collapses newlines and runs of whitespace onto one line", () => { + expect(titleFromPrompt("fix the\n\n parser bug")).toBe("fix the parser bug"); + }); + + test("truncates long prompts to a single readable row", () => { + const title = titleFromPrompt("x".repeat(200)); + expect(title.length).toBe(MAX_TITLE_CHARS); + expect(title.endsWith("…")).toBe(true); + }); + + test("an empty prompt is untitled rather than blank", () => { + expect(titleFromPrompt(" \n ")).toBe(UNTITLED); + }); +}); + +describe("projectSlug", () => { + test("keeps two directories that slugify alike apart", () => { + // Without the path hash both of these reduce to "a-b" and two unrelated + // projects silently share one session list. + expect(projectSlug("/tmp/a/b")).not.toBe(projectSlug("/tmp/a-b")); + }); + + test("is stable for the same path", () => { + expect(projectSlug("/tmp/x/y")).toBe(projectSlug("/tmp/x/y")); + }); + + test("stays readable for a normal path", () => { + expect(projectSlug("/Users/me/woop-code")).toStartWith("woop-code-"); + }); + + test("survives a path with no usable characters", () => { + expect(projectSlug("/___")).toStartWith("project-"); + }); +}); + +describe("resolveSessionRef", () => { + const summaries = [ + { id: "aaaa1111-0000", name: "auth-work", title: "add auth", slug: "p" }, + { id: "bbbb2222-0000", name: null, title: "fix the parser", slug: "p" }, + { id: "bbbb3333-0000", name: null, title: "fix the lexer", slug: "p" }, + ] as any[]; + + test("an exact name wins over everything else", () => { + const result = resolveSessionRef("auth-work", summaries); + expect(result.status).toBe("found"); + expect((result as any).session.id).toBe("aaaa1111-0000"); + }); + + test("a full id resolves", () => { + expect(resolveSessionRef("bbbb2222-0000", summaries).status).toBe("found"); + }); + + test("a unique id prefix resolves", () => { + const result = resolveSessionRef("aaaa", summaries); + expect((result as any).session.id).toBe("aaaa1111-0000"); + }); + + test("an ambiguous id prefix is reported, not guessed", () => { + const result = resolveSessionRef("bbbb", summaries); + expect(result.status).toBe("ambiguous"); + expect((result as any).matches).toHaveLength(2); + }); + + test("a unique title substring resolves", () => { + const result = resolveSessionRef("parser", summaries); + expect((result as any).session.id).toBe("bbbb2222-0000"); + }); + + test("an ambiguous title substring is reported", () => { + expect(resolveSessionRef("fix the", summaries).status).toBe("ambiguous"); + }); + + test("nothing matching is none", () => { + expect(resolveSessionRef("nope", summaries).status).toBe("none"); + }); + + test("an empty ref matches nothing rather than everything", () => { + expect(resolveSessionRef(" ", summaries).status).toBe("none"); + }); +}); + +describe("the session store", () => { + test("a saved session round trips", async () => { + const saved = await seed(); + const loaded = await loadSession(saved.id); + + expect(loaded!.id).toBe(saved.id); + expect(loaded!.messages).toHaveLength(2); + }); + + test("creating a session writes nothing until it is saved", async () => { + await createSession(); + + // An empty session file would be a resume target with nothing in it, and + // every launch would leave one behind. + expect(existsSync(projectDir())).toBe(false); + }); + + test("listing returns newest first", async () => { + const older = await seed(); + await saveSession({ ...older, updated: Date.now() - 60_000 }); + const newer = await seed(); + + const sessions = await listSessions(); + + expect(sessions[0]!.id).toBe(newer.id); + }); + + test("the index is rebuilt when it is deleted", async () => { + const saved = await seed(); + rmSync(join(projectDir(), "index.json"), { force: true }); + + const sessions = await listSessions(); + + expect(sessions.map((session) => session.id)).toContain(saved.id); + }); + + test("a corrupt index is rebuilt rather than emptying the picker", async () => { + const saved = await seed(); + writeFileSync(join(projectDir(), "index.json"), "{not json"); + + const sessions = await listSessions(); + + expect(sessions.map((session) => session.id)).toContain(saved.id); + }); + + test("a corrupt session is skipped and the rest still list", async () => { + const good = await seed(); + const bad = await seed(); + writeFileSync(join(projectDir(), `${bad.id}.json`), "{not json"); + rmSync(join(projectDir(), "index.json"), { force: true }); + + const sessions = await listSessions(); + + expect(sessions.map((session) => session.id)).toEqual([good.id]); + }); + + test("latestSession is what --continue reopens", async () => { + const first = await seed(); + await saveSession({ ...first, updated: Date.now() - 60_000 }); + const second = await seed(); + + expect((await latestSession())!.id).toBe(second.id); + }); + + test("renaming gives the session a resume handle", async () => { + const saved = await seed(); + await renameSession(saved.id, "auth-work"); + + const sessions = await listSessions(); + const resolution = resolveSessionRef("auth-work", sessions); + + expect((resolution as any).session.id).toBe(saved.id); + }); +}); + +describe("cross-project isolation", () => { + test("a session in one project is invisible from another", async () => { + // The regression this whole change exists to prevent: history from one + // repository being restored into a turn taken in a different one. + const here = await seed(); + + const elsewhere = mkdtempSync(join(tmpdir(), `woopcode-other-${crypto.randomUUID()}-`)); + // A .git directory makes it a project root of its own rather than resolving + // up into whatever contains the temp directory. + mkdirSync(join(elsewhere, ".git"), { recursive: true }); + + const theirs = await listSessions({ cwd: elsewhere }); + expect(theirs).toEqual([]); + + const ours = await listSessions(); + expect(ours.map((session) => session.id)).toContain(here.id); + + rmSync(elsewhere, { recursive: true, force: true }); + }); + + test("the execution log does not cross projects either", async () => { + const session = await createSession(); + await saveSession({ + ...session, + messages: [{ role: "user", content: "hi" }], + executionLog: [ + { iteration: 1, tool: "edit_file", subject: "a.ts", outcome: "written" }, + ], + }); + + const elsewhere = mkdtempSync(join(tmpdir(), `woopcode-other-${crypto.randomUUID()}-`)); + mkdirSync(join(elsewhere, ".git"), { recursive: true }); + + expect(await listSessions({ cwd: elsewhere })).toEqual([]); + + rmSync(elsewhere, { recursive: true, force: true }); + }); +}); + +describe("forking", () => { + test("the copy is a new session carrying the same messages", async () => { + const original = await seed(); + const copy = await forkSession(original.id, { name: "other-way" }); + + expect(copy!.id).not.toBe(original.id); + expect(copy!.forkedFrom).toBe(original.id); + expect(copy!.name).toBe("other-way"); + expect(copy!.messages).toEqual(original.messages); + }); + + test("the original is left exactly as it was", async () => { + const original = await seed(); + const before = await Bun.file(join(projectDir(), `${original.id}.json`)).text(); + + await forkSession(original.id); + + const after = await Bun.file(join(projectDir(), `${original.id}.json`)).text(); + expect(after).toBe(before); + }); + + test("forking something that does not exist returns null", async () => { + expect(await forkSession("no-such-id")).toBeNull(); + }); +}); + +describe("branching across projects", () => { + test("a session from another project can still be forked", async () => { + // forkSession used to look only in the current project, so /branch failed + // on exactly the sessions the picker's all-projects view exists to reach. + const other = mkdtempSync(join(tmpdir(), `woopcode-other-${crypto.randomUUID()}-`)); + mkdirSync(join(other, ".git"), { recursive: true }); + + const session = await createSession({ cwd: other }); + const saved = await saveSession({ + ...session, + messages: [{ role: "user", content: "elsewhere" }], + }); + + const copy = await forkSession(saved.id); + + expect(copy).not.toBeNull(); + expect(copy!.forkedFrom).toBe(saved.id); + // The copy belongs where the work continues, not where it came from. + expect(copy!.cwd).toBe(projectRoot()); + + rmSync(other, { recursive: true, force: true }); + }); + + test("migrated history can be forked", async () => { + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(configDir, "conversation.json"), + JSON.stringify([{ role: "user", content: "old" }]), + ); + resetSessionStoreForTests(); + const imported = await migrateLegacyConversation(); + + const copy = await forkSession(imported!.id, { slug: LEGACY_SLUG }); + + expect(copy).not.toBeNull(); + expect(copy!.messages).toHaveLength(1); + }); +}); + +describe("adopting migrated history", () => { + async function importLegacy() { + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(configDir, "conversation.json"), + JSON.stringify([{ role: "user", content: "legacy work" }]), + ); + resetSessionStoreForTests(); + return (await migrateLegacyConversation())!; + } + + test("a session with no project moves into the one it is worked in", async () => { + const imported = await importLegacy(); + expect(imported.cwd).toBeNull(); + + const adopted = await adoptSession({ + ...imported, + messages: [...imported.messages, { role: "assistant", content: "and more" }], + }); + + expect(adopted.cwd).toBe(projectRoot()); + expect((await listSessions()).map((session) => session.id)).toContain(imported.id); + }); + + test("it stops being listed under legacy, rather than appearing twice", async () => { + const imported = await importLegacy(); + + await adoptSession(imported); + + const all = await listSessions({ scope: "all" }); + const rows = all.filter((session) => session.id === imported.id); + expect(rows).toHaveLength(1); + expect(rows[0]!.slug).not.toBe(LEGACY_SLUG); + expect(existsSync(join(sessionsDir, LEGACY_SLUG, `${imported.id}.json`))).toBe(false); + }); + + test("the conversation survives the move intact", async () => { + const imported = await importLegacy(); + + const adopted = await adoptSession(imported); + const reloaded = await loadSession(adopted.id); + + expect(reloaded!.messages).toEqual(imported.messages); + expect(reloaded!.title).toBe(imported.title); + }); + + test("adopting a session already in this project is just a save", async () => { + const saved = await seed(); + + const adopted = await adoptSession(saved); + + expect(adopted.id).toBe(saved.id); + expect((await listSessions()).filter((s) => s.id === saved.id)).toHaveLength(1); + }); +}); + +describe("an index that has fallen behind the files", () => { + test("a session missing from the index is found again", async () => { + // Two Woopcode windows in one repository each save a session; the index is + // a read-modify-write, so the earlier row can be lost. The file is intact, + // and without the count check it would never be listed again. + const first = await seed(); + const second = await seed(); + + const indexPath = join(projectDir(), "index.json"); + const index = JSON.parse(await Bun.file(indexPath).text()); + index.sessions = index.sessions.filter((entry: any) => entry.id === second.id); + writeFileSync(indexPath, JSON.stringify(index)); + + const listed = (await listSessions()).map((session) => session.id); + + expect(listed).toContain(first.id); + expect(listed).toContain(second.id); + }); + + test("an index with a stale row count is rebuilt from the files", async () => { + const saved = await seed(); + const indexPath = join(projectDir(), "index.json"); + const index = JSON.parse(await Bun.file(indexPath).text()); + index.sessions.push({ ...index.sessions[0], id: "ghost-session" }); + writeFileSync(indexPath, JSON.stringify(index)); + + const listed = (await listSessions()).map((session) => session.id); + + expect(listed).toEqual([saved.id]); + }); +}); + +describe("retention", () => { + test("removes sessions past the cutoff and keeps the rest", async () => { + const day = 24 * 60 * 60 * 1000; + const fresh = await seed(); + const stale = await seed(); + await saveSession({ ...stale, updated: Date.now() - 40 * day }); + // saveSession stamps `updated` itself, so age it on disk afterwards. + const stalePath = join(projectDir(), `${stale.id}.json`); + const raw = JSON.parse(await Bun.file(stalePath).text()); + raw.updated = Date.now() - 40 * day; + writeFileSync(stalePath, JSON.stringify(raw)); + rmSync(join(projectDir(), "index.json"), { force: true }); + + const removed = await pruneSessions(30); + + expect(removed).toBe(1); + const remaining = await listSessions(); + expect(remaining.map((session) => session.id)).toEqual([fresh.id]); + }); + + test("a session exactly at the boundary survives", async () => { + const day = 24 * 60 * 60 * 1000; + const saved = await seed(); + const now = Date.now(); + const path = join(projectDir(), `${saved.id}.json`); + const raw = JSON.parse(await Bun.file(path).text()); + raw.updated = now - 30 * day; + writeFileSync(path, JSON.stringify(raw)); + rmSync(join(projectDir(), "index.json"), { force: true }); + + expect(await pruneSessions(30, now)).toBe(0); + }); + + test("a project with no sessions is left untouched", async () => { + // pruneIfDue used to write an index to record that it had run, creating the + // directory the lazy-creation rule exists to avoid: starting Woopcode in a + // repository and quitting must leave nothing behind. + const fresh = mkdtempSync(join(tmpdir(), `woopcode-fresh-${crypto.randomUUID()}-`)); + mkdirSync(join(fresh, ".git"), { recursive: true }); + const previousCwd = process.cwd(); + process.chdir(fresh); + try { + await pruneIfDue(30); + expect(existsSync(join(sessionsDir, projectSlug(projectRoot())))).toBe(false); + } finally { + process.chdir(previousCwd); + rmSync(fresh, { recursive: true, force: true }); + } + }); + + test("pruning one project does not restamp another", async () => { + // The removal counter used to be shared across every project, so a single + // expired session anywhere rewrote the index of every project visited + // afterwards and marked it as pruned. Needs two projects to show up: one + // with something stale, one with nothing to do. + const day = 24 * 60 * 60 * 1000; + + const stale = mkdtempSync(join(tmpdir(), `woopcode-stale-${crypto.randomUUID()}-`)); + mkdirSync(join(stale, ".git"), { recursive: true }); + const staleSession = await createSession({ cwd: stale }); + await saveSession({ ...staleSession, messages: [{ role: "user", content: "old" }] }); + const staleDir = join(sessionsDir, projectSlug(projectRoot(stale))); + const stalePath = join(staleDir, `${staleSession.id}.json`); + const staleRaw = JSON.parse(await Bun.file(stalePath).text()); + staleRaw.updated = Date.now() - 90 * day; + writeFileSync(stalePath, JSON.stringify(staleRaw)); + rmSync(join(staleDir, "index.json"), { force: true }); + + // The untouched project, whose index must come out byte-identical. + const kept = await seed(); + const keptIndex = join(projectDir(), "index.json"); + const before = await Bun.file(keptIndex).text(); + + const removed = await pruneSessions(30); + + expect(removed).toBe(1); + expect(await Bun.file(keptIndex).text()).toBe(before); + expect(await loadSession(kept.id)).not.toBeNull(); + + rmSync(stale, { recursive: true, force: true }); + }); + + test("zero days keeps everything — that is how retention is turned off", async () => { + const saved = await seed(); + const path = join(projectDir(), `${saved.id}.json`); + const raw = JSON.parse(await Bun.file(path).text()); + raw.updated = 0; + writeFileSync(path, JSON.stringify(raw)); + + expect(await pruneSessions(0)).toBe(0); + expect(await loadSession(saved.id)).not.toBeNull(); + }); +}); + +describe("migrating the pre-sessions conversation", () => { + const legacyConversation = () => join(configDir, "conversation.json"); + const legacyLog = () => join(configDir, "execution-log.json"); + + function writeLegacy(messages: unknown, log?: unknown) { + mkdirSync(configDir, { recursive: true }); + writeFileSync(legacyConversation(), JSON.stringify(messages)); + if (log) writeFileSync(legacyLog(), JSON.stringify(log)); + } + + test("imports the old history into the legacy bucket", async () => { + writeLegacy( + [ + { role: "user", content: "an old prompt" }, + { role: "assistant", content: "an old reply" }, + ], + [{ iteration: 1, tool: "read_file", subject: "a.ts", outcome: "12 lines" }], + ); + + const imported = await migrateLegacyConversation(); + + expect(imported).not.toBeNull(); + // Null cwd, not a guess: that file was shared by every repository on the + // machine, so no project can honestly claim it. + expect(imported!.cwd).toBeNull(); + expect(imported!.title).toBe("an old prompt"); + expect(imported!.messages).toHaveLength(2); + expect(imported!.executionLog).toHaveLength(1); + expect(existsSync(join(sessionsDir, LEGACY_SLUG, `${imported!.id}.json`))).toBe(true); + }); + + test("is idempotent — a second run finds nothing left to import", async () => { + writeLegacy([{ role: "user", content: "an old prompt" }]); + + expect(await migrateLegacyConversation()).not.toBeNull(); + expect(await migrateLegacyConversation()).toBeNull(); + + const legacy = await listSessions({ scope: "all" }); + expect(legacy.filter((session) => session.slug === LEGACY_SLUG)).toHaveLength(1); + }); + + test("retires the sources so they are not re-read forever", async () => { + writeLegacy([{ role: "user", content: "hi" }], []); + + await migrateLegacyConversation(); + + expect(existsSync(legacyConversation())).toBe(false); + expect(existsSync(`${legacyConversation()}.migrated`)).toBe(true); + }); + + test("an absent file is a no-op", async () => { + expect(await migrateLegacyConversation()).toBeNull(); + }); + + test("an empty conversation imports nothing but still retires the file", async () => { + writeLegacy([]); + + expect(await migrateLegacyConversation()).toBeNull(); + expect(existsSync(`${legacyConversation()}.migrated`)).toBe(true); + }); + + test("a corrupt file does not throw", async () => { + mkdirSync(configDir, { recursive: true }); + writeFileSync(legacyConversation(), "{not json"); + + expect(await migrateLegacyConversation()).toBeNull(); + }); + + test("migrated history is reachable only from the all-projects view", async () => { + writeLegacy([{ role: "user", content: "an old prompt" }]); + await migrateLegacyConversation(); + + const scoped = await listSessions(); + expect(scoped.some((session) => session.slug === LEGACY_SLUG)).toBe(false); + + const all = await listSessions({ scope: "all" }); + expect(all.some((session) => session.slug === LEGACY_SLUG)).toBe(true); + }); +}); diff --git a/packages/tests/e2e/chat.e2e.test.ts b/packages/tests/e2e/chat.e2e.test.ts index 05a5007..57b024c 100644 --- a/packages/tests/e2e/chat.e2e.test.ts +++ b/packages/tests/e2e/chat.e2e.test.ts @@ -218,9 +218,20 @@ describe("E2E Chat - Conversation Persistence", () => { // Create new controller and verify history is loaded const newController = new AgentController("test", "test-api-key", callbacks); await newController.initialize(); - - // History should be restored (implementation detail - can't verify directly) - expect(true).toBe(true); + + // This used to assert `true`, which passed whatever the controller did. + // A fresh controller continues the newest session in this project, so the + // turn above has to be visible in it — that is what "persists" means. + // Earlier tests in this file share the project's session, so this asserts + // on what *this* turn added rather than on a fixed position. + expect(newController.messageCount()).toBeGreaterThanOrEqual(2); + expect( + newController + .currentSession() + ?.messages.some( + (message) => message.role === "user" && message.content === "Test prompt", + ), + ).toBe(true); }); test("dispose saves conversation state", async () => { diff --git a/packages/tests/e2e/persistence.e2e.test.ts b/packages/tests/e2e/persistence.e2e.test.ts index dd9c770..899c943 100644 --- a/packages/tests/e2e/persistence.e2e.test.ts +++ b/packages/tests/e2e/persistence.e2e.test.ts @@ -1,11 +1,18 @@ import { describe, test, expect, beforeAll, beforeEach, afterEach, afterAll, mock } from "bun:test"; import { AgentController } from "../../../commands/agentController"; import { - getConversation, MAX_PERSISTED_MESSAGES, prepareConversationForDisk, - saveConversation, } from "../../../config/config"; +import { + createSession, + latestSession, + loadSession, + projectRoot, + projectSlug, + resetSessionStoreForTests, + saveSession, +} from "../../../config/sessions"; import { MockProviderClient, CallbackSpy } from "../shared/mocks"; import { createTextEvent, @@ -41,6 +48,34 @@ afterAll(() => { rmSync(temporaryConfigHome, { recursive: true, force: true }); }); +/** + * What a fresh controller would restore in this project. + * + * History is now a session rather than one global file, so "what is on disk" + * means the newest session here. These two helpers stand in for the + * `getConversation`/`saveConversation` pair the file was written against, so + * every assertion below keeps meaning exactly what it meant. + */ +async function getConversation(): Promise { + const latest = await latestSession(); + if (!latest) return []; + return (await loadSession(latest.id, latest.slug))?.messages ?? []; +} + +/** Replaces this project's history with `messages`, or clears it when empty. */ +async function saveConversation(messages: Message[]): Promise { + rmSync(joinPath(temporaryConfigHome, "woopcode", "sessions", projectSlug(projectRoot())), { + recursive: true, + force: true, + }); + resetSessionStoreForTests(); + + if (messages.length === 0) return; + + const session = await createSession(); + await saveSession({ ...session, messages }); +} + /** * End-to-End Persistence Workflow Tests * @@ -94,6 +129,91 @@ mock.module("../../../providers/client", () => ({ })); +describe("E2E Persistence - Adopting migrated history", () => { + test("a turn taken in migrated history moves it into this project", async () => { + // Migrated history belongs to no project. Working in it here should make it + // this project's, or an hour's work stays in the `legacy` bucket and is + // absent from the list of the repository it actually happened in. + const { migrateLegacyConversation, listSessions, LEGACY_SLUG } = await import( + "../../../config/sessions" + ); + const { mkdirSync, writeFileSync } = await import("node:fs"); + + const configDir = joinPath(temporaryConfigHome, "woopcode"); + mkdirSync(configDir, { recursive: true }); + writeFileSync( + joinPath(configDir, "conversation.json"), + JSON.stringify([{ role: "user", content: "legacy work" }]), + ); + resetSessionStoreForTests(); + const imported = (await migrateLegacyConversation())!; + expect(imported.cwd).toBeNull(); + + mockClient = new MockProviderClient([ + createTextEvent("continuing that work"), + createDoneEvent(), + ]); + + const controller = new AgentController("test", "key", new CallbackSpy()); + await controller.initialize({ sessionRef: imported.id }); + await controller.run("carry on"); + await controller.dispose(); + + const scoped = await listSessions(); + expect(scoped.map((session) => session.id)).toContain(imported.id); + + const all = await listSessions({ scope: "all" }); + const rows = all.filter((session) => session.id === imported.id); + expect(rows).toHaveLength(1); + expect(rows[0]!.slug).not.toBe(LEGACY_SLUG); + + // And the original conversation is still in it. + expect( + controller + .currentSession()! + .messages.some((message) => message.role === "user" && message.content === "legacy work"), + ).toBe(true); + }); +}); + +describe("E2E Persistence - Two windows on one session", () => { + test("a second window branches rather than overwriting the first's turn", async () => { + // Two terminals in one repository both continue the newest session. Each + // writes the whole record, so the later save used to discard the earlier + // turn outright — the single conversation file did this too. Now the + // clobber is detected and this turn is kept under a new id. + const { listSessions } = await import("../../../config/sessions"); + await saveConversation([]); + + mockClient = new MockProviderClient([createTextEvent("A"), createDoneEvent()]); + const windowA = new AgentController("test", "key", new CallbackSpy()); + await windowA.initialize(); + await windowA.run("from window A"); + + mockClient = new MockProviderClient([createTextEvent("B"), createDoneEvent()]); + const windowB = new AgentController("test", "key", new CallbackSpy()); + await windowB.initialize(); + + // A takes another turn, moving the record on disk underneath B. + mockClient = new MockProviderClient([createTextEvent("A again"), createDoneEvent()]); + await windowA.run("A again"); + + mockClient = new MockProviderClient([createTextEvent("B"), createDoneEvent()]); + await windowB.run("from window B"); + + const everything = await listSessions(); + const allMessages = []; + for (const summary of everything) { + const record = await loadSession(summary.id, summary.slug); + allMessages.push(...record!.messages.map((message: any) => message.content)); + } + + // Neither window's work is gone. + expect(allMessages).toContain("A again"); + expect(allMessages).toContain("from window B"); + }); +}); + describe("E2E Persistence - Save and Load", () => { let originalConversation: Message[]; diff --git a/packages/tests/runtime/agentController.test.ts b/packages/tests/runtime/agentController.test.ts index 4a943b8..4eb3d78 100644 --- a/packages/tests/runtime/agentController.test.ts +++ b/packages/tests/runtime/agentController.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, beforeEach, afterAll, mock } from "bun:test"; +import { describe, test, expect, beforeAll, beforeEach, afterAll, mock } from "bun:test"; import { AgentController } from "../../../commands/agentController"; import type { Message } from "../../../config/types"; import { @@ -13,6 +13,37 @@ import { createDoneEvent, } from "../shared/factories"; import { wait } from "../shared/helpers"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join as joinPath } from "node:path"; + +/** + * Belt as well as braces for the session stub below. + * + * The stub keeps writes in memory, but a module mock is a whole-run + * registration: the moment it is restored — or simply not in effect for the + * order a given run picks — the controller persists for real, into whichever + * config directory is current. That is how this file's turns ("read it", "now + * plan it") ended up inside another e2e file's temporary store and failed it. + * A directory of its own means the worst case is a stray temp file. + * + * `getConfigDir` reads the variable on every call, so setting it here — after + * the static imports, before any test body — is enough. + */ +const previousConfigHome = process.env.XDG_CONFIG_HOME; +const temporaryConfigHome = mkdtempSync( + joinPath(tmpdir(), `woopcode-controller-${crypto.randomUUID()}-`), +); +process.env.XDG_CONFIG_HOME = temporaryConfigHome; + +afterAll(() => { + if (previousConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previousConfigHome; + } + rmSync(temporaryConfigHome, { recursive: true, force: true }); +}); // Mock dependencies const mockToolRegistry = new MockToolRegistry(); @@ -41,26 +72,114 @@ const actualConfig = await import("../../../config/config"); // The execution log is stubbed for the same reason the conversation is: the // controller persists both after every turn, and the spread above kept the real -// saveExecutionLog — so these tests were writing the developer's own -// ~/.config/woopcode/execution-log.json, provable from its mtime. +// writer — so these tests were writing the developer's own +// ~/.config/woopcode, provable from its mtime. let mockExecutionRecords: unknown[] = []; -const getExecutionLog = mock(async () => [...mockExecutionRecords]); -const saveExecutionLog = mock(async (records: unknown[]) => { - mockExecutionRecords = [...records]; -}); mock.module("../../../config/config", () => ({ ...actualConfig, - getConversation, - saveConversation, - getExecutionLog, - saveExecutionLog, buildRepositoryContext, recentMessages: (messages: Message[], maxTurns: number) => messages, })); +// Sessions are stubbed as one in-memory record. `getConversation` and +// `saveConversation` survive as the names the assertions below read, so what a +// test means by "saved" is still the messages the controller handed over — +// only the module underneath them moved. +const actualSessions = await import("../../../config/sessions"); + +/** + * The real implementations, captured before the stub is registered. + * + * `mock.module` rewrites the module registry, and a namespace object's + * properties follow it — so reading `actualSessions.createSession` *after* + * registration hands back the stub, and a stub that delegates through the + * namespace calls itself until the stack runs out. + */ +const realSessions = { + createSession: actualSessions.createSession, + loadSession: actualSessions.loadSession, + latestSession: actualSessions.latestSession, + saveSession: actualSessions.saveSession, + listSessions: actualSessions.listSessions, + pruneIfDue: actualSessions.pruneIfDue, +}; + +function stubRecord() { + return { + version: 1, + id: "test-session", + name: null, + title: "test", + cwd: "/test-project", + branch: null, + created: 0, + updated: 0, + forkedFrom: null, + messages: [...mockConversation], + executionLog: [...mockExecutionRecords], + } as any; +} + +/** + * The stub is gated rather than simply registered. + * + * `mock.module` is a whole-run registration and Bun loads every test file's + * module graph before running any of them, so an ungated stub here is live + * during *other* files' tests — which is how this file's turns ("read it", + * "now plan it") surfaced inside the persistence e2e's assertions and failed + * them. Outside this file every function below delegates to the real one. + */ +let stubActive = false; + +beforeAll(() => { + stubActive = true; +}); + +afterAll(() => { + stubActive = false; +}); + +const createSession = mock(async (...args: any[]) => + stubActive + ? { ...stubRecord(), messages: [], executionLog: [] } + : (realSessions.createSession as any)(...args), +); +const loadSession = mock(async (...args: any[]) => + stubActive ? stubRecord() : (realSessions.loadSession as any)(...args), +); +const latestSession = mock(async (...args: any[]) => { + if (!stubActive) return (realSessions.latestSession as any)(...args); + // Null when there is nothing stored, so the controller creates rather than + // resumes — the same branch a first-ever launch takes. + return mockConversation.length > 0 ? ({ id: "test-session", slug: "test" } as any) : null; +}); +const saveSession = mock(async (record: any) => { + if (!stubActive) return (realSessions.saveSession as any)(record); + await saveConversation(record.messages); + mockExecutionRecords = [...record.executionLog]; + return { ...record, messages: [...mockConversation] }; +}); +const listSessions = mock(async (...args: any[]) => + stubActive ? [] : (realSessions.listSessions as any)(...args), +); +const pruneIfDue = mock(async (...args: any[]) => + stubActive ? 0 : (realSessions.pruneIfDue as any)(...args), +); + +mock.module("../../../config/sessions", () => ({ + ...actualSessions, + createSession, + loadSession, + latestSession, + saveSession, + listSessions, + pruneIfDue, +})); + afterAll(() => { mock.module("../../../config/config", () => actualConfig); + mock.module("../../../config/sessions", () => actualSessions); mock.module("../../../providers/client", () => actualClient); mock.module("../../../tools", () => actualTools); }); @@ -97,6 +216,15 @@ const mockStore = { clearPendingCommand: mock(() => {}), cancelPendingQuestion: mock(() => {}), clearPendingContinuation: mock(() => {}), + // Reached by the session slash commands rather than the controller. They are + // here for the reason in the comment above: this stub stands in for the store + // across the whole run, so a method missing from it fails in whichever file + // happens to call it. + clearTimeline: mock(() => {}), + addSystemMessage: mock(() => {}), + openSessionPicker: mock(() => {}), + closeSessionPicker: mock(() => {}), + hydrateTimeline: mock(() => {}), }; mock.module("../../../tui/src", () => ({ diff --git a/packages/tests/slash/sessions.test.ts b/packages/tests/slash/sessions.test.ts new file mode 100644 index 0000000..6f72855 --- /dev/null +++ b/packages/tests/slash/sessions.test.ts @@ -0,0 +1,308 @@ +import { describe, test, expect, beforeEach, afterAll } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { SlashCommandContext } from "../../../commands/slash/types"; + +const previousConfigHome = process.env.XDG_CONFIG_HOME; +const configHome = mkdtempSync(join(tmpdir(), `woopcode-slash-sessions-${crypto.randomUUID()}-`)); +process.env.XDG_CONFIG_HOME = configHome; + +const { registry } = await import("../../../commands/slash/registry"); +const { registerCommands } = await import("../../../commands/slash/commands"); +const { createSession, listSessions, loadSession, resetSessionStoreForTests, saveSession } = + await import("../../../config/sessions"); + +registerCommands(); + +afterAll(() => { + if (previousConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previousConfigHome; + } + rmSync(configHome, { recursive: true, force: true }); +}); + +beforeEach(() => { + rmSync(join(configHome, "woopcode"), { recursive: true, force: true }); + resetSessionStoreForTests(); +}); + +/** + * A controller stub over a real session store: the commands are the subject + * here, not the controller, but what they do has to actually reach disk for + * "the old session is still there" to mean anything. + */ +function createController(options: { busy?: boolean } = {}) { + let current: any = null; + + return { + busy: options.busy ?? false, + isBusy() { + return this.busy; + }, + currentSession() { + return current; + }, + messageCount() { + return current?.messages.length ?? 0; + }, + async adopt(record: any) { + current = record; + return record; + }, + async seed(messages: any[]) { + const session = await createSession(); + current = await saveSession({ ...session, messages }); + return current; + }, + async newSession() { + if (this.busy) return null; + current = await createSession(); + return current; + }, + async switchSession(ref: string) { + if (this.busy) return null; + const sessions = await listSessions(); + const { resolveSessionRef } = await import("../../../config/sessions"); + const resolution = resolveSessionRef(ref, sessions); + if (resolution.status === "ambiguous") { + throw new Error( + `"${ref}" matches ${resolution.matches.length} sessions. Use the id, or /sessions to list them.`, + ); + } + if (resolution.status === "none") return null; + current = await loadSession(resolution.session.id, resolution.session.slug); + return current; + }, + async branchSession(name?: string) { + if (this.busy || !current) return null; + const { forkSession } = await import("../../../config/sessions"); + current = await forkSession(current.id, { name: name ?? null }); + return current; + }, + async renameSession(name: string) { + if (this.busy || !current) return null; + current = await saveSession({ ...current, name: name.trim() || null }); + return current; + }, + }; +} + +function createContext(controller: unknown): SlashCommandContext { + return { + controller: controller as any, + onExit: async () => {}, + onOutput: () => {}, + }; +} + +async function run(name: string, controller: unknown, args: string[] = []) { + return registry.get(name)!.execute(createContext(controller), args); +} + +describe("/new", () => { + test("keeps the previous conversation instead of deleting it", async () => { + // The whole point of the change: /new used to call saveConversation([]), + // and the documentation said outright that there was no undo. + const controller = createController(); + const previous = await controller.seed([{ role: "user", content: "old work" }]); + + await run("new", controller); + + expect(await loadSession(previous.id)).not.toBeNull(); + }); + + test("names the way back to what was left", async () => { + const controller = createController(); + const previous = await controller.seed([{ role: "user", content: "old work" }]); + + const output = await run("new", controller); + + expect(output).toContain(previous.id.slice(0, 8)); + }); + + test("says nothing about resuming when there was nothing to keep", async () => { + const controller = createController(); + + expect(await run("new", controller)).toBe("Started a new session"); + }); + + test("refuses while a turn is running", async () => { + const controller = createController({ busy: true }); + + expect(await run("new", controller)).toContain("Cannot start a new session"); + }); +}); + +describe("/resume", () => { + test("switches to a session named by id prefix", async () => { + const controller = createController(); + const target = await controller.seed([{ role: "user", content: "earlier" }]); + await controller.newSession(); + + const output = await run("resume", controller, [target.id.slice(0, 8)]); + + expect(output).toContain("Resumed"); + expect(controller.currentSession().id).toBe(target.id); + }); + + test("resumes by the name /rename gave it", async () => { + const controller = createController(); + await controller.seed([{ role: "user", content: "earlier" }]); + await run("rename", controller, ["auth-work"]); + const named = controller.currentSession(); + await controller.newSession(); + + await run("resume", controller, ["auth-work"]); + + expect(controller.currentSession().id).toBe(named.id); + }); + + test("an unknown reference is reported, not silently ignored", async () => { + const controller = createController(); + + const output = await run("resume", controller, ["no-such-session"]); + + expect(output).toContain("No session found"); + }); + + test("refuses while a turn is running", async () => { + const controller = createController({ busy: true }); + + expect(await run("resume", controller, ["anything"])).toContain( + "Cannot switch sessions", + ); + }); +}); + +describe("migrated history in a real session", () => { + async function importLegacy() { + const { migrateLegacyConversation, resetSessionStoreForTests: reset } = await import( + "../../../config/sessions" + ); + const { mkdirSync, writeFileSync } = await import("node:fs"); + const configDir = join(configHome, "woopcode"); + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(configDir, "conversation.json"), + JSON.stringify([{ role: "user", content: "legacy work" }]), + ); + reset(); + return (await migrateLegacyConversation())!; + } + + test("resuming it without working in it leaves it where it is", async () => { + // Opening a conversation to read it must not move it. Only a turn does. + const { AgentController } = await import("../../../commands/agentController"); + const imported = await importLegacy(); + + const controller = new AgentController("test", "key", {}); + await controller.initialize({ sessionRef: imported.id }); + await controller.dispose(); + + const { listSessions: list } = await import("../../../config/sessions"); + const row = (await list({ scope: "all" })).find((s) => s.id === imported.id); + expect(row!.slug).toBe("legacy"); + expect(controller.currentSession()!.cwd).toBeNull(); + }); +}); + +describe("--fork-session", () => { + test("branches a session from another project instead of writing into it", async () => { + // Two defects met here: forkSession looked only in the current project, and + // the controller fell back to `fork() ?? original` when it came up empty — + // so the flag that exists to protect the original wrote straight into it. + const { AgentController } = await import("../../../commands/agentController"); + const { mkdirSync } = await import("node:fs"); + + const elsewhere = mkdtempSync(join(tmpdir(), `woopcode-fork-${crypto.randomUUID()}-`)); + mkdirSync(join(elsewhere, ".git"), { recursive: true }); + const session = await createSession({ cwd: elsewhere }); + const original = await saveSession({ + ...session, + messages: [{ role: "user", content: "from another project" }], + }); + + const controller = new AgentController("test", "key", {}); + await controller.initialize({ sessionRef: original.id, fork: true }); + + const current = controller.currentSession()!; + expect(current.id).not.toBe(original.id); + expect(current.forkedFrom).toBe(original.id); + expect(current.messages).toHaveLength(1); + + rmSync(elsewhere, { recursive: true, force: true }); + }); + + test("a reference that names nothing fails rather than starting somewhere else", async () => { + const { AgentController } = await import("../../../commands/agentController"); + const controller = new AgentController("test", "key", {}); + + await expect( + controller.initialize({ sessionRef: "no-such-session", fork: true }), + ).rejects.toThrow(/No session found/); + }); +}); + +describe("/rename", () => { + test("requires a name", async () => { + expect(await run("rename", createController(), [])).toBe("Usage: /rename "); + }); + + test("reports when there is nothing saved yet", async () => { + const output = await run("rename", createController(), ["anything"]); + + expect(output).toContain("run a turn first"); + }); + + test("sets the resume handle", async () => { + const controller = createController(); + await controller.seed([{ role: "user", content: "work" }]); + + expect(await run("rename", controller, ["auth-work"])).toBe("Renamed to auth-work"); + }); +}); + +describe("/branch", () => { + test("leaves the original session in place", async () => { + const controller = createController(); + const original = await controller.seed([{ role: "user", content: "one way" }]); + + await run("branch", controller, ["other-way"]); + + expect(controller.currentSession().id).not.toBe(original.id); + expect(await loadSession(original.id)).not.toBeNull(); + }); + + test("carries the conversation into the copy", async () => { + const controller = createController(); + await controller.seed([{ role: "user", content: "one way" }]); + + await run("branch", controller, ["other-way"]); + + expect(controller.currentSession().messages).toHaveLength(1); + expect(controller.currentSession().forkedFrom).not.toBeNull(); + }); + + test("reports when there is nothing to branch", async () => { + expect(await run("branch", createController(), [])).toContain("run a turn first"); + }); +}); + +describe("/sessions", () => { + test("reports an empty project plainly", async () => { + expect(await run("sessions", createController())).toContain("No saved sessions"); + }); + + test("lists what is stored, marking the active one", async () => { + const controller = createController(); + const session = await controller.seed([{ role: "user", content: "work" }]); + + const output = await run("sessions", controller); + + expect(output).toContain(session.id.slice(0, 8)); + expect(output).toContain("●"); + }); +}); diff --git a/site/src/docs/surface.json b/site/src/docs/surface.json index b3f2216..3e7402c 100644 --- a/site/src/docs/surface.json +++ b/site/src/docs/surface.json @@ -1,7 +1,7 @@ { "counts": { "tools": 14, - "commands": 11, + "commands": 15, "approvalModes": 4 }, "tools": [ @@ -310,9 +310,43 @@ "reset" ], "category": "session", - "description": "Start a new conversation", + "description": "Start a new conversation, keeping the current one", "usage": "/new" }, + { + "name": "resume", + "aliases": [ + "r" + ], + "category": "session", + "description": "Switch to a previous conversation", + "usage": "/resume [name-or-id]" + }, + { + "name": "sessions", + "aliases": [ + "ls" + ], + "category": "session", + "description": "List saved conversations for this project", + "usage": "/sessions" + }, + { + "name": "rename", + "aliases": [], + "category": "session", + "description": "Name the current conversation so it can be resumed by name", + "usage": "/rename " + }, + { + "name": "branch", + "aliases": [ + "fork" + ], + "category": "session", + "description": "Copy this conversation and continue in the copy", + "usage": "/branch [name]" + }, { "name": "exit", "aliases": [ diff --git a/tui/src/app.overlay.test.tsx b/tui/src/app.overlay.test.tsx index 259de00..1840478 100644 --- a/tui/src/app.overlay.test.tsx +++ b/tui/src/app.overlay.test.tsx @@ -124,6 +124,7 @@ describe("dialogs float over the app", () => { beforeEach(() => { store.clearTimeline(); store.closeModelPicker(); + store.closeSessionPicker(); store.addUserMessage(TRANSCRIPT); }); @@ -138,6 +139,19 @@ describe("dialogs float over the app", () => { app.unmount(); }); + test("keeps the transcript on screen behind the session picker", async () => { + // Also the only check that the picker renders at all: it reads the session + // store and the layout plan, and a mistake in either shows up as an empty + // or crashed frame rather than a type error. + const app = mount(); + store.openSessionPicker(); + await waitForFrame(app, "Resume session"); + + expect(app.stdout.text()).toContain(TRANSCRIPT); + expect(app.stdout.text()).toContain("Resume session"); + app.unmount(); + }); + test("keeps the transcript on screen behind a command approval", async () => { const app = mount(); const approval = store.setPendingCommand({ diff --git a/tui/src/app.tsx b/tui/src/app.tsx index 81505ba..9a77f23 100644 --- a/tui/src/app.tsx +++ b/tui/src/app.tsx @@ -9,6 +9,7 @@ import { HomeScreen, type HomeScreenData } from "./components/HomeScreen"; import { DiffPreview } from "./components/DiffPreview"; import { ModelPicker } from "./components/ModelPicker"; import { ApprovalPicker } from "./components/ApprovalPicker"; +import { SessionPicker } from "./components/SessionPicker"; import { CommandApproval } from "./components/CommandApproval"; import { ContinueTurn } from "./components/ContinueTurn"; import { QuestionDialog } from "./components/QuestionDialog"; @@ -48,6 +49,7 @@ export function App({ controller, onExit, homeScreen }: AppProps) { const dialogOpen = state.modelPickerOpen || state.approvalPickerOpen || + state.sessionPickerOpen || hasPendingCommand || hasPendingQuestion || hasPendingContinuation; @@ -164,6 +166,8 @@ export function App({ controller, onExit, homeScreen }: AppProps) { ) : state.approvalPickerOpen ? ( + ) : state.sessionPickerOpen ? ( + ) : hasPendingCommand ? ( ) : hasPendingContinuation ? ( diff --git a/tui/src/components/SessionPicker.tsx b/tui/src/components/SessionPicker.tsx new file mode 100644 index 0000000..6c2a5d9 --- /dev/null +++ b/tui/src/components/SessionPicker.tsx @@ -0,0 +1,237 @@ +import { Box, Text, useInput } from "ink"; +import TextInput from "ink-text-input"; +import { useEffect, useMemo, useState } from "react"; +import { listSessions, type SessionSummary } from "../../../config/sessions"; +import type { AgentController } from "../../../commands/agentController"; +import { store } from "../store/ui-store"; +import { colors } from "../styles/theme"; +import { planLayout, windowAround } from "../layout"; +import { useTerminalSize } from "../hooks/useTerminalSize"; +import { relativeTime } from "../relative-time"; + +interface SessionPickerProps { + controller: AgentController; +} + +export function SessionPicker({ controller }: SessionPickerProps) { + const [query, setQuery] = useState(""); + const [showCursor, setShowCursor] = useState(true); + const [switching, setSwitching] = useState(false); + const [error, setError] = useState(null); + const [sessions, setSessions] = useState(null); + /** + * Scope starts at this project, which is what someone opening the picker + * almost always wants. Widening is one key away and is the only way to reach + * migrated pre-sessions history, which belongs to no project. + */ + const [allProjects, setAllProjects] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + + useEffect(() => { + let cancelled = false; + setSessions(null); + listSessions({ scope: allProjects ? "all" : "project" }) + .then((found) => { + if (!cancelled) { + setSessions(found); + setSelectedIndex(0); + } + }) + .catch((failure: unknown) => { + if (cancelled) return; + setSessions([]); + setError(failure instanceof Error ? failure.message : String(failure)); + }); + return () => { + cancelled = true; + }; + }, [allProjects]); + + // Optional-called for the same reason /status does it: the picker is reachable + // from contexts holding a partly-wired controller, and failing to draw is a + // worse answer than drawing without the active marker. + const activeId = controller.currentSession?.()?.id; + + const matches = useMemo(() => { + const needle = query.toLowerCase(); + if (!needle) return sessions ?? []; + return (sessions ?? []).filter( + (session) => + session.title.toLowerCase().includes(needle) || + (session.name?.toLowerCase().includes(needle) ?? false) || + session.id.startsWith(needle), + ); + }, [sessions, query]); + + const { width, height } = useTerminalSize(); + const layout = planLayout(width, height); + const listRows = Math.max( + 1, + layout.dialogListRows - (error && !layout.showDialogHints ? 1 : 0), + ); + const visible = windowAround(selectedIndex, matches.length, listRows); + const hiddenAbove = visible.start; + const hiddenBelow = matches.length - visible.end; + + useEffect(() => { + const interval = setInterval(() => setShowCursor((on) => !on), 530); + return () => clearInterval(interval); + }, []); + + const close = () => store.closeSessionPicker(); + + const choose = async () => { + const session = matches[selectedIndex]; + if (!session || switching) return; + + if (session.id === activeId) { + close(); + return; + } + + setSwitching(true); + setError(null); + + try { + const record = await controller.switchSession(session.id); + if (!record) { + // isBusy is the likely cause: switching mid-turn would attribute the + // reply in flight to the session being left. + setError("Could not switch sessions right now."); + setSwitching(false); + return; + } + // Redraws the transcript and closes the picker in one update. + store.hydrateTimeline(record.messages); + store.addSystemMessage(`Resumed ${record.name ?? record.title}`); + } catch (failure) { + setError(failure instanceof Error ? failure.message : String(failure)); + setSwitching(false); + } + }; + + useInput((input, key) => { + if (key.escape) { + if (!switching) close(); + return; + } + // Ctrl+A widens to every project on this machine. Checked before the arrow + // keys so a terminal that also sends it as a control character cannot fall + // through into navigation. + if (key.ctrl && input.toLowerCase() === "a") { + setAllProjects((widened) => !widened); + return; + } + if (key.upArrow) { + setSelectedIndex((current) => Math.max(0, current - 1)); + return; + } + if (key.downArrow) { + setSelectedIndex((current) => Math.min(matches.length - 1, current + 1)); + return; + } + if (key.return) void choose(); + }); + + const scopeLabel = allProjects ? "All projects" : "This project"; + + return ( + + + + Resume session + {switching ? "switching…" : "esc"} + + + { + setQuery(value); + setSelectedIndex(0); + setShowCursor(true); + }} + /> + + {layout.showDialogLabel && ( + {scopeLabel} + )} + + {sessions === null ? ( + Loading… + ) : matches.length === 0 ? ( + + {query + ? "No matching sessions" + : allProjects + ? "No saved sessions yet" + : "No sessions in this project — Ctrl+A for all projects"} + + ) : ( + <> + {layout.showDialogScrollIndicators && hiddenAbove > 0 && ( + {` ↑ ${hiddenAbove} more`} + )} + {matches.slice(visible.start, visible.end).map((session, offset) => { + const index = visible.start + offset; + const selected = index === selectedIndex; + const label = session.name ?? session.title; + return ( + + + {session.id === activeId ? "● " : selected ? "› " : " "} + + + + {label} + + + + {layout.dialogWidth >= 40 && ( + + {relativeTime(session.updated)} + + )} + + ); + })} + {layout.showDialogScrollIndicators && hiddenBelow > 0 && ( + {` ↓ ${hiddenBelow} more`} + )} + + )} + + {error ? ( + + + {error} + + + ) : ( + layout.showDialogHints && ( + + Enter resume + ↑↓ navigate + ^A all projects + + ) + )} + + + ); +} diff --git a/tui/src/relative-time.ts b/tui/src/relative-time.ts new file mode 100644 index 0000000..f01b251 --- /dev/null +++ b/tui/src/relative-time.ts @@ -0,0 +1,23 @@ +/** + * How long ago something happened, in one short phrase. + * + * Coarse on purpose: a session row needs "when", not a timestamp, and the + * picker has one narrow column for it. Its own module rather than a helper + * inside the slash commands, so a TUI component can use it without pulling the + * command registry in behind it. + */ +export function relativeTime(when: number, now: number = Date.now()): string { + const seconds = Math.max(0, Math.round((now - when) / 1000)); + if (seconds < 60) return "just now"; + + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h ago`; + + const days = Math.round(hours / 24); + if (days < 30) return `${days}d ago`; + + return `${Math.round(days / 30)}mo ago`; +} diff --git a/tui/src/store/ui-store.test.ts b/tui/src/store/ui-store.test.ts index 0aaee13..b31f594 100644 --- a/tui/src/store/ui-store.test.ts +++ b/tui/src/store/ui-store.test.ts @@ -637,3 +637,113 @@ describe("UIStore blocked tools", () => { expect(store.getState().timeline).toHaveLength(0); }); }); + +describe("UIStore session picker", () => { + test("counts as an open modal, so global keys stand aside", () => { + const store = new UIStore(); + expect(store.hasOpenModal()).toBe(false); + + store.openSessionPicker(); + + expect(store.hasOpenModal()).toBe(true); + }); + + test("esc closes it", () => { + const store = new UIStore(); + store.openSessionPicker(); + + expect(store.dismissTopModal()).toBe(true); + expect(store.getState().sessionPickerOpen).toBe(false); + }); + + test("the model picker still takes precedence when both are open", () => { + // Matches the order app.tsx renders them in; a mismatch would dismiss the + // dialog underneath the one on screen. + const store = new UIStore(); + store.openModelPicker(); + store.openSessionPicker(); + + store.dismissTopModal(); + + expect(store.getState().modelPickerOpen).toBe(false); + expect(store.getState().sessionPickerOpen).toBe(true); + }); +}); + +describe("UIStore system messages", () => { + test("an empty message is not appended", () => { + // A command that only opens a dialog has nothing to say and returns "". + // Appending it left a blank row in the transcript behind every /resume. + const store = new UIStore(); + store.addSystemMessage(""); + store.addSystemMessage(" \n "); + + expect(store.getState().timeline).toHaveLength(0); + }); + + test("a real message is still appended", () => { + const store = new UIStore(); + store.addSystemMessage("Resumed auth-work"); + + expect(store.getState().timeline).toHaveLength(1); + }); +}); + +describe("UIStore hydrateTimeline", () => { + test("redraws a stored conversation as user and assistant rows", () => { + // Restored history used to be loaded into the controller and never + // rendered, so a resumed session showed an empty screen over a + // conversation the model could see. + const store = new UIStore(); + + store.hydrateTimeline([ + { role: "user", content: "first" }, + { role: "assistant", content: "second" }, + ]); + + expect(store.getState().timeline).toMatchObject([ + { type: "user", content: "first" }, + { type: "assistant", content: "second", streaming: false }, + ]); + }); + + test("replaces whatever was on screen rather than appending to it", () => { + const store = new UIStore(); + store.addUserMessage("from the session being left"); + + store.hydrateTimeline([{ role: "user", content: "from the session resumed" }]); + + expect(store.getState().timeline).toHaveLength(1); + }); + + test("skips anything with no text to draw", () => { + const store = new UIStore(); + + store.hydrateTimeline([ + { role: "user", content: "kept" }, + { role: "assistant", content: " " }, + { role: "tool", content: "tool output" }, + { role: "assistant_tool_call", toolName: "read_file" }, + ] as any); + + expect(store.getState().timeline).toHaveLength(1); + }); + + test("clears the usage meter, which described the previous conversation", () => { + const store = new UIStore(); + store.setUsage(1234); + + store.hydrateTimeline([{ role: "user", content: "hi" }]); + + expect(store.getState().usage).toBeNull(); + }); + + test("closes the picker that asked for it", () => { + const store = new UIStore(); + store.openSessionPicker(); + + store.hydrateTimeline([{ role: "user", content: "hi" }]); + + expect(store.getState().sessionPickerOpen).toBe(false); + }); +}); diff --git a/tui/src/store/ui-store.ts b/tui/src/store/ui-store.ts index ce27bfd..4fc0fa5 100644 --- a/tui/src/store/ui-store.ts +++ b/tui/src/store/ui-store.ts @@ -15,6 +15,7 @@ export class UIStore { modelPickerOpen: false, approvalMode: DEFAULT_APPROVAL_MODE, approvalPickerOpen: false, + sessionPickerOpen: false, // A session always starts able to work. Plan mode is deliberately not // persisted: a mode that survived a restart would silently swallow the first // edit of the next session. @@ -180,6 +181,11 @@ export class UIStore { } addSystemMessage(content: string) { + // A command that opens a dialog has nothing to say in the transcript, and + // returns "" to say so. Appending it would leave a blank row behind every + // such command. + if (!content.trim()) return; + this.state = { ...this.state, timeline: [ @@ -445,6 +451,58 @@ export class UIStore { this.emit(); } + openSessionPicker() { + this.state = { ...this.state, sessionPickerOpen: true }; + this.emit(); + } + + closeSessionPicker() { + this.state = { ...this.state, sessionPickerOpen: false }; + this.emit(); + } + + /** + * Redraws the transcript from a stored conversation. + * + * Restored history used to be loaded into the controller and never rendered, + * so relaunching showed an empty screen over a conversation the model could + * see — which reads as history having been lost. Only user and assistant + * messages exist on disk (tool traffic is deliberately not persisted), so + * this is the whole of what a resumed transcript can show. + */ + hydrateTimeline(messages: readonly { role: string; content?: unknown }[]) { + const items: TimeLineItem[] = []; + + for (const message of messages) { + if (typeof message.content !== "string" || !message.content.trim()) continue; + if (message.role === "user") { + items.push({ id: crypto.randomUUID(), type: "user", content: message.content }); + } else if (message.role === "assistant") { + items.push({ + id: crypto.randomUUID(), + type: "assistant", + content: message.content, + streaming: false, + }); + } + } + + this.follow = true; + this.state = { + ...this.state, + timeline: items, + activeTurn: null, + scrollOffset: 0, + maxScrollOffset: 0, + // The meter reports the prompt the current turn sends. A number carried + // over from the session being left would describe the wrong conversation. + usage: null, + sessionPickerOpen: false, + }; + this.activeAssistantId = null; + this.emit(); + } + // Pending Edit Management setPendingEdit(edit: PendingEdit): Promise { if (this.nonInteractive) { @@ -635,6 +693,7 @@ export class UIStore { const { modelPickerOpen, approvalPickerOpen, + sessionPickerOpen, pendingCommand, pendingQuestion, pendingContinuation, @@ -643,6 +702,7 @@ export class UIStore { return ( modelPickerOpen || approvalPickerOpen || + sessionPickerOpen || pendingCommand !== null || pendingQuestion !== null || pendingContinuation !== null || @@ -665,6 +725,10 @@ export class UIStore { this.closeApprovalPicker(); return true; } + if (this.state.sessionPickerOpen) { + this.closeSessionPicker(); + return true; + } if (this.state.pendingCommand) { this.rejectPendingCommand(); return true; diff --git a/tui/src/types.ts b/tui/src/types.ts index fe66b9f..76e238f 100644 --- a/tui/src/types.ts +++ b/tui/src/types.ts @@ -110,6 +110,7 @@ export interface UIState { status: string; isThinking: boolean; modelPickerOpen: boolean; + sessionPickerOpen: boolean; selectedModel: string | null; pendingEdit: PendingEdit | null; pendingCommand: PendingCommand | null;