pi-usereq 0.39.0 → 0.41.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.
@@ -3780,6 +3780,296 @@ test("worktree-backed prompt commands use replacement-session callbacks for prom
3780
3780
  }
3781
3781
  });
3782
3782
 
3783
+ /**
3784
+ * @brief Verifies worktree-backed prompt closure still merges and deletes the worktree when prompt delivery uses the production `sendMessage` custom-message path.
3785
+ * @details Simulates the pi runtime `sendMessage(message, { triggerTurn: true })` contract documented for `ReplacedSessionContext`: a `triggerTurn` custom message runs the agent loop which emits `agent_start` then `agent_end` (without a separate `before_agent_start`), and the `sendMessage` promise resolves only after the agent turn completes. The test proves that when the replacement session exposes `sendMessage`, prompt delivery must still drive the prompt-end closure so the worktree branch is merged into `base-path`, the worktree plus branch are deleted, and the workflow state returns to `idle`. Runtime is dominated by temporary git worktree setup and teardown. Side effects are limited to temporary repository mutation and temporary session-file writes.
3786
+ * @return {Promise<void>} Promise resolved after `sendMessage`-driven prompt delivery, merge handling, and restored-base assertions complete.
3787
+ * @throws {AssertionError} Throws when `sendMessage`-driven prompt delivery leaves the worktree unmerged, the worktree or branch retained, or the workflow state not `idle`.
3788
+ * @satisfies REQ-068, REQ-208, REQ-228, REQ-230, REQ-258, REQ-282, DES-016
3789
+ */
3790
+ test("worktree-backed prompt commands merge and delete the worktree when prompt delivery uses sendMessage triggerTurn", async () => {
3791
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3792
+ const previousCwd = process.cwd();
3793
+ try {
3794
+ const pi = createFakePi();
3795
+ piUsereqExtension(pi);
3796
+ const ctx = createFakeCtx(projectBase);
3797
+ await pi.emit("session_start", { reason: "startup" }, ctx);
3798
+
3799
+ const originalSessionFile = ctx.sessionManager.getSessionFile();
3800
+ const originalSwitchSession = ctx.switchSession.bind(ctx);
3801
+ const sentMessages: Array<{ message: unknown; options?: unknown }> = [];
3802
+ let activeSessionCtx: any = ctx;
3803
+ ctx.switchSession = async (
3804
+ sessionPath: string,
3805
+ options?: { withSession?: (replacementCtx: any) => Promise<void> },
3806
+ ) => {
3807
+ const result = await originalSwitchSession(sessionPath);
3808
+ const replacementCwd = readFakeSessionFileCwd(sessionPath, projectBase);
3809
+ const replacementCtx = {
3810
+ ...ctx,
3811
+ cwd: replacementCwd,
3812
+ sessionManager: {
3813
+ ...ctx.sessionManager,
3814
+ getCwd: () => replacementCwd,
3815
+ getSessionFile: () => sessionPath,
3816
+ },
3817
+ async sendUserMessage(content: unknown, sendOptions?: unknown) {
3818
+ pi.sentUserMessages.push({ content, options: sendOptions });
3819
+ },
3820
+ async sendMessage(message: unknown, sendOptions?: unknown) {
3821
+ sentMessages.push({ message, options: sendOptions });
3822
+ if ((sendOptions as { triggerTurn?: boolean } | undefined)?.triggerTurn === true) {
3823
+ await Promise.resolve();
3824
+ await pi.emit("agent_start", {}, activeSessionCtx);
3825
+ await pi.emit("agent_end", {
3826
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
3827
+ }, activeSessionCtx);
3828
+ }
3829
+ },
3830
+ };
3831
+ activeSessionCtx = replacementCtx;
3832
+ if (options?.withSession) {
3833
+ process.chdir(replacementCwd);
3834
+ await options.withSession(replacementCtx);
3835
+ ctx.cwd = projectBase;
3836
+ ctx.sessionManager.getSessionFile = () => originalSessionFile;
3837
+ ctx.sessionManager.getCwd = () => projectBase;
3838
+ return result;
3839
+ }
3840
+ ctx.cwd = projectBase;
3841
+ ctx.sessionManager.getSessionFile = () => originalSessionFile;
3842
+ ctx.sessionManager.getCwd = () => projectBase;
3843
+ process.chdir(projectBase);
3844
+ return result;
3845
+ };
3846
+
3847
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
3848
+
3849
+ assert.ok(
3850
+ sentMessages.some((entry) => (entry.options as { triggerTurn?: boolean } | undefined)?.triggerTurn === true),
3851
+ "prompt delivery must use sendMessage with triggerTurn",
3852
+ );
3853
+ const promptMessage = sentMessages.find((entry) =>
3854
+ typeof (entry.message as { content?: unknown } | undefined)?.content === "string"
3855
+ && String((entry.message as { content?: unknown }).content).includes("created worktree-dir"));
3856
+ const promptText = String((promptMessage?.message as { content?: string } | undefined)?.content ?? "");
3857
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
3858
+ assert.ok(worktreeMatch, promptText);
3859
+ const worktreeName = worktreeMatch?.[1] ?? "";
3860
+ const executionBasePath = worktreeMatch?.[2] ?? "";
3861
+
3862
+ assert.equal(process.cwd(), projectBase);
3863
+ assert.equal(fs.existsSync(executionBasePath), false, "worktree directory must be deleted after successful closure");
3864
+ assert.notEqual(
3865
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
3866
+ cwd: projectBase,
3867
+ encoding: "utf8",
3868
+ }).status,
3869
+ 0,
3870
+ "worktree branch must be deleted after successful closure",
3871
+ );
3872
+ const logResult = spawnSync("git", ["log", "--oneline", "-1"], { cwd: projectBase, encoding: "utf8" });
3873
+ assert.match(logResult.stdout, /init/, "base-path must remain on the original head after a no-commit ff-only merge");
3874
+ } finally {
3875
+ process.chdir(previousCwd);
3876
+ fs.rmSync(projectBase, { recursive: true, force: true });
3877
+ }
3878
+ });
3879
+
3880
+ /**
3881
+ * @brief Verifies a rejecting on-screen summary delivery never surfaces as an unhandled rejection and never interrupts the worktree merge.
3882
+ * @details Simulates the pi runtime `sendMessage` contract where the non-critical `display:true` summary delivery rejects (for example because the session is torn down mid-emit during prompt-end closure) while the authoritative `display:false` hidden prompt message with `triggerTurn:true` still drives the agent turn. The test proves `deliverPromptCommand` must await and suppress summary delivery failures so the rejection cannot terminate the process (Node default `--unhandled-rejections=throw`) before prompt-end closure runs the worktree merge, branch deletion, and worktree deletion. Runtime is dominated by temporary git worktree setup and teardown. Side effects are limited to temporary repository mutation and temporary session-file writes.
3883
+ * @return {Promise<void>} Promise resolved after summary-rejection-tolerant prompt delivery, merge handling, and restored-base assertions complete.
3884
+ * @throws {AssertionError} Throws when a rejecting summary surfaces as an unhandled rejection or leaves the worktree unmerged.
3885
+ * @satisfies REQ-068, REQ-208, REQ-228, REQ-230, REQ-258, REQ-282, REQ-334, DES-016
3886
+ */
3887
+ test("worktree-backed prompt commands tolerate a rejecting summary sendMessage without interrupting the merge", async () => {
3888
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3889
+ const previousCwd = process.cwd();
3890
+ let unhandledRejections: unknown[] = [];
3891
+ const rejectionHandler = (reason: unknown): void => { unhandledRejections.push(reason); };
3892
+ process.on("unhandledRejection", rejectionHandler);
3893
+ try {
3894
+ const pi = createFakePi();
3895
+ piUsereqExtension(pi);
3896
+ const ctx = createFakeCtx(projectBase);
3897
+ await pi.emit("session_start", { reason: "startup" }, ctx);
3898
+
3899
+ const originalSessionFile = ctx.sessionManager.getSessionFile();
3900
+ const originalSwitchSession = ctx.switchSession.bind(ctx);
3901
+ const sentMessages: Array<{ message: unknown; options?: unknown }> = [];
3902
+ let activeSessionCtx: any = ctx;
3903
+ ctx.switchSession = async (
3904
+ sessionPath: string,
3905
+ options?: { withSession?: (replacementCtx: any) => Promise<void> },
3906
+ ) => {
3907
+ const result = await originalSwitchSession(sessionPath);
3908
+ const replacementCwd = readFakeSessionFileCwd(sessionPath, projectBase);
3909
+ const replacementCtx = {
3910
+ ...ctx,
3911
+ cwd: replacementCwd,
3912
+ sessionManager: {
3913
+ ...ctx.sessionManager,
3914
+ getCwd: () => replacementCwd,
3915
+ getSessionFile: () => sessionPath,
3916
+ },
3917
+ async sendUserMessage(content: unknown, sendOptions?: unknown) {
3918
+ pi.sentUserMessages.push({ content, options: sendOptions });
3919
+ },
3920
+ async sendMessage(message: unknown, sendOptions?: unknown) {
3921
+ const isTrigger = (sendOptions as { triggerTurn?: boolean } | undefined)?.triggerTurn === true;
3922
+ sentMessages.push({ message, options: sendOptions });
3923
+ if (!isTrigger) {
3924
+ throw new Error("summary sendMessage rejected");
3925
+ }
3926
+ await Promise.resolve();
3927
+ await pi.emit("agent_start", {}, activeSessionCtx);
3928
+ await pi.emit("agent_end", {
3929
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
3930
+ }, activeSessionCtx);
3931
+ },
3932
+ };
3933
+ activeSessionCtx = replacementCtx;
3934
+ if (options?.withSession) {
3935
+ process.chdir(replacementCwd);
3936
+ await options.withSession(replacementCtx);
3937
+ ctx.cwd = projectBase;
3938
+ ctx.sessionManager.getSessionFile = () => originalSessionFile;
3939
+ ctx.sessionManager.getCwd = () => projectBase;
3940
+ return result;
3941
+ }
3942
+ ctx.cwd = projectBase;
3943
+ ctx.sessionManager.getSessionFile = () => originalSessionFile;
3944
+ ctx.sessionManager.getCwd = () => projectBase;
3945
+ process.chdir(projectBase);
3946
+ return result;
3947
+ };
3948
+
3949
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
3950
+ await new Promise((resolve) => setImmediate(resolve));
3951
+
3952
+ assert.ok(
3953
+ sentMessages.some((entry) => (entry.options as { triggerTurn?: boolean } | undefined)?.triggerTurn === true),
3954
+ "prompt delivery must still dispatch the hidden prompt message with triggerTurn",
3955
+ );
3956
+ assert.equal(process.cwd(), projectBase, "base-path must be restored after successful closure");
3957
+ assert.equal(unhandledRejections.length, 0, "rejecting summary delivery must not surface as an unhandled rejection");
3958
+ const promptMessage = sentMessages.find((entry) =>
3959
+ typeof (entry.message as { content?: unknown } | undefined)?.content === "string"
3960
+ && String((entry.message as { content?: unknown }).content).includes("created worktree-dir"));
3961
+ const promptText = String((promptMessage?.message as { content?: string } | undefined)?.content ?? "");
3962
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
3963
+ const executionBasePath = worktreeMatch?.[2] ?? "";
3964
+ assert.ok(worktreeMatch, promptText);
3965
+ assert.equal(fs.existsSync(executionBasePath), false, "worktree directory must be deleted after successful closure");
3966
+ } finally {
3967
+ process.off("unhandledRejection", rejectionHandler);
3968
+ process.chdir(previousCwd);
3969
+ fs.rmSync(projectBase, { recursive: true, force: true });
3970
+ }
3971
+ });
3972
+
3973
+ /**
3974
+ * @brief Verifies worktree-backed prompt closure still merges and deletes the worktree when prompt delivery falls back to `pi.sendMessage` because the replacement session omits `sendMessage`.
3975
+ * @details Simulates the pi runtime where `ctx.switchSession(...)` exposes a replacement context without `sendMessage` while the top-level `pi.sendMessage` is available as a fire-and-forget custom-message dispatcher. The test proves that even in this fallback path, prompt delivery must drive the prompt-end closure so the worktree branch is merged into `base-path`, the worktree plus branch are deleted, and the workflow state returns to `idle`. Runtime is dominated by temporary git worktree setup and teardown. Side effects are limited to temporary repository mutation and temporary session-file writes.
3976
+ * @return {Promise<void>} Promise resolved after `sendMessage`-fallback prompt delivery, merge handling, and restored-base assertions complete.
3977
+ * @throws {AssertionError} Throws when `pi.sendMessage`-fallback prompt delivery leaves the worktree unmerged, the worktree or branch retained, or the workflow state not `idle`.
3978
+ * @satisfies REQ-068, REQ-208, REQ-228, REQ-230, REQ-258, REQ-282, DES-016
3979
+ */
3980
+ test("worktree-backed prompt commands merge and delete the worktree when prompt delivery falls back to pi.sendMessage", async () => {
3981
+ const { projectBase } = initFixtureRepo({ fixtures: [] });
3982
+ const previousCwd = process.cwd();
3983
+ try {
3984
+ const pi = createFakePi();
3985
+ const sentMessages: Array<{ message: unknown; options?: unknown }> = [];
3986
+ (pi as any).sendMessage = (message: unknown, options?: unknown) => {
3987
+ sentMessages.push({ message, options });
3988
+ // Simulate the real SDK fire-and-forget dispatcher: trigger the agent turn asynchronously.
3989
+ if ((options as { triggerTurn?: boolean } | undefined)?.triggerTurn === true) {
3990
+ Promise.resolve().then(async () => {
3991
+ await pi.emit("agent_start", {}, activeSessionCtx);
3992
+ await pi.emit("agent_end", {
3993
+ messages: [{ role: "assistant", stopReason: "stop", content: [] }],
3994
+ }, activeSessionCtx);
3995
+ });
3996
+ }
3997
+ };
3998
+ piUsereqExtension(pi);
3999
+ const ctx = createFakeCtx(projectBase);
4000
+ await pi.emit("session_start", { reason: "startup" }, ctx);
4001
+
4002
+ const originalSessionFile = ctx.sessionManager.getSessionFile();
4003
+ const originalSwitchSession = ctx.switchSession.bind(ctx);
4004
+ let activeSessionCtx: any = ctx;
4005
+ ctx.switchSession = async (
4006
+ sessionPath: string,
4007
+ options?: { withSession?: (replacementCtx: any) => Promise<void> },
4008
+ ) => {
4009
+ const result = await originalSwitchSession(sessionPath);
4010
+ const replacementCwd = readFakeSessionFileCwd(sessionPath, projectBase);
4011
+ const replacementCtx = {
4012
+ ...ctx,
4013
+ cwd: replacementCwd,
4014
+ sessionManager: {
4015
+ ...ctx.sessionManager,
4016
+ getCwd: () => replacementCwd,
4017
+ getSessionFile: () => sessionPath,
4018
+ },
4019
+ async sendUserMessage(content: unknown, sendOptions?: unknown) {
4020
+ pi.sentUserMessages.push({ content, options: sendOptions });
4021
+ },
4022
+ };
4023
+ activeSessionCtx = replacementCtx;
4024
+ if (options?.withSession) {
4025
+ process.chdir(replacementCwd);
4026
+ await options.withSession(replacementCtx);
4027
+ ctx.cwd = projectBase;
4028
+ ctx.sessionManager.getSessionFile = () => originalSessionFile;
4029
+ ctx.sessionManager.getCwd = () => projectBase;
4030
+ return result;
4031
+ }
4032
+ ctx.cwd = projectBase;
4033
+ ctx.sessionManager.getSessionFile = () => originalSessionFile;
4034
+ ctx.sessionManager.getCwd = () => projectBase;
4035
+ process.chdir(projectBase);
4036
+ return result;
4037
+ };
4038
+
4039
+ await pi.commands.get("req-change")!.handler("Adjust docs", ctx);
4040
+ // The real SDK `pi.sendMessage` dispatcher is fire-and-forget; let the simulated agent turn drain.
4041
+ await new Promise((resolve) => setImmediate(resolve));
4042
+ await new Promise((resolve) => setImmediate(resolve));
4043
+
4044
+ assert.ok(
4045
+ sentMessages.some((entry) => (entry.options as { triggerTurn?: boolean } | undefined)?.triggerTurn === true),
4046
+ "prompt delivery must use pi.sendMessage with triggerTurn",
4047
+ );
4048
+ const promptMessage = sentMessages.find((entry) =>
4049
+ typeof (entry.message as { content?: unknown } | undefined)?.content === "string"
4050
+ && String((entry.message as { content?: unknown }).content).includes("created worktree-dir"));
4051
+ const promptText = String((promptMessage?.message as { content?: string } | undefined)?.content ?? "");
4052
+ const worktreeMatch = promptText.match(/created worktree-dir `([^`]+)` and prepared context-path `([^`]+)`\./);
4053
+ assert.ok(worktreeMatch, promptText);
4054
+ const worktreeName = worktreeMatch?.[1] ?? "";
4055
+ const executionBasePath = worktreeMatch?.[2] ?? "";
4056
+
4057
+ assert.equal(process.cwd(), projectBase);
4058
+ assert.equal(fs.existsSync(executionBasePath), false, "worktree directory must be deleted after successful closure");
4059
+ assert.notEqual(
4060
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${worktreeName}`], {
4061
+ cwd: projectBase,
4062
+ encoding: "utf8",
4063
+ }).status,
4064
+ 0,
4065
+ "worktree branch must be deleted after successful closure",
4066
+ );
4067
+ } finally {
4068
+ process.chdir(previousCwd);
4069
+ fs.rmSync(projectBase, { recursive: true, force: true });
4070
+ }
4071
+ });
4072
+
3783
4073
  /**
3784
4074
  * @brief Verifies worktree-backed prompt orchestration against the pi runtime session-switch contract.
3785
4075
  * @details Simulates the current pi runtime behavior discovered in `/tmp/pi-mono`: `ctx.switchSession(...)` exposes the replacement session through `withSession(...)` and replacement-session contexts while the host process cwd stays anchored to the original project directory until the extension updates it explicitly. The test proves prompt delivery and prompt-end closure must synchronize `process.cwd()` with the active worktree session during execution and restore `base-path` plus remove the worktree after successful merge even when the pi host leaves cwd unchanged. Runtime is dominated by temporary git worktree setup and teardown. Side effects are limited to temporary repository mutation and temporary session-file writes.
@@ -0,0 +1,36 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ DEFAULT_STATIC_CHECK_LANGUAGES,
5
+ getDefaultConfig,
6
+ } from "../src/core/config.js";
7
+ import { renderPromptCommandSummary } from "../src/core/prompts.js";
8
+
9
+ /**
10
+ * @brief Builds a configuration with no enabled context files, no enabled static-check languages, and no enabled tools.
11
+ * @details Clones the documented default configuration, disables every context-file flag, forces every canonical static-check language to `disable`, and empties the enabled-tool list so the command invocation summary is forced to render the `none` placeholder for all three empty-category fields. Runtime is O(l) in supported language count. No external state is mutated.
12
+ * @param[in] projectBase {string} Absolute project root path used to seed the default configuration.
13
+ * @return {ReturnType<typeof getDefaultConfig>} Configuration object with all three summary categories empty.
14
+ */
15
+ function buildEmptySummaryConfig(projectBase: string): ReturnType<typeof getDefaultConfig> {
16
+ const config = getDefaultConfig(projectBase);
17
+ config["context-files-requirements"] = false;
18
+ config["context-files-references"] = false;
19
+ config["context-files-workflow"] = false;
20
+ for (const language of DEFAULT_STATIC_CHECK_LANGUAGES) {
21
+ config["static-check"][language] = { ...config["static-check"][language], enabled: "disable" };
22
+ }
23
+ config["enabled-tools"] = [];
24
+ return config;
25
+ }
26
+
27
+ test("renderPromptCommandSummary renders none for empty context files, static code checks, and enabled tools", () => {
28
+ const projectBase = process.cwd();
29
+ const config = buildEmptySummaryConfig(projectBase);
30
+ const summary = renderPromptCommandSummary("analyze", "Inspect runtime behavior", config);
31
+ assert.match(summary, /Command: ANALYZE/);
32
+ assert.match(summary, /User's Request: Inspect runtime behavior/);
33
+ assert.match(summary, /- context files: none/);
34
+ assert.match(summary, /- static code checks: none/);
35
+ assert.match(summary, /- enabled tools: none/);
36
+ });