gentle-pi 3.7.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -6
- package/assets/agents/gentle-ai-explore.md +4 -4
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -9
- package/assets/orchestrator-delegation.md +40 -41
- package/assets/orchestrator-memory.md +1 -22
- package/assets/orchestrator-skills.md +1 -1
- package/assets/orchestrator.md +12 -24
- package/assets/support/strict-tdd-verify.md +4 -266
- package/assets/support/strict-tdd.md +8 -360
- package/bin/gentle-shell.mjs +254 -29
- package/docs/delegated-verification.md +26 -1
- package/docs/gentle-agents-activity.md +24 -0
- package/docs/gentle-shell.md +92 -28
- package/docs/native-authority-architecture.md +2 -2
- package/docs/prompt-history.md +280 -0
- package/docs/readme-reference.md +112 -206
- package/docs/telemetry.md +1 -1
- package/docs/yolo-mode.md +86 -0
- package/extensions/child-context.ts +26 -0
- package/extensions/child-safety.ts +23 -0
- package/extensions/gentle-agents.ts +429 -262
- package/extensions/gentle-ai.ts +781 -683
- package/extensions/gentle-shell.ts +1375 -88
- package/extensions/gentle-stats.ts +101 -0
- package/extensions/gentle-todo.ts +18 -7
- package/extensions/history/atomic-write.ts +38 -0
- package/extensions/history/hide-prompts.ts +183 -0
- package/extensions/history/index.ts +1419 -0
- package/extensions/history/load-shared-history.ts +39 -0
- package/extensions/history/selector-helpers.ts +538 -0
- package/extensions/history/session-scan.ts +233 -0
- package/extensions/history/store.ts +1119 -0
- package/extensions/nan-provider.ts +6 -0
- package/extensions/quiet-tools.ts +179 -87
- package/extensions/resume-hint.ts +60 -0
- package/extensions/skill-registry.ts +16 -12
- package/extensions/startup-banner.ts +60 -31
- package/lib/agent-assets.ts +604 -0
- package/lib/agent-profile-pin.ts +12 -0
- package/lib/agents-message-delivery.ts +181 -0
- package/lib/agents-protocol.ts +60 -0
- package/lib/agents-runner.ts +96 -98
- package/lib/agents-view.ts +26 -3
- package/lib/agents-widget.ts +16 -9
- package/lib/append-system-prompt.ts +21 -0
- package/lib/bounded-writer-admission.ts +147 -0
- package/lib/card-style-policy.ts +60 -0
- package/lib/child-context-files.ts +166 -0
- package/lib/codemode-renderer.ts +185 -0
- package/lib/command-palette-catalog.ts +3 -9
- package/lib/command-palette.ts +25 -14
- package/lib/destructive-command-guard.ts +144 -0
- package/lib/gentle-ai-elapsed-store.ts +87 -0
- package/lib/gentle-ai-renderer.ts +196 -39
- package/lib/gentle-shell-launcher.ts +24 -13
- package/lib/gentle-shell-resume-hint.ts +176 -0
- package/lib/history-capture-policy.ts +95 -0
- package/lib/model-routing-authority.ts +5 -1
- package/lib/nan-provider.ts +227 -0
- package/lib/native-review-cli.ts +49 -108
- package/lib/odd-phase-inference.ts +231 -0
- package/lib/odd-phase.ts +141 -0
- package/lib/overlay-repaint.ts +26 -0
- package/lib/pi-tui-keys.ts +53 -0
- package/lib/review-candidate-view-owner.ts +67 -17
- package/lib/review-candidate-view.ts +112 -25
- package/lib/review-reminder-receipt.ts +48 -8
- package/lib/review-risk-assessment.ts +156 -11
- package/lib/review-sidebar-state.ts +223 -0
- package/lib/selection-engine.ts +515 -0
- package/lib/session-messaging-grants.ts +135 -0
- package/lib/session-worktree-registry.ts +14 -2
- package/lib/shell-bar.ts +179 -24
- package/lib/shell-card.ts +287 -18
- package/lib/shell-changes-view.ts +2 -1
- package/lib/shell-prompt.ts +102 -6
- package/lib/shell-sidebar-layout.ts +78 -14
- package/lib/shell-sidebar.ts +15 -1
- package/lib/shell-todo.ts +23 -13
- package/lib/shell-usage-view.ts +9 -4
- package/lib/shell-usage.ts +66 -10
- package/lib/stats-collector.ts +381 -0
- package/lib/stats-view.ts +431 -0
- package/lib/theme-customization.ts +52 -0
- package/lib/vim-editor-adapter.ts +379 -0
- package/lib/vim-normal-engine.ts +154 -0
- package/lib/vim-operator-engine.ts +416 -0
- package/lib/vim-policy.ts +49 -0
- package/lib/vim-visual-engine.ts +107 -0
- package/lib/visual-customization-policy.ts +108 -0
- package/lib/visual-customize-view.ts +330 -0
- package/lib/visual-profiles.ts +228 -0
- package/lib/yolo-session-policy.ts +240 -0
- package/package.json +20 -8
- package/runtime/gentle-shell-launcher.mjs +23 -12
- package/runtime/gentle-shell-resume-hint.mjs +177 -0
- package/runtime/native-review-cli.mjs +49 -108
- package/runtime/review-risk-assessment.mjs +154 -9
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/gentle-ai-installer.mjs +14 -13
- package/scripts/mirror-odd-routing.mjs +2 -2
- package/scripts/run-test-suite.mjs +76 -0
- package/scripts/test-packed-runner.mjs +31 -14
- package/scripts/verify-package-files.mjs +11 -22
- package/skills/branch-pr/SKILL.md +24 -52
- package/skills/chained-pr/SKILL.md +31 -15
- package/skills/chained-pr/references/chaining-details.md +31 -20
- package/skills/gentle-ai/SKILL.md +9 -15
- package/skills/issue-creation/SKILL.md +8 -2
- package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
- package/skills/work-unit-commits/SKILL.md +4 -3
- package/tests/agent-profiles.test.ts +18 -0
- package/tests/agents-fake-child.ts +2 -2
- package/tests/agents-message-delivery.test.ts +106 -0
- package/tests/agents-protocol.test.ts +40 -0
- package/tests/agents-runner.test.ts +378 -89
- package/tests/agents-view-thread-identity.test.ts +169 -0
- package/tests/agents-view.test.ts +8 -2
- package/tests/agents-widget.test.ts +154 -15
- package/tests/append-system-prompt-route.test.ts +160 -0
- package/tests/append-system-prompt.test.ts +46 -0
- package/tests/artifact-language.test.ts +19 -213
- package/tests/ask-user-question.test.ts +44 -1
- package/tests/asset-installation-runtime.test.ts +5 -16
- package/tests/autonomous-guard.test.ts +69 -1
- package/tests/bounded-writer-admission.test.ts +95 -0
- package/tests/branch-pr-skill.test.ts +43 -0
- package/tests/card-style-policy.test.ts +55 -0
- package/tests/chained-pr-skill.test.ts +124 -0
- package/tests/child-context-files.test.ts +255 -0
- package/tests/child-safety.test.ts +82 -0
- package/tests/codemode-rendering.test.ts +491 -0
- package/tests/command-palette.test.ts +39 -3
- package/tests/delegated-key-learnings-contract.test.ts +0 -76
- package/tests/destructive-command-guard.test.ts +84 -0
- package/tests/devbinary/native-review-parity.devtest.ts +170 -2
- package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
- package/tests/generic-agent-tools.test.ts +54 -0
- package/tests/gentle-agents.test.ts +1047 -252
- package/tests/gentle-ai-binary.test.ts +3 -3
- package/tests/gentle-ai-elapsed-store.test.ts +68 -0
- package/tests/gentle-ai-installer.test.ts +68 -54
- package/tests/gentle-ai-renderer.test.ts +487 -8
- package/tests/gentle-ai.test.ts +288 -65
- package/tests/gentle-card-text.ts +2 -1
- package/tests/gentle-shell-bin.test.ts +651 -114
- package/tests/gentle-shell-launcher.test.ts +99 -52
- package/tests/gentle-shell-resume-hint.test.ts +270 -0
- package/tests/gentle-shell.test.ts +3839 -187
- package/tests/gentle-stats.test.ts +152 -0
- package/tests/gentle-theme.test.ts +4 -1
- package/tests/gentle-todo.test.ts +80 -8
- package/tests/history-atomic-write.test.ts +57 -0
- package/tests/history-capture-policy.test.ts +102 -0
- package/tests/history-command-registration.test.ts +164 -0
- package/tests/history-dedupe-entries.test.ts +123 -0
- package/tests/history-delete-backfill.test.ts +190 -0
- package/tests/history-delete-confirm.test.ts +460 -0
- package/tests/history-dispatch.test.ts +180 -0
- package/tests/history-drain-hidden.test.ts +110 -0
- package/tests/history-drain-order.test.ts +98 -0
- package/tests/history-expanded-globals.test.ts +62 -0
- package/tests/history-gc.test.ts +832 -0
- package/tests/history-header-layout.test.ts +265 -0
- package/tests/history-hide-prompts.test.ts +275 -0
- package/tests/history-lazy-windowing.test.ts +508 -0
- package/tests/history-legacy-migrate-v2.test.ts +297 -0
- package/tests/history-load-shared-history.test.ts +53 -0
- package/tests/history-max-results-cap.test.ts +76 -0
- package/tests/history-multi-reader.test.ts +203 -0
- package/tests/history-off-path.test.ts +170 -0
- package/tests/history-openflow-integration.test.ts +173 -0
- package/tests/history-overlay-margin.test.ts +326 -0
- package/tests/history-preview-layout.test.ts +93 -0
- package/tests/history-registry.test.ts +143 -0
- package/tests/history-scope-delete.test.ts +411 -0
- package/tests/history-search-caret-keys.test.ts +142 -0
- package/tests/history-seed-bootstrap.test.ts +170 -0
- package/tests/history-seed-regen.test.ts +129 -0
- package/tests/history-selector-windowing.test.ts +94 -0
- package/tests/history-session-scan-directory.test.ts +87 -0
- package/tests/history-session-scan-extract.test.ts +583 -0
- package/tests/history-session-writer.test.ts +351 -0
- package/tests/history-store-paths.test.ts +79 -0
- package/tests/history-tombstone-exact.test.ts +139 -0
- package/tests/history-wheel-mouse.test.ts +242 -0
- package/tests/inprocess-reviewer.test.ts +29 -19
- package/tests/issue-creation-skill.test.ts +61 -0
- package/tests/model-routing-authority.test.ts +16 -0
- package/tests/nan-provider.test.ts +471 -0
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-cli.test.ts +6 -120
- package/tests/native-review-parity-runtime.test.ts +100 -3
- package/tests/odd-integration.test.ts +67 -0
- package/tests/odd-phase-inference.test.ts +213 -0
- package/tests/odd-phase-loader.test.ts +253 -0
- package/tests/odd-phase.test.ts +307 -0
- package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
- package/tests/odd-routing-contract.test.ts +86 -35
- package/tests/orchestrator-budget.test.ts +14 -39
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/overlay-repaint.test.ts +74 -0
- package/tests/package-manifest.test.ts +252 -115
- package/tests/packed-runner-owned-path.test.ts +46 -0
- package/tests/persona-single-channel.test.ts +6 -6
- package/tests/provider-defect-handoff.test.ts +3 -11
- package/tests/quiet-bash-runtime.test.ts +76 -0
- package/tests/quiet-tool-rendering.test.ts +409 -184
- package/tests/rdd-aware-verification-contract.test.ts +76 -1
- package/tests/rdd-status-line.test.ts +9 -4
- package/tests/resume-hint-extension.test.ts +122 -0
- package/tests/review-agent-end-preflight.test.ts +176 -12
- package/tests/review-candidate-owner-retry.test.ts +22 -1
- package/tests/review-candidate-view.test.ts +298 -0
- package/tests/review-contract-prompt.test.ts +108 -43
- package/tests/review-controller-lock-status.test.ts +0 -1
- package/tests/review-controller-native-routing.test.ts +611 -5
- package/tests/review-controller-workspace-root.test.ts +163 -4
- package/tests/review-host-relay-routing.test.ts +338 -2
- package/tests/review-integration-v2-forward.test.ts +200 -0
- package/tests/review-ledger-contract.test.ts +10 -34
- package/tests/review-reminder-receipt.test.ts +47 -1
- package/tests/review-risk-assessment.test.ts +498 -6
- package/tests/review-sidebar-state.test.ts +402 -0
- package/tests/run-test-suite.test.ts +124 -0
- package/tests/runtime-harness.mjs +145 -786
- package/tests/runtime-metrics-children.test.ts +16 -23
- package/tests/selection-engine.test.ts +421 -0
- package/tests/session-messaging-grants.test.ts +255 -0
- package/tests/session-worktree-registry.test.ts +77 -0
- package/tests/shell-bar.test.ts +382 -1
- package/tests/shell-card.test.ts +353 -1
- package/tests/shell-changes-view.test.ts +52 -0
- package/tests/shell-prompt.test.ts +94 -2
- package/tests/shell-sidebar-layout.test.ts +325 -21
- package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
- package/tests/shell-todo.test.ts +87 -1
- package/tests/shell-usage-view.test.ts +27 -0
- package/tests/shell-usage.test.ts +73 -0
- package/tests/skill-registry.test.ts +50 -1
- package/tests/startup-banner.test.ts +130 -2
- package/tests/stats-collector.test.ts +195 -0
- package/tests/stats-view.test.ts +202 -0
- package/tests/telemetry-trigger.test.ts +81 -20
- package/tests/theme-customization.test.ts +72 -0
- package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
- package/tests/vim-editor-adapter.test.ts +804 -0
- package/tests/vim-normal-engine.test.ts +101 -0
- package/tests/vim-operator-engine.test.ts +215 -0
- package/tests/vim-policy.test.ts +19 -0
- package/tests/vim-visual-engine.test.ts +52 -0
- package/tests/visual-customization-policy.test.ts +110 -0
- package/tests/visual-customize-view.test.ts +418 -0
- package/tests/visual-profiles.test.ts +87 -0
- package/tests/yolo-customize.test.ts +256 -0
- package/tests/yolo-mode-runtime.test.ts +161 -0
- package/tests/yolo-mode.test.ts +261 -0
- package/tests/yolo-session-policy.test.ts +59 -0
- package/themes/Gentle.json +2 -1
- package/themes/Gentleman-Cute.json +2 -1
- package/themes/Gentleman-Sexy.json +2 -1
- package/assets/agents/sdd-apply.md +0 -159
- package/assets/agents/sdd-archive.md +0 -228
- package/assets/agents/sdd-design.md +0 -49
- package/assets/agents/sdd-explore.md +0 -48
- package/assets/agents/sdd-init.md +0 -56
- package/assets/agents/sdd-onboard.md +0 -52
- package/assets/agents/sdd-proposal.md +0 -64
- package/assets/agents/sdd-remediate.md +0 -37
- package/assets/agents/sdd-research.md +0 -49
- package/assets/agents/sdd-spec.md +0 -192
- package/assets/agents/sdd-status.md +0 -54
- package/assets/agents/sdd-tasks.md +0 -108
- package/assets/agents/sdd-verify.md +0 -124
- package/assets/chains/sdd-full.chain.md +0 -83
- package/assets/chains/sdd-plan.chain.md +0 -56
- package/assets/chains/sdd-verify.chain.md +0 -43
- package/assets/sdd-orchestrator-workflow.md +0 -319
- package/assets/support/sdd-status-contract.md +0 -77
- package/docs/assets/diagrams/sdd-cycle.svg +0 -14
- package/extensions/sdd-init.ts +0 -816
- package/lib/openspec-deltas.ts +0 -156
- package/lib/sdd-preflight.ts +0 -1066
- package/lib/sdd-research-capabilities.ts +0 -94
- package/lib/sdd-status.ts +0 -26
- package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
- package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
- package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
- package/tests/openspec-deltas.test.ts +0 -209
- package/tests/sdd-agent-tools.test.ts +0 -156
- package/tests/sdd-archive-replay.test.ts +0 -82
- package/tests/sdd-classical-continuation.test.ts +0 -74
- package/tests/sdd-execution-routing-contract.test.ts +0 -44
- package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
- package/tests/sdd-native-managed-uptake.test.ts +0 -243
- package/tests/sdd-no-attempts-contract.test.ts +0 -15
- package/tests/sdd-odd-integration.test.ts +0 -33
- package/tests/sdd-optional-research.test.ts +0 -124
- package/tests/sdd-planning-routing-contract.test.ts +0 -45
- package/tests/sdd-preflight-rpc-input.test.ts +0 -125
- package/tests/sdd-preflight.test.ts +0 -541
- package/tests/sdd-research-capabilities.test.ts +0 -114
- package/tests/sdd-research-live.test.ts +0 -241
- package/tests/sdd-selection-transport.test.ts +0 -653
- package/tests/sdd-status.test.ts +0 -9
- package/tests/sdd-task-truth.test.ts +0 -43
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { TaskStore, type TaskThread } from "../lib/agents-protocol.ts";
|
|
8
|
+
import { AgentsView } from "../lib/agents-view.ts";
|
|
9
|
+
|
|
10
|
+
// Regression suite for #1011: every presence poll re-reads remote activity from
|
|
11
|
+
// disk, so thread items arrive as fresh objects even when nothing changed.
|
|
12
|
+
// refreshPresence must reuse the prior ThreadItem object when the sanitized new
|
|
13
|
+
// item is deeply equal at the same position (WeakMap render cache hit), while
|
|
14
|
+
// changed items keep a new identity so cached renderings cannot go stale.
|
|
15
|
+
|
|
16
|
+
interface FixtureSummary {
|
|
17
|
+
id: string;
|
|
18
|
+
agent: string;
|
|
19
|
+
label: string;
|
|
20
|
+
status: string;
|
|
21
|
+
model: string;
|
|
22
|
+
createdAt: number;
|
|
23
|
+
startedAt: number | null;
|
|
24
|
+
endedAt: number | null;
|
|
25
|
+
lastActivityAt: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type FixtureItem = Record<string, unknown>;
|
|
29
|
+
type FixtureTask = { summary: FixtureSummary; thread: { version: number; dropped: number; items: FixtureItem[] } };
|
|
30
|
+
type FixtureSession = { sessionId: string; incarnation: string };
|
|
31
|
+
|
|
32
|
+
function summary(id: string): FixtureSummary {
|
|
33
|
+
return { id, agent: "worker", label: `task ${id}`, status: "running", model: "test-model", createdAt: 1000, startedAt: 1000, endedAt: null, lastActivityAt: 1000 };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sampleItems(): FixtureItem[] {
|
|
37
|
+
return [
|
|
38
|
+
{ kind: "text", text: "alpha" },
|
|
39
|
+
{ kind: "tool", callId: "c1", name: "grep", output: "matches", running: false, isError: false },
|
|
40
|
+
{ kind: "note", text: "note" },
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Writes the presence pair directly (same shape projectActivity publishes) so
|
|
45
|
+
// tests control content and timing without the publisher's coalescing debounce.
|
|
46
|
+
function publish(profile: string, session: FixtureSession, tasks: FixtureTask[]): void {
|
|
47
|
+
const root = join(profile, "gentle-agents", "presence");
|
|
48
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
49
|
+
const sessionHash = createHash("sha256").update(session.sessionId).digest("hex");
|
|
50
|
+
const bytes = Buffer.from(JSON.stringify({ schema: 1, sessionHash, incarnation: session.incarnation, generation: 1, activity: { tasks } }), "utf8");
|
|
51
|
+
const header = { schema: 1, sessionHash, incarnation: session.incarnation, label: "peer", heartbeat: Date.now(), generation: 1, counts: { running: tasks.length, queued: 0, waiting: 0, finished: 0 }, digest: createHash("sha256").update(bytes).digest("hex"), unavailable: null };
|
|
52
|
+
// Header last: readers validate activity bytes against the header digest.
|
|
53
|
+
writeFileSync(join(root, `${sessionHash}.${session.incarnation}.activity.json`), bytes);
|
|
54
|
+
writeFileSync(join(root, `${sessionHash}.${session.incarnation}.header.json`), JSON.stringify(header));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function taskKey(session: FixtureSession, summaryId: string): string {
|
|
58
|
+
const sessionHash = createHash("sha256").update(session.sessionId).digest("hex");
|
|
59
|
+
return `peer:${sessionHash}:${session.incarnation}:${summaryId}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function harness(profile: string): { view: AgentsView; remoteThreads(): Map<string, TaskThread> } {
|
|
63
|
+
const view = new AgentsView({
|
|
64
|
+
theme: { fg: (_color, text) => text },
|
|
65
|
+
rows: 10,
|
|
66
|
+
store: new TaskStore(),
|
|
67
|
+
sessionId: "local",
|
|
68
|
+
now: () => 61_000,
|
|
69
|
+
onCancel: () => {},
|
|
70
|
+
onOpen: () => {},
|
|
71
|
+
onClose: () => {},
|
|
72
|
+
requestRender: () => {},
|
|
73
|
+
presence: { profile },
|
|
74
|
+
});
|
|
75
|
+
return { view, remoteThreads: () => (view as unknown as { remoteThreads: Map<string, TaskThread> }).remoteThreads };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function withPresenceFixture(run: (profile: string) => Promise<void>): Promise<void> {
|
|
79
|
+
// Presence rejects symlinked profile ancestors, and macOS tmpdir() lives under
|
|
80
|
+
// the /var -> /private/var symlink, so the fixture must use the canonical path.
|
|
81
|
+
const profile = mkdtempSync(join(realpathSync(tmpdir()), "agents-view-identity-"));
|
|
82
|
+
try {
|
|
83
|
+
await run(profile);
|
|
84
|
+
} finally {
|
|
85
|
+
rmSync(profile, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
90
|
+
|
|
91
|
+
async function until<T>(probe: () => T | undefined, description: string, timeoutMs = 5_000): Promise<T> {
|
|
92
|
+
const deadline = Date.now() + timeoutMs;
|
|
93
|
+
for (;;) {
|
|
94
|
+
const found = probe();
|
|
95
|
+
if (found !== undefined) return found;
|
|
96
|
+
if (Date.now() > deadline) throw new Error(`timed out waiting for ${description}`);
|
|
97
|
+
await sleep(5);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The view replaces its remoteThreads map on every applied poll, so map
|
|
102
|
+
// identity is a reliable witness that a subsequent poll has been applied.
|
|
103
|
+
function nextApplication(h: ReturnType<typeof harness>, appliedOnce: Map<string, TaskThread>, key: string): Promise<TaskThread> {
|
|
104
|
+
return until(() => {
|
|
105
|
+
const map = h.remoteThreads();
|
|
106
|
+
return map === appliedOnce ? undefined : map.get(key);
|
|
107
|
+
}, "a subsequent presence poll");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
test("unchanged remote thread items keep object identity across presence polls", async () => {
|
|
111
|
+
await withPresenceFixture(async (profile) => {
|
|
112
|
+
const session = { sessionId: "peer session", incarnation: randomUUID() };
|
|
113
|
+
publish(profile, session, [{ summary: summary("t1"), thread: { version: 1, dropped: 0, items: sampleItems() } }]);
|
|
114
|
+
const h = harness(profile);
|
|
115
|
+
try {
|
|
116
|
+
const first = await until(() => h.remoteThreads().get(taskKey(session, "t1")), "the first presence poll");
|
|
117
|
+
const firstItems = [...first.items];
|
|
118
|
+
const appliedOnce = h.remoteThreads();
|
|
119
|
+
// Same files on disk: the second poll still re-parses them, which is
|
|
120
|
+
// exactly the unchanged-content case that must reuse prior identities.
|
|
121
|
+
const second = await nextApplication(h, appliedOnce, taskKey(session, "t1"));
|
|
122
|
+
assert.notEqual(second, first, "the thread container is still rebuilt per poll");
|
|
123
|
+
assert.equal(second.items.length, firstItems.length);
|
|
124
|
+
second.items.forEach((item, index) => assert.equal(item, firstItems[index],
|
|
125
|
+
`unchanged item ${index} (kind ${String((firstItems[index] as { kind?: string }).kind)}) must reuse the prior object identity`));
|
|
126
|
+
} finally {
|
|
127
|
+
h.view.dispose();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("a changed remote item takes a new identity while unchanged siblings keep theirs", async () => {
|
|
133
|
+
await withPresenceFixture(async (profile) => {
|
|
134
|
+
const session = { sessionId: "peer session", incarnation: randomUUID() };
|
|
135
|
+
publish(profile, session, [{ summary: summary("t1"), thread: { version: 1, dropped: 0, items: sampleItems() } }]);
|
|
136
|
+
const h = harness(profile);
|
|
137
|
+
try {
|
|
138
|
+
const first = await until(() => h.remoteThreads().get(taskKey(session, "t1")), "the first presence poll");
|
|
139
|
+
const firstItems = [...first.items];
|
|
140
|
+
const appliedOnce = h.remoteThreads();
|
|
141
|
+
const [, tool, note] = sampleItems();
|
|
142
|
+
publish(profile, session, [{ summary: summary("t1"), thread: { version: 1, dropped: 0, items: [{ kind: "text", text: "alpha changed" }, tool, note] } }]);
|
|
143
|
+
const second = await nextApplication(h, appliedOnce, taskKey(session, "t1"));
|
|
144
|
+
assert.notEqual(second.items[0], firstItems[0], "a changed item must yield a new object identity");
|
|
145
|
+
assert.equal(second.items[1], firstItems[1], "the unchanged tool sibling must reuse the prior identity");
|
|
146
|
+
assert.equal(second.items[2], firstItems[2], "the unchanged note sibling must reuse the prior identity");
|
|
147
|
+
} finally {
|
|
148
|
+
h.view.dispose();
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("tool items compare after args sanitization, so unchanged tools are reused with an empty args record", async () => {
|
|
154
|
+
await withPresenceFixture(async (profile) => {
|
|
155
|
+
const session = { sessionId: "peer session", incarnation: randomUUID() };
|
|
156
|
+
publish(profile, session, [{ summary: summary("t1"), thread: { version: 1, dropped: 0, items: [sampleItems()[1]] } }]);
|
|
157
|
+
const h = harness(profile);
|
|
158
|
+
try {
|
|
159
|
+
const first = await until(() => h.remoteThreads().get(taskKey(session, "t1")), "the first presence poll");
|
|
160
|
+
const appliedOnce = h.remoteThreads();
|
|
161
|
+
publish(profile, session, [{ summary: summary("t1"), thread: { version: 1, dropped: 0, items: [sampleItems()[1]] } }]);
|
|
162
|
+
const second = await nextApplication(h, appliedOnce, taskKey(session, "t1"));
|
|
163
|
+
assert.equal(second.items[0], first.items[0], "an unchanged tool item must reuse the prior sanitized object");
|
|
164
|
+
assert.deepEqual((second.items[0] as { args?: unknown }).args, {}, "the stored tool item keeps the sanitized args record");
|
|
165
|
+
} finally {
|
|
166
|
+
h.view.dispose();
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -242,7 +242,13 @@ test("renderThreadItem renders labeled text, thinking, tool-output, and note blo
|
|
|
242
242
|
const wide = renderThreadItem({ kind: "tool", callId: "c", name: "bash", args: {}, output: "alpha beta gamma delta", running: false, isError: false }, plainTheme, 12);
|
|
243
243
|
assert.deepEqual(wide.slice(2), [" alpha", " beta", " gamma", " delta"]);
|
|
244
244
|
assert.deepEqual(renderThreadItem({ kind: "note", text: "error: boom" }, plainTheme, 30), ["Note", " error: boom"]);
|
|
245
|
-
assert.equal(taskHeader(task("a"), 61_000), "explore · running · gpt-5.6-terra · 34k · $0.
|
|
245
|
+
assert.equal(taskHeader(task("a"), 61_000), "explore · running · gpt-5.6-terra · 34k · $0.270 · 1m00s");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("taskHeader formats subagent cost with formatCost (three decimals below $1, two at or above $1)", () => {
|
|
249
|
+
assert.match(taskHeader(task("small", { cost: 0.09 }), 1000), /\$0\.090/);
|
|
250
|
+
assert.match(taskHeader(task("large", { cost: 12.5 }), 1000), /\$12\.50/);
|
|
251
|
+
assert.doesNotMatch(taskHeader(task("zero", { cost: 0 }), 1000), /\$/);
|
|
246
252
|
});
|
|
247
253
|
|
|
248
254
|
test("AgentsView renders the frame with the task list and the selected thread's tail, at width", () => {
|
|
@@ -255,7 +261,7 @@ test("AgentsView renders the frame with the task list and the selected thread's
|
|
|
255
261
|
const plain = lines.map(stripAnsi);
|
|
256
262
|
assert.equal(plain.length, 8);
|
|
257
263
|
assert.match(plain[0], /^╭─ ❀ Agents · this session · 1 active · 1 finished ─+ \[F Fullscreen\] \[× Close\]╮$/);
|
|
258
|
-
assert.match(plain[1], /▸ └ ◐ Subagent explore.*explore · running · gpt-5\.6-terra · 34k · \$0\.
|
|
264
|
+
assert.match(plain[1], /▸ └ ◐ Subagent explore.*explore · running · gpt-5\.6-terra · 34k · \$0\.270 · 1m00s/);
|
|
259
265
|
assert.match(plain[2], /line 3/, "the thread window follows the tail");
|
|
260
266
|
assert.match(plain[3], /line 4/);
|
|
261
267
|
assert.match(plain.join("\n"), /Subagent worker/, "the session's own finished task now stays listed as history");
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import test from "node:test";
|
|
2
|
+
import test, { after, before, type TestContext } from "node:test";
|
|
3
3
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
4
4
|
import { TASK_STATUS, type TaskRecord } from "../lib/agents-protocol.ts";
|
|
5
5
|
import { formatElapsed, renderAgentsCard, widgetExpiryMs, widgetRows, widgetTasks } from "../lib/agents-widget.ts";
|
|
6
|
+
import { CARD_STYLE, cardStyle, setCardStyle, type CardStyle } from "../lib/shell-card.ts";
|
|
6
7
|
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
7
8
|
|
|
8
9
|
// Gentle Agents widget: the card above the editor that shows what the
|
|
@@ -11,6 +12,42 @@ import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
|
11
12
|
|
|
12
13
|
const plainTheme = { fg: (_color: string, text: string) => text };
|
|
13
14
|
|
|
15
|
+
// The card style defaults to float; these assertions pin the outlined (neon)
|
|
16
|
+
// panels unless a test switches the style itself.
|
|
17
|
+
const initialCardStyle = cardStyle();
|
|
18
|
+
before(() => setCardStyle(CARD_STYLE.NEON));
|
|
19
|
+
after(() => setCardStyle(initialCardStyle));
|
|
20
|
+
|
|
21
|
+
function useCardStyle(t: TestContext, style: CardStyle): void {
|
|
22
|
+
const found = cardStyle();
|
|
23
|
+
t.after(() => setCardStyle(found));
|
|
24
|
+
setCardStyle(style);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const BG_OPEN = "\x1b[48;5;22m";
|
|
28
|
+
const BG_CLOSE = "\x1b[49m";
|
|
29
|
+
|
|
30
|
+
/** The same theme with a background, so the float style applies (without one panels keep the frame). */
|
|
31
|
+
function withBackground<T extends object>(theme: T): T & { bg(color: string, text: string): string } {
|
|
32
|
+
return { ...theme, bg: (_color: string, text: string) => `${BG_OPEN}${text}${BG_CLOSE}` };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Float panel rows: a painted panel inside transparent one-column margins, between padding rows that keep the accent bar. */
|
|
36
|
+
function assertFloatRows(lines: readonly string[], width: number): void {
|
|
37
|
+
for (const line of lines) {
|
|
38
|
+
assert.equal(visibleWidth(line), width, `"${stripAnsi(line)}" is not ${width} wide`);
|
|
39
|
+
assert.ok(line.startsWith(` ${BG_OPEN}`) && line.endsWith(`${BG_CLOSE} `), `painted inside the margins: ${JSON.stringify(line)}`);
|
|
40
|
+
}
|
|
41
|
+
const padding = ` ▎${" ".repeat(width - 3)} `;
|
|
42
|
+
assert.equal(stripAnsi(lines[0]!), padding, "a padding row with the accent bar sits above the header");
|
|
43
|
+
assert.equal(stripAnsi(lines.at(-1)!), padding, "a padding row with the accent bar replaces the bottom rule");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The text of a body row, without the neon side rails or the float accent bar. */
|
|
47
|
+
function bodyText(row: string): string {
|
|
48
|
+
return stripAnsi(row).replace(/^ ?[│▎] /u, "").replace(/ ?│? ?$/u, "").trimEnd();
|
|
49
|
+
}
|
|
50
|
+
|
|
14
51
|
function task(overrides: Partial<TaskRecord>): TaskRecord {
|
|
15
52
|
return { id: "t", agent: "sdd-explore", mode: "task", prompt: "map footer data sources", label: "map footer data sources", cwd: "/r", parentSessionId: "s", status: TASK_STATUS.RUNNING, createdAt: 1000, startedAt: 1000, endedAt: null, model: "anthropic/claude-sonnet-5", thinking: undefined, sessionPath: null, error: null, result: null, lastStep: "grep", lastActivityAt: 1000, turns: 0, toolCalls: 0, tokens: 34_000, cost: 0.27, ...overrides };
|
|
16
53
|
}
|
|
@@ -59,8 +96,8 @@ test("renderAgentsCard draws columns for agent, task, and model · tokens · cos
|
|
|
59
96
|
for (const line of lines) assert.equal(visibleWidth(line), 84, `"${stripAnsi(line)}" is not 84 wide`);
|
|
60
97
|
const plain = lines.map(stripAnsi);
|
|
61
98
|
assert.match(plain[0], /^╭─ ❀ Agents · 1 active · 1 done ─+ 1m24s ╮$/);
|
|
62
|
-
assert.match(plain[1], /^│ ✓ sdd-explore map footer data sources +claude-sonnet-5 · 34k · \$0\.
|
|
63
|
-
assert.match(plain[2], /^│ ◐ sdd-apply write gentle-shell footer +claude-sonnet-5 · 12k · \$0\.
|
|
99
|
+
assert.match(plain[1], /^│ ✓ sdd-explore map footer data sources +claude-sonnet-5 · 34k · \$0\.270 · 25s │$/);
|
|
100
|
+
assert.match(plain[2], /^│ ◐ sdd-apply write gentle-shell footer +claude-sonnet-5 · 12k · \$0\.090 · 41s │$/);
|
|
64
101
|
assert.match(plain[3], /^╰─+╯$/);
|
|
65
102
|
assert.deepEqual(renderAgentsCard([], plainTheme, 60, 0, { collapsed: false }), []);
|
|
66
103
|
});
|
|
@@ -74,14 +111,14 @@ test("renderAgentsCard right-aligns model·effort, tokens, cost, and elapsed in
|
|
|
74
111
|
const [, rowA, rowB] = lines as [string, string, string];
|
|
75
112
|
assert.match(rowA, /34k/);
|
|
76
113
|
assert.match(rowB, /1\.2M/);
|
|
77
|
-
assert.match(rowA, /\$0\.
|
|
114
|
+
assert.match(rowA, /\$0\.270/);
|
|
78
115
|
assert.match(rowB, /\$12\.50/);
|
|
79
116
|
// Each column has a fixed width, so a shorter value in one row (e.g. "34k"
|
|
80
117
|
// next to "1.2M") still ends at the exact same offset as the wider one.
|
|
81
118
|
const tokensEndA = rowA.indexOf("34k") + "34k".length;
|
|
82
119
|
const tokensEndB = rowB.indexOf("1.2M") + "1.2M".length;
|
|
83
120
|
assert.equal(tokensEndA, tokensEndB, "the tokens column ends at the same offset on every row");
|
|
84
|
-
const costEndA = rowA.indexOf("$0.
|
|
121
|
+
const costEndA = rowA.indexOf("$0.270") + "$0.270".length;
|
|
85
122
|
const costEndB = rowB.indexOf("$12.50") + "$12.50".length;
|
|
86
123
|
assert.equal(costEndA, costEndB, "the cost column ends at the same offset on every row");
|
|
87
124
|
assert.equal(visibleWidth(rowA), visibleWidth(rowB));
|
|
@@ -118,13 +155,13 @@ test("renderAgentsCard keeps every task on one line, clipping long labels, and d
|
|
|
118
155
|
const tasks = [task({ id: "a", label: "write the gentle shell footer and all of its tests before lunch" })];
|
|
119
156
|
const wide = renderAgentsCard(tasks, plainTheme, 84, 5_000, { collapsed: false }).map(stripAnsi);
|
|
120
157
|
assert.equal(wide.length, 3);
|
|
121
|
-
assert.match(wide[1], /^│ ◐ sdd-explore write the gentle shell
|
|
158
|
+
assert.match(wide[1], /^│ ◐ sdd-explore write the gentle shell foo… +claude-sonnet-5 · 34k · \$0\.270 · 4s │$/);
|
|
122
159
|
// Narrow cards degrade per column: task text first, then the model name,
|
|
123
160
|
// then tokens and cost. Elapsed is the one value the reader cannot rebuild
|
|
124
161
|
// from anything else on screen, so it is the last to go.
|
|
125
162
|
const narrow = renderAgentsCard(tasks, plainTheme, 44, 5_000, { collapsed: false }).map(stripAnsi);
|
|
126
163
|
assert.equal(narrow.length, 3);
|
|
127
|
-
assert.match(narrow[1], /^│ ◐ sdd-explore +34k · \$0\.
|
|
164
|
+
assert.match(narrow[1], /^│ ◐ sdd-explore +34k · \$0\.270 · 4s │$/, "the model name goes before tokens, cost and elapsed");
|
|
128
165
|
});
|
|
129
166
|
|
|
130
167
|
// gentle-shell#1143: one long model id used to flip the whole card to the
|
|
@@ -133,9 +170,9 @@ test("renderAgentsCard keeps tokens, cost and elapsed when a long model name no
|
|
|
133
170
|
const tasks = [task({ id: "a", model: "anthropic/claude-sonnet-4-5-20250929", tokens: 12_345, cost: 0.42, startedAt: 5_000 - 184_000 })];
|
|
134
171
|
const lines = renderAgentsCard(tasks, plainTheme, 46, 5_000, { collapsed: false }).map(stripAnsi);
|
|
135
172
|
assert.equal(lines.length, 3);
|
|
136
|
-
assert.match(lines[1], /^│ ◐ sdd-explore +12k · \$0\.
|
|
173
|
+
assert.match(lines[1], /^│ ◐ sdd-explore +12k · \$0\.420 · 3m04s │$/, "tokens, cost and elapsed survive; the model name is what gives way");
|
|
137
174
|
const tighter = renderAgentsCard(tasks, plainTheme, 34, 5_000, { collapsed: false }).map(stripAnsi);
|
|
138
|
-
assert.match(tighter[1], /^│ ◐ sdd-explore +\$0\.
|
|
175
|
+
assert.match(tighter[1], /^│ ◐ sdd-explore +\$0\.420 · 3m04s │$/, "then tokens go, then cost, elapsed last");
|
|
139
176
|
const tightest = renderAgentsCard(tasks, plainTheme, 28, 5_000, { collapsed: false }).map(stripAnsi);
|
|
140
177
|
assert.match(tightest[1], /^│ ◐ sdd-explore +3m04s │$/, "elapsed is the last column standing");
|
|
141
178
|
});
|
|
@@ -146,8 +183,8 @@ test("renderAgentsCard degrades every row of a mixed card together so columns st
|
|
|
146
183
|
task({ id: "b", agent: "writer", model: "openai/gpt-5", tokens: 900, cost: 0.01 }),
|
|
147
184
|
];
|
|
148
185
|
const lines = renderAgentsCard(tasks, plainTheme, 46, 5_000, { collapsed: false }).map(stripAnsi);
|
|
149
|
-
assert.match(lines[1], /12k · \$0\.
|
|
150
|
-
assert.match(lines[2], /900 · \$0\.
|
|
186
|
+
assert.match(lines[1], /12k · \$0\.420 · 4s │$/);
|
|
187
|
+
assert.match(lines[2], /900 · \$0\.010 · 4s │$/);
|
|
151
188
|
assert.equal(lines[1].indexOf("· 4s"), lines[2].indexOf("· 4s"), "elapsed stays in one column across rows");
|
|
152
189
|
});
|
|
153
190
|
|
|
@@ -162,11 +199,11 @@ test("renderAgentsCard shows questions and failures in place of the task, and co
|
|
|
162
199
|
// The waiting row carries no tokens/cost of its own, but the failed row
|
|
163
200
|
// below it does, so those columns stay reserved (blank) rather than
|
|
164
201
|
// collapsing — the whole point of fixed columns over the old per-row join.
|
|
165
|
-
assert.match(plain[1], /^│ \? sdd-explore asked: Delete\? +claude-sonnet-5 · {5}· {
|
|
166
|
-
assert.match(plain[2], /^│ ✗ sdd-explore pi exited with
|
|
202
|
+
assert.match(plain[1], /^│ \? sdd-explore asked: Delete\? +claude-sonnet-5 · {5}· {8}· {5}2s │$/);
|
|
203
|
+
assert.match(plain[2], /^│ ✗ sdd-explore pi exited with cod… +claude-sonnet-5 · 34k · \$0\.270 · {5}1s │$/);
|
|
167
204
|
// Queued fills only the elapsed column with the literal word; model,
|
|
168
205
|
// tokens, and cost stay blank rather than the row's text spilling past them.
|
|
169
|
-
assert.match(plain[3], /^│ ○ sdd-explore map footer data
|
|
206
|
+
assert.match(plain[3], /^│ ○ sdd-explore map footer data so… +· {5}· {8}· queued │$/);
|
|
170
207
|
const collapsed = renderAgentsCard(tasks, plainTheme, 80, 3000, { collapsed: true, collapseKey: "ctrl+shift+a" }).map(stripAnsi);
|
|
171
208
|
assert.equal(collapsed.length, 3);
|
|
172
209
|
assert.match(collapsed[0], /ctrl\+shift\+a expand ╮$/);
|
|
@@ -181,7 +218,7 @@ test("usage outranks the model label at narrow widths without inventing unknown
|
|
|
181
218
|
const lines = renderAgentsCard([task({ agent: "worker", model: "openai/gpt-5", thinking: "high" })], plainTheme, width, 5000, { collapsed: false });
|
|
182
219
|
assert.equal(lines.length, 3);
|
|
183
220
|
assert.match(lines[1], /worker/);
|
|
184
|
-
assert.match(lines[1], /gpt-5 · high · 34k · \$0\.
|
|
221
|
+
assert.match(lines[1], /gpt-5 · high · 34k · \$0\.270 · 4s/, "wide enough for every column");
|
|
185
222
|
for (const line of lines) assert.equal(visibleWidth(line), width);
|
|
186
223
|
}
|
|
187
224
|
for (const width of [32, 44]) {
|
|
@@ -227,3 +264,105 @@ test("renderAgentsCard caps the rows at maxRows, keeps active tasks ahead of fin
|
|
|
227
264
|
const waiting = [...tasks, task({ id: "ask", status: TASK_STATUS.WAITING, lastStep: "asked: Delete?", createdAt: 4000, startedAt: 4000 })];
|
|
228
265
|
assert.match(renderAgentsCard(waiting, plainTheme, 80, 5000, { collapsed: false, maxRows: 2 }).map(stripAnsi)[1], /^│ \? sdd-explore asked: Delete\?/, "a question is never hidden");
|
|
229
266
|
});
|
|
267
|
+
|
|
268
|
+
test("renderAgentsCard in the float style spends its padding and separator rows from maxRows, so a capped card is exactly as tall as neon", (t) => {
|
|
269
|
+
const tasks = [
|
|
270
|
+
task({ id: "done", status: TASK_STATUS.COMPLETED, startedAt: 500, endedAt: 2000 }),
|
|
271
|
+
...Array.from({ length: 6 }, (_, index) => task({ id: `run${index}`, label: `job ${index}`, createdAt: 1000 + index, startedAt: 1000 + index })),
|
|
272
|
+
];
|
|
273
|
+
const theme = withBackground(plainTheme);
|
|
274
|
+
const neon = renderAgentsCard(tasks, theme, 80, 5000, { collapsed: false, maxRows: 4, viewKey: "alt+a" });
|
|
275
|
+
useCardStyle(t, CARD_STYLE.FLOAT);
|
|
276
|
+
const float = renderAgentsCard(tasks, theme, 80, 5000, { collapsed: false, maxRows: 4, viewKey: "alt+a" }).map(stripAnsi);
|
|
277
|
+
assert.equal(neon.length, 6, "neon: frame plus four rows");
|
|
278
|
+
assert.equal(float.length, neon.length, "float: padding, header, separator, two rows, padding");
|
|
279
|
+
assert.match(float[1]!, /6 active · 1 done/, "the title still counts every shown task");
|
|
280
|
+
assert.match(float[2]!, /^ ▎ +$/, "a blank separator row follows the header");
|
|
281
|
+
assert.match(float[3]!, /◐ sdd-explore job 0/);
|
|
282
|
+
assert.match(float[4]!, /^ ▎ … 6 more · alt\+a to view +$/, "two more tasks fold into the overflow row");
|
|
283
|
+
for (const maxRows of [3, 4, 5, 7, 8, 9]) {
|
|
284
|
+
const capped = renderAgentsCard(tasks, theme, 80, 5000, { collapsed: false, maxRows });
|
|
285
|
+
assert.ok(capped.length <= maxRows + 2, `maxRows ${maxRows}: ${capped.length} rows exceed the neon cap`);
|
|
286
|
+
}
|
|
287
|
+
assert.match(stripAnsi(renderAgentsCard(tasks, theme, 80, 5000, { collapsed: false, maxRows: 8 }).at(-2)!), /… 2 more/, "seven tasks no longer fit eight rows once the padding and separator rows are spent");
|
|
288
|
+
assert.equal(renderAgentsCard(tasks, theme, 80, 5000, { collapsed: false, maxRows: 9 }).length, 11, "at the cap no row is hidden");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("the minimum widget budget fits an overflow-only float body without changing neon", (t) => {
|
|
292
|
+
const maxRows = widgetRows(12);
|
|
293
|
+
assert.equal(maxRows, 3, "small terminals reach the three-row task budget");
|
|
294
|
+
const theme = withBackground(plainTheme);
|
|
295
|
+
for (const count of [1, 2, 6]) {
|
|
296
|
+
const tasks = Array.from({ length: count }, (_, index) => task({ id: `run${index}`, label: `job ${index}`, startedAt: 1000 + index }));
|
|
297
|
+
const options = { collapsed: false, maxRows, viewKey: "alt+a" };
|
|
298
|
+
setCardStyle(CARD_STYLE.NEON);
|
|
299
|
+
const neon = renderAgentsCard(tasks, plainTheme, 80, 5000, options);
|
|
300
|
+
assert.deepEqual(renderAgentsCard(tasks, theme, 80, 5000, options), neon, "neon stays byte-identical with background support");
|
|
301
|
+
useCardStyle(t, CARD_STYLE.FLOAT);
|
|
302
|
+
assert.deepEqual(renderAgentsCard(tasks, plainTheme, 80, 5000, options), neon, "missing-background fallback keeps neon rows");
|
|
303
|
+
setCardStyle(CARD_STYLE.NEON);
|
|
304
|
+
const neonNarrow = renderAgentsCard(tasks, theme, 9, 5000, options);
|
|
305
|
+
setCardStyle(CARD_STYLE.FLOAT);
|
|
306
|
+
assert.deepEqual(renderAgentsCard(tasks, theme, 9, 5000, options), neonNarrow, "narrow fallback keeps neon rows");
|
|
307
|
+
const rows = renderAgentsCard(tasks, theme, 80, 5000, options);
|
|
308
|
+
assert.equal(rows.length, maxRows + 2, `${count} tasks must fit five total float rows`);
|
|
309
|
+
assertFloatRows(rows, 80);
|
|
310
|
+
assert.match(stripAnsi(rows[2]!), /^ ▎ +$/, "separator stays intact");
|
|
311
|
+
if (count === 1) assert.match(stripAnsi(rows[3]!), /job 0/, "a single task fits directly");
|
|
312
|
+
else assert.match(stripAnsi(rows[3]!), new RegExp(`^ ▎ … ${count} more · alt\\+a to view +$`), "the only body row truthfully counts every hidden task");
|
|
313
|
+
assert.equal(renderAgentsCard(tasks, theme, 80, 5000, { ...options, collapsed: true }).length, maxRows + 2, "collapsed cards keep one task and fit");
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("float row budgets still prioritize a question when a task and overflow both fit", (t) => {
|
|
318
|
+
useCardStyle(t, CARD_STYLE.FLOAT);
|
|
319
|
+
const tasks = [
|
|
320
|
+
task({ id: "first", label: "running first", startedAt: 1000 }),
|
|
321
|
+
task({ id: "second", label: "running second", startedAt: 2000 }),
|
|
322
|
+
task({ id: "question", status: TASK_STATUS.WAITING, lastStep: "asked: Delete?", startedAt: 3000 }),
|
|
323
|
+
];
|
|
324
|
+
const rows = renderAgentsCard(tasks, withBackground(plainTheme), 80, 5000, { collapsed: false, maxRows: 4, viewKey: "alt+a" });
|
|
325
|
+
assert.equal(rows.length, 6);
|
|
326
|
+
assert.match(stripAnsi(rows[3]!), /^ ▎ \? sdd-explore asked: Delete\?/);
|
|
327
|
+
assert.match(stripAnsi(rows[4]!), /^ ▎ … 2 more · alt\+a to view +$/);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("renderAgentsCard formats subagent cost with formatCost (three decimals below $1, two at or above $1)", () => {
|
|
331
|
+
const tasks = [
|
|
332
|
+
task({ id: "small", agent: "scout", cost: 0.09, tokens: 1000, startedAt: 1000, endedAt: 2000 }),
|
|
333
|
+
task({ id: "large", agent: "builder", cost: 12.5, tokens: 50000, startedAt: 1000, endedAt: 5000 }),
|
|
334
|
+
];
|
|
335
|
+
const card = renderAgentsCard(tasks, plainTheme, 80, 5000, { collapsed: false });
|
|
336
|
+
assert.ok(card.some((line) => line.includes("$0.090")), "cost below $1 shows 3 decimals");
|
|
337
|
+
assert.ok(card.some((line) => line.includes("$12.50")), "cost at or above $1 shows 2 decimals");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("renderAgentsCard in the float style is a float panel two rows taller than neon with unclipped columns", (t) => {
|
|
341
|
+
const tasks = [
|
|
342
|
+
task({ id: "a", status: TASK_STATUS.COMPLETED, startedAt: 1000, endedAt: 26_000 }),
|
|
343
|
+
task({ id: "b", agent: "sdd-apply", label: "write gentle-shell footer", startedAt: 44_000, tokens: 12_000, cost: 0.09 }),
|
|
344
|
+
];
|
|
345
|
+
const theme = withBackground(plainTheme);
|
|
346
|
+
for (const options of [{ collapsed: false }, { collapsed: true, collapseKey: "ctrl+a" }, { collapsed: false, maxRows: 1, viewKey: "ctrl+v" }]) {
|
|
347
|
+
const neon = renderAgentsCard(tasks, theme, 84, 85_000, options);
|
|
348
|
+
useCardStyle(t, CARD_STYLE.FLOAT);
|
|
349
|
+
const float = renderAgentsCard(tasks, theme, 84, 85_000, options);
|
|
350
|
+
setCardStyle(CARD_STYLE.NEON);
|
|
351
|
+
assert.equal(float.length, neon.length + 2, "the top padding and separator rows add two rows");
|
|
352
|
+
assert.match(stripAnsi(float[1]!), /^ ▎ ❀ Agents 1 active · 1 done +\S.*\S {3}$/, "header on row 1, hint right-aligned");
|
|
353
|
+
assert.match(stripAnsi(float[2]!), /^ ▎ +$/, "a blank separator row follows the header");
|
|
354
|
+
assertFloatRows(float, 84);
|
|
355
|
+
for (const [index, row] of float.slice(3, -1).entries()) {
|
|
356
|
+
assert.match(stripAnsi(row), /^ ▎ \S/u);
|
|
357
|
+
if (bodyText(row).startsWith("…")) continue;
|
|
358
|
+
// Task columns fit the float body, so the right-aligned time is never clipped.
|
|
359
|
+
const tail = bodyText(neon[index + 1]!).split(" ").at(-1)!;
|
|
360
|
+
assert.ok(bodyText(row).endsWith(tail), `"${stripAnsi(row)}" keeps "${tail}"`);
|
|
361
|
+
assert.match(stripAnsi(row), /\S {3}$/u, "the time ends where the float body ends");
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
useCardStyle(t, CARD_STYLE.FLOAT);
|
|
365
|
+
const tagged = withBackground({ fg: (color: string, text: string) => `<${color}>${text}</${color}>` });
|
|
366
|
+
const [, header] = renderAgentsCard([task({ status: TASK_STATUS.WAITING })], tagged, 120, 5000, { collapsed: true, collapseKey: "ctrl+a" });
|
|
367
|
+
assert.match(stripAnsi(header!), /^ <warning>▎<\/warning> <warning>❀ Agents<\/warning> <muted>1 waiting<\/muted> +<muted>ctrl\+a expand<\/muted> {3}$/);
|
|
368
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test, { after, before } from "node:test";
|
|
3
|
+
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
8
|
+
import gentleTodo from "../extensions/gentle-todo.ts";
|
|
9
|
+
|
|
10
|
+
// gentle-shell#1485: pi-claude-bridge forwards only the structured
|
|
11
|
+
// systemPromptOptions parts of before_agent_start (contextFiles, skills,
|
|
12
|
+
// customPrompt, appendSystemPrompt) after Claude Code's own preset, so a
|
|
13
|
+
// handler-returned replacement systemPrompt never reaches the model on that
|
|
14
|
+
// provider. These tests exercise both extensions against one shared
|
|
15
|
+
// systemPromptOptions object, the same object every before_agent_start
|
|
16
|
+
// handler observes within a single pi emission (packages/coding-agent
|
|
17
|
+
// extensions/runner.js emitBeforeAgentStart: one `currentOptions` per call).
|
|
18
|
+
|
|
19
|
+
type Handler = (event: unknown, ctx: ExtensionContext) => unknown;
|
|
20
|
+
|
|
21
|
+
let fixtureCwd: string;
|
|
22
|
+
let fixtureRoot: string | undefined;
|
|
23
|
+
const fixtureEnvironment: NodeJS.ProcessEnv = {};
|
|
24
|
+
const previousEnvironment = new Map<string, string | undefined>();
|
|
25
|
+
before(async () => {
|
|
26
|
+
const root = await mkdtemp(join(tmpdir(), "gentle-pi-append-route-"));
|
|
27
|
+
fixtureRoot = root;
|
|
28
|
+
fixtureCwd = join(root, "project");
|
|
29
|
+
const home = join(root, "home");
|
|
30
|
+
for (const directory of [fixtureCwd, home]) await mkdir(directory);
|
|
31
|
+
Object.assign(fixtureEnvironment, {
|
|
32
|
+
HOME: home, USERPROFILE: home,
|
|
33
|
+
GENTLE_PI_CONFIG_HOME: join(home, "config"),
|
|
34
|
+
GENTLE_PI_AGENT_HOME: join(home, "agents"),
|
|
35
|
+
PI_CODING_AGENT_DIR: join(home, "pi"),
|
|
36
|
+
XDG_CONFIG_HOME: join(home, "xdg"),
|
|
37
|
+
});
|
|
38
|
+
for (const [key, value] of Object.entries(fixtureEnvironment)) {
|
|
39
|
+
previousEnvironment.set(key, process.env[key]);
|
|
40
|
+
process.env[key] = value;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
after(async () => {
|
|
44
|
+
for (const [key, value] of previousEnvironment) {
|
|
45
|
+
if (value === undefined) delete process.env[key];
|
|
46
|
+
else process.env[key] = value;
|
|
47
|
+
}
|
|
48
|
+
if (fixtureRoot !== undefined) await rm(fixtureRoot, { recursive: true, force: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
function gentleAiHandlers(processEnv: NodeJS.ProcessEnv = {}): Map<string, Handler> {
|
|
52
|
+
const handlers = new Map<string, Handler>();
|
|
53
|
+
const pi = {
|
|
54
|
+
on(name: string, handler: Handler) {
|
|
55
|
+
handlers.set(name, handler);
|
|
56
|
+
},
|
|
57
|
+
events: { emit() {} },
|
|
58
|
+
registerCommand() {},
|
|
59
|
+
registerTool() {},
|
|
60
|
+
} as unknown as ExtensionAPI;
|
|
61
|
+
createGentleAiExtension({
|
|
62
|
+
nativeReviewCli: null,
|
|
63
|
+
processEnv: { ...fixtureEnvironment, GENTLE_PI_AGENTS_CHILD: "0", ...processEnv, GENTLE_AI_TELEMETRY: "0" },
|
|
64
|
+
resolveTelemetryTriggerBinary: () => join(fixtureCwd, "never-executed"),
|
|
65
|
+
telemetryTriggerSpawn: () => assert.fail("Route fixtures must not spawn telemetry"),
|
|
66
|
+
})(pi);
|
|
67
|
+
return handlers;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function gentleTodoHandlers(): { handlers: Map<string, Handler[]>; tools: Map<string, { execute: (...args: unknown[]) => Promise<unknown> }> } {
|
|
71
|
+
const handlers = new Map<string, Handler[]>();
|
|
72
|
+
const tools = new Map<string, { execute: (...args: unknown[]) => Promise<unknown>; name: string }>();
|
|
73
|
+
const pi = {
|
|
74
|
+
on(event: string, handler: Handler) {
|
|
75
|
+
handlers.set(event, [...(handlers.get(event) ?? []), handler]);
|
|
76
|
+
},
|
|
77
|
+
registerTool(tool: { execute: (...args: unknown[]) => Promise<unknown>; name: string }) {
|
|
78
|
+
tools.set(tool.name, tool);
|
|
79
|
+
},
|
|
80
|
+
registerShortcut() {},
|
|
81
|
+
} as unknown as ExtensionAPI;
|
|
82
|
+
gentleTodo(pi, {});
|
|
83
|
+
return { handlers, tools };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function ctx(): ExtensionContext {
|
|
87
|
+
return {
|
|
88
|
+
cwd: fixtureCwd,
|
|
89
|
+
hasUI: true,
|
|
90
|
+
ui: { notify() {}, setWidget() {} },
|
|
91
|
+
sessionManager: { getSessionId: () => "append-route-session", getBranch: () => [] },
|
|
92
|
+
} as unknown as ExtensionContext;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
test("both extensions land their block in appendSystemPrompt on one shared options object, and neither returns a replacement systemPrompt", async () => {
|
|
96
|
+
const aiHandlers = gentleAiHandlers();
|
|
97
|
+
const { handlers: todoHandlers, tools } = gentleTodoHandlers();
|
|
98
|
+
const session = ctx();
|
|
99
|
+
|
|
100
|
+
for (const handler of todoHandlers.get("session_start") ?? []) await handler({}, session);
|
|
101
|
+
await tools.get("todo")!.execute("c1", { action: "write", tasks: [{ title: "Fix the bug" }] }, undefined, undefined, session);
|
|
102
|
+
for (const handler of todoHandlers.get("tool_execution_end") ?? []) await handler({ toolName: "todo" }, session);
|
|
103
|
+
|
|
104
|
+
const event = { systemPrompt: "base", systemPromptOptions: { appendSystemPrompt: "" } };
|
|
105
|
+
|
|
106
|
+
// Every handler registered for before_agent_start observes the same
|
|
107
|
+
// systemPromptOptions object within one emission, exactly as pi's runner
|
|
108
|
+
// does for the real event.
|
|
109
|
+
const aiResult = await aiHandlers.get("before_agent_start")!(event, session);
|
|
110
|
+
let todoResult: unknown;
|
|
111
|
+
for (const handler of todoHandlers.get("before_agent_start") ?? []) todoResult = await handler(event, session);
|
|
112
|
+
|
|
113
|
+
assert.equal(aiResult, undefined, "gentle-ai must not return a replacement systemPrompt");
|
|
114
|
+
assert.equal(todoResult, undefined, "gentle-todo must not return a replacement systemPrompt");
|
|
115
|
+
|
|
116
|
+
const appended = event.systemPromptOptions.appendSystemPrompt;
|
|
117
|
+
assert.match(appended, /el Gentleman Identity and Harness/);
|
|
118
|
+
assert.match(appended, /## Todo list/);
|
|
119
|
+
assert.match(appended, /1\. \[pending\] Fix the bug/);
|
|
120
|
+
assert.ok(
|
|
121
|
+
appended.indexOf("el Gentleman Identity and Harness") < appended.indexOf("## Todo list"),
|
|
122
|
+
"gentle-ai's block must precede gentle-todo's, matching handler registration order",
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("re-running both handlers on the same already-populated options object does not duplicate either block", async () => {
|
|
127
|
+
const aiHandlers = gentleAiHandlers();
|
|
128
|
+
const { handlers: todoHandlers, tools } = gentleTodoHandlers();
|
|
129
|
+
const session = ctx();
|
|
130
|
+
|
|
131
|
+
for (const handler of todoHandlers.get("session_start") ?? []) await handler({}, session);
|
|
132
|
+
await tools.get("todo")!.execute("c1", { action: "write", tasks: [{ title: "Fix the bug" }] }, undefined, undefined, session);
|
|
133
|
+
for (const handler of todoHandlers.get("tool_execution_end") ?? []) await handler({ toolName: "todo" }, session);
|
|
134
|
+
|
|
135
|
+
const event = { systemPrompt: "base", systemPromptOptions: { appendSystemPrompt: "" } };
|
|
136
|
+
await aiHandlers.get("before_agent_start")!(event, session);
|
|
137
|
+
const gentleAiOccurrencesAfterFirstRun = event.systemPromptOptions.appendSystemPrompt.split("el Gentleman Identity and Harness").length - 1;
|
|
138
|
+
assert.equal(gentleAiOccurrencesAfterFirstRun, 1);
|
|
139
|
+
|
|
140
|
+
// A defensive re-run of gentle-ai's own handler against an options object
|
|
141
|
+
// that already carries its exact block must not append it again.
|
|
142
|
+
await aiHandlers.get("before_agent_start")!(event, session);
|
|
143
|
+
const gentleAiOccurrencesAfterSecondRun = event.systemPromptOptions.appendSystemPrompt.split("el Gentleman Identity and Harness").length - 1;
|
|
144
|
+
assert.equal(gentleAiOccurrencesAfterSecondRun, 1, "a second gentle-ai run on the same options object must not duplicate the harness");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
for (const scenario of ["child", "named-agent"] as const) {
|
|
148
|
+
test(`${scenario} start leaves shared prompt options unchanged and returns no replacement`, async () => {
|
|
149
|
+
const handlers = gentleAiHandlers(scenario === "child" ? { GENTLE_PI_AGENTS_CHILD: "1" } : {});
|
|
150
|
+
const event = {
|
|
151
|
+
systemPrompt: "base",
|
|
152
|
+
systemPromptOptions: { appendSystemPrompt: "Existing provider section" },
|
|
153
|
+
...(scenario === "named-agent" ? { agentName: "gentle-ai-worker" } : {}),
|
|
154
|
+
};
|
|
155
|
+
const result = await handlers.get("before_agent_start")!(event, ctx());
|
|
156
|
+
assert.equal(result, undefined, "excluded sessions must not return a replacement system prompt");
|
|
157
|
+
assert.equal(event.systemPromptOptions.appendSystemPrompt, "Existing provider section");
|
|
158
|
+
assert.doesNotMatch(event.systemPromptOptions.appendSystemPrompt, /el Gentleman Identity and Harness/);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { appendSystemPromptOnce } from "../lib/append-system-prompt.ts";
|
|
4
|
+
|
|
5
|
+
// gentle-shell#1485: pi-claude-bridge forwards only the structured
|
|
6
|
+
// systemPromptOptions parts of before_agent_start, so extensions must mutate
|
|
7
|
+
// options.appendSystemPrompt instead of returning a replacement systemPrompt.
|
|
8
|
+
|
|
9
|
+
test("appends to an empty appendSystemPrompt, stripped of a leading blank line", () => {
|
|
10
|
+
const options = { appendSystemPrompt: "" };
|
|
11
|
+
appendSystemPromptOnce(options, "\n\nHarness block");
|
|
12
|
+
assert.equal(options.appendSystemPrompt, "Harness block");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("appends after existing content with a blank-line separator", () => {
|
|
16
|
+
const options = { appendSystemPrompt: "user APPEND_SYSTEM.md content" };
|
|
17
|
+
appendSystemPromptOnce(options, "\n\nHarness block");
|
|
18
|
+
assert.equal(options.appendSystemPrompt, "user APPEND_SYSTEM.md content\n\nHarness block");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("is idempotent: the same text is never appended twice to the same options object", () => {
|
|
22
|
+
const options = { appendSystemPrompt: "" };
|
|
23
|
+
appendSystemPromptOnce(options, "\n\nHarness block");
|
|
24
|
+
appendSystemPromptOnce(options, "\n\nHarness block");
|
|
25
|
+
assert.equal(options.appendSystemPrompt, "Harness block");
|
|
26
|
+
assert.equal(options.appendSystemPrompt.split("Harness block").length - 1, 1);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("two different callers accumulate without erasing each other", () => {
|
|
30
|
+
const options = { appendSystemPrompt: "" };
|
|
31
|
+
appendSystemPromptOnce(options, "\n\nGentle AI harness");
|
|
32
|
+
appendSystemPromptOnce(options, "\n\nTodo list block");
|
|
33
|
+
assert.equal(options.appendSystemPrompt, "Gentle AI harness\n\nTodo list block");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("empty or blank text is a no-op", () => {
|
|
37
|
+
const options = { appendSystemPrompt: "kept" };
|
|
38
|
+
appendSystemPromptOnce(options, "");
|
|
39
|
+
appendSystemPromptOnce(options, "\n\n");
|
|
40
|
+
assert.equal(options.appendSystemPrompt, "kept");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a missing options object never throws", () => {
|
|
44
|
+
assert.doesNotThrow(() => appendSystemPromptOnce(undefined, "\n\nHarness block"));
|
|
45
|
+
assert.doesNotThrow(() => appendSystemPromptOnce(null, "\n\nHarness block"));
|
|
46
|
+
});
|