210 lines
7.3 KiB
JavaScript
210 lines
7.3 KiB
JavaScript
import { test } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import fs from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
|
|
import {
|
|
classifyProgress,
|
|
clearHandoff,
|
|
extractQuestionsFromText,
|
|
loadGoal,
|
|
normaliseQuestions,
|
|
readHandoff,
|
|
resetProgress,
|
|
readProgress,
|
|
buildIterationPrompt,
|
|
} from '../src/goal.mjs'
|
|
|
|
function tmpProject() {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-loop-'))
|
|
return dir
|
|
}
|
|
|
|
test('loadGoal parses markdown checkboxes', () => {
|
|
const project = tmpProject()
|
|
const file = path.join(project, 'goal.md')
|
|
fs.writeFileSync(file, '# My goal\n\nBody text.\n\n- [x] done one\n- [ ] next one\n')
|
|
const goal = loadGoal({ project, goal: 'goal.md', goalText: '' })
|
|
assert.equal(goal.title, 'My goal')
|
|
assert.equal(goal.stages.length, 2)
|
|
assert.equal(goal.stages[0].done, true)
|
|
assert.equal(goal.stages[1].title, 'next one')
|
|
})
|
|
|
|
test('loadGoal parses json stages', () => {
|
|
const project = tmpProject()
|
|
const file = path.join(project, 'goal.json')
|
|
fs.writeFileSync(file, JSON.stringify({ title: 'T', stages: [{ id: 'a', title: 'A' }] }))
|
|
const goal = loadGoal({ project, goal: 'goal.json', goalText: '' })
|
|
assert.equal(goal.stages[0].id, 'a')
|
|
})
|
|
|
|
test('loadGoal unwraps inline-code backticks around verify commands', () => {
|
|
const project = tmpProject()
|
|
const file = path.join(project, 'goal.md')
|
|
fs.writeFileSync(
|
|
file,
|
|
'# G\n\n- [ ] One :: verify: `pnpm test` :: acceptance: ok\n- [ ] Two :: verify: pnpm build :: acceptance: ok\n',
|
|
)
|
|
const goal = loadGoal({ project, goal: 'goal.md', goalText: '' })
|
|
assert.equal(goal.stages[0].verify, 'pnpm test')
|
|
assert.equal(goal.stages[1].verify, 'pnpm build')
|
|
})
|
|
|
|
test('loadGoal unwraps inline-code backticks in json verify', () => {
|
|
const project = tmpProject()
|
|
const file = path.join(project, 'goal.json')
|
|
fs.writeFileSync(file, JSON.stringify({ title: 'T', stages: [{ id: 'a', title: 'A', verify: '`pnpm test`' }] }))
|
|
const goal = loadGoal({ project, goal: 'goal.json', goalText: '' })
|
|
assert.equal(goal.stages[0].verify, 'pnpm test')
|
|
})
|
|
|
|
test('loadGoal falls back to a single stage for inline text', () => {
|
|
const goal = loadGoal({ project: '/tmp', goal: '', goalText: 'Do the thing' })
|
|
assert.equal(goal.stages.length, 1)
|
|
assert.equal(goal.title, 'Do the thing')
|
|
})
|
|
|
|
test('classifyProgress prefers explicit percent and normalises questions', () => {
|
|
const goal = { title: 'G', stages: [{ id: 's1', title: 'One' }, { id: 's2', title: 'Two' }] }
|
|
const cls = classifyProgress(
|
|
{ topic: 'topic', percent: 42, stageId: 's2', stageStatus: 'in_progress', nextStage: 'Two', questions: ['Why?'] },
|
|
goal,
|
|
1,
|
|
)
|
|
assert.equal(cls.percent, 42)
|
|
assert.equal(cls.topic, 'topic')
|
|
assert.equal(cls.nextStage, 'Two')
|
|
assert.equal(cls.needsInput, true)
|
|
assert.equal(cls.questions[0].text, 'Why?')
|
|
})
|
|
|
|
test('classifyProgress derives percent from completed stages', () => {
|
|
const goal = { title: 'G', stages: [{ id: 's1', title: 'One' }, { id: 's2', title: 'Two' }] }
|
|
const cls = classifyProgress({ stageId: 's1', stageStatus: 'done' }, goal, 1)
|
|
assert.equal(cls.percent, 50)
|
|
assert.equal(cls.stageStatus, 'done')
|
|
})
|
|
|
|
test('classifyProgress clamps out-of-range percentages', () => {
|
|
const goal = { title: 'G', stages: [{ id: 's1', title: 'One' }] }
|
|
assert.equal(classifyProgress({ percent: 900 }, goal, 1).percent, 100)
|
|
assert.equal(classifyProgress({ percent: -5 }, goal, 1).percent, 0)
|
|
})
|
|
|
|
test('normaliseQuestions handles strings and objects, filters blanks', () => {
|
|
const qs = normaliseQuestions(['a?', { id: 'x', text: 'b?', options: [1, 2] }, { text: ' ' }])
|
|
assert.equal(qs.length, 2)
|
|
assert.deepEqual(qs[1].options, ['1', '2'])
|
|
})
|
|
|
|
test('extractQuestionsFromText pulls question lines only', () => {
|
|
const qs = extractQuestionsFromText(['Line one\ndoes this work?\nhttp://x?y=1\nAnother question?'])
|
|
assert.equal(qs.length, 2)
|
|
assert.match(qs[0].text, /does this work\?/)
|
|
})
|
|
|
|
test('resetProgress + readProgress round-trip', () => {
|
|
const project = tmpProject()
|
|
resetProgress({ project }, 'run-1', 3)
|
|
const p = readProgress({ project })
|
|
assert.equal(p.runId, 'run-1')
|
|
assert.equal(p.iteration, 3)
|
|
})
|
|
|
|
test('buildIterationPrompt embeds the progress path and answers', () => {
|
|
const project = tmpProject()
|
|
const goal = { title: 'G', description: 'd', stages: [{ id: 's1', title: 'One' }] }
|
|
const prompt = buildIterationPrompt({
|
|
config: { project, promptExtra: '' },
|
|
goal,
|
|
iteration: 2,
|
|
total: 5,
|
|
progress: { stageId: 's1', summary: 'prev', iteration: 1 },
|
|
answers: [{ question: 'Q?', answer: 'A!' }],
|
|
currentStageIndex: 0,
|
|
})
|
|
assert.match(prompt, /iteration 2\/5/i)
|
|
assert.match(prompt, /progress\.json/)
|
|
assert.match(prompt, /Q:/)
|
|
assert.match(prompt, /A!/)
|
|
})
|
|
|
|
test('buildIterationPrompt embeds the custom instruction before the goal', () => {
|
|
const project = tmpProject()
|
|
const goal = { title: 'G', description: 'd', stages: [{ id: 's1', title: 'One' }] }
|
|
const prompt = buildIterationPrompt({
|
|
config: { project, promptExtra: 'Always run pnpm test before reporting done.' },
|
|
goal,
|
|
iteration: 1,
|
|
total: 3,
|
|
progress: null,
|
|
answers: [],
|
|
currentStageIndex: 0,
|
|
})
|
|
assert.match(prompt, /## Operator instructions for this run/)
|
|
assert.match(prompt, /Always run pnpm test/)
|
|
assert.ok(prompt.indexOf('## Operator instructions') < prompt.indexOf('## Goal'))
|
|
|
|
const plain = buildIterationPrompt({
|
|
config: { project, promptExtra: ' ' },
|
|
goal,
|
|
iteration: 1,
|
|
total: 3,
|
|
progress: null,
|
|
answers: [],
|
|
currentStageIndex: 0,
|
|
})
|
|
assert.doesNotMatch(plain, /## Operator instructions/)
|
|
})
|
|
|
|
test('readHandoff + clearHandoff round-trip', () => {
|
|
const project = tmpProject()
|
|
assert.equal(readHandoff({ project }), null)
|
|
fs.mkdirSync(path.join(project, '.kilocode-loop'), { recursive: true })
|
|
fs.writeFileSync(path.join(project, '.kilocode-loop', 'handoff.md'), ' packet ')
|
|
assert.equal(readHandoff({ project }), 'packet')
|
|
clearHandoff({ project })
|
|
assert.equal(readHandoff({ project }), null)
|
|
})
|
|
|
|
test('buildIterationPrompt in fresh mode embeds the handoff packet and file list', () => {
|
|
const project = tmpProject()
|
|
const goal = { title: 'G', description: 'd', stages: [{ id: 's1', title: 'One' }] }
|
|
const prompt = buildIterationPrompt({
|
|
config: { project, promptExtra: '' },
|
|
goal,
|
|
iteration: 2,
|
|
total: 5,
|
|
progress: { stageId: 's1', summary: 'prev', iteration: 1, nextStage: 'Two' },
|
|
answers: [],
|
|
currentStageIndex: 0,
|
|
sharedContext: false,
|
|
handoff: '### Decision\nUse q-select.',
|
|
filesChanged: ['src/a.vue'],
|
|
})
|
|
assert.match(prompt, /fresh session/i)
|
|
assert.match(prompt, /Context handoff/)
|
|
assert.match(prompt, /handoff\.md/)
|
|
assert.match(prompt, /Use q-select/)
|
|
assert.match(prompt, /src\/a\.vue/)
|
|
})
|
|
|
|
test('buildIterationPrompt in shared mode omits the fresh-context handoff', () => {
|
|
const project = tmpProject()
|
|
const goal = { title: 'G', description: 'd', stages: [{ id: 's1', title: 'One' }] }
|
|
const prompt = buildIterationPrompt({
|
|
config: { project, promptExtra: '' },
|
|
goal,
|
|
iteration: 1,
|
|
total: 2,
|
|
progress: null,
|
|
answers: [],
|
|
currentStageIndex: 0,
|
|
sharedContext: true,
|
|
})
|
|
assert.doesNotMatch(prompt, /fresh session/i)
|
|
assert.doesNotMatch(prompt, /Handoff to the next iteration/)
|
|
})
|