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,253 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import { createJiti } from "jiti";
|
|
5
|
+
import { initTheme } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
7
|
+
|
|
8
|
+
initTheme("dark");
|
|
9
|
+
|
|
10
|
+
test("a child process cannot overwrite the primary process phase even with the same session id", async () => {
|
|
11
|
+
const registry = (await createJiti(import.meta.url, { moduleCache: false }).import<typeof import("../lib/odd-phase.ts")>("../lib/odd-phase.ts")).oddPhaseRegistry;
|
|
12
|
+
const sessionId = "process-isolation-test";
|
|
13
|
+
try {
|
|
14
|
+
registry.report(sessionId, "authorizing");
|
|
15
|
+
const output = execFileSync(process.execPath, ["--experimental-strip-types", "--input-type=module", "-e",
|
|
16
|
+
`import { oddPhaseRegistry } from ${JSON.stringify(new URL("../lib/odd-phase.ts", import.meta.url).href)}; console.log(oddPhaseRegistry.get("process-isolation-test") ?? "empty"); oddPhaseRegistry.report("process-isolation-test", "closing");`], { encoding: "utf8" });
|
|
17
|
+
assert.equal(output.trim(), "empty");
|
|
18
|
+
assert.equal(registry.get(sessionId), "authorizing");
|
|
19
|
+
} finally { registry.clear(sessionId); }
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Pi gives each extension its own uncached jiti loader. Exercise the actual
|
|
23
|
+
// writer tool and reader prompt, not two imports through Node's module cache.
|
|
24
|
+
test("separate extension loaders share only the active session's ODD phase and redraw", async () => {
|
|
25
|
+
const aiLoader = createJiti(import.meta.url, { moduleCache: false });
|
|
26
|
+
const shellLoader = createJiti(import.meta.url, { moduleCache: false });
|
|
27
|
+
const { createGentleAiExtension } = await aiLoader.import<typeof import("../extensions/gentle-ai.ts")>("../extensions/gentle-ai.ts");
|
|
28
|
+
const { default: shell } = await shellLoader.import<typeof import("../extensions/gentle-shell.ts")>("../extensions/gentle-shell.ts");
|
|
29
|
+
const aiRegistry = (await aiLoader.import<typeof import("../lib/odd-phase.ts")>("../lib/odd-phase.ts")).oddPhaseRegistry;
|
|
30
|
+
const shellRegistry = (await shellLoader.import<typeof import("../lib/odd-phase.ts")>("../lib/odd-phase.ts")).oddPhaseRegistry;
|
|
31
|
+
const tools = new Map<string, {
|
|
32
|
+
execute: (...args: unknown[]) => Promise<unknown>;
|
|
33
|
+
renderShell?: string;
|
|
34
|
+
renderCall?: (args: unknown, theme: unknown, context: unknown) => { render(width: number): string[]; invalidate(): void };
|
|
35
|
+
renderResult?: (result: unknown, options: unknown, theme: unknown, context: unknown) => { render(width: number): string[]; invalidate(): void };
|
|
36
|
+
}>();
|
|
37
|
+
const handlers = new Map<string, Array<(event: unknown, ctx: unknown) => unknown>>();
|
|
38
|
+
const pi = {
|
|
39
|
+
on(name: string, fn: (event: unknown, ctx: unknown) => unknown) { handlers.set(name, [...(handlers.get(name) ?? []), fn]); },
|
|
40
|
+
registerTool(tool: { name: string; execute: (...args: unknown[]) => Promise<unknown> }) { tools.set(tool.name, tool); },
|
|
41
|
+
registerCommand() {}, registerShortcut() {}, registerMessageRenderer() {}, appendEntry() {},
|
|
42
|
+
events: { on: () => () => {}, emit() {} },
|
|
43
|
+
exec: async () => ({ stdout: "", stderr: "", code: 0, killed: false }),
|
|
44
|
+
};
|
|
45
|
+
createGentleAiExtension({ nativeReviewCli: null } as never)(pi as never);
|
|
46
|
+
shell(pi as never, {}, { resolveWorktree: (path: string) => ({ root: path, commonDir: path }), gitRunner: () => async () => ({ stdout: "", stderr: "", code: 0, killed: false }), devBinary: () => undefined } as never);
|
|
47
|
+
let sessionId: string | undefined = "primary-loader-test";
|
|
48
|
+
let editorFactory: ((tui: unknown, theme: unknown, bindings: unknown) => { render(width: number): string[]; setAnimationPolicy(policy: string): void; dispose(): void }) | undefined;
|
|
49
|
+
let redraws = 0;
|
|
50
|
+
const ui = {
|
|
51
|
+
theme: { fg: (_color: string, text: string) => text, bold: (text: string) => text },
|
|
52
|
+
setFooter() {}, setWidget() {}, setWorkingVisible() {}, notify() {},
|
|
53
|
+
getEditorComponent: () => editorFactory,
|
|
54
|
+
setEditorComponent(factory: typeof editorFactory) { editorFactory = factory; },
|
|
55
|
+
};
|
|
56
|
+
const ctx = {
|
|
57
|
+
hasUI: true, mode: "tui", cwd: "/repo", ui,
|
|
58
|
+
sessionManager: { getSessionId: () => sessionId, getEntries: () => [], getCwd: () => "/repo" },
|
|
59
|
+
hasPendingMessages: () => false, isIdle: () => true,
|
|
60
|
+
modelRegistry: { isUsingOAuth: () => false },
|
|
61
|
+
};
|
|
62
|
+
const fire = async (name: string) => { for (const fn of handlers.get(name) ?? []) await fn({}, ctx); };
|
|
63
|
+
let editor: ReturnType<NonNullable<typeof editorFactory>> | undefined;
|
|
64
|
+
try {
|
|
65
|
+
await fire("session_start");
|
|
66
|
+
assert.ok(editorFactory, "Gentle Shell should install its prompt");
|
|
67
|
+
editor = editorFactory({ terminal: { rows: 40, columns: 120 }, requestRender() { redraws++; } }, { borderColor: (text: string) => text, selectList: {} }, { matches: () => false });
|
|
68
|
+
editor.setAnimationPolicy("potato");
|
|
69
|
+
await fire("agent_start");
|
|
70
|
+
const render = () => stripAnsi(editor!.render(60)[0]!);
|
|
71
|
+
assert.match(render(), /working…/);
|
|
72
|
+
assert.ok(tools.get("gentle_odd_phase"));
|
|
73
|
+
const report = (phase: string) => tools.get("gentle_odd_phase")!.execute("call", { phase }, undefined, undefined, ctx);
|
|
74
|
+
redraws = 0;
|
|
75
|
+
const phaseTool = tools.get("gentle_odd_phase")!;
|
|
76
|
+
const successful = await report("authorizing");
|
|
77
|
+
assert.equal(phaseTool.renderShell, "self", "Pi must not paint its default tool card");
|
|
78
|
+
const toolTheme = { fg: (_color: string, text: string) => text, bold: (text: string) => text };
|
|
79
|
+
const renderContext = (isError: boolean) => ({
|
|
80
|
+
args: { phase: "authorizing" }, toolCallId: "call", invalidate() {}, lastComponent: undefined,
|
|
81
|
+
state: {}, cwd: "/repo", executionStarted: true, argsComplete: true,
|
|
82
|
+
isPartial: false, expanded: false, showImages: false, isError,
|
|
83
|
+
});
|
|
84
|
+
const renderTool = (component: { render(width: number): string[]; invalidate(): void }) => component.render(80);
|
|
85
|
+
assert.ok(phaseTool.renderCall && phaseTool.renderResult);
|
|
86
|
+
assert.deepEqual(renderTool(phaseTool.renderCall({ phase: "authorizing" }, toolTheme, renderContext(false))), []);
|
|
87
|
+
assert.deepEqual(renderTool(phaseTool.renderResult(successful, { isPartial: false, expanded: false }, toolTheme, renderContext(false))), []);
|
|
88
|
+
assert.deepEqual(renderTool(phaseTool.renderResult(successful, { isPartial: false, expanded: true }, toolTheme, renderContext(false))), []);
|
|
89
|
+
assert.match(render(), /authorizing…/, "suppressing the transcript must not suppress the editor phase");
|
|
90
|
+
assert.ok(redraws > 0, "the phase must request redraw even without a pulse");
|
|
91
|
+
assert.equal(aiRegistry.get(sessionId), "authorizing");
|
|
92
|
+
assert.equal(shellRegistry.get(sessionId), "authorizing");
|
|
93
|
+
for (const [id, phase, expected] of [
|
|
94
|
+
["primary-loader-test", "not-a-phase", /Invalid ODD phase/],
|
|
95
|
+
[undefined, "exploring", /No active session/],
|
|
96
|
+
] as const) {
|
|
97
|
+
sessionId = id;
|
|
98
|
+
let message = "";
|
|
99
|
+
await assert.rejects(() => report(phase), (error: unknown) => {
|
|
100
|
+
assert.ok(error instanceof Error);
|
|
101
|
+
message = error.message;
|
|
102
|
+
assert.match(message, expected);
|
|
103
|
+
return true;
|
|
104
|
+
});
|
|
105
|
+
const failure = { content: [{ type: "text", text: message }], isError: true };
|
|
106
|
+
assert.match(renderTool(phaseTool.renderResult(failure, { isPartial: false, expanded: false }, toolTheme, renderContext(true))).join("\n"), expected);
|
|
107
|
+
assert.match(renderTool(phaseTool.renderResult(failure, { isPartial: false, expanded: true }, toolTheme, renderContext(true))).join("\n"), expected);
|
|
108
|
+
}
|
|
109
|
+
const callComponent = phaseTool.renderCall({ phase: "checking" }, toolTheme, renderContext(false));
|
|
110
|
+
const resultComponent = phaseTool.renderResult(successful, { isPartial: false, expanded: false }, toolTheme, renderContext(false));
|
|
111
|
+
for (const component of [callComponent, resultComponent]) {
|
|
112
|
+
assert.equal(typeof component.invalidate, "function");
|
|
113
|
+
component.invalidate();
|
|
114
|
+
assert.deepEqual(component.render(80), []);
|
|
115
|
+
}
|
|
116
|
+
sessionId = "primary-loader-test";
|
|
117
|
+
assert.match(render(), /authorizing…/, "a malformed report cannot erase the last valid phase");
|
|
118
|
+
const cleared = await report("clear");
|
|
119
|
+
assert.deepEqual(renderTool(phaseTool.renderResult(cleared, { isPartial: false, expanded: false }, toolTheme, renderContext(false))), []);
|
|
120
|
+
assert.match(render(), /working…/);
|
|
121
|
+
await report("authorizing");
|
|
122
|
+
sessionId = "child-session";
|
|
123
|
+
await report("exploring");
|
|
124
|
+
assert.equal(aiRegistry.get("primary-loader-test"), "authorizing");
|
|
125
|
+
sessionId = "primary-loader-test";
|
|
126
|
+
assert.match(render(), /authorizing…/);
|
|
127
|
+
await fire("agent_settled");
|
|
128
|
+
assert.equal(aiRegistry.get(sessionId), undefined);
|
|
129
|
+
await fire("agent_start");
|
|
130
|
+
assert.match(render(), /working…/);
|
|
131
|
+
await report("checking");
|
|
132
|
+
await fire("agent_start");
|
|
133
|
+
assert.match(render(), /working…/);
|
|
134
|
+
await report("closing");
|
|
135
|
+
await fire("session_shutdown");
|
|
136
|
+
assert.equal(aiRegistry.get(sessionId), undefined);
|
|
137
|
+
redraws = 0;
|
|
138
|
+
await report("planning");
|
|
139
|
+
assert.equal(redraws, 0, "shutdown must remove the old render callback");
|
|
140
|
+
} finally {
|
|
141
|
+
aiRegistry.clear("primary-loader-test");
|
|
142
|
+
aiRegistry.clear("child-session");
|
|
143
|
+
editor?.dispose();
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// The label follows the primary session's own tool activity without any
|
|
148
|
+
// gentle_odd_phase call; an explicit report still refines it.
|
|
149
|
+
test("tool activity in the primary session drives the working label across extension loaders", async () => {
|
|
150
|
+
const aiLoader = createJiti(import.meta.url, { moduleCache: false });
|
|
151
|
+
const shellLoader = createJiti(import.meta.url, { moduleCache: false });
|
|
152
|
+
const { createGentleAiExtension } = await aiLoader.import<typeof import("../extensions/gentle-ai.ts")>("../extensions/gentle-ai.ts");
|
|
153
|
+
const { default: shell } = await shellLoader.import<typeof import("../extensions/gentle-shell.ts")>("../extensions/gentle-shell.ts");
|
|
154
|
+
const registry = (await aiLoader.import<typeof import("../lib/odd-phase.ts")>("../lib/odd-phase.ts")).oddPhaseRegistry;
|
|
155
|
+
const tools = new Map<string, { execute: (...args: unknown[]) => Promise<unknown> }>();
|
|
156
|
+
const handlers = new Map<string, Array<(event: unknown, ctx: unknown) => unknown>>();
|
|
157
|
+
const pi = {
|
|
158
|
+
on(name: string, fn: (event: unknown, ctx: unknown) => unknown) { handlers.set(name, [...(handlers.get(name) ?? []), fn]); },
|
|
159
|
+
registerTool(tool: { name: string; execute: (...args: unknown[]) => Promise<unknown> }) { tools.set(tool.name, tool); },
|
|
160
|
+
registerCommand() {}, registerShortcut() {}, registerMessageRenderer() {}, appendEntry() {},
|
|
161
|
+
events: { on: () => () => {}, emit() {} },
|
|
162
|
+
exec: async () => ({ stdout: "", stderr: "", code: 0, killed: false }),
|
|
163
|
+
};
|
|
164
|
+
createGentleAiExtension({ nativeReviewCli: null } as never)(pi as never);
|
|
165
|
+
shell(pi as never, {}, { resolveWorktree: (path: string) => ({ root: path, commonDir: path }), gitRunner: () => async () => ({ stdout: "", stderr: "", code: 0, killed: false }), devBinary: () => undefined } as never);
|
|
166
|
+
let sessionId = "tool-activity-loader-test";
|
|
167
|
+
let redraws = 0;
|
|
168
|
+
let editorFactory: ((tui: unknown, theme: unknown, bindings: unknown) => { render(width: number): string[]; setAnimationPolicy(policy: string): void; dispose(): void }) | undefined;
|
|
169
|
+
const ui = {
|
|
170
|
+
theme: { fg: (_color: string, text: string) => text, bold: (text: string) => text },
|
|
171
|
+
setFooter() {}, setWidget() {}, setWorkingVisible() {}, notify() {},
|
|
172
|
+
getEditorComponent: () => editorFactory,
|
|
173
|
+
setEditorComponent(factory: typeof editorFactory) { editorFactory = factory; },
|
|
174
|
+
};
|
|
175
|
+
const ctx = {
|
|
176
|
+
hasUI: true, mode: "tui", cwd: "/repo", ui,
|
|
177
|
+
sessionManager: { getSessionId: () => sessionId, getEntries: () => [], getCwd: () => "/repo" },
|
|
178
|
+
hasPendingMessages: () => false, isIdle: () => true,
|
|
179
|
+
modelRegistry: { isUsingOAuth: () => false },
|
|
180
|
+
};
|
|
181
|
+
const fire = async (name: string, event: unknown = {}, context: unknown = ctx) => { for (const fn of handlers.get(name) ?? []) await fn(event, context); };
|
|
182
|
+
const tool = (toolName: string, args: unknown = {}) => fire("tool_execution_start", { type: "tool_execution_start", toolCallId: `call-${toolName}`, toolName, args });
|
|
183
|
+
let editor: ReturnType<NonNullable<typeof editorFactory>> | undefined;
|
|
184
|
+
try {
|
|
185
|
+
await fire("session_start");
|
|
186
|
+
assert.ok(editorFactory, "Gentle Shell should install its prompt");
|
|
187
|
+
editor = editorFactory({ terminal: { rows: 40, columns: 120 }, requestRender() { redraws++; } }, { borderColor: (text: string) => text, selectList: {} }, { matches: () => false });
|
|
188
|
+
editor.setAnimationPolicy("potato");
|
|
189
|
+
await fire("agent_start");
|
|
190
|
+
const render = () => stripAnsi(editor!.render(60)[0]!);
|
|
191
|
+
assert.match(render(), /working…/);
|
|
192
|
+
|
|
193
|
+
await tool("read", { path: "lib/odd-phase.ts" });
|
|
194
|
+
assert.match(render(), /exploring…/, "reading a file shows exploring");
|
|
195
|
+
await tool("edit", { path: "lib/odd-phase.ts" });
|
|
196
|
+
assert.match(render(), /implementing…/, "editing shows implementing");
|
|
197
|
+
await tool("bash", { command: "pnpm test" });
|
|
198
|
+
assert.match(render(), /checking…/, "running tests shows checking");
|
|
199
|
+
await tool("ask_user_question", {});
|
|
200
|
+
assert.match(render(), /deciding…/, "asking the user shows deciding");
|
|
201
|
+
await tool("mcp__custom-tools__write", { path: "odd/tasks/feature.md" });
|
|
202
|
+
assert.match(render(), /planning…/, "MCP-prefixed names are normalized");
|
|
203
|
+
await tool("subagent_start", {});
|
|
204
|
+
await tool("bash", { command: "git push" });
|
|
205
|
+
await tool("unknown_tool", {});
|
|
206
|
+
assert.match(render(), /planning…/, "unknown tools and ambiguous commands leave the label unchanged");
|
|
207
|
+
await tool("subagent_run", { agent: "gentle-ai-worker", mode: "task", task: "implement" });
|
|
208
|
+
assert.match(render(), /implementing…/, "foreground worker launch replaces stale planning");
|
|
209
|
+
redraws = 0;
|
|
210
|
+
await tool("read", { path: "lib/odd-phase.ts" });
|
|
211
|
+
await tool("todo", {});
|
|
212
|
+
await tool("write", { path: "odd/tasks/feature.md" });
|
|
213
|
+
assert.match(render(), /implementing…/, "incidental reads and task bookkeeping cannot hide worker activity");
|
|
214
|
+
assert.equal(redraws, 0, "ignored signals do not redraw the editor");
|
|
215
|
+
await tool("subagent_run", { agent: "gentle-ai-verify", mode: "background", task: "verify" });
|
|
216
|
+
assert.match(render(), /checking…/, "background verifier launch replaces active implementation");
|
|
217
|
+
assert.equal(redraws, 1, "phase change redraws even under potato animation");
|
|
218
|
+
await tool("read", { path: "tests/odd-phase.test.ts" });
|
|
219
|
+
await tool("todo", {});
|
|
220
|
+
assert.match(render(), /checking…/);
|
|
221
|
+
await tool("subagent_run", { agent: "unknown", task: "gentle-ai-worker implementing" });
|
|
222
|
+
assert.match(render(), /checking…/, "unknown agent prose is not evidence");
|
|
223
|
+
await tool("subagent_run", { agent: "gentle-ai-explore", mode: "task" });
|
|
224
|
+
assert.match(render(), /exploring…/, "later delegated exploration is a real phase transition");
|
|
225
|
+
await tool("subagent_run", { agent: "gentle-ai-worker", mode: "background" });
|
|
226
|
+
assert.match(render(), /implementing…/, "background worker replaces stale exploring");
|
|
227
|
+
await tool("subagent_run", { agent: "gentle-ai-verify", mode: "task" });
|
|
228
|
+
assert.match(render(), /checking…/, "foreground verifier replaces stale work");
|
|
229
|
+
await tool("edit", { path: "lib/odd-phase.ts" });
|
|
230
|
+
assert.match(render(), /implementing…/, "a subsequent source edit can restart implementation");
|
|
231
|
+
|
|
232
|
+
await tools.get("gentle_odd_phase")!.execute("call", { phase: "researching" }, undefined, undefined, ctx);
|
|
233
|
+
await tool("gentle_odd_phase", { phase: "researching" });
|
|
234
|
+
await tool("grep", { pattern: "x" });
|
|
235
|
+
assert.match(render(), /researching…/, "an explicit phase survives a following grep");
|
|
236
|
+
await tool("edit", { path: "lib/odd-phase.ts" });
|
|
237
|
+
assert.match(render(), /implementing…/, "a stronger inferred phase overrides an explicit one");
|
|
238
|
+
|
|
239
|
+
await fire("agent_start");
|
|
240
|
+
assert.match(render(), /working…/, "a new turn starts unlabeled");
|
|
241
|
+
const childCtx = { ...ctx, mode: "rpc" };
|
|
242
|
+
await fire("tool_execution_start", { type: "tool_execution_start", toolCallId: "child", toolName: "subagent_run", args: { agent: "gentle-ai-worker" } }, childCtx);
|
|
243
|
+
assert.equal(registry.get(sessionId), undefined, "a headless RPC (subagent) process never infers a phase");
|
|
244
|
+
sessionId = "other-session";
|
|
245
|
+
await tool("subagent_run", { agent: "gentle-ai-verify" });
|
|
246
|
+
assert.equal(registry.get("tool-activity-loader-test"), undefined, "another session cannot relabel the primary session");
|
|
247
|
+
assert.equal(registry.get(sessionId), "checking");
|
|
248
|
+
} finally {
|
|
249
|
+
registry.clear("tool-activity-loader-test");
|
|
250
|
+
registry.clear("other-session");
|
|
251
|
+
editor?.dispose();
|
|
252
|
+
}
|
|
253
|
+
});
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { isOddPhase, oddPhaseLabel, oddPhaseRegistry, ODD_PHASES, OddPhaseRegistry } from "../lib/odd-phase.ts";
|
|
5
|
+
import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
6
|
+
|
|
7
|
+
// Bounded ODD phase signal for the Gentle prompt's working label: inferred
|
|
8
|
+
// from observed tool activity (lib/odd-phase-inference.ts) and refined by
|
|
9
|
+
// explicit orchestrator reports through gentle_odd_phase.
|
|
10
|
+
|
|
11
|
+
test("ODD_PHASES is the fixed orchestrator-reportable vocabulary covering every ODD protocol step (deciding covers both step 3 and step 4)", () => {
|
|
12
|
+
assert.deepEqual(ODD_PHASES, ["authorizing", "exploring", "researching", "deciding", "planning", "implementing", "checking", "closing"]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("isOddPhase accepts only the bounded vocabulary", () => {
|
|
16
|
+
for (const phase of ODD_PHASES) assert.equal(isOddPhase(phase), true);
|
|
17
|
+
assert.equal(isOddPhase("coding"), false);
|
|
18
|
+
assert.equal(isOddPhase(""), false);
|
|
19
|
+
assert.equal(isOddPhase(undefined), false);
|
|
20
|
+
assert.equal(isOddPhase(42), false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("oddPhaseLabel renders the working-style ellipsis label for every ODD_PHASES member", () => {
|
|
24
|
+
for (const phase of ODD_PHASES) assert.equal(oddPhaseLabel(phase), `${phase}…`);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("OddPhaseRegistry.report stores a valid phase for a session and label() renders it", () => {
|
|
28
|
+
const registry = new OddPhaseRegistry();
|
|
29
|
+
assert.equal(registry.report("session-a", "exploring"), "exploring");
|
|
30
|
+
assert.equal(registry.get("session-a"), "exploring");
|
|
31
|
+
assert.equal(registry.label("session-a"), "exploring…");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("OddPhaseRegistry.clear resets a session's phase directly", () => {
|
|
35
|
+
const registry = new OddPhaseRegistry();
|
|
36
|
+
registry.report("session-a", "checking");
|
|
37
|
+
registry.clear("session-a");
|
|
38
|
+
assert.equal(registry.get("session-a"), undefined);
|
|
39
|
+
assert.equal(registry.label("session-a"), undefined);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("OddPhaseRegistry scopes phases per session; one session never sees another's report", () => {
|
|
43
|
+
const registry = new OddPhaseRegistry();
|
|
44
|
+
registry.report("session-a", "exploring");
|
|
45
|
+
registry.report("session-b", "checking");
|
|
46
|
+
assert.equal(registry.get("session-a"), "exploring");
|
|
47
|
+
assert.equal(registry.get("session-b"), "checking");
|
|
48
|
+
registry.clear("session-a");
|
|
49
|
+
assert.equal(registry.get("session-a"), undefined);
|
|
50
|
+
assert.equal(registry.get("session-b"), "checking", "clearing one session must not affect another");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("OddPhaseRegistry ignores reports and reads without a session id", () => {
|
|
54
|
+
const registry = new OddPhaseRegistry();
|
|
55
|
+
assert.equal(registry.report(undefined, "exploring"), undefined);
|
|
56
|
+
assert.equal(registry.get(undefined), undefined);
|
|
57
|
+
assert.equal(registry.label(undefined), undefined);
|
|
58
|
+
registry.clear(undefined);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("OddPhaseRegistry.report requests a redraw through the registered callback for that session", () => {
|
|
62
|
+
const registry = new OddPhaseRegistry();
|
|
63
|
+
let renders = 0;
|
|
64
|
+
registry.setRenderRequest("session-a", () => { renders += 1; });
|
|
65
|
+
registry.report("session-a", "authorizing");
|
|
66
|
+
assert.equal(renders, 1);
|
|
67
|
+
registry.report("session-a", "planning");
|
|
68
|
+
assert.equal(renders, 2);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("OddPhaseRegistry.report without a session id never requests a redraw", () => {
|
|
72
|
+
const registry = new OddPhaseRegistry();
|
|
73
|
+
let renders = 0;
|
|
74
|
+
registry.setRenderRequest(undefined, () => { renders += 1; });
|
|
75
|
+
registry.report(undefined, "authorizing");
|
|
76
|
+
assert.equal(renders, 0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("OddPhaseRegistry.clear requests a redraw only when a phase actually existed", () => {
|
|
80
|
+
const registry = new OddPhaseRegistry();
|
|
81
|
+
let renders = 0;
|
|
82
|
+
registry.setRenderRequest("session-a", () => { renders += 1; });
|
|
83
|
+
registry.clear("session-a");
|
|
84
|
+
assert.equal(renders, 0, "clearing an already-empty phase must not request a needless redraw");
|
|
85
|
+
registry.report("session-a", "checking");
|
|
86
|
+
renders = 0;
|
|
87
|
+
registry.clear("session-a");
|
|
88
|
+
assert.equal(renders, 1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("OddPhaseRegistry never calls another session's render-request callback", () => {
|
|
92
|
+
const registry = new OddPhaseRegistry();
|
|
93
|
+
let rendersA = 0;
|
|
94
|
+
let rendersB = 0;
|
|
95
|
+
registry.setRenderRequest("session-a", () => { rendersA += 1; });
|
|
96
|
+
registry.setRenderRequest("session-b", () => { rendersB += 1; });
|
|
97
|
+
registry.report("session-a", "exploring");
|
|
98
|
+
assert.equal(rendersA, 1);
|
|
99
|
+
assert.equal(rendersB, 0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("OddPhaseRegistry.clearRenderRequest stops future redraw requests for that session", () => {
|
|
103
|
+
const registry = new OddPhaseRegistry();
|
|
104
|
+
let renders = 0;
|
|
105
|
+
registry.setRenderRequest("session-a", () => { renders += 1; });
|
|
106
|
+
registry.clearRenderRequest("session-a");
|
|
107
|
+
registry.report("session-a", "deciding");
|
|
108
|
+
assert.equal(renders, 0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("OddPhaseRegistry.infer sets the phase when nothing was reported", () => {
|
|
112
|
+
const registry = new OddPhaseRegistry();
|
|
113
|
+
assert.equal(registry.infer("session-a", "exploring"), "exploring");
|
|
114
|
+
assert.equal(registry.label("session-a"), "exploring…");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("an inferred exploring never overrides an explicitly reported phase", () => {
|
|
118
|
+
const registry = new OddPhaseRegistry();
|
|
119
|
+
registry.report("session-a", "researching");
|
|
120
|
+
assert.equal(registry.infer("session-a", "exploring"), "researching");
|
|
121
|
+
assert.equal(registry.get("session-a"), "researching");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("stronger inferred phases override an explicitly reported phase", () => {
|
|
125
|
+
for (const inferred of ["deciding", "planning", "implementing", "checking"] as const) {
|
|
126
|
+
const registry = new OddPhaseRegistry();
|
|
127
|
+
registry.report("session-a", "researching");
|
|
128
|
+
assert.equal(registry.infer("session-a", inferred), inferred);
|
|
129
|
+
assert.equal(registry.get("session-a"), inferred);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("incidental reads and bookkeeping do not replace active implementation or verification", () => {
|
|
134
|
+
for (const active of ["implementing", "checking"] as const) {
|
|
135
|
+
const registry = new OddPhaseRegistry();
|
|
136
|
+
registry.report("session-a", "researching");
|
|
137
|
+
registry.infer("session-a", active);
|
|
138
|
+
assert.equal(registry.infer("session-a", "exploring"), active);
|
|
139
|
+
assert.equal(registry.infer("session-a", "planning"), active);
|
|
140
|
+
assert.equal(registry.get("session-a"), active);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("inferring the phase already reported explicitly keeps it explicit", () => {
|
|
145
|
+
const registry = new OddPhaseRegistry();
|
|
146
|
+
registry.report("session-a", "implementing");
|
|
147
|
+
registry.infer("session-a", "implementing");
|
|
148
|
+
assert.equal(registry.infer("session-a", "exploring"), "implementing");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("meaningful work transitions and explicit reports still change the phase", () => {
|
|
152
|
+
const registry = new OddPhaseRegistry();
|
|
153
|
+
registry.infer("session-a", "checking");
|
|
154
|
+
assert.equal(registry.infer("session-a", "implementing"), "implementing", "a later edit starts real work");
|
|
155
|
+
assert.equal(registry.infer("session-a", "checking"), "checking");
|
|
156
|
+
assert.equal(registry.infer("session-a", "exploring", "delegation"), "exploring", "a delegated exploration is deliberate");
|
|
157
|
+
assert.equal(registry.report("session-a", "closing"), "closing");
|
|
158
|
+
assert.equal(registry.infer("session-a", "exploring"), "closing");
|
|
159
|
+
registry.clear("session-a");
|
|
160
|
+
assert.equal(registry.infer("session-a", "exploring"), "exploring");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("clear resets the explicit source so the next turn's inferred exploring applies", () => {
|
|
164
|
+
const registry = new OddPhaseRegistry();
|
|
165
|
+
registry.report("session-a", "researching");
|
|
166
|
+
registry.clear("session-a");
|
|
167
|
+
assert.equal(registry.infer("session-a", "exploring"), "exploring");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("report with an inferred source behaves like infer-set state", () => {
|
|
171
|
+
const registry = new OddPhaseRegistry();
|
|
172
|
+
registry.report("session-a", "checking", "inferred");
|
|
173
|
+
assert.equal(registry.infer("session-a", "exploring"), "checking");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("OddPhaseRegistry.infer redraws only when the label changes", () => {
|
|
177
|
+
const registry = new OddPhaseRegistry();
|
|
178
|
+
let renders = 0;
|
|
179
|
+
registry.setRenderRequest("session-a", () => { renders += 1; });
|
|
180
|
+
registry.infer("session-a", "exploring");
|
|
181
|
+
registry.infer("session-a", "exploring");
|
|
182
|
+
assert.equal(renders, 1, "repeated reads must not redraw on every tool call");
|
|
183
|
+
registry.report("session-a", "researching");
|
|
184
|
+
renders = 0;
|
|
185
|
+
registry.infer("session-a", "exploring");
|
|
186
|
+
assert.equal(renders, 0, "an ignored inference must not redraw");
|
|
187
|
+
registry.infer("session-a", "implementing");
|
|
188
|
+
assert.equal(renders, 1);
|
|
189
|
+
registry.infer("session-a", "planning");
|
|
190
|
+
registry.infer("session-a", "exploring");
|
|
191
|
+
assert.equal(renders, 1, "incidental signals never repaint active work");
|
|
192
|
+
registry.infer("session-a", "exploring", "delegation");
|
|
193
|
+
assert.equal(renders, 2, "a delegated exploration must repaint");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("OddPhaseRegistry.infer without a session id is a no-op", () => {
|
|
197
|
+
const registry = new OddPhaseRegistry();
|
|
198
|
+
assert.equal(registry.infer(undefined, "exploring"), undefined);
|
|
199
|
+
assert.equal(registry.get(undefined), undefined);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("OddPhaseRegistry report/clear without a registered render-request callback is a safe no-op", () => {
|
|
203
|
+
const registry = new OddPhaseRegistry();
|
|
204
|
+
assert.doesNotThrow(() => {
|
|
205
|
+
registry.report("session-a", "closing");
|
|
206
|
+
registry.clear("session-a");
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
interface RegisteredTool {
|
|
211
|
+
execute: (
|
|
212
|
+
toolCallId: string,
|
|
213
|
+
params: unknown,
|
|
214
|
+
signal: AbortSignal | undefined,
|
|
215
|
+
onUpdate: undefined,
|
|
216
|
+
ctx: ExtensionContext,
|
|
217
|
+
) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function gentleOddPhaseTool(): RegisteredTool {
|
|
221
|
+
const tools = new Map<string, RegisteredTool>();
|
|
222
|
+
createGentleAiExtension({ nativeReviewCli: null } as unknown as Parameters<typeof createGentleAiExtension>[0])({
|
|
223
|
+
on() {},
|
|
224
|
+
registerTool(definition: RegisteredTool & { name: string }) { tools.set(definition.name, definition); },
|
|
225
|
+
registerCommand() {},
|
|
226
|
+
} as unknown as ExtensionAPI);
|
|
227
|
+
const tool = tools.get("gentle_odd_phase");
|
|
228
|
+
assert.ok(tool, "gentle_odd_phase must be registered");
|
|
229
|
+
return tool;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function toolContext(sessionId: string | undefined): ExtensionContext {
|
|
233
|
+
return { sessionManager: { getSessionId: () => sessionId } } as unknown as ExtensionContext;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
test("gentle_odd_phase reports a valid phase for the calling session and returns details", async () => {
|
|
237
|
+
const tool = gentleOddPhaseTool();
|
|
238
|
+
const ctx = toolContext("tool-session-a");
|
|
239
|
+
try {
|
|
240
|
+
const result = await tool.execute("call-1", { phase: "researching" }, undefined, undefined, ctx);
|
|
241
|
+
assert.deepEqual((result as { details?: unknown }).details, { phase: "researching" });
|
|
242
|
+
assert.equal(oddPhaseRegistry.get("tool-session-a"), "researching");
|
|
243
|
+
} finally {
|
|
244
|
+
oddPhaseRegistry.clear("tool-session-a");
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("gentle_odd_phase 'clear' leaves the current phase mid-turn", async () => {
|
|
249
|
+
const tool = gentleOddPhaseTool();
|
|
250
|
+
const ctx = toolContext("tool-session-b");
|
|
251
|
+
try {
|
|
252
|
+
await tool.execute("call-1", { phase: "implementing" }, undefined, undefined, ctx);
|
|
253
|
+
const result = await tool.execute("call-2", { phase: "clear" }, undefined, undefined, ctx);
|
|
254
|
+
assert.deepEqual((result as { details?: unknown }).details, { phase: undefined });
|
|
255
|
+
assert.equal(oddPhaseRegistry.get("tool-session-b"), undefined);
|
|
256
|
+
} finally {
|
|
257
|
+
oddPhaseRegistry.clear("tool-session-b");
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("gentle_odd_phase throws (never falsely returns success) for a phase outside the bounded vocabulary", async () => {
|
|
262
|
+
// Pi's own contract: only a thrown error sets isError: true; a returned
|
|
263
|
+
// value never does, regardless of an isError property on it
|
|
264
|
+
// (docs/extensions.md "Signaling errors").
|
|
265
|
+
const tool = gentleOddPhaseTool();
|
|
266
|
+
const ctx = toolContext("tool-session-c");
|
|
267
|
+
try {
|
|
268
|
+
await assert.rejects(
|
|
269
|
+
() => tool.execute("call-1", { phase: "refactoring" }, undefined, undefined, ctx),
|
|
270
|
+
/Invalid ODD phase/,
|
|
271
|
+
);
|
|
272
|
+
assert.equal(oddPhaseRegistry.get("tool-session-c"), undefined);
|
|
273
|
+
} finally {
|
|
274
|
+
oddPhaseRegistry.clear("tool-session-c");
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("gentle_odd_phase preserves the prior reported phase when given an invalid token; only 'clear' resets it", async () => {
|
|
279
|
+
const tool = gentleOddPhaseTool();
|
|
280
|
+
const ctx = toolContext("tool-session-d");
|
|
281
|
+
try {
|
|
282
|
+
await tool.execute("call-1", { phase: "checking" }, undefined, undefined, ctx);
|
|
283
|
+
await assert.rejects(() => tool.execute("call-2", { phase: "refactoring" }, undefined, undefined, ctx));
|
|
284
|
+
assert.equal(oddPhaseRegistry.get("tool-session-d"), "checking", "an invalid token must never wipe the previously reported phase");
|
|
285
|
+
} finally {
|
|
286
|
+
oddPhaseRegistry.clear("tool-session-d");
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("gentle_odd_phase throws, not falsely succeeds, when there is no active session for a valid phase", async () => {
|
|
291
|
+
const tool = gentleOddPhaseTool();
|
|
292
|
+
const ctx = toolContext(undefined);
|
|
293
|
+
await assert.rejects(
|
|
294
|
+
() => tool.execute("call-1", { phase: "implementing" }, undefined, undefined, ctx),
|
|
295
|
+
(error: unknown) => {
|
|
296
|
+
assert.ok(error instanceof Error);
|
|
297
|
+
assert.doesNotMatch(error.message, /Invalid ODD phase/, "the phase itself was valid; the failure is the missing session, not an invalid token");
|
|
298
|
+
return true;
|
|
299
|
+
},
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("gentle_odd_phase throws for an invalid token even with no active session", async () => {
|
|
304
|
+
const tool = gentleOddPhaseTool();
|
|
305
|
+
const ctx = toolContext(undefined);
|
|
306
|
+
await assert.rejects(() => tool.execute("call-1", { phase: "refactoring" }, undefined, undefined, ctx));
|
|
307
|
+
});
|
|
@@ -86,9 +86,12 @@ const ANCHORS: readonly RoutingAnchor[] = [
|
|
|
86
86
|
{
|
|
87
87
|
label: "mapping trigger at 4 or more files",
|
|
88
88
|
canonical: "**Mapping trigger:** when understanding the work requires 4 or more files",
|
|
89
|
+
// Gentle Shell intentionally leads the canon here: the local mirrors carry
|
|
90
|
+
// the measured evidence-budget rule instead of the 4-file count (tracked by
|
|
91
|
+
// gentle-ai#5139). The canonical anchor stays until gentle-ai follows.
|
|
89
92
|
mirrors: [
|
|
90
|
-
{ surface: DELEGATION, includes: "**Mapping trigger (
|
|
91
|
-
{ surface: CORE, includes: "**
|
|
93
|
+
{ surface: DELEGATION, includes: "**Mapping trigger (Evidence-budget rule):** read inline only when the evidence fits one parallel batch of at most 3 calls" },
|
|
94
|
+
{ surface: CORE, includes: "**Evidence-budget rule** — read inline only if evidence fits one parallel batch (at most 3 calls, ~10k tokens" },
|
|
92
95
|
],
|
|
93
96
|
},
|
|
94
97
|
{
|
|
@@ -107,9 +110,11 @@ const ANCHORS: readonly RoutingAnchor[] = [
|
|
|
107
110
|
{
|
|
108
111
|
label: "long-session backstop",
|
|
109
112
|
canonical: "**Long-session backstop:**",
|
|
113
|
+
// Gentle Shell intentionally leads the canon here: a parent-context token
|
|
114
|
+
// backstop replaces the tool-call count (tracked by gentle-ai#5139).
|
|
110
115
|
mirrors: [
|
|
111
|
-
{ surface: DELEGATION, includes: "**
|
|
112
|
-
{ surface: CORE, includes: "**
|
|
116
|
+
{ surface: DELEGATION, includes: "**Context backstop:** when the parent context passes ~150k tokens" },
|
|
117
|
+
{ surface: CORE, includes: "**Context backstop** — parent context past ~150k tokens" },
|
|
113
118
|
],
|
|
114
119
|
},
|
|
115
120
|
{
|
|
@@ -120,10 +125,10 @@ const ANCHORS: readonly RoutingAnchor[] = [
|
|
|
120
125
|
],
|
|
121
126
|
},
|
|
122
127
|
{
|
|
123
|
-
label: "triggers
|
|
128
|
+
label: "triggers select only the ODD topology",
|
|
124
129
|
canonical: "These triggers never select SDD and never create SDD artifacts",
|
|
125
130
|
mirrors: [
|
|
126
|
-
{ surface: DELEGATION, includes: "These triggers
|
|
131
|
+
{ surface: DELEGATION, includes: "These triggers only choose between direct inline and delegated direct inside ODD" },
|
|
127
132
|
],
|
|
128
133
|
},
|
|
129
134
|
{
|