Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ async function main(): Promise<number> {
allowedTools: args.allowedTools,
disallowedTools: args.disallowedTools,
maxTurns: args.maxTurns,
resume: args.resume,
resumeId: args.resumeId,
continueSession: args.continue,
forkSession: args.forkSession,
});
}

Expand Down
9 changes: 5 additions & 4 deletions apps/cli/src/parse-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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':
Expand Down Expand Up @@ -274,8 +274,9 @@ export function helpText(version: string): string {
USAGE
deepcode Interactive REPL
deepcode -p "<prompt>" Headless one-shot
deepcode --resume [<id>] Resume a session
deepcode --continue Continue most recent session
deepcode --resume, -r [<id>] Resume a session (picker if no id)
deepcode --continue, -c Continue most recent session here
deepcode --resume <id> --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 [<token>] Store a long-lived DeepSeek auth token (CI)
Expand Down
103 changes: 103 additions & 0 deletions apps/cli/src/repl-resume.test.ts
Original file line number Diff line number Diff line change
@@ -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<SessionManager> {
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<void> => 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 <id> 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);
});
});
128 changes: 126 additions & 2 deletions apps/cli/src/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
type McpClientHandle,
type Mode,
type AgentEvent,
type SessionMeta,
type StoredMessage,
type WireResult,
} from '@deepcode/core';
Expand Down Expand Up @@ -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 <id>` β†’ 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<SessionResolution> {
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<string | undefined> {
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<number> {
const { output, cwd } = opts;

Expand Down Expand Up @@ -125,7 +237,19 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
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 ?? '',
Expand Down Expand Up @@ -286,7 +410,7 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
output.write(` ⊞ Plugins: wire-up failed β€” ${(err as Error).message}\n`);
}

let history: StoredMessage[] = [];
let history: StoredMessage[] = resolved.seededHistory;
const ctx: SessionContext = {
cwd,
model,
Expand Down
2 changes: 1 addition & 1 deletion docs/BEHAVIOR_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` / `--continue` / `--fork-session` | πŸ”„ M3c+ |
| `--resume <id>` / `--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)

Expand Down
Loading