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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ jobs:
DC_REQUIRE_BWRAP: ${{ runner.os == 'Linux' && '1' || '0' }}
run: pnpm test

# Exercise the installed SDK executable on both operating systems. The
# fixture uses a loopback Messages server and fake keys, never vendor APIs.
- name: Claude SDK executable smoke
env:
DEEPCODE_SDK_SMOKE: '1'
run: pnpm --filter @deepcode/app-server exec vitest run src/engines/claude-sdk.smoke.test.ts

- name: Build + app-server release gate
run: pnpm release:check

Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ jobs:
env:
DC_SANDBOX_NET_TEST: '1'
run: pnpm test
- name: Claude SDK executable smoke
env:
DEEPCODE_SDK_SMOKE: '1'
run: pnpm --filter @deepcode/app-server exec vitest run src/engines/claude-sdk.smoke.test.ts
- run: pnpm docs:check
- run: pnpm release:check

Expand Down
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.4.0] — 2026-10-26

### Multi-model runtime

- Native Kimi and GLM profiles share provider routing, model budgets and scoped
credentials across the CLI and app-server clients. Unknown prices are shown
as unavailable. See [multi-model configuration](docs/multi-model.md).
- Claude Agent SDK 0.3.310 is an explicit **experimental** engine, with controlled
DeepCode tools, approvals, snapshots and isolated sessions. Native remains the
default. To roll back, select `engine: "native"` and start a new session;
existing SDK records are retained.
- Truncated responses and exhausted turn budgets report incomplete execution.
Summaries inherit the selected model and cancellation, credentials follow the
documented precedence, and user prompts can be cancelled.
- File mutations recheck permissions after approval; review restore respects
file restrictions, and SDK rollback records point to the pre-edit snapshot.
Hook processes and SDK resources are cleaned up when work stops. Cancellation
during the final path check or file preparation no longer starts a later write.
Additional writable directories work with the default sandbox mode; SDK
session forks are rejected before copying incomplete history.
- Real SDK executable tests run on Ubuntu and macOS against local fixtures;
Linux bwrap/netns, desktop protocol journeys and release gates also pass.
Real Kimi/GLM endpoints and long-task quality still need live acceptance.
- The VSIX uses native by default and does **not** bundle the optional SDK
executable. Signed macOS installers and npm publication require the release
credentials described in [Releasing DeepCode](docs/RELEASING.md).

The following changes accumulated on main since v0.3.0 are also included.

### ⚠️ Breaking

- **`deepcode mcp serve` now applies your permission settings.** It executed
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@oratis/deepcode",
"version": "0.3.0",
"version": "0.4.0",
"description": "DeepCode CLI — DeepSeek-powered AI coding agent for real codebases",
"license": "MIT",
"type": "module",
Expand Down
20 changes: 19 additions & 1 deletion apps/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@

import {
CredentialsStore,
loadSettings,
gateUntrustedSettings,
DirectoryTrustStore,
defaultModel,
providerForModel,
PROVIDERS,
VERSION,
diagnoseSettings,
fileContractWarnings,
Expand Down Expand Up @@ -204,7 +210,19 @@ async function main(): Promise<number> {
// Onboarding if no creds
const credsStore = new CredentialsStore();
const existing = await credsStore.load();
if (!existing.apiKey && !existing.authToken && !process.env.DEEPSEEK_API_KEY) {
const loaded = await loadSettings({ cwd: process.cwd(), settingsPath: args.settingsFile });
const trusted = await new DirectoryTrustStore().statusFor(process.cwd());
const settings = gateUntrustedSettings(loaded, trusted).settings;
const selected = providerForModel(args.model ?? defaultModel(settings), settings.provider);
const keyEnv = settings.providers?.[selected]?.apiKeyEnv ?? PROVIDERS[selected].apiKeyEnv;
if (
selected === 'deepseek' &&
!existing.apiKey &&
!existing.authToken &&
!process.env[keyEnv] &&
!(keyEnv === 'DEEPSEEK_API_KEY' && process.env.DEEPSEEK_AUTH_TOKEN) &&
!settings.apiKeyHelper
) {
const result = await runOnboarding({
input: process.stdin,
output: process.stdout,
Expand Down
66 changes: 64 additions & 2 deletions apps/cli/src/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,74 @@ describe('built-in command behavior', () => {
await reg.match('/model deepseek-reasoner')!.cmd.run(['deepseek-reasoner'], ctx);
expect(ctx.model).toBe('deepseek-reasoner');
});
it.each(['kimi-k2.5', 'glm-4.7', 'custom/model'])(
'/model accepts %s and uses its output budget',
async (model) => {
const reg = new CommandRegistry();
const ctx = makeContext();
await reg.match('/model')!.cmd.run([model], ctx);
expect(ctx.model).toBe(model);
if (model === 'kimi-k2.5') {
const out = await reg.match('/effort')!.cmd.run(['max'], ctx);
expect(out.join('\n')).toContain('maxTokens=16384');
}
},
);
it('/cost does not reprice mixed model usage using the current model', async () => {
const out = await new CommandRegistry()
.match('/cost')!
.cmd.run([], makeContext({ costModel: null }));
expect(out.join('\n')).toContain('Cost unavailable for mixed-model usage');
});
it('/clear creates a fresh SDK session while leaving the old session intact', async () => {
const sessions = new SessionManager({ root: sessRoot });
const old = await sessions.create('/tmp/x');
const ctx = makeContext({ sessions, sessionId: old.id, settings: { engine: 'claude-sdk' } });
await new CommandRegistry().match('/clear')!.cmd.run([], ctx);
expect(ctx.sessionId).not.toBe(old.id);
expect(await sessions.load(old.id)).not.toBeNull();
});
it('keeps the current SDK model when a session already has history', async () => {
const ctx = makeContext({
model: 'kimi-k2.5',
settings: { engine: 'claude-sdk' },
history: [{ role: 'user', content: [{ type: 'text', text: 'old turn' }] }],
});
const out = await new CommandRegistry().match('/model')!.cmd.run(['glm-4.7'], ctx);
expect(ctx.model).toBe('kimi-k2.5');
expect(out.join('\n')).toContain('/clear');
});
it('passes slash-command cancellation into compaction without replacing history', async () => {
const ac = new AbortController();
const ctx = makeContext({
signal: ac.signal,
history: Array.from({ length: 15 }, () => ({
role: 'user',
content: [{ type: 'text', text: 'keep' }],
})),
provider: {
name: 'mock',
runTurn: async (opts) => {
expect(opts.signal).toBe(ac.signal);
expect(opts.model).toBe('deepseek-chat');
ac.abort();
return {
content: [{ type: 'text', text: 'summary' }],
stopReason: 'end_turn',
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, reasoningTokens: 0 },
};
},
},
});
await new CommandRegistry().match('/compact')!.cmd.run([], ctx);
expect(ctx.newHistory).toBeUndefined();
});

it('/model rejects invalid name', async () => {
const reg = new CommandRegistry();
const ctx = makeContext();
const out = await reg.match('/model wrong')!.cmd.run(['wrong'], ctx);
expect(out.join('\n')).toMatch(/Unknown model/);
const out = await reg.match('/model bad model')!.cmd.run(['bad model'], ctx);
expect(out.join('\n')).toMatch(/Invalid model/);
expect(ctx.model).toBe('deepseek-chat'); // unchanged
});

Expand Down
90 changes: 61 additions & 29 deletions apps/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@ import type {
} from '@deepcode/core';
import {
describeSandboxMode,
validModelId,
MODEL_CATALOG,
resolveSandboxMode,
contextWindowFor,
estimateCost,
redact,
writeSettings,
EFFORT_PARAMS,
modelParameters,
providerForModel,
VERSION,
type Credentials,
type Effort,
Expand Down Expand Up @@ -144,6 +147,8 @@ export interface SessionContext {
voiceCapture?: () => Promise<{ transcript: string | null; lines: string[] }>;
/** Set by /voice — the REPL pre-fills the next input line with this text. */
prefillInput?: string;
/** Null means cumulative usage spans models and cannot be repriced as one model. */
costModel?: string | null;
sessionId: string;
sessions: SessionManager;
usage: {
Expand Down Expand Up @@ -176,6 +181,8 @@ export interface SessionContext {
initFlow?: () => Promise<string | null>;
/** Current conversation history — REPL refreshes this before each command call. */
history?: StoredMessage[];
/** Cancellation of a slash command that invokes a provider. */
signal?: AbortSignal;
/** Provider for commands that need to call the LLM (e.g. /rewind summarize). */
provider?: Provider;
/** Set by /rewind to request history replacement. REPL applies after run. */
Expand Down Expand Up @@ -221,8 +228,11 @@ export const HelpCommand: SlashCommand = {

export const ClearCommand: SlashCommand = {
name: '/clear',
description: 'Clear conversation history (keeps session ID).',
run(_args, ctx) {
description: 'Clear conversation history (SDK starts a new session).',
async run(_args, ctx) {
if (ctx.settings.engine === 'claude-sdk') {
ctx.sessionId = (await ctx.sessions.create(ctx.cwd)).id;
}
ctx.clearHistory = true;
return ['Conversation history cleared.'];
},
Expand Down Expand Up @@ -262,13 +272,17 @@ export const StatusCommand: SlashCommand = {

export const ModelCommand: SlashCommand = {
name: '/model',
description: 'Switch model: /model deepseek-chat | deepseek-reasoner',
description: 'Switch model: /model <model-id>',
run(args, ctx) {
if (args.length === 0) return [`Current model: ${ctx.model}`];
const next = args[0]!;
if (next !== 'deepseek-chat' && next !== 'deepseek-reasoner') {
return [`Unknown model "${next}". Valid: deepseek-chat | deepseek-reasoner`];
if (!validModelId(next)) {
return [`Invalid model ID. Presets: ${MODEL_CATALOG.map((m) => m.id).join(' | ')}`];
}
if (ctx.settings.engine === 'claude-sdk' && ctx.history?.length && next !== ctx.model)
return [
'Claude SDK fixes the model per session. Use /clear, then /model to choose a new model.',
];
ctx.model = next;
return [`Model switched to ${next}.`];
},
Expand Down Expand Up @@ -306,7 +320,7 @@ export const PlanCommand: SlashCommand = {

// Effort tier UI metadata surfaced by `/effort` with no args.
// why: the maxTokens/temperature numbers are NOT defined here — they are read
// from EFFORT_PARAMS in @deepcode/core, the single source of truth the REPL and
// from modelParameters in @deepcode/core, the single source of truth the REPL and
// headless paths actually send to the provider. A divergent hardcoded table
// here previously told users "max = 32768 tokens, temp 0.7" while the provider
// sent max_tokens=8192, temp=0.8 — pure misinformation. Only the human-readable
Expand All @@ -321,46 +335,40 @@ const EFFORT_USE: Record<Effort, string> = {
max: 'Open-ended exploration. Burns tokens.',
};

const EFFORT_TIERS: Array<{
name: Effort;
maxTokens: number;
temperature: number;
use: string;
}> = EFFORT_ORDER.map((name) => ({
name,
maxTokens: EFFORT_PARAMS[name].maxTokens,
temperature: EFFORT_PARAMS[name].temperature,
use: EFFORT_USE[name],
}));

export const EffortCommand: SlashCommand = {
name: '/effort',
description:
'Set effort tier (interactive picker if no arg): /effort [low|medium|high|xhigh|max]',
run(args, ctx) {
const id = providerForModel(ctx.model, ctx.settings.provider);
const tiers = EFFORT_ORDER.map((name) => ({
name,
...modelParameters(ctx.model, name, ctx.settings.providers?.[id], id),
use: EFFORT_USE[name],
}));
if (args.length === 0) {
// Selector UI — show the table; user picks via `/effort <name>` next turn.
const lines = [`Current effort: ${ctx.effort}`, ''];
lines.push('Available tiers:');
lines.push(' Tier maxTokens temperature Use case');
for (const t of EFFORT_TIERS) {
for (const t of tiers) {
const marker = t.name === ctx.effort ? '●' : ' ';
lines.push(
` ${marker} ${t.name.padEnd(7)} ${String(t.maxTokens).padStart(7)} ${t.temperature.toFixed(1).padStart(11)} ${t.use}`,
` ${marker} ${t.name.padEnd(7)} ${String(t.maxTokens).padStart(7)} ${(t.temperature?.toFixed(1) ?? 'default').padStart(11)} ${t.use}`,
);
}
lines.push('');
lines.push('Switch with: /effort <tier>');
return lines;
}
const next = args[0]!;
const tier = EFFORT_TIERS.find((t) => t.name === next);
const tier = tiers.find((t) => t.name === next);
if (!tier) {
return [`Unknown effort "${next}". Valid: ${EFFORT_TIERS.map((t) => t.name).join(' | ')}`];
return [`Unknown effort "${next}". Valid: ${tiers.map((t) => t.name).join(' | ')}`];
}
ctx.effort = next;
return [
`Effort switched to ${next}. (maxTokens=${tier.maxTokens}, temperature=${tier.temperature}, ${tier.use})`,
`Effort switched to ${next}. (maxTokens=${tier.maxTokens}, temperature=${tier.temperature ?? 'default'}, ${tier.use})`,
];
},
};
Expand All @@ -373,7 +381,12 @@ export const CostCommand: SlashCommand = {
// Cache-aware pricing per docs/design/effort-levels.md §2.4. DeepSeek's
// prompt caching is automatic server-side; cache-hit input tokens bill at
// ~10% of a miss, so a stable prompt prefix across turns saves real money.
const c = estimateCost(ctx.usage, ctx.model);
const c = estimateCost(ctx.usage, ctx.costModel ?? ctx.model);
if (!c.available || ctx.costModel === null)
return [
`Tokens — in: ${ctx.usage.inputTokens}, out: ${ctx.usage.outputTokens}`,
`Cost unavailable for ${ctx.costModel === null ? 'mixed-model usage' : ctx.model}; consult the provider usage dashboard.`,
];
const cacheHits = Math.min(ctx.usage.cacheReadTokens, ctx.usage.inputTokens);
const hitPct = (c.cacheHitRate * 100).toFixed(0);
const lines = [
Expand All @@ -393,7 +406,8 @@ export const ContextCommand: SlashCommand = {
description: 'Show context window usage.',
run(_args, ctx) {
const used = ctx.usage.inputTokens + ctx.usage.outputTokens;
const ctxMax = contextWindowFor(ctx.model);
const id = providerForModel(ctx.model, ctx.settings.provider);
const ctxMax = ctx.settings.providers?.[id]?.contextWindow ?? contextWindowFor(ctx.model);
const pct = ((used / ctxMax) * 100).toFixed(1);
return [
`Context: ${used.toLocaleString()} / ${ctxMax.toLocaleString()} (${pct}%)`,
Expand Down Expand Up @@ -722,6 +736,10 @@ export const RewindCommand: SlashCommand = {
}

const action = (args[1] ?? 'code').toLowerCase();
if (ctx.settings.engine === 'claude-sdk' && action !== 'code')
return [
'Claude SDK owns its conversation history. Use /clear to start a new session; file-only rewind remains available.',
];
const cutoffMs = Date.parse(target.capturedAt);
const currentHistory = ctx.history ?? [];

Expand Down Expand Up @@ -754,7 +772,11 @@ export const RewindCommand: SlashCommand = {
if (tail.length === 0) {
return [`Nothing after snapshot #${target.seq} to summarize.`];
}
const result = await compact(tail, { provider: ctx.provider });
const result = await compact(tail, {
provider: ctx.provider,
summarizerModel: ctx.model,
signal: ctx.signal,
});
// New history: head (verbatim) + the compacted tail
ctx.newHistory = [...kept, ...result.history];
return [
Expand All @@ -769,7 +791,11 @@ export const RewindCommand: SlashCommand = {
if (head.length === 0) {
return [`Nothing before snapshot #${target.seq} to summarize.`];
}
const result = await compact(head, { provider: ctx.provider });
const result = await compact(head, {
provider: ctx.provider,
summarizerModel: ctx.model,
signal: ctx.signal,
});
// New history: compacted head + tail (verbatim)
ctx.newHistory = [...result.history, ...tail];
return [
Expand Down Expand Up @@ -939,12 +965,18 @@ export const CompactCommand: SlashCommand = {
name: '/compact',
description: 'Summarize the conversation so far to free up context.',
async run(_args, ctx) {
if (ctx.settings.engine === 'claude-sdk')
return ['Claude SDK manages context compaction. Use /clear for a new session.'];
if (!ctx.provider) return ['(/compact needs a provider — none configured.)'];
const history = ctx.history ?? [];
if (history.length === 0) return ['Nothing to compact yet.'];
try {
const { compact } = await import('@deepcode/core');
const result = await compact(history, { provider: ctx.provider });
const result = await compact(history, {
provider: ctx.provider,
summarizerModel: ctx.model,
signal: ctx.signal,
});
if (result.messagesRemoved === 0) {
return ['Conversation is already short enough — nothing to compact.'];
}
Expand Down
Loading
Loading