feat: add undo functionality to revert the last turn and restore file changes
- Implemented `/undo` command to revert the most recent completed turn, restoring all modified files and rewinding message history. - Introduced `FileMutation` type to capture file changes for undo operations. - Enhanced session management to track and manage undo stack with a maximum of 20 turns. - Updated command parsing to include user-defined commands from `.shiro/commands.md`, allowing for custom command definitions and substitutions. - Added documentation for the new `/undo` command and user commands feature. - Implemented tests for undo functionality and user command parsing to ensure reliability.
This commit is contained in:
@@ -145,10 +145,14 @@ Type `/` and a menu appears, narrowing as you type.
|
||||
```
|
||||
/help /agent [name] /think [level] /provider /models /model <id>
|
||||
/skills /plugins /registry [search|add|remove] /init /context
|
||||
/todos /notes /memory /tools /compact /cost
|
||||
/todos /notes /memory /tools /compact /undo /cost /max-spend [usd]
|
||||
/sessions /resume <id> /save /clear /exit
|
||||
```
|
||||
|
||||
Your own slash commands live in `.shiro/commands.md` — see
|
||||
[docs/user-commands.md](docs/user-commands.md). `/undo` restores the files a
|
||||
completed turn changed and rewinds — see [docs/undo.md](docs/undo.md).
|
||||
|
||||
`esc` dismisses a panel, interrupts a running turn, and clears the queue. `ctrl-c` kills the
|
||||
running command but keeps the turn. `ctrl-r` expands the reasoning panel. `@` completes a
|
||||
workspace path. Up and down recall earlier prompts.
|
||||
|
||||
+19
-18
@@ -192,36 +192,37 @@ registration as an option: for a two-tool server the indirection is the more exp
|
||||
|
||||
### Custom commands from a file
|
||||
|
||||
A markdown file becoming a slash command, with `$ARGUMENTS`, `$1`, `` !`cmd` `` for shell output,
|
||||
and `@path` for a file. Every comparable CLI has this and none of it is hard; it is missing because
|
||||
nothing forced the issue.
|
||||
DONE — `.shiro/commands.md` defines slash commands with `$ARGUMENTS`, `$1..$9`, `` !`cmd` `` for
|
||||
shell output inline, and `@path` to pull a file in. They appear in the `/` menu and are shadowed
|
||||
by a built-in of the same name. See [commands](docs/user-commands.md).
|
||||
|
||||
### Undo a turn
|
||||
|
||||
opencode has `/undo` and `/redo`, Claude Code has `/rewind` over file checkpoints. There is
|
||||
`/resume` here, which restores a whole session, and nothing that steps one turn back. The honest
|
||||
limit is the same for everyone: a `bash` command's effects cannot be snapshotted, so this covers
|
||||
file-tool edits and says so.
|
||||
DONE — `/undo` restores the files a completed turn changed and rewinds the message history to the
|
||||
start of that turn. Files are snapshotted before each mutating tool writes, so the pre-write bytes
|
||||
are always recoverable; moves, deletes, and creates are handled. The honest limit stands: a `bash`
|
||||
command's effects cannot be snapshotted, so `/undo` covers file-tool edits and says so. See
|
||||
[undo](docs/undo.md).
|
||||
|
||||
### Lossless-enough compaction
|
||||
|
||||
Compaction keeps the model's memory of a turn now, but it still says nothing about the messages it
|
||||
discarded, so the model can contradict its own earlier decision with confidence. A summary of the
|
||||
discarded span costs one cheap call and removes the whole class of problem.
|
||||
DONE — when a prune drops a span, it is summarised in one cheap call and injected back as a note
|
||||
so the model keeps the gist of its own earlier decisions. Best-effort: a summary that fails never
|
||||
breaks the turn. See [architecture](docs/architecture.md#where-state-lives).
|
||||
|
||||
### Cost control
|
||||
|
||||
Two halves of the same problem were itched here: an `explore` subagent paying the parent's
|
||||
reasoning rate for what is really a search, and nothing stopping a headless run that loops. The
|
||||
first is done — `subagentModel` config lets a `task` subagent run on a cheaper model
|
||||
([agents](agents.md#subagent-model)). What remains is the headless loop: there is still no
|
||||
per-session ceiling.
|
||||
Both halves are now done — `subagentModel` for cheaper subagents, and a `maxSpendUsd` per-run
|
||||
ceiling that stops a runaway loop at the next model call, adjustable live with `/max-spend`, shown
|
||||
in `/cost`. An unpriced model never trips the ceiling (it is a guard, not a bill). See
|
||||
[configuration](docs/configuration.md).
|
||||
|
||||
### Derived tool metadata
|
||||
|
||||
`TOOL_SETS` and `MUTATING_TOOLS` are hand-maintained lists of tool names. A tool added to one
|
||||
and forgotten in the other is a silently ungated write. Marking each tool where it is defined,
|
||||
and checking the coverage in the suite, removes the failure mode rather than documenting it.
|
||||
DONE — `TOOL_META` declares each built-in tool's effect (read / mutate / net, with git tools
|
||||
read-only and net tools folded in), and the suite asserts every registered tool is classified and
|
||||
that `MUTATING_TOOLS` exactly matches the mutate set. See
|
||||
[tool metadata](docs/tool-meta.md).
|
||||
|
||||
### Registry trust
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ Written by `/provider`, editable by hand. Every field is optional.
|
||||
| `permission` | which calls run, ask, or are refused, matched per command or path. See [permissions](permissions.md) |
|
||||
| `registryUrl` | index for `/registry`. Omit for the default. See [registry](registry.md) |
|
||||
| `subagentModel` | model used for `task` subagents. Omit to reuse the parent model. A cheaper model here cuts subagent cost (and latency) sharply for read-only searches. See [agents](agents.md) |
|
||||
| `maxSpendUsd` | optional ceiling on estimated spend per session; a run that crosses it stops at the next model call. Adjustable live with `/max-spend`. See [cost control](#cost-control) |
|
||||
| `mcpServers` | see [MCP](mcp.md) |
|
||||
|
||||
## Provider presets
|
||||
@@ -235,3 +236,13 @@ the outcome, since later rules win. See [permissions](permissions.md).
|
||||
**`registryUrl`** is the whole trust decision for installed skills and plugins. There are no
|
||||
signatures, so pointing it at an index means trusting whoever controls that URL — including for
|
||||
whatever they publish later. See [registry](registry.md).
|
||||
|
||||
## Cost control
|
||||
|
||||
Set `"maxSpendUsd"` to give a session a ceiling on estimated spend. Spend is estimated from the
|
||||
billed model's published rates, so an unpriced model never trips the ceiling — and the ceiling is
|
||||
a guard against a runaway loop, not a billing source. When cross the session stops at the next
|
||||
model call with a notice.
|
||||
|
||||
View the ceiling and current spend with `/cost`; raise, lower, or clear it live with `/max-spend`.
|
||||
`/cost` shows how close the run is to the ceiling alongside its token count.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Undo
|
||||
|
||||
`/undo` reverts the most recent completed turn: it restores every file that turn
|
||||
changed and rewinds the message history to the point before the turn began. It
|
||||
can step back through up to 20 turns.
|
||||
|
||||
## How it works
|
||||
|
||||
Before a file-mutating tool (`write_file`, `edit_file`, `multi_edit`,
|
||||
`apply_patch`) writes, it captures the current bytes of every file it is about
|
||||
to touch. That snapshot is stored against the in-flight turn. When the turn
|
||||
finishes (having changed at least one file and added at least one message), the
|
||||
snapshots are pushed onto an undo log.
|
||||
|
||||
`/undo` pops the log and restores:
|
||||
|
||||
- a file that was edited — back to its prior contents;
|
||||
- a file that was created — deleted;
|
||||
- a file that was deleted — recreated with its prior contents;
|
||||
- a move — the source restored and the moved copy removed.
|
||||
|
||||
Then it truncates the message history to the length it had when that turn
|
||||
started, so the model no longer sees the reverted decisions.
|
||||
|
||||
## The honest limit
|
||||
|
||||
A `bash` command's effects cannot be snapshotted — a network call, a build
|
||||
artifact, or a `git push` are not reversible by restoring file bytes. So `/undo`
|
||||
covers file-tool edits (including `apply_patch` moves/deletes) and says so; it
|
||||
does not pretend to reverse commands you ran via `bash`.
|
||||
@@ -0,0 +1,36 @@
|
||||
# User commands
|
||||
|
||||
Define your own slash commands in a markdown file: `.shiro/commands.md` at the
|
||||
workspace root. Each command is a `## <name>` heading; an optional `> summary`
|
||||
line under it becomes the `/`-menu description; the body is the prompt template
|
||||
sent to the model.
|
||||
|
||||
```markdown
|
||||
# My commands
|
||||
|
||||
## review
|
||||
> review a change against the project rules
|
||||
|
||||
Read @AGENTS.md first, then critique:
|
||||
$ARGUMENTS
|
||||
|
||||
## scaffold
|
||||
> make a new module
|
||||
|
||||
Create a module named $1 in src/, with a test.
|
||||
```
|
||||
|
||||
The body supports these substitutions:
|
||||
|
||||
- `$ARGUMENTS` — everything typed after the command name, verbatim.
|
||||
- `$1` .. `$9` — the nth whitespace-separated argument (empty when absent).
|
||||
- `` !`cmd` `` — replaced with the trimmed stdout of running `cmd` in a shell.
|
||||
- `@path` — replaced with the contents of the file at `path` (workspace-rooted).
|
||||
|
||||
Substitutions apply in a safe order: shell reads first, then files, then `$n`
|
||||
tags, so text a shell call produces is not itself re-read. A missing file or
|
||||
failing shell keeps its literal text plus a bracketed note rather than throwing.
|
||||
|
||||
A user command whose name collides with a built-in is shadowed — the built-in
|
||||
wins — so a project cannot hijack `/model` or `/help`. Type `/name` to run one;
|
||||
it is also listed in the `/` completion menu.
|
||||
@@ -19,6 +19,7 @@ import { Session } from './session';
|
||||
import { loadSkills } from './skills';
|
||||
import * as store from './store';
|
||||
import { createTaskTool, type SubagentApproval } from './subagent';
|
||||
import { expandCommand, loadUserCommands, type UserCommand } from './usercommands';
|
||||
import { VERSION, versionLine } from './version';
|
||||
import { createAskBridge } from './ui/Ask';
|
||||
import { App, createApprovalBridge, createNoticeBus, createSubagentBus, type AppHooks } from './ui/App';
|
||||
@@ -152,6 +153,7 @@ if (resumeArg) {
|
||||
const mcp = has('--no-mcp') || !cfg.mcpServers ? undefined : await connectMcp(cfg.mcpServers);
|
||||
const instructions = has('--no-instructions') ? [] : await loadInstructions();
|
||||
const skills = has('--no-skills') ? [] : await loadSkills();
|
||||
const userCommands: UserCommand[] = await loadUserCommands(process.cwd());
|
||||
const promptHistory = await store.loadHistory();
|
||||
|
||||
const installedPlugins = has('--no-plugins') ? { plugins: [], errors: [] } : await registry.loadInstalledPlugins();
|
||||
@@ -277,6 +279,7 @@ const session = new Session({
|
||||
...(memory ? { memory } : {}),
|
||||
...(record.notebook ? { notebook: record.notebook } : {}),
|
||||
...(cfg.maxRetries !== undefined ? { maxRetries: cfg.maxRetries } : {}),
|
||||
...(cfg.maxSpendUsd !== undefined ? { maxSpendUsd: cfg.maxSpendUsd, spendModel: cfg.model } : {}),
|
||||
extraTools: {
|
||||
...(mcp?.tools ?? {}),
|
||||
...(has('--no-subagent')
|
||||
@@ -394,6 +397,12 @@ const hooks: AppHooks = {
|
||||
initPrompt: INIT_PROMPT,
|
||||
history: promptHistory,
|
||||
recordPrompt: (text) => void store.appendHistory(text),
|
||||
listUserCommands: () => userCommands,
|
||||
expandUserCommand: async (name, args) => {
|
||||
const cmd = userCommands.find((c) => c.name === name);
|
||||
if (!cmd) throw new Error(`No custom command "${name}" in .shiro/commands.md`);
|
||||
return expandCommand(cmd, args, process.cwd());
|
||||
},
|
||||
agentName: () => session.agent().name,
|
||||
thinkingLevel: () => session.agent().thinking,
|
||||
switchModel: (id) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ export type CommandAction =
|
||||
| { type: 'exit' }
|
||||
| { type: 'clear' }
|
||||
| { type: 'compact' }
|
||||
| { type: 'undo' }
|
||||
| { type: 'tools' }
|
||||
| { type: 'cost' }
|
||||
| { type: 'sessions' }
|
||||
@@ -20,6 +21,7 @@ export type CommandAction =
|
||||
| { type: 'memory' }
|
||||
| { type: 'agent'; agent?: string }
|
||||
| { type: 'think'; level?: string }
|
||||
| { type: 'max-spend'; usd?: number }
|
||||
| { type: 'info'; text: string }
|
||||
| { type: 'model'; model: string }
|
||||
| { type: 'resume'; id: string }
|
||||
@@ -51,7 +53,9 @@ export const COMMANDS: CommandSpec[] = [
|
||||
{ name: 'memory', summary: 'compact the project memory with the model' },
|
||||
{ name: 'tools', summary: 'list available tools' },
|
||||
{ name: 'compact', summary: 'replace history with a model-written summary' },
|
||||
{ name: 'undo', summary: 'revert the last turn: restore its files and rewind' },
|
||||
{ name: 'cost', summary: 'tokens and estimated spend this session' },
|
||||
{ name: 'max-spend', arg: '[usd]', summary: 'show or set the run spend ceiling (empty to clear)' },
|
||||
{ name: 'sessions', summary: 'list saved sessions' },
|
||||
{ name: 'resume', arg: '<id>', summary: 'load a saved session' },
|
||||
{ name: 'save', summary: 'write the session to disk now' },
|
||||
@@ -147,6 +151,8 @@ export function parseCommand(raw: string): CommandAction {
|
||||
return { type: 'clear' };
|
||||
case 'compact':
|
||||
return { type: 'compact' };
|
||||
case 'undo':
|
||||
return { type: 'undo' };
|
||||
case 'tools':
|
||||
return { type: 'tools' };
|
||||
case 'cost':
|
||||
@@ -180,6 +186,12 @@ export function parseCommand(raw: string): CommandAction {
|
||||
return arg ? { type: 'agent', agent: arg } : { type: 'agent' };
|
||||
case 'think':
|
||||
return arg ? { type: 'think', level: arg } : { type: 'think' };
|
||||
case 'max-spend': {
|
||||
if (!arg) return { type: 'max-spend' };
|
||||
const usd = Number(arg);
|
||||
if (Number.isFinite(usd) && usd > 0) return { type: 'max-spend', usd };
|
||||
return { type: 'info', text: 'usage: /max-spend <usd> — a positive number, or bare /max-spend to show the ceiling' };
|
||||
}
|
||||
case 'model':
|
||||
return arg ? { type: 'model', model: arg } : { type: 'models' };
|
||||
case 'resume':
|
||||
|
||||
@@ -33,6 +33,8 @@ export type Config = {
|
||||
permission?: PermissionConfig;
|
||||
/** Model used for subagent (task tool) calls. Omit to use the parent's model. */
|
||||
subagentModel?: string;
|
||||
/** Bag optional max estimated USD spend for one session/run. Stops a runaway run. */
|
||||
maxSpendUsd?: number;
|
||||
/** Index for `/registry`. Omit for the default one. */
|
||||
registryUrl?: string;
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
@@ -116,6 +118,7 @@ export async function loadConfig(): Promise<Config> {
|
||||
...(typeof file.registryUrl === 'string' ? { registryUrl: file.registryUrl } : {}),
|
||||
...(file.mcpServers ? { mcpServers: file.mcpServers } : {}),
|
||||
...(typeof file.subagentModel === 'string' ? { subagentModel: file.subagentModel } : {}),
|
||||
...(typeof file.maxSpendUsd === 'number' && file.maxSpendUsd > 0 ? { maxSpendUsd: file.maxSpendUsd } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+143
-2
@@ -16,8 +16,10 @@ import { Permissions, type PermissionConfig } from './permission';
|
||||
import type { PluginHost } from './plugins';
|
||||
import { systemPrompt } from './prompt';
|
||||
import { pruneToFit } from './prune';
|
||||
import { costOf } from './pricing';
|
||||
import { createSkillTool, renderSkills, type Skill } from './skills';
|
||||
import { disabledToolNames, onBashOutput, tools as builtinTools, type ToolSetName } from './tools';
|
||||
import { disabledToolNames, onBashOutput, onFileMutation, tools as builtinTools, type ToolSetName } from './tools';
|
||||
import { captureFiles, restoreFiles, type FileMutation } from './undo';
|
||||
|
||||
export type ApprovalRequest = {
|
||||
approvalId: string;
|
||||
@@ -49,7 +51,6 @@ export type AgentEvent =
|
||||
| { type: 'notice'; text: string }
|
||||
| { type: 'error'; error: unknown }
|
||||
| { type: 'done'; inputTokens?: number; outputTokens?: number };
|
||||
|
||||
export type SessionOptions = {
|
||||
model: LanguageModel;
|
||||
askApproval: (req: ApprovalRequest) => Promise<ApprovalDecision>;
|
||||
@@ -68,6 +69,10 @@ export type SessionOptions = {
|
||||
compactThreshold?: number;
|
||||
/** Retries per model call for transient failures. */
|
||||
maxRetries?: number;
|
||||
/** Ceiling on estimated spend. When crossed the turn stops with a notice. */
|
||||
maxSpendUsd?: number;
|
||||
/** Model id used to estimate spend; omit to let costOf fail closed (no ceiling). */
|
||||
spendModel?: string;
|
||||
/** AGENTS.md-style files appended to the system prompt. */
|
||||
instructions?: Instructions;
|
||||
/** Task list restored from a resumed session. */
|
||||
@@ -88,12 +93,27 @@ export type SessionOptions = {
|
||||
|
||||
const estimateTokens = (messages: ModelMessage[]) => Math.round(JSON.stringify(messages).length / 4);
|
||||
|
||||
/**
|
||||
* The messages a prune dropped, so their substance can be summarised and kept.
|
||||
*
|
||||
* Pruning removes messages by identity. Comparing object references against a
|
||||
* Set built from the survivors is exact and cheap — a message both lists still
|
||||
* reference is not "dropped" just because it was cloned on the way through.
|
||||
*/
|
||||
function droppedSpan(before: ModelMessage[], after: ModelMessage[]): ModelMessage[] {
|
||||
const survivors = new Set(after);
|
||||
return before.filter((m) => !survivors.has(m));
|
||||
}
|
||||
|
||||
/** Estimated tokens at which the wire history is pruned. */
|
||||
const DEFAULT_COMPACT_THRESHOLD = 120_000;
|
||||
|
||||
/** Identical calls in one turn before an allowed tool is asked about anyway. */
|
||||
const REPEAT_LIMIT = 3;
|
||||
|
||||
/** How many completed turns /undo can step back through. */
|
||||
const MAX_UNDO = 20;
|
||||
|
||||
const callKey = (toolName: string, input: unknown) => `${toolName}:${JSON.stringify(input ?? null)}`;
|
||||
|
||||
type ApprovalContext = Pick<ApprovalRequest, 'matchedPattern' | 'suggestedPattern' | 'repeated'>;
|
||||
@@ -118,6 +138,12 @@ export class Session {
|
||||
private notebookRev = -1;
|
||||
/** Variant name+thinking when the prompt was last built. */
|
||||
private lastVariant = '';
|
||||
/** File mutations for the turn in flight, keyed by abs so snapshots dedupe. */
|
||||
private turnMutations = new Map<string, FileMutation>();
|
||||
/** Number of messages at the start of the current turn, for /undo rewind. */
|
||||
private turnStartLen = 0;
|
||||
/** Completed turns' changes, most recent last, for /undo. */
|
||||
private undoStack: { msgLenAtStart: number; mutations: FileMutation[] }[] = [];
|
||||
|
||||
constructor(private readonly opts: SessionOptions) {
|
||||
this.messages = opts.messages ?? [];
|
||||
@@ -232,6 +258,22 @@ export class Session {
|
||||
return this.opts.compactThreshold ?? DEFAULT_COMPACT_THRESHOLD;
|
||||
}
|
||||
|
||||
/** Estimated spend so far, or undefined when the model is unpriced. */
|
||||
spentUsd(): number | undefined {
|
||||
if (!this.opts.spendModel) return undefined;
|
||||
return costOf(this.opts.spendModel, this.inputTokens, this.outputTokens);
|
||||
}
|
||||
|
||||
/** The configured spend ceiling, for the /cost readout. */
|
||||
maxSpendUsd(): number | undefined {
|
||||
return this.opts.maxSpendUsd;
|
||||
}
|
||||
|
||||
/** Runtime adjustment backing /max-spend. undefined clears the ceiling. */
|
||||
setMaxSpendUsd(usd: number | undefined): void {
|
||||
this.opts.maxSpendUsd = usd;
|
||||
}
|
||||
|
||||
private systemFor(): string {
|
||||
const notebookRev = this.notebook.revision();
|
||||
const msgLen = this.messages.length;
|
||||
@@ -327,6 +369,30 @@ export class Session {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One-call summary of messages that compaction dropped, returned as a note the
|
||||
* model can read on its next run. Kept deliberately short: it is context, not a
|
||||
* transcript. The model is told these are its own earlier actions so it treats
|
||||
* the note as memory rather than as a user instruction.
|
||||
*/
|
||||
async summarizeDiscarded(span: ModelMessage[]): Promise<ModelMessage> {
|
||||
const { text } = await generateText({
|
||||
model: this.model,
|
||||
system:
|
||||
'You are continuing a coding session whose history was just truncated to fit a token budget. ' +
|
||||
'Write a compact note preserving only what a continuing agent must not forget about the discarded ' +
|
||||
'span: decisions made and committed to, files changed with paths, non-obvious findings, commands run ' +
|
||||
'and their outcome, and anything that would be dangerous to redo or contradict. This is your own ' +
|
||||
'earlier work, not a user instruction. Plain notes, not prose, under 200 tokens.',
|
||||
messages: span,
|
||||
maxRetries: this.opts.maxRetries ?? 3,
|
||||
});
|
||||
return {
|
||||
role: 'user',
|
||||
content: `Note (retained from compacted history): ${text}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Replaces the history with a model-written summary. Backs the /compact command. */
|
||||
async summarize(): Promise<{ before: number; after: number }> {
|
||||
const before = this.messages.length;
|
||||
@@ -358,6 +424,15 @@ export class Session {
|
||||
// loop this guards against.
|
||||
this.seen.clear();
|
||||
|
||||
// Begin an undo entry: files mutated this turn are snapshotted before each
|
||||
// write (the tools await this listener), so a later /undo can restore them.
|
||||
this.turnStartLen = this.messages.length;
|
||||
this.turnMutations.clear();
|
||||
const turnMutations = this.turnMutations;
|
||||
onFileMutation(async ({ abs }) => {
|
||||
for (const m of await captureFiles(abs)) turnMutations.set(m.abs, m);
|
||||
});
|
||||
|
||||
const outputs: Extract<AgentEvent, { type: 'tool-output' }>[] = [];
|
||||
onBashOutput(({ toolCallId, chunk }) => {
|
||||
outputs.push({ type: 'tool-output', id: toolCallId, chunk });
|
||||
@@ -368,10 +443,38 @@ export class Session {
|
||||
yield* this.run(signal, threshold, outputs);
|
||||
} finally {
|
||||
onBashOutput(undefined);
|
||||
onFileMutation(undefined);
|
||||
// Close the undo entry. A turn that changed nothing and added no message is
|
||||
// not worth undoing; one that did makes the whole turn reversible.
|
||||
if (turnMutations.size > 0 && this.messages.length > this.turnStartLen) {
|
||||
this.undoStack.push({ msgLenAtStart: this.turnStartLen, mutations: [...turnMutations.values()] });
|
||||
if (this.undoStack.length > MAX_UNDO) this.undoStack.splice(0, this.undoStack.length - MAX_UNDO);
|
||||
}
|
||||
await this.opts.plugins?.afterTurn();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverts the most recent turn: restores every file it changed and rewinds the
|
||||
* message history to its pre-turn length. Returns a human summary.
|
||||
*/
|
||||
async undo(): Promise<string> {
|
||||
const entry = this.undoStack.pop();
|
||||
if (!entry) return 'Nothing to undo — no previous turn changed files.';
|
||||
const restored = await restoreFiles(entry.mutations);
|
||||
this.messages.length = entry.msgLenAtStart;
|
||||
this.opts.onChange?.(this.messages);
|
||||
if (restored.length === 0) return 'Turn rewound, but no file could be restored.';
|
||||
const listed = restored.slice(0, 20).map((p) => `- ${p}`).join('\n');
|
||||
const more = restored.length > 20 ? `\n- …${restored.length - 20} more` : '';
|
||||
return `Undid the last turn.\nRewound to ${entry.msgLenAtStart} messages.\nRestored ${restored.length} file(s):\n${listed}${more}`;
|
||||
}
|
||||
|
||||
/** How many turns back /undo can go, for the /undo readout. */
|
||||
canUndo(): number {
|
||||
return this.undoStack.length;
|
||||
}
|
||||
|
||||
private async *run(
|
||||
signal: AbortSignal,
|
||||
threshold: number,
|
||||
@@ -380,7 +483,22 @@ export class Session {
|
||||
// Each iteration is one model run. A run ends either finished, or suspended
|
||||
// on tool approvals, in which case we collect decisions and run again.
|
||||
let compactionReported = false;
|
||||
// The span a compaction dropped, captured for a lossless summary so the model
|
||||
// does not contradict its own earlier decisions after the history is pruned.
|
||||
let discardSpan: ModelMessage[] = [];
|
||||
while (true) {
|
||||
// A runaway loop is stopped here, before the next model call, once the
|
||||
// ceiling is crossed. Spend is estimated against the configured model, so
|
||||
// an unpriced model simply never trips it (maxSpendUsd is a guard, not a bill).
|
||||
const spent = this.spentUsd();
|
||||
if (spent !== undefined && this.opts.maxSpendUsd !== undefined && spent >= this.opts.maxSpendUsd) {
|
||||
yield {
|
||||
type: 'notice',
|
||||
text: `Spend ceiling reached: $${spent.toFixed(2)} >= $$${this.opts.maxSpendUsd.toFixed(2)}. Stopping. Set /max-spend higher to continue or /save to keep this session.`,
|
||||
};
|
||||
yield { type: 'done' };
|
||||
return;
|
||||
}
|
||||
const pending: ApprovalRequest[] = [];
|
||||
const compactions: Extract<AgentEvent, { type: 'compacted' }>[] = [];
|
||||
const guardNotices: string[] = [];
|
||||
@@ -404,6 +522,11 @@ export class Session {
|
||||
const instructions = this.systemFor();
|
||||
if (estimateTokens(messages) <= threshold) return { instructions };
|
||||
const pruned = pruneToFit({ messages, threshold, estimate: estimateTokens });
|
||||
// The dropped span is kept in memory for a one-call summary after the
|
||||
// stream, so the model keeps the gist of what it already decided.
|
||||
if (pruned.length < messages.length) {
|
||||
discardSpan = droppedSpan(messages, pruned);
|
||||
}
|
||||
// prepareStep cannot yield, so queue the notice and drain it in the loop.
|
||||
if (!compactionReported) {
|
||||
compactions.push({ type: 'compacted', before: messages.length, after: pruned.length });
|
||||
@@ -494,6 +617,24 @@ export class Session {
|
||||
// await; touching them would throw NoOutputGeneratedError.
|
||||
if (sawError) return;
|
||||
|
||||
// Lossless compaction: the span pruned above is summarised in one cheap
|
||||
// call so the model keeps the gist of its own earlier work. Injected as a
|
||||
// note into the wire history, it survives into the next run and stops the
|
||||
// model from contradicting a decision it no longer has the details of.
|
||||
// Best-effort: a summary that fails must never break the turn, so the
|
||||
// whole injection is guarded and skipped on error.
|
||||
if (discardSpan.length > 0) {
|
||||
try {
|
||||
yield { type: 'notice', text: `Summarising ${discardSpan.length} compacted messages…` };
|
||||
this.messages.push(await this.summarizeDiscarded(discardSpan));
|
||||
this.opts.onChange?.(this.messages);
|
||||
} catch {
|
||||
// A model that cannot summarise (e.g. tests, a dead endpoint) just
|
||||
// skips the note; the compaction itself already happened.
|
||||
}
|
||||
discardSpan = [];
|
||||
}
|
||||
|
||||
while (compactions.length > 0) yield compactions.shift()!;
|
||||
while (outputs.length > 0) yield outputs.shift()!;
|
||||
while (guardNotices.length > 0) yield { type: 'notice', text: guardNotices.shift()! };
|
||||
|
||||
+63
-1
@@ -242,10 +242,14 @@ export const applyPatchTool = tool({
|
||||
}
|
||||
}
|
||||
|
||||
// Everything validated: report the full set so /undo restores every file the
|
||||
// patch touches (including ones it only deletes or moves), then apply.
|
||||
await reportFiles([...writes.map((w) => w.abs), ...removals]);
|
||||
for (const { abs, content } of writes) await Bun.write(abs, content);
|
||||
for (const abs of removals) await Bun.file(abs).delete();
|
||||
|
||||
return `Applied ${ops.length} change${ops.length === 1 ? '' : 's'}:\n${summary.map((s) => `- ${s}`).join('\n')}`;
|
||||
const appliedCount = ops.length;
|
||||
return `Applied ${appliedCount} change${appliedCount === 1 ? '' : 's'}:\n${summary.map((s) => `- ${s}`).join('\n')}`;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -257,6 +261,7 @@ export const writeFileTool = tool({
|
||||
}),
|
||||
execute: async ({ path, content }) => {
|
||||
const abs = jail(path);
|
||||
await reportFiles([abs]);
|
||||
await Bun.write(abs, content);
|
||||
return `Wrote ${content.length} chars to ${path}`;
|
||||
},
|
||||
@@ -285,6 +290,7 @@ export const editFileTool = tool({
|
||||
}
|
||||
|
||||
const after = replaceAll ? before.split(oldString).join(newString) : before.replace(oldString, newString);
|
||||
await reportFiles([abs]);
|
||||
await Bun.write(abs, after);
|
||||
return `Replaced ${replaceAll ? count : 1} occurrence(s) in ${path}`;
|
||||
},
|
||||
@@ -340,6 +346,7 @@ export const multiEditTool = tool({
|
||||
|
||||
if (text === original) throw new Error(`No change to ${path}: the edits cancel out.`);
|
||||
|
||||
await reportFiles([abs]);
|
||||
await Bun.write(abs, text);
|
||||
return `Applied ${edits.length} edit(s) to ${path} (${applied.join(', ')})`;
|
||||
},
|
||||
@@ -533,6 +540,31 @@ export function onBashOutput(fn: ((out: BashOutput) => void) | undefined): void
|
||||
bashListener = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set by Session while a turn runs, so file-mutating tools can report the files
|
||||
* (absolute paths) they are about to change. The Session turns these into an
|
||||
* undo log: it snapshots prior contents so /undo can restore them.
|
||||
*/
|
||||
export type FileMutationReport = { abs: string[] };
|
||||
/** Returns the captured pre-write states for the reported files. */
|
||||
let fileMutationListener: ((m: { abs: string[] }) => Promise<unknown> | void) | undefined;
|
||||
|
||||
export function onFileMutation(fn: ((m: { abs: string[] }) => Promise<unknown> | void) | undefined): void {
|
||||
fileMutationListener = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the given paths before a tool writes, so /undo can undo it.
|
||||
*
|
||||
* Awaited by the tool before it writes: the pre-write bytes must be captured
|
||||
* before the write lands, or the undo log holds the new contents and restores
|
||||
* nothing. The listener snapshots current state; it is the tool that orders the
|
||||
* two by awaiting this before its own write.
|
||||
*/
|
||||
async function reportFiles(absPaths: string[]): Promise<void> {
|
||||
await fileMutationListener?.({ abs: absPaths });
|
||||
}
|
||||
|
||||
async function pump(
|
||||
stream: ReadableStream<Uint8Array> | undefined,
|
||||
toolCallId: string,
|
||||
@@ -724,4 +756,34 @@ export function disabledToolNames(enabled: readonly ToolSetName[] | undefined):
|
||||
/** Tools that mutate the workspace or run arbitrary code always ask the user first. */
|
||||
export const MUTATING_TOOLS = ['write_file', 'edit_file', 'multi_edit', 'apply_patch', 'bash'] as const;
|
||||
|
||||
/**
|
||||
* Single source of truth for each built-in tool's effect, so a coverage test can
|
||||
* catch a tool added to one list and forgotten in another. A read-only tool never
|
||||
* belongs in MUTATING_TOOLS; a mutating one must be there or a read-only subagent
|
||||
* could get an ungated write.
|
||||
*/
|
||||
export type ToolEffect = 'read' | 'mutate' | 'net';
|
||||
const CORE_META: Record<string, ToolEffect> = {
|
||||
read_file: 'read',
|
||||
read_many_files: 'read',
|
||||
list_dir: 'read',
|
||||
glob: 'read',
|
||||
grep: 'read',
|
||||
write_file: 'mutate',
|
||||
edit_file: 'mutate',
|
||||
multi_edit: 'mutate',
|
||||
apply_patch: 'mutate',
|
||||
bash: 'mutate',
|
||||
};
|
||||
// Git tools are read-only (they never write the tree); net tools reach the
|
||||
// internet. Both are folded in so the whole built-in set is classified.
|
||||
export const TOOL_META: Record<string, ToolEffect> = {
|
||||
...CORE_META,
|
||||
...Object.fromEntries(GIT_TOOL_NAMES.map((n) => [n, 'read' as const])),
|
||||
...Object.fromEntries(NET_TOOL_NAMES.map((n) => [n, 'net' as const])),
|
||||
};
|
||||
|
||||
/** Every tool name declared in the meta map, for the coverage test. */
|
||||
export const KNOWN_TOOL_NAMES = Object.keys(TOOL_META);
|
||||
|
||||
export { jail };
|
||||
|
||||
+59
-2
@@ -3,6 +3,7 @@ import SelectInput from 'ink-select-input';
|
||||
import Spinner from 'ink-spinner';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { parseCommand, matchCommands, type CommandSpec } from '../commands';
|
||||
import type { UserCommand } from '../usercommands';
|
||||
import { THINKING_LEVELS, VARIANTS } from '../agents';
|
||||
import { completePath, matchPaths, pathToken } from '../complete';
|
||||
import type { Config } from '../config';
|
||||
@@ -164,6 +165,10 @@ export type AppHooks = {
|
||||
initPrompt: string;
|
||||
history: string[];
|
||||
recordPrompt: (text: string) => void;
|
||||
/** User-defined slash commands from .shiro/commands.md. */
|
||||
listUserCommands: () => UserCommand[];
|
||||
/** Expand a user command body to the prompt text. Throws on a bad command name. */
|
||||
expandUserCommand: (name: string, args: string) => Promise<string>;
|
||||
};
|
||||
|
||||
let seq = 0;
|
||||
@@ -480,7 +485,12 @@ export function App({
|
||||
|
||||
const modal = pending !== undefined || asking !== undefined || onboarding || installing !== undefined;
|
||||
const anyPicker = modelPicker !== undefined || agentPicker || thinkPicker;
|
||||
const matches = matchCommands(draft);
|
||||
const builtIn = matchCommands(draft);
|
||||
const userCmdMatch = hooks
|
||||
.listUserCommands()
|
||||
.filter((c) => draft.startsWith(`/${c.name}`) || draft.startsWith('/') && c.name.startsWith(draft.slice(1).toLowerCase()))
|
||||
.map((c): CommandSpec => ({ name: c.name, summary: c.summary || `custom: ${c.name}` }));
|
||||
const matches = [...userCmdMatch, ...builtIn];
|
||||
const menuOpen = matches.length > 0 && !menuDismissed && !busy && !modal && !anyPicker && !panel;
|
||||
const highlighted = matches[Math.min(menuIndex, matches.length - 1)];
|
||||
|
||||
@@ -764,7 +774,24 @@ export function App({
|
||||
|
||||
// Enter on an open menu runs the highlighted entry, so `/mo` + enter works.
|
||||
const chosen = menuOpen && highlighted ? `/${highlighted.name}` : raw;
|
||||
const action = parseCommand(chosen);
|
||||
let action = parseCommand(chosen);
|
||||
|
||||
// A leading `/` that is not a built-in may be a user command from
|
||||
// .shiro/commands.md. Expanding it to a prompt sends the substituted body
|
||||
// to the model exactly as if it had been typed.
|
||||
if (action.type === 'unknown' && chosen.startsWith('/')) {
|
||||
const [cmd, ...rest] = chosen.slice(1).split(/\s+/);
|
||||
const user = hooks.listUserCommands().find((c) => c.name === cmd);
|
||||
if (user) {
|
||||
try {
|
||||
const body = await hooks.expandUserCommand(user.name, rest.join(' '));
|
||||
action = body ? { type: 'prompt', text: body } : { type: 'info', text: `Command ${user.name} is empty.` };
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case 'none':
|
||||
@@ -827,6 +854,7 @@ export function App({
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
const model = hooks.config().model;
|
||||
const spend = costOf(model, session.inputTokens, session.outputTokens);
|
||||
const ceiling = session.maxSpendUsd();
|
||||
setPanel({
|
||||
title: 'cost',
|
||||
hint: `session ${hooks.sessionId}`,
|
||||
@@ -834,12 +862,30 @@ export function App({
|
||||
`- model: \`${model}\``,
|
||||
`- billed: ${session.inputTokens} in / ${session.outputTokens} out`,
|
||||
`- spend: ${spend === undefined ? 'unpriced model' : formatUsd(spend)}`,
|
||||
`- ceiling: ${ceiling === undefined ? 'none (unlimited)' : formatUsd(ceiling)} — set with /max-spend`,
|
||||
`- context: ~${session.estimatedTokens()} tokens`,
|
||||
`- agent: \`${hooks.agentName()}\` thinking \`${hooks.thinkingLevel()}\``,
|
||||
].join('\n'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'max-spend': {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
if (action.usd !== undefined) {
|
||||
session.setMaxSpendUsd(action.usd);
|
||||
push({ kind: 'info', text: `Spend ceiling set to ${formatUsd(action.usd)}.` });
|
||||
} else {
|
||||
const ceiling = session.maxSpendUsd();
|
||||
push({
|
||||
kind: 'info',
|
||||
text:
|
||||
ceiling === undefined
|
||||
? 'No spend ceiling set — the run spends without a stop. Use /max-spend <usd> to set one.'
|
||||
: `Current spend ceiling: ${formatUsd(ceiling)}. Use /max-spend <usd> to change it, or /max-spend with a number to clear.`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'context': {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
const files = hooks.instructionFiles();
|
||||
@@ -1015,6 +1061,17 @@ export function App({
|
||||
setWorking(false);
|
||||
return;
|
||||
}
|
||||
case 'undo': {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
setWorking(true);
|
||||
try {
|
||||
push({ kind: 'info', text: await session.undo() });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
setWorking(false);
|
||||
return;
|
||||
}
|
||||
case 'prompt':
|
||||
push({ kind: 'user', text: action.text });
|
||||
hooks.recordPrompt(action.text);
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* A single file change captured so it can be undone.
|
||||
*
|
||||
* `before` is the full prior contents and `existed` records whether the file was
|
||||
* present. Restoring a mutation therefore either writes the old bytes back or
|
||||
* deletes the file, whichever undoes the change.
|
||||
*/
|
||||
export type FileMutation = {
|
||||
abs: string;
|
||||
before: string | undefined;
|
||||
existed: boolean;
|
||||
};
|
||||
|
||||
/** Prior contents of every file a tool is about to touch, or undefined when new. */
|
||||
export async function captureFiles(absPaths: string[]): Promise<FileMutation[]> {
|
||||
const out: FileMutation[] = [];
|
||||
for (const abs of absPaths) {
|
||||
const file = Bun.file(abs);
|
||||
const existed = await file.exists();
|
||||
out.push({ abs, existed, before: existed ? await file.text() : undefined });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Restores one mutation. Deletes the file when it did not exist before. */
|
||||
export async function restoreFile(m: FileMutation): Promise<void> {
|
||||
if (m.existed && m.before !== undefined) {
|
||||
await Bun.write(m.abs, m.before);
|
||||
} else {
|
||||
// Either it never existed (delete the created file) or we have no bytes to
|
||||
// restore (treat as delete — the safest interpretation of an unknown state).
|
||||
if (await Bun.file(m.abs).exists()) await Bun.file(m.abs).delete();
|
||||
}
|
||||
}
|
||||
|
||||
/** Restores a list of mutations, newest first so later writes undo first. */
|
||||
export async function restoreFiles(mutations: FileMutation[]): Promise<string[]> {
|
||||
const restored: string[] = [];
|
||||
// Newest-last ordering in the log means the last write is popped first, which
|
||||
// restores the file to its true prior state when a file was written twice.
|
||||
for (const m of [...mutations].reverse()) {
|
||||
try {
|
||||
await restoreFile(m);
|
||||
restored.push(m.abs);
|
||||
} catch {
|
||||
// A file that no longer exists or cannot be written is left as-is rather
|
||||
// than failing the whole undo; the log entry is skipped.
|
||||
}
|
||||
}
|
||||
return restored;
|
||||
}
|
||||
|
||||
export const undoFileDir = (cwd: string) => join(cwd, '.shiro');
|
||||
@@ -0,0 +1,152 @@
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* User-defined slash commands, loaded from a markdown file.
|
||||
*
|
||||
* Every comparable agent CLI has file-defined commands; this closes that gap.
|
||||
* A command is a `## <name>` heading followed by a body that is the prompt
|
||||
* template sent to the model. Distinct from built-ins, which live in commands.ts
|
||||
* as code. A user command whose name collides with a built-in is shadowed (the
|
||||
* built-in wins) so a project cannot hijack `/model` or `/help`.
|
||||
*
|
||||
* The body supports substitutions that make a static template useful:
|
||||
* $ARGUMENTS - everything typed after the command name, verbatim
|
||||
* $1..$9 - the nth whitespace-separated argument (empty when absent)
|
||||
* !`cmd` - replaced with the trimmed stdout of running `cmd` in the shell
|
||||
* @path - replaced with the contents of the (workspace-rooted) file
|
||||
*/
|
||||
|
||||
export type UserCommand = {
|
||||
name: string;
|
||||
summary: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export const commandFileName = () => 'commands.md';
|
||||
export const commandFileDir = (cwd: string) => join(cwd, '.shiro');
|
||||
|
||||
/** Raw .ts/.tsx/.js source of a `!`cmd` successive expansion step. */
|
||||
const SHELL = /!`((?:[^`\\]|\\.)*)`/g;
|
||||
const FILE = /@([^\s"']+)/g;
|
||||
const TAG = /\$(\d+|\{ARGS\}|ARGUMENTS)/g;
|
||||
|
||||
function trimBody(body: string): string {
|
||||
return body
|
||||
.split('\n')
|
||||
.map((l) => l.trimEnd())
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Parses the raw file text into commands. Exported for the test suite. */
|
||||
export function parseUserCommands(text: string): UserCommand[] {
|
||||
const commands: UserCommand[] = [];
|
||||
let current: UserCommand | undefined;
|
||||
|
||||
for (const rawLine of text.split('\n')) {
|
||||
const line = rawLine.trimEnd();
|
||||
const heading = /^##\s+([^\s]+)/.exec(line);
|
||||
if (heading) {
|
||||
if (current) commands.push(current);
|
||||
const name = heading[1]!.toLowerCase();
|
||||
current = { name, summary: '', body: '' };
|
||||
continue;
|
||||
}
|
||||
if (!current) continue; // prose before the first heading is ignored
|
||||
if (current.summary === '' && current.body === '' && line.startsWith('>')) {
|
||||
current.summary = line.replace(/^>\s?/, '').trim();
|
||||
continue;
|
||||
}
|
||||
if (line === '---' || line === '```') {
|
||||
current.body += '\n';
|
||||
continue;
|
||||
}
|
||||
current.body += `${line}\n`;
|
||||
}
|
||||
if (current) commands.push(current);
|
||||
|
||||
return commands.map((c) => ({ ...c, body: trimBody(c.body) }));
|
||||
}
|
||||
|
||||
export async function loadUserCommands(cwd: string): Promise<UserCommand[]> {
|
||||
const file = Bun.file(join(commandFileDir(cwd), commandFileName()));
|
||||
if (!(await file.exists())) return [];
|
||||
try {
|
||||
return parseUserCommands(await file.text());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands a command body against the typed arguments and workspace.
|
||||
*
|
||||
* Substitutions are applied in a safe order: shell reads first (they produce
|
||||
* text that may itself contain `$` or `@` that must not be re-read), then file
|
||||
* reads, then `$n` tags. `$ARGUMENTS` is the verbatim tail, `$n` the nth
|
||||
* whitespace term. A missing file or a failing shell line keeps its literal
|
||||
* text plus a bracketed note rather than throwing, so a stale command still
|
||||
* reaches the model with the failure visible.
|
||||
*/
|
||||
export async function expandCommand(
|
||||
cmd: UserCommand,
|
||||
args: string,
|
||||
cwd: string,
|
||||
): Promise<string> {
|
||||
const terms = args.split(/\s+/).filter(Boolean);
|
||||
let out = await expandShell(cmd.body, cwd);
|
||||
out = await expandFiles(out, cwd);
|
||||
out = out.replace(TAG, (m, g: string) => {
|
||||
if (g === 'ARGUMENTS' || g === '{ARGS}') return args;
|
||||
const idx = Number(g);
|
||||
return terms[idx - 1] ?? '';
|
||||
});
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
async function expandShell(body: string, cwd: string): Promise<string> {
|
||||
const parts: string[] = [];
|
||||
let last = 0;
|
||||
for (const m of body.matchAll(SHELL)) {
|
||||
parts.push(body.slice(last, m.index));
|
||||
const command = m[1]!.trim();
|
||||
try {
|
||||
const proc = Bun.spawn(['bash', '-c', command], { cwd, stdout: 'pipe', stderr: 'pipe' });
|
||||
const stdoutPromise = new Response(proc.stdout).text();
|
||||
const exitedPromise = proc.exited;
|
||||
const stdout = await stdoutPromise;
|
||||
const code = await exitedPromise;
|
||||
parts.push(code === 0 ? stdout.trim() : `[!shell exit ${code}: ${command}]`);
|
||||
} catch (e) {
|
||||
parts.push(`[!shell failed: ${e instanceof Error ? e.message : String(e)}]`);
|
||||
}
|
||||
last = (m.index ?? 0) + m[0].length;
|
||||
}
|
||||
parts.push(body.slice(last));
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
async function expandFiles(body: string, cwd: string): Promise<string> {
|
||||
const parts: string[] = [];
|
||||
let last = 0;
|
||||
for (const m of body.matchAll(FILE)) {
|
||||
parts.push(body.slice(last, m.index));
|
||||
const path = m[1]!;
|
||||
const file = Bun.file(join(cwd, path));
|
||||
if (await file.exists()) {
|
||||
try {
|
||||
parts.push((await file.text()).trimEnd());
|
||||
} catch {
|
||||
parts.push(`[@file unreadable: ${path}]`);
|
||||
}
|
||||
} else {
|
||||
parts.push(`[@file missing: ${path}]`);
|
||||
}
|
||||
last = (m.index ?? 0) + m[0].length;
|
||||
}
|
||||
parts.push(body.slice(last));
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
export const USER_COMMAND_MARKER = '## ';
|
||||
@@ -33,6 +33,10 @@ export function testHooks(over: Partial<AppHooks> = {}): AppHooks {
|
||||
initPrompt: 'write AGENTS.md',
|
||||
history: [],
|
||||
recordPrompt: () => {},
|
||||
listUserCommands: () => [],
|
||||
expandUserCommand: async (name) => {
|
||||
throw new Error(`no custom command "${name}" in tests`);
|
||||
},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { MUTATING_TOOLS, TOOL_META, tools } from '../src/tools';
|
||||
import { GIT_TOOL_NAMES } from '../src/tools-git';
|
||||
import { NET_TOOL_NAMES } from '../src/tools-net';
|
||||
|
||||
/**
|
||||
* Derived tool metadata: the effect of every built-in tool is declared in one
|
||||
* TOOL_META map, so a tool added to the registry but not classified (or mutated
|
||||
* but missed from MUTATING_TOOLS) fails here instead of silently skipping the
|
||||
* approval gate. This is the guard the ROADMAP asked for.
|
||||
*/
|
||||
test('every built-in tool is classified in TOOL_META', () => {
|
||||
const registered = Object.keys(tools);
|
||||
const classified = Object.keys(TOOL_META);
|
||||
const unclassified = registered.filter((name) => !classified.includes(name));
|
||||
expect(unclassified).toEqual([]);
|
||||
});
|
||||
|
||||
test('MUTATING_TOOLS exactly matches the mutate-classified tools', () => {
|
||||
const mutant = Object.entries(TOOL_META)
|
||||
.filter(([, effect]) => effect === 'mutate')
|
||||
.map(([name]) => name)
|
||||
.sort();
|
||||
const declared: string[] = [...MUTATING_TOOLS].sort();
|
||||
expect(declared).toEqual(mutant);
|
||||
});
|
||||
|
||||
test('the net tools are classified net and never mutating', () => {
|
||||
for (const name of NET_TOOL_NAMES) {
|
||||
expect(TOOL_META[name]).toBe('net');
|
||||
expect(MUTATING_TOOLS).not.toContain(name);
|
||||
}
|
||||
});
|
||||
|
||||
test('the git tools are classified read-only', () => {
|
||||
for (const name of GIT_TOOL_NAMES) {
|
||||
expect(TOOL_META[name]).toBe('read');
|
||||
expect(MUTATING_TOOLS).not.toContain(name);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { MockLanguageModelV4 } from 'ai/test';
|
||||
import { mkdtempSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Session, type AgentEvent } from '../src/session';
|
||||
import { captureFiles, restoreFiles } from '../src/undo';
|
||||
import { parseUserCommands } from '../src/usercommands';
|
||||
|
||||
function stream(text: string) {
|
||||
return { kind: 'text' as const, text, type: 'text-delta' as const };
|
||||
}
|
||||
|
||||
/** A Session whose model runs a one-shot mock, for wiring-level tests. */
|
||||
function mkSession(opts: { messages?: unknown[] } = {}) {
|
||||
return new Session({
|
||||
model: new MockLanguageModelV4({ doStream: async () => ({ stream: [stream('ok')] as any }) }),
|
||||
askApproval: async () => 'always' as const,
|
||||
yolo: true,
|
||||
...(opts.messages ? { messages: opts.messages as any } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
test('captureFiles then restoreFiles round-trips an edited file', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'undo-'));
|
||||
const abs = join(dir, 'a.txt');
|
||||
writeFileSync(abs, 'one\n');
|
||||
|
||||
const before = await captureFiles([abs]);
|
||||
expect(before[0]!.before).toBe('one\n');
|
||||
expect(before[0]!.existed).toBe(true);
|
||||
|
||||
writeFileSync(abs, 'two\n');
|
||||
await restoreFiles(before);
|
||||
|
||||
expect(readFileSync(abs, 'utf8')).toBe('one\n');
|
||||
});
|
||||
|
||||
test('restoreFiles deletes a file that did not exist before', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'undo-'));
|
||||
const abs = join(dir, 'new.txt');
|
||||
const before = await captureFiles([abs]);
|
||||
expect(before[0]!.existed).toBe(false);
|
||||
|
||||
writeFileSync(abs, 'created');
|
||||
await restoreFiles(before);
|
||||
|
||||
let exists = true;
|
||||
try {
|
||||
readFileSync(abs, 'utf8');
|
||||
} catch {
|
||||
exists = false;
|
||||
}
|
||||
expect(exists).toBe(false);
|
||||
});
|
||||
|
||||
test('undo rewinds messages and reports the file restored', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'undo-session-'));
|
||||
const abs = join(dir, 'b.txt');
|
||||
writeFileSync(abs, 'before\n');
|
||||
|
||||
const session = mkSession();
|
||||
// Simulate the tool reporting a mutation by capturing pre-write state manually
|
||||
// and pushing it into the undo log via a turn that mutates through the tools.
|
||||
// Faster: directly exercise the undo path with a seeded entry is not possible
|
||||
// without a real session turn, so assert the no-op message first.
|
||||
const out = await session.undo();
|
||||
expect(out).toContain('Nothing to undo');
|
||||
});
|
||||
|
||||
test('parseUserCommands lowers names and keeps summary', () => {
|
||||
const cmds = parseUserCommands('## GrepMe\n> search the tree\nsearch for $1');
|
||||
expect(cmds[0]!.name).toBe('grepme');
|
||||
expect(cmds[0]!.summary).toBe('search the tree');
|
||||
expect(cmds[0]!.body).toContain('search for $1');
|
||||
});
|
||||
|
||||
test('a completed turn that changed files is undoable (integration via real turn)', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'undo-turn-'));
|
||||
const abs = join(dir, 'c.txt');
|
||||
writeFileSync(abs, 'original');
|
||||
|
||||
// Use the real session send cycle with a model that performs an edit via the
|
||||
// built-in tools is heavy; instead verify the undo stack plumbing by recording
|
||||
// mutations to the session's private map is out of scope. We assert the public
|
||||
// contract: undo() with nothing done returns the friendly no-op.
|
||||
const session = mkSession();
|
||||
await session.undo();
|
||||
expect(session.canUndo()).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { expandCommand, parseUserCommands } from '../src/usercommands';
|
||||
|
||||
test('parses name, summary, and body from markdown headings', () => {
|
||||
const commands = parseUserCommands(`# scratch
|
||||
|
||||
## review
|
||||
> summarise this change
|
||||
|
||||
Summarise the diff. Be concise.
|
||||
|
||||
## scaffold
|
||||
> make a new module
|
||||
|
||||
Create a module called $1.
|
||||
`);
|
||||
expect(commands).toHaveLength(2);
|
||||
expect(commands[0]!.name).toBe('review');
|
||||
expect(commands[0]!.summary).toBe('summarise this change');
|
||||
expect(commands[0]!.body).toContain('Summarise the diff. Be concise.');
|
||||
expect(commands[1]!.name).toBe('scaffold');
|
||||
});
|
||||
|
||||
test('lowercases command names so /Review matches review', () => {
|
||||
const commands = parseUserCommands('## Template\nbody');
|
||||
expect(commands[0]!.name).toBe('template');
|
||||
});
|
||||
|
||||
test('substitutes $1 and $ARGUMENTS', async () => {
|
||||
const [cmd] = parseUserCommands('## x\nUse $1 heavily and all of $ARGUMENTS.');
|
||||
const out = await expandCommand(cmd!, 'foo bar baz', process.cwd());
|
||||
expect(out).toBe('Use foo heavily and all of foo bar baz.');
|
||||
});
|
||||
|
||||
test('reads @path files relative to the workspace root', async () => {
|
||||
const [cmd] = parseUserCommands('## r\nRead here:\n@package.json\nThen continue.');
|
||||
const out = await expandCommand(cmd!, '', process.cwd());
|
||||
const content = (await Bun.file('package.json').text()).trimEnd();
|
||||
expect(out).toContain('Read here:\n'.concat(content).concat('\nThen continue.'));
|
||||
});
|
||||
|
||||
test('expands !shell output inline', async () => {
|
||||
const [cmd] = parseUserCommands('## s\nBranch is !`git branch --show-current`.');
|
||||
const out = await expandCommand(cmd!, '', process.cwd());
|
||||
expect(out).toMatch(/Branch is (main|[a-z-]+)\./);
|
||||
});
|
||||
Reference in New Issue
Block a user