From 830ad81fcc7a6a51e01b2acfef4205f53e758d86 Mon Sep 17 00:00:00 2001 From: George Ng Date: Fri, 23 Oct 2026 16:36:43 -0700 Subject: [PATCH 1/2] fix(copilot-plugin): capture armed recordings in bypass mode Claim explicitly selected recordings before bypassing recipe matching. Keep recipe-off, recording feature flags, Copilot task ownership, and success-receipt checks intact. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0040b8ff-2701-4fb7-821f-760e05fe6b4b --- ts/packages/copilot-plugin/README.md | 18 ++++- .../copilot-plugin/src/hooks/hook-router.ts | 2 +- .../copilot-plugin/test/hookRouter.spec.ts | 80 +++++++++++-------- 3 files changed, 60 insertions(+), 40 deletions(-) diff --git a/ts/packages/copilot-plugin/README.md b/ts/packages/copilot-plugin/README.md index 159286fe70..26918d74bd 100644 --- a/ts/packages/copilot-plugin/README.md +++ b/ts/packages/copilot-plugin/README.md @@ -53,6 +53,9 @@ it. Without admission, existing routing and permission behavior are unchanged. The plugin's `hooks.json` registers the compiled `userPromptSubmitted` router. It reads mode per request, claims selected recordings, then uses only the local recipe matcher. A miss stays with Copilot; bypass disables recipe admission. +Explicitly armed recordings are still claimed in bypass mode, without matching +or executing a recipe or delegating the task to TypeAgent. Recording feature +flags and `recipeOwner: off` still disable recording. There is no PowerShell pre-tool redirect or alternate MCP/dev macro owner. Actual native per-invocation receipts are still required. @@ -646,7 +649,8 @@ No owner setting is required: the default and only enabled owner is `plugin`. `TYPEAGENT_RECIPE_OWNER` overrides config `recipeOwner`; supported values are `plugin` and `off`. Close and restart the native session after changing it. An obsolete explicit `typeagent` value is an actionable error, not a different -catalog selection. `off` disables recipes; `bypass` disables hook/MCP routing. +catalog selection. `off` disables recipes, including recording; `bypass` disables +recipe matching/execution and MCP calls, but permits explicitly armed recordings. Unmatched ordinary prompts go to native Copilot, not the TypeAgent planner. Explicit TypeAgent commands/tools remain optional backend operations; they are not required by the local recipe path. @@ -803,8 +807,13 @@ set a workspace learning preference, then arm the next interaction: /typeagent-macro-record ``` -Send this as the next ordinary message, rather than using the natural-language -file-reading shortcut: +To record Copilot-native or third-party MCP tools without recipe routing, use +`/typeagent-mode bypass` instead. The selected task stays with Copilot, including +the success receipt required for learning. TypeAgent workspace and macro MCP +calls remain disabled in bypass; use the extension recording/status commands. + +For the MCP-mode example, send this as the next ordinary message, rather than +using the natural-language file-reading shortcut: ```text Use the typeagent-workspace read tool with {"path":"package.json"}. Do not use typeagent-processCommand. @@ -1081,7 +1090,8 @@ that mode selection requires natural-language dispatch. Ordinary prompts use local checked-program matching followed by Copilot on a miss, in `direct`, `mcp` and `dev`. None of these modes selects a backend macro -owner or author. `bypass` disables recipe hook/MCP routing. +owner or author. `bypass` disables recipe matching/execution and MCP calls, +while still allowing explicitly armed recordings. The settings remain for explicit TypeAgent commands and MCP availability. `@typeagent run ` intentionally delegates to the TypeAgent dispatcher; diff --git a/ts/packages/copilot-plugin/src/hooks/hook-router.ts b/ts/packages/copilot-plugin/src/hooks/hook-router.ts index 186832e3fe..d3c91cad13 100644 --- a/ts/packages/copilot-plugin/src/hooks/hook-router.ts +++ b/ts/packages/copilot-plugin/src/hooks/hook-router.ts @@ -316,7 +316,6 @@ export async function routePrompt( signal: AbortSignal, dependencies: RoutePromptDependencies = routePromptDefaults, ): Promise { - if (mode === "bypass") return {}; const owner = (dependencies.recipeOwner ?? getRecipeOwner)(); if (owner === "off") return {}; const recording = await dependencies.claimRecording(input); @@ -326,6 +325,7 @@ export async function routePrompt( "This turn is explicitly selected for TypeAgent macro learning. Perform the user's task normally. Only after it actually succeeds, call typeagent_recorded_task_succeeded once with a concise factual summary, before delivering your final answer. Do not call it after failed, denied, incomplete or aborted work. It records success metadata only; do not rerun the task, launch authoring agents yourself, or wait for background learning. If the receipt tool is unavailable, deliver the answer and report that child authoring is unavailable; do not invent success.", }; if (recording) return {}; + if (mode === "bypass") return {}; return (dependencies.localRecipes ?? handleLocalRecipes)(input, signal); } diff --git a/ts/packages/copilot-plugin/test/hookRouter.spec.ts b/ts/packages/copilot-plugin/test/hookRouter.spec.ts index 18df615d11..eccd6725a2 100644 --- a/ts/packages/copilot-plugin/test/hookRouter.spec.ts +++ b/ts/packages/copilot-plugin/test/hookRouter.spec.ts @@ -43,40 +43,48 @@ describe("macro recording routing override", () => { ).resolves.toEqual({}); expect(dependencies.localRecipes).toHaveBeenCalledTimes(1); }); - it("off bypasses recipe recording and both execution owners", async () => { - const dependencies = { - ...createDependencies(false), - recipeOwner: () => "off" as const, - }; - await expect( - routePrompt( + it.each(["direct", "mcp", "dev", "bypass"] as const)( + "off disables recording and recipe execution in %s mode", + async (mode) => { + const dependencies = { + ...createDependencies(false), + recipeOwner: () => "off" as const, + }; + await expect( + routePrompt( + input, + mode, + new AbortController().signal, + dependencies, + ), + ).resolves.toEqual({}); + expect(dependencies.claimRecording).not.toHaveBeenCalled(); + expect(dependencies.localRecipes).not.toHaveBeenCalled(); + }, + ); + it.each(["direct", "mcp", "dev", "bypass"] as const)( + "requests a success receipt for selected child learning in %s mode without taking task ownership from Copilot", + async (mode) => { + const dependencies = createDependencies("child"); + const output = await routePrompt( input, - "direct", + mode, new AbortController().signal, dependencies, - ), - ).resolves.toEqual({}); - expect(dependencies.claimRecording).not.toHaveBeenCalled(); - expect(dependencies.localRecipes).not.toHaveBeenCalled(); - }); - it("requests an explicit success receipt only for selected child learning and leaves original task ownership in Copilot", async () => { - const dependencies = createDependencies("child"); - const output = await routePrompt( - input, - "direct", - new AbortController().signal, - dependencies, - ); - expect(output.additionalContext).toContain( - "typeagent_recorded_task_succeeded", - ); - expect(output.additionalContext).toContain( - "Do not call it after failed", - ); - expect(output.additionalContext).toContain("do not rerun the task"); - expect(output.modifiedPrompt).toBeUndefined(); - expect(dependencies.localRecipes).not.toHaveBeenCalled(); - }); + ); + expect(output.additionalContext).toContain( + "typeagent_recorded_task_succeeded", + ); + expect(output.additionalContext).toContain( + "Do not call it after failed", + ); + expect(output.additionalContext).toContain("do not rerun the task"); + expect(output.handled).toBeUndefined(); + expect(output.modifiedPrompt).toBeUndefined(); + expect(dependencies.claimRecording).toHaveBeenCalledWith(input); + expect(dependencies.localRecipes).not.toHaveBeenCalled(); + }, + ); it.each([ "list the playlists", "learn: create a playlist", @@ -98,7 +106,7 @@ describe("macro recording routing override", () => { expect.any(AbortSignal), ); }); - it.each(["direct", "mcp", "dev"] as const)( + it.each(["direct", "mcp", "dev", "bypass"] as const)( "falls through one claimed interaction in %s mode", async (mode) => { const dependencies = createDependencies(true); @@ -112,12 +120,13 @@ describe("macro recording routing override", () => { ), ).resolves.toEqual({}); expect(dependencies.claimRecording).toHaveBeenCalledTimes(1); + expect(dependencies.claimRecording).toHaveBeenCalledWith(input); expect(dependencies.localRecipes).not.toHaveBeenCalled(); }, ); - it("does not claim recordings while bypassed", async () => { - const dependencies = createDependencies(true); + it("does not match or execute recipes for an unclaimed bypass request", async () => { + const dependencies = createDependencies(false); await expect( routePrompt( @@ -127,7 +136,8 @@ describe("macro recording routing override", () => { dependencies, ), ).resolves.toEqual({}); - expect(dependencies.claimRecording).not.toHaveBeenCalled(); + expect(dependencies.claimRecording).toHaveBeenCalledWith(input); + expect(dependencies.localRecipes).not.toHaveBeenCalled(); }); }); From 276b30dffb1c5ddb293e3ccb6aca64b88269de6d Mon Sep 17 00:00:00 2001 From: George Ng Date: Fri, 23 Oct 2026 17:15:31 -0700 Subject: [PATCH 2/2] fix(copilot-plugin): keep absent recording endpoints quiet Update the bypass mode assertion to expect recording checks, preserve silent no-host behavior, and verify malformed endpoint diagnostics remain visible. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0040b8ff-2701-4fb7-821f-760e05fe6b4b --- ts/packages/copilot-plugin/README.md | 2 ++ .../copilot-plugin/src/hooks/hook-router.ts | 9 ++++++ .../test/controlMetadata.spec.ts | 31 +++++++++++++++++-- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/ts/packages/copilot-plugin/README.md b/ts/packages/copilot-plugin/README.md index 26918d74bd..ecf3c4c785 100644 --- a/ts/packages/copilot-plugin/README.md +++ b/ts/packages/copilot-plugin/README.md @@ -56,6 +56,8 @@ recipe matcher. A miss stays with Copilot; bypass disables recipe admission. Explicitly armed recordings are still claimed in bypass mode, without matching or executing a recipe or delegating the task to TypeAgent. Recording feature flags and `recipeOwner: off` still disable recording. +An absent session recording endpoint is normal and silent; invalid endpoints +and other recording failures still produce diagnostics. There is no PowerShell pre-tool redirect or alternate MCP/dev macro owner. Actual native per-invocation receipts are still required. diff --git a/ts/packages/copilot-plugin/src/hooks/hook-router.ts b/ts/packages/copilot-plugin/src/hooks/hook-router.ts index d3c91cad13..69e6fac85a 100644 --- a/ts/packages/copilot-plugin/src/hooks/hook-router.ts +++ b/ts/packages/copilot-plugin/src/hooks/hook-router.ts @@ -45,6 +45,7 @@ import { } from "../shared/plugin-config.js"; import { handleRecipeCommand } from "../shared/recipe-commands.js"; import { connectToMacroOwner } from "../shared/macro-owner.js"; +import { localRecipeEndpoint } from "../shared/recipe-local-paths.js"; import { handleLocalRecipes } from "./hook-local-recipes.js"; import { makeTurnId, writeDemoState } from "./demo-state.js"; import { handleDirect, type DirectHandlingOptions } from "./hook-direct.js"; @@ -345,6 +346,14 @@ async function claimMacroRecording( }); return token?.learning ? "child" : token !== undefined; } catch (error) { + if ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" && + "path" in error && + error.path === localRecipeEndpoint(input.cwd, input.sessionId) + ) + return false; console.error( `[macro] Unable to claim recording: ${error instanceof Error ? error.message : String(error)}`, ); diff --git a/ts/packages/copilot-plugin/test/controlMetadata.spec.ts b/ts/packages/copilot-plugin/test/controlMetadata.spec.ts index 66dd9c3d5e..f0dee53dbd 100644 --- a/ts/packages/copilot-plugin/test/controlMetadata.spec.ts +++ b/ts/packages/copilot-plugin/test/controlMetadata.spec.ts @@ -12,7 +12,7 @@ import { unwatchFile, } from "node:fs"; import { once } from "node:events"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { publishControlMetadata, @@ -20,6 +20,7 @@ import { monitorControlMetadata, } from "../src/shared/control-metadata.js"; import { routePrompt } from "../src/hooks/hook-router.js"; +import { localRecipeEndpoint } from "../src/shared/recipe-local-paths.js"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { createExtensionCommands } from "../src/extension/commands.js"; @@ -279,7 +280,8 @@ describe("owned control metadata", () => { dependencies, ), ).resolves.toEqual({}); - expect(dependencies.claimRecording).not.toHaveBeenCalled(); + expect(dependencies.claimRecording).toHaveBeenCalledTimes(1); + expect(dependencies.claimRecording).toHaveBeenCalledWith(request); expect(dependencies.localRecipes).not.toHaveBeenCalled(); writeFileSync( file, @@ -390,6 +392,31 @@ describe("owned control metadata", () => { "Native control config binding mismatch; no translation admitted", ); }); + it("reports malformed recording endpoints without handling bypass work", async () => { + const request = { + sessionId: "test-session", + timestamp: 1, + cwd: root, + prompt: "Read the synthetic document", + }; + const endpoint = localRecipeEndpoint(root, request.sessionId); + mkdirSync(dirname(endpoint), { recursive: true }); + writeFileSync(endpoint, "{}"); + const diagnostic = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + try { + await expect( + routePrompt(request, "bypass", new AbortController().signal), + ).resolves.toEqual({}); + expect(diagnostic).toHaveBeenCalledTimes(1); + expect(diagnostic).toHaveBeenCalledWith( + "[macro] Unable to claim recording: Invalid local recipe endpoint.", + ); + } finally { + diagnostic.mockRestore(); + } + }); it("registered status control getter never calls sendTask", async () => { writeFileSync( join(root, "plugin-data", "config.json"),