Skip to content

Commit 45dcfb8

Browse files
committed
pi-agenticoding/03: fix manual handoff availability diagnostics
1 parent e66ab80 commit 45dcfb8

12 files changed

Lines changed: 248 additions & 89 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- **Breaking:** handoff no longer auto-sends `Proceed.` after compaction and no longer supports configurable auto-resume. The superseded `handoff.resumeBehavior` (`"wait"`/`"proceed"`) setting is ignored.
1313
- Added `handoff.automaticEnabled` raw settings support with JSON boolean values. Missing settings default to automatic handoff enabled; `false` removes the agent-facing handoff tool and handoff-call guidance during normal turns while preserving explicit `/handoff <direction>`.
1414
- Added the extension-owned `/agenticoding-settings` TUI panel for automatic handoff availability. TUI saves are global-only to `~/.pi/agent/settings.json`, preserve unrelated settings keys, persist real booleans, and visibly warn when a project override masks the global value.
15+
- Manual `/handoff <direction>` now refuses while the assistant is streaming and tells the operator to retry once idle. This reflects Pi's tool-schema snapshot semantics and missing fresh-turn lifecycle hooks for queued follow-up turns.
1516

1617
## [0.3.0] - 2026-05-23
1718

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,9 +127,9 @@ To make handoff human-driven only, set `handoff.automaticEnabled` to `false` in
127127
}
128128
```
129129

130-
Settings are read from `~/.pi/agent/settings.json` and `<project>/.pi/settings.json`, with project settings overriding global settings. When automatic handoff is disabled, the agent-facing `handoff` tool and handoff-call guidance are removed from normal turns. The explicit operator command `/handoff <direction>` still works: it temporarily enables the tool for that requested handoff, compacts, restores the disabled state, and then waits for your next input.
130+
Settings are read from `~/.pi/agent/settings.json` and `<project>/.pi/settings.json`, with project settings overriding global settings. When automatic handoff is disabled, the agent-facing `handoff` tool and handoff-call guidance are removed from normal turns. The explicit operator command `/handoff <direction>` still works from an idle prompt: it temporarily enables the tool for that fresh requested handoff, compacts, restores the disabled state, and then waits for your next input. If the assistant is already streaming, `/handoff` will refuse with a diagnostic and ask you to retry once idle because Pi cannot add new tools to an already-snapshotted agent run or fire fresh-turn lifecycle hooks for queued follow-ups.
131131

132-
Run `/agenticoding-settings` to change the global value from the TUI. It saves global-only to `~/.pi/agent/settings.json`, preserves unrelated JSON keys, shows the effective runtime value separately, and warns when a project override masks the global value. Edit or remove project overrides manually.
132+
Run `/agenticoding-settings` to change the global value from the TUI. It saves global-only to `~/.pi/agent/settings.json`, preserves unrelated JSON keys, shows the effective runtime value separately, and warns when a project override masks the global value. Setting changes affect future fresh agent turns; they do not alter the tool schema of an in-flight queued follow-up. Edit or remove project overrides manually.
133133

134134
Migration note: the superseded PR-only `handoff.resumeBehavior` (`"wait"`/`"proceed"`) setting is ignored and cannot trigger automatic continuation. Remove it when convenient.
135135

agenticoding.test.ts

Lines changed: 121 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -381,11 +381,12 @@ test("/handoff sends the direction back through the LLM without opening the edit
381381
direction: "implement auth",
382382
enforcementAttempts: 0,
383383
toolCalled: false,
384+
awaitingAgentTurn: true,
384385
});
385386
assert.deepEqual(pi.sentUserMessages, [
386387
{
387388
content:
388-
"Handoff direction: implement auth\n\nPrepare a handoff in the current session. First, save any durable reusable knowledge to the notebook: findings worth keeping, constraints discovered, decisions made, or other grounding future contexts will need. Then draft a concise but sufficiently detailed handoff brief capturing only the remaining situational context: current state, blockers, unresolved questions, failed paths worth avoiding, and next steps. The next context will read the notebook on demand, so do not duplicate notebook content in the brief. Use any structure that makes the next work unambiguous. Reference notebook pages by name when relevant.",
389+
"Handoff direction: implement auth\n\nPrepare a handoff in the current session. First, save any durable reusable knowledge to the notebook: findings worth keeping, constraints discovered, decisions made, or other grounding future contexts will need. Then draft a concise but sufficiently detailed handoff brief capturing only the remaining situational context: current state, blockers, unresolved questions, failed paths worth avoiding, and next steps. The next context will read the notebook on demand, so do not duplicate notebook content in the brief. Use any structure that makes the next work unambiguous. Reference notebook pages by name when relevant. After drafting the brief, you must call the `handoff` tool with the brief as its task so the session actually compacts. Do not answer with only prose; if the `handoff` tool is unavailable, say that manual handoff cannot compact because the handoff tool is unavailable.",
389390
options: undefined,
390391
},
391392
]);
@@ -411,7 +412,7 @@ test("handoff automatic setting defaults to enabled without automatic continuati
411412
const pi = new MockPi();
412413
const state = createState();
413414
state.notebookPages.set("auth-refresh", "sensitive notebook body");
414-
state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false };
415+
state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false, awaitingAgentTurn: false };
415416
registerHandoffTool(pi as any, state);
416417

417418
let compactOptions: any;
@@ -634,12 +635,53 @@ test("manual slash handoff restores deactivated handoff after success error or s
634635
registerAgenticoding(pi as any);
635636
await pi.commands.get("handoff")!.handler("implement auth", { cwd, hasUI: false, isIdle: () => true });
636637
assert.ok(pi.activeTools.includes("handoff"));
638+
const [beforeAgentStart] = pi.handlers.get("before_agent_start")!;
637639
const [turnEnd] = pi.handlers.get("turn_end")!;
640+
await beforeAgentStart(
641+
{ prompt: pi.sentUserMessages[0].content, systemPrompt: "base" },
642+
{ cwd, hasUI: false } as any,
643+
);
638644
await turnEnd({}, { cwd, hasUI: false, getContextUsage: () => null } as any);
639645
assert.equal(pi.activeTools.includes("handoff"), false);
640646
});
641647
});
642648

649+
test("manual slash handoff refuses busy requests and asks the operator to retry once idle", async () => {
650+
await withIsolatedSettings(async ({ cwd }) => {
651+
await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { automaticEnabled: false } });
652+
const pi = new MockPi();
653+
registerAgenticoding(pi as any);
654+
655+
await pi.commands.get("handoff")!.handler("implement auth", { cwd, hasUI: false, isIdle: () => false });
656+
657+
assert.equal(pi.activeTools.includes("handoff"), false);
658+
assert.deepEqual(pi.sentUserMessages, []);
659+
assert.equal(pi.sentMessages[0]?.message.customType, "agenticoding-handoff-diagnostic");
660+
assert.match(pi.sentMessages[0]?.message.content, /currently streaming/);
661+
assert.match(pi.sentMessages[0]?.message.content, /Retry \/handoff once the assistant is idle/);
662+
});
663+
});
664+
665+
test("manual slash handoff reports a diagnostic instead of prompting prose when handoff cannot be activated", async () => {
666+
const pi = new MockPi();
667+
(pi as any).setActiveTools = undefined;
668+
const state = createState();
669+
registerHandoffCommand(pi as any, state);
670+
const notifications: Array<{ message: string; level: string }> = [];
671+
672+
await pi.commands.get("handoff")!.handler("implement auth", {
673+
hasUI: true,
674+
isIdle: () => true,
675+
ui: { notify: (message: string, level: string) => notifications.push({ message, level }) },
676+
});
677+
678+
assert.equal(state.pendingRequestedHandoff, null);
679+
assert.deepEqual(pi.sentUserMessages, []);
680+
assert.equal(notifications[0].level, "error");
681+
assert.match(notifications[0].message, /could not be activated/);
682+
assert.equal(pi.sentMessages[0].message.customType, "agenticoding-handoff-diagnostic");
683+
});
684+
643685
test("handoff automatic setting is documented in README", async () => {
644686
const readme = await readFile(new URL("./README.md", import.meta.url), "utf8");
645687
const changelog = await readFile(new URL("./CHANGELOG.md", import.meta.url), "utf8");
@@ -924,7 +966,7 @@ test("handoff compaction replaces old context with the queued task", async () =>
924966
const pi = new MockPi();
925967
const state = createState();
926968
state.pendingHandoff = { task: "Goal: continue", source: "tool" };
927-
state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 1, toolCalled: true };
969+
state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 1, toolCalled: true, awaitingAgentTurn: false };
928970
state.activeNotebookTopic = "oauth";
929971
state.activeNotebookTopicSource = "human";
930972
registerHandoffCompaction(pi as any, state);
@@ -986,7 +1028,7 @@ test("handoff compaction clears the handoff status indicator", async () => {
9861028
test("handoff compaction error clears pending state and status", async () => {
9871029
const pi = new MockPi();
9881030
const state = createState();
989-
state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false };
1031+
state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false, awaitingAgentTurn: false };
9901032
registerHandoffTool(pi as any, state);
9911033
let compactOptions: any;
9921034
const statuses = new Map<string, string | undefined>();
@@ -1023,18 +1065,28 @@ test("turn_end fallback clears stale requested handoff status", async () => {
10231065
},
10241066
});
10251067

1068+
const notifications: Array<{ message: string; level: string }> = [];
1069+
const [beforeAgentStart] = pi.handlers.get("before_agent_start")!;
10261070
const [turnEnd] = pi.handlers.get("turn_end")!;
1071+
await beforeAgentStart(
1072+
{ prompt: pi.sentUserMessages[0].content, systemPrompt: "base" },
1073+
{ hasUI: false } as any,
1074+
);
10271075
await turnEnd({}, {
10281076
hasUI: true,
10291077
ui: {
10301078
theme: { fg: (_name: string, text: string) => text },
1079+
notify: (message: string, level: string) => notifications.push({ message, level }),
10311080
setStatus: (key: string, value: string | undefined) => { statuses.set(key, value); },
10321081
setWidget: () => {},
10331082
},
10341083
getContextUsage: () => null,
10351084
});
10361085

10371086
assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined);
1087+
assert.equal(notifications[0].level, "warning");
1088+
assert.match(notifications[0].message, /did not call the handoff tool/);
1089+
assert.equal(pi.sentMessages.at(-1)?.message.customType, "agenticoding-handoff-diagnostic");
10381090
});
10391091

10401092
test("session_start new clears stale handoff status and warning widget", async () => {
@@ -1123,21 +1175,23 @@ test("context injects a boundary nudge below 30% after an explicit topic change"
11231175

11241176

11251177
test("context injects a no-topic nudge when context is high", async () => {
1126-
const pi = new MockPi();
1127-
registerAgenticoding(pi as any);
1128-
const [handler] = pi.handlers.get("context")!;
1178+
await withIsolatedSettings(async ({ cwd }) => {
1179+
const pi = new MockPi();
1180+
registerAgenticoding(pi as any);
1181+
const [handler] = pi.handlers.get("context")!;
11291182

1130-
const result = await handler(
1131-
{ messages: [{ role: "user", content: "hi", timestamp: 1 }] },
1132-
{ getContextUsage: () => ({ percent: 70 }) },
1133-
);
1183+
const result = await handler(
1184+
{ messages: [{ role: "user", content: "hi", timestamp: 1 }] },
1185+
{ cwd, getContextUsage: () => ({ percent: 70 }) },
1186+
);
11341187

1135-
assert.equal(result.messages.length, 2);
1136-
assert.equal(result.messages[1].role, "custom");
1137-
assert.equal(result.messages[1].customType, "agenticoding-watchdog");
1138-
assert.equal(result.messages[1].display, false);
1139-
assert.match(result.messages[1].content, /No active notebook topic is set/);
1140-
assert.match(result.messages[1].content, /Assign a fresh topic in the next clean context after handoff/i);
1188+
assert.equal(result.messages.length, 2);
1189+
assert.equal(result.messages[1].role, "custom");
1190+
assert.equal(result.messages[1].customType, "agenticoding-watchdog");
1191+
assert.equal(result.messages[1].display, false);
1192+
assert.match(result.messages[1].content, /No active notebook topic is set/);
1193+
assert.match(result.messages[1].content, /Assign a fresh topic in the next clean context after handoff/i);
1194+
});
11411195
});
11421196

11431197

@@ -1178,28 +1232,31 @@ test("buildNudge handles null percent and boundary hints before topic guidance",
11781232
assert.match(noTopic, /No active notebook topic is set/);
11791233
});
11801234

1181-
test("watchdog stays advisory when a requested handoff is not completed", async () => {
1235+
test("watchdog stale requested handoff cleanup emits a visible context diagnostic", async () => {
11821236
const pi = new MockPi();
11831237
const state = createState();
1184-
state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false };
1238+
state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false, awaitingAgentTurn: false };
11851239
registerWatchdog(pi as any, state);
11861240
const [handler] = pi.handlers.get("agent_end")!;
11871241

1188-
const notifications: string[] = [];
1242+
const notifications: Array<{ message: string; level: string }> = [];
11891243
await handler(
11901244
{},
11911245
{
11921246
hasUI: true,
11931247
ui: {
1194-
notify: (message: string) => notifications.push(message),
1248+
notify: (message: string, level: string) => notifications.push({ message, level }),
11951249
setStatus: () => {},
11961250
},
11971251
getContextUsage: () => ({ percent: 20 }),
11981252
},
11991253
);
12001254

12011255
assert.equal(state.pendingRequestedHandoff, null);
1202-
assert.deepEqual(notifications, []);
1256+
assert.equal(notifications[0].level, "warning");
1257+
assert.match(notifications[0].message, /did not call the handoff tool/);
1258+
assert.equal(pi.sentMessages[0].message.customType, "agenticoding-handoff-diagnostic");
1259+
assert.match(pi.sentMessages[0].message.content, /did not call the handoff tool/);
12031260
assert.deepEqual(pi.sentUserMessages, []);
12041261
});
12051262

@@ -2497,49 +2554,52 @@ test("notebook rehydration clears stale in-memory notebook state when persisted
24972554

24982555

24992556
test("session_start rehydrates the latest persisted notebook state through the full hook chain", async () => {
2500-
resetNotebookWriteLock();
2501-
const pi = new MockPi();
2502-
pi.activeTools = ["read", "notebook_read"];
2503-
registerAgenticoding(pi as any);
2557+
await withIsolatedSettings(async ({ cwd }) => {
2558+
resetNotebookWriteLock();
2559+
const pi = new MockPi();
2560+
pi.activeTools = ["read", "notebook_read"];
2561+
registerAgenticoding(pi as any);
25042562

2505-
try {
2506-
const notebookWrite = pi.tools.get("notebook_write");
2507-
await notebookWrite.execute(
2508-
"seed",
2509-
{ name: "stale-page", content: "stale body" },
2510-
undefined,
2511-
undefined,
2512-
makeTUICtx({ hasUI: false }),
2513-
);
2563+
try {
2564+
const notebookWrite = pi.tools.get("notebook_write");
2565+
await notebookWrite.execute(
2566+
"seed",
2567+
{ name: "stale-page", content: "stale body" },
2568+
undefined,
2569+
undefined,
2570+
makeTUICtx({ hasUI: false }),
2571+
);
25142572

2515-
const sessionStartHandlers = pi.handlers.get("session_start")!;
2516-
const ctx = {
2517-
hasUI: false,
2518-
getContextUsage: () => null,
2519-
sessionManager: {
2520-
getBranch: () => [
2521-
{ type: "custom", customType: "notebook-entry", data: { epoch: 6, name: "stale", content: "old" } },
2522-
{ type: "custom", customType: "notebook-entry", data: { epoch: 8, name: "keep", content: "fresh" } },
2523-
{ type: "custom", customType: "notebook-entry", data: { epoch: 8, name: "keep", content: "newer" } },
2524-
],
2525-
},
2526-
};
2527-
for (const sessionStart of sessionStartHandlers) {
2528-
await sessionStart({ reason: "resume" }, ctx as any);
2529-
}
2573+
const sessionStartHandlers = pi.handlers.get("session_start")!;
2574+
const ctx = {
2575+
cwd,
2576+
hasUI: false,
2577+
getContextUsage: () => null,
2578+
sessionManager: {
2579+
getBranch: () => [
2580+
{ type: "custom", customType: "notebook-entry", data: { epoch: 6, name: "stale", content: "old" } },
2581+
{ type: "custom", customType: "notebook-entry", data: { epoch: 8, name: "keep", content: "fresh" } },
2582+
{ type: "custom", customType: "notebook-entry", data: { epoch: 8, name: "keep", content: "newer" } },
2583+
],
2584+
},
2585+
};
2586+
for (const sessionStart of sessionStartHandlers) {
2587+
await sessionStart({ reason: "resume" }, ctx as any);
2588+
}
25302589

2531-
const notebookIndex = pi.tools.get("notebook_index");
2532-
const notebookRead = pi.tools.get("notebook_read");
2533-
const indexResult = await notebookIndex.execute("1", {}, undefined, undefined, {} as any);
2534-
assert.deepEqual(indexResult.details.entries, ["keep"]);
2590+
const notebookIndex = pi.tools.get("notebook_index");
2591+
const notebookRead = pi.tools.get("notebook_read");
2592+
const indexResult = await notebookIndex.execute("1", {}, undefined, undefined, {} as any);
2593+
assert.deepEqual(indexResult.details.entries, ["keep"]);
25352594

2536-
const readResult = await notebookRead.execute("2", { name: "keep" }, undefined, undefined, {} as any);
2537-
assert.equal(readResult.details.found, true);
2538-
assert.equal(readResult.details.body, "newer");
2539-
assert.deepEqual(pi.activeTools, ["read", "notebook_read", "notebook_index", "handoff"]);
2540-
} finally {
2541-
resetNotebookWriteLock();
2542-
}
2595+
const readResult = await notebookRead.execute("2", { name: "keep" }, undefined, undefined, {} as any);
2596+
assert.equal(readResult.details.found, true);
2597+
assert.equal(readResult.details.body, "newer");
2598+
assert.deepEqual(pi.activeTools, ["read", "notebook_read", "notebook_index", "handoff"]);
2599+
} finally {
2600+
resetNotebookWriteLock();
2601+
}
2602+
});
25432603
});
25442604

25452605
test("notebook tools add/get/list return stable contract details", async () => {

handoff/availability.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
22
import type { AgenticodingState } from "../state.js";
33
import { resolveHandoffAutomaticAvailability, type HandoffAutomaticAvailability } from "../settings.js";
44

5-
function getActiveTools(pi: ExtensionAPI): string[] {
6-
return typeof pi.getActiveTools === "function" ? pi.getActiveTools() : [];
5+
function getActiveTools(pi: ExtensionAPI): string[] | null {
6+
return typeof pi.getActiveTools === "function" ? pi.getActiveTools() : null;
77
}
88

9-
function setActiveTools(pi: ExtensionAPI, tools: string[]): void {
10-
if (typeof pi.setActiveTools === "function") {
11-
pi.setActiveTools(tools);
9+
function setActiveTools(pi: ExtensionAPI, tools: string[]): boolean {
10+
if (typeof pi.setActiveTools !== "function") {
11+
return false;
1212
}
13+
pi.setActiveTools(tools);
14+
return true;
1315
}
1416

1517
export function applyHandoffToolAvailability(
@@ -19,6 +21,9 @@ export function applyHandoffToolAvailability(
1921
): void {
2022
const shouldBeActive = automaticEnabled || manualRequested;
2123
const active = getActiveTools(pi);
24+
if (!active) {
25+
return;
26+
}
2227
const hasHandoff = active.includes("handoff");
2328

2429
if (shouldBeActive && !hasHandoff) {
@@ -41,9 +46,16 @@ export async function updateHandoffToolAvailability(
4146
return availability;
4247
}
4348

44-
export function temporarilyActivateHandoffTool(pi: ExtensionAPI): void {
49+
export function temporarilyActivateHandoffTool(pi: ExtensionAPI): boolean {
4550
const active = getActiveTools(pi);
46-
if (!active.includes("handoff")) {
47-
setActiveTools(pi, [...active, "handoff"]);
51+
if (!active) {
52+
return false;
53+
}
54+
if (active.includes("handoff")) {
55+
return true;
56+
}
57+
if (!setActiveTools(pi, [...active, "handoff"])) {
58+
return false;
4859
}
60+
return getActiveTools(pi)?.includes("handoff") ?? false;
4961
}

0 commit comments

Comments
 (0)