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
|
@@ -5,11 +5,14 @@ import { homedir, tmpdir } from "node:os";
|
|
|
5
5
|
import { createRequire, syncBuiltinESMExports } from "node:module";
|
|
6
6
|
import { dirname, isAbsolute, join, relative, resolve, sep, win32 } from "node:path";
|
|
7
7
|
import { pendingReviewMutation, REVIEW_REMINDER_RECEIPT } from "../lib/review-reminder-receipt.ts";
|
|
8
|
-
import { SESSION_WORKTREE_ENTRY, SESSION_WORKTREE_CHANGED } from "../lib/session-worktree-registry.ts";
|
|
8
|
+
import { SESSION_WORKTREE_ENTRY, SESSION_WORKTREE_CHANGED, resolveSessionWorktree } from "../lib/session-worktree-registry.ts";
|
|
9
|
+
import { installSessionChangeCapture } from "../lib/session-change-capture.ts";
|
|
10
|
+
import type { SessionChangeEvidence } from "../lib/session-changes.ts";
|
|
9
11
|
import test, { after, afterEach, mock } from "node:test";
|
|
10
12
|
import type { TestContext } from "node:test";
|
|
11
13
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import { visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
14
|
+
import { visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
15
|
+
import { sidebarState } from "../lib/shell-sidebar.ts";
|
|
13
16
|
import gentleAgents, { agentRuntimePaths, agentsCollapseKey, agentsEnabled, agentsStopKey, agentsViewKey, answerThroughUi, completionText, createDefaultSessionTransport, legacySubagentsInstalled, type AgentsDeps, type SessionTransportFactory } from "../extensions/gentle-agents.ts";
|
|
14
17
|
import { ActiveSessionClient, ActiveSessionListener, SessionPresenceRegistry } from "../lib/agents-session-transport.ts";
|
|
15
18
|
import { WindowsActiveSessionClient, WindowsActiveSessionListener } from "../lib/windows-session-transport.ts";
|
|
@@ -462,6 +465,21 @@ const drainLifecycle = async (label: string, promise: Promise<LifecycleOutcome>)
|
|
|
462
465
|
catch (error) { return { label, error }; }
|
|
463
466
|
};
|
|
464
467
|
|
|
468
|
+
// The disk history read behind restoreSessionHistory is fire-and-forget from
|
|
469
|
+
// session_start, so nothing signals when it lands. Poll the overlay's own
|
|
470
|
+
// render output (backed by the live, shared TaskStore) on setImmediate ticks
|
|
471
|
+
// instead of sleeping a fixed guess: fast when the restore already landed,
|
|
472
|
+
// bounded at 2s so a genuine regression still fails instead of hanging.
|
|
473
|
+
async function waitForOverlayMatch(render: () => string, pattern: RegExp, timeoutMs = 2000): Promise<string> {
|
|
474
|
+
const deadline = Date.now() + timeoutMs;
|
|
475
|
+
let rendered = render();
|
|
476
|
+
while (!pattern.test(rendered) && Date.now() < deadline) {
|
|
477
|
+
await tick();
|
|
478
|
+
rendered = render();
|
|
479
|
+
}
|
|
480
|
+
return rendered;
|
|
481
|
+
}
|
|
482
|
+
|
|
465
483
|
test("overlapping session transport startups preserve ownership and shutdown waits for every pending operation", async (t: TestContext) => {
|
|
466
484
|
const h = fakePi();
|
|
467
485
|
const runtime = deps();
|
|
@@ -1359,6 +1377,131 @@ for (const matching of [true, false]) {
|
|
|
1359
1377
|
});
|
|
1360
1378
|
}
|
|
1361
1379
|
|
|
1380
|
+
// Runs the CHILD half of installSessionChangeCapture against a real repo, the
|
|
1381
|
+
// same way an actual subagent process would: a tool_call/tool_result pair
|
|
1382
|
+
// with GENTLE_PI_AGENTS_CHILD set, using the real git-backed resolver rather
|
|
1383
|
+
// than a stub. Returns exactly the evidence object the child would put in
|
|
1384
|
+
// its tool result's details.gentleSessionChange.
|
|
1385
|
+
async function childSessionChangeEvidence(root: string, relPath: string, toolCallId: string, content: string): Promise<SessionChangeEvidence> {
|
|
1386
|
+
const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
|
|
1387
|
+
const childPi = {
|
|
1388
|
+
on: (key: string, fn: (event: unknown, ctx: unknown) => unknown) => handlers.set(key, fn),
|
|
1389
|
+
appendEntry: () => {},
|
|
1390
|
+
events: { on: () => () => {}, emit: () => {} },
|
|
1391
|
+
} as unknown as ExtensionAPI;
|
|
1392
|
+
const childCtx = { cwd: root, sessionManager: { getSessionId: () => "child-session", getEntries: () => [] } } as unknown as ExtensionContext;
|
|
1393
|
+
installSessionChangeCapture(childPi, { GENTLE_PI_AGENTS_CHILD: "1" }, resolveSessionWorktree);
|
|
1394
|
+
await handlers.get("session_start")?.({}, childCtx);
|
|
1395
|
+
const event = { toolCallId, toolName: "write", input: { path: relPath, content } };
|
|
1396
|
+
await handlers.get("tool_call")?.(event, childCtx);
|
|
1397
|
+
writeFileSync(join(root, relPath), content);
|
|
1398
|
+
const result = (await handlers.get("tool_result")?.({ ...event, isError: false }, childCtx)) as { details: { gentleSessionChange: SessionChangeEvidence } } | undefined;
|
|
1399
|
+
assert.ok(result?.details.gentleSessionChange, "the child must attach session-change evidence to its tool result");
|
|
1400
|
+
return result.details.gentleSessionChange;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// C1 investigation (odd/tasks/usage-click-and-changes-attribution.md): tried
|
|
1404
|
+
// to reproduce the live session's "16 subagent_run calls, zero relayed"
|
|
1405
|
+
// evidence end to end — real repo, real git-backed resolver on both the
|
|
1406
|
+
// child and parent sides (not the trivial path-echoing stub the other
|
|
1407
|
+
// fixtures in this file use), a real child tool_call/tool_result pair
|
|
1408
|
+
// producing the session-change evidence, and the real onSuccessfulMutation
|
|
1409
|
+
// guard chain in extensions/gentle-agents.ts, round-tripped through actual
|
|
1410
|
+
// JSON serialization the same way agents-fake-child.ts's emit() does for
|
|
1411
|
+
// every other test here. With every guard input constructed faithfully, the
|
|
1412
|
+
// relay fires correctly: parentSessionId/ownedTaskIds, root === childRoot,
|
|
1413
|
+
// worktrees.roots().includes(root), tool.evidence.root === root, and the
|
|
1414
|
+
// realpath comparison all pass.
|
|
1415
|
+
//
|
|
1416
|
+
// The one drop this file could reproduce was a test-harness gap, not a
|
|
1417
|
+
// product bug: SessionWorktreeRegistry.start() is never called for this
|
|
1418
|
+
// extension's own registry (registryFor() in extensions/gentle-agents.ts),
|
|
1419
|
+
// so worktrees.roots() is empty until some subagent's onLaunch callback
|
|
1420
|
+
// registers a root on the real child process's "spawn" event. In production
|
|
1421
|
+
// that event always fires before any tool call can complete, so the root is
|
|
1422
|
+
// registered well before any mutation; the fake child here only reproduces
|
|
1423
|
+
// that if the test explicitly wires "spawn" (as this test does). Making
|
|
1424
|
+
// registryFor() call start() eagerly was tried and reverted: it makes the
|
|
1425
|
+
// parent's own cwd a registered root at session start regardless of whether
|
|
1426
|
+
// any subagent ever actually launches into it, which breaks two existing,
|
|
1427
|
+
// deliberate invariants — "child mutation attribution through registered
|
|
1428
|
+
// subagent_run: unregistered" (a queued-but-never-spawned task must not be
|
|
1429
|
+
// attributed) and "queueing and returning a child handle do not register
|
|
1430
|
+
// roots" / "delayed child spawn retains the originating session..." (merely
|
|
1431
|
+
// constructing the registry must not append an entry). Deciding whether the
|
|
1432
|
+
// session's own root should be trusted before any subagent proves it by
|
|
1433
|
+
// actually spawning is a product/security question, not a guard bug fix, so
|
|
1434
|
+
// it was left to the parent instead of guessed at here.
|
|
1435
|
+
test("C1 investigation: the relay mechanism is correct when every guard input is real", async () => {
|
|
1436
|
+
// A real repository, spawned through the real (unstubbed) git-backed
|
|
1437
|
+
// resolver on both the child and parent sides — the same resolver the
|
|
1438
|
+
// live session used — rather than the trivial path-echoing stub the other
|
|
1439
|
+
// fixtures in this file use.
|
|
1440
|
+
const repoRoot = mkdtempSync(join(tmpdir(), "gentle-agents-c1-"));
|
|
1441
|
+
// Isolate every git spawn below from the developer's own environment: no
|
|
1442
|
+
// global/system config, no ambient $HOME gitconfig, no signing prompt, and
|
|
1443
|
+
// no hooks -- only the identity this fixture supplies explicitly.
|
|
1444
|
+
const gitHome = mkdtempSync(join(tmpdir(), "gentle-agents-c1-git-home-"));
|
|
1445
|
+
const gitHooksDir = mkdtempSync(join(tmpdir(), "gentle-agents-c1-git-hooks-"));
|
|
1446
|
+
const gitEnv = { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null", GIT_CONFIG_NOSYSTEM: "1", HOME: gitHome };
|
|
1447
|
+
const gitIdentity = ["-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false", "-c", `core.hooksPath=${gitHooksDir}`];
|
|
1448
|
+
const runGit = (args: string[]) => execFileSync("git", [...gitIdentity, ...args], { env: gitEnv });
|
|
1449
|
+
try {
|
|
1450
|
+
runGit(["init", "--quiet", "-b", "main", repoRoot]);
|
|
1451
|
+
writeFileSync(join(repoRoot, "README.md"), "seed\n");
|
|
1452
|
+
runGit(["-C", repoRoot, "add", "README.md"]);
|
|
1453
|
+
runGit(["-C", repoRoot, "commit", "--quiet", "-m", "seed"]);
|
|
1454
|
+
|
|
1455
|
+
const h = fakePi();
|
|
1456
|
+
const d = deps();
|
|
1457
|
+
const { ctx } = fakeContext();
|
|
1458
|
+
ctx.sessionManager.getCwd = () => repoRoot;
|
|
1459
|
+
ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
|
|
1460
|
+
ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
|
|
1461
|
+
d.deps.resolveWorktree = resolveSessionWorktree;
|
|
1462
|
+
// Mirror a real child process: the OS "spawn" event fires as soon as the
|
|
1463
|
+
// process starts, which is what triggers onLaunch -> registry.register.
|
|
1464
|
+
const spawn = d.deps.spawn!;
|
|
1465
|
+
d.deps.spawn = (...args) => {
|
|
1466
|
+
const child = spawn(...args);
|
|
1467
|
+
const on = child.on.bind(child);
|
|
1468
|
+
child.on = ((event: string, listener: () => void) => {
|
|
1469
|
+
if (event === "spawn") queueMicrotask(listener);
|
|
1470
|
+
return on(event as "spawn", listener);
|
|
1471
|
+
}) as typeof child.on;
|
|
1472
|
+
return child;
|
|
1473
|
+
};
|
|
1474
|
+
gentleAgents(h.pi, {}, d.deps);
|
|
1475
|
+
await h.fire("session_start", ctx);
|
|
1476
|
+
|
|
1477
|
+
const launched = await h.tools.get("subagent_run")!.execute("mutation", { agent: "explore", task: "Write a file", mode: "background" }, undefined, undefined, ctx);
|
|
1478
|
+
await tick();
|
|
1479
|
+
const gentleAgentsDetails = launched.details.gentleAgents as { taskId: string; cwd: string };
|
|
1480
|
+
const taskId = gentleAgentsDetails.taskId;
|
|
1481
|
+
// The task's cwd is whatever buildRequest resolved from the real
|
|
1482
|
+
// resolver: confirming this here pins down which value onSuccessfulMutation
|
|
1483
|
+
// will later compare against the freshly-resolved root.
|
|
1484
|
+
const childCwd = gentleAgentsDetails.cwd;
|
|
1485
|
+
|
|
1486
|
+
const evidence = await childSessionChangeEvidence(childCwd, "notes.md", "write", "agent output\n");
|
|
1487
|
+
|
|
1488
|
+
d.children[0].emit({ type: "tool_execution_start", toolCallId: "write", toolName: "write", args: { path: "notes.md" } });
|
|
1489
|
+
d.children[0].emit({ type: "tool_execution_end", toolCallId: "write", isError: false, result: { content: [], details: { gentleSessionChange: evidence } } });
|
|
1490
|
+
await tick();
|
|
1491
|
+
|
|
1492
|
+
const relays = h.events.filter((event) => event.name === "gentle-pi:child-session-change");
|
|
1493
|
+
assert.equal(relays.length, 1, `expected the child mutation to relay; evidence=${JSON.stringify(evidence)} childCwd=${childCwd}`);
|
|
1494
|
+
assert.equal((relays[0].data as { evidence: { id: string } }).evidence.id, `${taskId}:write`);
|
|
1495
|
+
|
|
1496
|
+
await h.fire("session_shutdown", ctx);
|
|
1497
|
+
await tick();
|
|
1498
|
+
} finally {
|
|
1499
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
1500
|
+
rmSync(gitHome, { recursive: true, force: true });
|
|
1501
|
+
rmSync(gitHooksDir, { recursive: true, force: true });
|
|
1502
|
+
}
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1362
1505
|
for (const scenario of ["own", "other-root", "escaped", "sibling", "session-switch", "shutdown", "unregistered"] as const) {
|
|
1363
1506
|
test(`child mutation attribution through registered subagent_run: ${scenario}`, async () => {
|
|
1364
1507
|
const h = fakePi();
|
|
@@ -1370,7 +1513,11 @@ for (const scenario of ["own", "other-root", "escaped", "sibling", "session-swit
|
|
|
1370
1513
|
ctx.sessionManager.getSessionId = () => sessionId;
|
|
1371
1514
|
ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
|
|
1372
1515
|
ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
|
|
1516
|
+
// The cheap session/ownership guards run before any worktree resolution:
|
|
1517
|
+
// a mutation from a switched-away session must never reach git.
|
|
1518
|
+
let resolutions = 0;
|
|
1373
1519
|
d.deps.resolveWorktree = (path, base) => {
|
|
1520
|
+
resolutions++;
|
|
1374
1521
|
const absolute = resolve(base, path);
|
|
1375
1522
|
const worktree = [cwd, sibling].find((candidate) => containsResolvedPath(candidate, absolute));
|
|
1376
1523
|
return worktree ? { root: worktree, commonDir: "/fixture/common" } : undefined;
|
|
@@ -1404,6 +1551,254 @@ for (const scenario of ["own", "other-root", "escaped", "sibling", "session-swit
|
|
|
1404
1551
|
});
|
|
1405
1552
|
}
|
|
1406
1553
|
|
|
1554
|
+
// C1 diagnostics (odd/tasks/usage-click-and-changes-attribution.md): the
|
|
1555
|
+
// guard chain's posture is unchanged (spawn-gated registration stays, a
|
|
1556
|
+
// parent decision) -- these only verify each drop explains itself once, in
|
|
1557
|
+
// the task's own thread, naming the exact guard that fired.
|
|
1558
|
+
async function openTaskThread(commands: ReturnType<typeof fakePi>["commands"], ctx: ExtensionContext, overlays: Overlay[]): Promise<string> {
|
|
1559
|
+
const opened = commands.get("gentle:agents")!.handler("", ctx);
|
|
1560
|
+
for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1561
|
+
const overlay = overlays[0];
|
|
1562
|
+
const text = overlay ? stripAnsi(overlay.render(100).join("\n")) : "";
|
|
1563
|
+
overlay?.handleInput("\x1b");
|
|
1564
|
+
await opened;
|
|
1565
|
+
overlays.length = 0;
|
|
1566
|
+
return text;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
for (const [scenario, guard] of [
|
|
1570
|
+
["escaped", "root-unresolved"],
|
|
1571
|
+
["sibling", "root-mismatch"],
|
|
1572
|
+
["session-switch", "parent-session-mismatch"],
|
|
1573
|
+
["unregistered", "root-not-registered"],
|
|
1574
|
+
] as const) {
|
|
1575
|
+
test(`a dropped mutation explains itself in the task thread: ${scenario} -> ${guard}`, async () => {
|
|
1576
|
+
const h = fakePi();
|
|
1577
|
+
const d = deps();
|
|
1578
|
+
const { ctx, overlays } = fakeContext();
|
|
1579
|
+
let sessionId = "s1";
|
|
1580
|
+
const sibling = join(root, `sibling-${scenario}`);
|
|
1581
|
+
ctx.sessionManager.getSessionId = () => sessionId;
|
|
1582
|
+
ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
|
|
1583
|
+
ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
|
|
1584
|
+
// The cheap session/ownership guards run before any worktree resolution:
|
|
1585
|
+
// a mutation from a switched-away session must never reach git.
|
|
1586
|
+
let resolutions = 0;
|
|
1587
|
+
d.deps.resolveWorktree = (path, base) => {
|
|
1588
|
+
resolutions++;
|
|
1589
|
+
const absolute = resolve(base, path);
|
|
1590
|
+
const worktree = [cwd, sibling].find((candidate) => containsResolvedPath(candidate, absolute));
|
|
1591
|
+
return worktree ? { root: worktree, commonDir: "/fixture/common" } : undefined;
|
|
1592
|
+
};
|
|
1593
|
+
const spawn = d.deps.spawn!;
|
|
1594
|
+
d.deps.spawn = (...args) => {
|
|
1595
|
+
const child = spawn(...args);
|
|
1596
|
+
const on = child.on.bind(child);
|
|
1597
|
+
child.on = ((event: string, listener: () => void) => {
|
|
1598
|
+
if (event === "spawn" && scenario !== "unregistered") queueMicrotask(listener);
|
|
1599
|
+
return on(event as "spawn", listener);
|
|
1600
|
+
}) as typeof child.on;
|
|
1601
|
+
return child;
|
|
1602
|
+
};
|
|
1603
|
+
gentleAgents(h.pi, {}, d.deps);
|
|
1604
|
+
await h.fire("session_start", ctx);
|
|
1605
|
+
await h.tools.get("subagent_run")!.execute("mutation", { agent: "explore", task: "Write", mode: "background", workspace_root: cwd }, undefined, undefined, ctx);
|
|
1606
|
+
await tick();
|
|
1607
|
+
if (scenario === "session-switch") sessionId = "s2";
|
|
1608
|
+
const path = scenario === "escaped" ? "../../outside.ts" : scenario === "sibling" ? join(sibling, "file.ts") : "file.ts";
|
|
1609
|
+
resolutions = 0; // spawn-time registration may resolve; only the mutation matters here
|
|
1610
|
+
d.children[0].emit({ type: "tool_execution_start", toolCallId: "write", toolName: "write", args: { path } });
|
|
1611
|
+
d.children[0].emit({ type: "tool_execution_end", toolCallId: "write", isError: false, result: { content: [] } });
|
|
1612
|
+
await tick();
|
|
1613
|
+
if (scenario === "session-switch") assert.equal(resolutions, 0, "session mismatch is decided before resolving any worktree");
|
|
1614
|
+
if (scenario === "session-switch") sessionId = "s1"; // back to the task's own session to inspect its thread
|
|
1615
|
+
const rendered = await openTaskThread(h.commands, ctx, overlays);
|
|
1616
|
+
assert.match(rendered, new RegExp(`changes not attributed: ${guard}\\b`), `expected the ${guard} guard to explain itself`);
|
|
1617
|
+
// The same drop repeated for the same task must not add a second note.
|
|
1618
|
+
d.children[0].emit({ type: "tool_execution_start", toolCallId: "write2", toolName: "write", args: { path } });
|
|
1619
|
+
d.children[0].emit({ type: "tool_execution_end", toolCallId: "write2", isError: false, result: { content: [] } });
|
|
1620
|
+
await tick();
|
|
1621
|
+
const renderedAgain = await openTaskThread(h.commands, ctx, overlays);
|
|
1622
|
+
assert.equal((renderedAgain.match(new RegExp(`changes not attributed: ${guard}`, "g")) ?? []).length, 1, "one note per (task, guard), not one per file");
|
|
1623
|
+
await h.fire("session_shutdown", ctx);
|
|
1624
|
+
await tick();
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
test("a mutation dropped for lacking any session-change evidence explains itself once", async () => {
|
|
1629
|
+
const h = fakePi();
|
|
1630
|
+
const d = deps();
|
|
1631
|
+
const { ctx, overlays } = fakeContext();
|
|
1632
|
+
ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
|
|
1633
|
+
ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
|
|
1634
|
+
d.deps.resolveWorktree = (path, base) => {
|
|
1635
|
+
const full = resolve(base, path);
|
|
1636
|
+
return full === cwd || full.startsWith(cwd + "/") ? { root: cwd, commonDir: "/fixture/common" } : undefined;
|
|
1637
|
+
};
|
|
1638
|
+
const spawn = d.deps.spawn!;
|
|
1639
|
+
d.deps.spawn = (...args) => {
|
|
1640
|
+
const child = spawn(...args);
|
|
1641
|
+
const on = child.on.bind(child);
|
|
1642
|
+
child.on = ((event: string, listener: () => void) => { if (event === "spawn") queueMicrotask(listener); return on(event as "spawn", listener); }) as typeof child.on;
|
|
1643
|
+
return child;
|
|
1644
|
+
};
|
|
1645
|
+
gentleAgents(h.pi, {}, d.deps);
|
|
1646
|
+
await h.fire("session_start", ctx);
|
|
1647
|
+
await h.tools.get("subagent_run")!.execute("mutation", { agent: "explore", task: "Write", mode: "background", workspace_root: cwd }, undefined, undefined, ctx);
|
|
1648
|
+
await tick();
|
|
1649
|
+
// No details.gentleSessionChange at all -- the evidence never surfaced.
|
|
1650
|
+
d.children[0].emit({ type: "tool_execution_start", toolCallId: "write", toolName: "write", args: { path: "file.ts" } });
|
|
1651
|
+
d.children[0].emit({ type: "tool_execution_end", toolCallId: "write", isError: false, result: { content: [] } });
|
|
1652
|
+
await tick();
|
|
1653
|
+
const rendered = await openTaskThread(h.commands, ctx, overlays);
|
|
1654
|
+
assert.match(rendered, /changes not attributed: evidence-missing\b/);
|
|
1655
|
+
await h.fire("session_shutdown", ctx);
|
|
1656
|
+
await tick();
|
|
1657
|
+
});
|
|
1658
|
+
|
|
1659
|
+
test("a mutation dropped for evidence pointing at a different worktree root explains itself once", async () => {
|
|
1660
|
+
const h = fakePi();
|
|
1661
|
+
const d = deps();
|
|
1662
|
+
const { ctx, overlays } = fakeContext();
|
|
1663
|
+
const target = realpathSync(cwd);
|
|
1664
|
+
(ctx as unknown as { cwd: string }).cwd = target;
|
|
1665
|
+
ctx.sessionManager.getCwd = () => target;
|
|
1666
|
+
ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
|
|
1667
|
+
ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
|
|
1668
|
+
d.deps.resolveWorktree = (path, base) => {
|
|
1669
|
+
const full = resolve(base, path);
|
|
1670
|
+
return full === target || full.startsWith(target + "/") ? { root: target, commonDir: "/fixture/common" } : undefined;
|
|
1671
|
+
};
|
|
1672
|
+
const spawn = d.deps.spawn!;
|
|
1673
|
+
d.deps.spawn = (...args) => {
|
|
1674
|
+
const child = spawn(...args);
|
|
1675
|
+
const on = child.on.bind(child);
|
|
1676
|
+
child.on = ((event: string, listener: () => void) => { if (event === "spawn") queueMicrotask(listener); return on(event as "spawn", listener); }) as typeof child.on;
|
|
1677
|
+
return child;
|
|
1678
|
+
};
|
|
1679
|
+
gentleAgents(h.pi, {}, d.deps);
|
|
1680
|
+
await h.fire("session_start", ctx);
|
|
1681
|
+
await h.tools.get("subagent_run")!.execute("mutation", { agent: "explore", task: "Write", mode: "background", workspace_root: target }, undefined, undefined, ctx);
|
|
1682
|
+
await tick();
|
|
1683
|
+
writeFileSync(join(target, "evidence-root-mismatch-test.ts"), "agent\n");
|
|
1684
|
+
// The evidence names a different root than the one the mutation actually
|
|
1685
|
+
// resolved into -- distinct from having no evidence at all.
|
|
1686
|
+
const evidence = { id: "write", root: join(target, "elsewhere"), path: "evidence-root-mismatch-test.ts", before: { kind: "text", text: "original\n" }, after: { kind: "text", text: "agent\n" } };
|
|
1687
|
+
d.children[0].emit({ type: "tool_execution_start", toolCallId: "write", toolName: "write", args: { path: "evidence-root-mismatch-test.ts" } });
|
|
1688
|
+
d.children[0].emit({ type: "tool_execution_end", toolCallId: "write", isError: false, result: { content: [], details: { gentleSessionChange: evidence } } });
|
|
1689
|
+
await tick();
|
|
1690
|
+
const rendered = await openTaskThread(h.commands, ctx, overlays);
|
|
1691
|
+
assert.match(rendered, /changes not attributed: evidence-root-mismatch\b/);
|
|
1692
|
+
await h.fire("session_shutdown", ctx);
|
|
1693
|
+
await tick();
|
|
1694
|
+
});
|
|
1695
|
+
|
|
1696
|
+
test("a mutation dropped for an unreadable evidence target explains itself once", async () => {
|
|
1697
|
+
const h = fakePi();
|
|
1698
|
+
const d = deps();
|
|
1699
|
+
const { ctx, overlays } = fakeContext();
|
|
1700
|
+
const target = realpathSync(cwd);
|
|
1701
|
+
(ctx as unknown as { cwd: string }).cwd = target;
|
|
1702
|
+
ctx.sessionManager.getCwd = () => target;
|
|
1703
|
+
ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
|
|
1704
|
+
ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
|
|
1705
|
+
d.deps.resolveWorktree = (path, base) => {
|
|
1706
|
+
const full = resolve(base, path);
|
|
1707
|
+
return full === target || full.startsWith(target + "/") ? { root: target, commonDir: "/fixture/common" } : undefined;
|
|
1708
|
+
};
|
|
1709
|
+
const spawn = d.deps.spawn!;
|
|
1710
|
+
d.deps.spawn = (...args) => {
|
|
1711
|
+
const child = spawn(...args);
|
|
1712
|
+
const on = child.on.bind(child);
|
|
1713
|
+
child.on = ((event: string, listener: () => void) => { if (event === "spawn") queueMicrotask(listener); return on(event as "spawn", listener); }) as typeof child.on;
|
|
1714
|
+
return child;
|
|
1715
|
+
};
|
|
1716
|
+
gentleAgents(h.pi, {}, d.deps);
|
|
1717
|
+
await h.fire("session_start", ctx);
|
|
1718
|
+
await h.tools.get("subagent_run")!.execute("mutation", { agent: "explore", task: "Write", mode: "background", workspace_root: target }, undefined, undefined, ctx);
|
|
1719
|
+
await tick();
|
|
1720
|
+
// The tool's own path never lands on disk -- realpathSync must throw
|
|
1721
|
+
// rather than report a plain mismatch.
|
|
1722
|
+
const evidence = { id: "write", root: target, path: "missing-target-test.ts", before: { kind: "text", text: "original\n" }, after: { kind: "text", text: "agent\n" } };
|
|
1723
|
+
d.children[0].emit({ type: "tool_execution_start", toolCallId: "write", toolName: "write", args: { path: "missing-target-test.ts" } });
|
|
1724
|
+
d.children[0].emit({ type: "tool_execution_end", toolCallId: "write", isError: false, result: { content: [], details: { gentleSessionChange: evidence } } });
|
|
1725
|
+
await tick();
|
|
1726
|
+
const rendered = await openTaskThread(h.commands, ctx, overlays);
|
|
1727
|
+
assert.match(rendered, /changes not attributed: evidence-path-unreadable\b/);
|
|
1728
|
+
await h.fire("session_shutdown", ctx);
|
|
1729
|
+
await tick();
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1732
|
+
test("a mutation dropped for mismatched session-change evidence explains itself once", async () => {
|
|
1733
|
+
const h = fakePi();
|
|
1734
|
+
const d = deps();
|
|
1735
|
+
const { ctx, overlays } = fakeContext();
|
|
1736
|
+
const target = realpathSync(cwd);
|
|
1737
|
+
(ctx as unknown as { cwd: string }).cwd = target;
|
|
1738
|
+
ctx.sessionManager.getCwd = () => target;
|
|
1739
|
+
ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
|
|
1740
|
+
ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
|
|
1741
|
+
d.deps.resolveWorktree = (path, base) => {
|
|
1742
|
+
const full = resolve(base, path);
|
|
1743
|
+
return full === target || full.startsWith(target + "/") ? { root: target, commonDir: "/fixture/common" } : undefined;
|
|
1744
|
+
};
|
|
1745
|
+
const spawn = d.deps.spawn!;
|
|
1746
|
+
d.deps.spawn = (...args) => {
|
|
1747
|
+
const child = spawn(...args);
|
|
1748
|
+
const on = child.on.bind(child);
|
|
1749
|
+
child.on = ((event: string, listener: () => void) => { if (event === "spawn") queueMicrotask(listener); return on(event as "spawn", listener); }) as typeof child.on;
|
|
1750
|
+
return child;
|
|
1751
|
+
};
|
|
1752
|
+
gentleAgents(h.pi, {}, d.deps);
|
|
1753
|
+
await h.fire("session_start", ctx);
|
|
1754
|
+
await h.tools.get("subagent_run")!.execute("mutation", { agent: "explore", task: "Write", mode: "background", workspace_root: target }, undefined, undefined, ctx);
|
|
1755
|
+
await tick();
|
|
1756
|
+
writeFileSync(join(target, "evidence-mismatch-test.ts"), "agent\n");
|
|
1757
|
+
const evidence = { id: "write", root: target, path: "different-file.ts", before: { kind: "text", text: "original\n" }, after: { kind: "text", text: "agent\n" } };
|
|
1758
|
+
d.children[0].emit({ type: "tool_execution_start", toolCallId: "write", toolName: "write", args: { path: "evidence-mismatch-test.ts" } });
|
|
1759
|
+
d.children[0].emit({ type: "tool_execution_end", toolCallId: "write", isError: false, result: { content: [], details: { gentleSessionChange: evidence } } });
|
|
1760
|
+
await tick();
|
|
1761
|
+
const rendered = await openTaskThread(h.commands, ctx, overlays);
|
|
1762
|
+
assert.match(rendered, /changes not attributed: evidence-path-mismatch\b/);
|
|
1763
|
+
await h.fire("session_shutdown", ctx);
|
|
1764
|
+
await tick();
|
|
1765
|
+
});
|
|
1766
|
+
|
|
1767
|
+
test("a successfully attributed mutation adds no drop note", async () => {
|
|
1768
|
+
const h = fakePi();
|
|
1769
|
+
const d = deps();
|
|
1770
|
+
const { ctx, overlays } = fakeContext();
|
|
1771
|
+
const target = realpathSync(cwd);
|
|
1772
|
+
(ctx as unknown as { cwd: string }).cwd = target;
|
|
1773
|
+
ctx.sessionManager.getCwd = () => target;
|
|
1774
|
+
ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
|
|
1775
|
+
ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
|
|
1776
|
+
d.deps.resolveWorktree = (path, base) => {
|
|
1777
|
+
const full = resolve(base, path);
|
|
1778
|
+
return full === target || full.startsWith(target + "/") ? { root: target, commonDir: "/fixture/common" } : undefined;
|
|
1779
|
+
};
|
|
1780
|
+
const spawn = d.deps.spawn!;
|
|
1781
|
+
d.deps.spawn = (...args) => {
|
|
1782
|
+
const child = spawn(...args);
|
|
1783
|
+
const on = child.on.bind(child);
|
|
1784
|
+
child.on = ((event: string, listener: () => void) => { if (event === "spawn") queueMicrotask(listener); return on(event as "spawn", listener); }) as typeof child.on;
|
|
1785
|
+
return child;
|
|
1786
|
+
};
|
|
1787
|
+
gentleAgents(h.pi, {}, d.deps);
|
|
1788
|
+
await h.fire("session_start", ctx);
|
|
1789
|
+
await h.tools.get("subagent_run")!.execute("mutation", { agent: "explore", task: "Write", mode: "background", workspace_root: target }, undefined, undefined, ctx);
|
|
1790
|
+
await tick();
|
|
1791
|
+
writeFileSync(join(target, "happy-path-test.ts"), "agent\n");
|
|
1792
|
+
const evidence = { id: "write", root: target, path: "happy-path-test.ts", before: { kind: "text", text: "original\n" }, after: { kind: "text", text: "agent\n" } };
|
|
1793
|
+
d.children[0].emit({ type: "tool_execution_start", toolCallId: "write", toolName: "write", args: { path: "happy-path-test.ts" } });
|
|
1794
|
+
d.children[0].emit({ type: "tool_execution_end", toolCallId: "write", isError: false, result: { content: [], details: { gentleSessionChange: evidence } } });
|
|
1795
|
+
await tick();
|
|
1796
|
+
const rendered = await openTaskThread(h.commands, ctx, overlays);
|
|
1797
|
+
assert.doesNotMatch(rendered, /changes not attributed/);
|
|
1798
|
+
await h.fire("session_shutdown", ctx);
|
|
1799
|
+
await tick();
|
|
1800
|
+
});
|
|
1801
|
+
|
|
1407
1802
|
test("worktree attribution containment respects Windows path boundaries", () => {
|
|
1408
1803
|
const candidate = win32.resolve("C:\\fixture", "project");
|
|
1409
1804
|
assert.equal(containsResolvedPath(candidate, win32.resolve(candidate), win32), true, "the worktree root itself is contained");
|
|
@@ -1929,6 +2324,32 @@ test("subagent_list_agents and subagent_run in task mode launch a child with the
|
|
|
1929
2324
|
assert.match(tools.get("subagent_run")!.renderCall({ agent: "explore" }, plainTheme).render(60).join(""), /❀ agent run · explore/);
|
|
1930
2325
|
});
|
|
1931
2326
|
|
|
2327
|
+
test("the Agents widget never registers a sidebar rail part and stays visible even while the fullscreen sidebar owns the host", async () => {
|
|
2328
|
+
const { pi, tools, fire } = fakePi();
|
|
2329
|
+
const harness = deps();
|
|
2330
|
+
gentleAgents(pi, {}, harness.deps);
|
|
2331
|
+
// A terminal-bearing host is what makes sidebarPart do anything at all
|
|
2332
|
+
// (a host with no .terminal is already a passthrough); this is the host
|
|
2333
|
+
// shape the fullscreen layout actually uses.
|
|
2334
|
+
const terminalTui = { requestRender() {}, terminal: { columns: 160, rows: 40 } };
|
|
2335
|
+
const { ctx, widget } = fakeContext(terminalTui);
|
|
2336
|
+
await fire("session_start", ctx);
|
|
2337
|
+
await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Map lib/", label: "map lib modules", mode: "background" }, undefined, undefined, ctx);
|
|
2338
|
+
await tick();
|
|
2339
|
+
|
|
2340
|
+
// The factory only runs (and only then could register a rail part) once
|
|
2341
|
+
// something actually renders the widget, exactly like the real host.
|
|
2342
|
+
assert.match(widget()![1]!, /explore map lib modules/);
|
|
2343
|
+
const state = sidebarState(terminalTui as unknown as TUI);
|
|
2344
|
+
assert.equal(state.parts.has("agents"), false, "Agents never claims a rail slot; the above-editor widget is its only surface");
|
|
2345
|
+
|
|
2346
|
+
// Simulate the fullscreen sidebar actively owning the host, the same
|
|
2347
|
+
// condition sidebarPart used to suppress a registered bottom widget under.
|
|
2348
|
+
state.active = true;
|
|
2349
|
+
state.ownsHost = () => true;
|
|
2350
|
+
assert.match(widget()![1]!, /explore map lib modules/, "the widget keeps rendering regardless of sidebar ownership");
|
|
2351
|
+
});
|
|
2352
|
+
|
|
1932
2353
|
test("background runs return at once; status, result, send_message, cancel, and continue follow the task", async () => {
|
|
1933
2354
|
const { pi, tools, fire, sent, renderers } = fakePi();
|
|
1934
2355
|
const harness = deps();
|
|
@@ -2184,7 +2605,7 @@ test("AgentsView production footer uses rendered bounds and invalidates them bef
|
|
|
2184
2605
|
}
|
|
2185
2606
|
});
|
|
2186
2607
|
|
|
2187
|
-
test("
|
|
2608
|
+
test("a task that finishes live stays visible as history, in both scopes, and its result also resolves after a restart", async () => {
|
|
2188
2609
|
const { pi, tools, fire, commands, shortcuts } = fakePi();
|
|
2189
2610
|
const harness = deps();
|
|
2190
2611
|
gentleAgents(pi, {}, harness.deps);
|
|
@@ -2204,21 +2625,25 @@ test("finished tasks remain available through resolveTask but never reappear in
|
|
|
2204
2625
|
}
|
|
2205
2626
|
assert.ok(stored.some((entry) => entry.task.id === id && entry.task.result === "Kept."), "the finished task is on disk");
|
|
2206
2627
|
|
|
2628
|
+
// A completely different session still resolves it by id on demand, with
|
|
2629
|
+
// nothing restored in advance.
|
|
2207
2630
|
const fresh = fakePi();
|
|
2208
2631
|
gentleAgents(fresh.pi, {}, deps().deps);
|
|
2209
2632
|
const again = fakeContext();
|
|
2210
2633
|
await fresh.fire("session_start", again.ctx);
|
|
2211
2634
|
assert.equal((await fresh.tools.get("subagent_result")!.execute("c2", { task_id: id }, undefined, undefined, again.ctx)).content[0].text, "Kept.");
|
|
2212
2635
|
|
|
2636
|
+
// Back in the session where it actually finished, it stays listed as
|
|
2637
|
+
// history -- in the current-session view and under "all sessions" too.
|
|
2213
2638
|
assert.ok(commands.has("gentle:agents") && shortcuts.has("alt+a"));
|
|
2214
2639
|
const opened = commands.get("gentle:agents")!.handler("", ctx);
|
|
2215
2640
|
for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
|
|
2216
2641
|
const overlay = overlays[0];
|
|
2217
2642
|
assert.ok(overlay, "the overlay component was created");
|
|
2218
|
-
assert.
|
|
2643
|
+
assert.match(overlay.render(80).map(stripAnsi).join("\n"), /finished|Subagent explore/, "the task that finished in this session stays visible as history");
|
|
2219
2644
|
overlay.handleInput("a");
|
|
2220
2645
|
overlay.handleInput("\x1b[C");
|
|
2221
|
-
assert.
|
|
2646
|
+
assert.match(overlay.render(80).map(stripAnsi).join("\n"), /Subagent explore/, "the current session's own history shows under all sessions too");
|
|
2222
2647
|
overlay.handleInput("\x1b");
|
|
2223
2648
|
await opened;
|
|
2224
2649
|
});
|
|
@@ -2333,6 +2758,104 @@ test("restored task history cannot enter the live panel or execute stop even wit
|
|
|
2333
2758
|
await opened;
|
|
2334
2759
|
});
|
|
2335
2760
|
|
|
2761
|
+
// A1 (odd/tasks/usage-click-and-changes-attribution.md): resuming a session
|
|
2762
|
+
// brings its own finished subagents back as visible history, never another
|
|
2763
|
+
// session's, and a brand-new session starts with none restored.
|
|
2764
|
+
test("resuming a session restores its own finished tasks as history, never another session's", async () => {
|
|
2765
|
+
const { pi, fire, commands } = fakePi();
|
|
2766
|
+
const harness = deps();
|
|
2767
|
+
const historyHome = join(root, "resume-history-home");
|
|
2768
|
+
const base: TaskRecord = { id: "own-1", agent: "explore-a", mode: "background", prompt: "p", label: "p", cwd, parentSessionId: "resumed-session", status: TASK_STATUS.COMPLETED, createdAt: 1, startedAt: 1, endedAt: 100, model: "m", thinking: undefined, sessionPath: null, error: null, result: "done", lastStep: "responded", lastActivityAt: 100, turns: 1, toolCalls: 0, tokens: 0, cost: 0 };
|
|
2769
|
+
const own1 = base;
|
|
2770
|
+
const own2: TaskRecord = { ...base, id: "own-2", agent: "explore-b", status: TASK_STATUS.FAILED, endedAt: 200, error: "boom", result: null };
|
|
2771
|
+
const other: TaskRecord = { ...base, id: "not-mine", agent: "explore-other", parentSessionId: "other-session" };
|
|
2772
|
+
await saveTask(historyDir(historyHome), own1, emptyThread());
|
|
2773
|
+
await saveTask(historyDir(historyHome), own2, emptyThread());
|
|
2774
|
+
await saveTask(historyDir(historyHome), other, emptyThread());
|
|
2775
|
+
harness.deps.home = historyHome;
|
|
2776
|
+
gentleAgents(pi, {}, harness.deps);
|
|
2777
|
+
const { ctx, overlays } = fakeContext();
|
|
2778
|
+
ctx.sessionManager.getSessionId = () => "resumed-session";
|
|
2779
|
+
await fire("session_start", ctx, { reason: "resume" });
|
|
2780
|
+
const opened = commands.get("gentle:agents")!.handler("", ctx);
|
|
2781
|
+
for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
|
|
2782
|
+
// The disk history read behind restoreSessionHistory is fire-and-forget,
|
|
2783
|
+
// so poll the overlay's own render output instead of sleeping a guess.
|
|
2784
|
+
const rendered = await waitForOverlayMatch(() => stripAnsi(overlays[0]!.render(100).join("\n")), /Subagent explore-a/);
|
|
2785
|
+
assert.match(rendered, /Subagent explore-a/, "the resumed session's own finished task is restored");
|
|
2786
|
+
assert.match(rendered, /Subagent explore-b/, "a second finished task of the same session is restored too");
|
|
2787
|
+
assert.doesNotMatch(rendered, /explore-other/, "another session's finished task never restores here");
|
|
2788
|
+
overlays[0]!.handleInput("\x1b");
|
|
2789
|
+
await opened;
|
|
2790
|
+
await fire("session_shutdown", ctx);
|
|
2791
|
+
|
|
2792
|
+
const { pi: freshPi, fire: freshFire, commands: freshCommands } = fakePi();
|
|
2793
|
+
gentleAgents(freshPi, {}, harness.deps);
|
|
2794
|
+
const { ctx: freshCtx, overlays: freshOverlays } = fakeContext();
|
|
2795
|
+
freshCtx.sessionManager.getSessionId = () => "brand-new-session";
|
|
2796
|
+
await freshFire("session_start", freshCtx, { reason: "new" });
|
|
2797
|
+
await tick();
|
|
2798
|
+
const freshOpened = freshCommands.get("gentle:agents")!.handler("", freshCtx);
|
|
2799
|
+
for (let attempt = 0; attempt < 40 && freshOverlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
|
|
2800
|
+
assert.doesNotMatch(stripAnsi(freshOverlays[0]!.render(100).join("\n")), /explore/, "a brand-new session restores nothing");
|
|
2801
|
+
freshOverlays[0]!.handleInput("\x1b");
|
|
2802
|
+
await freshOpened;
|
|
2803
|
+
await freshFire("session_shutdown", freshCtx);
|
|
2804
|
+
});
|
|
2805
|
+
|
|
2806
|
+
// A1 follow-up: Pi reports "startup" (not "resume") when the CLI is launched
|
|
2807
|
+
// directly into an existing session file (--continue / --resume picker,
|
|
2808
|
+
// agent-session.js:152) -- "resume" is only the in-session /resume switch.
|
|
2809
|
+
// A startup into a session that already has entries must restore its history
|
|
2810
|
+
// too; a "startup" with no entries (nothing pre-existing to restore) must not.
|
|
2811
|
+
test("starting up into an existing session also restores its own finished history; a startup with no prior entries does not", async () => {
|
|
2812
|
+
const historyHome = join(root, "startup-history-home");
|
|
2813
|
+
const finished: TaskRecord = { id: "startup-1", agent: "explore-startup", mode: "background", prompt: "p", label: "p", cwd, parentSessionId: "startup-session", status: TASK_STATUS.COMPLETED, createdAt: 1, startedAt: 1, endedAt: 100, model: "m", thinking: undefined, sessionPath: null, error: null, result: "done", lastStep: "responded", lastActivityAt: 100, turns: 1, toolCalls: 0, tokens: 0, cost: 0 };
|
|
2814
|
+
await saveTask(historyDir(historyHome), finished, emptyThread());
|
|
2815
|
+
|
|
2816
|
+
{
|
|
2817
|
+
// Startup into a session file that already has entries: restores.
|
|
2818
|
+
const { pi, fire, commands } = fakePi();
|
|
2819
|
+
const harness = deps();
|
|
2820
|
+
harness.deps.home = historyHome;
|
|
2821
|
+
gentleAgents(pi, {}, harness.deps);
|
|
2822
|
+
const { ctx, overlays } = fakeContext();
|
|
2823
|
+
ctx.sessionManager.getSessionId = () => "startup-session";
|
|
2824
|
+
ctx.sessionManager.getEntries = (() => [{ type: "message" }]) as typeof ctx.sessionManager.getEntries;
|
|
2825
|
+
await fire("session_start", ctx, { reason: "startup" });
|
|
2826
|
+
const opened = commands.get("gentle:agents")!.handler("", ctx);
|
|
2827
|
+
for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
|
|
2828
|
+
// The disk history read behind restoreSessionHistory is fire-and-forget,
|
|
2829
|
+
// so poll the overlay's own render output instead of sleeping a guess.
|
|
2830
|
+
const rendered = await waitForOverlayMatch(() => stripAnsi(overlays[0]!.render(100).join("\n")), /Subagent explore-startup/);
|
|
2831
|
+
assert.match(rendered, /Subagent explore-startup/, "startup into an existing session restores its own finished history");
|
|
2832
|
+
overlays[0]!.handleInput("\x1b");
|
|
2833
|
+
await opened;
|
|
2834
|
+
await fire("session_shutdown", ctx);
|
|
2835
|
+
}
|
|
2836
|
+
{
|
|
2837
|
+
// Startup reported for a session with no prior entries: nothing to
|
|
2838
|
+
// restore, even though the reason is "startup" and the id matches.
|
|
2839
|
+
const { pi, fire, commands } = fakePi();
|
|
2840
|
+
const harness = deps();
|
|
2841
|
+
harness.deps.home = historyHome;
|
|
2842
|
+
gentleAgents(pi, {}, harness.deps);
|
|
2843
|
+
const { ctx, overlays } = fakeContext();
|
|
2844
|
+
ctx.sessionManager.getSessionId = () => "startup-session";
|
|
2845
|
+
await fire("session_start", ctx, { reason: "startup" });
|
|
2846
|
+
// getEntries defaults to [] here, so preexisting is false and
|
|
2847
|
+
// restoreSessionHistory never runs -- nothing to wait for beyond
|
|
2848
|
+
// letting the already-fired session_start handler settle.
|
|
2849
|
+
await tick();
|
|
2850
|
+
const opened = commands.get("gentle:agents")!.handler("", ctx);
|
|
2851
|
+
for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
|
|
2852
|
+
assert.doesNotMatch(stripAnsi(overlays[0]!.render(100).join("\n")), /explore-startup/, "a startup report with no prior entries restores nothing");
|
|
2853
|
+
overlays[0]!.handleInput("\x1b");
|
|
2854
|
+
await opened;
|
|
2855
|
+
await fire("session_shutdown", ctx);
|
|
2856
|
+
}
|
|
2857
|
+
});
|
|
2858
|
+
|
|
2336
2859
|
test("Alt+S confirms a snapshot of active subagents and suppresses their follow-up delivery", async () => {
|
|
2337
2860
|
const { pi, tools, fire, shortcuts, sent } = fakePi();
|
|
2338
2861
|
const harness = deps();
|
|
@@ -97,7 +97,7 @@ async function writeWindowsSourceBinary(packageRoot: string): Promise<{ binaryPa
|
|
|
97
97
|
method: "go-sumdb-source-build",
|
|
98
98
|
package: "github.com/gentleman-programming/gentle-ai/v3/cmd/gentle-ai",
|
|
99
99
|
module: "github.com/gentleman-programming/gentle-ai/v3",
|
|
100
|
-
tag: "v3.
|
|
100
|
+
tag: "v3.4.0",
|
|
101
101
|
architecture: process.arch === "x64" ? "x64" : "arm64",
|
|
102
102
|
binarySha256: createHash("sha256").update(binary).digest("hex"),
|
|
103
103
|
moduleChecksum: GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM,
|