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/
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
cmd/entire/cli/agent/copilotcli
Mlifecycle.go+27
- Mlifecycle_test.go+47
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()