Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 29 additions & 23 deletions extensions/teams/leader-inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,14 @@ export async function pollLeaderInbox(opts: {
style: TeamsStyle;
pendingPlanApprovals: Map<string, { requestId: string; name: string; taskId?: string }>;
enqueueHook?: (invocation: TeamsHookInvocation) => void;
hooksEnabled?: boolean;
sendLeaderLlmMessage?: SendLeaderLlmMessage;
/** Batch delegation tracker for all-tasks-complete auto-notify. */
delegationTracker?: DelegationTracker;
}): Promise<void> {
const { ctx, teamId, teamDir, taskListId, leadName, style, pendingPlanApprovals, enqueueHook, sendLeaderLlmMessage, delegationTracker } = opts;
const { ctx, teamId, teamDir, taskListId, leadName, style, pendingPlanApprovals, enqueueHook, hooksEnabled, sendLeaderLlmMessage, delegationTracker } = opts;
const strings = getTeamsStrings(style);
const hooksActive = hooksEnabled ?? Boolean(enqueueHook);

let msgs: Awaited<ReturnType<typeof popUnreadMessages>>;
try {
Expand Down Expand Up @@ -173,38 +175,42 @@ export async function pollLeaderInbox(opts: {
const name = sanitizeName(idle.from);

// Hook: always emit "idle" (best-effort, non-blocking)
try {
enqueueHook?.({
event: "idle",
teamId,
teamDir,
taskListId,
style,
memberName: name,
timestamp: idle.timestamp,
completedTask: null,
});
} catch {
// ignore hook enqueue errors
}

// Hook: task completion / failure
if (idle.completedTaskId) {
const completedTask = await getTask(teamDir, taskListId, idle.completedTaskId);
if (hooksActive) {
try {
enqueueHook?.({
event: idle.completedStatus === "failed" ? "task_failed" : "task_completed",
event: "idle",
teamId,
teamDir,
taskListId,
style,
memberName: name,
timestamp: idle.timestamp,
completedTask,
completedTask: null,
});
} catch {
// ignore hook enqueue errors
}
}

// Hook: task completion / failure
if (idle.completedTaskId) {
const completedTask = await getTask(teamDir, taskListId, idle.completedTaskId);
if (hooksActive) {
try {
enqueueHook?.({
event: idle.completedStatus === "failed" ? "task_failed" : "task_completed",
teamId,
teamDir,
taskListId,
style,
memberName: name,
timestamp: idle.timestamp,
completedTask,
});
} catch {
// ignore hook enqueue errors
}
}

// Event-driven batch tracking: mark this task done and
// collect any batches that became fully complete.
Expand Down Expand Up @@ -319,7 +325,7 @@ export async function pollLeaderInbox(opts: {

if (allDone) {
lines.push("");
if (enqueueHook) {
if (hooksActive) {
// Hooks run asynchronously and may reopen tasks or create follow-ups.
lines.push(`All ${totalTasks} task(s) show completed — quality gates are still running and may change task states.`);
} else {
Expand Down Expand Up @@ -354,7 +360,7 @@ export async function pollLeaderInbox(opts: {
if (sendLeaderLlmMessage) {
for (const batch of batchCompletions) {
const taskRefs = batch.taskIds.map((id) => `#${id}`).join(", ");
const suffix = enqueueHook
const suffix = hooksActive
? "Quality gates are still running and may change task states."
: "Review the results and continue.";
const msg = `[Team] All delegated tasks completed (${taskRefs}). ${suffix}`;
Expand Down
2 changes: 2 additions & 0 deletions extensions/teams/leader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { getTeamsStyleFromEnv, type TeamsStyle, formatMemberDisplayName, getTeam
import { DelegationTracker, pollLeaderInbox as pollLeaderInboxImpl } from "./leader-inbox.js";
import {
getHookBaseName,
areTeamsHooksEnabled,
getTeamsHookFailureAction,
getTeamsHookFollowupOwnerPolicy,
getTeamsHookMaxReopensPerTask,
Expand Down Expand Up @@ -708,6 +709,7 @@ export function runLeader(pi: ExtensionAPI): void {
style,
pendingPlanApprovals,
enqueueHook,
hooksEnabled: areTeamsHooksEnabled(process.env),
sendLeaderLlmMessage: (content, options) => {
pi.sendUserMessage(content, options);
},
Expand Down
88 changes: 86 additions & 2 deletions scripts/smoke-test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ import {
isPlanApprovedMessage,
isPlanRejectedMessage,
} from "../extensions/teams/protocol.js";
import { pollLeaderInbox } from "../extensions/teams/leader-inbox.js";
import { DelegationTracker, pollLeaderInbox } from "../extensions/teams/leader-inbox.js";
import { getParentSessionId, shouldSilenceInheritedParentAttachClaimWarning } from "../extensions/teams/session-parent.js";
import { branchSelectionNote, ensureSessionFileMaterialized, resolveBranchLeafSelection } from "../extensions/teams/session-branching.js";
import { SessionManager, type ExtensionContext } from "@mariozechner/pi-coding-agent";
Expand Down Expand Up @@ -1309,6 +1309,7 @@ console.log("\n14. leader-inbox LLM message injection");
cwd: inboxTeamDir,
ui: { notify: () => {} },
sessionManager: { getSessionId: () => "inbox-team" },
isIdle: () => false,
} as unknown as ExtensionContext;

await pollLeaderInbox({
Expand Down Expand Up @@ -1415,7 +1416,8 @@ console.log("\n14. leader-inbox LLM message injection");
leadName,
style,
pendingPlanApprovals: new Map(),
enqueueHook: () => {}, // hooks present → should qualify allDone
enqueueHook: () => {},
hooksEnabled: true,
sendLeaderLlmMessage: (content, options) => {
llmMessages.push({ content, options });
},
Expand All @@ -1427,6 +1429,88 @@ console.log("\n14. leader-inbox LLM message injection");
assert(hookMsg.content.includes("quality gates are still running"), "allDone qualified when hooks active");
assert(!hookMsg.content.includes("Review results and determine next steps"), "no premature wrap-up prompt when hooks active");
}

// Hooks disabled should not qualify all-done messages just because a callback is wired.
const t4 = await createTask(inboxTeamDir, inboxTaskListId, { subject: "Post-review cleanup", description: "", owner: "dave" });
await completeTask(inboxTeamDir, inboxTaskListId, t4.id, "dave", "Cleanup complete");
const ts4 = new Date().toISOString();
await writeToMailbox(inboxTeamDir, TEAM_MAILBOX_NS, leadName, {
from: "dave",
text: JSON.stringify({
type: "idle_notification",
from: "dave",
timestamp: ts4,
completedTaskId: t4.id,
completedStatus: "completed",
}),
timestamp: ts4,
});

llmMessages.length = 0;
await pollLeaderInbox({
ctx: stubCtx,
teamId: "inbox-team",
teamDir: inboxTeamDir,
taskListId: inboxTaskListId,
leadName,
style,
pendingPlanApprovals: new Map(),
enqueueHook: () => {},
hooksEnabled: false,
sendLeaderLlmMessage: (content, options) => {
llmMessages.push({ content, options });
},
});

assert(llmMessages.length === 1, "one LLM message sent when hooks callback is wired but disabled");
const disabledHookMsg = llmMessages[0];
if (disabledHookMsg) {
assert(!disabledHookMsg.content.includes("quality gates are still running"), "disabled hooks do not qualify the per-task allDone summary");
assert(disabledHookMsg.content.includes("Review results and determine next steps"), "disabled hooks keep the normal per-task allDone summary");
}

// Batch-complete auto-wake should use the same hooks-enabled check.
const t5 = await createTask(inboxTeamDir, inboxTaskListId, { subject: "Batch wake task", description: "", owner: "erin" });
await completeTask(inboxTeamDir, inboxTaskListId, t5.id, "erin", "Batch wake done");
const ts5 = new Date().toISOString();
await writeToMailbox(inboxTeamDir, TEAM_MAILBOX_NS, leadName, {
from: "erin",
text: JSON.stringify({
type: "idle_notification",
from: "erin",
timestamp: ts5,
completedTaskId: t5.id,
completedStatus: "completed",
}),
timestamp: ts5,
});

const batchTracker = new DelegationTracker();
batchTracker.addBatch([t5.id]);
llmMessages.length = 0;
await pollLeaderInbox({
ctx: stubCtx,
teamId: "inbox-team",
teamDir: inboxTeamDir,
taskListId: inboxTaskListId,
leadName,
style,
pendingPlanApprovals: new Map(),
enqueueHook: () => {},
hooksEnabled: false,
delegationTracker: batchTracker,
sendLeaderLlmMessage: (content, options) => {
llmMessages.push({ content, options });
},
});

assert(llmMessages.length === 2, "per-task completion plus batch-complete messages sent when a tracked delegation finishes");
const batchMsg = llmMessages.find((entry) => entry.content.includes("All delegated tasks completed"));
assert(batchMsg !== undefined, "batch-complete notification sent");
if (batchMsg) {
assert(!batchMsg.content.includes("Quality gates are still running"), "disabled hooks do not qualify the batch-complete summary");
assert(batchMsg.content.includes("Review the results and continue."), "disabled hooks keep the normal batch-complete summary");
}
}

// ── 15. docs/help drift guard ────────────────────────────────────────
Expand Down
Loading