feat: implement auto-scaffolding for project workflow files in bare repos
This commit is contained in:
@@ -74,15 +74,17 @@ test('/changes is undefined for a turn that wrote nothing', () =>
|
||||
expect(session.lastTurnSummary()).toBeUndefined();
|
||||
}));
|
||||
|
||||
test('system prompt is memoized until a volatile part changes', () => {
|
||||
test('system prompt is memoized until a volatile part changes', async () => {
|
||||
const session = new Session({
|
||||
model: new MockLanguageModelV4({ doStream: async () => stream(text('ok')) }),
|
||||
askApproval: noop,
|
||||
});
|
||||
// private API is exercised through the public turn loop; assert the cache counts.
|
||||
for (let i = 0; i < 3; i++) void session.estimatedTokens();
|
||||
// force a miss then a few hits via send
|
||||
void drain(session);
|
||||
// force a miss then a few hits via send. Awaited: the turn loop must run to
|
||||
// completion before the stats are read (the auto-scaffold adds an async step
|
||||
// at the top of send, so a fire-and-forget drain races the first prompt build).
|
||||
await drain(session);
|
||||
const stats = session.promptCacheStats();
|
||||
expect(stats.misses).toBeGreaterThanOrEqual(1);
|
||||
expect(stats.hits).toBeGreaterThanOrEqual(0);
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { MockLanguageModelV4, simulateReadableStream } from 'ai/test';
|
||||
import type { LanguageModelV4StreamPart } from '@ai-sdk/provider';
|
||||
import { scaffoldMissingAuto, projectTracksAt } from '../src/scaffold';
|
||||
import { usageOf } from './helpers';
|
||||
|
||||
const usage = usageOf(5, 3);
|
||||
|
||||
function textParts(body: string): LanguageModelV4StreamPart[] {
|
||||
return [
|
||||
{ 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 stream(body: string) {
|
||||
const parts = textParts(body);
|
||||
return { stream: simulateReadableStream({ chunks: parts, chunkDelayInMs: null, initialDelayInMs: null }) };
|
||||
}
|
||||
|
||||
function makeRepo(): { dir: string; cleanup: () => void } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'shiro-scaffold-'));
|
||||
const cleanup = () => rmSync(dir, { recursive: true, force: true });
|
||||
return { dir, cleanup };
|
||||
}
|
||||
|
||||
const ALL_FOUR = `===FILE TODO.md===
|
||||
# TODO
|
||||
|
||||
## Now
|
||||
- first
|
||||
|
||||
## Next
|
||||
- second
|
||||
|
||||
===FILE ROADMAP.md===
|
||||
# Roadmap
|
||||
|
||||
## Next
|
||||
- plan
|
||||
|
||||
===FILE docs/README.md===
|
||||
# Docs
|
||||
|
||||
Developer docs.
|
||||
|
||||
===FILE AGENTS.md===
|
||||
# AGENTS
|
||||
|
||||
This project is a test.
|
||||
`;
|
||||
|
||||
test('projectTracksAt is false for an empty repo and true when a tracker exists', async () => {
|
||||
const { dir, cleanup } = makeRepo();
|
||||
try {
|
||||
expect(projectTracksAt(dir)).toBe(false);
|
||||
await Bun.write(join(dir, 'TODO.md'), '# Todo\n');
|
||||
expect(projectTracksAt(dir)).toBe(true);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('scaffoldMissingAuto writes all four files when the repo has none', async () => {
|
||||
const { dir, cleanup } = makeRepo();
|
||||
try {
|
||||
const model = new MockLanguageModelV4({ doStream: async () => stream(ALL_FOUR) });
|
||||
const written = await scaffoldMissingAuto(dir, model);
|
||||
// Expect 4 files written.
|
||||
expect(written.length).toBe(4);
|
||||
expect(existsSync(join(dir, 'TODO.md'))).toBe(true);
|
||||
expect(existsSync(join(dir, 'ROADMAP.md'))).toBe(true);
|
||||
expect(existsSync(join(dir, 'docs', 'README.md'))).toBe(true);
|
||||
expect(existsSync(join(dir, 'AGENTS.md'))).toBe(true);
|
||||
// Content came from the model, not the empty template.
|
||||
expect(readFileSync(join(dir, 'TODO.md'), 'utf8')).toContain('- first');
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('scaffoldMissingAuto bails when a repo already tracks progress', async () => {
|
||||
const { dir, cleanup } = makeRepo();
|
||||
try {
|
||||
await Bun.write(join(dir, 'TODO.md'), '# Todo\n- [ ] existing\n');
|
||||
const model = new MockLanguageModelV4({ doStream: async () => stream('') });
|
||||
const written = await scaffoldMissingAuto(dir, model);
|
||||
expect(written).toEqual([]);
|
||||
expect(existsSync(join(dir, 'ROADMAP.md'))).toBe(false);
|
||||
expect(readFileSync(join(dir, 'TODO.md'), 'utf8')).toBe('# Todo\n- [ ] existing\n');
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('scaffoldMissingAuto degrades to empty templates when the model fails', async () => {
|
||||
const { dir, cleanup } = makeRepo();
|
||||
try {
|
||||
const model = new MockLanguageModelV4({ doStream: async () => { throw new Error('model down'); } });
|
||||
const written = await scaffoldMissingAuto(dir, model);
|
||||
// The fallback writes the empty-tracker templates so the session never dies.
|
||||
expect(written.length).toBeGreaterThanOrEqual(3);
|
||||
expect(existsSync(join(dir, 'TODO.md'))).toBe(true);
|
||||
expect(existsSync(join(dir, 'ROADMAP.md'))).toBe(true);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('scaffoldMissingAuto bails entirely when any tracking file exists', async () => {
|
||||
const { dir, cleanup } = makeRepo();
|
||||
try {
|
||||
await Bun.write(join(dir, 'TODO.md'), 'precious');
|
||||
const model = new MockLanguageModelV4({
|
||||
doStream: async () =>
|
||||
stream('===FILE TODO.md===\n# TODO\noverwrite me\n===FILE ROADMAP.md===\n# Roadmap\n'),
|
||||
});
|
||||
const written = await scaffoldMissingAuto(dir, model);
|
||||
// A repo with any tracker is left alone — nothing new is written.
|
||||
expect(written).toEqual([]);
|
||||
expect(readFileSync(join(dir, 'TODO.md'), 'utf8')).toBe('precious');
|
||||
expect(existsSync(join(dir, 'ROADMAP.md'))).toBe(false);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
+73
-2
@@ -2,7 +2,7 @@ import { usageOf } from './helpers';
|
||||
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 { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Session } from '../src/session';
|
||||
@@ -85,7 +85,14 @@ test('workflow disabled renders no policy even with a TODO.md', () =>
|
||||
|
||||
test('bare repo renders no workflow policy', () =>
|
||||
inGitRepo(async () => {
|
||||
const session = new Session({ model: new MockLanguageModelV4({ doStream: async () => stream([]) }), askApproval: async () => "deny" });
|
||||
const session = new Session({
|
||||
model: new MockLanguageModelV4({ doStream: async () => stream([]) }),
|
||||
askApproval: async () => "deny",
|
||||
// Without this, the first send() would auto-scaffold TODO.md/etc into
|
||||
// the empty repo — that is the point of the feature, but this test is
|
||||
// specifically about the policy wire-up, so keep the repo bare.
|
||||
workflow: { autoScaffold: false },
|
||||
});
|
||||
const status = session.workflowStatus();
|
||||
expect(status.hasTodo).toBe(false);
|
||||
expect(status.hasRoadmap).toBe(false);
|
||||
@@ -277,4 +284,68 @@ test('todo_write in a turn suppresses that turn\'s nudge', async () => {
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('first turn auto-scaffolds TODO.md/ROADMAP.md/docs/AGENTS.md in a bare repo', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'shiro-wf-autoscaffold'));
|
||||
const orig = process.cwd();
|
||||
process.chdir(dir);
|
||||
try {
|
||||
await Bun.write(join(dir, '.git', 'HEAD'), 'ref: refs/heads/main\n');
|
||||
|
||||
// The model returns the four workflow files on the scaffold call, then a
|
||||
// plain text answer for the actual turn.
|
||||
let call = 0;
|
||||
const session = new Session({
|
||||
model: new MockLanguageModelV4({
|
||||
doStream: async () => {
|
||||
call++;
|
||||
if (call === 1) {
|
||||
// Scaffold call: emit text-delta chunks carrying all four file blocks.
|
||||
const body = [
|
||||
'===FILE TODO.md===',
|
||||
'# TODO\n\n## Now\n- first',
|
||||
'===FILE ROADMAP.md===',
|
||||
'# Roadmap\n\n## Next\n- plan',
|
||||
'===FILE docs/README.md===',
|
||||
'# Docs\n\nDeveloper docs.',
|
||||
'===FILE AGENTS.md===',
|
||||
'# AGENTS\n\nA test project.',
|
||||
].join('\n');
|
||||
return {
|
||||
stream: simulateReadableStream({
|
||||
chunks: [
|
||||
{ type: 'text-start', id: 's' },
|
||||
{ type: 'text-delta', id: 's', delta: body },
|
||||
{ type: 'text-end', id: 's' },
|
||||
{ type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage },
|
||||
],
|
||||
chunkDelayInMs: null,
|
||||
initialDelayInMs: null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
// The actual turn: no tool calls, just text.
|
||||
return stream(text('ok'));
|
||||
},
|
||||
}),
|
||||
askApproval: async () => 'once',
|
||||
});
|
||||
|
||||
const notices: string[] = [];
|
||||
for await (const ev of session.send('hello')) {
|
||||
if (ev.type === 'notice') notices.push(ev.text);
|
||||
}
|
||||
|
||||
// The scaffold notice fired before the real turn.
|
||||
expect(notices.some((n) => n.startsWith('scaffolded project workflow files'))).toBe(true);
|
||||
// All four files now exist at the git root.
|
||||
expect(existsSync(join(dir, 'TODO.md'))).toBe(true);
|
||||
expect(existsSync(join(dir, 'ROADMAP.md'))).toBe(true);
|
||||
expect(existsSync(join(dir, 'docs', 'README.md'))).toBe(true);
|
||||
expect(existsSync(join(dir, 'AGENTS.md'))).toBe(true);
|
||||
} finally {
|
||||
process.chdir(orig);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user