gentle-pi 3.2.1 → 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/docs/gentle-shell.md +27 -14
- package/docs/readme-reference.md +41 -7
- package/docs/review-integration.md +22 -17
- package/extensions/gentle-agents.ts +85 -17
- package/extensions/gentle-ai.ts +123 -21
- package/extensions/gentle-shell.ts +379 -31
- 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/native-review-cli.ts +14 -0
- package/lib/odd-runtime-delegation-gate.ts +88 -0
- package/lib/review-host-relay.ts +246 -171
- package/lib/review-integration-v2.ts +110 -26
- package/lib/shell-bar.ts +150 -75
- 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/package.json +1 -1
- package/runtime/native-review-cli.mjs +14 -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/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 +795 -23
- 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 +19 -1
- 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-host-relay-routing.test.ts +77 -0
- package/tests/review-host-relay.test.ts +277 -300
- 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 +22 -24
- 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 +192 -124
- 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 +120 -1
- package/lib/opaque-pi-reviewer-adapter.ts +0 -404
- package/tests/opaque-pi-reviewer-adapter.test.ts +0 -410
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
// `pnpm test` never imports it; `pnpm run test:maintainer` runs the tests;
|
|
10
10
|
// the CLI is the entry point the separate verifier uses for the real organic
|
|
11
11
|
// positive journey after a user-visible forecast.
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
12
13
|
import { spawn, spawnSync } from "node:child_process";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
15
|
+
import { delimiter, isAbsolute, resolve } from "node:path";
|
|
16
|
+
import { fauxAssistantMessage, registerFauxProvider } from "@earendil-works/pi-ai/compat";
|
|
16
17
|
import { REVIEW_HOST_RELAY_FAILURE, ReviewHostRelayError, classifyReviewHostRelayRefusal, resolveReviewHostRelaySubmission, runReviewHostRelaySlot } from "../../lib/review-host-relay.ts";
|
|
17
18
|
import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "../../lib/review-relay-contract.ts";
|
|
18
19
|
export const DESCRIPTOR_SCHEMA = "gentle-pi.maintainer.provider-relay-descriptor/v1";
|
|
@@ -88,13 +89,17 @@ const exactKeys = (obj, allowed, label) => {
|
|
|
88
89
|
// the completing form is provably bindable before any process launches.
|
|
89
90
|
export function validateDescriptor(value) {
|
|
90
91
|
if (!isObj(value)) fail("descriptor must be a JSON object");
|
|
91
|
-
exactKeys(value, new Set(["schema", "gentleAiExecutable", "
|
|
92
|
+
exactKeys(value, new Set(["schema", "gentleAiExecutable", "reviewerSelection", "cases"]), "descriptor");
|
|
92
93
|
if (value.schema !== DESCRIPTOR_SCHEMA) fail(`descriptor.schema must be exactly "${DESCRIPTOR_SCHEMA}"`);
|
|
93
94
|
if (!isStr(value.gentleAiExecutable) || !isAbsolute(value.gentleAiExecutable)) fail("descriptor.gentleAiExecutable must be a non-empty absolute path; the runner never re-resolves the production binary");
|
|
94
|
-
|
|
95
|
+
// gentle-pi#311 P4: the positive-lens journey used to need a declared real
|
|
96
|
+
// `pi` binary; lens captures now run in-process, so it instead needs a
|
|
97
|
+
// "provider/id" reviewer selection the armed run resolves through a
|
|
98
|
+
// scripted-but-real completion (never a fake pi child).
|
|
99
|
+
if (!isStr(value.reviewerSelection)) fail("descriptor.reviewerSelection must be a non-empty string");
|
|
95
100
|
if (!Array.isArray(value.cases) || value.cases.length === 0) fail("descriptor.cases must be a non-empty array");
|
|
96
101
|
const seen = new Set();
|
|
97
|
-
return { schema: value.schema, gentleAiExecutable: value.gentleAiExecutable,
|
|
102
|
+
return { schema: value.schema, gentleAiExecutable: value.gentleAiExecutable, reviewerSelection: value.reviewerSelection, cases: value.cases.map((entry, index) => {
|
|
98
103
|
if (!isObj(entry)) fail(`descriptor.cases[${index}] must be an object`);
|
|
99
104
|
if (!isStr(entry.name)) fail(`descriptor.cases[${index}].name must be a non-empty string`);
|
|
100
105
|
if (seen.has(entry.name)) fail(`descriptor.cases[${index}].name "${entry.name}" is duplicated`);
|
|
@@ -205,13 +210,77 @@ export function resolveDeclaredExecutable(executable) {
|
|
|
205
210
|
return null;
|
|
206
211
|
}
|
|
207
212
|
const missingReason = (executable, label) => `${label} "${executable}" is not an existing executable; arm the descriptor with a real binary, or set GENTLE_PI_REQUIRE_MAINTAINER=1 to fail instead of block.`;
|
|
208
|
-
// A
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
213
|
+
// A reviewer registry whose `find` always misses. Mirrors the pre-in-process
|
|
214
|
+
// "unlaunchable pi" trick this replaced: the maintainer-declared `kind` is
|
|
215
|
+
// not evidence that the declared gentle-ai binary is actually incapable, so
|
|
216
|
+
// a `relay-unavailable` negative control against a mis-declared CAPABLE
|
|
217
|
+
// binary must still never complete a reviewer or submit anything. Resolving
|
|
218
|
+
// nothing guarantees a typed MODEL_NOT_FOUND refusal before any completion
|
|
219
|
+
// or submission, so the control cannot become a hidden real mutation even
|
|
220
|
+
// against a binary that turns out to be capable (gentle-pi#311 P4).
|
|
221
|
+
function unresolvableReviewerRegistry() {
|
|
222
|
+
return {
|
|
223
|
+
find: () => undefined,
|
|
224
|
+
getApiKeyAndHeaders: async () => ({ ok: false, error: "the relay-unavailable negative control never resolves a reviewer" }),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
// The armed, organic positive-lens journey needs the real gentle-ai binary's
|
|
228
|
+
// materialize/submit protocol exercised end-to-end, but this harness cannot
|
|
229
|
+
// assume a maintainer's real provider credentials. Instead of a fake pi
|
|
230
|
+
// child, this registers pi-ai's own faux provider -- the SAME api-registry
|
|
231
|
+
// the real `completeSimple` dispatches through in production -- so the real
|
|
232
|
+
// `runInProcessReviewer` -> real `completeSimple` path runs with no network.
|
|
233
|
+
// The scripted response reads the frozen prompt's `GENTLE_AI_REVIEW_BINDING`
|
|
234
|
+
// line for the subject_hash and its `GENTLE_AI_REVIEW_CONTEXT` line for the
|
|
235
|
+
// changed-path manifest, and answers with every field the reviewer result
|
|
236
|
+
// contract requires -- subject_hash, a completed inspection over exactly the
|
|
237
|
+
// frozen paths, findings, evidence -- so the real Go admission accepts it
|
|
238
|
+
// (gentle-pi#311 P4). Not unregistered: each
|
|
239
|
+
// call uses a fresh random api id, and this runs at most once per short-lived
|
|
240
|
+
// process (the CLI's single `main()` or one test), so a leaked entry in the
|
|
241
|
+
// process-global api-registry is harmless.
|
|
242
|
+
// Reads the one-line JSON that follows a frozen prompt marker (the binding
|
|
243
|
+
// and context lines Go writes at the top of every reviewer task). Returns
|
|
244
|
+
// undefined when the marker is absent or its JSON is malformed, so the faux
|
|
245
|
+
// reviewer answers with an incomplete result that real admission refuses
|
|
246
|
+
// instead of throwing inside the provider and masking the contract drift.
|
|
247
|
+
function frozenPromptMarkerJson(promptText, marker) {
|
|
248
|
+
const prefix = `${marker} `;
|
|
249
|
+
for (const line of promptText.split("\n")) {
|
|
250
|
+
if (!line.startsWith(prefix)) continue;
|
|
251
|
+
try { return JSON.parse(line.slice(prefix.length)); } catch { return undefined; }
|
|
252
|
+
}
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
function armedFauxReviewerRegistry(reviewerSelection) {
|
|
256
|
+
const separatorIndex = reviewerSelection.indexOf("/");
|
|
257
|
+
if (separatorIndex <= 0 || separatorIndex === reviewerSelection.length - 1) {
|
|
258
|
+
throw new DescriptorValidationError(`descriptor.reviewerSelection "${reviewerSelection}" must have the "provider/id" shape`);
|
|
259
|
+
}
|
|
260
|
+
const provider = reviewerSelection.slice(0, separatorIndex);
|
|
261
|
+
const modelId = reviewerSelection.slice(separatorIndex + 1);
|
|
262
|
+
const api = `gentle-pi-maintainer-faux-reviewer-${randomUUID()}`;
|
|
263
|
+
const faux = registerFauxProvider({ api, provider, models: [{ id: modelId }] });
|
|
264
|
+
faux.setResponses([(context) => {
|
|
265
|
+
const content = context.messages[0]?.content;
|
|
266
|
+
const textPart = Array.isArray(content) ? content.find((part) => part.type === "text") : undefined;
|
|
267
|
+
const promptText = textPart?.text ?? "";
|
|
268
|
+
const binding = frozenPromptMarkerJson(promptText, "GENTLE_AI_REVIEW_BINDING");
|
|
269
|
+
const frozenContext = frozenPromptMarkerJson(promptText, "GENTLE_AI_REVIEW_CONTEXT");
|
|
270
|
+
const manifest = Array.isArray(frozenContext?.changed_path_manifest) ? frozenContext.changed_path_manifest : [];
|
|
271
|
+
const paths = manifest.map((entry) => entry?.path).filter((path) => typeof path === "string" && path.length > 0);
|
|
272
|
+
return fauxAssistantMessage(JSON.stringify({
|
|
273
|
+
subject_hash: binding?.subject_hash,
|
|
274
|
+
inspection: { status: "completed", paths },
|
|
275
|
+
findings: [],
|
|
276
|
+
evidence: ["armed maintainer faux reviewer inspected every frozen candidate path"],
|
|
277
|
+
}));
|
|
278
|
+
}]);
|
|
279
|
+
const model = faux.getModel();
|
|
280
|
+
return {
|
|
281
|
+
find: () => model,
|
|
282
|
+
getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "faux-key" }),
|
|
283
|
+
};
|
|
215
284
|
}
|
|
216
285
|
function terminateRoleProcessTree(child) {
|
|
217
286
|
if (child.pid === undefined) return false;
|
|
@@ -317,39 +386,41 @@ export async function runMatrix(descriptor, options = {}) {
|
|
|
317
386
|
}
|
|
318
387
|
continue;
|
|
319
388
|
}
|
|
320
|
-
// The positive lens needs
|
|
321
|
-
//
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
// executable the harness checked (issue #324).
|
|
326
|
-
let piPath = null;
|
|
327
|
-
if (caseEntry.kind === "positive-lens") {
|
|
328
|
-
piPath = resolveDeclaredExecutable(descriptor.piExecutable);
|
|
329
|
-
if (piPath === null) {
|
|
330
|
-
verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", reason: missingReason(descriptor.piExecutable, "piExecutable"), command: POSITIVE_JOURNEY_COMMAND });
|
|
331
|
-
continue;
|
|
332
|
-
}
|
|
333
|
-
if (!positiveArmed) {
|
|
334
|
-
verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", reason: `positive-lens case is not explicitly armed; set ${ARM_POSITIVE_ENV}=1 with a real capable gentle-ai binary, a real pi, and a real review session (real binding tokens from \`gentle-ai review status --next-transition\`) to run the organic journey. The runner never synthesizes a provider result.`, command: POSITIVE_JOURNEY_COMMAND });
|
|
335
|
-
continue;
|
|
336
|
-
}
|
|
389
|
+
// The positive lens needs an explicit arm; the negative control never
|
|
390
|
+
// resolves a reviewer (fails closed at the reviewer stage or earlier).
|
|
391
|
+
if (caseEntry.kind === "positive-lens" && !positiveArmed) {
|
|
392
|
+
verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", reason: `positive-lens case is not explicitly armed; set ${ARM_POSITIVE_ENV}=1 with a real capable gentle-ai binary and a real review session (real binding tokens from \`gentle-ai review status --next-transition\`) to run the organic journey. The runner never synthesizes a provider result.`, command: POSITIVE_JOURNEY_COMMAND });
|
|
393
|
+
continue;
|
|
337
394
|
}
|
|
338
|
-
// `relay-unavailable` is a NEGATIVE CONTROL: it must never
|
|
339
|
-
// never submit. The maintainer-declared `kind` is not
|
|
340
|
-
// declared binary is actually incapable, so the arm
|
|
341
|
-
// the declaration alone. Pointed at a mis-declared
|
|
342
|
-
// declaration-only gate materializes, runs a REAL
|
|
343
|
-
// a REAL `capture-result --input=...`
|
|
344
|
-
// `fail` — the mutation already
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
// stage
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
|
|
395
|
+
// `relay-unavailable` is a NEGATIVE CONTROL: it must never complete a
|
|
396
|
+
// reviewer and never submit. The maintainer-declared `kind` is not
|
|
397
|
+
// evidence that the declared binary is actually incapable, so the arm
|
|
398
|
+
// gate cannot key off the declaration alone. Pointed at a mis-declared
|
|
399
|
+
// CAPABLE binary, a declaration-only gate materializes, runs a REAL
|
|
400
|
+
// reviewer completion, and executes a REAL `capture-result --input=...`
|
|
401
|
+
// submission, and only then reports `fail` — the mutation already
|
|
402
|
+
// happened. So give the negative control a reviewer registry that can
|
|
403
|
+
// never resolve: an incapable binary still classifies relay-unavailable
|
|
404
|
+
// at materialize (the control stays genuine, since it never reached the
|
|
405
|
+
// reviewer stage anyway), while a capable one fails closed at the
|
|
406
|
+
// reviewer stage as kind=reviewer-model-not-found stage=pi
|
|
407
|
+
// mutationOutcome=none, with zero completion and zero submission
|
|
408
|
+
// (gentle-pi#311 P4, superseding the pre-in-process "unlaunchable pi").
|
|
352
409
|
try {
|
|
410
|
+
// A malformed `reviewerSelection` (caught here rather than at
|
|
411
|
+
// descriptor-validation time, since it is only ever exercised by an
|
|
412
|
+
// armed positive-lens case) must fail this case the same way a real
|
|
413
|
+
// relay error would, never crash the whole matrix.
|
|
414
|
+
const reviewerRegistry = caseEntry.kind === "relay-unavailable" ? unresolvableReviewerRegistry() : armedFauxReviewerRegistry(descriptor.reviewerSelection);
|
|
415
|
+
const request = {
|
|
416
|
+
captureArgumentTokens: caseEntry.captureArgumentTokens,
|
|
417
|
+
submission: caseEntry.submission,
|
|
418
|
+
gentleAiExecutable: gentleAiPath,
|
|
419
|
+
reviewerRegistry,
|
|
420
|
+
selection: descriptor.reviewerSelection,
|
|
421
|
+
routingKey: "review-relay-matrix",
|
|
422
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
423
|
+
};
|
|
353
424
|
const result = await relay(request);
|
|
354
425
|
if (caseEntry.kind === "relay-unavailable") {
|
|
355
426
|
verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "fail", reason: "expected relay-unavailable (runtime without --materialize) but the relay succeeded; the descriptor's binary is unexpectedly capable" });
|
|
@@ -357,7 +428,9 @@ export async function runMatrix(descriptor, options = {}) {
|
|
|
357
428
|
verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "pass", promptByteLength: result.promptByteLength, resultByteLength: result.resultByteLength, submissionByteLength: result.submission.length });
|
|
358
429
|
}
|
|
359
430
|
} catch (error) {
|
|
360
|
-
if (error instanceof
|
|
431
|
+
if (error instanceof DescriptorValidationError) {
|
|
432
|
+
verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "fail", reason: error.message });
|
|
433
|
+
} else if (error instanceof ReviewHostRelayError) {
|
|
361
434
|
if (caseEntry.kind === "relay-unavailable") {
|
|
362
435
|
if (error.kind === REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE && error.stage === "materialize" && error.mutationOutcome === "none") {
|
|
363
436
|
verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "pass", stage: error.stage, mutationOutcome: error.mutationOutcome, reason: REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE });
|
|
@@ -372,8 +445,6 @@ export async function runMatrix(descriptor, options = {}) {
|
|
|
372
445
|
} else {
|
|
373
446
|
verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "fail", reason: `unexpected non-relay error: ${error instanceof Error ? error.message : String(error)}` });
|
|
374
447
|
}
|
|
375
|
-
} finally {
|
|
376
|
-
if (negativeControl !== null) rmSync(negativeControl.directory, { recursive: true, force: true });
|
|
377
448
|
}
|
|
378
449
|
}
|
|
379
450
|
return verdicts;
|
|
@@ -181,7 +181,7 @@ const contractHashes = {
|
|
|
181
181
|
"contracts/review-integration/v2/schemas/start.schema.json": "2991e3fcca672d9257d61b6a336fb34e58b15a8e03f8a09a7adf892cae6a8085",
|
|
182
182
|
"contracts/review-integration/v2/schemas/status.schema.json": "c4dcc736cfc6300560a3c4262d2d982368529d5c49d58d499552a3b0beef9212",
|
|
183
183
|
"contracts/telemetry/runtime-aggregate-v1.schema.json": "eb0f2993d9271f55cb42eca343e6fbb601a733fb90bd40daeebc92ee60ae1ba9",
|
|
184
|
-
"docs/review-integration.md": "
|
|
184
|
+
"docs/review-integration.md": "9b60fa2775129c468f7eb3510905014553cee5f3cbcb78a608f08baf8bbd7336",
|
|
185
185
|
};
|
|
186
186
|
|
|
187
187
|
requiredPaths.push(...Object.keys(contractHashes));
|
|
@@ -339,7 +339,7 @@ async function main() {
|
|
|
339
339
|
});
|
|
340
340
|
|
|
341
341
|
if (driftedContracts.length > 0) {
|
|
342
|
-
console.error("gentle-pi packaged review-integration/v1 and review-integration/v2 contract bytes drifted from the pinned v3.
|
|
342
|
+
console.error("gentle-pi packaged review-integration/v1 and review-integration/v2 contract bytes drifted from the pinned v3.4.0 runtime's vendored Gentle AI contract artifacts:");
|
|
343
343
|
for (const drift of driftedContracts) console.error(`- ${drift.relativePath}: expected ${drift.expected}, got ${drift.actual}`);
|
|
344
344
|
process.exit(1);
|
|
345
345
|
}
|
|
@@ -384,7 +384,7 @@ async function main() {
|
|
|
384
384
|
process.exit(1);
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
-
console.log(`gentle-pi package resource check passed (${requiredPaths.length} files; ${Object.keys(contractHashes).length} exact byte-pinned contract artifacts for the v3.
|
|
387
|
+
console.log(`gentle-pi package resource check passed (${requiredPaths.length} files; ${Object.keys(contractHashes).length} exact byte-pinned contract artifacts for the v3.4.0 runtime).`);
|
|
388
388
|
}
|
|
389
389
|
|
|
390
390
|
const isMainModule = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import { type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
4
4
|
import { TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts";
|
|
5
|
-
import { AgentsView
|
|
5
|
+
import { AgentsView } from "../lib/agents-view.ts";
|
|
6
6
|
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
7
7
|
|
|
8
8
|
const plainTheme = { fg: (_color: string, text: string) => text };
|
|
@@ -111,30 +111,25 @@ test("AgentsView keeps headings non-actionable and clears a hidden selected chil
|
|
|
111
111
|
view.dispose();
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
-
test("AgentsView
|
|
114
|
+
test("AgentsView keeps finished children listed as history instead of dropping them", () => {
|
|
115
115
|
let now = 10_000;
|
|
116
116
|
const { store, view } = harness(12, () => now);
|
|
117
117
|
store.add(task("live", "current-session-id", { agent: "retained" }));
|
|
118
118
|
store.add(task("other", "other-session", { agent: "excluded" }));
|
|
119
119
|
store.apply("live", { type: "text", text: "kept thread" }, now);
|
|
120
120
|
store.update("live", { status: TASK_STATUS.COMPLETED, endedAt: now });
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
assert.equal(view.selectedTask(), undefined);
|
|
128
|
-
}
|
|
129
|
-
now = 10_000 + SESSION_FINISHED_TTL_MS;
|
|
130
|
-
assert.doesNotMatch(view.render(100).join("\n"), /Subagent retained/);
|
|
131
|
-
assert.equal(view.selectedTask(), undefined);
|
|
121
|
+
now += 60_000;
|
|
122
|
+
const output = view.render(100).map(stripAnsi).join("\n");
|
|
123
|
+
assert.match(output, /Subagent retained/, "a finished task of this session stays listed, long after it ended");
|
|
124
|
+
assert.equal(store.thread("live").items.length, 1, "its retained thread remains available");
|
|
125
|
+
assert.doesNotMatch(output, /excluded/, "another session's task never shows");
|
|
126
|
+
now += 60_000;
|
|
132
127
|
store.add(task("archive", "current-session-id", { agent: "archived", status: TASK_STATUS.COMPLETED, endedAt: now }));
|
|
133
|
-
assert.
|
|
128
|
+
assert.match(view.render(100).join("\n"), /Subagent archived/, "a later finished task joins the history too");
|
|
134
129
|
view.dispose();
|
|
135
130
|
});
|
|
136
131
|
|
|
137
|
-
test("AgentsView
|
|
132
|
+
test("AgentsView keeps an explicit collapse across a finish, and reveals the finished child as history on re-expand", () => {
|
|
138
133
|
const { store, view } = harness();
|
|
139
134
|
store.add(task("live", "current-session-id", { agent: "hidden" }));
|
|
140
135
|
view.handleInput("a");
|
|
@@ -142,10 +137,12 @@ test("AgentsView retains an idle open heading and explicit collapse after the fi
|
|
|
142
137
|
view.handleInput("k");
|
|
143
138
|
view.handleInput("\x1b[D");
|
|
144
139
|
store.update("live", { status: TASK_STATUS.COMPLETED, endedAt: 10_000 });
|
|
145
|
-
assert.doesNotMatch(view.render(100).join("\n"), /Subagent hidden
|
|
140
|
+
assert.doesNotMatch(view.render(100).join("\n"), /Subagent hidden/, "an explicit collapse persists across the child finishing");
|
|
146
141
|
view.handleInput("\x1b[C");
|
|
147
|
-
|
|
148
|
-
assert.match(
|
|
142
|
+
const revealed = view.render(100).join("\n");
|
|
143
|
+
assert.match(revealed, /Subagent hidden/, "re-expanding reveals the finished child as history, not nothing");
|
|
144
|
+
assert.match(revealed, /Current orchestrator · 1 Su/, "the group's own count now includes the finished child");
|
|
145
|
+
assert.match(revealed, /0 active · 1 finished/, "the panel header reflects the finished child too");
|
|
149
146
|
store.add(task("replacement", "current-session-id", { agent: "replacement" }));
|
|
150
147
|
assert.match(view.render(100).join("\n"), /Subagent replacement/);
|
|
151
148
|
view.dispose();
|
|
@@ -177,3 +174,63 @@ test("AgentsView keeps its selected listener stable while another listener recei
|
|
|
177
174
|
unsubscribe();
|
|
178
175
|
view.dispose();
|
|
179
176
|
});
|
|
177
|
+
|
|
178
|
+
// A1 (odd/tasks/usage-click-and-changes-attribution.md): finished subagents
|
|
179
|
+
// stay listed as history instead of vanishing, ordered active first and then
|
|
180
|
+
// newest-ended-first, with the panel header reading "N active . M finished",
|
|
181
|
+
// and never cancellable or reorderable by later activity.
|
|
182
|
+
|
|
183
|
+
test("AgentsView lists active children before finished ones, newest ended first", () => {
|
|
184
|
+
const { store, view } = harness();
|
|
185
|
+
store.add(task("old-finished", "current-session-id", { agent: "old-finished", status: TASK_STATUS.COMPLETED, endedAt: 1000, createdAt: 100 }));
|
|
186
|
+
store.add(task("new-finished", "current-session-id", { agent: "new-finished", status: TASK_STATUS.FAILED, endedAt: 3000, createdAt: 100 }));
|
|
187
|
+
store.add(task("active", "current-session-id", { agent: "active-one", createdAt: 50 }));
|
|
188
|
+
const output = view.render(100).join("\n");
|
|
189
|
+
const activeIndex = output.indexOf("Subagent active-one");
|
|
190
|
+
const newFinishedIndex = output.indexOf("Subagent new-finished");
|
|
191
|
+
const oldFinishedIndex = output.indexOf("Subagent old-finished");
|
|
192
|
+
assert.ok(activeIndex >= 0 && newFinishedIndex >= 0 && oldFinishedIndex >= 0, "every task is listed");
|
|
193
|
+
assert.ok(activeIndex < newFinishedIndex, "active tasks list before finished ones");
|
|
194
|
+
assert.ok(newFinishedIndex < oldFinishedIndex, "finished tasks order newest ended first, regardless of creation order");
|
|
195
|
+
view.dispose();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("the panel header counts active and finished separately", () => {
|
|
199
|
+
const { store, view } = harness();
|
|
200
|
+
store.add(task("active-1", "current-session-id", { agent: "active-1" }));
|
|
201
|
+
store.add(task("done-1", "current-session-id", { agent: "done-1", status: TASK_STATUS.COMPLETED, endedAt: 1000 }));
|
|
202
|
+
store.add(task("done-2", "current-session-id", { agent: "done-2", status: TASK_STATUS.FAILED, endedAt: 2000 }));
|
|
203
|
+
assert.match(view.render(100).join("\n"), /❀ Agents · this session · 1 active · 2 finished/);
|
|
204
|
+
view.dispose();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("a finished task's row offers no stop control and ignores the stop key", () => {
|
|
208
|
+
const { store, view } = harness();
|
|
209
|
+
store.add(task("done", "current-session-id", { agent: "done", status: TASK_STATUS.COMPLETED, endedAt: 1000 }));
|
|
210
|
+
let cancelled: string | undefined;
|
|
211
|
+
view.dispose();
|
|
212
|
+
const cancelView = new AgentsView({
|
|
213
|
+
theme: plainTheme,
|
|
214
|
+
rows: 12,
|
|
215
|
+
store,
|
|
216
|
+
sessionId: "current-session-id",
|
|
217
|
+
now: () => 10_000,
|
|
218
|
+
onCancel: (task) => { cancelled = task.id; },
|
|
219
|
+
onOpen: () => {},
|
|
220
|
+
onClose: () => {},
|
|
221
|
+
requestRender: () => {},
|
|
222
|
+
});
|
|
223
|
+
assert.doesNotMatch(cancelView.render(100).join("\n"), /Stop selected/, "the footer never offers to stop a finished task");
|
|
224
|
+
cancelView.handleInput("s");
|
|
225
|
+
assert.equal(cancelled, undefined, "the stop key is a no-op on a finished task's row");
|
|
226
|
+
cancelView.dispose();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("history retention caps finished tasks at 200 per session", () => {
|
|
230
|
+
const { store, view } = harness();
|
|
231
|
+
for (let i = 0; i < 202; i += 1) {
|
|
232
|
+
store.add(task(`f${i}`, "current-session-id", { agent: `f${i}`, status: TASK_STATUS.COMPLETED, endedAt: i, createdAt: i }));
|
|
233
|
+
}
|
|
234
|
+
assert.match(view.render(100).join("\n"), /0 active · 200 finished/, "the oldest finished tasks beyond the cap are dropped");
|
|
235
|
+
view.dispose();
|
|
236
|
+
});
|
|
@@ -163,15 +163,15 @@ test("activating a visible child preserves the manual list offset through Back a
|
|
|
163
163
|
}
|
|
164
164
|
});
|
|
165
165
|
|
|
166
|
-
test("
|
|
166
|
+
test("a task finishing while its details are open in narrow mode keeps its thread visible instead of kicking back to the list", () => {
|
|
167
167
|
const { store, view, events } = harness(8);
|
|
168
168
|
store.add(task("a"));
|
|
169
169
|
view.render(40);
|
|
170
170
|
clickLabel(view, "Fullscreen", 80);
|
|
171
171
|
assert.deepEqual(events, []);
|
|
172
172
|
store.update("a", { status: TASK_STATUS.COMPLETED, endedAt: 5000 });
|
|
173
|
-
assert.equal(view.selectedTask(),
|
|
174
|
-
assert.
|
|
173
|
+
assert.equal(view.selectedTask()?.id, "a", "the finished task stays selected as history instead of disappearing");
|
|
174
|
+
assert.match(view.render(80).join("\n"), /Back|Subagent explore/, "narrow details mode stays open on the now-finished task");
|
|
175
175
|
assert.deepEqual(events, []);
|
|
176
176
|
view.dispose();
|
|
177
177
|
});
|
|
@@ -254,11 +254,12 @@ test("AgentsView renders the frame with the task list and the selected thread's
|
|
|
254
254
|
for (const line of lines) assert.equal(visibleWidth(line), 90, `"${stripAnsi(line)}" is not 90 wide`);
|
|
255
255
|
const plain = lines.map(stripAnsi);
|
|
256
256
|
assert.equal(plain.length, 8);
|
|
257
|
-
assert.match(plain[0], /^╭─ ❀ Agents · this session · 1 active ─+ \[F Fullscreen\] \[× Close\]╮$/);
|
|
257
|
+
assert.match(plain[0], /^╭─ ❀ Agents · this session · 1 active · 1 finished ─+ \[F Fullscreen\] \[× Close\]╮$/);
|
|
258
258
|
assert.match(plain[1], /▸ └ ◐ Subagent explore.*explore · running · gpt-5\.6-terra · 34k · \$0\.27 · 1m00s/);
|
|
259
259
|
assert.match(plain[2], /line 3/, "the thread window follows the tail");
|
|
260
260
|
assert.match(plain[3], /line 4/);
|
|
261
|
-
assert.
|
|
261
|
+
assert.match(plain.join("\n"), /Subagent worker/, "the session's own finished task now stays listed as history");
|
|
262
|
+
assert.doesNotMatch(plain.join("\n"), /orchestrator/i);
|
|
262
263
|
assert.match(plain[4], /line 5/);
|
|
263
264
|
assert.match(plain[6], /\[ Follow \].*\[ Open session \].*\[Stop\]/);
|
|
264
265
|
assert.match(plain[7], /^╰─+╯$/);
|
|
@@ -306,7 +307,7 @@ test("AgentsView subscribes only to the selected task and survives an empty stor
|
|
|
306
307
|
assert.equal(renders(), before + 1, "disposed views stay quiet");
|
|
307
308
|
});
|
|
308
309
|
|
|
309
|
-
test("
|
|
310
|
+
test("current panel exposes active children directly at desktop and narrow widths, and keeps its own finished history", () => {
|
|
310
311
|
for (const width of [40, 80]) {
|
|
311
312
|
const { store, view, events } = harness(12, "s");
|
|
312
313
|
try {
|
|
@@ -318,8 +319,12 @@ test("live-only current panel exposes active children directly at desktop and na
|
|
|
318
319
|
}
|
|
319
320
|
store.add(task("foreign", { agent: "foreign", parentSessionId: "other" }));
|
|
320
321
|
const frame = view.render(width).map(stripAnsi).join("\n");
|
|
321
|
-
assert.doesNotMatch(frame, /orchestrator|
|
|
322
|
+
assert.doesNotMatch(frame, /orchestrator|foreign/i);
|
|
322
323
|
for (const status of ["running", "queued", "waiting"]) assert.match(frame, new RegExp(`Subagent ${status}`));
|
|
324
|
+
// The narrow list column truncates each "history_<status>" label to a
|
|
325
|
+
// common "history_…" prefix, so count occurrences instead of matching
|
|
326
|
+
// each one by its full name.
|
|
327
|
+
assert.equal((frame.match(/Subagent history_/g) ?? []).length, 4, "all four finished tasks of this session stay listed as history");
|
|
323
328
|
assert.match(stripAnsi(view.render(width)[1]), /Subagent queued/, "equal creation times sort by ID, with no parent wrapper");
|
|
324
329
|
assert.equal(view.selectedTask()?.id, "running", "adding earlier-sorted children preserves the existing selection");
|
|
325
330
|
view.handleInput("k");
|
|
@@ -332,22 +337,27 @@ test("live-only current panel exposes active children directly at desktop and na
|
|
|
332
337
|
view.handleInput("s");
|
|
333
338
|
assert.deepEqual(events, [`cancel:${selected.id}`]);
|
|
334
339
|
store.update(selected.id, { status: TASK_STATUS.COMPLETED, endedAt: 61_000 });
|
|
335
|
-
assert.
|
|
340
|
+
assert.equal(view.selectedTask()?.id, selected.id, "completion keeps the task selected, now as finished history");
|
|
336
341
|
} finally {
|
|
337
342
|
view.dispose();
|
|
338
343
|
}
|
|
339
344
|
}
|
|
340
345
|
});
|
|
341
346
|
|
|
342
|
-
test("
|
|
347
|
+
test("current panel never promotes retained foreign or unknown tasks into open sessions, but keeps its own finished history", () => {
|
|
343
348
|
const { store, view } = harness(12, "s");
|
|
344
349
|
try {
|
|
345
350
|
store.add(task("foreign", { agent: "foreign", parentSessionId: "closed" }));
|
|
346
351
|
store.add(task("unknown", { agent: "unknown", parentSessionId: "" }));
|
|
347
352
|
store.add(task("finished", { agent: "finished", status: TASK_STATUS.COMPLETED, endedAt: 61_000 }));
|
|
348
|
-
assert.equal(view.selectedTask(),
|
|
353
|
+
assert.equal(view.selectedTask()?.id, "finished", "the session's own finished task is selectable even with nothing active");
|
|
349
354
|
view.handleInput("a");
|
|
350
|
-
|
|
355
|
+
// The "Current orchestrator" heading starts collapsed here: it has no
|
|
356
|
+
// active task to auto-expand it, only finished history.
|
|
357
|
+
view.handleInput("\x1b[C");
|
|
358
|
+
const rendered = view.render(80).map(stripAnsi).join("\n");
|
|
359
|
+
assert.doesNotMatch(rendered, /Subagent (foreign|unknown)|Orchestrator closed|Unknown session/);
|
|
360
|
+
assert.match(rendered, /Subagent finished/, "the session's own finished task shows under all sessions too, once its heading is expanded");
|
|
351
361
|
assert.equal(store.list().length, 3, "panel filtering never deletes retained history");
|
|
352
362
|
} finally {
|
|
353
363
|
view.dispose();
|
|
@@ -553,7 +563,7 @@ test("AgentsView scrolls the task list so the selection stays visible when there
|
|
|
553
563
|
assert.match(listed()[2], /Subagent agent3/);
|
|
554
564
|
});
|
|
555
565
|
|
|
556
|
-
test("AgentsView scopes to
|
|
566
|
+
test("AgentsView scopes to this session's own children (active and finished) and toggles only the open-session directory", () => {
|
|
557
567
|
const { store, view } = harness(12, "s");
|
|
558
568
|
store.add(task("mine", { agent: "mine" }));
|
|
559
569
|
store.add(task("theirs", { agent: "theirs", parentSessionId: "other", createdAt: 900, lastActivityAt: 900 }));
|
|
@@ -561,19 +571,19 @@ test("AgentsView scopes to live children and toggles only the open-session direc
|
|
|
561
571
|
store.add(task("stale", { agent: "stale", status: TASK_STATUS.COMPLETED, endedAt: 61_000 - 16 * 60_000, createdAt: 800, lastActivityAt: 800 }));
|
|
562
572
|
const names = () => view.render(80).map(stripAnsi).filter((line) => /[◐✓] Subagent /.test(line)).map((line) => line.match(/[◐✓] Subagent (\w+)/)?.[1]);
|
|
563
573
|
let plain = view.render(80).map(stripAnsi);
|
|
564
|
-
assert.match(plain[0], /^╭─ ❀ Agents · this session · 1 active ─+ \[F Fullscreen\] \[× Close\]╮$/);
|
|
565
|
-
assert.deepEqual(names(), ["mine"], "
|
|
574
|
+
assert.match(plain[0], /^╭─ ❀ Agents · this session · 1 active · 2 finished ─+ \[F Fullscreen\] \[× Close\]╮$/);
|
|
575
|
+
assert.deepEqual(names(), ["mine", "fresh", "stale"], "the session's own history stays, newest ended first, after the active child; the foreign task stays out");
|
|
566
576
|
assert.match(plain.at(-2) ?? "", /\[Scope\]/);
|
|
567
577
|
view.handleInput("a");
|
|
568
578
|
plain = view.render(80).map(stripAnsi);
|
|
569
|
-
assert.match(plain[0], /^╭─ ❀ Agents · all sessions · 1 active ─+ \[F Fullscreen\] \[× Close\]╮$/);
|
|
570
|
-
assert.deepEqual(names(), ["mine"], "retained foreign tasks cannot manufacture open orchestrators");
|
|
579
|
+
assert.match(plain[0], /^╭─ ❀ Agents · all sessions · 1 active · 2 finished ─+ \[F Fullscreen\] \[× Close\]╮$/);
|
|
580
|
+
assert.deepEqual(names(), ["mine", "fresh", "stale"], "retained foreign tasks cannot manufacture open orchestrators");
|
|
571
581
|
assert.match(plain.at(-2) ?? "", /\[Scope\]/);
|
|
572
582
|
view.handleInput("j");
|
|
573
|
-
assert.equal(view.selectedTask()?.id, "
|
|
583
|
+
assert.equal(view.selectedTask()?.id, "fresh", "navigation moves to the session's own next-newest history entry");
|
|
574
584
|
view.handleInput("a");
|
|
575
585
|
assert.equal(view.selectedTask()?.id, "mine", "a new scope reads from the top");
|
|
576
|
-
assert.deepEqual(names(), ["mine"]);
|
|
586
|
+
assert.deepEqual(names(), ["mine", "fresh", "stale"]);
|
|
577
587
|
});
|
|
578
588
|
|
|
579
589
|
test("AgentsView footer buttons follow, open only a session-backed selection, and clear stale geometry", () => {
|