tissues 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -1
- package/package.json +4 -2
- package/src/cli.js +12 -1
- package/src/commands/ai.js +149 -173
- package/src/commands/config.js +143 -69
- package/src/commands/create.js +16 -9
- package/src/commands/create.test.js +381 -0
- package/src/commands/enhancements.js +282 -0
- package/src/commands/flush.test.js +299 -0
- package/src/commands/list.js +3 -2
- package/src/commands/providers.js +347 -0
- package/src/commands/providers.test.js +28 -0
- package/src/commands/storage.js +167 -0
- package/src/commands/sync.js +225 -0
- package/src/daemon/sync.js +189 -0
- package/src/lib/ai/adapters/claude-cli.js +55 -0
- package/src/lib/ai/adapters/cli-adapters.test.js +133 -0
- package/src/lib/ai/adapters/codex-cli.js +77 -0
- package/src/lib/ai/adapters/command.js +23 -13
- package/src/lib/ai/adapters/gemini-cli.js +55 -0
- package/src/lib/ai/adapters/openclaw.js +91 -0
- package/src/lib/ai/agent-actions.js +271 -0
- package/src/lib/ai/agent.js +323 -0
- package/src/lib/ai/body-template.js +15 -0
- package/src/lib/ai/discovery.js +89 -0
- package/src/lib/ai/discovery.test.js +74 -0
- package/src/lib/ai/enhance.js +48 -11
- package/src/lib/ai/enhancement-adapter.js +109 -0
- package/src/lib/ai/enhancement-adapter.test.js +188 -0
- package/src/lib/ai/index.js +2 -2
- package/src/lib/ai/pipeline.js +20 -2
- package/src/lib/ai/pipeline.test.js +257 -0
- package/src/lib/ai/prompt.test.js +30 -0
- package/src/lib/ai/router.js +118 -7
- package/src/lib/ai/router.test.js +481 -0
- package/src/lib/ai/steps.js +23 -3
- package/src/lib/ai/steps.test.js +335 -0
- package/src/lib/attribution.test.js +64 -0
- package/src/lib/cache.js +408 -0
- package/src/lib/db.js +42 -0
- package/src/lib/dedup.js +44 -48
- package/src/lib/dedup.test.js +227 -0
- package/src/lib/defaults.js +37 -1
- package/src/lib/defaults.test.js +217 -0
- package/src/lib/drafts-perf.test.js +203 -0
- package/src/lib/drafts.test.js +300 -0
- package/src/lib/enhancements.js +436 -0
- package/src/lib/enhancements.test.js +294 -0
- package/src/lib/gh.js +76 -10
- package/src/lib/safety.test.js +217 -0
- package/src/lib/storage.js +298 -0
- package/src/lib/templates.test.js +207 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge between enhancement descriptors and pipeline step objects.
|
|
3
|
+
*
|
|
4
|
+
* Converts enhancement descriptors (from enhancements.js) into step objects
|
|
5
|
+
* that pipeline.js can execute. Built-in steps retain their specialized
|
|
6
|
+
* shouldRun/parseResponse logic; custom steps get generic implementations.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { BUILT_IN_STEPS_MAP, tryParseJSON, contextSummary, priorStepsSummary } from './steps.js'
|
|
10
|
+
import { buildBodyGuidance } from './body-template.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Convert an enhancement descriptor into a pipeline step object.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} enhancement - descriptor from enhancements.js
|
|
16
|
+
* @returns {object} step object compatible with pipeline.js
|
|
17
|
+
*/
|
|
18
|
+
export function enhancementToStep(enhancement) {
|
|
19
|
+
const builtInStep = BUILT_IN_STEPS_MAP[enhancement.key]
|
|
20
|
+
|
|
21
|
+
// If this is a built-in enhancement, use the built-in step's specialized
|
|
22
|
+
// shouldRun and parseResponse, but allow the prompt to be overridden
|
|
23
|
+
if (builtInStep) {
|
|
24
|
+
const promptOverridden = enhancement.source && enhancement.source !== 'built-in'
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
name: enhancement.key,
|
|
28
|
+
displayName: enhancement.name,
|
|
29
|
+
maxTokens: enhancement.maxTokens,
|
|
30
|
+
provider: enhancement.provider || null,
|
|
31
|
+
|
|
32
|
+
shouldRun: builtInStep.shouldRun,
|
|
33
|
+
parseResponse: builtInStep.parseResponse,
|
|
34
|
+
|
|
35
|
+
buildMessages(ctx) {
|
|
36
|
+
if (promptOverridden && enhancement.prompt) {
|
|
37
|
+
// Use the user/repo-overridden prompt with the standard user message
|
|
38
|
+
return buildCustomMessages(enhancement, ctx)
|
|
39
|
+
}
|
|
40
|
+
return builtInStep.buildMessages(ctx)
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Custom enhancement — generic implementation
|
|
46
|
+
return {
|
|
47
|
+
name: enhancement.key,
|
|
48
|
+
displayName: enhancement.name,
|
|
49
|
+
maxTokens: enhancement.maxTokens,
|
|
50
|
+
provider: enhancement.provider || null,
|
|
51
|
+
|
|
52
|
+
shouldRun(ctx) {
|
|
53
|
+
// Check requires — all required context keys must be present
|
|
54
|
+
if (enhancement.requires?.length > 0) {
|
|
55
|
+
return enhancement.requires.every((key) => ctx[key] != null)
|
|
56
|
+
}
|
|
57
|
+
return true
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
buildMessages(ctx) {
|
|
61
|
+
return buildCustomMessages(enhancement, ctx)
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
parseResponse(raw, ctx) {
|
|
65
|
+
if (enhancement.format === 'json') {
|
|
66
|
+
try {
|
|
67
|
+
const parsed = tryParseJSON(raw)
|
|
68
|
+
ctx[enhancement.contextKey] = parsed
|
|
69
|
+
} catch {
|
|
70
|
+
ctx[enhancement.contextKey] = null
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
// markdown — store raw text
|
|
74
|
+
let cleaned = raw.trim()
|
|
75
|
+
const fenceMatch = cleaned.match(/^```(?:markdown|md)?\n([\s\S]*)\n```$/)
|
|
76
|
+
if (fenceMatch) cleaned = fenceMatch[1].trim()
|
|
77
|
+
if (cleaned.length > 0) {
|
|
78
|
+
ctx[enhancement.contextKey] = cleaned
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build messages for a custom or prompt-overridden enhancement.
|
|
87
|
+
*/
|
|
88
|
+
function buildCustomMessages(enhancement, ctx) {
|
|
89
|
+
let systemContent = enhancement.prompt
|
|
90
|
+
|
|
91
|
+
// For the format step, append body guidance
|
|
92
|
+
if (enhancement.key === 'format') {
|
|
93
|
+
const guidance = buildBodyGuidance(ctx)
|
|
94
|
+
systemContent = systemContent + '\n\n' + guidance
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// For the labels step, inject available labels
|
|
98
|
+
if (enhancement.key === 'labels' && ctx.repoLabels?.length) {
|
|
99
|
+
systemContent = systemContent.replace(
|
|
100
|
+
/Return ONLY valid JSON\.$/m,
|
|
101
|
+
`Available labels in this repo: ${ctx.repoLabels.join(', ')}\nReturn ONLY valid JSON.`,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return [
|
|
106
|
+
{ role: 'system', content: systemContent },
|
|
107
|
+
{ role: 'user', content: contextSummary(ctx) + priorStepsSummary(ctx) },
|
|
108
|
+
]
|
|
109
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { test, describe } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { enhancementToStep } from './enhancement-adapter.js'
|
|
4
|
+
import { BUILT_IN_ENHANCEMENTS } from '../enhancements.js'
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
function baseCtx(overrides = {}) {
|
|
11
|
+
return {
|
|
12
|
+
rawInput: '',
|
|
13
|
+
title: 'Fix auth bug',
|
|
14
|
+
description: 'The login page throws a 401 error',
|
|
15
|
+
instructions: '',
|
|
16
|
+
templateBody: '## Summary\n\n{{description}}',
|
|
17
|
+
labels: ['bug'],
|
|
18
|
+
existingIssues: [],
|
|
19
|
+
repoLabels: ['bug', 'feature', 'P0-critical'],
|
|
20
|
+
dedupScore: null,
|
|
21
|
+
structuredContext: null,
|
|
22
|
+
scopeAnalysis: null,
|
|
23
|
+
complexity: null,
|
|
24
|
+
complexityRationale: null,
|
|
25
|
+
risk: null,
|
|
26
|
+
riskRationale: null,
|
|
27
|
+
aiLabels: null,
|
|
28
|
+
body: '',
|
|
29
|
+
...overrides,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Built-in enhancement → step conversion
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
describe('enhancementToStep — built-in enhancements', () => {
|
|
38
|
+
test('converts built-in triage to step with correct fields', () => {
|
|
39
|
+
const step = enhancementToStep(BUILT_IN_ENHANCEMENTS.triage)
|
|
40
|
+
assert.equal(step.name, 'triage')
|
|
41
|
+
assert.equal(step.displayName, 'Input Analysis')
|
|
42
|
+
assert.equal(step.maxTokens, 1024)
|
|
43
|
+
assert.ok(typeof step.shouldRun === 'function')
|
|
44
|
+
assert.ok(typeof step.buildMessages === 'function')
|
|
45
|
+
assert.ok(typeof step.parseResponse === 'function')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('built-in step uses original shouldRun', () => {
|
|
49
|
+
const step = enhancementToStep(BUILT_IN_ENHANCEMENTS.triage)
|
|
50
|
+
assert.equal(step.shouldRun(baseCtx({ rawInput: 'hello' })), true)
|
|
51
|
+
assert.equal(step.shouldRun(baseCtx({ rawInput: '' })), false)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('built-in step uses original buildMessages', () => {
|
|
55
|
+
const step = enhancementToStep(BUILT_IN_ENHANCEMENTS.context)
|
|
56
|
+
const ctx = baseCtx()
|
|
57
|
+
const msgs = step.buildMessages(ctx)
|
|
58
|
+
assert.ok(Array.isArray(msgs))
|
|
59
|
+
assert.equal(msgs[0].role, 'system')
|
|
60
|
+
assert.equal(msgs[1].role, 'user')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('built-in step uses original parseResponse', () => {
|
|
64
|
+
const step = enhancementToStep(BUILT_IN_ENHANCEMENTS.complexity)
|
|
65
|
+
const ctx = baseCtx()
|
|
66
|
+
step.parseResponse(JSON.stringify({ score: 7, rationale: 'complex' }), ctx)
|
|
67
|
+
assert.equal(ctx.complexity, 7)
|
|
68
|
+
assert.equal(ctx.complexityRationale, 'complex')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('all built-in enhancements convert to valid steps', () => {
|
|
72
|
+
for (const [key, enh] of Object.entries(BUILT_IN_ENHANCEMENTS)) {
|
|
73
|
+
const step = enhancementToStep(enh)
|
|
74
|
+
assert.equal(step.name, key)
|
|
75
|
+
assert.ok(step.displayName)
|
|
76
|
+
assert.ok(typeof step.maxTokens === 'number')
|
|
77
|
+
assert.ok(typeof step.shouldRun === 'function')
|
|
78
|
+
assert.ok(typeof step.buildMessages === 'function')
|
|
79
|
+
assert.ok(typeof step.parseResponse === 'function')
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Prompt override
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
describe('enhancementToStep — prompt override', () => {
|
|
89
|
+
test('user-overridden built-in uses custom prompt', () => {
|
|
90
|
+
const overridden = {
|
|
91
|
+
...BUILT_IN_ENHANCEMENTS.context,
|
|
92
|
+
source: 'user',
|
|
93
|
+
prompt: 'Custom context prompt here.',
|
|
94
|
+
}
|
|
95
|
+
const step = enhancementToStep(overridden)
|
|
96
|
+
const msgs = step.buildMessages(baseCtx())
|
|
97
|
+
// Should use the custom prompt, not the built-in
|
|
98
|
+
assert.ok(msgs[0].content.includes('Custom context prompt here.'))
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('built-in source uses original buildMessages', () => {
|
|
102
|
+
const builtIn = { ...BUILT_IN_ENHANCEMENTS.context, source: 'built-in' }
|
|
103
|
+
const step = enhancementToStep(builtIn)
|
|
104
|
+
const msgs = step.buildMessages(baseCtx())
|
|
105
|
+
// Should use built-in prompt (includes "Extract structured context")
|
|
106
|
+
assert.ok(msgs[0].content.includes('Extract structured context'))
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Custom enhancement → step conversion
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
describe('enhancementToStep — custom enhancements', () => {
|
|
115
|
+
const customEnh = {
|
|
116
|
+
key: 'security',
|
|
117
|
+
name: 'Security Review',
|
|
118
|
+
maxTokens: 1024,
|
|
119
|
+
mode: 'auto',
|
|
120
|
+
format: 'json',
|
|
121
|
+
contextKey: 'securityScore',
|
|
122
|
+
order: 55,
|
|
123
|
+
requires: ['scopeAnalysis'],
|
|
124
|
+
isStructural: false,
|
|
125
|
+
prompt: 'You are a security reviewer.\nReturn JSON.',
|
|
126
|
+
source: 'user',
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
test('converts custom to step with correct fields', () => {
|
|
130
|
+
const step = enhancementToStep(customEnh)
|
|
131
|
+
assert.equal(step.name, 'security')
|
|
132
|
+
assert.equal(step.displayName, 'Security Review')
|
|
133
|
+
assert.equal(step.maxTokens, 1024)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('custom shouldRun checks requires array', () => {
|
|
137
|
+
const step = enhancementToStep(customEnh)
|
|
138
|
+
// Should not run when scopeAnalysis is missing
|
|
139
|
+
assert.equal(step.shouldRun(baseCtx({ scopeAnalysis: null })), false)
|
|
140
|
+
// Should run when scopeAnalysis is present
|
|
141
|
+
assert.equal(step.shouldRun(baseCtx({ scopeAnalysis: { files: [] } })), true)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('custom shouldRun returns true when no requires', () => {
|
|
145
|
+
const noReqs = { ...customEnh, requires: [] }
|
|
146
|
+
const step = enhancementToStep(noReqs)
|
|
147
|
+
assert.equal(step.shouldRun(baseCtx()), true)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('custom buildMessages uses prompt and context', () => {
|
|
151
|
+
const step = enhancementToStep(customEnh)
|
|
152
|
+
const msgs = step.buildMessages(baseCtx())
|
|
153
|
+
assert.equal(msgs[0].role, 'system')
|
|
154
|
+
assert.ok(msgs[0].content.includes('You are a security reviewer.'))
|
|
155
|
+
assert.equal(msgs[1].role, 'user')
|
|
156
|
+
assert.ok(msgs[1].content.includes('Fix auth bug'))
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('custom JSON parseResponse stores at contextKey', () => {
|
|
160
|
+
const step = enhancementToStep(customEnh)
|
|
161
|
+
const ctx = baseCtx()
|
|
162
|
+
step.parseResponse(JSON.stringify({ score: 8, rationale: 'risky' }), ctx)
|
|
163
|
+
assert.deepEqual(ctx.securityScore, { score: 8, rationale: 'risky' })
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('custom JSON parseResponse handles malformed JSON', () => {
|
|
167
|
+
const step = enhancementToStep(customEnh)
|
|
168
|
+
const ctx = baseCtx()
|
|
169
|
+
step.parseResponse('not json', ctx)
|
|
170
|
+
assert.equal(ctx.securityScore, null)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('custom markdown parseResponse stores raw text', () => {
|
|
174
|
+
const mdEnh = { ...customEnh, format: 'markdown', contextKey: 'review' }
|
|
175
|
+
const step = enhancementToStep(mdEnh)
|
|
176
|
+
const ctx = baseCtx()
|
|
177
|
+
step.parseResponse('## Review\n\nLooks good.', ctx)
|
|
178
|
+
assert.equal(ctx.review, '## Review\n\nLooks good.')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('custom markdown parseResponse strips code fences', () => {
|
|
182
|
+
const mdEnh = { ...customEnh, format: 'markdown', contextKey: 'review' }
|
|
183
|
+
const step = enhancementToStep(mdEnh)
|
|
184
|
+
const ctx = baseCtx()
|
|
185
|
+
step.parseResponse('```markdown\n## Review\n\nContent here.\n```', ctx)
|
|
186
|
+
assert.equal(ctx.review, '## Review\n\nContent here.')
|
|
187
|
+
})
|
|
188
|
+
})
|
package/src/lib/ai/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { resolveRoute, listProviders } from './router.js'
|
|
1
|
+
import { resolveRoute, resolveProviderAdapter, listProviders, listAllProviders } from './router.js'
|
|
2
2
|
import { buildMessages } from './prompt.js'
|
|
3
3
|
|
|
4
|
-
export { listProviders }
|
|
4
|
+
export { listProviders, listAllProviders, resolveProviderAdapter }
|
|
5
5
|
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
// Token usage tracking (in-memory, resets on process exit)
|
package/src/lib/ai/pipeline.js
CHANGED
|
@@ -5,19 +5,22 @@
|
|
|
5
5
|
* Failed steps log a warning and continue (graceful degradation).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { resolveProviderAdapter } from './router.js'
|
|
9
|
+
|
|
8
10
|
/**
|
|
9
11
|
* Run a sequence of pipeline steps.
|
|
10
12
|
*
|
|
11
13
|
* @param {object[]} steps - array of step objects (from steps.js)
|
|
12
14
|
* @param {object} ctx - mutable PipelineContext, accumulates results
|
|
13
15
|
* @param {{ adapter: object, model: string }} route - resolved adapter + model
|
|
16
|
+
* @param {object} [config] - merged config object (enables per-step provider overrides)
|
|
14
17
|
* @param {object} [budgets] - token budget config
|
|
15
18
|
* @param {object} [callbacks] - { onStepStart, onStepDone, onStepSkip, onStepFail }
|
|
16
19
|
* @param {{ checkBudgets: Function, recordUsage: Function }} [budgetHelpers]
|
|
17
20
|
* @returns {Promise<object>} the final ctx
|
|
18
21
|
*/
|
|
19
|
-
export async function runPipeline(steps, ctx, route, budgets, callbacks, budgetHelpers) {
|
|
20
|
-
const { adapter, model } = route
|
|
22
|
+
export async function runPipeline(steps, ctx, route, config, budgets, callbacks, budgetHelpers) {
|
|
23
|
+
const { adapter: defaultAdapter, model: defaultModel } = route
|
|
21
24
|
const cb = callbacks || {}
|
|
22
25
|
const { checkBudgets, recordUsage } = budgetHelpers || {}
|
|
23
26
|
|
|
@@ -43,6 +46,21 @@ export async function runPipeline(steps, ctx, route, budgets, callbacks, budgetH
|
|
|
43
46
|
|
|
44
47
|
cb.onStepStart?.(step)
|
|
45
48
|
|
|
49
|
+
// Resolve per-step adapter override
|
|
50
|
+
let adapter = defaultAdapter
|
|
51
|
+
let model = defaultModel
|
|
52
|
+
if (step.provider && config) {
|
|
53
|
+
try {
|
|
54
|
+
const override = resolveProviderAdapter(config, step.provider)
|
|
55
|
+
adapter = override.adapter
|
|
56
|
+
model = override.model
|
|
57
|
+
} catch (err) {
|
|
58
|
+
// Unknown/unconfigured provider — warn and fall back to default
|
|
59
|
+
cb.onStepFail?.(step, new Error(`Provider override "${step.provider}" failed: ${err.message}, using default`))
|
|
60
|
+
// Continue with default adapter (don't skip — aligns with "never lose data" principle)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
46
64
|
try {
|
|
47
65
|
// Check budget before the call (skip for format when budget already exhausted — it degrades gracefully)
|
|
48
66
|
if (checkBudgets && budgets && !budgetExhausted) {
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { test, describe } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { runPipeline } from './pipeline.js'
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Mock adapter
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
function mockAdapter(responses = {}) {
|
|
10
|
+
return {
|
|
11
|
+
name: 'mock',
|
|
12
|
+
complete(messages, opts) {
|
|
13
|
+
const lastUser = messages.find((m) => m.role === 'user')?.content || ''
|
|
14
|
+
// Return pre-configured response or a default
|
|
15
|
+
for (const [key, val] of Object.entries(responses)) {
|
|
16
|
+
if (lastUser.includes(key) || opts._stepName === key) return Promise.resolve(val)
|
|
17
|
+
}
|
|
18
|
+
return Promise.resolve('{}')
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makeStep(name, { shouldRun = true, response = '{}', parseResult = {}, failOnParse = false, provider = null } = {}) {
|
|
24
|
+
return {
|
|
25
|
+
name,
|
|
26
|
+
displayName: name,
|
|
27
|
+
maxTokens: 512,
|
|
28
|
+
provider,
|
|
29
|
+
shouldRun: () => shouldRun,
|
|
30
|
+
buildMessages: (ctx) => [
|
|
31
|
+
{ role: 'system', content: 'test' },
|
|
32
|
+
{ role: 'user', content: `step:${name} title:${ctx.title}` },
|
|
33
|
+
],
|
|
34
|
+
parseResponse: (raw, ctx) => {
|
|
35
|
+
if (failOnParse) throw new Error('parse failed')
|
|
36
|
+
Object.assign(ctx, parseResult)
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Tests
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
describe('runPipeline', () => {
|
|
46
|
+
test('steps execute in order, context accumulates', async () => {
|
|
47
|
+
const order = []
|
|
48
|
+
const steps = [
|
|
49
|
+
makeStep('a', { parseResult: { aResult: 'from-a' } }),
|
|
50
|
+
makeStep('b', { parseResult: { bResult: 'from-b' } }),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
const ctx = { title: 'test', _stepConfigs: { a: 'always', b: 'always' } }
|
|
54
|
+
const route = { adapter: mockAdapter(), model: 'test-model' }
|
|
55
|
+
|
|
56
|
+
await runPipeline(steps, ctx, route, null, null, {
|
|
57
|
+
onStepStart(step) { order.push(`start:${step.name}`) },
|
|
58
|
+
onStepDone(step) { order.push(`done:${step.name}`) },
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
assert.deepEqual(order, ['start:a', 'done:a', 'start:b', 'done:b'])
|
|
62
|
+
assert.equal(ctx.aResult, 'from-a')
|
|
63
|
+
assert.equal(ctx.bResult, 'from-b')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('shouldRun: false skips steps correctly', async () => {
|
|
67
|
+
const skipped = []
|
|
68
|
+
const steps = [
|
|
69
|
+
makeStep('a', { shouldRun: false }),
|
|
70
|
+
makeStep('b', { parseResult: { bDone: true } }),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
const ctx = { title: 'test', _stepConfigs: { a: 'auto', b: 'always' } }
|
|
74
|
+
const route = { adapter: mockAdapter(), model: 'test' }
|
|
75
|
+
|
|
76
|
+
await runPipeline(steps, ctx, route, null, null, {
|
|
77
|
+
onStepSkip(step, reason) { skipped.push({ name: step.name, reason }) },
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
assert.equal(skipped.length, 1)
|
|
81
|
+
assert.equal(skipped[0].name, 'a')
|
|
82
|
+
assert.equal(ctx.bDone, true)
|
|
83
|
+
assert.equal(ctx.aResult, undefined)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('step config "never" skips regardless of shouldRun', async () => {
|
|
87
|
+
const skipped = []
|
|
88
|
+
const steps = [makeStep('a', { shouldRun: true, parseResult: { aResult: true } })]
|
|
89
|
+
|
|
90
|
+
const ctx = { title: 'test', _stepConfigs: { a: 'never' } }
|
|
91
|
+
const route = { adapter: mockAdapter(), model: 'test' }
|
|
92
|
+
|
|
93
|
+
await runPipeline(steps, ctx, route, null, null, {
|
|
94
|
+
onStepSkip(step) { skipped.push(step.name) },
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
assert.equal(skipped.length, 1)
|
|
98
|
+
assert.equal(ctx.aResult, undefined)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('failed step does not block subsequent steps', async () => {
|
|
102
|
+
const failed = []
|
|
103
|
+
const done = []
|
|
104
|
+
const steps = [
|
|
105
|
+
makeStep('a'),
|
|
106
|
+
makeStep('b', { parseResult: { bDone: true } }),
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
// 'a' will fail because the adapter throws, 'b' uses a working adapter
|
|
110
|
+
// Actually both use the same adapter. Let's make a smarter adapter.
|
|
111
|
+
let callCount = 0
|
|
112
|
+
const smartAdapter = {
|
|
113
|
+
complete() {
|
|
114
|
+
callCount++
|
|
115
|
+
if (callCount === 1) return Promise.reject(new Error('API error'))
|
|
116
|
+
return Promise.resolve('{}')
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const ctx = { title: 'test', _stepConfigs: { a: 'always', b: 'always' } }
|
|
121
|
+
const route = { adapter: smartAdapter, model: 'test' }
|
|
122
|
+
|
|
123
|
+
await runPipeline(steps, ctx, route, null, null, {
|
|
124
|
+
onStepFail(step) { failed.push(step.name) },
|
|
125
|
+
onStepDone(step) { done.push(step.name) },
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
assert.deepEqual(failed, ['a'])
|
|
129
|
+
assert.deepEqual(done, ['b'])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('budget exceeded mid-pipeline skips remaining non-format steps', async () => {
|
|
133
|
+
const failed = []
|
|
134
|
+
const done = []
|
|
135
|
+
const skipped = []
|
|
136
|
+
|
|
137
|
+
const steps = [
|
|
138
|
+
makeStep('context', { parseResult: { contextDone: true } }),
|
|
139
|
+
makeStep('scope', { parseResult: { scopeDone: true } }),
|
|
140
|
+
makeStep('risk', { parseResult: { riskDone: true } }),
|
|
141
|
+
{ ...makeStep('format', { parseResult: { body: 'formatted' } }), name: 'format' },
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
const ctx = { title: 'test', _stepConfigs: { context: 'always', scope: 'always', risk: 'always', format: 'always' } }
|
|
145
|
+
const route = { adapter: mockAdapter(), model: 'test' }
|
|
146
|
+
|
|
147
|
+
// Budget check fails after first step
|
|
148
|
+
let budgetCalls = 0
|
|
149
|
+
const budgetHelpers = {
|
|
150
|
+
checkBudgets() {
|
|
151
|
+
budgetCalls++
|
|
152
|
+
if (budgetCalls > 1) throw new Error('budget exceeded')
|
|
153
|
+
},
|
|
154
|
+
recordUsage() {},
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
await runPipeline(steps, ctx, route, null, {}, {
|
|
158
|
+
onStepDone(step) { done.push(step.name) },
|
|
159
|
+
onStepSkip(step, reason) { skipped.push(step.name) },
|
|
160
|
+
onStepFail(step) { failed.push(step.name) },
|
|
161
|
+
}, budgetHelpers)
|
|
162
|
+
|
|
163
|
+
// context runs successfully
|
|
164
|
+
assert.ok(done.includes('context'))
|
|
165
|
+
// scope fails on budget check
|
|
166
|
+
assert.ok(failed.includes('scope'))
|
|
167
|
+
// risk is skipped because budgetExhausted is true and it's not format
|
|
168
|
+
assert.ok(skipped.includes('risk'))
|
|
169
|
+
// format still runs (allowed even when budget exhausted)
|
|
170
|
+
assert.ok(done.includes('format'))
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('callbacks fire with correct arguments', async () => {
|
|
174
|
+
const events = []
|
|
175
|
+
const steps = [
|
|
176
|
+
makeStep('a', { parseResult: { x: 1 } }),
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
const ctx = { title: 'test', _stepConfigs: { a: 'always' } }
|
|
180
|
+
const route = { adapter: mockAdapter(), model: 'test' }
|
|
181
|
+
|
|
182
|
+
await runPipeline(steps, ctx, route, null, null, {
|
|
183
|
+
onStepStart(step) { events.push({ type: 'start', step: step.name }) },
|
|
184
|
+
onStepDone(step, resultCtx) { events.push({ type: 'done', step: step.name, hasCtx: !!resultCtx }) },
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
assert.equal(events.length, 2)
|
|
188
|
+
assert.equal(events[0].type, 'start')
|
|
189
|
+
assert.equal(events[0].step, 'a')
|
|
190
|
+
assert.equal(events[1].type, 'done')
|
|
191
|
+
assert.equal(events[1].hasCtx, true)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('step with provider override uses different adapter', async () => {
|
|
195
|
+
const adapterNames = []
|
|
196
|
+
|
|
197
|
+
// Override adapter that records its name
|
|
198
|
+
const overrideAdapter = {
|
|
199
|
+
name: 'override',
|
|
200
|
+
complete() { return Promise.resolve('{}') },
|
|
201
|
+
isConfigured() { return true },
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const steps = [
|
|
205
|
+
makeStep('a', { parseResult: { aDone: true }, provider: 'my-cli' }),
|
|
206
|
+
makeStep('b', { parseResult: { bDone: true } }),
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
const ctx = { title: 'test', _stepConfigs: { a: 'always', b: 'always' } }
|
|
210
|
+
const defaultAdapter = mockAdapter()
|
|
211
|
+
const route = { adapter: defaultAdapter, model: 'test-model' }
|
|
212
|
+
|
|
213
|
+
// Config with a custom provider that resolveProviderAdapter will find
|
|
214
|
+
const config = {
|
|
215
|
+
ai: {
|
|
216
|
+
providers: {
|
|
217
|
+
'my-cli': { command: 'echo test' },
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const done = []
|
|
223
|
+
await runPipeline(steps, ctx, route, config, null, {
|
|
224
|
+
onStepDone(step) { done.push(step.name) },
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
// Both steps should complete (step a uses the override provider, step b uses default)
|
|
228
|
+
assert.deepEqual(done, ['a', 'b'])
|
|
229
|
+
assert.equal(ctx.aDone, true)
|
|
230
|
+
assert.equal(ctx.bDone, true)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test('step with invalid provider falls back to default route', async () => {
|
|
234
|
+
const failed = []
|
|
235
|
+
const done = []
|
|
236
|
+
|
|
237
|
+
const steps = [
|
|
238
|
+
makeStep('a', { parseResult: { aDone: true }, provider: 'nonexistent-provider' }),
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
const ctx = { title: 'test', _stepConfigs: { a: 'always' } }
|
|
242
|
+
const route = { adapter: mockAdapter(), model: 'test' }
|
|
243
|
+
const config = { ai: {} }
|
|
244
|
+
|
|
245
|
+
await runPipeline(steps, ctx, route, config, null, {
|
|
246
|
+
onStepFail(step, err) { failed.push({ name: step.name, msg: err.message }) },
|
|
247
|
+
onStepDone(step) { done.push(step.name) },
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
// Provider override fails — onStepFail called with warning, but step continues with default
|
|
251
|
+
assert.equal(failed.length, 1)
|
|
252
|
+
assert.ok(failed[0].msg.includes('nonexistent-provider'))
|
|
253
|
+
// Step still runs with default adapter
|
|
254
|
+
assert.deepEqual(done, ['a'])
|
|
255
|
+
assert.equal(ctx.aDone, true)
|
|
256
|
+
})
|
|
257
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { buildMessages } from './prompt.js'
|
|
4
|
+
|
|
5
|
+
describe('buildMessages', () => {
|
|
6
|
+
it('builds system and user messages', () => {
|
|
7
|
+
const msgs = buildMessages('Fix login', 'Login is broken', '## Bug\n\nLogin is broken')
|
|
8
|
+
assert.equal(msgs.length, 2)
|
|
9
|
+
assert.equal(msgs[0].role, 'system')
|
|
10
|
+
assert.equal(msgs[1].role, 'user')
|
|
11
|
+
assert.ok(msgs[1].content.includes('Fix login'))
|
|
12
|
+
assert.ok(msgs[1].content.includes('Login is broken'))
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('includes instructions in system message', () => {
|
|
16
|
+
const msgs = buildMessages('Fix it', 'desc', 'body', 'keep it short')
|
|
17
|
+
assert.ok(msgs[0].content.includes('keep it short'))
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('omits instructions when not provided', () => {
|
|
21
|
+
const msgs = buildMessages('Fix it', 'desc', 'body')
|
|
22
|
+
assert.ok(!msgs[0].content.includes('Additional instructions'))
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('handles empty description', () => {
|
|
26
|
+
const msgs = buildMessages('Title', '', '## Template')
|
|
27
|
+
assert.equal(msgs.length, 2)
|
|
28
|
+
assert.ok(msgs[1].content.includes('Title'))
|
|
29
|
+
})
|
|
30
|
+
})
|