@preapexis/piexis 0.2.2 → 0.2.4

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.
Files changed (149) hide show
  1. package/AGENTS.md +21 -41
  2. package/README.md +73 -139
  3. package/extensions/agents.ts +205 -0
  4. package/extensions/ask-question.ts +54 -95
  5. package/extensions/recommended-tools.ts +62 -0
  6. package/extensions/ui.ts +204 -58
  7. package/lib/agent-config.ts +55 -0
  8. package/lib/agents.ts +208 -0
  9. package/lib/question-model.ts +98 -0
  10. package/lib/question-ui.ts +214 -0
  11. package/lib/recommended-tools.ts +119 -0
  12. package/lib/subagent-runner.ts +146 -0
  13. package/lib/ui-tips.json +41 -0
  14. package/package.json +5 -13
  15. package/prompts/init.md +10 -2
  16. package/prompts/summary.md +7 -6
  17. package/schemas/agent.schema.json +48 -0
  18. package/skills/{claude-api/LICENSE.txt → caveman/LICENSE} +2 -2
  19. package/skills/caveman/LICENSE-MIT +21 -0
  20. package/skills/caveman/NOTICE +10 -0
  21. package/skills/caveman/SKILL.md +89 -0
  22. package/skills/find-skills/SKILL.md +141 -0
  23. package/skills/ponytail/LICENSE +21 -0
  24. package/skills/ponytail/NOTICE +9 -0
  25. package/skills/ponytail/SKILL.md +52 -0
  26. package/skills-lock.json +6 -0
  27. package/tests/agents.test.mjs +605 -0
  28. package/tests/ask-question-pty.test.mjs +16 -0
  29. package/tests/ask-question.test.mjs +269 -0
  30. package/tests/fixtures/agents-provider.ts +55 -0
  31. package/tests/fixtures/ask-question-pty.py +91 -0
  32. package/tests/fixtures/ask-question-terminal.mjs +69 -0
  33. package/tests/fixtures/recommended-tools-terminal.mjs +103 -0
  34. package/tests/init-workflow.test.mjs +31 -0
  35. package/tests/recommended-tools-pty.test.mjs +16 -0
  36. package/tests/recommended-tools.test.mjs +423 -0
  37. package/tests/resources.test.mjs +66 -0
  38. package/tests/skills-routing.test.mjs +7 -4
  39. package/tests/ui-tip-reload.test.mjs +70 -0
  40. package/tests/ui.test.mjs +450 -0
  41. package/tsconfig.json +1 -0
  42. package/eval/README.md +0 -72
  43. package/eval/permission-cases.json +0 -28
  44. package/eval/permissions.mjs +0 -103
  45. package/extensions/background-tasks.ts +0 -433
  46. package/extensions/background-worker.ts +0 -50
  47. package/extensions/glob.ts +0 -32
  48. package/extensions/mode.ts +0 -397
  49. package/lib/bash-approvals.ts +0 -113
  50. package/lib/bash-policy.ts +0 -164
  51. package/lib/mode-policy.ts +0 -174
  52. package/lib/path-policy.ts +0 -182
  53. package/lib/permission-classifier.ts +0 -128
  54. package/lib/permission-config.ts +0 -91
  55. package/lib/permission-denials.ts +0 -32
  56. package/lib/permission-intent.ts +0 -128
  57. package/lib/permission-taxonomy.ts +0 -15
  58. package/lib/task-broker.ts +0 -65
  59. package/lib/task-checkout.ts +0 -39
  60. package/lib/task-git.ts +0 -44
  61. package/lib/task-patch.ts +0 -106
  62. package/lib/task-permissions.ts +0 -51
  63. package/lib/task-report.ts +0 -62
  64. package/lib/task-worktree-policy.ts +0 -56
  65. package/skills/claude-api/SKILL.md +0 -572
  66. package/skills/claude-api/csharp/claude-api/README.md +0 -362
  67. package/skills/claude-api/csharp/claude-api/batches.md +0 -14
  68. package/skills/claude-api/csharp/claude-api/files-api.md +0 -25
  69. package/skills/claude-api/csharp/claude-api/streaming.md +0 -28
  70. package/skills/claude-api/csharp/claude-api/tool-use.md +0 -164
  71. package/skills/claude-api/curl/examples.md +0 -256
  72. package/skills/claude-api/curl/managed-agents.md +0 -371
  73. package/skills/claude-api/go/claude-api/README.md +0 -188
  74. package/skills/claude-api/go/claude-api/files-api.md +0 -23
  75. package/skills/claude-api/go/claude-api/streaming.md +0 -43
  76. package/skills/claude-api/go/claude-api/tool-use.md +0 -220
  77. package/skills/claude-api/go/managed-agents/README.md +0 -564
  78. package/skills/claude-api/java/claude-api/README.md +0 -238
  79. package/skills/claude-api/java/claude-api/files-api.md +0 -27
  80. package/skills/claude-api/java/claude-api/streaming.md +0 -24
  81. package/skills/claude-api/java/claude-api/tool-use.md +0 -228
  82. package/skills/claude-api/java/managed-agents/README.md +0 -443
  83. package/skills/claude-api/php/claude-api/README.md +0 -174
  84. package/skills/claude-api/php/claude-api/batches.md +0 -15
  85. package/skills/claude-api/php/claude-api/files-api.md +0 -13
  86. package/skills/claude-api/php/claude-api/streaming.md +0 -27
  87. package/skills/claude-api/php/claude-api/tool-use.md +0 -253
  88. package/skills/claude-api/php/managed-agents/README.md +0 -443
  89. package/skills/claude-api/python/claude-api/README.md +0 -565
  90. package/skills/claude-api/python/claude-api/batches.md +0 -198
  91. package/skills/claude-api/python/claude-api/files-api.md +0 -170
  92. package/skills/claude-api/python/claude-api/sdk-upgrade.md +0 -286
  93. package/skills/claude-api/python/claude-api/streaming.md +0 -179
  94. package/skills/claude-api/python/claude-api/tool-use.md +0 -627
  95. package/skills/claude-api/python/managed-agents/README.md +0 -335
  96. package/skills/claude-api/ruby/claude-api/README.md +0 -134
  97. package/skills/claude-api/ruby/claude-api/streaming.md +0 -16
  98. package/skills/claude-api/ruby/claude-api/tool-use.md +0 -41
  99. package/skills/claude-api/ruby/managed-agents/README.md +0 -394
  100. package/skills/claude-api/shared/admin-api.md +0 -179
  101. package/skills/claude-api/shared/agent-design.md +0 -101
  102. package/skills/claude-api/shared/anthropic-cli.md +0 -269
  103. package/skills/claude-api/shared/claude-platform-on-aws.md +0 -59
  104. package/skills/claude-api/shared/cost-optimization.md +0 -233
  105. package/skills/claude-api/shared/error-codes.md +0 -262
  106. package/skills/claude-api/shared/live-sources.md +0 -166
  107. package/skills/claude-api/shared/managed-agents-api-reference.md +0 -455
  108. package/skills/claude-api/shared/managed-agents-client-patterns.md +0 -220
  109. package/skills/claude-api/shared/managed-agents-core.md +0 -359
  110. package/skills/claude-api/shared/managed-agents-environments.md +0 -226
  111. package/skills/claude-api/shared/managed-agents-events.md +0 -290
  112. package/skills/claude-api/shared/managed-agents-memory.md +0 -201
  113. package/skills/claude-api/shared/managed-agents-multiagent.md +0 -260
  114. package/skills/claude-api/shared/managed-agents-onboarding.md +0 -82
  115. package/skills/claude-api/shared/managed-agents-outcomes.md +0 -108
  116. package/skills/claude-api/shared/managed-agents-overview.md +0 -77
  117. package/skills/claude-api/shared/managed-agents-scheduled-deployments.md +0 -156
  118. package/skills/claude-api/shared/managed-agents-self-hosted-sandboxes.md +0 -282
  119. package/skills/claude-api/shared/managed-agents-tools.md +0 -504
  120. package/skills/claude-api/shared/managed-agents-webhooks.md +0 -143
  121. package/skills/claude-api/shared/model-migration.md +0 -1872
  122. package/skills/claude-api/shared/models.md +0 -142
  123. package/skills/claude-api/shared/platform-availability.md +0 -53
  124. package/skills/claude-api/shared/prompt-audit.md +0 -223
  125. package/skills/claude-api/shared/prompt-caching.md +0 -292
  126. package/skills/claude-api/shared/token-counting.md +0 -56
  127. package/skills/claude-api/shared/tool-use-concepts.md +0 -526
  128. package/skills/claude-api/typescript/claude-api/README.md +0 -405
  129. package/skills/claude-api/typescript/claude-api/batches.md +0 -106
  130. package/skills/claude-api/typescript/claude-api/files-api.md +0 -98
  131. package/skills/claude-api/typescript/claude-api/streaming.md +0 -178
  132. package/skills/claude-api/typescript/claude-api/tool-use.md +0 -586
  133. package/skills/claude-api/typescript/managed-agents/README.md +0 -359
  134. package/tests/auto-permissions.test.mjs +0 -315
  135. package/tests/background-tasks.test.mjs +0 -27
  136. package/tests/background-worker.test.mjs +0 -60
  137. package/tests/bash-permissions.test.mjs +0 -276
  138. package/tests/glob.test.mjs +0 -31
  139. package/tests/mode.test.mjs +0 -62
  140. package/tests/permission-evaluation.test.mjs +0 -70
  141. package/tests/permission-foundation.test.mjs +0 -194
  142. package/tests/permission-policy.test.mjs +0 -158
  143. package/tests/runtime/README.md +0 -51
  144. package/tests/runtime/harness.mjs +0 -190
  145. package/tests/runtime/permissions.test.mjs +0 -192
  146. package/tests/runtime/tui.test.mjs +0 -60
  147. package/tests/task-integration.test.mjs +0 -324
  148. package/tests/task-permissions.test.mjs +0 -178
  149. package/tests/task-worktree-policy.test.mjs +0 -53
@@ -0,0 +1,605 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, mkdir, readFile, readdir, rm, stat, symlink, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { execFile } from "node:child_process";
7
+ import { promisify } from "node:util";
8
+ import { fileURLToPath } from "node:url";
9
+ import agentsExtension from "../extensions/agents.ts";
10
+ import uiExtension from "../extensions/ui.ts";
11
+ import { matchesKey, visibleWidth } from "@earendil-works/pi-tui";
12
+ import { agentNames, childResourceArgs, initializeAgentConfig, isSmallTask, loadAgentConfig, parseAgentConfig, routeSubagent, subagentTools } from "../lib/agents.ts";
13
+ import { agentConfigSchemaUrl } from "../lib/agent-config.ts";
14
+ import { emptyUsage, limitOutput, piInvocation, runSubagent } from "../lib/subagent-runner.ts";
15
+
16
+ async function fixture(work) {
17
+ const root = await mkdtemp(join(tmpdir(), "piexis-agents-"));
18
+ const cwd = join(root, "project"), agentDir = join(root, "agent");
19
+ await mkdir(join(cwd, ".pi"), { recursive: true });
20
+ await mkdir(agentDir);
21
+ const cli = join(root, "cli.mjs");
22
+ await writeFile(cli, `
23
+ let task = "";
24
+ for await (const chunk of process.stdin) task += chunk;
25
+ const usage = { input: 10, output: 5, cacheRead: 2, cacheWrite: 1, totalTokens: 18,
26
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.01 } };
27
+ const message = { role: "assistant", content: [{ type: "text", text: JSON.stringify({ task, args: process.argv.slice(2), depth: process.env.PIEXIS_SUBAGENT_DEPTH }) }], usage, stopReason: "stop" };
28
+ process.stdout.write(JSON.stringify({ type: "message_end", message }) + "\\n");
29
+ `);
30
+ try { await work({ root, cwd, agentDir, cli }); }
31
+ finally { await rm(root, { recursive: true, force: true }); }
32
+ }
33
+
34
+ async function environment(f, work) {
35
+ const argv = process.argv, dir = process.env.PI_CODING_AGENT_DIR, depth = process.env.PIEXIS_SUBAGENT_DEPTH;
36
+ process.argv = [process.execPath, f.cli];
37
+ process.env.PI_CODING_AGENT_DIR = f.agentDir;
38
+ delete process.env.PIEXIS_SUBAGENT_DEPTH;
39
+ try { await work(); }
40
+ finally {
41
+ process.argv = argv;
42
+ for (const [key, value] of [["PI_CODING_AGENT_DIR", dir], ["PIEXIS_SUBAGENT_DEPTH", depth]]) {
43
+ if (value === undefined) delete process.env[key]; else process.env[key] = value;
44
+ }
45
+ }
46
+ }
47
+
48
+ function harness(f, flag) {
49
+ const handlers = new Map(), commands = new Map(), shortcuts = new Map(), statuses = new Map(), notifications = [], states = [];
50
+ let tool, active = ["read", "bash", "edit", "write", "grep", "find", "ls", "AskQuestion", "subagent"], mainModel, effort;
51
+ const pi = {
52
+ on(name, handler) { handlers.set(name, handler); }, registerFlag() {}, getFlag: () => flag,
53
+ registerCommand(name, command) { commands.set(name, command); }, registerTool(value) { tool = value; },
54
+ registerShortcut(key, shortcut) { shortcuts.set(key, shortcut); },
55
+ getActiveTools: () => active, setActiveTools(value) { active = value; },
56
+ getThinkingLevel: () => "high", appendEntry(type, data) { states.push({ type: "custom", customType: type, data }); },
57
+ async setModel(model) {
58
+ if (model.id === "unauthenticated") return false;
59
+ mainModel = model; return true;
60
+ },
61
+ setThinkingLevel(value) { effort = value; },
62
+ };
63
+ agentsExtension(pi);
64
+ const ctx = {
65
+ cwd: f.cwd, hasUI: true, mode: "json", model: { provider: "test", id: "current" }, thinkingLevel: "high",
66
+ isProjectTrusted: () => true, isIdle: () => true, waitForIdle: async () => {},
67
+ sessionManager: { getBranch: () => states, getEntries: () => states },
68
+ modelRegistry: { find: (provider, id) => provider === "test" && id !== "missing" ? { provider, id } : undefined,
69
+ hasConfiguredAuth: (model) => model.id !== "unauthenticated" },
70
+ ui: { setStatus(key, value) { statuses.set(key, value); }, notify(...args) { notifications.push(args); }, select: async () => undefined },
71
+ };
72
+ return { ctx, handlers, commands, shortcuts, statuses, notifications, states, get tool() { return tool; },
73
+ get active() { return active; }, get mainModel() { return mainModel; }, get effort() { return effort; },
74
+ start: () => handlers.get("session_start")({}, ctx),
75
+ execute: (params, signal, update) => tool.execute("call", params, signal, update, ctx),
76
+ };
77
+ }
78
+
79
+ const current = "test/current";
80
+ test("validates configuration and rejects typos, bad models and efforts", () => {
81
+ assert.deepEqual(parseAgentConfig({}), {});
82
+ for (const bad of [null, [], { model: "bare-id" }, { model: "test/has space" }, { smallModel: "test/model" },
83
+ { subagent_variant_overrides: [] }, { subagent_variant_overrides: { worker: {} } },
84
+ { subagent_variant_overrides: { code: { effort: "huge" } } }, { subagent_variant_overrides: { code: { tools: [] } } }]) {
85
+ assert.throws(() => parseAgentConfig(bad));
86
+ }
87
+ assert.equal(parseAgentConfig({ subagent_model: "provider/org/model", delegate_exploration: true }).delegate_exploration, true);
88
+ assert.throws(() => parseAgentConfig({ delegate_exploration: "true" }));
89
+ });
90
+
91
+ test("trusted project config merges fields within overrides without writing settings", async () => {
92
+ await fixture(async (f) => {
93
+ const global = { model: "test/main", small_model: "test/small", subagent_variant_overrides: { code: { model: "test/code", effort: "high" }, debug: { effort: "high" } } };
94
+ const local = { subagent_model: "test/worker", subagent_variant_overrides: { code: { effort: "low" } } };
95
+ await writeFile(join(f.agentDir, "agents.json"), JSON.stringify(global));
96
+ await writeFile(join(f.cwd, ".pi", "agents.json"), JSON.stringify(local));
97
+ const config = await loadAgentConfig(f.cwd, f.agentDir, true);
98
+ assert.deepEqual(config.subagent_variant_overrides.code, { model: "test/code", effort: "low" });
99
+ assert.deepEqual(config.subagent_variant_overrides.debug, { effort: "high" });
100
+ assert.equal(config.small_model, "test/small");
101
+ assert.equal(config.subagent_model, "test/worker");
102
+ assert.deepEqual(await loadAgentConfig(f.cwd, f.agentDir, false), global);
103
+ await writeFile(join(f.cwd, ".pi", "agents.json"), "{bad");
104
+ await assert.rejects(loadAgentConfig(f.cwd, f.agentDir, true), /agents.json/);
105
+ assert.deepEqual(await loadAgentConfig(f.cwd, f.agentDir, false), global);
106
+ await assert.rejects(readFile(join(f.cwd, ".pi", "settings.json")), { code: "ENOENT" });
107
+ });
108
+ });
109
+
110
+ test("singular agent.json is preferred per scope, with legacy agents.json fallback", async () => {
111
+ await fixture(async (f) => {
112
+ await writeFile(join(f.agentDir, "agents.json"), '{"model":"test/legacy"}');
113
+ await writeFile(join(f.agentDir, "agent.json"), '{"model":"test/main"}');
114
+ await writeFile(join(f.cwd, ".pi", "agents.json"), '{"subagent_model":"test/worker"}');
115
+ assert.equal((await loadAgentConfig(f.cwd, f.agentDir, true)).model, "test/main");
116
+ assert.equal((await loadAgentConfig(f.cwd, f.agentDir, true)).subagent_model, "test/worker");
117
+ await writeFile(join(f.cwd, ".pi", "agent.json"), '{"subagent_model":"test/local"}');
118
+ assert.equal((await loadAgentConfig(f.cwd, f.agentDir, true)).subagent_model, "test/local");
119
+ assert.equal((await loadAgentConfig(f.cwd, f.agentDir, false)).subagent_model, undefined);
120
+ await writeFile(join(f.cwd, ".pi", "agent.json"), "{bad");
121
+ await assert.rejects(loadAgentConfig(f.cwd, f.agentDir, true), /agent.json/);
122
+ });
123
+ });
124
+
125
+ test("opt-in agent scaffold uses the active model, restrictive permissions and atomic create-only writes", async () => {
126
+ await fixture(async (f) => {
127
+ const created = await Promise.all([initializeAgentConfig(f.cwd, f.agentDir, "test/current"), initializeAgentConfig(f.cwd, f.agentDir, "test/other")]);
128
+ assert.deepEqual(created, [true, false]);
129
+ const path = join(f.cwd, ".pi", "agent.json");
130
+ assert.deepEqual(JSON.parse(await readFile(path, "utf8")), {
131
+ $schema: agentConfigSchemaUrl, model: "test/current", small_model: "test/current", subagent_model: "test/current", delegate_exploration: false,
132
+ subagent_variant_overrides: {
133
+ plan: { model: "test/current", effort: "high" },
134
+ orchestrator: { model: "test/current", effort: "high" },
135
+ debug: { model: "test/current", effort: "high" },
136
+ ask: { model: "test/current", effort: "low" },
137
+ code: { model: "test/current", effort: "medium" },
138
+ }
139
+ });
140
+ assert.equal((await stat(path)).mode & 0o777, 0o600);
141
+ assert.deepEqual(await readdir(join(f.cwd, ".pi")), ["agent.json"]);
142
+ assert.equal((await loadAgentConfig(f.cwd, f.agentDir, true)).model, "test/current");
143
+ });
144
+ });
145
+
146
+ test("agent-config command fills only missing project settings", async () => {
147
+ await fixture(async (f) => environment(f, async () => {
148
+ await writeFile(join(f.cwd, ".pi", "agent.json"), JSON.stringify({ model: "test/custom", delegate_exploration: true }));
149
+ const h = harness(f);
150
+ await h.commands.get("agent-config").handler("", h.ctx);
151
+ const config = JSON.parse(await readFile(join(f.cwd, ".pi", "agent.json"), "utf8"));
152
+ assert.equal(config.$schema, agentConfigSchemaUrl);
153
+ assert.equal(config.model, "test/custom");
154
+ assert.equal(config.delegate_exploration, true);
155
+ assert.equal(config.small_model, "test/custom");
156
+ assert.equal(config.subagent_model, "test/custom");
157
+ assert.equal(config.subagent_variant_overrides.code.model, "test/custom");
158
+ assert.match(h.notifications.at(-1)[0], /Added missing/);
159
+ await h.commands.get("agent-config").handler("", h.ctx);
160
+ assert.match(h.notifications.at(-1)[0], /already complete/);
161
+ }));
162
+ });
163
+
164
+ test("agent scaffold preserves inherited user configuration", async () => {
165
+ await fixture(async (f) => {
166
+ const inherited = { model: "test/main", small_model: "test/small", subagent_model: "test/worker", delegate_exploration: true, subagent_variant_overrides: {
167
+ debug: { effort: "off" }, ask: { model: "test/answer" }, code: { model: "test/code", effort: "max" }
168
+ } };
169
+ const path = join(f.agentDir, "agent.json");
170
+ const original = JSON.stringify(inherited);
171
+ await writeFile(path, original);
172
+ assert.equal(await initializeAgentConfig(f.cwd, f.agentDir, "test/current"), true);
173
+ assert.deepEqual(JSON.parse(await readFile(join(f.cwd, ".pi", "agent.json"), "utf8")), {
174
+ $schema: agentConfigSchemaUrl,
175
+ ...inherited,
176
+ subagent_variant_overrides: {
177
+ plan: { model: "test/worker", effort: "high" },
178
+ orchestrator: { model: "test/worker", effort: "high" },
179
+ debug: { model: "test/worker", effort: "off" },
180
+ ask: { model: "test/answer", effort: "low" },
181
+ code: { model: "test/code", effort: "max" },
182
+ },
183
+ });
184
+ assert.equal(await readFile(path, "utf8"), original);
185
+ });
186
+ });
187
+
188
+ test("agent scaffold never replaces existing, malformed, legacy or non-regular files", async () => {
189
+ for (const name of ["agent.json", "agents.json"]) {
190
+ for (const value of ['{"model":"test/custom"}', "{bad", "directory", "symlink"]) {
191
+ await fixture(async (f) => {
192
+ const path = join(f.cwd, ".pi", name);
193
+ const target = join(f.root, "target.json");
194
+ if (value === "directory") await mkdir(path);
195
+ else if (value === "symlink") { await writeFile(target, "do not change"); await symlink(target, path); }
196
+ else await writeFile(path, value);
197
+ assert.equal(await initializeAgentConfig(f.cwd, f.agentDir, "test/current"), false);
198
+ if (value === "symlink") assert.equal(await readFile(target, "utf8"), "do not change");
199
+ else if (value !== "directory") assert.equal(await readFile(path, "utf8"), value);
200
+ assert.deepEqual(await readdir(join(f.cwd, ".pi")), [name]);
201
+ });
202
+ }
203
+ }
204
+ });
205
+
206
+ test("missing models, invalid inherited configuration and cancellation do not create an agent file", async () => {
207
+ await fixture(async (f) => {
208
+ await assert.rejects(initializeAgentConfig(f.cwd, f.agentDir, undefined), /No active model/);
209
+ await assert.rejects(initializeAgentConfig(f.cwd, f.agentDir, "bare-id"), /provider\/model/);
210
+ const controller = new AbortController(); controller.abort();
211
+ await assert.rejects(initializeAgentConfig(f.cwd, f.agentDir, "test/current", controller.signal), { name: "AbortError" });
212
+ await writeFile(join(f.agentDir, "agent.json"), "{bad");
213
+ await assert.rejects(initializeAgentConfig(f.cwd, f.agentDir, "test/current"), /agent.json/);
214
+ assert.deepEqual(await readdir(join(f.cwd, ".pi")), []);
215
+ });
216
+ });
217
+
218
+ test("automatic small routing is conservative and variant overrides take precedence", () => {
219
+ const config = { model: "test/main", small_model: "test/small", subagent_model: "test/worker" };
220
+ for (const task of ["Locate the auth entry point", "What does parseConfig do?", "Summarize README.md"]) {
221
+ const route = routeSubagent(config, "ask", task, current, "high");
222
+ assert.deepEqual(route, { model: "test/small", effort: "low", reason: "automatic small task" });
223
+ }
224
+ assert.equal(isSmallTask("code", "Fix a typo in README.md"), true);
225
+ for (const [agent, task] of [["ask", "Explain the security architecture"], ["ask", "Summarize the entire repo"], ["code", "Implement login"], ["debug", "Fix typo"], ["plan", "List files"], ["orchestrator", "Find config"], ["ask", "List " + "x".repeat(401)]]) {
226
+ assert.equal(routeSubagent(config, agent, task, current, "high").model, "test/worker");
227
+ }
228
+ assert.equal(routeSubagent({}, "code", "task", current, "high").model, current);
229
+ assert.equal(routeSubagent({ model: "test/main" }, "code", "task", current, "high").model, current);
230
+ const overrides = { ...config, subagent_variant_overrides: { ask: { model: "test/ask", effort: "max" }, code: { effort: "high" } } };
231
+ assert.deepEqual(routeSubagent(overrides, "ask", "Locate config", current, "off"), { model: "test/ask", effort: "max", reason: "variant override" });
232
+ assert.equal(routeSubagent(overrides, "code", "Fix a typo", current, "off").effort, "high");
233
+ assert.deepEqual(routeSubagent(overrides, "ask", "Explore the configuration", current, "high", true), {
234
+ model: "test/small", effort: "low", reason: "configured exploration small model",
235
+ });
236
+ assert.throws(() => routeSubagent({}, "ask", "Explore", current, "high", true), /requires small_model/);
237
+ });
238
+
239
+ test("child tools never add inactive tools and resource flags preserve explicit extensions", () => {
240
+ const active = ["read", "bash", "write", "AskQuestion", "subagent", "custom"];
241
+ assert.deepEqual(subagentTools("ask", active, 1), ["read"]);
242
+ assert.deepEqual(subagentTools("plan", active, 1), ["read"]);
243
+ assert.deepEqual(subagentTools("plan", active, 1, true), ["read", "subagent"]);
244
+ assert.deepEqual(subagentTools("ask", active, 2, true), ["read"]);
245
+ assert.deepEqual(subagentTools("orchestrator", active, 1), ["read", "bash", "write", "subagent", "custom"]);
246
+ assert.deepEqual(subagentTools("code", active, 2), ["read", "bash", "write", "custom"]);
247
+ assert.deepEqual(childResourceArgs(["--model", "test/main", "--no-extensions", "-e", "gate.ts", "--no-mcp", "--api-key", "private", "--", "-e", "bad.ts"]), ["--no-extensions", "-e", "gate.ts", "--no-mcp"]);
248
+ });
249
+
250
+ test("all five profiles execute as child agents with visible routing and preserved loadout", async () => {
251
+ await fixture(async (f) => environment(f, async () => {
252
+ await writeFile(join(f.agentDir, "agents.json"), JSON.stringify({ small_model: "test/small", subagent_model: "test/worker", subagent_variant_overrides: { plan: { model: "test/planner", effort: "max" } } }));
253
+ process.argv.push("--no-extensions", "-e", "gate.ts");
254
+ const h = harness(f);
255
+ await h.start();
256
+ assert.equal(h.tool.executionMode, "sequential");
257
+ for (const agent of agentNames) {
258
+ const updates = [];
259
+ const result = await h.execute({ agent, task: agent === "ask" ? "Locate the config" : "Complete this task" }, undefined, (value) => updates.push(value));
260
+ assert.equal(result.isError, false);
261
+ const output = JSON.parse(result.details.output);
262
+ assert.equal(output.depth, "1");
263
+ assert.match(output.task, /^Task:/);
264
+ assert.ok(output.args.includes("--approve"));
265
+ assert.ok(output.args.includes("gate.ts"));
266
+ assert.equal(output.args[output.args.indexOf("--agent") + 1], agent);
267
+ const tools = output.args[output.args.indexOf("--tools") + 1].split(",");
268
+ assert.ok(!tools.includes("AskQuestion"));
269
+ if (["plan", "ask"].includes(agent)) {
270
+ assert.ok(!tools.includes("bash") && !tools.includes("subagent"));
271
+ assert.ok(output.args.includes("mcp__*"));
272
+ }
273
+ assert.equal(result.details.model, agent === "ask" ? "test/small" : agent === "plan" ? "test/planner" : "test/worker");
274
+ assert.equal(result.details.effort, agent === "ask" ? "low" : agent === "plan" ? "max" : "high");
275
+ assert.equal(result.usage.input, 10);
276
+ assert.match(result.content[0].text, new RegExp(result.details.reason));
277
+ assert.ok(updates.length > 0);
278
+ }
279
+ }));
280
+ });
281
+
282
+ test("configured exploration always uses the small model and stays read-only", async () => {
283
+ await fixture(async (f) => environment(f, async () => {
284
+ await writeFile(join(f.agentDir, "agent.json"), JSON.stringify({
285
+ small_model: "test/small", delegate_exploration: true,
286
+ subagent_variant_overrides: { ask: { model: "test/ask", effort: "max" } },
287
+ }));
288
+ const h = harness(f);
289
+ await h.start();
290
+ assert.match(h.handlers.get("before_agent_start")({ systemPrompt: "base" }).systemPrompt, /purpose: explore/);
291
+ const result = await h.execute({ agent: "ask", purpose: "explore", task: "Locate configuration loading" });
292
+ assert.equal(result.details.model, "test/small");
293
+ assert.equal(result.details.effort, "low");
294
+ assert.equal(result.details.reason, "configured exploration small model");
295
+ assert.deepEqual(h.ctx.model, { provider: "test", id: "current" });
296
+ const output = JSON.parse(result.details.output);
297
+ const tools = output.args[output.args.indexOf("--tools") + 1].split(",");
298
+ assert.ok(!tools.includes("subagent") && !tools.includes("bash"));
299
+ await assert.rejects(h.execute({ agent: "plan", purpose: "explore", task: "Locate configuration loading" }), /requires agent: ask/);
300
+ await writeFile(join(f.agentDir, "agent.json"), JSON.stringify({ small_model: "test/small", delegate_exploration: false }));
301
+ await assert.rejects(h.execute({ agent: "ask", purpose: "explore", task: "Locate configuration loading" }), /Enable delegate_exploration/);
302
+ await writeFile(join(f.agentDir, "agent.json"), JSON.stringify({ delegate_exploration: true }));
303
+ await assert.rejects(h.execute({ agent: "ask", purpose: "explore", task: "Locate configuration loading" }), /requires small_model/);
304
+ }));
305
+ });
306
+
307
+ test("profile commands cancel cleanly, inject instructions, persist and restore branch state", async () => {
308
+ await fixture(async (f) => environment(f, async () => {
309
+ const h = harness(f);
310
+ await h.start();
311
+ const command = h.commands.get("agent");
312
+ const prompt = () => h.handlers.get("before_agent_start")({ systemPrompt: "base" }, h.ctx).systemPrompt;
313
+ await command.handler("", h.ctx);
314
+ assert.equal(h.states.length, 0);
315
+ await command.handler("plan", h.ctx);
316
+ assert.match(prompt(), /base[\s\S]*profile: plan/);
317
+ await assert.rejects(h.execute({ agent: "code", task: "Make changes" }), /only to plan or ask/);
318
+ await command.handler("nonsense", h.ctx);
319
+ assert.match(prompt(), /profile: plan/);
320
+ await command.handler("debug", h.ctx);
321
+ assert.match(prompt(), /trace its cause/);
322
+ h.states.pop();
323
+ h.handlers.get("session_tree")({}, h.ctx);
324
+ assert.match(prompt(), /profile: plan/);
325
+ h.states.length = 0;
326
+ h.handlers.get("session_tree")({}, h.ctx);
327
+ assert.match(prompt(), /profile: code/);
328
+ }));
329
+ });
330
+
331
+ test("Alt+A cycles all profiles, persists the selection and respects busy/non-TUI sessions", async () => {
332
+ await fixture(async (f) => environment(f, async () => {
333
+ const h = harness(f);
334
+ h.ctx.mode = "tui";
335
+ await h.start();
336
+ assert.equal(h.shortcuts.size, 1);
337
+ const shortcut = h.shortcuts.get("alt+a");
338
+ assert.ok(shortcut);
339
+ assert.ok(matchesKey("\u001ba", "alt+a"));
340
+ assert.equal(h.statuses.get("piexis-agent"), "agent:code");
341
+ for (const name of agentNames) {
342
+ await shortcut.handler(h.ctx);
343
+ assert.equal(h.statuses.get("piexis-agent"), `agent:${name}`);
344
+ assert.equal(h.states.at(-1).data.agent, name);
345
+ assert.match(h.handlers.get("before_agent_start")({ systemPrompt: "base" }).systemPrompt, new RegExp(`profile: ${name}`));
346
+ }
347
+ const count = h.states.length;
348
+ h.ctx.isIdle = () => false;
349
+ await shortcut.handler(h.ctx);
350
+ assert.equal(h.states.length, count);
351
+ assert.equal(h.notifications.at(-1)[1], "warning");
352
+ h.ctx.isIdle = () => true;
353
+ for (const mode of ["json", "rpc", "print"]) {
354
+ h.ctx.mode = mode;
355
+ await shortcut.handler(h.ctx);
356
+ assert.equal(h.states.length, count);
357
+ }
358
+ h.ctx.mode = "tui";
359
+ await h.commands.get("agent").handler("debug", h.ctx);
360
+ await shortcut.handler(h.ctx);
361
+ assert.equal(h.statuses.get("piexis-agent"), "agent:ask");
362
+ h.states.pop();
363
+ h.handlers.get("session_tree")({}, h.ctx);
364
+ assert.equal(h.statuses.get("piexis-agent"), "agent:debug");
365
+ const resumed = harness(f);
366
+ resumed.states.push(...h.states);
367
+ await resumed.start();
368
+ assert.equal(resumed.statuses.get("piexis-agent"), "agent:debug");
369
+ }));
370
+ });
371
+
372
+ test("commands, picker and cycling apply merged profile models and effort", async () => {
373
+ await fixture(async (f) => environment(f, async () => {
374
+ await writeFile(join(f.agentDir, "agent.json"), JSON.stringify({
375
+ model: "test/main", small_model: "test/small", subagent_model: "test/worker",
376
+ subagent_variant_overrides: { plan: { model: "test/planner", effort: "high" }, debug: { effort: "max" } },
377
+ }));
378
+ await writeFile(join(f.cwd, ".pi", "agent.json"), JSON.stringify({
379
+ subagent_variant_overrides: { plan: { effort: "low" }, ask: { model: "test/answer", effort: "off" } },
380
+ }));
381
+ const h = harness(f);
382
+ h.ctx.mode = "tui";
383
+ await h.start();
384
+ await h.shortcuts.get("alt+a").handler(h.ctx);
385
+ assert.equal(h.mainModel.id, "planner");
386
+ assert.equal(h.effort, "low");
387
+ assert.equal(h.statuses.get("piexis-agent"), "agent:plan");
388
+ await h.commands.get("agent").handler("debug", h.ctx);
389
+ assert.equal(h.mainModel.id, "main");
390
+ assert.equal(h.effort, "max");
391
+ h.ctx.ui.select = async () => "ask";
392
+ await h.commands.get("agent").handler("", h.ctx);
393
+ assert.equal(h.mainModel.id, "answer");
394
+ assert.equal(h.effort, "off");
395
+ h.ctx.isProjectTrusted = () => false;
396
+ await h.commands.get("agent").handler("plan", h.ctx);
397
+ assert.equal(h.mainModel.id, "planner");
398
+ assert.equal(h.effort, "high");
399
+ }));
400
+ });
401
+
402
+ test("unconfigured profile fields leave current settings alone", async () => {
403
+ await fixture(async (f) => environment(f, async () => {
404
+ const h = harness(f);
405
+ await h.commands.get("agent").handler("plan", h.ctx);
406
+ assert.equal(h.mainModel, undefined);
407
+ assert.equal(h.effort, undefined);
408
+ await writeFile(join(f.agentDir, "agent.json"), JSON.stringify({
409
+ small_model: "test/small", subagent_model: "test/worker",
410
+ subagent_variant_overrides: { ask: { effort: "low" }, code: { model: "test/coder" } },
411
+ }));
412
+ await h.commands.get("agent").handler("ask", h.ctx);
413
+ assert.equal(h.mainModel, undefined);
414
+ assert.equal(h.effort, "low");
415
+ await h.commands.get("agent").handler("code", h.ctx);
416
+ assert.equal(h.mainModel.id, "coder");
417
+ assert.equal(h.effort, "low");
418
+ }));
419
+ });
420
+
421
+ test("failed profile switches preserve model, effort and profile and release the switch guard", async () => {
422
+ await fixture(async (f) => environment(f, async () => {
423
+ const h = harness(f);
424
+ h.ctx.mode = "tui";
425
+ await h.start();
426
+ await writeFile(join(f.agentDir, "agent.json"), JSON.stringify({
427
+ subagent_variant_overrides: { code: { model: "test/coder", effort: "medium" } },
428
+ }));
429
+ await h.commands.get("agent").handler("code", h.ctx);
430
+ const count = h.states.length;
431
+ for (const value of ["{bad", ...["missing", "unauthenticated"].map((id) => JSON.stringify({
432
+ subagent_variant_overrides: { plan: { model: `test/${id}`, effort: "max" } },
433
+ }))]) {
434
+ await writeFile(join(f.agentDir, "agent.json"), value);
435
+ await h.shortcuts.get("alt+a").handler(h.ctx);
436
+ assert.equal(h.mainModel.id, "coder");
437
+ assert.equal(h.effort, "medium");
438
+ assert.equal(h.statuses.get("piexis-agent"), "agent:code");
439
+ assert.equal(h.states.length, count);
440
+ assert.equal(h.notifications.at(-1)[1], "warning");
441
+ }
442
+ await writeFile(join(f.agentDir, "agent.json"), "{}");
443
+ await Promise.all([h.shortcuts.get("alt+a").handler(h.ctx), h.commands.get("agent").handler("ask", h.ctx)]);
444
+ assert.equal(h.states.length, count + 1);
445
+ assert.equal(h.statuses.get("piexis-agent"), "agent:plan");
446
+ await h.commands.get("agent").handler("ask", h.ctx);
447
+ assert.equal(h.statuses.get("piexis-agent"), "agent:ask");
448
+ }));
449
+ });
450
+
451
+ test("footer reads live agent status after commands, cycling and tree restoration without losing width bounds", async () => {
452
+ await fixture(async (f) => environment(f, async () => {
453
+ const h = harness(f);
454
+ h.ctx.mode = "tui";
455
+ h.ctx.getContextUsage = () => undefined;
456
+ const uiHandlers = new Map();
457
+ const theme = { fg: (_color, text) => `\u001b[36m${text}\u001b[39m`, bold: (text) => `\u001b[1m${text}\u001b[22m` };
458
+ let footer;
459
+ Object.assign(h.ctx.ui, {
460
+ setTitle() {}, setHeader() {},
461
+ setFooter(make) { footer = make({ requestRender() {} }, theme, {
462
+ getGitBranch: () => "main", getExtensionStatuses: () => h.statuses, onBranchChange: () => () => {},
463
+ }); },
464
+ });
465
+ uiExtension({ on(name, handler) { uiHandlers.set(name, handler); }, getThinkingLevel: () => "high" });
466
+ await uiHandlers.get("session_start")({}, h.ctx);
467
+ try {
468
+ assert.doesNotMatch(footer.render(160).join("\n"), /agent:/);
469
+ await h.start();
470
+ assert.match(footer.render(160).join("\n"), /agent:code/);
471
+ assert.match(footer.render(160).join("\n"), /Alt\+A/);
472
+ for (const name of agentNames) {
473
+ await h.shortcuts.get("alt+a").handler(h.ctx);
474
+ for (const width of [40, 60, 80, 160]) {
475
+ const rows = footer.render(width);
476
+ assert.match(rows.join("\n"), new RegExp(`agent:${name}`));
477
+ for (const row of rows) assert.ok(visibleWidth(row) <= width);
478
+ }
479
+ }
480
+ await h.commands.get("agent").handler("debug", h.ctx);
481
+ assert.match(footer.render(160).join("\n"), /agent:debug/);
482
+ h.states.pop();
483
+ h.handlers.get("session_tree")({}, h.ctx);
484
+ assert.match(footer.render(160).join("\n"), /agent:code/);
485
+ h.ctx.model = { id: "研究🚀".repeat(100) };
486
+ for (let width = 0; width <= 160; width++) {
487
+ for (const row of footer.render(width)) assert.ok(visibleWidth(row) <= width);
488
+ }
489
+ assert.match(footer.render(40).join("\n"), /agent:code/);
490
+ h.statuses.clear();
491
+ assert.doesNotMatch(footer.render(160).join("\n"), /agent:|Alt\+A/);
492
+ } finally { footer.dispose(); }
493
+ }));
494
+ });
495
+
496
+ test("configured main model initializes only new sessions, not CLI overrides or resumes", async () => {
497
+ await fixture(async (f) => environment(f, async () => {
498
+ await writeFile(join(f.agentDir, "agents.json"), '{"model":"test/main"}');
499
+ const h = harness(f, "ask");
500
+ await h.start();
501
+ assert.equal(h.mainModel.id, "main");
502
+ assert.match(h.handlers.get("before_agent_start")({ systemPrompt: "base" }).systemPrompt, /profile: ask/);
503
+ process.argv.push("--model", "test/explicit");
504
+ const explicit = harness(f);
505
+ await explicit.start();
506
+ assert.equal(explicit.mainModel, undefined);
507
+ process.argv.length -= 2;
508
+ const resumed = harness(f);
509
+ resumed.states.push({ type: "message" });
510
+ await resumed.start();
511
+ assert.equal(resumed.mainModel, undefined);
512
+ }));
513
+ });
514
+
515
+ test("invalid requests, unavailable models and depth limits fail without spawning", async () => {
516
+ await fixture(async (f) => environment(f, async () => {
517
+ const h = harness(f);
518
+ for (const params of [{ agent: "worker", task: "task" }, { agent: "code", task: " " }]) await assert.rejects(h.execute(params));
519
+ for (const id of ["missing", "unauthenticated"]) {
520
+ await writeFile(join(f.agentDir, "agents.json"), JSON.stringify({ subagent_model: `test/${id}` }));
521
+ await assert.rejects(h.execute({ agent: "code", task: "task" }), /unavailable/);
522
+ }
523
+ process.env.PIEXIS_SUBAGENT_DEPTH = "2";
524
+ const nested = harness(f);
525
+ await nested.start();
526
+ assert.ok(!nested.active.includes("subagent"));
527
+ await assert.rejects(nested.execute({ agent: "orchestrator", task: "task" }), /depth limit/);
528
+ }));
529
+ });
530
+
531
+ test("runner handles UTF-8 framing, multiple text blocks, tool usage, retries and failed responses", async () => {
532
+ await fixture(async (f) => {
533
+ const usage = emptyUsage(); usage.input = 4; usage.totalTokens = 4;
534
+ const events = [
535
+ { type: "message_end", message: { role: "assistant", content: [], stopReason: "error", errorMessage: "temporary", usage } },
536
+ { type: "message_end", message: { role: "toolResult", usage } },
537
+ { type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "你好\u2028🌍" }, { type: "text", text: "done" }], stopReason: "stop", usage } },
538
+ ];
539
+ await writeFile(f.cli, `const data = Buffer.from(${JSON.stringify(events.map((event) => JSON.stringify(event)).join("\n"))}); for (const byte of data) process.stdout.write(Buffer.from([byte]));`);
540
+ const run = () => runSubagent({ invocation: { command: process.execPath, args: [f.cli] }, args: [], cwd: f.cwd, depth: 1, task: "task", signal: new AbortController().signal });
541
+ const result = await run();
542
+ assert.equal(result.output, "你好\u2028🌍\ndone");
543
+ assert.equal(result.error, undefined);
544
+ assert.equal(result.usage.input, 12);
545
+ await writeFile(f.cli, `console.log(${JSON.stringify(JSON.stringify({ type: "message_end", message: { role: "assistant", content: [], stopReason: "error", errorMessage: "provider failed" } }))});`);
546
+ const failed = await run();
547
+ assert.equal(failed.exitCode, 0);
548
+ assert.equal(failed.error, "provider failed");
549
+ await writeFile(f.cli, 'process.stderr.write("startup failed"); process.exitCode = 3;');
550
+ assert.equal((await run()).exitCode, 3);
551
+ await writeFile(f.cli, 'console.log("not json");');
552
+ await assert.rejects(run(), /Invalid JSON/);
553
+ assert.match(limitOutput("x".repeat(60_000)), /truncated/);
554
+ });
555
+ });
556
+
557
+ test("real Pi CLI delegates to automatic and explicit small models without network or credentials", { timeout: 30_000 }, async () => {
558
+ await fixture(async (f) => {
559
+ for (const mode of ["lookup", "orchestrator", "exploration"]) {
560
+ const orchestrate = mode === "orchestrator";
561
+ const exploration = mode === "exploration";
562
+ await writeFile(join(f.agentDir, "agents.json"), JSON.stringify({ small_model: "piexis-test/small", delegate_exploration: true }));
563
+ const invocation = piInvocation();
564
+ const args = [...invocation.args, "--offline", "--no-session", "--mode", "json", "--no-extensions", "--no-skills", "--no-context-files", "--no-prompt-templates",
565
+ "-e", fileURLToPath(new URL("../extensions/agents.ts", import.meta.url)),
566
+ "-e", fileURLToPath(new URL("./fixtures/agents-provider.ts", import.meta.url)),
567
+ "--model", "piexis-test/main", "--tools", "subagent,read", "--", orchestrate ? "Use orchestrator for a lookup" : exploration ? "Delegate exploration" : "Delegate a lookup"];
568
+ const executionPromise = promisify(execFile)(invocation.command, args, {
569
+ cwd: f.cwd, env: { ...process.env, PI_CODING_AGENT_DIR: f.agentDir, PIEXIS_SUBAGENT_DEPTH: "0", PI_OFFLINE: "1" },
570
+ timeout: 25_000, maxBuffer: 4 * 1024 * 1024,
571
+ });
572
+ executionPromise.child.stdin.end();
573
+ const { stdout } = await executionPromise;
574
+ const events = stdout.trim().split("\n").map((line) => JSON.parse(line));
575
+ const execution = events.find((event) => event.type === "tool_execution_end" && event.toolName === "subagent");
576
+ assert.ok(execution, stdout);
577
+ assert.equal(execution.isError, false, JSON.stringify(execution));
578
+ assert.equal(execution.result.details.model, orchestrate ? "piexis-test/main" : "piexis-test/small");
579
+ assert.equal(execution.result.details.reason, orchestrate ? "default subagent" : exploration ? "configured exploration small model" : "automatic small task");
580
+ if (orchestrate) assert.match(execution.result.details.output, /child:small;depth:2/);
581
+ else assert.equal(execution.result.details.output, "child:small;depth:1");
582
+ assert.equal(execution.result.usage.input, orchestrate ? 30 : 10);
583
+ assert.ok(events.some((event) => event.type === "message_end" && event.message.content?.some((part) => part.text === "parent completed")));
584
+ }
585
+ });
586
+ });
587
+
588
+ test("abort and shutdown terminate running children; pre-aborted calls never start", { timeout: 10_000 }, async () => {
589
+ await fixture(async (f) => environment(f, async () => {
590
+ await writeFile(f.cli, 'process.on("SIGTERM", () => {}); console.log(JSON.stringify({type:"tool_execution_start",toolName:"read"})); setInterval(() => {}, 1000);');
591
+ const h = harness(f);
592
+ const pre = new AbortController(); pre.abort();
593
+ await assert.rejects(h.execute({ agent: "code", task: "task" }, pre.signal), { name: "AbortError" });
594
+ for (const shutdown of [false, true]) {
595
+ const controller = new AbortController();
596
+ let ready;
597
+ const started = new Promise((resolve) => { ready = resolve; });
598
+ const pending = h.execute({ agent: "code", task: "task" }, controller.signal, ready);
599
+ const rejected = assert.rejects(pending, /aborted/);
600
+ await started;
601
+ if (shutdown) await h.handlers.get("session_shutdown")(); else controller.abort();
602
+ await rejected;
603
+ }
604
+ }));
605
+ });
@@ -0,0 +1,16 @@
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/ask-question-terminal.mjs", import.meta.url));
9
+
10
+ for (const scenario of ["single", "multi", "text", "other", "navigation", "cancel", "ctrl-c", "abort"]) {
11
+ test(`real terminal: ${scenario}, focus restoration and cleanup`, { 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
+ }