fix(ui): stream phase progress without gates

This commit is contained in:
2026-08-24 12:10:56 +02:00
parent 36a7a0ab33
commit fa2298653b
10 changed files with 186 additions and 5 deletions
@@ -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" },
]);
});
+28 -1
View File
@@ -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) => {