Skip to content

feat(vscode-copilot): agent hooks for Copilot Chat in VS Code - #23

Open
svadrutk wants to merge 10 commits into
mainfrom
implement-agenthooks-support-for-vscode-copilot
Open

feat(vscode-copilot): agent hooks for Copilot Chat in VS Code#23
svadrutk wants to merge 10 commits into
mainfrom
implement-agenthooks-support-for-vscode-copilot

Conversation

@svadrutk

@svadrutk svadrutk commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

implement-agenthooks-support-for-vscode-copilot | HumanLayer Task

Why the change

Copilot Chat in VS Code fires agent hooks that this library could not serve, because it speaks the Claude-shaped dialect rather than the Copilot CLI's camelCase one, and the README told users the IDE surfaces fire nothing.

Special things to note

  • VS Code cannot be driven headlessly, so there is no e2e/vscode_test.go and no fake hook-executor harness. Verification is unit plus install plus a cross-runtime e2e against the real copilot binary, backed by one manual capture session whose recorded payloads are committed as fixtures. Two things stay unproven and are marked as such: the stop-continuation path (the recorder has no OnStop handler), and SubagentStart/SubagentStop/PreCompact, which never fired in either runtime.
  • event.go's canonical tool map is not VS Code-only. It is shared across every provider, and VS Code's tool names were missing from it, so run_in_terminal classified as ToolOther and a deny-shell policy silently did not block it — caught live when a capture session ran echo hello straight through a deny. The trap is that the extension registers copilot_readFile in its package.json but sends read_file on the wire, so auditing from the obvious source maps the wrong strings.
  • One generated file serves two runtimes. VS Code and the Copilot CLI glob the same two hook directories, so anything installed is loaded by both. Installing vscode-copilot and copilot together double-fires in the CLI.

Change outline

The wire shape is Claude Code's, so both codecs delegate and only the response placement differs. Decode relabels; encode wraps.

 decodePayload(provider, ...)
+  case ProviderVSCodeCopilot -> decodeClaudeAs(ProviderVSCodeCopilot, ...)   # decodeClaude hardcodes claude-code

 encodeDecision(...)
+  case ProviderVSCodeCopilot -> encodeVSCode(base, d)
+    out := encodeClaude(base, d)
+    if Kind is Stop or SubagentStop and out has "decision":
+      move decision/reason INTO out["hookSpecificOutput"]      # toolCallingLoop reads them nested
+    if Kind is SubagentStart:
+      set hookSpecificOutput.additionalContext                 # encodeClaude has no case; VS Code honors it
+    stamp hookSpecificOutput.hookEventName, never a top-level one

Placement is per-event and not symmetric. Getting it wrong fails silently rather than loudly, which is why each row is pinned by a test.

PreToolUse         permissionDecision, updatedInput   -> hookSpecificOutput
PostToolUse        decision, reason                   -> TOP LEVEL
UserPromptSubmit   decision, reason                   -> TOP LEVEL
Stop, SubagentStop decision, reason                   -> hookSpecificOutput   (the one move)
every type         continue, stopReason, systemMessage-> TOP LEVEL

The runtime is resolved before decode, so nothing downstream needs a second branch.

 Run(...)
   provider, conf := detectProvider(inv, payload)
+  provider = demoteVSCodeToCLI(provider)
+    if provider == vscode-copilot and copilotCLIEnv()   # COPILOT_CLI / COPILOT_PLUGIN_ROOT / COPILOT_PLUGIN_DATA
+      return ProviderCopilot                            # same PascalCase file, CLI session
   decodePayload(provider, ...)

The mirror case shares the helper, and fixes a bug that exists on main today: a camelCase CLI config discovered by VS Code decoded to KindOther with the tool fields lost.

 decodeCopilot(v, conf, now, payload)
   unmarshal copilotIn
+  if in.HookEventName != "" and in.SessionID == ""      # Claude-shaped, not camelCase
+    return decodeClaudeAs(ProviderCopilot, ...)          # label stays copilot -> encodeCopilot answers FLAT
   native := copilotEventName(in)

Files, by responsibility:

 agenthooks/
+├── codec_vscode.go          # encodeVSCode: encodeClaude plus two placement fixups
 ├── codec_claudecode.go      # + decodeClaudeAs, the relabel both new callers share
 ├── codec_copilot.go         # + the Claude-shaped fallthrough
 ├── detect.go                # + copilotCLIEnv, demoteVSCodeToCLI; flag-only provider
 ├── capability.go            # + the VS Code row, read from extension source not docs
 ├── event.go                 # ~ canonical tool map: VS Code's wire names (shared, all providers)
 ├── quirks.go                # + #38-41 Copilot CLI backfills, #42-48 VS Code
 ├── agenthookstest/fixtures/vscode/   # + 3 payloads recorded from a live agent turn
 └── install/
+    └── render_vscode.go     # agenthooks-vscode.json, PascalCase, four omissions

The renderer's omissions each prevent a silent failure, so they are worth reading as intent rather than as gaps: no matcher (VS Code parses matchers and then ignores them, so --filter on the argv is the only real enforcement), no version, no bash/powershell (VS Code's split is windows/linux/osx), and both timeout spellings because the reference table says timeout while a usage example says timeoutSec.

{"hooks": {"PreToolUse": [{
  "type": "command",
  "command": "/usr/local/bin/myhooks agenthooks run --provider=vscode-copilot --timeout=30s --filter=names=Bash",
  "timeout": 30, "timeoutSec": 30
}]}}
Scope Path Target.Dir
ScopeUser hooks/agenthooks-vscode.json ~/.copilot
ScopeProject .github/hooks/agenthooks-vscode.json repo root
ScopePlugin (error)

Summary by cubic

Adds Copilot Chat in VS Code as a ninth provider. It speaks Claude Code's wire dialect, so decoding delegates to the Claude codec and encoding wraps it with event-specific placement fixups. The installed config is shared with the Copilot CLI — both glob the same hook directories — so CLI sessions demote the provider via COPILOT_* env, and the mirror case lets a Claude-shaped CLI payload fall through to the Claude codec instead of KindOther.

New Features

  • Renders agenthooks-vscode.json at ~/.copilot/hooks/ (user) or .github/hooks/ (project) with PascalCase event keys, no matcher, and both timeout spellings.
  • Adds a capability row plus quirk registry entries #38–49, grounded in extension source that contradicts the docs on Stop nesting and the timeout key.
  • Detection is flag-only: VS Code sets no env marker, so a config without --provider=vscode-copilot degrades to claude-code.
  • Resolves MCP tool calls from .vscode/mcp.json and .mcp.json, with the extension's truncating mcp_ prefix sanitization and JSONC parsing.
  • Corrects README and DESIGN claims that Copilot hooks are CLI-only.

Bug Fixes

  • Maps VS Code wire tool names so deny-shell policies now cover run_in_terminal and six other execution tools, previously all ToolOther.
  • decodeCopilot falls through to the Claude codec on snake_case input, so tool args survive and the flat response schema is kept.
  • Normalizes toolArgs across Copilot CLI versions, where it is a JSON string through 1.0.80 and a plain object from 1.0.81.

Written for commit 64a288b. Summary will update on new commits.

Review in cubic


Manual VS Code verification

Verified on VS Code 1.135 / Copilot Chat 0.63 on macOS using a project-scoped .github/hooks/agenthooks-vscode.json in a real repository. Each result below was checked against the Copilot Hooks log; tool/continuation results were also checked in the session transcript or on disk. The normal project hook configuration was restored after the temporary decision hooks.

Scenario Result Evidence
Project-scope discovery and provider selection ✅ Pass A fresh Agent chat loaded agenthooks-vscode.json, invoked the generated command with --provider=vscode-copilot, and used the repository as cwd.
Normal lifecycle ✅ Pass Observed SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, and Stop, all completing successfully.
File and terminal tool round trip ✅ Pass read_file and run_in_terminal each produced matching pre/post tool-use IDs. Terminal output VSCODE_AGENTHOOKS_OK arrived intact in PostToolUse.tool_response.
Canonical terminal classification and deny ✅ Pass A temporary OnToolPre policy classified run_in_terminal as ToolShell and returned nested permissionDecision: "deny". VS Code blocked touch /tmp/VSCODE_AGENTHOOKS_SHOULD_NOT_EXIST; the file remained absent.
Stop continuation ✅ Pass First Stop arrived with stop_hook_active: false; nested decision: "block"/reason caused one more model iteration containing VSCODE_STOP_CONTINUATION_WORKED. The second Stop had stop_hook_active: true and finished without a loop.
Session-start context ✅ Pass hookSpecificOutput.additionalContext supplied VSCODE_SESSION_CONTEXT_WORKED; the model returned that marker when asked.
Prompt denial ✅ Pass Top-level decision: "block"/reason on UserPromptSubmit prevented the model from processing the marker prompt.
Ask-user flow ✅ Pass permissionDecision: "ask" displayed approval UI; approving allowed the harmless terminal command to run.
Updated tool input ✅ Pass A requested VSCODE_UPDATE_INPUT_ORIGINAL terminal command was rewritten through updatedInput; only VSCODE_UPDATED_INPUT_WORKED executed.
Post-tool model context ⚠️ Valid shape, upstream race Both documented nested hookSpecificOutput.additionalContext and a diagnostic top-level block/reason were parsed and logged, but neither marker appeared in the immediate next response on 1.135. Version-pinned source confirms support but shows appendHookContext(...) is started without await; see quirk #49, docs/research/vscode-copilot-posttooluse-context.md, microsoft/vscode#314118, and fix PR microsoft/vscode#331785. The PR therefore retains the documented nested shape and CapAddContext for fixed VS Code builds.

Scope notes

  • SubagentStart, SubagentStop, and PreCompact were not observed because this VS Code session did not expose a deterministic way to trigger them.
  • A real Windows VS Code invocation and an MCP tool call were not part of this manual session; renderer quoting and VS Code MCP resolution remain covered by automated tests.
  • Final automated check: go test ./... passes at c303db3.

… row, renderer

Ninth provider for GitHub Copilot Chat in VS Code. The wire dialect is
Claude Code's, so decode delegates via decodeClaudeAs and encode wraps
encodeClaude with two placement fixups VS Code's parser requires:
decision/reason nested in hookSpecificOutput for Stop/SubagentStop, and
additionalContext on SubagentStart.

Capability row is grounded in the extension source (chatHookService.ts,
toolCallingLoop.ts, hookResultProcessor.ts) rather than the docs, which
contradict themselves on placement.

install.Render writes agenthooks-vscode.json at ~/.copilot/hooks/ and
.github/hooks/ with PascalCase keys, no matcher (VS Code ignores them,
so --filter is the real enforcement), no version, and both timeout
spellings.
VS Code and the Copilot CLI glob the same two hook directories, so every
generated file is loaded by both. PascalCase keys make the input identical
but the CLI answers flat while VS Code answers nested, so the CLI's own
COPILOT_* env demotes ProviderVSCodeCopilot to ProviderCopilot before
decode — each session then gets an already-correct capability row and
encoder with no branch downstream.

The mirror case is the same helper: decodeCopilot now falls through to the
Claude codec on a snake_case payload, so a camelCase CLI config discovered
by VS Code no longer lands on KindOther with the tool fields lost.

e2e (real copilot 1.0.81) confirms the CLI registers all five drivable
PascalCase names, UserPromptSubmit included, and honors a flat deny.
Ten registry entries: #38-41 backfill the Copilot CLI dialect behaviors
DESIGN.md has described since the provider landed but never registered,
#42-47 cover VS Code Copilot Chat, each Mitigation naming the code that
handles it.

README no longer claims Copilot IDE surfaces fire nothing — it now
describes the two dialects, which install target to pick, and scopes the
cross-runtime claim to what the e2e actually measured.
VS Code's tool names reached CanonicalToolFor unmapped, so every one of
them classified as ToolOther. A deny-shell policy silently did not cover
run_in_terminal — confirmed live: a capture session ran 'echo hello'
straight through a deny that should have blocked it.

Enumerated from the extension's wire-name table rather than its
package.json, which registers different names (copilot_readFile vs the
read_file that actually reaches a hook). Seven execution tools now
classify as shell, the four subagent tools as task, plus the edit,
search and fetch spellings.

canonicalNames is shared and provider-independent, so this is fixed once
for every caller rather than per-provider. The read-only terminal tools
and run_vscode_command stay ToolOther, pinned by test so a later sweep
cannot silently widen an allow-shell policy.
Three payloads recorded from a real VS Code Copilot Chat agent turn
(1.135.0 / Copilot Chat 0.63.0), scrubbed but structurally intact. All 25
recorded events came back stamped vscode-copilot, so VS Code's own
hookExecutor spawned the hook rather than the bundled Copilot CLI.

The capture confirmed the field set matches claudeIn's tags exactly, and
that transcript_path, source, model and tool_use_id all ship — unlike the
CLI, VS Code needs no ID synthesis. One benign divergence recorded:
SessionStart.source is "new" where Claude Code says "startup"; nothing
switches on the value.

codec_vscode_test.go now decodes the recorded corpus as ground truth and
keeps the Claude corpus as an explicit drift assertion, which also covers
the five events the three fixtures do not. TestVSCodeDetection gains the
half that could not be proven before the capture: a real VS Code payload
shape-detects as claude-code, so flag-only detection is a constraint
rather than a preference.

Quirk #48 records the trap that produced the deny hole: the names the
extension registers are not the names a hook receives.

Not closed: the stop-continuation check never ran, because the recorder
has no OnStop handler and cannot emit Continue().
@svadrutk
svadrutk requested a review from a team as a code owner August 29, 2026 18:03
@cubic-dev-ai

cubic-dev-ai Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Running ultrareview automatically — This PR adds a whole new provider dialect (vscode-copilot) across 25 files, with subtle response-schema placement differences and silent-failure modes — a bug could silently break hooks for both VS Code and the Copilot CLI, warranting deep multi-pass review.. I'll post findings when complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ultrareview completed in 13m 50s

All reported issues were addressed across 25 files

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread install/render_vscode.go
Comment thread README.md Outdated
Comment thread codec_claudecode.go Outdated
Comment thread capability.go Outdated
Comment thread event.go
Comment thread codec_vscode_test.go Outdated
Comment thread quirks.go
Comment thread DESIGN.md Outdated
Comment thread DESIGN.md Outdated
Comment thread capability.go Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 14 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread mcpresolve.go

@danielkov danielkov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

& cubic comment(s)

Comment thread codec_claudecode.go Outdated
Comment thread detect.go
Comment on lines +37 to +40
// VS Code ships no provider env marker and no field Claude Code doesn't
// also send, so this one is reachable by the generated --provider flag
// only: neither detectFromEnv nor detectFromShape can produce it.
ProviderVSCodeCopilot: true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It's good to call this out, but wonder if there's anything we can do about this.

If I give VSCode or Copilot a hook and lie to agenthooks via --provider claude using the current version, what happens?

If behaviour is completely equivalent, I'd think about not adding explicit support and just documenting that --provider claude works for these two harnesses.

If there's any difference in behaviour, I'd smarten the detection logic to reliably detect it or remove detection support from Claude, as we can no longer guarantee it works faithfully.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Claude behavior isn't fully equivalent, there are some key differences that affect the provider functionality

  • Claude expects decision and reason at the top level. VS Code reads them inside hookSpecificOutput. Using the Claude codec makes ContinueWith(...) silently do nothing in VS Code.
  • VS Code accepts additionalContext for SubagentStart. Claude’s codec does not encode context for that event.
  • Events would report ProviderClaudeCode, so provider-specific policy, capability checks, MCP resolution, documentation, and diagnostics would all follow the wrong runtime.
    ...and a couple others.

It's hard to smarten the detection logic cause VSCode passes essentially the same shape as Claude, I think silent misclassification is worse than requiring this configuration

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants