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/rex.test.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { EventEmitter } from "node:events"
|
|
3
|
+
import { PassThrough } from "node:stream"
|
|
4
|
+
import test from "node:test"
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
6
|
+
import {
|
|
7
|
+
applyRexEvent,
|
|
8
|
+
createRexSiblings,
|
|
9
|
+
createRexState,
|
|
10
|
+
listRexSiblings,
|
|
11
|
+
parseProgramStatus,
|
|
12
|
+
parseWindowList,
|
|
13
|
+
type RexSpawn,
|
|
14
|
+
} from "../src/rex.js"
|
|
15
|
+
|
|
16
|
+
const SELF = "block:self"
|
|
17
|
+
const OTHER = "block:other"
|
|
18
|
+
const WIN_SELF = "window:self"
|
|
19
|
+
const WIN_OTHER = "window:other"
|
|
20
|
+
|
|
21
|
+
// Shapes captured from `rex window ls --json`, `rex block call … program_status` and `rex events --json`.
|
|
22
|
+
const windowList = JSON.stringify({
|
|
23
|
+
revision: 14,
|
|
24
|
+
session_id: "session:s",
|
|
25
|
+
label: "pi-dev",
|
|
26
|
+
active_window_id: WIN_SELF,
|
|
27
|
+
windows: [
|
|
28
|
+
{
|
|
29
|
+
window_id: WIN_SELF,
|
|
30
|
+
label: "Window 395a96cb",
|
|
31
|
+
focused_block_id: SELF,
|
|
32
|
+
layers: [{ kind: "tiled", blocks: [{ block_id: SELF, label: "shell", creator_name: "com.superlogical.terminal" }] }],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
window_id: WIN_OTHER,
|
|
36
|
+
label: "ry-fix-login",
|
|
37
|
+
focused_block_id: OTHER,
|
|
38
|
+
layers: [
|
|
39
|
+
{
|
|
40
|
+
kind: "tiled",
|
|
41
|
+
blocks: [
|
|
42
|
+
{ block_id: OTHER, label: "shell", creator_name: "com.superlogical.terminal" },
|
|
43
|
+
{ block_id: "block:browser", label: "web", creator_name: "com.superlogical.browser" },
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
function sessionEvent(event: unknown): string {
|
|
52
|
+
return JSON.stringify({ type: "session_event", payload: { session_id: "session:s", event } })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function statusChanged(blockId: string, record: Record<string, unknown>, id = ""): string {
|
|
56
|
+
return sessionEvent({
|
|
57
|
+
type: "block_event",
|
|
58
|
+
payload: { block_id: blockId, creator_name: "com.superlogical.terminal", name: "program_status_changed", payload: { id, record, reason: "report" } },
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function statusRemoved(blockId: string, ids: string[], reason = "child_exited"): string {
|
|
63
|
+
return sessionEvent({
|
|
64
|
+
type: "block_event",
|
|
65
|
+
payload: { block_id: blockId, creator_name: "com.superlogical.terminal", name: "program_status_removed", payload: { ids, reason } },
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test.describe("rex parsing", () => {
|
|
70
|
+
test("parseWindowList maps terminal blocks to tabs and skips non-terminal blocks", () => {
|
|
71
|
+
const layout = parseWindowList(windowList)
|
|
72
|
+
assert.deepEqual([...layout.tabs], [[WIN_SELF, "Window 395a96cb"], [WIN_OTHER, "ry-fix-login"]])
|
|
73
|
+
assert.deepEqual([...layout.blocks], [[SELF, WIN_SELF], [OTHER, WIN_OTHER]])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test("parseWindowList rejects output without windows", () => {
|
|
77
|
+
assert.throws(() => parseWindowList("{}"))
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test("parseProgramStatus returns the root record and prefers effective_app", () => {
|
|
81
|
+
const out = JSON.stringify({
|
|
82
|
+
records: [
|
|
83
|
+
{ state: "working", app: "pi", effective_app: "pi", updated_at: "2026-10-28T12:58:11Z", owner: { pid: 1, name: "node" } },
|
|
84
|
+
{ id: "build", state: "blocked", updated_at: "2026-10-28T12:58:11Z" },
|
|
85
|
+
],
|
|
86
|
+
})
|
|
87
|
+
assert.deepEqual(parseProgramStatus(out), { state: "working", app: "pi" })
|
|
88
|
+
assert.equal(parseProgramStatus(JSON.stringify({ records: [] })), undefined)
|
|
89
|
+
assert.equal(parseProgramStatus(JSON.stringify({ records: [{ id: "child", state: "working", updated_at: "x" }] })), undefined)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test.describe("rex events", () => {
|
|
94
|
+
test("root status reports create, replace and remove records", () => {
|
|
95
|
+
const state = createRexState()
|
|
96
|
+
state.layout = parseWindowList(windowList)
|
|
97
|
+
|
|
98
|
+
assert.equal(applyRexEvent(state, statusChanged(OTHER, { state: "working", app: "pi", effective_app: "pi", updated_at: "x" })), "status")
|
|
99
|
+
assert.deepEqual(listRexSiblings(state, SELF), [{ name: "ry-fix-login", status: "working" }])
|
|
100
|
+
|
|
101
|
+
assert.equal(applyRexEvent(state, statusChanged(OTHER, { state: "blocked", kind: "question", app: "pi", msg: "Approve edit?", updated_at: "x" })), "status")
|
|
102
|
+
assert.deepEqual(listRexSiblings(state, SELF), [{ name: "ry-fix-login", status: "blocked" }])
|
|
103
|
+
|
|
104
|
+
assert.equal(applyRexEvent(state, statusRemoved(OTHER, [""])), "status")
|
|
105
|
+
assert.equal(listRexSiblings(state, SELF), undefined)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test("child records, other ids and non-session lines are ignored", () => {
|
|
109
|
+
const state = createRexState()
|
|
110
|
+
assert.equal(applyRexEvent(state, statusChanged(OTHER, { state: "working", updated_at: "x" }, "build/test")), undefined)
|
|
111
|
+
assert.equal(applyRexEvent(state, statusRemoved(OTHER, ["build"])), undefined)
|
|
112
|
+
assert.equal(applyRexEvent(state, sessionEvent({ type: "session_view_changed", payload: { revision: 3 } })), undefined)
|
|
113
|
+
assert.equal(applyRexEvent(state, JSON.stringify({ type: "server_event" })), undefined)
|
|
114
|
+
assert.equal(applyRexEvent(state, "not json"), undefined)
|
|
115
|
+
assert.equal(state.status.size, 0)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test("block_closed drops the record; label and block changes request a layout refresh", () => {
|
|
119
|
+
const state = createRexState()
|
|
120
|
+
state.layout = parseWindowList(windowList)
|
|
121
|
+
applyRexEvent(state, statusChanged(OTHER, { state: "working", app: "pi", updated_at: "x" }))
|
|
122
|
+
assert.equal(applyRexEvent(state, sessionEvent({ type: "block_closed", payload: { block_id: OTHER } })), "layout")
|
|
123
|
+
assert.equal(state.status.has(OTHER), false)
|
|
124
|
+
assert.equal(state.layout.blocks.has(OTHER), false)
|
|
125
|
+
assert.equal(applyRexEvent(state, sessionEvent({ type: "window_label_changed", payload: { window_id: WIN_OTHER, label: "x" } })), "layout")
|
|
126
|
+
assert.equal(applyRexEvent(state, sessionEvent({ type: "block_created", payload: { block_id: "block:new" } })), "layout")
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test.describe("listRexSiblings", () => {
|
|
131
|
+
test("excludes self, falls back from auto labels to the app, and needs a self block", () => {
|
|
132
|
+
const state = createRexState()
|
|
133
|
+
state.layout = parseWindowList(windowList)
|
|
134
|
+
state.status.set(SELF, { state: "working", app: "pi" })
|
|
135
|
+
state.status.set(OTHER, { state: "idle", app: "pi" })
|
|
136
|
+
assert.deepEqual(listRexSiblings(state, SELF), [{ name: "ry-fix-login", status: "idle" }])
|
|
137
|
+
// From the other pane's point of view, our auto-labelled tab is named after the program.
|
|
138
|
+
assert.deepEqual(listRexSiblings(state, OTHER), [{ name: "pi", status: "working" }])
|
|
139
|
+
assert.equal(listRexSiblings(state, undefined), undefined)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test("a pane in the same tab as self is named after its program, not the shared tab", () => {
|
|
143
|
+
const state = createRexState()
|
|
144
|
+
state.layout = parseWindowList(windowList)
|
|
145
|
+
state.layout.blocks.set("block:split", WIN_OTHER)
|
|
146
|
+
state.status.set("block:split", { state: "blocked", app: "terraform" })
|
|
147
|
+
state.status.set(SELF, { state: "working", app: "pi" })
|
|
148
|
+
assert.deepEqual(listRexSiblings(state, OTHER), [
|
|
149
|
+
{ name: "terraform", status: "blocked" },
|
|
150
|
+
{ name: "pi", status: "working" },
|
|
151
|
+
])
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test("a record with neither a real label nor an app is skipped", () => {
|
|
155
|
+
const state = createRexState()
|
|
156
|
+
state.layout = parseWindowList(windowList)
|
|
157
|
+
state.status.set(SELF, { state: "working" })
|
|
158
|
+
assert.equal(listRexSiblings(state, OTHER), undefined)
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
type FakeChild = EventEmitter & { stdout: PassThrough; stderr: PassThrough; kill: () => boolean; killed: boolean }
|
|
163
|
+
|
|
164
|
+
function fakeSpawn() {
|
|
165
|
+
const children: FakeChild[] = []
|
|
166
|
+
const calls: string[][] = []
|
|
167
|
+
const spawn: RexSpawn = (command, args) => {
|
|
168
|
+
calls.push([command, ...args])
|
|
169
|
+
const child = new EventEmitter() as FakeChild
|
|
170
|
+
child.stdout = new PassThrough()
|
|
171
|
+
child.stderr = new PassThrough()
|
|
172
|
+
child.killed = false
|
|
173
|
+
child.kill = () => { child.killed = true; return true }
|
|
174
|
+
children.push(child)
|
|
175
|
+
return child as unknown as ReturnType<RexSpawn>
|
|
176
|
+
}
|
|
177
|
+
return { spawn, children, calls }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function fakePi(statusByBlock: Record<string, string>) {
|
|
181
|
+
const execCalls: string[][] = []
|
|
182
|
+
const pi = {
|
|
183
|
+
async exec(command: string, args: string[]) {
|
|
184
|
+
execCalls.push([command, ...args])
|
|
185
|
+
if (command === "rex" && args[0] === "window") return { stdout: windowList, stderr: "", code: 0, killed: false }
|
|
186
|
+
if (command === "rex" && args[0] === "block") {
|
|
187
|
+
const block = args[args.indexOf("-b") + 1]
|
|
188
|
+
const stdout = statusByBlock[block] ?? JSON.stringify({ records: [] })
|
|
189
|
+
return { stdout, stderr: "", code: 0, killed: false }
|
|
190
|
+
}
|
|
191
|
+
return { stdout: "", stderr: "", code: 1, killed: false }
|
|
192
|
+
},
|
|
193
|
+
} as unknown as ExtensionAPI
|
|
194
|
+
return { pi, execCalls }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const tick = () => new Promise((resolve) => setImmediate(resolve))
|
|
198
|
+
|
|
199
|
+
test.describe("createRexSiblings", () => {
|
|
200
|
+
test("seeds from window ls + program_status (skipping self), then follows the event stream", async () => {
|
|
201
|
+
const { spawn, children, calls } = fakeSpawn()
|
|
202
|
+
const { pi, execCalls } = fakePi({
|
|
203
|
+
[OTHER]: JSON.stringify({ records: [{ state: "working", app: "pi", effective_app: "pi", updated_at: "x" }] }),
|
|
204
|
+
})
|
|
205
|
+
const source = createRexSiblings({ pi, spawn, env: { REX_SESSION: "session:s", REX_BLOCK: SELF } })
|
|
206
|
+
let changes = 0
|
|
207
|
+
source.onChange(() => { changes += 1 })
|
|
208
|
+
|
|
209
|
+
assert.deepEqual(calls, [["rex", "events", "-s", "session:s", "--json"]])
|
|
210
|
+
for (let i = 0; i < 5; i++) await tick()
|
|
211
|
+
assert.deepEqual(source.get(), [{ name: "ry-fix-login", status: "working" }])
|
|
212
|
+
assert.equal(changes, 1)
|
|
213
|
+
assert.ok(execCalls.some((c) => c[1] === "window"))
|
|
214
|
+
assert.ok(execCalls.some((c) => c[1] === "block" && c.includes(OTHER)))
|
|
215
|
+
assert.equal(execCalls.some((c) => c[1] === "block" && c.includes(SELF)), false, "self is not probed")
|
|
216
|
+
|
|
217
|
+
children[0].stdout.write(statusChanged(OTHER, { state: "blocked", kind: "permission", app: "pi", updated_at: "y" }) + "\n")
|
|
218
|
+
for (let i = 0; i < 3; i++) await tick()
|
|
219
|
+
assert.deepEqual(source.get(), [{ name: "ry-fix-login", status: "blocked" }])
|
|
220
|
+
assert.equal(changes, 2)
|
|
221
|
+
|
|
222
|
+
// Same state again: no spurious notification.
|
|
223
|
+
children[0].stdout.write(statusChanged(OTHER, { state: "blocked", kind: "permission", app: "pi", updated_at: "z" }) + "\n")
|
|
224
|
+
for (let i = 0; i < 3; i++) await tick()
|
|
225
|
+
assert.equal(changes, 2)
|
|
226
|
+
|
|
227
|
+
source.dispose()
|
|
228
|
+
assert.equal(children[0].killed, true)
|
|
229
|
+
assert.equal(source.get(), undefined)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test("a stream exit clears siblings and a disposed source ignores late events", async () => {
|
|
233
|
+
const { spawn, children } = fakeSpawn()
|
|
234
|
+
const { pi } = fakePi({})
|
|
235
|
+
const source = createRexSiblings({ pi, spawn, env: { REX_SESSION: "session:s", REX_BLOCK: SELF } })
|
|
236
|
+
for (let i = 0; i < 5; i++) await tick()
|
|
237
|
+
children[0].stdout.write(statusChanged(OTHER, { state: "working", app: "pi", updated_at: "x" }) + "\n")
|
|
238
|
+
for (let i = 0; i < 3; i++) await tick()
|
|
239
|
+
assert.deepEqual(source.get(), [{ name: "ry-fix-login", status: "working" }])
|
|
240
|
+
|
|
241
|
+
children[0].emit("exit", 1)
|
|
242
|
+
assert.equal(source.get(), undefined)
|
|
243
|
+
|
|
244
|
+
source.dispose()
|
|
245
|
+
children[0].stdout.write(statusChanged(OTHER, { state: "working", app: "pi", updated_at: "x" }) + "\n")
|
|
246
|
+
for (let i = 0; i < 3; i++) await tick()
|
|
247
|
+
assert.equal(source.get(), undefined)
|
|
248
|
+
})
|
|
249
|
+
})
|
package/test/tools.test.ts
CHANGED
|
@@ -192,10 +192,15 @@ function editRows(expanded: boolean, result: unknown = editResult, isError = fal
|
|
|
192
192
|
return block.render(80).map((line) => line.trimEnd())
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
-
|
|
195
|
+
const longDiff = (n: number) => ({ content: [{ type: "text", text: "ok" }], details: { diff: Array.from({ length: n }, (_, i) => `+${i + 1} line ${i}`).join("\n") } })
|
|
196
|
+
|
|
197
|
+
test("editDiff=expanded (default) shows small diffs inline and larger ones only behind ctrl+o", (t) => {
|
|
196
198
|
t.after(() => setEditDiffMode("expanded"))
|
|
197
199
|
setEditDiffMode("expanded")
|
|
198
|
-
assert.
|
|
200
|
+
assert.equal(editRows(false).length, 4, "a 4-line diff is shown without ctrl+o")
|
|
201
|
+
assert.equal(editRows(false, longDiff(8)).length, 8, "8 lines is still small")
|
|
202
|
+
assert.deepEqual(editRows(false, longDiff(9)), [], "9 lines waits for ctrl+o")
|
|
203
|
+
assert.equal(editRows(true, longDiff(9)).length, 9)
|
|
199
204
|
const rows = editRows(true)
|
|
200
205
|
assert.equal(rows.length, 4)
|
|
201
206
|
assert.match(rows[0]!, /│ <context> 1 context$/)
|
|
@@ -227,7 +232,7 @@ test("editDiff=always shows the diff inline and editDiff=never only on request f
|
|
|
227
232
|
test("editDiff=always truncates a long inline diff and leaves the full diff to ctrl+o", (t) => {
|
|
228
233
|
t.after(() => setEditDiffMode("expanded"))
|
|
229
234
|
setEditDiffMode("always")
|
|
230
|
-
const long =
|
|
235
|
+
const long = longDiff(60)
|
|
231
236
|
const inline = editRows(false, long)
|
|
232
237
|
assert.equal(inline.length, 41)
|
|
233
238
|
assert.match(inline[40]!, /… 20 more lines \(ctrl\+o\)$/)
|
|
@@ -241,3 +246,31 @@ test("edit failures and results without a diff never render a diff block", (t) =
|
|
|
241
246
|
const failed = editRows(false, { content: [{ type: "text", text: "oldText not found" }] }, true)
|
|
242
247
|
assert.ok(failed.every((row) => !row.includes("<removed>")))
|
|
243
248
|
})
|
|
249
|
+
|
|
250
|
+
test("write rows show the written content on ctrl+o and nothing when collapsed", () => {
|
|
251
|
+
const renderers = compactRenderers("write")
|
|
252
|
+
const content = Array.from({ length: 450 }, (_, i) => `line ${i}`).join("\n")
|
|
253
|
+
const context = { args: { path: "a.ts", content }, toolCallId: "w1", invalidate() {}, state: {}, isPartial: false, isError: false, durationMs: 100 } as never
|
|
254
|
+
const result = { content: [{ type: "text", text: "Successfully wrote to a.ts" }] } as never
|
|
255
|
+
const collapsed = renderers.renderResult!(result, { expanded: false } as never, plainTheme, context).render(80)
|
|
256
|
+
assert.deepEqual(collapsed, [])
|
|
257
|
+
const expanded = renderers.renderResult!(result, { expanded: true } as never, plainTheme, context).render(80).map((l) => l.trimEnd())
|
|
258
|
+
assert.equal(expanded.length, 401)
|
|
259
|
+
assert.match(expanded[0]!, /│ line 0$/)
|
|
260
|
+
assert.match(expanded[400]!, /… 50 more lines$/)
|
|
261
|
+
const failed = { args: { path: "a.ts", content }, toolCallId: "w2", invalidate() {}, state: {}, isPartial: false, isError: true, durationMs: 100 } as never
|
|
262
|
+
const err = renderers.renderResult!({ content: [{ type: "text", text: "EACCES" }] } as never, { expanded: true } as never, plainTheme, failed).render(80)
|
|
263
|
+
assert.ok(err.every((l) => !l.includes("line 0")), "a failed write shows its error, not the content")
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
test("createRowRenderers keeps one live key per row across call and result", () => {
|
|
267
|
+
const renderers = compactRenderers("bash")
|
|
268
|
+
const state: Record<string, unknown> = {}
|
|
269
|
+
const call = { args: { command: "sleep 1" }, invalidate() {}, state, isPartial: true }
|
|
270
|
+
renderers.renderCall!({ command: "sleep 1" } as never, plainTheme, call as never)
|
|
271
|
+
assert.equal(state.liveId, "bash:sleep 1")
|
|
272
|
+
renderers.renderResult!({ content: [{ type: "text", text: "" }] } as never, { expanded: false } as never, plainTheme, { ...call, isPartial: false } as never)
|
|
273
|
+
assert.equal(state.liveId, "bash:sleep 1")
|
|
274
|
+
assert.equal((state as { running?: boolean }).running, false)
|
|
275
|
+
stopLiveWatch()
|
|
276
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import test from "node:test"
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
|
|
4
|
+
import { installWorkingStatus } from "../src/working-status.js"
|
|
5
|
+
|
|
6
|
+
function fixture() {
|
|
7
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => void>()
|
|
8
|
+
const messages: Array<string | undefined> = []
|
|
9
|
+
const ctx = { mode: "tui", ui: { setWorkingMessage: (text?: string) => { messages.push(text) } } } as unknown as ExtensionContext
|
|
10
|
+
const pi = { on: (name: string, handler: (event: unknown, ctx: ExtensionContext) => void) => { handlers.set(name, handler) } } as unknown as ExtensionAPI
|
|
11
|
+
const stop = installWorkingStatus(pi)
|
|
12
|
+
const emit = (name: string, event: unknown = {}) => handlers.get(name)!(event, ctx)
|
|
13
|
+
return { emit, messages, stop }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test("working status walks thinking → tool → thinking → writing and clears when the agent settles", (t) => {
|
|
17
|
+
t.mock.timers.enable({ apis: ["setInterval", "Date"] })
|
|
18
|
+
const f = fixture()
|
|
19
|
+
f.emit("session_start")
|
|
20
|
+
f.emit("agent_start")
|
|
21
|
+
assert.match(f.messages.at(-1)!, /^Thinking · /)
|
|
22
|
+
f.emit("tool_execution_start", { toolCallId: "t1", toolName: "bash", args: { command: "ls -la" } })
|
|
23
|
+
assert.match(f.messages.at(-1)!, /^Running ls -la/)
|
|
24
|
+
f.emit("tool_execution_end", { toolCallId: "t1" })
|
|
25
|
+
assert.match(f.messages.at(-1)!, /^Thinking · /)
|
|
26
|
+
f.emit("message_update", { message: { role: "assistant", content: [] }, assistantMessageEvent: { type: "text_delta" } })
|
|
27
|
+
assert.match(f.messages.at(-1)!, /^Writing/)
|
|
28
|
+
f.emit("agent_settled")
|
|
29
|
+
assert.equal(f.messages.at(-1), undefined, "settling clears the message")
|
|
30
|
+
f.stop()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test("events before session_start or after settle are ignored", () => {
|
|
34
|
+
const f = fixture()
|
|
35
|
+
f.emit("agent_start")
|
|
36
|
+
assert.deepEqual(f.messages, [])
|
|
37
|
+
f.emit("session_start")
|
|
38
|
+
f.emit("tool_execution_start", { toolCallId: "x", toolName: "read" })
|
|
39
|
+
assert.deepEqual(f.messages, [], "a tool without an active run does not paint")
|
|
40
|
+
f.stop()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test("session_shutdown clears the working message while the context is still known", (t) => {
|
|
44
|
+
t.mock.timers.enable({ apis: ["setInterval", "Date"] })
|
|
45
|
+
const f = fixture()
|
|
46
|
+
f.emit("session_start")
|
|
47
|
+
f.emit("agent_start")
|
|
48
|
+
assert.ok(f.messages.at(-1))
|
|
49
|
+
f.emit("session_shutdown")
|
|
50
|
+
assert.equal(f.messages.at(-1), undefined, "shutdown must call setWorkingMessage() to clear")
|
|
51
|
+
f.emit("agent_start")
|
|
52
|
+
assert.equal(f.messages.at(-1), undefined, "no painting after shutdown dropped the context")
|
|
53
|
+
})
|
|
File without changes
|
|
File without changes
|