gentle-pi 2.4.0 → 2.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.
Files changed (227) hide show
  1. package/README.md +292 -25
  2. package/assets/agents/gentle-ai-worker.md +13 -0
  3. package/assets/agents/jd-fix-agent.md +18 -0
  4. package/assets/agents/jd-judge-a.md +1 -1
  5. package/assets/agents/jd-judge-b.md +1 -1
  6. package/assets/agents/sdd-apply.md +7 -5
  7. package/assets/agents/sdd-archive.md +5 -3
  8. package/assets/agents/sdd-design.md +4 -0
  9. package/assets/agents/sdd-explore.md +4 -0
  10. package/assets/agents/sdd-init.md +4 -0
  11. package/assets/agents/sdd-onboard.md +4 -0
  12. package/assets/agents/sdd-proposal.md +4 -0
  13. package/assets/agents/sdd-remediate.md +37 -0
  14. package/assets/agents/sdd-research.md +26 -3
  15. package/assets/agents/sdd-spec.md +4 -0
  16. package/assets/agents/sdd-status.md +9 -75
  17. package/assets/agents/sdd-sync.md +4 -0
  18. package/assets/agents/sdd-tasks.md +4 -0
  19. package/assets/agents/sdd-verify.md +5 -3
  20. package/assets/chains/sdd-full.chain.md +4 -0
  21. package/assets/chains/sdd-plan.chain.md +4 -0
  22. package/assets/chains/sdd-verify.chain.md +4 -0
  23. package/assets/migrations/managed-assets-v2.5.0.json +7 -0
  24. package/assets/orchestrator-delegation.md +39 -11
  25. package/assets/orchestrator.md +5 -5
  26. package/assets/sdd-orchestrator-workflow.md +54 -21
  27. package/assets/support/sdd-status-contract.md +34 -90
  28. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  29. package/docs/delegated-verification.md +25 -0
  30. package/docs/telemetry.md +94 -0
  31. package/docs/windows-startup-console-visibility.md +18 -0
  32. package/extensions/ask-user-choice.ts +159 -25
  33. package/extensions/codegraph-tools.ts +95 -5
  34. package/extensions/gentle-agents.ts +1337 -0
  35. package/extensions/gentle-ai.ts +2916 -386
  36. package/extensions/gentle-shell.ts +650 -0
  37. package/extensions/gentle-todo.ts +234 -0
  38. package/extensions/quiet-tools.ts +2 -1
  39. package/extensions/runtime-metrics.ts +130 -0
  40. package/extensions/sdd-init.ts +2 -2
  41. package/extensions/startup-banner.ts +52 -75
  42. package/lib/agent-profiles.ts +550 -0
  43. package/lib/agents-completion-delivery.ts +72 -0
  44. package/lib/agents-config.ts +315 -0
  45. package/lib/agents-history.ts +88 -0
  46. package/lib/agents-messaging.ts +187 -0
  47. package/lib/agents-protocol.ts +501 -0
  48. package/lib/agents-runner.ts +1012 -0
  49. package/lib/agents-thread-view.ts +57 -0
  50. package/lib/agents-transcript.ts +87 -0
  51. package/lib/agents-view-layout.ts +40 -0
  52. package/lib/agents-view.ts +914 -0
  53. package/lib/agents-widget.ts +241 -0
  54. package/lib/gentle-ai-binary.ts +3 -1
  55. package/lib/gentle-ai-renderer.ts +143 -25
  56. package/lib/native-choice-list.ts +194 -0
  57. package/lib/native-fullscreen-interaction.ts +47 -0
  58. package/lib/native-pointer-region.ts +164 -0
  59. package/lib/native-review-cli.ts +371 -13
  60. package/lib/orchestrator-presence.ts +337 -0
  61. package/lib/profiles-orchestrator.ts +203 -0
  62. package/lib/review-candidate-view-owner.ts +427 -0
  63. package/lib/review-candidate-view.ts +150 -48
  64. package/lib/review-consent-component.ts +247 -0
  65. package/lib/review-consent-ui.ts +110 -0
  66. package/lib/review-host-relay.ts +28 -0
  67. package/lib/review-integration-v2.ts +243 -11
  68. package/lib/review-last-event-controller.ts +8 -4
  69. package/lib/review-relay-contract.ts +11 -0
  70. package/lib/review-reminder-receipt.ts +74 -0
  71. package/lib/review-repository.ts +2 -2
  72. package/lib/review-risk-assessment.ts +339 -0
  73. package/lib/review-session-standing-permission-ipc.ts +309 -0
  74. package/lib/review-session-standing-permission.ts +240 -0
  75. package/lib/runtime-metrics-children.ts +199 -0
  76. package/lib/runtime-metrics-delivery.ts +68 -0
  77. package/lib/runtime-metrics-native.ts +166 -0
  78. package/lib/runtime-metrics-pi-identity.ts +113 -0
  79. package/lib/runtime-metrics-policy.ts +51 -0
  80. package/lib/runtime-metrics.ts +255 -0
  81. package/lib/sdd-preflight.ts +362 -81
  82. package/lib/sdd-research-capabilities.ts +228 -0
  83. package/lib/sdd-status.ts +29 -7
  84. package/lib/session-worktree-registry.ts +118 -0
  85. package/lib/shell-bar.ts +184 -0
  86. package/lib/shell-card.ts +133 -0
  87. package/lib/shell-changes-view.ts +530 -0
  88. package/lib/shell-changes.ts +290 -0
  89. package/lib/shell-gauge.ts +40 -0
  90. package/lib/shell-prompt.ts +115 -0
  91. package/lib/shell-sidebar-banner.ts +11 -0
  92. package/lib/shell-sidebar-layout.ts +213 -0
  93. package/lib/shell-sidebar.ts +41 -0
  94. package/lib/shell-todo.ts +297 -0
  95. package/lib/shell-usage-view.ts +76 -0
  96. package/lib/shell-usage.ts +246 -0
  97. package/lib/telemetry-trigger.ts +153 -0
  98. package/package.json +8 -5
  99. package/runtime/gentle-ai-binary.mjs +3 -1
  100. package/runtime/native-review-cli.mjs +370 -12
  101. package/runtime/review-integration-v2.mjs +243 -11
  102. package/runtime/review-relay-contract.mjs +11 -0
  103. package/runtime/review-risk-assessment.mjs +340 -0
  104. package/runtime/telemetry-trigger.mjs +154 -0
  105. package/scripts/build-runtime-modules.mjs +11 -1
  106. package/scripts/check-types.mjs +125 -0
  107. package/scripts/gentle-ai-installer.mjs +10 -10
  108. package/scripts/install-gentle-ai.mjs +12 -0
  109. package/scripts/install-tui-mode-setting.mjs +114 -0
  110. package/scripts/test-packed-runner.mjs +38 -2
  111. package/scripts/types-baseline.json +99 -0
  112. package/scripts/verify-package-files.mjs +8 -2
  113. package/skills/_shared/review-ledger-contract.md +20 -2
  114. package/skills/issue-creation/SKILL.md +3 -3
  115. package/skills/judgment-day/SKILL.md +17 -3
  116. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  117. package/tests/agent-profiles.test.ts +722 -0
  118. package/tests/agents-completion-delivery.test.ts +94 -0
  119. package/tests/agents-config.test.ts +205 -0
  120. package/tests/agents-fake-child.ts +66 -0
  121. package/tests/agents-grouping.test.ts +179 -0
  122. package/tests/agents-history.test.ts +54 -0
  123. package/tests/agents-integration.test.ts +100 -0
  124. package/tests/agents-messaging.test.ts +94 -0
  125. package/tests/agents-protocol.test.ts +198 -0
  126. package/tests/agents-queries.test.ts +190 -0
  127. package/tests/agents-responsive.test.ts +43 -0
  128. package/tests/agents-runner-process.test.ts +111 -0
  129. package/tests/agents-runner.test.ts +959 -0
  130. package/tests/agents-thread-view.test.ts +45 -0
  131. package/tests/agents-transcript.test.ts +30 -0
  132. package/tests/agents-view.test.ts +685 -0
  133. package/tests/agents-widget.test.ts +141 -0
  134. package/tests/artifact-language.test.ts +25 -2
  135. package/tests/ask-user-choice.test.ts +325 -5
  136. package/tests/asset-installation-runtime.test.ts +108 -0
  137. package/tests/autonomous-guard.test.ts +116 -1
  138. package/tests/codegraph-tools.test.ts +112 -2
  139. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  140. package/tests/devbinary/native-review-parity.devtest.ts +110 -0
  141. package/tests/feature-request-form.test.ts +67 -0
  142. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  143. package/tests/fixtures/agents-process-child.mjs +23 -0
  144. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  145. package/tests/gentle-agents.test.ts +2168 -0
  146. package/tests/gentle-ai-binary.test.ts +7 -2
  147. package/tests/gentle-ai-installer.test.ts +47 -47
  148. package/tests/gentle-ai-renderer.test.ts +103 -0
  149. package/tests/gentle-ai.test.ts +971 -15
  150. package/tests/gentle-card-text.ts +35 -0
  151. package/tests/gentle-shell.test.ts +818 -0
  152. package/tests/gentle-todo.test.ts +226 -0
  153. package/tests/install-tui-mode-setting.test.ts +324 -0
  154. package/tests/issue-creation-skill.test.ts +22 -0
  155. package/tests/model-routing-authority.test.ts +12 -0
  156. package/tests/native-choice-list.test.ts +202 -0
  157. package/tests/native-fullscreen-interaction.test.ts +125 -0
  158. package/tests/native-pointer-region.test.ts +245 -0
  159. package/tests/native-review-capability-contract.test.ts +27 -1
  160. package/tests/native-review-cli.test.ts +317 -3
  161. package/tests/native-review-consent.test.ts +91 -0
  162. package/tests/native-review-parity-runtime.test.ts +8 -2
  163. package/tests/native-review-parity.test.ts +43 -29
  164. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  165. package/tests/orchestrator-budget.test.ts +69 -0
  166. package/tests/orchestrator-presence.test.ts +389 -0
  167. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  168. package/tests/package-manifest.test.ts +243 -7
  169. package/tests/profiles-orchestrator.test.ts +208 -0
  170. package/tests/quiet-tool-rendering.test.ts +97 -37
  171. package/tests/rdd-aware-verification-contract.test.ts +226 -0
  172. package/tests/rdd-status-line.test.ts +286 -0
  173. package/tests/review-agent-end-preflight.test.ts +332 -24
  174. package/tests/review-candidate-view.test.ts +751 -7
  175. package/tests/review-consent-ui.test.ts +352 -0
  176. package/tests/review-contract-prompt.test.ts +17 -0
  177. package/tests/review-controller-native-recovery.test.ts +29 -4
  178. package/tests/review-controller-native-routing.test.ts +884 -7
  179. package/tests/review-controller-workspace-root.test.ts +45 -2
  180. package/tests/review-controller.test.ts +26 -1
  181. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  182. package/tests/review-host-relay-routing.test.ts +384 -8
  183. package/tests/review-host-relay.test.ts +29 -0
  184. package/tests/review-integration-v2-forward.test.ts +44 -0
  185. package/tests/review-integration-v2.test.ts +276 -0
  186. package/tests/review-last-event-closure.test.ts +112 -3
  187. package/tests/review-ledger-contract.test.ts +61 -6
  188. package/tests/review-relay-contract.test.ts +26 -0
  189. package/tests/review-reminder-receipt.test.ts +62 -0
  190. package/tests/review-repository.test.ts +28 -1
  191. package/tests/review-risk-assessment.test.ts +626 -0
  192. package/tests/review-session-standing-permission-controller.test.ts +656 -0
  193. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  194. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  195. package/tests/review-session-standing-permission.test.ts +156 -0
  196. package/tests/runtime-harness.mjs +447 -39
  197. package/tests/runtime-metrics-children.test.ts +206 -0
  198. package/tests/runtime-metrics-delivery.test.ts +85 -0
  199. package/tests/runtime-metrics-extension.test.ts +187 -0
  200. package/tests/runtime-metrics-native.test.ts +209 -0
  201. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  202. package/tests/runtime-metrics-policy.test.ts +62 -0
  203. package/tests/runtime-metrics.test.ts +184 -0
  204. package/tests/sdd-agent-tools.test.ts +10 -1
  205. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  206. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  207. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  208. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  209. package/tests/sdd-preflight.test.ts +252 -8
  210. package/tests/sdd-research-capabilities.test.ts +256 -0
  211. package/tests/sdd-research-live.test.ts +241 -0
  212. package/tests/sdd-selection-transport.test.ts +504 -0
  213. package/tests/sdd-status.test.ts +51 -0
  214. package/tests/session-worktree-registry.test.ts +135 -0
  215. package/tests/shell-bar.test.ts +176 -0
  216. package/tests/shell-card.test.ts +139 -0
  217. package/tests/shell-changes-view.test.ts +609 -0
  218. package/tests/shell-changes.test.ts +350 -0
  219. package/tests/shell-prompt.test.ts +140 -0
  220. package/tests/shell-sidebar-banner.test.ts +23 -0
  221. package/tests/shell-sidebar-layout.test.ts +387 -0
  222. package/tests/shell-sidebar.test.ts +50 -0
  223. package/tests/shell-todo.test.ts +259 -0
  224. package/tests/shell-usage-view.test.ts +62 -0
  225. package/tests/shell-usage.test.ts +197 -0
  226. package/tests/startup-banner.test.ts +126 -0
  227. package/tests/telemetry-trigger.test.ts +351 -0
@@ -0,0 +1,94 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createCompletionQueue, STALE_COMPLETION_MS } from "../lib/agents-completion-delivery.ts";
4
+
5
+ // The completion queue owns Gentle Agents' pending background completions so a
6
+ // completion is delivered at most once, promptly, and never replays state the
7
+ // parent already consumed (issue #867).
8
+
9
+ interface FakeTask {
10
+ id: string;
11
+ }
12
+
13
+ const task = (id: string): FakeTask => ({ id });
14
+
15
+ test("STALE_COMPLETION_MS is pinned so tests can rely on the boundary", () => {
16
+ assert.equal(STALE_COMPLETION_MS, 90_000);
17
+ });
18
+
19
+ test("a pending completion is delivered exactly once, in settle order", () => {
20
+ const queue = createCompletionQueue<FakeTask>();
21
+ queue.enqueue(task("t1"), 1000);
22
+ queue.enqueue(task("t2"), 2000);
23
+ const first = queue.takeDeliverable(2000);
24
+ assert.deepEqual(first.map((entry) => entry.task.id), ["t1", "t2"]);
25
+ assert.equal(first[0]!.settledAt, 1000);
26
+ assert.equal(first[0]!.stale, false, "a completion inside the stale window is fresh");
27
+ assert.deepEqual(queue.takeDeliverable(2000), [], "a taken entry is never returned twice");
28
+ });
29
+
30
+ test("duplicate enqueue for a pending task id is a no-op", () => {
31
+ const queue = createCompletionQueue<FakeTask>();
32
+ queue.enqueue(task("t1"), 1000);
33
+ queue.enqueue(task("t1"), 5000);
34
+ assert.deepEqual(queue.takeDeliverable(5000).map((entry) => entry.task.id), ["t1"]);
35
+ });
36
+
37
+ test("duplicate enqueue for a delivered task id is a no-op", () => {
38
+ const queue = createCompletionQueue<FakeTask>();
39
+ queue.enqueue(task("t1"), 1000);
40
+ assert.equal(queue.takeDeliverable(1000).length, 1);
41
+ queue.enqueue(task("t1"), 5000);
42
+ assert.deepEqual(queue.takeDeliverable(5000), []);
43
+ });
44
+
45
+ test("duplicate enqueue for a consumed task id is a no-op", () => {
46
+ const queue = createCompletionQueue<FakeTask>();
47
+ queue.consume("t1");
48
+ queue.enqueue(task("t1"), 1000);
49
+ assert.deepEqual(queue.takeDeliverable(5000), []);
50
+ });
51
+
52
+ test("a consumed entry is dropped and never returned", () => {
53
+ const queue = createCompletionQueue<FakeTask>();
54
+ queue.enqueue(task("t1"), 1000);
55
+ queue.enqueue(task("t2"), 1000);
56
+ queue.consume("t1");
57
+ assert.deepEqual(queue.takeDeliverable(2000).map((entry) => entry.task.id), ["t2"]);
58
+ });
59
+
60
+ test("consume remembers unknown ids so a later completion is suppressed", () => {
61
+ const queue = createCompletionQueue<FakeTask>();
62
+ queue.consume("t9");
63
+ queue.enqueue(task("t9"), 1000);
64
+ assert.deepEqual(queue.takeDeliverable(2000), []);
65
+ });
66
+
67
+ test("consume is idempotent", () => {
68
+ const queue = createCompletionQueue<FakeTask>();
69
+ queue.consume("t1");
70
+ queue.consume("t1");
71
+ queue.enqueue(task("t1"), 1000);
72
+ assert.deepEqual(queue.takeDeliverable(2000), []);
73
+ });
74
+
75
+ test("an entry is stale once now - settledAt reaches STALE_COMPLETION_MS", () => {
76
+ const fresh = createCompletionQueue<FakeTask>();
77
+ fresh.enqueue(task("t1"), 1000);
78
+ assert.equal(fresh.takeDeliverable(1000 + STALE_COMPLETION_MS - 1)[0]!.stale, false);
79
+ const stale = createCompletionQueue<FakeTask>();
80
+ stale.enqueue(task("t1"), 1000);
81
+ assert.equal(stale.takeDeliverable(1000 + STALE_COMPLETION_MS)[0]!.stale, true);
82
+ });
83
+
84
+ test("dropAll discards pending, consumed, and delivered state", () => {
85
+ const queue = createCompletionQueue<FakeTask>();
86
+ queue.enqueue(task("t1"), 1000);
87
+ assert.equal(queue.takeDeliverable(1000).length, 1, "delivered bookkeeping exists");
88
+ queue.enqueue(task("t2"), 1000);
89
+ queue.consume("t3");
90
+ queue.dropAll();
91
+ assert.deepEqual(queue.takeDeliverable(5000), [], "nothing pending survives dropAll");
92
+ queue.enqueue(task("t1"), 5000);
93
+ assert.deepEqual(queue.takeDeliverable(5000).map((entry) => entry.task.id), ["t1"], "the queue is fresh after dropAll");
94
+ });
@@ -0,0 +1,205 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test, { after } from "node:test";
6
+ import {
7
+ AGENT_MODE,
8
+ THINKING_LEVEL,
9
+ agentDirectories,
10
+ discoverAgents,
11
+ loadAgentsConfig,
12
+ parseAgentDefinition,
13
+ parseAgentsConfig,
14
+ parseFrontmatter,
15
+ parseModelRef,
16
+ resolveAgentProfile,
17
+ } from "../lib/agents-config.ts";
18
+ import { THINKING_LEVELS } from "../lib/model-routing-authority.ts";
19
+
20
+ // Gentle Agents configuration: markdown agent definitions (the same files
21
+ // gentle-ai installs) and subagents.json, both parsed without touching pi.
22
+
23
+ const root = mkdtempSync(join(tmpdir(), "gentle-agents-config-"));
24
+ after(() => rmSync(root, { recursive: true, force: true }));
25
+
26
+ const EXPLORER = `---
27
+ name: gentle-ai-explore
28
+ description: Read-only exploration and mapping.
29
+ model: openai-codex/gpt-5.6-terra
30
+ thinking: high
31
+ tools:
32
+ - read
33
+ - grep
34
+ - codegraph
35
+ ---
36
+
37
+ You are the read-only explorer.
38
+ Map files and return a compressed handoff.
39
+ `;
40
+
41
+ test("parseFrontmatter reads scalars, inline lists, and block lists, and keeps the body", () => {
42
+ const { data, body } = parseFrontmatter("---\nname: a\ntools: [read, grep]\nlist:\n - one\n - two\nquoted: \"x: y\"\n---\nBody here\n");
43
+ assert.deepEqual(data, { name: "a", tools: ["read", "grep"], list: ["one", "two"], quoted: "x: y" });
44
+ assert.equal(body, "Body here");
45
+ assert.deepEqual(parseFrontmatter("no frontmatter"), { data: {}, body: "no frontmatter" });
46
+ });
47
+
48
+ test("parseModelRef splits provider/id and accepts a bare id", () => {
49
+ assert.deepEqual(parseModelRef("openai-codex/gpt-5.6-terra"), { provider: "openai-codex", id: "gpt-5.6-terra" });
50
+ assert.deepEqual(parseModelRef("sonnet"), { provider: undefined, id: "sonnet" });
51
+ assert.equal(parseModelRef(" "), undefined);
52
+ });
53
+
54
+ test("parseAgentDefinition builds a definition from the gentle-ai agent format", () => {
55
+ const agent = parseAgentDefinition(EXPLORER, "/home/x/.pi/agent/agents/gentle-ai-explore.md", "global");
56
+ assert.ok(!("error" in agent));
57
+ assert.equal(agent.name, "gentle-ai-explore");
58
+ assert.equal(agent.description, "Read-only exploration and mapping.");
59
+ assert.deepEqual(agent.model, { provider: "openai-codex", id: "gpt-5.6-terra" });
60
+ assert.equal(agent.thinking, "high");
61
+ assert.deepEqual(agent.tools, ["read", "grep", "codegraph"]);
62
+ assert.equal(agent.mode, undefined);
63
+ assert.equal(agent.scope, "global");
64
+ assert.match(agent.instructions, /^You are the read-only explorer\./);
65
+ });
66
+
67
+ test("parseAgentDefinition accepts effort and subagent_mode aliases, csv tools, and names from the file", () => {
68
+ const agent = parseAgentDefinition("---\ndescription: d\neffort: low\nsubagent_mode: background\ntools: read, bash\n---\nbody", "/p/.pi/agents/worker.md", "project");
69
+ assert.ok(!("error" in agent));
70
+ assert.equal(agent.name, "worker");
71
+ assert.equal(agent.thinking, "low");
72
+ assert.equal(agent.mode, AGENT_MODE.BACKGROUND);
73
+ assert.deepEqual(agent.tools, ["read", "bash"]);
74
+ });
75
+
76
+ test("parseAgentDefinition rejects unknown thinking levels, modes, and empty bodies", () => {
77
+ assert.match((parseAgentDefinition("---\nname: a\nthinking: extreme\n---\nbody", "/a.md", "global") as { error: string }).error, /thinking "extreme"/);
78
+ assert.match((parseAgentDefinition("---\nname: a\nsubagent_mode: forever\n---\nbody", "/a.md", "global") as { error: string }).error, /mode "forever"/);
79
+ assert.match((parseAgentDefinition("---\nname: a\n---\n \n", "/a.md", "global") as { error: string }).error, /no instructions/);
80
+ });
81
+
82
+ test("runtime thinking levels stay aligned with model routing authority", () => {
83
+ assert.deepEqual(Object.values(THINKING_LEVEL), THINKING_LEVELS);
84
+ assert.equal(THINKING_LEVEL.MAX, "max");
85
+ });
86
+
87
+ test("parseAgentDefinition accepts max through every thinking alias", () => {
88
+ for (const field of ["thinking", "effort", "thinking_level"]) {
89
+ const agent = parseAgentDefinition(`---\nname: worker\n${field}: max\n---\nbody`, "/worker.md", "global");
90
+ assert.ok(!("error" in agent), `${field} must accept max`);
91
+ assert.equal(agent.thinking, "max");
92
+ }
93
+ });
94
+
95
+ test("discoverAgents retains max definitions instead of falling back to medium", () => {
96
+ const home = join(root, "max-home");
97
+ const cwd = join(root, "max-project");
98
+ const agentHome = join(home, ".pi", "agent");
99
+ for (const dir of ["agents", "subagents"]) mkdirSync(join(agentHome, dir), { recursive: true });
100
+ writeFileSync(join(agentHome, "agents", "worker.md"), "---\nname: worker\nthinking: medium\n---\nlegacy worker");
101
+ writeFileSync(join(agentHome, "subagents", "worker.md"), "---\nname: worker\nthinking: max\n---\nselected worker");
102
+ writeFileSync(join(agentHome, "subagents.json"), JSON.stringify({ model_profiles: { worker: { model: "openai-codex/gpt-5.6-luna", effort: "max" } } }));
103
+ const { agents, errors } = discoverAgents({ cwd, home });
104
+ assert.deepEqual(errors, []);
105
+ assert.equal(agents.length, 1);
106
+ assert.equal(agents[0].thinking, "max");
107
+ assert.equal(agents[0].instructions, "selected worker");
108
+ const resolved = resolveAgentProfile(agents[0], loadAgentsConfig({ cwd, home }));
109
+ assert.equal(resolved.model?.id, "gpt-5.6-luna");
110
+ assert.equal(resolved.thinking, "max");
111
+ assert.equal(resolved.source.thinking, "profile");
112
+ });
113
+
114
+ test("max defaults and model profiles preserve routing precedence", () => {
115
+ const bare = parseAgentDefinition("---\nname: worker\n---\nbody", "/worker.md", "global");
116
+ const medium = parseAgentDefinition("---\nname: worker\nthinking: medium\n---\nbody", "/worker.md", "global");
117
+ assert.ok(!("error" in bare));
118
+ assert.ok(!("error" in medium));
119
+ for (const field of ["default_effort", "default_thinking_level", "default_thinking"]) {
120
+ const config = parseAgentsConfig({ [field]: "max" }, undefined);
121
+ assert.equal(config.defaultThinking, "max");
122
+ assert.equal(resolveAgentProfile(bare, config).thinking, "max");
123
+ assert.equal(resolveAgentProfile(bare, config).source.thinking, "default");
124
+ assert.equal(resolveAgentProfile(medium, config).thinking, "medium");
125
+ }
126
+ for (const field of ["effort", "thinking"]) {
127
+ const global = { model_profiles: { worker: { [field]: "max" } } };
128
+ const config = parseAgentsConfig(global, { model_profiles: { worker: { model: "openai-codex/gpt-5.6-luna" } } });
129
+ assert.equal(config.modelProfiles.worker.thinking, "max");
130
+ assert.equal(resolveAgentProfile(medium, config).thinking, "max");
131
+ assert.equal(resolveAgentProfile(medium, config).source.thinking, "profile");
132
+ }
133
+ const override = parseAgentsConfig(
134
+ { model_profiles: { worker: { model: "openai-codex/gpt-5.6-luna", effort: "high" } } },
135
+ { model_profiles: { worker: { effort: "max" } } },
136
+ );
137
+ assert.equal(override.modelProfiles.worker.thinking, "max");
138
+ assert.equal(resolveAgentProfile(medium, override).thinking, "max");
139
+ assert.equal(resolveAgentProfile(medium, override).model?.id, "gpt-5.6-luna");
140
+ });
141
+
142
+ test("discoverAgents merges the four directories with project over global and subagents over agents", () => {
143
+ const home = join(root, "home");
144
+ const cwd = join(root, "project");
145
+ for (const dir of [".pi/agent/agents", ".pi/agent/subagents"]) mkdirSync(join(home, dir), { recursive: true });
146
+ for (const dir of [".pi/agents", ".pi/subagents"]) mkdirSync(join(cwd, dir), { recursive: true });
147
+ writeFileSync(join(home, ".pi/agent/agents/explore.md"), EXPLORER);
148
+ writeFileSync(join(home, ".pi/agent/agents/shared.md"), "---\ndescription: global agents\n---\nglobal");
149
+ writeFileSync(join(home, ".pi/agent/subagents/shared.md"), "---\ndescription: global subagents\n---\nglobal sub");
150
+ writeFileSync(join(cwd, ".pi/agents/shared.md"), "---\ndescription: project agents\n---\nproject");
151
+ writeFileSync(join(cwd, ".pi/agents/broken.md"), "---\nthinking: nope\n---\nx");
152
+ writeFileSync(join(cwd, ".pi/agents/notes.txt"), "ignored");
153
+ const { agents, errors } = discoverAgents({ cwd, home });
154
+ assert.deepEqual(agents.map((agent) => `${agent.name}:${agent.description}:${agent.scope}`), ["gentle-ai-explore:Read-only exploration and mapping.:global", "shared:project agents:project"]);
155
+ assert.equal(errors.length, 1);
156
+ assert.match(errors[0], /broken\.md/);
157
+ assert.deepEqual(discoverAgents({ cwd: join(root, "empty"), home: join(root, "nohome") }), { agents: [], errors: [] });
158
+ });
159
+
160
+ test("profile agent roots isolate global definitions and subagents.json while explicit homes keep their fallback", () => {
161
+ const cwd = join(root, "profile-project");
162
+ const principal = join(root, "pi-principal", "agent");
163
+ const lab = join(root, "pi-lab", "agent");
164
+ for (const [agentHome, name, model] of [[principal, "principal", "openai/principal"], [lab, "lab", "openai/lab"]] as const) {
165
+ mkdirSync(join(agentHome, "agents"), { recursive: true });
166
+ writeFileSync(join(agentHome, "agents", `${name}.md`), `---\ndescription: ${name}\n---\n${name}`);
167
+ writeFileSync(join(agentHome, "subagents.json"), JSON.stringify({ default_model: model }));
168
+ }
169
+ assert.deepEqual(agentDirectories({ cwd, home: join(root, "legacy-home"), agentHome: principal }).slice(0, 2).map(({ dir }) => dir), [join(principal, "agents"), join(principal, "subagents")]);
170
+ assert.deepEqual(discoverAgents({ cwd, home: join(root, "legacy-home"), agentHome: principal }).agents.map((agent) => agent.name), ["principal"]);
171
+ assert.deepEqual(discoverAgents({ cwd, home: join(root, "legacy-home"), agentHome: lab }).agents.map((agent) => agent.name), ["lab"]);
172
+ assert.equal(loadAgentsConfig({ cwd, home: join(root, "legacy-home"), agentHome: principal }).defaultModel?.id, "principal");
173
+ assert.equal(loadAgentsConfig({ cwd, home: join(root, "legacy-home"), agentHome: lab }).defaultModel?.id, "lab");
174
+ assert.equal(agentDirectories({ cwd, home: join(root, "legacy-home") })[0].dir, join(root, "legacy-home", ".pi", "agent", "agents"));
175
+ });
176
+
177
+ test("parseAgentsConfig applies defaults, validates values, and silently ignores the retired total-timeout key", () => {
178
+ const config = parseAgentsConfig({ default_model: "openai-codex/gpt-6-astra", default_effort: "medium", max_concurrency: 3, timeout_ms: 1000, stall_timeout_ms: 12_000, model_profiles: { explore: { model: "openai-codex/gpt-5.6-terra", effort: "high" } } }, { max_concurrency: 2, timeout_ms: 500, model_profiles: { explore: { effort: "low" }, worker: { model: "anthropic/claude-sonnet-5" } } });
179
+ assert.deepEqual(config.defaultModel, { provider: "openai-codex", id: "gpt-6-astra" });
180
+ assert.equal(config.defaultThinking, "medium");
181
+ assert.equal(config.maxConcurrency, 2);
182
+ assert.equal(config.stallTimeoutMs, 12_000);
183
+ assert.equal("timeoutMs" in config, false, "legacy timeout_ms must not become an active runtime setting");
184
+ assert.deepEqual(config.modelProfiles.explore, { model: { provider: "openai-codex", id: "gpt-5.6-terra" }, thinking: "low" });
185
+ assert.deepEqual(config.modelProfiles.worker, { model: { provider: "anthropic", id: "claude-sonnet-5" }, thinking: undefined });
186
+ const defaults = parseAgentsConfig(undefined, undefined);
187
+ assert.equal(defaults.maxConcurrency, 5);
188
+ assert.equal("timeoutMs" in defaults, false);
189
+ assert.equal(defaults.stallTimeoutMs, 4 * 60_000);
190
+ assert.equal(defaults.defaultMode, AGENT_MODE.TASK);
191
+ assert.equal(defaults.historyMaxTasks, 200);
192
+ assert.equal(parseAgentsConfig({ max_concurrency: "many", default_effort: "wild", default_mode: "background" }, undefined).maxConcurrency, 5);
193
+ assert.equal(parseAgentsConfig({ default_mode: "background" }, undefined).defaultMode, AGENT_MODE.BACKGROUND);
194
+ });
195
+
196
+ test("resolveAgentProfile prefers the profile, then the definition, then the defaults", () => {
197
+ const config = parseAgentsConfig({ default_model: "openai-codex/gpt-6-astra", default_effort: "medium", model_profiles: { "gentle-ai-explore": { effort: "high" } } }, undefined);
198
+ const explore = parseAgentDefinition(EXPLORER, "/x/explore.md", "global");
199
+ assert.ok(!("error" in explore));
200
+ assert.deepEqual(resolveAgentProfile(explore, config), { model: { provider: "openai-codex", id: "gpt-5.6-terra" }, thinking: "high", source: { model: "definition", thinking: "profile" } });
201
+ const bare = parseAgentDefinition("---\nname: bare\n---\nbody", "/x/bare.md", "global");
202
+ assert.ok(!("error" in bare));
203
+ assert.deepEqual(resolveAgentProfile(bare, config), { model: { provider: "openai-codex", id: "gpt-6-astra" }, thinking: "medium", source: { model: "default", thinking: "default" } });
204
+ assert.deepEqual(resolveAgentProfile(bare, parseAgentsConfig(undefined, undefined)).source, { model: "unresolved", thinking: "unresolved" });
205
+ });
@@ -0,0 +1,66 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { PassThrough } from "node:stream";
3
+ import type { ChildLike } from "../lib/agents-runner.ts";
4
+
5
+ // A fake `pi --mode rpc` child: answers every command with a success
6
+ // response, records what the host wrote, and lets tests emit events.
7
+
8
+ export interface FakeChild {
9
+ child: ChildLike;
10
+ written: Array<Record<string, unknown>>;
11
+ emit(event: Record<string, unknown>): void;
12
+ exit(code: number): void;
13
+ fail(message: string): void;
14
+ killed: string[];
15
+ sent: Array<Record<string, unknown>>;
16
+ disconnects: number;
17
+ message(event: Record<string, unknown>): void;
18
+ }
19
+
20
+ export function fakeChild(options: { exitOnKill?: boolean; pid?: number } = {}): FakeChild {
21
+ const emitter = new EventEmitter();
22
+ const stdin = new PassThrough();
23
+ const stdout = new PassThrough();
24
+ const written: Array<Record<string, unknown>> = [];
25
+ const killed: string[] = [];
26
+ const sent: Array<Record<string, unknown>> = [];
27
+ let disconnects = 0;
28
+ let buffer = "";
29
+ stdin.on("data", (chunk: Buffer) => {
30
+ buffer += chunk.toString();
31
+ const lines = buffer.split("\n");
32
+ buffer = lines.pop() ?? "";
33
+ for (const line of lines) {
34
+ const command = JSON.parse(line) as Record<string, unknown>;
35
+ written.push(command);
36
+ if (command.type === "extension_ui_response") continue;
37
+ const data = command.type === "get_state" ? { sessionFile: "/sessions/child.jsonl" } : undefined;
38
+ stdout.write(`${JSON.stringify({ type: "response", id: command.id, command: command.type, success: true, data })}\n`);
39
+ }
40
+ });
41
+ const child: ChildLike = {
42
+ pid: options.pid,
43
+ stdin,
44
+ stdout,
45
+ stderr: new PassThrough(),
46
+ kill: (signal) => {
47
+ killed.push(String(signal ?? "SIGTERM"));
48
+ if (options.exitOnKill !== false) queueMicrotask(() => emitter.emit("exit", 0, signal ?? "SIGTERM"));
49
+ return true;
50
+ },
51
+ send: (message, callback) => {
52
+ sent.push(message);
53
+ callback?.(null);
54
+ return true;
55
+ },
56
+ disconnect: () => {
57
+ disconnects += 1;
58
+ emitter.emit("disconnect");
59
+ },
60
+ on: (event, listener) => {
61
+ emitter.on(event, listener);
62
+ return child;
63
+ },
64
+ };
65
+ return { child, written, killed, sent, get disconnects() { return disconnects; }, message: (event) => emitter.emit("message", event), emit: (event) => stdout.write(`${JSON.stringify(event)}\n`), exit: (code) => emitter.emit("exit", code, null), fail: (message) => emitter.emit("error", new Error(message)) };
66
+ }
@@ -0,0 +1,179 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { type TuiMouseEvent } from "@earendil-works/pi-tui";
4
+ import { TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts";
5
+ import { AgentsView, SESSION_FINISHED_TTL_MS } from "../lib/agents-view.ts";
6
+ import { stripAnsi } from "../lib/terminal-theme.ts";
7
+
8
+ const plainTheme = { fg: (_color: string, text: string) => text };
9
+
10
+ function task(id: string, parentSessionId: string, overrides: Partial<TaskRecord> = {}): TaskRecord {
11
+ return {
12
+ id,
13
+ agent: "worker",
14
+ mode: "task",
15
+ prompt: "p",
16
+ label: "p",
17
+ cwd: "/r",
18
+ parentSessionId,
19
+ status: TASK_STATUS.RUNNING,
20
+ createdAt: 1000,
21
+ startedAt: 1000,
22
+ endedAt: null,
23
+ model: "gpt",
24
+ thinking: undefined,
25
+ sessionPath: "/sessions/child.jsonl",
26
+ error: null,
27
+ result: null,
28
+ lastStep: "working",
29
+ lastActivityAt: 1000,
30
+ turns: 0,
31
+ toolCalls: 0,
32
+ tokens: 0,
33
+ cost: 0,
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ function harness(rows = 12, now = () => 10_000) {
39
+ const store = new TaskStore();
40
+ const view = new AgentsView({
41
+ theme: plainTheme,
42
+ rows,
43
+ store,
44
+ sessionId: "current-session-id",
45
+ now,
46
+ onCancel: () => {},
47
+ onOpen: () => {},
48
+ onClose: () => {},
49
+ requestRender: () => {},
50
+ });
51
+ return { store, view };
52
+ }
53
+
54
+ function mouse(x: number, y: number, width: number, height: number, type: TuiMouseEvent["type"] = "move"): TuiMouseEvent {
55
+ return { type, button: type === "move" ? "none" : "left", x, y, screenX: x, screenY: y, width, height, shift: false, alt: false, ctrl: false };
56
+ }
57
+
58
+ test("AgentsView shows direct current children and groups only open orchestrators in the directory", () => {
59
+ const { store, view } = harness();
60
+ store.add(task("current", "current-session-id", { agent: "current-worker" }));
61
+ store.add(task("other", "other-session-123456", { agent: "other-worker" }));
62
+
63
+ const plain = view.render(100).map(stripAnsi).join("\n");
64
+ assert.doesNotMatch(plain, /Current orchestrator/);
65
+ assert.match(plain, /Subagent current-worker/);
66
+ assert.doesNotMatch(plain, /other-worker/, "the default scope excludes another parent session");
67
+ view.handleInput("a");
68
+ assert.doesNotMatch(view.render(100).map(stripAnsi).join("\n"), /Orchestrator other-se/);
69
+ assert.match(view.render(100).join("\n"), /Current orchestrator/);
70
+ view.dispose();
71
+ });
72
+
73
+ test("AgentsView never infers unknown open sessions and keeps manual directory expansion", () => {
74
+ const { store, view } = harness();
75
+ store.add(task("current", "current-session-id", { lastActivityAt: 500 }));
76
+ store.add(task("other", "other-session-123456", { lastActivityAt: 400 }));
77
+ store.add(task("unknown-active", "", { agent: "unknown-active", lastActivityAt: 300 }));
78
+ store.add(task("unknown-terminal", "", { agent: "unknown-terminal", status: TASK_STATUS.COMPLETED, endedAt: 9000, lastActivityAt: 200 }));
79
+ view.handleInput("a");
80
+
81
+ let lines = view.render(100);
82
+ let plain = lines.map(stripAnsi).join("\n");
83
+ assert.doesNotMatch(plain, /Unknown session|unknown-active|other-worker/, "retained origins are not presence evidence");
84
+ assert.doesNotMatch(plain, /unknown-terminal/, "a terminal-only group starts collapsed");
85
+ const terminalHeading = lines.map(stripAnsi).findIndex((line) => /Current orchestrator/.test(line));
86
+ assert.equal(view.handleMouse(mouse(4, terminalHeading, 100, lines.length, "click"))?.handled, true, "left click toggles a heading");
87
+ assert.doesNotMatch(view.render(100).map(stripAnsi).join("\n"), /Subagent worker/, "click collapses the local directory group");
88
+ store.update("unknown-terminal", { status: TASK_STATUS.FAILED });
89
+ assert.doesNotMatch(view.render(100).map(stripAnsi).join("\n"), /Subagent worker/, "manual collapse persists across unrelated updates");
90
+ view.handleMouse(mouse(4, terminalHeading, 100, lines.length, "click"));
91
+ store.update("unknown-terminal", { status: TASK_STATUS.CANCELLED });
92
+ assert.match(view.render(100).map(stripAnsi).join("\n"), /Subagent worker/, "manual expansion persists across unrelated updates");
93
+ assert.doesNotMatch(view.render(100).join("\n"), /unknown-termina/, "unknown terminal history remains excluded");
94
+ view.dispose();
95
+ });
96
+
97
+ test("AgentsView keeps headings non-actionable and clears a hidden selected child thread", () => {
98
+ const { store, view } = harness();
99
+ store.add(task("first", "current-session-id", { createdAt: 2000, lastActivityAt: 200 }));
100
+ store.add(task("child", "current-session-id", { lastActivityAt: 100 }));
101
+ assert.equal(view.selectedTask()?.id, "first", "the first visible child starts selected");
102
+ view.handleInput("a");
103
+ view.handleInput("k");
104
+ view.handleInput("\x1b[D");
105
+ assert.equal(view.selectedTask(), undefined, "collapsing selects the heading instead of a hidden child");
106
+ assert.match(view.render(100).map(stripAnsi).join("\n"), /Select a task to inspect its thread/);
107
+ view.handleInput("\x1b[C");
108
+ assert.equal(view.selectedTask(), undefined, "expanding preserves heading focus until a child is selected");
109
+ view.handleInput("s");
110
+ view.handleInput("o");
111
+ view.dispose();
112
+ });
113
+
114
+ test("AgentsView removes terminal children immediately without deleting retained threads", () => {
115
+ let now = 10_000;
116
+ const { store, view } = harness(12, () => now);
117
+ store.add(task("live", "current-session-id", { agent: "retained" }));
118
+ store.add(task("other", "other-session", { agent: "excluded" }));
119
+ store.apply("live", { type: "text", text: "kept thread" }, now);
120
+ store.update("live", { status: TASK_STATUS.COMPLETED, endedAt: now });
121
+ for (const elapsed of [0, SESSION_FINISHED_TTL_MS - 1]) {
122
+ now = 10_000 + elapsed;
123
+ const output = view.render(100).map(stripAnsi).join("\n");
124
+ assert.doesNotMatch(output, /Subagent retained|kept thread/);
125
+ assert.equal(store.thread("live").items.length, 1, "retained thread remains available outside the panel");
126
+ assert.doesNotMatch(output, /excluded/);
127
+ assert.equal(view.selectedTask(), undefined);
128
+ }
129
+ now = 10_000 + SESSION_FINISHED_TTL_MS;
130
+ assert.doesNotMatch(view.render(100).join("\n"), /Subagent retained/);
131
+ assert.equal(view.selectedTask(), undefined);
132
+ store.add(task("archive", "current-session-id", { agent: "archived", status: TASK_STATUS.COMPLETED, endedAt: now }));
133
+ assert.doesNotMatch(view.render(100).join("\n"), /Subagent archived/, "absent groups lose implicit expansion");
134
+ view.dispose();
135
+ });
136
+
137
+ test("AgentsView retains an idle open heading and explicit collapse after the final child finishes", () => {
138
+ const { store, view } = harness();
139
+ store.add(task("live", "current-session-id", { agent: "hidden" }));
140
+ view.handleInput("a");
141
+ view.render(100);
142
+ view.handleInput("k");
143
+ view.handleInput("\x1b[D");
144
+ store.update("live", { status: TASK_STATUS.COMPLETED, endedAt: 10_000 });
145
+ assert.doesNotMatch(view.render(100).join("\n"), /Subagent hidden/);
146
+ view.handleInput("\x1b[C");
147
+ assert.doesNotMatch(view.render(100).join("\n"), /Subagent hidden/);
148
+ assert.match(view.render(100).join("\n"), /Current orchestrator.*0/);
149
+ store.add(task("replacement", "current-session-id", { agent: "replacement" }));
150
+ assert.match(view.render(100).join("\n"), /Subagent replacement/);
151
+ view.dispose();
152
+ });
153
+
154
+ test("AgentsView orders children by creation then ID, not renewed activity", () => {
155
+ const { store, view } = harness();
156
+ store.add(task("old", "current-session-id", { agent: "old", createdAt: 100 }));
157
+ store.add(task("b", "current-session-id", { agent: "new-b", createdAt: 200 }));
158
+ store.add(task("a", "current-session-id", { agent: "new-a", createdAt: 200 }));
159
+ store.update("old", { lastActivityAt: 20_000 });
160
+ const output = view.render(100).join("\n");
161
+ assert.ok(output.indexOf("Subagent new-a") < output.indexOf("Subagent new-b"));
162
+ assert.ok(output.indexOf("Subagent new-b") < output.indexOf("Subagent old"));
163
+ assert.equal(view.selectedTask()?.id, "old", "reordering preserves selection");
164
+ view.dispose();
165
+ });
166
+
167
+ test("AgentsView keeps its selected listener stable while another listener receives a stream update", () => {
168
+ const { store, view } = harness();
169
+ store.add(task("stream", "current-session-id"));
170
+ let notifications = 0;
171
+ const unsubscribe = store.subscribe("stream", () => {
172
+ notifications += 1;
173
+ if (notifications > 1) throw new Error("selected listener was re-added during notification");
174
+ });
175
+ store.apply("stream", { type: "text", text: "one" }, 2000);
176
+ assert.equal(notifications, 1, "a live listener set never revisits the same streaming update");
177
+ unsubscribe();
178
+ view.dispose();
179
+ });
@@ -0,0 +1,54 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test, { after } from "node:test";
6
+ import { historyDir, loadHistory, loadStoredTask, pruneHistory, saveTask } from "../lib/agents-history.ts";
7
+ import { applyTaskEvent, emptyThread, TASK_EVENT, TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts";
8
+
9
+ // Gentle Agents history: JSON per task, async, lazy, pruned by count.
10
+
11
+ const root = mkdtempSync(join(tmpdir(), "gentle-agents-history-"));
12
+ after(() => rmSync(root, { recursive: true, force: true }));
13
+ const dir = join(root, "tasks");
14
+
15
+ function task(id: string, createdAt: number): TaskRecord {
16
+ return { id, agent: "explore", mode: "task", prompt: "p", label: "p", cwd: "/r", parentSessionId: "s", status: TASK_STATUS.COMPLETED, createdAt, startedAt: createdAt, endedAt: createdAt + 5, model: "m", thinking: undefined, sessionPath: null, error: null, result: "ok", lastStep: "done", lastActivityAt: createdAt, turns: 1, toolCalls: 0, tokens: 10, cost: 0.01 };
17
+ }
18
+
19
+ test("historyDir follows an isolated agent profile while explicit homes retain the default fallback", () => {
20
+ assert.equal(historyDir("/home/x", "/profiles/pi-principal/agent"), join("/profiles/pi-principal/agent", "gentle-agents", "tasks"));
21
+ assert.equal(historyDir("/home/x", "/profiles/pi-lab/agent"), join("/profiles/pi-lab/agent", "gentle-agents", "tasks"));
22
+ assert.equal(historyDir("/home/x"), join("/home/x", ".pi", "agent", "gentle-agents", "tasks"));
23
+ });
24
+
25
+ test("saveTask writes a task with its thread and loadStoredTask reads it back", async () => {
26
+ const thread = applyTaskEvent(emptyThread(), { type: TASK_EVENT.TEXT, text: "hello" });
27
+ await saveTask(dir, task("a1", 1000), thread);
28
+ const stored = await loadStoredTask(dir, "a1");
29
+ assert.equal(stored?.task.result, "ok");
30
+ assert.deepEqual(stored?.thread.items, [{ kind: "text", text: "hello" }]);
31
+ assert.equal(await loadStoredTask(dir, "missing"), undefined);
32
+ assert.equal(await loadStoredTask(dir, "../etc/passwd"), undefined);
33
+ assert.deepEqual(readdirSync(dir), ["a1.json"], "no temp file is left behind");
34
+ });
35
+
36
+ test("loadHistory skips broken files, sorts newest first, and pruneHistory keeps the newest N", async () => {
37
+ await saveTask(dir, task("b2", 3000), emptyThread());
38
+ await saveTask(dir, task("c3", 2000), emptyThread());
39
+ writeFileSync(join(dir, "junk.json"), "{not json");
40
+ writeFileSync(join(dir, "shape.json"), JSON.stringify({ task: { id: 1 } }));
41
+ assert.deepEqual((await loadHistory(dir)).map((entry) => entry.task.id), ["b2", "c3", "a1"]);
42
+ assert.equal(await pruneHistory(dir, 2), 1);
43
+ assert.deepEqual((await loadHistory(dir)).map((entry) => entry.task.id), ["b2", "c3"]);
44
+ assert.deepEqual(await loadHistory(join(root, "nowhere")), []);
45
+ });
46
+
47
+ test("TaskStore.restore adds a stored task without clobbering a live one", () => {
48
+ const store = new TaskStore();
49
+ const thread = applyTaskEvent(emptyThread(), { type: TASK_EVENT.NOTE, text: "restored" });
50
+ assert.equal(store.restore(task("r1", 1000), thread), true);
51
+ assert.equal(store.thread("r1").items.length, 1);
52
+ assert.equal(store.restore({ ...task("r1", 1000), result: "other" }, emptyThread()), false);
53
+ assert.equal(store.get("r1")?.result, "ok");
54
+ });