@tmustier/pi-agent-teams 0.5.3 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.4
4
+
5
+ ### Fixes
6
+
7
+ - **Clean branch-mode worker sessions** — teammates spawned with `contextMode=branch` now branch from the last stable pre-turn boundary, replay the active user request, preserve compaction entries, and materialize user-only fallbacks. This prevents workers from inheriting the leader's in-progress tool-use turn or reopening as blank sessions. (#35, #36)
8
+ - **Accurate quality-gate completion notices** — leader completion and batch-complete messages now mention running quality gates only when hooks are actually enabled, instead of inferring that from the presence of an internal enqueue callback. (#37)
9
+
3
10
  ## 0.5.3
4
11
 
5
12
  ### Fixes
@@ -15,134 +15,6 @@ export type TranscriptEntry =
15
15
  | { kind: "turn_end"; turnNumber: number; tokens: number; timestamp: number };
16
16
 
17
17
  const MAX_TRANSCRIPT = 200;
18
- const MAX_SUMMARY_LENGTH = 120;
19
-
20
- // ── Tool content summarization ──
21
-
22
- function truncateSummary(text: string): string {
23
- if (text.length <= MAX_SUMMARY_LENGTH) return text;
24
- return `${text.slice(0, MAX_SUMMARY_LENGTH - 1)}…`;
25
- }
26
-
27
- function summarizeToolArgs(toolName: string, args: unknown): string {
28
- if (!isRecord(args)) return "";
29
- const key = toolName.toLowerCase();
30
-
31
- if (key === "read" || key === "write") {
32
- const path = typeof args.path === "string" ? args.path : null;
33
- if (!path) return "";
34
- return truncateSummary(path);
35
- }
36
-
37
- if (key === "edit") {
38
- const path = typeof args.path === "string" ? args.path : null;
39
- if (!path) return "";
40
- return truncateSummary(path);
41
- }
42
-
43
- if (key === "bash") {
44
- const cmd = typeof args.command === "string" ? args.command : null;
45
- if (!cmd) return "";
46
- return truncateSummary(cmd.replace(/\s+/g, " ").trim());
47
- }
48
-
49
- if (key === "grep" || key === "glob") {
50
- const pattern = typeof args.pattern === "string" ? args.pattern : null;
51
- const path = typeof args.path === "string" ? args.path : null;
52
- const parts: string[] = [];
53
- if (pattern) parts.push(pattern);
54
- if (path) parts.push(`in ${path}`);
55
- return truncateSummary(parts.join(" "));
56
- }
57
-
58
- if (key === "webfetch" || key === "websearch") {
59
- const url = typeof args.url === "string" ? args.url : null;
60
- const query = typeof args.query === "string" ? args.query : null;
61
- return truncateSummary(url ?? query ?? "");
62
- }
63
-
64
- if (key === "team_message") {
65
- const recipient = typeof args.recipient === "string" ? args.recipient : null;
66
- const message = typeof args.message === "string" ? args.message : null;
67
- if (recipient && message) return truncateSummary(`→ ${recipient}: ${message.replace(/\s+/g, " ").trim()}`);
68
- if (message) return truncateSummary(message.replace(/\s+/g, " ").trim());
69
- return recipient ? truncateSummary(`→ ${recipient}`) : "";
70
- }
71
-
72
- if (key === "task" || key === "teams") {
73
- const action = typeof args.action === "string" ? args.action : null;
74
- return action ? truncateSummary(action) : "";
75
- }
76
-
77
- // Fallback: try to find a meaningful first-string arg
78
- for (const v of Object.values(args)) {
79
- if (typeof v === "string" && v.length > 0) return truncateSummary(v);
80
- }
81
- return "";
82
- }
83
-
84
- /**
85
- * Extract the first text content from a ToolResultMessage-shaped result.
86
- * The agent-loop emits `{ content: [{type: "text", text: "..."}], ... }`.
87
- */
88
- function extractContentText(result: Record<string, unknown>): string | null {
89
- const content = result.content;
90
- if (!Array.isArray(content)) return null;
91
- for (const item of content) {
92
- if (isRecord(item) && item.type === "text" && typeof item.text === "string") {
93
- return item.text;
94
- }
95
- }
96
- return null;
97
- }
98
-
99
- function summarizeToolResult(toolName: string, result: unknown, isError: boolean): string {
100
- if (isError) {
101
- if (typeof result === "string") return truncateSummary(result.replace(/\s+/g, " ").trim());
102
- if (isRecord(result)) {
103
- // Try content array first (ToolResultMessage shape)
104
- const contentText = extractContentText(result);
105
- if (contentText !== null) {
106
- const trimmed = contentText.replace(/\s+/g, " ").trim();
107
- return trimmed.length > 0 ? truncateSummary(trimmed) : "error";
108
- }
109
- const msg = typeof result.message === "string"
110
- ? result.message
111
- : typeof result.error === "string"
112
- ? result.error
113
- : null;
114
- if (msg) return truncateSummary(msg.replace(/\s+/g, " ").trim());
115
- }
116
- return "error";
117
- }
118
-
119
- if (typeof result === "string") {
120
- const compact = result.replace(/\s+/g, " ").trim();
121
- if (compact.length === 0) return "(empty)";
122
- return truncateSummary(compact);
123
- }
124
-
125
- if (isRecord(result)) {
126
- // Try content array first (ToolResultMessage shape from agent-loop)
127
- const contentText = extractContentText(result);
128
- if (contentText !== null) {
129
- const compact = contentText.replace(/\s+/g, " ").trim();
130
- if (compact.length === 0) return "(empty)";
131
- return truncateSummary(compact);
132
- }
133
- // Fallback: check for common structured result shapes
134
- const status = typeof result.status === "string" ? result.status : null;
135
- if (status) return truncateSummary(status);
136
- const output = typeof result.output === "string" ? result.output : null;
137
- if (output) return truncateSummary(output.replace(/\s+/g, " ").trim());
138
- }
139
-
140
- if (Array.isArray(result)) {
141
- return `${String(result.length)} items`;
142
- }
143
-
144
- return "";
145
- }
146
18
 
147
19
  export class TranscriptLog {
148
20
  private entries: TranscriptEntry[] = [];
@@ -91,12 +91,14 @@ export async function pollLeaderInbox(opts: {
91
91
  style: TeamsStyle;
92
92
  pendingPlanApprovals: Map<string, { requestId: string; name: string; taskId?: string }>;
93
93
  enqueueHook?: (invocation: TeamsHookInvocation) => void;
94
+ hooksEnabled?: boolean;
94
95
  sendLeaderLlmMessage?: SendLeaderLlmMessage;
95
96
  /** Batch delegation tracker for all-tasks-complete auto-notify. */
96
97
  delegationTracker?: DelegationTracker;
97
98
  }): Promise<void> {
98
- const { ctx, teamId, teamDir, taskListId, leadName, style, pendingPlanApprovals, enqueueHook, sendLeaderLlmMessage, delegationTracker } = opts;
99
+ const { ctx, teamId, teamDir, taskListId, leadName, style, pendingPlanApprovals, enqueueHook, hooksEnabled, sendLeaderLlmMessage, delegationTracker } = opts;
99
100
  const strings = getTeamsStrings(style);
101
+ const hooksActive = hooksEnabled ?? Boolean(enqueueHook);
100
102
 
101
103
  let msgs: Awaited<ReturnType<typeof popUnreadMessages>>;
102
104
  try {
@@ -173,38 +175,42 @@ export async function pollLeaderInbox(opts: {
173
175
  const name = sanitizeName(idle.from);
174
176
 
175
177
  // Hook: always emit "idle" (best-effort, non-blocking)
176
- try {
177
- enqueueHook?.({
178
- event: "idle",
179
- teamId,
180
- teamDir,
181
- taskListId,
182
- style,
183
- memberName: name,
184
- timestamp: idle.timestamp,
185
- completedTask: null,
186
- });
187
- } catch {
188
- // ignore hook enqueue errors
189
- }
190
-
191
- // Hook: task completion / failure
192
- if (idle.completedTaskId) {
193
- const completedTask = await getTask(teamDir, taskListId, idle.completedTaskId);
178
+ if (hooksActive) {
194
179
  try {
195
180
  enqueueHook?.({
196
- event: idle.completedStatus === "failed" ? "task_failed" : "task_completed",
181
+ event: "idle",
197
182
  teamId,
198
183
  teamDir,
199
184
  taskListId,
200
185
  style,
201
186
  memberName: name,
202
187
  timestamp: idle.timestamp,
203
- completedTask,
188
+ completedTask: null,
204
189
  });
205
190
  } catch {
206
191
  // ignore hook enqueue errors
207
192
  }
193
+ }
194
+
195
+ // Hook: task completion / failure
196
+ if (idle.completedTaskId) {
197
+ const completedTask = await getTask(teamDir, taskListId, idle.completedTaskId);
198
+ if (hooksActive) {
199
+ try {
200
+ enqueueHook?.({
201
+ event: idle.completedStatus === "failed" ? "task_failed" : "task_completed",
202
+ teamId,
203
+ teamDir,
204
+ taskListId,
205
+ style,
206
+ memberName: name,
207
+ timestamp: idle.timestamp,
208
+ completedTask,
209
+ });
210
+ } catch {
211
+ // ignore hook enqueue errors
212
+ }
213
+ }
208
214
 
209
215
  // Event-driven batch tracking: mark this task done and
210
216
  // collect any batches that became fully complete.
@@ -319,7 +325,7 @@ export async function pollLeaderInbox(opts: {
319
325
 
320
326
  if (allDone) {
321
327
  lines.push("");
322
- if (enqueueHook) {
328
+ if (hooksActive) {
323
329
  // Hooks run asynchronously and may reopen tasks or create follow-ups.
324
330
  lines.push(`All ${totalTasks} task(s) show completed — quality gates are still running and may change task states.`);
325
331
  } else {
@@ -354,7 +360,7 @@ export async function pollLeaderInbox(opts: {
354
360
  if (sendLeaderLlmMessage) {
355
361
  for (const batch of batchCompletions) {
356
362
  const taskRefs = batch.taskIds.map((id) => `#${id}`).join(", ");
357
- const suffix = enqueueHook
363
+ const suffix = hooksActive
358
364
  ? "Quality gates are still running and may change task states."
359
365
  : "Review the results and continue.";
360
366
  const msg = `[Team] All delegated tasks completed (${taskRefs}). ${suffix}`;
@@ -22,6 +22,7 @@ import { getTeamsStyleFromEnv, type TeamsStyle, formatMemberDisplayName, getTeam
22
22
  import { DelegationTracker, pollLeaderInbox as pollLeaderInboxImpl } from "./leader-inbox.js";
23
23
  import {
24
24
  getHookBaseName,
25
+ areTeamsHooksEnabled,
25
26
  getTeamsHookFailureAction,
26
27
  getTeamsHookFollowupOwnerPolicy,
27
28
  getTeamsHookMaxReopensPerTask,
@@ -34,6 +35,7 @@ import {
34
35
  import { handleTeamCommand } from "./leader-team-command.js";
35
36
  import { registerTeamsTool } from "./leader-teams-tool.js";
36
37
  import { getParentSessionId, shouldSilenceInheritedParentAttachClaimWarning } from "./session-parent.js";
38
+ import { branchSelectionNote, ensureSessionFileMaterialized, resolveBranchLeafSelection } from "./session-branching.js";
37
39
  import type { ContextMode, SpawnTeammateFn, SpawnTeammateResult, WorkspaceMode } from "./spawn-types.js";
38
40
 
39
41
  function getTeamsExtensionEntryPath(): string | null {
@@ -90,12 +92,17 @@ async function createSessionForTeammate(
90
92
 
91
93
  try {
92
94
  const sm = SessionManager.open(parentSessionFile, teamSessionsDir);
93
- const branched = sm.createBranchedSession(leafId);
95
+ const selection = resolveBranchLeafSelection(sm.getBranch(leafId), leafId);
96
+ const branched = sm.createBranchedSession(selection.leafId);
94
97
  if (!branched) {
95
98
  const fallback = SessionManager.create(ctx.cwd, teamSessionsDir);
96
99
  return { sessionFile: fallback.getSessionFile(), note: "branch(failed->fresh)", warnings };
97
100
  }
98
- return { sessionFile: branched, note: "branch", warnings };
101
+ if (selection.replayUserMessage) {
102
+ sm.appendMessage(JSON.parse(JSON.stringify(selection.replayUserMessage)) as Parameters<typeof sm.appendMessage>[0]);
103
+ }
104
+ await ensureSessionFileMaterialized(sm, branched);
105
+ return { sessionFile: branched, note: branchSelectionNote(selection), warnings };
99
106
  } catch (err) {
100
107
  const msg = err instanceof Error ? err.message : String(err);
101
108
  if (/Entry .* not found/i.test(msg)) {
@@ -702,6 +709,7 @@ export function runLeader(pi: ExtensionAPI): void {
702
709
  style,
703
710
  pendingPlanApprovals,
704
711
  enqueueHook,
712
+ hooksEnabled: areTeamsHooksEnabled(process.env),
705
713
  sendLeaderLlmMessage: (content, options) => {
706
714
  pi.sendUserMessage(content, options);
707
715
  },
@@ -0,0 +1,133 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { SessionEntry, SessionManager } from "@mariozechner/pi-coding-agent";
4
+
5
+ export type BranchLeafSelection = {
6
+ leafId: string;
7
+ adjusted: boolean;
8
+ reason: "requested" | "clean-turn-replay" | "clean-turn-stable" | "clean-turn-user";
9
+ replayUserMessage?: UserMessageLike;
10
+ };
11
+
12
+ type MessageLike = Record<string, unknown> & { role: string };
13
+ type UserMessageLike = MessageLike & { role: "user"; content: unknown; timestamp: number };
14
+
15
+ type MessageEntryLike = SessionEntry & {
16
+ type: "message";
17
+ message: MessageLike;
18
+ };
19
+
20
+ function isRecord(value: unknown): value is Record<string, unknown> {
21
+ return typeof value === "object" && value !== null;
22
+ }
23
+
24
+ function isMessageEntry(entry: SessionEntry): entry is MessageEntryLike {
25
+ if (entry.type !== "message") return false;
26
+ return isRecord(entry.message) && typeof entry.message.role === "string";
27
+ }
28
+
29
+ function isUserMessageEntry(entry: SessionEntry): entry is MessageEntryLike & { message: UserMessageLike } {
30
+ return isMessageEntry(entry) && entry.message.role === "user";
31
+ }
32
+
33
+ function isAssistantToolUseEntry(entry: SessionEntry): entry is MessageEntryLike & { message: MessageLike & { role: "assistant"; stopReason: "toolUse" } } {
34
+ return (
35
+ isMessageEntry(entry) &&
36
+ entry.message.role === "assistant" &&
37
+ typeof entry.message.stopReason === "string" &&
38
+ entry.message.stopReason === "toolUse"
39
+ );
40
+ }
41
+
42
+ function isStableAssistantEntry(entry: SessionEntry): entry is MessageEntryLike & { message: MessageLike & { role: "assistant" } } {
43
+ return (
44
+ isMessageEntry(entry) &&
45
+ entry.message.role === "assistant" &&
46
+ typeof entry.message.stopReason === "string" &&
47
+ entry.message.stopReason !== "toolUse"
48
+ );
49
+ }
50
+
51
+ /**
52
+ * When the leader is mid-turn, the current leaf may point into an unfinished
53
+ * assistant/tool-use path. Branching from that leaf causes workers to inherit
54
+ * the leader's in-progress turn instead of a clean conversation context.
55
+ *
56
+ * In that case, branch from the last stable turn boundary instead:
57
+ * - Prefer the latest completed assistant message (persists as a real branched file)
58
+ * - Otherwise fall back to the latest user message
59
+ */
60
+ export function resolveBranchLeafSelection(path: SessionEntry[], requestedLeafId: string): BranchLeafSelection {
61
+ const lastAssistantIndex = [...path].map((entry, index) => ({ entry, index }))
62
+ .reverse()
63
+ .find(({ entry }) => isMessageEntry(entry) && entry.message.role === "assistant")?.index;
64
+
65
+ if (lastAssistantIndex === undefined) {
66
+ return { leafId: requestedLeafId, adjusted: false, reason: "requested" };
67
+ }
68
+
69
+ const lastAssistant = path[lastAssistantIndex];
70
+ if (!lastAssistant || !isAssistantToolUseEntry(lastAssistant)) {
71
+ return { leafId: requestedLeafId, adjusted: false, reason: "requested" };
72
+ }
73
+
74
+ let latestUserIndex = -1;
75
+ let latestUserBeforeActiveTurn: UserMessageLike | undefined;
76
+ for (let i = lastAssistantIndex - 1; i >= 0; i -= 1) {
77
+ const candidate = path[i];
78
+ if (!candidate) continue;
79
+ if (isUserMessageEntry(candidate)) {
80
+ latestUserIndex = i;
81
+ latestUserBeforeActiveTurn = candidate.message;
82
+ break;
83
+ }
84
+ }
85
+
86
+ if (latestUserIndex > 0 && latestUserBeforeActiveTurn) {
87
+ const boundary = path[latestUserIndex - 1];
88
+ if (boundary) {
89
+ return {
90
+ leafId: boundary.id,
91
+ adjusted: boundary.id !== requestedLeafId,
92
+ reason: "clean-turn-replay",
93
+ replayUserMessage: latestUserBeforeActiveTurn,
94
+ };
95
+ }
96
+ }
97
+
98
+ for (let i = lastAssistantIndex - 1; i >= 0; i -= 1) {
99
+ const candidate = path[i];
100
+ if (!candidate) continue;
101
+ if (isStableAssistantEntry(candidate)) {
102
+ return {
103
+ leafId: candidate.id,
104
+ adjusted: candidate.id !== requestedLeafId,
105
+ reason: "clean-turn-stable",
106
+ };
107
+ }
108
+ if (isUserMessageEntry(candidate)) {
109
+ return { leafId: candidate.id, adjusted: candidate.id !== requestedLeafId, reason: "clean-turn-user" };
110
+ }
111
+ }
112
+
113
+ return { leafId: requestedLeafId, adjusted: false, reason: "requested" };
114
+ }
115
+
116
+ export async function ensureSessionFileMaterialized(
117
+ sm: Pick<SessionManager, "getHeader" | "getEntries">,
118
+ sessionFile: string,
119
+ ): Promise<void> {
120
+ if (fs.existsSync(sessionFile)) return;
121
+ const header = sm.getHeader();
122
+ if (!header) return;
123
+ await fs.promises.mkdir(path.dirname(sessionFile), { recursive: true });
124
+ const lines = [JSON.stringify(header), ...sm.getEntries().map((entry) => JSON.stringify(entry))].join("\n") + "\n";
125
+ await fs.promises.writeFile(sessionFile, lines, "utf8");
126
+ }
127
+
128
+ export function branchSelectionNote(selection: BranchLeafSelection): string {
129
+ if (!selection.adjusted) return "branch";
130
+ if (selection.reason === "clean-turn-replay" || selection.reason === "clean-turn-stable") return "branch(clean-turn)";
131
+ if (selection.reason === "clean-turn-user") return "branch(clean-turn:user-fallback)";
132
+ return "branch";
133
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-agent-teams",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "description": "Claude Code agent teams style workflow for Pi.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",
@@ -35,7 +35,8 @@
35
35
  "integration-spawn-overrides-test": "tsx scripts/integration-spawn-overrides-test.mts",
36
36
  "integration-hooks-remediation-test": "tsx scripts/integration-hooks-remediation-test.mts",
37
37
  "integration-todo-test": "tsx scripts/integration-todo-test.mts",
38
- "integration-cleanup-test": "tsx scripts/integration-cleanup-test.mts"
38
+ "integration-cleanup-test": "tsx scripts/integration-cleanup-test.mts",
39
+ "integration-branch-context-test": "tsx scripts/integration-branch-context-test.mts"
39
40
  },
40
41
  "devDependencies": {
41
42
  "@eslint/js": "^9.39.2",
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Integration test: branch-mode worker sessions should strip the leader's
3
+ * in-progress tool-use turn before starting delegated work.
4
+ *
5
+ * What this covers:
6
+ * - Prepare a persisted parent session whose leaf ends inside an unfinished
7
+ * assistant/tool-use turn (user -> assistant toolUse -> toolResult)
8
+ * - Derive a branch session using the same clean-turn selection logic used by
9
+ * teammate spawning
10
+ * - Start a real worker process from that branched session in worktree mode
11
+ * context conditions (git repo + persisted session)
12
+ * - Deliver an assigned task and verify the worker starts and completes it
13
+ *
14
+ * Usage:
15
+ * npx tsx scripts/integration-branch-context-test.mts
16
+ * npx tsx scripts/integration-branch-context-test.mts --timeoutSec 120
17
+ */
18
+
19
+ import * as fs from "node:fs";
20
+ import * as os from "node:os";
21
+ import * as path from "node:path";
22
+ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
23
+ import { fileURLToPath } from "node:url";
24
+
25
+ import { SessionManager } from "@mariozechner/pi-coding-agent";
26
+ import type { AssistantMessage } from "@mariozechner/pi-ai";
27
+ import { writeToMailbox } from "../extensions/teams/mailbox.js";
28
+ import { taskAssignmentPayload } from "../extensions/teams/protocol.js";
29
+ import { branchSelectionNote, resolveBranchLeafSelection } from "../extensions/teams/session-branching.js";
30
+ import { createTask, getTask } from "../extensions/teams/task-store.js";
31
+ import { ensureTeamConfig, loadTeamConfig } from "../extensions/teams/team-config.js";
32
+ import { sleep, terminateAll } from "./lib/pi-workers.js";
33
+
34
+ function parseArgs(argv: readonly string[]): { timeoutSec: number } {
35
+ let timeoutSec = 120;
36
+ for (let i = 0; i < argv.length; i += 1) {
37
+ if (argv[i] === "--timeoutSec") {
38
+ const v = argv[i + 1];
39
+ if (v) timeoutSec = Number.parseInt(v, 10);
40
+ i += 1;
41
+ }
42
+ }
43
+ if (!Number.isFinite(timeoutSec) || timeoutSec < 30) timeoutSec = 120;
44
+ return { timeoutSec };
45
+ }
46
+
47
+ function assert(condition: boolean, message: string): void {
48
+ if (!condition) throw new Error(message);
49
+ }
50
+
51
+ async function waitFor(
52
+ fn: () => boolean | Promise<boolean>,
53
+ opts: { timeoutMs: number; pollMs: number; label: string },
54
+ ): Promise<void> {
55
+ const { timeoutMs, pollMs, label } = opts;
56
+ const deadline = Date.now() + timeoutMs;
57
+ while (Date.now() < deadline) {
58
+ if (await fn()) return;
59
+ await sleep(pollMs);
60
+ }
61
+ throw new Error(`Timeout waiting for ${label}`);
62
+ }
63
+
64
+ function git(args: string[], cwd: string): void {
65
+ const res = spawnSync("git", args, {
66
+ cwd,
67
+ encoding: "utf8",
68
+ });
69
+ if (res.status !== 0) {
70
+ throw new Error(`git ${args.join(" ")} failed: ${res.stderr || res.stdout}`);
71
+ }
72
+ }
73
+
74
+ async function latestMemberStatus(teamDir: string, name: string): Promise<{ status?: string; sessionFile?: string } | null> {
75
+ const cfg = await loadTeamConfig(teamDir);
76
+ if (!cfg) return null;
77
+ const member = cfg.members.find((m) => m.name === name);
78
+ if (!member) return null;
79
+ return { status: member.status, sessionFile: member.sessionFile };
80
+ }
81
+
82
+ const { timeoutSec } = parseArgs(process.argv.slice(2));
83
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-teams-branch-context-"));
84
+ const teamsRootDir = path.join(tmpRoot, "teams-root");
85
+ const repoDir = path.join(tmpRoot, "repo");
86
+ const teamId = "branch-context-team";
87
+ const taskListId = teamId;
88
+ const teamDir = path.join(teamsRootDir, teamId);
89
+ const sessionsDir = path.join(teamDir, "sessions");
90
+ const agentName = "alpha";
91
+ const leadName = "team-lead";
92
+ const procs: ChildProcess[] = [];
93
+
94
+ try {
95
+ fs.mkdirSync(repoDir, { recursive: true });
96
+ fs.mkdirSync(sessionsDir, { recursive: true });
97
+
98
+ git(["init", "-b", "main"], repoDir);
99
+ git(["config", "user.name", "Test User"], repoDir);
100
+ git(["config", "user.email", "test@example.com"], repoDir);
101
+ fs.writeFileSync(path.join(repoDir, "README.md"), "branch context integration\n", "utf8");
102
+ git(["add", "README.md"], repoDir);
103
+ git(["commit", "-m", "init"], repoDir);
104
+
105
+ const parent = SessionManager.create(repoDir, sessionsDir);
106
+ parent.appendModelChange("openai-codex", "gpt-5.4");
107
+ parent.appendThinkingLevelChange("minimal");
108
+ parent.appendMessage({
109
+ role: "user",
110
+ content: [{ type: "text", text: "Summarize the repo history." }],
111
+ timestamp: Date.now(),
112
+ });
113
+ const stableAssistantId = parent.appendMessage({
114
+ role: "assistant",
115
+ content: [{ type: "text", text: "The repo history is summarized." }],
116
+ api: "test",
117
+ provider: "test",
118
+ model: "test",
119
+ usage: {
120
+ input: 0,
121
+ output: 0,
122
+ cacheRead: 0,
123
+ cacheWrite: 0,
124
+ totalTokens: 0,
125
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
126
+ },
127
+ stopReason: "stop",
128
+ timestamp: Date.now(),
129
+ });
130
+ const compactionId = parent.appendCompaction("summarized", stableAssistantId, 1234);
131
+ const currentUserId = parent.appendMessage({
132
+ role: "user",
133
+ content: [{ type: "text", text: "Investigate the repo, then delegate part of it." }],
134
+ timestamp: Date.now(),
135
+ });
136
+ const toolUseAssistant: AssistantMessage = {
137
+ role: "assistant",
138
+ content: [{ type: "toolCall", id: "call-1", name: "read", arguments: { path: "README.md" } }],
139
+ api: "test",
140
+ provider: "test",
141
+ model: "test",
142
+ usage: {
143
+ input: 0,
144
+ output: 0,
145
+ cacheRead: 0,
146
+ cacheWrite: 0,
147
+ totalTokens: 0,
148
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
149
+ },
150
+ stopReason: "toolUse",
151
+ timestamp: Date.now(),
152
+ };
153
+ parent.appendMessage(toolUseAssistant);
154
+ parent.appendMessage({
155
+ role: "toolResult",
156
+ toolCallId: "call-1",
157
+ toolName: "read",
158
+ content: [{ type: "text", text: "branch context integration" }],
159
+ isError: false,
160
+ timestamp: Date.now(),
161
+ });
162
+
163
+ const parentLeafId = parent.getLeafId();
164
+ assert(parentLeafId !== null, "expected parent leaf id");
165
+ if (!parentLeafId) {
166
+ throw new Error("Missing parent leaf id");
167
+ }
168
+
169
+ const selection = resolveBranchLeafSelection(parent.getBranch(parentLeafId), parentLeafId);
170
+ assert(selection.adjusted, "expected unfinished turn branch selection to adjust away from active leaf");
171
+ assert(selection.leafId === compactionId, `expected branch selection to use the stable pre-user boundary id, got ${selection.leafId}`);
172
+ assert(branchSelectionNote(selection) === "branch(clean-turn)", "expected clean-turn branch note");
173
+ assert(selection.replayUserMessage?.role === "user", "expected the active user request to be replayed into the cleaned child branch");
174
+
175
+ const branchedSessionFile = parent.createBranchedSession(selection.leafId);
176
+ assert(Boolean(branchedSessionFile), "expected branched session file to be created");
177
+ if (!branchedSessionFile) {
178
+ throw new Error("Missing branched session file");
179
+ }
180
+ if (selection.replayUserMessage) {
181
+ parent.appendMessage(JSON.parse(JSON.stringify(selection.replayUserMessage)) as Parameters<typeof parent.appendMessage>[0]);
182
+ }
183
+
184
+ const childEntries = parent.getEntries();
185
+ assert(childEntries.some((entry) => entry.id === stableAssistantId), "child session should retain the latest completed assistant message");
186
+ assert(childEntries.some((entry) => entry.id === compactionId), "child session should retain the compaction entry before the active user");
187
+ assert(!childEntries.some((entry) => entry.id === currentUserId), "child session should drop the original unfinished-turn user entry");
188
+ assert(
189
+ childEntries.some(
190
+ (entry) =>
191
+ entry.type === "message" &&
192
+ typeof entry.message === "object" &&
193
+ entry.message !== null &&
194
+ (entry.message as { role?: string }).role === "user" &&
195
+ JSON.stringify((entry.message as { content?: unknown }).content).includes("Investigate the repo, then delegate part of it."),
196
+ ),
197
+ "child session should replay the active user request onto the cleaned branch",
198
+ );
199
+ assert(
200
+ !childEntries.some(
201
+ (entry) =>
202
+ entry.type === "message" &&
203
+ typeof entry.message === "object" &&
204
+ entry.message !== null &&
205
+ (entry.message as { role?: string }).role === "assistant" &&
206
+ entry.id !== stableAssistantId,
207
+ ),
208
+ "child session should exclude the in-progress assistant tool-use message",
209
+ );
210
+ assert(
211
+ !childEntries.some(
212
+ (entry) => entry.type === "message" && typeof entry.message === "object" && entry.message !== null && (entry.message as { role?: string }).role === "toolResult",
213
+ ),
214
+ "child session should exclude trailing tool results from the unfinished turn",
215
+ );
216
+
217
+ await ensureTeamConfig(teamDir, { teamId, taskListId, leadName, style: "normal" });
218
+
219
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
220
+ const repoRoot = path.resolve(scriptDir, "..");
221
+ const entryPath = path.join(repoRoot, "extensions", "teams", "index.ts");
222
+ assert(fs.existsSync(entryPath), `Missing teams entry path: ${entryPath}`);
223
+
224
+ const worker = spawn(
225
+ "pi",
226
+ [
227
+ "--mode",
228
+ "rpc",
229
+ "--session",
230
+ branchedSessionFile,
231
+ "--session-dir",
232
+ sessionsDir,
233
+ "--provider",
234
+ "openai-codex",
235
+ "--model",
236
+ "gpt-5.4",
237
+ "--thinking",
238
+ "minimal",
239
+ "--no-extensions",
240
+ "-e",
241
+ entryPath,
242
+ "--append-system-prompt",
243
+ `You are teammate '${agentName}'. Prefer working from the shared task list.`,
244
+ ],
245
+ {
246
+ cwd: repoDir,
247
+ env: {
248
+ ...process.env,
249
+ PI_TEAMS_ROOT_DIR: teamsRootDir,
250
+ PI_TEAMS_WORKER: "1",
251
+ PI_TEAMS_TEAM_ID: teamId,
252
+ PI_TEAMS_TASK_LIST_ID: taskListId,
253
+ PI_TEAMS_AGENT_NAME: agentName,
254
+ PI_TEAMS_LEAD_NAME: leadName,
255
+ PI_TEAMS_STYLE: "normal",
256
+ PI_TEAMS_AUTO_CLAIM: "0",
257
+ },
258
+ stdio: ["pipe", "pipe", "pipe"],
259
+ },
260
+ );
261
+ procs.push(worker);
262
+
263
+ await waitFor(
264
+ async () => {
265
+ const member = await latestMemberStatus(teamDir, agentName);
266
+ return member?.status === "online";
267
+ },
268
+ { timeoutMs: timeoutSec * 1000, pollMs: 250, label: `${agentName} online` },
269
+ );
270
+
271
+ const task = await createTask(teamDir, taskListId, {
272
+ subject: "Branch context integration",
273
+ description: "Reply with exactly 'branch context integration ok'. Do not edit files.",
274
+ owner: agentName,
275
+ });
276
+ await writeToMailbox(teamDir, taskListId, agentName, {
277
+ from: leadName,
278
+ text: JSON.stringify(taskAssignmentPayload(task, leadName)),
279
+ timestamp: new Date().toISOString(),
280
+ });
281
+
282
+ await waitFor(
283
+ async () => {
284
+ const current = await getTask(teamDir, taskListId, task.id);
285
+ const result = current?.metadata?.result;
286
+ return current?.status === "completed" && typeof result === "string" && result.includes("branch context integration ok");
287
+ },
288
+ { timeoutMs: timeoutSec * 1000, pollMs: 500, label: `task #${task.id} completion` },
289
+ );
290
+
291
+ console.log("PASS: branch context integration test passed");
292
+ } finally {
293
+ await terminateAll(procs);
294
+ try {
295
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
296
+ } catch {
297
+ // ignore
298
+ }
299
+ }
@@ -69,8 +69,9 @@ import {
69
69
  isPlanApprovedMessage,
70
70
  isPlanRejectedMessage,
71
71
  } from "../extensions/teams/protocol.js";
72
- import { pollLeaderInbox } from "../extensions/teams/leader-inbox.js";
72
+ import { DelegationTracker, pollLeaderInbox } from "../extensions/teams/leader-inbox.js";
73
73
  import { getParentSessionId, shouldSilenceInheritedParentAttachClaimWarning } from "../extensions/teams/session-parent.js";
74
+ import { branchSelectionNote, ensureSessionFileMaterialized, resolveBranchLeafSelection } from "../extensions/teams/session-branching.js";
74
75
  import { SessionManager, type ExtensionContext } from "@mariozechner/pi-coding-agent";
75
76
  import type { AssistantMessage } from "@mariozechner/pi-ai";
76
77
 
@@ -906,6 +907,200 @@ console.log("\n10b. branched sessions + inherited attach claims");
906
907
  );
907
908
  }
908
909
  }
910
+
911
+ const branchFromUser = SessionManager.create(tmpRoot, sessionsDir);
912
+ branchFromUser.appendModelChange("openai-codex", "gpt-5.4");
913
+ branchFromUser.appendThinkingLevelChange("minimal");
914
+ branchFromUser.appendMessage({
915
+ role: "user",
916
+ content: [{ type: "text", text: "What should we do next?" }],
917
+ timestamp: Date.now(),
918
+ });
919
+ const stableAssistantId = branchFromUser.appendMessage(assistantMessage);
920
+ const currentUserId = branchFromUser.appendMessage({
921
+ role: "user",
922
+ content: [{ type: "text", text: "Investigate something, then delegate it." }],
923
+ timestamp: Date.now(),
924
+ });
925
+ const activeTurnToolUse: AssistantMessage = {
926
+ role: "assistant",
927
+ content: [{ type: "toolCall", id: "call-1", name: "read", arguments: { path: "README.md" } }],
928
+ api: "test",
929
+ provider: "test",
930
+ model: "test",
931
+ usage: {
932
+ input: 0,
933
+ output: 0,
934
+ cacheRead: 0,
935
+ cacheWrite: 0,
936
+ totalTokens: 0,
937
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
938
+ },
939
+ stopReason: "toolUse",
940
+ timestamp: Date.now(),
941
+ };
942
+ branchFromUser.appendMessage(activeTurnToolUse);
943
+ branchFromUser.appendMessage({
944
+ role: "toolResult",
945
+ toolCallId: "call-1",
946
+ toolName: "read",
947
+ content: [{ type: "text", text: "README contents" }],
948
+ isError: false,
949
+ timestamp: Date.now(),
950
+ });
951
+ const unfinishedLeafId = branchFromUser.getLeafId();
952
+ assert(unfinishedLeafId !== null, "unfinished branch test leaf exists");
953
+ if (unfinishedLeafId) {
954
+ const selection = resolveBranchLeafSelection(branchFromUser.getBranch(unfinishedLeafId), unfinishedLeafId);
955
+ assert(selection.adjusted, "unfinished turn adjusts branch leaf away from active leaf");
956
+ assertEq(selection.leafId, stableAssistantId, "unfinished turn branches from latest completed assistant message");
957
+ assertEq(branchSelectionNote(selection), "branch(clean-turn)", "unfinished turn note marks clean-turn branch");
958
+ assert(
959
+ selection.replayUserMessage?.role === "user",
960
+ "unfinished turn keeps the active user request available for replay into the child branch",
961
+ );
962
+
963
+ const branchedPath = branchFromUser.createBranchedSession(selection.leafId);
964
+ assert(branchedPath !== null, "clean-turn branch session created");
965
+ if (selection.replayUserMessage) {
966
+ branchFromUser.appendMessage(JSON.parse(JSON.stringify(selection.replayUserMessage)) as Parameters<typeof branchFromUser.appendMessage>[0]);
967
+ }
968
+ if (branchedPath) await ensureSessionFileMaterialized(branchFromUser, branchedPath);
969
+ const childEntries = branchFromUser.getEntries();
970
+ assert(childEntries.some((entry) => entry.id === stableAssistantId), "clean-turn child keeps latest completed assistant");
971
+ assert(
972
+ childEntries.some(
973
+ (entry) =>
974
+ entry.type === "message" &&
975
+ isRecord(entry.message) &&
976
+ entry.message.role === "user" &&
977
+ JSON.stringify(entry.message.content).includes("Investigate something, then delegate it."),
978
+ ),
979
+ "clean-turn child replays the active user request onto the cleaned branch",
980
+ );
981
+ assert(
982
+ childEntries.filter((entry) => entry.id === currentUserId).length === 0,
983
+ "clean-turn child does not keep the original unfinished-turn user entry id",
984
+ );
985
+ assert(
986
+ !childEntries.some((entry) => entry.type === "message" && isRecord(entry.message) && entry.message.role === "toolResult"),
987
+ "clean-turn child excludes trailing tool results from active turn",
988
+ );
989
+ assert(
990
+ !childEntries.some(
991
+ (entry) =>
992
+ entry.type === "message" &&
993
+ isRecord(entry.message) &&
994
+ entry.message.role === "assistant" &&
995
+ entry.id !== stableAssistantId,
996
+ ),
997
+ "clean-turn child excludes in-progress assistant tool-use turn",
998
+ );
999
+ }
1000
+
1001
+ const compactedTurn = SessionManager.create(tmpRoot, sessionsDir);
1002
+ compactedTurn.appendModelChange("openai-codex", "gpt-5.4");
1003
+ compactedTurn.appendThinkingLevelChange("minimal");
1004
+ compactedTurn.appendMessage({
1005
+ role: "user",
1006
+ content: [{ type: "text", text: "Earlier request" }],
1007
+ timestamp: Date.now(),
1008
+ });
1009
+ const compactedAssistantId = compactedTurn.appendMessage(assistantMessage);
1010
+ const compactionId = compactedTurn.appendCompaction("summarized", compactedAssistantId, 1234);
1011
+ compactedTurn.appendMessage({
1012
+ role: "user",
1013
+ content: [{ type: "text", text: "Current request after compaction" }],
1014
+ timestamp: Date.now(),
1015
+ });
1016
+ compactedTurn.appendMessage(activeTurnToolUse);
1017
+ compactedTurn.appendMessage({
1018
+ role: "toolResult",
1019
+ toolCallId: "call-1",
1020
+ toolName: "read",
1021
+ content: [{ type: "text", text: "README contents" }],
1022
+ isError: false,
1023
+ timestamp: Date.now(),
1024
+ });
1025
+ const compactedLeafId = compactedTurn.getLeafId();
1026
+ assert(compactedLeafId !== null, "compacted branch test leaf exists");
1027
+ if (compactedLeafId) {
1028
+ const selection = resolveBranchLeafSelection(compactedTurn.getBranch(compactedLeafId), compactedLeafId);
1029
+ assert(selection.adjusted, "compacted unfinished turn adjusts branch leaf");
1030
+ assertEq(selection.leafId, compactionId, "compacted unfinished turn branches from the entry immediately before the active user");
1031
+ assert(selection.replayUserMessage?.role === "user", "compacted unfinished turn replays the active user request");
1032
+ const branchedPath = compactedTurn.createBranchedSession(selection.leafId);
1033
+ assert(branchedPath !== null, "compacted branch session created");
1034
+ if (selection.replayUserMessage) {
1035
+ compactedTurn.appendMessage(JSON.parse(JSON.stringify(selection.replayUserMessage)) as Parameters<typeof compactedTurn.appendMessage>[0]);
1036
+ }
1037
+ const childEntries = compactedTurn.getEntries();
1038
+ assert(childEntries.some((entry) => entry.id === compactionId), "compacted child keeps the compaction entry before the active user");
1039
+ assert(
1040
+ childEntries.some(
1041
+ (entry) =>
1042
+ entry.type === "message" &&
1043
+ isRecord(entry.message) &&
1044
+ entry.message.role === "user" &&
1045
+ JSON.stringify(entry.message.content).includes("Current request after compaction"),
1046
+ ),
1047
+ "compacted child replays the active user after the preserved compaction boundary",
1048
+ );
1049
+ assertEq(branchSelectionNote(selection), "branch(clean-turn)", "compacted unfinished turn keeps clean-turn note");
1050
+ }
1051
+
1052
+ const userOnlyTurn = SessionManager.create(tmpRoot, sessionsDir);
1053
+ userOnlyTurn.appendModelChange("openai-codex", "gpt-5.4");
1054
+ userOnlyTurn.appendThinkingLevelChange("minimal");
1055
+ userOnlyTurn.appendMessage({
1056
+ role: "user",
1057
+ content: [{ type: "text", text: "Only user context so far" }],
1058
+ timestamp: Date.now(),
1059
+ });
1060
+ userOnlyTurn.appendMessage(activeTurnToolUse);
1061
+ userOnlyTurn.appendMessage({
1062
+ role: "toolResult",
1063
+ toolCallId: "call-1",
1064
+ toolName: "read",
1065
+ content: [{ type: "text", text: "README contents" }],
1066
+ isError: false,
1067
+ timestamp: Date.now(),
1068
+ });
1069
+ const userOnlyLeafId = userOnlyTurn.getLeafId();
1070
+ assert(userOnlyLeafId !== null, "user-only fallback test leaf exists");
1071
+ if (userOnlyLeafId) {
1072
+ const selection = resolveBranchLeafSelection(userOnlyTurn.getBranch(userOnlyLeafId), userOnlyLeafId);
1073
+ assert(selection.adjusted, "user-only unfinished turn still adjusts branch leaf");
1074
+ assert(selection.leafId !== userOnlyLeafId, "user-only fallback rewinds away from the active unfinished leaf");
1075
+ assert(selection.replayUserMessage?.role === "user", "user-only fallback keeps the active user message for replay");
1076
+ assertEq(branchSelectionNote(selection), "branch(clean-turn)", "user-only fallback keeps the clean-turn note");
1077
+ const branchedPath = userOnlyTurn.createBranchedSession(selection.leafId);
1078
+ assert(branchedPath !== null, "user-only fallback branch session created");
1079
+ if (selection.replayUserMessage) {
1080
+ userOnlyTurn.appendMessage(JSON.parse(JSON.stringify(selection.replayUserMessage)) as Parameters<typeof userOnlyTurn.appendMessage>[0]);
1081
+ }
1082
+ if (branchedPath) {
1083
+ await ensureSessionFileMaterialized(userOnlyTurn, branchedPath);
1084
+ assert(fs.existsSync(branchedPath), "user-only fallback materializes a real session file");
1085
+ }
1086
+ }
1087
+
1088
+ const completedTurn = SessionManager.create(tmpRoot, sessionsDir);
1089
+ completedTurn.appendMessage({
1090
+ role: "user",
1091
+ content: [{ type: "text", text: "Done already" }],
1092
+ timestamp: Date.now(),
1093
+ });
1094
+ const completedAssistantId = completedTurn.appendMessage(assistantMessage);
1095
+ const completedLeafId = completedTurn.getLeafId();
1096
+ assert(completedLeafId !== null, "completed branch test leaf exists");
1097
+ if (completedLeafId) {
1098
+ const selection = resolveBranchLeafSelection(completedTurn.getBranch(completedLeafId), completedLeafId);
1099
+ assert(!selection.adjusted, "completed turn keeps requested leaf");
1100
+ assertEq(selection.leafId, completedLeafId, "completed turn branches from current leaf");
1101
+ assertEq(completedAssistantId, completedLeafId, "completed leaf stays on assistant reply");
1102
+ assertEq(branchSelectionNote(selection), "branch", "completed turn keeps plain branch note");
1103
+ }
909
1104
  }
910
1105
 
911
1106
  // ── 11. /team done (end-of-run cleanup) ──────────────────────────────
@@ -1114,6 +1309,7 @@ console.log("\n14. leader-inbox LLM message injection");
1114
1309
  cwd: inboxTeamDir,
1115
1310
  ui: { notify: () => {} },
1116
1311
  sessionManager: { getSessionId: () => "inbox-team" },
1312
+ isIdle: () => false,
1117
1313
  } as unknown as ExtensionContext;
1118
1314
 
1119
1315
  await pollLeaderInbox({
@@ -1220,7 +1416,8 @@ console.log("\n14. leader-inbox LLM message injection");
1220
1416
  leadName,
1221
1417
  style,
1222
1418
  pendingPlanApprovals: new Map(),
1223
- enqueueHook: () => {}, // hooks present → should qualify allDone
1419
+ enqueueHook: () => {},
1420
+ hooksEnabled: true,
1224
1421
  sendLeaderLlmMessage: (content, options) => {
1225
1422
  llmMessages.push({ content, options });
1226
1423
  },
@@ -1232,6 +1429,88 @@ console.log("\n14. leader-inbox LLM message injection");
1232
1429
  assert(hookMsg.content.includes("quality gates are still running"), "allDone qualified when hooks active");
1233
1430
  assert(!hookMsg.content.includes("Review results and determine next steps"), "no premature wrap-up prompt when hooks active");
1234
1431
  }
1432
+
1433
+ // Hooks disabled should not qualify all-done messages just because a callback is wired.
1434
+ const t4 = await createTask(inboxTeamDir, inboxTaskListId, { subject: "Post-review cleanup", description: "", owner: "dave" });
1435
+ await completeTask(inboxTeamDir, inboxTaskListId, t4.id, "dave", "Cleanup complete");
1436
+ const ts4 = new Date().toISOString();
1437
+ await writeToMailbox(inboxTeamDir, TEAM_MAILBOX_NS, leadName, {
1438
+ from: "dave",
1439
+ text: JSON.stringify({
1440
+ type: "idle_notification",
1441
+ from: "dave",
1442
+ timestamp: ts4,
1443
+ completedTaskId: t4.id,
1444
+ completedStatus: "completed",
1445
+ }),
1446
+ timestamp: ts4,
1447
+ });
1448
+
1449
+ llmMessages.length = 0;
1450
+ await pollLeaderInbox({
1451
+ ctx: stubCtx,
1452
+ teamId: "inbox-team",
1453
+ teamDir: inboxTeamDir,
1454
+ taskListId: inboxTaskListId,
1455
+ leadName,
1456
+ style,
1457
+ pendingPlanApprovals: new Map(),
1458
+ enqueueHook: () => {},
1459
+ hooksEnabled: false,
1460
+ sendLeaderLlmMessage: (content, options) => {
1461
+ llmMessages.push({ content, options });
1462
+ },
1463
+ });
1464
+
1465
+ assert(llmMessages.length === 1, "one LLM message sent when hooks callback is wired but disabled");
1466
+ const disabledHookMsg = llmMessages[0];
1467
+ if (disabledHookMsg) {
1468
+ assert(!disabledHookMsg.content.includes("quality gates are still running"), "disabled hooks do not qualify the per-task allDone summary");
1469
+ assert(disabledHookMsg.content.includes("Review results and determine next steps"), "disabled hooks keep the normal per-task allDone summary");
1470
+ }
1471
+
1472
+ // Batch-complete auto-wake should use the same hooks-enabled check.
1473
+ const t5 = await createTask(inboxTeamDir, inboxTaskListId, { subject: "Batch wake task", description: "", owner: "erin" });
1474
+ await completeTask(inboxTeamDir, inboxTaskListId, t5.id, "erin", "Batch wake done");
1475
+ const ts5 = new Date().toISOString();
1476
+ await writeToMailbox(inboxTeamDir, TEAM_MAILBOX_NS, leadName, {
1477
+ from: "erin",
1478
+ text: JSON.stringify({
1479
+ type: "idle_notification",
1480
+ from: "erin",
1481
+ timestamp: ts5,
1482
+ completedTaskId: t5.id,
1483
+ completedStatus: "completed",
1484
+ }),
1485
+ timestamp: ts5,
1486
+ });
1487
+
1488
+ const batchTracker = new DelegationTracker();
1489
+ batchTracker.addBatch([t5.id]);
1490
+ llmMessages.length = 0;
1491
+ await pollLeaderInbox({
1492
+ ctx: stubCtx,
1493
+ teamId: "inbox-team",
1494
+ teamDir: inboxTeamDir,
1495
+ taskListId: inboxTaskListId,
1496
+ leadName,
1497
+ style,
1498
+ pendingPlanApprovals: new Map(),
1499
+ enqueueHook: () => {},
1500
+ hooksEnabled: false,
1501
+ delegationTracker: batchTracker,
1502
+ sendLeaderLlmMessage: (content, options) => {
1503
+ llmMessages.push({ content, options });
1504
+ },
1505
+ });
1506
+
1507
+ assert(llmMessages.length === 2, "per-task completion plus batch-complete messages sent when a tracked delegation finishes");
1508
+ const batchMsg = llmMessages.find((entry) => entry.content.includes("All delegated tasks completed"));
1509
+ assert(batchMsg !== undefined, "batch-complete notification sent");
1510
+ if (batchMsg) {
1511
+ assert(!batchMsg.content.includes("Quality gates are still running"), "disabled hooks do not qualify the batch-complete summary");
1512
+ assert(batchMsg.content.includes("Review the results and continue."), "disabled hooks keep the normal batch-complete summary");
1513
+ }
1235
1514
  }
1236
1515
 
1237
1516
  // ── 15. docs/help drift guard ────────────────────────────────────────