feat(mcp): refresh provider candidates without switching (#8487)

* fix(mcp): report provider candidate observation scope

* feat(mcp): refresh provider candidates without switching

* docs: clarify candidate inspection ownership

* fix(mcp): clarify provider reselection on reopen

* test(mcp): bound candidate refresh lifecycle gates

* test(mcp): keep temporary fixture cleanup with its owner
This commit is contained in:
morluto authored and GitHub committed 2026-11-08 01:35:39 +00:00
1 parent 1a0e2c77f2
commit 0b028cc75a
12 files changed
+516 -10

No files matched your search

+16 -2
View File
@@ -1304,8 +1304,20 @@ export class BinarySession
options: {
readonly signal?: AbortSignal;
readonly detail?: BinarySessionDetail;
readonly refreshCandidates?: boolean;
} = {},
): Promise<JsonValue> {
return options.refreshCandidates === true
? this.#serialize(() => this.#status(options), { abortPrompts: false })
: this.#status(options);
}
/** Keep an explicit candidate refresh ordered with target transitions. */
#status(options: {
readonly signal?: AbortSignal;
readonly detail?: BinarySessionDetail;
readonly refreshCandidates?: boolean;
}): Promise<JsonValue> {
const resourceRun = this.#activeBundleResources;
const run =
this.#active ??
@@ -1606,9 +1618,11 @@ export class BinarySession
#serialize<T>(
operation: (admittedThrough: number) => Promise<T>,
options: { readonly abortPrompts?: boolean } = {},
): Promise<T> {
for (const controller of this.#promptCompletionControllers)
controller.abort();
if (options.abortPrompts !== false)
for (const controller of this.#promptCompletionControllers)
controller.abort();
this.#transitionPending += 1;
// Later calls wait for this transition and must not be included in its drain.
const admittedThrough = this.#transitionGeneration++;
@@ -166,6 +166,7 @@ it("opens a resource-only bundle without executable identity and keeps snapshot
expect(await session.status()).toMatchObject({
open: true,
session_kind: "bundle_resources",
analysis_provider_candidates_observed: "not_applicable",
resource_root: { canonical_path: opened.value.canonicalPath },
});
const snapshot = session.exportAnalysisSnapshot();
@@ -140,6 +140,7 @@ export interface BinarySessionPort
status(options?: {
readonly signal?: AbortSignal;
readonly detail?: BinarySessionDetail;
readonly refreshCandidates?: boolean;
}): Promise<JsonValue>;
activeTarget(): BinaryTarget | undefined;
/** Read a snapshot with failed descriptors retained for session cleanup retry. */
+15 -1
View File
@@ -47,6 +47,7 @@ interface BinarySessionStatusInput {
readonly cleanupRetry?: AnalysisCleanupRetry;
readonly detail?: BinarySessionDetail;
readonly signal?: AbortSignal;
readonly refreshCandidates?: boolean;
}
/** Project internal provider routing state into the caller-visible session status. */
@@ -66,6 +67,7 @@ export const binarySessionStatus = async ({
cleanupRetry,
detail = "complete",
signal,
refreshCandidates = false,
}: BinarySessionStatusInput): Promise<JsonValue> => {
const routeCapabilities = (
bundleResourceRoot === undefined
@@ -119,7 +121,13 @@ export const binarySessionStatus = async ({
const analysisProviderCandidates =
bundleResourceRoot !== undefined
? []
: (await router.candidateStatuses(route, signal)).map((candidate) =>
: (
await router.candidateStatuses(route, {
...(signal === undefined ? {} : { signal }),
...(target === undefined ? {} : { target }),
refresh: refreshCandidates,
})
).map((candidate) =>
detail === "summary"
? {
provider: providerSummary(candidate.provider),
@@ -153,6 +161,12 @@ export const binarySessionStatus = async ({
capabilities,
analysis_provider_binding: analysisProviderBinding,
analysis_provider_candidates: analysisProviderCandidates,
analysis_provider_candidates_observed:
bundleResourceRoot !== undefined
? "not_applicable"
: route.selection === undefined || refreshCandidates
? "status_request"
: "target_selection",
analysis_run:
runId === undefined
? null
@@ -129,11 +129,26 @@ export class SessionProviderRouter {
);
}
/** Candidate status authoritative for the active or target-free route. */
/** Preserve selection observations unless a caller requests a fresh inspection. */
async candidateStatuses(
route: SessionProviderRoute,
signal?: AbortSignal,
options: {
readonly signal?: AbortSignal;
readonly target?: BinaryTarget;
readonly refresh?: boolean;
} = {},
): Promise<readonly AnalysisProviderCandidateStatus[]> {
const { signal, target, refresh = false } = options;
if (refresh && route.selection !== undefined) {
const current = await this.registry.candidates(
target,
signal === undefined ? {} : { signal },
);
return current.map((candidate) => ({
...candidate,
selected: candidate.provider.id === route.binding?.identity.id,
}));
}
return (
route.selection?.candidates ??
this.registry.candidates(