gentle-pi 3.2.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/orchestrator-delegation.md +13 -8
- package/assets/orchestrator.md +2 -2
- package/docs/gentle-shell.md +40 -17
- package/docs/readme-reference.md +41 -7
- package/docs/review-integration.md +25 -11
- package/extensions/gentle-agents.ts +85 -17
- package/extensions/gentle-ai.ts +179 -12
- package/extensions/gentle-shell.ts +408 -38
- package/extensions/gentle-todo.ts +19 -1
- package/lib/agents-view.ts +41 -14
- package/lib/agents-widget.ts +84 -13
- package/lib/command-palette-catalog.ts +1 -0
- package/lib/double-esc-cancel-policy.ts +138 -0
- package/lib/inprocess-reviewer.ts +260 -0
- package/lib/model-routing-authority.ts +1 -1
- package/lib/native-review-cli.ts +23 -0
- package/lib/odd-runtime-delegation-gate.ts +88 -0
- package/lib/review-host-relay.ts +262 -94
- package/lib/review-integration-v2.ts +110 -26
- package/lib/shell-bar.ts +158 -29
- package/lib/shell-card.ts +19 -9
- package/lib/shell-changes-view.ts +43 -5
- package/lib/shell-changes.ts +92 -5
- package/lib/shell-hover.ts +39 -0
- package/lib/shell-prompt.ts +10 -1
- package/lib/shell-sidebar-layout.ts +111 -15
- package/lib/shell-sidebar.ts +16 -0
- package/lib/shell-todo.ts +7 -1
- package/lib/shell-usage-view.ts +98 -10
- package/lib/shell-usage.ts +226 -10
- package/package.json +2 -1
- package/runtime/native-review-cli.mjs +23 -0
- package/runtime/review-integration-v2.mjs +110 -26
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
- package/scripts/mirror-odd-routing.mjs +242 -0
- package/scripts/verify-package-files.mjs +3 -3
- package/tests/agents-grouping.test.ts +75 -18
- package/tests/agents-view.test.ts +28 -18
- package/tests/agents-widget.test.ts +100 -12
- package/tests/command-palette.test.ts +1 -0
- package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
- package/tests/double-esc-cancel-policy.test.ts +194 -0
- package/tests/gentle-agents.test.ts +528 -5
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +69 -5
- package/tests/gentle-shell.test.ts +903 -25
- package/tests/gentle-todo.test.ts +17 -4
- package/tests/inprocess-reviewer.test.ts +368 -0
- package/tests/maintainer/provider-relay.maintest.ts +101 -143
- package/tests/native-review-capability-contract.test.ts +32 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
- package/tests/odd-routing-contract.test.ts +57 -0
- package/tests/odd-runtime-delegation-gate.test.ts +212 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/package-manifest.test.ts +6 -6
- package/tests/review-controller-native-routing.test.ts +60 -1
- package/tests/review-host-relay-routing.test.ts +77 -0
- package/tests/review-host-relay.test.ts +285 -239
- package/tests/review-integration-v2-forward.test.ts +61 -0
- package/tests/review-integration-v2.test.ts +116 -1
- package/tests/review-relay-transport-agent.test.ts +83 -0
- package/tests/runtime-harness.mjs +11 -0
- package/tests/session-changes-shell.test.ts +27 -0
- package/tests/session-worktree-registry.test.ts +41 -0
- package/tests/shell-bar.test.ts +224 -6
- package/tests/shell-card.test.ts +5 -3
- package/tests/shell-changes-view.test.ts +47 -0
- package/tests/shell-changes.test.ts +177 -0
- package/tests/shell-hover.test.ts +19 -0
- package/tests/shell-prompt.test.ts +20 -0
- package/tests/shell-sidebar-fullscreen.test.ts +59 -0
- package/tests/shell-sidebar-layout.test.ts +243 -5
- package/tests/shell-sidebar.test.ts +25 -1
- package/tests/shell-todo.test.ts +36 -0
- package/tests/shell-usage-view.test.ts +123 -3
- package/tests/shell-usage.test.ts +254 -6
- package/lib/opaque-pi-reviewer-adapter.ts +0 -284
- package/tests/opaque-pi-reviewer-adapter.test.ts +0 -266
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ODD routing drift ratchet — canonical fixture regeneration.
|
|
3
|
+
//
|
|
4
|
+
// gentle-pi hand-mirrors the always-on ODD routing block rendered by gentle-ai
|
|
5
|
+
// `internal/components/agentguidance/routing.go` (RenderRouting). There is no
|
|
6
|
+
// automated sync, so a canonical change leaves the pi mirror stale with nothing
|
|
7
|
+
// catching it. This script renders the canonical block from a LOCAL gentle-ai
|
|
8
|
+
// checkout and vendors it into `fixtures/odd-routing-canonical.md` with managed
|
|
9
|
+
// provenance, where `tests/odd-routing-canonical-ratchet.test.ts` turns drift
|
|
10
|
+
// into a failing test.
|
|
11
|
+
//
|
|
12
|
+
// This script NEVER fetches anything from the network (GOPROXY=off), NEVER
|
|
13
|
+
// modifies the gentle-ai checkout beyond a transient temporary Go entrypoint
|
|
14
|
+
// that it always deletes, and NEVER touches the installer release pin
|
|
15
|
+
// (scripts/gentle-ai-installer.mjs INSTALLER_VERSION). Regeneration writes ONLY
|
|
16
|
+
// the fixture; it never auto-rewrites the pi mirror assets.
|
|
17
|
+
//
|
|
18
|
+
// Usage:
|
|
19
|
+
// node scripts/mirror-odd-routing.mjs [--gentle-ai <path to local checkout>]
|
|
20
|
+
|
|
21
|
+
import { execFileSync } from "node:child_process";
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { dirname, join, resolve } from "node:path";
|
|
25
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
26
|
+
|
|
27
|
+
export const ODD_ROUTING_FIXTURE_RELATIVE = "fixtures/odd-routing-canonical.md";
|
|
28
|
+
export const ODD_ROUTING_SOURCE_REPO = "https://github.com/Gentleman-Programming/gentle-ai";
|
|
29
|
+
export const ODD_ROUTING_SOURCE_PATH = "internal/components/agentguidance/routing.go";
|
|
30
|
+
export const ODD_ROUTING_HEADER_OPEN = "<!-- gentle-pi:managed-odd-routing-canonical\n";
|
|
31
|
+
export const ODD_ROUTING_HEADER_CLOSE = "\n-->\n";
|
|
32
|
+
export const ODD_ROUTING_TEMP_DIR = "tmp-odd-routing-dump";
|
|
33
|
+
export const ODD_ROUTING_BLOCK_PREFIX = "## Implementation Routing";
|
|
34
|
+
|
|
35
|
+
const GO_ENTRYPOINT = `package main
|
|
36
|
+
|
|
37
|
+
import (
|
|
38
|
+
"fmt"
|
|
39
|
+
"os"
|
|
40
|
+
|
|
41
|
+
"github.com/gentleman-programming/gentle-ai/v3/internal/components/agentguidance"
|
|
42
|
+
"github.com/gentleman-programming/gentle-ai/v3/internal/model"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
func main() {
|
|
46
|
+
rendered, err := agentguidance.RenderRouting(model.AgentPi)
|
|
47
|
+
if err != nil {
|
|
48
|
+
fmt.Fprintln(os.Stderr, err)
|
|
49
|
+
os.Exit(1)
|
|
50
|
+
}
|
|
51
|
+
fmt.Print(rendered)
|
|
52
|
+
}
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
export function sha256Hex(value) {
|
|
56
|
+
return createHash("sha256").update(value).digest("hex");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function parseOddRoutingFixture(raw) {
|
|
60
|
+
if (!raw.startsWith(ODD_ROUTING_HEADER_OPEN)) {
|
|
61
|
+
throw new Error(`fixture does not start with the managed header ${JSON.stringify(ODD_ROUTING_HEADER_OPEN)}`);
|
|
62
|
+
}
|
|
63
|
+
const headerEnd = raw.indexOf(ODD_ROUTING_HEADER_CLOSE, ODD_ROUTING_HEADER_OPEN.length);
|
|
64
|
+
if (headerEnd === -1) {
|
|
65
|
+
throw new Error(`fixture managed header is not terminated by ${JSON.stringify(ODD_ROUTING_HEADER_CLOSE)}`);
|
|
66
|
+
}
|
|
67
|
+
const headerText = raw.slice(ODD_ROUTING_HEADER_OPEN.length, headerEnd);
|
|
68
|
+
const body = raw.slice(headerEnd + ODD_ROUTING_HEADER_CLOSE.length);
|
|
69
|
+
const header = {};
|
|
70
|
+
for (const line of headerText.split("\n")) {
|
|
71
|
+
const separator = line.indexOf(": ");
|
|
72
|
+
if (separator === -1) continue;
|
|
73
|
+
header[line.slice(0, separator)] = line.slice(separator + 2);
|
|
74
|
+
}
|
|
75
|
+
return { header, body };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function renderOddRoutingFixture(block, { sourceCommit, generatedAt }) {
|
|
79
|
+
const body = block.endsWith("\n") ? block : `${block}\n`;
|
|
80
|
+
const header = [
|
|
81
|
+
ODD_ROUTING_HEADER_OPEN.trimEnd(),
|
|
82
|
+
`source_repo: ${ODD_ROUTING_SOURCE_REPO}`,
|
|
83
|
+
`source_path: ${ODD_ROUTING_SOURCE_PATH}`,
|
|
84
|
+
`source_commit: ${sourceCommit}`,
|
|
85
|
+
`generated_at: ${generatedAt}`,
|
|
86
|
+
`block_sha256: ${sha256Hex(body)}`,
|
|
87
|
+
"-->",
|
|
88
|
+
].join("\n");
|
|
89
|
+
return `${header}\n${body}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// `git status --porcelain --untracked-files=no` emits one `XY PATH` line per
|
|
93
|
+
// modified tracked file. Extract just the repository-relative path so the
|
|
94
|
+
// fail-closed error can name what would make provenance unverifiable.
|
|
95
|
+
export function parsePorcelainPaths(porcelainOutput) {
|
|
96
|
+
return porcelainOutput
|
|
97
|
+
.split("\n")
|
|
98
|
+
.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line))
|
|
99
|
+
.filter((line) => line.length > 0)
|
|
100
|
+
.map((line) => line.slice(3).trim())
|
|
101
|
+
.filter((path) => path.length > 0);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Fail closed when the checkout carries uncommitted changes to tracked files.
|
|
105
|
+
// The fixture header asserts the rendered block came from the committed tree;
|
|
106
|
+
// rendering a dirty tree under that claim would make the provenance a lie.
|
|
107
|
+
// Untracked files never reach this guard because the git call excludes them.
|
|
108
|
+
export function assertCleanGentleAiCheckout(porcelainOutput) {
|
|
109
|
+
const dirtyPaths = parsePorcelainPaths(porcelainOutput);
|
|
110
|
+
if (dirtyPaths.length > 0) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`gentle-ai checkout is dirty; provenance would be unverifiable: ${dirtyPaths.join(", ")}. Commit or stash first.`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseArguments(argv) {
|
|
118
|
+
const values = { gentleAi: undefined };
|
|
119
|
+
for (let index = 0; index < argv.length; index += 2) {
|
|
120
|
+
const flag = argv[index];
|
|
121
|
+
const value = argv[index + 1];
|
|
122
|
+
if (flag !== "--gentle-ai" || value === undefined) {
|
|
123
|
+
throw new Error("usage: node scripts/mirror-odd-routing.mjs [--gentle-ai <path to local checkout>]");
|
|
124
|
+
}
|
|
125
|
+
if (values.gentleAi !== undefined) throw new Error("duplicate --gentle-ai argument");
|
|
126
|
+
values.gentleAi = value;
|
|
127
|
+
}
|
|
128
|
+
return values;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function requireGentleAiCheckout(gentleAiRoot) {
|
|
132
|
+
if (!existsSync(gentleAiRoot)) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`no gentle-ai checkout at ${gentleAiRoot}; pass --gentle-ai <path> or clone ${ODD_ROUTING_SOURCE_REPO} as a sibling of gentle-pi`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
const routingPath = join(gentleAiRoot, ...ODD_ROUTING_SOURCE_PATH.split("/"));
|
|
138
|
+
if (!existsSync(routingPath)) {
|
|
139
|
+
throw new Error(`not a gentle-ai checkout: missing ${routingPath}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function renderCanonicalRouting(gentleAiRoot) {
|
|
144
|
+
const tempRoot = join(gentleAiRoot, ODD_ROUTING_TEMP_DIR);
|
|
145
|
+
// Clear any stale temp entrypoint from a previous crash so the render is clean.
|
|
146
|
+
rmSync(tempRoot, { recursive: true, force: true });
|
|
147
|
+
mkdirSync(tempRoot, { recursive: true });
|
|
148
|
+
try {
|
|
149
|
+
writeFileSync(join(tempRoot, "main.go"), GO_ENTRYPOINT, { encoding: "utf8" });
|
|
150
|
+
const rendered = execFileSync("go", ["run", `./${ODD_ROUTING_TEMP_DIR}`], {
|
|
151
|
+
cwd: gentleAiRoot,
|
|
152
|
+
encoding: "utf8",
|
|
153
|
+
// GOPROXY=off fails closed instead of downloading: this script must
|
|
154
|
+
// never touch the network.
|
|
155
|
+
env: { ...process.env, GOPROXY: "off" },
|
|
156
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
157
|
+
});
|
|
158
|
+
if (!rendered.startsWith(ODD_ROUTING_BLOCK_PREFIX)) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`RenderRouting(model.AgentPi) did not return the expected routing block (starts with ${JSON.stringify(ODD_ROUTING_BLOCK_PREFIX)})`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return rendered;
|
|
164
|
+
} finally {
|
|
165
|
+
rmSync(tempRoot, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Resolve fixture provenance from the committed gentle-ai tree. `generatedAt`
|
|
170
|
+
// is the committer date, not wall-clock time, so rendering the same commit
|
|
171
|
+
// twice yields byte-identical fixture files. `execGit` is injectable for tests.
|
|
172
|
+
export function resolveOddRoutingProvenance(gentleAiRoot, execGit) {
|
|
173
|
+
const git = execGit ?? ((args) => execFileSync("git", args, { cwd: gentleAiRoot, encoding: "utf8" }));
|
|
174
|
+
assertCleanGentleAiCheckout(git(["status", "--porcelain", "--untracked-files=no"]));
|
|
175
|
+
return {
|
|
176
|
+
sourceCommit: git(["rev-parse", "HEAD"]).trim(),
|
|
177
|
+
generatedAt: git(["show", "-s", "--format=%cI", "HEAD"]).trim(),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function mirrorOddRouting(packageRoot, gentleAiRoot) {
|
|
182
|
+
requireGentleAiCheckout(gentleAiRoot);
|
|
183
|
+
// Resolve (and fail closed on dirty) provenance BEFORE rendering: the
|
|
184
|
+
// transient Go entrypoint must not run against a tree whose commit hash the
|
|
185
|
+
// fixture would misrepresent.
|
|
186
|
+
const provenance = resolveOddRoutingProvenance(gentleAiRoot);
|
|
187
|
+
const block = renderCanonicalRouting(gentleAiRoot);
|
|
188
|
+
const contents = renderOddRoutingFixture(block, provenance);
|
|
189
|
+
|
|
190
|
+
const fixturePath = join(resolve(packageRoot), ...ODD_ROUTING_FIXTURE_RELATIVE.split("/"));
|
|
191
|
+
const previous = readPreviousFixture(fixturePath);
|
|
192
|
+
mkdirSync(dirname(fixturePath), { recursive: true });
|
|
193
|
+
|
|
194
|
+
// Atomic overwrite: write a sibling temp file, then rename it into place.
|
|
195
|
+
const stagingPath = `${fixturePath}.tmp`;
|
|
196
|
+
try {
|
|
197
|
+
writeFileSync(stagingPath, contents, { encoding: "utf8" });
|
|
198
|
+
renameSync(stagingPath, fixturePath);
|
|
199
|
+
} finally {
|
|
200
|
+
rmSync(stagingPath, { force: true });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const next = parseOddRoutingFixture(contents);
|
|
204
|
+
return {
|
|
205
|
+
fixturePath,
|
|
206
|
+
sourceCommit: next.header.source_commit,
|
|
207
|
+
blockSha256: next.header.block_sha256,
|
|
208
|
+
changed: previous === undefined || previous.body !== next.body || previous.header.source_commit !== next.header.source_commit,
|
|
209
|
+
previous,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function readPreviousFixture(fixturePath) {
|
|
214
|
+
if (!existsSync(fixturePath)) return undefined;
|
|
215
|
+
const raw = readFileSync(fixturePath, "utf8");
|
|
216
|
+
// Verify the existing fixture before clobbering it: an unparseable file is a
|
|
217
|
+
// signal to inspect, not to silently overwrite.
|
|
218
|
+
const parsed = parseOddRoutingFixture(raw);
|
|
219
|
+
const recorded = parsed.header.block_sha256;
|
|
220
|
+
const actual = sha256Hex(parsed.body);
|
|
221
|
+
if (recorded !== undefined && recorded !== actual) {
|
|
222
|
+
console.warn(`warning: existing fixture body does not match its recorded digest (${recorded}); regenerating from canonical`);
|
|
223
|
+
}
|
|
224
|
+
return parsed;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function main() {
|
|
228
|
+
const packageRoot = join(fileURLToPath(new URL("..", import.meta.url)));
|
|
229
|
+
const { gentleAi } = parseArguments(process.argv.slice(2));
|
|
230
|
+
const gentleAiRoot = resolve(gentleAi ?? join(packageRoot, "..", "gentle-ai"));
|
|
231
|
+
const result = mirrorOddRouting(packageRoot, gentleAiRoot);
|
|
232
|
+
console.log(`${result.changed ? "Wrote" : "Refreshed"} ${result.fixturePath}`);
|
|
233
|
+
console.log(`source commit ${result.sourceCommit}; block sha256 ${result.blockSha256}`);
|
|
234
|
+
if (result.previous !== undefined) {
|
|
235
|
+
console.log(`previous commit ${result.previous.header.source_commit}; previous block sha256 ${result.previous.header.block_sha256}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const isMainModule = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
240
|
+
if (isMainModule) {
|
|
241
|
+
await main();
|
|
242
|
+
}
|
|
@@ -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", () => {
|