gentle-pi 4.1.0 → 4.3.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 +352 -128
- package/assets/agents/gentle-ai-explore.md +1 -0
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -1
- package/assets/orchestrator-delegation-reference.md +163 -0
- package/assets/orchestrator-delegation.md +25 -176
- package/assets/orchestrator-tracking.md +1 -1
- package/assets/orchestrator-verification.md +14 -8
- package/assets/orchestrator-writer.md +5 -1
- package/assets/orchestrator.md +9 -9
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +188 -35
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
- package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/assets/diagrams/agent-orchestration.svg +54 -15
- package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
- package/docs/assets/diagrams/measured-routing.svg +49 -0
- package/docs/assets/diagrams/native-review.svg +52 -12
- package/docs/assets/diagrams/odd-workflow.svg +128 -66
- package/docs/assets/diagrams/permission-modes.svg +51 -0
- package/docs/assets/features/audio-notifications.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/auto-permission-answers.md +25 -0
- package/docs/auto-permission-evidence.md +39 -0
- package/docs/bundled-install.md +40 -8
- package/docs/delegated-verification.md +10 -8
- package/docs/gentle-agents-activity.md +11 -5
- package/docs/gentle-shell-uninstall.md +8 -1
- package/docs/gentle-shell.md +20 -12
- package/docs/install-wizard.md +17 -10
- package/docs/permission-modes.md +51 -6
- package/docs/readme-reference.md +155 -31
- package/docs/session-profile-format.md +39 -0
- package/docs/yolo-mode.md +21 -7
- package/extensions/ask-user-choice.ts +4 -0
- package/extensions/ask-user-question.ts +6 -1
- package/extensions/codegraph-tools.ts +68 -6
- package/extensions/gentle-agents.ts +146 -28
- package/extensions/gentle-ai.ts +380 -68
- package/extensions/gentle-herdr-activity.ts +2 -3
- package/extensions/gentle-shell.ts +53 -43
- package/extensions/gentle-todo.ts +62 -18
- package/extensions/quiet-tools.ts +1 -1
- package/extensions/session-scratch.ts +365 -0
- package/extensions/startup-banner.ts +2 -3
- package/lib/agent-home.ts +8 -3
- package/lib/agents-protocol.ts +3 -0
- package/lib/agents-runner.ts +55 -6
- package/lib/agents-view.ts +3 -1
- package/lib/auto-permission-answers.ts +133 -0
- package/lib/auto-permission-classifier.ts +215 -36
- package/lib/auto-permission-evidence.ts +88 -0
- package/lib/auto-permission-memory.ts +69 -0
- package/lib/auto-permission-runtime.ts +251 -35
- package/lib/auto-permission-tool-context.ts +61 -0
- package/lib/bounded-writer-admission.ts +51 -0
- package/lib/command-palette-catalog.ts +0 -4
- package/lib/commit-slice-signal.ts +100 -0
- package/lib/context-backstop.ts +84 -0
- package/lib/delivered-test-lint.ts +80 -0
- package/lib/destructive-command-guard.ts +215 -28
- package/lib/gauge-theme.ts +146 -0
- package/lib/gentle-ai-binary.ts +28 -7
- package/lib/gentle-shell-bundled.ts +62 -8
- package/lib/gentle-shell-import-pi.ts +233 -0
- package/lib/gentle-shell-launcher.ts +146 -12
- package/lib/gentle-shell-uninstall.ts +18 -1
- package/lib/herdr-activity.ts +24 -0
- package/lib/model-routing-authority.ts +6 -1
- package/lib/native-review-cli.ts +9 -1
- package/lib/orchestrator-discovery.ts +5 -1
- package/lib/orchestrator-presence.ts +9 -0
- package/lib/orchestrator-scope.ts +21 -3
- package/lib/provider-contract-bundle.ts +99 -53
- package/lib/review-candidate-view-owner.ts +29 -19
- package/lib/review-candidate-view.ts +36 -2
- package/lib/review-risk-assessment.ts +43 -38
- package/lib/review-session-standing-permission.ts +3 -1
- package/lib/selection-engine.ts +4 -1
- package/lib/session-profile-freeze.ts +40 -16
- package/lib/session-profile-persistence.ts +187 -0
- package/lib/session-scratch.ts +474 -0
- package/lib/shell-bar.ts +25 -25
- package/lib/shell-changes-view.ts +80 -13
- package/lib/shell-changes.ts +2 -0
- package/lib/shell-gauge.ts +49 -6
- package/lib/shell-prompt.ts +6 -4
- package/lib/shell-todo.ts +39 -8
- package/lib/shell-usage-view.ts +5 -1
- package/lib/shell-usage.ts +7 -7
- package/lib/verifier-admission.ts +29 -0
- package/lib/vim-editor-adapter.ts +22 -4
- package/lib/yolo-temp-scratch.ts +69 -0
- package/package.json +5 -6
- package/runtime/gentle-ai-binary.mjs +28 -7
- package/runtime/gentle-shell-bundled.mjs +61 -7
- package/runtime/gentle-shell-import-pi.mjs +234 -0
- package/runtime/gentle-shell-launcher.mjs +144 -10
- package/runtime/gentle-shell-uninstall.mjs +18 -1
- package/runtime/native-review-cli.mjs +8 -0
- package/runtime/review-risk-assessment.mjs +43 -38
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/bundled-engram-migration.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-wizard-preview.mjs +2 -2
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +1 -1
- package/scripts/installer-server.mjs +14 -4
- package/scripts/main-channel.mjs +3 -2
- package/scripts/pi-update.mjs +223 -0
- package/scripts/run-test-suite.mjs +5 -0
- package/scripts/verify-package-files.mjs +19 -13
- package/skills/gentle-ai/SKILL.md +3 -3
- package/skills/work-unit-commits/SKILL.md +5 -3
- package/tests/agent-risk-escalation.test.ts +37 -10
- package/tests/agents-runner.test.ts +260 -3
- package/tests/append-system-prompt-route.test.ts +18 -1
- package/tests/auto-mode-human-answers.test.ts +68 -0
- package/tests/auto-mode-invariants.test.ts +23 -5
- package/tests/auto-mode-sdk.test.ts +29 -2
- package/tests/auto-permission-answers.test.ts +170 -0
- package/tests/auto-permission-classifier.test.ts +353 -2
- package/tests/auto-permission-compound.test.ts +24 -0
- package/tests/auto-permission-evidence.test.ts +88 -0
- package/tests/auto-permission-memory.test.ts +46 -0
- package/tests/auto-permission-public-regressions.test.ts +111 -0
- package/tests/auto-permission-runtime.test.ts +270 -12
- package/tests/autonomous-guard.test.ts +208 -16
- package/tests/bounded-writer-admission.test.ts +11 -3
- package/tests/bundled-install.test.ts +3 -3
- package/tests/card-content-policy.test.ts +19 -9
- package/tests/child-safety.test.ts +39 -0
- package/tests/codegraph-tools.test.ts +123 -2
- package/tests/command-palette.test.ts +14 -4
- package/tests/commit-slice-signal.test.ts +162 -0
- package/tests/context-backstop.test.ts +159 -0
- package/tests/delegate-for-reason-contract.test.ts +91 -24
- package/tests/delivered-test-lint.test.ts +105 -0
- package/tests/destructive-command-guard.test.ts +85 -1
- package/tests/engram-safety.test.ts +35 -7
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
- package/tests/gauge-theme.test.ts +120 -0
- package/tests/gentle-agents.test.ts +443 -8
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-child-guards.test.ts +61 -0
- package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-shell-bin.test.ts +180 -17
- package/tests/gentle-shell-bundled.test.ts +235 -38
- package/tests/gentle-shell-import-pi.test.ts +262 -0
- package/tests/gentle-shell-launcher.test.ts +97 -13
- package/tests/gentle-shell-uninstall.test.ts +30 -0
- package/tests/gentle-shell.test.ts +113 -14
- package/tests/gentle-todo.test.ts +258 -27
- package/tests/headless-delivery-question.test.ts +58 -0
- package/tests/herdr-activity.test.ts +34 -1
- package/tests/install-wizard.test.ts +2 -2
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +3 -3
- package/tests/installer-probes.test.ts +8 -8
- package/tests/installer-runner.test.ts +14 -13
- package/tests/installer-server.test.ts +13 -5
- package/tests/installer-windows-bootstrap.test.ts +33 -4
- package/tests/main-channel-upgrade.test.ts +7 -1
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-parity.test.ts +22 -1
- package/tests/no-self-pause-contract.test.ts +5 -2
- package/tests/odd-integration.test.ts +5 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +127 -162
- package/tests/odd-routing-contract.test.ts +115 -30
- package/tests/odd-runtime-delegation-gate.test.ts +4 -1
- package/tests/orchestrator-budget.test.ts +19 -8
- package/tests/orchestrator-discovery.test.ts +29 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -1
- package/tests/orchestrator-scope.test.ts +66 -0
- package/tests/orchestrator-status-publication-contract.test.ts +12 -8
- package/tests/package-manifest.test.ts +28 -24
- package/tests/pi-update-launcher.test.ts +106 -0
- package/tests/pi-update.test.ts +186 -0
- package/tests/primary-session-prompt.test.ts +101 -0
- package/tests/provider-contract-bundle.test.ts +118 -0
- package/tests/provider-contract-mirror.test.ts +24 -0
- package/tests/quiet-tool-rendering.test.ts +23 -0
- package/tests/rdd-aware-verification-contract.test.ts +29 -11
- package/tests/rdd-status-line.test.ts +131 -0
- package/tests/review-agent-end-preflight.test.ts +6 -0
- package/tests/review-candidate-view.test.ts +49 -0
- package/tests/review-contract-prompt.test.ts +5 -2
- package/tests/review-controller-native-routing.test.ts +14 -1
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +59 -2
- package/tests/review-loadout-rdd-off-sdk.test.ts +237 -0
- package/tests/review-owner-identity.test.ts +132 -0
- package/tests/review-risk-assessment.test.ts +163 -76
- package/tests/review-session-standing-permission-controller.test.ts +14 -1
- package/tests/review-session-standing-permission.test.ts +27 -1
- package/tests/run-test-suite.test.ts +15 -0
- package/tests/runtime-harness.mjs +16 -6
- package/tests/selection-engine.test.ts +35 -5
- package/tests/session-changes-shell.test.ts +1 -1
- package/tests/session-profile-freeze.test.ts +37 -1
- package/tests/session-profile-persistence.test.ts +334 -0
- package/tests/session-scratch.test.ts +945 -0
- package/tests/session-subject-routing-contract.test.ts +2 -1
- package/tests/shell-bar.test.ts +7 -0
- package/tests/shell-changes-view.test.ts +137 -1
- package/tests/shell-changes.test.ts +18 -0
- package/tests/shell-gauge.test.ts +42 -0
- package/tests/shell-todo.test.ts +15 -12
- package/tests/small-task-inline-contract.test.ts +37 -0
- package/tests/standalone-config-home.test.ts +324 -0
- package/tests/startup-banner.test.ts +5 -4
- package/tests/startup-home-repo.test.ts +34 -0
- package/tests/support/orchestrator-modules.ts +1 -0
- package/tests/task-size-routing-contract.test.ts +69 -9
- package/tests/test-scratch-contract.test.ts +102 -0
- package/tests/test-strength-contract.test.ts +36 -1
- package/tests/tool-batching-contract.test.ts +64 -0
- package/tests/verification-proportionality.test.ts +46 -0
- package/tests/verifier-admission.test.ts +137 -0
- package/tests/verify-agent-contract.test.ts +8 -8
- package/tests/vim-editor-adapter.test.ts +41 -15
- package/tests/windows-hidden-processes.test.ts +5 -0
- package/tests/writer-edit-surface-scope.test.ts +3 -0
- package/tests/writer-feature-document-admission.test.ts +156 -0
- package/tests/yolo-mode.test.ts +24 -1
- package/tests/yolo-temp-scratch.test.ts +71 -0
- package/scripts/mirror-odd-routing.mjs +0 -242
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
|
@@ -4,19 +4,23 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSy
|
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import test, { after } from "node:test";
|
|
7
|
+
import test, { after, afterEach } from "node:test";
|
|
8
8
|
import { initTheme, type ExtensionAPI, type ExtensionContext, type SlashCommandInfo, type SourceInfo } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { CURSOR_MARKER, matchesKey, visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
9
|
+
import { CURSOR_MARKER, matchesKey, stripTerminalSequences as stripAnsi, TuiMainScreen, visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
10
10
|
import installGentleShell, { backgroundChangeRefreshesSettled, buildShellBarModel, createActiveProfileReader, changesShortcut, devBinaryCard, extractQueuedText, fetchCodexUsage, fetchNanUsage, loadFileDiff, shellGitRunner, openInExternalEditor, usageShortcut, GentlePromptEditor } from "../extensions/gentle-shell.ts";
|
|
11
11
|
import { CODEX_USAGE_URL, NAN_QUOTA_URL, USAGE_SOURCE_EVENT, USAGE_SOURCE_SCHEMA } from "../lib/shell-usage.ts";
|
|
12
12
|
import { bindSessionProfile, clearSessionProfileBinding, resetSessionProfileBindingsForTesting } from "../lib/session-profile-binding.ts";
|
|
13
13
|
import { createVimEditorAdapter, isAuditedPiEditorVersion } from "../lib/vim-editor-adapter.ts";
|
|
14
|
+
import { readFrozenInheritedProfile, resetFrozenInheritedProfilesForTesting } from "../lib/session-profile-freeze.ts";
|
|
15
|
+
|
|
16
|
+
// Fixture sessions share ids across tests; the inherited-profile freeze is
|
|
17
|
+
// process state keyed by session id (gentle-shell#1064 slice 3b-ii).
|
|
18
|
+
afterEach(() => resetFrozenInheritedProfilesForTesting());
|
|
14
19
|
import { buildCommandPaletteGroups } from "../lib/command-palette-catalog.ts";
|
|
15
20
|
import { CHANGE_STATUS, DIFF_MAX_BYTES, DIFF_MAX_LINES, DIFF_TOO_LARGE } from "../lib/shell-changes.ts";
|
|
16
21
|
import { sidebarPart, sidebarState, type SidebarRail } from "../lib/shell-sidebar.ts";
|
|
17
22
|
import { renderTodoCard } from "../lib/shell-todo.ts";
|
|
18
23
|
import type { ShellBarTheme } from "../lib/shell-bar.ts";
|
|
19
|
-
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
20
24
|
import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
21
25
|
import { decodeReviewStatusV3 } from "../lib/review-integration-v2.ts";
|
|
22
26
|
import { REVIEW_SIDEBAR_EVENT } from "../lib/review-sidebar-state.ts";
|
|
@@ -84,6 +88,7 @@ interface FakeUi {
|
|
|
84
88
|
overlay: unknown;
|
|
85
89
|
overlayView: { render(width: number): string[]; handleInput(data: string): void } | undefined;
|
|
86
90
|
closeOverlay: (() => void) | undefined;
|
|
91
|
+
customOptions?: unknown[];
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
interface GitScript {
|
|
@@ -171,7 +176,7 @@ async function fire(handlers: Map<string, Array<(event: unknown, ctx: ExtensionC
|
|
|
171
176
|
}
|
|
172
177
|
|
|
173
178
|
function fakeContext(options: { theme?: typeof plainTheme & { getBgAnsi?(): string }; hasUI?: boolean; entries?: unknown[]; oauth?: boolean; pending?: boolean; idle?: boolean; editorFactory?: unknown; token?: string; select?: (title: string, options: string[]) => Promise<string | undefined> } = {}): { ctx: ExtensionContext; ui: FakeUi; overlayReady: Promise<void> } {
|
|
174
|
-
const ui: FakeUi = { footerFactory: undefined, editorFactory: options.editorFactory, widgets: new Map(), widgetSets: 0, workingVisible: undefined, notices: [], overlay: undefined, overlayView: undefined, closeOverlay: undefined };
|
|
179
|
+
const ui: FakeUi = { footerFactory: undefined, editorFactory: options.editorFactory, widgets: new Map(), widgetSets: 0, workingVisible: undefined, notices: [], overlay: undefined, overlayView: undefined, closeOverlay: undefined, customOptions: [] };
|
|
175
180
|
let resolveOverlay: () => void;
|
|
176
181
|
const overlayReady = new Promise<void>((resolve) => { resolveOverlay = resolve; });
|
|
177
182
|
const entries = options.entries ?? [];
|
|
@@ -222,8 +227,9 @@ function fakeContext(options: { theme?: typeof plainTheme & { getBgAnsi?(): stri
|
|
|
222
227
|
// whatever their `done` callback is given (see ExtensionUIContext.custom
|
|
223
228
|
// in pi-coding-agent), not always null. `closeOverlay` stays a
|
|
224
229
|
// null-resolving escape hatch for tests that only need to end the wait.
|
|
225
|
-
custom<T>(factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: T) => void) => { render(width: number): string[]; handleInput(data: string): void }) {
|
|
230
|
+
custom<T>(factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: T) => void) => { render(width: number): string[]; handleInput(data: string): void }, options?: unknown) {
|
|
226
231
|
ui.overlay = factory;
|
|
232
|
+
ui.customOptions?.push(options);
|
|
227
233
|
return new Promise<T | null>((resolve) => {
|
|
228
234
|
ui.closeOverlay = () => resolve(null);
|
|
229
235
|
// An identity bg mock hides focused rows; omit it so overlay navigation
|
|
@@ -751,7 +757,9 @@ test("profile polling refreshes both fullscreen surfaces only on change and stop
|
|
|
751
757
|
t.mock.method(globalThis, "clearInterval", (handle: { timer: (typeof intervals)[number] }) => { handle.timer.stopped = true; });
|
|
752
758
|
const { pi, handlers } = fakePi();
|
|
753
759
|
let resolutions = 0;
|
|
754
|
-
|
|
760
|
+
// follow mode keeps the live p → P → G resolution, so a pin edit reaches the
|
|
761
|
+
// open session through the poll (gentle-shell#1064 slice 3b-ii).
|
|
762
|
+
gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off", GENTLE_PI_PROFILE_FOLLOW: "1" }, {
|
|
755
763
|
resolveWorktree: () => { resolutions++; return { root: repo, commonDir }; },
|
|
756
764
|
});
|
|
757
765
|
const first = fakeContext();
|
|
@@ -1800,6 +1808,8 @@ function reverseColumns(row: string): number[] {
|
|
|
1800
1808
|
let inverse = false;
|
|
1801
1809
|
let column = 0;
|
|
1802
1810
|
const selected: number[] = [];
|
|
1811
|
+
const host = TuiMainScreen.prototype as unknown as { resolveFakeCursors(rows: string[]): string[] };
|
|
1812
|
+
row = host.resolveFakeCursors.call({ showHardwareCursor: false }, [row])[0]!;
|
|
1803
1813
|
const tokens = row.match(/\x1b\[[0-9;]*m|\x1b_pi:c\x07|[^\x1b]/g) ?? [];
|
|
1804
1814
|
assert.equal(tokens.join(""), row);
|
|
1805
1815
|
for (const token of tokens) {
|
|
@@ -4767,6 +4777,35 @@ test("a registered worktree opens Changes as its own group", async () => {
|
|
|
4767
4777
|
} finally { ui.closeOverlay?.(); await opened; await fire(handlers,"session_shutdown",ctx); }
|
|
4768
4778
|
});
|
|
4769
4779
|
|
|
4780
|
+
// /gentle:changes opens like Gentle Agents: a full-terminal overlay whose
|
|
4781
|
+
// native fullscreen wrapper still routes mouse events to the changes view.
|
|
4782
|
+
test("Changes opens full screen and the wrapper keeps mouse hit-testing aligned", async () => {
|
|
4783
|
+
const {pi,handlers,commands}=fakePi([{numstat:"",porcelain:" M file.ts\0?? dirty.ts\0"}]);
|
|
4784
|
+
gentleShell(pi,{});
|
|
4785
|
+
const {ctx,ui,overlayReady}=fakeContext();
|
|
4786
|
+
await fire(handlers,"session_start",ctx);
|
|
4787
|
+
const opened=commands.get("gentle:changes")!.handler("",ctx);
|
|
4788
|
+
await overlayReady;
|
|
4789
|
+
const overlay=ui.overlayView as unknown as { render(width: number): string[]; handleInput(data: string): void; handleMouse(event: unknown): unknown };
|
|
4790
|
+
const at=(type: string, x: number, y: number) => ({ type, button: type === "move" ? "none" : "left", x, y, screenX: x, screenY: y, width: 140, height: 40, shift: false, alt: false, ctrl: false });
|
|
4791
|
+
try {
|
|
4792
|
+
assert.deepEqual(ui.customOptions?.at(-1), { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", margin: 0, anchor: "center" } });
|
|
4793
|
+
assert.equal(overlay.render(140).length, 40, "the overlay uses every terminal row");
|
|
4794
|
+
overlay.handleInput("\r");
|
|
4795
|
+
overlay.render(140);
|
|
4796
|
+
const { handled, render } = overlay.handleMouse(at("click", 3, 3)) as { handled?: boolean; render?: boolean };
|
|
4797
|
+
assert.deepEqual({ handled, render }, { handled: true, render: true });
|
|
4798
|
+
assert.match(overlay.render(140)[3]!, /▸ M file\.ts/, "a click through the wrapper selects the file");
|
|
4799
|
+
overlay.handleInput("F");
|
|
4800
|
+
assert.match(overlay.render(140)[1]!, /^│ M file\.ts +│$/, "F switches to diff-only inside the fullscreen overlay");
|
|
4801
|
+
} finally { ui.closeOverlay?.(); await opened; }
|
|
4802
|
+
const reopened=commands.get("gentle:changes")!.handler("",ctx);
|
|
4803
|
+
for (let attempt=0; attempt<100 && ui.overlayView===overlay; attempt++) await new Promise((resolve)=>setTimeout(resolve,5));
|
|
4804
|
+
try {
|
|
4805
|
+
assert.match(ui.overlayView!.render(140)[1]!, /main · repo/, "diff-only mode is not persisted across openings");
|
|
4806
|
+
} finally { ui.closeOverlay?.(); await reopened; await fire(handlers,"session_shutdown",ctx); }
|
|
4807
|
+
});
|
|
4808
|
+
|
|
4770
4809
|
test("new sessions do not inherit another session's worktrees", async () => {
|
|
4771
4810
|
const h=fakePi(); gentleShell(h.pi,{});
|
|
4772
4811
|
const first=fakeContext();
|
|
@@ -4841,7 +4880,7 @@ test("registered canonical root governs real Git discovery, status and diff desp
|
|
|
4841
4880
|
assert.ok(await loadFileDiff(bounded, { path: "tracked.txt", added: 70_001, deleted: 1, status: CHANGE_STATUS.MODIFIED }) === DIFF_TOO_LARGE);
|
|
4842
4881
|
const opened = h.commands.get("gentle:changes")!.handler("", ctx);
|
|
4843
4882
|
await overlayReady;
|
|
4844
|
-
const trees = (ui.overlayView as unknown as { trees: Array<{ root: string; model: { files: Array<{ path: string }> } }> }).trees;
|
|
4883
|
+
const trees = (ui.overlayView as unknown as { children: Array<{ trees: Array<{ root: string; model: { files: Array<{ path: string }> } }> }> }).children[0]!.trees;
|
|
4845
4884
|
assert.deepEqual(trees.map((tree) => [tree.root, tree.model.files.map((file) => file.path)]), [[selected, ["selected-only.txt", "tracked.txt"]]], "inherited routing never lists the foreign repository");
|
|
4846
4885
|
ui.closeOverlay?.();
|
|
4847
4886
|
await opened;
|
|
@@ -5945,7 +5984,10 @@ test("after a profile switch the panel stops presenting the old profile's provid
|
|
|
5945
5984
|
ui.closeOverlay?.();
|
|
5946
5985
|
await opened;
|
|
5947
5986
|
|
|
5948
|
-
|
|
5987
|
+
// An open session changes profile only through its own Enter (gentle-shell#1064
|
|
5988
|
+
// slice 3b-ii): a new global default would leave its frozen routing in place.
|
|
5989
|
+
bindSessionProfile("shell-session", "solo", {});
|
|
5990
|
+
t.after(() => resetSessionProfileBindingsForTesting());
|
|
5949
5991
|
const reopened = commands.get("gentle:usage")!.handler("", ctx);
|
|
5950
5992
|
await settle();
|
|
5951
5993
|
assert.equal(ui.overlayView!.render(90).map(stripAnsi).some((line) => line.includes("nan")), false, "a provider recorded under the previous profile's routing is not current scope");
|
|
@@ -5953,6 +5995,61 @@ test("after a profile switch the panel stops presenting the old profile's provid
|
|
|
5953
5995
|
await reopened;
|
|
5954
5996
|
});
|
|
5955
5997
|
|
|
5998
|
+
test("an open session keeps its frozen profile in the status and Usage scope when the defaults change", async (t) => {
|
|
5999
|
+
const home = mkdtempSync(join(tmpdir(), "shell-frozen-"));
|
|
6000
|
+
const commonDir = mkdtempSync(join(tmpdir(), "shell-frozen-git-"));
|
|
6001
|
+
t.after(() => {
|
|
6002
|
+
rmSync(home, { recursive: true, force: true });
|
|
6003
|
+
rmSync(commonDir, { recursive: true, force: true });
|
|
6004
|
+
});
|
|
6005
|
+
mkdirSync(join(commonDir, "gentle-ai"), { recursive: true });
|
|
6006
|
+
writeProfilesStore(home, { team: { reviewer: { model: "nan/glm5.3" } }, solo: { reviewer: { model: "openai-codex/gpt-5.5" } } }, "team");
|
|
6007
|
+
const { pi, handlers, commands } = fakePi();
|
|
6008
|
+
gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fakeFetch(NAN_QUOTA_PAYLOAD).fetchFn, now: () => 1_788_600_000_000, resolveWorktree: () => ({ root: "/repo", commonDir }) });
|
|
6009
|
+
const { ctx, ui } = fakeContext({ token: JWT });
|
|
6010
|
+
await fire(handlers, "session_start", ctx);
|
|
6011
|
+
assert.equal(readFrozenInheritedProfile("shell-session")?.profile?.name, "team", "the session froze the global active profile at start");
|
|
6012
|
+
writeProfilesStore(home, { team: { reviewer: { model: "nan/glm5.3" } }, solo: { reviewer: { model: "openai-codex/gpt-5.5" } } }, "solo");
|
|
6013
|
+
writeFileSync(join(commonDir, "gentle-ai", "profile-pin.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "solo" }));
|
|
6014
|
+
const opened = commands.get("gentle:usage")!.handler("", ctx);
|
|
6015
|
+
await settle();
|
|
6016
|
+
assert.ok(openPanelLines(ui).some((line) => /^│ nan ·/.test(line)), "Usage keeps the frozen profile's nan route");
|
|
6017
|
+
ui.closeOverlay?.();
|
|
6018
|
+
await opened;
|
|
6019
|
+
const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, data: unknown) => { dispose(): void };
|
|
6020
|
+
const tui = { terminal: { rows: 40, columns: 160 }, requestRender() {} };
|
|
6021
|
+
const component = factory(tui, plainTheme, { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} });
|
|
6022
|
+
try {
|
|
6023
|
+
const status = (sidebarState(tui as unknown as TUI).parts.get("footer") as SidebarRail).render(60).join("\n");
|
|
6024
|
+
assert.match(status, /Profile.*team/, "the status keeps the bare global name of the frozen profile");
|
|
6025
|
+
assert.doesNotMatch(status, /solo/);
|
|
6026
|
+
} finally {
|
|
6027
|
+
component.dispose();
|
|
6028
|
+
}
|
|
6029
|
+
});
|
|
6030
|
+
|
|
6031
|
+
test("active profile reader: a bound session shows its frozen profile, and follow shows the live default", (t) => {
|
|
6032
|
+
const root = mkdtempSync(join(tmpdir(), "shell-profile-frozen-"));
|
|
6033
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
6034
|
+
const commonDir = join(root, "git");
|
|
6035
|
+
mkdirSync(join(commonDir, "gentle-ai"), { recursive: true });
|
|
6036
|
+
writeFileSync(join(root, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active: "team", profiles: { team: {}, other: {} } }));
|
|
6037
|
+
const pin = (profile: string) => writeFileSync(join(commonDir, "gentle-ai", "profile-pin.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile }));
|
|
6038
|
+
pin("other");
|
|
6039
|
+
const frozen = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root });
|
|
6040
|
+
frozen.bind(root, () => ({ root, commonDir }), "session-frozen");
|
|
6041
|
+
assert.equal(frozen(), "other (local)");
|
|
6042
|
+
const follow = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root, GENTLE_PI_PROFILE_FOLLOW: "1" });
|
|
6043
|
+
follow.bind(root, () => ({ root, commonDir }), "session-follow");
|
|
6044
|
+
assert.equal(follow(), "other (local)");
|
|
6045
|
+
rmSync(join(commonDir, "gentle-ai", "profile-pin.json"));
|
|
6046
|
+
assert.equal(frozen.refresh(), false, "the frozen label does not move");
|
|
6047
|
+
assert.equal(frozen(), "other (local)");
|
|
6048
|
+
assert.equal(follow.refresh(), true);
|
|
6049
|
+
assert.equal(follow(), "team", "follow falls to the global active profile");
|
|
6050
|
+
assert.equal(readFrozenInheritedProfile("session-follow"), undefined);
|
|
6051
|
+
});
|
|
6052
|
+
|
|
5956
6053
|
function openPanelLines(ui: FakeUi): string[] {
|
|
5957
6054
|
return ui.overlayView!.render(90).map(stripAnsi);
|
|
5958
6055
|
}
|
|
@@ -6215,19 +6312,21 @@ test("an explicit session binding routes the Usage scope ahead of the pin and th
|
|
|
6215
6312
|
|
|
6216
6313
|
// S9: the config home each of these reads resolves, per case. The OS home is
|
|
6217
6314
|
// a temporary folder in every case, so no case can reach the real one.
|
|
6218
|
-
// "unset" and "empty"
|
|
6219
|
-
//
|
|
6220
|
-
// a bundled install (testing overlay) whose
|
|
6221
|
-
// user's own GENTLE_PI_CONFIG_HOME is then not
|
|
6315
|
+
// "unset" and "empty" both name ~/.pi/gentle-ai: an empty GENTLE_PI_CONFIG_HOME
|
|
6316
|
+
// counts as unset (#2201; it used to name the current folder). "bundled" stands
|
|
6317
|
+
// in for this package running from a bundled install (testing overlay) whose
|
|
6318
|
+
// prefix the launcher passed; the user's own GENTLE_PI_CONFIG_HOME is then not
|
|
6319
|
+
// this install's.
|
|
6222
6320
|
type ConfigHomeCase = "unset" | "empty" | "bundled";
|
|
6223
6321
|
function configHomeCase(t: { after: (fn: () => void) => void }, kind: ConfigHomeCase): { env: NodeJS.ProcessEnv; dir: string; userConfig: string } {
|
|
6224
6322
|
const base = realpathSync(mkdtempSync(join(tmpdir(), `config-home-${kind}-`)));
|
|
6225
|
-
const saved = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME, GENTLE_SHELL_PREFIX: process.env.GENTLE_SHELL_PREFIX };
|
|
6323
|
+
const saved = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME, GENTLE_SHELL_PREFIX: process.env.GENTLE_SHELL_PREFIX, GENTLE_SHELL_CONFIG_HOME: process.env.GENTLE_SHELL_CONFIG_HOME };
|
|
6226
6324
|
const cwd = process.cwd();
|
|
6227
6325
|
process.env.HOME = join(base, "home");
|
|
6228
6326
|
process.env.USERPROFILE = join(base, "home");
|
|
6229
6327
|
delete process.env.GENTLE_PI_CONFIG_HOME;
|
|
6230
6328
|
delete process.env.GENTLE_SHELL_PREFIX;
|
|
6329
|
+
delete process.env.GENTLE_SHELL_CONFIG_HOME;
|
|
6231
6330
|
t.after(() => {
|
|
6232
6331
|
process.chdir(cwd);
|
|
6233
6332
|
setBundledInstallForTesting(undefined);
|
|
@@ -6242,7 +6341,7 @@ function configHomeCase(t: { after: (fn: () => void) => void }, kind: ConfigHome
|
|
|
6242
6341
|
if (kind === "empty") {
|
|
6243
6342
|
mkdirSync(join(base, "cwd"));
|
|
6244
6343
|
process.chdir(join(base, "cwd"));
|
|
6245
|
-
return { env: { GENTLE_PI_CONFIG_HOME: "" }, dir: join(base, "
|
|
6344
|
+
return { env: { GENTLE_PI_CONFIG_HOME: "" }, dir: join(base, "home", ".pi", "gentle-ai"), userConfig };
|
|
6246
6345
|
}
|
|
6247
6346
|
const prefix = join(base, "prefix");
|
|
6248
6347
|
mkdirSync(prefix);
|
|
@@ -3,6 +3,7 @@ import test, { after, before } from "node:test";
|
|
|
3
3
|
import { convertToLlm, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
5
5
|
import gentleTodo, { todoCollapseKey, todoEnabled } from "../extensions/gentle-todo.ts";
|
|
6
|
+
import { TODO_PROGRESS_EVENT } from "../lib/shell-todo.ts";
|
|
6
7
|
import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
|
|
7
8
|
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
8
9
|
import { sidebarState } from "../lib/shell-sidebar.ts";
|
|
@@ -114,12 +115,27 @@ test("long Todo widgets stay bounded, scroll, resize and keep their collapse con
|
|
|
114
115
|
assert.equal((result.details.gentleTodo as { tasks: { title: string; note: string }[] }).tasks[0].title, title.trim());
|
|
115
116
|
assert.equal((result.details.gentleTodo as { tasks: { title: string; note: string }[] }).tasks[0].note, note.trim());
|
|
116
117
|
await shortcuts.get("ctrl+shift+t")!.handler(ctx);
|
|
117
|
-
await shortcuts.get("
|
|
118
|
+
await shortcuts.get("alt+shift+down")!.handler(ctx);
|
|
118
119
|
assert.notDeepEqual(bottom.render(40), collapsed, "keyboard scrolling is available without mouse");
|
|
119
120
|
}
|
|
120
121
|
t.after(() => setCardStyle(CARD_STYLE.NEON));
|
|
121
122
|
});
|
|
122
123
|
|
|
124
|
+
test("Todo shortcuts never take a key Pi binds by default, so startup reports no shortcut conflict", async () => {
|
|
125
|
+
// Pi keeps its default table in dist/core/keybindings.js, next to the package entry.
|
|
126
|
+
const { KEYBINDINGS } = await import(new URL("core/keybindings.js", import.meta.resolve("@earendil-works/pi-coding-agent")).href) as
|
|
127
|
+
{ KEYBINDINGS: Record<string, { defaultKeys?: string | string[] }> };
|
|
128
|
+
assert.ok("tui.altScreen.previousPrompt" in KEYBINDINGS, "the table includes Pi's built-in prompt navigation");
|
|
129
|
+
const owners = new Map<string, string[]>();
|
|
130
|
+
for (const [id, binding] of Object.entries(KEYBINDINGS)) {
|
|
131
|
+
for (const key of ([] as string[]).concat(binding.defaultKeys ?? [])) owners.set(key, [...(owners.get(key) ?? []), id]);
|
|
132
|
+
}
|
|
133
|
+
const { pi, shortcuts } = fakePi();
|
|
134
|
+
gentleTodo(pi, {});
|
|
135
|
+
assert.deepEqual([...shortcuts.keys()].sort(), ["alt+shift+down", "alt+shift+up", "ctrl+shift+t"]);
|
|
136
|
+
for (const key of shortcuts.keys()) assert.equal(owners.get(key), undefined, `${key} is a Pi default for ${owners.get(key)?.join(", ")}`);
|
|
137
|
+
});
|
|
138
|
+
|
|
123
139
|
test("Todo keyboard scrolling targets the visible card after a cached sidebar breakpoint round-trip", async (t) => {
|
|
124
140
|
const node = Symbol.for("@earendil-works/pi-tui/layout-node");
|
|
125
141
|
for (const style of [CARD_STYLE.NEON, CARD_STYLE.FLOAT]) {
|
|
@@ -146,11 +162,11 @@ test("Todo keyboard scrolling targets the visible card after a cached sidebar br
|
|
|
146
162
|
bottom.render(40);
|
|
147
163
|
tui.terminal.columns = 160;
|
|
148
164
|
const before = railLines();
|
|
149
|
-
await shortcuts.get("
|
|
165
|
+
await shortcuts.get("alt+shift+down")!.handler(ctx);
|
|
150
166
|
const after = railLines();
|
|
151
167
|
assert.notDeepEqual(after, before, "DOWN changes the visible cached rail, not the hidden bottom widget");
|
|
152
168
|
assert.match(after.join("\n"), /↑↓ 4–/);
|
|
153
|
-
await shortcuts.get("
|
|
169
|
+
await shortcuts.get("alt+shift+up")!.handler(ctx);
|
|
154
170
|
assert.deepEqual(railLines(), before, "UP restores the expanded viewport");
|
|
155
171
|
} finally {
|
|
156
172
|
dispose();
|
|
@@ -288,11 +304,25 @@ function toolTurn(toolName = "read") {
|
|
|
288
304
|
return { message: { role: "assistant", content: [{ type: "toolCall", id: "call", name: toolName, arguments: {} }] }, toolResults: [] };
|
|
289
305
|
}
|
|
290
306
|
|
|
291
|
-
|
|
307
|
+
type ContextMessage = { role: string; content: unknown; customType?: string; display?: boolean; timestamp?: number };
|
|
308
|
+
|
|
309
|
+
async function contextMessages(fire: ReturnType<typeof fakePi>["fire"], ctx: ExtensionContext): Promise<ContextMessage[] | undefined> {
|
|
292
310
|
const messages = [{ role: "user", content: "Keep working", timestamp: 1 }];
|
|
293
|
-
const result = await fire("context", ctx, { messages }) as { messages:
|
|
311
|
+
const result = await fire("context", ctx, { messages }) as { messages: ContextMessage[] } | undefined;
|
|
294
312
|
assert.deepEqual(messages, [{ role: "user", content: "Keep working", timestamp: 1 }], "request-local injection must not mutate the input transcript");
|
|
295
|
-
return
|
|
313
|
+
if (result === undefined) return undefined;
|
|
314
|
+
assert.deepEqual(result.messages[0], { role: "user", content: "Keep working", timestamp: 1 }, "the transcript stays first; todo messages trail it");
|
|
315
|
+
return result.messages;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The request-local reminder, if the next model request carries one. */
|
|
319
|
+
async function contextReminder(fire: ReturnType<typeof fakePi>["fire"], ctx: ExtensionContext): Promise<ContextMessage | undefined> {
|
|
320
|
+
return (await contextMessages(fire, ctx))?.find((message) => message.customType === "gentle-todo-reminder");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The request-local current-list message, if the next model request carries one (#1662). */
|
|
324
|
+
async function contextState(fire: ReturnType<typeof fakePi>["fire"], ctx: ExtensionContext): Promise<ContextMessage | undefined> {
|
|
325
|
+
return (await contextMessages(fire, ctx))?.find((message) => message.customType === "gentle-todo-state");
|
|
296
326
|
}
|
|
297
327
|
|
|
298
328
|
async function workTurns(fire: ReturnType<typeof fakePi>["fire"], ctx: ExtensionContext, count: number) {
|
|
@@ -312,13 +342,14 @@ test("runtime reminder arrives after four tool-use turns, only once, without cha
|
|
|
312
342
|
await workTurns(fire, ctx, 3);
|
|
313
343
|
assert.equal(await contextReminder(fire, ctx), undefined);
|
|
314
344
|
await workTurns(fire, ctx, 1);
|
|
315
|
-
const
|
|
316
|
-
assert.ok(
|
|
317
|
-
assert.equal(
|
|
318
|
-
assert.
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
345
|
+
const messages = await contextMessages(fire, ctx);
|
|
346
|
+
assert.ok(messages, "the next model request must carry a reminder within the same run");
|
|
347
|
+
assert.equal(messages.length, 3, "transcript, current list, then the reminder");
|
|
348
|
+
assert.equal(messages[1]!.customType, "gentle-todo-state");
|
|
349
|
+
const reminder = messages[2]!;
|
|
350
|
+
assert.deepEqual(reminder, { role: "custom", customType: "gentle-todo-reminder", content: REMINDER, display: false, timestamp: reminder.timestamp });
|
|
351
|
+
const converted = convertToLlm(messages as Parameters<typeof convertToLlm>[0]);
|
|
352
|
+
assert.deepEqual(converted[2], { role: "user", content: [{ type: "text", text: REMINDER }], timestamp: reminder.timestamp }, "Pi forwards the reminder as provider-compatible text");
|
|
322
353
|
await workTurns(fire, ctx, 4);
|
|
323
354
|
assert.equal(await contextReminder(fire, ctx), undefined, "no repeated nagging without a todo write");
|
|
324
355
|
const listed = await tool.execute("list", { action: "list" }, undefined, undefined, ctx);
|
|
@@ -418,31 +449,213 @@ function promptEvent(): { systemPrompt: string; systemPromptOptions: { appendSys
|
|
|
418
449
|
return { systemPrompt: "base", systemPromptOptions: { appendSystemPrompt: "" } };
|
|
419
450
|
}
|
|
420
451
|
|
|
421
|
-
test("
|
|
452
|
+
test("the system prompt carries only a byte-stable todo block; the current list travels as a request-local message (#1662)", async () => {
|
|
422
453
|
const { pi, tools, fire } = fakePi();
|
|
423
454
|
gentleTodo(pi, {});
|
|
424
455
|
const { ctx, widget } = fakeContext();
|
|
425
456
|
await fire("session_start", ctx);
|
|
426
|
-
|
|
457
|
+
const emptyEvent = promptEvent();
|
|
458
|
+
assert.equal(await fire("before_agent_start", ctx, emptyEvent), undefined, "the handler must not return a replacement systemPrompt");
|
|
459
|
+
assert.match(emptyEvent.systemPromptOptions.appendSystemPrompt, /^## Todo list/);
|
|
460
|
+
assert.match(emptyEvent.systemPromptOptions.appendSystemPrompt, /mark a task in_progress before starting/);
|
|
461
|
+
assert.equal(await contextState(fire, ctx), undefined, "an empty list sends no state message");
|
|
462
|
+
|
|
427
463
|
await tools.get("todo")!.execute("c1", { action: "write", tasks: [{ title: "Fix the bug" }] }, undefined, undefined, ctx);
|
|
428
464
|
await fire("tool_execution_end", ctx, { toolName: "todo" });
|
|
429
|
-
|
|
430
465
|
const withTasksEvent = promptEvent();
|
|
431
|
-
|
|
432
|
-
assert.equal(
|
|
433
|
-
assert.
|
|
434
|
-
|
|
466
|
+
await fire("before_agent_start", ctx, withTasksEvent);
|
|
467
|
+
assert.equal(withTasksEvent.systemPromptOptions.appendSystemPrompt, emptyEvent.systemPromptOptions.appendSystemPrompt, "writing a list must not change the system prompt");
|
|
468
|
+
assert.doesNotMatch(withTasksEvent.systemPromptOptions.appendSystemPrompt, /Fix the bug/, "task content never enters the system prompt");
|
|
469
|
+
const state = await contextState(fire, ctx);
|
|
470
|
+
assert.ok(state, "open tasks arrive as a trailing request-local message");
|
|
471
|
+
assert.equal(state.role, "custom");
|
|
472
|
+
assert.equal(state.display, false);
|
|
473
|
+
assert.match(String(state.content), /1\. \[pending\] Fix the bug/);
|
|
435
474
|
assert.doesNotMatch(widget()![0], /stale/);
|
|
436
475
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
476
|
+
// Silent turns: the UI card goes stale, the system prompt stays frozen (#1367).
|
|
477
|
+
for (const expected of ["stale · 2 turns", "stale · 3 turns"]) {
|
|
478
|
+
const silent = promptEvent();
|
|
479
|
+
await fire("before_agent_start", ctx, silent);
|
|
480
|
+
assert.equal(silent.systemPromptOptions.appendSystemPrompt, emptyEvent.systemPromptOptions.appendSystemPrompt);
|
|
481
|
+
assert.match(widget()![1], new RegExp(expected));
|
|
482
|
+
}
|
|
442
483
|
|
|
484
|
+
// Status, note, title and ordering changes reach the model through the message only.
|
|
443
485
|
await tools.get("todo")!.execute("c2", { action: "update", id: 1, status: "in_progress", note: "on it" }, undefined, undefined, ctx);
|
|
444
486
|
await fire("tool_execution_end", ctx, { toolName: "todo" });
|
|
487
|
+
const updatedEvent = promptEvent();
|
|
488
|
+
await fire("before_agent_start", ctx, updatedEvent);
|
|
489
|
+
assert.equal(updatedEvent.systemPromptOptions.appendSystemPrompt, emptyEvent.systemPromptOptions.appendSystemPrompt, "a status change must not change the system prompt");
|
|
490
|
+
assert.match(String((await contextState(fire, ctx))!.content), /1\. \[in_progress\] Fix the bug — on it/);
|
|
445
491
|
assert.doesNotMatch(widget()![0], /stale/);
|
|
492
|
+
|
|
493
|
+
await tools.get("todo")!.execute("c3", { action: "update", id: 1, status: "done" }, undefined, undefined, ctx);
|
|
494
|
+
await fire("tool_execution_end", ctx, { toolName: "todo" });
|
|
495
|
+
assert.equal(await contextState(fire, ctx), undefined, "a finished list sends no state message");
|
|
496
|
+
const finishedEvent = promptEvent();
|
|
497
|
+
await fire("before_agent_start", ctx, finishedEvent);
|
|
498
|
+
assert.equal(finishedEvent.systemPromptOptions.appendSystemPrompt, emptyEvent.systemPromptOptions.appendSystemPrompt, "finishing the list must not change the system prompt");
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Drives the extension the way Pi's agent loop does (#2195): every model request
|
|
503
|
+
* runs the context handlers over the persisted transcript, and a custom message
|
|
504
|
+
* sent with triggerTurn false while streaming is appended to the transcript at
|
|
505
|
+
* turn_end, after the tool results (agent-session `_flushPendingCustomMessages`).
|
|
506
|
+
*/
|
|
507
|
+
function liveSession() {
|
|
508
|
+
const base = fakePi();
|
|
509
|
+
const queued: ContextMessage[] = [];
|
|
510
|
+
const pi = {
|
|
511
|
+
...base.pi,
|
|
512
|
+
sendMessage(message: { customType: string; content: string; display: boolean }, options: { triggerTurn?: boolean }) {
|
|
513
|
+
assert.equal(options.triggerTurn, false, "todo state must never start a turn");
|
|
514
|
+
queued.push({ role: "custom", customType: message.customType, content: message.content, display: message.display, timestamp: 0 });
|
|
515
|
+
},
|
|
516
|
+
} as unknown as ExtensionAPI;
|
|
517
|
+
gentleTodo(pi, {});
|
|
518
|
+
const { ctx } = fakeContext();
|
|
519
|
+
const transcript: ContextMessage[] = [{ role: "user", content: "Work on it", timestamp: 1 }];
|
|
520
|
+
const requests: ContextMessage[][] = [];
|
|
521
|
+
let call = 0;
|
|
522
|
+
const request = async () => {
|
|
523
|
+
const result = await base.fire("context", ctx, { messages: [...transcript] }) as { messages: ContextMessage[] } | undefined;
|
|
524
|
+
const messages = result?.messages ?? [...transcript];
|
|
525
|
+
requests.push(messages);
|
|
526
|
+
return messages;
|
|
527
|
+
};
|
|
528
|
+
/** One model request whose assistant reply calls `todo` (when params are given) or `read`. */
|
|
529
|
+
const turn = async (todo?: Record<string, unknown>) => {
|
|
530
|
+
await request();
|
|
531
|
+
call += 1;
|
|
532
|
+
const id = `call-${call}`;
|
|
533
|
+
const name = todo ? "todo" : "read";
|
|
534
|
+
const assistant = { role: "assistant", content: [{ type: "toolCall", id, name, arguments: todo ?? {} }], timestamp: 0 };
|
|
535
|
+
transcript.push(assistant);
|
|
536
|
+
if (todo) await base.tools.get("todo")!.execute(id, todo, undefined, undefined, ctx);
|
|
537
|
+
transcript.push({ role: "toolResult", content: [{ type: "text", text: "ok" }], timestamp: 0 });
|
|
538
|
+
await base.fire("turn_end", ctx, { message: assistant, toolResults: [] });
|
|
539
|
+
transcript.push(...queued.splice(0));
|
|
540
|
+
};
|
|
541
|
+
const assertAppendOnly = (from: number) => {
|
|
542
|
+
for (let index = from + 1; index < requests.length; index++) {
|
|
543
|
+
const previous = requests[index - 1]!;
|
|
544
|
+
assert.deepEqual(requests[index]!.slice(0, previous.length), previous, `request ${index} must extend request ${index - 1} byte for byte`);
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
const visibleState = (messages: ContextMessage[]) => [...messages].reverse().find((message) => message.customType === "gentle-todo-state")?.content;
|
|
548
|
+
return { ctx, fire: base.fire, transcript, requests, request, turn, assertAppendOnly, visibleState };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
test("a todo write in a running tool loop reaches the transcript before the next request, which stays append-only (#2195)", async () => {
|
|
552
|
+
const live = liveSession();
|
|
553
|
+
await live.fire("session_start", live.ctx);
|
|
554
|
+
await live.turn({ action: "write", tasks: [{ title: "Fix the bug" }, { title: "Ship it" }] });
|
|
555
|
+
await live.turn();
|
|
556
|
+
await live.turn();
|
|
557
|
+
await live.turn({ action: "update", id: 1, status: "done" });
|
|
558
|
+
await live.turn();
|
|
559
|
+
await live.request();
|
|
560
|
+
assert.match(live.visibleState(live.requests.at(-1)!) as string, /1\. \[done\] Fix the bug\n2\. \[pending\] Ship it/);
|
|
561
|
+
live.assertAppendOnly(0);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test("closing every task does not leave a stale open list in the transcript (#2195)", async () => {
|
|
565
|
+
for (const close of [{ action: "update", id: 1, status: "done" }, { action: "update", id: 1, status: "dropped" }, { action: "clear" }]) {
|
|
566
|
+
const live = liveSession();
|
|
567
|
+
await live.fire("session_start", live.ctx);
|
|
568
|
+
await live.turn({ action: "write", tasks: [{ title: "Fix the bug" }] });
|
|
569
|
+
await live.turn(close);
|
|
570
|
+
await live.turn();
|
|
571
|
+
const messages = await live.request();
|
|
572
|
+
assert.equal(live.visibleState(messages), "Current todo list: no open tasks.", `${JSON.stringify(close)}: the model must not see the closed task as still open`);
|
|
573
|
+
live.assertAppendOnly(0);
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("an open list missing from the transcript (compaction, branch switch) is persisted again once, then requests stay append-only (#2195)", async () => {
|
|
578
|
+
const live = liveSession();
|
|
579
|
+
await live.fire("session_start", live.ctx);
|
|
580
|
+
await live.turn({ action: "write", tasks: [{ title: "Fix the bug" }] });
|
|
581
|
+
// Compaction summarises the head of the conversation, including the persisted state.
|
|
582
|
+
live.transcript.splice(0, live.transcript.length, { role: "user", content: "Summary of earlier work", timestamp: 2 });
|
|
583
|
+
const healedFrom = live.requests.length;
|
|
584
|
+
await live.turn();
|
|
585
|
+
await live.turn();
|
|
586
|
+
await live.turn();
|
|
587
|
+
await live.request();
|
|
588
|
+
for (const messages of live.requests.slice(healedFrom)) assert.match(String(live.visibleState(messages)), /\[pending\] Fix the bug/);
|
|
589
|
+
assert.equal(live.transcript.filter((message) => message.customType === "gentle-todo-state").length, 1, "the missing state is persisted exactly once");
|
|
590
|
+
live.assertAppendOnly(healedFrom + 1);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("consecutive context requests with an unchanged Todo list preserve prefix stability (#2195)", async () => {
|
|
594
|
+
const { pi, tools, fire } = fakePi();
|
|
595
|
+
gentleTodo(pi, {});
|
|
596
|
+
const { ctx } = fakeContext();
|
|
597
|
+
await fire("session_start", ctx);
|
|
598
|
+
await tools.get("todo")!.execute("c1", { action: "write", tasks: [{ title: "Fix the bug" }] }, undefined, undefined, ctx);
|
|
599
|
+
|
|
600
|
+
// Request 1: first model request
|
|
601
|
+
const userMsg = { role: "user", content: "Work on it", timestamp: 100 };
|
|
602
|
+
const req1 = await fire("context", ctx, { messages: [userMsg] }) as { messages: Array<{ role: string; customType?: string }> };
|
|
603
|
+
assert.ok(req1, "req1 must return context messages");
|
|
604
|
+
assert.ok(req1.messages.some((m) => (m as { customType?: string }).customType === "gentle-todo-state"), "req1 must include gentle-todo-state");
|
|
605
|
+
|
|
606
|
+
// Model executes a tool (read), producing assistant message and tool result
|
|
607
|
+
const assistantMsg = { role: "assistant", content: [{ type: "toolCall", id: "read-1", name: "read", arguments: {} }], timestamp: 200 };
|
|
608
|
+
const toolResultMsg = { role: "toolResult", toolCallId: "read-1", content: [{ type: "text", text: "file content" }], timestamp: 300 };
|
|
609
|
+
|
|
610
|
+
// Request 2: next model request with unchanged todo list.
|
|
611
|
+
// The input messages to context include the transcript with the already-delivered state message
|
|
612
|
+
const inputReq2 = [...req1.messages, assistantMsg, toolResultMsg];
|
|
613
|
+
const req2 = await fire("context", ctx, { messages: inputReq2 }) as { messages: Array<{ role: string; customType?: string }> } | undefined;
|
|
614
|
+
|
|
615
|
+
// req2 must not append a redundant trailing gentle-todo-state that would invalidate prefix caching
|
|
616
|
+
const effectiveReq2 = req2?.messages ?? inputReq2;
|
|
617
|
+
assert.deepEqual(
|
|
618
|
+
effectiveReq2.slice(0, req1.messages.length),
|
|
619
|
+
req1.messages,
|
|
620
|
+
"req1 must be an exact prefix of req2 to preserve prompt caching",
|
|
621
|
+
);
|
|
622
|
+
// No duplicate trailing state message at the end of req2
|
|
623
|
+
const stateMessages = effectiveReq2.filter((m) => (m as { customType?: string }).customType === "gentle-todo-state");
|
|
624
|
+
assert.equal(stateMessages.length, 1, "must not append duplicate gentle-todo-state when unchanged");
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test("todo write and update persist gentle-todo-state custom message to transcript (#2195)", async () => {
|
|
628
|
+
const sent: Array<{ message: unknown; options: unknown }> = [];
|
|
629
|
+
const base = fakePi();
|
|
630
|
+
const pi = {
|
|
631
|
+
...base.pi,
|
|
632
|
+
sendMessage(message: unknown, options: unknown) {
|
|
633
|
+
sent.push({ message, options });
|
|
634
|
+
},
|
|
635
|
+
} as unknown as ExtensionAPI;
|
|
636
|
+
gentleTodo(pi, {});
|
|
637
|
+
const { ctx } = fakeContext();
|
|
638
|
+
await base.fire("session_start", ctx);
|
|
639
|
+
|
|
640
|
+
// 1. todo write persists state
|
|
641
|
+
await base.tools.get("todo")!.execute("c1", { action: "write", tasks: [{ title: "Task 1" }] }, undefined, undefined, ctx);
|
|
642
|
+
assert.equal(sent.length, 1);
|
|
643
|
+
assert.deepEqual(sent[0]!.options, { triggerTurn: false });
|
|
644
|
+
const firstMsg = sent[0]!.message as { role: string; customType: string; content: string; display: boolean };
|
|
645
|
+
assert.equal(firstMsg.customType, "gentle-todo-state");
|
|
646
|
+
assert.equal(firstMsg.display, false);
|
|
647
|
+
assert.match(firstMsg.content, /1\. \[pending\] Task 1/);
|
|
648
|
+
|
|
649
|
+
// 2. todo update persists new state
|
|
650
|
+
await base.tools.get("todo")!.execute("c2", { action: "update", id: 1, status: "in_progress", note: "doing" }, undefined, undefined, ctx);
|
|
651
|
+
assert.equal(sent.length, 2);
|
|
652
|
+
assert.deepEqual(sent[1]!.options, { triggerTurn: false });
|
|
653
|
+
const secondMsg = sent[1]!.message as { content: string };
|
|
654
|
+
assert.match(secondMsg.content, /1\. \[in_progress\] Task 1 — doing/);
|
|
655
|
+
|
|
656
|
+
// 3. todo list does NOT persist extra messages
|
|
657
|
+
await base.tools.get("todo")!.execute("c3", { action: "list" }, undefined, undefined, ctx);
|
|
658
|
+
assert.equal(sent.length, 2, "read actions must not emit state messages");
|
|
446
659
|
});
|
|
447
660
|
|
|
448
661
|
test("before_agent_start is idempotent: a todo block already present in appendSystemPrompt is not duplicated", async () => {
|
|
@@ -555,8 +768,26 @@ test("a list left with only dropped tasks open finishes and clears; a blocked-on
|
|
|
555
768
|
await fire("agent_end", ctx);
|
|
556
769
|
const event = promptEvent();
|
|
557
770
|
await fire("before_agent_start", ctx, event);
|
|
558
|
-
assert.
|
|
559
|
-
assert.
|
|
771
|
+
assert.doesNotMatch(event.systemPromptOptions.appendSystemPrompt, /Deploy/);
|
|
772
|
+
assert.match(String((await contextState(fire, ctx))!.content), /2\. \[blocked\] Deploy — waiting for an admin/);
|
|
560
773
|
}
|
|
561
774
|
assert.match(widget()![2], /⊘ Deploy · waiting for an admin/);
|
|
562
775
|
});
|
|
776
|
+
|
|
777
|
+
// Harness roadmap T6: the todo list is the session's progress, so the runtime
|
|
778
|
+
// publishes it for peers instead of asking the model to call orchestrator_session_id.
|
|
779
|
+
test("a todo write emits the session's progress; list and errors emit nothing", async () => {
|
|
780
|
+
const { pi, tools } = fakePi();
|
|
781
|
+
const emitted: Array<{ name: string; data: unknown }> = [];
|
|
782
|
+
Object.assign(pi, { events: { emit: (name: string, data: unknown) => { emitted.push({ name, data }); }, on: () => () => {} } });
|
|
783
|
+
gentleTodo(pi, {});
|
|
784
|
+
const { ctx } = fakeContext();
|
|
785
|
+
const todo = (params: unknown) => tools.get("todo")!.execute("call", params, undefined, undefined, ctx);
|
|
786
|
+
await todo({ action: "write", tasks: [{ title: "Write parser", status: "done" }, { title: "Wire CLI", status: "in_progress" }, { title: "Docs" }] });
|
|
787
|
+
assert.deepEqual(emitted, [{ name: TODO_PROGRESS_EVENT, data: { sessionId: "s1", done: 1, total: 3, current: "Wire CLI" } }]);
|
|
788
|
+
await todo({ action: "list" });
|
|
789
|
+
await todo({ action: "update", id: 99, status: "done" });
|
|
790
|
+
assert.equal(emitted.length, 1, "list and a failed update publish nothing");
|
|
791
|
+
await todo({ action: "update", id: 2, status: "done" });
|
|
792
|
+
assert.deepEqual(emitted.at(-1)?.data, { sessionId: "s1", done: 2, total: 3, current: null });
|
|
793
|
+
});
|