claude-harness/dsh/tests/prestep.test.mjs

453 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import assert from 'node:assert/strict'
import { join } from 'node:path'
import test from 'node:test'
import { apply } from '../bundle/index.js'
import {
buildHarnessCheckText,
buildRoleHintText,
detectAdoption,
detectRole,
isHarnessMessage,
makePrestepMarker,
} from '../bundle/lib/prestep.js'
import { cleanup, tempDir, write } from './helpers.mjs'
const SECTION = '## OpenSpec и superpowers\n\nСекция подключения.\n'
/**
* A throwaway directory tree that looks like a harness-adopted repository:
* `.git/`, `openspec/`, `.codegraph/`, and the CLAUDE.md section.
*/
function adoptedRepo() {
const repo = tempDir('dsh-harness-adopted-')
write(join(repo, '.git', 'HEAD'), 'ref: refs/heads/main\n')
write(join(repo, 'openspec', 'specs', '.keep'), '')
write(join(repo, '.codegraph', '.keep'), '')
write(join(repo, 'CLAUDE.md'), `# Проект\n\n${SECTION}`)
return repo
}
// ---------------------------------------------------------------------------
// detectAdoption
// ---------------------------------------------------------------------------
test('detectAdoption: adopted repo reports adopted with no missing signals', () => {
const repo = adoptedRepo()
try {
assert.deepEqual(detectAdoption(repo), { adopted: true, root: repo, missing: [] })
} finally {
cleanup(repo)
}
})
test('detectAdoption: repo without openspec/ and the CLAUDE.md section is not adopted', () => {
const repo = tempDir('dsh-harness-partial-')
try {
write(join(repo, '.git', 'HEAD'), 'ref: refs/heads/main\n')
write(join(repo, '.codegraph', '.keep'), '')
write(join(repo, 'CLAUDE.md'), '# Проект без секции\n')
assert.deepEqual(detectAdoption(repo), {
adopted: false,
root: repo,
missing: ['openspec/', 'секция «OpenSpec и superpowers» в CLAUDE.md'],
})
} finally {
cleanup(repo)
}
})
test('detectAdoption: cwd in a subdirectory walks up to the repo root', () => {
const repo = adoptedRepo()
try {
const subdir = join(repo, 'dsh', 'bundle', 'lib')
write(join(subdir, 'module.js'), 'export {}\n')
assert.deepEqual(detectAdoption(subdir), { adopted: true, root: repo, missing: [] })
} finally {
cleanup(repo)
}
})
test('detectAdoption: cwd outside any git repo stays silent', () => {
const outside = tempDir('dsh-harness-outside-')
try {
assert.deepEqual(detectAdoption(outside), { adopted: true, root: null, missing: [] })
} finally {
cleanup(outside)
}
})
test('detectAdoption: a .git file (worktree checkout) is a repo root too', () => {
const repo = tempDir('dsh-harness-worktree-')
try {
write(join(repo, '.git'), `gitdir: ${join(repo, '..', 'main', '.git', 'worktrees', 'x')}\n`)
write(join(repo, 'openspec', 'specs', '.keep'), '')
write(join(repo, '.codegraph', '.keep'), '')
write(join(repo, 'CLAUDE.md'), SECTION)
assert.deepEqual(detectAdoption(join(repo, 'openspec')), {
adopted: true,
root: repo,
missing: [],
})
} finally {
cleanup(repo)
}
})
// ---------------------------------------------------------------------------
// buildHarnessCheckText
// ---------------------------------------------------------------------------
test('buildHarnessCheckText names the root, missing signals, and next steps', () => {
const text = buildHarnessCheckText('/tmp/proj', [
'openspec/',
'секция «OpenSpec и superpowers» в CLAUDE.md',
])
assert.match(
text,
/^\[dsh-harness\] Проект \/tmp\/proj не подключён к обвязке: нет: openspec\/; секция «OpenSpec и superpowers» в CLAUDE\.md\. /,
)
assert.match(text, /openspec init --tools agents/)
assert.match(text, /codegraph init/)
assert.match(text, /Предложи владельцу настройку/)
})
// ---------------------------------------------------------------------------
// detectRole
// ---------------------------------------------------------------------------
test('detectRole: admin keywords (jira, гант, спринт)', () => {
assert.equal(detectRole('Разбери jira-задачу'), 'admin')
assert.equal(detectRole('Покажи гант проекта'), 'admin')
assert.equal(detectRole('Новый спринт начинается'), 'admin')
assert.equal(detectRole('JIRA-тикет'), 'admin')
})
test('detectRole: qa keywords (стенд, прогон, скриншот, макет)', () => {
assert.equal(detectRole('Подними стенд'), 'qa')
assert.equal(detectRole('Нужен прогон сценария'), 'qa')
assert.equal(detectRole('Сделай скриншот'), 'qa')
assert.equal(detectRole('Сверь с макетом'), 'qa')
})
test('detectRole: analyst keywords (тз, oq, спека, методика)', () => {
assert.equal(detectRole('Оформи ТЗ'), 'analyst')
assert.equal(detectRole('Закрой OQ'), 'analyst')
assert.equal(detectRole('Спека готова'), 'analyst')
assert.equal(detectRole('Нужна методика'), 'analyst')
})
test('detectRole: dev keywords (срез, issue, pr, тест, миграция)', () => {
assert.equal(detectRole('Сними срез метрик'), 'dev')
assert.equal(detectRole('Открой issue'), 'dev')
assert.equal(detectRole('Проверь тест'), 'dev')
assert.equal(detectRole('Нужна миграция'), 'dev')
})
test('detectRole: pr matches only as a whole word', () => {
assert.equal(detectRole('сделай PR'), 'dev')
assert.equal(detectRole('просто посмотри'), null)
assert.equal(detectRole('preview the branch'), null)
})
test('detectRole: priority is admin before qa, analyst, and dev', () => {
assert.equal(detectRole('спринт и тест'), 'admin')
assert.equal(detectRole('прогон и issue'), 'qa')
assert.equal(detectRole('спека и тест'), 'analyst')
})
test('detectRole: no keyword or empty input yields null', () => {
assert.equal(detectRole('Привет, как дела?'), null)
assert.equal(detectRole(''), null)
})
// ---------------------------------------------------------------------------
// buildRoleHintText
// ---------------------------------------------------------------------------
test('buildRoleHintText: known role names it and asks to load the skill', () => {
const text = buildRoleHintText('qa')
assert.ok(text.startsWith('[dsh-harness]'))
assert.match(text, /Роль сессии: qa\./)
assert.match(text, /Вызови skill role\./)
})
test('buildRoleHintText: null asks one question with all four roles', () => {
const text = buildRoleHintText(null)
assert.ok(text.startsWith('[dsh-harness]'))
assert.match(text, /Роль сессии неясна\./)
for (const role of ['dev', 'admin', 'analyst', 'qa']) {
assert.ok(text.includes(`${role} —`), `${role} variant is listed`)
}
assert.match(text, /четырьмя вариантами/)
})
// ---------------------------------------------------------------------------
// isHarnessMessage
// ---------------------------------------------------------------------------
test('isHarnessMessage: bootstrap, DSH, and legacy claude-harness texts are harness messages', () => {
assert.equal(isHarnessMessage('<!-- DSH-HARNESS-BOOTSTRAP:abc123 -->\nYou have superpowers.'), true)
assert.equal(isHarnessMessage('[dsh-harness] Роль сессии: dev.'), true)
assert.equal(isHarnessMessage('[claude-harness] Роль сессии: dev'), true)
})
test('isHarnessMessage: plain user text is not a harness message', () => {
assert.equal(isHarnessMessage('Привет! Разбери задачу.'), false)
assert.equal(isHarnessMessage(''), false)
})
// ---------------------------------------------------------------------------
// makePrestepMarker
// ---------------------------------------------------------------------------
test('makePrestepMarker: marker format, body prefix, determinism, uniqueness', () => {
const { marker, body } = makePrestepMarker('Привет')
assert.match(marker, /^<!-- DSH-HARNESS-PRESTEP:[0-9a-f]{16} -->$/)
assert.ok(body.startsWith(marker))
assert.equal(body, `${marker}\nПривет`)
assert.equal(makePrestepMarker('Привет').marker, marker, 'same text, same marker')
assert.notEqual(makePrestepMarker('Пока').marker, marker, 'different text, different marker')
assert.notEqual(makePrestepMarker('привет').marker, marker, 'case matters')
})
// ---------------------------------------------------------------------------
// apply(): wiring of harness-check and role-hint into agent/pre-step
// ---------------------------------------------------------------------------
/** Text of the first text block of a message. */
function messageText(message) {
return message.content[0].text
}
/**
* apply() against a fake ctx: the `agent/pre-step` listener is captured and
* can be run with a canned decision. The session cwd is served by the fake
* workingDirectory; bootstrap is off in most cases so its big text does not
* pollute the message lists.
*/
function prestepHarness(config = {}, cwd) {
const listeners = {}
const ctx = {
tools: { guard() {} },
on(event, handler) { listeners[event] = handler },
inject() {},
workingDirectory: { get() { return cwd } },
logger: { info() {}, warn() {}, error() {} },
}
apply(ctx, config)
const handler = listeners['agent/pre-step']
assert.ok(handler, 'agent/pre-step listener is registered')
return {
run({ agent = {}, messages = [] } = {}) {
return handler({ agent, signal: {} }, async () => ({ kind: 'continue', messages }))
},
}
}
/** A tree with `.git` but none of the adoption signals. */
function nonAdoptedRepo() {
const repo = tempDir('dsh-harness-noadopt-')
write(join(repo, '.git', 'HEAD'), 'ref: refs/heads/main\n')
write(join(repo, 'CLAUDE.md'), '# Проект без секции\n')
return repo
}
function userMessage(text) {
return { content: [{ type: 'text', text }], source: { kind: 'user' } }
}
test('apply: harness-check injects when adoption signals are missing', async () => {
const repo = nonAdoptedRepo()
try {
const harness = prestepHarness({ bootstrap: false }, repo)
const decision = await harness.run({ agent: { session: 's1' }, messages: [] })
assert.equal(decision.messages.length, 1)
const text = messageText(decision.messages[0])
assert.match(text, /^<!-- DSH-HARNESS-PRESTEP:[0-9a-f]{16} -->/)
assert.match(text, /\[dsh-harness\] Проект .* не подключён к обвязке/)
assert.match(text, /openspec init --tools agents/)
assert.equal(decision.messages[0].source.kind, 'user')
} finally {
cleanup(repo)
}
})
test('apply: harness-check stays silent for an adopted repo and outside git', async () => {
const repo = adoptedRepo()
try {
const harness = prestepHarness({ bootstrap: false }, repo)
const decision = await harness.run({ agent: { session: 's1' }, messages: [] })
assert.deepEqual(decision.messages, [])
} finally {
cleanup(repo)
}
const outside = tempDir('dsh-harness-outside-')
try {
const harness = prestepHarness({ bootstrap: false }, outside)
const decision = await harness.run({ agent: { session: 's1' }, messages: [] })
assert.deepEqual(decision.messages, [])
} finally {
cleanup(outside)
}
})
test('apply: role-hint injects Роль сессии: dev for a keyword user message', async () => {
const outside = tempDir('dsh-harness-outside-')
try {
const harness = prestepHarness({ bootstrap: false }, outside)
const user = userMessage('Напиши тест для модуля')
const decision = await harness.run({ agent: { session: 's1' }, messages: [user] })
assert.equal(decision.messages.length, 2)
const text = messageText(decision.messages[1])
assert.match(text, /^<!-- DSH-HARNESS-PRESTEP:[0-9a-f]{16} -->/)
assert.match(text, /\[dsh-harness\] Роль сессии: dev\./)
} finally {
cleanup(outside)
}
})
test('apply: role-hint asks the four-variant question when no keyword matches', async () => {
const outside = tempDir('dsh-harness-outside-')
try {
const harness = prestepHarness({ bootstrap: false }, outside)
const user = userMessage('Привет, как дела?')
const decision = await harness.run({ agent: { session: 's1' }, messages: [user] })
assert.equal(decision.messages.length, 2)
const text = messageText(decision.messages[1])
assert.match(text, /^<!-- DSH-HARNESS-PRESTEP:[0-9a-f]{16} -->/)
assert.match(text, /\[dsh-harness\] Роль сессии неясна\./)
assert.match(text, /четырьмя вариантами/)
} finally {
cleanup(outside)
}
})
test('apply: role-hint stays silent when the step has no new user message', async () => {
const outside = tempDir('dsh-harness-outside-')
try {
const harness = prestepHarness({ bootstrap: false }, outside)
const decision = await harness.run({ agent: { session: 's1' }, messages: [] })
assert.deepEqual(decision.messages, [])
} finally {
cleanup(outside)
}
})
test('apply: a user message that equals the role-hint question does not re-trigger role-hint', async () => {
const outside = tempDir('dsh-harness-outside-')
try {
const harness = prestepHarness({ bootstrap: false }, outside)
const user = userMessage(buildRoleHintText(null))
const decision = await harness.run({ agent: { session: 's1' }, messages: [user] })
assert.deepEqual(decision.messages, [user], 'isHarnessMessage filters before detectRole')
} finally {
cleanup(outside)
}
})
test('apply: a [claude-harness]-prefixed user message is skipped by role-hint', async () => {
const outside = tempDir('dsh-harness-outside-')
try {
const harness = prestepHarness({ bootstrap: false }, outside)
const user = userMessage('[claude-harness] Роль сессии: dev. Вызови skill role.')
const decision = await harness.run({ agent: { session: 's1' }, messages: [user] })
assert.deepEqual(decision.messages, [user], 'legacy prefix is filtered too')
} finally {
cleanup(outside)
}
})
test('apply: bootstrap:false leaves harness-check wired (the gate is ctx.on, not bootstrapOk)', async () => {
const repo = nonAdoptedRepo()
try {
const harness = prestepHarness({ bootstrap: false, roleHint: false }, repo)
const decision = await harness.run({ agent: { session: 's1' }, messages: [] })
assert.equal(decision.messages.length, 1)
assert.match(messageText(decision.messages[0]), /не подключён к обвязке/)
} finally {
cleanup(repo)
}
})
// ---------------------------------------------------------------------------
// Subagent skip (T2.1): real runtime delegation signals
// ---------------------------------------------------------------------------
/**
* Agent shapes that the runtime uses for a delegated step: the session header
* carries `origin: 'subagent'` (or a positive delegationDepth / subagentDepth
* as a robust fallback).
*/
const SUBAGENT_AGENTS = [
{ session: { header: { delegationDepth: 1 } } },
{ session: { header: { origin: 'subagent', parentSession: 's0' } } },
{ options: { subagentDepth: 1 } },
]
test('apply: a delegated pre-step injects nothing (bootstrap, harness-check, role-hint)', async () => {
const repo = nonAdoptedRepo()
const skill = tempDir('dsh-harness-skill-')
try {
write(join(skill, 'SKILL.md'), '---\nname: using-superpowers\n---\nbody\n')
const harness = prestepHarness({ bootstrapSkillPath: join(skill, 'SKILL.md') }, repo)
for (const agent of SUBAGENT_AGENTS) {
const decision = await harness.run({ agent, messages: [userMessage('Напиши тест')] })
assert.equal(decision.messages.length, 1, `no injection for ${JSON.stringify(agent)}`)
assert.equal(messageText(decision.messages[0]), 'Напиши тест')
}
} finally {
cleanup(repo)
cleanup(skill)
}
})
test('apply: a plain pre-step still injects bootstrap, harness-check, and role-hint', async () => {
const repo = nonAdoptedRepo()
const skill = tempDir('dsh-harness-skill-')
try {
write(join(skill, 'SKILL.md'), '---\nname: using-superpowers\n---\nbody\n')
const harness = prestepHarness({ bootstrapSkillPath: join(skill, 'SKILL.md') }, repo)
const decision = await harness.run({ agent: { session: 's1' }, messages: [userMessage('Напиши тест')] })
assert.equal(decision.messages.length, 4)
assert.equal(messageText(decision.messages[0]), 'Напиши тест')
const texts = decision.messages.map(messageText)
assert.ok(texts.some((text) => text.includes('DSH-HARNESS-BOOTSTRAP')), 'bootstrap injected')
assert.ok(texts.some((text) => text.includes('не подключён к обвязке')), 'harness-check injected')
assert.ok(texts.some((text) => text.includes('Роль сессии: dev')), 'role-hint injected')
} finally {
cleanup(repo)
cleanup(skill)
}
})
test('apply: a forked session (parentSession without origin) still injects all three', async () => {
const repo = nonAdoptedRepo()
const skill = tempDir('dsh-harness-skill-')
try {
write(join(skill, 'SKILL.md'), '---\nname: using-superpowers\n---\nbody\n')
const harness = prestepHarness({ bootstrapSkillPath: join(skill, 'SKILL.md') }, repo)
const forkAgent = { session: { header: { parentSession: 's0', isSeeded: true } } }
const decision = await harness.run({ agent: forkAgent, messages: [userMessage('Напиши тест')] })
assert.equal(decision.messages.length, 4, 'forks are not subagents: all three injections expected')
const texts = decision.messages.map(messageText)
assert.ok(texts.some((text) => text.includes('DSH-HARNESS-BOOTSTRAP')), 'bootstrap injected')
assert.ok(texts.some((text) => text.includes('не подключён к обвязке')), 'harness-check injected')
assert.ok(texts.some((text) => text.includes('Роль сессии: dev')), 'role-hint injected')
} finally {
cleanup(repo)
cleanup(skill)
}
})