Skip to content

Commit ae4bf07

Browse files
Sophclaude
andcommitted
fix(copilot): address PR review of VS Code hook capture
- Register both agent-stop AND session-end under VS Code's single "Stop" event. validateVSCodeEvent routes terminal Stop payloads to session-end and rejects them for agent-stop, so registering only agent-stop dropped SessionEnd for VS Code-driven sessions. The non-matching handler no-ops. - Include VS Code hook additions in the count InstallHooks returns, so a fresh entire-vscode.json write is reported as an install rather than "already installed". - On uninstall, preserve unknown top-level fields: only delete entire-vscode.json when no hooks remain AND no user-added top-level keys exist, instead of deleting unconditionally. Updates tests for the two Stop handlers and adds top-level-field preservation coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Entire-Checkpoint: a6b05de34ff9
1 parent 0383cb3 commit ae4bf07

4 files changed

Lines changed: 147 additions & 57 deletions

File tree

cmd/entire/cli/agent/copilotcli/hooks.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ var hookConfigKey = map[string]string{
4444
// InstallHooks installs Copilot CLI hooks in .github/hooks/entire.json and the
4545
// VS Code-native hook file .github/hooks/entire-vscode.json (see vscode_hooks.go).
4646
// If force is true, removes existing Entire hooks before installing.
47-
// Returns the number of Copilot CLI hooks installed.
47+
// Returns the total number of hooks installed across both files.
4848
// Unknown top-level fields and hook types are preserved on round-trip.
4949
func (c *CopilotCLIAgent) InstallHooks(ctx context.Context, localDev bool, force bool) (int, error) {
5050
worktreeRoot, err := paths.WorktreeRoot(ctx)
@@ -54,7 +54,10 @@ func (c *CopilotCLIAgent) InstallHooks(ctx context.Context, localDev bool, force
5454

5555
// Install the VS Code-native hook file alongside the Copilot CLI file so
5656
// Copilot sessions run from VS Code's agent hooks (Preview) are captured.
57-
if err := c.installVSCodeHooks(worktreeRoot, localDev, force); err != nil {
57+
// Its additions count toward the total so a fresh VS Code-file write is
58+
// reported as an install rather than "already installed".
59+
vsCodeCount, err := c.installVSCodeHooks(worktreeRoot, localDev, force)
60+
if err != nil {
5861
return 0, err
5962
}
6063

@@ -137,7 +140,8 @@ func (c *CopilotCLIAgent) InstallHooks(ctx context.Context, localDev bool, force
137140
}
138141

139142
if count == 0 {
140-
return 0, nil
143+
// No Copilot CLI changes, but the VS Code file may have been updated.
144+
return vsCodeCount, nil
141145
}
142146

143147
// Marshal modified hook types back into rawHooks
@@ -169,7 +173,7 @@ func (c *CopilotCLIAgent) InstallHooks(ctx context.Context, localDev bool, force
169173
return 0, fmt.Errorf("failed to write %s: %w", HooksFileName, err)
170174
}
171175

172-
return count, nil
176+
return count + vsCodeCount, nil
173177
}
174178

175179
// UninstallHooks removes Entire hooks from Copilot CLI's entire.json.

cmd/entire/cli/agent/copilotcli/hooks_test.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ func TestInstallHooks_FreshInstall(t *testing.T) {
2222
t.Fatalf("InstallHooks() error = %v", err)
2323
}
2424

25-
if count != 8 {
26-
t.Errorf("InstallHooks() count = %d, want 8", count)
25+
// 8 Copilot CLI hooks + 3 VS Code hooks (user-prompt-submitted, agent-stop, session-end).
26+
if count != 11 {
27+
t.Errorf("InstallHooks() count = %d, want 11", count)
2728
}
2829

2930
hooksFile := readHooksFile(t, tempDir)
@@ -87,8 +88,8 @@ func TestInstallHooks_Idempotent(t *testing.T) {
8788
if err != nil {
8889
t.Fatalf("first InstallHooks() error = %v", err)
8990
}
90-
if count1 != 8 {
91-
t.Errorf("first InstallHooks() count = %d, want 8", count1)
91+
if count1 != 11 {
92+
t.Errorf("first InstallHooks() count = %d, want 11", count1)
9293
}
9394

9495
// Second install
@@ -189,8 +190,8 @@ func TestInstallHooks_ForceReinstall(t *testing.T) {
189190
if err != nil {
190191
t.Fatalf("force InstallHooks() error = %v", err)
191192
}
192-
if count != 8 {
193-
t.Errorf("force InstallHooks() count = %d, want 8", count)
193+
if count != 11 {
194+
t.Errorf("force InstallHooks() count = %d, want 11", count)
194195
}
195196

196197
// Verify no duplicates
@@ -271,8 +272,8 @@ func TestInstallHooks_PreservesUnknownFields(t *testing.T) {
271272
if err != nil {
272273
t.Fatalf("InstallHooks() error = %v", err)
273274
}
274-
if count != 8 {
275-
t.Errorf("InstallHooks() count = %d, want 8", count)
275+
if count != 11 {
276+
t.Errorf("InstallHooks() count = %d, want 11", count)
276277
}
277278

278279
// Read the raw JSON to verify unknown fields are preserved

cmd/entire/cli/agent/copilotcli/vscode_hooks.go

Lines changed: 62 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,27 @@ const VSCodeHooksFileName = "entire-vscode.json"
1919
// vsCodeManagedEvents lists the VS Code events Entire registers in
2020
// entire-vscode.json, paired with the CLI hook verb each one invokes.
2121
//
22-
// Only the two turn hooks are registered here. VS Code reads Copilot CLI
23-
// configs by converting lowerCamelCase event names to PascalCase
24-
// (userPromptSubmitted -> UserPromptSubmitted, agentStop -> AgentStop). Those
25-
// converted names are not real VS Code events, so the capture-critical turn
26-
// hooks never fire from entire.json inside VS Code. The events whose converted
27-
// names line up with a real VS Code event (SessionStart, SubagentStop,
28-
// PreToolUse, PostToolUse) are already delivered by VS Code from entire.json,
29-
// so registering them here too would only double-fire them.
22+
// Only the turn hooks are registered here. VS Code reads Copilot CLI configs by
23+
// converting lowerCamelCase event names to PascalCase (userPromptSubmitted ->
24+
// UserPromptSubmitted, agentStop -> AgentStop). Those converted names are not
25+
// real VS Code events, so the capture-critical turn hooks never fire from
26+
// entire.json inside VS Code. The events whose converted names line up with a
27+
// real VS Code event (SessionStart, SubagentStop, PreToolUse, PostToolUse) are
28+
// already delivered by VS Code from entire.json, so registering them here too
29+
// would only double-fire them.
30+
//
31+
// VS Code uses a single "Stop" event for both end-of-turn and terminal
32+
// session-stop, where Copilot CLI distinguishes agent-stop from session-end.
33+
// We therefore register BOTH verbs under "Stop"; validateVSCodeEvent (compat.go)
34+
// routes each payload to the matching handler by reason, and the non-matching
35+
// handler no-ops. Omitting session-end here would drop terminal SessionEnd
36+
// events for VS Code-driven sessions.
3037
var vsCodeManagedEvents = []struct {
31-
Event string // VS Code hookEventName (PascalCase)
32-
Verb string // Entire CLI hook verb
38+
Event string // VS Code hookEventName (PascalCase)
39+
Verbs []string // Entire CLI hook verbs registered under that event
3340
}{
34-
{VSCodeEventUserPromptSubmit, HookNameUserPromptSubmitted},
35-
{VSCodeEventStop, HookNameAgentStop},
41+
{VSCodeEventUserPromptSubmit, []string{HookNameUserPromptSubmitted}},
42+
{VSCodeEventStop, []string{HookNameAgentStop, HookNameSessionEnd}},
3643
}
3744

3845
// VSCodeHookEntry represents a single VS Code hook command. VS Code uses the
@@ -58,12 +65,13 @@ func vsCodeHookCommand(verb string, localDev bool) string {
5865
// installVSCodeHooks writes/updates .github/hooks/entire-vscode.json with the
5966
// VS Code turn hooks. If force is true, existing Entire entries are removed
6067
// before installing. Unknown fields and event types are preserved on round-trip.
61-
func (c *CopilotCLIAgent) installVSCodeHooks(worktreeRoot string, localDev bool, force bool) error {
68+
// Returns the number of hook entries newly added.
69+
func (c *CopilotCLIAgent) installVSCodeHooks(worktreeRoot string, localDev bool, force bool) (int, error) {
6270
hooksPath := filepath.Join(worktreeRoot, hooksDir, VSCodeHooksFileName)
6371

6472
rawFile, rawHooks, err := readVSCodeHooksFile(hooksPath)
6573
if err != nil {
66-
return err
74+
return 0, err
6775
}
6876
if rawFile == nil {
6977
rawFile = map[string]json.RawMessage{"version": json.RawMessage(`1`)}
@@ -72,33 +80,45 @@ func (c *CopilotCLIAgent) installVSCodeHooks(worktreeRoot string, localDev bool,
7280
rawHooks = make(map[string]json.RawMessage)
7381
}
7482

83+
count := 0
7584
for _, h := range vsCodeManagedEvents {
7685
var entries []VSCodeHookEntry
7786
if err := parseVSCodeHookEvent(rawHooks, h.Event, &entries); err != nil {
78-
return err
87+
return 0, err
7988
}
8089
if force {
8190
entries = removeEntireVSCodeHooks(entries)
8291
}
83-
cmd := vsCodeHookCommand(h.Verb, localDev)
84-
if !vsCodeCommandExists(entries, cmd) {
85-
entries = append(entries, VSCodeHookEntry{
86-
Type: "command",
87-
Command: cmd,
88-
Comment: "Entire CLI",
89-
})
92+
// All verbs share one event, so remove-on-force happens once above; then
93+
// add each missing verb. Doing the force-removal per verb would wipe a
94+
// sibling verb added earlier in this same loop.
95+
for _, verb := range h.Verbs {
96+
cmd := vsCodeHookCommand(verb, localDev)
97+
if !vsCodeCommandExists(entries, cmd) {
98+
entries = append(entries, VSCodeHookEntry{
99+
Type: "command",
100+
Command: cmd,
101+
Comment: "Entire CLI",
102+
})
103+
count++
104+
}
90105
}
91106
if err := marshalVSCodeHookEvent(rawHooks, h.Event, entries); err != nil {
92-
return err
107+
return 0, err
93108
}
94109
}
95110

96-
return writeVSCodeHooksFile(hooksPath, rawFile, rawHooks)
111+
if err := writeVSCodeHooksFile(hooksPath, rawFile, rawHooks); err != nil {
112+
return 0, err
113+
}
114+
return count, nil
97115
}
98116

99-
// uninstallVSCodeHooks removes Entire entries from entire-vscode.json. When no
100-
// hooks of any event type remain afterwards (nothing user-owned), the file is
101-
// deleted rather than left as an empty shell.
117+
// uninstallVSCodeHooks removes Entire entries from entire-vscode.json. When the
118+
// file holds nothing user-owned afterwards (no hooks of any event type and no
119+
// top-level fields beyond the structural version/hooks keys Entire writes), it
120+
// is deleted rather than left as an empty shell. If the user added their own
121+
// hooks or top-level fields, those are preserved and the file is rewritten.
102122
func (c *CopilotCLIAgent) uninstallVSCodeHooks(worktreeRoot string) error {
103123
hooksPath := filepath.Join(worktreeRoot, hooksDir, VSCodeHooksFileName)
104124

@@ -121,8 +141,9 @@ func (c *CopilotCLIAgent) uninstallVSCodeHooks(worktreeRoot string) error {
121141
}
122142
}
123143

124-
// If nothing user-owned remains, remove the file entirely.
125-
if len(rawHooks) == 0 {
144+
// Delete the file only when nothing user-owned remains: no hooks left and no
145+
// top-level fields other than the structural ones Entire itself writes.
146+
if len(rawHooks) == 0 && !hasUserTopLevelFields(rawFile) {
126147
if err := os.Remove(hooksPath); err != nil && !errors.Is(err, os.ErrNotExist) {
127148
return fmt.Errorf("failed to remove %s: %w", VSCodeHooksFileName, err)
128149
}
@@ -132,6 +153,18 @@ func (c *CopilotCLIAgent) uninstallVSCodeHooks(worktreeRoot string) error {
132153
return writeVSCodeHooksFile(hooksPath, rawFile, rawHooks)
133154
}
134155

156+
// hasUserTopLevelFields reports whether rawFile carries any top-level key beyond
157+
// the structural "version" and "hooks" keys Entire manages — i.e. fields a user
158+
// added that must survive uninstall.
159+
func hasUserTopLevelFields(rawFile map[string]json.RawMessage) bool {
160+
for key := range rawFile {
161+
if key != "version" && key != "hooks" {
162+
return true
163+
}
164+
}
165+
return false
166+
}
167+
135168
// areVSCodeHooksInstalled reports whether any Entire hook is present in
136169
// entire-vscode.json.
137170
func (c *CopilotCLIAgent) areVSCodeHooksInstalled(worktreeRoot string) bool {

cmd/entire/cli/agent/copilotcli/vscode_hooks_test.go

Lines changed: 68 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,17 @@ func TestInstallVSCodeHooks_FreshInstall(t *testing.T) {
3636
tempDir := t.TempDir()
3737

3838
ag := &CopilotCLIAgent{}
39-
require.NoError(t, ag.installVSCodeHooks(tempDir, false, false))
39+
count, err := ag.installVSCodeHooks(tempDir, false, false)
40+
require.NoError(t, err)
41+
require.Equal(t, 3, count, "user-prompt-submitted + agent-stop + session-end")
4042

4143
hooks := readVSCodeFile(t, tempDir)
4244

4345
// Only the two turn events are registered; the rest come from entire.json.
4446
require.Len(t, hooks, 2, "only UserPromptSubmit and Stop should be registered")
4547
require.Len(t, hooks[VSCodeEventUserPromptSubmit], 1)
46-
require.Len(t, hooks[VSCodeEventStop], 1)
48+
// VS Code's single Stop event carries both agent-stop and session-end.
49+
require.Len(t, hooks[VSCodeEventStop], 2)
4750

4851
ups := hooks[VSCodeEventUserPromptSubmit][0]
4952
require.Equal(t, "command", ups.Type)
@@ -53,22 +56,37 @@ func TestInstallVSCodeHooks_FreshInstall(t *testing.T) {
5356
ups.Command,
5457
"VS Code uses the command field, not bash")
5558

56-
require.Equal(t,
57-
agent.WrapProductionSilentHookCommand("entire hooks copilot-cli agent-stop"),
58-
hooks[VSCodeEventStop][0].Command)
59+
stopCommands := commandsOf(hooks[VSCodeEventStop])
60+
require.Contains(t, stopCommands,
61+
agent.WrapProductionSilentHookCommand("entire hooks copilot-cli agent-stop"))
62+
require.Contains(t, stopCommands,
63+
agent.WrapProductionSilentHookCommand("entire hooks copilot-cli session-end"),
64+
"terminal VS Code Stop must route to session-end")
65+
}
66+
67+
// commandsOf extracts the command strings from a slice of hook entries.
68+
func commandsOf(entries []VSCodeHookEntry) []string {
69+
cmds := make([]string, len(entries))
70+
for i, e := range entries {
71+
cmds[i] = e.Command
72+
}
73+
return cmds
5974
}
6075

6176
func TestInstallVSCodeHooks_Idempotent(t *testing.T) {
6277
t.Parallel()
6378
tempDir := t.TempDir()
6479

6580
ag := &CopilotCLIAgent{}
66-
require.NoError(t, ag.installVSCodeHooks(tempDir, false, false))
67-
require.NoError(t, ag.installVSCodeHooks(tempDir, false, false))
81+
_, err := ag.installVSCodeHooks(tempDir, false, false)
82+
require.NoError(t, err)
83+
count, err := ag.installVSCodeHooks(tempDir, false, false)
84+
require.NoError(t, err)
85+
require.Equal(t, 0, count, "reinstall adds nothing")
6886

6987
hooks := readVSCodeFile(t, tempDir)
7088
require.Len(t, hooks[VSCodeEventUserPromptSubmit], 1, "must not duplicate on reinstall")
71-
require.Len(t, hooks[VSCodeEventStop], 1)
89+
require.Len(t, hooks[VSCodeEventStop], 2, "agent-stop + session-end, no duplicates")
7290
}
7391

7492
func TestInstallVSCodeHooks_PreservesUserHooks(t *testing.T) {
@@ -92,12 +110,14 @@ func TestInstallVSCodeHooks_PreservesUserHooks(t *testing.T) {
92110
require.NoError(t, os.WriteFile(vsCodeHooksPath(tempDir), []byte(seed), 0o600))
93111

94112
ag := &CopilotCLIAgent{}
95-
require.NoError(t, ag.installVSCodeHooks(tempDir, false, false))
113+
_, err := ag.installVSCodeHooks(tempDir, false, false)
114+
require.NoError(t, err)
96115

97116
hooks := readVSCodeFile(t, tempDir)
98-
require.Len(t, hooks[VSCodeEventStop], 2, "user Stop hook retained alongside Entire's")
117+
require.Len(t, hooks[VSCodeEventStop], 3, "user Stop hook retained alongside Entire's agent-stop + session-end")
99118
require.Len(t, hooks[VSCodeEventPreToolUse], 1, "unknown event type preserved")
100119
require.Equal(t, "echo user-pretool", hooks[VSCodeEventPreToolUse][0].Command)
120+
require.Contains(t, commandsOf(hooks[VSCodeEventStop]), "echo user-stop", "user Stop hook survives")
101121

102122
// Unknown top-level field is preserved on round-trip.
103123
data, err := os.ReadFile(vsCodeHooksPath(tempDir))
@@ -110,12 +130,13 @@ func TestUninstallVSCodeHooks_DeletesWhenEmpty(t *testing.T) {
110130
tempDir := t.TempDir()
111131

112132
ag := &CopilotCLIAgent{}
113-
require.NoError(t, ag.installVSCodeHooks(tempDir, false, false))
133+
_, err := ag.installVSCodeHooks(tempDir, false, false)
134+
require.NoError(t, err)
114135
require.True(t, ag.areVSCodeHooksInstalled(tempDir))
115136

116137
require.NoError(t, ag.uninstallVSCodeHooks(tempDir))
117138

118-
_, err := os.Stat(vsCodeHooksPath(tempDir))
139+
_, err = os.Stat(vsCodeHooksPath(tempDir))
119140
require.ErrorIs(t, err, os.ErrNotExist, "file removed when nothing user-owned remains")
120141
require.False(t, ag.areVSCodeHooksInstalled(tempDir))
121142
}
@@ -125,7 +146,8 @@ func TestUninstallVSCodeHooks_KeepsUserHooks(t *testing.T) {
125146
tempDir := t.TempDir()
126147

127148
ag := &CopilotCLIAgent{}
128-
require.NoError(t, ag.installVSCodeHooks(tempDir, false, false))
149+
_, err := ag.installVSCodeHooks(tempDir, false, false)
150+
require.NoError(t, err)
129151

130152
// Add a user-owned hook to the Stop event.
131153
rawFile, rawHooks, err := readVSCodeHooksFile(vsCodeHooksPath(tempDir))
@@ -158,12 +180,42 @@ func TestInstallVSCodeHooks_ForceReinstall(t *testing.T) {
158180
tempDir := t.TempDir()
159181

160182
ag := &CopilotCLIAgent{}
161-
require.NoError(t, ag.installVSCodeHooks(tempDir, false, false))
162-
require.NoError(t, ag.installVSCodeHooks(tempDir, false, true))
183+
_, err := ag.installVSCodeHooks(tempDir, false, false)
184+
require.NoError(t, err)
185+
_, err = ag.installVSCodeHooks(tempDir, false, true)
186+
require.NoError(t, err)
163187

164188
hooks := readVSCodeFile(t, tempDir)
165189
require.Len(t, hooks[VSCodeEventUserPromptSubmit], 1)
166-
require.Len(t, hooks[VSCodeEventStop], 1)
190+
require.Len(t, hooks[VSCodeEventStop], 2, "force reinstall keeps both agent-stop + session-end")
191+
}
192+
193+
func TestUninstallVSCodeHooks_PreservesUserTopLevelFields(t *testing.T) {
194+
t.Parallel()
195+
tempDir := t.TempDir()
196+
197+
// File with only Entire-managed hooks but a user-added top-level field.
198+
seed := `{
199+
"version": 1,
200+
"customField": "keep-me",
201+
"hooks": {
202+
"Stop": [
203+
{"type": "command", "command": "` + agent.WrapProductionSilentHookCommand("entire hooks copilot-cli agent-stop") + `"}
204+
]
205+
}
206+
}`
207+
require.NoError(t, os.MkdirAll(filepath.Join(tempDir, hooksDir), 0o755))
208+
require.NoError(t, os.WriteFile(vsCodeHooksPath(tempDir), []byte(seed), 0o600))
209+
210+
ag := &CopilotCLIAgent{}
211+
require.NoError(t, ag.uninstallVSCodeHooks(tempDir))
212+
213+
// File must survive (custom field) but Entire's hook is gone.
214+
require.FileExists(t, vsCodeHooksPath(tempDir), "file kept because customField is user-owned")
215+
data, err := os.ReadFile(vsCodeHooksPath(tempDir))
216+
require.NoError(t, err)
217+
require.Contains(t, string(data), "keep-me")
218+
require.False(t, ag.areVSCodeHooksInstalled(tempDir))
167219
}
168220

169221
func TestInstallHooks_AlsoInstallsVSCodeFile(t *testing.T) {

0 commit comments

Comments
 (0)