chill-agent 1.4.0 → 1.6.0
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/.agents/rules/ponytail.md +30 -0
- package/.claude-plugin/contrast-mcp-launcher.mjs +31 -0
- package/.claude-plugin/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +8 -3
- package/.cline-plugin/plugin.js +6 -0
- package/.clinerules/ponytail.md +30 -0
- package/.codex-plugin/plugin.json +4 -3
- package/.cursor/rules/ponytail.mdc +36 -0
- package/.cursor-plugin/marketplace.json +16 -0
- package/.cursor-plugin/plugin.json +2 -2
- package/.devin-plugin/plugin.json +2 -2
- package/.grok-plugin/marketplace.json +16 -0
- package/.kimi-plugin/plugin.json +11 -29
- package/.kiro/steering/ponytail.md +35 -0
- package/.omp-plugin/marketplace.json +15 -0
- package/.openclaw/skills/ponytail/SKILL.md +47 -0
- package/.openclaw/skills/ponytail-audit/SKILL.md +100 -0
- package/.openclaw/skills/ponytail-debt/SKILL.md +43 -0
- package/.openclaw/skills/ponytail-gain/SKILL.md +51 -0
- package/.openclaw/skills/ponytail-help/SKILL.md +76 -0
- package/.openclaw/skills/ponytail-review/SKILL.md +98 -0
- package/.opencode/command/ponytail-audit.md +5 -0
- package/.opencode/command/ponytail-debt.md +5 -0
- package/.opencode/command/ponytail-gain.md +5 -0
- package/.opencode/command/ponytail-help.md +5 -0
- package/.opencode/command/ponytail-review.md +5 -0
- package/.opencode/command/ponytail.md +5 -0
- package/.opencode/plugins/index.js +4 -0
- package/.opencode/plugins/ponytail-frontmatter.cjs +63 -0
- package/.opencode/plugins/ponytail.mjs +206 -0
- package/.qoder-plugin/plugin.json +12 -26
- package/.windsurf/rules/ponytail.md +30 -0
- package/AGENTS.md +32 -0
- package/__init__.py +228 -0
- package/assets/antislop-banner.png +0 -0
- package/assets/band/band.png +0 -0
- package/assets/band/board.png +0 -0
- package/assets/benchmark-3model.svg +21 -0
- package/assets/benchmark-agentic.svg +62 -0
- package/assets/codex-icon.png +0 -0
- package/assets/compare/code/after.webp +0 -0
- package/assets/compare/code/before.webp +0 -0
- package/assets/compare/text/after.webp +0 -0
- package/assets/compare/text/before.webp +0 -0
- package/assets/compare/ui/w-all.webp +0 -0
- package/assets/compare/ui/w-antislop.webp +0 -0
- package/assets/compare/ui/w-design.webp +0 -0
- package/assets/compare/ui/without.webp +0 -0
- package/assets/logo-dark.png +0 -0
- package/assets/logo-dark.svg +115 -0
- package/assets/logo-greenpt-dark.svg +27 -0
- package/assets/logo-greenpt.svg +27 -0
- package/assets/logo.png +0 -0
- package/assets/photoreunion-logo-dark.svg +4 -0
- package/assets/photoreunion-logo-light.svg +4 -0
- package/assets/profile.png +0 -0
- package/assets/retriever-logo-dark.svg +4 -0
- package/assets/retriever-logo-light.svg +4 -0
- package/assets/stories-banner.png +0 -0
- package/assets/v5/audit-es.png +0 -0
- package/assets/v5/audit-ja.png +0 -0
- package/assets/v5/audit-ko.png +0 -0
- package/assets/v5/audit-zh-CN.png +0 -0
- package/assets/v5/audit.png +0 -0
- package/assets/v5/beforeafter-es.png +0 -0
- package/assets/v5/beforeafter-ja.png +0 -0
- package/assets/v5/beforeafter-ko.png +0 -0
- package/assets/v5/beforeafter-zh-CN.png +0 -0
- package/assets/v5/beforeafter.png +0 -0
- package/assets/v5/chart-es.png +0 -0
- package/assets/v5/chart-ja.png +0 -0
- package/assets/v5/chart-ko.png +0 -0
- package/assets/v5/chart-zh-CN.png +0 -0
- package/assets/v5/chart.png +0 -0
- package/assets/v5/hero-es.jpg +0 -0
- package/assets/v5/hero-ja.jpg +0 -0
- package/assets/v5/hero-ko.jpg +0 -0
- package/assets/v5/hero-zh-CN.jpg +0 -0
- package/assets/v5/hero.jpg +0 -0
- package/assets/v5/ladder-es.png +0 -0
- package/assets/v5/ladder-ja.png +0 -0
- package/assets/v5/ladder-ko.png +0 -0
- package/assets/v5/ladder-zh-CN.png +0 -0
- package/assets/v5/ladder.png +0 -0
- package/assets/v5/review-es.png +0 -0
- package/assets/v5/review-ja.png +0 -0
- package/assets/v5/review-ko.png +0 -0
- package/assets/v5/review-zh-CN.png +0 -0
- package/assets/v5/review.png +0 -0
- package/assets/v5/tests-es.png +0 -0
- package/assets/v5/tests-ja.png +0 -0
- package/assets/v5/tests-ko.png +0 -0
- package/assets/v5/tests-zh-CN.png +0 -0
- package/assets/v5/tests.png +0 -0
- package/assets/waitlist-banner-es.png +0 -0
- package/assets/waitlist-banner-ja.png +0 -0
- package/assets/waitlist-banner-ko.png +0 -0
- package/assets/waitlist-banner-zh-CN.png +0 -0
- package/assets/waitlist-banner.png +0 -0
- package/benchmarks/.env.example +2 -0
- package/benchmarks/agentic/README.md +201 -0
- package/benchmarks/agentic/answer.py +288 -0
- package/benchmarks/agentic/compare.py +93 -0
- package/benchmarks/agentic/complete.py +178 -0
- package/benchmarks/agentic/judge.py +198 -0
- package/benchmarks/agentic/run.py +543 -0
- package/benchmarks/agentic/tasks.py +1544 -0
- package/benchmarks/arms/baseline.js +2 -0
- package/benchmarks/arms/caveman-SKILL.md +67 -0
- package/benchmarks/arms/caveman.js +8 -0
- package/benchmarks/arms/ponytail.js +8 -0
- package/benchmarks/behavior.js +54 -0
- package/benchmarks/behavior.yaml +40 -0
- package/benchmarks/correctness.js +291 -0
- package/benchmarks/loc.js +17 -19
- package/benchmarks/promptfooconfig.gemini.yaml +32 -0
- package/benchmarks/promptfooconfig.gpt-newest.yaml +33 -0
- package/benchmarks/promptfooconfig.yaml +41 -0
- package/benchmarks/results/2026-06-12-caveman-vs-ponytail.md +71 -0
- package/benchmarks/results/2026-06-12-v4-hardening-vs-caveman.md +125 -0
- package/benchmarks/results/2026-06-15-llama3.2-local.md +82 -0
- package/benchmarks/results/2026-06-16-correctness-gate-fix.md +113 -0
- package/benchmarks/results/2026-06-16-robustness-audit.md +135 -0
- package/benchmarks/results/2026-06-17-agentic-safety.md +164 -0
- package/benchmarks/results/2026-06-17-cost-verification.md +93 -0
- package/benchmarks/results/2026-06-18-agentic.md +219 -0
- package/benchmarks/results/2026-06-22-issue-245-217-comprehension.md +98 -0
- package/benchmarks/results/2026-10-07-agentic.md +186 -0
- package/cli/index.mjs +241 -0
- package/cli/lib/banner.mjs +21 -0
- package/cli/lib/install.mjs +498 -0
- package/cli/lib/settings.mjs +48 -0
- package/cli/package-lock.json +87 -0
- package/cli/package.json +42 -0
- package/cli/scripts/smoke-test.mjs +95 -0
- package/cli/scripts/smoke-worker.mjs +411 -0
- package/cli/scripts/sync-skills.mjs +81 -0
- package/commands/ponytail-audit.toml +2 -0
- package/commands/ponytail-debt.toml +2 -0
- package/commands/ponytail-gain.toml +2 -0
- package/commands/ponytail-help.toml +2 -0
- package/commands/ponytail-review.toml +2 -0
- package/commands/ponytail.toml +2 -0
- package/examples/email-validation.md +156 -0
- package/examples/group-by.md +35 -0
- package/examples/infinite-scroll.md +58 -0
- package/examples/number-formatting.md +37 -0
- package/examples/rate-limit.md +272 -0
- package/examples/react-countdown.md +390 -0
- package/examples/url-params.md +41 -0
- package/gemini-extension.json +2 -2
- package/hooks/band/board.ts +560 -0
- package/hooks/band/logo.ts +3 -0
- package/hooks/band/metrics.ts +311 -0
- package/hooks/band/register.tsx +495 -0
- package/hooks/band/types.d.ts +34 -0
- package/hooks/chill-statusline.ps1 +1 -1
- package/hooks/claude-band.json +6 -0
- package/hooks/claude-codex-hooks.json +34 -4
- package/hooks/copilot-hooks.json +12 -2
- package/hooks/cursor-hooks.json +2 -2
- package/hooks/ponytail-activate.js +201 -0
- package/hooks/ponytail-band-import.js +274 -0
- package/hooks/ponytail-config.js +175 -0
- package/hooks/ponytail-instructions.js +98 -0
- package/hooks/ponytail-map.js +111 -0
- package/hooks/ponytail-mode-tracker.js +216 -0
- package/hooks/ponytail-runtime.js +182 -0
- package/hooks/ponytail-statusline.ps1 +24 -0
- package/hooks/ponytail-statusline.sh +18 -0
- package/hooks/ponytail-subagent.js +94 -0
- package/hooks/qoder-hooks.json +19 -4
- package/i18n/README.ko.md +230 -0
- package/package.json +17 -6
- package/pi-extension/index.js +254 -0
- package/pi-extension/package.json +8 -0
- package/pi-extension/test/extension.test.js +367 -0
- package/pi-extension/test/helpers.test.js +179 -0
- package/plugin.yaml +21 -12
- package/rules/antislop.mdc +18 -0
- package/scripts/build-openclaw-skills.js +60 -0
- package/scripts/check-repo.mjs +389 -0
- package/scripts/check-rule-copies.js +74 -0
- package/scripts/check-versions.js +77 -0
- package/scripts/cursor-hooks.js +145 -0
- package/scripts/generate-chillslop.mjs +42 -0
- package/scripts/generate-examples.mjs +149 -0
- package/scripts/generate-plugin-doors.mjs +108 -0
- package/scripts/publish-openclaw-skills.js +75 -0
- package/scripts/skill-index.mjs +81 -0
- package/scripts/sync-ponytail.mjs +65 -0
- package/scripts/uninstall.js +92 -0
- package/skills/antislop/SKILL.md +700 -0
- package/skills/antislop/VERSION +1 -0
- package/skills/antislop-code/SKILL.md +128 -0
- package/skills/antislop-copywriting/SKILL.md +407 -0
- package/skills/antislop-human/LICENSE-web-quality-skills +21 -0
- package/skills/antislop-human/SKILL.md +204 -0
- package/skills/antislop-human/contrast-check.py +168 -0
- package/skills/antislop-human/contrast-mcp.py +121 -0
- package/skills/antislop-human/references/A11Y-PATTERNS.md +249 -0
- package/skills/antislop-human/references/WCAG.md +127 -0
- package/skills/antislop-layoutmobile/SKILL.md +170 -0
- package/skills/antislop-ui/LICENSE-web-quality-skills +21 -0
- package/skills/antislop-ui/SKILL.md +344 -0
- package/skills/antislop-ui/references/CORE-WEB-VITALS.md +181 -0
- package/skills/antislop-ui/references/WEB-BASELINE.md +28 -0
- package/skills/plugin.json +5 -0
- package/skills/ponytail/SKILL.md +52 -0
- package/skills/ponytail-audit/SKILL.md +103 -0
- package/skills/ponytail-debt/SKILL.md +44 -0
- package/skills/ponytail-gain/SKILL.md +52 -0
- package/skills/ponytail-help/SKILL.md +76 -0
- package/skills/ponytail-review/SKILL.md +101 -0
- package/src/package.json +3 -0
- package/tests/activation-map.test.js +63 -0
- package/tests/band-import.test.js +111 -0
- package/tests/band.test.tsx +520 -0
- package/tests/behavior.test.js +80 -0
- package/tests/commands.test.js +94 -0
- package/tests/copilot-plugin.test.js +35 -0
- package/tests/correctness.test.js +245 -0
- package/tests/cursor-hooks.test.js +343 -0
- package/tests/gemini-extension.test.js +91 -0
- package/tests/grok-plugin.test.js +39 -0
- package/tests/hermes-plugin.test.js +403 -0
- package/tests/hooks-windows.test.js +205 -0
- package/tests/hooks.test.js +1005 -0
- package/tests/kimi-plugin.test.js +25 -0
- package/tests/loc.test.js +18 -0
- package/tests/map.test.js +30 -0
- package/tests/openclaw-skills.test.js +26 -0
- package/tests/opencode-plugin.test.js +340 -0
- package/tests/package-scripts.test.js +35 -0
- package/tests/package.test.js +23 -0
- package/tests/qoder-plugin.test.js +84 -0
- package/tests/uninstall.test.js +202 -0
- /package/bin/{chill-agent.js → chill-agent.mjs} +0 -0
- /package/tests/{manifests.test.js → manifests.test.mjs} +0 -0
- /package/tests/{rules.test.js → rules.test.mjs} +0 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from 'claude-code/testing'
|
|
2
|
+
|
|
3
|
+
import { boardHtml, parseCsv, tasksFrom } from '../hooks/band/board'
|
|
4
|
+
import type { Band } from '../hooks/band/types'
|
|
5
|
+
import {
|
|
6
|
+
NO_TOKENS,
|
|
7
|
+
addTokens,
|
|
8
|
+
addedLines,
|
|
9
|
+
callsIn,
|
|
10
|
+
defsIn,
|
|
11
|
+
duration,
|
|
12
|
+
elapsed,
|
|
13
|
+
fmtTokens,
|
|
14
|
+
inProject,
|
|
15
|
+
isTestCommand,
|
|
16
|
+
mainModel,
|
|
17
|
+
markdown,
|
|
18
|
+
metricsSvg,
|
|
19
|
+
parseDefs,
|
|
20
|
+
percent,
|
|
21
|
+
resume,
|
|
22
|
+
sessionTitle,
|
|
23
|
+
snapshot,
|
|
24
|
+
testCounts,
|
|
25
|
+
totalTokens,
|
|
26
|
+
upsertCsv,
|
|
27
|
+
CSV_HEADER,
|
|
28
|
+
webhookRequest,
|
|
29
|
+
} from '../hooks/band/metrics'
|
|
30
|
+
|
|
31
|
+
describe('board', () => {
|
|
32
|
+
const CSV = 'key,started,project,name,cost_usd,turns,agent_minutes,tokens_total,tokens_output,reuse_pct,reused,new,callers_open,tests,level\n' +
|
|
33
|
+
'a-1,2026-10-15 12:00,shop,"Fix ""cart"", totals\nand tax",3.10,6,26.0,1200000,85000,83,5,1,0,14 passed,full\n' +
|
|
34
|
+
'b-2,2026-10-14 09:30,shop,<script>alert(1)</script>,0.50,2,1.5,40000,900,,0,0,0,not run,lite\n'
|
|
35
|
+
|
|
36
|
+
test('reads quoted CSV fields with commas, quotes and line breaks', () => {
|
|
37
|
+
const rows = parseCsv(CSV)
|
|
38
|
+
expect(rows.length).toBe(3)
|
|
39
|
+
expect(rows[1]?.[3]).toBe('Fix "cart", totals\nand tax')
|
|
40
|
+
const tasks = tasksFrom(CSV)
|
|
41
|
+
expect(tasks[0]).toEqual({ started: '2026-10-15 12:00', project: 'shop', name: 'Fix "cart", totals\nand tax', cost: 3.1, turns: 6, agentMinutes: 26, tokens: 1_200_000, reuse: 83, tests: '14 passed', level: 'full', model: '' })
|
|
42
|
+
expect(tasks[1]?.reuse).toBeNull()
|
|
43
|
+
expect(tasksFrom('not,a,ledger\n1,2,3')).toEqual([])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const dataOf = (html: string) => JSON.parse(/<script type="application\/json" id="board-data">([\s\S]*?)<\/script>/.exec(html)?.[1] ?? 'null')
|
|
47
|
+
|
|
48
|
+
test('the board carries every task as data no name can break out of', () => {
|
|
49
|
+
const html = boardHtml(tasksFrom(CSV), Date.UTC(2026, 9, 15, 12, 0))
|
|
50
|
+
const data = dataOf(html)
|
|
51
|
+
expect(data.tasks.length).toBe(2)
|
|
52
|
+
expect(data.tasks[1].name).toBe('<script>alert(1)</script>')
|
|
53
|
+
expect(html).not.toContain('<script>alert(1)')
|
|
54
|
+
expect(html).toContain('\\u003cscript>alert(1)\\u003c/script>')
|
|
55
|
+
expect(html).toContain('not a bill')
|
|
56
|
+
expect(html).toContain('id="card"')
|
|
57
|
+
expect(html.split('id="pt-logo"').length).toBe(2)
|
|
58
|
+
expect(html).toContain('href="#pt-logo"')
|
|
59
|
+
expect(html).toContain('<link rel="icon" href="data:image/svg+xml,')
|
|
60
|
+
expect(html).toContain('id="c-time"')
|
|
61
|
+
expect(html).not.toContain('innerHTML')
|
|
62
|
+
expect(html).toContain('id="theme"')
|
|
63
|
+
expect(html).toContain('<html lang="en" data-theme="dark">')
|
|
64
|
+
expect(html).toContain(':root[data-theme=light]{')
|
|
65
|
+
expect(html).toContain(':root[data-theme=system]{')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('an empty board still loads and says there are no sessions yet', () => {
|
|
69
|
+
const html = boardHtml([], Date.UTC(2026, 9, 15, 12, 0))
|
|
70
|
+
expect(dataOf(html).tasks).toEqual([])
|
|
71
|
+
expect(html).toContain('No sessions yet')
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('metrics', () => {
|
|
76
|
+
test('adds up tokens and prints them short', () => {
|
|
77
|
+
const t = addTokens(addTokens(NO_TOKENS, { input_tokens: 10, output_tokens: 90, cache_read_input_tokens: 1000, cache_creation_input_tokens: 0 }), { input_tokens: 0, output_tokens: 10, cache_read_input_tokens: 140, cache_creation_input_tokens: 0 })
|
|
78
|
+
expect(totalTokens(t)).toBe(1250)
|
|
79
|
+
expect(t.output).toBe(100)
|
|
80
|
+
expect([fmtTokens(950), fmtTokens(1234), fmtTokens(1_240_000), fmtTokens(2000)]).toEqual(['950', '1.2k', '1.2M', '2k'])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const BASE: Band = {
|
|
84
|
+
level: 'full', model: 'claude-opus-5-5', cost: 31.9, spent: 3.1, turns: 6, turnCosts: [0.42], tokens: { input: 5000, output: 85_000, cacheRead: 1_100_000, cacheWrite: 10_000 },
|
|
85
|
+
busyMs: 1_560_000, name: 'Fix the cart | totals, again', project: 'shop', session: '4c69e3db-57a0', transcript: '', reused: ['to_cents'], created: [],
|
|
86
|
+
removed: [], openCallers: [], tests: { state: 'pass', passed: 14 }, details: false,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
test('a band that starts again mid-session goes on from its row instead of from zero', () => {
|
|
90
|
+
const csv = `${CSV_HEADER}\nother-1,2026-10-14 09:00,shop,Other,1.00,2,3.0,500,50,,0,0,0,not run,full\n4c69e3db-7,2026-10-15 01:10,shop,"Fix the cart, again",46.09,32,55.5,107900000,250000,,0,0,0,stale,full\n`
|
|
91
|
+
const fresh: Band = { ...BASE, name: '', busyMs: 0, tokens: NO_TOKENS }
|
|
92
|
+
const b = resume(fresh, csv, '4c69e3db-7')
|
|
93
|
+
expect(b.name).toBe('Fix the cart, again')
|
|
94
|
+
expect(b.busyMs).toBe(55.5 * 60000)
|
|
95
|
+
expect(totalTokens(b.tokens)).toBe(107_900_000)
|
|
96
|
+
expect(b.tokens.output).toBe(250_000)
|
|
97
|
+
// A band that already counted more keeps its own numbers and name.
|
|
98
|
+
const ahead: Band = { ...BASE, busyMs: 4_000_000, tokens: { input: 0, output: 1, cacheRead: 200_000_000, cacheWrite: 0 } }
|
|
99
|
+
expect(resume(ahead, csv, '4c69e3db-7')).toEqual(ahead)
|
|
100
|
+
expect(resume(fresh, csv, 'missing-1')).toEqual(fresh)
|
|
101
|
+
expect(resume(fresh, '', '4c69e3db-7')).toEqual(fresh)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('the row names the model that wrote most replies, and an older ledger keeps its rows', () => {
|
|
105
|
+
expect(mainModel('"model":"claude-opus-5-5"\n"model":"claude-haiku-5-5"\n"model":"claude-opus-5-5"\n')).toBe('claude-opus-5-5')
|
|
106
|
+
expect(mainModel('')).toBe('')
|
|
107
|
+
const old = 'key,started,project,name,cost_usd,turns,agent_minutes,tokens_total,tokens_output,reuse_pct,reused,new,callers_open,tests,level\n' +
|
|
108
|
+
'other-1,2026-10-14 09:00,shop,Other,1.00,2,3.0,500,50,,0,0,0,not run,full\n'
|
|
109
|
+
const csv = upsertCsv(old, snapshot(BASE, Date.UTC(2026, 9, 15, 12, 0)))
|
|
110
|
+
expect(csv.split('\n')[0]).toBe(CSV_HEADER)
|
|
111
|
+
expect(csv.split('\n').filter(line => line.startsWith('key,')).length).toBe(1)
|
|
112
|
+
expect(tasksFrom(csv).map(t => t.model)).toEqual(['', 'claude-opus-5-5'])
|
|
113
|
+
expect(markdown(snapshot(BASE, Date.UTC(2026, 9, 15, 12, 0)))).toContain('| Model | claude-opus-5-5 |')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('the webhook sends the token only over https, or to this machine', () => {
|
|
117
|
+
const row = snapshot(BASE, Date.UTC(2026, 9, 15, 12, 0))
|
|
118
|
+
const sent = webhookRequest('https://board.example.com/tasks', 'ptb_x', true, row)
|
|
119
|
+
expect('refused' in sent).toBe(false)
|
|
120
|
+
if (!('refused' in sent)) {
|
|
121
|
+
expect(sent.init.headers.authorization).toBe('Bearer ptb_x')
|
|
122
|
+
expect(JSON.parse(sent.init.body).name).toBe('Fix the cart | totals, again')
|
|
123
|
+
}
|
|
124
|
+
expect(webhookRequest('http://board.example.com/tasks', 'ptb_x', true, row)).toEqual({ refused: 'webhook needs https' })
|
|
125
|
+
expect(webhookRequest('not a url', 'ptb_x', true, row)).toEqual({ refused: 'webhook URL is not a URL' })
|
|
126
|
+
expect('refused' in webhookRequest('http://127.0.0.1:4711/tasks', 'ptb_x', true, row)).toBe(false)
|
|
127
|
+
const quiet = webhookRequest('https://board.example.com/tasks', '', false, row)
|
|
128
|
+
if (!('refused' in quiet)) {
|
|
129
|
+
expect(quiet.init.headers.authorization).toBeUndefined()
|
|
130
|
+
expect(JSON.parse(quiet.init.body).name).toBe('')
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('agent time reads in seconds, minutes, hours', () => {
|
|
135
|
+
expect([elapsed(45_000), elapsed(130_000), elapsed(4_320_000)]).toEqual(['45s', '2m 10s', '1h 12m'])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('the sidebar name is the last custom-title record of the transcript', () => {
|
|
139
|
+
const lines = [
|
|
140
|
+
JSON.stringify({ type: 'custom-title', customTitle: 'First name', sessionId: 's' }),
|
|
141
|
+
JSON.stringify({ type: 'assistant', meta: { type: 'custom-title', customTitle: 'not a record' } }),
|
|
142
|
+
JSON.stringify({ type: 'custom-title', customTitle: ' GitHub issues triage ' }),
|
|
143
|
+
'{"type":"custom-title", broken',
|
|
144
|
+
]
|
|
145
|
+
expect(sessionTitle(lines.join('\n'))).toBe('GitHub issues triage')
|
|
146
|
+
expect(sessionTitle(lines.slice(0, 2).join('\n'))).toBe('First name')
|
|
147
|
+
expect(sessionTitle(JSON.stringify({ type: 'custom-title', customTitle: ' ' }))).toBeUndefined()
|
|
148
|
+
expect(sessionTitle(JSON.stringify({ type: 'user', message: { content: 'no title yet' } }))).toBeUndefined()
|
|
149
|
+
expect(sessionTitle(JSON.stringify({ type: 'custom-title', customTitle: 'x'.repeat(80) }))?.length).toBe(60)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('the Markdown export holds every value and the task name', () => {
|
|
153
|
+
const md = markdown(snapshot(BASE, Date.UTC(2026, 9, 15, 12, 0)))
|
|
154
|
+
expect(md.split('\n')[0]).toBe('## Fix the cart \\| totals, again')
|
|
155
|
+
expect(md).toContain('| Cost | $3.10 · last turn $0.42 · ⌀ $0.52 / turn |')
|
|
156
|
+
expect(md).toContain('| Agent time | 26m 0s · ⌀ 4m 20s / turn |')
|
|
157
|
+
expect(md).toContain('| Tokens | 1.2M (input 5k, output 85k, cache read 1.1M, cache write 10k) |')
|
|
158
|
+
expect(md).toContain('| Tests | 14 passed |')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('the CSV keeps one row per task and replaces it on every turn', () => {
|
|
162
|
+
const at = Date.UTC(2026, 9, 15, 12, 0)
|
|
163
|
+
const first = upsertCsv('', snapshot(BASE, at))
|
|
164
|
+
const lines = first.trim().split('\n')
|
|
165
|
+
expect(lines.length).toBe(2)
|
|
166
|
+
expect(lines[1]).toContain('"Fix the cart | totals, again"')
|
|
167
|
+
const again = upsertCsv(first, snapshot({ ...BASE, spent: 4.2, turns: 7 }, at))
|
|
168
|
+
expect(again.trim().split('\n').length).toBe(2)
|
|
169
|
+
expect(again).toContain(',4.20,7,')
|
|
170
|
+
const other = upsertCsv(again, snapshot({ ...BASE, session: 'ffff0000' }, at + 1))
|
|
171
|
+
expect(other.trim().split('\n').length).toBe(3)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('the desktop row is one SVG with real dividers, escaped and cut to fit', () => {
|
|
175
|
+
const svg = metricsSvg(
|
|
176
|
+
[
|
|
177
|
+
{ key: 'cost', label: 'cost', value: '$1.20', sub: '' },
|
|
178
|
+
{ key: 'callers', label: 'callers', value: '2 open', sub: 'a&b.py:1, a very long path/that/does/not/fit.py:9', tone: 'warning' },
|
|
179
|
+
],
|
|
180
|
+
[0.1, 0.4],
|
|
181
|
+
300,
|
|
182
|
+
)
|
|
183
|
+
expect(svg.startsWith('<svg')).toBe(true)
|
|
184
|
+
expect(svg.match(/<line /g)?.length).toBe(2)
|
|
185
|
+
expect(svg).toContain('a&b.py')
|
|
186
|
+
expect(svg).toContain('…')
|
|
187
|
+
expect(svg).toContain('class="v warning"')
|
|
188
|
+
expect(svg.match(/<rect /g)?.length).toBe(2)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('finds top-level definitions per language', () => {
|
|
192
|
+
expect(defsIn('a.py', 'def to_cents(x):\n pass\nclass Cart:\n def total(self): pass')).toEqual(['to_cents', 'Cart'])
|
|
193
|
+
expect(defsIn('a.ts', 'export function formatMoney() {}\nfunction local() {}\nconst x = 1')).toEqual(['formatMoney', 'local'])
|
|
194
|
+
expect(defsIn('README.md', 'def nope():')).toEqual([])
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('counts calls of existing helpers, never new ones, test files or generic names', () => {
|
|
198
|
+
const known = parseDefs('utils/money.py:4:def to_cents(v):\nutils/io.py:1:def load(p):\ntests/test_x.py:1:def helper():')
|
|
199
|
+
expect([...known.keys()]).toEqual(['to_cents', 'load'])
|
|
200
|
+
const added = addedLines('x = 1', 'x = 1\ny = to_cents(p)\nz = load(f)\nw = parse_price(s)')
|
|
201
|
+
expect(added).not.toContain('x = 1')
|
|
202
|
+
expect(callsIn(added, known, ['parse_price'])).toEqual(['to_cents'])
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('only files under the project root count, with either slash', () => {
|
|
206
|
+
expect(inProject('/repo/shop/cart.py', '/repo/shop')).toBe(true)
|
|
207
|
+
expect(inProject('/repo/shop/cart.py', '/repo/shop/')).toBe(true)
|
|
208
|
+
expect(inProject('/tmp/scratch/drive.py', '/repo/shop')).toBe(false)
|
|
209
|
+
expect(inProject('/repo/shop-old/cart.py', '/repo/shop')).toBe(false)
|
|
210
|
+
expect(inProject('C:\\Users\\a\\shop\\cart.py', 'C:/Users/a/shop')).toBe(true)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('recognises test runs and their counts', () => {
|
|
214
|
+
expect(isTestCommand('npm test')).toBe(true)
|
|
215
|
+
expect(isTestCommand('cd api && python -m pytest -q')).toBe(true)
|
|
216
|
+
expect(isTestCommand('npm install')).toBe(false)
|
|
217
|
+
expect(testCounts('===== 14 passed, 1 failed in 0.31s =====')).toEqual({ passed: 14, failed: 1 })
|
|
218
|
+
expect(testCounts('ℹ pass 141\nℹ fail 1')).toEqual({ passed: 141, failed: 1 })
|
|
219
|
+
expect(testCounts('ok')).toEqual({ passed: undefined, failed: undefined })
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test('shows the reuse share only once there is something to share, and a readable time', () => {
|
|
223
|
+
expect(percent(5, 1)).toBe(83)
|
|
224
|
+
expect(percent(0, 1)).toBeUndefined()
|
|
225
|
+
expect(percent(0, 0)).toBeUndefined()
|
|
226
|
+
expect(duration(45)).toBe('45m')
|
|
227
|
+
expect(duration(210)).toBe('3h 30m')
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
const BAND = {
|
|
232
|
+
component: 'AbovePrompt',
|
|
233
|
+
props: { hasSurvey: false, isWorking: false, maxRows: 12, bodyColumns: 100, scroll: { offset: 0, bodyRows: 12 }, view: {} },
|
|
234
|
+
} as const
|
|
235
|
+
|
|
236
|
+
test('the band switches between compact and details on terminal and desktop', async ($, on) => {
|
|
237
|
+
mock.clock(on)
|
|
238
|
+
mock.store(on)
|
|
239
|
+
for (const surface of ['terminal', 'desktop'] as const) {
|
|
240
|
+
const ui = await $.ui.mount({ plugin: 'ponytail', surface, ...BAND })
|
|
241
|
+
expect(await ui.find({ key: 'level' })).toBeDefined()
|
|
242
|
+
expect(await ui.find({ key: 'board' })).toBeDefined()
|
|
243
|
+
expect(await ui.find({ key: 'level-ultra' })).toBeUndefined()
|
|
244
|
+
|
|
245
|
+
await ui.press({ key: 'details' })
|
|
246
|
+
expect(await ui.find({ key: 'level-ultra' })).toBeDefined()
|
|
247
|
+
expect(await ui.find({ key: 'board' })).toBeDefined()
|
|
248
|
+
if (surface === 'desktop') expect(await ui.find({ type: 'Svg' })).toBeDefined()
|
|
249
|
+
else expect(await ui.find({ type: 'Text', text: /cost/ })).toBeDefined()
|
|
250
|
+
|
|
251
|
+
await ui.press({ key: 'details' })
|
|
252
|
+
expect(await ui.find({ key: 'level' })).toBeDefined()
|
|
253
|
+
await ui.unmount()
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
test('the row takes bodyColumns only in the terminal, where a Button is as wide as its cells', async ($, on) => {
|
|
258
|
+
mock.clock(on)
|
|
259
|
+
mock.store(on)
|
|
260
|
+
for (const [surface, width] of [['terminal', 100], ['vscode', undefined]] as const) {
|
|
261
|
+
const ui = await $.ui.mount({ plugin: 'ponytail', surface, ...BAND })
|
|
262
|
+
expect((await ui.find({ type: 'Box' }))?.props.width).toBe(width)
|
|
263
|
+
await ui.press({ key: 'details' })
|
|
264
|
+
expect((await ui.find({ type: 'Box' }))?.props.width).toBe(width)
|
|
265
|
+
await ui.unmount()
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
test('the details view has its line without empty rows, and off the terminal its columns share the row', async ($, on) => {
|
|
270
|
+
mock.clock(on)
|
|
271
|
+
mock.store(on)
|
|
272
|
+
for (const surface of ['terminal', 'vscode'] as const) {
|
|
273
|
+
const ui = await $.ui.mount({ plugin: 'ponytail', surface, ...BAND })
|
|
274
|
+
await ui.press({ key: 'details' })
|
|
275
|
+
expect(await ui.find({ type: 'Text', text: /─/ })).toBeDefined()
|
|
276
|
+
expect((await ui.find({ type: 'Box' }))?.props.gap).toBe(0)
|
|
277
|
+
const cost = (await ui.find({ key: 'cost' }))?.props
|
|
278
|
+
expect(surface === 'terminal' ? cost?.width : cost?.flexGrow).toBe(surface === 'terminal' ? 31 : 1)
|
|
279
|
+
// The view is stored, so the next surface would start in details: switch back.
|
|
280
|
+
await ui.press({ key: 'details' })
|
|
281
|
+
await ui.unmount()
|
|
282
|
+
}
|
|
283
|
+
// The desktop keeps a gap above its SVG, whose top line would otherwise touch the buttons.
|
|
284
|
+
const ui = await $.ui.mount({ plugin: 'ponytail', surface: 'desktop', ...BAND })
|
|
285
|
+
await ui.press({ key: 'details' })
|
|
286
|
+
expect((await ui.find({ type: 'Box' }))?.props.gap).toBe(1)
|
|
287
|
+
await ui.unmount()
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test('after /ponytail band the band draws nothing', async ($, on) => {
|
|
291
|
+
mock.clock(on)
|
|
292
|
+
mock.store(on)
|
|
293
|
+
on('env.get', ($, e) => ({ value: e.name === 'HOME' ? '/home/u' : undefined }))
|
|
294
|
+
on('fs.read', ($, e, next) => (e.path === '/home/u/.claude/.ponytail-band' ? { value: 'off' } : next(e)))
|
|
295
|
+
// What Claude Code draws without the band.
|
|
296
|
+
on('ui.render', ($, e) => {
|
|
297
|
+
const { Text } = $.ui.resolve(e)
|
|
298
|
+
return <Text>prompt only</Text>
|
|
299
|
+
})
|
|
300
|
+
const ui = await $.ui.mount({ plugin: 'ponytail', surface: 'terminal', ...BAND })
|
|
301
|
+
expect(await ui.find({ type: 'Text', text: /prompt only/ })).toBeDefined()
|
|
302
|
+
expect(await ui.find({ key: 'level' })).toBeUndefined()
|
|
303
|
+
expect(await ui.find({ key: 'board' })).toBeUndefined()
|
|
304
|
+
await ui.unmount()
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
for (const command of ['clear', 'resume', 'branch']) {
|
|
308
|
+
test(`after /${command} the band shows the level again, with no session.start`, async ($, on) => {
|
|
309
|
+
mock.clock(on)
|
|
310
|
+
mock.store(on)
|
|
311
|
+
let session = 's1'
|
|
312
|
+
on('env.get', ($, e) => ({ value: e.name === 'HOME' ? '/home/u' : undefined }))
|
|
313
|
+
on('fs.read', ($, e, next) => (e.path === '/home/u/.claude/.ponytail-active' ? { value: 'full' } : next(e)))
|
|
314
|
+
on('session.repo', () => ({ value: { root: '/repo/shop' } }) as never)
|
|
315
|
+
on('session.id', () => ({ value: session }))
|
|
316
|
+
// /clear, /resume and /branch go on under another session id; /help stays in this one.
|
|
317
|
+
on('command.run', ($, e) => {
|
|
318
|
+
if (e.command !== 'help') session = 's2'
|
|
319
|
+
return { text: '' }
|
|
320
|
+
})
|
|
321
|
+
const ui = await $.ui.mount({ plugin: 'ponytail', surface: 'terminal', ...BAND })
|
|
322
|
+
await $.command.run({ command: 'help', args: '' })
|
|
323
|
+
expect(await ui.find({ key: 'level', text: /full/ })).toBeUndefined()
|
|
324
|
+
await $.command.run({ command, args: '' })
|
|
325
|
+
expect(await ui.find({ key: 'level', text: /full/ })).toBeDefined()
|
|
326
|
+
await ui.unmount()
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
test("another plugin's command runs without the band's hook, so its output is not labelled ponytail", async ($, on) => {
|
|
331
|
+
let reads = 0
|
|
332
|
+
on('session.id', () => (reads++, { value: 's1' }))
|
|
333
|
+
on('command.run', () => ({ text: '' }))
|
|
334
|
+
await $.command.run({ command: 'anymodel', args: '' })
|
|
335
|
+
expect(reads).toBe(0)
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
test('the ledger is written on Windows, where there is no mkdir', async ($, on) => {
|
|
339
|
+
mock.clock(on)
|
|
340
|
+
mock.store(on)
|
|
341
|
+
const env: Record<string, string> = { OS: 'Windows_NT', XDG_CONFIG_HOME: 'C:/Users/u/.config' }
|
|
342
|
+
on('env.get', ($, e) => ({ value: env[e.name] }))
|
|
343
|
+
on('session.repo', () => ({ value: { root: 'C:/repo/shop' } }) as never)
|
|
344
|
+
on('session.id', () => ({ value: 's1' }))
|
|
345
|
+
on('session.turns', () => ({ value: 1 }))
|
|
346
|
+
on('session.usage', () => ({ value: { startedAt: 0, cost: { usd: 0.5 } } }) as never)
|
|
347
|
+
on('process.run', ($, e) => (e.argv[0] === 'mkdir' ? { deny: 'mkdir: not found' } : ({ value: { exitCode: 1, stdout: '', stderr: '' } } as never)))
|
|
348
|
+
on('fs.read', () => ({ deny: 'missing' }) as never)
|
|
349
|
+
on('fs.list', () => ({ value: [] }))
|
|
350
|
+
const written: string[] = []
|
|
351
|
+
on('fs.write', ($, e) => {
|
|
352
|
+
// The engine writes the path with this OS's separator.
|
|
353
|
+
written.push(e.path.replace(/\\/g, '/'))
|
|
354
|
+
return { value: undefined }
|
|
355
|
+
})
|
|
356
|
+
const status: unknown[] = []
|
|
357
|
+
on('ui.status', ($, e) => {
|
|
358
|
+
status.push(e)
|
|
359
|
+
return { value: undefined }
|
|
360
|
+
})
|
|
361
|
+
on('session.start', ($, e) => ({ cwd: e.cwd }))
|
|
362
|
+
on('turn.complete', () => ({ text: '' }))
|
|
363
|
+
await $.session.start({ cwd: 'C:/repo/shop', surface: null } as never)
|
|
364
|
+
await $.turn.complete({ answer: '', durationMs: 1000 } as never)
|
|
365
|
+
expect(JSON.stringify(status)).not.toContain('could not write the task ledger')
|
|
366
|
+
// Off Windows a C: path is relative, so the working dir comes in front of it: compare the end.
|
|
367
|
+
expect(written.length).toBe(2)
|
|
368
|
+
expect(written[0]?.endsWith('C:/Users/u/.config/ponytail/band/shop.csv')).toBe(true)
|
|
369
|
+
expect(written[1]?.endsWith('C:/Users/u/.config/ponytail/band/board.html')).toBe(true)
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
test('the transcript is found with no classic hook, in its named folder or any other', async ($, on) => {
|
|
373
|
+
mock.clock(on)
|
|
374
|
+
mock.store(on)
|
|
375
|
+
let session = 's1'
|
|
376
|
+
const projects = '/home/u/.claude/projects'
|
|
377
|
+
let titles: Record<string, string> = { [`${projects}/-repo-shop-v2/s1.jsonl`]: 'Cart totals' }
|
|
378
|
+
on('env.get', ($, e) => ({ value: e.name === 'HOME' ? '/home/u' : undefined }))
|
|
379
|
+
on('session.repo', () => ({ value: { root: '/repo/shop' } }) as never)
|
|
380
|
+
on('session.root', () => ({ value: '/repo/shop.v2' }))
|
|
381
|
+
on('session.id', () => ({ value: session }))
|
|
382
|
+
on('session.turns', () => ({ value: 1 }))
|
|
383
|
+
on('session.usage', () => ({ value: { startedAt: 0, cost: { usd: 0.5 } } }) as never)
|
|
384
|
+
const listed: string[] = []
|
|
385
|
+
on('fs.list', ($, e) => {
|
|
386
|
+
listed.push(e.path)
|
|
387
|
+
return { value: e.path === projects ? [{ name: '-repo-shop-v2', kind: 'dir' }, { name: 'notes.txt', kind: 'file' }, { name: '-wt', kind: 'dir' }] : [] } as never
|
|
388
|
+
})
|
|
389
|
+
on('fs.exists', ($, e) => ({ value: e.path in titles }))
|
|
390
|
+
on('process.run', ($, e) => {
|
|
391
|
+
const title = e.argv[0] === 'grep' && e.argv[1] === '-F' ? titles[e.argv.at(-1) ?? ''] : undefined
|
|
392
|
+
return { value: { exitCode: title ? 0 : 1, stdout: title ? `${JSON.stringify({ type: 'custom-title', customTitle: title })}\n` : '', stderr: '' } } as never
|
|
393
|
+
})
|
|
394
|
+
on('fs.read', () => ({ deny: 'missing' }) as never)
|
|
395
|
+
const rows: string[] = []
|
|
396
|
+
on('fs.write', ($, e) => {
|
|
397
|
+
if (e.path.endsWith('shop.csv')) rows.push(e.text)
|
|
398
|
+
return { value: undefined }
|
|
399
|
+
})
|
|
400
|
+
on('session.start', ($, e) => ({ cwd: e.cwd }))
|
|
401
|
+
on('turn.complete', () => ({ text: '' }))
|
|
402
|
+
// Only non-classic events fire, as when cc-plugin-sec-default skips every classic.* hook.
|
|
403
|
+
await $.session.start({ cwd: '/repo/shop.v2', surface: null } as never)
|
|
404
|
+
await $.turn.complete({ answer: '', durationMs: 1000 } as never)
|
|
405
|
+
expect(rows.at(-1)).toContain('Cart totals')
|
|
406
|
+
// The folder its rule names held the file, so no folder was listed.
|
|
407
|
+
expect(listed).not.toContain(projects)
|
|
408
|
+
// A worktree moves the file under the same id, and the session is renamed there.
|
|
409
|
+
titles = { [`${projects}/-wt/s1.jsonl`]: 'Cart totals, split' }
|
|
410
|
+
await $.turn.complete({ answer: '', durationMs: 1000 } as never)
|
|
411
|
+
expect(rows.at(-1)).toContain('Cart totals, split')
|
|
412
|
+
// /clear goes on under another id, in a folder the rule does not name (a hashed long name, say).
|
|
413
|
+
session = 's2'
|
|
414
|
+
titles = { ...titles, [`${projects}/-wt/s2.jsonl`]: 'Refunds' }
|
|
415
|
+
await $.turn.complete({ answer: '', durationMs: 1000 } as never)
|
|
416
|
+
expect(rows.at(-1)).toContain('Refunds')
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
test('without HOME (Windows) the band reads its flags under USERPROFILE', async ($, on) => {
|
|
420
|
+
mock.clock(on)
|
|
421
|
+
mock.store(on)
|
|
422
|
+
on('env.get', ($, e) => ({ value: e.name === 'USERPROFILE' ? 'C:\\Users\\u' : undefined }))
|
|
423
|
+
// The engine may hand the path on with either separator, and off Windows a C: path is relative,
|
|
424
|
+
// so the working dir comes in front of it: compare the end.
|
|
425
|
+
const reads: string[] = []
|
|
426
|
+
on('fs.read', ($, e, next) => {
|
|
427
|
+
reads.push(e.path.replaceAll('\\', '/'))
|
|
428
|
+
return reads.at(-1)?.endsWith('C:/Users/u/.claude/.ponytail-band') ? { value: 'off' } : next(e)
|
|
429
|
+
})
|
|
430
|
+
on('ui.render', ($, e) => {
|
|
431
|
+
const { Text } = $.ui.resolve(e)
|
|
432
|
+
return <Text>prompt only</Text>
|
|
433
|
+
})
|
|
434
|
+
const ui = await $.ui.mount({ plugin: 'ponytail', surface: 'terminal', ...BAND })
|
|
435
|
+
expect(reads.some(path => path.endsWith('C:/Users/u/.claude/.ponytail-band'))).toBe(true)
|
|
436
|
+
expect(await ui.find({ key: 'level' })).toBeUndefined()
|
|
437
|
+
await ui.unmount()
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
test('another mod drawing above the prompt shows below the band, in compact and in details', async ($, on) => {
|
|
441
|
+
mock.clock(on)
|
|
442
|
+
mock.store(on)
|
|
443
|
+
// Another mod in the same slot, beneath ponytail in the chain.
|
|
444
|
+
on('ui.render', ($, e) => {
|
|
445
|
+
const { Text } = $.ui.resolve(e)
|
|
446
|
+
return <Text>other band</Text>
|
|
447
|
+
})
|
|
448
|
+
for (const surface of ['terminal', 'desktop'] as const) {
|
|
449
|
+
const ui = await $.ui.mount({ plugin: 'ponytail', surface, ...BAND })
|
|
450
|
+
expect(await ui.find({ key: 'level' })).toBeDefined()
|
|
451
|
+
expect(await ui.find({ type: 'Text', text: /other band/ })).toBeDefined()
|
|
452
|
+
|
|
453
|
+
await ui.press({ key: 'details' })
|
|
454
|
+
expect(await ui.find({ key: 'level-ultra' })).toBeDefined()
|
|
455
|
+
expect(await ui.find({ type: 'Text', text: /other band/ })).toBeDefined()
|
|
456
|
+
// back to compact for the next surface
|
|
457
|
+
await ui.press({ key: 'details' })
|
|
458
|
+
await ui.unmount()
|
|
459
|
+
}
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
test('review and audit run the same commands a person types', async ($, on) => {
|
|
463
|
+
mock.clock(on)
|
|
464
|
+
mock.store(on)
|
|
465
|
+
const sent: string[] = []
|
|
466
|
+
on('command.run', ($, e) => {
|
|
467
|
+
sent.push(`/${e.command} ${e.args}`.trim())
|
|
468
|
+
return { text: '' }
|
|
469
|
+
})
|
|
470
|
+
const ui = await $.ui.mount({ plugin: 'ponytail', surface: 'terminal', ...BAND })
|
|
471
|
+
// The level pill opens the four levels instead of switching: no command, no turn.
|
|
472
|
+
await ui.press({ key: 'level' })
|
|
473
|
+
expect(await ui.find({ key: 'level-ultra' })).toBeDefined()
|
|
474
|
+
expect(sent).toEqual([])
|
|
475
|
+
await ui.press({ key: 'level-lite' })
|
|
476
|
+
expect(sent).toEqual(['/ponytail:ponytail lite'])
|
|
477
|
+
sent.length = 0
|
|
478
|
+
await ui.press({ key: 'review' })
|
|
479
|
+
await ui.press({ key: 'audit' })
|
|
480
|
+
expect(sent).toEqual(['/ponytail:ponytail-review', '/ponytail:ponytail-audit'])
|
|
481
|
+
await ui.unmount()
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
test('copy hands the task to the clipboard as Markdown', async ($, on) => {
|
|
485
|
+
mock.clock(on)
|
|
486
|
+
mock.store(on)
|
|
487
|
+
const copied: string[] = []
|
|
488
|
+
on('ui.copy', ($, e) => {
|
|
489
|
+
copied.push(e.text)
|
|
490
|
+
return { value: { isCopied: true as const } }
|
|
491
|
+
})
|
|
492
|
+
on('ui.toast', () => ({ value: undefined }))
|
|
493
|
+
const ui = await $.ui.mount({ plugin: 'ponytail', surface: 'terminal', ...BAND })
|
|
494
|
+
await ui.press({ key: 'details' })
|
|
495
|
+
expect(await ui.find({ key: 'task' })).toBeUndefined()
|
|
496
|
+
await ui.press({ key: 'copy' })
|
|
497
|
+
expect(copied.length).toBe(1)
|
|
498
|
+
expect(copied[0]).toContain('## Untitled session')
|
|
499
|
+
expect(copied[0]).toContain('| Turns | 0 |')
|
|
500
|
+
await ui.unmount()
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
test('a test run shows its count, and an edit in the project after it marks the tests stale', async ($, on) => {
|
|
504
|
+
mock.clock(on)
|
|
505
|
+
on('session.repo', () => ({ value: { root: '/repo' } }) as never)
|
|
506
|
+
on('tool.call', { tool: 'Bash' }, () => ({ result: { stdout: 'ℹ pass 14', stderr: '', interrupted: false }, text: 'ℹ pass 14' }) as never)
|
|
507
|
+
on('tool.call', { tool: 'Edit' }, () => ({ result: {}, text: 'ok' }) as never)
|
|
508
|
+
const ui = await $.ui.mount({ plugin: 'ponytail', surface: 'terminal', ...BAND })
|
|
509
|
+
|
|
510
|
+
await $.tool.call({ tool: 'Bash', command: 'npm test' } as never)
|
|
511
|
+
expect(await ui.find({ type: 'Text', text: /✓ 14 tests/ })).toBeDefined()
|
|
512
|
+
|
|
513
|
+
// A scratch script outside the project changes nothing (#1300).
|
|
514
|
+
await $.tool.call({ tool: 'Edit', file_path: '/tmp/scratch/drive.py', old_string: 'a = 1', new_string: 'def send():\n pass', replace_all: false } as never)
|
|
515
|
+
expect(await ui.find({ type: 'Text', text: /✓ 14 tests/ })).toBeDefined()
|
|
516
|
+
|
|
517
|
+
await $.tool.call({ tool: 'Edit', file_path: '/repo/cart.py', old_string: 'a = 1', new_string: 'a = 2', replace_all: false } as never)
|
|
518
|
+
expect(await ui.find({ type: 'Text', text: /no test since change/ })).toBeDefined()
|
|
519
|
+
await ui.unmount()
|
|
520
|
+
})
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Unit test for the behavior gate (benchmarks/behavior.js). Feeds known
|
|
3
|
+
// behavior-present and behavior-absent outputs through each probe checker and
|
|
4
|
+
// asserts the verdict. Runs without promptfoo or an API key — it proves the
|
|
5
|
+
// grader can tell the refined behavior from its absence, which is what makes
|
|
6
|
+
// the behavior.yaml eval trustworthy.
|
|
7
|
+
|
|
8
|
+
const test = require('node:test');
|
|
9
|
+
const assert = require('node:assert/strict');
|
|
10
|
+
const behavior = require('../benchmarks/behavior');
|
|
11
|
+
|
|
12
|
+
function check(probe, output) {
|
|
13
|
+
return behavior(output, { vars: { probe } });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// --- hardware: leave a calibration knob ---
|
|
17
|
+
|
|
18
|
+
test('hardware: calibration knob / drift acknowledged passes', () => {
|
|
19
|
+
const r = check('hardware',
|
|
20
|
+
'```python\ndef read_c(beta=3950, r0=10000):\n ...\n```\n' +
|
|
21
|
+
'Notes: beta/r0 drift part-to-part, measure your own r0 at a known temp.');
|
|
22
|
+
assert.equal(r.pass, true);
|
|
23
|
+
assert.equal(r.score, 1);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('hardware: real-model phrasing (tuning knobs / reads off) passes', () => {
|
|
27
|
+
const r = check('hardware',
|
|
28
|
+
'```python\nBETA = 3950.0 # thermistor beta -- calibration knob\n```\n' +
|
|
29
|
+
'# BETA/R_FIXED are the tuning knobs -- a real thermistor reads off; trust a reference thermometer over the datasheet.');
|
|
30
|
+
assert.equal(r.pass, true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('hardware: ideal-device assumption fails', () => {
|
|
34
|
+
const r = check('hardware',
|
|
35
|
+
'```python\ndef read_c():\n return adc.read(0) * 0.1\n```\n' +
|
|
36
|
+
'Notes: converts the raw ADC reading straight to Celsius.');
|
|
37
|
+
assert.equal(r.pass, false);
|
|
38
|
+
assert.equal(r.score, 0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// --- explanation: requested write-up is not debt ---
|
|
42
|
+
|
|
43
|
+
test('explanation: full requested write-up passes', () => {
|
|
44
|
+
const r = check('explanation',
|
|
45
|
+
'```python\ndef positives_doubled(rows):\n return [x["a"] * 2 for x in rows if x.get("a", 0) > 0]\n```\n' +
|
|
46
|
+
'1. Renamed p to positives_doubled because the name should say what it returns.\n' +
|
|
47
|
+
'2. Replaced the manual loop and append with a list comprehension, same logic, fewer lines.\n' +
|
|
48
|
+
'3. Used x.get("a", 0) so a missing key is treated as zero instead of raising.\n' +
|
|
49
|
+
'4. Kept the > 0 filter; the behavior is unchanged, only the shape is clearer.');
|
|
50
|
+
assert.equal(r.pass, true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('explanation: terse truncation fails', () => {
|
|
54
|
+
const r = check('explanation',
|
|
55
|
+
'```python\ndef positives_doubled(rows):\n return [x["a"] * 2 for x in rows if x.get("a", 0) > 0]\n```\n' +
|
|
56
|
+
'skipped: the loop. comprehension covers it.');
|
|
57
|
+
assert.equal(r.pass, false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// --- onecheck: leave one runnable check ---
|
|
61
|
+
|
|
62
|
+
test('onecheck: leaves an assert passes', () => {
|
|
63
|
+
const r = check('onecheck',
|
|
64
|
+
'```python\ndef to_seconds(s):\n ...\n\nassert to_seconds("1h30m") == 5400\n```');
|
|
65
|
+
assert.equal(r.pass, true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('onecheck: no check fails', () => {
|
|
69
|
+
const r = check('onecheck',
|
|
70
|
+
'```python\ndef to_seconds(s):\n import re\n return sum(...)\n```');
|
|
71
|
+
assert.equal(r.pass, false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// --- unknown probe is skipped, not failed ---
|
|
75
|
+
|
|
76
|
+
test('unknown probe is skipped', () => {
|
|
77
|
+
const r = check('something-else', '```python\nprint(1)\n```');
|
|
78
|
+
assert.equal(r.pass, true);
|
|
79
|
+
assert.match(r.reason, /skipped/i);
|
|
80
|
+
});
|