feat: implement provider-side prompt caching and workflow scaffolding
ci / check (macos-latest) (push) Canceled after 0s
ci / check (ubuntu-latest) (push) Canceled after 0s
ci / check (windows-latest) (push) Canceled after 0s

- Added support for caching the stable prefix of the system prompt for Anthropic models.
- Introduced a new option `cacheSystemPrefix` in SessionOptions to enable caching.
- Implemented a nudge escalation system that reminds users to update the TODO.md file, capped at three nudges.
- Created a new `scaffoldWorkflowFiles` function to generate TODO.md, ROADMAP.md, and docs/ directory when they are missing.
- Updated the App component to scaffold workflow files during initialization.
- Added hooks functionality to allow external scripts to modify tool input and manage approvals.
- Implemented tests for the new hooks functionality, ensuring proper approval and execution flow.
This commit is contained in:
asepharyana
2026-09-09 20:11:23 +07:00
parent 4aeb0d2455
commit 7a1e415fea
12 changed files with 884 additions and 32 deletions
+1
View File
@@ -55,6 +55,7 @@ export function testHooks(over: Partial<AppHooks> = {}): AppHooks {
remove: async (name) => `removed ${name}`,
},
initPrompt: 'write AGENTS.md',
scaffoldWorkflow: () => [],
history: [],
recordPrompt: () => {},
...over,
+150
View File
@@ -0,0 +1,150 @@
import { expect, test } from 'bun:test';
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
hashFile,
loadApprovalStore,
loadHooks,
parseHookManifest,
runPreTool,
hooksToPlugin,
} from '../src/hooks';
import { createHost } from '../src/plugins';
function inTmp<T>(fn: (dir: string) => Promise<T>): Promise<T> {
const dir = mkdtempSync(join(tmpdir(), 'shiro-hooks-'));
const orig = process.env['SHIRO_HOME'];
process.env['SHIRO_HOME'] = dir;
return fn(dir).finally(() => {
process.env['SHIRO_HOME'] = orig;
rmSync(dir, { recursive: true, force: true });
});
}
/** A pre_tool hook that allows everything and echoes the input back. */
const ALLOW_HOOK = `#!/usr/bin/env node
const input = require('fs').readFileSync(0, 'utf8');
console.log(JSON.stringify({ allow: true }));
`;
/** A pre_tool hook that refuses. */
const BLOCK_HOOK = `#!/usr/bin/env node
console.log(JSON.stringify({ allow: false, reason: 'no writes in this repo' }));
`;
test('parseHookManifest accepts a valid manifest and rejects a bad one', () => {
const m = parseHookManifest(JSON.stringify({ name: 'no-force', hook: 'pre_tool', tools: ['bash'] }));
expect(m.name).toBe('no-force');
expect(m.hook).toBe('pre_tool');
expect(() => parseHookManifest('not json')).toThrow('not valid JSON');
expect(() => parseHookManifest(JSON.stringify({ name: 'X', hook: 'nope' }))).toThrow('malformed');
});
test('loadHooks finds a directory hook and hashes its executable', () =>
inTmp(async (dir) => {
const hookDir = join(dir, '.shiro', 'hooks', 'no-force');
mkdirSync(hookDir, { recursive: true });
writeFileSync(join(hookDir, 'manifest.json'), JSON.stringify({ name: 'no-force', hook: 'pre_tool' }));
writeFileSync(join(hookDir, 'run'), ALLOW_HOOK);
const hooks = await loadHooks(dir);
expect(hooks.length).toBe(1);
expect(hooks[0]!.manifest.name).toBe('no-force');
expect(hooks[0]!.hash).toMatch(/^[0-9a-f]{64}$/);
expect(hashFile(hooks[0]!.path)).toBe(hooks[0]!.hash);
}));
test('an unapproved hook blocks the call', () =>
inTmp(async (dir) => {
const hookDir = join(dir, '.shiro', 'hooks', 'no-force');
mkdirSync(hookDir, { recursive: true });
writeFileSync(join(hookDir, 'manifest.json'), JSON.stringify({ name: 'no-force', hook: 'pre_tool' }));
writeFileSync(join(hookDir, 'run'), BLOCK_HOOK);
const hooks = await loadHooks(dir);
const store = loadApprovalStore(); // empty store → nothing approved
const plugin = hooksToPlugin(hooks, store);
const blocked = await plugin.beforeToolCall?.({ toolName: 'bash', input: { command: 'rm -rf /' }, cwd: dir });
expect(blocked).toContain('has not been approved');
}));
test('an approved blocking hook refuses the call with its reason', () =>
inTmp(async (dir) => {
const hookDir = join(dir, '.shiro', 'hooks', 'no-force');
mkdirSync(hookDir, { recursive: true });
writeFileSync(join(hookDir, 'manifest.json'), JSON.stringify({ name: 'no-force', hook: 'pre_tool' }));
writeFileSync(join(hookDir, 'run'), BLOCK_HOOK);
const hooks = await loadHooks(dir);
const store = loadApprovalStore();
store.approve(hooks[0]!.hash);
const plugin = hooksToPlugin(hooks, store);
const blocked = await plugin.beforeToolCall?.({ toolName: 'bash', input: { command: 'rm -rf /' }, cwd: dir });
expect(blocked).toContain('no writes in this repo');
}));
test('an approved allowing hook lets the call through', () =>
inTmp(async (dir) => {
const hookDir = join(dir, '.shiro', 'hooks', 'allow');
mkdirSync(hookDir, { recursive: true });
writeFileSync(join(hookDir, 'manifest.json'), JSON.stringify({ name: 'allow', hook: 'pre_tool' }));
writeFileSync(join(hookDir, 'run'), ALLOW_HOOK);
const hooks = await loadHooks(dir);
const store = loadApprovalStore();
store.approve(hooks[0]!.hash);
const plugin = hooksToPlugin(hooks, store);
const allowed = await plugin.beforeToolCall?.({ toolName: 'bash', input: { command: 'ls' }, cwd: dir });
expect(allowed).toBeUndefined();
}));
test('a changed hash is refused until re-approved', () =>
inTmp(async (dir) => {
const hookDir = join(dir, '.shiro', 'hooks', 'no-force');
mkdirSync(hookDir, { recursive: true });
writeFileSync(join(hookDir, 'manifest.json'), JSON.stringify({ name: 'no-force', hook: 'pre_tool' }));
writeFileSync(join(hookDir, 'run'), BLOCK_HOOK);
const first = await loadHooks(dir);
const store = loadApprovalStore();
store.approve(first[0]!.hash);
// file changes → hash changes → approval no longer matches
writeFileSync(join(hookDir, 'run'), ALLOW_HOOK);
const second = await loadHooks(dir);
const plugin = hooksToPlugin(second, store);
const blocked = await plugin.beforeToolCall?.({ toolName: 'bash', input: { command: 'ls' }, cwd: dir });
expect(blocked).toContain('has not been approved');
}));
test('an invalid hook is skipped, not fatal', () =>
inTmp(async (dir) => {
const hookDir = join(dir, '.shiro', 'hooks', 'broken');
mkdirSync(hookDir, { recursive: true });
writeFileSync(join(hookDir, 'manifest.json'), 'not json');
writeFileSync(join(hookDir, 'run'), '#!/bin/sh\nexit 0\n');
const hooks = await loadHooks(dir);
expect(hooks.length).toBe(0);
}));
test('an approving hook runs in the guard chain', () =>
inTmp(async (dir) => {
const hookDir = join(dir, '.shiro', 'hooks', 'allow');
mkdirSync(hookDir, { recursive: true });
writeFileSync(join(hookDir, 'manifest.json'), JSON.stringify({ name: 'allow', hook: 'pre_tool' }));
writeFileSync(join(hookDir, 'run'), ALLOW_HOOK);
const hooks = await loadHooks(dir);
const store = loadApprovalStore();
store.approve(hooks[0]!.hash);
const host = createHost([hooksToPlugin(hooks, store)]);
const blocked = await host.guard({ toolName: 'bash', input: { command: 'ls' }, cwd: dir });
expect(blocked).toBeUndefined();
}));
test('a bad hook output blocks the call', () =>
inTmp(async (dir) => {
const hookDir = join(dir, '.shiro', 'hooks', 'bad');
mkdirSync(hookDir, { recursive: true });
writeFileSync(join(hookDir, 'manifest.json'), JSON.stringify({ name: 'bad', hook: 'pre_tool' }));
writeFileSync(join(hookDir, 'run'), '#!/usr/bin/env node\nconsole.log("not json");\n');
const hooks = await loadHooks(dir);
const store = loadApprovalStore();
store.approve(hooks[0]!.hash);
const outcome = await runPreTool(hooks[0]!, { toolName: 'bash', input: { command: 'ls' }, cwd: dir }, 5000);
expect(outcome.allow).toBe(false);
}));
+67 -11
View File
@@ -177,16 +177,72 @@ test('/workflow panel renders the status rows', () =>
expect(panel.body).toContain('workflow: on');
}));
test('context panel groups instructions and trackers separately', () => {
const pm = require('../src/ui/panel-bodies') as typeof import('../src/ui/panel-bodies');
const panel = pm.contextPanel(['/repo/AGENTS.md', '/repo/TODO.md', '/repo/docs/a.md']);
expect(panel.title).toBe('project instructions & trackers');
expect(panel.body).toContain('instructions:');
expect(panel.body).toContain('- `/repo/AGENTS.md`');
expect(panel.body).toContain('trackers:');
expect(panel.body).toContain('- `/repo/TODO.md`');
test('nudge ladder: fires up to 3 times, then stops', async () => {
const dir = mkdtempSync(join(tmpdir(), 'shiro-wf-ladder'));
try {
await Bun.write(join(dir, '.git', 'HEAD'), 'ref: refs/heads/main\n');
await Bun.write(join(dir, 'TODO.md'), '# Todo\n- [ ] task\n');
await Bun.write(join(dir, 'app.ts'), 'const a = 1;\n');
const empty = pm.contextPanel([]);
expect(empty.body).toContain('No `AGENTS.md`');
expect(empty.body).toContain('no project tracker is loaded');
let call = 0;
const session = new Session({
model: new MockLanguageModelV4({
doStream: async () => {
call++;
if (call <= 3) return stream(toolCall(`c${call}`, 'edit_file', { path: 'app.ts', oldString: 'const a = 1;', newString: `const a = ${call + 1};` }));
// Turn 4+: no tool call — agent stops
return stream(text('done'));
},
}),
askApproval: async () => 'once',
});
const notices: string[] = [];
for (let i = 0; i < 5; i++) {
for await (const ev of session.send(`turn ${i + 1}`)) {
if (ev.type === 'notice') notices.push(ev.text);
}
}
const nudgeNotices = notices.filter((n) => n.includes('without updating the project task list'));
expect(nudgeNotices.length).toBe(3);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('nudge resets after todo_write in a later turn', async () => {
const dir = mkdtempSync(join(tmpdir(), 'shiro-wf-reset'));
try {
await Bun.write(join(dir, '.git', 'HEAD'), 'ref: refs/heads/main\n');
await Bun.write(join(dir, 'TODO.md'), '# Todo\n- [ ] task\n');
await Bun.write(join(dir, 'app.ts'), 'const a = 1;\n');
let call = 0;
const session = new Session({
model: new MockLanguageModelV4({
doStream: async () => {
call++;
// Turn 1-3: edit file → 3 nudges
if (call <= 3) return stream(toolCall(`c${call}`, 'edit_file', { path: 'app.ts', oldString: 'const a = 1;', newString: `const a = ${call + 1};` }));
// Turn 4: todo_write → resets the nudge counter
if (call === 4) return stream(toolCall(`c${call}`, 'todo_write', { items: [{ text: 'completed task', done: true }] }));
// Turn 5: edit file → should nudge again (counter was reset)
return stream(toolCall(`c${call}`, 'edit_file', { path: 'app.ts', oldString: 'const a = 4;', newString: `const a = ${call + 1};` }));
},
}),
askApproval: async () => 'once',
});
const notices: string[] = [];
for (let i = 0; i < 6; i++) {
for await (const ev of session.send(`turn ${i + 1}`)) {
if (ev.type === 'notice') notices.push(ev.text);
}
}
const nudgeNotices = notices.filter((n) => n.includes('without updating the project task list'));
// 3 nudges (turns 1-3) + 1 reset + 1 more nudge (turn 5) = 4
expect(nudgeNotices.length).toBe(4);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});