gentle-pi 2.3.0 → 2.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 +195 -11
- package/assets/agents/gentle-ai-worker.md +9 -0
- package/assets/agents/sdd-explore.md +1 -0
- package/assets/orchestrator-delegation.md +21 -10
- package/assets/orchestrator.md +8 -12
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
- package/docs/delegated-verification.md +25 -0
- package/docs/review-integration.md +1 -1
- package/docs/telemetry.md +38 -0
- package/extensions/ask-user-choice.ts +26 -20
- package/extensions/codegraph-tools.ts +94 -5
- package/extensions/gentle-agents.ts +588 -0
- package/extensions/gentle-ai.ts +1421 -143
- package/extensions/gentle-shell.ts +547 -0
- package/extensions/gentle-todo.ts +199 -0
- package/extensions/quiet-tools.ts +1 -1
- package/lib/agent-home.ts +8 -0
- package/lib/agents-config.ts +318 -0
- package/lib/agents-history.ts +80 -0
- package/lib/agents-protocol.ts +429 -0
- package/lib/agents-runner.ts +490 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view.ts +557 -0
- package/lib/agents-widget.ts +222 -0
- package/lib/gentle-ai-renderer.ts +142 -26
- package/lib/native-choice-list.ts +194 -0
- package/lib/native-fullscreen-interaction.ts +47 -0
- package/lib/native-pointer-region.ts +164 -0
- package/lib/native-review-cli.ts +103 -12
- package/lib/provider-contract-bundle.ts +88 -6
- package/lib/review-candidate-view-owner.ts +177 -0
- package/lib/review-candidate-view.ts +127 -35
- package/lib/review-consent-ui.ts +65 -0
- package/lib/review-host-relay.ts +146 -60
- package/lib/review-integration-v2.ts +92 -13
- package/lib/review-last-event-controller.ts +1 -0
- package/lib/review-relay-contract.ts +11 -0
- package/lib/review-repository.ts +2 -2
- package/lib/review-risk-assessment.ts +339 -0
- package/lib/review-session-standing-permission-ipc.ts +309 -0
- package/lib/review-session-standing-permission.ts +219 -0
- package/lib/sdd-preflight.ts +2 -2
- package/lib/shell-bar.ts +138 -0
- package/lib/shell-card.ts +136 -0
- package/lib/shell-changes-view.ts +205 -0
- package/lib/shell-changes.ts +210 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +119 -0
- package/lib/shell-todo.ts +280 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +151 -0
- package/package.json +4 -4
- package/runtime/native-review-cli.mjs +102 -11
- package/runtime/review-integration-v2.mjs +92 -13
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +152 -0
- package/scripts/build-runtime-modules.mjs +2 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/test-packed-runner.mjs +22 -0
- package/scripts/verify-package-files.mjs +18 -13
- package/skills/_shared/review-ledger-contract.md +9 -1
- package/skills/issue-creation/SKILL.md +53 -93
- package/tests/agents-config.test.ts +143 -0
- package/tests/agents-fake-child.ts +52 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-protocol.test.ts +153 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +402 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +274 -0
- package/tests/agents-widget.test.ts +111 -0
- package/tests/ask-user-choice.test.ts +157 -3
- package/tests/codegraph-tools.test.ts +110 -1
- package/tests/devbinary/native-review-parity.devtest.ts +108 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
- package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
- package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
- package/tests/gentle-agents.test.ts +741 -0
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +65 -0
- package/tests/gentle-ai.test.ts +31 -14
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +527 -0
- package/tests/gentle-todo.test.ts +182 -0
- package/tests/issue-creation-skill.test.ts +103 -0
- package/tests/native-choice-list.test.ts +202 -0
- package/tests/native-fullscreen-interaction.test.ts +125 -0
- package/tests/native-pointer-region.test.ts +245 -0
- package/tests/native-review-capability-contract.test.ts +33 -1
- package/tests/native-review-cli.test.ts +40 -0
- package/tests/native-review-consent.test.ts +91 -0
- package/tests/native-review-parity-runtime.test.ts +8 -2
- package/tests/native-review-parity.test.ts +29 -22
- package/tests/orchestrator-budget.test.ts +71 -2
- package/tests/orchestrator-rdd-ownership.test.ts +10 -1
- package/tests/package-manifest.test.ts +134 -9
- package/tests/provider-contract-bundle.test.ts +76 -0
- package/tests/provider-contract-mirror.test.ts +19 -0
- package/tests/quiet-tool-rendering.test.ts +96 -37
- package/tests/rdd-aware-verification-contract.test.ts +216 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-agent-end-preflight.test.ts +408 -0
- package/tests/review-candidate-view.test.ts +452 -6
- package/tests/review-contract-prompt.test.ts +142 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +321 -4
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +26 -1
- package/tests/review-host-relay-routing.test.ts +229 -11
- package/tests/review-host-relay.test.ts +195 -7
- package/tests/review-integration-v2-forward.test.ts +47 -0
- package/tests/review-integration-v2.test.ts +112 -0
- package/tests/review-last-event-closure.test.ts +7 -2
- package/tests/review-ledger-contract.test.ts +1 -1
- package/tests/review-relay-contract.test.ts +26 -0
- package/tests/review-repository.test.ts +28 -1
- package/tests/review-risk-assessment.test.ts +626 -0
- package/tests/review-session-standing-permission-controller.test.ts +608 -0
- package/tests/review-session-standing-permission-ipc.test.ts +233 -0
- package/tests/review-session-standing-permission-runtime.test.ts +212 -0
- package/tests/review-session-standing-permission.test.ts +126 -0
- package/tests/runtime-harness.mjs +1 -0
- package/tests/shell-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +118 -0
- package/tests/shell-changes-view.test.ts +146 -0
- package/tests/shell-changes.test.ts +182 -0
- package/tests/shell-prompt.test.ts +118 -0
- package/tests/shell-todo.test.ts +170 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- package/tests/telemetry-trigger.test.ts +349 -0
- package/tests/writer-edit-surface-scope.test.ts +153 -17
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
- /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
|
@@ -6,9 +6,9 @@ import { join } from "node:path";
|
|
|
6
6
|
import test from "node:test";
|
|
7
7
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { __testing, createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
9
|
-
import type
|
|
9
|
+
import { NativeReviewIntegrationError, type NativeReviewCli } from "../lib/native-review-cli.ts";
|
|
10
10
|
import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
|
|
11
|
-
import type
|
|
11
|
+
import { decodeReviewFailureV2, type AuthorityRepairAssessmentV1, type ReviewStatusV3 } from "../lib/review-integration-v2.ts";
|
|
12
12
|
|
|
13
13
|
interface RegisteredTool {
|
|
14
14
|
execute: (
|
|
@@ -240,6 +240,49 @@ test("INSPECT and STATUS operate on the explicit workspace root while the sessio
|
|
|
240
240
|
assert.deepEqual(observedCwds, [realpathSync(worktree), realpathSync(worktree), sessionCwd]);
|
|
241
241
|
});
|
|
242
242
|
|
|
243
|
+
// gentle-pi#599: inspect without an explicit workspaceRoot against a parent
|
|
244
|
+
// repository that wraps an untracked nested Git repository fails at the
|
|
245
|
+
// native preflight with a decoded, actionable `invalid_request` envelope. The
|
|
246
|
+
// facade used to discard that envelope's cause, code, retry_safe, and
|
|
247
|
+
// next_action in favor of a generic `native-status-unavailable` outcome
|
|
248
|
+
// indistinguishable from authority corruption. It must preserve the native
|
|
249
|
+
// envelope instead.
|
|
250
|
+
test("inspect without workspaceRoot preserves the native invalid_request cause for a nested foreign Git repository", async (t) => {
|
|
251
|
+
const sessionCwd = repository(t);
|
|
252
|
+
const { controller } = runtime(fakeNative({
|
|
253
|
+
targetStatus: async () => {
|
|
254
|
+
throw new NativeReviewIntegrationError(decodeReviewFailureV2({
|
|
255
|
+
schema: "gentle-ai.review-integration.failure/v2",
|
|
256
|
+
contract: "gentle-ai.review-integration/v2",
|
|
257
|
+
operation: "review.status",
|
|
258
|
+
phase: "preflight",
|
|
259
|
+
code: "invalid_request",
|
|
260
|
+
message: "The negotiated review request is invalid.",
|
|
261
|
+
mutation_outcome: "not_started",
|
|
262
|
+
authority_applicability: "not_evaluated",
|
|
263
|
+
retry_safe: true,
|
|
264
|
+
replayability: "not_replayable",
|
|
265
|
+
required_inputs: [],
|
|
266
|
+
next_action: "correct_request",
|
|
267
|
+
cause: "untracked directory \"engram\" holds another Git repository that is not a linked worktree of this one, so it cannot enter the review candidate: add it to .gitignore, move it outside this repository, or register it as a linked worktree",
|
|
268
|
+
}));
|
|
269
|
+
},
|
|
270
|
+
}));
|
|
271
|
+
const result = await controller.execute("inspect-nested-foreign-repo", { operation: "inspect" }, undefined, undefined, context(sessionCwd));
|
|
272
|
+
const details = result.details as {
|
|
273
|
+
status?: string;
|
|
274
|
+
outcome?: string;
|
|
275
|
+
next_action?: string;
|
|
276
|
+
native_failure?: { code?: string; cause?: string; retry_safe?: boolean };
|
|
277
|
+
};
|
|
278
|
+
assert.equal(details.status, "blocked");
|
|
279
|
+
assert.equal(details.outcome, "native-status-unavailable");
|
|
280
|
+
assert.equal(details.native_failure?.code, "invalid_request");
|
|
281
|
+
assert.equal(details.native_failure?.retry_safe, true);
|
|
282
|
+
assert.match(details.native_failure?.cause ?? "", /engram/);
|
|
283
|
+
assert.equal(details.next_action, "correct_request");
|
|
284
|
+
});
|
|
285
|
+
|
|
243
286
|
test("START freezes the candidate from the explicit workspace root and returns the actor binding envelope", async (t) => {
|
|
244
287
|
const sessionCwd = repository(t);
|
|
245
288
|
writeFileSync(join(sessionCwd, "unrelated.ts"), "export const unrelated = true;\n");
|
|
@@ -363,6 +363,7 @@ test("general STATUS returns the typed native-status-unsupported boundary withou
|
|
|
363
363
|
mutation_performed: false,
|
|
364
364
|
inventory_complete: false,
|
|
365
365
|
next_action: "require-upstream-read-only-native-status-inventory",
|
|
366
|
+
remediation_command: "gentle-ai review status --cwd <repo> --contract gentle-ai.review-integration/v2 --agent pi --next-transition",
|
|
366
367
|
evidence: {
|
|
367
368
|
native_contract: "gentle-ai/2.1.4",
|
|
368
369
|
general_status: "unsupported",
|
|
@@ -371,6 +372,30 @@ test("general STATUS returns the typed native-status-unsupported boundary withou
|
|
|
371
372
|
});
|
|
372
373
|
});
|
|
373
374
|
|
|
375
|
+
test("gentle-pi#185: general STATUS on a non-negotiated native CLI names the exact status command to run", async (t) => {
|
|
376
|
+
// The legacy `correctionForecast` restoration guard this issue originally
|
|
377
|
+
// reported (extensions/gentle-ai.ts, then around line 5329) was scoped to
|
|
378
|
+
// `targetStatus !== undefined` and skipped restoring a candidate view when
|
|
379
|
+
// the native CLI lacked negotiated STATUS support, reproducing the #176
|
|
380
|
+
// empty-registry failure. That entire manual FINALIZE lifecycle (and the
|
|
381
|
+
// `correctionForecast` guard with it) has since been replaced by the
|
|
382
|
+
// STATUS/capture-driven flow: every operation now checks negotiated STATUS
|
|
383
|
+
// support up front, so no candidate-view restoration is ever attempted on
|
|
384
|
+
// a non-negotiated CLI. This test proves the current equivalent boundary
|
|
385
|
+
// (`nativeStatusUnsupported`) fails closed with an actionable envelope
|
|
386
|
+
// instead of a bare machine token, so a non-negotiated CLI can never
|
|
387
|
+
// surface as a silent empty-registry failure.
|
|
388
|
+
const fixture = createRepository(t);
|
|
389
|
+
const { controller } = registerRuntime();
|
|
390
|
+
const result = await controllerCall(controller, extensionContext(fixture.repository), { operation: "status" });
|
|
391
|
+
assert.equal(result.status, "blocked");
|
|
392
|
+
assert.equal(result.outcome, "native-status-unsupported");
|
|
393
|
+
assert.equal(
|
|
394
|
+
result.remediation_command,
|
|
395
|
+
"gentle-ai review status --cwd <repo> --contract gentle-ai.review-integration/v2 --agent pi --next-transition",
|
|
396
|
+
);
|
|
397
|
+
});
|
|
398
|
+
|
|
374
399
|
test("failed START gives exact mode and serialization guidance and creates no lineage", async (t) => {
|
|
375
400
|
const fixture = createRepository(t);
|
|
376
401
|
const { controller } = registerRuntime();
|
|
@@ -421,7 +446,7 @@ test("shipped controller fails closed while static prompts defer RDD lifecycle o
|
|
|
421
446
|
assert.match(toolContract, /ambiguous START output.*target-scoped native status.*declared action.*ambiguous gentle_review_capture.*never replays/is);
|
|
422
447
|
assert.doesNotMatch(toolContract, /START throws.*lineage does not exist/is);
|
|
423
448
|
|
|
424
|
-
const boundary = "
|
|
449
|
+
const boundary = "This package injects the mirrored provider-bundle review execution contract into this session's system prompt at start; Gentle AI writes nothing into the Pi system prompt, and this package owns everything else here. Absent that mirrored contract, this package invents no lifecycle instructions.";
|
|
425
450
|
const core = readFileSync("assets/orchestrator.md", "utf8");
|
|
426
451
|
assert.match(core, new RegExp(boundary.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
427
452
|
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
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
8
|
import type { NativeReviewCli } from "../lib/native-review-cli.ts";
|
|
9
9
|
import { REVIEW_HOST_RELAY_FAILURE, REVIEW_HOST_RELAY_PI_TIMEOUT_ENV, REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS, REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE, REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, ReviewHostRelayError, type ReviewHostRelayRequest } from "../lib/review-host-relay.ts";
|
|
10
|
-
import type
|
|
10
|
+
import { decodeReviewStatusV3, type ReviewArtifactSubjectV2, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3, type ReviewStatusV3 } from "../lib/review-integration-v2.ts";
|
|
11
11
|
|
|
12
12
|
// One-slot capture routing: the host relay runs only when the selected
|
|
13
13
|
// provider-returned collect input carries the --materialize token. Every
|
|
14
14
|
// other capture form stays untouched.
|
|
15
15
|
|
|
16
16
|
const SHA = `sha256:${"1".repeat(64)}`;
|
|
17
|
+
const PHASE_REVISION = `sha256:${"3".repeat(64)}`;
|
|
17
18
|
const TREE = "2".repeat(40);
|
|
18
19
|
|
|
19
20
|
function repository(t: test.TestContext): string {
|
|
@@ -26,10 +27,10 @@ function repository(t: test.TestContext): string {
|
|
|
26
27
|
return cwd;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
function bindingArguments(lineageId: string, lens:
|
|
30
|
+
function bindingArguments(lineageId: string, lens: ReviewArtifactSubjectV2["lens"], order: number, revision = SHA): ReviewCollectInputV3["arguments"] {
|
|
30
31
|
return [
|
|
31
32
|
{ name: "lineage", value: lineageId, token: `--lineage=${lineageId}` },
|
|
32
|
-
{ name: "expected-revision", value:
|
|
33
|
+
{ name: "expected-revision", value: revision, token: `--expected-revision=${revision}` },
|
|
33
34
|
{ name: "target", value: SHA, token: `--target=${SHA}` },
|
|
34
35
|
{ name: "repository-context", value: `rctx1_${"e".repeat(64)}`, token: `--repository-context=rctx1_${"e".repeat(64)}` },
|
|
35
36
|
{ name: "lens", value: lens, token: `--lens=${lens}` },
|
|
@@ -38,8 +39,8 @@ function bindingArguments(lineageId: string, lens: string, order: number): Revie
|
|
|
38
39
|
];
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
function providerSubmission(lineageId: string, lens:
|
|
42
|
-
const bindingTokens = bindingArguments(lineageId, lens, order).map((argument) => argument.token!);
|
|
42
|
+
function providerSubmission(lineageId: string, lens: ReviewArtifactSubjectV2["lens"], order: number, revision = SHA): ReviewCaptureSubmissionV1 {
|
|
43
|
+
const bindingTokens = bindingArguments(lineageId, lens, order, revision).map((argument) => argument.token!);
|
|
43
44
|
return {
|
|
44
45
|
operationToken: "capture-result",
|
|
45
46
|
argumentTokens: [...bindingTokens, "--input={{value}}"],
|
|
@@ -47,22 +48,71 @@ function providerSubmission(lineageId: string, lens: string, order: number): Rev
|
|
|
47
48
|
};
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
function relayCollectInput(lineageId: string, lens:
|
|
51
|
+
function relayCollectInput(lineageId: string, lens: ReviewArtifactSubjectV2["lens"], order: number, materialize = true, submission: ReviewCaptureSubmissionV1 | "provider" | "absent" = "provider", revision = SHA): ReviewCollectInputV3 {
|
|
51
52
|
return {
|
|
52
53
|
name: "reviewer_result",
|
|
53
54
|
schema: "https://gentle-ai.dev/schema/review/reviewer/v1",
|
|
54
55
|
captureOperation: "review.capture-result",
|
|
55
56
|
arguments: [
|
|
56
|
-
...bindingArguments(lineageId, lens, order),
|
|
57
|
+
...bindingArguments(lineageId, lens, order, revision),
|
|
57
58
|
...(materialize ? [
|
|
58
59
|
{ name: "agent", value: "pi", token: "--agent=pi" },
|
|
59
60
|
{ name: "materialize", value: "true", token: "--materialize=true" },
|
|
60
61
|
] : []),
|
|
61
62
|
],
|
|
62
|
-
|
|
63
|
+
artifactSubject: {
|
|
64
|
+
schema: "gentle-ai.review-artifact-subject/v2", subjectHash: `sha256:${String(order).repeat(64)}`,
|
|
65
|
+
lineageId, authorityRevision: revision, targetIdentity: SHA, baseTree: TREE, candidateTree: TREE,
|
|
66
|
+
changedPathManifestSha256: SHA, lens, selectedOrder: order,
|
|
67
|
+
},
|
|
68
|
+
baseTree: TREE, candidateTree: TREE, changedPathManifest: [],
|
|
69
|
+
...(materialize && submission !== "absent" ? { submission: submission === "provider" ? providerSubmission(lineageId, lens, order, revision) : submission } : {}),
|
|
63
70
|
};
|
|
64
71
|
}
|
|
65
72
|
|
|
73
|
+
function capturedGroupedStatus(lineageId: string, authorityRevision = SHA): ReviewStatusV3 {
|
|
74
|
+
const raw = JSON.parse(readFileSync(new URL("./fixtures/devbinary/status-v5-capture-result-submission.captured.json", import.meta.url), "utf8")) as Record<string, unknown>;
|
|
75
|
+
// The captured binary's forward-only `finalize` action is not accepted by
|
|
76
|
+
// this checked-out decoder; retain the v5 capture and normalize only that
|
|
77
|
+
// unrelated routing action before decoding its provider-owned group.
|
|
78
|
+
raw.action = "stop";
|
|
79
|
+
const authority = raw.authority as Record<string, unknown>, repositoryContext = raw.repository_context as Record<string, unknown>;
|
|
80
|
+
authority.lineage_id = lineageId;
|
|
81
|
+
authority.revision = authorityRevision;
|
|
82
|
+
const phaseRevision = String(repositoryContext.revision), targetIdentity = String(raw.target_identity);
|
|
83
|
+
const source = ((((raw.next_transition as Record<string, unknown>).collect as Record<string, unknown>).inputs as Array<Record<string, unknown>>)[0]!);
|
|
84
|
+
const lenses = ["review-risk", "review-resilience", "review-readability", "review-reliability"];
|
|
85
|
+
((raw.next_transition as Record<string, unknown>).collect as Record<string, unknown>).inputs = lenses.map((lens, order) => {
|
|
86
|
+
const input = JSON.parse(JSON.stringify(source)) as Record<string, unknown>;
|
|
87
|
+
const subjectHash = `sha256:${String(order + 1).repeat(64)}`;
|
|
88
|
+
const arguments_ = input.arguments as Array<Record<string, unknown>>;
|
|
89
|
+
for (const argument of arguments_) {
|
|
90
|
+
const value = argument.name === "lineage" ? lineageId
|
|
91
|
+
: argument.name === "lens" ? lens
|
|
92
|
+
: argument.name === "order" ? String(order)
|
|
93
|
+
: argument.name === "subject-hash" ? subjectHash
|
|
94
|
+
: argument.value;
|
|
95
|
+
argument.value = value;
|
|
96
|
+
argument.token = `--${String(argument.name)}=${String(value)}`;
|
|
97
|
+
}
|
|
98
|
+
const submission = input.submission as Record<string, unknown>;
|
|
99
|
+
submission.argument_tokens = [...arguments_.filter((argument) => argument.name !== "agent" && argument.name !== "materialize").map((argument) => argument.token), "--input={{value}}"];
|
|
100
|
+
const artifactSubject = input.artifact_subject as Record<string, unknown>;
|
|
101
|
+
artifactSubject.subject_hash = subjectHash;
|
|
102
|
+
artifactSubject.lineage_id = lineageId;
|
|
103
|
+
artifactSubject.authority_revision = phaseRevision;
|
|
104
|
+
artifactSubject.target_identity = targetIdentity;
|
|
105
|
+
artifactSubject.lens = lens;
|
|
106
|
+
artifactSubject.selected_order = order;
|
|
107
|
+
return input;
|
|
108
|
+
});
|
|
109
|
+
return decodeReviewStatusV3(raw);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function replaceArgument(input: ReviewCollectInputV3, name: string, value: string): void {
|
|
113
|
+
input.arguments = input.arguments.map((argument) => argument.name === name ? { ...argument, value, token: `--${name}=${value}` } : argument);
|
|
114
|
+
}
|
|
115
|
+
|
|
66
116
|
function finalizeStatus(lineageId: string, inputs?: readonly ReviewCollectInputV3[]): ReviewStatusV3 {
|
|
67
117
|
return {
|
|
68
118
|
contract: "gentle-ai.review-integration/v2",
|
|
@@ -86,14 +136,28 @@ function finalizeStatus(lineageId: string, inputs?: readonly ReviewCollectInputV
|
|
|
86
136
|
currentSnapshotIdentity: SHA,
|
|
87
137
|
},
|
|
88
138
|
candidates: [],
|
|
139
|
+
repositoryContext: { capability: "review.opaque_repository_context", handle: `rctx1_${"e".repeat(64)}`, revision: SHA, targetIdentity: SHA },
|
|
89
140
|
...(inputs === undefined ? {} : { nextTransition: { kind: "collect", reasonCode: "reviewer_results_required", collect: { inputs: [...inputs] } } }),
|
|
90
141
|
raw: { schema: "gentle-ai.review-integration.status/v3", action: "stop", lineage_id: lineageId },
|
|
91
142
|
} as unknown as ReviewStatusV3;
|
|
92
143
|
}
|
|
93
144
|
|
|
145
|
+
function providerRefuterRequiredStatus(lineageId: string): ReviewStatusV3 {
|
|
146
|
+
const refuter: ReviewCollectInputV3 = {
|
|
147
|
+
name: "provider_refuter",
|
|
148
|
+
schema: "https://gentle-ai.dev/schema/review/refuter/v1",
|
|
149
|
+
captureOperation: "review.capture-refuter",
|
|
150
|
+
arguments: bindingArguments(lineageId, "review-risk", 0),
|
|
151
|
+
};
|
|
152
|
+
return {
|
|
153
|
+
...finalizeStatus(lineageId),
|
|
154
|
+
nextTransition: { kind: "collect", reasonCode: "provider_refuter_required", collect: { inputs: [refuter] } },
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
94
158
|
interface RoutingHarness {
|
|
95
159
|
statusQueue: ReviewStatusV3[];
|
|
96
|
-
statusCalls: Array<{ cwd: string; lineageId?: string }>;
|
|
160
|
+
statusCalls: Array<{ cwd: string; lineageId?: string; agent?: "pi" }>;
|
|
97
161
|
native: NativeReviewCli;
|
|
98
162
|
}
|
|
99
163
|
|
|
@@ -105,7 +169,7 @@ function nativeHarness(statuses: readonly ReviewStatusV3[]): RoutingHarness {
|
|
|
105
169
|
};
|
|
106
170
|
harness.native = {
|
|
107
171
|
targetStatus: async (request) => {
|
|
108
|
-
harness.statusCalls.push({ cwd: request.cwd, ...(request.lineageId === undefined ? {} : { lineageId: request.lineageId }) });
|
|
172
|
+
harness.statusCalls.push({ cwd: request.cwd, ...(request.lineageId === undefined ? {} : { lineageId: request.lineageId }), ...(request.agent === undefined ? {} : { agent: request.agent }) });
|
|
109
173
|
const next = harness.statusQueue.shift();
|
|
110
174
|
if (next === undefined) throw new Error("status queue exhausted");
|
|
111
175
|
return next;
|
|
@@ -166,6 +230,159 @@ test("Pi-authored review documents are rejected at the capture input boundary",
|
|
|
166
230
|
assert.equal(relayCalls, 0);
|
|
167
231
|
});
|
|
168
232
|
|
|
233
|
+
function groupInputs(lineageId: string, revision = SHA): ReviewCollectInputV3[] { return ["review-risk", "review-resilience", "review-readability", "review-reliability"].map((lens, order) => relayCollectInput(lineageId, lens, order, true, "provider", revision)); }
|
|
234
|
+
|
|
235
|
+
async function runCaptureGroup(cwd: string, harness: RoutingHarness, lineageId: string, inputs: readonly ReviewCollectInputV3[], reviewerRunAcknowledged = true): Promise<Record<string, unknown>> { return await __testing.executeReviewCaptureGroupOperation({ lineageId, collectBindings: inputs.map((input) => JSON.stringify(input)), reviewerRunAcknowledged }, cwd, harness.native) as Record<string, unknown>; }
|
|
236
|
+
|
|
237
|
+
function prepared(request: ReviewHostRelayRequest) { return { request, promptByteLength: 64, resultByteLength: 32 }; }
|
|
238
|
+
|
|
239
|
+
test("grouped capture forecasts once, reaches a four-reviewer barrier, and reconciles unclosed submissions", async (t) => {
|
|
240
|
+
t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
|
|
241
|
+
const cwd = repository(t), lineageId = "relay-group", inputs = groupInputs(lineageId), lenses = inputs.map((input) => input.arguments.find((argument) => argument.name === "lens")!.value);
|
|
242
|
+
const harness = nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), ...inputs.map((_input, index) => finalizeStatus(lineageId, index === 1 ? [...inputs.slice(index), { name: "unrelated", schema: "example", captureOperation: "external.example", arguments: [] }] : inputs.slice(index))), providerRefuterRequiredStatus(lineageId)]);
|
|
243
|
+
let ready!: () => void;
|
|
244
|
+
const allStarted = new Promise<void>((resolve) => { ready = resolve; });
|
|
245
|
+
const releases = new Map<string, () => void>(), completed: string[] = [], submitted: string[] = [];
|
|
246
|
+
const reviewerGroup = async (requests: readonly ReviewHostRelayRequest[]) => await Promise.all(requests.map(async (request) => {
|
|
247
|
+
const lens = request.captureArgumentTokens.find((token) => token.startsWith("--lens="))!.slice(7);
|
|
248
|
+
await new Promise<void>((resolve) => { releases.set(lens, resolve); if (releases.size === requests.length) ready(); });
|
|
249
|
+
completed.push(lens); return prepared(request);
|
|
250
|
+
}));
|
|
251
|
+
__testing.setReviewHostRelayGroupRunnersForTesting(reviewerGroup, async (result) => {
|
|
252
|
+
submitted.push(result.request.captureArgumentTokens.find((token) => token.startsWith("--lens="))!.slice(7));
|
|
253
|
+
return { promptByteLength: result.promptByteLength, resultByteLength: result.resultByteLength, submission: "{}" };
|
|
254
|
+
});
|
|
255
|
+
const forecast = await runCaptureGroup(cwd, harness, lineageId, inputs, false);
|
|
256
|
+
assert.deepEqual(forecast.cost_forecast, { transport: "pi_host_relay", model_runs: 4, lenses });
|
|
257
|
+
const run = runCaptureGroup(cwd, harness, lineageId, inputs);
|
|
258
|
+
await allStarted;
|
|
259
|
+
assert.deepEqual([...releases.keys()], lenses, "all reviewers start before the group waits");
|
|
260
|
+
for (const lens of [...lenses].reverse()) releases.get(lens!)!();
|
|
261
|
+
const result = await run;
|
|
262
|
+
assert.deepEqual(completed, [...lenses].reverse(), "reviewer completion order is not submission order");
|
|
263
|
+
assert.deepEqual(submitted, lenses);
|
|
264
|
+
assert.deepEqual({ outcome: result.outcome, statusCalls: harness.statusCalls.length, providerAction: result.provider_action }, { outcome: "native-reviewer-group-status-reconciled", statusCalls: 7, providerAction: "stop" });
|
|
265
|
+
assert.equal(harness.statusCalls.at(-1)?.agent, "pi", "post-last-capture reconciliation preserves the Pi host runtime");
|
|
266
|
+
assert.equal((result.next_transition as { kind?: string; reasonCode?: string } | undefined)?.kind, "collect");
|
|
267
|
+
assert.equal((result.next_transition as { kind?: string; reasonCode?: string } | undefined)?.reasonCode, "provider_refuter_required");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("captured v5 STATUS accepts capture-phase Pn when authority has advanced to Rn at the forecast boundary", async (t) => {
|
|
271
|
+
t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
|
|
272
|
+
const cwd = repository(t), lineageId = "relay-group-phase-revision", status = capturedGroupedStatus(lineageId, SHA), inputs = status.nextTransition!.collect!.inputs!;
|
|
273
|
+
let launches = 0;
|
|
274
|
+
__testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { launches += requests.length; return requests.map(prepared); });
|
|
275
|
+
|
|
276
|
+
assert.notEqual(status.authority!.revision, status.repositoryContext!.revision, "captured v5 group keeps capture-phase Pn distinct from authority Rn");
|
|
277
|
+
const forecast = await runCaptureGroup(cwd, nativeHarness([status]), lineageId, inputs, false);
|
|
278
|
+
|
|
279
|
+
assert.equal(forecast.outcome, "reviewer-model-run-forecast");
|
|
280
|
+
assert.deepEqual(forecast.cost_forecast, { transport: "pi_host_relay", model_runs: 4, lenses: ["review-risk", "review-resilience", "review-readability", "review-reliability"] });
|
|
281
|
+
assert.equal(launches, 0, "decoder-valid capture-phase Pn must pass admission before any reviewer model launch");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("group rejects STATUS repository target mismatch and per-slot revision, context, or target drift before launch", async (t) => {
|
|
285
|
+
t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
|
|
286
|
+
const cwd = repository(t), lineageId = "relay-group-slot-consistency";
|
|
287
|
+
const repositoryTargetMismatch = capturedGroupedStatus(lineageId, SHA);
|
|
288
|
+
repositoryTargetMismatch.repositoryContext!.targetIdentity = SHA;
|
|
289
|
+
const revisionMismatch = capturedGroupedStatus(lineageId, SHA);
|
|
290
|
+
replaceArgument(revisionMismatch.nextTransition!.collect!.inputs![1]!, "expected-revision", SHA);
|
|
291
|
+
const contextMismatch = capturedGroupedStatus(lineageId, SHA);
|
|
292
|
+
replaceArgument(contextMismatch.nextTransition!.collect!.inputs![1]!, "repository-context", `rctx1_${"f".repeat(64)}`);
|
|
293
|
+
const targetMismatch = capturedGroupedStatus(lineageId, SHA);
|
|
294
|
+
replaceArgument(targetMismatch.nextTransition!.collect!.inputs![1]!, "target", SHA);
|
|
295
|
+
let launches = 0;
|
|
296
|
+
__testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { launches += requests.length; return requests.map(prepared); });
|
|
297
|
+
|
|
298
|
+
// Each call forwards this STATUS's own current set, so rejection proves an
|
|
299
|
+
// internal STATUS guard rather than stale or caller/canonical mismatch.
|
|
300
|
+
const repositoryTargetResult = await runCaptureGroup(cwd, nativeHarness([repositoryTargetMismatch]), lineageId, repositoryTargetMismatch.nextTransition!.collect!.inputs!);
|
|
301
|
+
assert.deepEqual({ outcome: repositoryTargetResult.outcome, reason: repositoryTargetResult.reason }, {
|
|
302
|
+
outcome: "capture-group-rejected",
|
|
303
|
+
reason: "current STATUS repository context does not match the reviewer group binding",
|
|
304
|
+
});
|
|
305
|
+
const revisionResult = await runCaptureGroup(cwd, nativeHarness([revisionMismatch]), lineageId, revisionMismatch.nextTransition!.collect!.inputs!);
|
|
306
|
+
assert.deepEqual({ outcome: revisionResult.outcome, reason: revisionResult.reason }, {
|
|
307
|
+
outcome: "capture-group-rejected",
|
|
308
|
+
reason: "current STATUS carries an incomplete or mismatched materialize reviewer binding",
|
|
309
|
+
});
|
|
310
|
+
const contextResult = await runCaptureGroup(cwd, nativeHarness([contextMismatch]), lineageId, contextMismatch.nextTransition!.collect!.inputs!);
|
|
311
|
+
assert.deepEqual({ outcome: contextResult.outcome, reason: contextResult.reason }, {
|
|
312
|
+
outcome: "capture-group-rejected",
|
|
313
|
+
reason: "current STATUS carries an incomplete or mismatched materialize reviewer binding",
|
|
314
|
+
});
|
|
315
|
+
const targetResult = await runCaptureGroup(cwd, nativeHarness([targetMismatch]), lineageId, targetMismatch.nextTransition!.collect!.inputs!);
|
|
316
|
+
assert.deepEqual({ outcome: targetResult.outcome, reason: targetResult.reason }, {
|
|
317
|
+
outcome: "capture-group-rejected",
|
|
318
|
+
reason: "current STATUS carries an incomplete or mismatched materialize reviewer binding",
|
|
319
|
+
});
|
|
320
|
+
assert.equal(launches, 0, "current STATUS consistency checks reject before reviewer model launch");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("group rejects absent or mismatched current repository context before launch", async (t) => {
|
|
324
|
+
t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
|
|
325
|
+
const cwd = repository(t), lineageId = "relay-group-context-reject", inputs = groupInputs(lineageId, PHASE_REVISION);
|
|
326
|
+
const revisionMismatch = finalizeStatus(lineageId, inputs);
|
|
327
|
+
const handleMismatch = finalizeStatus(lineageId, inputs);
|
|
328
|
+
const absent = finalizeStatus(lineageId, inputs);
|
|
329
|
+
handleMismatch.repositoryContext = { capability: "review.opaque_repository_context", handle: `rctx1_${"f".repeat(64)}`, revision: PHASE_REVISION, targetIdentity: SHA };
|
|
330
|
+
delete absent.repositoryContext;
|
|
331
|
+
let launches = 0;
|
|
332
|
+
__testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { launches += requests.length; return requests.map(prepared); });
|
|
333
|
+
|
|
334
|
+
for (const status of [revisionMismatch, handleMismatch, absent]) {
|
|
335
|
+
const result = await runCaptureGroup(cwd, nativeHarness([status]), lineageId, inputs);
|
|
336
|
+
assert.equal(result.outcome, "capture-group-rejected");
|
|
337
|
+
}
|
|
338
|
+
assert.equal(launches, 0, "repository-context validation must reject before reviewer model launch");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("group rejects partial, duplicate, reordered, stale, mixed, and late-invalid bindings before launch", async (t) => {
|
|
342
|
+
t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
|
|
343
|
+
const cwd = repository(t), lineageId = "relay-group-reject", inputs = groupInputs(lineageId);
|
|
344
|
+
const stale = JSON.parse(JSON.stringify(inputs[3]!)) as ReviewCollectInputV3;
|
|
345
|
+
stale.arguments = [...stale.arguments, { name: "replacement", value: "stale", token: "--replacement=stale" }];
|
|
346
|
+
const mixed = [...inputs.slice(0, 3), relayCollectInput(lineageId, "review-reliability", 3, false)];
|
|
347
|
+
const malformed = JSON.parse(JSON.stringify(inputs)) as ReviewCollectInputV3[];
|
|
348
|
+
malformed[3]!.submission!.argumentTokens = ["--input={{value}}"];
|
|
349
|
+
const cases = [
|
|
350
|
+
{ bindings: inputs.slice(0, 3), current: inputs }, { bindings: [...inputs].reverse(), current: inputs },
|
|
351
|
+
{ bindings: [...inputs.slice(0, 3), inputs[2]!], current: inputs }, { bindings: [...inputs.slice(0, 3), stale], current: inputs },
|
|
352
|
+
{ bindings: mixed, current: mixed }, { bindings: malformed, current: malformed },
|
|
353
|
+
];
|
|
354
|
+
let launches = 0;
|
|
355
|
+
__testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { launches += requests.length; return requests.map(prepared); });
|
|
356
|
+
for (const entry of cases) {
|
|
357
|
+
const result = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, entry.current)]), lineageId, entry.bindings);
|
|
358
|
+
assert.equal(result.outcome, "capture-group-rejected");
|
|
359
|
+
}
|
|
360
|
+
assert.equal(launches, 0);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("group preserves earlier admission when a later STATUS drifts, and stops on closure or unknown outcome", async (t) => {
|
|
364
|
+
t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
|
|
365
|
+
const cwd = repository(t), lineageId = "relay-group-stop", inputs = groupInputs(lineageId);
|
|
366
|
+
const reviewerGroup = async (requests: readonly ReviewHostRelayRequest[]) => requests.map(prepared);
|
|
367
|
+
let submissions = 0;
|
|
368
|
+
__testing.setReviewHostRelayGroupRunnersForTesting(reviewerGroup, async (result) => { submissions += 1; return { promptByteLength: result.promptByteLength, resultByteLength: result.resultByteLength, submission: "{}" }; });
|
|
369
|
+
const staleStatus = finalizeStatus(lineageId, inputs), stale = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), staleStatus]), lineageId, inputs);
|
|
370
|
+
assert.deepEqual({ outcome: stale.outcome, submissions, mutation: stale.mutation_performed, mutationOutcome: stale.mutation_outcome }, { outcome: "capture-group-authority-drift", submissions: 1, mutation: true, mutationOutcome: "partial" });
|
|
371
|
+
assert.equal(stale.reconciliation, staleStatus.raw);
|
|
372
|
+
submissions = 0;
|
|
373
|
+
const drift = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs.slice(2))]), lineageId, inputs);
|
|
374
|
+
assert.deepEqual({ outcome: drift.outcome, submissions, mutation: drift.mutation_performed, mutationOutcome: drift.mutation_outcome }, { outcome: "capture-group-authority-drift", submissions: 1, mutation: true, mutationOutcome: "partial" });
|
|
375
|
+
assert.deepEqual(((drift.host_relay as { reviewers: Array<{ lens: string }> }).reviewers).map((reviewer) => reviewer.lens), ["review-risk"]);
|
|
376
|
+
submissions = 0;
|
|
377
|
+
__testing.setReviewHostRelayGroupRunnersForTesting(reviewerGroup, async (result) => { submissions += 1; return { promptByteLength: result.promptByteLength, resultByteLength: result.resultByteLength, submission: JSON.stringify({ schema: "gentle-ai.review-last-event-closure/v1", operation: "review/capture-result", lineage_id: lineageId, state: "approved", store_revision: SHA, action: "native last event closed the review" }) }; });
|
|
378
|
+
const terminal = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs)]), lineageId, inputs);
|
|
379
|
+
assert.deepEqual({ status: terminal.status, submissions }, { status: "closed", submissions: 1 });
|
|
380
|
+
submissions = 0;
|
|
381
|
+
__testing.setReviewHostRelayGroupRunnersForTesting(reviewerGroup, async () => { submissions += 1; throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", "unknown", { timedOut: true }); });
|
|
382
|
+
const unknown = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs)]), lineageId, inputs);
|
|
383
|
+
assert.deepEqual({ status: unknown.status, submissions }, { status: "reconciled", submissions: 1 });
|
|
384
|
+
});
|
|
385
|
+
|
|
169
386
|
test("an old binary reports the relay as unavailable without touching existing behavior", async (t) => {
|
|
170
387
|
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
171
388
|
const cwd = repository(t);
|
|
@@ -263,6 +480,7 @@ test("a submission whose outcome is genuinely indeterminate still reconciles thr
|
|
|
263
480
|
assert.deepEqual(result.failure, { kind: "submission-refused", stage: "submit", exit_code: null, timed_out: true, elapsed_ms: 120_004, timeout_ms: 120_000 });
|
|
264
481
|
assert.match(String(result.reason), /exceeded its 120000ms bound/);
|
|
265
482
|
assert.equal(harness.statusCalls.length, 2, "an indeterminate submission reconciles exactly once through STATUS");
|
|
483
|
+
assert.equal(harness.statusCalls.at(-1)?.agent, "pi", "ordinary host-relay reconciliation preserves the Pi host runtime");
|
|
266
484
|
});
|
|
267
485
|
|
|
268
486
|
test("a relay timeout reports its one-slot measurements and no auto-follow", async (t) => {
|