copilot: don't create phantom sessions for subagent turns · Entire

copilot: don't create phantom sessions for subagent turns

Copilot fires the per-turn/session lifecycle hooks for subagent turns too, reusing the Task tool-use id (e.g. "toolu_…") as the sessionId. That spun up a second top-level Entire session which never received a matching stop (subagent-stop carries the main session id), so it stayed "active" forever and pinned its shadow branch open. After a user commit, the branch was preserved instead of cleaned up — TestSubagentCommitFlow then failed on WaitForNoShadowBranches with a leaked entire/ branch.

Drop the session-lifecycle hooks (user-prompt-submitted, session-start, agent-stop, session-end) when the sessionId is a subagent tool-use id; real Copilot session ids are UUIDs so the "toolu_" prefix is an unambiguous marker. subagent-stop is left untouched (it carries the main id and drives the task-checkpoint path), so the subagent's work is still captured via the main session.

Sessions

Changes

3 unmodified lines  
4  
5  
6  
7  
8  
9  
10  
11  
12  
13  
14  
15  
16  
17  
18  
19  
20  
21  
22  
23  
24  
25  
26  
27  
53 unmodified lines  
81  
82  
83  
84  
85  
86  
87  
88  
89  
90  
91  
92  
93  
94  
95  
96  
97  
98  
99  
100

3 unmodified lines

"context"  
    "fmt"  
    "io"  
    "strings"

"github.com/entireio/cli/cmd/entire/cli/agent"  
    "github.com/entireio/cli/cmd/entire/cli/logging"

// subagentSessionIDPrefix is the prefix Copilot uses when it reuses a Task  
// tool-use id as the sessionId on lifecycle hooks fired for a subagent turn.  
// Real Copilot session ids are UUIDs, so this prefix unambiguously marks a  
// subagent context (e.g. "toolu_bdrk_01K…" on Bedrock-backed models).  
const subagentSessionIDPrefix = "toolu_"

// isSubagentSessionID reports whether a Copilot sessionId is actually a  
// subagent's tool-use id rather than a real interactive session id.  
func isSubagentSessionID(sessionID string) bool {  
    return strings.HasPrefix(sessionID, subagentSessionIDPrefix)  
}

// Ensure CopilotCLIAgent implements HookSupport at compile time.  
var _ agent.HookSupport = (*CopilotCLIAgent)(nil)

53 unmodified lines

}  
}

// Copilot fires the per-turn/session lifecycle hooks for subagent turns too,  
// using the subagent's Task tool-use id (e.g. "toolu_…") as the sessionId.  
// Those must NOT spin up a top-level Entire session: the subagent never gets  
// a matching stop for that id, so the phantom session would stay "active"  
// forever and pin its shadow branch open after the user commits. The  
// subagent's work is still captured via the main session's subagentStop →  
// task checkpoint path, so we drop only the session-lifecycle hooks here and  
// leave subagentStop itself to run.
if hookName != HookNameSubagentStop && isSubagentSessionID(env.SessionID) {  
    logging.Debug(ctx, "copilot-cli: skipping lifecycle event for subagent session",  
        "sessionID", env.SessionID, "hook", hookName)  
    return nil, nil //nolint:nilnil // Subagent lifecycle hook — no top-level session action.  
}

switch hookName {  
case HookNameUserPromptSubmitted:  
    return c.buildUserPromptSubmitted(ctx, env), nil
}

func TestParseHookEvent_SubagentSession_LifecycleHooksReturnNil(t *testing.T) {
t.Parallel()

const subagentSessionID = "toolu_bdrk_01KTyZvJLaUtkjgvdA355rMX"
ag := &CopilotCLIAgent{}

lifecycleHooks := []string{
HookNameUserPromptSubmitted,
HookNameSessionStart,
HookNameAgentStop,
HookNameSessionEnd,
}

for _, hookName := range lifecycleHooks {
t.Run(hookName, func(t *testing.T) {
t.Parallel()
input := {"timestamp":1771480081360,"cwd":"/path/to/repo","sessionId":" + subagentSessionID + ","prompt":"hi"}

event, err := ag.ParseHookEvent(context.Background(), hookName, strings.NewReader(input))

require.NoError(t, err)
require.Nil(t, event, "expected nil event for subagent-session lifecycle hook %s", hookName)
})
}
}

// TestParseHookEvent_SubagentSession_SubagentStopStillFires verifies the
// subagent-stop hook is NOT dropped — it always carries the main session id and
// drives the task-checkpoint path. func TestParseHookEvent_SubagentSession_SubagentStopStillFires(t *testing.T) {
t.Parallel()

ag := &CopilotCLIAgent{}
input := {"timestamp":1771480085412,"cwd":"/path/to/repo","sessionId":" + testSessionID + "}

event, err := ag.ParseHookEvent(context.Background(), HookNameSubagentStop, strings.NewReader(input))

require.NoError(t, err)
require.NotNil(t, event, "subagent-stop must still produce an event")
require.Equal(t, agent.SubagentEnd, event.Type)
}

func TestParseHookEvent_PassthroughHooks_ReturnNil(t *testing.T) {
t.Parallel()