Compare commits

...
4 Commits
Author SHA1 Message Date
esengine e3d6fe3a86 fix(client): bump default timeout past DeepSeek's 10-min queue cap (0.5.22)
DeepSeek's 2026-04 docs spell out that when concurrency is saturated
but the request is accepted, the connection stays open for up to 10
minutes while the request waits in the queue — non-streaming bodies
fill with empty lines, streaming bodies fill with `:` SSE keep-alive
comments. Both are invisible to our parsers (JSON.parse tolerates
leading whitespace; eventsource-parser drops `:`-prefixed comment
lines per the SSE spec), so no separate handling is needed.

What WAS broken: `timeoutMs` defaulted to 120s. A request that sat
in the queue past 2 min triggered our AbortController, which burned
the queue slot and bounced through retryWithBackoff — potentially
looping through the whole queue repeatedly and never landing.

Fix: bump default to 660_000ms (11 min). Server's own 10-min cap
closes the connection first on genuine queue overflow (clean EOF
→ our retry is normal), while still catching truly hung sockets.

Callers that want a tighter budget (short-lived CI checks, smoke
tests) keep their explicit `timeoutMs` override. No API shape change.

1007 tests. Lint / typecheck / build clean.
2026-04-23 21:17:04 -07:00
esengine bfc4cd38ee docs(readme): update for V4 models, /effort, /resource, /prompt, 1M context, !/@ shortcuts
- Tagline + /preset max + /think: V3/R1 → V4 (flash + pro)
- /model row lists v4-flash/pro as primary, chat/reasoner as compat aliases
- Add /effort, /resource, /prompt, /models, /context, /stats, /keys rows
- Context window 131k → 1M; test count 648 → 1007
- New "Prompt prefixes — !cmd and @path" section documenting the bang-
  mode shell shortcut (0.5.8, now session-durable) and @-mentions
  picker (0.5.5-0.5.7) — both are daily-use features that had no home
  in the README until now.
- Panel header example bumped from 0.4.22 to the current 0.5.21 with
  the reasoning-effort `· max` badge visible.

No code changes.
2026-04-23 21:13:00 -07:00
esengine 0ccc419d0d feat(v4): integrate DeepSeek V4 — models, pricing, thinking toggle, effort control (0.5.21)
Models & pricing (src/telemetry.ts):
- Add deepseek-v4-flash (¥0.2 / ¥1 / ¥2 per 1M hit/miss/out) and
  deepseek-v4-pro (¥1 / ¥12 / ¥24), converted at 7.2 CNY/USD.
- deepseek-chat and deepseek-reasoner now priced identically to
  v4-flash — per the 2026-04 V4 launch they're compat aliases for
  flash non-thinking / thinking modes respectively.
- Context bumped to 1M tokens across all four entries.

Defaults shifted to v4-pro (agent-class flagship):
- `reasonix code` mode, subagent DEFAULT_SUBAGENT_MODEL, loop
  constructor fallback, `max` preset all point at deepseek-v4-pro.
- `fast` / `smart` presets stay on chat/reasoner — those aliases are
  currently our only way to pin thinking ON or OFF via model name,
  so the preset system still has a cheap/fast option.
- Subagent tool enum widened to [v4-flash, v4-pro, chat, reasoner];
  description flipped to treat flash as the "cheaper opt-out" path.

Thinking-mode toggle + reasoning_effort (new API params):
- src/types.ts ChatRequestOptions gains `thinking?: "enabled" | "disabled"`
  serialized as extra_body.thinking.type, and `reasoningEffort?:
  "high" | "max"` as the top-level reasoning_effort field.
- src/loop.ts exposes thinkingModeForModel(model) and pins the
  correct value per model name (chat=disabled, reasoner=enabled,
  v4-*=enabled, unknown=undefined/server-default). Every turn now
  sets thinking + reasoningEffort explicitly — no more relying on
  server defaults to match what reasoning_content round-trip expects.
- reasoning_effort defaults to "max" because Reasonix is exactly the
  agent-class use case DeepSeek's docs call out.

UI: /effort slash + StatsPanel badge:
- `/effort [high|max]` flips the loop's reasoningEffort mid-session.
- `/effort` no-arg reports the current value.
- StatsPanel header shows a green `· max` or yellow `· high` tag next
  to harvest/branch/PLAN so users always see which tier the next turn
  will use. `/status` flags line also reports it.

Harvest cost tuning (src/harvest.ts):
- Default model reverted to deepseek-chat — harvest is a micro-
  extraction (4 small arrays of strings), not agent work.
- chat() call explicitly pins thinking=disabled + effort=high so a
  future default-model swap can't accidentally promote this micro-
  call into a multi-thousand-reasoning-token request.

1007 tests (+9 new: thinking toggle, /effort slash, v4 pricing).
Lint / typecheck / build clean.
2026-04-23 21:10:13 -07:00
esengine 7542aca687 feat(ui): show "N ready · building call M" during multi-tool turns (0.5.19)
Long assistant responses with many tool_calls used to render as a
single static "building tool call <write_file> · 37 chars" spinner
for 10–30s — user could see tokens being consumed but had no sense of
how many files were coming or how far along the stream was.

Each tool_call's `arguments` blob is now tried as JSON at every delta.
Once it first parses, the index is marked "ready" and the count goes
into subsequent `tool_call_delta` events. The UI label becomes
"building tool call (call 3) <write_file> · 127 chars · 2 ready"
so the user sees progress accumulating.

Dispatch timing is UNCHANGED — this is purely a visibility upgrade.
Mid-stream eager dispatch was considered and punted because it would
interact awkwardly with the repair step (truncation fix, storm
breaker, scavenge) that runs post-stream.

+6 tests locking down looksLikeCompleteJson (the gate that decides
when a streaming call counts as "ready"). 991 tests.
2026-04-23 20:18:10 -07:00
21 changed files with 561 additions and 75 deletions

No files matched your search

+51 -12
View File
@@ -3,7 +3,7 @@
</p>
<p align="center">
<em>Cache-first agent loop for DeepSeek V3 &amp; R1 — Ink TUI, MCP first-class, no LangChain.</em>
<em>Cache-first agent loop for DeepSeek V4 (flash + pro) — Ink TUI, MCP first-class, no LangChain.</em>
</p>
# Reasonix
@@ -133,6 +133,34 @@ shell call will execute. Use for high-stakes changes you want to
audit before the model touches disk. `/plan off` or picker
Approve/Cancel exits.
### Prompt prefixes — `!cmd` and `@path`
Two inline shortcuts that don't need a slash:
**`!<cmd>` — run a shell command in the sandbox and feed it to the
model.** Typed at the prompt, like bash. Output lands in the visible
log AND in the session so the model's next turn reasons about it:
```
reasonix code › !git status --short
▸ M src/users.ts
▸ M src/users.test.ts
reasonix code › 把这两个文件的改动说明一下
assistant
▸ tool<read_file> → src/users.ts, src/users.test.ts
▸ …
```
No allowlist gate — user-typed shell = explicit consent. 60s timeout,
32k char cap, survives session resume since 0.5.14.
**`@path/to/file` — inline a file under "Referenced files."** Start
typing `@` and a picker appears (↑/↓ navigate, Tab/Enter to insert).
Good for "what does @src/users.ts do?" without making the model
`read_file` it first. Sandboxed: relative paths only, no `..` escape,
64KB per-file cap. Recent files rank higher.
### `/commit` — stage + commit in one step
```
@@ -145,12 +173,14 @@ reasonix code › /commit "fix: findByEmail case-insensitive"
- `/tool 1` — dump the last tool call's full output (when the 400-char
inline clip isn't enough).
- `/think` — see the model's full R1 reasoning for the last turn
(reasoner preset only).
- `/think` — see the model's full reasoning for the last turn
(thinking-mode models: v4-flash / v4-pro / reasoner alias).
- `/undo` — roll back the last applied edit batch.
- `/new` — start fresh in the same directory without losing the
session file.
- `npx reasonix code --preset max` — R1 + 3-way self-consistency
- `/effort high` — step down from the default `max` agent-class
reasoning_effort for cheaper/faster turns on simple tasks.
- `npx reasonix code --preset max` — v4-pro + 3-way self-consistency
branching for gnarly refactors.
- `npx reasonix code src/` — narrower sandbox (only `src/` is
writable).
@@ -182,7 +212,9 @@ in the file. No prompts, no completions, no tool arguments.
### Staying current
The panel header shows the running version next to `Reasonix` (e.g.
`Reasonix v0.4.22 · model …`). A quiet 24-hour background check against
`Reasonix v0.5.21 · deepseek-v4-pro · harvest · max …`, the trailing
`max` is the reasoning-effort badge — `/effort high` to step down).
A quiet 24-hour background check against
the npm registry surfaces a yellow `update: X.Y.Z` on the right side
of the same row when a newer version has been published. No blocking,
no nagging — the check runs once per day max and is silent on failure
@@ -403,18 +435,25 @@ rendering, retries.
| command | what it does |
|---|---|
| `/preset <fast\|smart\|max>` | one-tap bundle (model + harvest + branch) |
| `/model <id>` | switch DeepSeek model (`deepseek-chat`, `deepseek-reasoner`) |
| `/model <id>` | switch DeepSeek model (`deepseek-v4-flash`, `deepseek-v4-pro`, plus `deepseek-chat` / `deepseek-reasoner` compat aliases) |
| `/models` | list live models from DeepSeek `/models` endpoint |
| `/harvest [on\|off]` | toggle R1 plan-state extraction |
| `/branch <N\|off>` | run N parallel samples per turn, pick best (N ≥ 2) |
| `/think` | dump the last turn's full R1 reasoning |
| `/effort <high\|max>` | reasoning_effort cap — `max` is the agent default, `high` is cheaper/faster |
| `/think` | dump the last turn's full thinking-mode reasoning |
**Context & tools**
| command | what it does |
|---|---|
| `/mcp` | list attached MCP servers and their tools |
| `/mcp` | list attached MCP servers and their tools / resources / prompts |
| `/resource [uri]` | browse + read MCP resources (no arg → list URIs; `<uri>` → fetch) |
| `/prompt [name]` | browse + fetch MCP prompts |
| `/tool [N]` | dump the Nth tool call's full output (1 = latest) |
| `/compact [cap]` | shrink oversized tool results in the log |
| `/compact [tokens]` | shrink oversized tool results in the log (default 4000 tokens/result) |
| `/context` | break down where context tokens are going (system / tools / log) |
| `/stats` | cross-session cost dashboard (today / week / month / all-time) |
| `/keys` | keyboard shortcuts + prompt prefixes (`!` / `@` / `/`) cheatsheet |
**Memory & skills**
@@ -468,8 +507,8 @@ rendering, retries.
- Malformed `assistant.tool_calls` / `tool` pairing is validated on
every outgoing API call so a corrupted session can't keep 400ing.
- Context gauge turns yellow at 50%, red at 80% with a `/compact`
nudge. Approaching the 131k window triggers an automatic
compaction attempt before falling back to a forced summary.
nudge. Approaching the 1M-token window (V4 flash + pro) triggers an
automatic compaction attempt before falling back to a forced summary.
- The `reasonix code` sandbox refuses any path that resolves outside
the launch directory, including symlink escape and `..` traversal.
@@ -728,7 +767,7 @@ cd reasonix
npm install
npm run dev code # run CLI from source via tsx
npm run build # tsup to dist/
npm test # vitest (648 tests)
npm test # vitest (1007 tests)
npm run lint # biome
npm run typecheck # tsc --noEmit
```
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "reasonix",
"version": "0.5.18",
"version": "0.5.22",
"description": "DeepSeek-native agent framework: cache-first loop, R1 thought harvesting, tool-call repair.",
"type": "module",
"bin": {
+1 -1
View File
@@ -95,7 +95,7 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
);
await chatCommand({
model: opts.model ?? "deepseek-reasoner",
model: opts.model ?? "deepseek-v4-pro",
harvest: opts.harvest ?? false,
system: codeSystemPrompt(rootDir),
transcript: opts.transcript,
+11 -1
View File
@@ -1180,7 +1180,14 @@ export function App({
const contentBuf = { current: "" };
const reasoningBuf = { current: "" };
// Coalesces tool_call_delta events into one re-render per flush tick.
const toolCallBuildBuf: { current: { name: string; chars: number } | null } = {
const toolCallBuildBuf: {
current: {
name: string;
chars: number;
index?: number;
readyCount?: number;
} | null;
} = {
current: null,
};
@@ -1259,6 +1266,8 @@ export function App({
toolCallBuildBuf.current = {
name: ev.toolName,
chars: ev.toolCallArgsChars ?? 0,
index: ev.toolCallIndex,
readyCount: ev.toolCallReadyCount,
};
}
} else if (ev.role === "branch_start") {
@@ -1752,6 +1761,7 @@ export function App({
prefixHash={prefixHash}
harvestOn={loop.harvestEnabled}
branchBudget={loop.branchOptions.budget}
reasoningEffort={loop.reasoningEffort}
planMode={planMode}
balance={balance}
busy={busy}
+28 -3
View File
@@ -21,7 +21,7 @@ export interface DisplayEvent {
stats?: TurnStats;
repair?: string;
streaming?: boolean;
toolCallBuild?: { name: string; chars: number };
toolCallBuild?: { name: string; chars: number; index?: number; readyCount?: number };
/**
* Render a thin horizontal rule above this event. Used to mark
* "start of a new user turn" so long scrollbacks get visual
@@ -405,13 +405,15 @@ function StreamingAssistant({ event }: { event: DisplayEvent }) {
label = `R1 reasoning · ${event.reasoning?.length ?? 0} chars of thought`;
labelColor = "cyan";
} else if (toolCallOnly) {
label = `assembling tool call <${toolCallBuild.name}> · ${toolCallBuild.chars} chars of arguments`;
label = `assembling tool call${formatToolCallIndex(toolCallBuild)} <${toolCallBuild.name}> · ${toolCallBuild.chars} chars of arguments${formatReadyTail(toolCallBuild)}`;
labelColor = "magenta";
} else {
const parts: string[] = [`writing response · ${event.text.length} chars`];
if (event.reasoning) parts.push(`after ${event.reasoning.length} chars of reasoning`);
if (toolCallBuild) {
parts.push(`building tool call <${toolCallBuild.name}> · ${toolCallBuild.chars} chars`);
parts.push(
`building tool call${formatToolCallIndex(toolCallBuild)} <${toolCallBuild.name}> · ${toolCallBuild.chars} chars${formatReadyTail(toolCallBuild)}`,
);
}
label = parts.join(" · ");
labelColor = "green";
@@ -469,6 +471,29 @@ function Pulse() {
return <Text color="cyan">{frames[Math.floor(tick / 4) % frames.length]}</Text>;
}
/**
* "(call 3)" suffix for the tool-call label when we have the index.
* Hides when the turn only has one call so the common case stays tidy.
*/
function formatToolCallIndex(tb: { index?: number; readyCount?: number } | undefined): string {
if (!tb || tb.index === undefined) return "";
// Only show the index when this isn't obviously the first + only call.
if (tb.index === 0 && (tb.readyCount ?? 0) === 0) return "";
return ` (call ${tb.index + 1})`;
}
/**
* "· 2 ready" tail that answers "how many tool calls have finished
* streaming so far this turn?" — user feedback for multi-file turns
* where the response takes 10–30s to stream. Hidden until readyCount
* > 0 so single-call turns aren't cluttered.
*/
function formatReadyTail(tb: { readyCount?: number } | undefined): string {
const n = tb?.readyCount ?? 0;
if (n <= 0) return "";
return ` · ${n} ready`;
}
function lastLine(s: string, maxChars: number): string {
const flat = s.replace(/\s+/g, " ").trim();
if (!flat) return "";
+13
View File
@@ -56,6 +56,13 @@ export interface StatsPanelProps {
prefixHash: string;
harvestOn?: boolean;
branchBudget?: number;
/**
* Current `reasoning_effort` cap. Shown as a green "· max" / yellow
* "· high" tag in the header so the user always sees which tier the
* next turn will use. Absent/undefined hides the tag (e.g. before
* the loop is constructed).
*/
reasoningEffort?: "high" | "max";
/**
* True when `reasonix code` is currently running in read-only Plan
* Mode. Surfaced as a red "PLAN" tag in the panel header so the user
@@ -110,6 +117,7 @@ export function StatsPanel({
prefixHash,
harvestOn,
branchBudget,
reasoningEffort,
planMode,
balance,
updateAvailable,
@@ -135,6 +143,7 @@ export function StatsPanel({
harvestOn={harvestOn}
branchOn={branchOn}
branchBudget={branchBudget ?? 1}
reasoningEffort={reasoningEffort}
planMode={planMode}
turns={summary.turns}
updateAvailable={updateAvailable}
@@ -168,6 +177,7 @@ function Header({
harvestOn,
branchOn,
branchBudget,
reasoningEffort,
planMode,
turns,
updateAvailable,
@@ -179,6 +189,7 @@ function Header({
harvestOn?: boolean;
branchOn: boolean;
branchBudget: number;
reasoningEffort?: "high" | "max";
planMode?: boolean;
turns: number;
updateAvailable?: string | null;
@@ -200,6 +211,8 @@ function Header({
)}
{harvestOn ? <Text color="magenta"> · harvest</Text> : null}
{branchOn ? <Text color="blue"> · branch{branchBudget}</Text> : null}
{reasoningEffort === "max" ? <Text color="green"> · max</Text> : null}
{reasoningEffort === "high" ? <Text color="yellow"> · high</Text> : null}
{planMode ? (
<Text color="red" bold>
{" · PLAN"}
+10 -5
View File
@@ -17,22 +17,27 @@ export interface PresetSettings {
}
export const PRESETS: Record<PresetName, PresetSettings> = {
// `deepseek-chat` / `deepseek-reasoner` are retained as the fast /
// smart models because they're deprecated-but-working compat aliases
// for v4-flash's non-thinking and thinking modes respectively. Same
// billing, smaller config churn for existing users. `max` promotes
// to v4-pro — 12× flash on input/output, reserved for hard tasks.
fast: { model: "deepseek-chat", harvest: false, branch: 1 },
smart: { model: "deepseek-reasoner", harvest: true, branch: 1 },
max: { model: "deepseek-reasoner", harvest: true, branch: 3 },
max: { model: "deepseek-v4-pro", harvest: true, branch: 3 },
};
export const PRESET_DESCRIPTIONS: Record<PresetName, { headline: string; cost: string }> = {
fast: {
headline: "deepseek-chat, no reasoning harvest, no branching",
headline: "deepseek-chat (= v4-flash non-thinking), no harvest, no branching",
cost: "~1¢ per 100 turns · default",
},
smart: {
headline: "deepseek-reasoner + Pillar 2 harvest",
cost: "~10× cost vs fast · slower · better on multi-step tasks",
headline: "deepseek-reasoner (= v4-flash thinking) + Pillar 2 harvest",
cost: "same price as fast · slower · better on multi-step tasks",
},
max: {
headline: "reasoner + harvest + self-consistency (3 branches)",
headline: "deepseek-v4-pro + harvest + self-consistency (3 branches)",
cost: "~30× cost vs fast · slowest · for hard single-shots",
},
};
+22 -1
View File
@@ -242,6 +242,12 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
summary: "run N parallel samples per turn (N>=2)",
argCompleter: ["off", "2", "3", "4", "5"],
},
{
cmd: "effort",
argsHint: "<high|max>",
summary: "reasoning_effort cap — max is default (agent-class), high is cheaper/faster",
argCompleter: ["max", "high"],
},
{ cmd: "mcp", summary: "list MCP servers + tools attached to this session" },
{
cmd: "resource",
@@ -485,6 +491,7 @@ export function handleSlash(
" /model <id> deepseek-chat or deepseek-reasoner",
" /harvest [on|off] Pillar 2: structured plan-state extraction",
" /branch <N|off> run N parallel samples (N>=2), pick most confident",
" /effort <high|max> reasoning_effort cap (max=agent default, high=cheaper)",
" /mcp list MCP servers + tools attached to this session",
" /resource [uri] browse + read MCP resources (no arg → list URIs; <uri> → fetch)",
" /prompt [name] browse + fetch MCP prompts (no arg → list names; <name> → render)",
@@ -917,7 +924,7 @@ export function handleSlash(
const planLine = ctx.planMode ? " plan ON — writes gated (submit_plan + approval)" : "";
const lines = [
` model ${loop.model}`,
` flags harvest=${loop.harvestEnabled ? "on" : "off"} · branch=${branchBudget > 1 ? branchBudget : "off"} · stream=${loop.stream ? "on" : "off"}`,
` flags harvest=${loop.harvestEnabled ? "on" : "off"} · branch=${branchBudget > 1 ? branchBudget : "off"} · stream=${loop.stream ? "on" : "off"} · effort=${loop.reasoningEffort}`,
ctxLine,
mcpLine,
sessionLine,
@@ -1019,6 +1026,20 @@ export function handleSlash(
return { info: `branch → ${n} (harvest auto-enabled; streaming disabled)` };
}
case "effort": {
const raw = (args[0] ?? "").toLowerCase();
if (raw === "") {
return {
info: `reasoning_effort → ${loop.reasoningEffort} (use /effort high for cheaper/faster, /effort max for the agent-class default)`,
};
}
if (raw !== "high" && raw !== "max") {
return { info: "usage: /effort <high|max>" };
}
loop.configure({ reasoningEffort: raw });
return { info: `reasoning_effort → ${raw}` };
}
default:
return { unknown: true, info: `unknown command: /${cmd} (try /help)` };
}
+23 -1
View File
@@ -110,7 +110,17 @@ export class DeepSeekClient {
process.env.DEEPSEEK_BASE_URL ??
"https://api.deepseek.com"
).replace(/\/+$/, "");
this.timeoutMs = opts.timeoutMs ?? 120_000;
// 11 min. DeepSeek's load-balancer may keep a connection open for
// up to 10 minutes while the request waits in queue (non-streaming
// sends empty lines, streaming sends `:` SSE keep-alive comments —
// both are invisible to our parsers, so neither surfaces until the
// real response starts). Timing out at the legacy 2-min default
// killed queued requests prematurely, burned the queue slot on
// retry, and could loop through the whole queue repeatedly.
// Setting 11 min lets the server's own 10-min cap close the
// connection first (clean EOF → natural retry), and our timer
// is a safety net for genuinely hung sockets.
this.timeoutMs = opts.timeoutMs ?? 660_000;
this._fetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
this.retry = opts.retry ?? {};
}
@@ -125,6 +135,18 @@ export class DeepSeekClient {
if (opts.temperature !== undefined) payload.temperature = opts.temperature;
if (opts.maxTokens !== undefined) payload.max_tokens = opts.maxTokens;
if (opts.responseFormat) payload.response_format = opts.responseFormat;
// V4 thinking-mode toggle: lives under `extra_body.thinking.type` per
// DeepSeek's docs. Docs also note that in thinking mode `temperature`,
// `top_p`, `presence_penalty`, `frequency_penalty` are silently
// ignored — we don't strip them here because the server's explicit
// "setting won't report an error" contract means leaving them in is
// safe and keeps the request payload diffable against OpenAI tooling.
if (opts.thinking) {
payload.extra_body = { thinking: { type: opts.thinking } };
}
if (opts.reasoningEffort) {
payload.reasoning_effort = opts.reasoningEffort;
}
return payload;
}
+13
View File
@@ -78,6 +78,12 @@ export async function harvest(
const trimmed = reasoningContent.trim();
if (trimmed.length < minLen) return emptyPlanState();
// Harvest is schema-constrained JSON extraction, not agent reasoning.
// Default to `deepseek-chat` (= v4-flash non-thinking mode) — a few
// hundred output tokens fit easily in the non-thinking budget, the
// reply comes back ~10× faster than thinking mode, and the
// per-turn cost stays an asterisk next to the main loop's spend
// rather than a visible slice of it.
const model = options.model ?? "deepseek-chat";
const maxItems = options.maxItems ?? 5;
const maxItemLen = options.maxItemLen ?? 80;
@@ -96,6 +102,13 @@ export async function harvest(
responseFormat: { type: "json_object" },
temperature: 0,
maxTokens: 600,
// Pin mode + effort so a future default-model swap (e.g. someone
// sets `options.model = "deepseek-v4-pro"`) can't accidentally
// turn this micro-extraction into a multi-thousand-reasoning-
// token call. DeepSeek ignores these on non-thinking models, so
// the request stays valid regardless of the chosen model.
thinking: "disabled",
reasoningEffort: "high",
signal,
});
return parsePlanState(resp.content, maxItems, maxItemLen);
+119 -2
View File
@@ -96,6 +96,19 @@ export interface LoopEvent {
toolArgs?: string;
/** Cumulative arguments-string length for `role === "tool_call_delta"`. */
toolCallArgsChars?: number;
/**
* Zero-based index of the tool call this delta belongs to. Surfaces
* multi-tool turns: on a response emitting 4 write_file calls the UI
* can show "building call 3/?" instead of a context-free spinner.
*/
toolCallIndex?: number;
/**
* Count of prior tool calls (this turn) whose arguments have finished
* streaming into valid JSON. Not all ready calls have been dispatched
* yet — dispatch still happens post-stream — but the user gets "2
* ready" progress feedback while later calls keep streaming.
*/
toolCallReadyCount?: number;
stats?: TurnStats;
planState?: TypedPlanState;
repair?: RepairReport;
@@ -134,6 +147,11 @@ export interface CacheFirstLoopOptions {
* since the default selector scores samples by plan-state uncertainty.
*/
branch?: number | BranchOptions;
/**
* Reasoning-effort cap. See {@link ReconfigurableOptions} — default
* `max` for Reasonix (agent-class use per DeepSeek V4 docs).
*/
reasoningEffort?: "high" | "max";
/**
* Session name. When set, the loop pre-loads the session's prior messages
* into its log on construction, and appends every new log entry to
@@ -171,6 +189,14 @@ export interface ReconfigurableOptions {
harvest?: boolean | HarvestOptions;
branch?: number | BranchOptions;
stream?: boolean;
/**
* Reasoning-effort cap sent per turn (V4 thinking mode only;
* deepseek-chat ignores it). Reasonix pins `max` by default because
* DeepSeek's V4 docs flag Claude-Code-style agent loops as the
* canonical `max` use case. `/effort high` lets a user step down
* mid-session for cheaper, faster turns on simple tasks.
*/
reasoningEffort?: "high" | "max";
}
export class CacheFirstLoop {
@@ -191,6 +217,8 @@ export class CacheFirstLoop {
harvestOptions: HarvestOptions;
branchEnabled: boolean;
branchOptions: BranchOptions;
/** See ReconfigurableOptions — mutable so `/effort` can flip mid-session. */
reasoningEffort: "high" | "max";
sessionName: string | null;
/**
@@ -220,7 +248,8 @@ export class CacheFirstLoop {
this.client = opts.client;
this.prefix = opts.prefix;
this.tools = opts.tools ?? new ToolRegistry();
this.model = opts.model ?? "deepseek-chat";
this.model = opts.model ?? "deepseek-v4-pro";
this.reasoningEffort = opts.reasoningEffort ?? "max";
// Iter cap is a safety net, not the primary stop condition. The
// primary stop is the token-context guard inside step(): after
// every model response we check whether the prompt is already past
@@ -389,6 +418,7 @@ export class CacheFirstLoop {
configure(opts: ReconfigurableOptions): void {
if (opts.model !== undefined) this.model = opts.model;
if (opts.stream !== undefined) this._streamPreference = opts.stream;
if (opts.reasoningEffort !== undefined) this.reasoningEffort = opts.reasoningEffort;
if (opts.branch !== undefined) {
if (typeof opts.branch === "number") {
@@ -651,6 +681,8 @@ export class CacheFirstLoop {
messages,
tools: toolSpecs.length ? toolSpecs : undefined,
signal,
thinking: thinkingModeForModel(this.model),
reasoningEffort: this.reasoningEffort,
},
{
...this.branchOptions,
@@ -705,11 +737,20 @@ export class CacheFirstLoop {
};
} else if (this.stream) {
const callBuf: Map<number, ToolCall> = new Map();
// Indices whose accumulated args have parsed as valid JSON at
// least once. Purely informational — we don't dispatch until
// the stream ends (that's the eager-dispatch feature we
// intentionally punted) but the UI shows "N ready" so the
// user sees progress on long multi-tool turns instead of a
// stagnant "building tool call" spinner.
const readyIndices = new Set<number>();
for await (const chunk of this.client.stream({
model: this.model,
messages,
tools: toolSpecs.length ? toolSpecs : undefined,
signal,
thinking: thinkingModeForModel(this.model),
reasoningEffort: this.reasoningEffort,
})) {
if (chunk.contentDelta) {
assistantContent += chunk.contentDelta;
@@ -740,6 +781,18 @@ export class CacheFirstLoop {
if (d.argumentsDelta)
cur.function.arguments = (cur.function.arguments ?? "") + d.argumentsDelta;
callBuf.set(d.index, cur);
// Mark this index "ready" once its args first parse as
// valid JSON. JSON.parse is sub-millisecond on typical
// tool-call payloads; skip the check once already ready.
if (
!readyIndices.has(d.index) &&
cur.function.name &&
looksLikeCompleteJson(cur.function.arguments ?? "")
) {
readyIndices.add(d.index);
}
// Skip the id-only opener: name is empty until the next chunk.
if (cur.function.name) {
yield {
@@ -748,6 +801,8 @@ export class CacheFirstLoop {
content: "",
toolName: cur.function.name,
toolCallArgsChars: (cur.function.arguments ?? "").length,
toolCallIndex: d.index,
toolCallReadyCount: readyIndices.size,
};
}
}
@@ -760,6 +815,8 @@ export class CacheFirstLoop {
messages,
tools: toolSpecs.length ? toolSpecs : undefined,
signal,
thinking: thinkingModeForModel(this.model),
reasoningEffort: this.reasoningEffort,
});
assistantContent = resp.content;
reasoningContent = resp.reasoningContent ?? "";
@@ -1071,6 +1128,8 @@ export class CacheFirstLoop {
messages,
// no tools → model is forced to answer in text
signal: this._turnAbort.signal,
thinking: thinkingModeForModel(this.model),
reasoningEffort: this.reasoningEffort,
});
const rawContent = resp.content?.trim() ?? "";
const cleaned = stripHallucinatedToolMarkup(rawContent);
@@ -1143,13 +1202,52 @@ export class CacheFirstLoop {
*/
private syntheticAssistantMessage(content: string): ChatMessage {
const msg: ChatMessage = { role: "assistant", content };
if (this.model.includes("reasoner")) {
if (isThinkingModeModel(this.model)) {
msg.reasoning_content = "";
}
return msg;
}
}
/**
* True when the model emits `reasoning_content` and therefore requires
* it round-tripped on follow-up requests.
* - `deepseek-reasoner`: legacy R1 alias (= v4-flash thinking mode)
* - `deepseek-v4-flash` / `deepseek-v4-pro`: default to thinking mode
* per the V4 docs (2026-04)
* - `deepseek-chat`: non-thinking compat alias → false
*
* Exported so tests can lock the behavior down as DeepSeek adds more
* model variants.
*/
export function isThinkingModeModel(model: string): boolean {
if (model.includes("reasoner")) return true;
if (model === "deepseek-v4-flash" || model === "deepseek-v4-pro") return true;
return false;
}
/**
* What `extra_body.thinking.type` value to send for a given model. Pins
* the mode explicitly rather than relying on the server default, which
* removes one source of ambiguity when DeepSeek validates the request's
* `reasoning_content` round-trip.
*
* - `deepseek-chat` → "disabled" (non-thinking alias)
* - `deepseek-reasoner` → "enabled" (thinking alias)
* - `deepseek-v4-flash` / `-v4-pro` → "enabled" (V4 docs default)
* - anything else → undefined (let server decide)
*
* Returning `undefined` makes the client skip the field entirely so
* third-party models routed through a DeepSeek-compatible endpoint
* don't get a parameter they don't recognize.
*/
export function thinkingModeForModel(model: string): "enabled" | "disabled" | undefined {
if (model === "deepseek-chat") return "disabled";
if (model.includes("reasoner")) return "enabled";
if (model === "deepseek-v4-flash" || model === "deepseek-v4-pro") return "enabled";
return undefined;
}
/**
* R1 occasionally hallucinates tool-call markup as plain text when the
* real tool channel has been closed — typically our forced-summary
@@ -1189,6 +1287,25 @@ function safeParseToolArgs(raw: string): unknown {
}
}
/**
* Cheap "is this accumulated tool-call arguments blob now a complete
* JSON value?" check. Used during streaming to mark a tool call as
* ready for UI progress feedback — not for dispatch gating. Empty /
* whitespace-only is not complete; anything that parses is.
*
* Exported so tests can lock down the precise shapes we consider
* "ready" vs "still streaming."
*/
export function looksLikeCompleteJson(s: string): boolean {
if (!s || !s.trim()) return false;
try {
JSON.parse(s);
return true;
} catch {
return false;
}
}
/** Format non-pass hook outcomes as `LoopEvent`s of role `warning`. */
function* hookWarnings(outcomes: HookOutcome[], turn: number): Generator<LoopEvent> {
for (const o of outcomes) {
+33 -16
View File
@@ -1,24 +1,33 @@
import type { Usage } from "./client.js";
/**
* USD per 1M tokens. Source of truth is DeepSeek's CNY price sheet at
* https://api-docs.deepseek.com/zh-cn/quick_start/pricing (as of
* 2026-04-23): chat and reasoner are unified at ¥0.2 / ¥2 / ¥3 per 1M
* tokens (cache-hit input / cache-miss input / output). Converted at
* a fixed 7.2 CNY/USD rate so stats stay stable across the daily FX
* USD per 1M tokens. Source: DeepSeek's CNY price sheet
* (https://api-docs.deepseek.com/zh-cn/quick_start/pricing) converted
* at a fixed 7.2 CNY/USD rate so billing stays stable across daily FX
* drift; revisit if the rate moves more than ±5%.
*
* Historical note: the pre-unification prices were chat $0.07/$0.27/$1.10
* and reasoner $0.14/$0.55/$2.19. Sessions logged under those values
* in `~/.reasonix/usage.jsonl` remain as-is (USD frozen at record time)
* — we never retroactively rewrite billing history.
* 2026-04 V4 launch:
* - deepseek-v4-flash ¥0.2 / ¥1 / ¥2 (hit / miss / out per 1M)
* - deepseek-v4-pro ¥1 / ¥12 / ¥24
*
* deepseek-chat and deepseek-reasoner are now thin compat aliases for
* v4-flash's non-thinking and thinking modes respectively — same
* underlying model, same bill. We keep them in the table so existing
* sessions (and configs that hard-code these names) keep pricing.
*
* Historical note: sessions logged before this file was updated remain
* as-is in `~/.reasonix/usage.jsonl` — USD is frozen at record time,
* we never retroactively rewrite billing history.
*/
export const DEEPSEEK_PRICING: Record<
string,
{ inputCacheHit: number; inputCacheMiss: number; output: number }
> = {
"deepseek-chat": { inputCacheHit: 0.028, inputCacheMiss: 0.28, output: 0.42 },
"deepseek-reasoner": { inputCacheHit: 0.028, inputCacheMiss: 0.28, output: 0.42 },
"deepseek-v4-flash": { inputCacheHit: 0.028, inputCacheMiss: 0.139, output: 0.278 },
"deepseek-v4-pro": { inputCacheHit: 0.139, inputCacheMiss: 1.667, output: 3.333 },
// Compat aliases — priced as v4-flash per the deprecation notice.
"deepseek-chat": { inputCacheHit: 0.028, inputCacheMiss: 0.139, output: 0.278 },
"deepseek-reasoner": { inputCacheHit: 0.028, inputCacheMiss: 0.139, output: 0.278 },
};
/** Reference Claude Sonnet 4.6 pricing (USD per 1M tokens). */
@@ -26,13 +35,21 @@ export const CLAUDE_SONNET_PRICING = { input: 3.0, output: 15.0 };
/**
* Maximum prompt-side context window per DeepSeek model, in tokens.
* Both V3 (`deepseek-chat`) and R1 (`deepseek-reasoner`) currently expose
* a 131,072-token prompt limit per the OpenAPI spec; completion caps
* differ but don't affect the prompt budget the StatsPanel shows.
* V4 (flash + pro) jumps to 1,000,000 tokens. The compat aliases
* (deepseek-chat / deepseek-reasoner) inherit that through the
* v4-flash route — we bump them so the StatsPanel gauge reflects
* what the API actually accepts.
*
* Completion caps (e.g. 384K for V4) are enforced by the server, not
* tracked here — they don't affect the prompt-side budget the panel
* shows. If a future feature surfaces output-cap warnings we'll add
* a sibling table.
*/
export const DEEPSEEK_CONTEXT_TOKENS: Record<string, number> = {
"deepseek-chat": 131_072,
"deepseek-reasoner": 131_072,
"deepseek-v4-flash": 1_000_000,
"deepseek-v4-pro": 1_000_000,
"deepseek-chat": 1_000_000,
"deepseek-reasoner": 1_000_000,
};
/** Fallback when the caller's model id isn't in the table — safe lower bound. */
+5 -5
View File
@@ -109,7 +109,7 @@ export interface SpawnSubagentOptions {
system: string;
/** The task / question / instruction the subagent must address. */
task: string;
/** Model id for the child loop. Defaults to `deepseek-chat`. */
/** Model id for the child loop. Defaults to `deepseek-v4-pro`. */
model?: string;
/** Iteration ceiling for the child loop. Defaults to 16. */
maxToolIters?: number;
@@ -173,7 +173,7 @@ export interface SubagentToolOptions {
defaultSystem?: string;
/** Project root for `applyProjectMemory` lookup. Omit in chat mode. */
projectRoot?: string;
/** Default model. `deepseek-chat` (V3) by default. */
/** Default model. `deepseek-v4-pro` by default. */
defaultModel?: string;
/** Iteration ceiling. Lower than the parent (16 by default). */
maxToolIters?: number;
@@ -200,7 +200,7 @@ Formatting rules (the parent renders your reply in a TUI with a real markdown re
const DEFAULT_MAX_RESULT_CHARS = 8000;
const DEFAULT_MAX_ITERS = 16;
const DEFAULT_SUBAGENT_MODEL = "deepseek-chat";
const DEFAULT_SUBAGENT_MODEL = "deepseek-v4-pro";
const SUBAGENT_TOOL_NAME = "spawn_subagent";
/**
@@ -423,9 +423,9 @@ export function registerSubagentTool(
},
model: {
type: "string",
enum: ["deepseek-chat", "deepseek-reasoner"],
enum: ["deepseek-v4-flash", "deepseek-v4-pro", "deepseek-chat", "deepseek-reasoner"],
description:
"Which DeepSeek model the subagent runs on. 'deepseek-chat' (V3) is the default — fast and cheap. Use 'deepseek-reasoner' (R1) only when the subtask genuinely needs planning or multi-step reasoning; it is roughly 5-10x more expensive.",
"Which DeepSeek model the subagent runs on. Default is 'deepseek-v4-pro' — the strongest model, best for complex subtasks. Override to 'deepseek-v4-flash' (or the legacy 'deepseek-chat' / 'deepseek-reasoner' aliases, which route to flash non-thinking / thinking modes) when the subtask is simple enough that flash's quality suffices — flash is roughly 12× cheaper.",
},
},
required: ["task"],
+15
View File
@@ -64,4 +64,19 @@ export interface ChatRequestOptions {
signal?: AbortSignal;
/** DeepSeek response_format — use { type: "json_object" } to force valid JSON. */
responseFormat?: { type: "json_object" | "text" };
/**
* Explicitly toggle V4 thinking mode. Serialized as
* `extra_body.thinking.type = enabled|disabled`. Omit to let the
* server default apply (thinking enabled). Mainly used so the loop
* can pin the mode per model: `deepseek-chat` → disabled (legacy
* non-thinking compat), everything else → enabled.
*/
thinking?: "enabled" | "disabled";
/**
* Per-request reasoning-effort cap. Serialized as the top-level
* `reasoning_effort` field. DeepSeek accepts `high` (standard) or
* `max` (Agent-class, auto-applied to Claude-Code-style flows per
* the V4 docs). Reasonix pins `max` because every turn is agent-like.
*/
reasoningEffort?: "high" | "max";
}
+100 -4
View File
@@ -9,11 +9,45 @@
import { describe, expect, it, vi } from "vitest";
import { DeepSeekClient } from "../src/client.js";
import { CacheFirstLoop } from "../src/loop.js";
import { CacheFirstLoop, isThinkingModeModel, thinkingModeForModel } from "../src/loop.js";
import { ImmutablePrefix } from "../src/memory.js";
import { ToolRegistry } from "../src/tools.js";
import type { ChatMessage } from "../src/types.js";
describe("isThinkingModeModel", () => {
it("deepseek-reasoner → true (legacy R1 alias)", () => {
expect(isThinkingModeModel("deepseek-reasoner")).toBe(true);
});
it("deepseek-v4-flash → true (default thinking)", () => {
expect(isThinkingModeModel("deepseek-v4-flash")).toBe(true);
});
it("deepseek-v4-pro → true (default thinking)", () => {
expect(isThinkingModeModel("deepseek-v4-pro")).toBe(true);
});
it("deepseek-chat → false (non-thinking compat alias)", () => {
expect(isThinkingModeModel("deepseek-chat")).toBe(false);
});
it("unknown models → false (safe default)", () => {
expect(isThinkingModeModel("gpt-4")).toBe(false);
expect(isThinkingModeModel("")).toBe(false);
});
});
describe("thinkingModeForModel", () => {
it("chat → disabled, reasoner → enabled (compat aliases pin the mode)", () => {
expect(thinkingModeForModel("deepseek-chat")).toBe("disabled");
expect(thinkingModeForModel("deepseek-reasoner")).toBe("enabled");
});
it("v4 models → enabled (docs default)", () => {
expect(thinkingModeForModel("deepseek-v4-flash")).toBe("enabled");
expect(thinkingModeForModel("deepseek-v4-pro")).toBe("enabled");
});
it("unknown models → undefined (let server decide)", () => {
expect(thinkingModeForModel("gpt-4")).toBeUndefined();
expect(thinkingModeForModel("anthropic-claude")).toBeUndefined();
});
});
interface FakeResponseShape {
content?: string;
reasoning_content?: string;
@@ -23,13 +57,25 @@ interface FakeResponseShape {
function capturingFetch(responses: FakeResponseShape[]): {
fetch: typeof fetch;
bodies: Array<{ messages: ChatMessage[] }>;
bodies: Array<{
messages: ChatMessage[];
extra_body?: { thinking?: { type?: string } };
reasoning_effort?: string;
}>;
} {
const bodies: Array<{ messages: ChatMessage[] }> = [];
const bodies: Array<{
messages: ChatMessage[];
extra_body?: { thinking?: { type?: string } };
reasoning_effort?: string;
}> = [];
let i = 0;
const fn = vi.fn(async (_url: any, init: any) => {
const body = init?.body ? JSON.parse(init.body) : {};
bodies.push({ messages: body.messages });
bodies.push({
messages: body.messages,
extra_body: body.extra_body,
reasoning_effort: body.reasoning_effort,
});
const resp = responses[i++] ?? responses[responses.length - 1]!;
return new Response(
JSON.stringify({
@@ -143,4 +189,54 @@ describe("R1 reasoning_content round-trip", () => {
expect(assistant).toBeDefined();
expect(assistant?.reasoning_content).toBe("reasoning attached to a plain-text turn");
});
it("pins thinking=enabled + reasoning_effort=max for v4-pro (agent-class default)", async () => {
const { fetch: fakeFetch, bodies } = capturingFetch([{ content: "done" }]);
const client = new DeepSeekClient({ apiKey: "sk-test", fetch: fakeFetch });
const loop = new CacheFirstLoop({
client,
prefix: new ImmutablePrefix({ system: "s" }),
model: "deepseek-v4-pro",
stream: false,
});
for await (const _ev of loop.step("hello")) {
/* drain */
}
expect(bodies[0]!.extra_body?.thinking?.type).toBe("enabled");
expect(bodies[0]!.reasoning_effort).toBe("max");
});
it("pins thinking=disabled for deepseek-chat (non-thinking compat alias)", async () => {
const { fetch: fakeFetch, bodies } = capturingFetch([{ content: "done" }]);
const client = new DeepSeekClient({ apiKey: "sk-test", fetch: fakeFetch });
const loop = new CacheFirstLoop({
client,
prefix: new ImmutablePrefix({ system: "s" }),
model: "deepseek-chat",
stream: false,
});
for await (const _ev of loop.step("hello")) {
/* drain */
}
expect(bodies[0]!.extra_body?.thinking?.type).toBe("disabled");
expect(bodies[0]!.reasoning_effort).toBe("max");
});
it("omits thinking entirely for unknown models (let the server decide)", async () => {
const { fetch: fakeFetch, bodies } = capturingFetch([{ content: "done" }]);
const client = new DeepSeekClient({ apiKey: "sk-test", fetch: fakeFetch });
const loop = new CacheFirstLoop({
client,
prefix: new ImmutablePrefix({ system: "s" }),
model: "some-third-party-model",
stream: false,
});
for await (const _ev of loop.step("hello")) {
/* drain */
}
expect(bodies[0]!.extra_body).toBeUndefined();
// reasoning_effort is always set — it's a benign field for models
// that don't know it (OpenAI just ignores unknown top-level fields).
expect(bodies[0]!.reasoning_effort).toBe("max");
});
});
+22 -17
View File
@@ -102,7 +102,10 @@ describe("CacheFirstLoop (non-streaming)", () => {
await loop.run("q");
expect(loop.stats.aggregateCacheHitRatio).toBeCloseTo(0.8);
expect(loop.stats.totalCost).toBeGreaterThan(0);
expect(loop.stats.savingsVsClaude).toBeGreaterThan(0.9);
// Savings vs Claude depends on which DeepSeek model is the loop's
// default. v4-pro lands around 0.85; v4-flash around 0.97. Test the
// lower bound so a future default swap doesn't churn this assertion.
expect(loop.stats.savingsVsClaude).toBeGreaterThan(0.8);
});
it("dispatches a tool call and loops until the model stops", async () => {
@@ -386,18 +389,18 @@ describe("CacheFirstLoop (non-streaming)", () => {
fn: async () => "ok",
});
// First response: chaining tool call with a prompt-token count
// deliberately over 80% of DeepSeek's 131k window (131k * 0.8 = 104_857).
// 120k trips the guard.
// deliberately over 80% of DeepSeek V4's 1M window (1M * 0.8 =
// 800k). 900k trips the guard.
const responses: FakeResponseShape[] = [
{
content: "",
tool_calls: [{ id: "c", type: "function", function: { name: "probe", arguments: "{}" } }],
usage: {
prompt_tokens: 120_000,
prompt_tokens: 900_000,
completion_tokens: 50,
total_tokens: 120_050,
prompt_cache_hit_tokens: 90_000,
prompt_cache_miss_tokens: 30_000,
total_tokens: 900_050,
prompt_cache_hit_tokens: 700_000,
prompt_cache_miss_tokens: 200_000,
},
},
// Forced-summary response (no tools)
@@ -446,16 +449,17 @@ describe("CacheFirstLoop (non-streaming)", () => {
fn: async () => "ok",
});
const responses: FakeResponseShape[] = [
// Iter 0: chains a tool call, usage trips the guard.
// Iter 0: chains a tool call, usage trips the 80% guard against
// V4's 1M window.
{
content: "",
tool_calls: [{ id: "c1", type: "function", function: { name: "probe", arguments: "{}" } }],
usage: {
prompt_tokens: 120_000,
prompt_tokens: 900_000,
completion_tokens: 10,
total_tokens: 120_010,
prompt_cache_hit_tokens: 100_000,
prompt_cache_miss_tokens: 20_000,
total_tokens: 900_010,
prompt_cache_hit_tokens: 750_000,
prompt_cache_miss_tokens: 150_000,
},
},
// Iter 1: model wraps up normally after the tool result.
@@ -505,16 +509,17 @@ describe("CacheFirstLoop (non-streaming)", () => {
fn: async () => "ok",
});
const responses: FakeResponseShape[] = [
// 131k * 0.72 ≈ 94,320 tokens — squarely in the proactive band.
// V4's 1M window × 0.72 ≈ 720k tokens — squarely in the 60–80%
// proactive band without tripping the reactive 80% guard.
{
content: "",
tool_calls: [{ id: "c1", type: "function", function: { name: "probe", arguments: "{}" } }],
usage: {
prompt_tokens: 95_000,
prompt_tokens: 720_000,
completion_tokens: 10,
total_tokens: 95_010,
prompt_cache_hit_tokens: 70_000,
prompt_cache_miss_tokens: 25_000,
total_tokens: 720_010,
prompt_cache_hit_tokens: 550_000,
prompt_cache_miss_tokens: 170_000,
},
},
{ content: "done." },
+1 -1
View File
@@ -62,7 +62,7 @@ describe("resolveDefaults", () => {
it("--preset max overrides config.preset=fast", () => {
writeConfig({ preset: "fast" }, join(home, ".reasonix", "config.json"));
const r = resolveDefaults({ preset: "max" });
expect(r.model).toBe("deepseek-reasoner");
expect(r.model).toBe("deepseek-v4-pro");
expect(r.branch).toBe(3);
});
+23
View File
@@ -184,6 +184,29 @@ describe("handleSlash", () => {
expect(loop.branchEnabled).toBe(false);
});
it("/effort <high|max> flips the reasoningEffort cap", () => {
const loop = makeLoop();
expect(loop.reasoningEffort).toBe("max");
handleSlash("effort", ["high"], loop);
expect(loop.reasoningEffort).toBe("high");
handleSlash("effort", ["max"], loop);
expect(loop.reasoningEffort).toBe("max");
});
it("/effort with no arg reports the current value, doesn't change it", () => {
const loop = makeLoop();
const r = handleSlash("effort", [], loop);
expect(r.info).toMatch(/reasoning_effort → max/);
expect(loop.reasoningEffort).toBe("max");
});
it("/effort rejects unknown values", () => {
const loop = makeLoop();
const r = handleSlash("effort", ["low"], loop);
expect(r.info).toMatch(/usage: \/effort <high\|max>/);
expect(loop.reasoningEffort).toBe("max");
});
it("/branch caps at 8", () => {
const loop = makeLoop();
const r = handleSlash("branch", ["99"], loop);
+1 -1
View File
@@ -288,7 +288,7 @@ describe("registerSubagentTool", () => {
registerSubagentTool(parent, { client });
// "gpt-4" is not a deepseek-* model — should be ignored.
await parent.dispatch("spawn_subagent", JSON.stringify({ task: "go", model: "gpt-4" }));
expect(seenModels[0]).toBe("deepseek-chat");
expect(seenModels[0]).toBe("deepseek-v4-pro");
});
it("aborts the child when the parent's tool ctx signal fires", async () => {
+24 -4
View File
@@ -97,13 +97,33 @@ describe("inputCostUsd / outputCostUsd", () => {
});
it("chat and reasoner are unified at the same price", () => {
// Post-unification (2026-04): DeepSeek charges identically for both
// models. If this test starts failing, either DeepSeek reintroduced
// tiered pricing (update the constants) or someone accidentally
// edited only one entry — catch that before shipping.
// 2026-04 V4 launch: `deepseek-chat` and `deepseek-reasoner` are
// compat aliases for v4-flash's non-thinking and thinking modes
// respectively, so billing is identical. If this diverges, either
// DeepSeek split them again (update the constants) or one alias
// got out of sync during an update — catch before shipping.
const chat = DEEPSEEK_PRICING["deepseek-chat"]!;
const reasoner = DEEPSEEK_PRICING["deepseek-reasoner"]!;
const flash = DEEPSEEK_PRICING["deepseek-v4-flash"]!;
expect(reasoner).toEqual(chat);
expect(chat).toEqual(flash);
});
it("v4-pro pricing is present and strictly above v4-flash", () => {
const flash = DEEPSEEK_PRICING["deepseek-v4-flash"]!;
const pro = DEEPSEEK_PRICING["deepseek-v4-pro"]!;
expect(pro.inputCacheHit).toBeGreaterThan(flash.inputCacheHit);
expect(pro.inputCacheMiss).toBeGreaterThan(flash.inputCacheMiss);
expect(pro.output).toBeGreaterThan(flash.output);
});
it("v4-pro cost is computed with its own tier, not flash's", () => {
// Sanity: passing the pro model to costUsd doesn't silently fall
// back to flash rates, otherwise billing on pro would under-count.
const u = new Usage(0, 100, 0, 0, 1000);
const flashCost = costUsd("deepseek-v4-flash", u);
const proCost = costUsd("deepseek-v4-pro", u);
expect(proCost).toBeGreaterThan(flashCost * 5); // ~12x on output+miss
});
it("both return 0 for an unknown model", () => {
+45
View File
@@ -0,0 +1,45 @@
/**
* Tool-call "ready" progress signal during streaming.
*
* Matches the 0.5.19 UX fix: as each tool_call's arguments stream into
* valid JSON we emit `tool_call_delta` with an incrementing
* `toolCallReadyCount`, so the UI can render "N ready · building call
* M" instead of a context-free spinner. Dispatch still happens after
* the stream ends — this is purely a visibility improvement.
*/
import { describe, expect, it } from "vitest";
import { looksLikeCompleteJson } from "../src/loop.js";
describe("looksLikeCompleteJson", () => {
it("empty / whitespace → false", () => {
expect(looksLikeCompleteJson("")).toBe(false);
expect(looksLikeCompleteJson(" ")).toBe(false);
});
it("partial JSON → false (the common streaming case)", () => {
expect(looksLikeCompleteJson("{")).toBe(false);
expect(looksLikeCompleteJson('{"path"')).toBe(false);
expect(looksLikeCompleteJson('{"path": "foo.md", "content": "hel')).toBe(false);
});
it("complete JSON object → true", () => {
expect(looksLikeCompleteJson("{}")).toBe(true);
expect(looksLikeCompleteJson('{"path": "foo.md", "content": "hello"}')).toBe(true);
});
it("complete JSON array → true", () => {
expect(looksLikeCompleteJson("[]")).toBe(true);
expect(looksLikeCompleteJson('[{"a": 1}]')).toBe(true);
});
it("primitive JSON values → true", () => {
expect(looksLikeCompleteJson("true")).toBe(true);
expect(looksLikeCompleteJson("42")).toBe(true);
expect(looksLikeCompleteJson('"text"')).toBe(true);
});
it("valid JSON with trailing whitespace → true", () => {
expect(looksLikeCompleteJson('{"a":1}\n')).toBe(true);
});
});