@tmustier/pi-agent-teams 0.5.3 → 0.5.5

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.
@@ -69,10 +69,11 @@ 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 { SessionManager, type ExtensionContext } from "@mariozechner/pi-coding-agent";
75
- import type { AssistantMessage } from "@mariozechner/pi-ai";
74
+ import { branchSelectionNote, ensureSessionFileMaterialized, resolveBranchLeafSelection } from "../extensions/teams/session-branching.js";
75
+ import { SessionManager, type ExtensionContext } from "@earendil-works/pi-coding-agent";
76
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
76
77
 
77
78
  // ── helpers ──────────────────────────────────────────────────────────
78
79
  let passed = 0;
@@ -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 ────────────────────────────────────────