fix(ui): stream phase progress without gates
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const cp = require("node:child_process");
|
||||
const path = require("node:path");
|
||||
const { createRequire } = require("node:module");
|
||||
const { createFakePi } = require("./fake_pi_runtime.js");
|
||||
|
||||
const GATE = path.join(__dirname, "..", "..", "tht-gate.js");
|
||||
globalThis.require = createRequire(GATE);
|
||||
|
||||
let phase = 2;
|
||||
cp.execFileSync = (_file, args) => {
|
||||
if (args.join(" ") === "phase meta --json") {
|
||||
return JSON.stringify({
|
||||
max_phase: 8,
|
||||
phases: Array.from({ length: 8 }, (_, index) => ({
|
||||
num: index + 1,
|
||||
id: `F${index + 1}`,
|
||||
name: `phase-${index + 1}`,
|
||||
emits: [],
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (args.slice(0, 4).join(" ") === "phase show --session session-1") {
|
||||
return `Fase corrente: ${phase}\n`;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const installGate = require(GATE).default ?? require(GATE);
|
||||
|
||||
test("tool completion announces each newly active phase even without a reviewer gate", async () => {
|
||||
const { pi, ctx } = createFakePi();
|
||||
ctx.mode = "rpc";
|
||||
installGate(pi);
|
||||
await pi.emit("session_start", {});
|
||||
|
||||
await pi.emit("tool_result", {
|
||||
toolName: "reviewer_select",
|
||||
input: { session: "session-1" },
|
||||
isError: false,
|
||||
});
|
||||
phase = 3;
|
||||
await pi.emit("tool_result", {
|
||||
toolName: "reviewer_decide",
|
||||
input: { session: "session-1" },
|
||||
isError: false,
|
||||
});
|
||||
await pi.emit("tool_result", {
|
||||
toolName: "reviewer_decide",
|
||||
input: { session: "session-1" },
|
||||
isError: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(ctx.notifications, [
|
||||
{ message: "__tht_phase_started__:F2", level: "info" },
|
||||
{ message: "__tht_phase_started__:F3", level: "info" },
|
||||
]);
|
||||
});
|
||||
@@ -52,6 +52,11 @@ import {
|
||||
} from "./gate/disambiguation/index.js";
|
||||
import { isReserved } from "./gate/core/reserved-labels.mjs";
|
||||
|
||||
// Machine-readable RPC notification consumed by SessionBridge. Pi's extension API
|
||||
// exposes notify/input but no custom client-event emitter, so this reserved prefix is
|
||||
// the narrow transport contract for phase progress that does not require a human gate.
|
||||
export const PHASE_STARTED_NOTIFICATION_PREFIX = "__tht_phase_started__:";
|
||||
|
||||
// Load the workflow contract once when Pi loads the extension. Asking the model to
|
||||
// discover/read the skill as its first action proved unreliable with remote models:
|
||||
// they can spend the whole RPC turn exploring the repository before opening the exact
|
||||
@@ -596,6 +601,18 @@ export default function (pi) {
|
||||
let lastSteered = false;
|
||||
let pendingKickoff = null;
|
||||
let activeSessionId = null;
|
||||
const announcedPhases = new Map();
|
||||
const announceCurrentPhase = async (ctx, session) => {
|
||||
if (ctx.mode !== "rpc" || typeof session !== "string" || !session.trim()) return;
|
||||
try {
|
||||
const phase = phaseId(ctx, currentPhase(ctx, session));
|
||||
if (!/^F[1-8]$/.test(phase) || announcedPhases.get(session) === phase) return;
|
||||
await ctx.ui.notify(`${PHASE_STARTED_NOTIFICATION_PREFIX}${phase}`, "info");
|
||||
announcedPhases.set(session, phase);
|
||||
} catch {
|
||||
// Progress reporting is observational: it must never block the workflow.
|
||||
}
|
||||
};
|
||||
const memoryGate = createMemoryGate({
|
||||
workflow: {
|
||||
activate: () => {
|
||||
@@ -748,7 +765,8 @@ export default function (pi) {
|
||||
|
||||
// 3) BEFORE_AGENT_START: one-shot kickoff injection (appends the operational
|
||||
// instructions to the system prompt on the turn that starts/resumes a session).
|
||||
pi.on("before_agent_start", (event, ctx) => {
|
||||
pi.on("before_agent_start", async (event, ctx) => {
|
||||
await announceCurrentPhase(ctx, process.env.THT_SESSION);
|
||||
if (!pendingKickoff) return undefined;
|
||||
const sessionId = process.env.THT_SESSION;
|
||||
const isProvidedNewSession =
|
||||
@@ -789,9 +807,18 @@ export default function (pi) {
|
||||
lastSteered = false;
|
||||
}
|
||||
activeSessionId = null;
|
||||
announcedPhases.clear();
|
||||
_phaseMetaCache = null;
|
||||
});
|
||||
|
||||
// A reviewer tool is the ownership boundary that persists a decision and may
|
||||
// advance the folded workflow phase. Observe the persisted phase after the tool
|
||||
// completes, including auto-approved phases that never open a human widget.
|
||||
pi.on("tool_result", async (event, ctx) => {
|
||||
const session = typeof event.input?.session === "string" ? event.input.session : null;
|
||||
if (session) await announceCurrentPhase(ctx, session);
|
||||
});
|
||||
|
||||
// 5) AGENT_END prose safety net: if the model emits prose instead of a reviewer_*
|
||||
// tool call (and the lock is active), nudge it back to the gate tools.
|
||||
pi.on("agent_end", async (event) => {
|
||||
|
||||
Reference in New Issue
Block a user