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;