Initial commit: shiro-neko 0.1.0-beta.1
Agentic coding CLI on Bun, Ink, and the AI SDK. Core: streamText loop with SDK-level tool approval so a denied call provably never executes; endpoint fallback for OpenAI reasoning models; retry with backoff. Tools: read/write/edit/glob/grep/bash, path-jailed, gitignore-aware, ripgrep with a JS fallback, binary rejection, live bash streaming. Agents: five variants crossing thinking level with tool restriction; plan and review withhold mutating tools from the model. Extensibility: frontmatter skills with on-demand bodies, plugin host with blocking hooks, MCP stdio and HTTP, read-only subagents. State: durable per-project memory, session task lists, session persistence, compaction that repairs provider-item dependencies. Distribution: five-platform cross-compiled binaries with checksums, install scripts, CI on three operating systems. 404 tests, typecheck clean.
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { MockLanguageModelV4, simulateReadableStream } from 'ai/test';
|
||||
import type { LanguageModelV4StreamPart } from '@ai-sdk/provider';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { runHeadless } from '../src/headless';
|
||||
import { Session } from '../src/session';
|
||||
|
||||
const usage = {
|
||||
inputTokens: { total: 9, noCache: 9, cacheRead: 0, cacheWrite: 0 },
|
||||
outputTokens: { total: 4 },
|
||||
} as any;
|
||||
|
||||
const stream = (parts: LanguageModelV4StreamPart[]) => ({
|
||||
stream: simulateReadableStream({ chunks: parts, chunkDelayInMs: null, initialDelayInMs: null }),
|
||||
});
|
||||
|
||||
const toolCall = (id: string, toolName: string, input: unknown): LanguageModelV4StreamPart[] => [
|
||||
{ type: 'tool-input-start', id, toolName },
|
||||
{ type: 'tool-input-end', id },
|
||||
{ type: 'tool-call', toolCallId: id, toolName, input: JSON.stringify(input) },
|
||||
{ type: 'finish', finishReason: { unified: 'tool-calls', raw: 'tool_use' }, usage },
|
||||
];
|
||||
|
||||
const text = (body: string): LanguageModelV4StreamPart[] => [
|
||||
{ type: 'text-start', id: '0' },
|
||||
{ type: 'text-delta', id: '0', delta: body },
|
||||
{ type: 'text-end', id: '0' },
|
||||
{ type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage },
|
||||
];
|
||||
|
||||
function inTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||
const orig = process.cwd();
|
||||
const dir = mkdtempSync(join(tmpdir(), 'shiro-head-'));
|
||||
process.chdir(dir);
|
||||
return fn(dir).finally(() => {
|
||||
process.chdir(orig);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
}
|
||||
|
||||
test('text mode writes only assistant text to stdout and exits 0', async () => {
|
||||
let out = '';
|
||||
const session = new Session({
|
||||
model: new MockLanguageModelV4({ doStream: async () => stream(text('42')) }),
|
||||
askApproval: async () => 'deny',
|
||||
});
|
||||
|
||||
const code = await runHeadless({ session, prompt: 'what is 6*7', out: (s) => (out += s) });
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(out).toBe('42\n');
|
||||
});
|
||||
|
||||
test('json mode emits one parseable event per line', async () => {
|
||||
let out = '';
|
||||
const session = new Session({
|
||||
model: new MockLanguageModelV4({ doStream: async () => stream(text('hi')) }),
|
||||
askApproval: async () => 'deny',
|
||||
});
|
||||
|
||||
await runHeadless({ session, prompt: 'hello', format: 'json', out: (s) => (out += s) });
|
||||
|
||||
const events = out.trim().split('\n').map((l) => JSON.parse(l));
|
||||
expect(events.map((e) => e.type)).toEqual(['text', 'done']);
|
||||
expect(events.at(-1)).toMatchObject({ inputTokens: 9, outputTokens: 4 });
|
||||
});
|
||||
|
||||
test('json mode flattens an Error into a readable message', async () => {
|
||||
let out = '';
|
||||
const session = new Session({
|
||||
model: new MockLanguageModelV4({
|
||||
doStream: async () => {
|
||||
throw new Error('upstream is on fire');
|
||||
},
|
||||
}),
|
||||
askApproval: async () => 'deny',
|
||||
});
|
||||
|
||||
await runHeadless({ session, prompt: 'go', format: 'json', out: (s) => (out += s) });
|
||||
|
||||
const events = out.trim().split('\n').map((l) => JSON.parse(l) as { type: string; error?: unknown });
|
||||
const failure = events.find((e) => e.type === 'error');
|
||||
expect(failure?.error).toContain('upstream is on fire');
|
||||
});
|
||||
|
||||
test('json mode flattens a tool error too', async () =>
|
||||
inTempDir(async () => {
|
||||
let out = '';
|
||||
let n = 0;
|
||||
const session = new Session({
|
||||
yolo: true,
|
||||
model: new MockLanguageModelV4({
|
||||
doStream: async () =>
|
||||
n++ === 0 ? stream(toolCall('c1', 'read_file', { path: 'missing.txt' })) : stream(text('could not read it')),
|
||||
}),
|
||||
askApproval: async () => 'deny',
|
||||
});
|
||||
|
||||
await runHeadless({ session, prompt: 'read it', format: 'json', out: (s) => (out += s) });
|
||||
|
||||
const events = out.trim().split('\n').map((l) => JSON.parse(l) as { type: string; error?: unknown });
|
||||
const failure = events.find((e) => e.type === 'tool-error');
|
||||
expect(String(failure?.error)).toContain('No such file');
|
||||
}));
|
||||
|
||||
test('a model error sets exit code 1', async () => {
|
||||
const session = new Session({
|
||||
model: new MockLanguageModelV4({
|
||||
doStream: async () => {
|
||||
throw new Error('upstream exploded');
|
||||
},
|
||||
}),
|
||||
askApproval: async () => 'deny',
|
||||
});
|
||||
|
||||
const code = await runHeadless({ session, prompt: 'go', out: () => {} });
|
||||
expect(code).toBe(1);
|
||||
});
|
||||
|
||||
test('without yolo a mutating tool is denied and the workspace is untouched', async () =>
|
||||
inTempDir(async (dir) => {
|
||||
let n = 0;
|
||||
const session = new Session({
|
||||
model: new MockLanguageModelV4({
|
||||
doStream: async () =>
|
||||
n++ === 0
|
||||
? stream(toolCall('c1', 'write_file', { path: 'pwned.txt', content: 'x' }))
|
||||
: stream(text('I was not allowed to write.')),
|
||||
}),
|
||||
askApproval: async () => 'deny',
|
||||
});
|
||||
|
||||
let out = '';
|
||||
const code = await runHeadless({ session, prompt: 'write a file', format: 'json', out: (s) => (out += s) });
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(out).toContain('"tool-denied"');
|
||||
expect(await Bun.file(join(dir, 'pwned.txt')).exists()).toBe(false);
|
||||
}));
|
||||
|
||||
test('with yolo the tool runs unattended', async () =>
|
||||
inTempDir(async (dir) => {
|
||||
let n = 0;
|
||||
const session = new Session({
|
||||
yolo: true,
|
||||
model: new MockLanguageModelV4({
|
||||
doStream: async () =>
|
||||
n++ === 0 ? stream(toolCall('c1', 'write_file', { path: 'ok.txt', content: 'yes' })) : stream(text('written')),
|
||||
}),
|
||||
askApproval: async () => {
|
||||
throw new Error('yolo must not prompt');
|
||||
},
|
||||
});
|
||||
|
||||
const code = await runHeadless({ session, prompt: 'write ok.txt', out: () => {} });
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(await Bun.file(join(dir, 'ok.txt')).text()).toBe('yes');
|
||||
}));
|
||||
Reference in New Issue
Block a user