diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index e895aee..7b140f8 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -163,6 +163,10 @@ async function main(): Promise { allowedTools: args.allowedTools, disallowedTools: args.disallowedTools, maxTurns: args.maxTurns, + resume: args.resume, + resumeId: args.resumeId, + continueSession: args.continue, + forkSession: args.forkSession, }); } diff --git a/apps/cli/src/parse-args.ts b/apps/cli/src/parse-args.ts index cdf1fb3..23d4323 100644 --- a/apps/cli/src/parse-args.ts +++ b/apps/cli/src/parse-args.ts @@ -138,7 +138,7 @@ export function parseArgs(argv: string[]): ParsedArgs { case a === '-p' || a === '--print': out.prompt = next(); break; - case a === '--resume': { + case a === '--resume' || a === '-r': { const maybeId = argv[i + 1]; if (maybeId && !maybeId.startsWith('-')) { out.resumeId = maybeId; @@ -147,7 +147,7 @@ export function parseArgs(argv: string[]): ParsedArgs { out.resume = true; break; } - case a === '--continue': + case a === '--continue' || a === '-c': out.continue = true; break; case a === '--fork-session': @@ -274,8 +274,9 @@ export function helpText(version: string): string { USAGE deepcode Interactive REPL deepcode -p "" Headless one-shot - deepcode --resume [] Resume a session - deepcode --continue Continue most recent session + deepcode --resume, -r [] Resume a session (picker if no id) + deepcode --continue, -c Continue most recent session here + deepcode --resume --fork-session Resume into a new session (keep original) deepcode doctor Diagnostic checks deepcode upgrade Self-update (CLI; Mac client auto-updates) deepcode setup-token [] Store a long-lived DeepSeek auth token (CI) diff --git a/apps/cli/src/repl-resume.test.ts b/apps/cli/src/repl-resume.test.ts new file mode 100644 index 0000000..6f582f7 --- /dev/null +++ b/apps/cli/src/repl-resume.test.ts @@ -0,0 +1,103 @@ +// Tests for resolveSession — the resume / continue / fork decision behind the +// --resume / --continue / --fork-session CLI flags. Pure over a SessionManager +// pointed at a throwaway root, so no live REPL or provider is needed. + +import { afterEach, describe, expect, it } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SessionManager, type StoredMessage } from '@deepcode/core'; +import { resolveSession } from './repl.js'; + +const roots: string[] = []; +async function freshManager(): Promise { + const root = await mkdtemp(join(tmpdir(), 'dc-resume-')); + roots.push(root); + return new SessionManager({ root }); +} +afterEach(async () => { + await Promise.all(roots.splice(0).map((r) => rm(r, { recursive: true, force: true }))); +}); + +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); +function text(t: string): StoredMessage { + return { role: 'user', content: [{ type: 'text', text: t }] }; +} + +describe('resolveSession', () => { + it('starts a fresh session by default', async () => { + const sm = await freshManager(); + const r = await resolveSession(sm, '/proj', 'deepseek-chat', {}); + expect(r.seededHistory).toEqual([]); + expect(r.notice).toBeUndefined(); + expect(r.session.cwd).toBe('/proj'); + }); + + it('--resume resumes that session with its stored history', async () => { + const sm = await freshManager(); + const s = await sm.create('/proj', { model: 'deepseek-chat' }); + await sm.append(s.id, text('hello')); + await sm.append(s.id, text('world')); + const r = await resolveSession(sm, '/proj', 'deepseek-chat', { resumeId: s.id }); + expect(r.session.id).toBe(s.id); + expect(r.seededHistory).toHaveLength(2); + expect(r.notice).toContain('Resumed'); + }); + + it('--continue picks the most recent session in the same cwd', async () => { + const sm = await freshManager(); + const older = await sm.create('/proj', {}); + await sm.append(older.id, text('older')); + await sleep(10); // guarantee a strictly-later updatedAt + const newer = await sm.create('/proj', {}); + await sm.append(newer.id, text('newer')); + // a more-recently-touched session in a DIFFERENT cwd must be ignored + await sleep(10); + const other = await sm.create('/elsewhere', {}); + await sm.append(other.id, text('elsewhere')); + + const r = await resolveSession(sm, '/proj', 'deepseek-chat', { continueSession: true }); + expect(r.session.id).toBe(newer.id); + expect(r.seededHistory).toHaveLength(1); + expect(r.seededHistory[0]!.content[0]).toMatchObject({ text: 'newer' }); + }); + + it('--continue with no session in this cwd starts fresh', async () => { + const sm = await freshManager(); + await sm.create('/elsewhere', {}); + const r = await resolveSession(sm, '/proj', 'deepseek-chat', { continueSession: true }); + expect(r.seededHistory).toEqual([]); + expect(r.notice).toMatch(/no previous session/i); + }); + + it('--fork-session copies history into a new id and leaves the source intact', async () => { + const sm = await freshManager(); + const src = await sm.create('/proj', { model: 'deepseek-chat' }); + await sm.append(src.id, text('a')); + await sm.append(src.id, text('b')); + + const r = await resolveSession(sm, '/proj', 'deepseek-chat', { + resumeId: src.id, + forkSession: true, + }); + expect(r.session.id).not.toBe(src.id); + expect(r.seededHistory).toHaveLength(2); + expect(r.notice).toContain('Forked'); + + // The forked session persisted a copy … + const forked = await sm.load(r.session.id); + expect(forked!.messages).toHaveLength(2); + // … and the source is untouched. + const source = await sm.load(src.id); + expect(source!.messages).toHaveLength(2); + }); + + it('falls back to a fresh session when the resume id is unknown', async () => { + const sm = await freshManager(); + const r = await resolveSession(sm, '/proj', 'deepseek-chat', { + resumeId: 'nope-does-not-exist', + }); + expect(r.seededHistory).toEqual([]); + expect(r.notice).toMatch(/not found/i); + }); +}); diff --git a/apps/cli/src/repl.ts b/apps/cli/src/repl.ts index 3641b3c..72f7bc9 100644 --- a/apps/cli/src/repl.ts +++ b/apps/cli/src/repl.ts @@ -44,6 +44,7 @@ import { type McpClientHandle, type Mode, type AgentEvent, + type SessionMeta, type StoredMessage, type WireResult, } from '@deepcode/core'; @@ -79,10 +80,121 @@ export interface ReplOpts { disallowedTools?: string[]; /** Cap on agent loop turns. */ maxTurns?: number; + // Session resume (--resume / --continue / --fork-session) + /** `--resume` with no id → pick a session interactively. */ + resume?: boolean; + /** `--resume ` → resume this specific session (append to it). */ + resumeId?: string; + /** `--continue` → resume the most recently updated session in this cwd. */ + continueSession?: boolean; + /** `--fork-session` → resume into a NEW session, leaving the source intact. */ + forkSession?: boolean; } const DEFAULT_SYSTEM_PROMPT = `You are DeepCode, an AI coding assistant powered by DeepSeek. Help the user with their codebase using the available tools (Read, Write, Edit, Bash, Grep, Glob). Be concise and accurate. When you modify files, briefly explain what you changed and why.`; +export interface SessionResolution { + session: SessionMeta; + /** Prior messages to seed into the agent's context (empty for a fresh session). */ + seededHistory: StoredMessage[]; + /** One-line status to print (resumed / forked / fell back to fresh). */ + notice?: string; +} + +/** + * Decide which session a REPL launch should use: + * - `resumeId` → resume that exact session (append to it) + * - `continueSession` → resume the most-recently-updated session in `cwd` + * - `forkSession` → resume into a NEW session seeded with a copy of the + * source history, leaving the original untouched + * - otherwise → a fresh session + * Pure over a `SessionManager`, so it's unit-testable without a live REPL. + */ +export async function resolveSession( + sessions: SessionManager, + cwd: string, + model: string, + opts: { resumeId?: string; continueSession?: boolean; forkSession?: boolean }, +): Promise { + let sourceId = opts.resumeId; + + if (!sourceId && opts.continueSession) { + // Most recent session in THIS directory (list() is updatedAt-desc). + const inCwd = (await sessions.list()).filter((m) => m.cwd === cwd); + if (inCwd.length === 0) { + return { + session: await sessions.create(cwd, { model }), + seededHistory: [], + notice: 'No previous session in this directory — starting a new one.', + }; + } + sourceId = inCwd[0]!.id; + } + + if (sourceId) { + const loaded = await sessions.load(sourceId); + if (!loaded) { + return { + session: await sessions.create(cwd, { model }), + seededHistory: [], + notice: `Session ${sourceId} not found — starting a new one.`, + }; + } + const n = loaded.messages.length; + const plural = n === 1 ? '' : 's'; + if (opts.forkSession) { + const forked = await sessions.create(cwd, { + model: loaded.meta.model ?? model, + title: loaded.meta.title, + }); + for (const m of loaded.messages) await sessions.append(forked.id, m); + return { + session: forked, + seededHistory: loaded.messages, + notice: `⎇ Forked ${sourceId} → ${forked.id} (${n} message${plural} copied).`, + }; + } + return { + session: loaded.meta, + seededHistory: loaded.messages, + notice: `↻ Resumed ${sourceId} (${n} message${plural}).`, + }; + } + + return { session: await sessions.create(cwd, { model }), seededHistory: [] }; +} + +/** + * Interactive `--resume` with no id: list recent sessions and read a choice. + * Returns the chosen session id, or undefined to start fresh. + */ +async function pickSessionId( + sessions: SessionManager, + input: Readable, + output: Writable, +): Promise { + const list = (await sessions.list()).slice(0, 20); + if (list.length === 0) { + output.write(' No sessions to resume — starting a new one.\n'); + return undefined; + } + output.write('\n Resume which session?\n'); + list.forEach((m, i) => { + const when = m.updatedAt.slice(0, 16).replace('T', ' '); + const label = m.title?.trim() ? m.title.trim() : m.id; + output.write(` ${String(i + 1).padStart(2)}. ${label} · ${when}\n`); + }); + const picker = createInterface({ input, output, terminal: false }); + const answer = (await picker.question(' Number (blank = new session): ')).trim(); + picker.close(); + const n = Number(answer); + if (!Number.isInteger(n) || n < 1 || n > list.length) { + if (answer) output.write(' No match — starting a new one.\n'); + return undefined; + } + return list[n - 1]!.id; +} + export async function startRepl(opts: ReplOpts): Promise { const { output, cwd } = opts; @@ -125,7 +237,19 @@ export async function startRepl(opts: ReplOpts): Promise { const { maxTokens, temperature } = EFFORT_PARAMS[effort as Effort] ?? EFFORT_PARAMS.medium; const sessions = new SessionManager(); - const session = await sessions.create(cwd, { model }); + // Resolve which session to use: resume an explicit id, continue the most + // recent in this cwd, fork either into a new session, or start fresh. + let resumeId = opts.resumeId; + if (opts.resume && !resumeId && !opts.continueSession) { + resumeId = await pickSessionId(sessions, opts.input, output); + } + const resolved = await resolveSession(sessions, cwd, model, { + resumeId, + continueSession: opts.continueSession, + forkSession: opts.forkSession, + }); + const session = resolved.session; + if (resolved.notice) output.write(` ${resolved.notice}\n`); const provider = new DeepSeekProvider({ apiKey: creds.apiKey ?? '', @@ -286,7 +410,7 @@ export async function startRepl(opts: ReplOpts): Promise { output.write(` ⊞ Plugins: wire-up failed — ${(err as Error).message}\n`); } - let history: StoredMessage[] = []; + let history: StoredMessage[] = resolved.seededHistory; const ctx: SessionContext = { cwd, model, diff --git a/docs/BEHAVIOR_PARITY.md b/docs/BEHAVIOR_PARITY.md index fb9357f..c01d535 100644 --- a/docs/BEHAVIOR_PARITY.md +++ b/docs/BEHAVIOR_PARITY.md @@ -177,7 +177,7 @@ Specific deviations: | `--no-plugins` / `--strict` | 🔄 (parsed only) | | `-p` headless | ✅ text/json/stream-json, 5 exit codes | | `--output-format` / `--json-schema` / `--include-partial-messages` | ✅ output-format + json-schema (lightweight top-level validation) + include-partial-messages all implemented (`headless.ts`) | -| `--resume ` / `--continue` / `--fork-session` | 🔄 M3c+ | +| `--resume ` / `--continue` / `--fork-session` | ✅ resume by id (picker if no id, `-r`), most-recent-in-cwd (`-c`), fork-into-new | ## What DeepCode adds that Claude Code doesn't have (yet)