Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions ts/packages/copilot-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ 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.
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.

Expand Down Expand Up @@ -646,7 +651,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.
Expand Down Expand Up @@ -803,8 +809,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.
Expand Down Expand Up @@ -1081,7 +1092,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 <request>` intentionally delegates to the TypeAgent dispatcher;
Expand Down
11 changes: 10 additions & 1 deletion ts/packages/copilot-plugin/src/hooks/hook-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -316,7 +317,6 @@ export async function routePrompt(
signal: AbortSignal,
dependencies: RoutePromptDependencies = routePromptDefaults,
): Promise<UserPromptSubmittedOutput> {
if (mode === "bypass") return {};
const owner = (dependencies.recipeOwner ?? getRecipeOwner)();
if (owner === "off") return {};
const recording = await dependencies.claimRecording(input);
Expand All @@ -326,6 +326,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);
}

Expand All @@ -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)}`,
);
Expand Down
31 changes: 29 additions & 2 deletions ts/packages/copilot-plugin/test/controlMetadata.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ 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,
readControlledMode,
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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
Expand Down
80 changes: 45 additions & 35 deletions ts/packages/copilot-plugin/test/hookRouter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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);
Expand All @@ -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(
Expand All @@ -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();
});
});

Expand Down
Loading