@preapexis/piexis 0.2.4 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -2
- package/apps/server/src/index.ts +97 -0
- package/apps/server/src/session-manager.ts +95 -0
- package/apps/server/src/workspace.ts +39 -0
- package/{tsconfig.json → apps/server/tsconfig.json} +3 -4
- package/apps/web/dist/assets/index-D4n3_lAw.js +10 -0
- package/apps/web/dist/assets/index-igiK0FPY.css +1 -0
- package/apps/web/dist/index.html +3 -0
- package/bin/piexis-web.mjs +24 -0
- package/extensions/webui.ts +45 -0
- package/package.json +38 -3
- package/packages/pi-integration/src/rpc-client.ts +104 -0
- package/packages/shared/src/index.ts +27 -0
- package/themes/midnight.json +81 -0
- package/.github/workflows/publish.yml +0 -35
- package/AGENTS.md +0 -38
- package/scripts/release.mjs +0 -32
- package/skills-lock.json +0 -365
- package/tests/agents.test.mjs +0 -605
- package/tests/ask-question-pty.test.mjs +0 -16
- package/tests/ask-question.test.mjs +0 -269
- package/tests/fixtures/agents-provider.ts +0 -55
- package/tests/fixtures/ask-question-pty.py +0 -91
- package/tests/fixtures/ask-question-terminal.mjs +0 -69
- package/tests/fixtures/recommended-tools-terminal.mjs +0 -103
- package/tests/init-workflow.test.mjs +0 -31
- package/tests/recommended-tools-pty.test.mjs +0 -16
- package/tests/recommended-tools.test.mjs +0 -423
- package/tests/resources.test.mjs +0 -66
- package/tests/skills-routing.test.mjs +0 -54
- package/tests/ui-tip-reload.test.mjs +0 -70
- package/tests/ui.test.mjs +0 -450
|
@@ -1,269 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { EventEmitter, getEventListeners } from "node:events";
|
|
3
|
-
import test from "node:test";
|
|
4
|
-
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
5
|
-
import askQuestion from "../extensions/ask-question.ts";
|
|
6
|
-
import { normalizeQuestions, initialDraft, validateAnswer } from "../lib/question-model.ts";
|
|
7
|
-
import { QuestionScreen } from "../lib/question-ui.ts";
|
|
8
|
-
|
|
9
|
-
const theme = { fg: (_color, text) => text };
|
|
10
|
-
const keys = { up: "\x1b[A", down: "\x1b[B", enter: "\r", escape: "\x1b", tab: "\t", backtab: "\x1b[Z", space: " ", ctrlC: "\x03" };
|
|
11
|
-
const question = (patch = {}) => normalizeQuestions({ questions: [{ question: "Which?", options: [{ id: "a", label: "A", description: "First choice" }, { id: "b", label: "B" }], ...patch }] })[0];
|
|
12
|
-
const draft = (indices, patch = {}) => ({ indices, text: "", custom: false, ...patch });
|
|
13
|
-
|
|
14
|
-
function screen(questions = [question()], signal) {
|
|
15
|
-
const stream = new EventEmitter();
|
|
16
|
-
let completions = 0, result;
|
|
17
|
-
let resolve;
|
|
18
|
-
const promise = new Promise((done) => { resolve = done; });
|
|
19
|
-
const component = new QuestionScreen(questions, { requestRender() {}, terminal: { rows: 24 } }, theme, (value) => {
|
|
20
|
-
completions++;
|
|
21
|
-
result = value;
|
|
22
|
-
resolve(value);
|
|
23
|
-
}, signal, stream);
|
|
24
|
-
component.focused = true;
|
|
25
|
-
return { component, stream, promise, get result() { return result; }, get completions() { return completions; } };
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function send(ui, ...inputs) { inputs.forEach((key) => ui.component.handleInput(keys[key] ?? key)); }
|
|
29
|
-
|
|
30
|
-
for (const [name, indices] of [["invalid", [999]], ["mixed valid/invalid", [0, 999]], ["malformed", ["1"]], ["fractional", [0.5]], ["negative", [-1]]]) {
|
|
31
|
-
test(`rejects ${name} selections without discarding values`, () => {
|
|
32
|
-
assert.throws(() => validateAnswer(question({ type: "multi" }), draft(indices)), /listed options/);
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
test("single and multi validation, deterministic duplicate removal, min/max limits", () => {
|
|
37
|
-
assert.equal(validateAnswer(question(), draft([1])).answer, "B");
|
|
38
|
-
const multi = question({ type: "multi" });
|
|
39
|
-
assert.deepEqual(validateAnswer(multi, draft([1, 0, 1])).optionIds, ["a", "b"]);
|
|
40
|
-
assert.deepEqual(validateAnswer(multi, draft([1, 0, 1])).answer, ["A", "B"]);
|
|
41
|
-
assert.throws(() => validateAnswer(question(), draft([])), /at least 1/);
|
|
42
|
-
assert.throws(() => validateAnswer(question({ type: "multi", minSelections: 0 }), draft([])), /at least 1/);
|
|
43
|
-
assert.throws(() => validateAnswer(question({ type: "multi", minSelections: 2 }), draft([0])), /at least 2/);
|
|
44
|
-
assert.throws(() => validateAnswer(question({ type: "multi", maxSelections: 1 }), draft([0, 1])), /at most 1/);
|
|
45
|
-
assert.equal(validateAnswer(question({ required: false }), draft([])).answer, "");
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test("custom text is explicit, untrimmed, Unicode-aware and never parsed as numbers", () => {
|
|
49
|
-
const q = question({ allowOther: true, maxLength: 20 });
|
|
50
|
-
for (const text of ["1, 999", "1, hello", " café 🦊 & <> "]) {
|
|
51
|
-
assert.equal(validateAnswer(q, draft([], { custom: true, text })).answer, text);
|
|
52
|
-
}
|
|
53
|
-
assert.throws(() => validateAnswer(q, draft([], { custom: true, text: " " })), /required/);
|
|
54
|
-
assert.throws(() => validateAnswer(question(), draft([], { custom: true, text: "A" })), /not enabled/);
|
|
55
|
-
assert.throws(() => validateAnswer(q, draft([], { custom: true, text: "x".repeat(21) })), /at most 20/);
|
|
56
|
-
assert.equal(validateAnswer(question({ type: "text", options: [], required: false }), draft([], { custom: true, text: "" })).answer, "");
|
|
57
|
-
assert.equal(validateAnswer(question({ allowOther: true, maxLength: 1 }), draft([], { custom: true, text: "🦊" })).answer, "🦊");
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test("normalization preserves legacy calls and rejects unusable contracts", () => {
|
|
61
|
-
assert.equal(question({ multiSelect: true }).type, "multi");
|
|
62
|
-
assert.equal(question({ options: [], allowOther: true }).type, "text");
|
|
63
|
-
assert.equal(question({ options: [], multiSelect: true, allowOther: true }).type, "text");
|
|
64
|
-
assert.deepEqual(initialDraft(question({ defaultSelection: "b" })).indices, [1]);
|
|
65
|
-
for (const patch of [
|
|
66
|
-
{ options: [] }, { type: "single", options: [] }, { type: "text" },
|
|
67
|
-
{ defaultSelection: "unknown" }, { defaultSelection: ["a", "b"] },
|
|
68
|
-
{ type: "multi", minSelections: 2, maxSelections: 1 },
|
|
69
|
-
{ type: "multi", maxSelections: 3 }, { type: "single", multiSelect: true },
|
|
70
|
-
{ options: [{ id: "a", label: "A" }, { id: "a", label: "B" }] }
|
|
71
|
-
]) assert.throws(() => question(patch));
|
|
72
|
-
assert.throws(() => normalizeQuestions({ questions: [] }));
|
|
73
|
-
assert.throws(() => normalizeQuestions({ questions: [{ ...question(), id: "same" }, { ...question(), id: "same" }] }));
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test("arrow navigation, description rendering, default focus and Enter", async () => {
|
|
77
|
-
const ui = screen([question({ defaultSelection: "b" })]);
|
|
78
|
-
assert.match(ui.component.render(80).join("\n"), /❯ 2\. B/);
|
|
79
|
-
send(ui, "up");
|
|
80
|
-
assert.match(ui.component.render(80).join("\n"), /❯ 1\. A\n First choice/);
|
|
81
|
-
send(ui, "down", "enter");
|
|
82
|
-
assert.equal((await ui.promise).answers[0].answer, "B");
|
|
83
|
-
assert.equal(ui.stream.listenerCount("end"), 0);
|
|
84
|
-
assert.equal(ui.stream.listenerCount("close"), 0);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test("Space toggles multi selections; errors retain screen and allow retry", async () => {
|
|
88
|
-
const ui = screen([question({ type: "multi", minSelections: 2 })]);
|
|
89
|
-
send(ui, "space", "enter");
|
|
90
|
-
assert.equal(ui.result, undefined);
|
|
91
|
-
assert.match(ui.component.render(80).join("\n"), /at least 2/);
|
|
92
|
-
assert.match(ui.component.render(80).join("\n"), /❯ \[x\] 1\. A/);
|
|
93
|
-
send(ui, "space", "space", "down", "space", "enter");
|
|
94
|
-
assert.deepEqual((await ui.promise).answers[0].optionIds, ["a", "b"]);
|
|
95
|
-
const max = screen([question({ type: "multi", maxSelections: 1 })]);
|
|
96
|
-
send(max, "space", "down", "space", "enter");
|
|
97
|
-
assert.match(max.component.render(80).join("\n"), /at most 1/);
|
|
98
|
-
send(max, "space", "enter");
|
|
99
|
-
assert.deepEqual((await max.promise).answers[0].optionIds, ["a"]);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test("Other label collisions cannot activate text; Other is enabled only explicitly", async () => {
|
|
103
|
-
for (const allowOther of [false, true]) {
|
|
104
|
-
const ui = screen([question({ allowOther, options: [{ id: "other", label: "Other — type your own answer" }, { id: "__other__", label: "Type something else" }] })]);
|
|
105
|
-
send(ui, "enter");
|
|
106
|
-
assert.equal((await ui.promise).answers[0].custom, false);
|
|
107
|
-
assert.equal(ui.result.answers[0].answer, "Other — type your own answer");
|
|
108
|
-
}
|
|
109
|
-
const ui = screen([question({ allowOther: true })]);
|
|
110
|
-
send(ui, "down", "down", "enter", "enter");
|
|
111
|
-
assert.match(ui.component.render(80).join("\n"), /An answer is required/);
|
|
112
|
-
send(ui, "1, 999 café 🦊", "enter");
|
|
113
|
-
assert.equal((await ui.promise).answers[0].answer, "1, 999 café 🦊");
|
|
114
|
-
assert.equal(ui.result.answers[0].custom, true);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
test("text required/optional, optional single skip, and maximum text length", async () => {
|
|
118
|
-
const ui = screen([question({ type: "text", options: [], maxLength: 3 })]);
|
|
119
|
-
send(ui, " ", "enter");
|
|
120
|
-
assert.equal(ui.result, undefined);
|
|
121
|
-
send(ui, "abcd", "enter");
|
|
122
|
-
assert.match(ui.component.render(80).join("\n"), /at most 3/);
|
|
123
|
-
send(ui, "escape");
|
|
124
|
-
assert.equal((await ui.promise).status, "cancelled");
|
|
125
|
-
const optional = screen([question({ type: "text", options: [], required: false })]);
|
|
126
|
-
send(optional, "enter");
|
|
127
|
-
assert.equal((await optional.promise).answers[0].answer, "");
|
|
128
|
-
assert.equal(optional.result.status, "answered");
|
|
129
|
-
const skip = screen([question({ required: false })]);
|
|
130
|
-
send(skip, "up", "enter");
|
|
131
|
-
assert.equal((await skip.promise).answers[0].answer, "");
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
test("Tab navigation preserves text and selections; review requires every confirmation", async () => {
|
|
135
|
-
const ui = screen([question(), { ...question({ type: "text", options: [] }), id: "second" }]);
|
|
136
|
-
send(ui, "down", "enter", "hello", "backtab");
|
|
137
|
-
assert.match(ui.component.render(80).join("\n"), /Question 1\/2/);
|
|
138
|
-
send(ui, "tab");
|
|
139
|
-
assert.match(ui.component.render(80).join("\n"), /hello/);
|
|
140
|
-
send(ui, "tab", "enter");
|
|
141
|
-
assert.match(ui.component.render(80).join("\n"), /Confirm this question/);
|
|
142
|
-
send(ui, "enter");
|
|
143
|
-
assert.match(ui.component.render(80).join("\n"), /Review answers/);
|
|
144
|
-
send(ui, "enter");
|
|
145
|
-
assert.deepEqual((await ui.promise).answers.map((a) => a.answer), ["B", "hello"]);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
test("abort before render, pending abort, both submit/abort races, listener cleanup", async () => {
|
|
149
|
-
for (const order of ["before", "pending", "submit-first", "abort-first"]) {
|
|
150
|
-
const controller = new AbortController();
|
|
151
|
-
if (order === "before") controller.abort();
|
|
152
|
-
const ui = screen(undefined, controller.signal);
|
|
153
|
-
if (order === "submit-first") send(ui, "enter");
|
|
154
|
-
controller.abort();
|
|
155
|
-
send(ui, "enter");
|
|
156
|
-
const result = await ui.promise;
|
|
157
|
-
assert.equal(result.status, order === "submit-first" ? "answered" : "aborted");
|
|
158
|
-
assert.equal(ui.completions, 1);
|
|
159
|
-
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
|
|
160
|
-
assert.equal(ui.stream.listenerCount("end"), 0);
|
|
161
|
-
assert.deepEqual(ui.component.render(80), []);
|
|
162
|
-
}
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
test("Esc/Ctrl+C cancellation and unexpected input closure always settle", async () => {
|
|
166
|
-
for (const key of ["escape", "ctrlC"]) {
|
|
167
|
-
const ui = screen();
|
|
168
|
-
send(ui, key);
|
|
169
|
-
assert.equal((await ui.promise).status, "cancelled");
|
|
170
|
-
}
|
|
171
|
-
for (const event of ["end", "close", "error"]) {
|
|
172
|
-
const ui = screen();
|
|
173
|
-
ui.stream.emit(event, new Error("broken input"));
|
|
174
|
-
assert.equal((await ui.promise).status, "error");
|
|
175
|
-
assert.equal(ui.stream.listenerCount(event), 0);
|
|
176
|
-
}
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
test("narrow terminals, long menus and untrusted labels render safely", () => {
|
|
180
|
-
const ui = screen([question({ question: "Very long question 🦊", options: Array.from({ length: 50 }, (_, i) => ({ label: `Choice ${i}\x1b[2J` })) })]);
|
|
181
|
-
for (const width of [1, 8, 20, 80]) {
|
|
182
|
-
const lines = ui.component.render(width);
|
|
183
|
-
assert.ok(lines.every((line) => visibleWidth(line) <= width));
|
|
184
|
-
assert.ok(lines.length < 24);
|
|
185
|
-
assert.ok(lines.every((line) => !line.includes("\x1b[2J")));
|
|
186
|
-
}
|
|
187
|
-
send(ui, "up");
|
|
188
|
-
assert.match(ui.component.render(80).join("\n"), /❯ 50\. Choice 49/);
|
|
189
|
-
send(ui, "escape");
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
test("render/input failures settle rather than leaving an active question", async () => {
|
|
193
|
-
let result;
|
|
194
|
-
const stream = new EventEmitter();
|
|
195
|
-
const ui = new QuestionScreen([question()], { requestRender() { throw new Error("render unavailable"); }, terminal: { rows: 24 } }, theme, (value) => { result = value; }, undefined, stream);
|
|
196
|
-
ui.handleInput(keys.down);
|
|
197
|
-
assert.equal(result.status, "error");
|
|
198
|
-
assert.equal(stream.listenerCount("end"), 0);
|
|
199
|
-
const badTheme = new QuestionScreen([question()], { requestRender() {}, terminal: { rows: 24 } }, { fg() { throw new Error("bad theme"); } }, (value) => { result = value; }, undefined, stream);
|
|
200
|
-
badTheme.render(80);
|
|
201
|
-
assert.equal(result.status, "error");
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
async function tty(work) {
|
|
205
|
-
const streams = [process.stdin, process.stdout];
|
|
206
|
-
const saved = streams.map((stream) => Object.getOwnPropertyDescriptor(stream, "isTTY"));
|
|
207
|
-
streams.forEach((stream) => Object.defineProperty(stream, "isTTY", { configurable: true, value: true }));
|
|
208
|
-
try { await work(); } finally {
|
|
209
|
-
streams.forEach((stream, i) => saved[i] ? Object.defineProperty(stream, "isTTY", saved[i]) : delete stream.isTTY);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function toolHarness() {
|
|
214
|
-
let tool, component, restorations = 0;
|
|
215
|
-
const handlers = new Map();
|
|
216
|
-
askQuestion({ registerTool(value) { tool = value; }, on(name, handler) { handlers.set(name, handler); } });
|
|
217
|
-
const ctx = { mode: "tui", hasUI: true, ui: { custom(factory) {
|
|
218
|
-
return new Promise((resolve) => {
|
|
219
|
-
component = factory({ requestRender() {}, terminal: { rows: 24 } }, theme, {}, (value) => { restorations++; resolve(value); });
|
|
220
|
-
});
|
|
221
|
-
} } };
|
|
222
|
-
const params = { questions: [{ question: "Which?", options: [{ label: "A" }] }] };
|
|
223
|
-
return { tool, ctx, params, handlers, get component() { return component; }, get restorations() { return restorations; } };
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
test("result renderer supports retained legacy sessions and missing details", () => {
|
|
227
|
-
const { tool } = toolHarness();
|
|
228
|
-
const old = { content: [], details: { answers: [{ question: "Which?", answer: "A" }], cancelled: false } };
|
|
229
|
-
assert.match(tool.renderResult(old, {}, theme).render(80).join("\n"), /✓ Which\?: A/);
|
|
230
|
-
assert.match(tool.renderResult({ content: [{ type: "text", text: "Original result" }] }, {}, theme).render(80).join("\n"), /Original result/);
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
test("tool protects non-TTY, legacy calls, concurrency, shutdown and failures", async () => {
|
|
234
|
-
const h = toolHarness();
|
|
235
|
-
assert.equal((await h.tool.execute("id", h.params, undefined, undefined, h.ctx)).details.status, "requires_interaction");
|
|
236
|
-
await tty(async () => {
|
|
237
|
-
for (const patch of [{ hasUI: false }, { mode: "json" }, { mode: "rpc" }]) {
|
|
238
|
-
assert.equal((await h.tool.execute("unavailable", h.params, undefined, undefined, { ...h.ctx, ...patch })).details.status, "requires_interaction");
|
|
239
|
-
}
|
|
240
|
-
const first = h.tool.execute("id", h.params, undefined, undefined, h.ctx);
|
|
241
|
-
const conflict = await h.tool.execute("second", h.params, undefined, undefined, h.ctx);
|
|
242
|
-
assert.equal(conflict.details.status, "error");
|
|
243
|
-
assert.match(conflict.details.error, /already active/);
|
|
244
|
-
h.component.handleInput(keys.enter);
|
|
245
|
-
const result = await first;
|
|
246
|
-
assert.equal(result.details.status, "answered");
|
|
247
|
-
assert.equal(result.details.answers[0].answer, "A");
|
|
248
|
-
assert.deepEqual(JSON.parse(result.content[0].text), result.details);
|
|
249
|
-
assert.equal(h.restorations, 1);
|
|
250
|
-
const pending = h.tool.execute("next", h.params, undefined, undefined, h.ctx);
|
|
251
|
-
await h.handlers.get("session_shutdown")();
|
|
252
|
-
assert.equal((await pending).details.status, "aborted");
|
|
253
|
-
const invalid = await h.tool.execute("bad", { questions: [] }, undefined, undefined, h.ctx);
|
|
254
|
-
assert.equal(invalid.details.status, "error");
|
|
255
|
-
const failed = await h.tool.execute("bad", h.params, undefined, undefined, { ...h.ctx, ui: { custom() { throw new Error("UI unavailable"); } } });
|
|
256
|
-
assert.equal(failed.details.status, "error");
|
|
257
|
-
const controller = new AbortController();
|
|
258
|
-
controller.abort();
|
|
259
|
-
const aborted = await h.tool.execute("aborted", h.params, controller.signal, undefined, h.ctx);
|
|
260
|
-
assert.equal(aborted.details.status, "aborted");
|
|
261
|
-
const runningController = new AbortController();
|
|
262
|
-
const running = h.tool.execute("agent-abort", h.params, runningController.signal, undefined, h.ctx);
|
|
263
|
-
runningController.abort();
|
|
264
|
-
assert.equal((await running).details.status, "aborted");
|
|
265
|
-
const waiting = h.tool.execute("waiting", h.params, undefined, undefined, h.ctx);
|
|
266
|
-
h.component.handleInput(keys.escape);
|
|
267
|
-
assert.equal((await waiting).details.status, "cancelled");
|
|
268
|
-
});
|
|
269
|
-
});
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
// Deterministic, offline provider for the real CLI/subagent integration test.
|
|
2
|
-
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
3
|
-
|
|
4
|
-
export default function (pi: any) {
|
|
5
|
-
pi.registerProvider("piexis-test", {
|
|
6
|
-
api: "piexis-test-api",
|
|
7
|
-
apiKey: "disposable-test-key",
|
|
8
|
-
baseUrl: "http://unused.invalid",
|
|
9
|
-
models: ["main", "small"].map((id) => ({
|
|
10
|
-
id, name: id, reasoning: true, input: ["text"], contextWindow: 32000, maxTokens: 1024,
|
|
11
|
-
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
12
|
-
})),
|
|
13
|
-
streamSimple(model: any, context: any) {
|
|
14
|
-
const stream = createAssistantMessageEventStream();
|
|
15
|
-
queueMicrotask(() => {
|
|
16
|
-
const last = context.messages.at(-1);
|
|
17
|
-
const depth = Number(process.env.PIEXIS_SUBAGENT_DEPTH ?? 0);
|
|
18
|
-
const delegated = depth > 0;
|
|
19
|
-
const orchestration = !delegated && JSON.stringify(last?.content).includes("orchestrator");
|
|
20
|
-
const exploration = !delegated && JSON.stringify(last?.content).includes("exploration");
|
|
21
|
-
const orchestrator = /Piexis agent profile: orchestrator/.test(context.systemPrompt ?? JSON.stringify(context.messages.filter((message: any) => message.role === "system")));
|
|
22
|
-
const delegate = last?.role === "user" && (!delegated || (depth === 1 && orchestrator));
|
|
23
|
-
const content = delegate
|
|
24
|
-
? [{ type: "toolCall", id: `delegate-${depth}`, name: "subagent", arguments: {
|
|
25
|
-
agent: orchestration ? "orchestrator" : "ask", ...(exploration ? { purpose: "explore" } : {}),
|
|
26
|
-
task: orchestration ? "Coordinate a configuration lookup" : "Locate the configuration loader",
|
|
27
|
-
} }]
|
|
28
|
-
: [{ type: "text", text: delegated
|
|
29
|
-
? (orchestrator ? `orchestrated:${JSON.stringify(last?.content)}` : `child:${model.id};depth:${depth}`)
|
|
30
|
-
: "parent completed" }];
|
|
31
|
-
const message = {
|
|
32
|
-
role: "assistant", api: "piexis-test-api", provider: model.provider, model: model.id,
|
|
33
|
-
timestamp: Date.now(), content: [], stopReason: "pending",
|
|
34
|
-
usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, totalTokens: 15,
|
|
35
|
-
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
36
|
-
};
|
|
37
|
-
stream.push({ type: "start", partial: message });
|
|
38
|
-
message.content = content as any;
|
|
39
|
-
const block = content[0];
|
|
40
|
-
if (block.type === "toolCall") {
|
|
41
|
-
stream.push({ type: "toolcall_start", contentIndex: 0, partial: message });
|
|
42
|
-
stream.push({ type: "toolcall_end", contentIndex: 0, toolCall: block, partial: message });
|
|
43
|
-
} else {
|
|
44
|
-
stream.push({ type: "text_start", contentIndex: 0, partial: message });
|
|
45
|
-
stream.push({ type: "text_delta", contentIndex: 0, delta: block.text, partial: message });
|
|
46
|
-
stream.push({ type: "text_end", contentIndex: 0, content: block.text, partial: message });
|
|
47
|
-
}
|
|
48
|
-
message.stopReason = block.type === "toolCall" ? "toolUse" : "stop";
|
|
49
|
-
stream.push({ type: "done", reason: message.stopReason, message });
|
|
50
|
-
stream.end();
|
|
51
|
-
});
|
|
52
|
-
return stream;
|
|
53
|
-
},
|
|
54
|
-
});
|
|
55
|
-
}
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
"""Drive actual pi-tui keyboard input using only Python's standard library."""
|
|
2
|
-
import errno
|
|
3
|
-
import fcntl
|
|
4
|
-
import json
|
|
5
|
-
import os
|
|
6
|
-
import pty
|
|
7
|
-
import select
|
|
8
|
-
import struct
|
|
9
|
-
import subprocess
|
|
10
|
-
import sys
|
|
11
|
-
import termios
|
|
12
|
-
import time
|
|
13
|
-
|
|
14
|
-
fixture, scenario, node = sys.argv[1:]
|
|
15
|
-
startup = scenario.startswith("setup-")
|
|
16
|
-
ready_text = b"Piexis setup for this project?" if startup else b"Question 1/"
|
|
17
|
-
master, slave = pty.openpty()
|
|
18
|
-
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 72, 0, 0))
|
|
19
|
-
before = termios.tcgetattr(slave)
|
|
20
|
-
child = subprocess.Popen([node, fixture, scenario], stdin=slave, stdout=slave, stderr=slave,
|
|
21
|
-
env={**os.environ, "TERM": "xterm-256color"})
|
|
22
|
-
output = bytearray()
|
|
23
|
-
|
|
24
|
-
def drain(wait=0.1):
|
|
25
|
-
if select.select([master], [], [], wait)[0]:
|
|
26
|
-
try:
|
|
27
|
-
output.extend(os.read(master, 65536))
|
|
28
|
-
except OSError as error:
|
|
29
|
-
if error.errno != errno.EIO:
|
|
30
|
-
raise
|
|
31
|
-
|
|
32
|
-
actions = {
|
|
33
|
-
"single": [b"\x1b[B", b"\r"],
|
|
34
|
-
"multi": [b"\r", b" ", b"\x1b[B", b" ", b"\r"],
|
|
35
|
-
"text": [b"\r", "\x1b[200~1, 999 café 🦊 & <>\x1b[201~".encode(), b"\r"],
|
|
36
|
-
"other": [b"\x1b[B", b"\x1b[B", b"\r", b"1, 999", b"\r"],
|
|
37
|
-
"navigation": [b"\x1b[B", b"\r", b"hello", b"\x1b[Z", b"\t", b"\r", b"\r"],
|
|
38
|
-
"cancel": [b"\x1b"],
|
|
39
|
-
"ctrl-c": [b"\x03"],
|
|
40
|
-
"abort": [b"!"],
|
|
41
|
-
"setup-enable": [b"\x1b[B", b"\r"],
|
|
42
|
-
"setup-agent-upgrade": [b"\x1b[B", b"\r"],
|
|
43
|
-
"setup-not-now": [b"\r"],
|
|
44
|
-
"setup-disabled": [b"\x1b[B", b"\x1b[B", b"\r"],
|
|
45
|
-
}
|
|
46
|
-
try:
|
|
47
|
-
deadline = time.monotonic() + 8
|
|
48
|
-
while ready_text not in output and child.poll() is None and time.monotonic() < deadline:
|
|
49
|
-
drain()
|
|
50
|
-
if ready_text not in output:
|
|
51
|
-
raise AssertionError("Question did not render: " + output.decode(errors="replace"))
|
|
52
|
-
for action in actions[scenario]:
|
|
53
|
-
os.write(master, action)
|
|
54
|
-
end = time.monotonic() + 0.15
|
|
55
|
-
while time.monotonic() < end:
|
|
56
|
-
drain(0.03)
|
|
57
|
-
while child.poll() is None and time.monotonic() < deadline:
|
|
58
|
-
drain()
|
|
59
|
-
if child.poll() is None:
|
|
60
|
-
raise AssertionError("Terminal interaction did not settle")
|
|
61
|
-
drain()
|
|
62
|
-
assert child.returncode == 0, output.decode(errors="replace")
|
|
63
|
-
assert termios.tcgetattr(slave) == before, "Terminal attributes were not restored"
|
|
64
|
-
assert b"\x1b[?25h" in output, "Cursor was not restored"
|
|
65
|
-
decoded = output.decode(errors="replace")
|
|
66
|
-
result_line = decoded.split("RESULT:")[-1].splitlines()[0]
|
|
67
|
-
result = json.loads(result_line)
|
|
68
|
-
assert result["rawRestored"] and result["listenersRestored"] and result["restored"] == 1
|
|
69
|
-
if scenario in ("single", "navigation"):
|
|
70
|
-
assert result["answers"][0]["optionIds"] == ["b"]
|
|
71
|
-
if scenario == "multi":
|
|
72
|
-
assert "Choose at least 2" in decoded
|
|
73
|
-
assert result["answers"][0]["optionIds"] == ["a", "b"]
|
|
74
|
-
assert "[x]" in decoded
|
|
75
|
-
if scenario == "text":
|
|
76
|
-
assert "An answer is required" in decoded
|
|
77
|
-
assert result["answers"][0]["answer"] == "1, 999 café 🦊 & <>"
|
|
78
|
-
if scenario == "other":
|
|
79
|
-
assert result["answers"][0]["answer"] == "1, 999"
|
|
80
|
-
assert result["answers"][0]["custom"]
|
|
81
|
-
if scenario == "navigation":
|
|
82
|
-
assert result["answers"][1]["answer"] == "hello"
|
|
83
|
-
expected = "aborted" if scenario == "abort" else "cancelled" if scenario in ("cancel", "ctrl-c") else "answered"
|
|
84
|
-
assert result["status"] == expected, result
|
|
85
|
-
print(json.dumps({"scenario": scenario, "status": result["status"], "terminalRestored": True}))
|
|
86
|
-
finally:
|
|
87
|
-
if child.poll() is None:
|
|
88
|
-
child.kill()
|
|
89
|
-
child.wait()
|
|
90
|
-
os.close(master)
|
|
91
|
-
os.close(slave)
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { Input, ProcessTerminal, TuiMainScreen } from "@earendil-works/pi-tui";
|
|
3
|
-
import askQuestion from "../../extensions/ask-question.ts";
|
|
4
|
-
|
|
5
|
-
const scenario = process.argv[2];
|
|
6
|
-
const controller = new AbortController();
|
|
7
|
-
let tool;
|
|
8
|
-
askQuestion({ on() {}, registerTool(value) { tool = value; } });
|
|
9
|
-
const terminal = new ProcessTerminal();
|
|
10
|
-
const tui = new TuiMainScreen(terminal, true);
|
|
11
|
-
const editor = new Input();
|
|
12
|
-
const originalRaw = !!process.stdin.isRaw;
|
|
13
|
-
const originalDataListeners = process.stdin.listenerCount("data");
|
|
14
|
-
const originalEndListeners = process.stdin.listenerCount("end");
|
|
15
|
-
const theme = { fg: (_color, text) => text };
|
|
16
|
-
let restored = 0;
|
|
17
|
-
|
|
18
|
-
// Exercise the production component with Pi's real renderer/input transport.
|
|
19
|
-
// This adapter mirrors ctx.ui.custom's editor restoration/completion contract.
|
|
20
|
-
const ctx = { mode: "tui", hasUI: true, ui: { custom(factory) {
|
|
21
|
-
return new Promise((resolve) => {
|
|
22
|
-
let component;
|
|
23
|
-
let closed = false;
|
|
24
|
-
const done = (value) => {
|
|
25
|
-
if (closed) return;
|
|
26
|
-
closed = true;
|
|
27
|
-
tui.clear();
|
|
28
|
-
tui.addChild(editor);
|
|
29
|
-
tui.setFocus(editor);
|
|
30
|
-
tui.requestRender();
|
|
31
|
-
component?.dispose();
|
|
32
|
-
restored++;
|
|
33
|
-
resolve(value);
|
|
34
|
-
};
|
|
35
|
-
component = factory(tui, theme, {}, done);
|
|
36
|
-
if (!closed) {
|
|
37
|
-
tui.clear();
|
|
38
|
-
tui.addChild(component);
|
|
39
|
-
tui.setFocus(component);
|
|
40
|
-
tui.requestRender();
|
|
41
|
-
}
|
|
42
|
-
});
|
|
43
|
-
} } };
|
|
44
|
-
const options = [{ id: "a", label: "JWT Authentication", description: "Stateless tokens" }, { id: "b", label: "OAuth 2.0" }];
|
|
45
|
-
let questions = [{ question: "Authentication method?", options }];
|
|
46
|
-
if (scenario === "multi") questions[0] = { ...questions[0], multiSelect: true, minSelections: 2 };
|
|
47
|
-
if (scenario === "text") questions = [{ question: "Your response?", type: "text", required: true }];
|
|
48
|
-
if (scenario === "other") questions[0].allowOther = true;
|
|
49
|
-
if (scenario === "navigation") questions.push({ question: "Your response?", type: "text" });
|
|
50
|
-
const removeListener = tui.addInputListener((data) => {
|
|
51
|
-
if (data === "!") { controller.abort(); return { consume: true }; }
|
|
52
|
-
});
|
|
53
|
-
tui.addChild(editor);
|
|
54
|
-
tui.setFocus(editor);
|
|
55
|
-
tui.start();
|
|
56
|
-
let details;
|
|
57
|
-
try {
|
|
58
|
-
details = (await tool.execute("pty", { questions }, controller.signal, undefined, ctx)).details;
|
|
59
|
-
assert.equal(restored, 1);
|
|
60
|
-
assert.equal(tui.getFocusedComponent(), editor);
|
|
61
|
-
assert.equal(editor.focused, true);
|
|
62
|
-
assert.equal(process.stdin.listenerCount("end"), originalEndListeners);
|
|
63
|
-
} finally {
|
|
64
|
-
removeListener();
|
|
65
|
-
tui.stop();
|
|
66
|
-
}
|
|
67
|
-
assert.equal(!!process.stdin.isRaw, originalRaw);
|
|
68
|
-
assert.equal(process.stdin.listenerCount("data"), originalDataListeners);
|
|
69
|
-
process.stdout.write(`\nRESULT:${JSON.stringify({ ...details, restored, rawRestored: true, listenersRestored: true })}\n`);
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { getEventListeners } from "node:events";
|
|
6
|
-
import { ExtensionSelectorComponent, initTheme } from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import { Input, ProcessTerminal, TuiMainScreen } from "@earendil-works/pi-tui";
|
|
8
|
-
import recommendedTools from "../../extensions/recommended-tools.ts";
|
|
9
|
-
|
|
10
|
-
const scenario = process.argv[2];
|
|
11
|
-
const root = await mkdtemp(join(tmpdir(), "piexis-setup-pty-"));
|
|
12
|
-
const cwd = join(root, "project");
|
|
13
|
-
await mkdir(cwd);
|
|
14
|
-
process.env.PI_CODING_AGENT_DIR = join(root, "agent");
|
|
15
|
-
initTheme("dark", false);
|
|
16
|
-
const handlers = new Map();
|
|
17
|
-
let active = ["read", "bash", "edit", "write", "AskQuestion"];
|
|
18
|
-
if (scenario === "setup-agent-upgrade") {
|
|
19
|
-
active.push("grep", "find", "ls");
|
|
20
|
-
await mkdir(join(cwd, ".pi"));
|
|
21
|
-
await writeFile(join(cwd, ".pi", "settings.json"), JSON.stringify({
|
|
22
|
-
defaultTools: ["read", "bash", "edit", "write", "grep", "find", "ls"],
|
|
23
|
-
piexis: { recommendedTools: "enabled" }
|
|
24
|
-
}));
|
|
25
|
-
}
|
|
26
|
-
const available = ["read", "bash", "edit", "write", "grep", "find", "ls"].map((name) => ({ name, sourceInfo: { source: "builtin" } }));
|
|
27
|
-
available.push({ name: "subagent", sourceInfo: { source: "extension" } });
|
|
28
|
-
recommendedTools({
|
|
29
|
-
on(name, handler) { handlers.set(name, handler); },
|
|
30
|
-
getActiveTools: () => active,
|
|
31
|
-
getAllTools: () => available,
|
|
32
|
-
setActiveTools: (tools) => { active = tools; }
|
|
33
|
-
});
|
|
34
|
-
const originalRaw = !!process.stdin.isRaw;
|
|
35
|
-
const originalListeners = process.stdin.listenerCount("data");
|
|
36
|
-
const tui = new TuiMainScreen(new ProcessTerminal(), true);
|
|
37
|
-
const editor = new Input();
|
|
38
|
-
let restored = 0;
|
|
39
|
-
const ctx = { cwd, mode: "tui", hasUI: true, model: { provider: "test", id: "default" }, isProjectTrusted: () => true, ui: {
|
|
40
|
-
notify() {},
|
|
41
|
-
select(title, options, { signal }) {
|
|
42
|
-
return new Promise((resolve) => {
|
|
43
|
-
let closed = false;
|
|
44
|
-
const finish = (value) => {
|
|
45
|
-
if (closed) return;
|
|
46
|
-
closed = true;
|
|
47
|
-
signal.removeEventListener("abort", abort);
|
|
48
|
-
selector.dispose();
|
|
49
|
-
tui.clear();
|
|
50
|
-
tui.addChild(editor);
|
|
51
|
-
tui.setFocus(editor);
|
|
52
|
-
tui.requestRender();
|
|
53
|
-
restored++;
|
|
54
|
-
resolve(value);
|
|
55
|
-
};
|
|
56
|
-
const abort = () => finish(undefined);
|
|
57
|
-
const selector = new ExtensionSelectorComponent(title, options, finish, () => finish(undefined));
|
|
58
|
-
signal.addEventListener("abort", abort, { once: true });
|
|
59
|
-
tui.clear();
|
|
60
|
-
tui.addChild(selector);
|
|
61
|
-
tui.setFocus(selector);
|
|
62
|
-
tui.requestRender();
|
|
63
|
-
if (signal.aborted) abort();
|
|
64
|
-
}).then((value) => {
|
|
65
|
-
assert.equal(getEventListeners(signal, "abort").length, 0);
|
|
66
|
-
return value;
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
} };
|
|
70
|
-
tui.addChild(editor);
|
|
71
|
-
tui.setFocus(editor);
|
|
72
|
-
tui.start();
|
|
73
|
-
let saved;
|
|
74
|
-
try {
|
|
75
|
-
await handlers.get("session_start")({}, ctx);
|
|
76
|
-
assert.equal(restored, 1);
|
|
77
|
-
assert.equal(tui.getFocusedComponent(), editor);
|
|
78
|
-
try { saved = JSON.parse(await readFile(join(cwd, ".pi", "settings.json"), "utf8")); }
|
|
79
|
-
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
80
|
-
if (scenario === "setup-enable" || scenario === "setup-agent-upgrade") {
|
|
81
|
-
assert.equal(saved.piexis.recommendedTools, "enabled");
|
|
82
|
-
assert.deepEqual(active, ["read", "bash", "edit", "write", "AskQuestion", "grep", "find", "ls"]);
|
|
83
|
-
const agentConfig = JSON.parse(await readFile(join(cwd, ".pi", "agent.json"), "utf8"));
|
|
84
|
-
assert.equal(agentConfig.model, "test/default");
|
|
85
|
-
assert.deepEqual(agentConfig.subagent_variant_overrides, {
|
|
86
|
-
plan: { model: "test/default", effort: "high" },
|
|
87
|
-
orchestrator: { model: "test/default", effort: "high" },
|
|
88
|
-
debug: { model: "test/default", effort: "high" },
|
|
89
|
-
ask: { model: "test/default", effort: "low" },
|
|
90
|
-
code: { model: "test/default", effort: "medium" },
|
|
91
|
-
});
|
|
92
|
-
if (scenario === "setup-agent-upgrade") assert.deepEqual(saved.defaultTools, ["read", "bash", "edit", "write", "grep", "find", "ls"]);
|
|
93
|
-
} else if (scenario === "setup-disabled") {
|
|
94
|
-
assert.deepEqual(saved, { piexis: { recommendedTools: "disabled", agentSetup: "disabled" } });
|
|
95
|
-
} else assert.equal(saved, undefined);
|
|
96
|
-
if (scenario !== "setup-enable" && scenario !== "setup-agent-upgrade") await assert.rejects(readFile(join(cwd, ".pi", "agent.json")), { code: "ENOENT" });
|
|
97
|
-
} finally {
|
|
98
|
-
tui.stop();
|
|
99
|
-
await rm(root, { recursive: true, force: true });
|
|
100
|
-
}
|
|
101
|
-
assert.equal(!!process.stdin.isRaw, originalRaw);
|
|
102
|
-
assert.equal(process.stdin.listenerCount("data"), originalListeners);
|
|
103
|
-
process.stdout.write(`\nRESULT:${JSON.stringify({ status: "answered", restored, rawRestored: true, listenersRestored: true, scenario })}\n`);
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { access, readFile } from "node:fs/promises";
|
|
3
|
-
import test from "node:test";
|
|
4
|
-
|
|
5
|
-
const root = new URL("../", import.meta.url);
|
|
6
|
-
|
|
7
|
-
test("init writes skill workflow guidance without installing or granting permissions", async () => {
|
|
8
|
-
const prompt = await readFile(new URL("prompts/init.md", root), "utf8");
|
|
9
|
-
assert.match(prompt, /Include an \*\*Agent workflow\*\* section/);
|
|
10
|
-
for (const name of ["caveman", "grilling", "ponytail"]) {
|
|
11
|
-
assert.match(prompt, new RegExp(`\\*\\*${name}:\\*\\*`));
|
|
12
|
-
await access(new URL(`skills/${name}/SKILL.md`, root));
|
|
13
|
-
}
|
|
14
|
-
assert.match(prompt, /Read the matching `SKILL\.md`/);
|
|
15
|
-
assert.match(prompt, /otherwise ask in chat/);
|
|
16
|
-
assert.match(prompt, /Cancellation is not consent/);
|
|
17
|
-
assert.match(prompt, /not runtime enforcement or tool permissions/);
|
|
18
|
-
assert.match(prompt, /do not install anything during `\/init`/);
|
|
19
|
-
assert.match(prompt, /Only create or edit `AGENTS\.md`/);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
test("bundled third-party skills retain provenance and licenses", async () => {
|
|
23
|
-
for (const name of ["caveman", "ponytail"]) {
|
|
24
|
-
await access(new URL(`skills/${name}/LICENSE`, root));
|
|
25
|
-
const notice = await readFile(new URL(`skills/${name}/NOTICE`, root), "utf8");
|
|
26
|
-
assert.match(notice, /https:\/\/github\.com\//);
|
|
27
|
-
assert.match(notice, /Commit: [a-f0-9]{40}/);
|
|
28
|
-
}
|
|
29
|
-
const caveman = await readFile(new URL("skills/caveman/SKILL.md", root), "utf8");
|
|
30
|
-
assert.doesNotMatch(caveman, /`(?:ultracave|megacave|\/caveman-compress)`/);
|
|
31
|
-
});
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { spawnSync } from "node:child_process";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
import test from "node:test";
|
|
5
|
-
|
|
6
|
-
const python = spawnSync("python3", ["-c", "import pty, termios"], { encoding: "utf8" });
|
|
7
|
-
const driver = fileURLToPath(new URL("fixtures/ask-question-pty.py", import.meta.url));
|
|
8
|
-
const fixture = fileURLToPath(new URL("fixtures/recommended-tools-terminal.mjs", import.meta.url));
|
|
9
|
-
|
|
10
|
-
for (const scenario of ["setup-enable", "setup-agent-upgrade", "setup-not-now", "setup-disabled"]) {
|
|
11
|
-
test(`real terminal startup: ${scenario}`, { skip: python.status !== 0 ? "Python/Unix PTY support unavailable" : false }, () => {
|
|
12
|
-
const result = spawnSync("python3", [driver, fixture, scenario, process.execPath], { encoding: "utf8", timeout: 12000 });
|
|
13
|
-
assert.equal(result.status, 0, result.stderr + result.stdout);
|
|
14
|
-
assert.equal(JSON.parse(result.stdout).terminalRestored, true);
|
|
15
|
-
});
|
|
16
|
-
}
|