Skip to content

preToolUse silently dropped after mid-loop extensions_reload (snapshot staleness in runAgenticLoop) #3167

@pacovidal

Description

@pacovidal

Summary

The agent's main reaction loop captures the session's hooks once at loop start and then uses that snapshot for every subsequent iteration. After extensions_reload is called from inside the loop, the snapshot still references the now-dead old extension's IPC proxy. The proxy's calls fail with EPIPE / "connection reset" / "stream was destroyed", which are caught and silently swallowed by the proxy's own error handler.

The result: preToolUse, userPromptSubmitted, and sessionStart invocations registered via that proxy are silently dropped for the rest of the loop. Meanwhile postToolUse (and other paths that call getEffectiveHooks() live each time) continue to fire on the freshly-launched extension instance.

For an extension that observes both pre and post hooks, every tool call that happens after a mid-loop extensions_reload becomes a postToolUse-only event with no matching preToolUse. The behavior persists until the user submits the next prompt and a fresh runAgenticLoop retakes the snapshot.

Repro

Minimal extension that registers preToolUse and postToolUse and logs both:

import { joinSession } from "@github/copilot-sdk/extension";
const session = await joinSession({
  hooks: {
    onPreToolUse:  async (input) => { console.error("PRE",  input.toolName); },
    onPostToolUse: async (input) => { console.error("POST", input.toolName); },
  },
});

Then in a Copilot CLI session with the extension installed:

  1. Send a prompt that triggers a tool call. Both PRE and POST fire.
  2. Send a prompt that asks the agent to call extensions_reload and then run another tool in the same response (e.g. "reload extensions, then list the files in the current directory").
  3. Observe extension logs (or the CLI's own debug logs).

Expected: PRE and POST both fire for the post-reload tool call.

Actual: Only POST fires for the post-reload tool call. PRE is silently dropped. Every subsequent tool call in the same agentic loop has the same behavior. The next user-submitted prompt restarts the loop and the behavior self-heals.

Root cause (reading shipped CLI 1.0.43-0)

Path: <copilot-pkg>/app.js.

Inside runAgenticLoop (one call per user prompt; iterates internally through many LLM-tool cycles):

async runAgenticLoop(...) {
  ...
  let _ = this.getEffectiveHooks();   // ← SNAPSHOT taken once
  try {
    let T = await h1(_?.userPromptSubmitted, ...);                  // uses snapshot
    ...
    new XIr(_, this.workingDir, this.sessionId, ...)                // PreToolUseHooksProcessor uses snapshot
    ...
  }
}

Vs. processToolExecutionResult (one call per tool result, anywhere in the same loop):

async processToolExecutionResult(toolName, toolArgs, toolResult) {
  let s = (toolResult.resultType === "success"
    ? await h1(this.getEffectiveHooks()?.postToolUse, ...)          // ← LIVE per call
    : void 0);
  ...
}

Hook proxies for connected extensions are built via createHooksProxy(sessionId, connection). Each proxy closes over the IPC connection to that specific extension instance:

createHooksProxy(sessionId, connection) {
  let n = async (hookType, input) => {
    ...
    try {
      return (await connection.sendRequest(c0.HOOKS_INVOKE, { sessionId, hookType, input })).output;
    } catch (a) {
      let l = q(a), c = l.toLowerCase();
      // EPIPE / broken pipe / EOF / closed / connection reset / write after end /
      // stream was destroyed / shutting down → just log; otherwise warn.
      ...
      return; // ← swallowed
    }
  };
  return {
    preToolUse:  [o => n("preToolUse",  o)],
    postToolUse: [o => n("postToolUse", o)],
    ...
  };
}

When extensions_reload runs stopAllExtensions(), the old child process is killed (SIGTERM then SIGKILL after 5 s) and its connection is disposed. The new extension is launched and registers fresh hooks via addAdHocHooks(connectionId, createHooksProxy(sessionId, newConnection)).

But the snapshot _ captured at the top of runAgenticLoop still holds the old proxy in its hook arrays. preToolUse calls on the snapshot route through the dead proxy → sendRequest rejects → catch swallows → returns undefined → no extension sees the hook.

Suggested fix (one of)

  1. Move the snapshot to live look-up. Replace _=this.getEffectiveHooks() with live calls at each hook invocation site (mirroring processToolExecutionResult). This is the smallest semantic change.

  2. Refresh the snapshot reactively. When an extension connection drops or a new one is registered (addAdHocHooks / removeAdHocHooks), invalidate any in-flight runAgenticLoop snapshot.

  3. Refresh the snapshot after extensions_reload specifically. Smaller scope, but doesn't cover other ways an extension might disconnect mid-loop (crashes, network issues for remote sessions, etc.).

Of these, (1) is the most consistent with how postToolUse already works.

Impact

  • Extensions that observe preToolUse for tool tracking, telemetry, audit logs, fault injection, or UI live-update have visible gaps after any mid-loop extensions_reload.
  • userPromptSubmitted is also affected (same snapshot), so any extension that relies on it for memory / state could miss prompts after a mid-loop reload (though the reload itself is rarely between submission and tool execution, so this is less observable).
  • sessionStart is fired through the same captured snapshot earlier in the loop, but is gated by !this._sessionStartHooksFired, so its impact is bounded to the very first iteration of the very first loop.
  • Symptoms are silent — no warning is surfaced to the user, the agent, or the extension. Extensions just stop receiving certain hooks.

Environment

  • @github/copilot 1.0.43-0 (Windows win32-x64 package).
  • Reproduced on Windows 11.
  • Source confirmed by reading the bundled app.js shipped in the CLI package.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:pluginsPlugin system, marketplace, hooks, skills, extensions, and custom agentsarea:toolsBuilt-in tools: file editing, shell, search, LSP, git, and tool call behavior

    Type

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions