gentle-pi 3.3.0 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -59
- package/assets/orchestrator-delegation.md +1 -1
- package/bin/gentle-shell.mjs +198 -0
- package/docs/assets/brand/gentle-shell-banner.gif +0 -0
- package/docs/assets/diagrams/odd-workflow.svg +74 -0
- package/docs/assets/features/agents-view.png +0 -0
- package/docs/assets/features/changes-view.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/assets/features/profiles-routing.png +0 -0
- package/docs/gentle-agents-activity.md +95 -0
- package/docs/gentle-shell.md +26 -2
- package/docs/readme-reference.md +99 -6
- package/extensions/ask-user-choice.ts +70 -22
- package/extensions/ask-user-question.ts +338 -0
- package/extensions/gentle-agents.ts +41 -1
- package/extensions/gentle-ai.ts +59 -18
- package/extensions/gentle-shell.ts +99 -10
- package/extensions/quiet-tools.ts +28 -5
- package/extensions/startup-banner.ts +25 -10
- package/lib/agents-rpc-publisher.ts +342 -0
- package/lib/agents-runner.ts +7 -2
- package/lib/animation-policy.ts +52 -0
- package/lib/background-cache-warming.ts +38 -0
- package/lib/command-palette-catalog.ts +1 -0
- package/lib/gentle-shell-launcher.ts +482 -0
- package/lib/inprocess-reviewer.ts +38 -1
- package/lib/native-review-cli.ts +36 -10
- package/lib/questionnaire/questionnaire-view.ts +603 -0
- package/lib/questionnaire/schema.ts +82 -0
- package/lib/questionnaire/validate.ts +141 -0
- package/lib/review-candidate-view-owner.ts +20 -5
- package/lib/review-candidate-view.ts +9 -2
- package/lib/review-host-relay.ts +10 -0
- package/lib/review-integration-v2.ts +4 -1
- package/lib/rpc-host.ts +36 -0
- package/lib/shell-bar.ts +13 -0
- package/lib/shell-sidebar-layout.ts +10 -4
- package/lib/shell-usage-view.ts +5 -2
- package/lib/shell-usage.ts +120 -6
- package/package.json +5 -1
- package/runtime/gentle-shell-launcher.mjs +483 -0
- package/runtime/native-review-cli.mjs +35 -9
- package/runtime/review-integration-v2.mjs +4 -1
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +14 -7
- package/scripts/install-tui-mode-setting.mjs +78 -1
- package/scripts/verify-package-files.mjs +6 -3
- package/tests/agents-rpc-publisher.test.ts +407 -0
- package/tests/agents-runner.test.ts +10 -0
- package/tests/animation-policy.test.ts +42 -0
- package/tests/ask-user-choice.test.ts +129 -0
- package/tests/ask-user-question.test.ts +661 -0
- package/tests/background-cache-warming.test.ts +60 -0
- package/tests/background-subagents.test.ts +68 -0
- package/tests/command-palette.test.ts +9 -0
- package/tests/gentle-agents.test.ts +161 -2
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +56 -4
- package/tests/gentle-shell-bin.test.ts +188 -0
- package/tests/gentle-shell-launcher.test.ts +718 -0
- package/tests/gentle-shell.test.ts +355 -2
- package/tests/inprocess-reviewer.test.ts +92 -0
- package/tests/install-tui-mode-guard.test.ts +99 -0
- package/tests/install-tui-mode-setting.test.ts +39 -1
- package/tests/native-review-capability-contract.test.ts +16 -1
- package/tests/native-review-parity.test.ts +19 -0
- package/tests/package-manifest.test.ts +6 -17
- package/tests/questionnaire-schema.test.ts +274 -0
- package/tests/questionnaire-view.test.ts +446 -0
- package/tests/rdd-status-line.test.ts +21 -4
- package/tests/review-candidate-owner-retry.test.ts +63 -0
- package/tests/review-candidate-view.test.ts +15 -0
- package/tests/review-controller-native-routing.test.ts +86 -0
- package/tests/review-host-relay.test.ts +21 -0
- package/tests/review-integration-v2.test.ts +30 -0
- package/tests/review-ledger-contract.test.ts +1 -2
- package/tests/review-relay-transport-agent.test.ts +107 -2
- package/tests/review-risk-assessment.test.ts +104 -0
- package/tests/rpc-host.test.ts +77 -0
- package/tests/shell-bar.test.ts +8 -0
- package/tests/shell-sidebar-layout.test.ts +60 -5
- package/tests/shell-usage.test.ts +129 -0
- package/tests/skill-collision-prefixes.test.ts +1 -1
- package/tests/startup-banner.test.ts +93 -2
- package/docs/assets/brand/gentle-pi-banner.png +0 -0
- package/skills/release/SKILL.md +0 -137
|
@@ -556,6 +556,27 @@ test("the relay forwards the caller-owned selection and thinking level to the co
|
|
|
556
556
|
assert.equal(calls[0]!.routingKey, "review-risk");
|
|
557
557
|
});
|
|
558
558
|
|
|
559
|
+
// The in-process reviewer completion is an extension side-call that bypasses
|
|
560
|
+
// pi's main agent loop, so the relay must forward the caller's live session id
|
|
561
|
+
// into the completion request; the completion itself turns it into the
|
|
562
|
+
// OpenCode attribution headers (lib/inprocess-reviewer.ts). A absent session
|
|
563
|
+
// id forwards nothing and is never an error.
|
|
564
|
+
test("the relay forwards the caller's reviewer session id into the completion request", async (t) => {
|
|
565
|
+
const fixture = harness(t);
|
|
566
|
+
const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
|
|
567
|
+
await runRelay(fixture, { reviewerSessionId: "ses-live-1" }, runReviewer);
|
|
568
|
+
assert.equal(calls.length, 1);
|
|
569
|
+
assert.equal(calls[0]!.sessionId, "ses-live-1");
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("a relay request with no reviewer session id adds no session id to the completion request", async (t) => {
|
|
573
|
+
const fixture = harness(t);
|
|
574
|
+
const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
|
|
575
|
+
await runRelay(fixture, {}, runReviewer);
|
|
576
|
+
assert.equal(calls.length, 1);
|
|
577
|
+
assert.equal(calls[0]!.sessionId, undefined);
|
|
578
|
+
});
|
|
579
|
+
|
|
559
580
|
test("a missing model registry is refused typed before materialize ever runs", async (t) => {
|
|
560
581
|
const fixture = harness(t);
|
|
561
582
|
const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
decodeReviewProjectionV1,
|
|
15
15
|
decodeReviewRepairV2,
|
|
16
16
|
decodeReviewStartV3,
|
|
17
|
+
decodeReviewStartV4,
|
|
17
18
|
decodeReviewStatusV3,
|
|
18
19
|
} from "../lib/review-integration-v2.ts";
|
|
19
20
|
|
|
@@ -349,6 +350,35 @@ test("capabilities gates and projections still enforce the required floor", () =
|
|
|
349
350
|
assert.throws(() => decode(missingProjection), /projections/);
|
|
350
351
|
});
|
|
351
352
|
|
|
353
|
+
test("START/v4 preserves provider-generated manifest evidence while retaining exact omitempty validation", () => {
|
|
354
|
+
const source = fixture<JsonObject>("start.fixture.json");
|
|
355
|
+
source.schema = "gentle-ai.review-integration.start/v4";
|
|
356
|
+
source.action = "closed";
|
|
357
|
+
source.lenses_required = false;
|
|
358
|
+
source.state = "approved";
|
|
359
|
+
source.selected_lenses = [];
|
|
360
|
+
delete source.repository_context;
|
|
361
|
+
const entry = (source.changed_path_manifest as JsonObject[])[0];
|
|
362
|
+
entry.generated = true;
|
|
363
|
+
|
|
364
|
+
const decoded = decodeReviewStartV4(source);
|
|
365
|
+
assert.equal(decoded.changedPathManifest?.[0]?.generated, true);
|
|
366
|
+
|
|
367
|
+
const omitted = clone(source);
|
|
368
|
+
delete (omitted.changed_path_manifest as JsonObject[])[0].generated;
|
|
369
|
+
assert.doesNotThrow(() => decodeReviewStartV4(omitted));
|
|
370
|
+
|
|
371
|
+
for (const generated of [false, "true"] as const) {
|
|
372
|
+
const invalid = clone(source);
|
|
373
|
+
((invalid.changed_path_manifest as JsonObject[])[0]).generated = generated;
|
|
374
|
+
assert.throws(() => decodeReviewStartV4(invalid), /generated/);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const unknown = clone(source);
|
|
378
|
+
((unknown.changed_path_manifest as JsonObject[])[0]).unrelated = true;
|
|
379
|
+
assert.throws(() => decodeReviewStartV4(unknown), /not allowed/);
|
|
380
|
+
});
|
|
381
|
+
|
|
352
382
|
test("START independently binds base/candidate tree and the target-mode overlay pair", () => {
|
|
353
383
|
const source = fixture<JsonObject>("start.fixture.json");
|
|
354
384
|
const decoded = decodeReviewStartV3(source);
|
|
@@ -22,7 +22,6 @@ const README = "README.md";
|
|
|
22
22
|
const TECHNICAL_REFERENCE = "docs/readme-reference.md";
|
|
23
23
|
const CHAIN = "assets/chains/4r-review.chain.md";
|
|
24
24
|
const SDD_WORKFLOW = "assets/sdd-orchestrator-workflow.md";
|
|
25
|
-
const RELEASE_SKILL = "skills/release/SKILL.md";
|
|
26
25
|
const WORKER = "assets/agents/gentle-ai-worker.md";
|
|
27
26
|
const CANONICAL_LIFECYCLE_SPECS = [
|
|
28
27
|
"openspec/specs/review-orchestration/spec.md",
|
|
@@ -400,7 +399,7 @@ test("technical reference documents the dynamic runtime authority boundary witho
|
|
|
400
399
|
});
|
|
401
400
|
|
|
402
401
|
test("managed contracts retain no fresh lifecycle review directive", () => {
|
|
403
|
-
const managed = union([...ORCHESTRATOR, SDD_WORKFLOW,
|
|
402
|
+
const managed = union([...ORCHESTRATOR, SDD_WORKFLOW, WORKER, GENTLE_SKILL, README]);
|
|
404
403
|
for (const obsolete of [
|
|
405
404
|
"A fresh review still follows delegated implementation.",
|
|
406
405
|
"run a fresh-context review lens unless",
|
|
@@ -8,7 +8,7 @@ import { __testing } from "../extensions/gentle-ai.ts";
|
|
|
8
8
|
import { REVIEW_HOST_RELAY_FAILURE, ReviewHostRelayError } from "../lib/review-host-relay.ts";
|
|
9
9
|
import { NativeReviewIntegrationError, type NativeReviewCli } from "../lib/native-review-cli.ts";
|
|
10
10
|
import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
|
|
11
|
-
import type { ReviewCollectInputV3, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
|
|
11
|
+
import type { ReviewArtifactSubjectV2, ReviewCollectInputV3, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
|
|
12
12
|
import type { ReviewHostRelayRequest } from "../lib/review-host-relay.ts";
|
|
13
13
|
|
|
14
14
|
// Third field failure on the recovered-lineage defect (2026-08-16, gentle-pi
|
|
@@ -149,10 +149,11 @@ function transportAwareNative(options: { refusalCode?: string } = {}): {
|
|
|
149
149
|
return { native, agents };
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
async function runCapture(cwd: string, native: NativeReviewCli, lineageId: string, input: Record<string, unknown> = { reviewerRunAcknowledged: true }): Promise<Record<string, unknown>> {
|
|
152
|
+
async function runCapture(cwd: string, native: NativeReviewCli, lineageId: string, input: Record<string, unknown> = { reviewerRunAcknowledged: true }, reviewerSessionId?: string): Promise<Record<string, unknown>> {
|
|
153
153
|
return await __testing.executeReviewCaptureOperation(
|
|
154
154
|
{ lineageId, collectBinding: JSON.stringify(collectInput(lineageId, true)), ...input },
|
|
155
155
|
cwd, native, undefined, new CandidateViewRegistry(),
|
|
156
|
+
undefined, undefined, undefined, reviewerSessionId,
|
|
156
157
|
) as Record<string, unknown>;
|
|
157
158
|
}
|
|
158
159
|
|
|
@@ -217,6 +218,110 @@ test("capture forwards the lens's user-owned selection and thinking level to the
|
|
|
217
218
|
assert.equal(relayed[0]!.routingKey, "review-reliability");
|
|
218
219
|
});
|
|
219
220
|
|
|
221
|
+
// The live session id threads from the extension context through both
|
|
222
|
+
// request builders, so an OpenCode-routed reviewer model carries its
|
|
223
|
+
// attribution headers (a side-call bypasses pi's main agent loop, where pi
|
|
224
|
+
// adds them itself). A absent session id adds nothing and is never an error.
|
|
225
|
+
test("capture threads the caller's live session id into the relay request", async (t) => {
|
|
226
|
+
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
227
|
+
const cwd = repository(t);
|
|
228
|
+
const { native } = transportAwareNative();
|
|
229
|
+
const relayed: ReviewHostRelayRequest[] = [];
|
|
230
|
+
__testing.setReviewHostRelayRunnerForTesting(async (request: ReviewHostRelayRequest) => {
|
|
231
|
+
relayed.push(request);
|
|
232
|
+
return { promptByteLength: 128, resultByteLength: 64, submission: '{"admission_decision":"completed"}' };
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
await runCapture(cwd, native, "session-lineage", { reviewerRunAcknowledged: true }, "ses-live-1");
|
|
236
|
+
assert.equal(relayed.length, 1);
|
|
237
|
+
assert.equal(relayed[0]!.reviewerSessionId, "ses-live-1");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("capture with no live session id adds no reviewer session id to the relay request", async (t) => {
|
|
241
|
+
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
242
|
+
const cwd = repository(t);
|
|
243
|
+
const { native } = transportAwareNative();
|
|
244
|
+
const relayed: ReviewHostRelayRequest[] = [];
|
|
245
|
+
__testing.setReviewHostRelayRunnerForTesting(async (request: ReviewHostRelayRequest) => {
|
|
246
|
+
relayed.push(request);
|
|
247
|
+
return { promptByteLength: 128, resultByteLength: 64, submission: '{"admission_decision":"completed"}' };
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
await runCapture(cwd, native, "no-session-lineage");
|
|
251
|
+
assert.equal(relayed.length, 1);
|
|
252
|
+
assert.equal(relayed[0]!.reviewerSessionId, undefined);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
function groupInput(lineageId: string, lens: string, order: number): ReviewCollectInputV3 {
|
|
256
|
+
const subjectHash = `sha256:${String(order + 1).repeat(64)}`;
|
|
257
|
+
const base = collectInput(lineageId, true);
|
|
258
|
+
const arguments_ = base.arguments.map((argument) => {
|
|
259
|
+
if (argument.name === "lens") return { ...argument, value: lens, token: `--lens=${lens}` };
|
|
260
|
+
if (argument.name === "order") return { ...argument, value: String(order), token: `--order=${order}` };
|
|
261
|
+
if (argument.name === "subject-hash") return { ...argument, value: subjectHash, token: `--subject-hash=${subjectHash}` };
|
|
262
|
+
return argument;
|
|
263
|
+
});
|
|
264
|
+
return { ...base, arguments: arguments_, artifactSubject: { ...base.artifactSubject, subjectHash, lens: lens as ReviewArtifactSubjectV2["lens"], selectedOrder: order } };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function groupStatus(lineageId: string, inputs: readonly ReviewCollectInputV3[]): ReviewStatusV3 {
|
|
268
|
+
const status = recoveredStatus(lineageId, false) as ReviewStatusV3 & { repositoryContext?: Record<string, unknown>; nextTransition?: { kind: string; reasonCode: string; collect: { inputs: ReviewCollectInputV3[] } } };
|
|
269
|
+
// The reviewer group binds one repository context per slot; reuse the same
|
|
270
|
+
// opaque handle shape the finalizeStatus fixtures in the routing tests use.
|
|
271
|
+
status.repositoryContext = { capability: "review.opaque_repository_context", handle: `rctx1_${"e".repeat(64)}`, revision: SHA, targetIdentity: SHA };
|
|
272
|
+
status.nextTransition = { kind: "collect", reasonCode: "reviewer_results_required", collect: { inputs: [...inputs] } };
|
|
273
|
+
return status;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function queueNative(statuses: readonly ReviewStatusV3[]): { native: NativeReviewCli; agents: Array<string | undefined> } {
|
|
277
|
+
const agents: Array<string | undefined> = [];
|
|
278
|
+
const queue = [...statuses];
|
|
279
|
+
const native = {
|
|
280
|
+
targetStatus: async (request: { agent?: string }) => {
|
|
281
|
+
agents.push(request.agent);
|
|
282
|
+
const next = queue.shift();
|
|
283
|
+
if (next === undefined) throw new Error("status queue exhausted");
|
|
284
|
+
return next;
|
|
285
|
+
},
|
|
286
|
+
} as unknown as NativeReviewCli;
|
|
287
|
+
return { native, agents };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function prepared(request: ReviewHostRelayRequest) {
|
|
291
|
+
return { request, promptByteLength: 64, resultByteLength: 32 };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
test("capture group threads the caller's live session id into every relay request", async (t) => {
|
|
295
|
+
t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
|
|
296
|
+
const cwd = repository(t);
|
|
297
|
+
const lineageId = "group-session-lineage";
|
|
298
|
+
const inputs = [groupInput(lineageId, "review-risk", 0), groupInput(lineageId, "review-resilience", 1)];
|
|
299
|
+
// The final reconciliation STATUS must no longer offer the submitted
|
|
300
|
+
// reviewer suffix; a STATUS without a collect transition reconciles the
|
|
301
|
+
// drained group instead of replaying the submitted set.
|
|
302
|
+
const finalStatus = groupStatus(lineageId, []) as ReviewStatusV3 & { nextTransition?: { kind: string; reasonCode: string; collect: { inputs: ReviewCollectInputV3[] } } };
|
|
303
|
+
delete finalStatus.nextTransition;
|
|
304
|
+
const { native } = queueNative([groupStatus(lineageId, inputs), groupStatus(lineageId, inputs), groupStatus(lineageId, inputs.slice(1)), finalStatus]);
|
|
305
|
+
const relayed: ReviewHostRelayRequest[] = [];
|
|
306
|
+
__testing.setReviewHostRelayGroupRunnersForTesting(
|
|
307
|
+
async (requests: readonly ReviewHostRelayRequest[]) => {
|
|
308
|
+
relayed.push(...requests);
|
|
309
|
+
return requests.map(prepared);
|
|
310
|
+
},
|
|
311
|
+
async (result: { promptByteLength: number; resultByteLength: number }) => ({ promptByteLength: result.promptByteLength, resultByteLength: result.resultByteLength, submission: "{}" }),
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
const result = await __testing.executeReviewCaptureGroupOperation(
|
|
315
|
+
{ lineageId, collectBindings: inputs.map((input) => JSON.stringify(input)), reviewerRunAcknowledged: true },
|
|
316
|
+
cwd, native, undefined, new CandidateViewRegistry(),
|
|
317
|
+
undefined, undefined, undefined, "ses-live-1",
|
|
318
|
+
) as Record<string, unknown>;
|
|
319
|
+
|
|
320
|
+
assert.equal(relayed.length, 2);
|
|
321
|
+
assert.ok(relayed.every((request) => request.reviewerSessionId === "ses-live-1"), "every grouped reviewer request must carry the live session id");
|
|
322
|
+
assert.equal(result.outcome, "native-reviewer-group-status-reconciled");
|
|
323
|
+
});
|
|
324
|
+
|
|
220
325
|
// gentle-pi#311 P2 decision (flagged for confirmation): the in-process path
|
|
221
326
|
// has no ambient default model. A routing entry with no configured model used
|
|
222
327
|
// to launch the child selection-free (inheriting pi's own default); the real
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
NativeReviewCliV216,
|
|
10
10
|
type ExecFileAdapter,
|
|
11
11
|
type NativeReviewCli,
|
|
12
|
+
type NativeReviewAssessRequest,
|
|
12
13
|
} from "../lib/native-review-cli.ts";
|
|
13
14
|
import {
|
|
14
15
|
decodeReviewAssessmentV1,
|
|
@@ -573,6 +574,109 @@ function nativeClient(adapter: ExecFileAdapter): NativeReviewCliV216 {
|
|
|
573
574
|
return new NativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024);
|
|
574
575
|
}
|
|
575
576
|
|
|
577
|
+
test("gentle_review assess: stderr-only refusal preserves safe actionable diagnostics and fails closed", async () => {
|
|
578
|
+
const queue = queuedAdapter([{ stdout: "", stderr: "untracked scope declaration required; use --untracked-scope=exclude or select; token=hidden-secret", exitCode: 1 }]);
|
|
579
|
+
const client = nativeClient(queue.adapter);
|
|
580
|
+
const tool = reviewControllerTool({ assess: client.assess.bind(client) });
|
|
581
|
+
const result = await tool.execute("refusal", { operation: "assess" }, undefined, undefined, ctx);
|
|
582
|
+
const details = result.details as { risk: string; reasons: { code: string; detail: string }[]; plan: { independentVerifier: boolean } };
|
|
583
|
+
assert.equal(details.risk, "unassessable");
|
|
584
|
+
assert.equal(details.plan.independentVerifier, true);
|
|
585
|
+
assert.equal(details.reasons[0].code, NATIVE_REVIEW_ERROR_CODE.EMPTY_OUTPUT);
|
|
586
|
+
assert.match(details.reasons[0].detail, /untracked scope declaration required/);
|
|
587
|
+
assert.match(details.reasons[0].detail, /--untracked-scope=exclude/);
|
|
588
|
+
assert.doesNotMatch(JSON.stringify(result), /hidden-secret/);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test("gentle_review assess: diagnostic projection is bounded and redacts paths and environment assignments", async () => {
|
|
592
|
+
const queue = queuedAdapter([{ stdout: "", stderr: "untracked declaration required /private/project/file C:\\private\\project\\file PRIVATE_PROJECT=hidden-project token=hidden-token " + "x".repeat(6000), exitCode: 1 }]);
|
|
593
|
+
const client = nativeClient(queue.adapter);
|
|
594
|
+
const tool = reviewControllerTool({ assess: client.assess.bind(client) });
|
|
595
|
+
const result = await tool.execute("private-refusal", { operation: "assess" }, undefined, undefined, ctx);
|
|
596
|
+
const serialized = JSON.stringify(result);
|
|
597
|
+
const detail = (result.details as { reasons: { detail: string }[] }).reasons[0].detail;
|
|
598
|
+
assert.match(detail, /untracked declaration required/);
|
|
599
|
+
assert.ok(detail.length < 4300);
|
|
600
|
+
assert.doesNotMatch(serialized, /hidden-project|hidden-token|private.project|\/package\//);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
for (const [shape, sensitive] of [
|
|
604
|
+
["lowercase environment", "private_project=hidden-project"],
|
|
605
|
+
["quoted environment", 'private_project="hidden project value"'],
|
|
606
|
+
["credential argument", "--password hidden-credential"],
|
|
607
|
+
["quoted credential argument", '--api-key "hidden credential value"'],
|
|
608
|
+
["POSIX path with spaces", "/private/hidden project/hidden file.ts"],
|
|
609
|
+
["quoted POSIX path", '"/private/hidden project/hidden file.ts"'],
|
|
610
|
+
["Windows path with spaces", String.raw`C:\private\hidden project\hidden file.ts`],
|
|
611
|
+
["quoted Windows path", String.raw`"C:\private\hidden project\hidden file.ts"`],
|
|
612
|
+
]) {
|
|
613
|
+
test(`assess diagnostic redacts ${shape} without losing provider guidance`, async () => {
|
|
614
|
+
const queue = queuedAdapter([{ stdout: "", stderr: `${sensitive}\nuntracked scope declaration required; use --untracked-scope=exclude or select`, exitCode: 1 }]);
|
|
615
|
+
const client = nativeClient(queue.adapter);
|
|
616
|
+
const result = await reviewControllerTool({ assess: client.assess.bind(client) }).execute("private-shape", { operation: "assess" }, undefined, undefined, ctx);
|
|
617
|
+
const details = result.details as { risk: string; reasons: { code: string; detail: string }[] };
|
|
618
|
+
assert.equal(details.risk, "unassessable");
|
|
619
|
+
assert.equal(details.reasons[0].code, NATIVE_REVIEW_ERROR_CODE.EMPTY_OUTPUT);
|
|
620
|
+
assert.doesNotMatch(JSON.stringify(result), /hidden|private_project| project|file\.ts|\/private\//);
|
|
621
|
+
assert.match(details.reasons[0].detail, /untracked scope declaration required; use --untracked-scope=exclude or select/);
|
|
622
|
+
assert.ok(details.reasons[0].detail.length < 4300);
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
for (const selection of [
|
|
627
|
+
{ untrackedScope: "exclude", expectedUntrackedInventory: "inventory-v1" },
|
|
628
|
+
{ untrackedScope: "select", expectedUntrackedInventory: "inventory-v1", intendedUntracked: ["new/file.ts", "notes.md"] },
|
|
629
|
+
] as const) {
|
|
630
|
+
test(`native and facade assess forward explicit ${selection.untrackedScope} without choosing scope`, async () => {
|
|
631
|
+
const queue = queuedAdapter([{ stdout: JSON.stringify(validEnvelope()) }, { stdout: JSON.stringify(validEnvelope()) }]);
|
|
632
|
+
const client = nativeClient(queue.adapter);
|
|
633
|
+
await client.assess({ cwd: process.cwd(), ...selection });
|
|
634
|
+
const tool = reviewControllerTool({ assess: client.assess.bind(client) });
|
|
635
|
+
const result = await tool.execute("selected", { operation: "assess", input: JSON.stringify({ ...selection, baseRef: "origin/main", committedOnly: true }) }, undefined, undefined, ctx);
|
|
636
|
+
assert.equal((result.details as { risk: string }).risk, "medium");
|
|
637
|
+
const flags = [`--untracked-scope=${selection.untrackedScope}`, "--expected-untracked-inventory=inventory-v1", ...(selection.untrackedScope === "select" ? selection.intendedUntracked.map((path) => `--intended-untracked=${path}`) : [])];
|
|
638
|
+
assert.deepEqual(queue.calls[0].arguments, ["review", "assess", "--cwd", process.cwd(), ...flags, "--json"]);
|
|
639
|
+
assert.deepEqual(queue.calls[1].arguments, ["review", "assess", "--cwd", process.cwd(), "--base-ref", "origin/main", "--committed-only", ...flags, "--json"]);
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
test("native and facade assess reject invalid declarations before launching", async () => {
|
|
644
|
+
const invalid = [
|
|
645
|
+
{ untrackedScope: "exclude" },
|
|
646
|
+
{ expectedUntrackedInventory: "inventory" },
|
|
647
|
+
{ intendedUntracked: ["file.ts"] },
|
|
648
|
+
{ untrackedScope: "all", expectedUntrackedInventory: "inventory" },
|
|
649
|
+
{ untrackedScope: "exclude", expectedUntrackedInventory: "inventory", intendedUntracked: ["file.ts"] },
|
|
650
|
+
{ untrackedScope: "select", expectedUntrackedInventory: "inventory" },
|
|
651
|
+
{ untrackedScope: "select", expectedUntrackedInventory: "inventory", intendedUntracked: [] },
|
|
652
|
+
...[null, 42, "", " inventory", "inventory\n", "inv\u0000entory"].map((expectedUntrackedInventory) => ({ untrackedScope: "exclude", expectedUntrackedInventory })),
|
|
653
|
+
...["/absolute", "C:\\absolute", "dir\\file", ".", "..", "../file", "dir/../file", "dir/./file", "dir//file", "dir/", " file", "file\n"].map((path) => ({ untrackedScope: "select", expectedUntrackedInventory: "inventory", intendedUntracked: [path] })),
|
|
654
|
+
{ untrackedScope: "select", expectedUntrackedInventory: "inventory", intendedUntracked: ["file", "file"] },
|
|
655
|
+
{ untrackedScope: "select", expectedUntrackedInventory: "inventory", intendedUntracked: "file" },
|
|
656
|
+
];
|
|
657
|
+
for (const selection of invalid) {
|
|
658
|
+
const queue = queuedAdapter([]);
|
|
659
|
+
const client = nativeClient(queue.adapter);
|
|
660
|
+
await assert.rejects(() => client.assess({ cwd: process.cwd(), ...selection } as NativeReviewAssessRequest), TypeError);
|
|
661
|
+
const tool = reviewControllerTool({ assess: client.assess.bind(client) });
|
|
662
|
+
await assert.rejects(() => tool.execute("invalid", { operation: "assess", input: JSON.stringify(selection) }, undefined, undefined, ctx));
|
|
663
|
+
assert.equal(queue.calls.length, 0);
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test("facade assess leaves absent declarations to native and preserves stale-inventory refusal", async () => {
|
|
668
|
+
for (const selection of [{}, { untrackedScope: "exclude", expectedUntrackedInventory: "stale-inventory" }]) {
|
|
669
|
+
const queue = queuedAdapter([{ stdout: "", stderr: "untracked inventory changed; inspect and declare the current inventory", exitCode: 1 }]);
|
|
670
|
+
const client = nativeClient(queue.adapter);
|
|
671
|
+
const tool = reviewControllerTool({ assess: client.assess.bind(client) });
|
|
672
|
+
const result = await tool.execute("stale", { operation: "assess", input: JSON.stringify(selection) }, undefined, undefined, ctx);
|
|
673
|
+
const details = result.details as { risk: string; reasons: { detail: string }[] };
|
|
674
|
+
assert.equal(details.risk, "unassessable");
|
|
675
|
+
assert.match(details.reasons[0].detail, /inventory changed/);
|
|
676
|
+
if (!("untrackedScope" in selection)) assert.deepEqual(queue.calls[0].arguments, ["review", "assess", "--cwd", process.cwd(), "--json"]);
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
|
|
576
680
|
test("native assess: decodes a well-formed envelope and sends the exact plain-versioned argv", async () => {
|
|
577
681
|
const queue = queuedAdapter([{ stdout: JSON.stringify({ schema: REVIEW_ASSESSMENT_SCHEMA, risk: "medium", reasons: [], changed_paths: 1, changed_lines: 2, candidate: { kind: "current-changes" } }) }]);
|
|
578
682
|
const result = await nativeClient(queue.adapter).assess!({ cwd: process.cwd() });
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
INTERACTIVE_HOST_ENV,
|
|
5
|
+
isInteractiveMode,
|
|
6
|
+
isInteractiveRpcHost,
|
|
7
|
+
withoutInteractiveHost,
|
|
8
|
+
} from "../lib/rpc-host.ts";
|
|
9
|
+
|
|
10
|
+
// Interactive host signal: the desktop app sets GENTLE_SHELL_INTERACTIVE_HOST=1
|
|
11
|
+
// on the pi process it spawns directly (`--mode rpc`). Subagent children
|
|
12
|
+
// spawned by lib/agents-runner.ts must never see it, so rpc without the
|
|
13
|
+
// variable stays byte-identical to today's headless behaviour.
|
|
14
|
+
|
|
15
|
+
test("isInteractiveRpcHost is true only for rpc mode with the variable set to \"1\"", () => {
|
|
16
|
+
assert.equal(isInteractiveRpcHost("rpc", { [INTERACTIVE_HOST_ENV]: "1" }), true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("isInteractiveRpcHost is false for rpc mode without the variable", () => {
|
|
20
|
+
assert.equal(isInteractiveRpcHost("rpc", {}), false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("isInteractiveRpcHost is false for rpc mode when the variable is not exactly \"1\"", () => {
|
|
24
|
+
assert.equal(isInteractiveRpcHost("rpc", { [INTERACTIVE_HOST_ENV]: "true" }), false);
|
|
25
|
+
assert.equal(isInteractiveRpcHost("rpc", { [INTERACTIVE_HOST_ENV]: "0" }), false);
|
|
26
|
+
assert.equal(isInteractiveRpcHost("rpc", { [INTERACTIVE_HOST_ENV]: "" }), false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("isInteractiveRpcHost is false for every non-rpc mode even with the variable set", () => {
|
|
30
|
+
for (const mode of ["tui", "print", "json"]) {
|
|
31
|
+
assert.equal(isInteractiveRpcHost(mode, { [INTERACTIVE_HOST_ENV]: "1" }), false, `mode ${mode}`);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("isInteractiveRpcHost defaults to process.env when no env is supplied", (t) => {
|
|
36
|
+
const previous = process.env[INTERACTIVE_HOST_ENV];
|
|
37
|
+
process.env[INTERACTIVE_HOST_ENV] = "1";
|
|
38
|
+
t.after(() => {
|
|
39
|
+
if (previous === undefined) delete process.env[INTERACTIVE_HOST_ENV];
|
|
40
|
+
else process.env[INTERACTIVE_HOST_ENV] = previous;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
assert.equal(isInteractiveRpcHost("rpc"), true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("isInteractiveMode is true for tui regardless of the variable", () => {
|
|
47
|
+
assert.equal(isInteractiveMode("tui", {}), true);
|
|
48
|
+
assert.equal(isInteractiveMode("tui", { [INTERACTIVE_HOST_ENV]: "1" }), true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("isInteractiveMode is true for an interactive rpc host", () => {
|
|
52
|
+
assert.equal(isInteractiveMode("rpc", { [INTERACTIVE_HOST_ENV]: "1" }), true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("isInteractiveMode is false for rpc without the variable, and for print/json", () => {
|
|
56
|
+
assert.equal(isInteractiveMode("rpc", {}), false);
|
|
57
|
+
assert.equal(isInteractiveMode("print", { [INTERACTIVE_HOST_ENV]: "1" }), false);
|
|
58
|
+
assert.equal(isInteractiveMode("json", { [INTERACTIVE_HOST_ENV]: "1" }), false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("withoutInteractiveHost returns a copy without the variable, preserving the rest", () => {
|
|
62
|
+
const source = { PATH: "/bin", [INTERACTIVE_HOST_ENV]: "1", KEEP: "yes" };
|
|
63
|
+
|
|
64
|
+
const result = withoutInteractiveHost(source);
|
|
65
|
+
|
|
66
|
+
assert.deepEqual(result, { PATH: "/bin", KEEP: "yes" });
|
|
67
|
+
assert.deepEqual(source, { PATH: "/bin", [INTERACTIVE_HOST_ENV]: "1", KEEP: "yes" }, "the source env is not mutated");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("withoutInteractiveHost is a no-op copy when the variable is absent", () => {
|
|
71
|
+
const source = { PATH: "/bin" };
|
|
72
|
+
|
|
73
|
+
const result = withoutInteractiveHost(source);
|
|
74
|
+
|
|
75
|
+
assert.deepEqual(result, { PATH: "/bin" });
|
|
76
|
+
assert.notEqual(result, source, "a fresh object is returned, not the same reference");
|
|
77
|
+
});
|
package/tests/shell-bar.test.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
renderGauge,
|
|
10
10
|
renderShellBar,
|
|
11
11
|
renderShellHeaderBar,
|
|
12
|
+
renderShellHeaderRule,
|
|
12
13
|
renderShellSidebarBar,
|
|
13
14
|
shellEnabled,
|
|
14
15
|
type ShellBarModel,
|
|
@@ -427,3 +428,10 @@ test("renderShellHeaderBar's usage span always points at the usage text, not ctx
|
|
|
427
428
|
assert.equal(usageSpan.end, visibleWidth(text), "the usage segment always ends at the right edge");
|
|
428
429
|
}
|
|
429
430
|
});
|
|
431
|
+
|
|
432
|
+
test("renderShellHeaderRule paints one full-width line in the editor frame color", () => {
|
|
433
|
+
assert.equal(renderShellHeaderRule(taggedTheme, 4), "<border>────</border>", "the rule uses the editor frame's border role");
|
|
434
|
+
assert.equal(renderShellHeaderRule(plainTheme, 12), "─".repeat(12), "the rule spans the full width");
|
|
435
|
+
assert.equal(renderShellHeaderRule(plainTheme, 0), "");
|
|
436
|
+
assert.equal(renderShellHeaderRule(plainTheme, -3), "", "negative widths clamp to an empty rule");
|
|
437
|
+
});
|
|
@@ -23,7 +23,7 @@ function rail(f: ReturnType<typeof fixture>): ScrollView {
|
|
|
23
23
|
assert.equal(node.type, "hstack");
|
|
24
24
|
return node.entries[1].component;
|
|
25
25
|
}
|
|
26
|
-
type HstackNode = { type: string; gap: number; align: string; entries: { component: unknown; basis: number; grow: number; shrink: number; minSize: number }[] };
|
|
26
|
+
type HstackNode = { type: string; gap: number; align: string; entries: { component: unknown; basis: number | string; grow: number; shrink: number; minSize: number }[] };
|
|
27
27
|
// Finds the [left, scroll] hstack regardless of whether it is returned
|
|
28
28
|
// directly (no active header) or nested one level under the header vstack.
|
|
29
29
|
function hstackOf(f: ReturnType<typeof fixture>): HstackNode {
|
|
@@ -130,7 +130,7 @@ test("wheel scrolls the rail and is consumed at both boundaries and blank space"
|
|
|
130
130
|
f.host.terminal.columns = 160;
|
|
131
131
|
const restored = rail(f);
|
|
132
132
|
assert.deepEqual(f.bottom.render(80), []);
|
|
133
|
-
const layout = f.root[NODE]() as unknown as { gap: number; entries: { basis: number; grow: number; shrink: number; minSize: number }[] };
|
|
133
|
+
const layout = f.root[NODE]() as unknown as { gap: number; entries: { basis: number | string; grow: number; shrink: number; minSize: number }[] };
|
|
134
134
|
assert.equal(layout.gap, 3);
|
|
135
135
|
assert.deepEqual(layout.entries.map(({ basis, grow, shrink, minSize }) => ({ basis, grow, shrink, minSize })), [
|
|
136
136
|
{ basis: 0, grow: 1, shrink: 1, minSize: 1 },
|
|
@@ -188,11 +188,13 @@ test("rail dispatches a clipped, scroll-translated left click to only the matchi
|
|
|
188
188
|
const hit = scroll.handleMouse(mouse("click", "left", headerY));
|
|
189
189
|
assert.equal(hit?.handled, true);
|
|
190
190
|
assert.equal(clicks, 1);
|
|
191
|
-
|
|
191
|
+
// With a transient scrollbar the rail no longer reserves a column, so
|
|
192
|
+
// clipped parts are one column wider than in the always-on geometry.
|
|
193
|
+
assert.deepEqual(received.at(-1) && { x: received.at(-1)!.x, y: received.at(-1)!.y, width: received.at(-1)!.width, height: received.at(-1)!.height }, { x: 1, y: 0, width: 48, height: 2 });
|
|
192
194
|
|
|
193
195
|
const gapY = headerY - 1;
|
|
194
196
|
assert.equal(scroll.handleMouse(mouse("click", "left", gapY)), undefined, "section gaps do not hit a neighbor");
|
|
195
|
-
assert.equal(scroll.handleMouse({ ...mouse("click", "left", headerY), x:
|
|
197
|
+
assert.equal(scroll.handleMouse({ ...mouse("click", "left", headerY), x: 49 }), undefined, "padding outside the clipped part is inert");
|
|
196
198
|
invalidateSidebar(f.tui);
|
|
197
199
|
assert.equal(scroll.handleMouse(mouse("click", "left", headerY)), undefined, "stale geometry is inert");
|
|
198
200
|
f.host.terminal.columns = 139;
|
|
@@ -586,8 +588,10 @@ test("an active header wraps the hstack in a vstack and removes the banner from
|
|
|
586
588
|
assert.equal(node.type, "vstack");
|
|
587
589
|
assert.equal(node.gap, 0);
|
|
588
590
|
assert.equal(node.align, "stretch");
|
|
591
|
+
// basis "auto": pi-tui measures the header leaf from its rendered lines
|
|
592
|
+
// (one line: the status bar; with the rule row: two) instead of hard-pinning the row.
|
|
589
593
|
assert.deepEqual(node.entries.map(({ basis, grow, shrink, minSize }) => ({ basis, grow, shrink, minSize })), [
|
|
590
|
-
{ basis:
|
|
594
|
+
{ basis: "auto", grow: 0, shrink: 0, minSize: 1 },
|
|
591
595
|
{ basis: 0, grow: 1, shrink: 1, minSize: 1 },
|
|
592
596
|
]);
|
|
593
597
|
const header = node.entries[0].component as { render(width: number): string[] };
|
|
@@ -608,6 +612,31 @@ test("an active header wraps the hstack in a vstack and removes the banner from
|
|
|
608
612
|
assert.notEqual(rail[1]?.trim(), "", "the first card starts on the second row");
|
|
609
613
|
});
|
|
610
614
|
|
|
615
|
+
test("the header leaf carries the rule row: a two-line header renders both lines at full width", (t) => {
|
|
616
|
+
const f = fixture();
|
|
617
|
+
sidebarHeader(f.tui, {
|
|
618
|
+
render: (width: number) => [`HEADER ${width}`, `RULE ${width}`],
|
|
619
|
+
invalidate() {},
|
|
620
|
+
});
|
|
621
|
+
t.after(installSidebar(f.tui, theme));
|
|
622
|
+
|
|
623
|
+
const node = f.root[NODE]() as unknown as { type: string; entries: { component: Component }[] };
|
|
624
|
+
assert.equal(node.type, "vstack", "a two-line header still wraps the hstack in a vstack");
|
|
625
|
+
const header = node.entries[0]!.component as { render(width: number): string[] };
|
|
626
|
+
assert.deepEqual(header.render(0), ["HEADER 138", "RULE 138"], "both the status line and the rule row reach the layout, rendered at the terminal width minus the rail's right inset");
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
test("below the sidebar breakpoint a rule-like header paints no rule row", (t) => {
|
|
630
|
+
const f = fixture("fullscreen", 139);
|
|
631
|
+
sidebarHeader(f.tui, { render: (width: number) => [`HEADER ${width}`, `RULE ${width}`], invalidate() {} });
|
|
632
|
+
t.after(installSidebar(f.tui, theme));
|
|
633
|
+
|
|
634
|
+
// Below SIDEBAR_BREAKPOINT prepare() declines, so the replacement hands
|
|
635
|
+
// back the native root's own layout node: neither the header wrapper nor
|
|
636
|
+
// a rule row ever paints.
|
|
637
|
+
assert.deepEqual(f.root[NODE](), f.original(), "below the breakpoint the rule is removed with the whole sidebar");
|
|
638
|
+
});
|
|
639
|
+
|
|
611
640
|
test("without a registered header the rail keeps the banner and the plain hstack", (t) => {
|
|
612
641
|
const f = fixture();
|
|
613
642
|
t.after(installSidebar(f.tui, theme));
|
|
@@ -707,3 +736,29 @@ test("the header component's handleMouse is a harmless no-op when no header part
|
|
|
707
736
|
// backward-compatible fallback), so there is no header leaf to click at all.
|
|
708
737
|
assert.equal(node.type, "hstack");
|
|
709
738
|
});
|
|
739
|
+
|
|
740
|
+
test("rail scrollbar is transient: hidden by default, visible only while scrolling overflow", (t) => {
|
|
741
|
+
const f = fixture();
|
|
742
|
+
t.mock.timers.enable({ apis: ["setTimeout"] });
|
|
743
|
+
t.after(installSidebar(f.tui, theme));
|
|
744
|
+
const scroll = rail(f);
|
|
745
|
+
// Regression guard: the rail scroll view used to hardcode scrollbar
|
|
746
|
+
// "always", re-slicing its column on every render pass even when idle.
|
|
747
|
+
assert.equal(scroll.scrollbar, "auto");
|
|
748
|
+
sidebarPart(f.tui, "todo", { render: () => Array.from({ length: 60 }, (_, i) => `todo row ${i}`), invalidate() {} });
|
|
749
|
+
renderLayoutFrame(f.root, 140, 20, () => {});
|
|
750
|
+
assert.equal(scroll.isScrollbarVisible, false, "no scroll yet: hidden even with overflow");
|
|
751
|
+
scroll.scrollBy(1);
|
|
752
|
+
assert.equal(scroll.isScrollbarVisible, true, "rail scroll reveals the transient scrollbar");
|
|
753
|
+
t.mock.timers.tick(1000);
|
|
754
|
+
assert.equal(scroll.isScrollbarVisible, false, "hidden again after the transient window");
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
test("rail scrollbar stays hidden when the rail does not overflow", (t) => {
|
|
758
|
+
const f = fixture();
|
|
759
|
+
t.after(installSidebar(f.tui, theme));
|
|
760
|
+
const scroll = rail(f);
|
|
761
|
+
renderLayoutFrame(f.root, 140, 20, () => {});
|
|
762
|
+
scroll.scrollBy(1);
|
|
763
|
+
assert.equal(scroll.isScrollbarVisible, false);
|
|
764
|
+
});
|