gentle-pi 3.2.0 → 3.3.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/assets/orchestrator-delegation.md +13 -8
- package/assets/orchestrator.md +2 -2
- package/docs/gentle-shell.md +40 -17
- package/docs/readme-reference.md +41 -7
- package/docs/review-integration.md +25 -11
- package/extensions/gentle-agents.ts +85 -17
- package/extensions/gentle-ai.ts +179 -12
- package/extensions/gentle-shell.ts +408 -38
- package/extensions/gentle-todo.ts +19 -1
- package/lib/agents-view.ts +41 -14
- package/lib/agents-widget.ts +84 -13
- package/lib/command-palette-catalog.ts +1 -0
- package/lib/double-esc-cancel-policy.ts +138 -0
- package/lib/inprocess-reviewer.ts +260 -0
- package/lib/model-routing-authority.ts +1 -1
- package/lib/native-review-cli.ts +23 -0
- package/lib/odd-runtime-delegation-gate.ts +88 -0
- package/lib/review-host-relay.ts +262 -94
- package/lib/review-integration-v2.ts +110 -26
- package/lib/shell-bar.ts +158 -29
- package/lib/shell-card.ts +19 -9
- package/lib/shell-changes-view.ts +43 -5
- package/lib/shell-changes.ts +92 -5
- package/lib/shell-hover.ts +39 -0
- package/lib/shell-prompt.ts +10 -1
- package/lib/shell-sidebar-layout.ts +111 -15
- package/lib/shell-sidebar.ts +16 -0
- package/lib/shell-todo.ts +7 -1
- package/lib/shell-usage-view.ts +98 -10
- package/lib/shell-usage.ts +226 -10
- package/package.json +2 -1
- package/runtime/native-review-cli.mjs +23 -0
- package/runtime/review-integration-v2.mjs +110 -26
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
- package/scripts/mirror-odd-routing.mjs +242 -0
- package/scripts/verify-package-files.mjs +3 -3
- package/tests/agents-grouping.test.ts +75 -18
- package/tests/agents-view.test.ts +28 -18
- package/tests/agents-widget.test.ts +100 -12
- package/tests/command-palette.test.ts +1 -0
- package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
- package/tests/double-esc-cancel-policy.test.ts +194 -0
- package/tests/gentle-agents.test.ts +528 -5
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +69 -5
- package/tests/gentle-shell.test.ts +903 -25
- package/tests/gentle-todo.test.ts +17 -4
- package/tests/inprocess-reviewer.test.ts +368 -0
- package/tests/maintainer/provider-relay.maintest.ts +101 -143
- package/tests/native-review-capability-contract.test.ts +32 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
- package/tests/odd-routing-contract.test.ts +57 -0
- package/tests/odd-runtime-delegation-gate.test.ts +212 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/package-manifest.test.ts +6 -6
- package/tests/review-controller-native-routing.test.ts +60 -1
- package/tests/review-host-relay-routing.test.ts +77 -0
- package/tests/review-host-relay.test.ts +285 -239
- package/tests/review-integration-v2-forward.test.ts +61 -0
- package/tests/review-integration-v2.test.ts +116 -1
- package/tests/review-relay-transport-agent.test.ts +83 -0
- package/tests/runtime-harness.mjs +11 -0
- package/tests/session-changes-shell.test.ts +27 -0
- package/tests/session-worktree-registry.test.ts +41 -0
- package/tests/shell-bar.test.ts +224 -6
- package/tests/shell-card.test.ts +5 -3
- package/tests/shell-changes-view.test.ts +47 -0
- package/tests/shell-changes.test.ts +177 -0
- package/tests/shell-hover.test.ts +19 -0
- package/tests/shell-prompt.test.ts +20 -0
- package/tests/shell-sidebar-fullscreen.test.ts +59 -0
- package/tests/shell-sidebar-layout.test.ts +243 -5
- package/tests/shell-sidebar.test.ts +25 -1
- package/tests/shell-todo.test.ts +36 -0
- package/tests/shell-usage-view.test.ts +123 -3
- package/tests/shell-usage.test.ts +254 -6
- package/lib/opaque-pi-reviewer-adapter.ts +0 -284
- package/tests/opaque-pi-reviewer-adapter.test.ts +0 -266
|
@@ -440,6 +440,67 @@ test("capabilities/v2.5 negotiates the v2.6.0 advertisement and status/v7 decode
|
|
|
440
440
|
assert.throws(() => decodeReviewStatusV3(v6WithDigest), /eligible_untracked_inventory/);
|
|
441
441
|
});
|
|
442
442
|
|
|
443
|
+
test("capabilities/v2.6 negotiates the v3.4.0 advertisement on the same v2.5 requirement floor", () => {
|
|
444
|
+
// gentle-ai v3.4.0 advertises capabilities/v2.6: the v2.5 surface plus
|
|
445
|
+
// status/v8 (status/v6 and status/v7 stay advertised for compatibility).
|
|
446
|
+
// The `review assess` review_due/review_due_reason/next_transition
|
|
447
|
+
// additions are a separate schema (gentle-ai.review-assessment/v1),
|
|
448
|
+
// unrelated to this negotiated capabilities surface.
|
|
449
|
+
const v26 = clone(fixture(DEV_FIXTURES, "capabilities-v2.2.captured.json") as JsonObject);
|
|
450
|
+
v26.schema = "gentle-ai.review-integration.capabilities/v2.6";
|
|
451
|
+
(v26.protocol as JsonObject).minor = 6;
|
|
452
|
+
const features = v26.features as JsonObject;
|
|
453
|
+
features.mandatory = (features.mandatory as JsonObject[]).filter((feature) =>
|
|
454
|
+
!["exact_receipt_replay", "five_delivery_gates", "sdd_receipt_binding"].includes(feature.name as string));
|
|
455
|
+
v26.schemas = [
|
|
456
|
+
...(v26.schemas as string[]).map((schema) => schema
|
|
457
|
+
.replace("capabilities/v2.2", "capabilities/v2.6")
|
|
458
|
+
.replace("start/v3", "start/v4")
|
|
459
|
+
.replace("status/v5", "status/v6")),
|
|
460
|
+
"gentle-ai.review-intended-untracked-selection/v1",
|
|
461
|
+
"gentle-ai.review-integration.status/v7",
|
|
462
|
+
"gentle-ai.review-integration.status/v8",
|
|
463
|
+
];
|
|
464
|
+
const decoded = decodeReviewCapabilitiesV2(v26, CAPTURED_DIGEST);
|
|
465
|
+
assert.equal(decoded.schemas.has("gentle-ai.review-integration.status/v6"), true);
|
|
466
|
+
// The negotiated set is the v2.5 requirement floor unchanged; status/v7
|
|
467
|
+
// and status/v8 are additive and never required, so an advertisement
|
|
468
|
+
// without either still negotiates.
|
|
469
|
+
const withoutV7V8 = clone(v26);
|
|
470
|
+
withoutV7V8.schemas = (withoutV7V8.schemas as string[]).filter((schema) => !["gentle-ai.review-integration.status/v7", "gentle-ai.review-integration.status/v8"].includes(schema as string));
|
|
471
|
+
assert.equal(decodeReviewCapabilitiesV2(withoutV7V8, CAPTURED_DIGEST).schemas.has("gentle-ai.review-integration.status/v6"), true);
|
|
472
|
+
|
|
473
|
+
// status/v6 stays required: v7/v8 are additive extensions, not a replacement.
|
|
474
|
+
const missingStatusV6 = clone(v26);
|
|
475
|
+
missingStatusV6.schemas = (missingStatusV6.schemas as string[]).filter((schema) => schema !== "gentle-ai.review-integration.status/v6");
|
|
476
|
+
assert.throws(() => decodeReviewCapabilitiesV2(missingStatusV6, CAPTURED_DIGEST), /status\/v6/);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// status/v8 (gentle-ai main, PR #4765; the current released contract) only
|
|
480
|
+
// extended the reviewer-result transition for OpenCode provider tasks -- no
|
|
481
|
+
// new top-level key -- so it decodes on the exact v7 surface: same optional
|
|
482
|
+
// eligible_untracked_inventory digest, same rejection of a v6 envelope
|
|
483
|
+
// carrying it. status/v9 (the sibling gentle-ai branch's contract, ahead of
|
|
484
|
+
// the released v8) adds nothing new at the top level either; its one
|
|
485
|
+
// addition -- the host-mediated role submission -- lives in
|
|
486
|
+
// next_transition.collect.inputs and is covered by
|
|
487
|
+
// tests/review-integration-v2.test.ts's "v9 host-mediated" tests.
|
|
488
|
+
test("status/v8 and status/v9 decode with their exact identity on the v7 surface", () => {
|
|
489
|
+
const v8 = initialIntendedUntrackedStatusV6();
|
|
490
|
+
v8.schema = "gentle-ai.review-integration.status/v8";
|
|
491
|
+
v8.eligible_untracked_inventory = sha("e");
|
|
492
|
+
const decodedV8 = decodeReviewStatusV3(v8);
|
|
493
|
+
assert.equal(decodedV8.raw.schema, "gentle-ai.review-integration.status/v8");
|
|
494
|
+
assert.equal(decodedV8.eligibleUntrackedInventory, sha("e"));
|
|
495
|
+
|
|
496
|
+
const v9 = initialIntendedUntrackedStatusV6();
|
|
497
|
+
v9.schema = "gentle-ai.review-integration.status/v9";
|
|
498
|
+
v9.eligible_untracked_inventory = sha("e");
|
|
499
|
+
const decodedV9 = decodeReviewStatusV3(v9);
|
|
500
|
+
assert.equal(decodedV9.raw.schema, "gentle-ai.review-integration.status/v9");
|
|
501
|
+
assert.equal(decodedV9.eligibleUntrackedInventory, sha("e"));
|
|
502
|
+
});
|
|
503
|
+
|
|
443
504
|
test("status/v6 decodes and enforces the intended-untracked selection submission", () => {
|
|
444
505
|
const decoded = decodeReviewStatusV3(initialIntendedUntrackedStatusV6());
|
|
445
506
|
const input = decoded.nextTransition?.collect?.inputs[0];
|
|
@@ -818,11 +818,14 @@ test("next_transition decodes the self-contained provider role capture vectors s
|
|
|
818
818
|
() => decodeRoleTransition(roleInput("provider_refuter", "review.capture-refuter", "https://gentle-ai.dev/schema/review/reviewer/v1")),
|
|
819
819
|
/schema must be https:\/\/gentle-ai\.dev\/schema\/review\/refuter\/v1/,
|
|
820
820
|
);
|
|
821
|
+
// This fixture decodes on the v5 surface only (no v9): a submission
|
|
822
|
+
// descriptor on a role input is rejected for lacking the v9 provider
|
|
823
|
+
// contract, before the materialize/execute discriminator is even read.
|
|
821
824
|
assert.throws(
|
|
822
825
|
() => decodeRoleTransition(roleInput("provider_refuter", "review.capture-refuter", "https://gentle-ai.dev/schema/review/refuter/v1", {
|
|
823
826
|
submission: { operation_token: "capture-refuter", argument_tokens: ["--input={{value}}"], values: [{ slot: "{{value}}", domain: "artifact-path", substitution_location: 0 }] },
|
|
824
827
|
})),
|
|
825
|
-
/submission
|
|
828
|
+
/submission requires the v9 provider contract/,
|
|
826
829
|
);
|
|
827
830
|
|
|
828
831
|
const validator = roleInput("provider_targeted_validator", "review.capture-validation", "https://gentle-ai.dev/schema/review/validator/v1");
|
|
@@ -837,6 +840,87 @@ test("next_transition decodes the self-contained provider role capture vectors s
|
|
|
837
840
|
);
|
|
838
841
|
});
|
|
839
842
|
|
|
843
|
+
// gentle-ai's v9 contract makes the same two role operations host-mediated
|
|
844
|
+
// exactly like a lens materialize slot: the input carries a submission
|
|
845
|
+
// descriptor alongside --materialize=true (never --execute), and the host
|
|
846
|
+
// completes the frozen prompt in-process and submits through that
|
|
847
|
+
// descriptor instead of executing a Go-owned pi subprocess.
|
|
848
|
+
test("next_transition decodes the v9 host-mediated provider role submission form", () => {
|
|
849
|
+
const materializeArguments = [
|
|
850
|
+
{ name: "lineage", value: "review-fixture", token: "--lineage=review-fixture" },
|
|
851
|
+
{ name: "expected-revision", value: digest, token: `--expected-revision=${digest}` },
|
|
852
|
+
{ name: "target", value: digest, token: `--target=${digest}` },
|
|
853
|
+
{ name: "repository-context", value: `rctx1_${"c".repeat(64)}`, token: `--repository-context=rctx1_${"c".repeat(64)}` },
|
|
854
|
+
{ name: "agent", value: "pi", token: "--agent=pi" },
|
|
855
|
+
{ name: "materialize", value: "true", token: "--materialize=true" },
|
|
856
|
+
];
|
|
857
|
+
const roleInput = (name: string, captureOperation: string, schema: string, extra: Record<string, unknown> = {}) => ({
|
|
858
|
+
kind: "collect",
|
|
859
|
+
reason_code: "provider_refuter_required",
|
|
860
|
+
collect: { inputs: [{ name, schema, capture_operation: captureOperation, arguments: materializeArguments, ...extra }] },
|
|
861
|
+
});
|
|
862
|
+
// This decoder path is gated on v9 specifically -- the sibling gentle-ai
|
|
863
|
+
// branch's contract, ahead of the currently-released v8 -- not on v5,
|
|
864
|
+
// which v8 (and v6/v7) already satisfy.
|
|
865
|
+
const decodeRoleTransition = (value: unknown) => decodeReviewNextTransitionV3(value, { v9: true });
|
|
866
|
+
const refuterSubmission = {
|
|
867
|
+
operation_token: "capture-refuter",
|
|
868
|
+
argument_tokens: [...materializeArguments.map((argument) => argument.token), "--input={{value}}"],
|
|
869
|
+
value: { slot: "provider_refuter", domain: "artifact_path_or_stdin", schema: "https://gentle-ai.dev/schema/review/refuter/v1", substitution_location: materializeArguments.length },
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
const refuter = roleInput("provider_refuter", "review.capture-refuter", "https://gentle-ai.dev/schema/review/refuter/v1", { submission: refuterSubmission });
|
|
873
|
+
const decodedSubmission = decodeRoleTransition(refuter).collect?.inputs[0]?.submission;
|
|
874
|
+
assert.equal(decodedSubmission?.operationToken, "capture-refuter");
|
|
875
|
+
assert.equal(decodedSubmission?.argumentTokens.length, materializeArguments.length + 1);
|
|
876
|
+
assert.deepEqual(decodedSubmission?.values, [{
|
|
877
|
+
slot: "provider_refuter",
|
|
878
|
+
domain: "artifact_path_or_stdin",
|
|
879
|
+
schema: "https://gentle-ai.dev/schema/review/refuter/v1",
|
|
880
|
+
substitutionLocation: materializeArguments.length,
|
|
881
|
+
}]);
|
|
882
|
+
|
|
883
|
+
// The exact same payload under v8 (or any earlier non-v9 rung) is
|
|
884
|
+
// rejected: v8 only extended the reviewer-result transition for OpenCode
|
|
885
|
+
// provider tasks and never renders a role submission descriptor.
|
|
886
|
+
assert.throws(
|
|
887
|
+
() => decodeReviewNextTransitionV3(refuter, { v6: true }),
|
|
888
|
+
/submission requires the v9 provider contract/,
|
|
889
|
+
);
|
|
890
|
+
|
|
891
|
+
// A submission descriptor with no --materialize=true (and no --execute
|
|
892
|
+
// either) matches neither wire form.
|
|
893
|
+
assert.throws(
|
|
894
|
+
() => decodeRoleTransition(roleInput("provider_refuter", "review.capture-refuter", "https://gentle-ai.dev/schema/review/refuter/v1", {
|
|
895
|
+
submission: refuterSubmission,
|
|
896
|
+
arguments: materializeArguments.filter((argument) => argument.name !== "materialize"),
|
|
897
|
+
})),
|
|
898
|
+
/submission requires --materialize=true/,
|
|
899
|
+
);
|
|
900
|
+
|
|
901
|
+
// --execute alongside a submission descriptor mixes both wire forms on
|
|
902
|
+
// one input, which is never valid.
|
|
903
|
+
assert.throws(
|
|
904
|
+
() => decodeRoleTransition(roleInput("provider_refuter", "review.capture-refuter", "https://gentle-ai.dev/schema/review/refuter/v1", {
|
|
905
|
+
submission: refuterSubmission,
|
|
906
|
+
arguments: [...materializeArguments, { name: "execute", value: "true", token: "--execute=true" }],
|
|
907
|
+
})),
|
|
908
|
+
/must not carry --execute alongside a submission descriptor/,
|
|
909
|
+
);
|
|
910
|
+
|
|
911
|
+
// The targeted-validator's host-mediated form still requires its
|
|
912
|
+
// validation_request, exactly like the self-contained vector.
|
|
913
|
+
const validatorSubmission = {
|
|
914
|
+
operation_token: "capture-validation",
|
|
915
|
+
argument_tokens: [...materializeArguments.map((argument) => argument.token), "--input={{value}}"],
|
|
916
|
+
value: { slot: "provider_targeted_validator", domain: "artifact_path_or_stdin", schema: "https://gentle-ai.dev/schema/review/validator/v1", substitution_location: materializeArguments.length },
|
|
917
|
+
};
|
|
918
|
+
assert.throws(
|
|
919
|
+
() => decodeRoleTransition(roleInput("provider_targeted_validator", "review.capture-validation", "https://gentle-ai.dev/schema/review/validator/v1", { submission: validatorSubmission })),
|
|
920
|
+
/validation_request is required/,
|
|
921
|
+
);
|
|
922
|
+
});
|
|
923
|
+
|
|
840
924
|
test("v5 targeted-validator collect inputs carry the exact provider-owned validation request", () => {
|
|
841
925
|
const requestHash = `sha256:${"a".repeat(64)}`;
|
|
842
926
|
const expectedRevision = `sha256:${"b".repeat(64)}`;
|
|
@@ -913,6 +997,37 @@ test("v5 targeted-validator collect inputs carry the exact provider-owned valida
|
|
|
913
997
|
}]);
|
|
914
998
|
assert.deepEqual(decoded.collect?.inputs[0]?.arguments, input.arguments, "provider-rendered arguments must remain unchanged");
|
|
915
999
|
|
|
1000
|
+
// The same validation_request rides the v9 host-mediated form too: swap
|
|
1001
|
+
// --execute=true for --materialize=true and add the provider-owned
|
|
1002
|
+
// submission descriptor.
|
|
1003
|
+
const materializeArguments = input.arguments.map((argument) => argument.name === "execute" ? { name: "materialize", value: "true", token: "--materialize=true" } : argument);
|
|
1004
|
+
const hostMediatedInput = {
|
|
1005
|
+
...input,
|
|
1006
|
+
arguments: materializeArguments,
|
|
1007
|
+
submission: {
|
|
1008
|
+
operation_token: "capture-validation",
|
|
1009
|
+
argument_tokens: [...materializeArguments.map((argument) => argument.token), "--input={{value}}"],
|
|
1010
|
+
value: { slot: "provider_targeted_validator", domain: "artifact_path_or_stdin", schema: "https://gentle-ai.dev/schema/review/validator/v1", substitution_location: materializeArguments.length },
|
|
1011
|
+
},
|
|
1012
|
+
};
|
|
1013
|
+
const hostMediatedTransition = { kind: "collect" as const, reason_code: "targeted_validation_required", collect: { inputs: [hostMediatedInput] } };
|
|
1014
|
+
const decodedHostMediated = decodeReviewNextTransitionV3(hostMediatedTransition, { v9: true });
|
|
1015
|
+
assert.equal(decodedHostMediated.collect?.inputs[0]?.submission?.operationToken, "capture-validation");
|
|
1016
|
+
assert.deepEqual(decodedHostMediated.collect?.inputs[0]?.submission?.values, [{
|
|
1017
|
+
slot: "provider_targeted_validator",
|
|
1018
|
+
domain: "artifact_path_or_stdin",
|
|
1019
|
+
schema: "https://gentle-ai.dev/schema/review/validator/v1",
|
|
1020
|
+
substitutionLocation: materializeArguments.length,
|
|
1021
|
+
}]);
|
|
1022
|
+
assert.equal(decodedHostMediated.collect?.inputs[0]?.validationRequest?.requestHash, requestHash);
|
|
1023
|
+
|
|
1024
|
+
// The identical v8 surface (v6/v7/v8 all decode collect inputs alike)
|
|
1025
|
+
// rejects the same host-mediated submission: only v9 renders it.
|
|
1026
|
+
assert.throws(
|
|
1027
|
+
() => decodeReviewNextTransitionV3(hostMediatedTransition, { v6: true }),
|
|
1028
|
+
/submission requires the v9 provider contract/,
|
|
1029
|
+
);
|
|
1030
|
+
|
|
916
1031
|
const missingRequest = clone(transition);
|
|
917
1032
|
delete (missingRequest.collect.inputs[0] as JsonObject).validation_request;
|
|
918
1033
|
assert.throws(() => decodeReviewNextTransitionV3(missingRequest, { v5: true }), /validation_request is required/);
|
|
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import test from "node:test";
|
|
7
7
|
import { __testing } from "../extensions/gentle-ai.ts";
|
|
8
|
+
import { REVIEW_HOST_RELAY_FAILURE, ReviewHostRelayError } from "../lib/review-host-relay.ts";
|
|
8
9
|
import { NativeReviewIntegrationError, type NativeReviewCli } from "../lib/native-review-cli.ts";
|
|
9
10
|
import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
|
|
10
11
|
import type { ReviewCollectInputV3, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
|
|
@@ -186,6 +187,88 @@ test("the negotiated status asks for the pi agent so the provider offers its mat
|
|
|
186
187
|
assert.equal(hostRelay.transport, "pi_host_relay");
|
|
187
188
|
});
|
|
188
189
|
|
|
190
|
+
// gentle-pi#311 P2 (superseding gentle-shell#1136 / #1158): the lens's
|
|
191
|
+
// user-owned completion selection (agent model routing config) rides the
|
|
192
|
+
// relay request alongside the live model registry; there is no extension
|
|
193
|
+
// allowlist and no ambient default model to fall back to.
|
|
194
|
+
test("capture forwards the lens's user-owned selection and thinking level to the relay request", async (t) => {
|
|
195
|
+
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
196
|
+
const configHome = mkdtempSync(join(tmpdir(), "gentle-pi-relay-config-"));
|
|
197
|
+
const cwd = repository(t);
|
|
198
|
+
t.after(() => rmSync(configHome, { recursive: true, force: true }));
|
|
199
|
+
writeFileSync(join(configHome, "models.json"), JSON.stringify({ "review-reliability": { model: "minimax/MiniMax-M3", thinking: "high" } }), "utf8");
|
|
200
|
+
const previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
|
|
201
|
+
process.env.GENTLE_PI_CONFIG_HOME = configHome;
|
|
202
|
+
t.after(() => {
|
|
203
|
+
if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
|
|
204
|
+
else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
|
|
205
|
+
});
|
|
206
|
+
const { native } = transportAwareNative();
|
|
207
|
+
const relayed: ReviewHostRelayRequest[] = [];
|
|
208
|
+
__testing.setReviewHostRelayRunnerForTesting(async (request: ReviewHostRelayRequest) => {
|
|
209
|
+
relayed.push(request);
|
|
210
|
+
return { promptByteLength: 128, resultByteLength: 64, submission: '{"admission_decision":"completed"}' };
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
await runCapture(cwd, native, "selection-lineage");
|
|
214
|
+
assert.equal(relayed.length, 1);
|
|
215
|
+
assert.equal(relayed[0]!.selection, "minimax/MiniMax-M3", "the lens's routing entry must name the completion's selection");
|
|
216
|
+
assert.equal(relayed[0]!.thinking, "high");
|
|
217
|
+
assert.equal(relayed[0]!.routingKey, "review-reliability");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// gentle-pi#311 P2 decision (flagged for confirmation): the in-process path
|
|
221
|
+
// has no ambient default model. A routing entry with no configured model used
|
|
222
|
+
// to launch the child selection-free (inheriting pi's own default); the real
|
|
223
|
+
// relay now refuses it typed instead, since there is no child to inherit a
|
|
224
|
+
// default from (lib/review-host-relay.ts's own tests exercise that refusal
|
|
225
|
+
// through the real, unfaked runner). This extension layer only forwards
|
|
226
|
+
// whatever the routing config yields — it never validates the selection
|
|
227
|
+
// itself — so with the runner faked here the request still reaches it, and
|
|
228
|
+
// its `selection` field is simply absent.
|
|
229
|
+
test("capture forwards no selection when the lens has no configured model, leaving the missing-model refusal to the relay", async (t) => {
|
|
230
|
+
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
231
|
+
const configHome = mkdtempSync(join(tmpdir(), "gentle-pi-relay-config-empty-"));
|
|
232
|
+
const cwd = repository(t);
|
|
233
|
+
t.after(() => rmSync(configHome, { recursive: true, force: true }));
|
|
234
|
+
const previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
|
|
235
|
+
process.env.GENTLE_PI_CONFIG_HOME = configHome;
|
|
236
|
+
t.after(() => {
|
|
237
|
+
if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
|
|
238
|
+
else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
|
|
239
|
+
});
|
|
240
|
+
const { native } = transportAwareNative();
|
|
241
|
+
const relayed: ReviewHostRelayRequest[] = [];
|
|
242
|
+
__testing.setReviewHostRelayRunnerForTesting(async (request: ReviewHostRelayRequest) => {
|
|
243
|
+
relayed.push(request);
|
|
244
|
+
return { promptByteLength: 128, resultByteLength: 64, submission: '{"admission_decision":"completed"}' };
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
await runCapture(cwd, native, "default-lineage");
|
|
248
|
+
assert.equal(relayed.length, 1);
|
|
249
|
+
assert.equal(relayed[0]!.selection, undefined);
|
|
250
|
+
assert.equal(relayed[0]!.routingKey, "review-reliability");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("a relayed empty-output failure carries the child's own evidence in the failure report", async (t) => {
|
|
254
|
+
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
255
|
+
const cwd = repository(t);
|
|
256
|
+
const { native } = transportAwareNative();
|
|
257
|
+
__testing.setReviewHostRelayRunnerForTesting(async () => {
|
|
258
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT, "pi", "pi subprocess produced no assistant text (stdout kind: no-assistant-text; a tool call was attempted)", {
|
|
259
|
+
reviewerEvidence: { stdoutKind: "no-assistant-text", reviewerModel: "nan/deepseek-v4-flash", toolCallAttempted: true },
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const result = await runCapture(cwd, native, "evidence-lineage");
|
|
264
|
+
assert.equal(result.outcome, "pi-host-relay-transport-failure");
|
|
265
|
+
const failure = result.failure as { reviewer?: { stdoutKind?: string; reviewerModel?: string; toolCallAttempted?: boolean } } | undefined;
|
|
266
|
+
assert.ok(failure !== undefined, "the envelope carries the failure report");
|
|
267
|
+
assert.equal(failure.reviewer?.stdoutKind, "no-assistant-text");
|
|
268
|
+
assert.equal(failure.reviewer?.reviewerModel, "nan/deepseek-v4-flash");
|
|
269
|
+
assert.equal(failure.reviewer?.toolCallAttempted, true);
|
|
270
|
+
});
|
|
271
|
+
|
|
189
272
|
test("capture forecasts the reviewer model run once and spends nothing until it is acknowledged", async (t) => {
|
|
190
273
|
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
191
274
|
const cwd = repository(t);
|
|
@@ -524,7 +524,18 @@ async function run() {
|
|
|
524
524
|
|
|
525
525
|
const toolCwd = await tempWorkspace();
|
|
526
526
|
try {
|
|
527
|
+
execFileSync("git", ["init"], { cwd: toolCwd, stdio: "ignore" });
|
|
527
528
|
const toolHook = hooks.get("tool_call")[0];
|
|
529
|
+
const toolResultHook = hooks.get("tool_result")[0];
|
|
530
|
+
const promptHook = hooks.get("before_agent_start")[0];
|
|
531
|
+
const oddCtx = createCtx(toolCwd, false, "odd-runtime-gate");
|
|
532
|
+
await promptHook({ systemPrompt: "primary" }, oddCtx);
|
|
533
|
+
const firstOddPath = join(toolCwd, "first.ts");
|
|
534
|
+
assert.equal(await toolHook({ toolName: "write", input: { path: firstOddPath } }, oddCtx), undefined);
|
|
535
|
+
await toolResultHook({ toolName: "write", toolCallId: "odd-first", input: { path: firstOddPath }, isError: false }, oddCtx);
|
|
536
|
+
const secondOdd = await toolHook({ toolName: "edit", input: { path: join(toolCwd, "second.ts") } }, oddCtx);
|
|
537
|
+
assert.equal(secondOdd?.block, true, "the real primary hook must stop a second distinct direct file");
|
|
538
|
+
assert.match(secondOdd?.reason ?? "", /subagent_run/);
|
|
528
539
|
const ghPrCwd = await tempWorkspace();
|
|
529
540
|
try {
|
|
530
541
|
execFileSync("git", ["init"], { cwd: ghPrCwd, stdio: "ignore" });
|
|
@@ -36,3 +36,30 @@ test("reload and new evidence refresh Changes without Git or live file reads",as
|
|
|
36
36
|
assert.equal(typeof f.widgets.get("gentle-shell-changes"),"function");
|
|
37
37
|
await f.fire("session_shutdown");
|
|
38
38
|
});
|
|
39
|
+
test("the changes overlay labels each session tree with its root's branch instead of detached",async()=>{
|
|
40
|
+
const evidence=(root:string,id:string)=>({type:"custom",customType:SESSION_CHANGE_ENTRY,data:{sessionId:"session",evidence:{id,root,path:"own.ts",before:{kind:"absent"},after:{kind:"text",text:"own\n"}}}});
|
|
41
|
+
const entries=[evidence("/repo","child:1"),evidence("/other","child:2")];
|
|
42
|
+
const handlers=new Map<string,Function[]>();
|
|
43
|
+
const commands=new Map<string,any>();
|
|
44
|
+
const gitRoots:string[]=[];
|
|
45
|
+
const pi:any={on:(key,fn)=>handlers.set(key,[...(handlers.get(key)??[]),fn]),events:{on:()=>()=>{},emit(){}},appendEntry(){},registerTool(){},registerShortcut(){},registerMessageRenderer(){},registerCommand:(key,registration)=>commands.set(key,registration)};
|
|
46
|
+
let view:any; let renders=0;
|
|
47
|
+
const ctx:any={hasUI:true,cwd:"/repo",sessionManager:{getSessionId:()=>"session",getEntries:()=>entries},
|
|
48
|
+
ui:{setFooter(){},getEditorComponent:()=>({}),setWorkingVisible(){},setWidget(){},notify(){},
|
|
49
|
+
custom:async(factory:any)=>{view=factory({terminal:{rows:40,columns:120},requestRender:()=>renders++},{fg:(_c:string,t:string)=>t,bold:(t:string)=>t},{},()=>{});return new Promise(()=>{});}}};
|
|
50
|
+
shell(pi,{},{resolveWorktree:()=>({root:"/repo",commonDir:"/git"}),devBinary:()=>undefined,
|
|
51
|
+
gitRunner:(root:string)=>async(args:string[])=>{gitRoots.push(root);await new Promise(r=>setImmediate(r));
|
|
52
|
+
if(args[0]==="symbolic-ref")return root==="/repo"?{stdout:"feature\n",code:0}:{stdout:"",code:1};
|
|
53
|
+
return {stdout:"deadbeef\n",code:0};}});
|
|
54
|
+
for(const fn of handlers.get("session_start")??[])await fn({},ctx);
|
|
55
|
+
void commands.get("gentle:changes").handler("",ctx);
|
|
56
|
+
await new Promise(r=>setImmediate(r));
|
|
57
|
+
assert.ok(view,"the overlay opened");
|
|
58
|
+
for(let i=0;i<6;i++)await new Promise(r=>setImmediate(r));
|
|
59
|
+
const rendered=view.render(120).join("\n");
|
|
60
|
+
assert.match(rendered,/feature · repo/,"the repo tree carries its branch");
|
|
61
|
+
assert.match(rendered,/detached · other/,"a genuinely detached root still reads detached");
|
|
62
|
+
assert.deepEqual([...new Set(gitRoots)].sort(),["/other","/repo"],"git is asked once per root, only while the overlay is open");
|
|
63
|
+
assert.ok(renders>0,"a resolved label asks the overlay to repaint");
|
|
64
|
+
for(const fn of handlers.get("session_shutdown")??[])await fn({},ctx);
|
|
65
|
+
});
|
|
@@ -133,3 +133,44 @@ test("only standard path-bearing calls have registration candidates; shell and p
|
|
|
133
133
|
for (const name of ["bash", "powershell", "custom", "subagent_run"]) assert.equal(toolWorktreePath(name, { path: "/linked", command: "cd /linked", task: "/linked" }), undefined);
|
|
134
134
|
assert.equal(toolWorktreePath("read", { path: 42 }), undefined);
|
|
135
135
|
});
|
|
136
|
+
|
|
137
|
+
// C2 (odd/tasks/usage-click-and-changes-attribution.md): a repo nested inside
|
|
138
|
+
// another repo (the live session's ~/work/NaN-builders inside ~/work) must
|
|
139
|
+
// resolve to the INNER repo, never the outer one, because Git itself walks
|
|
140
|
+
// up from the file's own directory and stops at the first .git it finds.
|
|
141
|
+
function nestedFixture(t: test.TestContext) {
|
|
142
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), "session-worktrees-nested-")));
|
|
143
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
144
|
+
const outer = join(dir, "work");
|
|
145
|
+
const inner = join(outer, "NaN-builders");
|
|
146
|
+
const empty = join(dir, "empty");
|
|
147
|
+
mkdirSync(empty);
|
|
148
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")));
|
|
149
|
+
Object.assign(env, { GIT_CONFIG_GLOBAL: join(empty, "config"), GIT_CONFIG_NOSYSTEM: "1", GIT_ATTR_NOSYSTEM: "1" });
|
|
150
|
+
writeFileSync(join(empty, "config"), "");
|
|
151
|
+
const git = (cwd: string, args: string[]) => execFileSync("git", ["-C", cwd, "-c", `core.hooksPath=${empty}`, "-c", "commit.gpgsign=false", ...args], { env, stdio: ["ignore", "pipe", "pipe"] });
|
|
152
|
+
// Outer repo: no commits, matching the live evidence's `~/work` exactly.
|
|
153
|
+
git(dir, ["init", "--initial-branch=main", `--template=${empty}`, outer]);
|
|
154
|
+
// Inner repo: its own .git, a real branch and a commit.
|
|
155
|
+
git(dir, ["init", "--initial-branch=feature", `--template=${empty}`, inner]);
|
|
156
|
+
git(inner, ["-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "--allow-empty", "-m", "Fixture"]);
|
|
157
|
+
mkdirSync(join(inner, "odd", "tasks"), { recursive: true });
|
|
158
|
+
writeFileSync(join(inner, "odd", "tasks", "jpg-png-converter.md"), "converted\n");
|
|
159
|
+
return { dir, outer, inner };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
test("nearest-repository resolution: an inner repo's files never resolve to an outer ancestor repo", (t) => {
|
|
163
|
+
const f = nestedFixture(t);
|
|
164
|
+
const forFile = resolveSessionWorktree(join("NaN-builders", "odd", "tasks", "jpg-png-converter.md"), f.outer);
|
|
165
|
+
assert.equal(forFile?.root, f.inner, "the file's own nearest repository must win, not the outer ~/work repo");
|
|
166
|
+
// The inner repo's own root and files resolve to itself too, whether
|
|
167
|
+
// addressed from the outer cwd or the inner cwd directly.
|
|
168
|
+
const forRoot = resolveSessionWorktree("NaN-builders", f.outer);
|
|
169
|
+
assert.equal(forRoot?.root, f.inner);
|
|
170
|
+
const fromInnerCwd = resolveSessionWorktree(join("odd", "tasks", "jpg-png-converter.md"), f.inner);
|
|
171
|
+
assert.equal(fromInnerCwd?.root, f.inner);
|
|
172
|
+
// The outer repo is still resolvable for its own files.
|
|
173
|
+
const outerFile = join(f.outer, "README.md");
|
|
174
|
+
writeFileSync(outerFile, "outer\n");
|
|
175
|
+
assert.equal(resolveSessionWorktree("README.md", f.outer)?.root, f.outer);
|
|
176
|
+
});
|