@onyx-robotics/agent 0.1.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/LICENSE +202 -0
- package/README.md +72 -0
- package/bin/onyx.ts +4 -0
- package/package.json +52 -0
- package/scripts/install.sh +115 -0
- package/skills/onyx/SKILL.md +150 -0
- package/src/commands/agent.ts +23 -0
- package/src/commands/branch.ts +96 -0
- package/src/commands/exp.ts +432 -0
- package/src/commands/listen.ts +327 -0
- package/src/commands/login.ts +198 -0
- package/src/commands/profile.ts +112 -0
- package/src/commands/sync.ts +88 -0
- package/src/install.test.ts +38 -0
- package/src/lib/api.ts +227 -0
- package/src/lib/args.ts +68 -0
- package/src/lib/config.ts +148 -0
- package/src/lib/events.ts +97 -0
- package/src/lib/git.ts +57 -0
- package/src/lib/history.ts +272 -0
- package/src/lib/login.ts +233 -0
- package/src/lib/markdown.ts +148 -0
- package/src/lib/metrics.ts +41 -0
- package/src/lib/outbox.ts +173 -0
- package/src/lib/process.ts +73 -0
- package/src/lib/project.ts +42 -0
- package/src/lib/skill-content.ts +1 -0
- package/src/lib/skill.ts +50 -0
- package/src/lib/sync.ts +294 -0
- package/src/lib/tui.ts +364 -0
- package/src/main.ts +84 -0
- package/src/onyx.test.ts +952 -0
- package/src/onyx.ts +92 -0
- package/src/profile.test.ts +472 -0
- package/src/protocol/index.ts +2 -0
- package/src/protocol/local-research.ts +152 -0
- package/src/protocol/research.ts +75 -0
package/src/onyx.test.ts
ADDED
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmod,
|
|
3
|
+
mkdir,
|
|
4
|
+
mkdtemp,
|
|
5
|
+
readdir,
|
|
6
|
+
readFile,
|
|
7
|
+
rm,
|
|
8
|
+
writeFile,
|
|
9
|
+
} from "node:fs/promises"
|
|
10
|
+
import { tmpdir } from "node:os"
|
|
11
|
+
import { join } from "node:path"
|
|
12
|
+
|
|
13
|
+
import { describe, expect, test } from "bun:test"
|
|
14
|
+
|
|
15
|
+
import type { LocalResearchHistoryRecord } from "./protocol"
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
appendHistory,
|
|
19
|
+
appendOutbox,
|
|
20
|
+
applyHistorySyncUpdates,
|
|
21
|
+
commandBranchCreate,
|
|
22
|
+
commandAgent,
|
|
23
|
+
commandExpList,
|
|
24
|
+
commandExpLog,
|
|
25
|
+
commandExpRun,
|
|
26
|
+
commandStatus,
|
|
27
|
+
formatAge,
|
|
28
|
+
historyPath,
|
|
29
|
+
installOnyxSkill,
|
|
30
|
+
lastRunPath,
|
|
31
|
+
mergeHistory,
|
|
32
|
+
outboxPath,
|
|
33
|
+
parseArgs,
|
|
34
|
+
parseMetricLines,
|
|
35
|
+
readEvents,
|
|
36
|
+
readHistory,
|
|
37
|
+
readLastRun,
|
|
38
|
+
readOutbox,
|
|
39
|
+
renderFrame,
|
|
40
|
+
spinnerChar,
|
|
41
|
+
stripAnsi,
|
|
42
|
+
USAGE,
|
|
43
|
+
main,
|
|
44
|
+
} from "./onyx"
|
|
45
|
+
|
|
46
|
+
// Force the offline path: every flush resolves the project over the network,
|
|
47
|
+
// and a closed port makes that fail fast so records stay queued in the outbox.
|
|
48
|
+
process.env.ONYX_API_URL = "http://127.0.0.1:9"
|
|
49
|
+
process.env.ONYX_API_KEY = "test-offline"
|
|
50
|
+
|
|
51
|
+
async function git(root: string, args: string[]) {
|
|
52
|
+
const process = Bun.spawn(["git", ...args], {
|
|
53
|
+
cwd: root,
|
|
54
|
+
stdout: "pipe",
|
|
55
|
+
stderr: "pipe",
|
|
56
|
+
})
|
|
57
|
+
const stdout = await new Response(process.stdout).text()
|
|
58
|
+
const stderr = await new Response(process.stderr).text()
|
|
59
|
+
const code = await process.exited
|
|
60
|
+
if (code !== 0) {
|
|
61
|
+
throw new Error(`git ${args.join(" ")} failed: ${stderr || stdout}`)
|
|
62
|
+
}
|
|
63
|
+
return stdout.trim()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function withCwd<T>(cwd: string, fn: () => Promise<T>) {
|
|
67
|
+
const previous = process.cwd()
|
|
68
|
+
process.chdir(cwd)
|
|
69
|
+
try {
|
|
70
|
+
return await fn()
|
|
71
|
+
} finally {
|
|
72
|
+
process.chdir(previous)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function captureLogs(fn: () => Promise<void>) {
|
|
77
|
+
const previous = console.log
|
|
78
|
+
const logs: string[] = []
|
|
79
|
+
console.log = (...args: unknown[]) => {
|
|
80
|
+
logs.push(args.map(String).join(" "))
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
await fn()
|
|
84
|
+
} finally {
|
|
85
|
+
console.log = previous
|
|
86
|
+
}
|
|
87
|
+
return logs.join("\n")
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function createGitRepo() {
|
|
91
|
+
const root = await mkdtemp(join(tmpdir(), "onyx-cli-git-test-"))
|
|
92
|
+
await git(root, ["init"])
|
|
93
|
+
await git(root, ["config", "user.email", "test@example.com"])
|
|
94
|
+
await git(root, ["config", "user.name", "Test User"])
|
|
95
|
+
await writeFile(join(root, "README.md"), "# Test\n", "utf8")
|
|
96
|
+
await git(root, ["add", "README.md"])
|
|
97
|
+
await git(root, ["commit", "-m", "initial"])
|
|
98
|
+
return root
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function writeOnyxFiles({
|
|
102
|
+
root,
|
|
103
|
+
projectPath = "",
|
|
104
|
+
evalBody = 'echo "METRIC score=0.9"\n',
|
|
105
|
+
checksBody,
|
|
106
|
+
}: {
|
|
107
|
+
root: string
|
|
108
|
+
projectPath?: string
|
|
109
|
+
evalBody?: string
|
|
110
|
+
checksBody?: string
|
|
111
|
+
}) {
|
|
112
|
+
const scoped = projectPath ? join(root, projectPath) : root
|
|
113
|
+
await mkdir(join(scoped, "onyx"), { recursive: true })
|
|
114
|
+
await writeFile(
|
|
115
|
+
join(scoped, "onyx", "onyx.md"),
|
|
116
|
+
`# Onyx Research
|
|
117
|
+
|
|
118
|
+
## Objective
|
|
119
|
+
|
|
120
|
+
Test objective.
|
|
121
|
+
|
|
122
|
+
## Branches
|
|
123
|
+
`,
|
|
124
|
+
"utf8"
|
|
125
|
+
)
|
|
126
|
+
await writeFile(
|
|
127
|
+
join(scoped, "onyx", "eval.sh"),
|
|
128
|
+
`#!/usr/bin/env bash
|
|
129
|
+
set -euo pipefail
|
|
130
|
+
${evalBody}`,
|
|
131
|
+
{ encoding: "utf8", mode: 0o755 }
|
|
132
|
+
)
|
|
133
|
+
await chmod(join(scoped, "onyx", "eval.sh"), 0o755)
|
|
134
|
+
|
|
135
|
+
if (checksBody !== undefined) {
|
|
136
|
+
await writeFile(
|
|
137
|
+
join(scoped, "onyx", "checks.sh"),
|
|
138
|
+
`#!/usr/bin/env bash
|
|
139
|
+
set -euo pipefail
|
|
140
|
+
${checksBody}`,
|
|
141
|
+
{ encoding: "utf8", mode: 0o755 }
|
|
142
|
+
)
|
|
143
|
+
await chmod(join(scoped, "onyx", "checks.sh"), 0o755)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function startBranch({
|
|
148
|
+
root,
|
|
149
|
+
options,
|
|
150
|
+
projectPath = "",
|
|
151
|
+
evalBody,
|
|
152
|
+
checksBody,
|
|
153
|
+
}: {
|
|
154
|
+
root: string
|
|
155
|
+
options: Record<string, string>
|
|
156
|
+
projectPath?: string
|
|
157
|
+
evalBody?: string
|
|
158
|
+
checksBody?: string
|
|
159
|
+
}) {
|
|
160
|
+
await writeOnyxFiles({ root, projectPath, evalBody, checksBody })
|
|
161
|
+
await git(root, ["add", projectPath ? join(projectPath, "onyx") : "onyx"])
|
|
162
|
+
await git(root, ["commit", "-m", "add onyx files"])
|
|
163
|
+
await commandBranchCreate({
|
|
164
|
+
positional: ["branch", "create"],
|
|
165
|
+
options: {
|
|
166
|
+
...options,
|
|
167
|
+
...(projectPath ? { "project-path": projectPath } : {}),
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
await git(root, [
|
|
171
|
+
"add",
|
|
172
|
+
projectPath
|
|
173
|
+
? join(projectPath, "onyx", "onyx.md")
|
|
174
|
+
: join("onyx", "onyx.md"),
|
|
175
|
+
])
|
|
176
|
+
await git(root, ["commit", "-m", "start branch"])
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
describe("onyx CLI helpers", () => {
|
|
180
|
+
test("parses positional args and long options", () => {
|
|
181
|
+
expect(
|
|
182
|
+
parseArgs([
|
|
183
|
+
"branch",
|
|
184
|
+
"create",
|
|
185
|
+
"--name",
|
|
186
|
+
"fast-eval",
|
|
187
|
+
"--metric=score",
|
|
188
|
+
"--dry-run",
|
|
189
|
+
])
|
|
190
|
+
).toEqual({
|
|
191
|
+
positional: ["branch", "create"],
|
|
192
|
+
options: {
|
|
193
|
+
name: "fast-eval",
|
|
194
|
+
metric: "score",
|
|
195
|
+
"dry-run": "true",
|
|
196
|
+
},
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test("parses metric lines and numeric fallback output", () => {
|
|
201
|
+
expect(
|
|
202
|
+
parseMetricLines("log\nMETRIC score=0.42\nMETRIC loss=1e-3")
|
|
203
|
+
).toEqual({
|
|
204
|
+
score: 0.42,
|
|
205
|
+
loss: 0.001,
|
|
206
|
+
})
|
|
207
|
+
expect(parseMetricLines("0.7", "accuracy")).toEqual({ accuracy: 0.7 })
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test("public usage stays focused on primitive commands", () => {
|
|
211
|
+
expect(USAGE).toContain("onyx --version")
|
|
212
|
+
expect(USAGE).toContain("onyx agent skill-path")
|
|
213
|
+
expect(USAGE).toContain("onyx agent install-skill")
|
|
214
|
+
expect(USAGE).not.toContain("onyx init")
|
|
215
|
+
expect(USAGE).toContain("onyx login")
|
|
216
|
+
expect(USAGE).toContain("onyx branch create")
|
|
217
|
+
expect(USAGE).toContain("onyx exp run")
|
|
218
|
+
expect(USAGE).toContain("onyx exp log")
|
|
219
|
+
expect(USAGE).toContain("onyx status")
|
|
220
|
+
expect(USAGE).toContain("onyx push")
|
|
221
|
+
|
|
222
|
+
expect(USAGE).not.toContain("remote-create")
|
|
223
|
+
expect(USAGE).not.toContain("remote-list")
|
|
224
|
+
expect(USAGE).not.toContain("onyx exp create")
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test("prints the package version for --version", async () => {
|
|
228
|
+
const output = await captureLogs(() => main(["--version"]))
|
|
229
|
+
|
|
230
|
+
expect(output).toBe("0.1.0")
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test("exp run writes last-run and exp log queues it when offline", async () => {
|
|
234
|
+
const root = await createGitRepo()
|
|
235
|
+
try {
|
|
236
|
+
await withCwd(root, async () => {
|
|
237
|
+
await startBranch({
|
|
238
|
+
root,
|
|
239
|
+
options: {
|
|
240
|
+
name: "fast-eval",
|
|
241
|
+
metric: "score",
|
|
242
|
+
unit: "points",
|
|
243
|
+
direction: "maximize",
|
|
244
|
+
},
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
await commandExpRun({ positional: ["exp", "run"], options: {} })
|
|
248
|
+
const lastRun = await readLastRun(root)
|
|
249
|
+
expect(lastRun?.status).toBe("succeeded")
|
|
250
|
+
expect(lastRun?.primaryMetricValue).toBe(0.9)
|
|
251
|
+
|
|
252
|
+
await commandExpLog({
|
|
253
|
+
positional: ["exp", "log"],
|
|
254
|
+
options: {
|
|
255
|
+
description: "First experiment",
|
|
256
|
+
"agent-notes": '{"hypothesis":"baseline"}',
|
|
257
|
+
},
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
const { records } = await readOutbox(root)
|
|
261
|
+
const experiments = records.flatMap((record) =>
|
|
262
|
+
record.type === "experiment_logged" ? [record] : []
|
|
263
|
+
)
|
|
264
|
+
expect(experiments).toHaveLength(1)
|
|
265
|
+
const experiment = experiments[0]!
|
|
266
|
+
expect(experiment.branchName).toBe("fast-eval")
|
|
267
|
+
expect(experiment.status).toBe("succeeded")
|
|
268
|
+
expect(experiment.primaryMetricValue).toBe(0.9)
|
|
269
|
+
expect(experiment.agentNotes.hypothesis).toBe("baseline")
|
|
270
|
+
expect(experiment.runRef).toContain("local/fast-eval/")
|
|
271
|
+
expect(await readLastRun(root)).toBeNull()
|
|
272
|
+
expect(
|
|
273
|
+
records.some(
|
|
274
|
+
(record) =>
|
|
275
|
+
record.type === "branch_started" && record.name === "fast-eval"
|
|
276
|
+
)
|
|
277
|
+
).toBe(true)
|
|
278
|
+
})
|
|
279
|
+
} finally {
|
|
280
|
+
await rm(root, { recursive: true, force: true })
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test("failed evals are measured and logged as failed", async () => {
|
|
285
|
+
const root = await createGitRepo()
|
|
286
|
+
try {
|
|
287
|
+
await withCwd(root, async () => {
|
|
288
|
+
await startBranch({
|
|
289
|
+
root,
|
|
290
|
+
options: { name: "fast-eval", metric: "score" },
|
|
291
|
+
evalBody: 'echo "boom" >&2\nexit 2\n',
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
await commandExpRun({ positional: ["exp", "run"], options: {} })
|
|
295
|
+
process.exitCode = 0
|
|
296
|
+
const lastRun = await readLastRun(root)
|
|
297
|
+
expect(lastRun?.status).toBe("failed")
|
|
298
|
+
expect(lastRun?.primaryMetricValue).toBeNull()
|
|
299
|
+
|
|
300
|
+
await commandExpLog({
|
|
301
|
+
positional: ["exp", "log"],
|
|
302
|
+
options: { description: "Eval failed" },
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
const { records } = await readOutbox(root)
|
|
306
|
+
const experiment = records.find(
|
|
307
|
+
(record) => record.type === "experiment_logged"
|
|
308
|
+
)
|
|
309
|
+
expect(experiment?.status).toBe("failed")
|
|
310
|
+
})
|
|
311
|
+
} finally {
|
|
312
|
+
process.exitCode = 0
|
|
313
|
+
await rm(root, { recursive: true, force: true })
|
|
314
|
+
}
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
test("checks failures are measured and logged as checks_failed", async () => {
|
|
318
|
+
const root = await createGitRepo()
|
|
319
|
+
try {
|
|
320
|
+
await withCwd(root, async () => {
|
|
321
|
+
await startBranch({
|
|
322
|
+
root,
|
|
323
|
+
options: { name: "fast-eval", metric: "score" },
|
|
324
|
+
checksBody: 'echo "type error" >&2\nexit 1\n',
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
await commandExpRun({ positional: ["exp", "run"], options: {} })
|
|
328
|
+
process.exitCode = 0
|
|
329
|
+
const lastRun = await readLastRun(root)
|
|
330
|
+
expect(lastRun?.status).toBe("checks_failed")
|
|
331
|
+
expect(lastRun?.checks?.status).toBe("failed")
|
|
332
|
+
|
|
333
|
+
await commandExpLog({
|
|
334
|
+
positional: ["exp", "log"],
|
|
335
|
+
options: { description: "Checks failed" },
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
const { records } = await readOutbox(root)
|
|
339
|
+
const experiment = records.find(
|
|
340
|
+
(record) => record.type === "experiment_logged"
|
|
341
|
+
)
|
|
342
|
+
expect(experiment?.status).toBe("checks_failed")
|
|
343
|
+
expect(experiment?.checks?.status).toBe("failed")
|
|
344
|
+
})
|
|
345
|
+
} finally {
|
|
346
|
+
process.exitCode = 0
|
|
347
|
+
await rm(root, { recursive: true, force: true })
|
|
348
|
+
}
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
test("stub evals are rejected before measurement", async () => {
|
|
352
|
+
const root = await createGitRepo()
|
|
353
|
+
try {
|
|
354
|
+
await withCwd(root, async () => {
|
|
355
|
+
await startBranch({
|
|
356
|
+
root,
|
|
357
|
+
options: { name: "fast-eval", metric: "score" },
|
|
358
|
+
evalBody: '# ONYX_STUB_EVAL\necho "METRIC score=0"\n',
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
await expect(
|
|
362
|
+
commandExpRun({ positional: ["exp", "run"], options: {} })
|
|
363
|
+
).rejects.toThrow("ONYX_STUB_EVAL")
|
|
364
|
+
})
|
|
365
|
+
} finally {
|
|
366
|
+
await rm(root, { recursive: true, force: true })
|
|
367
|
+
}
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
test("project-path commands use scoped onyx files", async () => {
|
|
371
|
+
const root = await createGitRepo()
|
|
372
|
+
try {
|
|
373
|
+
await withCwd(root, async () => {
|
|
374
|
+
const projectPath = "packages/demo"
|
|
375
|
+
await mkdir(join(root, projectPath), { recursive: true })
|
|
376
|
+
await writeFile(join(root, projectPath, "marker.txt"), "ok\n", "utf8")
|
|
377
|
+
await startBranch({
|
|
378
|
+
root,
|
|
379
|
+
projectPath,
|
|
380
|
+
options: { name: "scoped", metric: "score" },
|
|
381
|
+
evalBody: 'test -f marker.txt\nprintf "METRIC score=1.5\\n"\n',
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
await commandExpRun({
|
|
385
|
+
positional: ["exp", "run"],
|
|
386
|
+
options: { "project-path": projectPath },
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
const lastRun = await readLastRun(root)
|
|
390
|
+
expect(lastRun?.projectPath).toBe(projectPath)
|
|
391
|
+
expect(lastRun?.primaryMetricValue).toBe(1.5)
|
|
392
|
+
})
|
|
393
|
+
} finally {
|
|
394
|
+
await rm(root, { recursive: true, force: true })
|
|
395
|
+
}
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
test("status reports pending outbox records and last run", async () => {
|
|
399
|
+
const root = await createGitRepo()
|
|
400
|
+
try {
|
|
401
|
+
await withCwd(root, async () => {
|
|
402
|
+
await startBranch({
|
|
403
|
+
root,
|
|
404
|
+
options: { name: "fast-eval", metric: "score" },
|
|
405
|
+
})
|
|
406
|
+
await commandExpRun({ positional: ["exp", "run"], options: {} })
|
|
407
|
+
await commandExpLog({
|
|
408
|
+
positional: ["exp", "log"],
|
|
409
|
+
options: { metric: "0.9" },
|
|
410
|
+
})
|
|
411
|
+
await commandExpRun({ positional: ["exp", "run"], options: {} })
|
|
412
|
+
|
|
413
|
+
const output = await captureLogs(() =>
|
|
414
|
+
commandStatus({ positional: ["status"], options: {} })
|
|
415
|
+
)
|
|
416
|
+
expect(output).toContain("1 experiment(s)")
|
|
417
|
+
expect(output).toContain("pending")
|
|
418
|
+
expect(output).toContain("last run:")
|
|
419
|
+
})
|
|
420
|
+
} finally {
|
|
421
|
+
await rm(root, { recursive: true, force: true })
|
|
422
|
+
}
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
test("readOutbox skips corrupt lines instead of throwing", async () => {
|
|
426
|
+
const root = await createGitRepo()
|
|
427
|
+
try {
|
|
428
|
+
await appendOutbox(root, {
|
|
429
|
+
schemaVersion: 1,
|
|
430
|
+
type: "branch_started",
|
|
431
|
+
createdAt: "2026-05-02T12:00:00.000Z",
|
|
432
|
+
name: "fast-eval",
|
|
433
|
+
gitBranchName: "onyx/fast-eval",
|
|
434
|
+
metricName: "score",
|
|
435
|
+
metricDirection: "maximize",
|
|
436
|
+
})
|
|
437
|
+
await writeFile(await outboxPath(root), "{ not json\n", { flag: "a" })
|
|
438
|
+
|
|
439
|
+
const { records, corrupt } = await readOutbox(root)
|
|
440
|
+
expect(records).toHaveLength(1)
|
|
441
|
+
expect(corrupt).toBe(1)
|
|
442
|
+
} finally {
|
|
443
|
+
await rm(root, { recursive: true, force: true })
|
|
444
|
+
}
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
test("packaged skills have valid metadata and use public CLI primitives", async () => {
|
|
448
|
+
const skillsDir = join(import.meta.dir, "..", "skills")
|
|
449
|
+
const skillNames = await readdir(skillsDir)
|
|
450
|
+
expect(skillNames.sort()).toEqual(["onyx"])
|
|
451
|
+
|
|
452
|
+
for (const skillName of skillNames) {
|
|
453
|
+
const skill = await readFile(
|
|
454
|
+
join(skillsDir, skillName, "SKILL.md"),
|
|
455
|
+
"utf8"
|
|
456
|
+
)
|
|
457
|
+
expect(skill).toMatch(/^---\n[\s\S]*\n---\n/)
|
|
458
|
+
expect(skill).toMatch(/\nname: [a-z0-9-]+\n/)
|
|
459
|
+
expect(skill).toMatch(/\ndescription: .+\n/)
|
|
460
|
+
expect(skill).not.toContain("remote-create")
|
|
461
|
+
expect(skill).not.toContain("remote-list")
|
|
462
|
+
expect(skill).not.toContain("onyx exp create")
|
|
463
|
+
expect(skill).not.toContain("onyx init")
|
|
464
|
+
expect(skill).toContain("onyx status")
|
|
465
|
+
expect(skill).toContain("onyx exp run")
|
|
466
|
+
expect(skill).toContain("onyx exp log")
|
|
467
|
+
}
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
test("package declares bundled agent skills", async () => {
|
|
471
|
+
const pkg = JSON.parse(
|
|
472
|
+
await readFile(join(import.meta.dir, "..", "package.json"), "utf8")
|
|
473
|
+
) as { pi?: { skills?: string[] }; files?: string[] }
|
|
474
|
+
|
|
475
|
+
expect(pkg.pi?.skills).toEqual(["./skills"])
|
|
476
|
+
expect(pkg.files).toContain("skills")
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
test("agent skill commands expose and install the bundled skill", async () => {
|
|
480
|
+
const root = await mkdtemp(join(tmpdir(), "onyx-agent-skill-test-"))
|
|
481
|
+
try {
|
|
482
|
+
const pathOutput = await captureLogs(() =>
|
|
483
|
+
commandAgent({ positional: ["agent", "skill-path"], options: {} })
|
|
484
|
+
)
|
|
485
|
+
expect(pathOutput).toContain("SKILL.md")
|
|
486
|
+
|
|
487
|
+
await installOnyxSkill({ dir: root, quiet: true })
|
|
488
|
+
const installed = await readFile(
|
|
489
|
+
join(root, "onyx", "SKILL.md"),
|
|
490
|
+
"utf8"
|
|
491
|
+
)
|
|
492
|
+
expect(installed).toContain("name: onyx")
|
|
493
|
+
|
|
494
|
+
const commandOutput = await captureLogs(() =>
|
|
495
|
+
commandAgent({
|
|
496
|
+
positional: ["agent", "install-skill"],
|
|
497
|
+
options: { dir: root, quiet: "true" },
|
|
498
|
+
})
|
|
499
|
+
)
|
|
500
|
+
expect(commandOutput).toBe("")
|
|
501
|
+
} finally {
|
|
502
|
+
await rm(root, { recursive: true, force: true })
|
|
503
|
+
}
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
test("last-run path lives in the git onyx state directory", async () => {
|
|
507
|
+
const root = await createGitRepo()
|
|
508
|
+
try {
|
|
509
|
+
const path = await lastRunPath(root)
|
|
510
|
+
expect(path).toContain(join(".git", "onyx", "last-run.json"))
|
|
511
|
+
} finally {
|
|
512
|
+
await rm(root, { recursive: true, force: true })
|
|
513
|
+
}
|
|
514
|
+
})
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
function historyRecord(
|
|
518
|
+
overrides: Partial<LocalResearchHistoryRecord> = {}
|
|
519
|
+
): LocalResearchHistoryRecord {
|
|
520
|
+
return {
|
|
521
|
+
schemaVersion: 1,
|
|
522
|
+
source: "local",
|
|
523
|
+
branchName: "fast-eval",
|
|
524
|
+
runRef: `local/fast-eval/${overrides.name ?? "fixture"}`,
|
|
525
|
+
commitSha: "abc1234",
|
|
526
|
+
status: "succeeded",
|
|
527
|
+
name: "experiment-abc1234",
|
|
528
|
+
primaryMetricName: "score",
|
|
529
|
+
primaryMetricValue: 0.9,
|
|
530
|
+
metrics: { score: 0.9 },
|
|
531
|
+
agentNotes: {},
|
|
532
|
+
createdAt: "2026-06-01T12:00:00.000Z",
|
|
533
|
+
...overrides,
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
describe("local history cache", () => {
|
|
538
|
+
test("exp log appends a provisional history record and emits events", async () => {
|
|
539
|
+
const root = await createGitRepo()
|
|
540
|
+
try {
|
|
541
|
+
await withCwd(root, async () => {
|
|
542
|
+
await startBranch({
|
|
543
|
+
root,
|
|
544
|
+
options: { name: "fast-eval", metric: "score" },
|
|
545
|
+
})
|
|
546
|
+
await commandExpRun({ positional: ["exp", "run"], options: {} })
|
|
547
|
+
await commandExpLog({
|
|
548
|
+
positional: ["exp", "log"],
|
|
549
|
+
options: { "agent-notes": '{"idea":"baseline"}' },
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
const { records } = await readHistory(root)
|
|
553
|
+
expect(records).toHaveLength(1)
|
|
554
|
+
const record = records[0]!
|
|
555
|
+
expect(record.source).toBe("local")
|
|
556
|
+
expect(record.branchName).toBe("fast-eval")
|
|
557
|
+
expect(record.status).toBe("succeeded")
|
|
558
|
+
expect(record.primaryMetricValue).toBe(0.9)
|
|
559
|
+
expect(record.agentNotes.idea).toBe("baseline")
|
|
560
|
+
|
|
561
|
+
const { records: outbox } = await readOutbox(root)
|
|
562
|
+
const logged = outbox.find((r) => r.type === "experiment_logged")
|
|
563
|
+
expect(record.runRef).toBe(logged!.runRef)
|
|
564
|
+
|
|
565
|
+
const eventTypes = (await readEvents(root)).map((event) => event.type)
|
|
566
|
+
expect(eventTypes).toContain("branch_created")
|
|
567
|
+
expect(eventTypes).toContain("exp_run_started")
|
|
568
|
+
expect(eventTypes).toContain("eval_finished")
|
|
569
|
+
expect(eventTypes).toContain("run_finished")
|
|
570
|
+
expect(eventTypes).toContain("exp_logged")
|
|
571
|
+
})
|
|
572
|
+
} finally {
|
|
573
|
+
await rm(root, { recursive: true, force: true })
|
|
574
|
+
}
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
test("readHistory skips corrupt lines instead of throwing", async () => {
|
|
578
|
+
const root = await createGitRepo()
|
|
579
|
+
try {
|
|
580
|
+
await appendHistory(root, historyRecord())
|
|
581
|
+
await writeFile(await historyPath(root), "{ not json\n", { flag: "a" })
|
|
582
|
+
|
|
583
|
+
const { records, corrupt } = await readHistory(root)
|
|
584
|
+
expect(records).toHaveLength(1)
|
|
585
|
+
expect(corrupt).toBe(1)
|
|
586
|
+
} finally {
|
|
587
|
+
await rm(root, { recursive: true, force: true })
|
|
588
|
+
}
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
test("mergeHistory dedupes by runRef, canonical winning, branch-grouped", () => {
|
|
592
|
+
const canonical = [
|
|
593
|
+
historyRecord({
|
|
594
|
+
source: "api",
|
|
595
|
+
runRef: "local/fast-eval/a",
|
|
596
|
+
status: "accepted",
|
|
597
|
+
sequenceNumber: 2,
|
|
598
|
+
name: "exp-a",
|
|
599
|
+
}),
|
|
600
|
+
historyRecord({
|
|
601
|
+
source: "api",
|
|
602
|
+
runRef: "local/fast-eval/b",
|
|
603
|
+
sequenceNumber: 1,
|
|
604
|
+
name: "exp-b",
|
|
605
|
+
}),
|
|
606
|
+
// A branch created on another machine appears without conflict.
|
|
607
|
+
historyRecord({
|
|
608
|
+
source: "api",
|
|
609
|
+
branchName: "other-machine",
|
|
610
|
+
runRef: "local/other-machine/c",
|
|
611
|
+
sequenceNumber: 1,
|
|
612
|
+
name: "exp-c",
|
|
613
|
+
}),
|
|
614
|
+
]
|
|
615
|
+
const localPending = [
|
|
616
|
+
// Same runRef as a canonical record: canonical status wins.
|
|
617
|
+
historyRecord({ runRef: "local/fast-eval/a", status: "succeeded" }),
|
|
618
|
+
// Unflushed local record: preserved, sorted after sequenced rows.
|
|
619
|
+
historyRecord({
|
|
620
|
+
runRef: "local/fast-eval/z",
|
|
621
|
+
name: "exp-z",
|
|
622
|
+
createdAt: "2026-06-02T08:00:00.000Z",
|
|
623
|
+
}),
|
|
624
|
+
]
|
|
625
|
+
|
|
626
|
+
const merged = mergeHistory(canonical, localPending)
|
|
627
|
+
expect(merged.map((record) => record.runRef)).toEqual([
|
|
628
|
+
"local/fast-eval/b",
|
|
629
|
+
"local/fast-eval/a",
|
|
630
|
+
"local/fast-eval/z",
|
|
631
|
+
"local/other-machine/c",
|
|
632
|
+
])
|
|
633
|
+
expect(
|
|
634
|
+
merged.find((record) => record.runRef === "local/fast-eval/a")?.status
|
|
635
|
+
).toBe("accepted")
|
|
636
|
+
expect(
|
|
637
|
+
merged.find((record) => record.runRef === "local/fast-eval/z")?.source
|
|
638
|
+
).toBe("local")
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
test("flush write-back stamps server sequence numbers onto history", async () => {
|
|
642
|
+
const root = await createGitRepo()
|
|
643
|
+
try {
|
|
644
|
+
await appendHistory(
|
|
645
|
+
root,
|
|
646
|
+
historyRecord({ runRef: "local/fast-eval/a", name: "exp-a" })
|
|
647
|
+
)
|
|
648
|
+
await appendHistory(
|
|
649
|
+
root,
|
|
650
|
+
historyRecord({ runRef: "local/fast-eval/b", name: "exp-b" })
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
await applyHistorySyncUpdates(
|
|
654
|
+
root,
|
|
655
|
+
new Map([
|
|
656
|
+
[
|
|
657
|
+
"local/fast-eval/a",
|
|
658
|
+
{
|
|
659
|
+
sequenceNumber: 7,
|
|
660
|
+
experimentId: "3e2c5a31-94a1-4be4-9f74-1de4e9f4d8aa",
|
|
661
|
+
branchId: "9b8d6e42-7c15-4f7a-8a3b-2fe6c7a9d1bb",
|
|
662
|
+
},
|
|
663
|
+
],
|
|
664
|
+
])
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
const { records } = await readHistory(root)
|
|
668
|
+
const stamped = records.find((r) => r.runRef === "local/fast-eval/a")!
|
|
669
|
+
expect(stamped.sequenceNumber).toBe(7)
|
|
670
|
+
expect(stamped.source).toBe("api")
|
|
671
|
+
expect(stamped.experimentId).toBe("3e2c5a31-94a1-4be4-9f74-1de4e9f4d8aa")
|
|
672
|
+
// Unmatched records stay provisional.
|
|
673
|
+
const untouched = records.find((r) => r.runRef === "local/fast-eval/b")!
|
|
674
|
+
expect(untouched.sequenceNumber).toBeUndefined()
|
|
675
|
+
expect(untouched.source).toBe("local")
|
|
676
|
+
} finally {
|
|
677
|
+
await rm(root, { recursive: true, force: true })
|
|
678
|
+
}
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
test("exp list filters by branch, status, and grep", async () => {
|
|
682
|
+
const root = await createGitRepo()
|
|
683
|
+
try {
|
|
684
|
+
await withCwd(root, async () => {
|
|
685
|
+
await appendHistory(
|
|
686
|
+
root,
|
|
687
|
+
historyRecord({
|
|
688
|
+
runRef: "local/fast-eval/1",
|
|
689
|
+
name: "tune-cache",
|
|
690
|
+
agentNotes: { idea: "memoize hot path" },
|
|
691
|
+
createdAt: "2026-06-01T10:00:00.000Z",
|
|
692
|
+
})
|
|
693
|
+
)
|
|
694
|
+
await appendHistory(
|
|
695
|
+
root,
|
|
696
|
+
historyRecord({
|
|
697
|
+
runRef: "local/fast-eval/2",
|
|
698
|
+
name: "wider-batch",
|
|
699
|
+
status: "failed",
|
|
700
|
+
primaryMetricValue: null,
|
|
701
|
+
createdAt: "2026-06-01T11:00:00.000Z",
|
|
702
|
+
})
|
|
703
|
+
)
|
|
704
|
+
await appendHistory(
|
|
705
|
+
root,
|
|
706
|
+
historyRecord({
|
|
707
|
+
branchName: "other-branch",
|
|
708
|
+
runRef: "local/other-branch/3",
|
|
709
|
+
name: "other-idea",
|
|
710
|
+
createdAt: "2026-06-01T12:00:00.000Z",
|
|
711
|
+
})
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
const all = await captureLogs(() =>
|
|
715
|
+
commandExpList({ positional: ["exp", "list"], options: {} })
|
|
716
|
+
)
|
|
717
|
+
expect(all).toContain("tune-cache")
|
|
718
|
+
expect(all).toContain("wider-batch")
|
|
719
|
+
expect(all).toContain("other-idea")
|
|
720
|
+
// Newest first.
|
|
721
|
+
expect(all.indexOf("other-idea")).toBeLessThan(
|
|
722
|
+
all.indexOf("tune-cache")
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
const byBranch = await captureLogs(() =>
|
|
726
|
+
commandExpList({
|
|
727
|
+
positional: ["exp", "list"],
|
|
728
|
+
options: { branch: "fast-eval" },
|
|
729
|
+
})
|
|
730
|
+
)
|
|
731
|
+
expect(byBranch).toContain("tune-cache")
|
|
732
|
+
expect(byBranch).not.toContain("other-idea")
|
|
733
|
+
|
|
734
|
+
const byStatus = await captureLogs(() =>
|
|
735
|
+
commandExpList({
|
|
736
|
+
positional: ["exp", "list"],
|
|
737
|
+
options: { status: "failed" },
|
|
738
|
+
})
|
|
739
|
+
)
|
|
740
|
+
expect(byStatus).toContain("wider-batch")
|
|
741
|
+
expect(byStatus).not.toContain("tune-cache")
|
|
742
|
+
|
|
743
|
+
// Grep matches agent notes, case-insensitively.
|
|
744
|
+
const byGrep = await captureLogs(() =>
|
|
745
|
+
commandExpList({
|
|
746
|
+
positional: ["exp", "list"],
|
|
747
|
+
options: { grep: "MEMOIZE|cache" },
|
|
748
|
+
})
|
|
749
|
+
)
|
|
750
|
+
expect(byGrep).toContain("tune-cache")
|
|
751
|
+
expect(byGrep).not.toContain("wider-batch")
|
|
752
|
+
|
|
753
|
+
const asJson = await captureLogs(() =>
|
|
754
|
+
commandExpList({
|
|
755
|
+
positional: ["exp", "list"],
|
|
756
|
+
options: { json: "true", limit: "2" },
|
|
757
|
+
})
|
|
758
|
+
)
|
|
759
|
+
const parsed = JSON.parse(asJson) as LocalResearchHistoryRecord[]
|
|
760
|
+
expect(parsed).toHaveLength(2)
|
|
761
|
+
expect(parsed[0]!.runRef).toBe("local/other-branch/3")
|
|
762
|
+
|
|
763
|
+
await expect(
|
|
764
|
+
commandExpList({
|
|
765
|
+
positional: ["exp", "list"],
|
|
766
|
+
options: { grep: "(" },
|
|
767
|
+
})
|
|
768
|
+
).rejects.toThrow("--grep is not a valid regex")
|
|
769
|
+
})
|
|
770
|
+
} finally {
|
|
771
|
+
await rm(root, { recursive: true, force: true })
|
|
772
|
+
}
|
|
773
|
+
})
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
describe("listen TUI rendering", () => {
|
|
777
|
+
const NOW = Date.parse("2026-06-01T12:05:00.000Z")
|
|
778
|
+
|
|
779
|
+
test("formatAge renders compact relative times", () => {
|
|
780
|
+
expect(formatAge("2026-06-01T12:04:58.000Z", NOW)).toBe("now")
|
|
781
|
+
expect(formatAge("2026-06-01T12:04:18.000Z", NOW)).toBe("42s")
|
|
782
|
+
expect(formatAge("2026-06-01T12:00:00.000Z", NOW)).toBe("5m")
|
|
783
|
+
expect(formatAge("2026-06-01T10:05:00.000Z", NOW)).toBe("2h")
|
|
784
|
+
expect(formatAge("2026-05-29T12:05:00.000Z", NOW)).toBe("3d")
|
|
785
|
+
expect(formatAge(null, NOW)).toBe("—")
|
|
786
|
+
})
|
|
787
|
+
|
|
788
|
+
test("renderFrame draws a titled rounded box with newest at the bottom", () => {
|
|
789
|
+
const lines = renderFrame(
|
|
790
|
+
{
|
|
791
|
+
projectName: "demo-repo",
|
|
792
|
+
branchName: "onyx/fast-eval",
|
|
793
|
+
metricName: "score",
|
|
794
|
+
metricUnit: null,
|
|
795
|
+
metricDirection: "maximize",
|
|
796
|
+
bestValue: 0.92,
|
|
797
|
+
activity: "running eval · abc1234 · 12s",
|
|
798
|
+
rows: [
|
|
799
|
+
historyRecord({
|
|
800
|
+
sequenceNumber: 1,
|
|
801
|
+
name: "baseline",
|
|
802
|
+
status: "failed",
|
|
803
|
+
primaryMetricValue: null,
|
|
804
|
+
description: "first attempt",
|
|
805
|
+
}),
|
|
806
|
+
historyRecord({
|
|
807
|
+
sequenceNumber: 2,
|
|
808
|
+
name: "tune-cache",
|
|
809
|
+
description: "memoize the hot path",
|
|
810
|
+
}),
|
|
811
|
+
],
|
|
812
|
+
active: true,
|
|
813
|
+
pendingOutbox: 2,
|
|
814
|
+
syncedCount: 11,
|
|
815
|
+
},
|
|
816
|
+
{ columns: 100, rows: 24, nowMs: NOW }
|
|
817
|
+
).map(stripAnsi)
|
|
818
|
+
|
|
819
|
+
// A blank line above the box, then the ONYX | repo | branch title
|
|
820
|
+
// interrupting the top border on the left; best metric on the right.
|
|
821
|
+
expect(lines[0]).toBe("")
|
|
822
|
+
expect(lines[1]).toMatch(
|
|
823
|
+
/^╭─ ONYX \| demo-repo \| onyx\/fast-eval ─+─ best 0\.92 ↑ score ─╮$/
|
|
824
|
+
)
|
|
825
|
+
// A blank box line separates the title border from the column header.
|
|
826
|
+
expect(lines[2]).toMatch(/^│ +│$/)
|
|
827
|
+
expect(lines.at(-4)).toMatch(/^╰─+╯$/)
|
|
828
|
+
// Activity sits below the box; a blank line separates it from the footer.
|
|
829
|
+
expect(lines.at(-3)).toContain("running eval · abc1234 · 12s")
|
|
830
|
+
expect(lines.at(-2)).toBe("")
|
|
831
|
+
expect(lines.at(-1)).toContain("outbox 2 pending · 11 synced · q quit")
|
|
832
|
+
|
|
833
|
+
const body = lines.join("\n")
|
|
834
|
+
// Tree-view column vocabulary: glyphs, lowercase labels, value-only metric.
|
|
835
|
+
expect(body).toContain("NAME")
|
|
836
|
+
expect(body).toContain("DESCRIPTION")
|
|
837
|
+
expect(body).toContain("CREATED")
|
|
838
|
+
expect(body).toContain("✗")
|
|
839
|
+
expect(body).toContain("failed")
|
|
840
|
+
expect(body).toContain("•")
|
|
841
|
+
expect(body).toMatch(/ok\s+│/)
|
|
842
|
+
expect(body).toContain("memoize the hot path")
|
|
843
|
+
expect(body).toContain("#2")
|
|
844
|
+
// Most recent experiment renders below the older one.
|
|
845
|
+
expect(body.indexOf("baseline")).toBeLessThan(body.indexOf("tune-cache"))
|
|
846
|
+
// Table rows are framed by the box border.
|
|
847
|
+
expect(lines[3]).toMatch(/^│ .*│$/)
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
test("renderFrame clips every line to the terminal width", () => {
|
|
851
|
+
const lines = renderFrame(
|
|
852
|
+
{
|
|
853
|
+
projectName: "a-rather-long-project-name",
|
|
854
|
+
branchName: "onyx/a-long-branch-name",
|
|
855
|
+
metricName: "integral_abs_error",
|
|
856
|
+
metricUnit: "error",
|
|
857
|
+
metricDirection: "minimize",
|
|
858
|
+
bestValue: 0.9084,
|
|
859
|
+
activity: null,
|
|
860
|
+
rows: [
|
|
861
|
+
historyRecord({
|
|
862
|
+
name: "an-experiment-with-a-very-long-descriptive-name",
|
|
863
|
+
primaryMetricName: "integral_abs_error",
|
|
864
|
+
primaryMetricValue: 0.9084,
|
|
865
|
+
}),
|
|
866
|
+
],
|
|
867
|
+
active: true,
|
|
868
|
+
pendingOutbox: 0,
|
|
869
|
+
syncedCount: 0,
|
|
870
|
+
},
|
|
871
|
+
{ columns: 60, rows: 24, nowMs: NOW }
|
|
872
|
+
)
|
|
873
|
+
for (const line of lines) {
|
|
874
|
+
expect(stripAnsi(line).length).toBeLessThanOrEqual(60)
|
|
875
|
+
}
|
|
876
|
+
// The metric value column survives narrow widths.
|
|
877
|
+
expect(stripAnsi(lines.join("\n"))).toContain("0.9084")
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
test("activity spinner orbits the braille square clockwise", () => {
|
|
881
|
+
// One full revolution: the notch sweeps top-left → top-right → down the
|
|
882
|
+
// right side → bottom-right → bottom-left → up the left side.
|
|
883
|
+
const frames = Array.from({ length: 8 }, (_, i) => spinnerChar(i * 120))
|
|
884
|
+
expect(frames).toEqual(["⣾", "⣷", "⣯", "⣟", "⡿", "⢿", "⣻", "⣽"])
|
|
885
|
+
expect(spinnerChar(8 * 120)).toBe("⣾") // wraps around
|
|
886
|
+
|
|
887
|
+
const model = {
|
|
888
|
+
projectName: "demo-repo",
|
|
889
|
+
branchName: "onyx/fast-eval",
|
|
890
|
+
metricName: "score",
|
|
891
|
+
metricUnit: null,
|
|
892
|
+
metricDirection: "maximize" as const,
|
|
893
|
+
bestValue: null,
|
|
894
|
+
activity: "running eval",
|
|
895
|
+
rows: [],
|
|
896
|
+
active: true,
|
|
897
|
+
pendingOutbox: 0,
|
|
898
|
+
syncedCount: 0,
|
|
899
|
+
}
|
|
900
|
+
const live = renderFrame(model, { columns: 80, rows: 24, nowMs: NOW }).map(
|
|
901
|
+
stripAnsi
|
|
902
|
+
)
|
|
903
|
+
// The activity line leads with the current spinner frame and the label.
|
|
904
|
+
expect(live.at(-3)).toBe(
|
|
905
|
+
` ${spinnerChar(NOW)} Research Agent: running eval`
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
// Idle sessions show the full square instead of animating.
|
|
909
|
+
const idle = renderFrame(
|
|
910
|
+
{ ...model, active: false },
|
|
911
|
+
{ columns: 80, rows: 24, nowMs: NOW }
|
|
912
|
+
).map(stripAnsi)
|
|
913
|
+
expect(idle.at(-3)).toBe(" ⣿ Research Agent: running eval")
|
|
914
|
+
})
|
|
915
|
+
|
|
916
|
+
test("renderFrame shows duration and created columns like the tree view", () => {
|
|
917
|
+
const lines = renderFrame(
|
|
918
|
+
{
|
|
919
|
+
projectName: "demo-repo",
|
|
920
|
+
branchName: "onyx/fast-eval",
|
|
921
|
+
metricName: "score",
|
|
922
|
+
metricUnit: null,
|
|
923
|
+
metricDirection: "maximize",
|
|
924
|
+
bestValue: 0.9,
|
|
925
|
+
activity: null,
|
|
926
|
+
rows: [
|
|
927
|
+
historyRecord({
|
|
928
|
+
sequenceNumber: 1,
|
|
929
|
+
startedAt: "2026-06-01T12:00:00.000Z",
|
|
930
|
+
completedAt: "2026-06-01T12:00:02.000Z",
|
|
931
|
+
}),
|
|
932
|
+
],
|
|
933
|
+
active: true,
|
|
934
|
+
pendingOutbox: 0,
|
|
935
|
+
syncedCount: 1,
|
|
936
|
+
},
|
|
937
|
+
{ columns: 130, rows: 24, nowMs: NOW }
|
|
938
|
+
).map(stripAnsi)
|
|
939
|
+
|
|
940
|
+
const body = lines.join("\n")
|
|
941
|
+
expect(body).toContain("DURATION")
|
|
942
|
+
expect(body).toContain("2.0000s")
|
|
943
|
+
// Created column renders MM-DD HH:mm:ss.
|
|
944
|
+
expect(body).toMatch(/\d{2}-\d{2} \d{2}:\d{2}:\d{2}/)
|
|
945
|
+
})
|
|
946
|
+
|
|
947
|
+
test("usage covers the new history and listen commands", () => {
|
|
948
|
+
expect(USAGE).toContain("onyx exp list")
|
|
949
|
+
expect(USAGE).toContain("onyx listen")
|
|
950
|
+
expect(USAGE).toContain("history.jsonl")
|
|
951
|
+
})
|
|
952
|
+
})
|