pi-minimal 0.4.0 → 0.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/CHANGELOG.md +81 -1
- package/CONTEXT.md +107 -0
- package/README.md +9 -6
- package/dist/background-overlay.js +95 -0
- package/dist/background.js +206 -0
- package/dist/config.js +10 -1
- package/dist/cwd.js +2 -2
- package/dist/edit-diff.js +74 -0
- package/dist/footer-contribution-events.js +5 -3
- package/dist/footer-contributions.js +15 -13
- package/dist/footer.js +15 -3
- package/dist/git-state.js +31 -11
- package/dist/herdr.js +78 -0
- package/dist/index.js +79 -202
- package/dist/listeners.js +23 -0
- package/dist/prs-command.js +76 -0
- package/dist/prs.js +270 -0
- package/dist/reclaim.js +0 -3
- package/dist/rex.js +289 -0
- package/dist/session.js +168 -0
- package/dist/siblings.js +14 -0
- package/dist/tools.js +213 -374
- package/dist/tracker.js +2 -2
- package/dist/working-status.js +2 -1
- package/doc/archive/README.md +3 -0
- package/doc/footer-contributions.md +7 -7
- package/package.json +5 -3
- package/release-notes.md +33 -12
- package/src/background-overlay.ts +103 -0
- package/src/background.ts +250 -0
- package/src/config.ts +10 -1
- package/src/cwd.ts +5 -4
- package/src/edit-diff.ts +73 -0
- package/src/footer-contribution-events.ts +7 -4
- package/src/footer-contributions.ts +15 -13
- package/src/footer.ts +23 -3
- package/src/git-state.ts +29 -10
- package/src/herdr.ts +78 -5
- package/src/index.ts +81 -195
- package/src/listeners.ts +22 -0
- package/src/prs-command.ts +81 -0
- package/src/prs.ts +348 -0
- package/src/reclaim.ts +0 -4
- package/src/rex.ts +304 -0
- package/src/session.ts +185 -0
- package/src/siblings.ts +38 -0
- package/src/tools.ts +261 -389
- package/src/tracker.ts +11 -5
- package/src/working-status.ts +2 -1
- package/test/background.test.ts +198 -0
- package/test/config.test.ts +24 -11
- package/test/footer-composition.test.ts +61 -0
- package/test/footer-contribution-events.test.ts +15 -0
- package/test/git-state.test.ts +35 -0
- package/test/index.test.ts +202 -4
- package/test/prs.test.ts +272 -0
- package/test/rex.test.ts +249 -0
- package/test/tools.test.ts +36 -3
- package/test/working-status.test.ts +53 -0
- /package/doc/{plans/2026-09-22-pi-minimal-package.md → archive/2026-09-22-plan-pi-minimal-package.md} +0 -0
- /package/doc/{specs/2026-09-22-pi-minimal-package.md → archive/2026-09-22-spec-pi-minimal-package.md} +0 -0
package/test/index.test.ts
CHANGED
|
@@ -17,11 +17,17 @@ type SessionMode = "tui" | "rpc" | "json" | "print"
|
|
|
17
17
|
|
|
18
18
|
const AGENT_DIR_ENV = "PI_CODING_AGENT_DIR"
|
|
19
19
|
const originalHerdrEnv = process.env.HERDR_ENV
|
|
20
|
+
const originalRexSession = process.env.REX_SESSION
|
|
21
|
+
const originalRexBlock = process.env.REX_BLOCK
|
|
20
22
|
const originalAgentDir = process.env[AGENT_DIR_ENV]
|
|
21
23
|
|
|
22
24
|
function restoreEnv() {
|
|
23
25
|
if (originalHerdrEnv === undefined) delete process.env.HERDR_ENV
|
|
24
26
|
else process.env.HERDR_ENV = originalHerdrEnv
|
|
27
|
+
if (originalRexSession === undefined) delete process.env.REX_SESSION
|
|
28
|
+
else process.env.REX_SESSION = originalRexSession
|
|
29
|
+
if (originalRexBlock === undefined) delete process.env.REX_BLOCK
|
|
30
|
+
else process.env.REX_BLOCK = originalRexBlock
|
|
25
31
|
if (originalAgentDir === undefined) delete process.env[AGENT_DIR_ENV]
|
|
26
32
|
else process.env[AGENT_DIR_ENV] = originalAgentDir
|
|
27
33
|
}
|
|
@@ -90,6 +96,9 @@ function createFake(options: {
|
|
|
90
96
|
if (command === "herdr" && args[0] === "agent") {
|
|
91
97
|
return { stdout: JSON.stringify({ result: { agents: [] } }), stderr: "", code: 0, killed: false }
|
|
92
98
|
}
|
|
99
|
+
if (command === "gh") {
|
|
100
|
+
return { stdout: "", stderr: "not a github repo", code: 1, killed: false }
|
|
101
|
+
}
|
|
93
102
|
if (command === "herdr" && args[0] === "tab") {
|
|
94
103
|
return { stdout: JSON.stringify({ result: { tabs: [] } }), stderr: "", code: 0, killed: false }
|
|
95
104
|
}
|
|
@@ -98,6 +107,12 @@ function createFake(options: {
|
|
|
98
107
|
registerTool(...args: unknown[]) {
|
|
99
108
|
record(registerCalls, "registerTool", args)
|
|
100
109
|
},
|
|
110
|
+
registerCommand(...args: unknown[]) {
|
|
111
|
+
record(registerCalls, "registerCommand", args)
|
|
112
|
+
},
|
|
113
|
+
registerShortcut(...args: unknown[]) {
|
|
114
|
+
record(registerCalls, "registerShortcut", args)
|
|
115
|
+
},
|
|
101
116
|
registerToolRenderer(...args: unknown[]) {
|
|
102
117
|
record(registerCalls, "registerToolRenderer", args)
|
|
103
118
|
},
|
|
@@ -132,10 +147,12 @@ function herdrProbes(execCalls: Call[]): Call[] {
|
|
|
132
147
|
}
|
|
133
148
|
|
|
134
149
|
test.describe("session chrome", { concurrency: 1 }, () => {
|
|
135
|
-
test("TUI session_start installs chrome and
|
|
150
|
+
test("TUI session_start installs chrome and leaves Gauntlet widgets alone by default", async (t) => {
|
|
136
151
|
const { agentDir, cwd } = setupDirs(t)
|
|
137
152
|
process.env[AGENT_DIR_ENV] = agentDir
|
|
138
153
|
delete process.env.HERDR_ENV
|
|
154
|
+
delete process.env.REX_SESSION
|
|
155
|
+
delete process.env.REX_BLOCK
|
|
139
156
|
t.after(restoreEnv)
|
|
140
157
|
|
|
141
158
|
const hideThinking = t.mock.method(SettingsManager.prototype, "setHideThinkingBlock")
|
|
@@ -155,8 +172,8 @@ test.describe("session chrome", { concurrency: 1 }, () => {
|
|
|
155
172
|
(call) => typeof call.args[1] === "function" || (call.args[1] !== undefined && call.args[1] !== null),
|
|
156
173
|
),
|
|
157
174
|
)
|
|
158
|
-
assert.
|
|
159
|
-
assert.
|
|
175
|
+
assert.equal(widgetCalls(uiCalls, "phase_tracker").length, 0)
|
|
176
|
+
assert.equal(widgetCalls(uiCalls, "plan_tracker").length, 0)
|
|
160
177
|
assert.equal(
|
|
161
178
|
allCalls.some((call) => call.method === "setHideThinkingBlock"),
|
|
162
179
|
false,
|
|
@@ -172,6 +189,8 @@ test.describe("session chrome", { concurrency: 1 }, () => {
|
|
|
172
189
|
const { agentDir, cwd } = setupDirs(t)
|
|
173
190
|
process.env[AGENT_DIR_ENV] = agentDir
|
|
174
191
|
delete process.env.HERDR_ENV
|
|
192
|
+
delete process.env.REX_SESSION
|
|
193
|
+
delete process.env.REX_BLOCK
|
|
175
194
|
t.after(restoreEnv)
|
|
176
195
|
|
|
177
196
|
const { pi, allCalls, emit } = createFake({ cwd })
|
|
@@ -197,7 +216,29 @@ test.describe("session chrome", { concurrency: 1 }, () => {
|
|
|
197
216
|
assert.ok(firstHeader < firstReclaim, "header before reclaim")
|
|
198
217
|
assert.ok(firstFooter < firstReclaim, "footer before reclaim")
|
|
199
218
|
assert.ok(firstCwd < firstReclaim, "cwd before reclaim")
|
|
200
|
-
assert.
|
|
219
|
+
assert.deepEqual(allCalls.filter((call) => call.method === "registerTool").map((call) => (call.args[0] as { name: string }).name), ["list_open_prs"], "only pi-minimal's own tool is registered")
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test("clears Gauntlet widgets on every chrome reassertion when hiding is opted in, and never on a timer", async (t) => {
|
|
223
|
+
const { agentDir, cwd } = setupDirs(t)
|
|
224
|
+
writeFileSync(join(agentDir, "pi-minimal.json"), JSON.stringify({ hideGauntletWidgets: true }))
|
|
225
|
+
process.env[AGENT_DIR_ENV] = agentDir
|
|
226
|
+
delete process.env.HERDR_ENV
|
|
227
|
+
delete process.env.REX_SESSION
|
|
228
|
+
delete process.env.REX_BLOCK
|
|
229
|
+
t.after(restoreEnv)
|
|
230
|
+
|
|
231
|
+
const { pi, uiCalls, emit } = createFake({ cwd, projectTrusted: true })
|
|
232
|
+
install(pi)
|
|
233
|
+
t.after(() => emit("session_shutdown"))
|
|
234
|
+
await emit("session_start")
|
|
235
|
+
await new Promise(resolve => setTimeout(resolve, 60))
|
|
236
|
+
const afterSettle = widgetCalls(uiCalls, "phase_tracker").length
|
|
237
|
+
assert.ok(afterSettle >= 1, "hidden at session_start")
|
|
238
|
+
await new Promise(resolve => setTimeout(resolve, 450))
|
|
239
|
+
assert.equal(widgetCalls(uiCalls, "phase_tracker").length, afterSettle, "no steal interval")
|
|
240
|
+
await emit("turn_end")
|
|
241
|
+
assert.equal(widgetCalls(uiCalls, "phase_tracker").length, afterSettle + 1, "hidden again on turn_end")
|
|
201
242
|
})
|
|
202
243
|
|
|
203
244
|
test("does not clear Gauntlet widgets when config disables hiding", async (t) => {
|
|
@@ -205,6 +246,8 @@ test.describe("session chrome", { concurrency: 1 }, () => {
|
|
|
205
246
|
writeFileSync(join(agentDir, "pi-minimal.json"), JSON.stringify({ hideGauntletWidgets: false }))
|
|
206
247
|
process.env[AGENT_DIR_ENV] = agentDir
|
|
207
248
|
delete process.env.HERDR_ENV
|
|
249
|
+
delete process.env.REX_SESSION
|
|
250
|
+
delete process.env.REX_BLOCK
|
|
208
251
|
t.after(restoreEnv)
|
|
209
252
|
|
|
210
253
|
const { pi, uiCalls, emit } = createFake({ cwd, projectTrusted: true })
|
|
@@ -223,6 +266,8 @@ test.describe("session chrome", { concurrency: 1 }, () => {
|
|
|
223
266
|
const { agentDir, cwd } = setupDirs(t)
|
|
224
267
|
process.env[AGENT_DIR_ENV] = agentDir
|
|
225
268
|
delete process.env.HERDR_ENV
|
|
269
|
+
delete process.env.REX_SESSION
|
|
270
|
+
delete process.env.REX_BLOCK
|
|
226
271
|
t.after(restoreEnv)
|
|
227
272
|
|
|
228
273
|
const { pi, execCalls, emit } = createFake({ cwd })
|
|
@@ -241,6 +286,8 @@ test.describe("session chrome", { concurrency: 1 }, () => {
|
|
|
241
286
|
const { agentDir, cwd } = setupDirs(t)
|
|
242
287
|
process.env[AGENT_DIR_ENV] = agentDir
|
|
243
288
|
process.env.HERDR_ENV = "1"
|
|
289
|
+
delete process.env.REX_SESSION
|
|
290
|
+
delete process.env.REX_BLOCK
|
|
244
291
|
t.after(restoreEnv)
|
|
245
292
|
|
|
246
293
|
const { pi, execCalls, emit } = createFake({ cwd })
|
|
@@ -249,12 +296,135 @@ test.describe("session chrome", { concurrency: 1 }, () => {
|
|
|
249
296
|
await emit("session_start")
|
|
250
297
|
|
|
251
298
|
assert.equal(herdrProbes(execCalls).length, 1)
|
|
299
|
+
assert.equal(execCalls.filter((call) => call.args[0] === "rex").length, 0)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
test("inside Rex (REX_SESSION + REX_BLOCK) it reads the Rex session instead of herdr", async (t) => {
|
|
303
|
+
const { agentDir, cwd } = setupDirs(t)
|
|
304
|
+
process.env[AGENT_DIR_ENV] = agentDir
|
|
305
|
+
delete process.env.HERDR_ENV
|
|
306
|
+
process.env.REX_SESSION = "session:test"
|
|
307
|
+
process.env.REX_BLOCK = "block:self"
|
|
308
|
+
t.after(restoreEnv)
|
|
309
|
+
|
|
310
|
+
const { pi, execCalls, emit } = createFake({ cwd })
|
|
311
|
+
install(pi)
|
|
312
|
+
t.after(() => emit("session_shutdown"))
|
|
313
|
+
await emit("session_start")
|
|
314
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
315
|
+
|
|
316
|
+
assert.equal(herdrProbes(execCalls).length, 0)
|
|
317
|
+
assert.deepEqual(
|
|
318
|
+
execCalls.filter((call) => call.args[0] === "rex").map((call) => call.args[1]),
|
|
319
|
+
[["window", "ls", "-s", "session:test", "--json"]],
|
|
320
|
+
)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
test("Herdr wins when both Herdr and Rex are present", async (t) => {
|
|
324
|
+
const { agentDir, cwd } = setupDirs(t)
|
|
325
|
+
process.env[AGENT_DIR_ENV] = agentDir
|
|
326
|
+
process.env.HERDR_ENV = "1"
|
|
327
|
+
process.env.REX_SESSION = "session:test"
|
|
328
|
+
process.env.REX_BLOCK = "block:self"
|
|
329
|
+
t.after(restoreEnv)
|
|
330
|
+
|
|
331
|
+
const { pi, execCalls, emit } = createFake({ cwd })
|
|
332
|
+
install(pi)
|
|
333
|
+
t.after(() => emit("session_shutdown"))
|
|
334
|
+
await emit("session_start")
|
|
335
|
+
|
|
336
|
+
assert.equal(herdrProbes(execCalls).length, 1)
|
|
337
|
+
assert.equal(execCalls.filter((call) => call.args[0] === "rex").length, 0)
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
test("registers /bg and the background shortcut once, honouring backgroundShortcut", async (t) => {
|
|
341
|
+
const { agentDir, cwd } = setupDirs(t)
|
|
342
|
+
writeFileSync(join(agentDir, "pi-minimal.json"), JSON.stringify({ backgroundShortcut: "ctrl+g" }))
|
|
343
|
+
process.env[AGENT_DIR_ENV] = agentDir
|
|
344
|
+
delete process.env.HERDR_ENV
|
|
345
|
+
delete process.env.REX_SESSION
|
|
346
|
+
delete process.env.REX_BLOCK
|
|
347
|
+
t.after(restoreEnv)
|
|
348
|
+
|
|
349
|
+
const { pi, registerCalls, emit } = createFake({ cwd })
|
|
350
|
+
install(pi)
|
|
351
|
+
t.after(() => emit("session_shutdown"))
|
|
352
|
+
assert.ok(registerCalls.some((call) => call.method === "registerCommand" && call.args[0] === "bg"))
|
|
353
|
+
assert.equal(registerCalls.filter((call) => call.method === "registerShortcut").length, 0, "shortcut waits for session config")
|
|
354
|
+
await emit("session_start")
|
|
355
|
+
await emit("session_start")
|
|
356
|
+
const shortcuts = registerCalls.filter((call) => call.method === "registerShortcut")
|
|
357
|
+
assert.deepEqual(shortcuts.map((call) => call.args[0]), ["ctrl+g"])
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
test("a TUI session asks gh for the repo once and registers /prs", async (t) => {
|
|
361
|
+
const { agentDir, cwd } = setupDirs(t)
|
|
362
|
+
process.env[AGENT_DIR_ENV] = agentDir
|
|
363
|
+
delete process.env.HERDR_ENV
|
|
364
|
+
delete process.env.REX_SESSION
|
|
365
|
+
delete process.env.REX_BLOCK
|
|
366
|
+
t.after(restoreEnv)
|
|
367
|
+
|
|
368
|
+
const { pi, execCalls, registerCalls, emit } = createFake({ cwd })
|
|
369
|
+
install(pi)
|
|
370
|
+
t.after(() => emit("session_shutdown"))
|
|
371
|
+
assert.ok(registerCalls.some((call) => call.method === "registerCommand" && call.args[0] === "prs"))
|
|
372
|
+
await emit("session_start")
|
|
373
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
374
|
+
assert.deepEqual(execCalls.filter((call) => call.args[0] === "gh").map((call) => call.args[1]), [["repo", "view", "--json", "nameWithOwner,url"]])
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
test("a bash call running gh pr create records the PR URL as a session entry", async (t) => {
|
|
378
|
+
const { agentDir, cwd } = setupDirs(t)
|
|
379
|
+
process.env[AGENT_DIR_ENV] = agentDir
|
|
380
|
+
delete process.env.HERDR_ENV
|
|
381
|
+
delete process.env.REX_SESSION
|
|
382
|
+
delete process.env.REX_BLOCK
|
|
383
|
+
t.after(restoreEnv)
|
|
384
|
+
|
|
385
|
+
const { pi, execCalls, registerCalls, emit } = createFake({ cwd })
|
|
386
|
+
install(pi)
|
|
387
|
+
t.after(() => emit("session_shutdown"))
|
|
388
|
+
await emit("session_start")
|
|
389
|
+
await emit("tool_execution_start", { toolCallId: "b1", toolName: "bash", args: { command: "gh pr create --fill" } })
|
|
390
|
+
await emit("tool_execution_end", {
|
|
391
|
+
toolCallId: "b1", toolName: "bash", isError: false,
|
|
392
|
+
result: { content: [{ type: "text", text: "Creating pull request…\nhttps://github.com/raviy06/pi-minimal/pull/24\n" }] },
|
|
393
|
+
})
|
|
394
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
395
|
+
const entries = registerCalls.filter((call) => call.method === "appendEntry")
|
|
396
|
+
assert.deepEqual(entries.map((call) => call.args), [["pi-minimal.pr", { url: "https://github.com/raviy06/pi-minimal/pull/24" }]])
|
|
397
|
+
assert.ok(execCalls.some((call) => call.args[0] === "gh" && (call.args[1] as string[])[1] === "view"), "the PR is fetched by URL")
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
test("bash tool calls are inspected with ps; other tools are not", async (t) => {
|
|
401
|
+
const { agentDir, cwd } = setupDirs(t)
|
|
402
|
+
process.env[AGENT_DIR_ENV] = agentDir
|
|
403
|
+
delete process.env.HERDR_ENV
|
|
404
|
+
delete process.env.REX_SESSION
|
|
405
|
+
delete process.env.REX_BLOCK
|
|
406
|
+
t.after(restoreEnv)
|
|
407
|
+
|
|
408
|
+
const { pi, execCalls, emit } = createFake({ cwd })
|
|
409
|
+
install(pi)
|
|
410
|
+
t.after(() => emit("session_shutdown"))
|
|
411
|
+
await emit("session_start")
|
|
412
|
+
await emit("tool_execution_start", { toolCallId: "r1", toolName: "read" })
|
|
413
|
+
await emit("tool_execution_end", { toolCallId: "r1", toolName: "read" })
|
|
414
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
415
|
+
assert.equal(execCalls.filter((call) => call.args[0] === "ps").length, 0)
|
|
416
|
+
await emit("tool_execution_start", { toolCallId: "b1", toolName: "bash" })
|
|
417
|
+
await emit("tool_execution_end", { toolCallId: "b1", toolName: "bash" })
|
|
418
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
419
|
+
assert.equal(execCalls.filter((call) => call.args[0] === "ps").length, 2)
|
|
252
420
|
})
|
|
253
421
|
|
|
254
422
|
test("non-TUI modes install no TUI components", async (t) => {
|
|
255
423
|
const { agentDir, cwd } = setupDirs(t)
|
|
256
424
|
process.env[AGENT_DIR_ENV] = agentDir
|
|
257
425
|
delete process.env.HERDR_ENV
|
|
426
|
+
delete process.env.REX_SESSION
|
|
427
|
+
delete process.env.REX_BLOCK
|
|
258
428
|
t.after(restoreEnv)
|
|
259
429
|
|
|
260
430
|
const { pi, uiCalls, execCalls, emit } = createFake({ cwd, mode: "print" })
|
|
@@ -287,6 +457,8 @@ test("footer registration survives chrome reinstalls and becomes inert on shutdo
|
|
|
287
457
|
const { agentDir, cwd } = setupDirs(t)
|
|
288
458
|
process.env[AGENT_DIR_ENV] = agentDir
|
|
289
459
|
delete process.env.HERDR_ENV
|
|
460
|
+
delete process.env.REX_SESSION
|
|
461
|
+
delete process.env.REX_BLOCK
|
|
290
462
|
t.after(restoreEnv)
|
|
291
463
|
const { pi, emit, events, uiCalls } = createFake({ cwd })
|
|
292
464
|
install(pi)
|
|
@@ -312,6 +484,8 @@ test("agent_end appends a turn summary entry in TUI mode", async (t) => {
|
|
|
312
484
|
const { agentDir, cwd } = setupDirs(t)
|
|
313
485
|
process.env[AGENT_DIR_ENV] = agentDir
|
|
314
486
|
delete process.env.HERDR_ENV
|
|
487
|
+
delete process.env.REX_SESSION
|
|
488
|
+
delete process.env.REX_BLOCK
|
|
315
489
|
t.after(restoreEnv)
|
|
316
490
|
const { pi, registerCalls, emit } = createFake({ cwd })
|
|
317
491
|
install(pi)
|
|
@@ -329,3 +503,27 @@ test("agent_end appends a turn summary entry in TUI mode", async (t) => {
|
|
|
329
503
|
assert.equal(data.outcome, "completed")
|
|
330
504
|
assert.ok(registerCalls.some((c) => c.method === "registerEntryRenderer" && c.args[0] === "pi-minimal.turn"))
|
|
331
505
|
})
|
|
506
|
+
|
|
507
|
+
test("a second session_start disposes the first session's chrome and the stale one cannot draw", async (t) => {
|
|
508
|
+
const { agentDir, cwd } = setupDirs(t)
|
|
509
|
+
process.env[AGENT_DIR_ENV] = agentDir
|
|
510
|
+
delete process.env.HERDR_ENV
|
|
511
|
+
delete process.env.REX_SESSION
|
|
512
|
+
delete process.env.REX_BLOCK
|
|
513
|
+
t.after(restoreEnv)
|
|
514
|
+
const { pi, emit, events, uiCalls } = createFake({ cwd })
|
|
515
|
+
install(pi)
|
|
516
|
+
t.after(() => emit("session_shutdown"))
|
|
517
|
+
await emit("session_start")
|
|
518
|
+
const first: RegisterRequest = { version: 1, owner: "a", sessionId: "S1", slot: "activity" }
|
|
519
|
+
events.emit(FOOTER_EVENTS.register, first)
|
|
520
|
+
assert.ok(first.result?.ok)
|
|
521
|
+
await emit("session_start")
|
|
522
|
+
assert.equal(first.result.registration.publish({ revision: 1, rows: [], omitted: 0, motion: "static", format: "icons" }).ok, false, "old registration is revoked")
|
|
523
|
+
const second: RegisterRequest = { version: 1, owner: "a", sessionId: "S1", slot: "activity" }
|
|
524
|
+
events.emit(FOOTER_EVENTS.register, second)
|
|
525
|
+
assert.ok(second.result?.ok, "the new session accepts a fresh registration")
|
|
526
|
+
const count = uiCalls.length
|
|
527
|
+
await new Promise(resolve => setTimeout(resolve, 70))
|
|
528
|
+
assert.ok(uiCalls.length >= count, "settle callbacks of the live session still run")
|
|
529
|
+
})
|
package/test/prs.test.ts
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import test from "node:test"
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
4
|
+
import { stripTerminalSequences } from "@earendil-works/pi-tui"
|
|
5
|
+
import { filterPrs, parsePrFilter, prsToolResult } from "../src/prs-command.js"
|
|
6
|
+
import {
|
|
7
|
+
ciFailing,
|
|
8
|
+
createPrSource,
|
|
9
|
+
describePr,
|
|
10
|
+
findPrUrls,
|
|
11
|
+
isPrCreateCommand,
|
|
12
|
+
parsePrList,
|
|
13
|
+
parsePrView,
|
|
14
|
+
parseRepoView,
|
|
15
|
+
PR_LIST_FIELDS,
|
|
16
|
+
prChipLabel,
|
|
17
|
+
prColor,
|
|
18
|
+
prSegment,
|
|
19
|
+
prUrlRepo,
|
|
20
|
+
type PrRepo,
|
|
21
|
+
type PullRequest,
|
|
22
|
+
} from "../src/prs.js"
|
|
23
|
+
|
|
24
|
+
const repo: PrRepo = { nameWithOwner: "raviy06/pi-minimal", host: "github.com", url: "https://github.com/raviy06/pi-minimal" }
|
|
25
|
+
|
|
26
|
+
// Shapes from `gh pr list --json …` on github.com and on a GitHub Enterprise host.
|
|
27
|
+
const ghList = JSON.stringify([
|
|
28
|
+
{
|
|
29
|
+
number: 23, title: "feat: background processes", url: "https://github.com/raviy06/pi-minimal/pull/23", isDraft: false,
|
|
30
|
+
author: { login: "raviy06" }, reviewRequests: [], reviewDecision: "",
|
|
31
|
+
statusCheckRollup: [{ state: null, conclusion: "", status: "IN_PROGRESS" }], updatedAt: "2026-10-28T13:40:00Z",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
number: 22, title: "feat: Rex siblings", url: "https://github.com/raviy06/pi-minimal/pull/22", isDraft: true,
|
|
35
|
+
author: { login: "raviy06" }, reviewRequests: [], reviewDecision: "",
|
|
36
|
+
statusCheckRollup: [{ state: null, conclusion: "SUCCESS", status: "COMPLETED" }], updatedAt: "2026-10-28T13:30:00Z",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
number: 21, title: "docs: glossary", url: "https://github.com/raviy06/pi-minimal/pull/21", isDraft: false,
|
|
40
|
+
author: { login: "raviy06" }, reviewRequests: [], reviewDecision: "CHANGES_REQUESTED",
|
|
41
|
+
statusCheckRollup: [], updatedAt: "2026-10-27T10:00:00Z",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
number: 20, title: "fix: review me", url: "https://github.com/raviy06/pi-minimal/pull/20", isDraft: false,
|
|
45
|
+
author: { login: "someone" }, reviewRequests: [{ __typename: "User", login: "RaviY06" }], reviewDecision: "REVIEW_REQUIRED",
|
|
46
|
+
statusCheckRollup: [{ state: "PENDING", conclusion: null, status: null }, { state: "FAILURE", conclusion: null, status: null }], updatedAt: "2026-10-28T09:00:00Z",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
number: 19, title: "team review", url: "https://github.com/raviy06/pi-minimal/pull/19", isDraft: false,
|
|
50
|
+
author: { login: "someone" }, reviewRequests: [{ __typename: "Team", name: "HotelTonight", slug: "hoteltonight" }], reviewDecision: "",
|
|
51
|
+
statusCheckRollup: [], updatedAt: "2026-10-28T08:00:00Z",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
number: 18, title: "just mentioned", url: "https://github.com/raviy06/pi-minimal/pull/18", isDraft: false,
|
|
55
|
+
author: { login: "someone" }, reviewRequests: [], reviewDecision: "", statusCheckRollup: [], updatedAt: "2026-10-28T07:00:00Z",
|
|
56
|
+
},
|
|
57
|
+
])
|
|
58
|
+
|
|
59
|
+
test.describe("prs parsing", () => {
|
|
60
|
+
test("parseRepoView derives the host from the URL", () => {
|
|
61
|
+
assert.deepEqual(parseRepoView(JSON.stringify({ nameWithOwner: "airbnb/ht-mainapp", url: "https://git.musta.ch/airbnb/ht-mainapp" })), {
|
|
62
|
+
nameWithOwner: "airbnb/ht-mainapp", host: "git.musta.ch", url: "https://git.musta.ch/airbnb/ht-mainapp",
|
|
63
|
+
})
|
|
64
|
+
assert.throws(() => parseRepoView("{}"))
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test("ciFailing reads both status contexts and check runs", () => {
|
|
68
|
+
assert.equal(ciFailing([{ state: "SUCCESS" }, { conclusion: "SUCCESS", status: "COMPLETED" }]), false)
|
|
69
|
+
assert.equal(ciFailing([{ state: "PENDING" }]), false)
|
|
70
|
+
assert.equal(ciFailing([{ state: "FAILURE" }]), true)
|
|
71
|
+
assert.equal(ciFailing([{ conclusion: "TIMED_OUT", status: "COMPLETED" }]), true)
|
|
72
|
+
assert.equal(ciFailing(null), false)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test("parsePrList keeps mine and review-requested (case-insensitive), drops mentions, sorts reviews first", () => {
|
|
76
|
+
const prs = parsePrList(ghList, repo, "raviy06")
|
|
77
|
+
assert.deepEqual(prs.map((pr) => [pr.number, pr.kind]), [[20, "review"], [23, "mine"], [22, "mine"], [21, "mine"]])
|
|
78
|
+
assert.equal(prs.find((pr) => pr.number === 20)?.ciFailing, true)
|
|
79
|
+
assert.equal(prs.find((pr) => pr.number === 22)?.isDraft, true)
|
|
80
|
+
assert.equal(prs[0]?.repo, "github.com/raviy06/pi-minimal")
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("team review requests count when my teams are known", () => {
|
|
84
|
+
const prs = parsePrList(ghList, repo, "raviy06", ["HotelTonight"])
|
|
85
|
+
assert.ok(prs.some((pr) => pr.number === 19 && pr.kind === "review"))
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test.describe("prs display", () => {
|
|
90
|
+
const theme = { fg: (c: string, t: string) => `[${c}]${t}[/${c}]` }
|
|
91
|
+
const prs = parsePrList(ghList, repo, "raviy06")
|
|
92
|
+
const by = (n: number) => prs.find((pr) => pr.number === n) as PullRequest
|
|
93
|
+
|
|
94
|
+
test("prColor: trouble > review > draft > mine", () => {
|
|
95
|
+
assert.equal(prColor(by(20)), "error", "failing CI on a review request")
|
|
96
|
+
assert.equal(prColor(by(21)), "error", "changes requested")
|
|
97
|
+
assert.equal(prColor({ ...by(20), ciFailing: false }), "warning")
|
|
98
|
+
assert.equal(prColor(by(22)), "dim")
|
|
99
|
+
assert.equal(prColor(by(23)), "accent")
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test("prSegment links each chip, colours it, and collapses the overflow", () => {
|
|
103
|
+
const segment = prSegment(prs, theme, 2, "github.com/raviy06/pi-minimal") as string
|
|
104
|
+
const esc = "\u001b"
|
|
105
|
+
assert.ok(segment.includes(`[error]${esc}]8;;https://github.com/raviy06/pi-minimal/pull/20${esc}\\#20${esc}]8;;${esc}\\[/error]`), "chip #20 is a red OSC 8 link")
|
|
106
|
+
assert.match(segment, /\[accent\].*#23.*\[\/accent\]/)
|
|
107
|
+
assert.match(segment, /\[dim\]\+2\[\/dim\]$/)
|
|
108
|
+
assert.equal(stripTerminalSequences(segment.replace(/\[\/?[a-z]+\]/g, "")), "#20 #23 +2")
|
|
109
|
+
assert.equal(prSegment([], theme), undefined)
|
|
110
|
+
assert.equal(stripTerminalSequences((prSegment(prs.slice(0, 1), theme, 4) as string).replace(/\[\/?[a-z]+\]/g, "")), "pi-minimal#20", "outside any repo the chip names its repo")
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test("describePr summarises kind and trouble", () => {
|
|
114
|
+
assert.equal(describePr(by(20), "github.com/raviy06/pi-minimal"), "#20 fix: review me · review requested · CI failing · @someone")
|
|
115
|
+
assert.equal(describePr(by(22), "github.com/raviy06/pi-minimal"), "#22 feat: Rex siblings · draft")
|
|
116
|
+
assert.equal(describePr(by(21), "github.com/raviy06/pi-minimal"), "#21 docs: glossary · mine · changes requested")
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test("filter parsing and filtering", () => {
|
|
120
|
+
assert.equal(parsePrFilter(""), "all")
|
|
121
|
+
assert.equal(parsePrFilter("mine"), "mine")
|
|
122
|
+
assert.equal(parsePrFilter("Review extra"), "review")
|
|
123
|
+
assert.deepEqual(filterPrs(prs, "review").map((pr) => pr.number), [20])
|
|
124
|
+
assert.deepEqual(filterPrs(prs, "mine").map((pr) => pr.number), [23, 22, 21])
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test.describe("createPrSource", () => {
|
|
129
|
+
const tick = () => new Promise((resolve) => setImmediate(resolve))
|
|
130
|
+
const settle = async () => { for (let i = 0; i < 12; i++) await tick() }
|
|
131
|
+
|
|
132
|
+
const sessionPrUrl = "https://git.musta.ch/airbnb/ht-mainapp/pull/22741"
|
|
133
|
+
const ghView = (state = "OPEN") => JSON.stringify({
|
|
134
|
+
number: 22741, title: "Make the Rails session cookie host-only", url: sessionPrUrl, isDraft: false, state,
|
|
135
|
+
author: { login: "ravi-yadav" }, reviewRequests: [], reviewDecision: "REVIEW_REQUIRED",
|
|
136
|
+
statusCheckRollup: [{ state: "FAILURE" }], updatedAt: "2026-10-28T12:00:00Z",
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
function fakePi(options: { repoOk?: boolean; login?: string; viewState?: string } = {}) {
|
|
140
|
+
const execCalls: string[][] = []
|
|
141
|
+
const pi = {
|
|
142
|
+
async exec(command: string, args: string[]) {
|
|
143
|
+
execCalls.push([command, ...args])
|
|
144
|
+
if (command !== "gh") return { stdout: "", stderr: "", code: 1, killed: false }
|
|
145
|
+
if (args[0] === "pr" && args[1] === "view") return { stdout: ghView(options.viewState), stderr: "", code: 0, killed: false }
|
|
146
|
+
if (args[0] === "repo") {
|
|
147
|
+
return options.repoOk === false
|
|
148
|
+
? { stdout: "", stderr: "none of the git remotes configured for this repository point to a known GitHub host", code: 1, killed: false }
|
|
149
|
+
: { stdout: JSON.stringify({ nameWithOwner: repo.nameWithOwner, url: repo.url }), stderr: "", code: 0, killed: false }
|
|
150
|
+
}
|
|
151
|
+
if (args[0] === "api") return { stdout: `${options.login ?? "raviy06"}\n`, stderr: "", code: 0, killed: false }
|
|
152
|
+
if (args[0] === "pr") return { stdout: ghList, stderr: "", code: 0, killed: false }
|
|
153
|
+
return { stdout: "", stderr: "", code: 1, killed: false }
|
|
154
|
+
},
|
|
155
|
+
} as unknown as ExtensionAPI
|
|
156
|
+
return { pi, execCalls }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
test("fetches repo, viewer login (per host) and the PR list once; later refreshes reuse the repo and login", async (t) => {
|
|
160
|
+
const { pi, execCalls } = fakePi()
|
|
161
|
+
let now = 1_000_000
|
|
162
|
+
const source = createPrSource({ pi, cwd: () => "/repo", now: () => now, minIntervalMs: 60_000, pollMs: 10 * 60_000 })
|
|
163
|
+
t.after(() => source.dispose())
|
|
164
|
+
let changes = 0
|
|
165
|
+
source.onChange(() => { changes += 1 })
|
|
166
|
+
await settle()
|
|
167
|
+
assert.deepEqual(execCalls.map((c) => c.slice(0, 3)), [
|
|
168
|
+
["gh", "repo", "view"],
|
|
169
|
+
["gh", "api", "--hostname"],
|
|
170
|
+
["gh", "pr", "list"],
|
|
171
|
+
])
|
|
172
|
+
const hostnameIdx = execCalls[1]?.indexOf("--hostname") ?? -1
|
|
173
|
+
assert.ok(hostnameIdx >= 0)
|
|
174
|
+
assert.equal(execCalls[1]?.[hostnameIdx + 1], "github.com")
|
|
175
|
+
assert.ok(execCalls[2]?.includes("github.com/raviy06/pi-minimal") && execCalls[2]?.includes(PR_LIST_FIELDS))
|
|
176
|
+
assert.equal(changes, 1)
|
|
177
|
+
assert.deepEqual(source.get().prs.map((pr) => pr.number), [20, 23, 22, 21])
|
|
178
|
+
assert.equal(source.get().fetchedAt, now)
|
|
179
|
+
|
|
180
|
+
// Throttled: an event-driven refresh inside the window does nothing.
|
|
181
|
+
await source.refresh()
|
|
182
|
+
assert.equal(execCalls.length, 3)
|
|
183
|
+
// Forced: only the list is fetched again; nothing changed so no notification.
|
|
184
|
+
now += 1000
|
|
185
|
+
await source.refresh(true)
|
|
186
|
+
assert.equal(execCalls.length, 4)
|
|
187
|
+
assert.equal(execCalls[3]?.[1], "pr")
|
|
188
|
+
assert.equal(changes, 1)
|
|
189
|
+
|
|
190
|
+
const tool = prsToolResult(source, "review")
|
|
191
|
+
assert.equal(tool.repo, "raviy06/pi-minimal")
|
|
192
|
+
assert.deepEqual(tool.prs.map((pr) => pr.number), [20])
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test("outside a GitHub repo the snapshot is empty with the error, and no PR list is requested", async (t) => {
|
|
196
|
+
const { pi, execCalls } = fakePi({ repoOk: false })
|
|
197
|
+
const source = createPrSource({ pi, cwd: () => "/elsewhere", pollMs: 10 * 60_000 })
|
|
198
|
+
t.after(() => source.dispose())
|
|
199
|
+
await settle()
|
|
200
|
+
assert.equal(execCalls.length, 1)
|
|
201
|
+
assert.equal(source.get().repo, undefined)
|
|
202
|
+
assert.deepEqual(source.get().prs, [])
|
|
203
|
+
assert.match(source.get().error ?? "", /known GitHub host/)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test("a PR created in this session shows up even outside any repo, is persisted via sessionPrs(), and is forgotten once merged", async (t) => {
|
|
207
|
+
const { pi, execCalls } = fakePi({ repoOk: false })
|
|
208
|
+
const source = createPrSource({ pi, cwd: () => "/elsewhere", pollMs: 10 * 60_000 })
|
|
209
|
+
t.after(() => source.dispose())
|
|
210
|
+
await settle()
|
|
211
|
+
assert.deepEqual(source.get().prs, [])
|
|
212
|
+
|
|
213
|
+
source.addSessionPr("Creating pull request…\nhttps://git.musta.ch/airbnb/ht-mainapp/pull/22741\n")
|
|
214
|
+
await settle()
|
|
215
|
+
assert.deepEqual(source.sessionPrs(), [sessionPrUrl])
|
|
216
|
+
assert.ok(execCalls.some((c) => c[1] === "pr" && c[2] === "view" && c[3] === sessionPrUrl))
|
|
217
|
+
assert.deepEqual(source.get().prs.map((pr) => [pr.number, pr.kind, pr.repo, pr.ciFailing]), [[22741, "mine", "git.musta.ch/airbnb/ht-mainapp", true]])
|
|
218
|
+
assert.equal(source.get().repo, undefined, "still not a repo here")
|
|
219
|
+
|
|
220
|
+
// Same URL again is a no-op.
|
|
221
|
+
const calls = execCalls.length
|
|
222
|
+
source.addSessionPr(sessionPrUrl)
|
|
223
|
+
await settle()
|
|
224
|
+
assert.equal(execCalls.length, calls)
|
|
225
|
+
|
|
226
|
+
// A restored session starts with the URL and fetches it on the first refresh.
|
|
227
|
+
const restored = createPrSource({ pi, cwd: () => "/elsewhere", pollMs: 10 * 60_000, sessionPrs: [sessionPrUrl] })
|
|
228
|
+
t.after(() => restored.dispose())
|
|
229
|
+
await settle()
|
|
230
|
+
assert.deepEqual(restored.get().prs.map((pr) => pr.number), [22741])
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test("a session PR in the cwd repo is not listed twice; a merged one disappears", async (t) => {
|
|
234
|
+
const merged = fakePi({ viewState: "MERGED" })
|
|
235
|
+
const source = createPrSource({ pi: merged.pi, cwd: () => "/repo", pollMs: 10 * 60_000, sessionPrs: [sessionPrUrl, "https://github.com/raviy06/pi-minimal/pull/23"] })
|
|
236
|
+
t.after(() => source.dispose())
|
|
237
|
+
await settle()
|
|
238
|
+
// #23 comes from the list (so no `pr view` for it); the GHE one was merged, so it is dropped and forgotten.
|
|
239
|
+
assert.equal(merged.execCalls.filter((c) => c[1] === "pr" && c[2] === "view").length, 1)
|
|
240
|
+
assert.deepEqual(source.get().prs.map((pr) => pr.number), [20, 23, 22, 21])
|
|
241
|
+
assert.deepEqual(source.sessionPrs(), ["https://github.com/raviy06/pi-minimal/pull/23"])
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test.describe("session PRs", () => {
|
|
246
|
+
test("PR URLs are found in gh pr create output and mapped to their repo", () => {
|
|
247
|
+
const out = "Creating pull request for ry-prs-status into main in raviy06/pi-minimal\n\nhttps://github.com/raviy06/pi-minimal/pull/24\n"
|
|
248
|
+
assert.deepEqual(findPrUrls(out), ["https://github.com/raviy06/pi-minimal/pull/24"])
|
|
249
|
+
assert.deepEqual(findPrUrls("see https://git.musta.ch/airbnb/ht-mainapp/pull/22741 and https://git.musta.ch/airbnb/ht-mainapp/pull/22741"), ["https://git.musta.ch/airbnb/ht-mainapp/pull/22741"])
|
|
250
|
+
assert.deepEqual(findPrUrls("https://github.com/raviy06/pi-minimal/issues/5"), [])
|
|
251
|
+
assert.equal(prUrlRepo("https://git.musta.ch/airbnb/ht-mainapp/pull/22741"), "git.musta.ch/airbnb/ht-mainapp")
|
|
252
|
+
assert.equal(isPrCreateCommand("cd x && gh pr create --fill"), true)
|
|
253
|
+
assert.equal(isPrCreateCommand("gt submit --stack"), true)
|
|
254
|
+
assert.equal(isPrCreateCommand("gh pr view 24"), false)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
test("parsePrView keeps open PRs as mine and drops merged/closed ones", () => {
|
|
258
|
+
const url = "https://git.musta.ch/airbnb/ht-mainapp/pull/22741"
|
|
259
|
+
const raw = { number: 22741, title: "t", url, isDraft: false, author: { login: "x" }, statusCheckRollup: [], state: "OPEN" }
|
|
260
|
+
assert.equal(parsePrView(JSON.stringify(raw), url)?.kind, "mine")
|
|
261
|
+
assert.equal(parsePrView(JSON.stringify(raw), url)?.repo, "git.musta.ch/airbnb/ht-mainapp")
|
|
262
|
+
assert.equal(parsePrView(JSON.stringify({ ...raw, state: "MERGED" }), url), undefined)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
test("chips outside the cwd repo carry the repo name", () => {
|
|
266
|
+
const pr = parsePrList(ghList, repo, "raviy06")[0] as PullRequest
|
|
267
|
+
assert.equal(prChipLabel(pr, "github.com/raviy06/pi-minimal"), "#20")
|
|
268
|
+
assert.equal(prChipLabel(pr, "git.musta.ch/airbnb/ht-mainapp"), "pi-minimal#20")
|
|
269
|
+
assert.equal(prChipLabel(pr, undefined), "pi-minimal#20", "no cwd repo: name the repo")
|
|
270
|
+
assert.match(describePr(pr, "git.musta.ch/airbnb/other"), /^pi-minimal#20 {2}/)
|
|
271
|
+
})
|
|
272
|
+
})
|