Preserve app-server metadata on probe failures

This commit is contained in:
murashit 2026-06-28 15:57:45 +09:00
parent 26a5267ce7
commit 1f95937dcf
4 changed files with 46 additions and 16 deletions

View file

@ -188,13 +188,16 @@ export class AppServerQueryCache {
writeAppServerMetadata(context: AppServerQueryContext, metadata: SharedServerMetadata): SharedServerMetadata | null {
if (!appServerQueryContextIsComplete(context)) return null;
const previous = this.appServerMetadataSnapshot(context);
const probes = metadata.serverDiagnostics.probes;
const next = cloneSharedServerMetadata({
...metadata,
availableModels:
metadata.serverDiagnostics.probes["model/list"].status === "ok" ? metadata.availableModels : (this.modelsSnapshot(context) ?? []),
availableModels: probes["model/list"].status === "ok" ? metadata.availableModels : (this.modelsSnapshot(context) ?? []),
availableSkills: probes["skills/list"].status === "ok" ? metadata.availableSkills : (previous?.availableSkills ?? []),
rateLimit: probes["account/rateLimits/read"].status === "ok" ? metadata.rateLimit : (previous?.rateLimit ?? null),
});
this.client.setQueryData(appServerMetadataQueryKey(context), cloneSharedServerMetadata(next));
if (metadata.serverDiagnostics.probes["model/list"].status === "ok") {
if (probes["model/list"].status === "ok") {
this.client.setQueryData(appServerModelsQueryKey(context), cloneModelMetadata(next.availableModels));
}
return cloneSharedServerMetadata(next);

View file

@ -96,7 +96,7 @@ async function refreshSkillResource(host: ChatServerMetadataActionsHost, forceRe
if (!metadata) return null;
return {
...metadata,
availableSkills: skills.value,
...(skills.probe.status === "ok" ? { availableSkills: skills.value } : {}),
serverDiagnostics: diagnosticsWithProbe(cloneServerDiagnostics(metadata.serverDiagnostics), skills.probe),
};
});
@ -105,11 +105,15 @@ async function refreshSkillResource(host: ChatServerMetadataActionsHost, forceRe
return next;
}
const diagnostics = diagnosticsWithProbe(currentMetadataDiagnostics(host), skills.probe);
host.stateStore.dispatch({
type: "connection/metadata-applied",
availableSkills: skills.value,
serverDiagnostics: diagnostics,
});
host.stateStore.dispatch(
skills.probe.status === "ok"
? {
type: "connection/metadata-applied",
availableSkills: skills.value,
serverDiagnostics: diagnostics,
}
: { type: "connection/metadata-applied", serverDiagnostics: diagnostics },
);
return null;
}

View file

@ -14,7 +14,7 @@ import {
import type { SharedServerMetadata } from "../../src/domain/server/metadata";
describe("AppServerQueryCache", () => {
it("stores metadata snapshots without replacing failed resource values with stale values", () => {
it("preserves successful resource values when metadata probes fail", () => {
const cache = new AppServerQueryCache();
const context = cacheContext();
const goodMetadata = metadata({
@ -29,7 +29,7 @@ describe("AppServerQueryCache", () => {
context,
metadata({
availableModels: [modelMetadata("gpt-5.6")],
availableSkills: [skillMetadata("stale-skill")],
availableSkills: [skillMetadata("failed-skill")],
rateLimit: rateLimit(90),
skillsProbeStatus: "failed",
rateLimitProbeStatus: "failed",
@ -37,8 +37,8 @@ describe("AppServerQueryCache", () => {
);
expect(cache.appServerMetadataSnapshot(context)?.availableModels.map((model) => model.model)).toEqual(["gpt-5.6"]);
expect(cache.appServerMetadataSnapshot(context)?.availableSkills.map((skill) => skill.name)).toEqual(["stale-skill"]);
expect(cache.appServerMetadataSnapshot(context)?.rateLimit?.primary?.usedPercent).toBe(90);
expect(cache.appServerMetadataSnapshot(context)?.availableSkills.map((skill) => skill.name)).toEqual(["writer"]);
expect(cache.appServerMetadataSnapshot(context)?.rateLimit?.primary?.usedPercent).toBe(42);
expect(cache.appServerMetadataSnapshot(context)?.serverDiagnostics.probes["skills/list"].status).toBe("failed");
expect(cache.appServerMetadataSnapshot(context)?.serverDiagnostics.probes["account/rateLimits/read"].status).toBe("failed");
@ -47,7 +47,7 @@ describe("AppServerQueryCache", () => {
expect(cache.appServerMetadataSnapshot(context)?.serverDiagnostics.probes["model/list"].status).toBe("failed");
});
it("does not accept failed metadata model payloads as model cache truth", () => {
it("does not accept failed metadata resource payloads as cache truth", () => {
const cache = new AppServerQueryCache();
const context = cacheContext();
@ -58,6 +58,7 @@ describe("AppServerQueryCache", () => {
availableSkills: [skillMetadata("writer")],
rateLimit: rateLimit(90),
modelProbeStatus: "failed",
skillsProbeStatus: "failed",
rateLimitProbeStatus: "failed",
}),
);
@ -66,8 +67,8 @@ describe("AppServerQueryCache", () => {
expect(cached?.runtimeConfig).not.toBeNull();
expect(cached?.serverDiagnostics.probes["model/list"].status).toBe("failed");
expect(cached?.availableModels).toEqual([]);
expect(cached?.availableSkills.map((skill) => skill.name)).toEqual(["writer"]);
expect(cached?.rateLimit?.primary?.usedPercent).toBe(90);
expect(cached?.availableSkills).toEqual([]);
expect(cached?.rateLimit).toBeNull();
expect(cache.modelsSnapshot(context)).toBeNull();
});

View file

@ -504,6 +504,28 @@ describe("chat app-server actions", () => {
expect(updateAppServerMetadata).not.toHaveBeenCalled();
});
it("keeps previous skills when sparse skill refresh fails", async () => {
let state = chatStateFixture();
const previousSkills = [{ name: "writer", description: "", path: "/tmp/writer", enabled: true }];
state = chatStateWith(state, { connection: { availableSkills: previousSkills } });
const stateStore = createChatStateStore(state);
const listSkills = vi.fn().mockRejectedValue(new Error("offline"));
const client = requestClient({ "skills/list": listSkills });
const controller = createChatServerMetadataActions({
stateStore,
vaultPath: "/vault",
currentClient: () => client,
...metadataCacheHost(),
refreshAppServerMetadata: async () => null,
});
await controller.applyAppServerResourceEvent({ type: "skills-changed", forceReload: true });
expect(listSkills).toHaveBeenCalledWith({ cwds: ["/vault"], forceReload: true });
expect(stateStore.getState().connection.availableSkills).toEqual(previousSkills);
expect(stateStore.getState().connection.serverDiagnostics.probes["skills/list"]).toMatchObject({ status: "failed" });
});
it("publishes refreshed rate limits from sparse update notifications", async () => {
const state = chatStateFixture();
const stateStore = createChatStateStore(state);