pi-usereq 0.10.0 → 0.12.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/CHANGELOG.md +66 -0
- package/README.md +6 -6
- package/package.json +1 -1
- package/pi-usereq/docs/REFERENCES.md +861 -704
- package/pi-usereq/docs/REQUIREMENTS.md +152 -95
- package/pi-usereq/docs/WORKFLOW.md +228 -65
- package/scripts/lib/extension-debug-harness.ts +2 -2
- package/scripts/tool-args-to-params.ts +2 -2
- package/src/cli.ts +12 -12
- package/src/core/debug-runtime.ts +2 -2
- package/src/core/extension-status.ts +98 -36
- package/src/core/pi-notify.ts +5 -5
- package/src/core/pi-usereq-tools.ts +4 -2
- package/src/core/prompt-command-catalog.ts +4 -5
- package/src/core/prompt-command-runtime.ts +347 -42
- package/src/core/prompts.ts +0 -2
- package/src/core/req-references-command.ts +175 -0
- package/src/core/req-reset-command.ts +323 -0
- package/src/core/resources.ts +6 -23
- package/src/core/runtime-project-paths.ts +21 -1
- package/src/core/settings-menu.ts +85 -28
- package/src/core/tool-runner.ts +26 -6
- package/src/index.ts +530 -104
- package/tests/attended-results-scenarios.ts +5 -5
- package/tests/cli-command-option-parity.test.ts +25 -25
- package/tests/debug-extension-harness.test.ts +8 -2
- package/tests/extension-registration.test.ts +1109 -76
- package/tests/oracle-project.test.ts +4 -4
- package/tests/oracle-standalone.test.ts +5 -5
- package/src/core/reference-payload.ts +0 -752
- package/src/resources/prompts/references.md +0 -64
- /package/tests/fixtures_attended_results/project/{references.json → summarize.json} +0 -0
- /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_c.c.json +0 -0
- /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_cpp.cpp.json +0 -0
- /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_csharp.cs.json +0 -0
- /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_elixir.ex.json +0 -0
- /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_go.go.json +0 -0
- /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_haskell.hs.json +0 -0
- /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_java.java.json +0 -0
- /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_javascript.js.json +0 -0
- /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_kotlin.kt.json +0 -0
- /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_lua.lua.json +0 -0
- /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_perl.pl.json +0 -0
- /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_php.php.json +0 -0
- /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_python.py.json +0 -0
- /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_ruby.rb.json +0 -0
- /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_rust.rs.json +0 -0
- /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_scala.scala.json +0 -0
- /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_shell.sh.json +0 -0
- /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_swift.swift.json +0 -0
- /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_typescript.ts.json +0 -0
- /package/tests/fixtures_attended_results/standalone/{files-references → files-summarize}/fixture_zig.zig.json +0 -0
|
@@ -5,6 +5,13 @@ import fs from "node:fs";
|
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import piUsereqExtension from "../src/index.ts";
|
|
8
|
+
import {
|
|
9
|
+
REQ_REFERENCES_COMMAND_DESCRIPTION,
|
|
10
|
+
REQ_REFERENCES_COMMIT_MESSAGE,
|
|
11
|
+
} from "../src/core/req-references-command.js";
|
|
12
|
+
import {
|
|
13
|
+
REQ_RESET_COMMAND_DESCRIPTION,
|
|
14
|
+
} from "../src/core/req-reset-command.js";
|
|
8
15
|
import {
|
|
9
16
|
createStaticCheckLanguageConfig,
|
|
10
17
|
DEFAULT_DOCS_DIR,
|
|
@@ -45,9 +52,11 @@ import {
|
|
|
45
52
|
} from "../src/core/pi-usereq-tools.js";
|
|
46
53
|
import {
|
|
47
54
|
createPiUsereqStatusController,
|
|
55
|
+
renderPiUsereqStatus,
|
|
48
56
|
setPiUsereqStatusConfig,
|
|
49
57
|
setPiUsereqWorkflowState,
|
|
50
58
|
} from "../src/core/extension-status.js";
|
|
59
|
+
import { showPiUsereqSettingsMenu } from "../src/core/settings-menu.js";
|
|
51
60
|
import { getSupportedStaticCheckLanguageSupport } from "../src/core/static-check.js";
|
|
52
61
|
import { createTempDir, initFixtureRepo } from "./helpers.js";
|
|
53
62
|
|
|
@@ -406,9 +415,30 @@ function formatFakeThemeBackgroundFromForeground(color: string, text: string): s
|
|
|
406
415
|
return `<bg-from-fg-${color}>${text}</bg-from-fg-${color}>`;
|
|
407
416
|
}
|
|
408
417
|
|
|
418
|
+
/**
|
|
419
|
+
* @brief Resolves the expected fake branch payload for status assertions.
|
|
420
|
+
* @details Reads `git branch --show-current` from the supplied base path and falls back to `unknown` when the path is outside a repository or HEAD has no branch name. Runtime is dominated by git execution. Side effects include subprocess creation.
|
|
421
|
+
* @param[in] basePath {string | undefined} Base path represented by the fake context.
|
|
422
|
+
* @return {string} Expected branch name or `unknown` when unavailable.
|
|
423
|
+
*/
|
|
424
|
+
function buildExpectedFakeBranchValue(basePath: string | undefined): string {
|
|
425
|
+
if (!basePath || !fs.existsSync(basePath)) {
|
|
426
|
+
return "unknown";
|
|
427
|
+
}
|
|
428
|
+
const result = spawnSync("git", ["branch", "--show-current"], {
|
|
429
|
+
cwd: basePath,
|
|
430
|
+
encoding: "utf8",
|
|
431
|
+
});
|
|
432
|
+
if (result.error || result.status !== 0) {
|
|
433
|
+
return "unknown";
|
|
434
|
+
}
|
|
435
|
+
const branchName = result.stdout.trim();
|
|
436
|
+
return branchName === "" ? "unknown" : branchName;
|
|
437
|
+
}
|
|
438
|
+
|
|
409
439
|
/**
|
|
410
440
|
* @brief Builds the expected fake context-gauge payload for assertions.
|
|
411
|
-
* @details Resolves the documented icon thresholds for `0
|
|
441
|
+
* @details Resolves the documented icon thresholds for `0`, `>0-<25`, `>=25-<50`, `>=50-<75`, and `>=75-<90`, renders those non-error bands with the same `warning` token used by the `status` value, emits the non-blinking error full icon for `>=90-<100`, and emits the blinking error full icon for `>=100`. Runtime is O(1). No external state is mutated.
|
|
412
442
|
* @param[in] options {{ filledCells: number; percent?: number | null }} Expected context-gauge facts.
|
|
413
443
|
* @return {string} Encoded context-gauge string.
|
|
414
444
|
*/
|
|
@@ -420,30 +450,33 @@ function buildExpectedFakeContextBar(options: {
|
|
|
420
450
|
if (percent === undefined || percent === null || percent <= 0) {
|
|
421
451
|
return formatFakeThemeForeground("warning", "▕_▏");
|
|
422
452
|
}
|
|
423
|
-
if (percent
|
|
453
|
+
if (percent >= 100) {
|
|
424
454
|
return formatFakeThemeForeground("error", "\u001b[5m▕█▏\u001b[25m");
|
|
425
455
|
}
|
|
426
|
-
if (percent
|
|
456
|
+
if (percent >= 90) {
|
|
457
|
+
return formatFakeThemeForeground("error", "▕█▏");
|
|
458
|
+
}
|
|
459
|
+
if (percent < 25) {
|
|
427
460
|
return formatFakeThemeForeground("warning", "▕▂▏");
|
|
428
461
|
}
|
|
429
|
-
if (percent
|
|
462
|
+
if (percent < 50) {
|
|
430
463
|
return formatFakeThemeForeground("warning", "▕▄▏");
|
|
431
464
|
}
|
|
432
|
-
if (percent
|
|
465
|
+
if (percent < 75) {
|
|
433
466
|
return formatFakeThemeForeground("warning", "▕▆▏");
|
|
434
467
|
}
|
|
435
|
-
return formatFakeThemeForeground("warning", "
|
|
468
|
+
return formatFakeThemeForeground("warning", "▕█▏");
|
|
436
469
|
}
|
|
437
470
|
|
|
438
471
|
/**
|
|
439
472
|
* @brief Builds the expected fake pi-usereq status-bar string for assertions.
|
|
440
|
-
* @details Reconstructs the field order, workflow-state highlighting,
|
|
441
|
-
* @param[in] options {{ workflowState?: string; basePath
|
|
473
|
+
* @details Reconstructs the field order, workflow-state highlighting, branch field, icon-based context gauge, consolidated elapsed field, and sound field emitted by the extension using deterministic fake theme markers. Runtime is O(1) plus optional git execution for branch discovery. No external state is mutated.
|
|
474
|
+
* @param[in] options {{ workflowState?: string; basePath?: string; contextFilledCells: number; contextPercent?: number | null; et: string; sound?: string }} Expected status facts for rendered `status`, `branch`, `context`, `elapsed`, and `sound` fields.
|
|
442
475
|
* @return {string} Encoded status-bar string.
|
|
443
476
|
*/
|
|
444
477
|
function buildExpectedFakeStatusText(options: {
|
|
445
478
|
workflowState?: string;
|
|
446
|
-
basePath
|
|
479
|
+
basePath?: string;
|
|
447
480
|
docsDir?: string;
|
|
448
481
|
testsDir?: string;
|
|
449
482
|
srcDir?: string[];
|
|
@@ -458,13 +491,14 @@ function buildExpectedFakeStatusText(options: {
|
|
|
458
491
|
const workflowStateValue = workflowStateText === "error"
|
|
459
492
|
? formatFakeThemeForeground("error", "\u001b[5merror\u001b[25m")
|
|
460
493
|
: formatFakeThemeForeground("warning", workflowStateText);
|
|
494
|
+
const branchValue = buildExpectedFakeBranchValue(options.basePath);
|
|
461
495
|
const contextBar = buildExpectedFakeContextBar({
|
|
462
496
|
filledCells: options.contextFilledCells,
|
|
463
497
|
percent: options.contextPercent,
|
|
464
498
|
});
|
|
465
499
|
return [
|
|
466
500
|
`${formatFakeThemeForeground("accent", "status:")}${workflowStateValue}`,
|
|
467
|
-
buildField("
|
|
501
|
+
buildField("branch", branchValue),
|
|
468
502
|
`${formatFakeThemeForeground("accent", "context:")}${contextBar}`,
|
|
469
503
|
buildField("elapsed", options.et),
|
|
470
504
|
buildField("sound", options.sound ?? "none"),
|
|
@@ -472,10 +506,11 @@ function buildExpectedFakeStatusText(options: {
|
|
|
472
506
|
}
|
|
473
507
|
|
|
474
508
|
/**
|
|
475
|
-
* @brief Formats one expected absolute base path for
|
|
509
|
+
* @brief Formats one expected absolute base path for prompt and status assertions.
|
|
476
510
|
* @details Resolves the supplied cwd and normalizes path separators to `/` so
|
|
477
|
-
*
|
|
478
|
-
* in path length. No external state is
|
|
511
|
+
* prompt-text comparisons and legacy status-call-site inputs stay stable across
|
|
512
|
+
* operating systems. Runtime is O(p) in path length. No external state is
|
|
513
|
+
* mutated.
|
|
479
514
|
* @param[in] cwd {string} Runtime working directory.
|
|
480
515
|
* @return {string} Slash-normalized absolute base path.
|
|
481
516
|
*/
|
|
@@ -759,6 +794,7 @@ test("extension registers prompt and config commands while exposing tool capabil
|
|
|
759
794
|
"req-recreate",
|
|
760
795
|
"req-refactor",
|
|
761
796
|
"req-references",
|
|
797
|
+
"req-reset",
|
|
762
798
|
"req-renumber",
|
|
763
799
|
"req-workflow",
|
|
764
800
|
"req-write",
|
|
@@ -769,9 +805,10 @@ test("extension registers prompt and config commands while exposing tool capabil
|
|
|
769
805
|
|
|
770
806
|
for (const name of [
|
|
771
807
|
"files-tokens",
|
|
772
|
-
"files-
|
|
808
|
+
"files-summarize",
|
|
773
809
|
"files-compress",
|
|
774
810
|
"files-search",
|
|
811
|
+
"summarize",
|
|
775
812
|
"references",
|
|
776
813
|
"compress",
|
|
777
814
|
"search",
|
|
@@ -787,9 +824,10 @@ test("extension registers prompt and config commands while exposing tool capabil
|
|
|
787
824
|
const toolNames = pi.tools.map((tool: RegisteredTool) => tool.name).sort();
|
|
788
825
|
for (const name of [
|
|
789
826
|
"files-tokens",
|
|
790
|
-
"files-
|
|
827
|
+
"files-summarize",
|
|
791
828
|
"files-compress",
|
|
792
829
|
"files-search",
|
|
830
|
+
"summarize",
|
|
793
831
|
"references",
|
|
794
832
|
"compress",
|
|
795
833
|
"search",
|
|
@@ -801,6 +839,46 @@ test("extension registers prompt and config commands while exposing tool capabil
|
|
|
801
839
|
}
|
|
802
840
|
});
|
|
803
841
|
|
|
842
|
+
/**
|
|
843
|
+
* @brief Verifies `req-references` registration is independent from bundled prompt inventory.
|
|
844
|
+
* @details Confirms `references` is absent from `PROMPT_COMMAND_NAMES` while extension activation still registers `req-references` with the fixed dedicated description. Runtime is O(1). Side effects are limited to extension registration.
|
|
845
|
+
* @return {void} No return value.
|
|
846
|
+
* @throws {AssertionError} Throws when `references` remains in the bundled prompt inventory or the command description diverges.
|
|
847
|
+
* @satisfies TST-102
|
|
848
|
+
*/
|
|
849
|
+
test("req-references registers outside the bundled prompt inventory and keeps its dedicated description", () => {
|
|
850
|
+
const bundledPromptNames = PROMPT_COMMAND_NAMES as readonly string[];
|
|
851
|
+
assert.equal(bundledPromptNames.includes("references"), false);
|
|
852
|
+
|
|
853
|
+
const pi = createFakePi();
|
|
854
|
+
piUsereqExtension(pi);
|
|
855
|
+
|
|
856
|
+
assert.equal(
|
|
857
|
+
pi.commands.get("req-references")?.description,
|
|
858
|
+
REQ_REFERENCES_COMMAND_DESCRIPTION,
|
|
859
|
+
);
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* @brief Verifies `req-reset` registration is independent from bundled prompt inventory.
|
|
864
|
+
* @details Confirms `reset` is absent from `PROMPT_COMMAND_NAMES` while extension activation still registers `req-reset` with the fixed dedicated description. Runtime is O(1). Side effects are limited to extension registration.
|
|
865
|
+
* @return {void} No return value.
|
|
866
|
+
* @throws {AssertionError} Throws when `reset` remains in the bundled prompt inventory or the command description diverges.
|
|
867
|
+
* @satisfies TST-105
|
|
868
|
+
*/
|
|
869
|
+
test("req-reset registers outside the bundled prompt inventory and keeps its dedicated description", () => {
|
|
870
|
+
const bundledPromptNames = PROMPT_COMMAND_NAMES as readonly string[];
|
|
871
|
+
assert.equal(bundledPromptNames.includes("reset"), false);
|
|
872
|
+
|
|
873
|
+
const pi = createFakePi();
|
|
874
|
+
piUsereqExtension(pi);
|
|
875
|
+
|
|
876
|
+
assert.equal(
|
|
877
|
+
pi.commands.get("req-reset")?.description,
|
|
878
|
+
REQ_RESET_COMMAND_DESCRIPTION,
|
|
879
|
+
);
|
|
880
|
+
});
|
|
881
|
+
|
|
804
882
|
test("token tools register agent-oriented descriptions and schema details", () => {
|
|
805
883
|
const pi = createFakePi();
|
|
806
884
|
piUsereqExtension(pi);
|
|
@@ -819,24 +897,37 @@ test("token tools register agent-oriented descriptions and schema details", () =
|
|
|
819
897
|
assert.match(String((tokens.parameters as { description?: string } | undefined)?.description ?? ""), /monolithic pack-summary text/);
|
|
820
898
|
});
|
|
821
899
|
|
|
822
|
-
test("
|
|
900
|
+
test("summary tools register agent-oriented descriptions and schema details", () => {
|
|
901
|
+
const pi = createFakePi();
|
|
902
|
+
piUsereqExtension(pi);
|
|
903
|
+
|
|
904
|
+
const filesSummarize = pi.tools.find((tool: RegisteredTool) => tool.name === "files-summarize");
|
|
905
|
+
const summarize = pi.tools.find((tool: RegisteredTool) => tool.name === "summarize");
|
|
906
|
+
assert.ok(filesSummarize, "missing files-summarize tool");
|
|
907
|
+
assert.ok(summarize, "missing summarize tool");
|
|
908
|
+
|
|
909
|
+
assert.match(filesSummarize.description ?? "", /monolithic summary markdown report/);
|
|
910
|
+
assert.ok(filesSummarize.promptGuidelines?.some((line) => line.includes("monolithic markdown")));
|
|
911
|
+
assert.ok(filesSummarize.promptGuidelines?.some((line) => line.includes("Formatting contract:")));
|
|
912
|
+
assert.match(String((filesSummarize.parameters as { description?: string } | undefined)?.description ?? ""), /monolithic markdown/);
|
|
913
|
+
|
|
914
|
+
assert.match(summarize.description ?? "", /configured project source directories/);
|
|
915
|
+
assert.ok(summarize.promptGuidelines?.some((line) => line.includes("file-structure markdown block")));
|
|
916
|
+
assert.ok(summarize.promptGuidelines?.some((line) => line.includes("Configuration contract:")));
|
|
917
|
+
assert.match(String((summarize.parameters as { description?: string } | undefined)?.description ?? ""), /monolithic markdown/);
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
test("references tool registers agent-oriented descriptions and schema details", () => {
|
|
823
921
|
const pi = createFakePi();
|
|
824
922
|
piUsereqExtension(pi);
|
|
825
923
|
|
|
826
|
-
const filesReferences = pi.tools.find((tool: RegisteredTool) => tool.name === "files-references");
|
|
827
924
|
const references = pi.tools.find((tool: RegisteredTool) => tool.name === "references");
|
|
828
|
-
assert.ok(filesReferences, "missing files-references tool");
|
|
829
925
|
assert.ok(references, "missing references tool");
|
|
830
926
|
|
|
831
|
-
assert.match(
|
|
832
|
-
assert.ok(
|
|
833
|
-
assert.ok(
|
|
834
|
-
assert.match(String((
|
|
835
|
-
|
|
836
|
-
assert.match(references.description ?? "", /configured project source directories/);
|
|
837
|
-
assert.ok(references.promptGuidelines?.some((line) => line.includes("file-structure markdown block")));
|
|
838
|
-
assert.ok(references.promptGuidelines?.some((line) => line.includes("Configuration contract:")));
|
|
839
|
-
assert.match(String((references.parameters as { description?: string } | undefined)?.description ?? ""), /monolithic markdown/);
|
|
927
|
+
assert.match(references.description ?? "", /configured project source directories and configured docs-dir/);
|
|
928
|
+
assert.ok(references.promptGuidelines?.some((line) => line.includes("overwrite `<docs-dir>/REFERENCES.md`")));
|
|
929
|
+
assert.ok(references.promptGuidelines?.some((line) => line.includes("generated markdown is never returned")));
|
|
930
|
+
assert.match(String((references.parameters as { description?: string } | undefined)?.description ?? ""), /content\[0\]\.text returns only `success` or `error: <diagnostic>`/);
|
|
840
931
|
});
|
|
841
932
|
|
|
842
933
|
test("search tools register agent-oriented descriptions and schema details", () => {
|
|
@@ -887,9 +978,10 @@ test("agent tools define custom renderResult with compact and expanded structure
|
|
|
887
978
|
piUsereqExtension(pi);
|
|
888
979
|
const toolNames = [
|
|
889
980
|
"files-tokens",
|
|
890
|
-
"files-
|
|
981
|
+
"files-summarize",
|
|
891
982
|
"files-compress",
|
|
892
983
|
"files-search",
|
|
984
|
+
"summarize",
|
|
893
985
|
"references",
|
|
894
986
|
"compress",
|
|
895
987
|
"search",
|
|
@@ -937,7 +1029,7 @@ test("files-tokens returns monolithic token summary text with execution diagnost
|
|
|
937
1029
|
docs: {
|
|
938
1030
|
"REQUIREMENTS.md": "---\ntitle: Requirements\n---\n# Requirements\nAlpha\nBeta\n",
|
|
939
1031
|
"WORKFLOW.md": "# Workflow\nStep one\n",
|
|
940
|
-
"REFERENCES.md": "#
|
|
1032
|
+
"REFERENCES.md": "# Summarize\nEntry one\n",
|
|
941
1033
|
},
|
|
942
1034
|
});
|
|
943
1035
|
try {
|
|
@@ -985,7 +1077,7 @@ test("files-tokens defers js-tiktoken loading until execution and returns monoli
|
|
|
985
1077
|
}
|
|
986
1078
|
});
|
|
987
1079
|
|
|
988
|
-
test("files-
|
|
1080
|
+
test("files-summarize returns monolithic summary markdown", async () => {
|
|
989
1081
|
const { projectBase } = initFixtureRepo({ fixtures: [] });
|
|
990
1082
|
const samplePath = path.join(projectBase, "src", "sample.ts");
|
|
991
1083
|
fs.writeFileSync(samplePath, `/**
|
|
@@ -1006,7 +1098,7 @@ export function buildSample(input: string): string {
|
|
|
1006
1098
|
try {
|
|
1007
1099
|
const pi = createFakePi();
|
|
1008
1100
|
piUsereqExtension(pi);
|
|
1009
|
-
const result = await executeRegisteredTool(pi, "files-
|
|
1101
|
+
const result = await executeRegisteredTool(pi, "files-summarize", projectBase, {
|
|
1010
1102
|
files: ["src/sample.ts", "src/missing.ts"],
|
|
1011
1103
|
}) as {
|
|
1012
1104
|
content?: Array<{ type: string; text?: string }>;
|
|
@@ -1025,7 +1117,7 @@ export function buildSample(input: string): string {
|
|
|
1025
1117
|
}
|
|
1026
1118
|
});
|
|
1027
1119
|
|
|
1028
|
-
test("
|
|
1120
|
+
test("summarize returns monolithic project summary markdown", async () => {
|
|
1029
1121
|
const { projectBase } = initFixtureRepo({ fixtures: [] });
|
|
1030
1122
|
fs.writeFileSync(path.join(projectBase, "src", "alpha.ts"), "export const ALPHA = 1;\n", "utf8");
|
|
1031
1123
|
fs.mkdirSync(path.join(projectBase, "src", "nested"), { recursive: true });
|
|
@@ -1033,7 +1125,7 @@ test("references returns monolithic project references markdown", async () => {
|
|
|
1033
1125
|
try {
|
|
1034
1126
|
const pi = createFakePi();
|
|
1035
1127
|
piUsereqExtension(pi);
|
|
1036
|
-
const result = await executeRegisteredTool(pi, "
|
|
1128
|
+
const result = await executeRegisteredTool(pi, "summarize", projectBase, {}) as {
|
|
1037
1129
|
content?: Array<{ type: string; text?: string }>;
|
|
1038
1130
|
details?: { execution: { code: number; stderr_lines?: string[] } };
|
|
1039
1131
|
};
|
|
@@ -1051,6 +1143,85 @@ test("references returns monolithic project references markdown", async () => {
|
|
|
1051
1143
|
}
|
|
1052
1144
|
});
|
|
1053
1145
|
|
|
1146
|
+
test("references overwrites the configured REFERENCES.md and returns status-only success", async () => {
|
|
1147
|
+
const { projectBase } = initFixtureRepo({ fixtures: [] });
|
|
1148
|
+
fs.writeFileSync(path.join(projectBase, "src", "alpha.ts"), "export const ALPHA = 1;\n", "utf8");
|
|
1149
|
+
fs.mkdirSync(path.join(projectBase, "src", "nested"), { recursive: true });
|
|
1150
|
+
fs.writeFileSync(path.join(projectBase, "src", "nested", "beta.ts"), "export function beta(): number {\n return 2;\n}\n", "utf8");
|
|
1151
|
+
const docsDir = path.join(projectBase, "docs", "custom");
|
|
1152
|
+
fs.mkdirSync(docsDir, { recursive: true });
|
|
1153
|
+
fs.writeFileSync(path.join(docsDir, "REFERENCES.md"), "stale\n", "utf8");
|
|
1154
|
+
const configPath = getProjectConfigPath(projectBase);
|
|
1155
|
+
const config = JSON.parse(fs.readFileSync(configPath, "utf8")) as Record<string, unknown>;
|
|
1156
|
+
config["docs-dir"] = "docs/custom";
|
|
1157
|
+
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
1158
|
+
|
|
1159
|
+
try {
|
|
1160
|
+
const pi = createFakePi();
|
|
1161
|
+
piUsereqExtension(pi);
|
|
1162
|
+
const referencesResult = await executeRegisteredTool(pi, "references", projectBase, {}) as {
|
|
1163
|
+
content?: Array<{ type: string; text?: string }>;
|
|
1164
|
+
details?: { execution: { code: number; stderr_lines?: string[]; stdout_lines?: string[] } };
|
|
1165
|
+
};
|
|
1166
|
+
const summarizeResult = await executeRegisteredTool(pi, "summarize", projectBase, {}) as {
|
|
1167
|
+
content?: Array<{ type: string; text?: string }>;
|
|
1168
|
+
};
|
|
1169
|
+
|
|
1170
|
+
const referencesPath = path.join(docsDir, "REFERENCES.md");
|
|
1171
|
+
const writtenText = fs.readFileSync(referencesPath, "utf8");
|
|
1172
|
+
assert.equal(referencesResult.content?.[0]?.text ?? "", "success");
|
|
1173
|
+
assert.equal(writtenText.trimEnd(), summarizeResult.content?.[0]?.text ?? "");
|
|
1174
|
+
assert.doesNotMatch(writtenText, /^stale$/m);
|
|
1175
|
+
assert.equal(referencesResult.details?.execution.code, 0);
|
|
1176
|
+
assert.deepEqual(referencesResult.details?.execution.stderr_lines, undefined);
|
|
1177
|
+
assert.deepEqual(referencesResult.details?.execution.stdout_lines, undefined);
|
|
1178
|
+
} finally {
|
|
1179
|
+
fs.rmSync(projectBase, { recursive: true, force: true });
|
|
1180
|
+
}
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
test("references returns status-only errors for source-discovery and write failures", async () => {
|
|
1184
|
+
const sourceFailure = initFixtureRepo({ fixtures: [] });
|
|
1185
|
+
try {
|
|
1186
|
+
const pi = createFakePi();
|
|
1187
|
+
piUsereqExtension(pi);
|
|
1188
|
+
const result = await executeRegisteredTool(pi, "references", sourceFailure.projectBase, {}) as {
|
|
1189
|
+
content?: Array<{ type: string; text?: string }>;
|
|
1190
|
+
details?: { execution: { code: number; stderr_lines?: string[]; stdout_lines?: string[] } };
|
|
1191
|
+
};
|
|
1192
|
+
|
|
1193
|
+
assert.equal(result.content?.[0]?.text ?? "", "error: no source files found in configured directories.");
|
|
1194
|
+
assert.equal(result.details?.execution.code, 1);
|
|
1195
|
+
assert.deepEqual(result.details?.execution.stdout_lines, undefined);
|
|
1196
|
+
assert.ok(result.details?.execution.stderr_lines?.some((line) => line.includes("no source files found in configured directories.")));
|
|
1197
|
+
} finally {
|
|
1198
|
+
fs.rmSync(sourceFailure.projectBase, { recursive: true, force: true });
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
const writeFailure = initFixtureRepo({ fixtures: [] });
|
|
1202
|
+
fs.writeFileSync(path.join(writeFailure.projectBase, "src", "alpha.ts"), "export const ALPHA = 1;\n", "utf8");
|
|
1203
|
+
const writeFailureConfigPath = getProjectConfigPath(writeFailure.projectBase);
|
|
1204
|
+
const writeFailureConfig = JSON.parse(fs.readFileSync(writeFailureConfigPath, "utf8")) as Record<string, unknown>;
|
|
1205
|
+
writeFailureConfig["docs-dir"] = "docs/missing/custom";
|
|
1206
|
+
fs.writeFileSync(writeFailureConfigPath, `${JSON.stringify(writeFailureConfig, null, 2)}\n`, "utf8");
|
|
1207
|
+
|
|
1208
|
+
try {
|
|
1209
|
+
const pi = createFakePi();
|
|
1210
|
+
piUsereqExtension(pi);
|
|
1211
|
+
const result = await executeRegisteredTool(pi, "references", writeFailure.projectBase, {}) as {
|
|
1212
|
+
content?: Array<{ type: string; text?: string }>;
|
|
1213
|
+
details?: { execution: { code: number; stderr_lines?: string[]; stdout_lines?: string[] } };
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
assert.match(result.content?.[0]?.text ?? "", /^error: ENOENT:/);
|
|
1217
|
+
assert.equal(result.details?.execution.code, 1);
|
|
1218
|
+
assert.deepEqual(result.details?.execution.stdout_lines, undefined);
|
|
1219
|
+
assert.ok(result.details?.execution.stderr_lines?.some((line) => /^error: ENOENT:/.test(line)));
|
|
1220
|
+
} finally {
|
|
1221
|
+
fs.rmSync(writeFailure.projectBase, { recursive: true, force: true });
|
|
1222
|
+
}
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1054
1225
|
test("files-compress returns monolithic compression markdown", async () => {
|
|
1055
1226
|
const { projectBase } = initFixtureRepo({ fixtures: [] });
|
|
1056
1227
|
const samplePath = path.join(projectBase, "src", "sample.ts");
|
|
@@ -1238,21 +1409,21 @@ func (s *Server) Start(ctx context.Context) error {
|
|
|
1238
1409
|
const pi = createFakePi();
|
|
1239
1410
|
piUsereqExtension(pi);
|
|
1240
1411
|
|
|
1241
|
-
const
|
|
1412
|
+
const filesSummarizeResult = await executeRegisteredTool(pi, "files-summarize", projectBase, {
|
|
1242
1413
|
files: ["src/sample.go"],
|
|
1243
1414
|
}) as {
|
|
1244
1415
|
content?: Array<{ type: string; text?: string }>;
|
|
1245
1416
|
details?: { execution: { code: number } };
|
|
1246
1417
|
};
|
|
1247
|
-
assert.match(
|
|
1248
|
-
assert.equal(
|
|
1418
|
+
assert.match(filesSummarizeResult.content?.[0]?.text ?? "", /\tvar buffer bytes\.Buffer/);
|
|
1419
|
+
assert.equal(filesSummarizeResult.details?.execution.code, 0);
|
|
1249
1420
|
|
|
1250
|
-
const
|
|
1421
|
+
const summarizeResult = await executeRegisteredTool(pi, "summarize", projectBase, {}) as {
|
|
1251
1422
|
content?: Array<{ type: string; text?: string }>;
|
|
1252
1423
|
details?: { execution: { code: number } };
|
|
1253
1424
|
};
|
|
1254
|
-
assert.match(
|
|
1255
|
-
assert.equal(
|
|
1425
|
+
assert.match(summarizeResult.content?.[0]?.text ?? "", /\tvar buffer bytes\.Buffer/);
|
|
1426
|
+
assert.equal(summarizeResult.details?.execution.code, 0);
|
|
1256
1427
|
|
|
1257
1428
|
const filesCompressResult = await executeRegisteredTool(pi, "files-compress", projectBase, {
|
|
1258
1429
|
files: ["src/sample.go"],
|
|
@@ -1475,12 +1646,12 @@ test("configuration menus expose show-config ordering and omit overview or notif
|
|
|
1475
1646
|
assert.ok(renderedMenu.includes("disable"));
|
|
1476
1647
|
assert.doesNotMatch(renderedMenu, /beep:/);
|
|
1477
1648
|
assert.match(renderedNotificationsMenu, /<dim>Enable pushover/);
|
|
1478
|
-
assert.match(renderedNotificationsMenu, /<dim>
|
|
1649
|
+
assert.match(renderedNotificationsMenu, /<dim>configure user\/token keys first<\/dim>/);
|
|
1479
1650
|
assert.deepEqual(ctx.__state.selectCalls[1]?.items ?? [], [
|
|
1480
1651
|
"Enable notification",
|
|
1481
1652
|
"Notification events",
|
|
1482
1653
|
"Notify command",
|
|
1483
|
-
"Enable sound",
|
|
1654
|
+
"Enable sound (boot value)",
|
|
1484
1655
|
"Sound events",
|
|
1485
1656
|
"Sound toggle hotkey bind",
|
|
1486
1657
|
"Sound command (low vol.)",
|
|
@@ -1530,7 +1701,7 @@ test("descendant configuration menus end with Reset defaults only", async () =>
|
|
|
1530
1701
|
const ctx = createFakeCtx(cwd, {
|
|
1531
1702
|
selects: [
|
|
1532
1703
|
"notifications",
|
|
1533
|
-
"Enable sound",
|
|
1704
|
+
"Enable sound (boot value)",
|
|
1534
1705
|
"Save and close",
|
|
1535
1706
|
"Pushover priority",
|
|
1536
1707
|
"Save and close",
|
|
@@ -1557,19 +1728,34 @@ test("descendant configuration menus end with Reset defaults only", async () =>
|
|
|
1557
1728
|
|
|
1558
1729
|
await command!.handler("", ctx);
|
|
1559
1730
|
|
|
1731
|
+
const getMenuIndex = (title: string, marker?: string): number => {
|
|
1732
|
+
const index = ctx.__state.selectCalls.findIndex(
|
|
1733
|
+
(entry) => entry.title === title && (marker === undefined || entry.items.includes(marker)),
|
|
1734
|
+
);
|
|
1735
|
+
assert.notEqual(index, -1, `missing menu ${title}`);
|
|
1736
|
+
return index;
|
|
1737
|
+
};
|
|
1560
1738
|
const getTerminalRows = (title: string, marker?: string): string[] => {
|
|
1561
|
-
const
|
|
1562
|
-
|
|
1563
|
-
|
|
1739
|
+
const index = getMenuIndex(title, marker);
|
|
1740
|
+
return ctx.__state.selectCalls[index]!.items.slice(-1);
|
|
1741
|
+
};
|
|
1742
|
+
const getRenderedMenu = (title: string, marker?: string): string => {
|
|
1743
|
+
const index = getMenuIndex(title, marker);
|
|
1744
|
+
return (ctx.__state.customRenderLines[index] ?? []).join("\n");
|
|
1564
1745
|
};
|
|
1565
1746
|
|
|
1566
|
-
assert.deepEqual(getTerminalRows("Enable sound", "high"), ["Reset defaults"]);
|
|
1747
|
+
assert.deepEqual(getTerminalRows("Enable sound (boot value)", "high"), ["Reset defaults"]);
|
|
1567
1748
|
assert.deepEqual(getTerminalRows("Pushover priority", "High"), ["Reset defaults"]);
|
|
1568
1749
|
assert.deepEqual(getTerminalRows("Enable tools", "static-check"), ["Reset defaults"]);
|
|
1569
1750
|
assert.deepEqual(getTerminalRows("Debug", "Log on status"), ["Reset defaults"]);
|
|
1570
1751
|
assert.deepEqual(getTerminalRows("static-check language", "Python"), ["Reset defaults"]);
|
|
1571
1752
|
assert.deepEqual(getTerminalRows("Remove static code checker"), ["Reset defaults"]);
|
|
1572
1753
|
assert.deepEqual(getTerminalRows("Remove source-code directory", "src"), ["Reset defaults"]);
|
|
1754
|
+
assert.doesNotMatch(getRenderedMenu("Enable sound (boot value)", "high"), /Reset defaults.*none/);
|
|
1755
|
+
assert.doesNotMatch(getRenderedMenu("Pushover priority", "High"), /Reset defaults.*Normal/);
|
|
1756
|
+
assert.doesNotMatch(getRenderedMenu("Enable tools", "static-check"), /Reset defaults.*defaults/);
|
|
1757
|
+
assert.doesNotMatch(getRenderedMenu("Debug", "Log on status"), /Reset defaults.*running/);
|
|
1758
|
+
assert.doesNotMatch(getRenderedMenu("Remove source-code directory", "src"), /Reset defaults.*src/);
|
|
1573
1759
|
});
|
|
1574
1760
|
|
|
1575
1761
|
test("debug menu dims locked rows and persists debug settings with focus-preserving re-renders", async () => {
|
|
@@ -1876,6 +2062,101 @@ test("notifications menu preserves focus on toggled and edited rows", async () =
|
|
|
1876
2062
|
assert.equal(notificationEventCalls[1]?.selectedChoiceId, "notify-on-failed");
|
|
1877
2063
|
});
|
|
1878
2064
|
|
|
2065
|
+
/**
|
|
2066
|
+
* @brief Verifies inline toggle rows stay open and preserve selection for confirm keys.
|
|
2067
|
+
* @details Builds one shared settings-menu instance with a primary inline toggle and a dependent secondary row whose disabled styling changes after each toggle. The test proves `space` and `enter` must mutate the selected row in place, rebuild dependent rows without closing the menu, and preserve focus on the toggled row until explicit cancel.
|
|
2068
|
+
* @return {Promise<void>} Promise resolved after the raw-input interaction sequence completes.
|
|
2069
|
+
* @throws {AssertionError} Throws when the menu closes early, loses focus, or fails to rebuild dependent row styling after a toggle.
|
|
2070
|
+
* @satisfies REQ-192, TST-052
|
|
2071
|
+
*/
|
|
2072
|
+
test("shared settings menu keeps inline toggles focused and open for space and enter", async () => {
|
|
2073
|
+
let primaryEnabled = true;
|
|
2074
|
+
let component:
|
|
2075
|
+
| {
|
|
2076
|
+
handleInput?: (data: string) => void;
|
|
2077
|
+
render: (width: number) => string[];
|
|
2078
|
+
}
|
|
2079
|
+
| undefined;
|
|
2080
|
+
let resolved = false;
|
|
2081
|
+
const changes: string[] = [];
|
|
2082
|
+
const theme = {
|
|
2083
|
+
fg: (color: string, text: string) => formatFakeThemeForeground(color, text),
|
|
2084
|
+
bgFromFg: (color: string, text: string) => formatFakeThemeBackgroundFromForeground(color, text),
|
|
2085
|
+
bold: (text: string) => `<bold>${text}</bold>`,
|
|
2086
|
+
};
|
|
2087
|
+
const ctx = {
|
|
2088
|
+
ui: {
|
|
2089
|
+
async custom<T>(factory: (tui: { requestRender: () => void }, customTheme: typeof theme, keybindings: Record<string, never>, done: (value?: T) => void) => unknown) {
|
|
2090
|
+
let resolveResult!: (value: T | undefined) => void;
|
|
2091
|
+
const resultPromise = new Promise<T | undefined>((resolve) => {
|
|
2092
|
+
resolveResult = (value?: T) => {
|
|
2093
|
+
resolved = true;
|
|
2094
|
+
resolve(value);
|
|
2095
|
+
};
|
|
2096
|
+
});
|
|
2097
|
+
component = factory(
|
|
2098
|
+
{ requestRender: () => undefined },
|
|
2099
|
+
theme,
|
|
2100
|
+
{},
|
|
2101
|
+
(value?: T) => resolveResult(value),
|
|
2102
|
+
) as typeof component;
|
|
2103
|
+
return resultPromise;
|
|
2104
|
+
},
|
|
2105
|
+
},
|
|
2106
|
+
} as any;
|
|
2107
|
+
const buildChoices = () => [
|
|
2108
|
+
{
|
|
2109
|
+
id: "primary",
|
|
2110
|
+
label: "Primary",
|
|
2111
|
+
value: primaryEnabled ? "enable" : "disable",
|
|
2112
|
+
values: ["enable", "disable"],
|
|
2113
|
+
description: "Toggle the primary row in place.",
|
|
2114
|
+
},
|
|
2115
|
+
{
|
|
2116
|
+
id: "secondary",
|
|
2117
|
+
label: "Secondary",
|
|
2118
|
+
labelTone: primaryEnabled ? undefined : "dim",
|
|
2119
|
+
value: primaryEnabled ? "editable" : "locked",
|
|
2120
|
+
valueTone: primaryEnabled ? undefined : "dim",
|
|
2121
|
+
disabled: !primaryEnabled,
|
|
2122
|
+
description: "Dependent row that must rebuild without closing the menu.",
|
|
2123
|
+
},
|
|
2124
|
+
] as any;
|
|
2125
|
+
|
|
2126
|
+
const resultPromise = showPiUsereqSettingsMenu(
|
|
2127
|
+
ctx,
|
|
2128
|
+
"Toggle menu",
|
|
2129
|
+
buildChoices(),
|
|
2130
|
+
{
|
|
2131
|
+
getChoices: buildChoices,
|
|
2132
|
+
onChange: (choiceId: string, newValue: string) => {
|
|
2133
|
+
if (choiceId === "primary") {
|
|
2134
|
+
primaryEnabled = newValue === "enable";
|
|
2135
|
+
changes.push(newValue);
|
|
2136
|
+
}
|
|
2137
|
+
},
|
|
2138
|
+
} as any,
|
|
2139
|
+
);
|
|
2140
|
+
|
|
2141
|
+
assert.ok(component);
|
|
2142
|
+
assert.doesNotMatch(component.render(120).join("\n"), /<dim>Secondary/);
|
|
2143
|
+
|
|
2144
|
+
component.handleInput?.(" ");
|
|
2145
|
+
await Promise.resolve();
|
|
2146
|
+
assert.deepEqual(changes, ["disable"]);
|
|
2147
|
+
assert.equal(resolved, false);
|
|
2148
|
+
assert.match(component.render(120).join("\n"), /<dim>Secondary/);
|
|
2149
|
+
|
|
2150
|
+
component.handleInput?.("\r");
|
|
2151
|
+
await Promise.resolve();
|
|
2152
|
+
assert.deepEqual(changes, ["disable", "enable"]);
|
|
2153
|
+
assert.equal(resolved, false);
|
|
2154
|
+
assert.doesNotMatch(component.render(120).join("\n"), /<dim>Secondary/);
|
|
2155
|
+
|
|
2156
|
+
component.handleInput?.("\x1b");
|
|
2157
|
+
assert.equal(await resultPromise, undefined);
|
|
2158
|
+
});
|
|
2159
|
+
|
|
1879
2160
|
test("notifications reset defaults preserves non-notification settings", async () => {
|
|
1880
2161
|
const cwd = createTempDir("pi-usereq-menu-reset-notifications-");
|
|
1881
2162
|
fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
|
|
@@ -1888,7 +2169,7 @@ test("notifications reset defaults preserves non-notification settings", async (
|
|
|
1888
2169
|
"docs-dir",
|
|
1889
2170
|
"notifications",
|
|
1890
2171
|
"Enable notification",
|
|
1891
|
-
"Enable sound",
|
|
2172
|
+
"Enable sound (boot value)",
|
|
1892
2173
|
"high",
|
|
1893
2174
|
"Reset defaults",
|
|
1894
2175
|
"Approve reset",
|
|
@@ -1992,6 +2273,376 @@ test("prompt commands use the current session by default", async () => {
|
|
|
1992
2273
|
}
|
|
1993
2274
|
});
|
|
1994
2275
|
|
|
2276
|
+
/**
|
|
2277
|
+
* @brief Verifies successful `req-references` completion uses the specialized direct commit workflow.
|
|
2278
|
+
* @details Confirms the command regenerates configured `REFERENCES.md`, creates no worktree, sends no LLM user message, stages only the target file, commits the fixed message, and restores workflow state `idle`. Runtime is dominated by temporary git execution and reference generation. Side effects are limited to temporary repository mutation and command-handler notifications.
|
|
2279
|
+
* @return {Promise<void>} Promise resolved after success-path assertions complete.
|
|
2280
|
+
* @throws {AssertionError} Throws when `req-references` creates a worktree, starts a session handoff, skips the fixed commit, or leaves the repository dirty.
|
|
2281
|
+
* @satisfies TST-103
|
|
2282
|
+
*/
|
|
2283
|
+
test("req-references updates REFERENCES.md with a direct commit workflow and no LLM session", async () => {
|
|
2284
|
+
const { projectBase } = initFixtureRepo({ fixtures: [] });
|
|
2285
|
+
const previousCwd = process.cwd();
|
|
2286
|
+
try {
|
|
2287
|
+
fs.writeFileSync(path.join(projectBase, "src", "alpha.ts"), "export const ALPHA = 1;\n", "utf8");
|
|
2288
|
+
fs.mkdirSync(path.join(projectBase, "src", "nested"), { recursive: true });
|
|
2289
|
+
fs.writeFileSync(path.join(projectBase, "src", "nested", "beta.ts"), "export function beta(): number {\n return 2;\n}\n", "utf8");
|
|
2290
|
+
const docsDir = path.join(projectBase, "docs", "custom");
|
|
2291
|
+
fs.mkdirSync(docsDir, { recursive: true });
|
|
2292
|
+
fs.writeFileSync(path.join(docsDir, "REFERENCES.md"), "stale\n", "utf8");
|
|
2293
|
+
writeProjectConfigOverrides(projectBase, { "docs-dir": "docs/custom" });
|
|
2294
|
+
assert.equal(spawnSync("git", ["add", "src/alpha.ts", "src/nested/beta.ts", "docs/custom/REFERENCES.md"], {
|
|
2295
|
+
cwd: projectBase,
|
|
2296
|
+
encoding: "utf8",
|
|
2297
|
+
}).status, 0);
|
|
2298
|
+
const fixtureCommit = spawnSync("git", ["commit", "-m", "fixture sources"], {
|
|
2299
|
+
cwd: projectBase,
|
|
2300
|
+
encoding: "utf8",
|
|
2301
|
+
});
|
|
2302
|
+
assert.equal(fixtureCommit.status, 0, fixtureCommit.stderr);
|
|
2303
|
+
|
|
2304
|
+
const worktreeParentPath = path.dirname(projectBase);
|
|
2305
|
+
const initialEntries = new Set(fs.readdirSync(worktreeParentPath));
|
|
2306
|
+
const pi = createFakePi();
|
|
2307
|
+
piUsereqExtension(pi);
|
|
2308
|
+
const ctx = createFakeCtx(projectBase);
|
|
2309
|
+
const recordedStatuses: string[] = [];
|
|
2310
|
+
let switchSessionCalls = 0;
|
|
2311
|
+
const originalSetStatus = ctx.ui.setStatus.bind(ctx.ui);
|
|
2312
|
+
const originalSwitchSession = ctx.switchSession.bind(ctx);
|
|
2313
|
+
ctx.ui.setStatus = (key: string, value?: string) => {
|
|
2314
|
+
if (key === "pi-usereq" && value !== undefined) {
|
|
2315
|
+
recordedStatuses.push(value);
|
|
2316
|
+
}
|
|
2317
|
+
originalSetStatus(key, value);
|
|
2318
|
+
};
|
|
2319
|
+
ctx.switchSession = async (sessionPath: string) => {
|
|
2320
|
+
switchSessionCalls += 1;
|
|
2321
|
+
return originalSwitchSession(sessionPath);
|
|
2322
|
+
};
|
|
2323
|
+
|
|
2324
|
+
await pi.emit("session_start", { reason: "startup" }, ctx);
|
|
2325
|
+
await pi.commands.get("req-references")!.handler("", ctx);
|
|
2326
|
+
|
|
2327
|
+
const summarizeResult = await executeRegisteredTool(pi, "summarize", projectBase, {}) as {
|
|
2328
|
+
content?: Array<{ type?: string; text?: string }>;
|
|
2329
|
+
};
|
|
2330
|
+
const referencesPath = path.join(docsDir, "REFERENCES.md");
|
|
2331
|
+
const writtenText = fs.readFileSync(referencesPath, "utf8");
|
|
2332
|
+
const lastCommitMessage = spawnSync("git", ["log", "-1", "--pretty=%s"], {
|
|
2333
|
+
cwd: projectBase,
|
|
2334
|
+
encoding: "utf8",
|
|
2335
|
+
}).stdout.trim();
|
|
2336
|
+
const committedPaths = spawnSync("git", ["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"], {
|
|
2337
|
+
cwd: projectBase,
|
|
2338
|
+
encoding: "utf8",
|
|
2339
|
+
}).stdout.trim().split(/\r?\n/).filter((line) => line !== "");
|
|
2340
|
+
const createdWorktreeEntries = fs.readdirSync(worktreeParentPath)
|
|
2341
|
+
.filter((entry) => !initialEntries.has(entry))
|
|
2342
|
+
.filter((entry) => /^PI-useReq-/.test(entry));
|
|
2343
|
+
const finalStatus = ctx.__state.statuses.get("pi-usereq") ?? "";
|
|
2344
|
+
|
|
2345
|
+
assert.equal(pi.sentUserMessages.length, 0);
|
|
2346
|
+
assert.equal(switchSessionCalls, 0);
|
|
2347
|
+
assert.deepEqual(createdWorktreeEntries, []);
|
|
2348
|
+
assert.equal(writtenText.trimEnd(), summarizeResult.content?.[0]?.text ?? "");
|
|
2349
|
+
assert.doesNotMatch(writtenText, /^stale$/m);
|
|
2350
|
+
assert.equal(lastCommitMessage, REQ_REFERENCES_COMMIT_MESSAGE);
|
|
2351
|
+
assert.deepEqual(committedPaths, ["docs/custom/REFERENCES.md"]);
|
|
2352
|
+
assert.equal(
|
|
2353
|
+
spawnSync("git", ["status", "--porcelain"], { cwd: projectBase, encoding: "utf8" }).stdout.trim(),
|
|
2354
|
+
"",
|
|
2355
|
+
);
|
|
2356
|
+
assert.ok(ctx.__state.notifications.some((entry) => /SUCCESS: Updated .*REFERENCES\.md/.test(entry.message)));
|
|
2357
|
+
assert.ok(recordedStatuses.some((status) => /<accent>status:<\/accent><warning>running<\/warning>/.test(status)));
|
|
2358
|
+
assert.ok(recordedStatuses.every((status) => !/<accent>status:<\/accent><warning>merging<\/warning>/.test(status)));
|
|
2359
|
+
assert.equal(
|
|
2360
|
+
finalStatus,
|
|
2361
|
+
buildExpectedFakeStatusText({
|
|
2362
|
+
workflowState: "idle",
|
|
2363
|
+
basePath: buildExpectedFakeBasePath(projectBase),
|
|
2364
|
+
docsDir: "docs/custom",
|
|
2365
|
+
testsDir: "tests",
|
|
2366
|
+
srcDir: ["src"],
|
|
2367
|
+
contextFilledCells: 0,
|
|
2368
|
+
et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
|
|
2369
|
+
}),
|
|
2370
|
+
);
|
|
2371
|
+
} finally {
|
|
2372
|
+
process.chdir(previousCwd);
|
|
2373
|
+
fs.rmSync(projectBase, { recursive: true, force: true });
|
|
2374
|
+
}
|
|
2375
|
+
});
|
|
2376
|
+
|
|
2377
|
+
/**
|
|
2378
|
+
* @brief Verifies failing `req-references` runs report errors without session handoff.
|
|
2379
|
+
* @details Covers both git-preflight failure and reference-generation failure, proving the command creates no worktree, sends no LLM message, transitions workflow state to `error`, and preserves the previous git commit when execution aborts. Runtime is dominated by temporary repository setup plus failing command invocations. Side effects are limited to temporary filesystem and git mutation inside fixture repositories.
|
|
2380
|
+
* @return {Promise<void>} Promise resolved after failure-path assertions complete.
|
|
2381
|
+
* @throws {AssertionError} Throws when `req-references` hides failures, mutates history, or attempts prompt-session orchestration.
|
|
2382
|
+
* @satisfies TST-104
|
|
2383
|
+
*/
|
|
2384
|
+
test("req-references surfaces git-validation and reference-generation failures without session handoff", async () => {
|
|
2385
|
+
const gitFailure = initFixtureRepo({ fixtures: [] });
|
|
2386
|
+
const previousCwd = process.cwd();
|
|
2387
|
+
try {
|
|
2388
|
+
fs.writeFileSync(path.join(gitFailure.projectBase, "DIRTY.txt"), "dirty\n", "utf8");
|
|
2389
|
+
const worktreeParentPath = path.dirname(gitFailure.projectBase);
|
|
2390
|
+
const initialEntries = new Set(fs.readdirSync(worktreeParentPath));
|
|
2391
|
+
const pi = createFakePi();
|
|
2392
|
+
piUsereqExtension(pi);
|
|
2393
|
+
const ctx = createFakeCtx(gitFailure.projectBase);
|
|
2394
|
+
|
|
2395
|
+
await pi.emit("session_start", { reason: "startup" }, ctx);
|
|
2396
|
+
await assert.rejects(
|
|
2397
|
+
pi.commands.get("req-references")!.handler("", ctx),
|
|
2398
|
+
/ERROR: Git status unclear!/,
|
|
2399
|
+
);
|
|
2400
|
+
|
|
2401
|
+
const createdWorktreeEntries = fs.readdirSync(worktreeParentPath)
|
|
2402
|
+
.filter((entry) => !initialEntries.has(entry))
|
|
2403
|
+
.filter((entry) => /^PI-useReq-/.test(entry));
|
|
2404
|
+
assert.equal(pi.sentUserMessages.length, 0);
|
|
2405
|
+
assert.deepEqual(createdWorktreeEntries, []);
|
|
2406
|
+
assert.ok(ctx.__state.notifications.some((entry) => /ERROR: Git status unclear!/.test(entry.message)));
|
|
2407
|
+
assert.equal(
|
|
2408
|
+
ctx.__state.statuses.get("pi-usereq"),
|
|
2409
|
+
buildExpectedFakeStatusText({
|
|
2410
|
+
workflowState: "error",
|
|
2411
|
+
basePath: buildExpectedFakeBasePath(gitFailure.projectBase),
|
|
2412
|
+
docsDir: DEFAULT_DOCS_DIR,
|
|
2413
|
+
testsDir: "tests",
|
|
2414
|
+
srcDir: ["src"],
|
|
2415
|
+
contextFilledCells: 0,
|
|
2416
|
+
et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
|
|
2417
|
+
}),
|
|
2418
|
+
);
|
|
2419
|
+
assert.equal(
|
|
2420
|
+
spawnSync("git", ["log", "-1", "--pretty=%s"], {
|
|
2421
|
+
cwd: gitFailure.projectBase,
|
|
2422
|
+
encoding: "utf8",
|
|
2423
|
+
}).stdout.trim(),
|
|
2424
|
+
"init",
|
|
2425
|
+
);
|
|
2426
|
+
} finally {
|
|
2427
|
+
process.chdir(previousCwd);
|
|
2428
|
+
fs.rmSync(gitFailure.projectBase, { recursive: true, force: true });
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
const sourceFailure = initFixtureRepo({ fixtures: [] });
|
|
2432
|
+
try {
|
|
2433
|
+
const worktreeParentPath = path.dirname(sourceFailure.projectBase);
|
|
2434
|
+
const initialEntries = new Set(fs.readdirSync(worktreeParentPath));
|
|
2435
|
+
const pi = createFakePi();
|
|
2436
|
+
piUsereqExtension(pi);
|
|
2437
|
+
const ctx = createFakeCtx(sourceFailure.projectBase);
|
|
2438
|
+
const recordedStatuses: string[] = [];
|
|
2439
|
+
const originalSetStatus = ctx.ui.setStatus.bind(ctx.ui);
|
|
2440
|
+
ctx.ui.setStatus = (key: string, value?: string) => {
|
|
2441
|
+
if (key === "pi-usereq" && value !== undefined) {
|
|
2442
|
+
recordedStatuses.push(value);
|
|
2443
|
+
}
|
|
2444
|
+
originalSetStatus(key, value);
|
|
2445
|
+
};
|
|
2446
|
+
|
|
2447
|
+
await pi.emit("session_start", { reason: "startup" }, ctx);
|
|
2448
|
+
await assert.rejects(
|
|
2449
|
+
pi.commands.get("req-references")!.handler("", ctx),
|
|
2450
|
+
/no source files found in configured directories/,
|
|
2451
|
+
);
|
|
2452
|
+
|
|
2453
|
+
const createdWorktreeEntries = fs.readdirSync(worktreeParentPath)
|
|
2454
|
+
.filter((entry) => !initialEntries.has(entry))
|
|
2455
|
+
.filter((entry) => /^PI-useReq-/.test(entry));
|
|
2456
|
+
assert.equal(pi.sentUserMessages.length, 0);
|
|
2457
|
+
assert.deepEqual(createdWorktreeEntries, []);
|
|
2458
|
+
assert.ok(ctx.__state.notifications.some((entry) => /no source files found in configured directories/.test(entry.message)));
|
|
2459
|
+
assert.ok(recordedStatuses.some((status) => /<accent>status:<\/accent><warning>running<\/warning>/.test(status)));
|
|
2460
|
+
assert.equal(
|
|
2461
|
+
ctx.__state.statuses.get("pi-usereq"),
|
|
2462
|
+
buildExpectedFakeStatusText({
|
|
2463
|
+
workflowState: "error",
|
|
2464
|
+
basePath: buildExpectedFakeBasePath(sourceFailure.projectBase),
|
|
2465
|
+
docsDir: DEFAULT_DOCS_DIR,
|
|
2466
|
+
testsDir: "tests",
|
|
2467
|
+
srcDir: ["src"],
|
|
2468
|
+
contextFilledCells: 0,
|
|
2469
|
+
et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
|
|
2470
|
+
}),
|
|
2471
|
+
);
|
|
2472
|
+
assert.equal(
|
|
2473
|
+
spawnSync("git", ["log", "-1", "--pretty=%s"], {
|
|
2474
|
+
cwd: sourceFailure.projectBase,
|
|
2475
|
+
encoding: "utf8",
|
|
2476
|
+
}).stdout.trim(),
|
|
2477
|
+
"init",
|
|
2478
|
+
);
|
|
2479
|
+
} finally {
|
|
2480
|
+
process.chdir(previousCwd);
|
|
2481
|
+
fs.rmSync(sourceFailure.projectBase, { recursive: true, force: true });
|
|
2482
|
+
}
|
|
2483
|
+
});
|
|
2484
|
+
|
|
2485
|
+
/**
|
|
2486
|
+
* @brief Verifies successful `req-reset` recovers from a non-`idle` worktree-backed prompt state.
|
|
2487
|
+
* @details Starts one worktree-backed `req-change` run until the fake command context remains attached to the execution worktree with workflow state `running`, appends one unique execution-session record, invokes `req-reset` from that non-`idle` state, and verifies transcript preservation, base-path restoration, generated worktree plus branch deletion, unchanged LLM prompt count, success notification, and final workflow state `idle`. Runtime is dominated by temporary git worktree setup plus session-file inspection. Side effects are limited to temporary repository mutation and fake UI notifications.
|
|
2488
|
+
* @return {Promise<void>} Promise resolved after recovery-path assertions complete.
|
|
2489
|
+
* @throws {AssertionError} Throws when `req-reset` rejects the non-`idle` state, loses the execution transcript, leaves generated cleanup artifacts behind, or dispatches an additional LLM prompt.
|
|
2490
|
+
* @satisfies TST-106
|
|
2491
|
+
*/
|
|
2492
|
+
test("req-reset accepts non-idle workflow state, preserves transcript, and removes generated worktrees", async () => {
|
|
2493
|
+
const { projectBase } = initFixtureRepo({ fixtures: [] });
|
|
2494
|
+
const previousCwd = process.cwd();
|
|
2495
|
+
try {
|
|
2496
|
+
const pi = createFakePi();
|
|
2497
|
+
piUsereqExtension(pi);
|
|
2498
|
+
const ctx = createFakeCtx(projectBase);
|
|
2499
|
+
|
|
2500
|
+
await pi.emit("session_start", { reason: "startup" }, ctx);
|
|
2501
|
+
const originalSessionFile = ctx.sessionManager.getSessionFile() ?? "";
|
|
2502
|
+
assert.notEqual(originalSessionFile, "");
|
|
2503
|
+
|
|
2504
|
+
await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
|
|
2505
|
+
|
|
2506
|
+
const sentMessageCountBeforeReset = pi.sentUserMessages.length;
|
|
2507
|
+
const promptText = String(pi.sentUserMessages[0]?.content ?? "");
|
|
2508
|
+
const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
|
|
2509
|
+
assert.ok(worktreeMatch, promptText);
|
|
2510
|
+
const worktreeName = worktreeMatch?.[1] ?? "";
|
|
2511
|
+
const executionBasePath = worktreeMatch?.[2] ?? "";
|
|
2512
|
+
const executionSessionFile = ctx.sessionManager.getSessionFile() ?? "";
|
|
2513
|
+
const appendedEntryId = "req-reset-transcript-entry";
|
|
2514
|
+
|
|
2515
|
+
assert.notEqual(executionSessionFile, "");
|
|
2516
|
+
assert.notEqual(executionSessionFile, originalSessionFile);
|
|
2517
|
+
assert.equal(ctx.cwd, executionBasePath);
|
|
2518
|
+
assert.equal(process.cwd(), executionBasePath);
|
|
2519
|
+
assert.equal(fs.existsSync(executionBasePath), true);
|
|
2520
|
+
assert.match(
|
|
2521
|
+
ctx.__state.statuses.get("pi-usereq") ?? "",
|
|
2522
|
+
/<accent>status:<\/accent><warning>running<\/warning>/,
|
|
2523
|
+
);
|
|
2524
|
+
|
|
2525
|
+
fs.appendFileSync(
|
|
2526
|
+
executionSessionFile,
|
|
2527
|
+
`${JSON.stringify({
|
|
2528
|
+
type: "custom",
|
|
2529
|
+
id: appendedEntryId,
|
|
2530
|
+
parentId: null,
|
|
2531
|
+
timestamp: new Date(0).toISOString(),
|
|
2532
|
+
customType: "req-reset-test",
|
|
2533
|
+
data: { recovered: true },
|
|
2534
|
+
})}\n`,
|
|
2535
|
+
"utf8",
|
|
2536
|
+
);
|
|
2537
|
+
|
|
2538
|
+
await pi.commands.get("req-reset")!.handler("", ctx);
|
|
2539
|
+
|
|
2540
|
+
assert.equal(pi.sentUserMessages.length, sentMessageCountBeforeReset);
|
|
2541
|
+
assert.equal(ctx.cwd, projectBase);
|
|
2542
|
+
assert.equal(process.cwd(), projectBase);
|
|
2543
|
+
assert.equal(ctx.sessionManager.getSessionFile(), originalSessionFile);
|
|
2544
|
+
assert.equal(fs.existsSync(executionBasePath), false);
|
|
2545
|
+
assert.equal(
|
|
2546
|
+
spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
|
|
2547
|
+
cwd: projectBase,
|
|
2548
|
+
encoding: "utf8",
|
|
2549
|
+
}).status,
|
|
2550
|
+
1,
|
|
2551
|
+
);
|
|
2552
|
+
assert.match(fs.readFileSync(originalSessionFile, "utf8"), new RegExp(appendedEntryId));
|
|
2553
|
+
assert.ok(
|
|
2554
|
+
ctx.__state.notifications.some((entry) => /SUCCESS: req-reset/.test(entry.message)),
|
|
2555
|
+
);
|
|
2556
|
+
assert.equal(
|
|
2557
|
+
ctx.__state.statuses.get("pi-usereq"),
|
|
2558
|
+
buildExpectedFakeStatusText({
|
|
2559
|
+
workflowState: "idle",
|
|
2560
|
+
basePath: buildExpectedFakeBasePath(projectBase),
|
|
2561
|
+
docsDir: DEFAULT_DOCS_DIR,
|
|
2562
|
+
testsDir: "tests",
|
|
2563
|
+
srcDir: ["src"],
|
|
2564
|
+
contextFilledCells: 0,
|
|
2565
|
+
et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
|
|
2566
|
+
}),
|
|
2567
|
+
);
|
|
2568
|
+
} finally {
|
|
2569
|
+
process.chdir(previousCwd);
|
|
2570
|
+
fs.rmSync(projectBase, { recursive: true, force: true });
|
|
2571
|
+
}
|
|
2572
|
+
});
|
|
2573
|
+
|
|
2574
|
+
/**
|
|
2575
|
+
* @brief Verifies failing `req-reset` surfaces restoration errors without starting new worktree or LLM execution.
|
|
2576
|
+
* @details Starts one worktree-backed `req-change` run until the fake command context remains attached to the execution worktree, forces the subsequent base-path restoration switch to return `cancelled`, invokes `req-reset` from that non-`idle` state, and verifies the command reports the restoration failure, leaves the generated worktree plus branch in place for manual recovery, sends no additional LLM prompt, and transitions workflow state to `error`. Runtime is dominated by temporary git worktree setup. Side effects are limited to temporary repository mutation and fake UI notifications.
|
|
2577
|
+
* @return {Promise<void>} Promise resolved after failure-path assertions complete.
|
|
2578
|
+
* @throws {AssertionError} Throws when `req-reset` hides restoration failures, creates new cleanup artifacts, or clears the generated worktree plus branch despite the failed restore.
|
|
2579
|
+
* @satisfies TST-107
|
|
2580
|
+
*/
|
|
2581
|
+
test("req-reset surfaces restoration failures and transitions workflow state to error", async () => {
|
|
2582
|
+
const { projectBase } = initFixtureRepo({ fixtures: [] });
|
|
2583
|
+
const previousCwd = process.cwd();
|
|
2584
|
+
try {
|
|
2585
|
+
const pi = createFakePi();
|
|
2586
|
+
piUsereqExtension(pi);
|
|
2587
|
+
const ctx = createFakeCtx(projectBase);
|
|
2588
|
+
|
|
2589
|
+
await pi.emit("session_start", { reason: "startup" }, ctx);
|
|
2590
|
+
const originalSessionFile = ctx.sessionManager.getSessionFile() ?? "";
|
|
2591
|
+
assert.notEqual(originalSessionFile, "");
|
|
2592
|
+
|
|
2593
|
+
await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
|
|
2594
|
+
|
|
2595
|
+
const sentMessageCountBeforeReset = pi.sentUserMessages.length;
|
|
2596
|
+
const promptText = String(pi.sentUserMessages[0]?.content ?? "");
|
|
2597
|
+
const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
|
|
2598
|
+
assert.ok(worktreeMatch, promptText);
|
|
2599
|
+
const worktreeName = worktreeMatch?.[1] ?? "";
|
|
2600
|
+
const executionBasePath = worktreeMatch?.[2] ?? "";
|
|
2601
|
+
|
|
2602
|
+
assert.match(
|
|
2603
|
+
ctx.__state.statuses.get("pi-usereq") ?? "",
|
|
2604
|
+
/<accent>status:<\/accent><warning>running<\/warning>/,
|
|
2605
|
+
);
|
|
2606
|
+
|
|
2607
|
+
ctx.switchSession = async (_sessionPath: string) => ({ cancelled: true });
|
|
2608
|
+
|
|
2609
|
+
await assert.rejects(
|
|
2610
|
+
pi.commands.get("req-reset")!.handler("", ctx),
|
|
2611
|
+
/Session switch cancelled/,
|
|
2612
|
+
);
|
|
2613
|
+
|
|
2614
|
+
assert.equal(pi.sentUserMessages.length, sentMessageCountBeforeReset);
|
|
2615
|
+
assert.equal(ctx.cwd, executionBasePath);
|
|
2616
|
+
assert.equal(process.cwd(), executionBasePath);
|
|
2617
|
+
assert.equal(fs.existsSync(executionBasePath), true);
|
|
2618
|
+
assert.equal(
|
|
2619
|
+
spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
|
|
2620
|
+
cwd: projectBase,
|
|
2621
|
+
encoding: "utf8",
|
|
2622
|
+
}).status,
|
|
2623
|
+
0,
|
|
2624
|
+
);
|
|
2625
|
+
assert.ok(
|
|
2626
|
+
ctx.__state.notifications.some((entry) => /Session switch cancelled/.test(entry.message)),
|
|
2627
|
+
);
|
|
2628
|
+
assert.equal(
|
|
2629
|
+
ctx.__state.statuses.get("pi-usereq"),
|
|
2630
|
+
buildExpectedFakeStatusText({
|
|
2631
|
+
workflowState: "error",
|
|
2632
|
+
basePath: buildExpectedFakeBasePath(executionBasePath),
|
|
2633
|
+
docsDir: DEFAULT_DOCS_DIR,
|
|
2634
|
+
testsDir: "tests",
|
|
2635
|
+
srcDir: ["src"],
|
|
2636
|
+
contextFilledCells: 0,
|
|
2637
|
+
et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
|
|
2638
|
+
}),
|
|
2639
|
+
);
|
|
2640
|
+
} finally {
|
|
2641
|
+
process.chdir(previousCwd);
|
|
2642
|
+
fs.rmSync(projectBase, { recursive: true, force: true });
|
|
2643
|
+
}
|
|
2644
|
+
});
|
|
2645
|
+
|
|
1995
2646
|
test("prompt commands skip worktree creation when auto git commit is disabled", async () => {
|
|
1996
2647
|
const { projectBase } = initFixtureRepo({ fixtures: [] });
|
|
1997
2648
|
try {
|
|
@@ -2045,9 +2696,76 @@ test("prompt commands capture checking before handoff and running at prompt inje
|
|
|
2045
2696
|
assert.ok(worktreeMatch, promptText);
|
|
2046
2697
|
const executionBasePath = worktreeMatch?.[2] ?? "";
|
|
2047
2698
|
assert.equal(
|
|
2048
|
-
checkingStatus,
|
|
2699
|
+
checkingStatus,
|
|
2700
|
+
buildExpectedFakeStatusText({
|
|
2701
|
+
workflowState: "checking",
|
|
2702
|
+
basePath: buildExpectedFakeBasePath(projectBase),
|
|
2703
|
+
docsDir: DEFAULT_DOCS_DIR,
|
|
2704
|
+
testsDir: "tests",
|
|
2705
|
+
srcDir: ["src"],
|
|
2706
|
+
contextFilledCells: 0,
|
|
2707
|
+
et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
|
|
2708
|
+
}),
|
|
2709
|
+
);
|
|
2710
|
+
assert.equal(
|
|
2711
|
+
handoffStatus,
|
|
2712
|
+
buildExpectedFakeStatusText({
|
|
2713
|
+
workflowState: "checking",
|
|
2714
|
+
basePath: buildExpectedFakeBasePath(executionBasePath),
|
|
2715
|
+
docsDir: DEFAULT_DOCS_DIR,
|
|
2716
|
+
testsDir: "tests",
|
|
2717
|
+
srcDir: ["src"],
|
|
2718
|
+
contextFilledCells: 0,
|
|
2719
|
+
et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
|
|
2720
|
+
}),
|
|
2721
|
+
);
|
|
2722
|
+
assert.equal(
|
|
2723
|
+
ctx.__state.statuses.get("pi-usereq"),
|
|
2724
|
+
buildExpectedFakeStatusText({
|
|
2725
|
+
workflowState: "running",
|
|
2726
|
+
basePath: buildExpectedFakeBasePath(executionBasePath),
|
|
2727
|
+
docsDir: DEFAULT_DOCS_DIR,
|
|
2728
|
+
testsDir: "tests",
|
|
2729
|
+
srcDir: ["src"],
|
|
2730
|
+
contextFilledCells: 0,
|
|
2731
|
+
et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
|
|
2732
|
+
}),
|
|
2733
|
+
);
|
|
2734
|
+
|
|
2735
|
+
await pi.emit("before_agent_start", {}, ctx);
|
|
2736
|
+
await pi.emit("agent_start", {}, ctx);
|
|
2737
|
+
await pi.emit("agent_end", {
|
|
2738
|
+
messages: [{ role: "assistant", stopReason: "stop", content: [] }],
|
|
2739
|
+
}, ctx);
|
|
2740
|
+
} finally {
|
|
2741
|
+
setPromptCommandPostCreateHookForTests(undefined);
|
|
2742
|
+
process.chdir(previousCwd);
|
|
2743
|
+
fs.rmSync(projectBase, { recursive: true, force: true });
|
|
2744
|
+
}
|
|
2745
|
+
});
|
|
2746
|
+
|
|
2747
|
+
/**
|
|
2748
|
+
* @brief Verifies every req command except `req-reset` rejects non-`idle` workflow state and transitions to `error`.
|
|
2749
|
+
* @details Starts one no-worktree `req-change` run until workflow state becomes `running`, invokes `req-analyze` and `req-references` while orchestration remains active, and verifies both handlers reject with deterministic diagnostics, emit error notifications, send no additional LLM prompt, and move the shared workflow state to `error` before `agent_end` restores `idle`. Runtime is dominated by temporary project setup plus fake lifecycle dispatch. Side effects are limited to temporary filesystem mutation and fake UI notifications.
|
|
2750
|
+
* @return {Promise<void>} Promise resolved after rejection-path assertions complete.
|
|
2751
|
+
* @throws {AssertionError} Throws when a busy req command starts new orchestration, skips the error transition, or prevents the active no-worktree prompt from returning to `idle`.
|
|
2752
|
+
* @satisfies TST-068
|
|
2753
|
+
*/
|
|
2754
|
+
test("req commands except req-reset reject non-idle workflow state and transition to error", async () => {
|
|
2755
|
+
const { projectBase } = initFixtureRepo({ fixtures: [] });
|
|
2756
|
+
const previousCwd = process.cwd();
|
|
2757
|
+
try {
|
|
2758
|
+
writeProjectConfigOverrides(projectBase, { GIT_WORKTREE_ENABLED: "disable" });
|
|
2759
|
+
const pi = createFakePi();
|
|
2760
|
+
piUsereqExtension(pi);
|
|
2761
|
+
const ctx = createFakeCtx(projectBase);
|
|
2762
|
+
await pi.emit("session_start", { reason: "startup" }, ctx);
|
|
2763
|
+
|
|
2764
|
+
await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
|
|
2765
|
+
assert.equal(
|
|
2766
|
+
ctx.__state.statuses.get("pi-usereq"),
|
|
2049
2767
|
buildExpectedFakeStatusText({
|
|
2050
|
-
workflowState: "
|
|
2768
|
+
workflowState: "running",
|
|
2051
2769
|
basePath: buildExpectedFakeBasePath(projectBase),
|
|
2052
2770
|
docsDir: DEFAULT_DOCS_DIR,
|
|
2053
2771
|
testsDir: "tests",
|
|
@@ -2056,11 +2774,21 @@ test("prompt commands capture checking before handoff and running at prompt inje
|
|
|
2056
2774
|
et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
|
|
2057
2775
|
}),
|
|
2058
2776
|
);
|
|
2777
|
+
|
|
2778
|
+
await assert.rejects(
|
|
2779
|
+
pi.commands.get("req-analyze")!.handler("Inspect src/index.ts", ctx),
|
|
2780
|
+
/expected idle/,
|
|
2781
|
+
);
|
|
2782
|
+
assert.equal(pi.sentUserMessages.length, 1);
|
|
2059
2783
|
assert.equal(
|
|
2060
|
-
|
|
2784
|
+
ctx.__state.notifications.filter((entry) => /expected idle/.test(entry.message)).length,
|
|
2785
|
+
1,
|
|
2786
|
+
);
|
|
2787
|
+
assert.equal(
|
|
2788
|
+
ctx.__state.statuses.get("pi-usereq"),
|
|
2061
2789
|
buildExpectedFakeStatusText({
|
|
2062
|
-
workflowState: "
|
|
2063
|
-
basePath: buildExpectedFakeBasePath(
|
|
2790
|
+
workflowState: "error",
|
|
2791
|
+
basePath: buildExpectedFakeBasePath(projectBase),
|
|
2064
2792
|
docsDir: DEFAULT_DOCS_DIR,
|
|
2065
2793
|
testsDir: "tests",
|
|
2066
2794
|
srcDir: ["src"],
|
|
@@ -2068,11 +2796,21 @@ test("prompt commands capture checking before handoff and running at prompt inje
|
|
|
2068
2796
|
et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
|
|
2069
2797
|
}),
|
|
2070
2798
|
);
|
|
2799
|
+
|
|
2800
|
+
await assert.rejects(
|
|
2801
|
+
pi.commands.get("req-references")!.handler("", ctx),
|
|
2802
|
+
/expected idle/,
|
|
2803
|
+
);
|
|
2804
|
+
assert.equal(pi.sentUserMessages.length, 1);
|
|
2805
|
+
assert.equal(
|
|
2806
|
+
ctx.__state.notifications.filter((entry) => /expected idle/.test(entry.message)).length,
|
|
2807
|
+
2,
|
|
2808
|
+
);
|
|
2071
2809
|
assert.equal(
|
|
2072
2810
|
ctx.__state.statuses.get("pi-usereq"),
|
|
2073
2811
|
buildExpectedFakeStatusText({
|
|
2074
|
-
workflowState: "
|
|
2075
|
-
basePath: buildExpectedFakeBasePath(
|
|
2812
|
+
workflowState: "error",
|
|
2813
|
+
basePath: buildExpectedFakeBasePath(projectBase),
|
|
2076
2814
|
docsDir: DEFAULT_DOCS_DIR,
|
|
2077
2815
|
testsDir: "tests",
|
|
2078
2816
|
srcDir: ["src"],
|
|
@@ -2081,33 +2819,64 @@ test("prompt commands capture checking before handoff and running at prompt inje
|
|
|
2081
2819
|
}),
|
|
2082
2820
|
);
|
|
2083
2821
|
|
|
2084
|
-
await pi.emit("before_agent_start", {}, ctx);
|
|
2085
2822
|
await pi.emit("agent_start", {}, ctx);
|
|
2086
2823
|
await pi.emit("agent_end", {
|
|
2087
2824
|
messages: [{ role: "assistant", stopReason: "stop", content: [] }],
|
|
2088
2825
|
}, ctx);
|
|
2826
|
+
assert.equal(
|
|
2827
|
+
ctx.__state.statuses.get("pi-usereq"),
|
|
2828
|
+
buildExpectedFakeStatusText({
|
|
2829
|
+
workflowState: "idle",
|
|
2830
|
+
basePath: buildExpectedFakeBasePath(projectBase),
|
|
2831
|
+
docsDir: DEFAULT_DOCS_DIR,
|
|
2832
|
+
testsDir: "tests",
|
|
2833
|
+
srcDir: ["src"],
|
|
2834
|
+
contextFilledCells: 0,
|
|
2835
|
+
et: "⏱︎ --:-- ⚑ 0:00 ⌛︎0:00",
|
|
2836
|
+
}),
|
|
2837
|
+
);
|
|
2089
2838
|
} finally {
|
|
2090
|
-
setPromptCommandPostCreateHookForTests(undefined);
|
|
2091
2839
|
process.chdir(previousCwd);
|
|
2092
2840
|
fs.rmSync(projectBase, { recursive: true, force: true });
|
|
2093
2841
|
}
|
|
2094
2842
|
});
|
|
2095
2843
|
|
|
2096
|
-
|
|
2844
|
+
/**
|
|
2845
|
+
* @brief Verifies a worktree-backed prompt still finalizes after a busy req-command rejection moves workflow state to `error`.
|
|
2846
|
+
* @details Starts one worktree-backed `req-change` run until the fake command context remains attached to the execution worktree with workflow state `running`, invokes `req-analyze` while the active run is still in progress so the busy-command guard forces workflow state to `error`, then emits the matched lifecycle completion and verifies `base-path` restoration, worktree plus branch deletion, unchanged prompt count, and final workflow state `idle`. Runtime is dominated by temporary git worktree setup plus fake lifecycle dispatch. Side effects are limited to temporary repository mutation and fake UI notifications.
|
|
2847
|
+
* @return {Promise<void>} Promise resolved after finalization-path assertions complete.
|
|
2848
|
+
* @throws {AssertionError} Throws when busy-command rejection orphaned the successful worktree-backed run or prevented cleanup.
|
|
2849
|
+
* @satisfies TST-068, TST-082
|
|
2850
|
+
*/
|
|
2851
|
+
test("worktree-backed prompt finalization still succeeds after a busy-command rejection", async () => {
|
|
2097
2852
|
const { projectBase } = initFixtureRepo({ fixtures: [] });
|
|
2853
|
+
const previousCwd = process.cwd();
|
|
2098
2854
|
try {
|
|
2099
|
-
writeProjectConfigOverrides(projectBase, { GIT_WORKTREE_ENABLED: "disable" });
|
|
2100
2855
|
const pi = createFakePi();
|
|
2101
2856
|
piUsereqExtension(pi);
|
|
2102
2857
|
const ctx = createFakeCtx(projectBase);
|
|
2858
|
+
|
|
2103
2859
|
await pi.emit("session_start", { reason: "startup" }, ctx);
|
|
2860
|
+
const originalSessionFile = ctx.sessionManager.getSessionFile() ?? "";
|
|
2861
|
+
assert.notEqual(originalSessionFile, "");
|
|
2104
2862
|
|
|
2105
2863
|
await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
|
|
2864
|
+
|
|
2865
|
+
const sentMessageCount = pi.sentUserMessages.length;
|
|
2866
|
+
const promptText = String(pi.sentUserMessages[0]?.content ?? "");
|
|
2867
|
+
const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
|
|
2868
|
+
assert.ok(worktreeMatch, promptText);
|
|
2869
|
+
const worktreeName = worktreeMatch?.[1] ?? "";
|
|
2870
|
+
const executionBasePath = worktreeMatch?.[2] ?? "";
|
|
2871
|
+
|
|
2872
|
+
assert.equal(ctx.cwd, executionBasePath);
|
|
2873
|
+
assert.equal(process.cwd(), executionBasePath);
|
|
2874
|
+
assert.equal(fs.existsSync(executionBasePath), true);
|
|
2106
2875
|
assert.equal(
|
|
2107
2876
|
ctx.__state.statuses.get("pi-usereq"),
|
|
2108
2877
|
buildExpectedFakeStatusText({
|
|
2109
2878
|
workflowState: "running",
|
|
2110
|
-
basePath: buildExpectedFakeBasePath(
|
|
2879
|
+
basePath: buildExpectedFakeBasePath(executionBasePath),
|
|
2111
2880
|
docsDir: DEFAULT_DOCS_DIR,
|
|
2112
2881
|
testsDir: "tests",
|
|
2113
2882
|
srcDir: ["src"],
|
|
@@ -2120,13 +2889,16 @@ test("prompt commands reject non-idle workflow state before starting a new orche
|
|
|
2120
2889
|
pi.commands.get("req-analyze")!.handler("Inspect src/index.ts", ctx),
|
|
2121
2890
|
/expected idle/,
|
|
2122
2891
|
);
|
|
2123
|
-
assert.equal(pi.sentUserMessages.length,
|
|
2124
|
-
assert.
|
|
2892
|
+
assert.equal(pi.sentUserMessages.length, sentMessageCount);
|
|
2893
|
+
assert.equal(
|
|
2894
|
+
ctx.__state.notifications.filter((entry) => /expected idle/.test(entry.message)).length,
|
|
2895
|
+
1,
|
|
2896
|
+
);
|
|
2125
2897
|
assert.equal(
|
|
2126
2898
|
ctx.__state.statuses.get("pi-usereq"),
|
|
2127
2899
|
buildExpectedFakeStatusText({
|
|
2128
|
-
workflowState: "
|
|
2129
|
-
basePath: buildExpectedFakeBasePath(
|
|
2900
|
+
workflowState: "error",
|
|
2901
|
+
basePath: buildExpectedFakeBasePath(executionBasePath),
|
|
2130
2902
|
docsDir: DEFAULT_DOCS_DIR,
|
|
2131
2903
|
testsDir: "tests",
|
|
2132
2904
|
srcDir: ["src"],
|
|
@@ -2135,10 +2907,30 @@ test("prompt commands reject non-idle workflow state before starting a new orche
|
|
|
2135
2907
|
}),
|
|
2136
2908
|
);
|
|
2137
2909
|
|
|
2910
|
+
await pi.emit("before_agent_start", {}, ctx);
|
|
2138
2911
|
await pi.emit("agent_start", {}, ctx);
|
|
2139
2912
|
await pi.emit("agent_end", {
|
|
2140
2913
|
messages: [{ role: "assistant", stopReason: "stop", content: [] }],
|
|
2141
2914
|
}, ctx);
|
|
2915
|
+
|
|
2916
|
+
assert.equal(ctx.cwd, projectBase);
|
|
2917
|
+
assert.equal(process.cwd(), projectBase);
|
|
2918
|
+
assert.equal(ctx.sessionManager.getSessionFile(), originalSessionFile);
|
|
2919
|
+
assert.equal(fs.existsSync(executionBasePath), false);
|
|
2920
|
+
assert.notEqual(
|
|
2921
|
+
spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
|
|
2922
|
+
cwd: projectBase,
|
|
2923
|
+
encoding: "utf8",
|
|
2924
|
+
}).status,
|
|
2925
|
+
0,
|
|
2926
|
+
);
|
|
2927
|
+
assert.equal(
|
|
2928
|
+
spawnSync("git", ["status", "--porcelain"], {
|
|
2929
|
+
cwd: projectBase,
|
|
2930
|
+
encoding: "utf8",
|
|
2931
|
+
}).stdout.trim(),
|
|
2932
|
+
"",
|
|
2933
|
+
);
|
|
2142
2934
|
assert.equal(
|
|
2143
2935
|
ctx.__state.statuses.get("pi-usereq"),
|
|
2144
2936
|
buildExpectedFakeStatusText({
|
|
@@ -2152,6 +2944,7 @@ test("prompt commands reject non-idle workflow state before starting a new orche
|
|
|
2152
2944
|
}),
|
|
2153
2945
|
);
|
|
2154
2946
|
} finally {
|
|
2947
|
+
process.chdir(previousCwd);
|
|
2155
2948
|
fs.rmSync(projectBase, { recursive: true, force: true });
|
|
2156
2949
|
}
|
|
2157
2950
|
});
|
|
@@ -2238,6 +3031,7 @@ test("worktree-enabled prompt commands merge successful runs, restore base-path,
|
|
|
2238
3031
|
};
|
|
2239
3032
|
await pi.emit("session_start", { reason: "startup" }, ctx);
|
|
2240
3033
|
|
|
3034
|
+
const originalBranchName = buildExpectedFakeBranchValue(projectBase);
|
|
2241
3035
|
await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
|
|
2242
3036
|
const promptText = String(pi.sentUserMessages[0]?.content ?? "");
|
|
2243
3037
|
const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
|
|
@@ -2256,6 +3050,7 @@ test("worktree-enabled prompt commands merge successful runs, restore base-path,
|
|
|
2256
3050
|
assert.equal(process.cwd(), executionBasePath);
|
|
2257
3051
|
assert.equal(ctx.cwd, executionBasePath);
|
|
2258
3052
|
assert.ok(recordedStatuses.some((status) => /<accent>status:<\/accent><warning>running<\/warning>/.test(status)));
|
|
3053
|
+
assert.ok(recordedStatuses.some((status) => status.includes(`${formatFakeThemeForeground("accent", "branch:")}${formatFakeThemeForeground("warning", worktreeName)}`)));
|
|
2259
3054
|
|
|
2260
3055
|
await pi.emit("before_agent_start", {}, ctx);
|
|
2261
3056
|
assert.equal(process.cwd(), executionBasePath);
|
|
@@ -2290,7 +3085,114 @@ test("worktree-enabled prompt commands merge successful runs, restore base-path,
|
|
|
2290
3085
|
);
|
|
2291
3086
|
assert.equal(ctx.__state.notifications.filter((entry) => entry.level === "error").length, 0);
|
|
2292
3087
|
assert.ok(recordedStatuses.some((status) => /<accent>status:<\/accent><warning>merging<\/warning>/.test(status)));
|
|
2293
|
-
assert.
|
|
3088
|
+
assert.ok(recordedStatuses.some((status) => status.includes(`${formatFakeThemeForeground("accent", "branch:")}${formatFakeThemeForeground("warning", originalBranchName)}`)));
|
|
3089
|
+
const finalStatus = ctx.__state.statuses.get("pi-usereq") ?? "";
|
|
3090
|
+
assert.match(finalStatus, /<accent>status:<\/accent><warning>idle<\/warning>/);
|
|
3091
|
+
assert.ok(finalStatus.includes(`${formatFakeThemeForeground("accent", "branch:")}${formatFakeThemeForeground("warning", originalBranchName)}`));
|
|
3092
|
+
} finally {
|
|
3093
|
+
process.chdir(previousCwd);
|
|
3094
|
+
fs.rmSync(projectBase, { recursive: true, force: true });
|
|
3095
|
+
}
|
|
3096
|
+
});
|
|
3097
|
+
|
|
3098
|
+
/**
|
|
3099
|
+
* @brief Verifies successful closure stashes tracked `base-path` changes before merge and restores them afterward.
|
|
3100
|
+
* @details Creates tracked `base-path` files, dirties one in the index and one in the worktree after worktree handoff, commits a worktree change, and proves prompt finalization runs `git stash`/merge/`git stash pop` semantics by merging successfully, restoring the local `base-path` changes, deleting the worktree, and surfacing a warning-only notification about the dirty restored base path.
|
|
3101
|
+
* @return {Promise<void>} Promise resolved after stash-assisted merge assertions complete.
|
|
3102
|
+
* @throws {AssertionError} Throws when finalization drops local `base-path` changes, skips the merge, or reports an error instead of a warning.
|
|
3103
|
+
* @satisfies REQ-208, REQ-291, REQ-292
|
|
3104
|
+
*/
|
|
3105
|
+
test("worktree-backed successful closure stashes tracked base-path changes, merges, restores changes, and warns about dirty base-path", async () => {
|
|
3106
|
+
const { projectBase } = initFixtureRepo({ fixtures: [] });
|
|
3107
|
+
const previousCwd = process.cwd();
|
|
3108
|
+
try {
|
|
3109
|
+
const stagedBasePath = path.join(projectBase, "staged-base.txt");
|
|
3110
|
+
const worktreeBasePath = path.join(projectBase, "worktree-base.txt");
|
|
3111
|
+
fs.writeFileSync(stagedBasePath, "staged base clean\n", "utf8");
|
|
3112
|
+
fs.writeFileSync(worktreeBasePath, "worktree base clean\n", "utf8");
|
|
3113
|
+
assert.equal(
|
|
3114
|
+
spawnSync("git", ["add", "staged-base.txt", "worktree-base.txt"], {
|
|
3115
|
+
cwd: projectBase,
|
|
3116
|
+
encoding: "utf8",
|
|
3117
|
+
}).status,
|
|
3118
|
+
0,
|
|
3119
|
+
);
|
|
3120
|
+
const baseCommit = spawnSync("git", ["commit", "-m", "tracked base files"], {
|
|
3121
|
+
cwd: projectBase,
|
|
3122
|
+
encoding: "utf8",
|
|
3123
|
+
});
|
|
3124
|
+
assert.equal(baseCommit.status, 0, baseCommit.stderr);
|
|
3125
|
+
|
|
3126
|
+
const pi = createFakePi();
|
|
3127
|
+
piUsereqExtension(pi);
|
|
3128
|
+
const ctx = createFakeCtx(projectBase);
|
|
3129
|
+
await pi.emit("session_start", { reason: "startup" }, ctx);
|
|
3130
|
+
|
|
3131
|
+
await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
|
|
3132
|
+
const promptText = String(pi.sentUserMessages[0]?.content ?? "");
|
|
3133
|
+
const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
|
|
3134
|
+
assert.ok(worktreeMatch, promptText);
|
|
3135
|
+
const worktreeName = worktreeMatch?.[1] ?? "";
|
|
3136
|
+
const executionBasePath = worktreeMatch?.[2] ?? "";
|
|
3137
|
+
|
|
3138
|
+
await pi.emit("before_agent_start", {}, ctx);
|
|
3139
|
+
await pi.emit("agent_start", {}, ctx);
|
|
3140
|
+
fs.mkdirSync(path.join(executionBasePath, "src"), { recursive: true });
|
|
3141
|
+
fs.writeFileSync(path.join(executionBasePath, "src", "stash-assisted.ts"), "export const STASH_ASSISTED = 1;\n", "utf8");
|
|
3142
|
+
assert.equal(
|
|
3143
|
+
spawnSync("git", ["add", "src/stash-assisted.ts"], {
|
|
3144
|
+
cwd: executionBasePath,
|
|
3145
|
+
encoding: "utf8",
|
|
3146
|
+
}).status,
|
|
3147
|
+
0,
|
|
3148
|
+
);
|
|
3149
|
+
const worktreeCommit = spawnSync("git", ["commit", "-m", "stash assisted change"], {
|
|
3150
|
+
cwd: executionBasePath,
|
|
3151
|
+
encoding: "utf8",
|
|
3152
|
+
});
|
|
3153
|
+
assert.equal(worktreeCommit.status, 0, worktreeCommit.stderr);
|
|
3154
|
+
|
|
3155
|
+
fs.writeFileSync(stagedBasePath, "staged base dirty\n", "utf8");
|
|
3156
|
+
assert.equal(
|
|
3157
|
+
spawnSync("git", ["add", "staged-base.txt"], {
|
|
3158
|
+
cwd: projectBase,
|
|
3159
|
+
encoding: "utf8",
|
|
3160
|
+
}).status,
|
|
3161
|
+
0,
|
|
3162
|
+
);
|
|
3163
|
+
fs.writeFileSync(worktreeBasePath, "worktree base dirty\n", "utf8");
|
|
3164
|
+
|
|
3165
|
+
await pi.emit("agent_end", {
|
|
3166
|
+
messages: [{ role: "assistant", stopReason: "stop", content: [] }],
|
|
3167
|
+
}, ctx);
|
|
3168
|
+
|
|
3169
|
+
const statusResult = spawnSync("git", ["status", "--porcelain"], {
|
|
3170
|
+
cwd: projectBase,
|
|
3171
|
+
encoding: "utf8",
|
|
3172
|
+
});
|
|
3173
|
+
assert.equal(statusResult.status, 0, statusResult.stderr);
|
|
3174
|
+
assert.equal(process.cwd(), projectBase);
|
|
3175
|
+
assert.equal(ctx.cwd, projectBase);
|
|
3176
|
+
assert.ok(fs.existsSync(path.join(projectBase, "src", "stash-assisted.ts")));
|
|
3177
|
+
assert.equal(fs.readFileSync(stagedBasePath, "utf8"), "staged base dirty\n");
|
|
3178
|
+
assert.equal(fs.readFileSync(worktreeBasePath, "utf8"), "worktree base dirty\n");
|
|
3179
|
+
assert.match(statusResult.stdout, /staged-base\.txt/);
|
|
3180
|
+
assert.match(statusResult.stdout, /worktree-base\.txt/);
|
|
3181
|
+
assert.equal(fs.existsSync(executionBasePath), false);
|
|
3182
|
+
assert.notEqual(
|
|
3183
|
+
spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
|
|
3184
|
+
cwd: projectBase,
|
|
3185
|
+
encoding: "utf8",
|
|
3186
|
+
}).status,
|
|
3187
|
+
0,
|
|
3188
|
+
);
|
|
3189
|
+
assert.equal(ctx.__state.notifications.filter((entry) => entry.level === "error").length, 0);
|
|
3190
|
+
assert.ok(
|
|
3191
|
+
ctx.__state.notifications.some(
|
|
3192
|
+
(entry) => entry.level === "info"
|
|
3193
|
+
&& /WARNING: Restored base-path changes after merge; base-path is not clean\./.test(entry.message),
|
|
3194
|
+
),
|
|
3195
|
+
);
|
|
2294
3196
|
} finally {
|
|
2295
3197
|
process.chdir(previousCwd);
|
|
2296
3198
|
fs.rmSync(projectBase, { recursive: true, force: true });
|
|
@@ -3950,6 +4852,58 @@ test("session_start renders the 0-percent context icon when context usage is emp
|
|
|
3950
4852
|
);
|
|
3951
4853
|
});
|
|
3952
4854
|
|
|
4855
|
+
/**
|
|
4856
|
+
* @brief Verifies direct status renders refresh context usage before drawing the footer.
|
|
4857
|
+
* @details Exercises `renderPiUsereqStatus(...)` without going through an intercepted lifecycle hook, then mutates the fake `getContextUsage()` result to simulate a post-compaction context reset. The test proves direct rerender call sites must fetch fresh context usage so the `context:` gauge updates immediately instead of reusing stale controller state.
|
|
4858
|
+
* @return {void} No return value.
|
|
4859
|
+
* @throws {AssertionError} Throws when a direct render ignores the live `getContextUsage()` snapshot or fails to clear the gauge after context reset.
|
|
4860
|
+
* @satisfies REQ-118, REQ-119, REQ-122, REQ-284
|
|
4861
|
+
*/
|
|
4862
|
+
test("direct status rerenders refresh the context gauge after context reset", () => {
|
|
4863
|
+
const cwd = createTempDir("pi-usereq-context-direct-render-");
|
|
4864
|
+
fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
|
|
4865
|
+
const controller = createPiUsereqStatusController();
|
|
4866
|
+
setPiUsereqStatusConfig(controller, getDefaultConfig(cwd));
|
|
4867
|
+
const contextUsage: { tokens: number | null; contextWindow: number; percent: number | null } = {
|
|
4868
|
+
tokens: 910,
|
|
4869
|
+
contextWindow: 1000,
|
|
4870
|
+
percent: 91,
|
|
4871
|
+
};
|
|
4872
|
+
const ctx = createFakeCtx(cwd, { selects: [] }, {
|
|
4873
|
+
getContextUsage: () => ({ ...contextUsage }),
|
|
4874
|
+
});
|
|
4875
|
+
|
|
4876
|
+
renderPiUsereqStatus(controller, ctx);
|
|
4877
|
+
assert.equal(
|
|
4878
|
+
ctx.__state.statuses.get("pi-usereq"),
|
|
4879
|
+
buildExpectedFakeStatusText({
|
|
4880
|
+
basePath: buildExpectedFakeBasePath(cwd),
|
|
4881
|
+
docsDir: DEFAULT_DOCS_DIR,
|
|
4882
|
+
testsDir: "tests",
|
|
4883
|
+
srcDir: ["src"],
|
|
4884
|
+
contextFilledCells: 10,
|
|
4885
|
+
contextPercent: 91,
|
|
4886
|
+
et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
|
|
4887
|
+
}),
|
|
4888
|
+
);
|
|
4889
|
+
|
|
4890
|
+
contextUsage.tokens = null;
|
|
4891
|
+
contextUsage.percent = null;
|
|
4892
|
+
renderPiUsereqStatus(controller, ctx);
|
|
4893
|
+
assert.equal(
|
|
4894
|
+
ctx.__state.statuses.get("pi-usereq"),
|
|
4895
|
+
buildExpectedFakeStatusText({
|
|
4896
|
+
basePath: buildExpectedFakeBasePath(cwd),
|
|
4897
|
+
docsDir: DEFAULT_DOCS_DIR,
|
|
4898
|
+
testsDir: "tests",
|
|
4899
|
+
srcDir: ["src"],
|
|
4900
|
+
contextFilledCells: 0,
|
|
4901
|
+
contextPercent: null,
|
|
4902
|
+
et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
|
|
4903
|
+
}),
|
|
4904
|
+
);
|
|
4905
|
+
});
|
|
4906
|
+
|
|
3953
4907
|
/**
|
|
3954
4908
|
* @brief Verifies stale replacement contexts do not break workflow-state rendering.
|
|
3955
4909
|
* @details Simulates the guarded pi runtime error thrown when an extension context is accessed after session replacement or reload. The test proves workflow-state updates must preserve internal state while suppressing stale-context render failures instead of surfacing extension errors.
|
|
@@ -4044,7 +4998,47 @@ test("context hook refreshes context usage and rounds progress cells upward", as
|
|
|
4044
4998
|
);
|
|
4045
4999
|
});
|
|
4046
5000
|
|
|
4047
|
-
|
|
5001
|
+
/**
|
|
5002
|
+
* @brief Verifies context-gauge icon cutovers below the error range.
|
|
5003
|
+
* @details Replays deterministic context percentages at documented threshold boundaries below `90%` so the status bar proves the `▕▂▏`, `▕▄▏`, `▕▆▏`, and `▕█▏` transitions while rendering every non-error band with the same warning-token color used by the `status` value. Runtime is O(n) in case count. Side effects are limited to in-memory status updates.
|
|
5004
|
+
* @return {Promise<void>} Promise resolved after all threshold assertions complete.
|
|
5005
|
+
* @throws {AssertionError} Throws when any threshold renders the wrong icon or low-band color.
|
|
5006
|
+
* @satisfies REQ-122, REQ-126, TST-096
|
|
5007
|
+
*/
|
|
5008
|
+
test("context hook applies warning-colored quarter-threshold gauge icons below the error range", async () => {
|
|
5009
|
+
const cwd = createTempDir("pi-usereq-context-threshold-status-");
|
|
5010
|
+
fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
|
|
5011
|
+
const contextUsage = { tokens: 0, contextWindow: 1000, percent: 0 };
|
|
5012
|
+
const pi = createFakePi();
|
|
5013
|
+
piUsereqExtension(pi);
|
|
5014
|
+
const ctx = createFakeCtx(cwd, { selects: [] }, {
|
|
5015
|
+
getContextUsage: () => ({ ...contextUsage }),
|
|
5016
|
+
});
|
|
5017
|
+
|
|
5018
|
+
await pi.emit("session_start", { reason: "startup" }, ctx);
|
|
5019
|
+
|
|
5020
|
+
for (const percent of [24.9, 25, 49.9, 50, 74.9, 75, 89.9]) {
|
|
5021
|
+
contextUsage.tokens = Math.round(percent * 10);
|
|
5022
|
+
contextUsage.percent = percent;
|
|
5023
|
+
await pi.emit("context", { messages: [] }, ctx);
|
|
5024
|
+
|
|
5025
|
+
assert.equal(
|
|
5026
|
+
ctx.__state.statuses.get("pi-usereq"),
|
|
5027
|
+
buildExpectedFakeStatusText({
|
|
5028
|
+
basePath: buildExpectedFakeBasePath(cwd),
|
|
5029
|
+
docsDir: DEFAULT_DOCS_DIR,
|
|
5030
|
+
testsDir: "tests",
|
|
5031
|
+
srcDir: ["src"],
|
|
5032
|
+
contextFilledCells: Math.ceil(percent / 10),
|
|
5033
|
+
contextPercent: percent,
|
|
5034
|
+
et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
|
|
5035
|
+
}),
|
|
5036
|
+
`unexpected context icon at ${percent}%`,
|
|
5037
|
+
);
|
|
5038
|
+
}
|
|
5039
|
+
});
|
|
5040
|
+
|
|
5041
|
+
test("context hook renders the error full context icon when usage exceeds ninety percent", async () => {
|
|
4048
5042
|
const cwd = createTempDir("pi-usereq-context-full-status-");
|
|
4049
5043
|
fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
|
|
4050
5044
|
const contextUsage = { tokens: 910, contextWindow: 1000, percent: 91 };
|
|
@@ -4071,14 +5065,14 @@ test("context hook renders the full context icon when usage exceeds ninety perce
|
|
|
4071
5065
|
);
|
|
4072
5066
|
});
|
|
4073
5067
|
|
|
4074
|
-
test("status overflow context icon uses only CLI-supported theme tokens", async () => {
|
|
5068
|
+
test("status overflow-threshold context icon uses only CLI-supported theme tokens", async () => {
|
|
4075
5069
|
const cwd = createTempDir("pi-usereq-context-theme-contract-");
|
|
4076
5070
|
fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
|
|
4077
5071
|
const usedColors = new Set<string>();
|
|
4078
5072
|
const pi = createFakePi();
|
|
4079
5073
|
piUsereqExtension(pi);
|
|
4080
5074
|
const ctx = createFakeCtx(cwd, { selects: [] }, {
|
|
4081
|
-
getContextUsage: () => ({ tokens:
|
|
5075
|
+
getContextUsage: () => ({ tokens: 1000, contextWindow: 1000, percent: 100 }),
|
|
4082
5076
|
theme: {
|
|
4083
5077
|
fg(color: string, text: string) {
|
|
4084
5078
|
assert.ok(["accent", "warning", "dim", "error"].includes(color));
|
|
@@ -4107,7 +5101,7 @@ test("status overflow context icon uses only CLI-supported theme tokens", async
|
|
|
4107
5101
|
testsDir: "tests",
|
|
4108
5102
|
srcDir: ["src"],
|
|
4109
5103
|
contextFilledCells: 10,
|
|
4110
|
-
contextPercent:
|
|
5104
|
+
contextPercent: 100,
|
|
4111
5105
|
et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
|
|
4112
5106
|
}),
|
|
4113
5107
|
);
|
|
@@ -4390,11 +5384,12 @@ test("configuration menu orders tool toggles and can enable embedded builtin too
|
|
|
4390
5384
|
"references",
|
|
4391
5385
|
"search",
|
|
4392
5386
|
"static-check",
|
|
5387
|
+
"summarize",
|
|
4393
5388
|
"tokens",
|
|
4394
5389
|
"files-compress",
|
|
4395
|
-
"files-references",
|
|
4396
5390
|
"files-search",
|
|
4397
5391
|
"files-static-check",
|
|
5392
|
+
"files-summarize",
|
|
4398
5393
|
"files-tokens",
|
|
4399
5394
|
"bash",
|
|
4400
5395
|
"edit",
|
|
@@ -4429,7 +5424,7 @@ test("configuration menu can disable configurable active tools", async () => {
|
|
|
4429
5424
|
assert.deepEqual(pi.getActiveTools().filter((toolName: string) => PI_USEREQ_STARTUP_TOOL_NAMES.includes(toolName as never)), []);
|
|
4430
5425
|
});
|
|
4431
5426
|
|
|
4432
|
-
test("configuration menu can persist notify and sound settings", async () => {
|
|
5427
|
+
test("configuration menu can persist notify and boot-sound settings without changing runtime sound", async () => {
|
|
4433
5428
|
const cwd = createTempDir("pi-usereq-menu-notify-");
|
|
4434
5429
|
fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
|
|
4435
5430
|
const pi = createFakePi();
|
|
@@ -4443,7 +5438,7 @@ test("configuration menu can persist notify and sound settings", async () => {
|
|
|
4443
5438
|
"Notification events",
|
|
4444
5439
|
"Prompt failed",
|
|
4445
5440
|
"Save and close",
|
|
4446
|
-
"Enable sound",
|
|
5441
|
+
"Enable sound (boot value)",
|
|
4447
5442
|
"high",
|
|
4448
5443
|
"Sound events",
|
|
4449
5444
|
"Prompt interrupted",
|
|
@@ -4465,6 +5460,7 @@ test("configuration menu can persist notify and sound settings", async () => {
|
|
|
4465
5460
|
],
|
|
4466
5461
|
});
|
|
4467
5462
|
|
|
5463
|
+
await pi.emit("session_start", { reason: "startup" }, ctx);
|
|
4468
5464
|
await command!.handler("", ctx);
|
|
4469
5465
|
|
|
4470
5466
|
const config = JSON.parse(fs.readFileSync(getProjectConfigPath(cwd), "utf8"));
|
|
@@ -4490,7 +5486,7 @@ test("configuration menu can persist notify and sound settings", async () => {
|
|
|
4490
5486
|
srcDir: ["src"],
|
|
4491
5487
|
contextFilledCells: 0,
|
|
4492
5488
|
et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
|
|
4493
|
-
sound: "
|
|
5489
|
+
sound: "none",
|
|
4494
5490
|
}),
|
|
4495
5491
|
);
|
|
4496
5492
|
});
|
|
@@ -4576,7 +5572,7 @@ test("notifications menu escapes pushover text and locks pushover enablement unt
|
|
|
4576
5572
|
const renderedNotificationsMenu = (ctx.__state.customRenderLines[2] ?? []).join("\n");
|
|
4577
5573
|
assert.match(renderedNotificationsMenu, /first line\\nsecond line/);
|
|
4578
5574
|
assert.match(renderedNotificationsMenu, /<dim>Enable pushover/);
|
|
4579
|
-
assert.match(renderedNotificationsMenu, /<dim>
|
|
5575
|
+
assert.match(renderedNotificationsMenu, /<dim>configure user\/token keys first<\/dim>/);
|
|
4580
5576
|
});
|
|
4581
5577
|
|
|
4582
5578
|
test("prompt-end pushover requests honor global enable, event toggles, credentials, priority, and templates", async () => {
|
|
@@ -5039,9 +6035,11 @@ test("sound routing honors the selected sound state and per-event toggles", asyn
|
|
|
5039
6035
|
}
|
|
5040
6036
|
});
|
|
5041
6037
|
|
|
5042
|
-
test("sound toggle shortcut cycles
|
|
6038
|
+
test("sound toggle shortcut cycles active runtime pi-notify sound levels without persisting boot config", async () => {
|
|
5043
6039
|
const cwd = createTempDir("pi-usereq-shortcut-notify-");
|
|
5044
6040
|
fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
|
|
6041
|
+
const persistedConfigText = `${JSON.stringify(getDefaultConfig(cwd), null, 2)}\n`;
|
|
6042
|
+
fs.writeFileSync(getProjectConfigPath(cwd), persistedConfigText, "utf8");
|
|
5045
6043
|
const previousCwd = process.cwd();
|
|
5046
6044
|
const pi = createFakePi();
|
|
5047
6045
|
process.chdir(cwd);
|
|
@@ -5058,8 +6056,7 @@ test("sound toggle shortcut cycles persisted pi-notify sound levels", async () =
|
|
|
5058
6056
|
|
|
5059
6057
|
for (const expectedSound of ["low", "mid", "high", "none"]) {
|
|
5060
6058
|
await shortcut!.handler(ctx);
|
|
5061
|
-
|
|
5062
|
-
assert.equal(config["notify-sound"], expectedSound);
|
|
6059
|
+
assert.equal(fs.readFileSync(getProjectConfigPath(cwd), "utf8"), persistedConfigText);
|
|
5063
6060
|
assert.equal(
|
|
5064
6061
|
ctx.__state.statuses.get("pi-usereq"),
|
|
5065
6062
|
buildExpectedFakeStatusText({
|
|
@@ -5075,6 +6072,42 @@ test("sound toggle shortcut cycles persisted pi-notify sound levels", async () =
|
|
|
5075
6072
|
}
|
|
5076
6073
|
});
|
|
5077
6074
|
|
|
6075
|
+
test("session_start loads the active runtime sound from persisted boot config", async () => {
|
|
6076
|
+
const cwd = createTempDir("pi-usereq-session-start-sound-");
|
|
6077
|
+
fs.mkdirSync(path.dirname(getProjectConfigPath(cwd)), { recursive: true });
|
|
6078
|
+
const persistedConfig = getDefaultConfig(cwd);
|
|
6079
|
+
persistedConfig["notify-sound"] = "mid";
|
|
6080
|
+
fs.writeFileSync(
|
|
6081
|
+
getProjectConfigPath(cwd),
|
|
6082
|
+
`${JSON.stringify(persistedConfig, null, 2)}\n`,
|
|
6083
|
+
"utf8",
|
|
6084
|
+
);
|
|
6085
|
+
const previousCwd = process.cwd();
|
|
6086
|
+
const pi = createFakePi();
|
|
6087
|
+
process.chdir(cwd);
|
|
6088
|
+
try {
|
|
6089
|
+
piUsereqExtension(pi);
|
|
6090
|
+
} finally {
|
|
6091
|
+
process.chdir(previousCwd);
|
|
6092
|
+
}
|
|
6093
|
+
|
|
6094
|
+
const ctx = createFakeCtx(cwd);
|
|
6095
|
+
await pi.emit("session_start", { reason: "startup" }, ctx);
|
|
6096
|
+
|
|
6097
|
+
assert.equal(
|
|
6098
|
+
ctx.__state.statuses.get("pi-usereq"),
|
|
6099
|
+
buildExpectedFakeStatusText({
|
|
6100
|
+
basePath: buildExpectedFakeBasePath(cwd),
|
|
6101
|
+
docsDir: DEFAULT_DOCS_DIR,
|
|
6102
|
+
testsDir: "tests",
|
|
6103
|
+
srcDir: ["src"],
|
|
6104
|
+
contextFilledCells: 0,
|
|
6105
|
+
et: "⏱︎ --:-- ⚑ --:-- ⌛︎--:--",
|
|
6106
|
+
sound: "mid",
|
|
6107
|
+
}),
|
|
6108
|
+
);
|
|
6109
|
+
});
|
|
6110
|
+
|
|
5078
6111
|
test("static-check exposes the supported programming languages", () => {
|
|
5079
6112
|
const supported = getSupportedStaticCheckLanguageSupport();
|
|
5080
6113
|
assert.equal(supported.length, 20);
|