Compare commits
4 Commits
d29ee5ce46
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a8a8547355 | |||
| 8990a94321 | |||
| f01d4c2264 | |||
| 6213281b3c |
@@ -19,6 +19,7 @@ Pygienium is a `pi` extension: code-hygiene checks that scan a target with isola
|
|||||||
|
|
||||||
- Checks: `gate` (skip reason), `buildScanTask`, optional `buildFixTask`, optional `verify`. Scan tasks embed `!write`/`!echo` lines as deterministic fallback for the fake runner.
|
- Checks: `gate` (skip reason), `buildScanTask`, optional `buildFixTask`, optional `verify`. Scan tasks embed `!write`/`!echo` lines as deterministic fallback for the fake runner.
|
||||||
- Sub-agent sessions must be pinned to the invoking session's model: thread `ctx.model` through `PygieniumCtx` → run options → `AgentTaskOptions.model` → `createAgentSession({ model })`.
|
- Sub-agent sessions must be pinned to the invoking session's model: thread `ctx.model` through `PygieniumCtx` → run options → `AgentTaskOptions.model` → `createAgentSession({ model })`.
|
||||||
|
- A pinned model needs its provider too: a provider an extension registered (`pi.registerProvider`) exists only in the invoking `ModelRegistry`, and the sub-session runs `noExtensions`, so thread `ctx.modelRegistry` (narrowed by `asProviderRegistry`) through the same chain to `AgentTaskOptions.modelRegistry` → `providerForwardingExtension`, which re-registers that one provider through an inline factory. Drop that link and every check dies with `No API key found for <provider>`.
|
||||||
- Keep files modular; no compatibility shims.
|
- Keep files modular; no compatibility shims.
|
||||||
|
|
||||||
## Commit process (IMPORTANT)
|
## Commit process (IMPORTANT)
|
||||||
@@ -29,7 +30,7 @@ Commits are gated by the pre-commit hook (enable: `git config core.hooksPath .gi
|
|||||||
|
|
||||||
- `src/index.ts` — removes `mode: ctx.mode` from the `pygieniumCtx` literal
|
- `src/index.ts` — removes `mode: ctx.mode` from the `pygieniumCtx` literal
|
||||||
- `src/commands.ts` — removes `mode` from the `PygieniumCtx` Pick; swaps `ctx.mode` for `ctx.hasUI` in `print`
|
- `src/commands.ts` — removes `mode` from the `PygieniumCtx` Pick; swaps `ctx.mode` for `ctx.hasUI` in `print`
|
||||||
- `src/agent-runner.ts` — swaps the SDK import + loader for the omp SDK's `createAgentSession` options (`toolNames`, `systemPrompt`, `agentRegistry`, …)
|
- `src/agent-runner.ts` — swaps the SDK import + loader for the omp SDK's `createAgentSession` options (`toolNames`, `systemPrompt`, `agentRegistry`, …); reduces `ProviderRegistry` to an SDK-free shape and stubs `providerForwardingExtension` (the port disables extension discovery, so it has nothing to forward)
|
||||||
- `src/agents.ts` — `find` → `glob`
|
- `src/agents.ts` — `find` → `glob`
|
||||||
|
|
||||||
After touching any of those, run `bun port-to-omp.mjs --out <tmp>` and `(cd <tmp> && bun run typecheck)` before committing; the hook does the same.
|
After touching any of those, run `bun port-to-omp.mjs --out <tmp>` and `(cd <tmp> && bun run typecheck)` before committing; the hook does the same.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mikefreno/pygienium",
|
"name": "@mikefreno/pygienium",
|
||||||
"version": "0.1.2",
|
"version": "0.1.4",
|
||||||
"description": "Code hygiene extension for pi — isolated sub-agent checks that scan a target, apply fixes, and emit a findings+changes report.",
|
"description": "Code hygiene extension for pi — isolated sub-agent checks that scan a target, apply fixes, and emit a findings+changes report.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"pi-package",
|
"pi-package",
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ const OMP = join(HOME, ".omp", "agent", "extensions");
|
|||||||
const SKIP = new Set([
|
const SKIP = new Set([
|
||||||
"port-to-omp.mjs",
|
"port-to-omp.mjs",
|
||||||
"release-tag.sh",
|
"release-tag.sh",
|
||||||
|
// Pins pi-only behaviour: provider forwarding into an in-memory session. The
|
||||||
|
// port disables extension discovery, so it has no such behaviour to assert
|
||||||
|
// (see the agent-runner rules below).
|
||||||
|
"provider-forwarding.test.ts",
|
||||||
".gitea",
|
".gitea",
|
||||||
".github",
|
".github",
|
||||||
"node_modules",
|
"node_modules",
|
||||||
@@ -246,9 +250,28 @@ const FILE_RULES = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
"src/agent-runner.ts": [
|
"src/agent-runner.ts": [
|
||||||
|
{
|
||||||
|
// The port has no extension provider registrations to read: its sessions
|
||||||
|
// are created with extension discovery disabled and its ModelRegistry
|
||||||
|
// has no getRegistered*Provider lookups. Keep only the shape the plumbing
|
||||||
|
// passes around, so the ported files stay type-correct.
|
||||||
|
re: /\/\*\* The invoking session's provider registry[\s\S]*?\n\}\n/,
|
||||||
|
to:
|
||||||
|
"/**\n * Provider registrations as the omp port passes them around. The port does not\n * forward any: omp sessions disable extension discovery, and its ModelRegistry\n * keeps no extension provider registrations to forward.\n */\nexport interface ProviderRegistry {\n\tgetRegisteredNativeProvider(providerId: string): object | undefined;\n\tgetRegisteredProviderConfig(providerId: string): object | undefined;\n}\n",
|
||||||
|
label: "provider registry typing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The inline provider extension has no purpose in the port — extension
|
||||||
|
// discovery is disabled when the session is created — so the port carries
|
||||||
|
// only the stub the plumbing references.
|
||||||
|
re: /\n\/\*\*\n \* Build the inline extension that re-registers a provider inside a sub-session\.[\s\S]*?\n\t\};\n\}\n/,
|
||||||
|
to:
|
||||||
|
"\n/**\n * The omp port forwards no provider registrations (see `ProviderRegistry`), so\n * sub-agent sessions get no inline extension.\n */\nexport function providerForwardingExtension(): undefined {\n\treturn undefined;\n}\n",
|
||||||
|
label: "provider forwarding stub",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
from:
|
from:
|
||||||
"\t// Lazily import the SDK so the rest of the module graph (and tests using the\n\t// fake runner) never resolve the heavy pi-coding-agent package.\n\tconst {\n\t\tcreateAgentSession,\n\t\tDefaultResourceLoader,\n\t\tgetAgentDir,\n\t\tSessionManager,\n\t} = await import(\"@oh-my-pi/pi-coding-agent\");\n\tconst loader = new DefaultResourceLoader({\n\t\tcwd: opts.cwd,\n\t\tagentDir: getAgentDir(),\n\t\tsystemPromptOverride: () => agent.systemPrompt,\n\t\t// Keep the sub-agent isolated: no nested extensions/skills/themes/etc.\n\t\tnoExtensions: true,\n\t\tnoSkills: true,\n\t\tnoThemes: true,\n\t\tnoPromptTemplates: true,\n\t});\n\tawait loader.reload();",
|
"\t// Lazily import the SDK so the rest of the module graph (and tests using the\n\t// fake runner) never resolve the heavy pi-coding-agent package.\n\tconst {\n\t\tcreateAgentSession,\n\t\tDefaultResourceLoader,\n\t\tgetAgentDir,\n\t\tSessionManager,\n\t} = await import(\"@oh-my-pi/pi-coding-agent\");\n\t// Re-register the pinned model's provider inside the sub-session: inline\n\t// extension factories load even under `noExtensions`, so this is the one\n\t// registration a pinned model needs in order to authenticate.\n\tconst providerExtension = providerForwardingExtension(\n\t\topts.modelRegistry,\n\t\topts.model?.provider,\n\t);\n\tconst loader = new DefaultResourceLoader({\n\t\tcwd: opts.cwd,\n\t\tagentDir: getAgentDir(),\n\t\tsystemPromptOverride: () => agent.systemPrompt,\n\t\t// Keep the sub-agent isolated: no nested extensions/skills/themes/etc.\n\t\tnoExtensions: true,\n\t\tnoSkills: true,\n\t\tnoThemes: true,\n\t\tnoPromptTemplates: true,\n\t\textensionFactories: providerExtension ? [providerExtension] : [],\n\t});\n\tawait loader.reload();",
|
||||||
to:
|
to:
|
||||||
"\t// Lazily import the SDK so the rest of the module graph (and tests using the\n\t// fake runner) never resolve the heavy pi-coding-agent package. The specifier\n\t// is static by intent (dynamic-import exception: module is intentionally\n\t// excluded from the import-time graph to keep fake-runner tests SDK-free).\n\tconst { createAgentSession, AgentRegistry, SessionManager } = await import(\n\t\t\"@oh-my-pi/pi-coding-agent\"\n\t);",
|
"\t// Lazily import the SDK so the rest of the module graph (and tests using the\n\t// fake runner) never resolve the heavy pi-coding-agent package. The specifier\n\t// is static by intent (dynamic-import exception: module is intentionally\n\t// excluded from the import-time graph to keep fake-runner tests SDK-free).\n\tconst { createAgentSession, AgentRegistry, SessionManager } = await import(\n\t\t\"@oh-my-pi/pi-coding-agent\"\n\t);",
|
||||||
label: "SDK import + loader removal",
|
label: "SDK import + loader removal",
|
||||||
|
|||||||
@@ -20,10 +20,61 @@ import { dirname, isAbsolute, join } from "node:path";
|
|||||||
import type {
|
import type {
|
||||||
AgentSession,
|
AgentSession,
|
||||||
AgentSessionEvent,
|
AgentSessionEvent,
|
||||||
|
ExtensionAPI,
|
||||||
ExtensionCommandContext,
|
ExtensionCommandContext,
|
||||||
} from "@earendil-works/pi-coding-agent";
|
} from "@earendil-works/pi-coding-agent";
|
||||||
import { loadAgents, extensionRoot, type AgentDef } from "./agents.js";
|
import { loadAgents, extensionRoot, type AgentDef } from "./agents.js";
|
||||||
|
|
||||||
|
/** The invoking session's provider registry, as pi types it. */
|
||||||
|
type HostModelRegistry = ExtensionCommandContext["modelRegistry"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two capabilities a sub-agent needs from the invoking session's
|
||||||
|
* `ModelRegistry`: the registrations extensions made through
|
||||||
|
* `pi.registerProvider`. Values are left in their host types — this module
|
||||||
|
* relays them, it never interprets them.
|
||||||
|
*/
|
||||||
|
export interface ProviderRegistry {
|
||||||
|
getRegisteredNativeProvider(
|
||||||
|
providerId: string,
|
||||||
|
): ReturnType<HostModelRegistry["getRegisteredNativeProvider"]>;
|
||||||
|
getRegisteredProviderConfig(
|
||||||
|
providerId: string,
|
||||||
|
): ReturnType<HostModelRegistry["getRegisteredProviderConfig"]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inline extension that re-registers a provider in a sub-session. Spelled out
|
||||||
|
* structurally — an `InlineExtension` in pi — so the generated omp port, which
|
||||||
|
* names the type differently, still compiles.
|
||||||
|
*/
|
||||||
|
export interface InlineProviderExtension {
|
||||||
|
name: string;
|
||||||
|
hidden: boolean;
|
||||||
|
factory: (pi: ExtensionAPI) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrow a host context's `modelRegistry` to a {@link ProviderRegistry},
|
||||||
|
* returning `undefined` when it cannot answer the two questions this module
|
||||||
|
* asks. This is the decode boundary: everything downstream works with the
|
||||||
|
* narrow interface, and a host whose registry does not know extension
|
||||||
|
* registrations simply gets no provider forwarding.
|
||||||
|
*/
|
||||||
|
export function asProviderRegistry(
|
||||||
|
candidate: unknown,
|
||||||
|
): ProviderRegistry | undefined {
|
||||||
|
if (!candidate || typeof candidate !== "object") return undefined;
|
||||||
|
const registry = candidate as Partial<ProviderRegistry>;
|
||||||
|
if (
|
||||||
|
typeof registry.getRegisteredNativeProvider !== "function" ||
|
||||||
|
typeof registry.getRegisteredProviderConfig !== "function"
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return registry as ProviderRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Env var overriding the per-agent settle timeout (ms). Guards against a
|
* Env var overriding the per-agent settle timeout (ms). Guards against a
|
||||||
* sub-agent session that never settles (stalled provider stream, hung retry /
|
* sub-agent session that never settles (stalled provider stream, hung retry /
|
||||||
@@ -58,6 +109,12 @@ export interface AgentTaskOptions {
|
|||||||
* `createAgentSession` falls back to the settings default model.
|
* `createAgentSession` falls back to the settings default model.
|
||||||
*/
|
*/
|
||||||
model?: ExtensionCommandContext["model"];
|
model?: ExtensionCommandContext["model"];
|
||||||
|
/**
|
||||||
|
* The invoking session's provider registry, used to make the pinned
|
||||||
|
* `model`'s provider resolvable inside the sub-session (see
|
||||||
|
* {@link providerForwardingExtension}).
|
||||||
|
*/
|
||||||
|
modelRegistry?: ProviderRegistry;
|
||||||
/** Optional explicit agent definition (skips `loadAgents`). */
|
/** Optional explicit agent definition (skips `loadAgents`). */
|
||||||
agent?: AgentDef;
|
agent?: AgentDef;
|
||||||
/**
|
/**
|
||||||
@@ -166,6 +223,49 @@ export function resetAgentRunner(): void {
|
|||||||
currentRunner = defaultAgentRunner;
|
currentRunner = defaultAgentRunner;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the inline extension that re-registers a provider inside a sub-session.
|
||||||
|
*
|
||||||
|
* A provider that exists only because an extension called
|
||||||
|
* `pi.registerProvider` (custom base URL, wrapped stream, its own auth wiring)
|
||||||
|
* lives in the invoking session's registry and nowhere else — not in
|
||||||
|
* `auth.json`, not in `models.json`. Sub-sessions are deliberately created with
|
||||||
|
* `noExtensions: true`, so their own `ModelRuntime` cannot resolve credentials
|
||||||
|
* for such a provider: pinning the invoking session's model then dies with
|
||||||
|
* pi's `No API key found for <provider>` before the first token, which reads as
|
||||||
|
* a pygienium failure on a perfectly healthy login.
|
||||||
|
*
|
||||||
|
* Inline extension factories load regardless of `noExtensions`, so registering
|
||||||
|
* the pinned model's provider there is the narrow cost of pinning: the
|
||||||
|
* sub-agent authenticates as the host does, while every other extension stays
|
||||||
|
* out of its scope.
|
||||||
|
*
|
||||||
|
* Returns `undefined` when there is nothing to forward — no registry, no pinned
|
||||||
|
* model, or a provider pi already knows from its own catalogue (built-ins and
|
||||||
|
* `models.json` providers need no registration).
|
||||||
|
*/
|
||||||
|
export function providerForwardingExtension(
|
||||||
|
registry: ProviderRegistry | undefined,
|
||||||
|
providerId: string | undefined,
|
||||||
|
): InlineProviderExtension | undefined {
|
||||||
|
if (!registry || !providerId) return undefined;
|
||||||
|
// A native provider carries its own stream/auth implementation, so it wins
|
||||||
|
// over a config-only registration (which only augments a built-in one).
|
||||||
|
const native = registry.getRegisteredNativeProvider(providerId);
|
||||||
|
const config = native
|
||||||
|
? undefined
|
||||||
|
: registry.getRegisteredProviderConfig(providerId);
|
||||||
|
if (!native && !config) return undefined;
|
||||||
|
return {
|
||||||
|
name: "pygienium-provider",
|
||||||
|
hidden: true,
|
||||||
|
factory: (pi) => {
|
||||||
|
if (native) pi.registerProvider(native);
|
||||||
|
else if (config) pi.registerProvider(providerId, config);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Real sub-agent runner: spins up an in-memory `AgentSession` scoped to `cwd`,
|
* Real sub-agent runner: spins up an in-memory `AgentSession` scoped to `cwd`,
|
||||||
* overrides the system prompt with the agent definition's body, restricts tools
|
* overrides the system prompt with the agent definition's body, restricts tools
|
||||||
@@ -197,6 +297,13 @@ export async function defaultAgentRunner(
|
|||||||
getAgentDir,
|
getAgentDir,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
} = await import("@earendil-works/pi-coding-agent");
|
} = await import("@earendil-works/pi-coding-agent");
|
||||||
|
// Re-register the pinned model's provider inside the sub-session: inline
|
||||||
|
// extension factories load even under `noExtensions`, so this is the one
|
||||||
|
// registration a pinned model needs in order to authenticate.
|
||||||
|
const providerExtension = providerForwardingExtension(
|
||||||
|
opts.modelRegistry,
|
||||||
|
opts.model?.provider,
|
||||||
|
);
|
||||||
const loader = new DefaultResourceLoader({
|
const loader = new DefaultResourceLoader({
|
||||||
cwd: opts.cwd,
|
cwd: opts.cwd,
|
||||||
agentDir: getAgentDir(),
|
agentDir: getAgentDir(),
|
||||||
@@ -206,6 +313,7 @@ export async function defaultAgentRunner(
|
|||||||
noSkills: true,
|
noSkills: true,
|
||||||
noThemes: true,
|
noThemes: true,
|
||||||
noPromptTemplates: true,
|
noPromptTemplates: true,
|
||||||
|
extensionFactories: providerExtension ? [providerExtension] : [],
|
||||||
});
|
});
|
||||||
await loader.reload();
|
await loader.reload();
|
||||||
|
|
||||||
|
|||||||
@@ -44,12 +44,20 @@ import {
|
|||||||
type ExportFilters,
|
type ExportFilters,
|
||||||
} from "./export.js";
|
} from "./export.js";
|
||||||
import type { SendChatMessage } from "./phases.js";
|
import type { SendChatMessage } from "./phases.js";
|
||||||
|
import type { ProviderRegistry } from "./agent-runner.js";
|
||||||
|
|
||||||
/** Narrow context slice handlers need (a subset of `ExtensionCommandContext`). */
|
/** Narrow context slice handlers need (a subset of `ExtensionCommandContext`). */
|
||||||
export type PygieniumCtx = Pick<
|
export type PygieniumCtx = Pick<
|
||||||
ExtensionCommandContext,
|
ExtensionCommandContext,
|
||||||
"cwd" | "mode" | "hasUI" | "ui" | "model"
|
"cwd" | "mode" | "hasUI" | "ui" | "model"
|
||||||
> & {
|
> & {
|
||||||
|
/**
|
||||||
|
* The invoking session's extension provider registrations, already narrowed
|
||||||
|
* at the extension entry point (see `asProviderRegistry`). Carried into every
|
||||||
|
* sub-agent so a pinned model whose provider only an extension registered
|
||||||
|
* stays resolvable there.
|
||||||
|
*/
|
||||||
|
modelRegistry?: ProviderRegistry;
|
||||||
/** Optional callback to post messages to the chat window. */
|
/** Optional callback to post messages to the chat window. */
|
||||||
sendChatMessage?: SendChatMessage;
|
sendChatMessage?: SendChatMessage;
|
||||||
/** Optional callback forwarding raw sub-agent events to the chat stream. */
|
/** Optional callback forwarding raw sub-agent events to the chat stream. */
|
||||||
@@ -177,6 +185,7 @@ export async function handleCheckCommand(
|
|||||||
ui: ctx.ui,
|
ui: ctx.ui,
|
||||||
hasUI: ctx.hasUI,
|
hasUI: ctx.hasUI,
|
||||||
model: ctx.model,
|
model: ctx.model,
|
||||||
|
modelRegistry: ctx.modelRegistry,
|
||||||
sendChatMessage: ctx.sendChatMessage,
|
sendChatMessage: ctx.sendChatMessage,
|
||||||
onAgentEvent: ctx.onAgentEvent,
|
onAgentEvent: ctx.onAgentEvent,
|
||||||
sendPhaseLine: ctx.sendPhaseLine,
|
sendPhaseLine: ctx.sendPhaseLine,
|
||||||
@@ -217,6 +226,7 @@ export async function handleAllCommand(
|
|||||||
ui: ctx.ui,
|
ui: ctx.ui,
|
||||||
hasUI: ctx.hasUI,
|
hasUI: ctx.hasUI,
|
||||||
model: ctx.model,
|
model: ctx.model,
|
||||||
|
modelRegistry: ctx.modelRegistry,
|
||||||
sendChatMessage: ctx.sendChatMessage,
|
sendChatMessage: ctx.sendChatMessage,
|
||||||
onAgentEvent: ctx.onAgentEvent,
|
onAgentEvent: ctx.onAgentEvent,
|
||||||
sendPhaseLine: ctx.sendPhaseLine,
|
sendPhaseLine: ctx.sendPhaseLine,
|
||||||
@@ -306,6 +316,7 @@ export async function handleResumeCommand(
|
|||||||
ui: ctx.ui,
|
ui: ctx.ui,
|
||||||
hasUI: ctx.hasUI,
|
hasUI: ctx.hasUI,
|
||||||
model: ctx.model,
|
model: ctx.model,
|
||||||
|
modelRegistry: ctx.modelRegistry,
|
||||||
existingState: state,
|
existingState: state,
|
||||||
sendChatMessage: ctx.sendChatMessage,
|
sendChatMessage: ctx.sendChatMessage,
|
||||||
onAgentEvent: ctx.onAgentEvent,
|
onAgentEvent: ctx.onAgentEvent,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import type {
|
|||||||
import { Box, Text } from "@earendil-works/pi-tui";
|
import { Box, Text } from "@earendil-works/pi-tui";
|
||||||
import "./checks/all.js";
|
import "./checks/all.js";
|
||||||
import { registerPygieniumCommands, type PygieniumCtx } from "./commands.js";
|
import { registerPygieniumCommands, type PygieniumCtx } from "./commands.js";
|
||||||
|
import { asProviderRegistry } from "./agent-runner.js";
|
||||||
import {
|
import {
|
||||||
type SendChatMessage,
|
type SendChatMessage,
|
||||||
type CheckCompletionDetails,
|
type CheckCompletionDetails,
|
||||||
@@ -405,6 +406,7 @@ export default async function pygieniumExtension(
|
|||||||
hasUI: ctx.hasUI,
|
hasUI: ctx.hasUI,
|
||||||
ui: ctx.ui,
|
ui: ctx.ui,
|
||||||
model: ctx.model,
|
model: ctx.model,
|
||||||
|
modelRegistry: asProviderRegistry(ctx.modelRegistry),
|
||||||
sendChatMessage,
|
sendChatMessage,
|
||||||
onAgentEvent,
|
onAgentEvent,
|
||||||
sendPhaseLine,
|
sendPhaseLine,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import type {
|
|||||||
} from "@earendil-works/pi-coding-agent";
|
} from "@earendil-works/pi-coding-agent";
|
||||||
import { getAllChecks, type CheckDefinition } from "../checks/registry.js";
|
import { getAllChecks, type CheckDefinition } from "../checks/registry.js";
|
||||||
import { runCheck } from "./check-runner.js";
|
import { runCheck } from "./check-runner.js";
|
||||||
|
import type { ProviderRegistry } from "../agent-runner.js";
|
||||||
import { createPhaseStrip, type SendChatMessage } from "../phases.js";
|
import { createPhaseStrip, type SendChatMessage } from "../phases.js";
|
||||||
import { createPipelineFooter, type ItemStatus } from "../footer.js";
|
import { createPipelineFooter, type ItemStatus } from "../footer.js";
|
||||||
import { runRecon } from "../recon.js";
|
import { runRecon } from "../recon.js";
|
||||||
@@ -42,6 +43,8 @@ import {
|
|||||||
reconcileRunStatus,
|
reconcileRunStatus,
|
||||||
resetCheckEntry,
|
resetCheckEntry,
|
||||||
saveRunState,
|
saveRunState,
|
||||||
|
settleTerminalPhases,
|
||||||
|
shouldResumeRun,
|
||||||
shouldRunOnResume,
|
shouldRunOnResume,
|
||||||
stateDir,
|
stateDir,
|
||||||
type RunState,
|
type RunState,
|
||||||
@@ -83,6 +86,12 @@ export interface AllRunOptions {
|
|||||||
* the settings default.
|
* the settings default.
|
||||||
*/
|
*/
|
||||||
model?: ExtensionCommandContext["model"];
|
model?: ExtensionCommandContext["model"];
|
||||||
|
/**
|
||||||
|
* The invoking session's provider registry; carried into every check's
|
||||||
|
* sub-agents so a provider only an extension registered stays resolvable
|
||||||
|
* (see `providerForwardingExtension` in `agent-runner.ts`).
|
||||||
|
*/
|
||||||
|
modelRegistry?: ProviderRegistry;
|
||||||
/** Optional callback to post completion messages into the chat. */
|
/** Optional callback to post completion messages into the chat. */
|
||||||
sendChatMessage?: SendChatMessage;
|
sendChatMessage?: SendChatMessage;
|
||||||
/** Optional callback forwarding raw sub-agent events to the chat stream. */
|
/** Optional callback forwarding raw sub-agent events to the chat stream. */
|
||||||
@@ -275,9 +284,14 @@ export async function runAllChecks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Init / resume the single shared run-state --------------------------
|
// --- Init / resume the single shared run-state --------------------------
|
||||||
|
// A cleanly completed run is finished work, not a run to continue: a new
|
||||||
|
// invocation is a new scan. Without this, re-running `/pygienium-all`
|
||||||
|
// dispatched nothing but whatever an interrupted run had left non-terminal.
|
||||||
|
const stored = await loadRunState(cwd);
|
||||||
let state =
|
let state =
|
||||||
(await loadRunState(cwd)) ??
|
stored && shouldResumeRun(stored)
|
||||||
initRunState(
|
? stored
|
||||||
|
: initRunState(
|
||||||
cwd,
|
cwd,
|
||||||
selected.map((c) => ({ name: c.name, label: c.label, fix })),
|
selected.map((c) => ({ name: c.name, label: c.label, fix })),
|
||||||
);
|
);
|
||||||
@@ -356,6 +370,11 @@ export async function runAllChecks(
|
|||||||
// Resumability: skip terminal checks unless --fresh.
|
// Resumability: skip terminal checks unless --fresh.
|
||||||
if (entry && !shouldRunOnResume(entry, fresh)) {
|
if (entry && !shouldRunOnResume(entry, fresh)) {
|
||||||
skipped.push(check.name);
|
skipped.push(check.name);
|
||||||
|
// The entry may still carry a phase an interrupted run left
|
||||||
|
// `in_progress`; settle it so the state never reads as both finished
|
||||||
|
// and still running.
|
||||||
|
settleTerminalPhases(entry);
|
||||||
|
await saveRunState(state);
|
||||||
footerSettle(
|
footerSettle(
|
||||||
check.name,
|
check.name,
|
||||||
entry.status === "complete" ? "skipped" : "skipped",
|
entry.status === "complete" ? "skipped" : "skipped",
|
||||||
@@ -379,6 +398,7 @@ export async function runAllChecks(
|
|||||||
ui: opts.ui,
|
ui: opts.ui,
|
||||||
hasUI,
|
hasUI,
|
||||||
model: opts.model,
|
model: opts.model,
|
||||||
|
modelRegistry: opts.modelRegistry,
|
||||||
existingState: state,
|
existingState: state,
|
||||||
sendChatMessage: opts.sendChatMessage,
|
sendChatMessage: opts.sendChatMessage,
|
||||||
onAgentEvent: opts.onAgentEvent,
|
onAgentEvent: opts.onAgentEvent,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import type {
|
|||||||
ExtensionUIContext,
|
ExtensionUIContext,
|
||||||
} from "@earendil-works/pi-coding-agent";
|
} from "@earendil-works/pi-coding-agent";
|
||||||
import type { CheckDefinition, CheckScope } from "../checks/registry.js";
|
import type { CheckDefinition, CheckScope } from "../checks/registry.js";
|
||||||
import { runAgentTask } from "../agent-runner.js";
|
import { runAgentTask, type ProviderRegistry } from "../agent-runner.js";
|
||||||
import { runRecon } from "../recon.js";
|
import { runRecon } from "../recon.js";
|
||||||
import {
|
import {
|
||||||
createPhaseStrip,
|
createPhaseStrip,
|
||||||
@@ -93,6 +93,12 @@ export interface RunCheckOptions {
|
|||||||
* settings default.
|
* settings default.
|
||||||
*/
|
*/
|
||||||
model?: ExtensionCommandContext["model"];
|
model?: ExtensionCommandContext["model"];
|
||||||
|
/**
|
||||||
|
* The invoking session's provider registry; carried into every sub-agent so a
|
||||||
|
* provider only an extension registered stays resolvable (see
|
||||||
|
* `providerForwardingExtension` in `agent-runner.ts`).
|
||||||
|
*/
|
||||||
|
modelRegistry?: ProviderRegistry;
|
||||||
/** Pre-existing run state to update (for `/pygienium-all` and resume). */
|
/** Pre-existing run state to update (for `/pygienium-all` and resume). */
|
||||||
existingState?: RunState;
|
existingState?: RunState;
|
||||||
/** Optional callback to post completion messages into the chat. */
|
/** Optional callback to post completion messages into the chat. */
|
||||||
@@ -288,6 +294,7 @@ async function runCheckImplInner(
|
|||||||
agentName: check.agentName,
|
agentName: check.agentName,
|
||||||
task: scanTask,
|
task: scanTask,
|
||||||
model: opts.model,
|
model: opts.model,
|
||||||
|
modelRegistry: opts.modelRegistry,
|
||||||
onEvent: forward(PHASE_ANALYSIS),
|
onEvent: forward(PHASE_ANALYSIS),
|
||||||
});
|
});
|
||||||
findings = scanResult.text;
|
findings = scanResult.text;
|
||||||
@@ -321,6 +328,7 @@ async function runCheckImplInner(
|
|||||||
agentName: check.fixAgentName ?? "fixer",
|
agentName: check.fixAgentName ?? "fixer",
|
||||||
task: fixTask,
|
task: fixTask,
|
||||||
model: opts.model,
|
model: opts.model,
|
||||||
|
modelRegistry: opts.modelRegistry,
|
||||||
onEvent: forward(PHASE_FIX),
|
onEvent: forward(PHASE_FIX),
|
||||||
});
|
});
|
||||||
changes = fixResult.text;
|
changes = fixResult.text;
|
||||||
|
|||||||
@@ -269,6 +269,36 @@ export function shouldRunOnResume(check: CheckRun, fresh: boolean): boolean {
|
|||||||
return fresh || !isCheckTerminal(check);
|
return fresh || !isCheckTerminal(check);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a stored run should be continued rather than started over.
|
||||||
|
*
|
||||||
|
* Only a cleanly completed run is finished work: asking for another run then
|
||||||
|
* means a new scan, so its completed entries must not be skipped (doing that
|
||||||
|
* made the second `/pygienium-all` dispatch nothing at all). Anything else —
|
||||||
|
* interrupted, partial, failed — is continued, which is what carries completed
|
||||||
|
* checks forward cheaply while an unfinished one picks up where it stopped.
|
||||||
|
*/
|
||||||
|
export function shouldResumeRun(state: RunState): boolean {
|
||||||
|
return state.status !== "complete";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settle the phases of a terminal check that an interrupted run left
|
||||||
|
* `in_progress`, so the stored state never reads as finished and still running
|
||||||
|
* at once. A completed check's phases become `complete`; a skipped check's
|
||||||
|
* become `skipped`.
|
||||||
|
*/
|
||||||
|
export function settleTerminalPhases(check: CheckRun): void {
|
||||||
|
if (!isCheckTerminal(check)) return;
|
||||||
|
const settled: PhaseStatus = check.status === "complete" ? "complete" : "skipped";
|
||||||
|
for (const phase of check.phases) {
|
||||||
|
if (phase.status === "in_progress") {
|
||||||
|
phase.status = settled;
|
||||||
|
phase.finishedAt ??= check.finishedAt ?? Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset a single check entry back to `pending` with fresh phases. Used by
|
* Reset a single check entry back to `pending` with fresh phases. Used by
|
||||||
* `/pygienium-resume --fresh` so that previously-complete checks are
|
* `/pygienium-resume --fresh` so that previously-complete checks are
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
type AgentRunner,
|
type AgentRunner,
|
||||||
} from "../src/agent-runner.js";
|
} from "../src/agent-runner.js";
|
||||||
import { handleAllCommand, type PygieniumCtx } from "../src/commands.js";
|
import { handleAllCommand, type PygieniumCtx } from "../src/commands.js";
|
||||||
import { loadRunState } from "../src/run-state.js";
|
import { loadRunState, markCheckStatus, markRunStatus, saveRunState } from "../src/run-state.js";
|
||||||
import { canonicalChecksRoot } from "../src/export.js";
|
import { canonicalChecksRoot } from "../src/export.js";
|
||||||
|
|
||||||
/** Stub check whose fake-runner task writes artifacts + echoes a line. */
|
/** Stub check whose fake-runner task writes artifacts + echoes a line. */
|
||||||
@@ -147,16 +147,27 @@ describe("/pygienium-all end-to-end (task 14)", () => {
|
|||||||
expect(out.join("\n")).toContain("no checks registered");
|
expect(out.join("\n")).toContain("no checks registered");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is resumable: a second call reuses the existing run-state", async () => {
|
it("is resumable: an unfinished run-state is continued, not restarted", async () => {
|
||||||
registerCheck(fakeCheck("alpha"));
|
registerCheck(fakeCheck("alpha"));
|
||||||
|
// A completed run is finished work: the next call starts a new run-state.
|
||||||
await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd)));
|
await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd)));
|
||||||
const first = await loadRunState(cwd);
|
const completed = await loadRunState(cwd);
|
||||||
const firstStarted = first!.startedAt;
|
expect(completed!.status).toBe("complete");
|
||||||
|
await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd)));
|
||||||
|
const fresh = await loadRunState(cwd);
|
||||||
|
expect(fresh!.startedAt).toBeGreaterThan(completed!.startedAt);
|
||||||
|
|
||||||
|
// An unfinished one is continued: same run, same startedAt, and the
|
||||||
|
// completed check is not dispatched again.
|
||||||
|
const interrupted = await loadRunState(cwd);
|
||||||
|
markRunStatus(interrupted!, "in_progress");
|
||||||
|
markCheckStatus(interrupted!, "alpha", "in_progress");
|
||||||
|
await saveRunState(interrupted!);
|
||||||
|
const before = (await loadRunState(cwd))!.startedAt;
|
||||||
|
|
||||||
// Second run reloads the existing run-state (same startedAt).
|
|
||||||
await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd)));
|
await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd)));
|
||||||
const second = await loadRunState(cwd);
|
const resumed = await loadRunState(cwd);
|
||||||
expect(second!.startedAt).toBe(firstStarted);
|
expect(resumed!.startedAt).toBe(before);
|
||||||
expect(second!.status).toBe("complete");
|
expect(resumed!.status).toBe("complete");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ import {
|
|||||||
loadRunState,
|
loadRunState,
|
||||||
markCheckStatus,
|
markCheckStatus,
|
||||||
applyPhaseStatus,
|
applyPhaseStatus,
|
||||||
|
initRunState,
|
||||||
|
markRunStatus,
|
||||||
|
saveRunState,
|
||||||
PHASE_RECON,
|
PHASE_RECON,
|
||||||
PHASE_ANALYSIS,
|
PHASE_ANALYSIS,
|
||||||
PHASE_FIX,
|
PHASE_FIX,
|
||||||
@@ -229,41 +232,100 @@ describe("/pygienium-all orchestrator (task 12)", () => {
|
|||||||
expect(summary).not.toContain("## beta");
|
expect(summary).not.toContain("## beta");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips terminal checks on re-run; --fresh re-runs them", async () => {
|
it("a completed run is not resumed: the next invocation runs every check again", async () => {
|
||||||
registerCheck(fakeCheck("alpha"));
|
registerCheck(fakeCheck("alpha"));
|
||||||
registerCheck(fakeCheck("beta"));
|
registerCheck(fakeCheck("beta"));
|
||||||
|
|
||||||
// First run: both complete.
|
// First run: both complete.
|
||||||
await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd)));
|
await captureStdout(() => handleAllCommand("--fix", stubCtx(cwd)));
|
||||||
expect(track.dispatched.length).toBe(4); // 2 checks × 2 phases
|
expect(track.dispatched).toEqual(["alpha", "alpha", "beta", "beta"]);
|
||||||
const firstAlphaFix = await readFile(
|
|
||||||
join(cwd, ".pygienium", "checks", "alpha", "changes.md"),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Second run without --fresh: both already terminal → skipped.
|
// Second run, no flags: the stored run completed, so this is a new scan.
|
||||||
|
// Every check dispatches again — the regression this pins dispatched
|
||||||
|
// nothing but whichever check an interrupted run happened to leave open.
|
||||||
track.dispatched.length = 0;
|
track.dispatched.length = 0;
|
||||||
const out = await captureStdout(() =>
|
const out = await captureStdout(() =>
|
||||||
handleAllCommand("--fix", stubCtx(cwd)),
|
handleAllCommand("--fix", stubCtx(cwd)),
|
||||||
);
|
);
|
||||||
expect(track.dispatched).toHaveLength(0);
|
expect(track.dispatched).toEqual(["alpha", "alpha", "beta", "beta"]);
|
||||||
expect(out.join("\n")).toContain("skipping");
|
expect(out.join("\n")).not.toContain("skipping");
|
||||||
const state2 = await loadRunState(cwd);
|
const state = await loadRunState(cwd);
|
||||||
expect(state2?.status).toBe("complete");
|
expect(state?.status).toBe("complete");
|
||||||
|
expect(state?.checks.alpha?.status).toBe("complete");
|
||||||
|
expect(state?.checks.beta?.status).toBe("complete");
|
||||||
|
|
||||||
// Third run with --fresh: both re-dispatched from scratch.
|
// --fresh remains the explicit reset and dispatches the same way.
|
||||||
track.dispatched.length = 0;
|
track.dispatched.length = 0;
|
||||||
await captureStdout(() => handleAllCommand("--fix --fresh", stubCtx(cwd)));
|
await captureStdout(() => handleAllCommand("--fix --fresh", stubCtx(cwd)));
|
||||||
expect(track.dispatched).toEqual(["alpha", "alpha", "beta", "beta"]);
|
expect(track.dispatched).toEqual(["alpha", "alpha", "beta", "beta"]);
|
||||||
const state3 = await loadRunState(cwd);
|
});
|
||||||
expect(state3?.status).toBe("complete");
|
|
||||||
// The fresh re-run overwrote alpha's changes.md (still valid content).
|
it("resumes an unfinished run: terminal checks skip, the rest re-dispatch", async () => {
|
||||||
const alphaFix2 = await readFile(
|
registerCheck(fakeCheck("alpha"));
|
||||||
join(cwd, ".pygienium", "checks", "alpha", "changes.md"),
|
registerCheck(fakeCheck("beta"));
|
||||||
"utf8",
|
|
||||||
|
// A run that stopped with alpha done and beta failed is unfinished work,
|
||||||
|
// so it continues instead of starting over.
|
||||||
|
const state = initRunState(cwd, [
|
||||||
|
{ name: "alpha", label: "alpha", fix: true },
|
||||||
|
{ name: "beta", label: "beta", fix: true },
|
||||||
|
]);
|
||||||
|
state.recon = {
|
||||||
|
complete: true,
|
||||||
|
path: join(cwd, ".pygienium", "recon.json"),
|
||||||
|
finishedAt: Date.now(),
|
||||||
|
};
|
||||||
|
markComplete(state, "alpha");
|
||||||
|
markCheckStatus(state, "beta", "failed", "provider reset mid-scan");
|
||||||
|
markRunStatus(state, "partial");
|
||||||
|
await saveRunState(state);
|
||||||
|
|
||||||
|
const out = await captureStdout(() =>
|
||||||
|
handleAllCommand("--fix", stubCtx(cwd)),
|
||||||
);
|
);
|
||||||
expect(alphaFix2).toContain("alpha changes");
|
expect(track.dispatched).toEqual(["beta", "beta"]);
|
||||||
void firstAlphaFix;
|
expect(out.join("\n")).toContain("skipping");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("settles a phase an interrupted run left in_progress on a skipped check", async () => {
|
||||||
|
registerCheck(fakeCheck("alpha"));
|
||||||
|
registerCheck(fakeCheck("beta"));
|
||||||
|
|
||||||
|
// alpha finished, but a later interrupted run left its analysis phase open.
|
||||||
|
const state = initRunState(cwd, [
|
||||||
|
{ name: "alpha", label: "alpha", fix: false },
|
||||||
|
{ name: "beta", label: "beta", fix: false },
|
||||||
|
]);
|
||||||
|
state.recon = {
|
||||||
|
complete: true,
|
||||||
|
path: join(cwd, ".pygienium", "recon.json"),
|
||||||
|
finishedAt: Date.now(),
|
||||||
|
};
|
||||||
|
markComplete(state, "alpha");
|
||||||
|
// Directly, because applyPhaseStatus would also move the check itself back
|
||||||
|
// to in_progress — the corruption here is a phase left open behind a
|
||||||
|
// check that still reads complete.
|
||||||
|
const staleAnalysis = state.checks.alpha?.phases.find(
|
||||||
|
(p) => p.id === PHASE_ANALYSIS,
|
||||||
|
);
|
||||||
|
if (!staleAnalysis) throw new Error("alpha has no analysis phase");
|
||||||
|
staleAnalysis.status = "in_progress";
|
||||||
|
applyPhaseStatus(state, "beta", PHASE_ANALYSIS, "in_progress");
|
||||||
|
await saveRunState(state);
|
||||||
|
|
||||||
|
await captureStdout(() => handleAllCommand("", stubCtx(cwd)));
|
||||||
|
|
||||||
|
const after = await loadRunState(cwd);
|
||||||
|
// alpha was skipped as already complete: its stale phase settles to
|
||||||
|
// complete rather than leaving the check finished-but-running.
|
||||||
|
const alphaPhases = after?.checks.alpha?.phases ?? [];
|
||||||
|
expect(alphaPhases.every((p) => p.status !== "in_progress")).toBe(true);
|
||||||
|
expect(
|
||||||
|
alphaPhases.find((p) => p.id === PHASE_ANALYSIS)?.status,
|
||||||
|
).toBe("complete");
|
||||||
|
// beta never finished, so the run resumes it and its scan runs again
|
||||||
|
// (scan-only: this run passes no --fix).
|
||||||
|
expect(track.dispatched).toEqual(["beta"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resumes an interrupted run (re-dispatches non-terminal checks only)", async () => {
|
it("resumes an interrupted run (re-dispatches non-terminal checks only)", async () => {
|
||||||
|
|||||||
105
tests/provider-forwarding.test.ts
Normal file
105
tests/provider-forwarding.test.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* provider-forwarding.test.ts — the provider hand-off from the invoking session
|
||||||
|
* into a sub-agent session.
|
||||||
|
*
|
||||||
|
* A sub-agent session is created with `noExtensions: true`, so a provider that
|
||||||
|
* exists only because an extension called `pi.registerProvider` is absent from
|
||||||
|
* its `ModelRuntime` and its credentials cannot be resolved — pinning the
|
||||||
|
* invoking session's model then fails with `No API key found for <provider>`
|
||||||
|
* before a single token, on a perfectly healthy login. These tests pin the two
|
||||||
|
* halves of the fix: narrowing the host registry at the boundary, and the inline
|
||||||
|
* extension that re-registers the pinned model's provider inside the sub-session
|
||||||
|
* (inline factories load even under `noExtensions`).
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import {
|
||||||
|
asProviderRegistry,
|
||||||
|
providerForwardingExtension,
|
||||||
|
type ProviderRegistry,
|
||||||
|
} from "../src/agent-runner.js";
|
||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
|
||||||
|
/** Record `registerProvider` calls so the relay can be asserted on. */
|
||||||
|
function recorder(): {
|
||||||
|
pi: ExtensionAPI;
|
||||||
|
calls: unknown[][];
|
||||||
|
} {
|
||||||
|
const calls: unknown[][] = [];
|
||||||
|
const pi = {
|
||||||
|
registerProvider: (...args: unknown[]) => {
|
||||||
|
calls.push(args);
|
||||||
|
},
|
||||||
|
} as unknown as ExtensionAPI;
|
||||||
|
return { pi, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registry stub holding one provider id, native and/or config form. */
|
||||||
|
function registry(entry: {
|
||||||
|
native?: unknown;
|
||||||
|
config?: unknown;
|
||||||
|
}): ProviderRegistry {
|
||||||
|
return {
|
||||||
|
getRegisteredNativeProvider: (_id) =>
|
||||||
|
entry.native === undefined ? undefined : entry.native,
|
||||||
|
getRegisteredProviderConfig: (_id) =>
|
||||||
|
entry.config === undefined ? undefined : entry.config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("asProviderRegistry", () => {
|
||||||
|
it("accepts a registry that exposes both registration lookups", () => {
|
||||||
|
const host = registry({ native: { id: "neuralwatt" } });
|
||||||
|
expect(asProviderRegistry(host)).toBe(host);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects values that cannot answer both lookups", () => {
|
||||||
|
expect(asProviderRegistry(undefined)).toBeUndefined();
|
||||||
|
expect(asProviderRegistry(null)).toBeUndefined();
|
||||||
|
expect(asProviderRegistry("modelRegistry")).toBeUndefined();
|
||||||
|
// A registry from a host that never records extension providers, such as
|
||||||
|
// the omp port, degrades to "nothing to forward" instead of throwing.
|
||||||
|
expect(asProviderRegistry({})).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
asProviderRegistry({ getRegisteredNativeProvider: () => undefined }),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("providerForwardingExtension", () => {
|
||||||
|
it("is a no-op without a registry, a pinned model, or a registered provider", () => {
|
||||||
|
// Built-in and models.json providers need no forwarding: pi's own
|
||||||
|
// catalogue already answers for them.
|
||||||
|
const empty = registry({});
|
||||||
|
expect(providerForwardingExtension(undefined, "neuralwatt")).toBeUndefined();
|
||||||
|
expect(providerForwardingExtension(empty, undefined)).toBeUndefined();
|
||||||
|
expect(providerForwardingExtension(empty, "neuralwatt")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-registers a native provider, keeping its own stream and auth wiring", () => {
|
||||||
|
const native = { id: "neuralwatt" };
|
||||||
|
const extension = providerForwardingExtension(
|
||||||
|
registry({ native, config: { baseUrl: "ignored-when-native" } }),
|
||||||
|
"neuralwatt",
|
||||||
|
);
|
||||||
|
if (!extension) throw new Error("expected a forwarding extension");
|
||||||
|
expect(extension.name).toBe("pygienium-provider");
|
||||||
|
// Hidden: the sub-agent's extension list must not advertise an extension
|
||||||
|
// the user never installed for it.
|
||||||
|
expect(extension.hidden).toBe(true);
|
||||||
|
|
||||||
|
const { pi, calls } = recorder();
|
||||||
|
extension.factory(pi);
|
||||||
|
// The native provider wins over a config-only registration, which would
|
||||||
|
// only augment a built-in provider and lose the wrapped stream.
|
||||||
|
expect(calls).toEqual([[native]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-registers a config-only provider under its provider id", () => {
|
||||||
|
const config = { baseUrl: "config-only-provider" };
|
||||||
|
const extension = providerForwardingExtension(registry({ config }), "probe");
|
||||||
|
if (!extension) throw new Error("expected a forwarding extension");
|
||||||
|
const { pi, calls } = recorder();
|
||||||
|
extension.factory(pi);
|
||||||
|
expect(calls).toEqual([["probe", config]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user