Skip to content

Commit 1041f05

Browse files
committed
pi-agenticoding/03: make manual handoff seamless
1 parent 45dcfb8 commit 1041f05

5 files changed

Lines changed: 66 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +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.
15+
- Manual `/handoff <direction>` now waits behind the scenes when the assistant is streaming, then enables the `handoff` tool and starts a fresh handoff turn once idle. Visible `agenticoding-handoff-diagnostic` conversation messages have been removed from this flow.
1616

1717
## [0.3.0] - 2026-05-23
1818

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 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.
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 idle or busy prompts: if the assistant is streaming, it waits behind the scenes until the current run is idle, temporarily enables the tool for a fresh requested handoff turn, compacts, restores the disabled state, and then waits for your next input.
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. 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.
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; manual `/handoff` uses that same rule by waiting for idle before enabling the tool and starting its own fresh turn. 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: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -646,23 +646,44 @@ test("manual slash handoff restores deactivated handoff after success error or s
646646
});
647647
});
648648

649-
test("manual slash handoff refuses busy requests and asks the operator to retry once idle", async () => {
649+
test("manual slash handoff waits for busy runs then starts a fresh handoff turn", async () => {
650650
await withIsolatedSettings(async ({ cwd }) => {
651651
await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { automaticEnabled: false } });
652652
const pi = new MockPi();
653653
registerAgenticoding(pi as any);
654+
const deferred = createDeferred();
655+
let idle = false;
656+
let waitCalls = 0;
654657

655-
await pi.commands.get("handoff")!.handler("implement auth", { cwd, hasUI: false, isIdle: () => false });
658+
const command = pi.commands.get("handoff")!.handler("implement auth", {
659+
cwd,
660+
hasUI: false,
661+
isIdle: () => idle,
662+
waitForIdle: async () => {
663+
waitCalls += 1;
664+
await deferred.promise;
665+
idle = true;
666+
},
667+
});
656668

669+
await Promise.resolve();
670+
assert.equal(waitCalls, 1);
657671
assert.equal(pi.activeTools.includes("handoff"), false);
658672
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/);
673+
assert.deepEqual(pi.sentMessages, []);
674+
675+
deferred.resolve();
676+
await command;
677+
678+
assert.ok(pi.activeTools.includes("handoff"));
679+
assert.equal(pi.sentUserMessages.length, 1);
680+
assert.equal(pi.sentUserMessages[0].options, undefined);
681+
assert.match(pi.sentUserMessages[0].content, /Handoff direction: implement auth/);
682+
assert.deepEqual(pi.sentMessages, []);
662683
});
663684
});
664685

665-
test("manual slash handoff reports a diagnostic instead of prompting prose when handoff cannot be activated", async () => {
686+
test("manual slash handoff reports only a UI error when handoff cannot be activated", async () => {
666687
const pi = new MockPi();
667688
(pi as any).setActiveTools = undefined;
668689
const state = createState();
@@ -679,7 +700,7 @@ test("manual slash handoff reports a diagnostic instead of prompting prose when
679700
assert.deepEqual(pi.sentUserMessages, []);
680701
assert.equal(notifications[0].level, "error");
681702
assert.match(notifications[0].message, /could not be activated/);
682-
assert.equal(pi.sentMessages[0].message.customType, "agenticoding-handoff-diagnostic");
703+
assert.deepEqual(pi.sentMessages, []);
683704
});
684705

685706
test("handoff automatic setting is documented in README", async () => {
@@ -1086,7 +1107,7 @@ test("turn_end fallback clears stale requested handoff status", async () => {
10861107
assert.equal(statuses.get(STATUS_KEY_HANDOFF), undefined);
10871108
assert.equal(notifications[0].level, "warning");
10881109
assert.match(notifications[0].message, /did not call the handoff tool/);
1089-
assert.equal(pi.sentMessages.at(-1)?.message.customType, "agenticoding-handoff-diagnostic");
1110+
assert.deepEqual(pi.sentMessages, []);
10901111
});
10911112

10921113
test("session_start new clears stale handoff status and warning widget", async () => {
@@ -1232,7 +1253,7 @@ test("buildNudge handles null percent and boundary hints before topic guidance",
12321253
assert.match(noTopic, /No active notebook topic is set/);
12331254
});
12341255

1235-
test("watchdog stale requested handoff cleanup emits a visible context diagnostic", async () => {
1256+
test("watchdog stale requested handoff cleanup avoids conversation diagnostics", async () => {
12361257
const pi = new MockPi();
12371258
const state = createState();
12381259
state.pendingRequestedHandoff = { direction: "implement auth", enforcementAttempts: 0, toolCalled: false, awaitingAgentTurn: false };
@@ -1255,8 +1276,7 @@ test("watchdog stale requested handoff cleanup emits a visible context diagnosti
12551276
assert.equal(state.pendingRequestedHandoff, null);
12561277
assert.equal(notifications[0].level, "warning");
12571278
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/);
1279+
assert.deepEqual(pi.sentMessages, []);
12601280
assert.deepEqual(pi.sentUserMessages, []);
12611281
});
12621282

handoff/cleanup.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,6 @@ export function buildMissingRequestedHandoffDiagnostic(direction: string): strin
77
return `Manual /handoff did not compact for direction "${direction}" because the assistant did not call the handoff tool. The temporary handoff tool activation has been cleared.`;
88
}
99

10-
export function buildBusyRequestedHandoffDiagnostic(direction: string): string {
11-
return `Manual /handoff was not queued for direction "${direction}" because the assistant is currently streaming. Retry /handoff once the assistant is idle so Pi can start a fresh turn with the handoff tool available.`;
12-
}
13-
1410
export function emitHandoffDiagnostic(
1511
pi: ExtensionAPI,
1612
ctx: ExtensionContext,
@@ -20,11 +16,6 @@ export function emitHandoffDiagnostic(
2016
if (ctx.hasUI) {
2117
ctx.ui.notify?.(message, level);
2218
}
23-
pi.sendMessage({
24-
customType: "agenticoding-handoff-diagnostic",
25-
content: message,
26-
display: true,
27-
});
2819
}
2920

3021
export async function clearStaleRequestedHandoff(

handoff/command.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
99
import type { AgenticodingState } from "../state.js";
1010
import { STATUS_KEY_HANDOFF } from "../tui.js";
11-
import { temporarilyActivateHandoffTool } from "./availability.js";
12-
import { buildBusyRequestedHandoffDiagnostic, emitHandoffDiagnostic } from "./cleanup.js";
11+
import { temporarilyActivateHandoffTool, updateHandoffToolAvailability } from "./availability.js";
12+
import { emitHandoffDiagnostic } from "./cleanup.js";
1313

1414
export function registerHandoffCommand(pi: ExtensionAPI, state: AgenticodingState): void {
1515
pi.registerCommand("handoff", {
@@ -24,9 +24,25 @@ export function registerHandoffCommand(pi: ExtensionAPI, state: AgenticodingStat
2424
return;
2525
}
2626

27-
const isIdle = typeof ctx.isIdle === "function" ? ctx.isIdle() : true;
27+
let isIdle = typeof ctx.isIdle === "function" ? ctx.isIdle() : true;
2828
if (!isIdle) {
29-
emitHandoffDiagnostic(pi, ctx, buildBusyRequestedHandoffDiagnostic(direction), "warning");
29+
if (ctx.hasUI && ctx.ui.theme) {
30+
ctx.ui.setStatus?.(
31+
STATUS_KEY_HANDOFF,
32+
ctx.ui.theme.fg("accent", "\uD83E\uDD1D Handoff pending"),
33+
);
34+
}
35+
if (typeof ctx.waitForIdle === "function") {
36+
await ctx.waitForIdle();
37+
}
38+
isIdle = typeof ctx.isIdle === "function" ? ctx.isIdle() : true;
39+
}
40+
41+
if (!isIdle) {
42+
if (ctx.hasUI) {
43+
ctx.ui.setStatus?.(STATUS_KEY_HANDOFF, undefined);
44+
ctx.ui.notify?.("Manual /handoff is waiting for the assistant to become idle before starting a fresh handoff turn.", "warning");
45+
}
3046
return;
3147
}
3248

@@ -55,10 +71,18 @@ export function registerHandoffCommand(pi: ExtensionAPI, state: AgenticodingStat
5571
);
5672
}
5773

58-
pi.sendUserMessage(
59-
prompt,
60-
isIdle ? undefined : { deliverAs: "followUp" },
61-
);
74+
void Promise.resolve(pi.sendUserMessage(prompt)).catch(async (error) => {
75+
state.pendingRequestedHandoff = null;
76+
state.pendingRequestedHandoffPrompt = null;
77+
if (ctx.hasUI) {
78+
ctx.ui.setStatus?.(STATUS_KEY_HANDOFF, undefined);
79+
ctx.ui.notify?.(
80+
`Manual /handoff could not start: ${error instanceof Error ? error.message : String(error)}`,
81+
"error",
82+
);
83+
}
84+
await updateHandoffToolAvailability(pi, state, ctx);
85+
});
6286
},
6387
});
6488
}

0 commit comments

Comments
 (0)