tissues 0.6.1 → 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 +10 -1
- package/src/commands/ai.js +147 -173
- package/src/commands/create.js +6 -6
- package/src/commands/create.test.js +381 -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/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/discovery.js +89 -0
- package/src/lib/ai/discovery.test.js +74 -0
- package/src/lib/ai/enhancement-adapter.test.js +188 -0
- package/src/lib/ai/pipeline.test.js +257 -0
- package/src/lib/ai/prompt.test.js +30 -0
- package/src/lib/ai/router.js +23 -0
- package/src/lib/ai/router.test.js +481 -0
- 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 +8 -18
- package/src/lib/dedup.test.js +227 -0
- package/src/lib/defaults.js +20 -0
- 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.test.js +294 -0
- package/src/lib/gh.js +60 -0
- 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,335 @@
|
|
|
1
|
+
import { test, describe } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { ALL_STEPS, getStep } from './steps.js'
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Helpers
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
function baseCtx(overrides = {}) {
|
|
10
|
+
return {
|
|
11
|
+
rawInput: '',
|
|
12
|
+
title: 'Fix auth bug in login flow',
|
|
13
|
+
description: 'The login page throws a 401 error when using OAuth',
|
|
14
|
+
instructions: '',
|
|
15
|
+
templateBody: '## Summary\n\n{{description}}',
|
|
16
|
+
labels: ['bug'],
|
|
17
|
+
existingIssues: [],
|
|
18
|
+
repoLabels: ['bug', 'feature', 'P0-critical', 'P1-high', 'P2-medium'],
|
|
19
|
+
dedupScore: null,
|
|
20
|
+
structuredContext: null,
|
|
21
|
+
scopeAnalysis: null,
|
|
22
|
+
complexity: null,
|
|
23
|
+
complexityRationale: null,
|
|
24
|
+
risk: null,
|
|
25
|
+
riskRationale: null,
|
|
26
|
+
aiLabels: null,
|
|
27
|
+
body: '',
|
|
28
|
+
...overrides,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// ALL_STEPS structure
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
describe('ALL_STEPS', () => {
|
|
37
|
+
test('has 8 steps in correct order', () => {
|
|
38
|
+
assert.equal(ALL_STEPS.length, 8)
|
|
39
|
+
assert.deepEqual(
|
|
40
|
+
ALL_STEPS.map((s) => s.name),
|
|
41
|
+
['triage', 'dedup', 'context', 'scope', 'complexity', 'risk', 'labels', 'format'],
|
|
42
|
+
)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('every step has required fields', () => {
|
|
46
|
+
for (const step of ALL_STEPS) {
|
|
47
|
+
assert.ok(step.name, `${step.name} has name`)
|
|
48
|
+
assert.ok(step.displayName, `${step.name} has displayName`)
|
|
49
|
+
assert.ok(typeof step.maxTokens === 'number', `${step.name} has maxTokens`)
|
|
50
|
+
assert.ok(typeof step.shouldRun === 'function', `${step.name} has shouldRun`)
|
|
51
|
+
assert.ok(typeof step.buildMessages === 'function', `${step.name} has buildMessages`)
|
|
52
|
+
assert.ok(typeof step.parseResponse === 'function', `${step.name} has parseResponse`)
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('getStep returns correct step', () => {
|
|
57
|
+
assert.equal(getStep('dedup').name, 'dedup')
|
|
58
|
+
assert.equal(getStep('format').name, 'format')
|
|
59
|
+
assert.equal(getStep('nonexistent'), undefined)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// buildMessages tests
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
describe('buildMessages', () => {
|
|
68
|
+
test('each step returns well-formed messages', () => {
|
|
69
|
+
const ctx = baseCtx({ rawInput: 'the login page throws a 401 error when using OAuth' })
|
|
70
|
+
for (const step of ALL_STEPS) {
|
|
71
|
+
const msgs = step.buildMessages(ctx)
|
|
72
|
+
assert.ok(Array.isArray(msgs), `${step.name} returns array`)
|
|
73
|
+
assert.ok(msgs.length >= 2, `${step.name} has system + user messages`)
|
|
74
|
+
assert.equal(msgs[0].role, 'system')
|
|
75
|
+
assert.equal(msgs[1].role, 'user')
|
|
76
|
+
assert.ok(msgs[0].content.length > 0, `${step.name} system message not empty`)
|
|
77
|
+
assert.ok(msgs[1].content.length > 0, `${step.name} user message not empty`)
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('format step includes prior analysis when available', () => {
|
|
82
|
+
const ctx = baseCtx({
|
|
83
|
+
complexity: 7,
|
|
84
|
+
complexityRationale: 'Multi-service change',
|
|
85
|
+
risk: 4,
|
|
86
|
+
riskRationale: 'Low data loss risk',
|
|
87
|
+
scopeAnalysis: { files: [{ path: 'auth.js' }], affectedAreas: ['auth'] },
|
|
88
|
+
})
|
|
89
|
+
const msgs = getStep('format').buildMessages(ctx)
|
|
90
|
+
const userContent = msgs[1].content
|
|
91
|
+
assert.ok(userContent.includes('Complexity: 7/10'))
|
|
92
|
+
assert.ok(userContent.includes('Risk: 4/10'))
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('dedup step includes existing issues', () => {
|
|
96
|
+
const ctx = baseCtx({
|
|
97
|
+
existingIssues: [
|
|
98
|
+
{ number: 1, title: 'Fix login bug' },
|
|
99
|
+
{ number: 2, title: 'Add OAuth support' },
|
|
100
|
+
],
|
|
101
|
+
})
|
|
102
|
+
const msgs = getStep('dedup').buildMessages(ctx)
|
|
103
|
+
const userContent = msgs[1].content
|
|
104
|
+
assert.ok(userContent.includes('#1: Fix login bug'))
|
|
105
|
+
assert.ok(userContent.includes('#2: Add OAuth support'))
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('labels step includes available repo labels', () => {
|
|
109
|
+
const ctx = baseCtx()
|
|
110
|
+
const msgs = getStep('labels').buildMessages(ctx)
|
|
111
|
+
const systemContent = msgs[0].content
|
|
112
|
+
assert.ok(systemContent.includes('bug'))
|
|
113
|
+
assert.ok(systemContent.includes('P0-critical'))
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// parseResponse tests
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
describe('parseResponse', () => {
|
|
122
|
+
test('dedup parses valid JSON', () => {
|
|
123
|
+
const ctx = baseCtx()
|
|
124
|
+
getStep('dedup').parseResponse(
|
|
125
|
+
JSON.stringify({ confidence: 85, level: 'high', matches: [{ number: 1, reason: 'similar title' }] }),
|
|
126
|
+
ctx,
|
|
127
|
+
)
|
|
128
|
+
assert.equal(ctx.dedupScore.confidence, 85)
|
|
129
|
+
assert.equal(ctx.dedupScore.level, 'high')
|
|
130
|
+
assert.equal(ctx.dedupScore.matches.length, 1)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('dedup handles malformed JSON gracefully', () => {
|
|
134
|
+
const ctx = baseCtx()
|
|
135
|
+
getStep('dedup').parseResponse('not json at all', ctx)
|
|
136
|
+
assert.equal(ctx.dedupScore, null)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('context parses valid JSON', () => {
|
|
140
|
+
const ctx = baseCtx()
|
|
141
|
+
getStep('context').parseResponse(
|
|
142
|
+
JSON.stringify({ problem: 'Auth fails', files: ['auth.js'], errors: ['401'], sessionContext: '' }),
|
|
143
|
+
ctx,
|
|
144
|
+
)
|
|
145
|
+
assert.equal(ctx.structuredContext.problem, 'Auth fails')
|
|
146
|
+
assert.deepEqual(ctx.structuredContext.files, ['auth.js'])
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('context handles malformed JSON gracefully', () => {
|
|
150
|
+
const ctx = baseCtx()
|
|
151
|
+
getStep('context').parseResponse('invalid', ctx)
|
|
152
|
+
assert.equal(ctx.structuredContext, null)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('scope parses valid JSON', () => {
|
|
156
|
+
const ctx = baseCtx()
|
|
157
|
+
getStep('scope').parseResponse(
|
|
158
|
+
JSON.stringify({ files: [{ path: 'a.js', purpose: 'auth', deps: [] }], affectedAreas: ['auth'] }),
|
|
159
|
+
ctx,
|
|
160
|
+
)
|
|
161
|
+
assert.equal(ctx.scopeAnalysis.files.length, 1)
|
|
162
|
+
assert.deepEqual(ctx.scopeAnalysis.affectedAreas, ['auth'])
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('complexity parses valid JSON and clamps score', () => {
|
|
166
|
+
const ctx = baseCtx()
|
|
167
|
+
getStep('complexity').parseResponse(
|
|
168
|
+
JSON.stringify({ score: 15, rationale: 'very complex' }),
|
|
169
|
+
ctx,
|
|
170
|
+
)
|
|
171
|
+
assert.equal(ctx.complexity, 10) // clamped to max
|
|
172
|
+
assert.equal(ctx.complexityRationale, 'very complex')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('complexity handles malformed JSON gracefully', () => {
|
|
176
|
+
const ctx = baseCtx()
|
|
177
|
+
getStep('complexity').parseResponse('nope', ctx)
|
|
178
|
+
assert.equal(ctx.complexity, null)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('risk parses valid JSON', () => {
|
|
182
|
+
const ctx = baseCtx()
|
|
183
|
+
getStep('risk').parseResponse(
|
|
184
|
+
JSON.stringify({ score: 3, rationale: 'low risk' }),
|
|
185
|
+
ctx,
|
|
186
|
+
)
|
|
187
|
+
assert.equal(ctx.risk, 3)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('labels parses valid JSON and filters to repo labels', () => {
|
|
191
|
+
const ctx = baseCtx()
|
|
192
|
+
getStep('labels').parseResponse(
|
|
193
|
+
JSON.stringify({ labels: ['bug', 'nonexistent', 'P2-medium'], reasoning: 'fits' }),
|
|
194
|
+
ctx,
|
|
195
|
+
)
|
|
196
|
+
assert.deepEqual(ctx.aiLabels, ['bug', 'P2-medium'])
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('labels handles malformed JSON gracefully', () => {
|
|
200
|
+
const ctx = baseCtx()
|
|
201
|
+
getStep('labels').parseResponse('bad', ctx)
|
|
202
|
+
assert.equal(ctx.aiLabels, null)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('format sets body from raw markdown', () => {
|
|
206
|
+
const ctx = baseCtx()
|
|
207
|
+
getStep('format').parseResponse('## Summary\n\nFixed the auth bug.', ctx)
|
|
208
|
+
assert.equal(ctx.body, '## Summary\n\nFixed the auth bug.')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('format ignores empty response', () => {
|
|
212
|
+
const ctx = baseCtx({ body: 'original template' })
|
|
213
|
+
getStep('format').parseResponse(' ', ctx)
|
|
214
|
+
assert.equal(ctx.body, 'original template')
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
test('dedup parses response with markdown fences', () => {
|
|
218
|
+
const ctx = baseCtx()
|
|
219
|
+
getStep('dedup').parseResponse(
|
|
220
|
+
'```json\n{"confidence": 50, "level": "medium", "matches": []}\n```',
|
|
221
|
+
ctx,
|
|
222
|
+
)
|
|
223
|
+
assert.equal(ctx.dedupScore.confidence, 50)
|
|
224
|
+
assert.equal(ctx.dedupScore.level, 'medium')
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// shouldRun tests
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
describe('shouldRun heuristics', () => {
|
|
233
|
+
test('dedup AI step is always disabled', () => {
|
|
234
|
+
assert.equal(getStep('dedup').shouldRun(baseCtx({ existingIssues: [] })), false)
|
|
235
|
+
assert.equal(getStep('dedup').shouldRun(baseCtx({ existingIssues: [{ number: 1, title: 'x' }] })), false)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('context always runs', () => {
|
|
239
|
+
assert.equal(getStep('context').shouldRun(baseCtx()), true)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('scope runs when description mentions files', () => {
|
|
243
|
+
assert.equal(getStep('scope').shouldRun(baseCtx({ description: 'fix auth.js module' })), true)
|
|
244
|
+
assert.equal(getStep('scope').shouldRun(baseCtx({ description: 'button color wrong' })), false)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test('scope runs when context step found files', () => {
|
|
248
|
+
assert.equal(
|
|
249
|
+
getStep('scope').shouldRun(baseCtx({ structuredContext: { files: ['a.js'] } })),
|
|
250
|
+
true,
|
|
251
|
+
)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test('complexity runs when scope exists', () => {
|
|
255
|
+
assert.equal(
|
|
256
|
+
getStep('complexity').shouldRun(baseCtx({ scopeAnalysis: { files: [], affectedAreas: [] } })),
|
|
257
|
+
true,
|
|
258
|
+
)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
test('complexity runs when description is long enough', () => {
|
|
262
|
+
assert.equal(getStep('complexity').shouldRun(baseCtx({ description: 'x'.repeat(51) })), true)
|
|
263
|
+
assert.equal(getStep('complexity').shouldRun(baseCtx({ description: 'short', scopeAnalysis: null })), false)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
test('labels only runs when repoLabels present', () => {
|
|
267
|
+
assert.equal(getStep('labels').shouldRun(baseCtx({ repoLabels: [] })), false)
|
|
268
|
+
assert.equal(getStep('labels').shouldRun(baseCtx({ repoLabels: ['bug'] })), true)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
test('format always runs', () => {
|
|
272
|
+
assert.equal(getStep('format').shouldRun(baseCtx()), true)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test('triage runs when rawInput is present', () => {
|
|
276
|
+
assert.equal(getStep('triage').shouldRun(baseCtx({ rawInput: 'the login page is broken on safari' })), true)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
test('triage does not run when rawInput is empty', () => {
|
|
280
|
+
assert.equal(getStep('triage').shouldRun(baseCtx({ rawInput: '' })), false)
|
|
281
|
+
assert.equal(getStep('triage').shouldRun(baseCtx()), false)
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Triage step tests
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
describe('triage step', () => {
|
|
290
|
+
test('buildMessages sends rawInput as user content', () => {
|
|
291
|
+
const ctx = baseCtx({ rawInput: 'the login page throws a 401 on safari when using oauth' })
|
|
292
|
+
const msgs = getStep('triage').buildMessages(ctx)
|
|
293
|
+
assert.equal(msgs.length, 2)
|
|
294
|
+
assert.equal(msgs[0].role, 'system')
|
|
295
|
+
assert.equal(msgs[1].role, 'user')
|
|
296
|
+
assert.equal(msgs[1].content, 'the login page throws a 401 on safari when using oauth')
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
test('parseResponse sets title and description from valid JSON', () => {
|
|
300
|
+
const ctx = baseCtx({ rawInput: 'long freeform text here' })
|
|
301
|
+
getStep('triage').parseResponse(
|
|
302
|
+
JSON.stringify({ title: 'Fix OAuth 401 on Safari', description: 'The login page throws a 401 error.' }),
|
|
303
|
+
ctx,
|
|
304
|
+
)
|
|
305
|
+
assert.equal(ctx.title, 'Fix OAuth 401 on Safari')
|
|
306
|
+
assert.equal(ctx.description, 'The login page throws a 401 error.')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
test('parseResponse truncates title to 80 chars', () => {
|
|
310
|
+
const ctx = baseCtx()
|
|
311
|
+
const longTitle = 'A'.repeat(120)
|
|
312
|
+
getStep('triage').parseResponse(
|
|
313
|
+
JSON.stringify({ title: longTitle, description: 'desc' }),
|
|
314
|
+
ctx,
|
|
315
|
+
)
|
|
316
|
+
assert.equal(ctx.title.length, 80)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
test('parseResponse handles malformed JSON gracefully', () => {
|
|
320
|
+
const ctx = baseCtx({ title: 'original title', description: 'original desc' })
|
|
321
|
+
getStep('triage').parseResponse('not valid json', ctx)
|
|
322
|
+
assert.equal(ctx.title, 'original title')
|
|
323
|
+
assert.equal(ctx.description, 'original desc')
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
test('parseResponse handles markdown-fenced JSON', () => {
|
|
327
|
+
const ctx = baseCtx()
|
|
328
|
+
getStep('triage').parseResponse(
|
|
329
|
+
'```json\n{"title": "Fix the bug", "description": "It breaks"}\n```',
|
|
330
|
+
ctx,
|
|
331
|
+
)
|
|
332
|
+
assert.equal(ctx.title, 'Fix the bug')
|
|
333
|
+
assert.equal(ctx.description, 'It breaks')
|
|
334
|
+
})
|
|
335
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { test, describe } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { buildAttribution, renderAttribution } from './attribution.js'
|
|
4
|
+
|
|
5
|
+
describe('buildAttribution', () => {
|
|
6
|
+
test('returns empty object with no options', () => {
|
|
7
|
+
const meta = buildAttribution()
|
|
8
|
+
assert.deepEqual(meta, {})
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('omits undefined optional fields', () => {
|
|
12
|
+
const meta = buildAttribution()
|
|
13
|
+
assert.equal('session' in meta, false)
|
|
14
|
+
assert.equal('model' in meta, false)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('sets session and model when provided', () => {
|
|
18
|
+
const meta = buildAttribution({ session: 'abc123', model: 'claude-opus-4-6' })
|
|
19
|
+
assert.equal(meta.session, 'abc123')
|
|
20
|
+
assert.equal(meta.model, 'claude-opus-4-6')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('only includes session when model is missing', () => {
|
|
24
|
+
const meta = buildAttribution({ session: 'sess-1' })
|
|
25
|
+
assert.equal(meta.session, 'sess-1')
|
|
26
|
+
assert.equal('model' in meta, false)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('only includes model when session is missing', () => {
|
|
30
|
+
const meta = buildAttribution({ model: 'gpt-4' })
|
|
31
|
+
assert.equal(meta.model, 'gpt-4')
|
|
32
|
+
assert.equal('session' in meta, false)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('renderAttribution', () => {
|
|
37
|
+
test('returns null when no fields provided', () => {
|
|
38
|
+
const output = renderAttribution()
|
|
39
|
+
assert.equal(output, null)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('wraps output in frontmatter delimiters', () => {
|
|
43
|
+
const output = renderAttribution({ session: 'abc' })
|
|
44
|
+
assert.ok(output.startsWith('---\ntissues-meta:'))
|
|
45
|
+
assert.ok(output.endsWith('---'))
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('includes session line', () => {
|
|
49
|
+
const output = renderAttribution({ session: 'sess-123' })
|
|
50
|
+
assert.ok(output.includes('session: sess-123'))
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('includes model line', () => {
|
|
54
|
+
const output = renderAttribution({ model: 'claude-opus-4-6' })
|
|
55
|
+
assert.ok(output.includes('model: claude-opus-4-6'))
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('each key appears on its own line', () => {
|
|
59
|
+
const output = renderAttribution({ session: 'abc', model: 'gpt-4' })
|
|
60
|
+
const lines = output.split('\n')
|
|
61
|
+
assert.ok(lines.find((l) => l.startsWith('session:')))
|
|
62
|
+
assert.ok(lines.find((l) => l.startsWith('model:')))
|
|
63
|
+
})
|
|
64
|
+
})
|