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
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { __testing } from "../extensions/gentle-ai.ts";
|
|
4
|
+
import {
|
|
5
|
+
NATIVE_REVIEW_MODE_OPERATION,
|
|
6
|
+
NATIVE_REVIEW_MODE_SOURCE,
|
|
7
|
+
type NativeReviewCli,
|
|
8
|
+
type NativeReviewModeRequest,
|
|
9
|
+
type NativeReviewModeResult,
|
|
10
|
+
type NativeReviewModeStatus,
|
|
11
|
+
} from "../lib/native-review-cli.ts";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// gentle-pi#661: the always-on parent prompt renders a second status line,
|
|
15
|
+
// `Receipt-driven development: on|off (decided by <source>)`, next to
|
|
16
|
+
// `Background subagent policy`, sourced from the native review mode status
|
|
17
|
+
// reader (`gentle-ai review mode status --json`, schema
|
|
18
|
+
// `gentle-ai.rdd-mode-status/v1`, decoded to `NativeReviewModeStatus`).
|
|
19
|
+
//
|
|
20
|
+
// `getOrchestratorPrompt`/`renderOrchestratorPrompt` stay synchronous and
|
|
21
|
+
// default `rddStatusLine` to the "unknown (native status unavailable)" line
|
|
22
|
+
// -- the longest of the three renderable forms -- so a no-argument call IS
|
|
23
|
+
// the worst case the canonical 8 KiB budget in
|
|
24
|
+
// tests/orchestrator-budget.test.ts measures, not a smaller placeholder
|
|
25
|
+
// production later exceeds. Production (before_agent_start) still resolves
|
|
26
|
+
// and passes the real on/off/unknown line through `resolveRddStatusLine`,
|
|
27
|
+
// bounded by `RDD_STATUS_TIMEOUT_MS` and memoized per cwd for
|
|
28
|
+
// `RDD_STATUS_MEMO_TTL_MS` so a hung or repeatedly-invoked native reader
|
|
29
|
+
// cannot stall or repeatedly respawn on every session/agent-start build.
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
const {
|
|
33
|
+
renderRddStatusLine,
|
|
34
|
+
resolveRddModeStatus,
|
|
35
|
+
resolveRddStatusLine,
|
|
36
|
+
getOrchestratorPrompt,
|
|
37
|
+
clearRddStatusMemoForTesting,
|
|
38
|
+
RDD_STATUS_TIMEOUT_MS,
|
|
39
|
+
RDD_STATUS_MEMO_TTL_MS,
|
|
40
|
+
} = __testing;
|
|
41
|
+
|
|
42
|
+
function fakeReviewMode(
|
|
43
|
+
result: NativeReviewModeResult | (() => never) | (() => Promise<never>),
|
|
44
|
+
): Pick<NativeReviewCli, "reviewMode"> {
|
|
45
|
+
return {
|
|
46
|
+
async reviewMode(_request: NativeReviewModeRequest): Promise<NativeReviewModeResult> {
|
|
47
|
+
if (typeof result === "function") return result();
|
|
48
|
+
return result;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function modeResult(effective: "on" | "off", source: (typeof NATIVE_REVIEW_MODE_SOURCE)[keyof typeof NATIVE_REVIEW_MODE_SOURCE]): NativeReviewModeResult {
|
|
54
|
+
return {
|
|
55
|
+
operation: NATIVE_REVIEW_MODE_OPERATION.STATUS,
|
|
56
|
+
scope: "global",
|
|
57
|
+
status: {
|
|
58
|
+
global: effective,
|
|
59
|
+
cloneLocal: "",
|
|
60
|
+
effective,
|
|
61
|
+
source,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A counting wrapper for memo tests: counts how many times reviewMode is
|
|
67
|
+
// actually invoked, independent of how many times resolveRddModeStatus is
|
|
68
|
+
// called.
|
|
69
|
+
function countingReviewMode(result: NativeReviewModeResult): { cli: Pick<NativeReviewCli, "reviewMode">; calls: () => number } {
|
|
70
|
+
let calls = 0;
|
|
71
|
+
return {
|
|
72
|
+
cli: {
|
|
73
|
+
async reviewMode(_request: NativeReviewModeRequest): Promise<NativeReviewModeResult> {
|
|
74
|
+
calls += 1;
|
|
75
|
+
return result;
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
calls: () => calls,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
test("renderRddStatusLine renders the fail-closed unknown line for an unresolved status", () => {
|
|
83
|
+
assert.equal(
|
|
84
|
+
renderRddStatusLine(undefined),
|
|
85
|
+
"Receipt-driven development: unknown (native status unavailable)",
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("renderRddStatusLine renders the effective mode and deciding source", () => {
|
|
90
|
+
assert.equal(
|
|
91
|
+
renderRddStatusLine({ global: "on", cloneLocal: "", effective: "on", source: NATIVE_REVIEW_MODE_SOURCE.GLOBAL }),
|
|
92
|
+
"Receipt-driven development: on (decided by global)",
|
|
93
|
+
);
|
|
94
|
+
assert.equal(
|
|
95
|
+
renderRddStatusLine({ global: "off", cloneLocal: "", effective: "off", source: NATIVE_REVIEW_MODE_SOURCE.DEFAULT }),
|
|
96
|
+
"Receipt-driven development: off (decided by default)",
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("renderRddStatusLine fails closed to unknown for a malformed or partial status object", () => {
|
|
101
|
+
// A bad upstream decode, a future field rename, or a hand-built fixture
|
|
102
|
+
// must never render an unrecognized value verbatim -- the render boundary
|
|
103
|
+
// validates independently of the type signature.
|
|
104
|
+
const malformed: readonly NativeReviewModeStatus[] = [
|
|
105
|
+
{} as NativeReviewModeStatus,
|
|
106
|
+
{ effective: "maybe" } as unknown as NativeReviewModeStatus,
|
|
107
|
+
{ effective: "on" } as NativeReviewModeStatus, // missing source
|
|
108
|
+
{ effective: "on", source: "not-a-real-source" } as unknown as NativeReviewModeStatus,
|
|
109
|
+
{ global: "on", cloneLocal: "", effective: "ON", source: NATIVE_REVIEW_MODE_SOURCE.GLOBAL } as unknown as NativeReviewModeStatus,
|
|
110
|
+
null as unknown as NativeReviewModeStatus,
|
|
111
|
+
];
|
|
112
|
+
for (const status of malformed) {
|
|
113
|
+
assert.equal(
|
|
114
|
+
renderRddStatusLine(status),
|
|
115
|
+
"Receipt-driven development: unknown (native status unavailable)",
|
|
116
|
+
`expected unknown line for ${JSON.stringify(status)}`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("resolveRddModeStatus reads the on status from a stubbed native reviewMode reader", async () => {
|
|
122
|
+
clearRddStatusMemoForTesting();
|
|
123
|
+
const status = await resolveRddModeStatus(fakeReviewMode(modeResult("on", NATIVE_REVIEW_MODE_SOURCE.CLONE_LOCAL)), "/repo-on");
|
|
124
|
+
assert.deepEqual(status, {
|
|
125
|
+
global: "on",
|
|
126
|
+
cloneLocal: "",
|
|
127
|
+
effective: "on",
|
|
128
|
+
source: "clone_local",
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("resolveRddModeStatus reads the off status from a stubbed native reviewMode reader", async () => {
|
|
133
|
+
clearRddStatusMemoForTesting();
|
|
134
|
+
const status = await resolveRddModeStatus(fakeReviewMode(modeResult("off", NATIVE_REVIEW_MODE_SOURCE.GLOBAL)), "/repo-off");
|
|
135
|
+
assert.equal(status?.effective, "off");
|
|
136
|
+
assert.equal(status?.source, "global");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("resolveRddModeStatus resolves to undefined when the native review CLI wrapper is null or absent", async () => {
|
|
140
|
+
for (const wrapper of [undefined, null] as const) {
|
|
141
|
+
clearRddStatusMemoForTesting();
|
|
142
|
+
const status = await resolveRddModeStatus(wrapper, "/repo-no-wrapper");
|
|
143
|
+
assert.equal(status, undefined, `expected undefined for a ${wrapper === null ? "null" : "missing"} wrapper`);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("resolveRddModeStatus resolves to undefined when the native reviewMode call rejects", async () => {
|
|
148
|
+
clearRddStatusMemoForTesting();
|
|
149
|
+
const status = await resolveRddModeStatus(
|
|
150
|
+
fakeReviewMode(() => {
|
|
151
|
+
throw new Error("native process failed");
|
|
152
|
+
}),
|
|
153
|
+
"/repo-rejecting",
|
|
154
|
+
);
|
|
155
|
+
assert.equal(status, undefined);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("resolveRddModeStatus resolves to undefined within the deadline when reviewMode never settles", async () => {
|
|
159
|
+
// gentle-pi#661 native-review escalation: a hung `gentle-ai` child must
|
|
160
|
+
// not stall session start. resolveRddModeStatus races the call against
|
|
161
|
+
// its own abort listener, so this holds even for a stub reviewMode that
|
|
162
|
+
// itself ignores the passed `signal`, as a real hung child process would
|
|
163
|
+
// eventually be killed and reject via its own signal handling.
|
|
164
|
+
clearRddStatusMemoForTesting();
|
|
165
|
+
const neverSettling = fakeReviewMode(() => new Promise<never>(() => {}));
|
|
166
|
+
const deadlineMs = 150;
|
|
167
|
+
const start = Date.now();
|
|
168
|
+
const status = await resolveRddModeStatus(neverSettling, "/repo-hung", AbortSignal.timeout(deadlineMs));
|
|
169
|
+
const elapsed = Date.now() - start;
|
|
170
|
+
assert.equal(status, undefined);
|
|
171
|
+
assert.ok(elapsed < deadlineMs + 1000, `expected the read to resolve near the ${deadlineMs}ms deadline, took ${elapsed}ms`);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("resolveRddModeStatus memoizes a resolved status per cwd for RDD_STATUS_MEMO_TTL_MS", async () => {
|
|
175
|
+
clearRddStatusMemoForTesting();
|
|
176
|
+
const { cli, calls } = countingReviewMode(modeResult("on", NATIVE_REVIEW_MODE_SOURCE.GLOBAL));
|
|
177
|
+
const cwd = "/repo-memo-hit";
|
|
178
|
+
const first = await resolveRddModeStatus(cli, cwd);
|
|
179
|
+
const second = await resolveRddModeStatus(cli, cwd);
|
|
180
|
+
assert.deepEqual(first, second);
|
|
181
|
+
assert.equal(calls(), 1, "a second call within the TTL must reuse the memoized status, not respawn the native reader");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("resolveRddModeStatus re-reads once the memoized status expires", async () => {
|
|
185
|
+
clearRddStatusMemoForTesting();
|
|
186
|
+
const { cli, calls } = countingReviewMode(modeResult("on", NATIVE_REVIEW_MODE_SOURCE.GLOBAL));
|
|
187
|
+
const cwd = "/repo-memo-expiry";
|
|
188
|
+
let clock = 1_000_000;
|
|
189
|
+
const now = () => clock;
|
|
190
|
+
await resolveRddModeStatus(cli, cwd, undefined, now);
|
|
191
|
+
assert.equal(calls(), 1);
|
|
192
|
+
clock += RDD_STATUS_MEMO_TTL_MS + 1;
|
|
193
|
+
await resolveRddModeStatus(cli, cwd, undefined, now);
|
|
194
|
+
assert.equal(calls(), 2, "a call after the TTL elapses must re-read rather than reuse the stale memo");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("resolveRddModeStatus memoizes independently per cwd", async () => {
|
|
198
|
+
clearRddStatusMemoForTesting();
|
|
199
|
+
const { cli, calls } = countingReviewMode(modeResult("on", NATIVE_REVIEW_MODE_SOURCE.GLOBAL));
|
|
200
|
+
await resolveRddModeStatus(cli, "/repo-a");
|
|
201
|
+
await resolveRddModeStatus(cli, "/repo-b");
|
|
202
|
+
assert.equal(calls(), 2, "distinct cwds must not share a memo entry");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("resolveRddStatusLine renders on, off, and unavailable from the stubbed native reader", async () => {
|
|
206
|
+
clearRddStatusMemoForTesting();
|
|
207
|
+
assert.equal(
|
|
208
|
+
await resolveRddStatusLine(fakeReviewMode(modeResult("on", NATIVE_REVIEW_MODE_SOURCE.GLOBAL)), "/repo-line-on"),
|
|
209
|
+
"Receipt-driven development: on (decided by global)",
|
|
210
|
+
);
|
|
211
|
+
assert.equal(
|
|
212
|
+
await resolveRddStatusLine(fakeReviewMode(modeResult("off", NATIVE_REVIEW_MODE_SOURCE.DEFAULT)), "/repo-line-off"),
|
|
213
|
+
"Receipt-driven development: off (decided by default)",
|
|
214
|
+
);
|
|
215
|
+
assert.equal(
|
|
216
|
+
await resolveRddStatusLine(undefined, "/repo-line-unknown"),
|
|
217
|
+
"Receipt-driven development: unknown (native status unavailable)",
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("RDD_STATUS_TIMEOUT_MS is a small, positive bounded-deadline constant", () => {
|
|
222
|
+
assert.equal(typeof RDD_STATUS_TIMEOUT_MS, "number");
|
|
223
|
+
assert.ok(RDD_STATUS_TIMEOUT_MS > 0 && RDD_STATUS_TIMEOUT_MS <= 10_000);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("getOrchestratorPrompt renders the resolved RDD status line next to the background policy line", () => {
|
|
227
|
+
const cwd = process.cwd();
|
|
228
|
+
for (const line of [
|
|
229
|
+
"Receipt-driven development: on (decided by global)",
|
|
230
|
+
"Receipt-driven development: off (decided by default)",
|
|
231
|
+
"Receipt-driven development: unknown (native status unavailable)",
|
|
232
|
+
]) {
|
|
233
|
+
const rendered = getOrchestratorPrompt(cwd, undefined, line);
|
|
234
|
+
assert.ok(rendered.includes(line), `rendered prompt missing RDD status line: ${line}`);
|
|
235
|
+
assert.match(rendered, /Background subagent policy: (?:on|off) \(capability: (?:ready|absent)\)/);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("getOrchestratorPrompt defaults to the worst-case unknown RDD status line (budget-critical path)", () => {
|
|
240
|
+
// gentle-pi#661 follow-up: the no-argument default must BE the worst case
|
|
241
|
+
// the byte budget in tests/orchestrator-budget.test.ts measures, so a
|
|
242
|
+
// caller that never resolves a status renders the longest line rather
|
|
243
|
+
// than none at all.
|
|
244
|
+
const rendered = getOrchestratorPrompt();
|
|
245
|
+
assert.ok(rendered.includes("Receipt-driven development: unknown (native status unavailable)"));
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("the prompt cache key distinguishes on, off, and unknown so each renders a distinct prompt", () => {
|
|
249
|
+
const cwd = process.cwd();
|
|
250
|
+
const on = getOrchestratorPrompt(cwd, undefined, "Receipt-driven development: on (decided by global)");
|
|
251
|
+
const off = getOrchestratorPrompt(cwd, undefined, "Receipt-driven development: off (decided by default)");
|
|
252
|
+
const unknown = getOrchestratorPrompt(cwd, undefined, "Receipt-driven development: unknown (native status unavailable)");
|
|
253
|
+
const none = getOrchestratorPrompt(cwd);
|
|
254
|
+
assert.notEqual(on, off);
|
|
255
|
+
assert.notEqual(off, unknown);
|
|
256
|
+
assert.notEqual(on, unknown);
|
|
257
|
+
// The no-argument default renders the same worst-case "unknown" line, so
|
|
258
|
+
// it is the same prompt as the explicit unknown-line call.
|
|
259
|
+
assert.equal(none, unknown);
|
|
260
|
+
// Re-rendering the same status line returns the memoized prompt.
|
|
261
|
+
assert.equal(
|
|
262
|
+
getOrchestratorPrompt(cwd, undefined, "Receipt-driven development: on (decided by global)"),
|
|
263
|
+
on,
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("the on and off renders are never longer than the unknown (worst-case) render", () => {
|
|
268
|
+
// Every renderable NativeReviewModeSource crossed with on/off must stay at
|
|
269
|
+
// or under the "unknown (native status unavailable)" line's length, since
|
|
270
|
+
// the budget is sized against that one worst case. Sourced from the
|
|
271
|
+
// exported NATIVE_REVIEW_MODE_SOURCE constants, not a literal list, so a
|
|
272
|
+
// future source addition is exercised automatically.
|
|
273
|
+
const cwd = process.cwd();
|
|
274
|
+
const unknownLine = "Receipt-driven development: unknown (native status unavailable)";
|
|
275
|
+
const unknownBytes = Buffer.byteLength(getOrchestratorPrompt(cwd, undefined, unknownLine), "utf8");
|
|
276
|
+
for (const source of Object.values(NATIVE_REVIEW_MODE_SOURCE)) {
|
|
277
|
+
for (const effective of ["on", "off"] as const) {
|
|
278
|
+
const line = renderRddStatusLine({ global: effective, cloneLocal: "", effective, source });
|
|
279
|
+
const bytes = Buffer.byteLength(getOrchestratorPrompt(cwd, undefined, line), "utf8");
|
|
280
|
+
assert.ok(
|
|
281
|
+
bytes <= unknownBytes,
|
|
282
|
+
`"${line}" rendered ${bytes} B, longer than the unknown-line render at ${unknownBytes} B`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
});
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
8
|
+
import type { NativeReviewCli } from "../lib/native-review-cli.ts";
|
|
9
|
+
import type { ReviewStatusV3 } from "../lib/review-integration-v2.ts";
|
|
10
|
+
|
|
11
|
+
// gentle-pi#556 / gentle-ai#4051: with RDD enabled, the agent finished an
|
|
12
|
+
// implementation and reported completion without ever entering the review
|
|
13
|
+
// preflight. These tests cover the read-only, idempotent `agent_end` nudge
|
|
14
|
+
// that reminds the agent to call gentle_review before reporting completion,
|
|
15
|
+
// without ever starting a review or answering consent itself.
|
|
16
|
+
//
|
|
17
|
+
// gentle-pi#568: `session_start` records the target identity STATUS reports
|
|
18
|
+
// at session start as a baseline, so `agent_end` skips a candidate that
|
|
19
|
+
// already existed before this session touched the worktree (the user's own
|
|
20
|
+
// pre-session work, not this session's output). These tests point
|
|
21
|
+
// `GENTLE_PI_AGENT_HOME` and the session `cwd` at fresh temp directories so
|
|
22
|
+
// `session_start`'s real SDD asset install and model config sweep never
|
|
23
|
+
// touch this machine's actual home directory.
|
|
24
|
+
|
|
25
|
+
type AnyHandler = (event: unknown, ctx: ExtensionContext) => unknown;
|
|
26
|
+
type SentMessage = { message: Record<string, unknown>; options: Record<string, unknown> };
|
|
27
|
+
|
|
28
|
+
function harness(nativeReviewCli: NativeReviewCli | null): {
|
|
29
|
+
handlers: Map<string, AnyHandler>;
|
|
30
|
+
sent: SentMessage[];
|
|
31
|
+
} {
|
|
32
|
+
const handlers = new Map<string, AnyHandler>();
|
|
33
|
+
const sent: SentMessage[] = [];
|
|
34
|
+
const pi = {
|
|
35
|
+
on(name: string, handler: AnyHandler) {
|
|
36
|
+
handlers.set(name, handler);
|
|
37
|
+
},
|
|
38
|
+
events: { emit() {} },
|
|
39
|
+
registerCommand() {},
|
|
40
|
+
registerTool() {},
|
|
41
|
+
sendMessage(message: Record<string, unknown>, options: Record<string, unknown> = {}) {
|
|
42
|
+
sent.push({ message, options });
|
|
43
|
+
},
|
|
44
|
+
} as unknown as ExtensionAPI;
|
|
45
|
+
createGentleAiExtension({ nativeReviewCli })(pi);
|
|
46
|
+
return { handlers, sent };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ctx(sessionId: string, hasUI = true, cwd = process.cwd()): ExtensionContext {
|
|
50
|
+
return {
|
|
51
|
+
cwd,
|
|
52
|
+
hasUI,
|
|
53
|
+
ui: { notify() {} },
|
|
54
|
+
sessionManager: { getSessionId: () => sessionId },
|
|
55
|
+
} as unknown as ExtensionContext;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function withSessionStartEnv<T>(callback: (cwd: string) => Promise<T>): Promise<T> {
|
|
59
|
+
const previousAgentHome = process.env.GENTLE_PI_AGENT_HOME;
|
|
60
|
+
const previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
|
|
61
|
+
process.env.GENTLE_PI_AGENT_HOME = await mkdtemp(join(tmpdir(), "gentle-pi-session-baseline-agent-home-"));
|
|
62
|
+
// Isolates both the model-config sweep and the dev-binary registration
|
|
63
|
+
// lookup from this machine's real ~/.pi/gentle-ai, so `session_start`'s
|
|
64
|
+
// unrelated notifications never leak into these assertions.
|
|
65
|
+
process.env.GENTLE_PI_CONFIG_HOME = await mkdtemp(join(tmpdir(), "gentle-pi-session-baseline-config-home-"));
|
|
66
|
+
try {
|
|
67
|
+
const cwd = await mkdtemp(join(tmpdir(), "gentle-pi-session-baseline-cwd-"));
|
|
68
|
+
return await callback(cwd);
|
|
69
|
+
} finally {
|
|
70
|
+
if (previousAgentHome === undefined) delete process.env.GENTLE_PI_AGENT_HOME;
|
|
71
|
+
else process.env.GENTLE_PI_AGENT_HOME = previousAgentHome;
|
|
72
|
+
if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
|
|
73
|
+
else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function onMode(effective: "on" | "off"): NativeReviewCli["reviewMode"] {
|
|
78
|
+
return async () => ({
|
|
79
|
+
operation: "status",
|
|
80
|
+
scope: "clone",
|
|
81
|
+
status: { global: "", cloneLocal: effective === "on" ? "on" : "", effective, source: "clone_local" },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function executeStartStatus(targetIdentity: string): ReviewStatusV3 {
|
|
86
|
+
return {
|
|
87
|
+
applicability: "unrelated",
|
|
88
|
+
action: "start",
|
|
89
|
+
targetIdentity,
|
|
90
|
+
nextTransition: { kind: "execute", execute: { operation: "review.start", arguments: [] } },
|
|
91
|
+
} as unknown as ReviewStatusV3;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function collectStatus(targetIdentity: string): ReviewStatusV3 {
|
|
95
|
+
return {
|
|
96
|
+
applicability: "unrelated",
|
|
97
|
+
action: "start",
|
|
98
|
+
targetIdentity,
|
|
99
|
+
nextTransition: { kind: "collect", collect: { inputs: [] } },
|
|
100
|
+
} as unknown as ReviewStatusV3;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function stopStatus(targetIdentity: string): ReviewStatusV3 {
|
|
104
|
+
return {
|
|
105
|
+
applicability: "unrelated",
|
|
106
|
+
action: "start",
|
|
107
|
+
targetIdentity,
|
|
108
|
+
nextTransition: { kind: "stop" },
|
|
109
|
+
} as unknown as ReviewStatusV3;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const agentEndEvent = { type: "agent_end", messages: [] };
|
|
113
|
+
|
|
114
|
+
test("agent_end performs no STATUS call and sends nothing when RDD is off", async () => {
|
|
115
|
+
const statusRequests: unknown[] = [];
|
|
116
|
+
const native = {
|
|
117
|
+
reviewMode: onMode("off"),
|
|
118
|
+
targetStatus: async (request: unknown) => {
|
|
119
|
+
statusRequests.push(request);
|
|
120
|
+
throw new Error("targetStatus must not be called when RDD is off");
|
|
121
|
+
},
|
|
122
|
+
} as unknown as NativeReviewCli;
|
|
123
|
+
const { handlers, sent } = harness(native);
|
|
124
|
+
const agentEnd = handlers.get("agent_end");
|
|
125
|
+
assert.equal(typeof agentEnd, "function");
|
|
126
|
+
await agentEnd!(agentEndEvent, ctx("agent-end-rdd-off"));
|
|
127
|
+
assert.deepEqual(statusRequests, []);
|
|
128
|
+
assert.deepEqual(sent, []);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("agent_end nudges exactly once when RDD is on and STATUS offers review.start", async () => {
|
|
132
|
+
const targetIdentity = `sha256:${"a".repeat(64)}`;
|
|
133
|
+
const statusRequests: Array<{ agent?: string }> = [];
|
|
134
|
+
const native = {
|
|
135
|
+
reviewMode: onMode("on"),
|
|
136
|
+
targetStatus: async (request: { agent?: string }) => {
|
|
137
|
+
statusRequests.push(request);
|
|
138
|
+
return executeStartStatus(targetIdentity);
|
|
139
|
+
},
|
|
140
|
+
} as unknown as NativeReviewCli;
|
|
141
|
+
const { handlers, sent } = harness(native);
|
|
142
|
+
const agentEnd = handlers.get("agent_end");
|
|
143
|
+
await agentEnd!(agentEndEvent, ctx("agent-end-execute"));
|
|
144
|
+
|
|
145
|
+
assert.equal(sent.length, 1);
|
|
146
|
+
const [entry] = sent;
|
|
147
|
+
assert.equal(entry?.message.customType, "gentle-pi.review-preflight");
|
|
148
|
+
const content = String(entry?.message.content);
|
|
149
|
+
assert.match(content, /gentle_review/);
|
|
150
|
+
assert.ok(content.includes(targetIdentity), "message must name the target identity");
|
|
151
|
+
assert.equal(entry?.options.triggerTurn, true);
|
|
152
|
+
assert.equal(statusRequests[0]?.agent, "pi");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("agent_end nudges once per target identity and again for a fresh identity", async () => {
|
|
156
|
+
let targetIdentity = `sha256:${"b".repeat(64)}`;
|
|
157
|
+
const native = {
|
|
158
|
+
reviewMode: onMode("on"),
|
|
159
|
+
targetStatus: async () => executeStartStatus(targetIdentity),
|
|
160
|
+
} as unknown as NativeReviewCli;
|
|
161
|
+
const { handlers, sent } = harness(native);
|
|
162
|
+
const agentEnd = handlers.get("agent_end");
|
|
163
|
+
const session = ctx("agent-end-repeat");
|
|
164
|
+
|
|
165
|
+
await agentEnd!(agentEndEvent, session);
|
|
166
|
+
await agentEnd!(agentEndEvent, session);
|
|
167
|
+
assert.equal(sent.length, 1, "the same target identity nudges only once");
|
|
168
|
+
|
|
169
|
+
targetIdentity = `sha256:${"e".repeat(64)}`;
|
|
170
|
+
await agentEnd!(agentEndEvent, session);
|
|
171
|
+
assert.equal(sent.length, 2, "a different target identity nudges again");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("agent_end sends nothing when STATUS offers collect or stop", async () => {
|
|
175
|
+
for (const [label, status] of [
|
|
176
|
+
["collect", collectStatus(`sha256:${"c".repeat(64)}`)],
|
|
177
|
+
["stop", stopStatus(`sha256:${"d".repeat(64)}`)],
|
|
178
|
+
] as const) {
|
|
179
|
+
const native = {
|
|
180
|
+
reviewMode: onMode("on"),
|
|
181
|
+
targetStatus: async () => status,
|
|
182
|
+
} as unknown as NativeReviewCli;
|
|
183
|
+
const { handlers, sent } = harness(native);
|
|
184
|
+
const agentEnd = handlers.get("agent_end");
|
|
185
|
+
await agentEnd!(agentEndEvent, ctx(`agent-end-${label}`));
|
|
186
|
+
assert.deepEqual(sent, [], label);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("agent_end skips headless sessions before any native call", async () => {
|
|
191
|
+
const native = {
|
|
192
|
+
reviewMode: async () => {
|
|
193
|
+
throw new Error("reviewMode must not be called when hasUI is false");
|
|
194
|
+
},
|
|
195
|
+
targetStatus: async () => {
|
|
196
|
+
throw new Error("targetStatus must not be called when hasUI is false");
|
|
197
|
+
},
|
|
198
|
+
} as unknown as NativeReviewCli;
|
|
199
|
+
const { handlers, sent } = harness(native);
|
|
200
|
+
const agentEnd = handlers.get("agent_end");
|
|
201
|
+
await agentEnd!(agentEndEvent, ctx("agent-end-no-ui", false));
|
|
202
|
+
assert.deepEqual(sent, []);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("agent_end pairs a named agent's start with its own end, then still nudges for the primary loop's end", async () => {
|
|
206
|
+
const targetIdentity = `sha256:${"f".repeat(64)}`;
|
|
207
|
+
const native = {
|
|
208
|
+
reviewMode: onMode("on"),
|
|
209
|
+
targetStatus: async () => executeStartStatus(targetIdentity),
|
|
210
|
+
} as unknown as NativeReviewCli;
|
|
211
|
+
const { handlers, sent } = harness(native);
|
|
212
|
+
const beforeAgentStart = handlers.get("before_agent_start");
|
|
213
|
+
const agentEnd = handlers.get("agent_end");
|
|
214
|
+
assert.equal(typeof beforeAgentStart, "function");
|
|
215
|
+
const session = ctx("agent-end-subagent");
|
|
216
|
+
|
|
217
|
+
await beforeAgentStart!({ agentName: "review-readability", systemPrompt: "" }, session);
|
|
218
|
+
await agentEnd!(agentEndEvent, session);
|
|
219
|
+
assert.deepEqual(sent, [], "the subagent's own loop end is suppressed");
|
|
220
|
+
|
|
221
|
+
await agentEnd!(agentEndEvent, session);
|
|
222
|
+
assert.equal(sent.length, 1, "the primary loop's end still nudges once the subagent's end is paired off");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("agent_end resets the subagent depth when a fresh primary loop starts", async () => {
|
|
226
|
+
const targetIdentity = `sha256:${"1".repeat(64)}`;
|
|
227
|
+
const native = {
|
|
228
|
+
reviewMode: onMode("on"),
|
|
229
|
+
targetStatus: async () => executeStartStatus(targetIdentity),
|
|
230
|
+
} as unknown as NativeReviewCli;
|
|
231
|
+
const { handlers, sent } = harness(native);
|
|
232
|
+
const beforeAgentStart = handlers.get("before_agent_start");
|
|
233
|
+
const agentEnd = handlers.get("agent_end");
|
|
234
|
+
const session = ctx("agent-end-subagent-reset");
|
|
235
|
+
|
|
236
|
+
await beforeAgentStart!({ agentName: "review-readability", systemPrompt: "" }, session);
|
|
237
|
+
await beforeAgentStart!({ systemPrompt: "" }, session);
|
|
238
|
+
await agentEnd!(agentEndEvent, session);
|
|
239
|
+
|
|
240
|
+
assert.equal(sent.length, 1, "a fresh primary-loop start resets the depth so its own end nudges");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("agent_end handler exists but sends nothing when nativeReviewCli is null", async () => {
|
|
244
|
+
const { handlers, sent } = harness(null);
|
|
245
|
+
const agentEnd = handlers.get("agent_end");
|
|
246
|
+
assert.equal(typeof agentEnd, "function");
|
|
247
|
+
await agentEnd!(agentEndEvent, ctx("agent-end-null-cli"));
|
|
248
|
+
assert.deepEqual(sent, []);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("agent_end sends nothing and does not throw when target STATUS rejects", async () => {
|
|
252
|
+
const native = {
|
|
253
|
+
reviewMode: onMode("on"),
|
|
254
|
+
targetStatus: async () => {
|
|
255
|
+
throw new Error("native status unavailable");
|
|
256
|
+
},
|
|
257
|
+
} as unknown as NativeReviewCli;
|
|
258
|
+
const { handlers, sent } = harness(native);
|
|
259
|
+
const agentEnd = handlers.get("agent_end");
|
|
260
|
+
await assert.doesNotReject(async () => agentEnd!(agentEndEvent, ctx("agent-end-status-throws")));
|
|
261
|
+
assert.deepEqual(sent, []);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("session_shutdown clears the nudged-target set for its session key", async () => {
|
|
265
|
+
const targetIdentity = `sha256:${"9".repeat(64)}`;
|
|
266
|
+
const native = {
|
|
267
|
+
reviewMode: onMode("on"),
|
|
268
|
+
targetStatus: async () => executeStartStatus(targetIdentity),
|
|
269
|
+
} as unknown as NativeReviewCli;
|
|
270
|
+
const { handlers, sent } = harness(native);
|
|
271
|
+
const agentEnd = handlers.get("agent_end");
|
|
272
|
+
const shutdown = handlers.get("session_shutdown");
|
|
273
|
+
assert.equal(typeof shutdown, "function");
|
|
274
|
+
const session = ctx("agent-end-shutdown");
|
|
275
|
+
|
|
276
|
+
await agentEnd!(agentEndEvent, session);
|
|
277
|
+
assert.equal(sent.length, 1);
|
|
278
|
+
|
|
279
|
+
await shutdown!({}, session);
|
|
280
|
+
await agentEnd!(agentEndEvent, session);
|
|
281
|
+
assert.equal(sent.length, 2, "shutdown clears the nudged set so the same identity nudges again");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("session_start records the baseline target identity when RDD is on", async () => {
|
|
285
|
+
const targetIdentity = `sha256:${"2".repeat(64)}`;
|
|
286
|
+
const statusRequests: Array<{ agent?: string }> = [];
|
|
287
|
+
const native = {
|
|
288
|
+
reviewMode: onMode("on"),
|
|
289
|
+
targetStatus: async (request: { agent?: string }) => {
|
|
290
|
+
statusRequests.push(request);
|
|
291
|
+
return executeStartStatus(targetIdentity);
|
|
292
|
+
},
|
|
293
|
+
} as unknown as NativeReviewCli;
|
|
294
|
+
await withSessionStartEnv(async (cwd) => {
|
|
295
|
+
const { handlers, sent } = harness(native);
|
|
296
|
+
const sessionStart = handlers.get("session_start");
|
|
297
|
+
assert.equal(typeof sessionStart, "function");
|
|
298
|
+
const notifications: Array<{ message: string; severity: string }> = [];
|
|
299
|
+
const session = {
|
|
300
|
+
...ctx("session-baseline-record", true, cwd),
|
|
301
|
+
ui: { notify: (message: string, severity: string) => notifications.push({ message, severity }) },
|
|
302
|
+
};
|
|
303
|
+
await sessionStart!({}, session);
|
|
304
|
+
assert.equal(statusRequests[0]?.agent, "pi");
|
|
305
|
+
assert.deepEqual(sent, []);
|
|
306
|
+
assert.deepEqual(notifications, []);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("agent_end skips the candidate that matches the session's recorded baseline, then nudges for a new one", async () => {
|
|
311
|
+
let targetIdentity = `sha256:${"3".repeat(64)}`;
|
|
312
|
+
const native = {
|
|
313
|
+
reviewMode: onMode("on"),
|
|
314
|
+
targetStatus: async () => executeStartStatus(targetIdentity),
|
|
315
|
+
} as unknown as NativeReviewCli;
|
|
316
|
+
await withSessionStartEnv(async (cwd) => {
|
|
317
|
+
const { handlers, sent } = harness(native);
|
|
318
|
+
const sessionStart = handlers.get("session_start");
|
|
319
|
+
const agentEnd = handlers.get("agent_end");
|
|
320
|
+
const session = ctx("session-baseline-skip", true, cwd);
|
|
321
|
+
|
|
322
|
+
await sessionStart!({}, session);
|
|
323
|
+
await agentEnd!(agentEndEvent, session);
|
|
324
|
+
assert.deepEqual(sent, [], "the baseline candidate predates the session and is not nudged");
|
|
325
|
+
|
|
326
|
+
targetIdentity = `sha256:${"4".repeat(64)}`;
|
|
327
|
+
await agentEnd!(agentEndEvent, session);
|
|
328
|
+
assert.equal(sent.length, 1, "a candidate identity different from the baseline still nudges");
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("session_start with RDD off records no baseline, so agent_end still reminds once", async () => {
|
|
333
|
+
const targetIdentity = `sha256:${"5".repeat(64)}`;
|
|
334
|
+
let mode: "on" | "off" = "off";
|
|
335
|
+
const statusRequests: unknown[] = [];
|
|
336
|
+
const native = {
|
|
337
|
+
reviewMode: async () => ({
|
|
338
|
+
operation: "status",
|
|
339
|
+
scope: "clone",
|
|
340
|
+
status: { global: "", cloneLocal: mode === "on" ? "on" : "", effective: mode, source: "clone_local" },
|
|
341
|
+
}),
|
|
342
|
+
targetStatus: async (request: unknown) => {
|
|
343
|
+
statusRequests.push(request);
|
|
344
|
+
if (mode === "off") throw new Error("targetStatus must not be called when RDD is off");
|
|
345
|
+
return executeStartStatus(targetIdentity);
|
|
346
|
+
},
|
|
347
|
+
} as unknown as NativeReviewCli;
|
|
348
|
+
await withSessionStartEnv(async (cwd) => {
|
|
349
|
+
const { handlers, sent } = harness(native);
|
|
350
|
+
const sessionStart = handlers.get("session_start");
|
|
351
|
+
const agentEnd = handlers.get("agent_end");
|
|
352
|
+
const session = ctx("session-baseline-rdd-off", true, cwd);
|
|
353
|
+
|
|
354
|
+
await sessionStart!({}, session);
|
|
355
|
+
assert.deepEqual(statusRequests, []);
|
|
356
|
+
|
|
357
|
+
mode = "on";
|
|
358
|
+
await agentEnd!(agentEndEvent, session);
|
|
359
|
+
assert.equal(sent.length, 1, "no baseline was recorded while RDD was off, so the first dirty candidate still reminds");
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("session_start whose targetStatus rejects records no baseline, so agent_end still reminds once", async () => {
|
|
364
|
+
const targetIdentity = `sha256:${"6".repeat(64)}`;
|
|
365
|
+
let shouldThrow = true;
|
|
366
|
+
const native = {
|
|
367
|
+
reviewMode: onMode("on"),
|
|
368
|
+
targetStatus: async () => {
|
|
369
|
+
if (shouldThrow) throw new Error("native status unavailable at session start");
|
|
370
|
+
return executeStartStatus(targetIdentity);
|
|
371
|
+
},
|
|
372
|
+
} as unknown as NativeReviewCli;
|
|
373
|
+
await withSessionStartEnv(async (cwd) => {
|
|
374
|
+
const { handlers, sent } = harness(native);
|
|
375
|
+
const sessionStart = handlers.get("session_start");
|
|
376
|
+
const agentEnd = handlers.get("agent_end");
|
|
377
|
+
const session = ctx("session-baseline-throws", true, cwd);
|
|
378
|
+
|
|
379
|
+
await assert.doesNotReject(async () => sessionStart!({}, session));
|
|
380
|
+
|
|
381
|
+
shouldThrow = false;
|
|
382
|
+
await agentEnd!(agentEndEvent, session);
|
|
383
|
+
assert.equal(sent.length, 1, "STATUS threw at session_start, so no baseline was recorded and the candidate still reminds");
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("session_shutdown clears the recorded baseline so the same identity reminds again", async () => {
|
|
388
|
+
const targetIdentity = `sha256:${"7".repeat(64)}`;
|
|
389
|
+
const native = {
|
|
390
|
+
reviewMode: onMode("on"),
|
|
391
|
+
targetStatus: async () => executeStartStatus(targetIdentity),
|
|
392
|
+
} as unknown as NativeReviewCli;
|
|
393
|
+
await withSessionStartEnv(async (cwd) => {
|
|
394
|
+
const { handlers, sent } = harness(native);
|
|
395
|
+
const sessionStart = handlers.get("session_start");
|
|
396
|
+
const agentEnd = handlers.get("agent_end");
|
|
397
|
+
const shutdown = handlers.get("session_shutdown");
|
|
398
|
+
const session = ctx("session-baseline-shutdown", true, cwd);
|
|
399
|
+
|
|
400
|
+
await sessionStart!({}, session);
|
|
401
|
+
await agentEnd!(agentEndEvent, session);
|
|
402
|
+
assert.deepEqual(sent, [], "the baseline candidate is skipped");
|
|
403
|
+
|
|
404
|
+
await shutdown!({}, session);
|
|
405
|
+
await agentEnd!(agentEndEvent, session);
|
|
406
|
+
assert.equal(sent.length, 1, "shutdown cleared the baseline, so the same identity reminds again in the next session");
|
|
407
|
+
});
|
|
408
|
+
});
|