Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 4 additions & 2 deletions extensions/teams/leader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
import { handleTeamCommand } from "./leader-team-command.js";
import { registerTeamsTool } from "./leader-teams-tool.js";
import { getParentSessionId, shouldSilenceInheritedParentAttachClaimWarning } from "./session-parent.js";
import { branchSelectionNote, resolveBranchLeafSelection } from "./session-branching.js";
import type { ContextMode, SpawnTeammateFn, SpawnTeammateResult, WorkspaceMode } from "./spawn-types.js";

function getTeamsExtensionEntryPath(): string | null {
Expand Down Expand Up @@ -90,12 +91,13 @@ async function createSessionForTeammate(

try {
const sm = SessionManager.open(parentSessionFile, teamSessionsDir);
const branched = sm.createBranchedSession(leafId);
const selection = resolveBranchLeafSelection(sm.getBranch(leafId), leafId);
const branched = sm.createBranchedSession(selection.leafId);
if (!branched) {
const fallback = SessionManager.create(ctx.cwd, teamSessionsDir);
return { sessionFile: fallback.getSessionFile(), note: "branch(failed->fresh)", warnings };
}
return { sessionFile: branched, note: "branch", warnings };
return { sessionFile: branched, note: branchSelectionNote(selection), warnings };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (/Entry .* not found/i.test(msg)) {
Expand Down
94 changes: 94 additions & 0 deletions extensions/teams/session-branching.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { SessionEntry } from "@mariozechner/pi-coding-agent";

export type BranchLeafSelection = {
leafId: string;
adjusted: boolean;
reason: "requested" | "clean-turn-assistant" | "clean-turn-user";
};

type MessageLike = Record<string, unknown> & { role: string };

type MessageEntryLike = SessionEntry & {
type: "message";
message: MessageLike;
};

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

function isMessageEntry(entry: SessionEntry): entry is MessageEntryLike {
if (entry.type !== "message") return false;
return isRecord(entry.message) && typeof entry.message.role === "string";
}

function isUserMessageEntry(entry: SessionEntry): entry is MessageEntryLike & { message: MessageLike & { role: "user" } } {
return isMessageEntry(entry) && entry.message.role === "user";
}

function isAssistantToolUseEntry(entry: SessionEntry): entry is MessageEntryLike & { message: MessageLike & { role: "assistant"; stopReason: "toolUse" } } {
return (
isMessageEntry(entry) &&
entry.message.role === "assistant" &&
typeof entry.message.stopReason === "string" &&
entry.message.stopReason === "toolUse"
);
}

function isStableAssistantEntry(entry: SessionEntry): entry is MessageEntryLike & { message: MessageLike & { role: "assistant" } } {
return (
isMessageEntry(entry) &&
entry.message.role === "assistant" &&
typeof entry.message.stopReason === "string" &&
entry.message.stopReason !== "toolUse"
);
}

/**
* When the leader is mid-turn, the current leaf may point into an unfinished
* assistant/tool-use path. Branching from that leaf causes workers to inherit
* the leader's in-progress turn instead of a clean conversation context.
*
* In that case, branch from the last stable turn boundary instead:
* - Prefer the latest completed assistant message (persists as a real branched file)
* - Otherwise fall back to the latest user message
*/
export function resolveBranchLeafSelection(path: SessionEntry[], requestedLeafId: string): BranchLeafSelection {
const lastAssistantIndex = [...path].map((entry, index) => ({ entry, index }))
.reverse()
.find(({ entry }) => isMessageEntry(entry) && entry.message.role === "assistant")?.index;

if (lastAssistantIndex === undefined) {
return { leafId: requestedLeafId, adjusted: false, reason: "requested" };
}

const lastAssistant = path[lastAssistantIndex];
if (!lastAssistant || !isAssistantToolUseEntry(lastAssistant)) {
return { leafId: requestedLeafId, adjusted: false, reason: "requested" };
}

for (let i = lastAssistantIndex - 1; i >= 0; i -= 1) {
const candidate = path[i];
if (!candidate) continue;
if (isStableAssistantEntry(candidate)) {
return { leafId: candidate.id, adjusted: candidate.id !== requestedLeafId, reason: "clean-turn-assistant" };
}
}

for (let i = lastAssistantIndex - 1; i >= 0; i -= 1) {
const candidate = path[i];
if (!candidate) continue;
if (isUserMessageEntry(candidate)) {
return { leafId: candidate.id, adjusted: candidate.id !== requestedLeafId, reason: "clean-turn-user" };
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep branch leaf on persisted assistant path

Returning clean-turn-user here can select a user-only branch when the leader is in its first tool-use turn (no earlier completed assistant). In that case createSessionForTeammate() passes this user leaf to SessionManager.createBranchedSession(...), and the pi-coding-agent implementation does not persist a branched session file unless the branch contains an assistant message; the spawned worker then starts from a fresh/empty session instead of the intended branch context. This regresses branch mode specifically for first-turn delegation with in-flight tool use.

Useful? React with 👍 / 👎.

}
}

return { leafId: requestedLeafId, adjusted: false, reason: "requested" };
}

export function branchSelectionNote(selection: BranchLeafSelection): string {
if (!selection.adjusted) return "branch";
if (selection.reason === "clean-turn-assistant") return "branch(clean-turn)";
if (selection.reason === "clean-turn-user") return "branch(clean-turn:user-fallback)";
return "branch";
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@
"integration-spawn-overrides-test": "tsx scripts/integration-spawn-overrides-test.mts",
"integration-hooks-remediation-test": "tsx scripts/integration-hooks-remediation-test.mts",
"integration-todo-test": "tsx scripts/integration-todo-test.mts",
"integration-cleanup-test": "tsx scripts/integration-cleanup-test.mts"
"integration-cleanup-test": "tsx scripts/integration-cleanup-test.mts",
"integration-branch-context-test": "tsx scripts/integration-branch-context-test.mts"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
Expand Down
Loading
Loading