mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3484c04133 | ||
|
|
7075608ddc | ||
|
|
27a32b7b94 | ||
|
|
1391534f6c | ||
|
|
169d66d46a | ||
|
|
2836b5d406 | ||
|
|
d0bd6852c1 | ||
|
|
cb2f2a550b | ||
|
|
e472f4cb79 | ||
|
|
14887264bb |
No files matched your search
+619
@@ -3,6 +3,625 @@
|
||||
All notable changes to Reasonix. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
|
||||
this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.4.8] — 2026-04-21
|
||||
|
||||
**Headline:** MCP progress notifications — long-running tool calls
|
||||
now stream incremental progress into the spinner row instead of
|
||||
sitting silent for minutes. "▸ tool\<fs_scan\> running… 42s" grows
|
||||
to "[█████░░░░░░░░░░░░░░░] 500/2000 25% reading src/…" as the
|
||||
server reports.
|
||||
|
||||
### Added
|
||||
|
||||
- **`McpClient.callTool(name, args, { onProgress })`** — attaches
|
||||
a fresh `_meta.progressToken` per call; server-emitted
|
||||
`notifications/progress` frames are routed to the handler until
|
||||
the final response arrives. Handler is dropped on completion or
|
||||
timeout — no leaks, late frames are silently swallowed.
|
||||
- **Dispatch routing for `notifications/progress`** in the client's
|
||||
reader loop. Other server-initiated notifications are still
|
||||
dropped (list_changed frames not implemented yet).
|
||||
- **`bridgeMcpTools({ onProgress })`** — pipes the per-call
|
||||
callback through to bridged tools. The info object includes the
|
||||
*registered* (prefix-applied) tool name so multi-server UIs can
|
||||
attribute progress correctly.
|
||||
- **Progress bar in `OngoingToolRow`** — when a frame arrives with
|
||||
`total`, renders `[███░░░░░░] n/total pct% message`. Without
|
||||
`total`, falls back to `progress: n message`. Resets on each
|
||||
new tool call so stale progress doesn't linger.
|
||||
- **Public types in `src/mcp/types.ts`**: `McpProgressHandler`,
|
||||
`McpProgressInfo`, `ProgressNotificationParams`. Re-exported
|
||||
from `src/index.ts` for library consumers.
|
||||
|
||||
### Tests (+5, suite 382→387)
|
||||
|
||||
- `tests/mcp.test.ts` (+5) — progress frames routed to onProgress
|
||||
in order; `_meta.progressToken` omitted when no callback is
|
||||
given; distinct token when present; late frames after resolution
|
||||
silently swallowed; `bridgeMcpTools` forwards progress with the
|
||||
prefixed tool name.
|
||||
|
||||
---
|
||||
|
||||
## [0.4.7] — 2026-04-21
|
||||
|
||||
**Headline:** Multi-line input in the chat TUI. Paste a code block
|
||||
without it getting chopped on the first newline; compose structured
|
||||
prompts across multiple lines; still hit Enter once to send.
|
||||
|
||||
### Added
|
||||
|
||||
- **Multi-line prompt input** replacing the old single-line
|
||||
`ink-text-input`. Newline-insertion paths, in order of terminal
|
||||
reliability:
|
||||
- `Ctrl+J` — universal (real ASCII LF), works on every terminal
|
||||
- `Shift+Enter` — works on terminals that enable CSI-u modifier
|
||||
reporting (iTerm2 with that setting on, WezTerm, Ghostty, etc.)
|
||||
- `\<Enter>` — bash-style line continuation, always works as a
|
||||
portable fallback
|
||||
- Pasted multi-line text lands intact instead of submitting on
|
||||
the first embedded `\r`.
|
||||
- **Visible blinking cursor** on the active line so the input box
|
||||
looks alive even when you stop typing mid-compose.
|
||||
- **`processMultilineKey` pure reducer** in `src/cli/ui/multiline-keys.ts`.
|
||||
Keystroke → action function that's fully unit-testable; the
|
||||
React component is a thin wrapper. Parent-owned keys (Tab for
|
||||
slash auto-complete, ↑/↓ for slash-nav + history, Esc for abort,
|
||||
left/right/page arrows) are no-ops in the reducer so the buffer
|
||||
never eats a stray control sequence when both parent and child
|
||||
`useInput` fire on the same event.
|
||||
|
||||
### Design notes
|
||||
|
||||
- No mid-string insertion cursor. Edits are cursor-at-end (backspace
|
||||
to delete, paste to insert). Matches how readline-in-raw-mode
|
||||
feels, covers ~95% of prompt-composition cases, and skips a pile
|
||||
of complexity (arrow-key cursor nav, selection, kill/yank) that
|
||||
would collide with the parent's arrow-key handling for slash-nav
|
||||
and history recall.
|
||||
- `ink-text-input` is still used by `Wizard`, `Select`, `Setup` — it
|
||||
fits those single-line forms fine and didn't need replacing.
|
||||
|
||||
### Tests (+18, suite 364→382)
|
||||
|
||||
- `tests/multiline-keys.test.ts` (new) — printable input, multi-char
|
||||
paste, Enter-submit, Shift+Enter-newline, Ctrl+J (raw LF and
|
||||
normalized `ctrl+'j'`), bash continuation, backspace across
|
||||
newlines, delete, tab/arrows/esc/ctrl-letter/meta all ignored,
|
||||
empty-buffer edge cases.
|
||||
|
||||
---
|
||||
|
||||
## [0.4.6] — 2026-04-21
|
||||
|
||||
**Headline:** Slash-command UX overhaul + MCP discovery closes in
|
||||
two places. Typing `/` now pops an IntelliSense-style suggestion
|
||||
list you can walk with ↑/↓ and pick with Enter or Tab — no more
|
||||
memorizing commands or reading a cluttered footer. The footer is
|
||||
gone. `/mcp` inside chat now shows each server's tools + resources
|
||||
+ prompts in one grouped view. For scripting/CI there's a new
|
||||
`reasonix mcp inspect <spec>` CLI doing the same.
|
||||
|
||||
### Added
|
||||
|
||||
- **Slash autocomplete popup.** When the input starts with `/` and
|
||||
matches exist, a floating panel lists commands (name + args hint
|
||||
+ one-line summary). ↑/↓ navigate the list; Tab inserts the
|
||||
highlighted name into the input; Enter runs it directly. Leaves
|
||||
slash mode the moment you type a space — then ↑/↓ goes back to
|
||||
shell-style prompt history as before. Registry lives in
|
||||
`SLASH_COMMANDS` and gates code-mode-only entries (`/apply`,
|
||||
`/discard`, `/undo`, `/commit`) behind the TUI's `codeMode` flag.
|
||||
- **`/mcp` is now the discovery view.** Rich output per connected
|
||||
server: name + version + spec, tool count, resources list, prompts
|
||||
list. Unsupported sections collapse to `(not supported)` so a
|
||||
tools-only server still reads clean. Inspection happens once at
|
||||
chat startup and flows through `SlashContext.mcpServers` — the
|
||||
slash handler stays sync.
|
||||
- **`reasonix mcp inspect <spec>`**. CLI counterpart to `/mcp`, for
|
||||
running outside chat (CI, scripting, "does this server even
|
||||
work?"). Same spec grammar as `--mcp`; `--json` emits the full
|
||||
report.
|
||||
- **`inspectMcpServer(client)`** public API in `src/mcp/inspect.ts`.
|
||||
Pure function — testable against any `McpClient` instance; returns
|
||||
an `InspectionReport` with per-section `{supported, items}` or
|
||||
`{supported: false, reason}`. Re-exported from `src/index.ts`.
|
||||
- **`McpClient.serverInfo` + `.protocolVersion` + `.serverInstructions`**.
|
||||
The full initialize handshake result is now exposed, not just
|
||||
`.serverCapabilities`. Needed by any UI that wants to surface
|
||||
"connected to X v1.2.3".
|
||||
|
||||
### Removed
|
||||
|
||||
- **Static command-strip footer under the input.** Took 3-4 dimmed
|
||||
lines listing a random subset of commands; superseded by the
|
||||
on-demand slash popup that only surfaces when the user asks for
|
||||
it (by typing `/`).
|
||||
|
||||
### Tests (+11, suite 353→364)
|
||||
|
||||
- `tests/mcp-inspect.test.ts` (new, +5) — full-support server,
|
||||
-32601 → `supported: false`, non-32601 forwarded as the section
|
||||
reason, serverInfo/protocolVersion/instructions accessors,
|
||||
undefined-instructions fallback.
|
||||
- `tests/slash.test.ts` (+6) — `SLASH_COMMANDS` contains every
|
||||
handler case, `suggestSlashCommands` prefix + case + empty-string
|
||||
behavior, code-mode gating, `/mcp` rich view renders tools +
|
||||
resources + prompts grouped per server, `/mcp` spec-only fallback.
|
||||
|
||||
---
|
||||
|
||||
## [0.4.5] — 2026-04-21
|
||||
|
||||
**Headline:** Two protocol-level completions bundled together. (1)
|
||||
DSML-hallucinated tool calls are now **recovered** (not just stripped
|
||||
from display) — when R1 emits its chat-template markup in the content
|
||||
channel instead of the proper `tool_calls` field, the repair pipeline
|
||||
parses it back into a real ToolCall and executes it. (2) The MCP
|
||||
client gains `resources/*` and `prompts/*` — the remaining method
|
||||
families needed for spec parity beyond tools.
|
||||
|
||||
### Added
|
||||
|
||||
- **DSML invoke parser in `scavengeToolCalls`.** Pattern A in
|
||||
`src/repair/scavenge.ts` now recognizes `<|DSML|invoke name="X">…</|DSML|invoke>` blocks with nested `<|DSML|parameter name="k" string="true|false">v</|DSML|parameter>` children. `string="true"` → literal; `string="false"` → JSON. Both full-width `|` and ASCII `|` variants accepted. Malformed JSON under `string="false"` falls back to a literal string so data isn't lost.
|
||||
- **Content-channel scavenge.** `ToolCallRepair.process` now takes an
|
||||
optional third arg `content` and scans both reasoning + content for
|
||||
leaked calls. The loop wires `assistantContent` through. This closes
|
||||
the hole noted in the v0.4 deferred queue: before, DSML in a regular
|
||||
turn was stripped from display but the tool never ran.
|
||||
- **MCP `resources/list` + `resources/read`** on `McpClient`. Types:
|
||||
`McpResource`, `McpResourceContents` (text + blob shapes),
|
||||
`ListResourcesResult`, `ReadResourceResult`. Pagination cursor
|
||||
supported.
|
||||
- **MCP `prompts/list` + `prompts/get`** on `McpClient`. Types:
|
||||
`McpPrompt`, `McpPromptArgument`, `McpPromptMessage`,
|
||||
`McpPromptResourceBlock`, `ListPromptsResult`, `GetPromptResult`.
|
||||
- **Initialize capabilities** now advertise `resources` and `prompts`
|
||||
alongside `tools`. Servers that don't implement them respond with
|
||||
−32601 method-not-found; client surfaces that as a thrown Error.
|
||||
|
||||
### Tests (+13, suite 340→353)
|
||||
|
||||
- `tests/repair/scavenge.test.ts` (+5) — DSML with string + JSON
|
||||
params, ASCII-pipe variant, allow-list skip, `string="false"`
|
||||
malformed-JSON fallback, no double-counting via Pattern B.
|
||||
- `tests/repair/pipeline.test.ts` (+2) — content-channel DSML yields
|
||||
scavenged call; no double-count when DSML appears in both channels.
|
||||
- `tests/mcp.test.ts` (+6) — list+read resources, method-not-found
|
||||
on unsupported server, capabilities payload advertises all three,
|
||||
cursor round-trip; list+get prompts with args, argument omission.
|
||||
|
||||
---
|
||||
|
||||
## [0.4.4] — 2026-04-21
|
||||
|
||||
**Headline:** `/tool` slash command — inspect the full untruncated
|
||||
output of any tool call this session. The `EventLog` renderer has
|
||||
always clipped tool results at 400 chars for display; when the model
|
||||
says "I read your file, it says …", users had no way to verify that
|
||||
claim against what the tool actually returned. Now they do.
|
||||
|
||||
### Added
|
||||
|
||||
- **`/tool`** (no arg) — list up to 10 most recent tool calls with
|
||||
tool name, char count, and a one-line preview. `#1` is the most
|
||||
recent; older entries are paged behind a "… (N earlier)" hint.
|
||||
- **`/tool N`** — dump the Nth-most-recent tool result in full,
|
||||
untruncated. Reads from an in-memory ref populated as each `tool`
|
||||
event lands in `App.tsx`. Not persisted across process restarts
|
||||
(resumed sessions don't rebuild the history — the tool messages
|
||||
are still in the session log for the model's sake, but `/tool`
|
||||
history is per-process).
|
||||
- **`SlashContext.toolHistory` callback** — the TUI passes
|
||||
`() => toolHistoryRef.current`; pure `handleSlash` tests stub
|
||||
an array directly. Keeps `slash.ts` stateless.
|
||||
|
||||
### Tests (+8, suite 332→340)
|
||||
|
||||
- `tests/slash.test.ts` (+8) — empty-history message, list ordering
|
||||
(most recent first), `/tool 1` dumps full content, `/tool 2`
|
||||
reaches one back, out-of-bounds message, non-numeric → usage,
|
||||
list pagination at 15 entries, `/help` mentions `/tool`.
|
||||
|
||||
---
|
||||
|
||||
## [0.4.3] — 2026-04-21
|
||||
|
||||
**Headline:** Seven more UX improvements on top of 0.4.2. Layered in
|
||||
after live `reasonix code` sessions surfaced pain points: R1 fake
|
||||
tool-call hallucinations leaking into forced summaries, no quick
|
||||
retry, /status too thin, tool errors blending in, no prompt history,
|
||||
no one-key pending-edit confirmation, and — critically — Esc
|
||||
blocking for 30-90s on a reasoner call the user never asked for.
|
||||
|
||||
### Added
|
||||
|
||||
- **`/retry` slash command.** Truncates the log back to just before
|
||||
your last user message, then re-submits so the model runs a fresh
|
||||
turn from a clean slate. Persists the truncation to the session
|
||||
file. `SlashResult` grows a `resubmit?: string` field the TUI
|
||||
honors after displaying `info`.
|
||||
- **`/status` is now a real situation-report.** Labeled table:
|
||||
model, harvest/branch/stream flags, last-turn context usage
|
||||
against the window (`42k/131k (32%)`), MCP server + tool counts,
|
||||
session name + log length + resumed-count, pending edit count.
|
||||
- **Prompt history with ↑/↓.** Shell-style recall. Lives in an
|
||||
`App.tsx` ref; cursor −1 = live input, 0+ walks back. Process-
|
||||
scoped — no cross-run persistence.
|
||||
- **Y/N fast-path for pending edits.** When pending count > 0,
|
||||
`y` + Enter = `/apply`, `n` + Enter = `/discard`. Doesn't
|
||||
interfere otherwise. Preview message ends with `(or y / n)`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Tool errors render red + ✗**, not yellow + →. Tool results
|
||||
prefixed `ERROR:` (from `flattenMcpResult` on `isError`) now
|
||||
visually distinguish from success. A failure needs different
|
||||
attention than a directory listing.
|
||||
- **Esc abort no longer forces another API call.** Previously:
|
||||
Esc → `warning: aborted at iter N/M — forcing summary` → another
|
||||
full reasoner call that took 30-90s → done. Users reported the
|
||||
wait was the opposite of "cancel." Now: Esc → quick warning →
|
||||
synthetic `assistant_final` ("no summary produced — ask again
|
||||
or `/retry` when ready") → done. Takes milliseconds. Prior tool
|
||||
output stays in the log so a follow-up question hits the warm
|
||||
prefix cache. Budget / context-guard still call `forceSummary`
|
||||
because there the user didn't choose to stop; we did.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Forced-summary path no longer leaks DSML tool-call markup as
|
||||
prose.** Passing `tools: undefined` wasn't enough — R1 primed
|
||||
for tool use still emitted `<|DSML|function_calls>…
|
||||
</|DSML|function_calls>` as plain text. Two layers: (1) append
|
||||
an explicit user-role instruction at the end of the forced-summary
|
||||
message list ("summarize in plain prose, do NOT emit any tool
|
||||
calls or function-call markup"); (2) post-hoc strip known
|
||||
envelopes (DSML full-width, DSML ASCII, Anthropic
|
||||
`<function_calls>`, truncated un-closed DSML openers) from the
|
||||
response. Exported as `stripHallucinatedToolMarkup`. Fallback
|
||||
message when stripping leaves nothing points at `/retry` and
|
||||
`/think`.
|
||||
|
||||
### Tests (+13, suite 319→332)
|
||||
|
||||
- `tests/slash.test.ts` (+8) — `/think` empty/populated/help,
|
||||
`/retry` happy path + empty-log + help listing, `/status` new
|
||||
format + pending-edit suppression at count 0.
|
||||
- `tests/loop-error.test.ts` (+5) — `stripHallucinatedToolMarkup`
|
||||
live R1 DSML shape, Anthropic-style, truncated un-closed opener,
|
||||
plain prose passthrough, all-markup edge case.
|
||||
- `tests/loop.test.ts` — abort test rewritten to confirm no extra
|
||||
API call is made (previously asserted a "partial findings"
|
||||
summary from the never-needed follow-up).
|
||||
|
||||
---
|
||||
|
||||
## [0.4.2] — 2026-04-21
|
||||
|
||||
**Headline:** Three small but visible UX improvements from a real
|
||||
session: tool-call spinner now shows elapsed time + meaningful args
|
||||
(not raw JSON), reasoning preview shows the *tail* instead of the
|
||||
head (where the decision actually lives), and a `/think` slash
|
||||
command dumps the full R1 reasoning for the most recent turn.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Tool-running row surfaces elapsed seconds + per-tool argument
|
||||
summary.** Instead of `⠋ tool<filesystem_edit_file> running…
|
||||
{"path":"F:\\testtest\\index.html","edits":[…]}`, you now see:
|
||||
```
|
||||
⠋ tool<filesystem_edit_file> running… 3s
|
||||
path: F:\testtest\index.html (2 edits)
|
||||
```
|
||||
Per-tool summarizers for `read_file`, `write_file`, `edit_file`,
|
||||
`list_directory`, `directory_tree`, `search_files`, `move_file`,
|
||||
`get_file_info`. Matches on suffix (`_read_file`) so namespaced
|
||||
servers (`filesystem_read_file`) and anonymous servers both work.
|
||||
Unknown tools fall back to a truncated raw-JSON preview — better
|
||||
than nothing.
|
||||
- **Reasoning preview shows the tail, not the head.** R1 opens every
|
||||
turn with the same "let me look at the structure…" scaffolding, so
|
||||
previously the `↳ thinking: …` line repeated across turns and hid
|
||||
the real content in `(+N chars)`. Now the preview window shows the
|
||||
last ~260 chars — which is where the model actually decides what
|
||||
to do next. Users reported the head-only preview made R1 turns
|
||||
look identical; this fixes the underlying information-hiding bug.
|
||||
|
||||
### Added
|
||||
|
||||
- **`/think` slash command.** Dumps the full raw reasoning text from
|
||||
the most recent turn (read from `loop.scratch.reasoning`). Intended
|
||||
for when the 260-char tail isn't enough and you want to see R1's
|
||||
actual chain. Reports a helpful message if no reasoning is cached
|
||||
(e.g. the current model is `deepseek-chat`, which doesn't produce
|
||||
`reasoning_content`). Also listed as an alias `/reasoning`.
|
||||
- **`/retry` slash command.** Truncates the log back to just before
|
||||
your last user message, then re-submits it so the model runs a
|
||||
fresh turn from a clean slate. Persists the truncation to the
|
||||
session file so reload doesn't rehydrate the stale exchange.
|
||||
Useful to resample R1 when the first try was off, without typing
|
||||
the question again. `SlashResult` grows a `resubmit?: string` field
|
||||
the TUI honors after displaying the result's `info` line.
|
||||
- **`/status` is now a real situation-report.** Previously it was
|
||||
four key=value pairs on one line; now it's a labeled table
|
||||
covering model, harvest/branch/stream flags, last turn's context
|
||||
usage against the window (`42k/131k (32%)`), MCP server + tool
|
||||
counts, session name + log length + resumed-count, and pending
|
||||
edit count in code mode. One command, whole state.
|
||||
- **Prompt history with ↑/↓.** Shell-style recall of previously
|
||||
submitted prompts. Lives in a ref in `App.tsx`; ↑ walks back, ↓
|
||||
walks forward (empty input at cursor=-1). Scoped to the current
|
||||
session process — no cross-launch persistence. Fast path for
|
||||
iterating on the same question with small tweaks.
|
||||
- **Y/N fast-path for pending edits.** When edit blocks are waiting
|
||||
for `/apply` or `/discard`, typing just `y` or `n` + Enter maps
|
||||
to those commands. Doesn't interfere with normal input because
|
||||
the branch only triggers when pending count > 0. Preview line
|
||||
now ends with `(or y) … (or n)` so users know the shortcut exists.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Tool-running row surfaces elapsed seconds + per-tool argument
|
||||
summary.** Instead of `⠋ tool<filesystem_edit_file> running…
|
||||
{"path":"F:\\testtest\\index.html","edits":[…]}`, you now see:
|
||||
```
|
||||
⠋ tool<filesystem_edit_file> running… 3s
|
||||
path: F:\testtest\index.html (2 edits)
|
||||
```
|
||||
Per-tool summarizers for `read_file`, `write_file`, `edit_file`,
|
||||
`list_directory`, `directory_tree`, `search_files`, `move_file`,
|
||||
`get_file_info`. Matches on suffix (`_read_file`) so namespaced
|
||||
servers (`filesystem_read_file`) and anonymous servers both work.
|
||||
Unknown tools fall back to a truncated raw-JSON preview — better
|
||||
than nothing.
|
||||
- **Reasoning preview shows the tail, not the head.** R1 opens every
|
||||
turn with the same "let me look at the structure…" scaffolding, so
|
||||
previously the `↳ thinking: …` line repeated across turns and hid
|
||||
the real content in `(+N chars)`. Now the preview window shows the
|
||||
last ~260 chars — which is where the model actually decides what
|
||||
to do next. Users reported the head-only preview made R1 turns
|
||||
look identical; this fixes the underlying information-hiding bug.
|
||||
- **Tool errors render red, not yellow.** Tool results whose content
|
||||
starts with `ERROR:` (the prefix `flattenMcpResult` adds when the
|
||||
server reports `isError: true`) now show as a red `tool<X> ✗`
|
||||
header + red body, instead of the same yellow `→` as successful
|
||||
results. A failure needs different attention than "here's your
|
||||
directory listing."
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Forced-summary no longer leaks DSML tool-call markup as prose.**
|
||||
When the loop forces a no-tools summary (Esc / budget /
|
||||
context-guard), passing `tools: undefined` turned out not to be
|
||||
enough — R1 primed for tool use would still emit
|
||||
`<|DSML|function_calls>…</|DSML|function_calls>` as plain text,
|
||||
which rendered verbatim in the TUI. Fix is two layers:
|
||||
1. Inject an explicit user-role instruction at the end of the
|
||||
forced-summary message list ("summarize in plain prose, do
|
||||
NOT emit any tool calls or function-call markup").
|
||||
2. Post-hoc strip known hallucinated envelopes (DSML full-width,
|
||||
DSML ASCII, Anthropic-style `<function_calls>`, and
|
||||
truncated un-closed DSML openers) from the model's response
|
||||
before yielding. Exported as `stripHallucinatedToolMarkup(s)`
|
||||
so library callers building their own UIs can apply the same
|
||||
cleanup.
|
||||
When stripping leaves nothing behind, the loop emits a clear
|
||||
fallback message pointing at `/retry` and `/think` rather than
|
||||
showing an empty assistant turn.
|
||||
|
||||
### Tests (+13, suite 319→332)
|
||||
|
||||
- `tests/slash.test.ts` (+8) — `/think`, `/retry` happy path +
|
||||
empty-log path + help listing, `/status` new format with rich
|
||||
rows, `/status` pending-edit suppression at count 0.
|
||||
- `tests/loop-error.test.ts` (+5) — `stripHallucinatedToolMarkup`
|
||||
against the live R1 DSML shape, Anthropic-style
|
||||
`<function_calls>`, truncated unpaired DSML opener, plain prose
|
||||
passthrough, and the all-markup-no-prose edge case.
|
||||
|
||||
---
|
||||
|
||||
## [0.4.1] — 2026-04-21
|
||||
|
||||
**Headline:** `reasonix code` grows `/undo`, `/commit`, `.gitignore`
|
||||
awareness — and, **critically, stops auto-writing edits to disk.** A
|
||||
real-session bug ("I asked to analyze the project, it silently edited
|
||||
a file") exposed that v0.4.0's auto-apply was the wrong default.
|
||||
Edits now sit as **pending** until the user says `/apply`. This
|
||||
release also replaces the fixed iter-count budget with a
|
||||
token-context guard, which you were right to call out as the correct
|
||||
abstraction from the start.
|
||||
|
||||
### Fixed (behavior change for code-mode users)
|
||||
|
||||
- **Edits are now gated behind `/apply`.** Each assistant turn's
|
||||
SEARCH/REPLACE blocks are parsed and shown as a preview line
|
||||
(`▸ N pending edit block(s) — /apply to commit, /discard to drop`)
|
||||
with per-block `path (-N +M lines)`. Nothing touches disk without
|
||||
explicit `/apply`. Pending state survives across user messages —
|
||||
you can keep chatting and land the batch later. Aider's model, which
|
||||
we should have picked from the start.
|
||||
- **Forced-summary events are tagged `forcedSummary: true` on
|
||||
`LoopEvent`.** The code-mode edit applier ignores tagged events
|
||||
entirely. Without this, a budget / abort / context-guard summary
|
||||
could dump SEARCH/REPLACE blocks into output and silently turn
|
||||
"analysis" into "edit". This was the root-cause bug for the
|
||||
real-session report.
|
||||
- **Token-context guard replaces iter count as the primary stop.**
|
||||
After every model response, if `promptTokens / contextWindow > 0.8`
|
||||
the loop emits a yellow warning, skips executing the tool calls the
|
||||
model just proposed, and diverts to the no-tools summary path
|
||||
(`reason: "context-guard"`). Iter cap bumped 24 → 64 as a
|
||||
last-resort backstop — the real constraint is the 131k-token
|
||||
window, not a magic iteration count.
|
||||
- **Stray `EditSummary` / `summarizeEdit` reverted** from
|
||||
`src/code/edit-blocks.ts`. v0.4.0's auto-apply let the model write
|
||||
it during a failed forced-summary run. Nothing referenced it.
|
||||
Removed.
|
||||
- **SEARCH/REPLACE blocks render as a real diff, not mangled prose.**
|
||||
Previously the Markdown renderer fed SEARCH/REPLACE content through
|
||||
the paragraph path — which joined lines with spaces and let the
|
||||
inline bold/italic regex eat `*` characters inside JSDoc `/** … */`
|
||||
comments. Output looked like `/** Edit landed on disk. /` with
|
||||
trailing `*` consumed and newlines flattened. Now the parser
|
||||
recognizes the `<filename>` / `<<<<<<< SEARCH` / `=======` /
|
||||
`>>>>>>> REPLACE` envelope and emits a dedicated `edit-block` block
|
||||
kind, rendered as `- ` / `+ ` diff rows with the filename on top
|
||||
and (new file) tagged for empty-SEARCH creations. No inline
|
||||
markdown inside — content is shown verbatim.
|
||||
- **"Reasoning before it speaks" UX no longer looks frozen.** Under
|
||||
`deepseek-reasoner`, R1 streams `reasoning_content` first and
|
||||
`content` only after — often 20-90 seconds of silence from the
|
||||
user's perspective. The streaming preview used to show
|
||||
`(waiting for first token…)` during that window, making the app
|
||||
look hung. Now:
|
||||
- A cyan braille-spinner pulse ticks at 500 ms so the heartbeat
|
||||
is visible regardless of stream bursts.
|
||||
- Label switches `streaming` → `reasoning` while body is empty.
|
||||
- The "waiting" line is replaced with an explicit
|
||||
`R1 is thinking before it speaks — body text starts when
|
||||
reasoning completes (typically 20-90s)` so the user knows to
|
||||
wait, not to bail.
|
||||
- **Tool calls now show a spinner while dispatching.** The loop
|
||||
gains a new `tool_start` event yielded *before* `await
|
||||
tools.dispatch(...)`, separate from the existing `tool` event
|
||||
yielded with the result. The TUI renders a
|
||||
`⠋ tool<filesystem_edit_file> running…` row (with a short args
|
||||
preview) while the Promise is pending. Without this, a multi-KB
|
||||
edit could sit for a full second with no visual feedback — the
|
||||
streaming block was already cleared on `assistant_final` and the
|
||||
input was disabled. Transcripts still only record the `tool`
|
||||
result event (not `tool_start`), so replay/diff output is
|
||||
unchanged.
|
||||
|
||||
### Added (code mode)
|
||||
|
||||
- **`/apply`** — commits pending edit blocks, snapshots for `/undo`,
|
||||
per-block status.
|
||||
- **`/discard`** — forgets pending edits without writing.
|
||||
- **`/undo`** — roll back the *last applied* edit batch. Restores
|
||||
files to their pre-`/apply` content, deletes any file the batch had
|
||||
just created. One level of history for now, Aider-style.
|
||||
- **`/commit "msg"`** — `git add -A && git commit -m "msg"` inside
|
||||
the code-mode rootDir. Surfaces git's stderr on failure (hooks,
|
||||
nothing staged, detached HEAD, etc.).
|
||||
- **.gitignore awareness** — `reasonix code` reads the project's
|
||||
`.gitignore` on launch and injects it into the system prompt as
|
||||
"don't traverse or edit these paths unless asked". Hard-coded
|
||||
baseline ignores (`node_modules`, `dist`, `.git`, `.venv`, etc.) are
|
||||
also baked into the base prompt for projects without a `.gitignore`.
|
||||
Stops the model wasting 5 tool calls listing `node_modules`.
|
||||
|
||||
### Tightened
|
||||
|
||||
- **`CODE_SYSTEM_PROMPT` gains a "when to edit vs. when to explore"
|
||||
section.** Explicitly tells the model: only propose edits when the
|
||||
user asks to change / fix / add / remove / refactor. For analyze /
|
||||
explain / describe, stay read-only. Belt-and-braces with the
|
||||
`/apply` gate below.
|
||||
|
||||
### Tests (+35, suite 292→318)
|
||||
|
||||
- `tests/edit-blocks.test.ts` (+5) — `snapshotBeforeEdits` +
|
||||
`restoreSnapshots` round-trip: restore modified file, delete
|
||||
newly-created file on undo, de-dup per path in batches, refuse
|
||||
path-escape in snapshots.
|
||||
- `tests/code-prompt.test.ts` (+4 new file) — `.gitignore` injection:
|
||||
no-file case, happy path, truncation over 2KB, base prompt still
|
||||
names the built-in ignores.
|
||||
- `tests/slash.test.ts` (+13) — `/apply`, `/discard`, `/undo`,
|
||||
`/commit`: inside vs. outside code mode, usage hint on empty
|
||||
message, double-quote stripping, help listing all of them.
|
||||
- `tests/loop.test.ts` (+1) — context-guard warning + forced-summary
|
||||
flag when prompt tokens exceed 80% of the window.
|
||||
- `tests/markdown.test.ts` (+5) — `parseBlocks` extracts SEARCH/
|
||||
REPLACE into `edit-block` blocks, preserves multi-line JSDoc
|
||||
verbatim, handles new-file (empty SEARCH), rejects stray markers
|
||||
without close, multi-block responses interleaved with prose.
|
||||
- `tests/loop.test.ts` (+1) — `tool_start` precedes `tool` for each
|
||||
dispatch, so UI consumers can pair them.
|
||||
|
||||
### Notes
|
||||
|
||||
- If you relied on 0.4.0's auto-apply behavior in scripts, that's
|
||||
gone. For automation, call `applyEditBlocks` directly from the
|
||||
library — the CLI TUI is for interactive use where the new gate
|
||||
is correct.
|
||||
|
||||
---
|
||||
|
||||
## [0.4.0] — 2026-04-21
|
||||
|
||||
**Headline:** `reasonix code` — a new subcommand that turns Reasonix
|
||||
into a coding assistant. Auto-bridges the filesystem MCP at your
|
||||
working directory, teaches the model to emit Aider-style
|
||||
SEARCH/REPLACE blocks, applies them to disk after each turn. The
|
||||
"cheap Claude Code" pitch becomes real.
|
||||
|
||||
### Added
|
||||
|
||||
- **`npx reasonix code [dir]`** — opinionated wrapper around chat:
|
||||
- Filesystem MCP auto-bridged at `[dir]` (default CWD). No wizard,
|
||||
no config merge. Out-of-box ready.
|
||||
- Code-specialized system prompt that teaches SEARCH/REPLACE.
|
||||
- Reasoner + harvest on by default (coding tasks repay R1 thinking).
|
||||
- Per-directory session name (`code-<basename>`) so different
|
||||
projects don't share history.
|
||||
- **SEARCH/REPLACE edit blocks** (`src/code/edit-blocks.ts`). The
|
||||
model emits:
|
||||
```
|
||||
path/to/file.ts
|
||||
<<<<<<< SEARCH
|
||||
(exact existing lines)
|
||||
=======
|
||||
(replacement)
|
||||
>>>>>>> REPLACE
|
||||
```
|
||||
Reasonix parses them from `assistant_final`, applies them under
|
||||
the root dir, reports each result (`✓ applied`, `✓ created`,
|
||||
`✗ not-found`, `✗ path-escape`, …) as an info line in the TUI.
|
||||
Empty SEARCH creates a new file (Aider convention). SEARCH must
|
||||
match byte-for-byte; we never fuzzy-match, because a silently wrong
|
||||
edit is worse than a loud rejection.
|
||||
- **New public API** on the library: `parseEditBlocks`,
|
||||
`applyEditBlock`, `applyEditBlocks`, `CODE_SYSTEM_PROMPT`, and the
|
||||
types `EditBlock` / `ApplyResult` / `ApplyStatus`. Anyone building
|
||||
their own code-assistant UX can compose from these.
|
||||
- **`ChatOptions.codeMode`** — opt-in flag to enable edit-block
|
||||
processing inside the existing TUI event loop. Plain `reasonix chat`
|
||||
leaves it off.
|
||||
|
||||
### Why 0.4.0 (minor, not patch)
|
||||
|
||||
This is a new user-facing primitive, not a bug fix or UX polish. The
|
||||
library exports grow; the `ChatOptions` interface gains a field.
|
||||
Nothing breaks for existing 0.3.x users — `reasonix chat` behaves
|
||||
exactly as before when `codeMode` is absent. But the SemVer convention
|
||||
is: additive new surface = minor bump.
|
||||
|
||||
### Tests (+13, suite 279→292)
|
||||
|
||||
- `tests/edit-blocks.test.ts` (+13 new file). `parseEditBlocks`
|
||||
round-trips single + multi + multi-line + empty-SEARCH blocks, and
|
||||
ignores stray 7-char runs in arbitrary prose. `applyEditBlock`
|
||||
covers happy path, new-file creation, not-found rejection,
|
||||
file-missing, path-escape defense, first-occurrence semantics.
|
||||
Batch `applyEditBlocks` confirms failures don't cascade.
|
||||
|
||||
### Notes
|
||||
|
||||
- v1 scope is deliberately narrow: no `/commit`, no `/undo`, no
|
||||
.gitignore filtering, no diff preview. The user's own `git diff` +
|
||||
`git checkout` is the review + undo surface — and we run inside a
|
||||
git repo by convention.
|
||||
- The ctx gauge + Esc + /compact safety net from 0.3.1/0.3.2 applies
|
||||
equally to code mode. Exploring a large repo now has visible
|
||||
progress and a hard off-switch.
|
||||
|
||||
---
|
||||
|
||||
## [0.3.2] — 2026-04-21
|
||||
|
||||
**Headline:** Long exploration sessions are now interruptible and
|
||||
|
||||
@@ -173,6 +173,38 @@ every message appended atomically, so killing the CLI never loses
|
||||
context. Oversized tool results auto-heal on load, so poisoning a
|
||||
session with one giant `read_file` doesn't brick your history.
|
||||
|
||||
### Code mode — `npx reasonix code`
|
||||
|
||||
A thin opinionated layer on top of chat: filesystem MCP bridged at
|
||||
`cwd`, coding system prompt, reasoner preset, per-directory session.
|
||||
The model proposes edits as **SEARCH/REPLACE blocks**:
|
||||
|
||||
```
|
||||
src/foo.ts
|
||||
<<<<<<< SEARCH
|
||||
const x = 1;
|
||||
=======
|
||||
const x = 2;
|
||||
>>>>>>> REPLACE
|
||||
```
|
||||
|
||||
Reasonix parses them out of each turn, applies them to disk, reports
|
||||
`✓ applied src/foo.ts` in the TUI. SEARCH must match byte-for-byte;
|
||||
we never fuzzy-match (silent wrong edits are worse than loud
|
||||
rejections). Run `git diff` to review, `git checkout .` to undo.
|
||||
|
||||
```bash
|
||||
cd my-project
|
||||
npx reasonix code # default: cwd, reasoner, per-dir session
|
||||
npx reasonix code src/ # scope the filesystem sandbox tighter
|
||||
npx reasonix code --no-session # ephemeral
|
||||
```
|
||||
|
||||
First-run sandbox: because code mode uses the filesystem MCP from
|
||||
`@modelcontextprotocol/server-filesystem`, the model can only read
|
||||
and write inside the directory you pointed at. It literally can't
|
||||
touch files above that root.
|
||||
|
||||
### Advanced — CLI subcommands and flags
|
||||
|
||||
```bash
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "reasonix",
|
||||
"version": "0.3.2",
|
||||
"version": "0.4.8",
|
||||
"description": "DeepSeek-native agent framework: cache-first loop, R1 thought harvesting, tool-call repair.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { useState } from "react";
|
||||
import { loadApiKey } from "../../config.js";
|
||||
import { loadDotenv } from "../../env.js";
|
||||
import { McpClient } from "../../mcp/client.js";
|
||||
import { type InspectionReport, inspectMcpServer } from "../../mcp/inspect.js";
|
||||
import { bridgeMcpTools } from "../../mcp/registry.js";
|
||||
import { parseMcpSpec } from "../../mcp/spec.js";
|
||||
import { SseTransport } from "../../mcp/sse.js";
|
||||
@@ -10,6 +11,14 @@ import { type McpTransport, StdioTransport } from "../../mcp/stdio.js";
|
||||
import { ToolRegistry } from "../../tools.js";
|
||||
import { App } from "../ui/App.js";
|
||||
import { Setup } from "../ui/Setup.js";
|
||||
import type { McpServerSummary } from "../ui/slash.js";
|
||||
|
||||
export interface ProgressInfo {
|
||||
toolName: string;
|
||||
progress: number;
|
||||
total?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ChatOptions {
|
||||
model: string;
|
||||
@@ -22,15 +31,29 @@ export interface ChatOptions {
|
||||
mcp?: string[];
|
||||
/** Global prefix — only used when a single anonymous server is given. */
|
||||
mcpPrefix?: string;
|
||||
/**
|
||||
* Enable SEARCH/REPLACE edit-block processing after each assistant turn.
|
||||
* Set by `reasonix code`; plain `reasonix chat` leaves this off.
|
||||
*/
|
||||
codeMode?: { rootDir: string };
|
||||
}
|
||||
|
||||
interface RootProps extends ChatOptions {
|
||||
initialKey: string | undefined;
|
||||
tools: ToolRegistry | undefined;
|
||||
mcpSpecs: string[];
|
||||
mcpServers: McpServerSummary[];
|
||||
/**
|
||||
* Shared ref the bridge's `onProgress` callback writes through.
|
||||
* App sets `.current` to its own handler on mount so every
|
||||
* progress frame from any bridged tool lands in the UI's
|
||||
* `OngoingToolRow`. Ref keeps the wire-up synchronous with
|
||||
* React reconciliation (no effect-timing surprises).
|
||||
*/
|
||||
progressSink: { current: ((info: ProgressInfo) => void) | null };
|
||||
}
|
||||
|
||||
function Root({ initialKey, tools, mcpSpecs, ...appProps }: RootProps) {
|
||||
function Root({ initialKey, tools, mcpSpecs, mcpServers, progressSink, ...appProps }: RootProps) {
|
||||
const [key, setKey] = useState<string | undefined>(initialKey);
|
||||
if (!key) {
|
||||
return (
|
||||
@@ -53,6 +76,9 @@ function Root({ initialKey, tools, mcpSpecs, ...appProps }: RootProps) {
|
||||
session={appProps.session}
|
||||
tools={tools}
|
||||
mcpSpecs={mcpSpecs}
|
||||
mcpServers={mcpServers}
|
||||
progressSink={progressSink}
|
||||
codeMode={appProps.codeMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -65,6 +91,12 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
const clients: McpClient[] = [];
|
||||
const successfulSpecs: string[] = [];
|
||||
const failedSpecs: Array<{ spec: string; reason: string }> = [];
|
||||
const mcpServers: McpServerSummary[] = [];
|
||||
// Shared progress sink: the bridge's onProgress callback writes
|
||||
// through `progressSink.current`, which App.tsx sets to its UI
|
||||
// updater on mount. Started null so early progress frames (before
|
||||
// the App has mounted) are dropped rather than buffered.
|
||||
const progressSink: { current: ((info: ProgressInfo) => void) | null } = { current: null };
|
||||
let tools: ToolRegistry | undefined;
|
||||
|
||||
if (requestedSpecs.length > 0) {
|
||||
@@ -83,7 +115,31 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
: new StdioTransport({ command: spec.command, args: spec.args });
|
||||
const mcp = new McpClient({ transport });
|
||||
await mcp.initialize();
|
||||
const bridge = await bridgeMcpTools(mcp, { registry: tools, namePrefix: prefix });
|
||||
const bridge = await bridgeMcpTools(mcp, {
|
||||
registry: tools,
|
||||
namePrefix: prefix,
|
||||
onProgress: (info) => progressSink.current?.(info),
|
||||
});
|
||||
// Collect resources + prompts once at startup so the /mcp
|
||||
// slash can render them synchronously. Servers that don't
|
||||
// support these fall through as `{supported: false}` instead
|
||||
// of throwing — see inspectMcpServer.
|
||||
let report: InspectionReport;
|
||||
try {
|
||||
report = await inspectMcpServer(mcp);
|
||||
} catch {
|
||||
// If the inspect call itself fails (rare — shouldn't happen
|
||||
// since inspectMcpServer swallows -32601), synthesize a
|
||||
// minimal report so `/mcp` still has something to render.
|
||||
report = {
|
||||
protocolVersion: mcp.protocolVersion,
|
||||
serverInfo: mcp.serverInfo,
|
||||
capabilities: mcp.serverCapabilities ?? {},
|
||||
tools: { supported: true, items: [] },
|
||||
resources: { supported: false, reason: "inspect failed" },
|
||||
prompts: { supported: false, reason: "inspect failed" },
|
||||
};
|
||||
}
|
||||
const label = spec.name ?? "anon";
|
||||
const source =
|
||||
spec.transport === "sse" ? spec.url : `${spec.command} ${spec.args.join(" ")}`;
|
||||
@@ -92,6 +148,12 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
);
|
||||
clients.push(mcp);
|
||||
successfulSpecs.push(raw);
|
||||
mcpServers.push({
|
||||
label,
|
||||
spec: raw,
|
||||
toolCount: bridge.registeredNames.length,
|
||||
report,
|
||||
});
|
||||
} catch (err) {
|
||||
// Per-server failure is non-fatal: one broken server shouldn't
|
||||
// kill a chat that has working servers configured. We record
|
||||
@@ -114,7 +176,14 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
const mcpSpecs = successfulSpecs;
|
||||
|
||||
const { waitUntilExit } = render(
|
||||
<Root initialKey={initialKey} tools={tools} mcpSpecs={mcpSpecs} {...opts} />,
|
||||
<Root
|
||||
initialKey={initialKey}
|
||||
tools={tools}
|
||||
mcpSpecs={mcpSpecs}
|
||||
mcpServers={mcpServers}
|
||||
progressSink={progressSink}
|
||||
{...opts}
|
||||
/>,
|
||||
{ exitOnCtrlC: true },
|
||||
);
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* `reasonix code [dir]` — opinionated wrapper around `reasonix chat` for
|
||||
* code-editing workflows.
|
||||
*
|
||||
* What it does differently from plain chat:
|
||||
* - Auto-bridges the official filesystem MCP server rooted at the
|
||||
* given directory (CWD by default). No wizard, no config merge.
|
||||
* - Uses a coding-focused system prompt (src/code/prompt.ts) that
|
||||
* teaches the model to propose edits as SEARCH/REPLACE blocks.
|
||||
* - Defaults to the `smart` preset (reasoner + harvest) because
|
||||
* coding tasks pay back R1 thinking.
|
||||
* - Scopes its session to the directory so projects don't share
|
||||
* conversation history.
|
||||
* - Hooks `codeMode` into the TUI so assistant replies get parsed
|
||||
* for SEARCH/REPLACE blocks and applied on disk after each turn.
|
||||
*
|
||||
* Out of scope for v1: /commit, /undo, diff preview, .gitignore
|
||||
* filtering. The user's own `git diff` + `git checkout` is the review
|
||||
* / undo surface for now.
|
||||
*/
|
||||
|
||||
import { basename, resolve } from "node:path";
|
||||
import { sanitizeName } from "../../session.js";
|
||||
import { chatCommand } from "./chat.js";
|
||||
|
||||
export interface CodeOptions {
|
||||
/** Directory to root the filesystem MCP at. Defaults to process.cwd(). */
|
||||
dir?: string;
|
||||
/** Override the default `smart` model. */
|
||||
model?: string;
|
||||
/** Disable session persistence. */
|
||||
noSession?: boolean;
|
||||
/** Transcript file for replay/diff. */
|
||||
transcript?: string;
|
||||
}
|
||||
|
||||
export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
|
||||
const { codeSystemPrompt } = await import("../../code/prompt.js");
|
||||
const rootDir = resolve(opts.dir ?? process.cwd());
|
||||
// Per-directory session so switching projects doesn't mix histories.
|
||||
// `code-<sanitized-basename>` fits the session name rules without
|
||||
// truncating most project names.
|
||||
const session = opts.noSession ? undefined : `code-${sanitizeName(basename(rootDir))}`;
|
||||
// Filesystem MCP spec pointing at rootDir. Quote the path in case it
|
||||
// has spaces — the existing shellSplit inside parseMcpSpec understands
|
||||
// double quotes.
|
||||
const fsSpec = `filesystem=npx -y @modelcontextprotocol/server-filesystem ${quoteIfNeeded(rootDir)}`;
|
||||
|
||||
process.stderr.write(
|
||||
`▸ reasonix code: rooted at ${rootDir}, session "${session ?? "(ephemeral)"}"\n`,
|
||||
);
|
||||
|
||||
await chatCommand({
|
||||
model: opts.model ?? "deepseek-reasoner",
|
||||
harvest: true, // smart preset's harvest setting, always on for code
|
||||
system: codeSystemPrompt(rootDir),
|
||||
transcript: opts.transcript,
|
||||
session,
|
||||
mcp: [fsSpec],
|
||||
codeMode: { rootDir },
|
||||
});
|
||||
}
|
||||
|
||||
function quoteIfNeeded(s: string): string {
|
||||
return /\s|"/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* `reasonix mcp inspect <spec>` — connect to one MCP server, list
|
||||
* everything it exposes (tools, resources, prompts), print a
|
||||
* human-readable summary. Useful as:
|
||||
* - a diagnostic tool for "does my MCP server even work?"
|
||||
* - a discovery tool for "what can this server do?"
|
||||
* - the only CLI surface for the resources+prompts methods added
|
||||
* in 0.4.5 (chat mode consumes tools but hasn't wired up the
|
||||
* other two families yet).
|
||||
*/
|
||||
|
||||
import { McpClient } from "../../mcp/client.js";
|
||||
import { inspectMcpServer } from "../../mcp/inspect.js";
|
||||
import type { InspectionReport } from "../../mcp/inspect.js";
|
||||
import { parseMcpSpec } from "../../mcp/spec.js";
|
||||
import { SseTransport } from "../../mcp/sse.js";
|
||||
import { type McpTransport, StdioTransport } from "../../mcp/stdio.js";
|
||||
|
||||
export interface McpInspectOptions {
|
||||
/** The raw --mcp spec string (e.g. `fs=npx -y @modelcontextprotocol/server-filesystem .`). */
|
||||
spec: string;
|
||||
/** Emit JSON on stdout instead of the human-readable table. */
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function mcpInspectCommand(opts: McpInspectOptions): Promise<void> {
|
||||
const spec = parseMcpSpec(opts.spec);
|
||||
const transport: McpTransport =
|
||||
spec.transport === "sse"
|
||||
? new SseTransport({ url: spec.url })
|
||||
: new StdioTransport({ command: spec.command, args: spec.args });
|
||||
const client = new McpClient({ transport });
|
||||
try {
|
||||
await client.initialize();
|
||||
const report = await inspectMcpServer(client);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
} else {
|
||||
console.log(formatReport(spec.name ?? "(anon)", report));
|
||||
}
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
function formatReport(nsName: string, r: InspectionReport): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`MCP server [${nsName}]`);
|
||||
lines.push(
|
||||
` server ${r.serverInfo.name || "(unknown)"}${r.serverInfo.version ? ` v${r.serverInfo.version}` : ""}`,
|
||||
);
|
||||
lines.push(` protocol ${r.protocolVersion}`);
|
||||
const capKeys = Object.keys(r.capabilities);
|
||||
lines.push(` caps ${capKeys.length > 0 ? capKeys.join(", ") : "(none advertised)"}`);
|
||||
if (r.instructions) {
|
||||
lines.push(` notes ${r.instructions.trim().slice(0, 200)}`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(formatSection("Tools", r.tools, toolLine));
|
||||
lines.push(formatSection("Resources", r.resources, resourceLine));
|
||||
lines.push(formatSection("Prompts", r.prompts, promptLine));
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function formatSection<T>(
|
||||
title: string,
|
||||
section: { supported: true; items: T[] } | { supported: false; reason: string },
|
||||
render: (item: T) => string,
|
||||
): string {
|
||||
if (!section.supported) {
|
||||
return `${title}: (not supported — ${section.reason})`;
|
||||
}
|
||||
if (section.items.length === 0) {
|
||||
return `${title}: (none)`;
|
||||
}
|
||||
const lines = [`${title} (${section.items.length}):`];
|
||||
for (const item of section.items) lines.push(` ${render(item)}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function toolLine(t: { name: string; description?: string }): string {
|
||||
const desc = t.description ? ` — ${oneLine(t.description, 80)}` : "";
|
||||
return `· ${t.name}${desc}`;
|
||||
}
|
||||
|
||||
function resourceLine(r: { uri: string; name: string; mimeType?: string }): string {
|
||||
const mime = r.mimeType ? ` [${r.mimeType}]` : "";
|
||||
return `· ${r.name}${mime} ${r.uri}`;
|
||||
}
|
||||
|
||||
function promptLine(p: {
|
||||
name: string;
|
||||
description?: string;
|
||||
arguments?: Array<{ name: string; required?: boolean }>;
|
||||
}): string {
|
||||
const argPart =
|
||||
p.arguments && p.arguments.length > 0
|
||||
? ` (${p.arguments.map((a) => (a.required ? a.name : `${a.name}?`)).join(", ")})`
|
||||
: "";
|
||||
const desc = p.description ? ` — ${oneLine(p.description, 80)}` : "";
|
||||
return `· ${p.name}${argPart}${desc}`;
|
||||
}
|
||||
|
||||
function oneLine(s: string, max: number): string {
|
||||
const flat = s.replace(/\s+/g, " ").trim();
|
||||
return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`;
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import { Command } from "commander";
|
||||
import { readConfig } from "../config.js";
|
||||
import { VERSION } from "../index.js";
|
||||
import { chatCommand } from "./commands/chat.js";
|
||||
import { codeCommand } from "./commands/code.js";
|
||||
import { diffCommand } from "./commands/diff.js";
|
||||
import { mcpInspectCommand } from "./commands/mcp-inspect.js";
|
||||
import { mcpListCommand } from "./commands/mcp.js";
|
||||
import { replayCommand } from "./commands/replay.js";
|
||||
import { runCommand } from "./commands/run.js";
|
||||
@@ -50,6 +52,23 @@ program
|
||||
await setupCommand({});
|
||||
});
|
||||
|
||||
program
|
||||
.command("code [dir]")
|
||||
.description(
|
||||
"Code-editing chat — filesystem MCP auto-bridged at <dir> (default: cwd), coding system prompt, smart preset. Model proposes SEARCH/REPLACE blocks; Reasonix applies them to disk.",
|
||||
)
|
||||
.option("-m, --model <id>", "Override default reasoner model")
|
||||
.option("--no-session", "Disable session persistence for this run")
|
||||
.option("--transcript <path>", "Write a JSONL transcript to this path")
|
||||
.action(async (dir: string | undefined, opts) => {
|
||||
await codeCommand({
|
||||
dir,
|
||||
model: opts.model,
|
||||
noSession: opts.session === false,
|
||||
transcript: opts.transcript,
|
||||
});
|
||||
});
|
||||
|
||||
program
|
||||
.command("chat")
|
||||
.description("Interactive Ink TUI with live cache/cost panel.")
|
||||
@@ -215,6 +234,21 @@ mcp
|
||||
mcpListCommand({ json: !!opts.json });
|
||||
});
|
||||
|
||||
mcp
|
||||
.command("inspect <spec>")
|
||||
.description(
|
||||
'Connect to one MCP server and print its server info + tools/resources/prompts. <spec> takes the same forms as --mcp ("name=cmd args", "cmd args", or an SSE URL).',
|
||||
)
|
||||
.option("--json", "Emit the full inspection report as JSON instead of the human-readable summary")
|
||||
.action(async (spec: string, opts) => {
|
||||
try {
|
||||
await mcpInspectCommand({ spec, json: !!opts.json });
|
||||
} catch (err) {
|
||||
process.stderr.write(`mcp inspect failed: ${(err as Error).message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program.command("version").description("Print Reasonix version.").action(versionCommand);
|
||||
|
||||
program.parseAsync(process.argv).catch((err) => {
|
||||
|
||||
+494
-18
@@ -1,6 +1,15 @@
|
||||
import type { WriteStream } from "node:fs";
|
||||
import { Box, Static, Text, useApp, useInput } from "ink";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
type ApplyResult,
|
||||
type EditBlock,
|
||||
type EditSnapshot,
|
||||
applyEditBlocks,
|
||||
parseEditBlocks,
|
||||
restoreSnapshots,
|
||||
snapshotBeforeEdits,
|
||||
} from "../../code/edit-blocks.js";
|
||||
import { CacheFirstLoop, DeepSeekClient, ImmutablePrefix } from "../../index.js";
|
||||
import type { LoopEvent } from "../../loop.js";
|
||||
import type { SessionSummary } from "../../telemetry.js";
|
||||
@@ -8,8 +17,9 @@ import type { ToolRegistry } from "../../tools.js";
|
||||
import { openTranscriptFile, recordFromLoopEvent, writeRecord } from "../../transcript.js";
|
||||
import { type DisplayEvent, EventRow } from "./EventLog.js";
|
||||
import { PromptInput } from "./PromptInput.js";
|
||||
import { SlashSuggestions } from "./SlashSuggestions.js";
|
||||
import { StatsPanel } from "./StatsPanel.js";
|
||||
import { handleSlash, parseSlash } from "./slash.js";
|
||||
import { type McpServerSummary, handleSlash, parseSlash, suggestSlashCommands } from "./slash.js";
|
||||
|
||||
export interface AppProps {
|
||||
model: string;
|
||||
@@ -26,6 +36,28 @@ export interface AppProps {
|
||||
tools?: ToolRegistry;
|
||||
/** Raw `--mcp` / config-derived spec strings, for `/mcp` slash display. */
|
||||
mcpSpecs?: string[];
|
||||
/**
|
||||
* Pre-captured inspection reports for each connected MCP server,
|
||||
* collected once at chat startup. Drives the rich `/mcp` slash view
|
||||
* (tools + resources + prompts per server).
|
||||
*/
|
||||
mcpServers?: McpServerSummary[];
|
||||
/**
|
||||
* Shared ref the MCP bridge's onProgress callback writes through.
|
||||
* We attach our updater to `progressSink.current` on mount so any
|
||||
* `notifications/progress` frame from any bridged tool flows into
|
||||
* the UI. `null` allowed — chat mode without MCP leaves it unset.
|
||||
*/
|
||||
progressSink?: {
|
||||
current:
|
||||
| ((info: { toolName: string; progress: number; total?: number; message?: string }) => void)
|
||||
| null;
|
||||
};
|
||||
/**
|
||||
* When set, parse SEARCH/REPLACE blocks from assistant responses and
|
||||
* apply them to disk under `rootDir`. Set by `reasonix code`.
|
||||
*/
|
||||
codeMode?: { rootDir: string };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,6 +82,9 @@ export function App({
|
||||
session,
|
||||
tools,
|
||||
mcpSpecs,
|
||||
mcpServers,
|
||||
progressSink,
|
||||
codeMode,
|
||||
}: AppProps) {
|
||||
const { exit } = useApp();
|
||||
const [historical, setHistorical] = useState<DisplayEvent[]>([]);
|
||||
@@ -60,6 +95,48 @@ export function App({
|
||||
// Esc handler only fires once per turn (repeated presses would yield
|
||||
// stacked warning events).
|
||||
const abortedThisTurn = useRef(false);
|
||||
// Name + truncated args of the tool currently dispatching. Populated
|
||||
// on `tool_start`, cleared on `tool` (or error). Drives the
|
||||
// "▸ tool<X> running…" pulse-spinner row so long tool calls don't
|
||||
// look like the app hung.
|
||||
const [ongoingTool, setOngoingTool] = useState<{ name: string; args?: string } | null>(null);
|
||||
// Latest progress frame for the currently-running tool (MCP
|
||||
// `notifications/progress`). `null` when no progress has been
|
||||
// reported for this tool call — OngoingToolRow still spins, just
|
||||
// without a progress number.
|
||||
const [toolProgress, setToolProgress] = useState<{
|
||||
progress: number;
|
||||
total?: number;
|
||||
message?: string;
|
||||
} | null>(null);
|
||||
// Snapshots of every file the *last* edit batch touched, keyed by
|
||||
// nothing more than "most recent". `/undo` restores from this ref
|
||||
// and nulls it out — one level of undo, Aider-style. Multi-step
|
||||
// undo would need a proper history stack and a clear policy for
|
||||
// when the stack clears; v1 keeps it simple.
|
||||
const lastEditSnapshots = useRef<EditSnapshot[] | null>(null);
|
||||
// Pending edit blocks awaiting `/apply` or `/discard`. We do NOT
|
||||
// auto-apply — v0.4.1 showed that "model proposed, so apply" turns
|
||||
// analysis into unintended edits. The user explicitly confirms now.
|
||||
const pendingEdits = useRef<EditBlock[]>([]);
|
||||
// Shell-style history of user prompts. ↑/↓ while idle walks it;
|
||||
// submit pushes to the end. Cursor -1 = "live input", 0+ = "N turns
|
||||
// back from newest". We don't persist history to disk — sessions
|
||||
// already keep the message log, and cross-session bash-style recall
|
||||
// would need per-project scoping we haven't designed.
|
||||
const promptHistory = useRef<string[]>([]);
|
||||
const historyCursor = useRef<number>(-1);
|
||||
// Full untruncated tool results, in arrival order. The EventLog
|
||||
// renderer clips tool output at 400 chars for display; `/tool N`
|
||||
// reads from this ref to show the real thing. Not persisted — a
|
||||
// resumed session replays the log (which has the same content in
|
||||
// `tool` messages) but we don't repopulate this ref on resume
|
||||
// because the user wouldn't expect `/tool` to reach back across
|
||||
// process boundaries.
|
||||
const toolHistoryRef = useRef<Array<{ toolName: string; text: string }>>([]);
|
||||
// Highlighted suggestion index. ↑/↓ move within the current match
|
||||
// set while the user is typing a `/…` prefix; Enter or Tab pick.
|
||||
const [slashSelected, setSlashSelected] = useState(0);
|
||||
const [summary, setSummary] = useState<SessionSummary>({
|
||||
turns: 0,
|
||||
totalCostUsd: 0,
|
||||
@@ -84,6 +161,25 @@ export function App({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The currently matching slash suggestions, or `null` when the
|
||||
// user isn't in slash-prefix mode. Shared between the useInput
|
||||
// handler (navigation + Tab-complete) and SlashSuggestions
|
||||
// (rendering + highlight) so both stay in sync.
|
||||
const slashMatches = useMemo(() => {
|
||||
if (!input.startsWith("/") || input.includes(" ")) return null;
|
||||
return suggestSlashCommands(input.slice(1), !!codeMode);
|
||||
}, [input, codeMode]);
|
||||
useEffect(() => {
|
||||
// Keep selection in range whenever the match set shrinks. Reset
|
||||
// to 0 whenever we re-enter slash mode (matches goes null → array
|
||||
// triggers the useEffect too).
|
||||
setSlashSelected((prev) => {
|
||||
if (!slashMatches || slashMatches.length === 0) return 0;
|
||||
if (prev >= slashMatches.length) return slashMatches.length - 1;
|
||||
return prev;
|
||||
});
|
||||
}, [slashMatches]);
|
||||
|
||||
const loopRef = useRef<CacheFirstLoop | null>(null);
|
||||
const loop = useMemo(() => {
|
||||
if (loopRef.current) return loopRef.current;
|
||||
@@ -97,6 +193,24 @@ export function App({
|
||||
return l;
|
||||
}, [model, system, harvest, branch, session, tools]);
|
||||
|
||||
// Wire the shared progressSink so the bridge's onProgress → us.
|
||||
// Only updates progress when the frame belongs to the currently-
|
||||
// running tool: late frames from a previous call shouldn't overwrite
|
||||
// the spinner of whatever's running next.
|
||||
useEffect(() => {
|
||||
if (!progressSink) return;
|
||||
progressSink.current = (info) => {
|
||||
setToolProgress({
|
||||
progress: info.progress,
|
||||
total: info.total,
|
||||
message: info.message,
|
||||
});
|
||||
};
|
||||
return () => {
|
||||
if (progressSink.current) progressSink.current = null;
|
||||
};
|
||||
}, [progressSink]);
|
||||
|
||||
// Surface a one-time banner about session state on first mount.
|
||||
const sessionBannerShown = useRef(false);
|
||||
useEffect(() => {
|
||||
@@ -137,14 +251,105 @@ export function App({
|
||||
// mid-write), then diverts to its no-tools summary path so the user
|
||||
// gets an answer instead of a hard stop. Only listens while busy so
|
||||
// we don't accidentally hijack Esc in other contexts.
|
||||
//
|
||||
// Also handles ↑/↓ shell-style history while idle. We don't use
|
||||
// ink-text-input's (absent) history support; parent-level useInput
|
||||
// is simpler and lets us own the cursor semantics.
|
||||
useInput((_input, key) => {
|
||||
if (!key.escape) return;
|
||||
if (!busy) return;
|
||||
if (abortedThisTurn.current) return;
|
||||
abortedThisTurn.current = true;
|
||||
loop.abort();
|
||||
if (key.escape && busy) {
|
||||
if (abortedThisTurn.current) return;
|
||||
abortedThisTurn.current = true;
|
||||
loop.abort();
|
||||
return;
|
||||
}
|
||||
if (busy) return;
|
||||
|
||||
// Slash-suggestion mode takes priority over history recall.
|
||||
// When the user is typing a `/…` prefix and there are matches,
|
||||
// ↑/↓ walk the suggestion list and Tab snaps the input to the
|
||||
// highlighted command. Enter is handled in `handleSubmit` so
|
||||
// TextInput's onSubmit still fires cleanly.
|
||||
if (slashMatches && slashMatches.length > 0) {
|
||||
if (key.upArrow) {
|
||||
setSlashSelected((i) => Math.max(0, i - 1));
|
||||
return;
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setSlashSelected((i) => Math.min(slashMatches.length - 1, i + 1));
|
||||
return;
|
||||
}
|
||||
if (key.tab) {
|
||||
const sel = slashMatches[slashSelected] ?? slashMatches[0];
|
||||
if (sel) setInput(`/${sel.cmd}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Outside slash mode: ↑/↓ recall prior prompts.
|
||||
const hist = promptHistory.current;
|
||||
if (key.upArrow) {
|
||||
if (hist.length === 0) return;
|
||||
const nextCursor = Math.min(historyCursor.current + 1, hist.length - 1);
|
||||
historyCursor.current = nextCursor;
|
||||
setInput(hist[hist.length - 1 - nextCursor] ?? "");
|
||||
return;
|
||||
}
|
||||
if (key.downArrow) {
|
||||
if (historyCursor.current < 0) return;
|
||||
const nextCursor = historyCursor.current - 1;
|
||||
historyCursor.current = nextCursor;
|
||||
setInput(nextCursor < 0 ? "" : (hist[hist.length - 1 - nextCursor] ?? ""));
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Callback wired into the `/undo` slash command. Restores the files
|
||||
* last edit batch to their pre-edit state and reports per-file
|
||||
* results. Only available when running in code mode — the slash
|
||||
* handler gates on this callback's presence.
|
||||
*/
|
||||
const codeUndo = useCallback((): string => {
|
||||
if (!codeMode) return "not in code mode";
|
||||
const snaps = lastEditSnapshots.current;
|
||||
if (!snaps || snaps.length === 0) {
|
||||
return "nothing to undo — no recent edit batch to restore";
|
||||
}
|
||||
const results = restoreSnapshots(snaps, codeMode.rootDir);
|
||||
lastEditSnapshots.current = null;
|
||||
return formatUndoResults(results);
|
||||
}, [codeMode]);
|
||||
|
||||
/**
|
||||
* /apply callback — write pending edit blocks to disk, snapshot
|
||||
* beforehand so /undo still works, report per-file results.
|
||||
*/
|
||||
const codeApply = useCallback((): string => {
|
||||
if (!codeMode) return "not in code mode";
|
||||
const blocks = pendingEdits.current;
|
||||
if (blocks.length === 0) {
|
||||
return "nothing pending — the assistant hasn't proposed edits since the last /apply or /discard.";
|
||||
}
|
||||
const snaps = snapshotBeforeEdits(blocks, codeMode.rootDir);
|
||||
const results = applyEditBlocks(blocks, codeMode.rootDir);
|
||||
const anyApplied = results.some((r) => r.status === "applied" || r.status === "created");
|
||||
if (anyApplied) lastEditSnapshots.current = snaps;
|
||||
pendingEdits.current = [];
|
||||
return formatEditResults(results);
|
||||
}, [codeMode]);
|
||||
|
||||
/**
|
||||
* /discard callback — forget the pending edits without touching
|
||||
* disk. Keeps the conversation going without the user having to
|
||||
* argue the model out of its proposal.
|
||||
*/
|
||||
const codeDiscard = useCallback((): string => {
|
||||
const count = pendingEdits.current.length;
|
||||
if (count === 0) return "nothing pending to discard.";
|
||||
pendingEdits.current = [];
|
||||
return `▸ discarded ${count} pending edit block(s). Nothing was written to disk.`;
|
||||
}, []);
|
||||
|
||||
const prefixHash = loop.prefix.fingerprint;
|
||||
|
||||
const writeTranscript = useCallback(
|
||||
@@ -158,12 +363,52 @@ export function App({
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (raw: string) => {
|
||||
const text = raw.trim();
|
||||
let text = raw.trim();
|
||||
if (!text || busy) return;
|
||||
|
||||
// Slash auto-complete on Enter. When the user typed a prefix
|
||||
// (e.g. "/he") and the suggestion list is visible, substitute
|
||||
// the highlighted match so Enter runs it — same effect as Tab
|
||||
// + Enter, one keystroke less. Skip substitution if the user
|
||||
// already typed a full, exact command name (respect verbatim
|
||||
// input when they know what they want).
|
||||
if (text.startsWith("/") && !text.includes(" ")) {
|
||||
const typed = text.slice(1).toLowerCase();
|
||||
const matches = suggestSlashCommands(typed, !!codeMode);
|
||||
const exact = matches.find((m) => m.cmd === typed);
|
||||
if (!exact && matches.length > 0) {
|
||||
const chosen = matches[slashSelected] ?? matches[0];
|
||||
if (chosen) text = `/${chosen.cmd}`;
|
||||
}
|
||||
}
|
||||
|
||||
setInput("");
|
||||
historyCursor.current = -1;
|
||||
|
||||
// Y/N fast-path when edits are pending. One keystroke is all it
|
||||
// takes to commit or drop — matches the muscle memory of `git
|
||||
// add -p` / most prompts. Deliberately scoped: only when there
|
||||
// ARE pending edits, so "y" as a normal message still works
|
||||
// when nothing's waiting.
|
||||
if (codeMode && pendingEdits.current.length > 0 && (text === "y" || text === "n")) {
|
||||
const out = text === "y" ? codeApply() : codeDiscard();
|
||||
setHistorical((prev) => [...prev, { id: `sys-${Date.now()}`, role: "info", text: out }]);
|
||||
promptHistory.current.push(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const slash = parseSlash(text);
|
||||
if (slash) {
|
||||
const result = handleSlash(slash.cmd, slash.args, loop, { mcpSpecs });
|
||||
const result = handleSlash(slash.cmd, slash.args, loop, {
|
||||
mcpSpecs,
|
||||
mcpServers,
|
||||
codeUndo: codeMode ? codeUndo : undefined,
|
||||
codeApply: codeMode ? codeApply : undefined,
|
||||
codeDiscard: codeMode ? codeDiscard : undefined,
|
||||
codeRoot: codeMode?.rootDir,
|
||||
pendingEditCount: codeMode ? pendingEdits.current.length : undefined,
|
||||
toolHistory: () => toolHistoryRef.current,
|
||||
});
|
||||
if (result.exit) {
|
||||
transcriptRef.current?.end();
|
||||
exit();
|
||||
@@ -183,10 +428,19 @@ export function App({
|
||||
},
|
||||
]);
|
||||
}
|
||||
return;
|
||||
// `/retry` (and anything else that requests a resubmit) falls
|
||||
// through to the normal user-message flow with the provided
|
||||
// text instead of returning.
|
||||
if (result.resubmit) {
|
||||
text = result.resubmit;
|
||||
} else {
|
||||
promptHistory.current.push(text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// User message is immutable — push to Static immediately.
|
||||
promptHistory.current.push(text);
|
||||
setHistorical((prev) => [...prev, { id: `u-${Date.now()}`, role: "user", text }]);
|
||||
|
||||
const assistantId = `a-${Date.now()}`;
|
||||
@@ -246,12 +500,13 @@ export function App({
|
||||
flush();
|
||||
const repairNote = ev.repair ? describeRepair(ev.repair) : "";
|
||||
setStreaming(null);
|
||||
const finalText = ev.content || streamRef.text;
|
||||
setHistorical((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: assistantId,
|
||||
role: "assistant",
|
||||
text: ev.content || streamRef.text,
|
||||
text: finalText,
|
||||
reasoning: streamRef.reasoning || undefined,
|
||||
planState: ev.planState,
|
||||
branch: ev.branch,
|
||||
@@ -260,8 +515,44 @@ export function App({
|
||||
streaming: false,
|
||||
},
|
||||
]);
|
||||
if (codeMode && finalText && !ev.forcedSummary) {
|
||||
// Parse SEARCH/REPLACE blocks but DO NOT write them to
|
||||
// disk. Store as pending — the user has to say /apply
|
||||
// explicitly. This prevents "analyze the project" from
|
||||
// silently drifting into "edit the project".
|
||||
//
|
||||
// `ev.forcedSummary` gates us out entirely: if the loop
|
||||
// had to force a summary (budget / aborted / context-
|
||||
// guard), its text is a wrap-up, not a plan to execute.
|
||||
// Blocks dropped in a forced summary are display-only.
|
||||
const blocks = parseEditBlocks(finalText);
|
||||
if (blocks.length > 0) {
|
||||
pendingEdits.current = blocks;
|
||||
setHistorical((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `pending-${Date.now()}`,
|
||||
role: "info",
|
||||
text: formatPendingPreview(blocks),
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
} else if (ev.role === "tool_start") {
|
||||
// Kick off the visual indicator. Cleared when `tool`
|
||||
// (result) or `error` arrives, or on the finally below.
|
||||
// Also reset any lingering progress from a prior call so
|
||||
// the new spinner starts clean.
|
||||
setOngoingTool({ name: ev.toolName ?? "?", args: ev.toolArgs });
|
||||
setToolProgress(null);
|
||||
} else if (ev.role === "tool") {
|
||||
flush();
|
||||
setOngoingTool(null);
|
||||
setToolProgress(null);
|
||||
toolHistoryRef.current.push({
|
||||
toolName: ev.toolName ?? "?",
|
||||
text: ev.content,
|
||||
});
|
||||
setHistorical((prev) => [
|
||||
...prev,
|
||||
{
|
||||
@@ -287,11 +578,25 @@ export function App({
|
||||
} finally {
|
||||
clearInterval(timer);
|
||||
setStreaming(null);
|
||||
setOngoingTool(null);
|
||||
setToolProgress(null);
|
||||
setSummary(loop.stats.summary());
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[busy, exit, loop, mcpSpecs, writeTranscript],
|
||||
[
|
||||
busy,
|
||||
codeApply,
|
||||
codeDiscard,
|
||||
codeMode,
|
||||
codeUndo,
|
||||
exit,
|
||||
loop,
|
||||
mcpSpecs,
|
||||
mcpServers,
|
||||
slashSelected,
|
||||
writeTranscript,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -309,23 +614,194 @@ export function App({
|
||||
<EventRow event={streaming} />
|
||||
</Box>
|
||||
) : null}
|
||||
{ongoingTool ? <OngoingToolRow tool={ongoingTool} progress={toolProgress} /> : null}
|
||||
<PromptInput value={input} onChange={setInput} onSubmit={handleSubmit} disabled={busy} />
|
||||
<CommandStrip />
|
||||
<SlashSuggestions matches={slashMatches} selectedIndex={slashSelected} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandStrip() {
|
||||
/**
|
||||
* Live spinner row while a tool call is in flight. Without this the
|
||||
* window between the model's `tool_calls` decision and the tool's
|
||||
* result (often seconds for a multi-KB `filesystem_edit_file`) looks
|
||||
* like the app has frozen — the streaming assistant display is
|
||||
* already cleared and the input is disabled, so there's nothing to
|
||||
* look at.
|
||||
*
|
||||
* We show three signals: a braille spinner (liveness), an elapsed
|
||||
* timer in seconds (so "long" has a number attached), and a
|
||||
* per-tool summary of the most informative argument fields (path,
|
||||
* edits count, pattern, etc.). As of 0.4.8, MCP progress frames
|
||||
* (`notifications/progress`) land here too — bar + "n/N" when the
|
||||
* server reports a total, free-form message when not.
|
||||
*/
|
||||
function OngoingToolRow({
|
||||
tool,
|
||||
progress,
|
||||
}: {
|
||||
tool: { name: string; args?: string };
|
||||
progress: { progress: number; total?: number; message?: string } | null;
|
||||
}) {
|
||||
const [tick, setTick] = useState(0);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
useEffect(() => {
|
||||
const start = Date.now();
|
||||
const frameId = setInterval(() => setTick((t) => t + 1), 120);
|
||||
const secId = setInterval(() => setElapsed(Math.floor((Date.now() - start) / 1000)), 1000);
|
||||
return () => {
|
||||
clearInterval(frameId);
|
||||
clearInterval(secId);
|
||||
};
|
||||
}, []);
|
||||
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
const summary = summarizeToolArgs(tool.name, tool.args);
|
||||
return (
|
||||
<Box paddingX={2} flexDirection="column">
|
||||
<Text dimColor>
|
||||
/help · /preset {"<fast|smart|max>"} · /mcp · /compact · /sessions · /setup · /clear · /exit
|
||||
</Text>
|
||||
<Text dimColor>Esc (while thinking) — abort & summarize what was found so far</Text>
|
||||
<Box marginY={1} flexDirection="column">
|
||||
<Box>
|
||||
<Text color="cyan">{frames[tick % frames.length]}</Text>
|
||||
<Text color="yellow">{` tool<${tool.name}> running…`}</Text>
|
||||
<Text dimColor>{` ${elapsed}s`}</Text>
|
||||
</Box>
|
||||
{progress ? (
|
||||
<Box paddingLeft={2}>
|
||||
<Text color="cyan">{renderProgressLine(progress)}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{summary ? (
|
||||
<Box paddingLeft={2}>
|
||||
<Text dimColor>{summary}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn raw JSON tool arguments into a one-line human summary. For
|
||||
* common filesystem MCP tools we pull the actually-useful fields; for
|
||||
* anything else we fall back to a truncated raw string so the user
|
||||
* still sees *something* beyond the tool name.
|
||||
*
|
||||
* Match on suffix (e.g. `_read_file`) rather than exact name because
|
||||
* `bridgeMcpTools({ namePrefix: "filesystem_" })` prepends the server
|
||||
* namespace — tools arrive as `filesystem_read_file` in practice but
|
||||
* callers might wire up anonymous too.
|
||||
*/
|
||||
/**
|
||||
* Render an MCP progress frame as a single line. When the server
|
||||
* reports `total`, show an ASCII progress bar + "n/total pct%";
|
||||
* otherwise just "progress" + optional message. Width is modest
|
||||
* so the line fits even in a narrow terminal.
|
||||
*/
|
||||
function renderProgressLine(p: { progress: number; total?: number; message?: string }): string {
|
||||
const msg = p.message ? ` ${p.message}` : "";
|
||||
if (p.total && p.total > 0) {
|
||||
const ratio = Math.max(0, Math.min(1, p.progress / p.total));
|
||||
const width = 20;
|
||||
const filled = Math.round(ratio * width);
|
||||
const bar = "█".repeat(filled) + "░".repeat(width - filled);
|
||||
const pct = (ratio * 100).toFixed(0);
|
||||
return `[${bar}] ${p.progress}/${p.total} ${pct}%${msg}`;
|
||||
}
|
||||
return `progress: ${p.progress}${msg}`;
|
||||
}
|
||||
|
||||
function summarizeToolArgs(name: string, args?: string): string {
|
||||
if (!args || args === "{}") return "";
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(args) as Record<string, unknown>;
|
||||
} catch {
|
||||
// Unparseable JSON — show a head slice so user at least sees
|
||||
// what the model tried.
|
||||
return args.length > 80 ? `${args.slice(0, 80)}…` : args;
|
||||
}
|
||||
const hasSuffix = (s: string) => name === s || name.endsWith(`_${s}`);
|
||||
const path = typeof parsed.path === "string" ? parsed.path : undefined;
|
||||
if (hasSuffix("read_file")) {
|
||||
const head = typeof parsed.head === "number" ? `, head=${parsed.head}` : "";
|
||||
const tail = typeof parsed.tail === "number" ? `, tail=${parsed.tail}` : "";
|
||||
return `path: ${path ?? "?"}${head}${tail}`;
|
||||
}
|
||||
if (hasSuffix("write_file")) {
|
||||
const content = typeof parsed.content === "string" ? parsed.content : "";
|
||||
return `path: ${path ?? "?"} (${content.length} chars)`;
|
||||
}
|
||||
if (hasSuffix("edit_file")) {
|
||||
const edits = Array.isArray(parsed.edits) ? parsed.edits.length : 0;
|
||||
return `path: ${path ?? "?"} (${edits} edit${edits === 1 ? "" : "s"})`;
|
||||
}
|
||||
if (hasSuffix("list_directory") || hasSuffix("directory_tree")) {
|
||||
return `path: ${path ?? "?"}`;
|
||||
}
|
||||
if (hasSuffix("search_files")) {
|
||||
const pattern = typeof parsed.pattern === "string" ? parsed.pattern : "?";
|
||||
return `path: ${path ?? "?"} · pattern: ${pattern}`;
|
||||
}
|
||||
if (hasSuffix("move_file")) {
|
||||
const src = typeof parsed.source === "string" ? parsed.source : "?";
|
||||
const dst = typeof parsed.destination === "string" ? parsed.destination : "?";
|
||||
return `${src} → ${dst}`;
|
||||
}
|
||||
if (hasSuffix("get_file_info")) {
|
||||
return `path: ${path ?? "?"}`;
|
||||
}
|
||||
return args.length > 80 ? `${args.slice(0, 80)}…` : args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a batch of SEARCH/REPLACE application results as one
|
||||
* human-scannable info line per edit. Prefixes denote status so the
|
||||
* line reads well even without color (e.g. when piped to a log file
|
||||
* or stripped for screenshots):
|
||||
* ✓ applied src/foo.ts
|
||||
* ✓ created src/new.ts
|
||||
* ✗ not-found src/bar.ts (SEARCH text does not match…)
|
||||
*/
|
||||
function formatEditResults(results: ApplyResult[]): string {
|
||||
const lines = results.map((r) => {
|
||||
const mark = r.status === "applied" || r.status === "created" ? "✓" : "✗";
|
||||
const detail = r.message ? ` (${r.message})` : "";
|
||||
return ` ${mark} ${r.status.padEnd(11)} ${r.path}${detail}`;
|
||||
});
|
||||
const ok = results.filter((r) => r.status === "applied" || r.status === "created").length;
|
||||
const total = results.length;
|
||||
const header = `▸ edit blocks: ${ok}/${total} applied — /undo to roll back, or \`git diff\` to review`;
|
||||
return [header, ...lines].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending-edits preview shown after each assistant turn that proposed
|
||||
* changes. We keep it deliberately thin — path + approximate
|
||||
* ±line-count — because a full diff would crowd the TUI and the user
|
||||
* can always run `git diff` after /apply.
|
||||
*/
|
||||
function formatPendingPreview(blocks: EditBlock[]): string {
|
||||
const lines = blocks.map((b) => {
|
||||
const removed = b.search === "" ? 0 : countLines(b.search);
|
||||
const added = countLines(b.replace);
|
||||
const tag = b.search === "" ? "NEW " : " ";
|
||||
return ` ${tag}${b.path} (-${removed} +${added} lines)`;
|
||||
});
|
||||
const header = `▸ ${blocks.length} pending edit block(s) — /apply (or y) to commit · /discard (or n) to drop`;
|
||||
return [header, ...lines].join("\n");
|
||||
}
|
||||
|
||||
function countLines(s: string): number {
|
||||
if (s.length === 0) return 0;
|
||||
return (s.match(/\n/g)?.length ?? 0) + 1;
|
||||
}
|
||||
|
||||
function formatUndoResults(results: ApplyResult[]): string {
|
||||
const lines = results.map((r) => {
|
||||
const mark = r.status === "applied" ? "✓" : "✗";
|
||||
const detail = r.message ? ` (${r.message})` : "";
|
||||
return ` ${mark} ${r.path}${detail}`;
|
||||
});
|
||||
return [`▸ undo: restored ${results.length} file(s) to pre-edit state`, ...lines].join("\n");
|
||||
}
|
||||
|
||||
function describeRepair(repair: {
|
||||
scavenged: number;
|
||||
truncationsFixed: number;
|
||||
|
||||
+51
-6
@@ -54,10 +54,21 @@ export const EventRow = React.memo(function EventRow({ event }: { event: Display
|
||||
);
|
||||
}
|
||||
if (event.role === "tool") {
|
||||
// `flattenMcpResult` prefixes server-side errors with "ERROR: ".
|
||||
// Render those in red with a ✗ marker so they don't blend into
|
||||
// successful tool output (yellow) — the failure mode is what the
|
||||
// model most likely needs to act on next, and the user needs to
|
||||
// see at a glance.
|
||||
const isError = event.text.startsWith("ERROR:");
|
||||
const color = isError ? "red" : "yellow";
|
||||
const marker = isError ? "✗" : "→";
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color="yellow">{`tool<${event.toolName ?? "?"}> →`}</Text>
|
||||
<Text dimColor> {truncate(event.text, 400)}</Text>
|
||||
<Text color={color}>{`tool<${event.toolName ?? "?"}> ${marker}`}</Text>
|
||||
<Text color={isError ? "red" : undefined} dimColor={!isError}>
|
||||
{" "}
|
||||
{truncate(event.text, 400)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -114,10 +125,15 @@ function BranchBlock({ branch }: { branch: BranchSummary }) {
|
||||
}
|
||||
|
||||
function ReasoningBlock({ reasoning }: { reasoning: string }) {
|
||||
const max = 220;
|
||||
const max = 260;
|
||||
const flat = reasoning.replace(/\s+/g, " ").trim();
|
||||
// Show the TAIL of the reasoning rather than the head. R1 opens
|
||||
// with generic scaffolding ("let me look at the structure...") that
|
||||
// repeats across turns and hides the part users actually want to
|
||||
// see — the decision right before the model commits to an action.
|
||||
// Users can dump the full reasoning with `/think` if needed.
|
||||
const preview =
|
||||
flat.length <= max ? flat : `${flat.slice(0, max)}… (+${flat.length - max} chars)`;
|
||||
flat.length <= max ? flat : `… (+${flat.length - max} earlier chars) ${flat.slice(-max)}`;
|
||||
return (
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor italic>
|
||||
@@ -192,14 +208,22 @@ function StreamingAssistant({ event }: { event: DisplayEvent }) {
|
||||
|
||||
const tail = lastLine(event.text, 140);
|
||||
const reasoningTail = event.reasoning ? lastLine(event.reasoning, 120) : "";
|
||||
// R1 ("deepseek-reasoner") generates reasoning_content first, then
|
||||
// content. While reasoning is streaming but content is still empty,
|
||||
// we were showing "(waiting for first token…)" — which looked like a
|
||||
// hang. The data is flowing, it's just landing in the thinking
|
||||
// channel. Reflect that honestly.
|
||||
const reasoningOnly = !event.text && !!event.reasoning;
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text bold color="green">
|
||||
assistant{" "}
|
||||
</Text>
|
||||
<Pulse />
|
||||
<Text dimColor>
|
||||
(streaming · {event.text.length}
|
||||
{" "}
|
||||
({reasoningOnly ? "reasoning" : "streaming"} · {event.text.length}
|
||||
{event.reasoning ? ` + think ${event.reasoning.length}` : ""} chars){" "}
|
||||
</Text>
|
||||
<Elapsed />
|
||||
@@ -211,15 +235,36 @@ function StreamingAssistant({ event }: { event: DisplayEvent }) {
|
||||
) : null}
|
||||
{tail ? (
|
||||
<Text dimColor>▸ {tail}</Text>
|
||||
) : reasoningOnly ? (
|
||||
<Text color="yellow" dimColor>
|
||||
{
|
||||
" R1 is thinking before it speaks — body text starts when reasoning completes (typically 20-90s)."
|
||||
}
|
||||
</Text>
|
||||
) : (
|
||||
<Text dimColor italic>
|
||||
{" (waiting for first token…)"}
|
||||
{" (waiting for first byte — connection is open)"}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Blinking indicator so the user can tell the stream is alive even
|
||||
* when the reasoner hasn't produced body text yet. Ticks every 500 ms
|
||||
* regardless of content flow — it's a heartbeat, not a progress bar.
|
||||
*/
|
||||
function Pulse() {
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), 500);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
return <Text color="cyan">{frames[tick % frames.length]}</Text>;
|
||||
}
|
||||
|
||||
function lastLine(s: string, maxChars: number): string {
|
||||
const flat = s.replace(/\s+/g, " ").trim();
|
||||
if (!flat) return "";
|
||||
|
||||
+91
-19
@@ -1,6 +1,6 @@
|
||||
import { Box, Text } from "ink";
|
||||
import TextInput from "ink-text-input";
|
||||
import React from "react";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { type MultilineKey, processMultilineKey } from "./multiline-keys.js";
|
||||
|
||||
export interface PromptInputProps {
|
||||
value: string;
|
||||
@@ -11,10 +11,19 @@ export interface PromptInputProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep `<TextInput>` mounted at all times and use its `focus` prop to gate
|
||||
* input. Conditionally rendering it (mount / unmount between turns) loses
|
||||
* the stdin raw-mode claim on some terminals, which silently drops
|
||||
* keystrokes after the first turn finishes.
|
||||
* Input box with multi-line composition support. Behaves like the
|
||||
* old single-line prompt by default, but:
|
||||
* - Ctrl+J inserts a newline (universal across terminals).
|
||||
* - Shift+Enter inserts a newline (only on terminals that report
|
||||
* the modifier via CSI-u — many don't).
|
||||
* - `\<Enter>` at end of line acts as bash-style continuation —
|
||||
* always works, even on terminals without modifier reporting.
|
||||
* - Pasted multi-line text lands as-is instead of submitting on
|
||||
* the first '\r'.
|
||||
*
|
||||
* No mid-string insertion cursor — edits happen at the end, like
|
||||
* a readline prompt in raw mode. Simple, predictable, enough for
|
||||
* 95% of prompts and any code paste.
|
||||
*/
|
||||
export function PromptInput({
|
||||
value,
|
||||
@@ -23,21 +32,84 @@ export function PromptInput({
|
||||
disabled,
|
||||
placeholder,
|
||||
}: PromptInputProps) {
|
||||
const [showCursor, setShowCursor] = useState(true);
|
||||
|
||||
// Blink the end-of-input cursor so it's visibly alive even when
|
||||
// the user hasn't typed for a while. 500 ms matches a typical
|
||||
// terminal cursor blink.
|
||||
useEffect(() => {
|
||||
if (disabled) {
|
||||
setShowCursor(false);
|
||||
return;
|
||||
}
|
||||
setShowCursor(true);
|
||||
const id = setInterval(() => setShowCursor((s) => !s), 500);
|
||||
return () => clearInterval(id);
|
||||
}, [disabled]);
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
const keyEvent: MultilineKey = {
|
||||
input,
|
||||
return: key.return,
|
||||
shift: key.shift,
|
||||
ctrl: key.ctrl,
|
||||
meta: key.meta,
|
||||
backspace: key.backspace,
|
||||
delete: key.delete,
|
||||
tab: key.tab,
|
||||
upArrow: key.upArrow,
|
||||
downArrow: key.downArrow,
|
||||
leftArrow: key.leftArrow,
|
||||
rightArrow: key.rightArrow,
|
||||
escape: key.escape,
|
||||
pageUp: key.pageUp,
|
||||
pageDown: key.pageDown,
|
||||
};
|
||||
const action = processMultilineKey(value, keyEvent);
|
||||
if (action.next !== null) onChange(action.next);
|
||||
if (action.submit) onSubmit(action.submitValue ?? value);
|
||||
},
|
||||
{ isActive: !disabled },
|
||||
);
|
||||
|
||||
const effectivePlaceholder = disabled
|
||||
? (placeholder ?? "…waiting for response…")
|
||||
: (placeholder ?? "type a message, or /command");
|
||||
: (placeholder ?? "type a message, or /command · Ctrl+J for newline");
|
||||
|
||||
const lines = value.length > 0 ? value.split("\n") : [""];
|
||||
const borderColor = disabled ? "gray" : "cyan";
|
||||
|
||||
return (
|
||||
<Box borderStyle="round" borderColor={disabled ? "gray" : "cyan"} paddingX={1}>
|
||||
<Text bold color={disabled ? "gray" : "cyan"}>
|
||||
you ›{" "}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onSubmit={onSubmit}
|
||||
focus={!disabled}
|
||||
placeholder={effectivePlaceholder}
|
||||
/>
|
||||
<Box borderStyle="round" borderColor={borderColor} paddingX={1} flexDirection="column">
|
||||
{lines.map((line, i) => {
|
||||
const isLast = i === lines.length - 1;
|
||||
const isFirst = i === 0;
|
||||
const showPlaceholder = isFirst && value.length === 0;
|
||||
// Line content never survives a re-keying here: the value buffer
|
||||
// fully re-renders on every keystroke, so the array index IS the
|
||||
// stable identity. Biome's rule targets list reordering, which
|
||||
// doesn't apply to an append-only splits-by-newline view.
|
||||
return (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: stable by construction — lines are derived from `value.split("\n")` and never reordered
|
||||
<Box key={i}>
|
||||
{isFirst ? (
|
||||
<Text bold color={borderColor}>
|
||||
you ›{" "}
|
||||
</Text>
|
||||
) : (
|
||||
<Text dimColor>{" "}</Text>
|
||||
)}
|
||||
{showPlaceholder ? <Text dimColor>{effectivePlaceholder}</Text> : <Text>{line}</Text>}
|
||||
{isLast && !disabled && !showPlaceholder ? (
|
||||
<Text color={borderColor}>{showCursor ? "▌" : " "}</Text>
|
||||
) : null}
|
||||
{isLast && !disabled && showPlaceholder ? (
|
||||
<Text color={borderColor}>{showCursor ? "▌" : " "}</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig.jsx = "react" needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import type { SlashCommandSpec } from "./slash.js";
|
||||
|
||||
export interface SlashSuggestionsProps {
|
||||
/**
|
||||
* Current matching suggestions, computed by the parent. `null` means
|
||||
* "not in slash-prefix mode" — render nothing. Empty array means "in
|
||||
* slash mode but no matches" — render the "no matches" hint.
|
||||
*/
|
||||
matches: SlashCommandSpec[] | null;
|
||||
/** Index (within `matches`) of the currently highlighted row. */
|
||||
selectedIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating slash-command panel. Rendered below the input box when
|
||||
* the user is typing a `/…` prefix. Navigation state lives in the
|
||||
* parent (App.tsx owns `slashSelected`) so ↑/↓/Tab/Enter stay
|
||||
* consistent with the useInput handler. This component is pure
|
||||
* display: `matches` and `selectedIndex` come in, rows go out.
|
||||
*/
|
||||
export function SlashSuggestions({
|
||||
matches,
|
||||
selectedIndex,
|
||||
}: SlashSuggestionsProps): React.ReactElement | null {
|
||||
if (matches === null) return null;
|
||||
if (matches.length === 0) {
|
||||
return (
|
||||
<Box paddingX={1}>
|
||||
<Text color="yellow">no slash command matches that prefix</Text>
|
||||
<Text dimColor> — Backspace to edit, or /help for the full list</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
// Limit rows so the suggestion list never dwarfs the rest of the
|
||||
// UI. Keep the currently-selected row in view by sliding the
|
||||
// window when the selection is near either edge.
|
||||
const MAX = 8;
|
||||
const total = matches.length;
|
||||
const windowStart =
|
||||
total <= MAX ? 0 : Math.max(0, Math.min(selectedIndex - Math.floor(MAX / 2), total - MAX));
|
||||
const shown = matches.slice(windowStart, windowStart + MAX);
|
||||
const hiddenAbove = windowStart;
|
||||
const hiddenBelow = total - windowStart - shown.length;
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{hiddenAbove > 0 ? <Text dimColor> ↑ {hiddenAbove} more above</Text> : null}
|
||||
{shown.map((spec, i) => (
|
||||
<SuggestionRow key={spec.cmd} spec={spec} isSelected={windowStart + i === selectedIndex} />
|
||||
))}
|
||||
{hiddenBelow > 0 ? <Text dimColor> ↓ {hiddenBelow} more below</Text> : null}
|
||||
<Text dimColor> ↑/↓ navigate · Tab or Enter to pick</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SuggestionRow({ spec, isSelected }: { spec: SlashCommandSpec; isSelected: boolean }) {
|
||||
const marker = isSelected ? "▸" : " ";
|
||||
const name = `/${spec.cmd}`;
|
||||
const argsSuffix = spec.argsHint ? ` ${spec.argsHint}` : "";
|
||||
// Selected row gets full cyan coloring so it pops out against the
|
||||
// dimmed others — mirrors the style GitHub Issues / Discord use
|
||||
// for focused dropdown rows.
|
||||
if (isSelected) {
|
||||
return (
|
||||
<Box>
|
||||
<Text bold color="cyan">
|
||||
{marker} {name.padEnd(12)}
|
||||
{argsSuffix.padEnd(16)}
|
||||
</Text>
|
||||
<Text color="cyan"> {spec.summary}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
{marker} {name.padEnd(12)}
|
||||
{argsSuffix.padEnd(16)} {spec.summary}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+103
-3
@@ -206,10 +206,22 @@ interface CodeBlock {
|
||||
interface HrBlock {
|
||||
kind: "hr";
|
||||
}
|
||||
// First-class Aider-style SEARCH/REPLACE block. We detect these at
|
||||
// parse time instead of routing them through the paragraph / inline
|
||||
// markdown path because the inline parser would otherwise eat `**`
|
||||
// inside JSDoc `/** ... *\/` comments and `para.join(" ")` would
|
||||
// collapse the block's newlines. Rendered as a diff so the user can
|
||||
// actually read what's about to change.
|
||||
interface EditBlockView {
|
||||
kind: "edit-block";
|
||||
filename: string;
|
||||
search: string;
|
||||
replace: string;
|
||||
}
|
||||
|
||||
type Block = ParagraphBlock | HeadingBlock | BulletBlock | CodeBlock | HrBlock;
|
||||
type Block = ParagraphBlock | HeadingBlock | BulletBlock | CodeBlock | HrBlock | EditBlockView;
|
||||
|
||||
function parseBlocks(raw: string): Block[] {
|
||||
export function parseBlocks(raw: string): Block[] {
|
||||
const lines = raw.split(/\r?\n/);
|
||||
const out: Block[] = [];
|
||||
let para: string[] = [];
|
||||
@@ -231,9 +243,50 @@ function parseBlocks(raw: string): Block[] {
|
||||
}
|
||||
};
|
||||
|
||||
for (const rawLine of lines) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const rawLine = lines[i]!;
|
||||
const line = rawLine.replace(/\s+$/g, "");
|
||||
|
||||
// Detect Aider-style SEARCH/REPLACE block. Matches the preceding
|
||||
// non-blank line as the filename, then `<<<<<<< SEARCH`, content,
|
||||
// `=======`, content, `>>>>>>> REPLACE`. We don't do markdown
|
||||
// inside — neither the paragraph nor inline parsers should touch
|
||||
// this content.
|
||||
if (!inCode && /^<{7} SEARCH\s*$/.test(line)) {
|
||||
// Filename is the previous non-blank line we just pushed to para.
|
||||
// Pull it back out; if there isn't one, treat as literal text.
|
||||
const filename = para.pop()?.trim();
|
||||
if (filename) {
|
||||
flushPara();
|
||||
flushList();
|
||||
let j = i + 1;
|
||||
const searchLines: string[] = [];
|
||||
while (j < lines.length && !/^={7}\s*$/.test(lines[j]!)) {
|
||||
searchLines.push(lines[j]!);
|
||||
j++;
|
||||
}
|
||||
const replaceLines: string[] = [];
|
||||
let k = j + 1;
|
||||
while (k < lines.length && !/^>{7} REPLACE\s*$/.test(lines[k]!)) {
|
||||
replaceLines.push(lines[k]!);
|
||||
k++;
|
||||
}
|
||||
if (j < lines.length && k < lines.length) {
|
||||
out.push({
|
||||
kind: "edit-block",
|
||||
filename,
|
||||
search: searchLines.join("\n"),
|
||||
replace: replaceLines.join("\n"),
|
||||
});
|
||||
i = k;
|
||||
continue;
|
||||
}
|
||||
// Malformed — no separator or no close. Fall through: put
|
||||
// the filename back in the paragraph so we don't lose it.
|
||||
para.push(filename);
|
||||
}
|
||||
}
|
||||
|
||||
const fence = line.match(/^```(\w*)/);
|
||||
if (fence) {
|
||||
if (inCode) {
|
||||
@@ -336,11 +389,58 @@ function BlockView({ block }: { block: Block }) {
|
||||
<Text color="yellow">{block.text}</Text>
|
||||
</Box>
|
||||
);
|
||||
case "edit-block":
|
||||
return <EditBlockRow block={block} />;
|
||||
case "hr":
|
||||
return <Text dimColor>{"────────────────────────"}</Text>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SEARCH/REPLACE rendered as a minimal diff: filename on top, red
|
||||
* `-` lines, a gutter, green `+` lines. No inner markdown / inline
|
||||
* parsing — content is shown verbatim so JSDoc `/**` and `*` won't
|
||||
* be eaten by bold/italic regex.
|
||||
*
|
||||
* A truly empty SEARCH means "new file" and we label the filename
|
||||
* accordingly instead of rendering an empty red half.
|
||||
*/
|
||||
function EditBlockRow({ block }: { block: EditBlockView }) {
|
||||
const isNewFile = block.search.length === 0;
|
||||
const searchLines = block.search.split("\n");
|
||||
const replaceLines = block.replace.split("\n");
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Box>
|
||||
<Text bold color="cyan">
|
||||
{block.filename}
|
||||
</Text>
|
||||
{isNewFile ? (
|
||||
<Text color="green" bold>
|
||||
{" (new file)"}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
{isNewFile ? null : (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{searchLines.map((line, i) => (
|
||||
<Text key={`s-${i}-${line.length}`} color="red">
|
||||
{`- ${line}`}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<Box flexDirection="column" marginTop={isNewFile ? 1 : 0}>
|
||||
{replaceLines.map((line, i) => (
|
||||
<Text key={`r-${i}-${line.length}`} color="green">
|
||||
{`+ ${line}`}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function Markdown({ text }: { text: string }) {
|
||||
const cleaned = stripMath(text);
|
||||
const blocks = React.useMemo(() => parseBlocks(cleaned), [cleaned]);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Pure keystroke → action reducer for MultilineInput.
|
||||
*
|
||||
* Kept separate from the React component so the keyboard
|
||||
* semantics are easy to unit-test. MultilineInput is otherwise
|
||||
* a thin Ink wrapper that threads its `useInput` callback
|
||||
* through this function and applies the result.
|
||||
*
|
||||
* Terminal reality check: on most terminals, pressing Shift+Enter
|
||||
* sends the exact same byte as plain Enter — the modifier is lost.
|
||||
* That's why the reliable newline insertion path is Ctrl+J (which
|
||||
* really is LF, distinct from CR), with Shift+Enter only working
|
||||
* on terminals that opt into CSI-u modifier encoding. The `\<Enter>`
|
||||
* bash-style continuation is the portable fallback.
|
||||
*/
|
||||
|
||||
export interface MultilineKey {
|
||||
/** Printable character(s) delivered by the key event, may be empty. */
|
||||
input: string;
|
||||
/** Modifier + named-key flags as Ink reports them. */
|
||||
return?: boolean;
|
||||
shift?: boolean;
|
||||
ctrl?: boolean;
|
||||
meta?: boolean;
|
||||
backspace?: boolean;
|
||||
delete?: boolean;
|
||||
/** Navigation/editing keys owned by the parent App (slash-nav, history). */
|
||||
tab?: boolean;
|
||||
upArrow?: boolean;
|
||||
downArrow?: boolean;
|
||||
leftArrow?: boolean;
|
||||
rightArrow?: boolean;
|
||||
escape?: boolean;
|
||||
pageUp?: boolean;
|
||||
pageDown?: boolean;
|
||||
}
|
||||
|
||||
export interface MultilineAction {
|
||||
/** New value to set on the text buffer. `null` = unchanged. */
|
||||
next: string | null;
|
||||
/** When `true`, the caller should fire `onSubmit(next ?? value)`. */
|
||||
submit: boolean;
|
||||
/** When submitting, which value to submit (after any final trims). */
|
||||
submitValue?: string;
|
||||
}
|
||||
|
||||
const BACKSLASH_SUFFIX = /\\$/;
|
||||
|
||||
/**
|
||||
* Decide what to do with a single keystroke against the current buffer.
|
||||
*
|
||||
* Decisions:
|
||||
* Ctrl+J → insert '\n' (terminal-universal newline)
|
||||
* Shift+Return → insert '\n' (works where CSI-u is on)
|
||||
* Enter with "\" end → replace trailing '\' with '\n' (bash continuation)
|
||||
* Enter → submit the current buffer
|
||||
* Backspace/Delete → drop one char from the end (cursor-at-end model)
|
||||
* Printable char(s) → append at the end
|
||||
*
|
||||
* We don't model an insertion cursor inside the string: always-at-end
|
||||
* keeps the reducer tiny and matches how readline-in-raw-mode feels. The
|
||||
* user can still edit by backspacing — for deep edits, exit and compose
|
||||
* in $EDITOR.
|
||||
*/
|
||||
export function processMultilineKey(value: string, key: MultilineKey): MultilineAction {
|
||||
// Parent App owns these keys: Tab for slash auto-complete, arrows
|
||||
// for slash-nav/history, Esc for abort. Bowing out here keeps the
|
||||
// text buffer from eating a stray "\t" or arrow escape sequence
|
||||
// when both the parent and child useInput fire on the same event.
|
||||
if (
|
||||
key.tab ||
|
||||
key.upArrow ||
|
||||
key.downArrow ||
|
||||
key.leftArrow ||
|
||||
key.rightArrow ||
|
||||
key.escape ||
|
||||
key.pageUp ||
|
||||
key.pageDown
|
||||
) {
|
||||
return { next: null, submit: false };
|
||||
}
|
||||
|
||||
// Reliable newline: Ctrl+J arrives as literal '\n' in key.input on
|
||||
// every terminal we've checked. We also accept key.ctrl && input=='j'
|
||||
// as a belt-and-braces match against terminals that normalize the
|
||||
// control-char into its ASCII letter form.
|
||||
if (key.input === "\n" || (key.ctrl && key.input === "j")) {
|
||||
return { next: `${value}\n`, submit: false };
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
// Shift+Return → newline (when terminal reports the modifier).
|
||||
if (key.shift) {
|
||||
return { next: `${value}\n`, submit: false };
|
||||
}
|
||||
// Bash-style line continuation: trailing '\' + Enter.
|
||||
if (BACKSLASH_SUFFIX.test(value)) {
|
||||
return { next: `${value.slice(0, -1)}\n`, submit: false };
|
||||
}
|
||||
// Plain Enter → submit.
|
||||
return { next: null, submit: true, submitValue: value };
|
||||
}
|
||||
|
||||
if (key.backspace || key.delete) {
|
||||
if (value.length === 0) return { next: null, submit: false };
|
||||
return { next: value.slice(0, -1), submit: false };
|
||||
}
|
||||
|
||||
// Ignore bare modifier keys (ctrl/meta without accompanying printable).
|
||||
if ((key.ctrl || key.meta) && key.input.length === 0) {
|
||||
return { next: null, submit: false };
|
||||
}
|
||||
|
||||
// Printable input (may be a multi-char paste burst — accept as-is;
|
||||
// pasted newlines become part of the buffer rather than triggering
|
||||
// submit on the first line).
|
||||
if (key.input.length > 0 && !key.ctrl && !key.meta) {
|
||||
return { next: value + key.input, submit: false };
|
||||
}
|
||||
|
||||
return { next: null, submit: false };
|
||||
}
|
||||
+402
-8
@@ -1,5 +1,8 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import type { CacheFirstLoop } from "../../loop.js";
|
||||
import type { InspectionReport } from "../../mcp/inspect.js";
|
||||
import { deleteSession, listSessions } from "../../session.js";
|
||||
import { DEEPSEEK_CONTEXT_TOKENS, DEFAULT_CONTEXT_TOKENS } from "../../telemetry.js";
|
||||
|
||||
export interface SlashResult {
|
||||
/** Text to display back to the user as a system/info line. */
|
||||
@@ -10,6 +13,13 @@ export interface SlashResult {
|
||||
clear?: boolean;
|
||||
/** Unknown command — display usage hint. */
|
||||
unknown?: boolean;
|
||||
/**
|
||||
* Re-submit this text as a user message after displaying `info`.
|
||||
* Used by `/retry` — the slash command truncates the log, then
|
||||
* asks the TUI to push the original text back through the normal
|
||||
* submit flow so a fresh turn runs.
|
||||
*/
|
||||
resubmit?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,6 +35,124 @@ export interface SlashContext {
|
||||
* omitted → no MCP servers attached.
|
||||
*/
|
||||
mcpSpecs?: string[];
|
||||
/**
|
||||
* Callback for `/undo` — provided by the TUI when it's running in
|
||||
* code mode. Returns a human-readable report of what was restored.
|
||||
* Absent outside code mode → `/undo` replies "not available here".
|
||||
*/
|
||||
codeUndo?: () => string;
|
||||
/**
|
||||
* Callback for `/apply` — commits pending edit blocks to disk. Returns
|
||||
* a report of what landed. Absent → `/apply` replies "nothing pending"
|
||||
* or "not available outside code mode".
|
||||
*/
|
||||
codeApply?: () => string;
|
||||
/**
|
||||
* Callback for `/discard` — drops the pending edit blocks without
|
||||
* touching disk.
|
||||
*/
|
||||
codeDiscard?: () => string;
|
||||
/**
|
||||
* Root directory passed by `reasonix code`. Enables `/commit`, which
|
||||
* runs `git add -A && git commit` in this directory. Missing → `/commit`
|
||||
* replies "only available in code mode".
|
||||
*/
|
||||
codeRoot?: string;
|
||||
/**
|
||||
* How many edit blocks are currently pending `/apply` or `/discard`.
|
||||
* Surfaced by `/status`. TUI populates it live from its pending ref;
|
||||
* omitted → treat as 0 (chat-only session).
|
||||
*/
|
||||
pendingEditCount?: number;
|
||||
/**
|
||||
* Callback returning every tool result seen this session in
|
||||
* chronological order (oldest first). Powers `/tool [N]` for
|
||||
* inspecting the full untruncated output that `EventLog` clips at
|
||||
* 400 chars for display. Absent → `/tool` replies "not available".
|
||||
*/
|
||||
toolHistory?: () => Array<{ toolName: string; text: string }>;
|
||||
/**
|
||||
* Pre-captured inspection reports for each connected MCP server.
|
||||
* Populated once at chat startup (chat.tsx) so `/mcp` can render
|
||||
* tools + resources + prompts synchronously without needing async
|
||||
* handler support.
|
||||
*/
|
||||
mcpServers?: McpServerSummary[];
|
||||
}
|
||||
|
||||
export interface McpServerSummary {
|
||||
/** Short label shown in the `/mcp` output (server namespace or "anon"). */
|
||||
label: string;
|
||||
/** Original --mcp spec string. */
|
||||
spec: string;
|
||||
/** Count of tools bridged into the Reasonix registry from this server. */
|
||||
toolCount: number;
|
||||
/** Full inspection snapshot — used for the resources + prompts sections. */
|
||||
report: InspectionReport;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slash command registry. Drives `/help`, the on-type suggestion
|
||||
* popup (`SlashSuggestions`), and auto-complete. Kept as data rather
|
||||
* than derived from the `handleSlash` switch so summaries can be
|
||||
* user-facing rather than code comments.
|
||||
*
|
||||
* `contextual` gates commands that only make sense in certain modes:
|
||||
* - `"code"` — only show when the TUI is running `reasonix code`
|
||||
* - absent → always shown
|
||||
*/
|
||||
export interface SlashCommandSpec {
|
||||
cmd: string;
|
||||
summary: string;
|
||||
contextual?: "code";
|
||||
/** If the command takes args, hint text shown after the name. */
|
||||
argsHint?: string;
|
||||
}
|
||||
|
||||
export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
|
||||
{ cmd: "help", summary: "show the full command reference" },
|
||||
{ cmd: "status", summary: "current model, flags, context, session" },
|
||||
{
|
||||
cmd: "preset",
|
||||
argsHint: "<fast|smart|max>",
|
||||
summary: "one-tap model + harvest + branch bundle",
|
||||
},
|
||||
{ cmd: "model", argsHint: "<id>", summary: "switch DeepSeek model id" },
|
||||
{ cmd: "harvest", argsHint: "[on|off]", summary: "toggle Pillar-2 plan-state extraction" },
|
||||
{ cmd: "branch", argsHint: "<N|off>", summary: "run N parallel samples per turn (N>=2)" },
|
||||
{ cmd: "mcp", summary: "list MCP servers + tools attached to this session" },
|
||||
{ cmd: "tool", argsHint: "[N]", summary: "dump full output of the Nth tool call (1=latest)" },
|
||||
{ cmd: "think", summary: "dump the last turn's full R1 reasoning (reasoner only)" },
|
||||
{ cmd: "retry", summary: "truncate & resend your last message (fresh sample)" },
|
||||
{ cmd: "compact", argsHint: "[cap]", summary: "shrink oversized tool results in the log" },
|
||||
{ cmd: "sessions", summary: "list saved sessions (current marked with ▸)" },
|
||||
{ cmd: "forget", summary: "delete the current session from disk" },
|
||||
{ cmd: "setup", summary: "reminds you to exit and run `reasonix setup`" },
|
||||
{ cmd: "clear", summary: "clear the visible scrollback (log + session kept)" },
|
||||
{ cmd: "exit", summary: "quit the TUI" },
|
||||
// Code-mode only
|
||||
{ cmd: "apply", summary: "commit pending edit blocks to disk", contextual: "code" },
|
||||
{ cmd: "discard", summary: "drop pending edit blocks without writing", contextual: "code" },
|
||||
{ cmd: "undo", summary: "roll back the last applied edit batch", contextual: "code" },
|
||||
{
|
||||
cmd: "commit",
|
||||
argsHint: '"msg"',
|
||||
summary: "git add -A && git commit -m ...",
|
||||
contextual: "code",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Filter the registry by a prefix string (without the leading `/`).
|
||||
* Empty prefix returns the full non-contextual list (plus code-mode
|
||||
* entries when `codeMode` is true). Case-insensitive.
|
||||
*/
|
||||
export function suggestSlashCommands(prefix: string, codeMode = false): SlashCommandSpec[] {
|
||||
const p = prefix.toLowerCase();
|
||||
return SLASH_COMMANDS.filter((c) => {
|
||||
if (c.contextual === "code" && !codeMode) return false;
|
||||
return c.cmd.startsWith(p);
|
||||
});
|
||||
}
|
||||
|
||||
export function parseSlash(text: string): { cmd: string; args: string[] } | null {
|
||||
@@ -63,6 +191,13 @@ export function handleSlash(
|
||||
" /mcp list MCP servers + tools attached to this session",
|
||||
" /setup (exit + reconfigure) → run `reasonix setup`",
|
||||
" /compact [cap] shrink large tool results in history (default 4k/result)",
|
||||
" /think dump the most recent turn's full R1 reasoning (reasoner only)",
|
||||
" /tool [N] list tool calls (or dump full output of #N, 1=most recent)",
|
||||
" /retry truncate & resend your last message (fresh sample from the model)",
|
||||
" /apply (code mode) commit the pending edit blocks to disk",
|
||||
" /discard (code mode) drop pending edits without writing",
|
||||
" /undo (code mode) roll back the last applied edit batch",
|
||||
' /commit "msg" (code mode) git add -A && git commit -m "msg"',
|
||||
" /sessions list saved sessions (current is marked with ▸)",
|
||||
" /forget delete the current session from disk",
|
||||
" /clear clear displayed history (log + session kept)",
|
||||
@@ -80,15 +215,39 @@ export function handleSlash(
|
||||
};
|
||||
|
||||
case "mcp": {
|
||||
const servers = ctx.mcpServers ?? [];
|
||||
const specs = ctx.mcpSpecs ?? [];
|
||||
const toolSpecs = loop.prefix.toolSpecs ?? [];
|
||||
if (specs.length === 0 && toolSpecs.length === 0) {
|
||||
if (servers.length === 0 && specs.length === 0 && toolSpecs.length === 0) {
|
||||
return {
|
||||
info:
|
||||
"no MCP servers attached. Run `reasonix setup` to pick some, " +
|
||||
'or launch with --mcp "<spec>". `reasonix mcp list` shows the catalog.',
|
||||
};
|
||||
}
|
||||
// Rich path — we have full inspection reports, so show each
|
||||
// server with its tools / resources / prompts grouped together.
|
||||
if (servers.length > 0) {
|
||||
const lines: string[] = [];
|
||||
for (const s of servers) {
|
||||
const { report } = s;
|
||||
const serverName = report.serverInfo.name || "(unknown)";
|
||||
const serverVer = report.serverInfo.version ? ` v${report.serverInfo.version}` : "";
|
||||
lines.push(`[${s.label}] ${serverName}${serverVer} — ${s.spec}`);
|
||||
lines.push(` tools ${s.toolCount}`);
|
||||
appendSection(lines, "resources", report.resources);
|
||||
appendSection(lines, "prompts ", report.prompts);
|
||||
lines.push("");
|
||||
}
|
||||
lines.push(
|
||||
"Chat mode consumes tools today; resources+prompts are surfaced here for awareness.",
|
||||
);
|
||||
lines.push(
|
||||
"Full catalog: `reasonix mcp list` · deeper diagnosis: `reasonix mcp inspect <spec>`.",
|
||||
);
|
||||
return { info: lines.join("\n") };
|
||||
}
|
||||
// Fallback — older path when the TUI hasn't populated `mcpServers`.
|
||||
const lines: string[] = [];
|
||||
if (specs.length > 0) {
|
||||
lines.push(`MCP servers (${specs.length}):`);
|
||||
@@ -111,6 +270,117 @@ export function handleSlash(
|
||||
"`reasonix setup`. Changes take effect on next launch.",
|
||||
};
|
||||
|
||||
case "retry": {
|
||||
const prev = loop.retryLastUser();
|
||||
if (!prev) {
|
||||
return {
|
||||
info: "nothing to retry — no prior user message in this session's log.",
|
||||
};
|
||||
}
|
||||
const preview = prev.length > 80 ? `${prev.slice(0, 80)}…` : prev;
|
||||
return {
|
||||
info: `▸ retrying: "${preview}"`,
|
||||
resubmit: prev,
|
||||
};
|
||||
}
|
||||
|
||||
case "think":
|
||||
case "reasoning": {
|
||||
const raw = loop.scratch.reasoning;
|
||||
if (!raw || !raw.trim()) {
|
||||
return {
|
||||
info:
|
||||
"no reasoning cached. `/think` shows the full R1 thought for the most recent turn — " +
|
||||
"only `deepseek-reasoner` produces it, and only once the turn completes.",
|
||||
};
|
||||
}
|
||||
return { info: `↳ full thinking (${raw.length} chars):\n\n${raw.trim()}` };
|
||||
}
|
||||
|
||||
case "tool": {
|
||||
// EventLog truncates tool results at 400 chars for display. When
|
||||
// the user wants to check what the model actually read (e.g. to
|
||||
// verify it isn't hallucinating a file's contents), they need
|
||||
// the full text. `/tool` is the escape hatch.
|
||||
const history = ctx.toolHistory?.() ?? [];
|
||||
if (history.length === 0) {
|
||||
return {
|
||||
info:
|
||||
"no tool calls yet in this session. `/tool` lists them once the model has actually " +
|
||||
"used a tool; `/tool N` dumps the full (untruncated) output of the Nth-most-recent.",
|
||||
};
|
||||
}
|
||||
const raw = (args[0] ?? "").toLowerCase();
|
||||
if (raw === "" || raw === "list" || raw === "ls") {
|
||||
return { info: formatToolList(history) };
|
||||
}
|
||||
const n = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(n) || n < 1) {
|
||||
return {
|
||||
info: "usage: /tool [N] (no arg → list; N=1 → most recent result in full, N=2 → previous, …)",
|
||||
};
|
||||
}
|
||||
if (n > history.length) {
|
||||
return {
|
||||
info: `only ${history.length} tool call(s) in history — asked for #${n}. Try /tool with no arg to see the list.`,
|
||||
};
|
||||
}
|
||||
const entry = history[history.length - n];
|
||||
if (!entry) {
|
||||
return { info: `could not read tool call #${n}` };
|
||||
}
|
||||
return {
|
||||
info: `↳ tool<${entry.toolName}> #${n} (${entry.text.length} chars):\n\n${entry.text}`,
|
||||
};
|
||||
}
|
||||
|
||||
case "undo": {
|
||||
if (!ctx.codeUndo) {
|
||||
return {
|
||||
info: "/undo is only available inside `reasonix code` — chat mode doesn't apply edits.",
|
||||
};
|
||||
}
|
||||
return { info: ctx.codeUndo() };
|
||||
}
|
||||
|
||||
case "apply": {
|
||||
if (!ctx.codeApply) {
|
||||
return {
|
||||
info: "/apply is only available inside `reasonix code` (nothing to apply here).",
|
||||
};
|
||||
}
|
||||
return { info: ctx.codeApply() };
|
||||
}
|
||||
|
||||
case "discard": {
|
||||
if (!ctx.codeDiscard) {
|
||||
return {
|
||||
info: "/discard is only available inside `reasonix code`.",
|
||||
};
|
||||
}
|
||||
return { info: ctx.codeDiscard() };
|
||||
}
|
||||
|
||||
case "commit": {
|
||||
if (!ctx.codeRoot) {
|
||||
return {
|
||||
info: "/commit is only available inside `reasonix code` (needs a rooted git repo).",
|
||||
};
|
||||
}
|
||||
// Reassemble the original argv. The parser lowercases cmd but
|
||||
// leaves args alone, and the TUI splits on whitespace which
|
||||
// mangles quoted messages — rejoin with spaces and strip a
|
||||
// surrounding pair of double quotes if the user wrote them.
|
||||
const raw = args.join(" ").trim();
|
||||
const message = stripOuterQuotes(raw);
|
||||
if (!message) {
|
||||
return {
|
||||
info: `usage: /commit "your commit message" — runs \`git add -A && git commit -m "…"\` in ${ctx.codeRoot}`,
|
||||
};
|
||||
}
|
||||
return runGitCommit(ctx.codeRoot, message);
|
||||
}
|
||||
|
||||
case "compact": {
|
||||
// Manual companion to the automatic heal-on-load. Re-applies
|
||||
// truncation with a tighter cap (4k chars per tool result) and
|
||||
@@ -166,13 +436,31 @@ export function handleSlash(
|
||||
|
||||
case "status": {
|
||||
const branchBudget = loop.branchOptions.budget ?? 1;
|
||||
return {
|
||||
info:
|
||||
`model=${loop.model} ` +
|
||||
`harvest=${loop.harvestEnabled ? "on" : "off"} ` +
|
||||
`branch=${branchBudget > 1 ? branchBudget : "off"} ` +
|
||||
`stream=${loop.stream ? "on" : "off"}`,
|
||||
};
|
||||
const ctxMax = DEEPSEEK_CONTEXT_TOKENS[loop.model] ?? DEFAULT_CONTEXT_TOKENS;
|
||||
const lastPromptTokens = loop.stats.summary().lastPromptTokens;
|
||||
const ctxPct = ctxMax > 0 ? Math.round((lastPromptTokens / ctxMax) * 100) : 0;
|
||||
const ctxLine =
|
||||
lastPromptTokens > 0
|
||||
? ` ctx ${compactNum(lastPromptTokens)}/${compactNum(ctxMax)} (${ctxPct}%)`
|
||||
: " ctx no turns yet";
|
||||
const pending = ctx.pendingEditCount ?? 0;
|
||||
const sessionLine = loop.sessionName
|
||||
? ` session "${loop.sessionName}" · ${loop.log.length} messages in log (resumed ${loop.resumedMessageCount})`
|
||||
: " session (ephemeral — no persistence)";
|
||||
const mcpCount = ctx.mcpSpecs?.length ?? 0;
|
||||
const toolCount = loop.prefix.toolSpecs.length;
|
||||
const mcpLine = ` mcp ${mcpCount} server(s), ${toolCount} tool(s) in registry`;
|
||||
const pendingLine =
|
||||
pending > 0 ? ` edits ${pending} pending (/apply to commit, /discard to drop)` : "";
|
||||
const lines = [
|
||||
` model ${loop.model}`,
|
||||
` flags harvest=${loop.harvestEnabled ? "on" : "off"} · branch=${branchBudget > 1 ? branchBudget : "off"} · stream=${loop.stream ? "on" : "off"}`,
|
||||
ctxLine,
|
||||
mcpLine,
|
||||
sessionLine,
|
||||
];
|
||||
if (pendingLine) lines.push(pendingLine);
|
||||
return { info: lines.join("\n") };
|
||||
}
|
||||
|
||||
case "model": {
|
||||
@@ -229,3 +517,109 @@ export function handleSlash(
|
||||
return { unknown: true, info: `unknown command: /${cmd} (try /help)` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a section (resources / prompts) of an MCP inspection into a
|
||||
* compact "name count items" form, collapsing when unsupported.
|
||||
* Names-only — descriptions and full metadata live in
|
||||
* `reasonix mcp inspect`, which is purpose-built for the deep view.
|
||||
*/
|
||||
function appendSection(
|
||||
lines: string[],
|
||||
label: string,
|
||||
section:
|
||||
| { supported: true; items: Array<{ name: string }> }
|
||||
| { supported: false; reason: string }
|
||||
| undefined,
|
||||
): void {
|
||||
if (!section || !section.supported) {
|
||||
lines.push(
|
||||
` ${label.trim()} ${section?.supported === false ? "(not supported)" : "(none)"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const names = section.items.map((i) => i.name);
|
||||
if (names.length === 0) {
|
||||
lines.push(` ${label.trim()} (none)`);
|
||||
return;
|
||||
}
|
||||
const head = names.slice(0, 5).join(", ");
|
||||
const more = names.length > 5 ? ` +${names.length - 5} more` : "";
|
||||
lines.push(` ${label.trim()} ${names.length} [${head}${more}]`);
|
||||
}
|
||||
|
||||
function formatToolList(history: Array<{ toolName: string; text: string }>): string {
|
||||
const total = history.length;
|
||||
const header = `Tool calls in this session (${total}, most recent first):`;
|
||||
// Show the 10 most recent. Older ones are rarely what the user
|
||||
// wants — and the help footer tells them how to reach any entry
|
||||
// by index if they do.
|
||||
const shown = Math.min(total, 10);
|
||||
const lines: string[] = [header];
|
||||
for (let i = 0; i < shown; i++) {
|
||||
const entry = history[total - 1 - i];
|
||||
if (!entry) continue;
|
||||
const idx = i + 1; // 1-based from most recent
|
||||
const flat = entry.text.replace(/\s+/g, " ").trim();
|
||||
const preview = flat.length > 80 ? `${flat.slice(0, 80)}…` : flat;
|
||||
const name = entry.toolName.length > 24 ? `${entry.toolName.slice(0, 23)}…` : entry.toolName;
|
||||
lines.push(
|
||||
` #${String(idx).padStart(2)} ${name.padEnd(24)} ${String(entry.text.length).padStart(6)} chars ${preview}`,
|
||||
);
|
||||
}
|
||||
if (total > shown) {
|
||||
lines.push(` … (${total - shown} earlier, reach with /tool N)`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("View full output: /tool N (N=1 → most recent)");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function compactNum(n: number): string {
|
||||
if (n < 1000) return String(n);
|
||||
const k = n / 1000;
|
||||
return k >= 100 ? `${Math.round(k)}k` : `${k.toFixed(1)}k`;
|
||||
}
|
||||
|
||||
function stripOuterQuotes(s: string): string {
|
||||
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
|
||||
return s.slice(1, -1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `git add -A` then `git commit -m <message>` in `rootDir`. Returns
|
||||
* a SlashResult with a human-scannable info line. We surface stderr on
|
||||
* failure so the user sees exactly what git complained about (bad
|
||||
* config, pre-commit hook rejection, nothing staged, etc.).
|
||||
*/
|
||||
function runGitCommit(rootDir: string, message: string): SlashResult {
|
||||
const add = spawnSync("git", ["add", "-A"], { cwd: rootDir, encoding: "utf8" });
|
||||
if (add.error || add.status !== 0) {
|
||||
return { info: `git add failed (${add.status ?? "?"}):\n${gitTail(add)}` };
|
||||
}
|
||||
const commit = spawnSync("git", ["commit", "-m", message], {
|
||||
cwd: rootDir,
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (commit.error || commit.status !== 0) {
|
||||
return { info: `git commit failed (${commit.status ?? "?"}):\n${gitTail(commit)}` };
|
||||
}
|
||||
const firstLine = (commit.stdout || "").split(/\r?\n/)[0] ?? "";
|
||||
return { info: `▸ committed: ${message}${firstLine ? `\n ${firstLine}` : ""}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extract whatever diagnostic text is available from a spawnSync
|
||||
* result — on Windows or when cwd doesn't exist, `stderr`/`stdout` can
|
||||
* be `undefined` and the caller has only `error.message` to go on.
|
||||
*/
|
||||
function gitTail(res: ReturnType<typeof spawnSync>): string {
|
||||
const stderr = (res.stderr as string | undefined) ?? "";
|
||||
const stdout = (res.stdout as string | undefined) ?? "";
|
||||
const body = stderr.trim() || stdout.trim();
|
||||
if (body) return body;
|
||||
if (res.error) return (res.error as Error).message;
|
||||
return "(no output from git)";
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Aider-style SEARCH/REPLACE edit blocks.
|
||||
*
|
||||
* The model emits blocks in this exact shape, one or more per response:
|
||||
*
|
||||
* path/to/file.ts
|
||||
* <<<<<<< SEARCH
|
||||
* exact existing lines (whitespace-sensitive)
|
||||
* =======
|
||||
* replacement lines
|
||||
* >>>>>>> REPLACE
|
||||
*
|
||||
* We chose this over unified diffs because:
|
||||
* - Models produce it reliably — no line-number drift.
|
||||
* - It tolerates multi-edit responses without ambiguity over which
|
||||
* hunk belongs to which file.
|
||||
* - Aider has years of evidence that this format works even against
|
||||
* weaker models than DeepSeek R1, so it's a conservative pick.
|
||||
*
|
||||
* The SEARCH text must match the file byte-for-byte. Empty SEARCH is a
|
||||
* sentinel for "create new file" — the REPLACE becomes the whole file.
|
||||
* If SEARCH doesn't match we refuse the edit and surface the failure;
|
||||
* we do NOT guess or fuzzy-match. A wrong silent edit is worse than a
|
||||
* missing one — the user can re-ask with the exact current content.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
export interface EditBlock {
|
||||
/** Path as written by the model — relative to rootDir, or absolute. */
|
||||
path: string;
|
||||
/** Literal text to match in the target file. Empty → create new file. */
|
||||
search: string;
|
||||
/** Replacement text to write in place of `search`. */
|
||||
replace: string;
|
||||
/** Char offset in the source message where this block started. */
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export type ApplyStatus =
|
||||
/** Edit landed on disk. */
|
||||
| "applied"
|
||||
/** New file created (SEARCH was empty and file didn't exist). */
|
||||
| "created"
|
||||
/** File exists but SEARCH block wasn't found in its content. */
|
||||
| "not-found"
|
||||
/** File doesn't exist and SEARCH was non-empty (can't create without content). */
|
||||
| "file-missing"
|
||||
/** Path escapes rootDir — refused on safety grounds. */
|
||||
| "path-escape"
|
||||
/** fs write / read threw. */
|
||||
| "error";
|
||||
|
||||
export interface ApplyResult {
|
||||
path: string;
|
||||
status: ApplyStatus;
|
||||
/** Extra detail (e.g. error message) for logs. */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One edit block per match. The regex is anchored to the 7-char marker
|
||||
* lines because those are visually distinct and unlikely to appear in
|
||||
* normal code.
|
||||
*
|
||||
* Anchored with `^` + `m` flag so the filename has to live on its own
|
||||
* line. Keeps us from matching e.g. a JS-import string that happens to
|
||||
* contain `<<<<<<< SEARCH` in inner text.
|
||||
*/
|
||||
// `\n?` before the =======/REPLACE separators makes the body optional:
|
||||
// empty SEARCH (new-file sentinel) works without requiring a gratuitous
|
||||
// empty line, and the same holds for empty REPLACE (file-deletion
|
||||
// semantics, not yet supported but cheaply representable).
|
||||
const BLOCK_RE = /^(\S[^\n]*)\n<{7} SEARCH\n([\s\S]*?)\n?={7}\n([\s\S]*?)\n?>{7} REPLACE/gm;
|
||||
|
||||
export function parseEditBlocks(text: string): EditBlock[] {
|
||||
const out: EditBlock[] = [];
|
||||
BLOCK_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null = BLOCK_RE.exec(text);
|
||||
while (m !== null) {
|
||||
out.push({
|
||||
path: m[1]!.trim(),
|
||||
search: m[2]!,
|
||||
replace: m[3]!,
|
||||
offset: m.index,
|
||||
});
|
||||
m = BLOCK_RE.exec(text);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function applyEditBlock(block: EditBlock, rootDir: string): ApplyResult {
|
||||
const absRoot = resolve(rootDir);
|
||||
const absTarget = resolve(absRoot, block.path);
|
||||
// Refuse paths that escape rootDir. `resolve` normalizes `..`, so
|
||||
// startsWith on the normalized pair is enough.
|
||||
if (absTarget !== absRoot && !absTarget.startsWith(`${absRoot}${sep()}`)) {
|
||||
return {
|
||||
path: block.path,
|
||||
status: "path-escape",
|
||||
message: `resolved path ${absTarget} is outside rootDir ${absRoot}`,
|
||||
};
|
||||
}
|
||||
|
||||
const searchEmpty = block.search.length === 0;
|
||||
const exists = existsSync(absTarget);
|
||||
|
||||
try {
|
||||
if (!exists) {
|
||||
if (!searchEmpty) {
|
||||
return {
|
||||
path: block.path,
|
||||
status: "file-missing",
|
||||
message: "file does not exist; to create it, use an empty SEARCH block",
|
||||
};
|
||||
}
|
||||
mkdirSync(dirname(absTarget), { recursive: true });
|
||||
writeFileSync(absTarget, block.replace, "utf8");
|
||||
return { path: block.path, status: "created" };
|
||||
}
|
||||
|
||||
const content = readFileSync(absTarget, "utf8");
|
||||
if (searchEmpty) {
|
||||
return {
|
||||
path: block.path,
|
||||
status: "not-found",
|
||||
message: "empty SEARCH only creates new files — this file already exists",
|
||||
};
|
||||
}
|
||||
const idx = content.indexOf(block.search);
|
||||
if (idx === -1) {
|
||||
return {
|
||||
path: block.path,
|
||||
status: "not-found",
|
||||
message: "SEARCH text does not match the current file content exactly",
|
||||
};
|
||||
}
|
||||
// Replace only the first occurrence — if the model needs multiple
|
||||
// identical edits it should emit multiple blocks (each anchored by
|
||||
// more surrounding context). Auto-expanding to replace-all is a
|
||||
// footgun when the same string legitimately appears in several
|
||||
// unrelated places.
|
||||
const replaced = `${content.slice(0, idx)}${block.replace}${content.slice(idx + block.search.length)}`;
|
||||
writeFileSync(absTarget, replaced, "utf8");
|
||||
return { path: block.path, status: "applied" };
|
||||
} catch (err) {
|
||||
return { path: block.path, status: "error", message: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
export function applyEditBlocks(blocks: EditBlock[], rootDir: string): ApplyResult[] {
|
||||
return blocks.map((b) => applyEditBlock(b, rootDir));
|
||||
}
|
||||
|
||||
// ---------- snapshot / restore (for /undo) ----------
|
||||
|
||||
export interface EditSnapshot {
|
||||
/** Path relative to rootDir, as the block named it. */
|
||||
path: string;
|
||||
/**
|
||||
* File content before the edit batch was applied. `null` means the
|
||||
* file didn't exist yet — restoring that means deleting whatever the
|
||||
* edit created.
|
||||
*/
|
||||
prevContent: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the current state of every file an edit batch is about to
|
||||
* touch, so `/undo` can roll back if the user doesn't like the result.
|
||||
* De-duplicates by path because one batch can contain multiple blocks
|
||||
* for the same file, and we only want one "before" snapshot per file.
|
||||
*/
|
||||
export function snapshotBeforeEdits(blocks: EditBlock[], rootDir: string): EditSnapshot[] {
|
||||
const absRoot = resolve(rootDir);
|
||||
const seen = new Set<string>();
|
||||
const snapshots: EditSnapshot[] = [];
|
||||
for (const b of blocks) {
|
||||
if (seen.has(b.path)) continue;
|
||||
seen.add(b.path);
|
||||
const abs = resolve(absRoot, b.path);
|
||||
if (!existsSync(abs)) {
|
||||
snapshots.push({ path: b.path, prevContent: null });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
snapshots.push({ path: b.path, prevContent: readFileSync(abs, "utf8") });
|
||||
} catch {
|
||||
// Unreadable (permission / binary) — record null so we at least
|
||||
// don't pretend the snapshot is authoritative. The restore path
|
||||
// will treat null as "delete on undo", which is wrong in that
|
||||
// case but the file wasn't ours to begin with.
|
||||
snapshots.push({ path: b.path, prevContent: null });
|
||||
}
|
||||
}
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore files to their snapshotted state. Snapshots with
|
||||
* `prevContent === null` were created by the edit, so undo = delete.
|
||||
* Otherwise the prior content is written back, replacing whatever the
|
||||
* edit left behind.
|
||||
*/
|
||||
export function restoreSnapshots(snapshots: EditSnapshot[], rootDir: string): ApplyResult[] {
|
||||
const absRoot = resolve(rootDir);
|
||||
return snapshots.map((snap) => {
|
||||
const abs = resolve(absRoot, snap.path);
|
||||
if (abs !== absRoot && !abs.startsWith(`${absRoot}${sep()}`)) {
|
||||
return {
|
||||
path: snap.path,
|
||||
status: "path-escape",
|
||||
message: "snapshot path escapes rootDir — refusing to restore",
|
||||
};
|
||||
}
|
||||
try {
|
||||
if (snap.prevContent === null) {
|
||||
if (existsSync(abs)) unlinkSync(abs);
|
||||
return {
|
||||
path: snap.path,
|
||||
status: "applied",
|
||||
message: "removed (the edit had created it)",
|
||||
};
|
||||
}
|
||||
writeFileSync(abs, snap.prevContent, "utf8");
|
||||
return {
|
||||
path: snap.path,
|
||||
status: "applied",
|
||||
message: "restored to pre-edit content",
|
||||
};
|
||||
} catch (err) {
|
||||
return { path: snap.path, status: "error", message: (err as Error).message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Platform separator — `\` on Windows, `/` elsewhere. */
|
||||
function sep(): string {
|
||||
return process.platform === "win32" ? "\\" : "/";
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* System prompt used by `reasonix code`. Teaches the model:
|
||||
*
|
||||
* 1. It has a filesystem MCP bridge rooted at the user's CWD.
|
||||
* 2. To modify files it emits SEARCH/REPLACE blocks (not
|
||||
* `write_file` — that would whole-file rewrite and kill diff
|
||||
* reviewability).
|
||||
* 3. Read first, edit second — SEARCH must match byte-for-byte.
|
||||
* 4. Be concise. The user can read a diff faster than prose.
|
||||
*
|
||||
* Kept short on purpose. Long system prompts eat context budget that
|
||||
* the Cache-First Loop is trying to conserve. The SEARCH/REPLACE spec
|
||||
* is the one unavoidable bloat; we trim everything else.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export const CODE_SYSTEM_PROMPT = `You are Reasonix Code, a coding assistant. You have filesystem tools (read_file, write_file, list_directory, search_files, etc.) rooted at the user's working directory.
|
||||
|
||||
# When to edit vs. when to explore
|
||||
|
||||
Only propose edits when the user explicitly asks you to change, fix, add, remove, refactor, or write something. Do NOT propose edits when the user asks you to:
|
||||
- analyze, read, explore, describe, or summarize a project
|
||||
- explain how something works
|
||||
- answer a question about the code
|
||||
|
||||
In those cases, use tools to gather what you need, then reply in prose. No SEARCH/REPLACE blocks, no file changes. If you're unsure what the user wants, ask.
|
||||
|
||||
When you do propose edits, the user will review them and decide whether to \`/apply\` or \`/discard\`. Don't assume they'll accept — write as if each edit will be audited, because it will.
|
||||
|
||||
# Editing files
|
||||
|
||||
When you've been asked to change a file, output one or more SEARCH/REPLACE blocks in this exact format:
|
||||
|
||||
path/to/file.ext
|
||||
<<<<<<< SEARCH
|
||||
exact existing lines from the file, including whitespace
|
||||
=======
|
||||
the new lines
|
||||
>>>>>>> REPLACE
|
||||
|
||||
Rules:
|
||||
- Always read_file first so your SEARCH matches byte-for-byte. If it doesn't match, the edit is rejected and you'll have to retry with the exact current content.
|
||||
- One edit per block. Multiple blocks in one response are fine.
|
||||
- To create a new file, leave SEARCH empty:
|
||||
path/to/new.ts
|
||||
<<<<<<< SEARCH
|
||||
=======
|
||||
(whole file content here)
|
||||
>>>>>>> REPLACE
|
||||
- Do NOT use write_file to change existing files — the user reviews your edits as SEARCH/REPLACE. write_file is only for files you explicitly want to overwrite wholesale (rare).
|
||||
- Paths are relative to the working directory. Don't use absolute paths.
|
||||
|
||||
# Exploration
|
||||
|
||||
- Avoid listing or reading inside these common dependency / build directories unless the user explicitly asks about them: node_modules, dist, build, out, .next, .nuxt, .svelte-kit, .git, .venv, venv, __pycache__, target, coverage, .turbo, .cache. They're expensive and usually irrelevant.
|
||||
- Prefer search_files / grep over list_directory when you know roughly what you're looking for — it saves context and avoids enumerating huge trees.
|
||||
|
||||
# Style
|
||||
|
||||
- Show edits; don't narrate them in prose. "Here's the fix:" is enough.
|
||||
- One short paragraph explaining *why*, then the blocks.
|
||||
- If you need to explore first (list / grep / read), do it with tool calls before writing any prose — silence while exploring is fine.
|
||||
`;
|
||||
|
||||
/**
|
||||
* Inject the project's `.gitignore` content into the system prompt as a
|
||||
* "respect this on top of the built-in denylist" hint. We don't parse
|
||||
* the file — we hand it to the model as-is. Truncate long ones so we
|
||||
* don't eat context budget on huge generated ignore lists.
|
||||
*
|
||||
* Missing or unreadable .gitignore → returns the base prompt unchanged.
|
||||
*/
|
||||
export function codeSystemPrompt(rootDir: string): string {
|
||||
const gitignorePath = join(rootDir, ".gitignore");
|
||||
if (!existsSync(gitignorePath)) return CODE_SYSTEM_PROMPT;
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileSync(gitignorePath, "utf8");
|
||||
} catch {
|
||||
return CODE_SYSTEM_PROMPT;
|
||||
}
|
||||
const MAX = 2000;
|
||||
const truncated =
|
||||
content.length > MAX
|
||||
? `${content.slice(0, MAX)}\n… (truncated ${content.length - MAX} chars)`
|
||||
: content;
|
||||
return `${CODE_SYSTEM_PROMPT}
|
||||
|
||||
# Project .gitignore
|
||||
|
||||
The user's repo ships this .gitignore — treat every pattern as "don't traverse or edit inside these paths unless explicitly asked":
|
||||
|
||||
\`\`\`
|
||||
${truncated}
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
+40
-2
@@ -3,7 +3,12 @@
|
||||
export { DeepSeekClient, Usage } from "./client.js";
|
||||
export type { ChatResponse, StreamChunk, DeepSeekClientOptions } from "./client.js";
|
||||
|
||||
export { CacheFirstLoop, formatLoopError, healLoadedMessages } from "./loop.js";
|
||||
export {
|
||||
CacheFirstLoop,
|
||||
formatLoopError,
|
||||
healLoadedMessages,
|
||||
stripHallucinatedToolMarkup,
|
||||
} from "./loop.js";
|
||||
export type {
|
||||
CacheFirstLoopOptions,
|
||||
LoopEvent,
|
||||
@@ -101,6 +106,24 @@ export {
|
||||
export type { BridgeOptions, BridgeResult, FlattenOptions } from "./mcp/registry.js";
|
||||
export { parseMcpSpec } from "./mcp/spec.js";
|
||||
export type { McpSpec, StdioMcpSpec, SseMcpSpec } from "./mcp/spec.js";
|
||||
export { inspectMcpServer } from "./mcp/inspect.js";
|
||||
export type { InspectionReport, SectionResult } from "./mcp/inspect.js";
|
||||
|
||||
// ---------- code mode (v0.3 — `reasonix code`) ----------
|
||||
export {
|
||||
parseEditBlocks,
|
||||
applyEditBlock,
|
||||
applyEditBlocks,
|
||||
snapshotBeforeEdits,
|
||||
restoreSnapshots,
|
||||
} from "./code/edit-blocks.js";
|
||||
export type {
|
||||
EditBlock,
|
||||
ApplyResult,
|
||||
ApplyStatus,
|
||||
EditSnapshot,
|
||||
} from "./code/edit-blocks.js";
|
||||
export { CODE_SYSTEM_PROMPT, codeSystemPrompt } from "./code/prompt.js";
|
||||
export {
|
||||
MCP_PROTOCOL_VERSION,
|
||||
isJsonRpcError,
|
||||
@@ -115,6 +138,21 @@ export type {
|
||||
JsonRpcRequest,
|
||||
JsonRpcResponse,
|
||||
JsonRpcMessage,
|
||||
McpResource,
|
||||
McpResourceContents,
|
||||
McpResourceContentsText,
|
||||
McpResourceContentsBlob,
|
||||
ListResourcesResult,
|
||||
ReadResourceResult,
|
||||
McpPrompt,
|
||||
McpPromptArgument,
|
||||
McpPromptMessage,
|
||||
McpPromptResourceBlock,
|
||||
ListPromptsResult,
|
||||
GetPromptResult,
|
||||
McpProgressHandler,
|
||||
McpProgressInfo,
|
||||
ProgressNotificationParams,
|
||||
} from "./mcp/types.js";
|
||||
|
||||
export { fetchWithRetry } from "./retry.js";
|
||||
@@ -140,4 +178,4 @@ export type {
|
||||
JSONSchema,
|
||||
} from "./types.js";
|
||||
|
||||
export const VERSION = "0.3.2";
|
||||
export const VERSION = "0.4.3";
|
||||
+188
-21
@@ -10,13 +10,26 @@ import { DEFAULT_MAX_RESULT_CHARS, truncateForModel } from "./mcp/registry.js";
|
||||
import { AppendOnlyLog, type ImmutablePrefix, VolatileScratch } from "./memory.js";
|
||||
import { type RepairReport, ToolCallRepair } from "./repair/index.js";
|
||||
import { appendSessionMessage, loadSessionMessages, rewriteSession } from "./session.js";
|
||||
import { SessionStats, type TurnStats } from "./telemetry.js";
|
||||
import {
|
||||
DEEPSEEK_CONTEXT_TOKENS,
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
SessionStats,
|
||||
type TurnStats,
|
||||
} from "./telemetry.js";
|
||||
import { ToolRegistry } from "./tools.js";
|
||||
import type { ChatMessage, ToolCall } from "./types.js";
|
||||
|
||||
export type EventRole =
|
||||
| "assistant_delta"
|
||||
| "assistant_final"
|
||||
/**
|
||||
* Yielded immediately before a tool is dispatched. Lets the TUI put
|
||||
* up a "▸ tool<X> running…" spinner while the tool's Promise is
|
||||
* pending — otherwise the UI looks frozen whenever a tool call
|
||||
* takes more than a few hundred ms (a big `filesystem_edit_file`
|
||||
* is a typical trigger).
|
||||
*/
|
||||
| "tool_start"
|
||||
| "tool"
|
||||
| "done"
|
||||
| "error"
|
||||
@@ -58,6 +71,16 @@ export interface LoopEvent {
|
||||
branch?: BranchSummary;
|
||||
branchProgress?: BranchProgress;
|
||||
error?: string;
|
||||
/**
|
||||
* True on `assistant_final` events emitted by the no-tools fallback
|
||||
* when the loop hit its budget, was aborted, or tripped the
|
||||
* token-context guard. Consumers that act on assistant text (notably
|
||||
* the code-mode edit applier) MUST treat these as display-only —
|
||||
* the model is "wrapping up," not proposing new work. Applying
|
||||
* SEARCH/REPLACE blocks found in a forced summary caused the
|
||||
* "analysis became edits" bug in v0.4.1 and earlier.
|
||||
*/
|
||||
forcedSummary?: boolean;
|
||||
}
|
||||
|
||||
export interface CacheFirstLoopOptions {
|
||||
@@ -141,13 +164,16 @@ export class CacheFirstLoop {
|
||||
this.prefix = opts.prefix;
|
||||
this.tools = opts.tools ?? new ToolRegistry();
|
||||
this.model = opts.model ?? "deepseek-chat";
|
||||
// 24 is a compromise: enough for real filesystem / MCP exploration
|
||||
// (which legitimately chains read_file → list_directory → read_file)
|
||||
// without letting a confused model burn unlimited cost. When this
|
||||
// ceiling is hit the loop forces a final no-tools summary call —
|
||||
// see the post-loop branch at the bottom of step() — so the user
|
||||
// never sees a silent stop, just a "hit tool-call budget" summary.
|
||||
this.maxToolIters = opts.maxToolIters ?? 24;
|
||||
// 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
|
||||
// 80% of the model's context window, and if so divert to the
|
||||
// forced-summary path. 64 is high enough that exploration almost
|
||||
// never exhausts it before the token guard fires first — which
|
||||
// is the point: let the real constraint (context window) drive
|
||||
// the decision, keep the iter cap as a last-resort backstop for
|
||||
// the case where something spins without growing the prompt.
|
||||
this.maxToolIters = opts.maxToolIters ?? 64;
|
||||
|
||||
// Resolve branch config first (since it forces harvest on).
|
||||
if (typeof opts.branch === "number") {
|
||||
@@ -300,6 +326,44 @@ export class CacheFirstLoop {
|
||||
this._aborted = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop everything in the log after (and including) the most recent
|
||||
* user message. Used by `/retry` so the caller can re-send that
|
||||
* message with a fresh turn instead of layering another response on
|
||||
* top of the prior exchange. Returns the content of the dropped user
|
||||
* message, or `null` if there isn't one yet.
|
||||
*
|
||||
* Persists by rewriting the session file — otherwise the next
|
||||
* launch would rehydrate the old exchange and `/retry` would seem
|
||||
* to have done nothing.
|
||||
*/
|
||||
retryLastUser(): string | null {
|
||||
const entries = this.log.entries;
|
||||
let lastUserIdx = -1;
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
if (entries[i]!.role === "user") {
|
||||
lastUserIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastUserIdx < 0) return null;
|
||||
const raw = entries[lastUserIdx]!.content;
|
||||
const userText = typeof raw === "string" ? raw : "";
|
||||
// Keep everything strictly before the user message. The caller
|
||||
// will submit the text again through the normal path, which
|
||||
// re-appends the user turn on first successful API response.
|
||||
const preserved = entries.slice(0, lastUserIdx).map((m) => ({ ...m }));
|
||||
this.log.compactInPlace(preserved);
|
||||
if (this.sessionName) {
|
||||
try {
|
||||
rewriteSession(this.sessionName, preserved);
|
||||
} catch {
|
||||
/* disk-full / perms — in-memory compaction still applies */
|
||||
}
|
||||
}
|
||||
return userText;
|
||||
}
|
||||
|
||||
async *step(userInput: string): AsyncGenerator<LoopEvent> {
|
||||
this._turn++;
|
||||
this.scratch.reset();
|
||||
@@ -314,12 +378,34 @@ export class CacheFirstLoop {
|
||||
|
||||
for (let iter = 0; iter < this.maxToolIters; iter++) {
|
||||
if (this._aborted) {
|
||||
// Esc means "stop now" — not "stop and force another 30-90s
|
||||
// reasoner call to produce a summary I didn't ask for". The
|
||||
// user's mental model of cancel is immediate. We emit a
|
||||
// synthetic assistant_final (tagged forcedSummary so the
|
||||
// code-mode applier ignores it) with a short stopped
|
||||
// message, then done. The prior tool outputs are still in
|
||||
// the log if the user wants to continue — asking again
|
||||
// will hit a warm cache and be cheap.
|
||||
//
|
||||
// Budget / context-guard still call forceSummaryAfterIterLimit
|
||||
// because there the USER didn't choose to stop — we did —
|
||||
// and leaving them staring at nothing is worse than one extra
|
||||
// call.
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "warning",
|
||||
content: `aborted at iter ${iter}/${this.maxToolIters} — forcing summary from what was gathered`,
|
||||
content: `aborted at iter ${iter}/${this.maxToolIters} — stopped without producing a summary (press ↑ + Enter or /retry to resume)`,
|
||||
};
|
||||
yield* this.forceSummaryAfterIterLimit({ reason: "aborted" });
|
||||
const stoppedMsg =
|
||||
"[aborted by user (Esc) — no summary produced. Ask again or /retry when ready; prior tool output is still in the log.]";
|
||||
this.appendAndPersist({ role: "assistant", content: stoppedMsg });
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "assistant_final",
|
||||
content: stoppedMsg,
|
||||
forcedSummary: true,
|
||||
};
|
||||
yield { turn: this._turn, role: "done", content: stoppedMsg };
|
||||
return;
|
||||
}
|
||||
if (!warnedForIterBudget && iter >= warnAt) {
|
||||
@@ -508,6 +594,7 @@ export class CacheFirstLoop {
|
||||
const { calls: repairedCalls, report } = this.repair.process(
|
||||
toolCalls,
|
||||
reasoningContent || null,
|
||||
assistantContent || null,
|
||||
);
|
||||
|
||||
this.appendAndPersist(this.assistantMessage(assistantContent, repairedCalls));
|
||||
@@ -527,9 +614,42 @@ export class CacheFirstLoop {
|
||||
return;
|
||||
}
|
||||
|
||||
// Token-budget guard — the real stop condition. Iter count is a
|
||||
// proxy that misses the actual constraint: how close the prompt
|
||||
// already is to DeepSeek's 131k context. If we're over 80%, the
|
||||
// NEXT call (with the just-executed tools' results stuffed into
|
||||
// history) will be worse, and fairly soon it'll 400 with
|
||||
// "maximum context length". Divert to summary instead — same
|
||||
// path as iter-cap and Esc-abort, just triggered by a different
|
||||
// signal so the user sees *why* it happened.
|
||||
const ctxMax = DEEPSEEK_CONTEXT_TOKENS[this.model] ?? DEFAULT_CONTEXT_TOKENS;
|
||||
if (usage && usage.promptTokens / ctxMax > 0.8) {
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "warning",
|
||||
content: `context ${usage.promptTokens}/${ctxMax} (${Math.round(
|
||||
(usage.promptTokens / ctxMax) * 100,
|
||||
)}%) — more tools would overflow. Forcing summary from what was gathered.`,
|
||||
};
|
||||
yield* this.forceSummaryAfterIterLimit({ reason: "context-guard" });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const call of repairedCalls) {
|
||||
const name = call.function?.name ?? "";
|
||||
const args = call.function?.arguments ?? "{}";
|
||||
// Announce the tool BEFORE awaiting it so the TUI can render a
|
||||
// "running…" indicator. Without this, the window between
|
||||
// assistant_final and the tool-result yield is silent from the
|
||||
// UI's perspective — which makes long tool calls feel like the
|
||||
// app has hung.
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "tool_start",
|
||||
content: "",
|
||||
toolName: name,
|
||||
toolArgs: args,
|
||||
};
|
||||
const result = await this.tools.dispatch(name, args);
|
||||
this.appendAndPersist({
|
||||
role: "tool",
|
||||
@@ -556,22 +676,32 @@ export class CacheFirstLoop {
|
||||
}
|
||||
|
||||
private async *forceSummaryAfterIterLimit(
|
||||
opts: { reason: "budget" | "aborted" } = { reason: "budget" },
|
||||
opts: { reason: "budget" | "aborted" | "context-guard" } = { reason: "budget" },
|
||||
): AsyncGenerator<LoopEvent> {
|
||||
try {
|
||||
const messages = this.buildMessages(null);
|
||||
// Passing `tools: undefined` was supposed to force a text
|
||||
// response, but R1 can still hallucinate tool-call markup
|
||||
// (e.g. DSML `<|DSML|function_calls>…</|DSML|function_calls>`)
|
||||
// in prose when it's been primed by prior tool use. An explicit
|
||||
// user-role instruction plus post-hoc stripping of known
|
||||
// hallucination shapes keeps the user from seeing raw markup.
|
||||
messages.push({
|
||||
role: "user",
|
||||
content:
|
||||
"I'm out of tool-call budget for this turn. Summarize in plain prose what you learned from the tool results above. Do NOT emit any tool calls, function-call markup, DSML invocations, or SEARCH/REPLACE edit blocks — they will be silently discarded. Just plain text.",
|
||||
});
|
||||
const resp = await this.client.chat({
|
||||
model: this.model,
|
||||
messages,
|
||||
// no tools → model is forced to answer in text
|
||||
});
|
||||
const rawContent = resp.content?.trim() ?? "";
|
||||
const cleaned = stripHallucinatedToolMarkup(rawContent);
|
||||
const summary =
|
||||
resp.content?.trim() ||
|
||||
"(model returned no text; try a narrower question or raise --max-tool-iters)";
|
||||
const reasonPrefix =
|
||||
opts.reason === "aborted"
|
||||
? "[aborted by user (Esc) — summarizing what I found so far]"
|
||||
: `[tool-call budget (${this.maxToolIters}) reached — forcing summary from what I found]`;
|
||||
cleaned ||
|
||||
"(model emitted fake tool-call markup instead of a prose summary — try /retry with a narrower question, or /think to inspect R1's reasoning)";
|
||||
const reasonPrefix = reasonPrefixFor(opts.reason, this.maxToolIters);
|
||||
const annotated = `${reasonPrefix}\n\n${summary}`;
|
||||
const summaryStats = this.stats.record(this._turn, this.model, resp.usage ?? new Usage());
|
||||
this.appendAndPersist({ role: "assistant", content: summary });
|
||||
@@ -580,13 +710,11 @@ export class CacheFirstLoop {
|
||||
role: "assistant_final",
|
||||
content: annotated,
|
||||
stats: summaryStats,
|
||||
forcedSummary: true,
|
||||
};
|
||||
yield { turn: this._turn, role: "done", content: summary };
|
||||
} catch (err) {
|
||||
const label =
|
||||
opts.reason === "aborted"
|
||||
? "aborted by user"
|
||||
: `tool-call budget (${this.maxToolIters}) reached`;
|
||||
const label = errorLabelFor(opts.reason, this.maxToolIters);
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "error",
|
||||
@@ -614,6 +742,45 @@ export class CacheFirstLoop {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* R1 occasionally hallucinates tool-call markup as plain text when the
|
||||
* real tool channel has been closed — typically our forced-summary
|
||||
* path, where `tools: undefined` is supposed to force prose but isn't
|
||||
* always respected. The markup isn't parsed by our tool-call path
|
||||
* (the API response's structured `tool_calls` field is empty), so
|
||||
* it's just noise in the user's view. Strip known envelope shapes.
|
||||
*
|
||||
* Exported so tests can exercise it against concrete R1 outputs.
|
||||
*/
|
||||
export function stripHallucinatedToolMarkup(s: string): string {
|
||||
let out = s;
|
||||
// DeepSeek's DSML envelope (both the full-width "|" character and
|
||||
// the ASCII-only fallback we've seen — the full-width form is the
|
||||
// one R1 emits in practice)
|
||||
out = out.replace(/<|DSML|function_calls>[\s\S]*?<\/?|DSML|function_calls>/g, "");
|
||||
out = out.replace(/<\|DSML\|function_calls>[\s\S]*?<\/?\|DSML\|function_calls>/g, "");
|
||||
// Anthropic / generic XML-ish envelope
|
||||
out = out.replace(/<function_calls>[\s\S]*?<\/function_calls>/g, "");
|
||||
// Lone unpaired DSML opener left over after the closer was on a
|
||||
// different line (seen when R1 truncates mid-call).
|
||||
out = out.replace(/<|DSML|[\s\S]*$/g, "");
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
function reasonPrefixFor(reason: "budget" | "aborted" | "context-guard", iterCap: number): string {
|
||||
if (reason === "aborted") return "[aborted by user (Esc) — summarizing what I found so far]";
|
||||
if (reason === "context-guard") {
|
||||
return "[context budget running low — summarizing before the next call would overflow]";
|
||||
}
|
||||
return `[tool-call budget (${iterCap}) reached — forcing summary from what I found]`;
|
||||
}
|
||||
|
||||
function errorLabelFor(reason: "budget" | "aborted" | "context-guard", iterCap: number): string {
|
||||
if (reason === "aborted") return "aborted by user";
|
||||
if (reason === "context-guard") return "context-guard triggered (prompt > 80% of window)";
|
||||
return `tool-call budget (${iterCap}) reached`;
|
||||
}
|
||||
|
||||
function summarizeBranch(chosen: BranchSample, samples: BranchSample[]): BranchSummary {
|
||||
return {
|
||||
budget: samples.length,
|
||||
|
||||
+124
-9
@@ -8,15 +8,25 @@ import type { McpTransport } from "./stdio.js";
|
||||
import {
|
||||
type CallToolParams,
|
||||
type CallToolResult,
|
||||
type GetPromptParams,
|
||||
type GetPromptResult,
|
||||
type InitializeParams,
|
||||
type InitializeResult,
|
||||
type JsonRpcId,
|
||||
type JsonRpcMessage,
|
||||
type JsonRpcRequest,
|
||||
type JsonRpcResponse,
|
||||
type ListPromptsParams,
|
||||
type ListPromptsResult,
|
||||
type ListResourcesParams,
|
||||
type ListResourcesResult,
|
||||
type ListToolsResult,
|
||||
MCP_PROTOCOL_VERSION,
|
||||
type McpClientInfo,
|
||||
type McpProgressHandler,
|
||||
type ProgressNotificationParams,
|
||||
type ReadResourceParams,
|
||||
type ReadResourceResult,
|
||||
isJsonRpcError,
|
||||
} from "./types.js";
|
||||
|
||||
@@ -42,6 +52,16 @@ export class McpClient {
|
||||
private readerStarted = false;
|
||||
private initialized = false;
|
||||
private _serverCapabilities: InitializeResult["capabilities"] = {};
|
||||
private _serverInfo: InitializeResult["serverInfo"] = { name: "", version: "" };
|
||||
private _protocolVersion = "";
|
||||
private _instructions: string | undefined;
|
||||
// Progress-token → handler for notifications/progress routing. Tokens
|
||||
// are minted per call when the caller supplies an onProgress
|
||||
// callback; cleared when the final response lands (or the pending
|
||||
// request rejects). No leaks — the `try/finally` in callTool
|
||||
// guarantees cleanup even on timeout.
|
||||
private readonly progressHandlers = new Map<string | number, McpProgressHandler>();
|
||||
private nextProgressToken = 1;
|
||||
|
||||
constructor(opts: McpClientOptions) {
|
||||
this.transport = opts.transport;
|
||||
@@ -54,6 +74,21 @@ export class McpClient {
|
||||
return this._serverCapabilities;
|
||||
}
|
||||
|
||||
/** Server's self-reported name + version, available after initialize(). */
|
||||
get serverInfo(): InitializeResult["serverInfo"] {
|
||||
return this._serverInfo;
|
||||
}
|
||||
|
||||
/** Protocol version the server agreed to during the handshake. */
|
||||
get protocolVersion(): string {
|
||||
return this._protocolVersion;
|
||||
}
|
||||
|
||||
/** Optional free-form instructions the server provides at handshake. */
|
||||
get serverInstructions(): string | undefined {
|
||||
return this._instructions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the initialize → initialized handshake. Must be called
|
||||
* before any other method (otherwise compliant servers reject).
|
||||
@@ -63,10 +98,18 @@ export class McpClient {
|
||||
this.startReaderIfNeeded();
|
||||
const result = await this.request<InitializeResult>("initialize", {
|
||||
protocolVersion: MCP_PROTOCOL_VERSION,
|
||||
capabilities: { tools: {} },
|
||||
// Advertise every method the client can consume so servers know
|
||||
// they can send listChanged notifications etc. Sub-feature flags
|
||||
// (e.g. `resources.subscribe`) are omitted — we don't implement
|
||||
// those yet and the empty object means "method-level support, no
|
||||
// sub-features."
|
||||
capabilities: { tools: {}, resources: {}, prompts: {} },
|
||||
clientInfo: this.clientInfo,
|
||||
} satisfies InitializeParams);
|
||||
this._serverCapabilities = result.capabilities ?? {};
|
||||
this._serverInfo = result.serverInfo ?? { name: "", version: "" };
|
||||
this._protocolVersion = result.protocolVersion ?? "";
|
||||
this._instructions = result.instructions;
|
||||
// Per spec: client sends notifications/initialized after receiving the
|
||||
// initialize response. Only then is the connection live for other
|
||||
// methods.
|
||||
@@ -84,13 +127,74 @@ export class McpClient {
|
||||
return this.request<ListToolsResult>("tools/list", {});
|
||||
}
|
||||
|
||||
/** Invoke a tool by name. Returns the raw MCP result (caller unwraps content). */
|
||||
async callTool(name: string, args?: Record<string, unknown>): Promise<CallToolResult> {
|
||||
/**
|
||||
* Invoke a tool by name. When `onProgress` is supplied, attaches a
|
||||
* fresh progress token so the server can send incremental updates
|
||||
* via `notifications/progress`; they're routed to the callback until
|
||||
* the final response arrives (or the request times out, in which
|
||||
* case the handler is simply dropped — no extra notification).
|
||||
*/
|
||||
async callTool(
|
||||
name: string,
|
||||
args?: Record<string, unknown>,
|
||||
opts: { onProgress?: McpProgressHandler } = {},
|
||||
): Promise<CallToolResult> {
|
||||
this.assertInitialized();
|
||||
return this.request<CallToolResult>("tools/call", {
|
||||
const params: CallToolParams = { name, arguments: args ?? {} };
|
||||
let token: number | undefined;
|
||||
if (opts.onProgress) {
|
||||
token = this.nextProgressToken++;
|
||||
this.progressHandlers.set(token, opts.onProgress);
|
||||
params._meta = { progressToken: token };
|
||||
}
|
||||
try {
|
||||
return await this.request<CallToolResult>("tools/call", params);
|
||||
} finally {
|
||||
if (token !== undefined) this.progressHandlers.delete(token);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List resources the server exposes. Supports a pagination cursor;
|
||||
* callers interested in the full set should loop on `nextCursor`.
|
||||
* Servers that don't support resources respond with method-not-found
|
||||
* (−32601) — we surface that as a thrown Error so callers can gate
|
||||
* on the `serverCapabilities.resources` field first.
|
||||
*/
|
||||
async listResources(cursor?: string): Promise<ListResourcesResult> {
|
||||
this.assertInitialized();
|
||||
return this.request<ListResourcesResult>("resources/list", {
|
||||
...(cursor ? { cursor } : {}),
|
||||
} satisfies ListResourcesParams);
|
||||
}
|
||||
|
||||
/** Read the contents of a resource by URI. */
|
||||
async readResource(uri: string): Promise<ReadResourceResult> {
|
||||
this.assertInitialized();
|
||||
return this.request<ReadResourceResult>("resources/read", {
|
||||
uri,
|
||||
} satisfies ReadResourceParams);
|
||||
}
|
||||
|
||||
/** List prompt templates the server exposes. */
|
||||
async listPrompts(cursor?: string): Promise<ListPromptsResult> {
|
||||
this.assertInitialized();
|
||||
return this.request<ListPromptsResult>("prompts/list", {
|
||||
...(cursor ? { cursor } : {}),
|
||||
} satisfies ListPromptsParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a rendered prompt by name. `args` supplies values for any
|
||||
* required template arguments; the server validates. Returns messages
|
||||
* ready to prepend to the model's input.
|
||||
*/
|
||||
async getPrompt(name: string, args?: Record<string, string>): Promise<GetPromptResult> {
|
||||
this.assertInitialized();
|
||||
return this.request<GetPromptResult>("prompts/get", {
|
||||
name,
|
||||
arguments: args ?? {},
|
||||
} satisfies CallToolParams);
|
||||
...(args ? { arguments: args } : {}),
|
||||
} satisfies GetPromptParams);
|
||||
}
|
||||
|
||||
/** Close the transport and reject any outstanding requests. */
|
||||
@@ -152,9 +256,20 @@ export class McpClient {
|
||||
}
|
||||
|
||||
private dispatch(msg: JsonRpcMessage): void {
|
||||
// We only care about responses (have an `id`) for now. Server-initiated
|
||||
// notifications are dropped until we support resources/prompts.
|
||||
if (!("id" in msg) || msg.id === null || msg.id === undefined) return;
|
||||
// Notifications (no `id`): route by method. Progress notifications
|
||||
// go to the per-call handler if one was registered; everything
|
||||
// else is dropped silently (we don't yet handle tools/list_changed
|
||||
// or resources/list_changed).
|
||||
if (!("id" in msg) || msg.id === null || msg.id === undefined) {
|
||||
if ("method" in msg && msg.method === "notifications/progress") {
|
||||
const p = msg.params as ProgressNotificationParams | undefined;
|
||||
if (!p || p.progressToken === undefined) return;
|
||||
const handler = this.progressHandlers.get(p.progressToken);
|
||||
if (!handler) return; // late notification after the call resolved
|
||||
handler({ progress: p.progress, total: p.total, message: p.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!("result" in msg) && !("error" in msg)) return; // it's a request from server
|
||||
const pending = this.pending.get(msg.id);
|
||||
if (!pending) return; // late response after timeout; drop
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Gather a full inspection report from an initialized MCP client:
|
||||
* server info, capabilities, tools, resources, prompts. Methods the
|
||||
* server doesn't support come back as `{ supported: false }` instead
|
||||
* of throwing, so a CLI or UI can render a consistent "what this
|
||||
* server exposes" summary even against minimal implementations.
|
||||
*
|
||||
* Pure with respect to I/O beyond the passed-in client — the CLI
|
||||
* layer owns argument parsing, connection setup, and printing.
|
||||
*/
|
||||
|
||||
import type { McpClient } from "./client.js";
|
||||
import type { McpPrompt, McpResource, McpTool } from "./types.js";
|
||||
|
||||
export interface InspectionReport {
|
||||
protocolVersion: string;
|
||||
serverInfo: { name: string; version: string };
|
||||
capabilities: Record<string, unknown>;
|
||||
instructions?: string;
|
||||
tools: SectionResult<McpTool>;
|
||||
resources: SectionResult<McpResource>;
|
||||
prompts: SectionResult<McpPrompt>;
|
||||
}
|
||||
|
||||
export type SectionResult<T> =
|
||||
| { supported: true; items: T[] }
|
||||
| { supported: false; reason: string };
|
||||
|
||||
/**
|
||||
* Run an inspection against a **already-initialized** client. Caller
|
||||
* is responsible for `initialize()` before this and `close()` after.
|
||||
* We keep this pure so unit tests can feed in a FakeMcpTransport and
|
||||
* verify the aggregate shape without spinning up a real process.
|
||||
*/
|
||||
export async function inspectMcpServer(client: McpClient): Promise<InspectionReport> {
|
||||
// We always *try* the three listings so the client learns whether a
|
||||
// server without explicit capability flags still serves them —
|
||||
// some servers omit capabilities but still respond to the methods.
|
||||
const tools = await trySection<McpTool>(() => client.listTools().then((r) => r.tools));
|
||||
const resources = await trySection<McpResource>(() =>
|
||||
client.listResources().then((r) => r.resources),
|
||||
);
|
||||
const prompts = await trySection<McpPrompt>(() => client.listPrompts().then((r) => r.prompts));
|
||||
|
||||
return {
|
||||
protocolVersion: client.protocolVersion || "(unknown)",
|
||||
serverInfo: client.serverInfo,
|
||||
capabilities: client.serverCapabilities ?? {},
|
||||
instructions: client.serverInstructions,
|
||||
tools,
|
||||
resources,
|
||||
prompts,
|
||||
};
|
||||
}
|
||||
|
||||
async function trySection<T>(load: () => Promise<T[]>): Promise<SectionResult<T>> {
|
||||
try {
|
||||
const items = await load();
|
||||
return { supported: true, items };
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message ?? String(err);
|
||||
// -32601 is JSON-RPC "method not found" — the canonical response
|
||||
// from a server that doesn't implement this family. Treat it as
|
||||
// "not supported" rather than a hard error, so the CLI can render
|
||||
// a clean summary instead of aborting on the first missing method.
|
||||
if (/-32601/.test(msg) || /method not found/i.test(msg)) {
|
||||
return { supported: false, reason: "method not found (-32601)" };
|
||||
}
|
||||
return { supported: false, reason: msg };
|
||||
}
|
||||
}
|
||||
+23
-1
@@ -36,6 +36,19 @@ export interface BridgeOptions {
|
||||
* legitimately want bigger payloads can raise it explicitly.
|
||||
*/
|
||||
maxResultChars?: number;
|
||||
/**
|
||||
* Callback fired for every `notifications/progress` frame the server
|
||||
* emits during any bridged tool call. Includes the registered
|
||||
* (prefix-applied) tool name so a multi-server UI can attribute
|
||||
* progress correctly. Absent → no `_meta.progressToken` is sent and
|
||||
* the server won't emit progress for these calls.
|
||||
*/
|
||||
onProgress?: (info: {
|
||||
toolName: string;
|
||||
progress: number;
|
||||
total?: number;
|
||||
message?: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,7 +94,16 @@ export async function bridgeMcpTools(
|
||||
description: mcpTool.description ?? "",
|
||||
parameters: mcpTool.inputSchema as JSONSchema,
|
||||
fn: async (args: Record<string, unknown>) => {
|
||||
const toolResult = await client.callTool(mcpTool.name, args);
|
||||
const toolResult = await client.callTool(mcpTool.name, args, {
|
||||
// Forward server-side progress frames to the bridge caller,
|
||||
// tagged with the registered name so multi-server UIs can
|
||||
// disambiguate. No-op when `onProgress` isn't configured —
|
||||
// the client then also omits the _meta.progressToken and
|
||||
// the server won't emit progress.
|
||||
onProgress: opts.onProgress
|
||||
? (info) => opts.onProgress!({ toolName: registeredName, ...info })
|
||||
: undefined,
|
||||
});
|
||||
return flattenMcpResult(toolResult, { maxChars: maxResultChars });
|
||||
},
|
||||
});
|
||||
|
||||
+144
-3
@@ -10,8 +10,9 @@
|
||||
* insulated as long as we keep up with the spec itself.
|
||||
*
|
||||
* Spec reference: https://spec.modelcontextprotocol.io/ (2024-11-05 draft
|
||||
* at time of writing). Only the subset Reasonix consumes is modeled here —
|
||||
* tools list/call + init handshake. Resources and prompts are deferred.
|
||||
* at time of writing). Reasonix models the subset it consumes: tools
|
||||
* list/call, resources list/read, prompts list/get, plus the init
|
||||
* handshake. Sampling and progress notifications remain deferred.
|
||||
*
|
||||
* Transport note: the wire format for stdio MCP is **newline-delimited
|
||||
* JSON** (NDJSON), not the LSP-style Content-Length header framing that
|
||||
@@ -66,7 +67,11 @@ export interface McpClientInfo {
|
||||
export interface McpClientCapabilities {
|
||||
/** Empty object advertises support without any optional sub-features. */
|
||||
tools?: Record<string, never>;
|
||||
// resources / prompts / sampling would go here — deferred.
|
||||
/** Advertised when the client can consume `resources/list` + `resources/read`. */
|
||||
resources?: Record<string, never>;
|
||||
/** Advertised when the client can consume `prompts/list` + `prompts/get`. */
|
||||
prompts?: Record<string, never>;
|
||||
// sampling would go here — deferred.
|
||||
}
|
||||
|
||||
export interface InitializeParams {
|
||||
@@ -111,8 +116,38 @@ export interface ListToolsResult {
|
||||
export interface CallToolParams {
|
||||
name: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
/**
|
||||
* MCP's `_meta` envelope carries out-of-band protocol metadata.
|
||||
* Setting `progressToken` here tells the server "send me progress
|
||||
* notifications back using this token"; the server must then emit
|
||||
* `notifications/progress` frames until the response arrives.
|
||||
*/
|
||||
_meta?: { progressToken?: string | number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Server → client notification emitted during a long-running request
|
||||
* that the client subscribed to via `_meta.progressToken`. `progress`
|
||||
* and `total` are typically matched units (files scanned, bytes
|
||||
* processed, etc.); `total` may be missing when the server can't
|
||||
* estimate the upper bound up front.
|
||||
*/
|
||||
export interface ProgressNotificationParams {
|
||||
progressToken: string | number;
|
||||
progress: number;
|
||||
total?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** Values a `ProgressHandler` receives — `progressToken` is already matched away. */
|
||||
export interface McpProgressInfo {
|
||||
progress: number;
|
||||
total?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type McpProgressHandler = (info: McpProgressInfo) => void;
|
||||
|
||||
export interface McpContentBlockText {
|
||||
type: "text";
|
||||
text: string;
|
||||
@@ -133,6 +168,112 @@ export interface CallToolResult {
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
// ---------- MCP resources ----------
|
||||
|
||||
/**
|
||||
* A resource the server can expose — think "file the model can read."
|
||||
* The URI is opaque to the client: servers may use `file://`, custom
|
||||
* schemes, or bare strings. Reasonix doesn't interpret them.
|
||||
*/
|
||||
export interface McpResource {
|
||||
uri: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
/** Hint for the content type (e.g. "text/markdown"). Purely informational. */
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export interface ListResourcesParams {
|
||||
/** Pagination cursor from a previous listResources response. */
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
export interface ListResourcesResult {
|
||||
resources: McpResource[];
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
export interface ReadResourceParams {
|
||||
uri: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One resource can return multiple content blobs (e.g. the file + a
|
||||
* side-car). `text` is the common case for UTF-8 content; `blob` is
|
||||
* base64-encoded bytes for binary content. Servers populate exactly
|
||||
* one of the two for each entry.
|
||||
*/
|
||||
export interface McpResourceContentsText {
|
||||
uri: string;
|
||||
mimeType?: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface McpResourceContentsBlob {
|
||||
uri: string;
|
||||
mimeType?: string;
|
||||
blob: string;
|
||||
}
|
||||
|
||||
export type McpResourceContents = McpResourceContentsText | McpResourceContentsBlob;
|
||||
|
||||
export interface ReadResourceResult {
|
||||
contents: McpResourceContents[];
|
||||
}
|
||||
|
||||
// ---------- MCP prompts ----------
|
||||
|
||||
/**
|
||||
* A parameterizable prompt template the server exposes. Clients fetch
|
||||
* it with `prompts/get` and pass the result to the model as-is.
|
||||
*/
|
||||
export interface McpPromptArgument {
|
||||
name: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export interface McpPrompt {
|
||||
name: string;
|
||||
description?: string;
|
||||
arguments?: McpPromptArgument[];
|
||||
}
|
||||
|
||||
export interface ListPromptsParams {
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
export interface ListPromptsResult {
|
||||
prompts: McpPrompt[];
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
export interface GetPromptParams {
|
||||
name: string;
|
||||
arguments?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP prompt messages are modeled after chat completions: role + content.
|
||||
* Content can be a text block OR (per the spec) a resource/image block;
|
||||
* Reasonix cares about text in v1, but surfaces the raw array so callers
|
||||
* can render other kinds if they need to.
|
||||
*/
|
||||
export interface McpPromptMessage {
|
||||
role: "user" | "assistant";
|
||||
content: McpContentBlock | McpPromptResourceBlock;
|
||||
}
|
||||
|
||||
export interface McpPromptResourceBlock {
|
||||
type: "resource";
|
||||
resource: McpResourceContents;
|
||||
}
|
||||
|
||||
export interface GetPromptResult {
|
||||
description?: string;
|
||||
messages: McpPromptMessage[];
|
||||
}
|
||||
|
||||
// ---------- convenience ----------
|
||||
|
||||
/** Current MCP protocol version Reasonix is coded against. */
|
||||
|
||||
+8
-1
@@ -49,6 +49,7 @@ export class ToolCallRepair {
|
||||
process(
|
||||
declaredCalls: ToolCall[],
|
||||
reasoningContent: string | null,
|
||||
content: string | null = null,
|
||||
): { calls: ToolCall[]; report: RepairReport } {
|
||||
const report: RepairReport = {
|
||||
scavenged: 0,
|
||||
@@ -58,7 +59,13 @@ export class ToolCallRepair {
|
||||
};
|
||||
|
||||
// 1. Scavenge — only add calls whose (name,args) signature is novel.
|
||||
const scavenged = scavengeToolCalls(reasoningContent, {
|
||||
// Scan both channels: reasoning (where R1 leaks JSON calls into
|
||||
// <think>) AND content (where it emits DSML markup in regular
|
||||
// turns). Joined with a newline so the scanners see the blobs as
|
||||
// independent bodies. Dedup below keeps us from inflating if the
|
||||
// same call shows up in both — first seen wins.
|
||||
const combined = [reasoningContent ?? "", content ?? ""].filter(Boolean).join("\n");
|
||||
const scavenged = scavengeToolCalls(combined || null, {
|
||||
allowedNames: this.opts.allowedToolNames,
|
||||
maxCalls: this.opts.maxScavenge ?? 4,
|
||||
});
|
||||
|
||||
+101
-1
@@ -30,7 +30,28 @@ export function scavengeToolCalls(
|
||||
const notes: string[] = [];
|
||||
const out: ToolCall[] = [];
|
||||
|
||||
for (const candidate of iterateJsonObjects(reasoningContent)) {
|
||||
// Pattern A: DSML invoke blocks. R1 sometimes emits tool calls as
|
||||
// its chat-template markup in the content channel instead of the
|
||||
// proper `tool_calls` field. 0.4.3 stripped these from display;
|
||||
// here we actually turn them back into proper ToolCalls so the
|
||||
// model's intent isn't lost.
|
||||
for (const invoke of iterateDsmlInvokes(reasoningContent)) {
|
||||
if (out.length >= max) break;
|
||||
if (!opts.allowedNames.has(invoke.name)) continue;
|
||||
out.push({
|
||||
function: {
|
||||
name: invoke.name,
|
||||
arguments: JSON.stringify(invoke.args),
|
||||
},
|
||||
});
|
||||
notes.push(`scavenged DSML call: ${invoke.name}`);
|
||||
}
|
||||
|
||||
// Pattern B: raw JSON objects (the original three shapes). Strip
|
||||
// any DSML blocks we already processed so parameter JSON buried
|
||||
// inside them doesn't get re-scavenged as a standalone call.
|
||||
const nonDsml = stripDsmlBlocks(reasoningContent);
|
||||
for (const candidate of iterateJsonObjects(nonDsml)) {
|
||||
if (out.length >= max) break;
|
||||
const call = coerceToToolCall(candidate, opts.allowedNames);
|
||||
if (call) {
|
||||
@@ -41,6 +62,85 @@ export function scavengeToolCalls(
|
||||
return { calls: out, notes };
|
||||
}
|
||||
|
||||
interface DsmlInvoke {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Yield DeepSeek DSML invoke blocks scavenged from text. R1's chat
|
||||
* template uses these when the server-side tool-call serializer
|
||||
* misfires — we see them verbatim in the content channel:
|
||||
*
|
||||
* <|DSML|function_calls>
|
||||
* <|DSML|invoke name="filesystem_edit_file">
|
||||
* <|DSML|parameter name="path" string="true">F:/x.html</|DSML|parameter>
|
||||
* <|DSML|parameter name="edits" string="false">[…]</|DSML|parameter>
|
||||
* </|DSML|invoke>
|
||||
* </|DSML|function_calls>
|
||||
*
|
||||
* The wrapper class (| U+FF5C vs ASCII |) varies by model build, so
|
||||
* we accept both. `string="true"` means the parameter value is a
|
||||
* literal string; `string="false"` means it's JSON.
|
||||
*/
|
||||
/**
|
||||
* Remove DSML invoke/function_calls blocks from text so the raw-JSON
|
||||
* scanner below doesn't see parameter payloads as standalone calls.
|
||||
* Mirrors the strip pass in `stripHallucinatedToolMarkup` but scoped
|
||||
* to scavenge (we also want the `function_calls` wrapper gone —
|
||||
* nothing useful to scavenge there).
|
||||
*/
|
||||
function stripDsmlBlocks(text: string): string {
|
||||
let out = text;
|
||||
out = out.replace(/<[||]DSML[||]function_calls>[\s\S]*?<\/?[||]DSML[||]function_calls>/g, "");
|
||||
out = out.replace(/<[||]DSML[||]invoke\s+[^>]*>[\s\S]*?<\/[||]DSML[||]invoke>/g, "");
|
||||
return out;
|
||||
}
|
||||
|
||||
function* iterateDsmlInvokes(text: string): Generator<DsmlInvoke> {
|
||||
// `|` (U+FF5C) in practice; `|` (ASCII) as a fallback seen in a
|
||||
// minority of builds. `[||]` inside the regex covers both.
|
||||
const INVOKE_RE = /<[||]DSML[||]invoke\s+name="([^"]+)">([\s\S]*?)<\/[||]DSML[||]invoke>/g;
|
||||
for (const match of text.matchAll(INVOKE_RE)) {
|
||||
const name = match[1];
|
||||
const body = match[2];
|
||||
if (!name || body === undefined) continue;
|
||||
yield { name, args: parseDsmlParameters(body) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the parameter children of a DSML invoke body into a plain
|
||||
* object. We tolerate:
|
||||
* - `string="true"` → literal text; whitespace trimmed at the edges.
|
||||
* - `string="false"` → JSON-parsed; falls back to literal text if the
|
||||
* payload turns out to be malformed (better to hand a string
|
||||
* through than lose the parameter entirely).
|
||||
* - no `string="…"` attribute → treat as literal text (older/shorter
|
||||
* DSML shapes we haven't formally seen but want to handle).
|
||||
*/
|
||||
function parseDsmlParameters(body: string): Record<string, unknown> {
|
||||
const PARAM_RE =
|
||||
/<[||]DSML[||]parameter\s+name="([^"]+)"(?:\s+string="(true|false)")?\s*>([\s\S]*?)<\/[||]DSML[||]parameter>/g;
|
||||
const args: Record<string, unknown> = {};
|
||||
for (const m of body.matchAll(PARAM_RE)) {
|
||||
const key = m[1];
|
||||
const stringFlag = m[2];
|
||||
const raw = (m[3] ?? "").trim();
|
||||
if (!key) continue;
|
||||
if (stringFlag === "false") {
|
||||
try {
|
||||
args[key] = JSON.parse(raw);
|
||||
continue;
|
||||
} catch {
|
||||
// Fall through — keep as literal so the information isn't lost.
|
||||
}
|
||||
}
|
||||
args[key] = raw;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
/** Yield every top-level JSON object substring in `text`. */
|
||||
function* iterateJsonObjects(text: string): Generator<string> {
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Tests for `codeSystemPrompt` — the .gitignore injection helper.
|
||||
* Pure I/O at the edge, so filesystem-backed tests using a temp dir.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { CODE_SYSTEM_PROMPT, codeSystemPrompt } from "../src/code/prompt.js";
|
||||
|
||||
describe("codeSystemPrompt", () => {
|
||||
let root: string;
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "reasonix-prompt-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns the base prompt unchanged when no .gitignore exists", () => {
|
||||
expect(codeSystemPrompt(root)).toBe(CODE_SYSTEM_PROMPT);
|
||||
});
|
||||
|
||||
it("appends the .gitignore content as a fenced block", () => {
|
||||
writeFileSync(join(root, ".gitignore"), "node_modules/\ndist/\n.env\n", "utf8");
|
||||
const out = codeSystemPrompt(root);
|
||||
expect(out.length).toBeGreaterThan(CODE_SYSTEM_PROMPT.length);
|
||||
expect(out).toMatch(/# Project \.gitignore/);
|
||||
expect(out).toContain("node_modules/");
|
||||
expect(out).toContain(".env");
|
||||
});
|
||||
|
||||
it("truncates a .gitignore larger than 2000 chars", () => {
|
||||
const huge = `${"# comment ".repeat(500)}\n`; // ~5000 chars
|
||||
writeFileSync(join(root, ".gitignore"), huge, "utf8");
|
||||
const out = codeSystemPrompt(root);
|
||||
expect(out).toMatch(/truncated \d+ chars/);
|
||||
// The prompt (base + truncated + fences) is bounded, not 5000+.
|
||||
expect(out.length).toBeLessThan(CODE_SYSTEM_PROMPT.length + 2300);
|
||||
});
|
||||
|
||||
it("still reminds the model about built-in ignores even without a .gitignore", () => {
|
||||
// Base prompt mentions node_modules / dist / .git as "avoid listing"
|
||||
// so new-repo users without .gitignore still get the hint.
|
||||
expect(CODE_SYSTEM_PROMPT).toMatch(/node_modules/);
|
||||
expect(CODE_SYSTEM_PROMPT).toMatch(/dist/);
|
||||
expect(CODE_SYSTEM_PROMPT).toMatch(/\.git/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Unit tests for SEARCH/REPLACE parsing + application.
|
||||
*
|
||||
* Filesystem tests use a fresh temp dir per test so they don't collide
|
||||
* or leak across runs.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyEditBlock,
|
||||
applyEditBlocks,
|
||||
parseEditBlocks,
|
||||
restoreSnapshots,
|
||||
snapshotBeforeEdits,
|
||||
} from "../src/code/edit-blocks.js";
|
||||
|
||||
describe("parseEditBlocks", () => {
|
||||
it("parses a single block", () => {
|
||||
const text = [
|
||||
"Here is the edit:",
|
||||
"",
|
||||
"src/foo.ts",
|
||||
"<<<<<<< SEARCH",
|
||||
"const x = 1;",
|
||||
"=======",
|
||||
"const x = 2;",
|
||||
">>>>>>> REPLACE",
|
||||
].join("\n");
|
||||
const blocks = parseEditBlocks(text);
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0]).toMatchObject({
|
||||
path: "src/foo.ts",
|
||||
search: "const x = 1;",
|
||||
replace: "const x = 2;",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses multiple blocks in one response", () => {
|
||||
const text = [
|
||||
"src/a.ts",
|
||||
"<<<<<<< SEARCH",
|
||||
"old_a",
|
||||
"=======",
|
||||
"new_a",
|
||||
">>>>>>> REPLACE",
|
||||
"",
|
||||
"src/b.ts",
|
||||
"<<<<<<< SEARCH",
|
||||
"old_b",
|
||||
"=======",
|
||||
"new_b",
|
||||
">>>>>>> REPLACE",
|
||||
].join("\n");
|
||||
const blocks = parseEditBlocks(text);
|
||||
expect(blocks).toHaveLength(2);
|
||||
expect(blocks[0]!.path).toBe("src/a.ts");
|
||||
expect(blocks[1]!.path).toBe("src/b.ts");
|
||||
});
|
||||
|
||||
it("handles multi-line SEARCH and REPLACE bodies", () => {
|
||||
const text = [
|
||||
"src/foo.ts",
|
||||
"<<<<<<< SEARCH",
|
||||
"line one",
|
||||
"line two",
|
||||
"line three",
|
||||
"=======",
|
||||
"line one modified",
|
||||
"NEW line",
|
||||
"line three",
|
||||
">>>>>>> REPLACE",
|
||||
].join("\n");
|
||||
const [block] = parseEditBlocks(text);
|
||||
expect(block!.search).toBe("line one\nline two\nline three");
|
||||
expect(block!.replace).toBe("line one modified\nNEW line\nline three");
|
||||
});
|
||||
|
||||
it("recognizes an empty SEARCH (new-file sentinel)", () => {
|
||||
const text = [
|
||||
"src/new.ts",
|
||||
"<<<<<<< SEARCH",
|
||||
"=======",
|
||||
"brand new file",
|
||||
">>>>>>> REPLACE",
|
||||
].join("\n");
|
||||
const [block] = parseEditBlocks(text);
|
||||
expect(block!.search).toBe("");
|
||||
expect(block!.replace).toBe("brand new file");
|
||||
});
|
||||
|
||||
it("returns an empty list when there are no blocks", () => {
|
||||
expect(parseEditBlocks("just prose, no edits here")).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores stray 7-char runs that aren't part of a real block", () => {
|
||||
// A JS file that happens to contain the marker string in an unrelated context.
|
||||
const text = 'const note = "<<<<<<< not an edit block";\nmore prose';
|
||||
expect(parseEditBlocks(text)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyEditBlock", () => {
|
||||
let root: string;
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "reasonix-edit-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("applies a simple replacement", () => {
|
||||
writeFileSync(join(root, "a.txt"), "hello world\n", "utf8");
|
||||
const result = applyEditBlock(
|
||||
{ path: "a.txt", search: "hello", replace: "goodbye", offset: 0 },
|
||||
root,
|
||||
);
|
||||
expect(result.status).toBe("applied");
|
||||
expect(readFileSync(join(root, "a.txt"), "utf8")).toBe("goodbye world\n");
|
||||
});
|
||||
|
||||
it("creates a new file when SEARCH is empty and file doesn't exist", () => {
|
||||
const result = applyEditBlock(
|
||||
{ path: "new/nested/file.ts", search: "", replace: "export const x = 1;\n", offset: 0 },
|
||||
root,
|
||||
);
|
||||
expect(result.status).toBe("created");
|
||||
expect(existsSync(join(root, "new/nested/file.ts"))).toBe(true);
|
||||
expect(readFileSync(join(root, "new/nested/file.ts"), "utf8")).toBe("export const x = 1;\n");
|
||||
});
|
||||
|
||||
it("reports not-found when SEARCH doesn't match", () => {
|
||||
writeFileSync(join(root, "a.txt"), "hello world\n", "utf8");
|
||||
const result = applyEditBlock(
|
||||
{ path: "a.txt", search: "NOT THERE", replace: "x", offset: 0 },
|
||||
root,
|
||||
);
|
||||
expect(result.status).toBe("not-found");
|
||||
// File unchanged.
|
||||
expect(readFileSync(join(root, "a.txt"), "utf8")).toBe("hello world\n");
|
||||
});
|
||||
|
||||
it("reports file-missing when SEARCH is non-empty and file absent", () => {
|
||||
const result = applyEditBlock(
|
||||
{ path: "missing.txt", search: "something", replace: "x", offset: 0 },
|
||||
root,
|
||||
);
|
||||
expect(result.status).toBe("file-missing");
|
||||
});
|
||||
|
||||
it("refuses paths that escape rootDir", () => {
|
||||
const result = applyEditBlock(
|
||||
{ path: "../escape.txt", search: "", replace: "boom", offset: 0 },
|
||||
root,
|
||||
);
|
||||
expect(result.status).toBe("path-escape");
|
||||
expect(existsSync(join(root, "..", "escape.txt"))).toBe(false);
|
||||
});
|
||||
|
||||
it("replaces only the first occurrence when SEARCH appears twice", () => {
|
||||
writeFileSync(join(root, "a.txt"), "foo bar foo\n", "utf8");
|
||||
const result = applyEditBlock(
|
||||
{ path: "a.txt", search: "foo", replace: "FOO", offset: 0 },
|
||||
root,
|
||||
);
|
||||
expect(result.status).toBe("applied");
|
||||
// First "foo" replaced, second left alone.
|
||||
expect(readFileSync(join(root, "a.txt"), "utf8")).toBe("FOO bar foo\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("snapshotBeforeEdits + restoreSnapshots", () => {
|
||||
let root: string;
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "reasonix-undo-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("restores a modified file to its pre-edit content", () => {
|
||||
writeFileSync(join(root, "a.txt"), "original\n", "utf8");
|
||||
const block = { path: "a.txt", search: "original", replace: "CHANGED", offset: 0 };
|
||||
const snaps = snapshotBeforeEdits([block], root);
|
||||
applyEditBlocks([block], root);
|
||||
expect(readFileSync(join(root, "a.txt"), "utf8")).toBe("CHANGED\n");
|
||||
|
||||
const undoResults = restoreSnapshots(snaps, root);
|
||||
expect(undoResults).toHaveLength(1);
|
||||
expect(undoResults[0]!.status).toBe("applied");
|
||||
expect(readFileSync(join(root, "a.txt"), "utf8")).toBe("original\n");
|
||||
});
|
||||
|
||||
it("deletes a file that was newly created by the edit", () => {
|
||||
const block = { path: "fresh.ts", search: "", replace: "new content", offset: 0 };
|
||||
const snaps = snapshotBeforeEdits([block], root);
|
||||
applyEditBlocks([block], root);
|
||||
expect(existsSync(join(root, "fresh.ts"))).toBe(true);
|
||||
|
||||
restoreSnapshots(snaps, root);
|
||||
expect(existsSync(join(root, "fresh.ts"))).toBe(false);
|
||||
});
|
||||
|
||||
it("de-duplicates per path even when a batch has multiple blocks for the same file", () => {
|
||||
writeFileSync(join(root, "a.txt"), "one two three\n", "utf8");
|
||||
const blocks = [
|
||||
{ path: "a.txt", search: "one", replace: "ONE", offset: 0 },
|
||||
{ path: "a.txt", search: "two", replace: "TWO", offset: 10 },
|
||||
];
|
||||
const snaps = snapshotBeforeEdits(blocks, root);
|
||||
expect(snaps).toHaveLength(1); // not 2 — same file
|
||||
expect(snaps[0]!.prevContent).toBe("one two three\n");
|
||||
});
|
||||
|
||||
it("restores multiple files in a single batch independently", () => {
|
||||
writeFileSync(join(root, "a.txt"), "aa\n", "utf8");
|
||||
writeFileSync(join(root, "b.txt"), "bb\n", "utf8");
|
||||
const blocks = [
|
||||
{ path: "a.txt", search: "aa", replace: "AAA", offset: 0 },
|
||||
{ path: "b.txt", search: "bb", replace: "BBB", offset: 10 },
|
||||
];
|
||||
const snaps = snapshotBeforeEdits(blocks, root);
|
||||
applyEditBlocks(blocks, root);
|
||||
|
||||
restoreSnapshots(snaps, root);
|
||||
expect(readFileSync(join(root, "a.txt"), "utf8")).toBe("aa\n");
|
||||
expect(readFileSync(join(root, "b.txt"), "utf8")).toBe("bb\n");
|
||||
});
|
||||
|
||||
it("refuses to restore a snapshot whose path escapes rootDir", () => {
|
||||
const fakeSnap = [{ path: "../escape.txt", prevContent: "boom" }];
|
||||
const results = restoreSnapshots(fakeSnap, root);
|
||||
expect(results[0]!.status).toBe("path-escape");
|
||||
expect(existsSync(join(root, "..", "escape.txt"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyEditBlocks (batch)", () => {
|
||||
let root: string;
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "reasonix-edit-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("applies a two-file batch; each result is independent", () => {
|
||||
writeFileSync(join(root, "a.txt"), "alpha\n", "utf8");
|
||||
writeFileSync(join(root, "b.txt"), "bravo\n", "utf8");
|
||||
const results = applyEditBlocks(
|
||||
[
|
||||
{ path: "a.txt", search: "alpha", replace: "ALPHA", offset: 0 },
|
||||
{ path: "b.txt", search: "NOPE", replace: "X", offset: 10 },
|
||||
],
|
||||
root,
|
||||
);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]!.status).toBe("applied");
|
||||
expect(results[1]!.status).toBe("not-found");
|
||||
expect(readFileSync(join(root, "a.txt"), "utf8")).toBe("ALPHA\n");
|
||||
expect(readFileSync(join(root, "b.txt"), "utf8")).toBe("bravo\n"); // untouched
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatLoopError, healLoadedMessages } from "../src/loop.js";
|
||||
import { formatLoopError, healLoadedMessages, stripHallucinatedToolMarkup } from "../src/loop.js";
|
||||
import type { ChatMessage } from "../src/types.js";
|
||||
|
||||
describe("formatLoopError", () => {
|
||||
@@ -75,3 +75,47 @@ describe("healLoadedMessages", () => {
|
||||
expect(healedFrom).toBe(90_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripHallucinatedToolMarkup", () => {
|
||||
it("removes a full DSML function_calls block (the R1 hallucination we saw live)", () => {
|
||||
const input = [
|
||||
"Let me look at the file structure.",
|
||||
"",
|
||||
'<|DSML|function_calls> <|DSML|invoke name="filesystem_edit_file">',
|
||||
' <|DSML|parameter name="path" string="true">F:.html</|DSML|parameter>',
|
||||
' <|DSML|parameter name="edits" string="false">[...]</|DSML|parameter>',
|
||||
"</|DSML|invoke> </|DSML|function_calls>",
|
||||
"",
|
||||
"Saved.",
|
||||
].join("\n");
|
||||
const out = stripHallucinatedToolMarkup(input);
|
||||
expect(out).toContain("Let me look at the file structure.");
|
||||
expect(out).toContain("Saved.");
|
||||
expect(out).not.toContain("DSML");
|
||||
expect(out).not.toContain("filesystem_edit_file");
|
||||
});
|
||||
|
||||
it("removes an Anthropic-style <function_calls> block", () => {
|
||||
const input = "Here is the plan.\n<function_calls>\n<tool>...</tool>\n</function_calls>\nDone.";
|
||||
const out = stripHallucinatedToolMarkup(input);
|
||||
expect(out).toContain("Here is the plan.");
|
||||
expect(out).toContain("Done.");
|
||||
expect(out).not.toContain("function_calls");
|
||||
});
|
||||
|
||||
it("strips a truncated DSML opener that never gets closed", () => {
|
||||
const input = 'Before the junk.\n<|DSML|function_calls> <|DSML|invoke name="x"> ...';
|
||||
const out = stripHallucinatedToolMarkup(input);
|
||||
expect(out).toBe("Before the junk.");
|
||||
});
|
||||
|
||||
it("leaves plain prose completely alone", () => {
|
||||
const input = "Just a normal summary with no markup anywhere.";
|
||||
expect(stripHallucinatedToolMarkup(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("returns empty string when ALL content was hallucinated markup", () => {
|
||||
const input = "<|DSML|function_calls>garbage</|DSML|function_calls>";
|
||||
expect(stripHallucinatedToolMarkup(input)).toBe("");
|
||||
});
|
||||
});
|
||||
+121
-12
@@ -156,6 +156,48 @@ describe("CacheFirstLoop (non-streaming)", () => {
|
||||
expect(loop.stats.turns.length).toBe(2); // two model round-trips
|
||||
});
|
||||
|
||||
it("yields tool_start before each tool dispatch so the TUI can show 'running…'", async () => {
|
||||
const client = makeClient([
|
||||
{
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: "add", arguments: '{"a":1,"b":2}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ content: "done" },
|
||||
]);
|
||||
const tools = new ToolRegistry();
|
||||
tools.register<{ a: number; b: number }, number>({
|
||||
name: "add",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { a: { type: "integer" }, b: { type: "integer" } },
|
||||
required: ["a", "b"],
|
||||
},
|
||||
fn: ({ a, b }) => a + b,
|
||||
});
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({ system: "s", toolSpecs: tools.specs() }),
|
||||
tools,
|
||||
stream: false,
|
||||
});
|
||||
|
||||
const roleOrder: { role: string; toolName?: string }[] = [];
|
||||
for await (const ev of loop.step("go")) {
|
||||
if (ev.role === "tool_start" || ev.role === "tool") {
|
||||
roleOrder.push({ role: ev.role, toolName: ev.toolName });
|
||||
}
|
||||
}
|
||||
// tool_start must precede the matching tool result.
|
||||
expect(roleOrder[0]).toEqual({ role: "tool_start", toolName: "add" });
|
||||
expect(roleOrder[1]).toEqual({ role: "tool", toolName: "add" });
|
||||
});
|
||||
|
||||
it("immutable prefix is preserved across turns (cache-stability invariant)", async () => {
|
||||
const sharedFetch = fakeFetch([{ content: "a" }, { content: "b" }]);
|
||||
const client = new DeepSeekClient({ apiKey: "sk-test", fetch: sharedFetch });
|
||||
@@ -220,7 +262,7 @@ describe("CacheFirstLoop (non-streaming)", () => {
|
||||
expect(warnings[0]).toMatch(/Esc/);
|
||||
});
|
||||
|
||||
it("abort() mid-step diverts to the summary path with an 'aborted' prefix", async () => {
|
||||
it("abort() mid-step stops immediately without a follow-up API call", async () => {
|
||||
const reg = new ToolRegistry();
|
||||
reg.register({
|
||||
name: "probe",
|
||||
@@ -232,11 +274,15 @@ describe("CacheFirstLoop (non-streaming)", () => {
|
||||
content: "",
|
||||
tool_calls: [{ id: "c", type: "function", function: { name: "probe", arguments: "{}" } }],
|
||||
};
|
||||
// Only one chaining response — after the abort fires on its tool
|
||||
// event, the loop's next API call IS the forced-summary request, so
|
||||
// the summary response needs to be index 1.
|
||||
const responses: FakeResponseShape[] = [chainingToolCall, { content: "partial findings." }];
|
||||
const client = makeClient(responses);
|
||||
// Only one chaining response needed — abort should stop the loop
|
||||
// before any follow-up model call. A second response in the array
|
||||
// would indicate the loop made an unwanted extra API call.
|
||||
const fetchSpy = vi.fn() as unknown as typeof fetch;
|
||||
const responses: FakeResponseShape[] = [chainingToolCall];
|
||||
const client = new DeepSeekClient({
|
||||
apiKey: "sk-test",
|
||||
fetch: fakeFetch(responses) as unknown as typeof fetch,
|
||||
});
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({ system: "s", toolSpecs: reg.specs() }),
|
||||
@@ -247,22 +293,33 @@ describe("CacheFirstLoop (non-streaming)", () => {
|
||||
|
||||
// Call abort AFTER the first tool event fires — simulates the user
|
||||
// hitting Esc while the loop is exploring.
|
||||
const events: { role: string; content?: string }[] = [];
|
||||
const events: { role: string; content?: string; forcedSummary?: boolean }[] = [];
|
||||
let aborted = false;
|
||||
for await (const ev of loop.step("go")) {
|
||||
events.push({ role: ev.role, content: ev.content });
|
||||
events.push({ role: ev.role, content: ev.content, forcedSummary: ev.forcedSummary });
|
||||
if (!aborted && ev.role === "tool") {
|
||||
aborted = true;
|
||||
loop.abort();
|
||||
}
|
||||
}
|
||||
|
||||
// Warning fires with the abort notice.
|
||||
const warnings = events.filter((e) => e.role === "warning");
|
||||
expect(warnings.some((w) => /aborted/.test(w.content ?? ""))).toBe(true);
|
||||
expect(warnings.some((w) => /aborted at iter/.test(w.content ?? ""))).toBe(true);
|
||||
|
||||
// Synthetic assistant_final is tagged forcedSummary and carries
|
||||
// the stopped-message text. It should NOT contain any model
|
||||
// output because no second API call was made.
|
||||
const finals = events.filter((e) => e.role === "assistant_final");
|
||||
const summary = finals[finals.length - 1];
|
||||
expect(summary!.content).toMatch(/aborted by user/);
|
||||
expect(summary!.content).toContain("partial findings.");
|
||||
const stopped = finals[finals.length - 1]!;
|
||||
expect(stopped.forcedSummary).toBe(true);
|
||||
expect(stopped.content).toMatch(/aborted by user \(Esc\)/);
|
||||
expect(stopped.content).toMatch(/no summary produced/);
|
||||
|
||||
// Suite ends with `done`.
|
||||
expect(events[events.length - 1]!.role).toBe("done");
|
||||
// Silence unused-var warning.
|
||||
void fetchSpy;
|
||||
});
|
||||
|
||||
it("forces a summary when maxToolIters is exhausted, instead of stopping silently", async () => {
|
||||
@@ -318,6 +375,58 @@ describe("CacheFirstLoop (non-streaming)", () => {
|
||||
expect(events[events.length - 1]!.role).toBe("done");
|
||||
});
|
||||
|
||||
it("context-guard diverts to summary when promptTokens > 80% of the window, tagging forcedSummary", async () => {
|
||||
const reg = new ToolRegistry();
|
||||
reg.register({
|
||||
name: "probe",
|
||||
description: "no-op",
|
||||
parameters: { type: "object", properties: {} },
|
||||
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.
|
||||
const responses: FakeResponseShape[] = [
|
||||
{
|
||||
content: "",
|
||||
tool_calls: [{ id: "c", type: "function", function: { name: "probe", arguments: "{}" } }],
|
||||
usage: {
|
||||
prompt_tokens: 120_000,
|
||||
completion_tokens: 50,
|
||||
total_tokens: 120_050,
|
||||
prompt_cache_hit_tokens: 90_000,
|
||||
prompt_cache_miss_tokens: 30_000,
|
||||
},
|
||||
},
|
||||
// Forced-summary response (no tools)
|
||||
{ content: "based on what I saw, X." },
|
||||
];
|
||||
const client = makeClient(responses);
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({ system: "s", toolSpecs: reg.specs() }),
|
||||
tools: reg,
|
||||
stream: false,
|
||||
maxToolIters: 64,
|
||||
});
|
||||
|
||||
const events: { role: string; forcedSummary?: boolean; content?: string }[] = [];
|
||||
for await (const ev of loop.step("analyze the repo")) {
|
||||
events.push({ role: ev.role, forcedSummary: ev.forcedSummary, content: ev.content });
|
||||
}
|
||||
|
||||
// A warning must fire about the context guard.
|
||||
const warn = events.find((e) => e.role === "warning");
|
||||
expect(warn).toBeDefined();
|
||||
expect(warn!.content).toMatch(/context \d+\/\d+/);
|
||||
|
||||
// The final assistant_final must be tagged forcedSummary and carry the context-guard prefix.
|
||||
const finals = events.filter((e) => e.role === "assistant_final");
|
||||
const summary = finals[finals.length - 1];
|
||||
expect(summary!.forcedSummary).toBe(true);
|
||||
expect(summary!.content).toMatch(/context budget running low/);
|
||||
});
|
||||
|
||||
it("surfaces an error event when the HTTP call fails with a non-retryable status", async () => {
|
||||
// 401 is non-retryable (bad key). Using this avoids multi-retry waits.
|
||||
const errFetch = vi.fn(async () => new Response("boom", { status: 401 }));
|
||||
|
||||
+93
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stripMath } from "../src/cli/ui/markdown.js";
|
||||
import { parseBlocks, stripMath } from "../src/cli/ui/markdown.js";
|
||||
|
||||
describe("stripMath", () => {
|
||||
it("converts \\frac, \\dfrac, \\tfrac uniformly", () => {
|
||||
@@ -67,3 +67,95 @@ describe("stripMath", () => {
|
||||
expect(out).toContain("(d)/(30)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBlocks — SEARCH/REPLACE detection", () => {
|
||||
it("extracts a single SEARCH/REPLACE block into a first-class edit-block", () => {
|
||||
const text = [
|
||||
"Here is the fix:",
|
||||
"",
|
||||
"src/foo.ts",
|
||||
"<<<<<<< SEARCH",
|
||||
"const x = 1;",
|
||||
"=======",
|
||||
"const x = 2;",
|
||||
">>>>>>> REPLACE",
|
||||
].join("\n");
|
||||
const blocks = parseBlocks(text);
|
||||
const edit = blocks.find((b) => b.kind === "edit-block");
|
||||
expect(edit).toBeDefined();
|
||||
if (edit?.kind !== "edit-block") throw new Error("unreachable");
|
||||
expect(edit.filename).toBe("src/foo.ts");
|
||||
expect(edit.search).toBe("const x = 1;");
|
||||
expect(edit.replace).toBe("const x = 2;");
|
||||
});
|
||||
|
||||
it("preserves multi-line SEARCH and REPLACE verbatim (no markdown mangling)", () => {
|
||||
// The original user-reported shape: JSDoc comments inside SEARCH.
|
||||
// Before this fix, `/** ... */` got eaten by bold/italic regex and
|
||||
// `para.join(" ")` collapsed newlines.
|
||||
const text = [
|
||||
"src/code/edit-blocks.ts",
|
||||
"<<<<<<< SEARCH",
|
||||
"/** Edit landed on disk. */",
|
||||
"| 'applied'",
|
||||
"=======",
|
||||
"/** Edit landed on disk. */",
|
||||
"| 'applied-new'",
|
||||
">>>>>>> REPLACE",
|
||||
].join("\n");
|
||||
const [edit] = parseBlocks(text).filter((b) => b.kind === "edit-block");
|
||||
if (edit?.kind !== "edit-block") throw new Error("expected edit-block");
|
||||
// The `/** ... */` and `|` chars survive intact — no `*`-eating,
|
||||
// no newline-flattening.
|
||||
expect(edit.search).toContain("/** Edit landed on disk. */");
|
||||
expect(edit.search).toContain("\n");
|
||||
expect(edit.replace).toContain("'applied-new'");
|
||||
});
|
||||
|
||||
it("recognizes new-file (empty SEARCH) blocks", () => {
|
||||
const text = [
|
||||
"src/new.ts",
|
||||
"<<<<<<< SEARCH",
|
||||
"=======",
|
||||
"export const x = 1;",
|
||||
">>>>>>> REPLACE",
|
||||
].join("\n");
|
||||
const [edit] = parseBlocks(text).filter((b) => b.kind === "edit-block");
|
||||
if (edit?.kind !== "edit-block") throw new Error("expected edit-block");
|
||||
expect(edit.search).toBe("");
|
||||
expect(edit.replace).toBe("export const x = 1;");
|
||||
});
|
||||
|
||||
it("ignores a stray <<<<<<< SEARCH without a filename or close marker", () => {
|
||||
const text = "just some prose with <<<<<<< SEARCH left over in the middle";
|
||||
const blocks = parseBlocks(text);
|
||||
expect(blocks.find((b) => b.kind === "edit-block")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("extracts multiple edit-blocks in one response, keeping the prose between them", () => {
|
||||
const text = [
|
||||
"First change:",
|
||||
"src/a.ts",
|
||||
"<<<<<<< SEARCH",
|
||||
"old_a",
|
||||
"=======",
|
||||
"new_a",
|
||||
">>>>>>> REPLACE",
|
||||
"",
|
||||
"And second:",
|
||||
"src/b.ts",
|
||||
"<<<<<<< SEARCH",
|
||||
"old_b",
|
||||
"=======",
|
||||
"new_b",
|
||||
">>>>>>> REPLACE",
|
||||
].join("\n");
|
||||
const blocks = parseBlocks(text);
|
||||
const edits = blocks.filter((b) => b.kind === "edit-block");
|
||||
expect(edits).toHaveLength(2);
|
||||
const paragraphs = blocks.filter((b) => b.kind === "paragraph");
|
||||
expect(paragraphs.map((p) => (p.kind === "paragraph" ? p.text : ""))).toEqual(
|
||||
expect.arrayContaining(["First change:", "And second:"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Tests for inspectMcpServer (pure, runs against the fake transport).
|
||||
* We don't unit-test the CLI wrapper — that's just argv parsing and
|
||||
* stdout printing, covered by the integration story of "types compile".
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { McpClient } from "../src/mcp/client.js";
|
||||
import { inspectMcpServer } from "../src/mcp/inspect.js";
|
||||
import type { McpTransport } from "../src/mcp/stdio.js";
|
||||
import {
|
||||
type JsonRpcMessage,
|
||||
type JsonRpcRequest,
|
||||
MCP_PROTOCOL_VERSION,
|
||||
} from "../src/mcp/types.js";
|
||||
|
||||
// A minimal in-process transport that answers methods from a handler
|
||||
// map. Simpler than the FakeMcpTransport in mcp.test.ts — we only
|
||||
// care about shape-of-response here, not call ordering.
|
||||
class HandlerTransport implements McpTransport {
|
||||
private readonly queue: JsonRpcMessage[] = [];
|
||||
private readonly waiters: Array<(m: JsonRpcMessage | null) => void> = [];
|
||||
private closed = false;
|
||||
constructor(private readonly handlers: Record<string, (req: JsonRpcRequest) => JsonRpcMessage>) {}
|
||||
|
||||
async send(msg: JsonRpcMessage): Promise<void> {
|
||||
if (this.closed) throw new Error("closed");
|
||||
if (!("id" in msg) || !("method" in msg)) return; // notification from client
|
||||
const req = msg as JsonRpcRequest;
|
||||
const handler = this.handlers[req.method];
|
||||
const response = handler
|
||||
? handler(req)
|
||||
: {
|
||||
jsonrpc: "2.0",
|
||||
id: req.id,
|
||||
error: { code: -32601, message: `method not found: ${req.method}` },
|
||||
};
|
||||
const w = this.waiters.shift();
|
||||
if (w) w(response as JsonRpcMessage);
|
||||
else this.queue.push(response as JsonRpcMessage);
|
||||
}
|
||||
|
||||
async *messages(): AsyncIterableIterator<JsonRpcMessage> {
|
||||
while (true) {
|
||||
if (this.queue.length > 0) {
|
||||
yield this.queue.shift()!;
|
||||
continue;
|
||||
}
|
||||
if (this.closed) return;
|
||||
const next = await new Promise<JsonRpcMessage | null>((resolve) => {
|
||||
this.waiters.push(resolve);
|
||||
});
|
||||
if (next === null) return;
|
||||
yield next;
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
while (this.waiters.length > 0) this.waiters.shift()!(null);
|
||||
}
|
||||
}
|
||||
|
||||
function initOk(req: JsonRpcRequest): JsonRpcMessage {
|
||||
return {
|
||||
jsonrpc: "2.0",
|
||||
id: req.id,
|
||||
result: {
|
||||
protocolVersion: MCP_PROTOCOL_VERSION,
|
||||
serverInfo: { name: "test-server", version: "1.2.3" },
|
||||
capabilities: { tools: {}, resources: {}, prompts: {} },
|
||||
instructions: "Use tool X before Y.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("inspectMcpServer", () => {
|
||||
it("reports server info, capabilities, tools, resources, prompts — full-support server", async () => {
|
||||
const transport = new HandlerTransport({
|
||||
initialize: initOk,
|
||||
"tools/list": (req) => ({
|
||||
jsonrpc: "2.0",
|
||||
id: req.id,
|
||||
result: {
|
||||
tools: [
|
||||
{ name: "echo", description: "echoes", inputSchema: { type: "object" } },
|
||||
{ name: "add", description: "a+b", inputSchema: { type: "object" } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
"resources/list": (req) => ({
|
||||
jsonrpc: "2.0",
|
||||
id: req.id,
|
||||
result: {
|
||||
resources: [
|
||||
{ uri: "file:///a.md", name: "a", mimeType: "text/markdown" },
|
||||
{ uri: "custom://b", name: "b" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
"prompts/list": (req) => ({
|
||||
jsonrpc: "2.0",
|
||||
id: req.id,
|
||||
result: {
|
||||
prompts: [
|
||||
{
|
||||
name: "summarize",
|
||||
description: "summarize a doc",
|
||||
arguments: [{ name: "lang", required: true }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
const client = new McpClient({ transport });
|
||||
await client.initialize();
|
||||
const report = await inspectMcpServer(client);
|
||||
|
||||
expect(report.protocolVersion).toBe(MCP_PROTOCOL_VERSION);
|
||||
expect(report.serverInfo).toEqual({ name: "test-server", version: "1.2.3" });
|
||||
expect(report.instructions).toBe("Use tool X before Y.");
|
||||
expect(report.tools).toEqual({
|
||||
supported: true,
|
||||
items: expect.arrayContaining([expect.objectContaining({ name: "echo" })]),
|
||||
});
|
||||
expect(report.resources.supported).toBe(true);
|
||||
if (report.resources.supported) {
|
||||
expect(report.resources.items.map((r) => r.name)).toEqual(["a", "b"]);
|
||||
}
|
||||
expect(report.prompts.supported).toBe(true);
|
||||
if (report.prompts.supported) {
|
||||
expect(report.prompts.items[0]!.arguments?.[0]?.name).toBe("lang");
|
||||
}
|
||||
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("marks resources+prompts as not supported when server returns -32601", async () => {
|
||||
// Tools-only server: init returns, tools/list works, resources/list
|
||||
// + prompts/list fall through to the default -32601 in HandlerTransport.
|
||||
const transport = new HandlerTransport({
|
||||
initialize: initOk,
|
||||
"tools/list": (req) => ({
|
||||
jsonrpc: "2.0",
|
||||
id: req.id,
|
||||
result: { tools: [] },
|
||||
}),
|
||||
});
|
||||
const client = new McpClient({ transport });
|
||||
await client.initialize();
|
||||
const report = await inspectMcpServer(client);
|
||||
|
||||
expect(report.tools.supported).toBe(true);
|
||||
expect(report.resources.supported).toBe(false);
|
||||
expect(report.prompts.supported).toBe(false);
|
||||
if (!report.resources.supported) {
|
||||
expect(report.resources.reason).toMatch(/-32601/);
|
||||
}
|
||||
if (!report.prompts.supported) {
|
||||
expect(report.prompts.reason).toMatch(/-32601/);
|
||||
}
|
||||
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("forwards non-32601 errors as the section reason so user sees real diagnostics", async () => {
|
||||
const transport = new HandlerTransport({
|
||||
initialize: initOk,
|
||||
"tools/list": (req) => ({
|
||||
jsonrpc: "2.0",
|
||||
id: req.id,
|
||||
error: { code: -32000, message: "server is overloaded" },
|
||||
}),
|
||||
"resources/list": (req) => ({
|
||||
jsonrpc: "2.0",
|
||||
id: req.id,
|
||||
result: { resources: [] },
|
||||
}),
|
||||
"prompts/list": (req) => ({
|
||||
jsonrpc: "2.0",
|
||||
id: req.id,
|
||||
result: { prompts: [] },
|
||||
}),
|
||||
});
|
||||
const client = new McpClient({ transport });
|
||||
await client.initialize();
|
||||
const report = await inspectMcpServer(client);
|
||||
|
||||
expect(report.tools.supported).toBe(false);
|
||||
if (!report.tools.supported) {
|
||||
expect(report.tools.reason).toMatch(/overloaded/);
|
||||
}
|
||||
// Resources and prompts are supported and empty — should not be affected.
|
||||
expect(report.resources.supported).toBe(true);
|
||||
expect(report.prompts.supported).toBe(true);
|
||||
|
||||
await client.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("McpClient: initialize records serverInfo + protocolVersion + instructions", () => {
|
||||
it("exposes server info, protocol version, instructions from the handshake", async () => {
|
||||
const transport = new HandlerTransport({ initialize: initOk });
|
||||
const client = new McpClient({ transport });
|
||||
await client.initialize();
|
||||
expect(client.serverInfo).toEqual({ name: "test-server", version: "1.2.3" });
|
||||
expect(client.protocolVersion).toBe(MCP_PROTOCOL_VERSION);
|
||||
expect(client.serverInstructions).toBe("Use tool X before Y.");
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("leaves serverInstructions undefined when absent from the init result", async () => {
|
||||
const transport = new HandlerTransport({
|
||||
initialize: (req) => ({
|
||||
jsonrpc: "2.0",
|
||||
id: req.id,
|
||||
result: {
|
||||
protocolVersion: MCP_PROTOCOL_VERSION,
|
||||
serverInfo: { name: "minimal", version: "0" },
|
||||
capabilities: { tools: {} },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const client = new McpClient({ transport });
|
||||
await client.initialize();
|
||||
expect(client.serverInstructions).toBeUndefined();
|
||||
await client.close();
|
||||
});
|
||||
});
|
||||
+476
-1
@@ -13,10 +13,14 @@ import { bridgeMcpTools, flattenMcpResult } from "../src/mcp/registry.js";
|
||||
import type { McpTransport } from "../src/mcp/stdio.js";
|
||||
import {
|
||||
type CallToolResult,
|
||||
type GetPromptResult,
|
||||
type JsonRpcMessage,
|
||||
type JsonRpcRequest,
|
||||
type ListPromptsResult,
|
||||
type ListResourcesResult,
|
||||
MCP_PROTOCOL_VERSION,
|
||||
type McpTool,
|
||||
type ReadResourceResult,
|
||||
} from "../src/mcp/types.js";
|
||||
|
||||
// ---------- fake transport ----------
|
||||
@@ -29,6 +33,16 @@ interface FakeServerOptions {
|
||||
errorFor?: Set<string>;
|
||||
/** Track every call the server received. */
|
||||
received?: JsonRpcRequest[];
|
||||
/** resources/list response. Optional — omit to return empty. */
|
||||
listResources?: () => ListResourcesResult;
|
||||
/** resources/read response keyed by URI. Throw-returns method-not-found for unknowns. */
|
||||
readResource?: (uri: string) => ReadResourceResult;
|
||||
/** prompts/list response. */
|
||||
listPrompts?: () => ListPromptsResult;
|
||||
/** prompts/get response keyed by name. */
|
||||
getPrompt?: (name: string, args?: Record<string, string>) => GetPromptResult;
|
||||
/** Initialize capabilities override — defaults advertise tools only. */
|
||||
capabilities?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,7 +101,7 @@ class FakeMcpTransport implements McpTransport {
|
||||
result: {
|
||||
protocolVersion: MCP_PROTOCOL_VERSION,
|
||||
serverInfo: { name: "fake-mcp", version: "0.0.0" },
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
capabilities: this.opts.capabilities ?? { tools: { listChanged: false } },
|
||||
},
|
||||
};
|
||||
case "tools/list":
|
||||
@@ -110,6 +124,49 @@ class FakeMcpTransport implements McpTransport {
|
||||
: { content: [{ type: "text" as const, text: `called ${params.name}` }] };
|
||||
return { jsonrpc: "2.0", id: req.id, result };
|
||||
}
|
||||
case "resources/list":
|
||||
if (!this.opts.listResources) {
|
||||
return {
|
||||
jsonrpc: "2.0",
|
||||
id: req.id,
|
||||
error: { code: -32601, message: "method not found: resources/list" },
|
||||
};
|
||||
}
|
||||
return { jsonrpc: "2.0", id: req.id, result: this.opts.listResources() };
|
||||
case "resources/read": {
|
||||
if (!this.opts.readResource) {
|
||||
return {
|
||||
jsonrpc: "2.0",
|
||||
id: req.id,
|
||||
error: { code: -32601, message: "method not found: resources/read" },
|
||||
};
|
||||
}
|
||||
const { uri } = req.params as { uri: string };
|
||||
return { jsonrpc: "2.0", id: req.id, result: this.opts.readResource(uri) };
|
||||
}
|
||||
case "prompts/list":
|
||||
if (!this.opts.listPrompts) {
|
||||
return {
|
||||
jsonrpc: "2.0",
|
||||
id: req.id,
|
||||
error: { code: -32601, message: "method not found: prompts/list" },
|
||||
};
|
||||
}
|
||||
return { jsonrpc: "2.0", id: req.id, result: this.opts.listPrompts() };
|
||||
case "prompts/get": {
|
||||
if (!this.opts.getPrompt) {
|
||||
return {
|
||||
jsonrpc: "2.0",
|
||||
id: req.id,
|
||||
error: { code: -32601, message: "method not found: prompts/get" },
|
||||
};
|
||||
}
|
||||
const { name, arguments: a } = req.params as {
|
||||
name: string;
|
||||
arguments?: Record<string, string>;
|
||||
};
|
||||
return { jsonrpc: "2.0", id: req.id, result: this.opts.getPrompt(name, a) };
|
||||
}
|
||||
default:
|
||||
return {
|
||||
jsonrpc: "2.0",
|
||||
@@ -352,3 +409,421 @@ describe("bridgeMcpTools: result-size cap", () => {
|
||||
void transport;
|
||||
});
|
||||
});
|
||||
|
||||
describe("McpClient: progress notifications", () => {
|
||||
/**
|
||||
* Build a transport where tools/call takes multiple "ticks" before
|
||||
* responding — each tick emits a notifications/progress frame tied
|
||||
* to the token the client sent in `_meta.progressToken`.
|
||||
*/
|
||||
function makeProgressTransport(
|
||||
progressFrames: Array<{ progress: number; total?: number; message?: string }>,
|
||||
): { transport: McpTransport; received: JsonRpcRequest[] } {
|
||||
const received: JsonRpcRequest[] = [];
|
||||
const queue: JsonRpcMessage[] = [];
|
||||
const waiters: Array<(m: JsonRpcMessage | null) => void> = [];
|
||||
let closed = false;
|
||||
const push = (m: JsonRpcMessage) => {
|
||||
const w = waiters.shift();
|
||||
if (w) w(m);
|
||||
else queue.push(m);
|
||||
};
|
||||
const transport: McpTransport = {
|
||||
async send(msg) {
|
||||
if (closed) throw new Error("closed");
|
||||
if (!("id" in msg) || !("method" in msg)) return;
|
||||
const req = msg as JsonRpcRequest;
|
||||
received.push(req);
|
||||
if (req.method === "initialize") {
|
||||
push({
|
||||
jsonrpc: "2.0",
|
||||
id: req.id,
|
||||
result: {
|
||||
protocolVersion: MCP_PROTOCOL_VERSION,
|
||||
serverInfo: { name: "fake", version: "0" },
|
||||
capabilities: { tools: {} },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.method === "tools/call") {
|
||||
const token = (req.params as { _meta?: { progressToken?: string | number } })._meta
|
||||
?.progressToken;
|
||||
// Emit progress frames first (all with the same token), then
|
||||
// the final response.
|
||||
for (const frame of progressFrames) {
|
||||
push({
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/progress",
|
||||
params: { progressToken: token, ...frame },
|
||||
});
|
||||
}
|
||||
push({
|
||||
jsonrpc: "2.0",
|
||||
id: req.id,
|
||||
result: { content: [{ type: "text" as const, text: "done" }] },
|
||||
});
|
||||
return;
|
||||
}
|
||||
push({
|
||||
jsonrpc: "2.0",
|
||||
id: req.id,
|
||||
error: { code: -32601, message: "method not found" },
|
||||
});
|
||||
},
|
||||
async *messages() {
|
||||
while (true) {
|
||||
if (queue.length > 0) {
|
||||
yield queue.shift()!;
|
||||
continue;
|
||||
}
|
||||
if (closed) return;
|
||||
const next = await new Promise<JsonRpcMessage | null>((resolve) => {
|
||||
waiters.push(resolve);
|
||||
});
|
||||
if (next === null) return;
|
||||
yield next;
|
||||
}
|
||||
},
|
||||
async close() {
|
||||
closed = true;
|
||||
while (waiters.length > 0) waiters.shift()!(null);
|
||||
},
|
||||
};
|
||||
return { transport, received };
|
||||
}
|
||||
|
||||
it("routes progress notifications to the per-call onProgress callback", async () => {
|
||||
const { transport } = makeProgressTransport([
|
||||
{ progress: 1, total: 3, message: "reading..." },
|
||||
{ progress: 2, total: 3 },
|
||||
{ progress: 3, total: 3, message: "done" },
|
||||
]);
|
||||
const client = new McpClient({ transport });
|
||||
await client.initialize();
|
||||
|
||||
const received: Array<{ progress: number; total?: number; message?: string }> = [];
|
||||
const result = await client.callTool("scan", {}, { onProgress: (info) => received.push(info) });
|
||||
expect(result.content[0]).toEqual({ type: "text", text: "done" });
|
||||
expect(received).toEqual([
|
||||
{ progress: 1, total: 3, message: "reading..." },
|
||||
{ progress: 2, total: 3, message: undefined },
|
||||
{ progress: 3, total: 3, message: "done" },
|
||||
]);
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("omits _meta.progressToken when no onProgress callback is provided", async () => {
|
||||
const { transport, received } = makeProgressTransport([]);
|
||||
const client = new McpClient({ transport });
|
||||
await client.initialize();
|
||||
await client.callTool("x", { k: 1 });
|
||||
const callReq = received.find((r) => r.method === "tools/call")!;
|
||||
expect((callReq.params as { _meta?: unknown })._meta).toBeUndefined();
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("sets a distinct _meta.progressToken when onProgress IS provided", async () => {
|
||||
const { transport, received } = makeProgressTransport([]);
|
||||
const client = new McpClient({ transport });
|
||||
await client.initialize();
|
||||
await client.callTool("x", {}, { onProgress: () => {} });
|
||||
const callReq = received.find((r) => r.method === "tools/call")!;
|
||||
const token = (callReq.params as { _meta?: { progressToken?: number } })._meta?.progressToken;
|
||||
expect(token).toBeTypeOf("number");
|
||||
});
|
||||
|
||||
it("late progress frames after the call resolved are dropped silently", async () => {
|
||||
// Use the handler-transport shape from progress-transport: we
|
||||
// send the final result, THEN push a trailing progress frame.
|
||||
// The client must not throw when the handler map is already empty.
|
||||
const received: JsonRpcRequest[] = [];
|
||||
const queue: JsonRpcMessage[] = [];
|
||||
const waiters: Array<(m: JsonRpcMessage | null) => void> = [];
|
||||
let closed = false;
|
||||
const push = (m: JsonRpcMessage) => {
|
||||
const w = waiters.shift();
|
||||
if (w) w(m);
|
||||
else queue.push(m);
|
||||
};
|
||||
let lastToken: string | number | undefined;
|
||||
const transport: McpTransport = {
|
||||
async send(msg) {
|
||||
if (!("id" in msg) || !("method" in msg)) return;
|
||||
const req = msg as JsonRpcRequest;
|
||||
received.push(req);
|
||||
if (req.method === "initialize") {
|
||||
push({
|
||||
jsonrpc: "2.0",
|
||||
id: req.id,
|
||||
result: {
|
||||
protocolVersion: MCP_PROTOCOL_VERSION,
|
||||
serverInfo: { name: "fake", version: "0" },
|
||||
capabilities: {},
|
||||
},
|
||||
});
|
||||
} else if (req.method === "tools/call") {
|
||||
lastToken = (req.params as { _meta?: { progressToken?: string | number } })._meta
|
||||
?.progressToken;
|
||||
// Final response first, then a trailing progress — mimics
|
||||
// a race where the server finished but a progress frame
|
||||
// was already in flight.
|
||||
push({
|
||||
jsonrpc: "2.0",
|
||||
id: req.id,
|
||||
result: { content: [] },
|
||||
});
|
||||
push({
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/progress",
|
||||
params: { progressToken: lastToken, progress: 42 },
|
||||
});
|
||||
}
|
||||
},
|
||||
async *messages() {
|
||||
while (true) {
|
||||
if (queue.length > 0) {
|
||||
yield queue.shift()!;
|
||||
continue;
|
||||
}
|
||||
if (closed) return;
|
||||
const next = await new Promise<JsonRpcMessage | null>((resolve) => {
|
||||
waiters.push(resolve);
|
||||
});
|
||||
if (next === null) return;
|
||||
yield next;
|
||||
}
|
||||
},
|
||||
async close() {
|
||||
closed = true;
|
||||
while (waiters.length > 0) waiters.shift()!(null);
|
||||
},
|
||||
};
|
||||
const client = new McpClient({ transport });
|
||||
await client.initialize();
|
||||
const seen: number[] = [];
|
||||
await client.callTool("x", {}, { onProgress: (i) => seen.push(i.progress) });
|
||||
// Give the reader loop a tick to process the trailing
|
||||
// notification — should be swallowed, not thrown.
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(seen).toEqual([]); // the trailing frame was dropped
|
||||
await client.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bridgeMcpTools: progress pass-through", () => {
|
||||
it("threads per-tool progress callbacks with the registered (prefixed) name", async () => {
|
||||
const { transport } = (function makeProgressTransport(
|
||||
frames: Array<{ progress: number; total?: number }>,
|
||||
) {
|
||||
const queue: JsonRpcMessage[] = [];
|
||||
const waiters: Array<(m: JsonRpcMessage | null) => void> = [];
|
||||
let closed = false;
|
||||
const push = (m: JsonRpcMessage) => {
|
||||
const w = waiters.shift();
|
||||
if (w) w(m);
|
||||
else queue.push(m);
|
||||
};
|
||||
const t: McpTransport = {
|
||||
async send(msg) {
|
||||
if (!("id" in msg) || !("method" in msg)) return;
|
||||
const req = msg as JsonRpcRequest;
|
||||
if (req.method === "initialize") {
|
||||
push({
|
||||
jsonrpc: "2.0",
|
||||
id: req.id,
|
||||
result: {
|
||||
protocolVersion: MCP_PROTOCOL_VERSION,
|
||||
serverInfo: { name: "fs", version: "0" },
|
||||
capabilities: { tools: {} },
|
||||
},
|
||||
});
|
||||
} else if (req.method === "tools/list") {
|
||||
push({
|
||||
jsonrpc: "2.0",
|
||||
id: req.id,
|
||||
result: {
|
||||
tools: [{ name: "scan", description: "", inputSchema: { type: "object" } }],
|
||||
},
|
||||
});
|
||||
} else if (req.method === "tools/call") {
|
||||
const token = (req.params as { _meta?: { progressToken?: string | number } })._meta
|
||||
?.progressToken;
|
||||
for (const f of frames) {
|
||||
push({
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/progress",
|
||||
params: { progressToken: token, ...f },
|
||||
});
|
||||
}
|
||||
push({
|
||||
jsonrpc: "2.0",
|
||||
id: req.id,
|
||||
result: { content: [{ type: "text" as const, text: "ok" }] },
|
||||
});
|
||||
}
|
||||
},
|
||||
async *messages() {
|
||||
while (true) {
|
||||
if (queue.length > 0) {
|
||||
yield queue.shift()!;
|
||||
continue;
|
||||
}
|
||||
if (closed) return;
|
||||
const next = await new Promise<JsonRpcMessage | null>((resolve) => {
|
||||
waiters.push(resolve);
|
||||
});
|
||||
if (next === null) return;
|
||||
yield next;
|
||||
}
|
||||
},
|
||||
async close() {
|
||||
closed = true;
|
||||
while (waiters.length > 0) waiters.shift()!(null);
|
||||
},
|
||||
};
|
||||
return { transport: t };
|
||||
})([{ progress: 5, total: 10 }]);
|
||||
|
||||
const client = new McpClient({ transport });
|
||||
await client.initialize();
|
||||
const observed: Array<{ toolName: string; progress: number; total?: number }> = [];
|
||||
const { registry } = await bridgeMcpTools(client, {
|
||||
namePrefix: "fs_",
|
||||
onProgress: ({ toolName, progress, total }) => observed.push({ toolName, progress, total }),
|
||||
});
|
||||
await registry.dispatch("fs_scan", "{}");
|
||||
expect(observed).toEqual([{ toolName: "fs_scan", progress: 5, total: 10 }]);
|
||||
await client.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("McpClient: resources", () => {
|
||||
it("lists resources and reads one by URI", async () => {
|
||||
const transport = new FakeMcpTransport({
|
||||
tools: [],
|
||||
capabilities: { tools: {}, resources: { listChanged: false } },
|
||||
listResources: () => ({
|
||||
resources: [
|
||||
{ uri: "file:///a.md", name: "a", mimeType: "text/markdown" },
|
||||
{ uri: "custom://b", name: "b" },
|
||||
],
|
||||
}),
|
||||
readResource: (uri) => ({
|
||||
contents: [{ uri, mimeType: "text/plain", text: `body of ${uri}` }],
|
||||
}),
|
||||
});
|
||||
const client = new McpClient({ transport });
|
||||
await client.initialize();
|
||||
|
||||
const list = await client.listResources();
|
||||
expect(list.resources).toHaveLength(2);
|
||||
expect(list.resources[0]!.uri).toBe("file:///a.md");
|
||||
|
||||
const read = await client.readResource("file:///a.md");
|
||||
expect(read.contents).toHaveLength(1);
|
||||
const block = read.contents[0]!;
|
||||
expect("text" in block ? block.text : "").toBe("body of file:///a.md");
|
||||
expect(block.mimeType).toBe("text/plain");
|
||||
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("surfaces method-not-found as an error when the server lacks resources support", async () => {
|
||||
// Default FakeMcpTransport (no listResources handler) → −32601.
|
||||
const transport = new FakeMcpTransport({ tools: [] });
|
||||
const client = new McpClient({ transport });
|
||||
await client.initialize();
|
||||
await expect(client.listResources()).rejects.toThrow(/-32601/);
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("advertises resources + prompts in the initialize capabilities payload", async () => {
|
||||
const received: JsonRpcRequest[] = [];
|
||||
const transport = new FakeMcpTransport({ tools: [], received });
|
||||
const client = new McpClient({ transport });
|
||||
await client.initialize();
|
||||
const init = received.find((r) => r.method === "initialize")!;
|
||||
const params = init.params as { capabilities: Record<string, unknown> };
|
||||
// The client now claims to support all three method families.
|
||||
expect(params.capabilities).toHaveProperty("tools");
|
||||
expect(params.capabilities).toHaveProperty("resources");
|
||||
expect(params.capabilities).toHaveProperty("prompts");
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("propagates the pagination cursor to resources/list", async () => {
|
||||
const received: JsonRpcRequest[] = [];
|
||||
const transport = new FakeMcpTransport({
|
||||
tools: [],
|
||||
received,
|
||||
listResources: () => ({ resources: [], nextCursor: undefined }),
|
||||
});
|
||||
const client = new McpClient({ transport });
|
||||
await client.initialize();
|
||||
await client.listResources("page2");
|
||||
const listReq = received.find((r) => r.method === "resources/list")!;
|
||||
expect((listReq.params as { cursor?: string }).cursor).toBe("page2");
|
||||
await client.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("McpClient: prompts", () => {
|
||||
it("lists prompts and fetches a rendered one with arguments", async () => {
|
||||
const transport = new FakeMcpTransport({
|
||||
tools: [],
|
||||
capabilities: { tools: {}, prompts: { listChanged: false } },
|
||||
listPrompts: () => ({
|
||||
prompts: [
|
||||
{
|
||||
name: "summarize",
|
||||
description: "summarize a document",
|
||||
arguments: [{ name: "lang", required: true }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
getPrompt: (name, args) => ({
|
||||
description: `rendered ${name}`,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: { type: "text", text: `please summarize in ${args?.lang ?? "?"}` },
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const client = new McpClient({ transport });
|
||||
await client.initialize();
|
||||
|
||||
const list = await client.listPrompts();
|
||||
expect(list.prompts).toHaveLength(1);
|
||||
expect(list.prompts[0]!.name).toBe("summarize");
|
||||
expect(list.prompts[0]!.arguments?.[0]?.required).toBe(true);
|
||||
|
||||
const got = await client.getPrompt("summarize", { lang: "zh" });
|
||||
expect(got.messages).toHaveLength(1);
|
||||
const msg = got.messages[0]!;
|
||||
expect(msg.role).toBe("user");
|
||||
expect(msg.content.type).toBe("text");
|
||||
if (msg.content.type === "text") {
|
||||
expect(msg.content.text).toContain("zh");
|
||||
}
|
||||
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("omits the arguments field when caller passes no args", async () => {
|
||||
const received: JsonRpcRequest[] = [];
|
||||
const transport = new FakeMcpTransport({
|
||||
tools: [],
|
||||
received,
|
||||
getPrompt: () => ({ messages: [] }),
|
||||
});
|
||||
const client = new McpClient({ transport });
|
||||
await client.initialize();
|
||||
await client.getPrompt("hello");
|
||||
const getReq = received.find((r) => r.method === "prompts/get")!;
|
||||
expect(getReq.params).toEqual({ name: "hello" });
|
||||
await client.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type MultilineKey, processMultilineKey } from "../src/cli/ui/multiline-keys.js";
|
||||
|
||||
function key(overrides: Partial<MultilineKey> = {}): MultilineKey {
|
||||
return { input: "", ...overrides };
|
||||
}
|
||||
|
||||
describe("processMultilineKey", () => {
|
||||
it("appends printable input at the end", () => {
|
||||
const r = processMultilineKey("hel", key({ input: "l" }));
|
||||
expect(r).toEqual({ next: "hell", submit: false });
|
||||
});
|
||||
|
||||
it("appends a multi-char paste burst as-is", () => {
|
||||
const r = processMultilineKey("", key({ input: "line1\nline2" }));
|
||||
expect(r.next).toBe("line1\nline2");
|
||||
expect(r.submit).toBe(false);
|
||||
});
|
||||
|
||||
it("Enter on a plain buffer submits", () => {
|
||||
const r = processMultilineKey("hi", key({ return: true }));
|
||||
expect(r.submit).toBe(true);
|
||||
expect(r.submitValue).toBe("hi");
|
||||
expect(r.next).toBeNull();
|
||||
});
|
||||
|
||||
it("Enter with trailing backslash converts to newline (bash-style continuation)", () => {
|
||||
const r = processMultilineKey("line1\\", key({ return: true }));
|
||||
expect(r.submit).toBe(false);
|
||||
expect(r.next).toBe("line1\n");
|
||||
});
|
||||
|
||||
it("Shift+Enter inserts a newline without submitting", () => {
|
||||
const r = processMultilineKey("abc", key({ return: true, shift: true }));
|
||||
expect(r.submit).toBe(false);
|
||||
expect(r.next).toBe("abc\n");
|
||||
});
|
||||
|
||||
it("Ctrl+J (ASCII LF) inserts a newline", () => {
|
||||
const r = processMultilineKey("abc", key({ input: "\n" }));
|
||||
expect(r.next).toBe("abc\n");
|
||||
expect(r.submit).toBe(false);
|
||||
});
|
||||
|
||||
it("Ctrl+J reported as ctrl+'j' (normalized form) also inserts a newline", () => {
|
||||
const r = processMultilineKey("abc", key({ input: "j", ctrl: true }));
|
||||
expect(r.next).toBe("abc\n");
|
||||
expect(r.submit).toBe(false);
|
||||
});
|
||||
|
||||
it("Backspace drops one char from the end", () => {
|
||||
const r = processMultilineKey("abcd", key({ backspace: true }));
|
||||
expect(r.next).toBe("abc");
|
||||
});
|
||||
|
||||
it("Backspace on empty buffer is a no-op", () => {
|
||||
const r = processMultilineKey("", key({ backspace: true }));
|
||||
expect(r.next).toBeNull();
|
||||
expect(r.submit).toBe(false);
|
||||
});
|
||||
|
||||
it("Backspace can delete across newlines", () => {
|
||||
const r = processMultilineKey("a\nb", key({ backspace: true }));
|
||||
expect(r.next).toBe("a\n");
|
||||
const r2 = processMultilineKey(r.next!, key({ backspace: true }));
|
||||
expect(r2.next).toBe("a");
|
||||
});
|
||||
|
||||
it("Delete behaves like Backspace (cursor-at-end model)", () => {
|
||||
const r = processMultilineKey("xyz", key({ delete: true }));
|
||||
expect(r.next).toBe("xy");
|
||||
});
|
||||
|
||||
it("Tab is ignored (parent handles slash auto-complete)", () => {
|
||||
const r = processMultilineKey("/he", key({ tab: true, input: "" }));
|
||||
expect(r.next).toBeNull();
|
||||
expect(r.submit).toBe(false);
|
||||
});
|
||||
|
||||
it("Tab with '\\t' in input is still ignored", () => {
|
||||
const r = processMultilineKey("x", key({ tab: true, input: "\t" }));
|
||||
expect(r.next).toBeNull();
|
||||
});
|
||||
|
||||
it("Arrow keys are ignored (parent handles slash-nav + history)", () => {
|
||||
expect(processMultilineKey("x", key({ upArrow: true })).next).toBeNull();
|
||||
expect(processMultilineKey("x", key({ downArrow: true })).next).toBeNull();
|
||||
expect(processMultilineKey("x", key({ leftArrow: true })).next).toBeNull();
|
||||
expect(processMultilineKey("x", key({ rightArrow: true })).next).toBeNull();
|
||||
});
|
||||
|
||||
it("Escape is ignored (parent handles abort)", () => {
|
||||
const r = processMultilineKey("x", key({ escape: true }));
|
||||
expect(r.next).toBeNull();
|
||||
});
|
||||
|
||||
it("Ctrl+<letter> that isn't j is dropped (no accidental text insert)", () => {
|
||||
const r = processMultilineKey("x", key({ input: "c", ctrl: true }));
|
||||
expect(r.next).toBeNull();
|
||||
});
|
||||
|
||||
it("Meta (Alt) key events are dropped", () => {
|
||||
const r = processMultilineKey("x", key({ input: "a", meta: true }));
|
||||
expect(r.next).toBeNull();
|
||||
});
|
||||
|
||||
it("Plain return on an empty buffer still submits (empty message case handled upstream)", () => {
|
||||
const r = processMultilineKey("", key({ return: true }));
|
||||
expect(r.submit).toBe(true);
|
||||
expect(r.submitValue).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -47,4 +47,33 @@ describe("ToolCallRepair pipeline", () => {
|
||||
expect(calls.length).toBe(1);
|
||||
expect(report.scavenged).toBe(0);
|
||||
});
|
||||
|
||||
it("scavenges DSML tool calls from the content channel (regular turn, not just reasoning)", () => {
|
||||
// R1 sometimes emits the DSML envelope in the content stream
|
||||
// instead of the proper tool_calls field. Before this wire-up,
|
||||
// the model's intent was silently dropped.
|
||||
const repair = new ToolCallRepair({
|
||||
allowedToolNames: new Set(["filesystem_read_file"]),
|
||||
});
|
||||
const content = [
|
||||
"I'll read the file next.",
|
||||
'<|DSML|invoke name="filesystem_read_file">',
|
||||
' <|DSML|parameter name="path" string="true">README.md</|DSML|parameter>',
|
||||
"</|DSML|invoke>",
|
||||
].join("\n");
|
||||
const { calls, report } = repair.process([], null, content);
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0]!.function.name).toBe("filesystem_read_file");
|
||||
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: "README.md" });
|
||||
expect(report.scavenged).toBe(1);
|
||||
});
|
||||
|
||||
it("does not double-count when DSML appears in both reasoning and content", () => {
|
||||
const repair = new ToolCallRepair({ allowedToolNames: new Set(["search"]) });
|
||||
const dsml =
|
||||
'<|DSML|invoke name="search"><|DSML|parameter name="q" string="true">ts</|DSML|parameter></|DSML|invoke>';
|
||||
const { calls, report } = repair.process([], dsml, dsml);
|
||||
expect(calls.length).toBe(1);
|
||||
expect(report.scavenged).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -44,4 +44,60 @@ describe("scavengeToolCalls", () => {
|
||||
const r = scavengeToolCalls(reasoning, { allowedNames: allowed, maxCalls: 2 });
|
||||
expect(r.calls.length).toBe(2);
|
||||
});
|
||||
|
||||
it("extracts a DSML invoke block with string + JSON parameters", () => {
|
||||
const dsmlAllowed = new Set(["filesystem_edit_file"]);
|
||||
const input = [
|
||||
"Let me make the edit.",
|
||||
"",
|
||||
'<|DSML|function_calls> <|DSML|invoke name="filesystem_edit_file">',
|
||||
' <|DSML|parameter name="path" string="true">F:/x.html</|DSML|parameter>',
|
||||
' <|DSML|parameter name="edits" string="false">[{"oldText":"a","newText":"b"}]</|DSML|parameter>',
|
||||
"</|DSML|invoke> </|DSML|function_calls>",
|
||||
].join("\n");
|
||||
const r = scavengeToolCalls(input, { allowedNames: dsmlAllowed });
|
||||
expect(r.calls.length).toBe(1);
|
||||
const call = r.calls[0]!;
|
||||
expect(call.function.name).toBe("filesystem_edit_file");
|
||||
const args = JSON.parse(call.function.arguments);
|
||||
expect(args.path).toBe("F:/x.html");
|
||||
expect(args.edits).toEqual([{ oldText: "a", newText: "b" }]);
|
||||
expect(r.notes[0]).toMatch(/DSML/);
|
||||
});
|
||||
|
||||
it("accepts ASCII pipe DSML variant too", () => {
|
||||
const dsmlAllowed = new Set(["search"]);
|
||||
const input =
|
||||
'<|DSML|invoke name="search"><|DSML|parameter name="q" string="true">ts</|DSML|parameter></|DSML|invoke>';
|
||||
const r = scavengeToolCalls(input, { allowedNames: dsmlAllowed });
|
||||
expect(r.calls.length).toBe(1);
|
||||
expect(JSON.parse(r.calls[0]!.function.arguments)).toEqual({ q: "ts" });
|
||||
});
|
||||
|
||||
it("DSML call with unknown tool name is skipped (allow-list guards us)", () => {
|
||||
const input =
|
||||
'<|DSML|invoke name="rm_rf_slash"><|DSML|parameter name="x" string="true">y</|DSML|parameter></|DSML|invoke>';
|
||||
const r = scavengeToolCalls(input, { allowedNames: allowed });
|
||||
expect(r.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("DSML 'string=false' with malformed JSON falls back to literal text (loses no data)", () => {
|
||||
const dsmlAllowed = new Set(["search"]);
|
||||
const input =
|
||||
'<|DSML|invoke name="search"><|DSML|parameter name="q" string="false">not valid json</|DSML|parameter></|DSML|invoke>';
|
||||
const r = scavengeToolCalls(input, { allowedNames: dsmlAllowed });
|
||||
expect(r.calls.length).toBe(1);
|
||||
expect(JSON.parse(r.calls[0]!.function.arguments)).toEqual({ q: "not valid json" });
|
||||
});
|
||||
|
||||
it("does not double-count: JSON args inside a DSML block don't become separate calls", () => {
|
||||
const dsmlAllowed = new Set(["filesystem_edit_file"]);
|
||||
// The inner JSON is a param value, not a standalone scavenge target.
|
||||
const input =
|
||||
'<|DSML|invoke name="filesystem_edit_file"><|DSML|parameter name="edits" string="false">{"name": "filesystem_edit_file", "arguments": {}}</|DSML|parameter></|DSML|invoke>';
|
||||
const r = scavengeToolCalls(input, { allowedNames: dsmlAllowed });
|
||||
// Expect exactly one call — the DSML wrapper. If Pattern B also
|
||||
// fired on the inner JSON we'd see two.
|
||||
expect(r.calls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
+310
-3
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { handleSlash, parseSlash } from "../src/cli/ui/slash.js";
|
||||
import { DeepSeekClient } from "../src/client.js";
|
||||
import {
|
||||
SLASH_COMMANDS,
|
||||
handleSlash,
|
||||
parseSlash,
|
||||
suggestSlashCommands,
|
||||
} from "../src/cli/ui/slash.js";
|
||||
import { DeepSeekClient, Usage } from "../src/client.js";
|
||||
import { CacheFirstLoop } from "../src/loop.js";
|
||||
import { ImmutablePrefix } from "../src/memory.js";
|
||||
|
||||
@@ -49,7 +54,9 @@ describe("handleSlash", () => {
|
||||
it("/status reflects current loop config", () => {
|
||||
const loop = makeLoop();
|
||||
const r = handleSlash("status", [], loop);
|
||||
expect(r.info).toMatch(/model=/);
|
||||
// New format: indented table with labeled rows ("model", "flags",
|
||||
// "ctx", "mcp", "session"). Harvest/branch live on the flags row.
|
||||
expect(r.info).toMatch(/model\s+deepseek-/);
|
||||
expect(r.info).toMatch(/harvest=off/);
|
||||
expect(r.info).toMatch(/branch=off/);
|
||||
});
|
||||
@@ -228,6 +235,306 @@ describe("handleSlash", () => {
|
||||
expect(r.info).toMatch(/\/setup/);
|
||||
});
|
||||
|
||||
it("/undo outside code mode says it's not available", () => {
|
||||
const r = handleSlash("undo", [], makeLoop());
|
||||
expect(r.info).toMatch(/only available inside .reasonix code/);
|
||||
});
|
||||
|
||||
it("/undo in code mode invokes the callback", () => {
|
||||
const r = handleSlash("undo", [], makeLoop(), {
|
||||
codeUndo: () => "▸ restored 2 file(s)",
|
||||
});
|
||||
expect(r.info).toMatch(/restored 2 file/);
|
||||
});
|
||||
|
||||
it("/help mentions /undo and /commit", () => {
|
||||
const r = handleSlash("help", [], makeLoop());
|
||||
expect(r.info).toMatch(/\/undo/);
|
||||
expect(r.info).toMatch(/\/commit/);
|
||||
});
|
||||
|
||||
it("/commit outside code mode says it's not available", () => {
|
||||
const r = handleSlash("commit", ["foo"], makeLoop());
|
||||
expect(r.info).toMatch(/only available inside .reasonix code/);
|
||||
});
|
||||
|
||||
it("/commit with no message prints usage", () => {
|
||||
const r = handleSlash("commit", [], makeLoop(), { codeRoot: "/tmp" });
|
||||
expect(r.info).toMatch(/usage: \/commit/);
|
||||
});
|
||||
|
||||
it("/apply outside code mode says it's not available", () => {
|
||||
const r = handleSlash("apply", [], makeLoop());
|
||||
expect(r.info).toMatch(/only available inside .reasonix code/);
|
||||
});
|
||||
|
||||
it("/apply in code mode invokes the callback", () => {
|
||||
const r = handleSlash("apply", [], makeLoop(), {
|
||||
codeApply: () => "▸ 2/2 edits applied",
|
||||
});
|
||||
expect(r.info).toMatch(/2\/2 edits applied/);
|
||||
});
|
||||
|
||||
it("/discard outside code mode says it's not available", () => {
|
||||
const r = handleSlash("discard", [], makeLoop());
|
||||
expect(r.info).toMatch(/only available inside .reasonix code/);
|
||||
});
|
||||
|
||||
it("/discard in code mode invokes the callback", () => {
|
||||
const r = handleSlash("discard", [], makeLoop(), {
|
||||
codeDiscard: () => "▸ discarded 3 pending",
|
||||
});
|
||||
expect(r.info).toMatch(/discarded 3 pending/);
|
||||
});
|
||||
|
||||
it("/help mentions /apply and /discard", () => {
|
||||
const r = handleSlash("help", [], makeLoop());
|
||||
expect(r.info).toMatch(/\/apply/);
|
||||
expect(r.info).toMatch(/\/discard/);
|
||||
});
|
||||
|
||||
it("/think says no reasoning cached when scratch is empty", () => {
|
||||
const r = handleSlash("think", [], makeLoop());
|
||||
expect(r.info).toMatch(/no reasoning cached/);
|
||||
});
|
||||
|
||||
it("/think dumps the full reasoning when scratch has content", () => {
|
||||
const loop = makeLoop();
|
||||
loop.scratch.reasoning = "lots of R1 deliberation here over many sentences";
|
||||
const r = handleSlash("think", [], loop);
|
||||
expect(r.info).toMatch(/full thinking/);
|
||||
expect(r.info).toContain("lots of R1 deliberation");
|
||||
});
|
||||
|
||||
it("/help mentions /think", () => {
|
||||
const r = handleSlash("help", [], makeLoop());
|
||||
expect(r.info).toMatch(/\/think/);
|
||||
});
|
||||
|
||||
it("/retry returns info + resubmit when there's a prior user message", () => {
|
||||
const loop = makeLoop();
|
||||
loop.log.append({ role: "user", content: "hello" });
|
||||
loop.log.append({ role: "assistant", content: "hi back" });
|
||||
const r = handleSlash("retry", [], loop);
|
||||
expect(r.resubmit).toBe("hello");
|
||||
expect(r.info).toMatch(/retrying/);
|
||||
// After retry, the log should be empty (last user message and
|
||||
// everything after were dropped; user will be re-pushed on next
|
||||
// successful turn).
|
||||
expect(loop.log.length).toBe(0);
|
||||
});
|
||||
|
||||
it("/retry says nothing to retry when log has no user messages", () => {
|
||||
const r = handleSlash("retry", [], makeLoop());
|
||||
expect(r.info).toMatch(/nothing to retry/);
|
||||
expect(r.resubmit).toBeUndefined();
|
||||
});
|
||||
|
||||
it("/help mentions /retry", () => {
|
||||
const r = handleSlash("help", [], makeLoop());
|
||||
expect(r.info).toMatch(/\/retry/);
|
||||
});
|
||||
|
||||
it("/tool with no history says none yet", () => {
|
||||
const r = handleSlash("tool", [], makeLoop());
|
||||
expect(r.info).toMatch(/no tool calls yet/);
|
||||
});
|
||||
|
||||
it("/tool with history lists entries, most recent first", () => {
|
||||
const r = handleSlash("tool", [], makeLoop(), {
|
||||
toolHistory: () => [
|
||||
{ toolName: "fs_read_file", text: "old content" },
|
||||
{ toolName: "fs_list_directory", text: "newer content" },
|
||||
],
|
||||
});
|
||||
expect(r.info).toMatch(/Tool calls in this session \(2/);
|
||||
// Most recent is #1 — the list must order so fs_list_directory
|
||||
// (newer) appears before fs_read_file (older).
|
||||
const idxNewer = r.info!.indexOf("fs_list_directory");
|
||||
const idxOlder = r.info!.indexOf("fs_read_file");
|
||||
expect(idxNewer).toBeGreaterThan(-1);
|
||||
expect(idxOlder).toBeGreaterThan(idxNewer);
|
||||
});
|
||||
|
||||
it("/tool N dumps the Nth-most-recent tool output in full", () => {
|
||||
const big = "X".repeat(2000);
|
||||
const r = handleSlash("tool", ["1"], makeLoop(), {
|
||||
toolHistory: () => [
|
||||
{ toolName: "fs_read_file", text: "older" },
|
||||
{ toolName: "fs_list_directory", text: big },
|
||||
],
|
||||
});
|
||||
expect(r.info).toMatch(/tool<fs_list_directory>/);
|
||||
expect(r.info).toMatch(/2000 chars/);
|
||||
// Full 2000 X's included — not truncated.
|
||||
expect(r.info).toContain(big);
|
||||
});
|
||||
|
||||
it("/tool 2 reaches one call back from the most recent", () => {
|
||||
const r = handleSlash("tool", ["2"], makeLoop(), {
|
||||
toolHistory: () => [
|
||||
{ toolName: "fs_read_file", text: "target" },
|
||||
{ toolName: "fs_list_directory", text: "most recent" },
|
||||
],
|
||||
});
|
||||
expect(r.info).toMatch(/tool<fs_read_file>/);
|
||||
expect(r.info).toContain("target");
|
||||
});
|
||||
|
||||
it("/tool N past history length reports bounds", () => {
|
||||
const r = handleSlash("tool", ["5"], makeLoop(), {
|
||||
toolHistory: () => [{ toolName: "fs_read_file", text: "one" }],
|
||||
});
|
||||
expect(r.info).toMatch(/only 1 tool call/);
|
||||
});
|
||||
|
||||
it("/tool with non-numeric arg returns usage", () => {
|
||||
const r = handleSlash("tool", ["huh"], makeLoop(), {
|
||||
toolHistory: () => [{ toolName: "fs_read_file", text: "one" }],
|
||||
});
|
||||
expect(r.info).toMatch(/usage: \/tool/);
|
||||
});
|
||||
|
||||
it("/tool list trims the display to 10 most recent but hints at older", () => {
|
||||
const many = Array.from({ length: 15 }, (_, i) => ({
|
||||
toolName: `t${i}`,
|
||||
text: `call-${i}`,
|
||||
}));
|
||||
const r = handleSlash("tool", [], makeLoop(), { toolHistory: () => many });
|
||||
// Most recent (#1 = t14) and 10th back (#10 = t5) should be shown.
|
||||
expect(r.info).toContain("t14");
|
||||
expect(r.info).toContain("t5");
|
||||
// t4 would be #11 — beyond the first-page cutoff.
|
||||
expect(r.info).toMatch(/5 earlier.*reach with \/tool N/);
|
||||
});
|
||||
|
||||
it("/help mentions /tool", () => {
|
||||
const r = handleSlash("help", [], makeLoop());
|
||||
expect(r.info).toMatch(/\/tool/);
|
||||
});
|
||||
|
||||
it("SLASH_COMMANDS registry contains every handler switch case", () => {
|
||||
// Spot-check a handful so the registry doesn't silently drift
|
||||
// from `handleSlash`. If a new case lands in handleSlash, it
|
||||
// should also show up in suggestions — bump this list when
|
||||
// adding.
|
||||
const names = SLASH_COMMANDS.map((s) => s.cmd);
|
||||
for (const required of [
|
||||
"help",
|
||||
"status",
|
||||
"preset",
|
||||
"model",
|
||||
"branch",
|
||||
"mcp",
|
||||
"tool",
|
||||
"think",
|
||||
"retry",
|
||||
"compact",
|
||||
"sessions",
|
||||
"clear",
|
||||
"exit",
|
||||
"apply",
|
||||
"discard",
|
||||
"undo",
|
||||
"commit",
|
||||
]) {
|
||||
expect(names, `registry missing /${required}`).toContain(required);
|
||||
}
|
||||
});
|
||||
|
||||
it("suggestSlashCommands filters by prefix", () => {
|
||||
expect(suggestSlashCommands("h").map((s) => s.cmd)).toEqual(["help", "harvest"]);
|
||||
// Case-insensitive.
|
||||
expect(suggestSlashCommands("HE").map((s) => s.cmd)).toEqual(["help"]);
|
||||
// Empty prefix returns everything (non-contextual).
|
||||
expect(suggestSlashCommands("").length).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it("suggestSlashCommands hides code-mode-only entries when codeMode=false", () => {
|
||||
const names = suggestSlashCommands("", false).map((s) => s.cmd);
|
||||
expect(names).not.toContain("apply");
|
||||
expect(names).not.toContain("undo");
|
||||
});
|
||||
|
||||
it("suggestSlashCommands shows code-mode-only entries when codeMode=true", () => {
|
||||
const names = suggestSlashCommands("", true).map((s) => s.cmd);
|
||||
expect(names).toContain("apply");
|
||||
expect(names).toContain("undo");
|
||||
});
|
||||
|
||||
it("/mcp with mcpServers renders per-server tools+resources+prompts", () => {
|
||||
const r = handleSlash("mcp", [], makeLoop(), {
|
||||
mcpServers: [
|
||||
{
|
||||
label: "fs",
|
||||
spec: "fs=npx -y @scope/fs /tmp",
|
||||
toolCount: 4,
|
||||
report: {
|
||||
protocolVersion: "2024-11-05",
|
||||
serverInfo: { name: "fs-server", version: "1.0.0" },
|
||||
capabilities: { tools: {}, resources: {} },
|
||||
tools: { supported: true, items: [] },
|
||||
resources: {
|
||||
supported: true,
|
||||
items: [
|
||||
{ uri: "file:///a", name: "docs" },
|
||||
{ uri: "file:///b", name: "readme" },
|
||||
],
|
||||
},
|
||||
prompts: { supported: false, reason: "method not found (-32601)" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(r.info).toMatch(/\[fs\].*fs-server v1\.0\.0/);
|
||||
expect(r.info).toMatch(/tools\s+4/);
|
||||
expect(r.info).toMatch(/resources\s+2\s+\[docs, readme\]/);
|
||||
expect(r.info).toMatch(/prompts\s+\(not supported\)/);
|
||||
});
|
||||
|
||||
it("/mcp falls back to the spec-only list when mcpServers is absent", () => {
|
||||
const r = handleSlash("mcp", [], makeLoop(), {
|
||||
mcpSpecs: ["filesystem=npx -y @scope/fs /tmp"],
|
||||
});
|
||||
expect(r.info).toMatch(/MCP servers \(1\)/);
|
||||
expect(r.info).toMatch(/server-filesystem|fs/);
|
||||
});
|
||||
|
||||
it("/status shows ctx / session / mcp / pending lines with rich detail", () => {
|
||||
const loop = makeLoop();
|
||||
// Make it look like one turn ran so lastPromptTokens > 0.
|
||||
loop.stats.record(1, loop.model, new Usage(42_000, 50, 42_050, 40_000, 2_000));
|
||||
loop.log.append({ role: "user", content: "hi" });
|
||||
loop.log.append({ role: "assistant", content: "there" });
|
||||
const r = handleSlash("status", [], loop, {
|
||||
mcpSpecs: ["filesystem=npx -y @scope/fs /tmp", "mem=npx -y @scope/mem"],
|
||||
pendingEditCount: 3,
|
||||
});
|
||||
expect(r.info).toMatch(/model\s+deepseek-/);
|
||||
expect(r.info).toMatch(/ctx\s+\d+\.?\d*k?\/\d+k/);
|
||||
expect(r.info).toMatch(/mcp\s+2 server\(s\)/);
|
||||
expect(r.info).toMatch(/session.*\(ephemeral|session\s+"/);
|
||||
expect(r.info).toMatch(/edits\s+3 pending/);
|
||||
});
|
||||
|
||||
it("/status with pendingEditCount=0 hides the edits line", () => {
|
||||
const r = handleSlash("status", [], makeLoop(), { pendingEditCount: 0 });
|
||||
expect(r.info).not.toMatch(/pending/);
|
||||
});
|
||||
|
||||
it("/commit strips surrounding double quotes from the message", () => {
|
||||
// We can't exercise git without a real repo; instead, rely on the
|
||||
// fact that /commit fails (no git repo at /nonexistent) but the
|
||||
// failure output should reveal the stripped message in the
|
||||
// arguments we passed. We mirror this by just confirming usage
|
||||
// ISN'T printed — meaning the parser accepted a non-empty message.
|
||||
const r = handleSlash("commit", ['"fix: tests"'], makeLoop(), { codeRoot: "/nonexistent" });
|
||||
expect(r.info).not.toMatch(/usage: \/commit/);
|
||||
// It WILL say git failed since /nonexistent isn't a git repo, but
|
||||
// we don't assert the exact message — it varies by platform.
|
||||
expect(r.info).toMatch(/git (add|commit) failed/);
|
||||
});
|
||||
|
||||
it("/sessions returns a hint when none exist", () => {
|
||||
const r = handleSlash("sessions", [], makeLoop());
|
||||
expect(r.info).toMatch(/no saved sessions yet|Saved sessions/);
|
||||
|
||||
Reference in new issue
Block a user