gentle-pi 3.3.0 → 3.4.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 +63 -59
- package/assets/orchestrator-delegation.md +1 -1
- 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-shell.md +26 -2
- package/docs/readme-reference.md +32 -6
- package/extensions/ask-user-question.ts +210 -0
- package/extensions/gentle-agents.ts +8 -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/animation-policy.ts +52 -0
- package/lib/background-cache-warming.ts +38 -0
- package/lib/command-palette-catalog.ts +1 -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/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 +1 -1
- package/runtime/native-review-cli.mjs +35 -9
- package/runtime/review-integration-v2.mjs +4 -1
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/verify-package-files.mjs +2 -3
- package/tests/animation-policy.test.ts +42 -0
- package/tests/ask-user-question.test.ts +435 -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 +71 -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.test.ts +355 -2
- package/tests/inprocess-reviewer.test.ts +92 -0
- 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/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() });
|
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
|
+
});
|
|
@@ -7,15 +7,20 @@ import {
|
|
|
7
7
|
parseAnthropicHeaders,
|
|
8
8
|
parseCodexHeaders,
|
|
9
9
|
parseNanQuota,
|
|
10
|
+
parseProviderUsage,
|
|
10
11
|
parseUsageHeaders,
|
|
11
12
|
parseCodexUsage,
|
|
13
|
+
parseUsageSource,
|
|
12
14
|
providerNote,
|
|
13
15
|
renderUsageBar,
|
|
14
16
|
renderUsagePanel,
|
|
15
17
|
SUPPORTED_USAGE_PROVIDERS,
|
|
18
|
+
UsageSourceRegistry,
|
|
19
|
+
USAGE_SOURCE_SCHEMA,
|
|
16
20
|
UsageStore,
|
|
17
21
|
windowLabel,
|
|
18
22
|
type ProviderUsage,
|
|
23
|
+
type UsageSource,
|
|
19
24
|
type UsageWindow,
|
|
20
25
|
} from "../lib/shell-usage.ts";
|
|
21
26
|
|
|
@@ -243,6 +248,130 @@ test("nan is a supported usage provider with its own pending note", () => {
|
|
|
243
248
|
assert.deepEqual(renderUsagePanel([], plainTheme, 100, NOW, { provider: "nan" }), ["✿ nan · no usage yet · r to fetch"]);
|
|
244
249
|
});
|
|
245
250
|
|
|
251
|
+
// A generic hook: any extension can register a usage source for its own
|
|
252
|
+
// provider at runtime, without gentle-shell knowing anything about it.
|
|
253
|
+
|
|
254
|
+
test("parseUsageSource validates the registration payload and ignores anything malformed", () => {
|
|
255
|
+
const fetchFn = async () => undefined;
|
|
256
|
+
const valid = parseUsageSource({ schema: USAGE_SOURCE_SCHEMA, provider: "acme-cloud", fetch: fetchFn });
|
|
257
|
+
assert.equal(valid?.schema, USAGE_SOURCE_SCHEMA);
|
|
258
|
+
assert.equal(valid?.provider, "acme-cloud");
|
|
259
|
+
assert.equal(valid?.fetch, fetchFn);
|
|
260
|
+
assert.equal(valid?.pendingNote, undefined);
|
|
261
|
+
|
|
262
|
+
const withNote = parseUsageSource({ schema: USAGE_SOURCE_SCHEMA, provider: "acme-cloud", pendingNote: "warming up", fetch: fetchFn });
|
|
263
|
+
assert.equal(withNote?.pendingNote, "warming up");
|
|
264
|
+
|
|
265
|
+
assert.equal(parseUsageSource({ schema: "gentle-pi.usage-source/v0", provider: "acme-cloud", fetch: fetchFn }), undefined, "wrong schema");
|
|
266
|
+
assert.equal(parseUsageSource({ schema: USAGE_SOURCE_SCHEMA, provider: "", fetch: fetchFn }), undefined, "empty provider");
|
|
267
|
+
assert.equal(parseUsageSource({ schema: USAGE_SOURCE_SCHEMA, provider: "acme cloud", fetch: fetchFn }), undefined, "unsafe provider id");
|
|
268
|
+
assert.equal(parseUsageSource({ schema: USAGE_SOURCE_SCHEMA, provider: "acme-cloud", fetch: "nope" }), undefined, "fetch not a function");
|
|
269
|
+
assert.equal(parseUsageSource({ schema: USAGE_SOURCE_SCHEMA, provider: "acme-cloud", pendingNote: 7, fetch: fetchFn }), undefined, "pendingNote must be a string when present");
|
|
270
|
+
assert.equal(parseUsageSource(null), undefined);
|
|
271
|
+
assert.equal(parseUsageSource(undefined), undefined);
|
|
272
|
+
assert.equal(parseUsageSource("acme-cloud"), undefined);
|
|
273
|
+
assert.equal(parseUsageSource({}), undefined);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("UsageSourceRegistry replaces a provider's source on re-registration", () => {
|
|
277
|
+
const registry = new UsageSourceRegistry();
|
|
278
|
+
assert.equal(registry.has("acme-cloud"), false);
|
|
279
|
+
assert.equal(registry.get("acme-cloud"), undefined);
|
|
280
|
+
assert.equal(registry.note("acme-cloud"), undefined);
|
|
281
|
+
|
|
282
|
+
const first: UsageSource = { schema: USAGE_SOURCE_SCHEMA, provider: "acme-cloud", fetch: async () => undefined };
|
|
283
|
+
registry.register(first);
|
|
284
|
+
assert.equal(registry.has("acme-cloud"), true);
|
|
285
|
+
assert.equal(registry.get("acme-cloud"), first);
|
|
286
|
+
assert.equal(registry.note("acme-cloud"), "no usage yet · r to fetch", "default note when the source sets none");
|
|
287
|
+
|
|
288
|
+
const second: UsageSource = { schema: USAGE_SOURCE_SCHEMA, provider: "acme-cloud", pendingNote: "still warming up", fetch: async () => undefined };
|
|
289
|
+
registry.register(second);
|
|
290
|
+
assert.equal(registry.get("acme-cloud"), second, "re-registration replaces, never accumulates");
|
|
291
|
+
assert.equal(registry.note("acme-cloud"), "still warming up");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("a registered provider is supported without touching the built-in note map", () => {
|
|
295
|
+
const registry = new UsageSourceRegistry();
|
|
296
|
+
assert.equal(providerNote("acme-cloud"), "no subscription usage for this provider");
|
|
297
|
+
assert.equal(providerNote("acme-cloud", registry), "no subscription usage for this provider", "an empty registry changes nothing");
|
|
298
|
+
registry.register({ schema: USAGE_SOURCE_SCHEMA, provider: "acme-cloud", fetch: async () => undefined });
|
|
299
|
+
assert.equal(providerNote("acme-cloud", registry), "no usage yet · r to fetch");
|
|
300
|
+
assert.equal(providerNote("nan", registry), "no usage yet · r to fetch", "the static built-ins are unaffected");
|
|
301
|
+
assert.deepEqual(renderUsagePanel([], plainTheme, 100, NOW, { provider: "acme-cloud" }, registry), ["✿ acme-cloud · no usage yet · r to fetch"]);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// A registered source's resolved value crosses the same trust boundary a
|
|
305
|
+
// parsed HTTP payload does. parseProviderUsage validates it exactly like
|
|
306
|
+
// one, and the object gentle-shell records is always freshly built, never
|
|
307
|
+
// the caller's own reference.
|
|
308
|
+
|
|
309
|
+
const VALID_SOURCE_USAGE = {
|
|
310
|
+
provider: "acme-cloud",
|
|
311
|
+
plan: "Acme · 42 credits",
|
|
312
|
+
fetchedAt: 100,
|
|
313
|
+
limits: [
|
|
314
|
+
{
|
|
315
|
+
name: "acme-cloud",
|
|
316
|
+
limitReached: false,
|
|
317
|
+
windows: [
|
|
318
|
+
{ label: "week", usedPercent: 40, windowSeconds: 604_800, resetAt: null },
|
|
319
|
+
{ label: "day", usedPercent: 12, windowSeconds: 86_400, resetAt: 200, used: 5, budget: 40 },
|
|
320
|
+
],
|
|
321
|
+
},
|
|
322
|
+
],
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
test("parseProviderUsage accepts a matching shape and copies it defensively", () => {
|
|
326
|
+
const source = structuredClone(VALID_SOURCE_USAGE);
|
|
327
|
+
const parsed = parseProviderUsage(source, "acme-cloud");
|
|
328
|
+
assert.deepEqual(parsed, source);
|
|
329
|
+
assert.notEqual(parsed, source, "the recorded object must never be the caller's own reference");
|
|
330
|
+
assert.notEqual(parsed?.limits, source.limits);
|
|
331
|
+
assert.notEqual(parsed?.limits[0], source.limits[0]);
|
|
332
|
+
assert.notEqual(parsed?.limits[0].windows, source.limits[0].windows);
|
|
333
|
+
assert.notEqual(parsed?.limits[0].windows[0], source.limits[0].windows[0]);
|
|
334
|
+
|
|
335
|
+
// Mutating the caller's own object after the fact must never reach the copy.
|
|
336
|
+
source.limits[0].windows[0].usedPercent = 999;
|
|
337
|
+
assert.equal(parsed?.limits[0].windows[0].usedPercent, 40);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("parseProviderUsage rejects a resolution for a different provider", () => {
|
|
341
|
+
assert.equal(parseProviderUsage(structuredClone(VALID_SOURCE_USAGE), "other-provider"), undefined);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("parseProviderUsage rejects a missing or malformed limits array", () => {
|
|
345
|
+
assert.equal(parseProviderUsage({ provider: "acme-cloud", plan: undefined, fetchedAt: 0 }, "acme-cloud"), undefined, "limits missing entirely");
|
|
346
|
+
assert.equal(parseProviderUsage({ provider: "acme-cloud", plan: undefined, fetchedAt: 0, limits: "nope" }, "acme-cloud"), undefined, "limits not an array");
|
|
347
|
+
assert.equal(
|
|
348
|
+
parseProviderUsage({ provider: "acme-cloud", plan: undefined, fetchedAt: 0, limits: [{ name: "x", limitReached: "nope", windows: [] }] }, "acme-cloud"),
|
|
349
|
+
undefined,
|
|
350
|
+
"limitReached must be a boolean",
|
|
351
|
+
);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("parseProviderUsage rejects a non-numeric usedPercent inside a window", () => {
|
|
355
|
+
const usage = structuredClone(VALID_SOURCE_USAGE);
|
|
356
|
+
(usage.limits[0].windows[0] as unknown as { usedPercent: unknown }).usedPercent = "40";
|
|
357
|
+
assert.equal(parseProviderUsage(usage, "acme-cloud"), undefined);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("parseProviderUsage rejects a limit whose windows is not an array", () => {
|
|
361
|
+
const usage = structuredClone(VALID_SOURCE_USAGE);
|
|
362
|
+
(usage.limits[0] as unknown as { windows: unknown }).windows = "nope";
|
|
363
|
+
assert.equal(parseProviderUsage(usage, "acme-cloud"), undefined);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("parseProviderUsage rejects malformed inputs outright", () => {
|
|
367
|
+
assert.equal(parseProviderUsage(undefined, "acme-cloud"), undefined);
|
|
368
|
+
assert.equal(parseProviderUsage(null, "acme-cloud"), undefined);
|
|
369
|
+
assert.equal(parseProviderUsage("acme-cloud", "acme-cloud"), undefined);
|
|
370
|
+
assert.equal(parseProviderUsage({ provider: "acme-cloud", plan: 7, fetchedAt: 0, limits: [] }, "acme-cloud"), undefined, "plan must be a string when present");
|
|
371
|
+
assert.equal(parseProviderUsage({ provider: "acme-cloud", plan: undefined, fetchedAt: Number.POSITIVE_INFINITY, limits: [] }, "acme-cloud"), undefined, "fetchedAt must be finite");
|
|
372
|
+
assert.deepEqual(parseProviderUsage({ provider: "acme-cloud", plan: undefined, fetchedAt: 0, limits: [] }, "acme-cloud"), { provider: "acme-cloud", plan: undefined, fetchedAt: 0, limits: [] });
|
|
373
|
+
});
|
|
374
|
+
|
|
246
375
|
test("renderUsagePanel lists each NaN model allowance with its reset on the same row", () => {
|
|
247
376
|
const usage = parseNanQuota(NAN_QUOTA, NOW);
|
|
248
377
|
const lines = renderUsagePanel([usage], plainTheme, 80, NOW, { provider: "nan" });
|
|
@@ -25,7 +25,7 @@ const PREFIXED_NAMES: Record<string, string> = {
|
|
|
25
25
|
"work-unit-commits": "gentle-ai-work-unit-commits",
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
-
const UNPREFIXED_DIRS = ["gentle-ai"
|
|
28
|
+
const UNPREFIXED_DIRS = ["gentle-ai"];
|
|
29
29
|
|
|
30
30
|
for (const [dir, expectedName] of Object.entries(PREFIXED_NAMES)) {
|
|
31
31
|
test(`skills/${dir}/SKILL.md frontmatter name is prefixed`, () => {
|