|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#8328] The skill prompt bridge reads the MERGED metadata listing, so a |
| 5 | + * runtime meta override reaches MCP prompts. |
| 6 | + * |
| 7 | + * --------------------------------------------------------------------------- |
| 8 | + * The defect |
| 9 | + * --------------------------------------------------------------------------- |
| 10 | + * `PUT /api/v1/meta/skill/<name>` with `{active:true}` returned 200 and the |
| 11 | + * flip never reached the prompt surface. The two surfaces read different |
| 12 | + * layers: the meta HTTP list goes through the protocol's `getMetaItems`, which |
| 13 | + * merges the `sys_metadata` overlay over the registry / MetadataService |
| 14 | + * baselines, while this bridge read `IMetadataService.list('skill')` — one |
| 15 | + * layer BELOW where any overlay merging happens. Same skill name, two answers. |
| 16 | + * |
| 17 | + * Measured on a booted showcase before the fix: `GET /api/v1/meta/skill` |
| 18 | + * served the overridden row and `[MCP] Bridged 0 skill prompts` was logged at |
| 19 | + * the same boot. The `active` flip is what makes the count binary — a row with |
| 20 | + * `active:false` is not projected at all (`projectSkillPrompt`) — so the |
| 21 | + * override's arrival is visible as 0 → 1 rather than as a body diff. |
| 22 | + * |
| 23 | + * --------------------------------------------------------------------------- |
| 24 | + * What this file pins, and what it deliberately does NOT |
| 25 | + * --------------------------------------------------------------------------- |
| 26 | + * It pins the LAYER the bridge reads from, and that #6504's completeness |
| 27 | + * verdict survives the layer change. It does not re-pin the projection rules |
| 28 | + * (`skill-prompts.test.ts` owns those) or the overlay merge itself |
| 29 | + * (`packages/metadata-protocol` owns that — `getMetaItems` is a double here). |
| 30 | + * |
| 31 | + * ⚠️ Scope, stated so a reader does not over-read a green file: this covers the |
| 32 | + * LONG-LIVED server's bridge, which is the stdio transport's prompt surface and |
| 33 | + * the half of #8328 that lives in this package. The HTTP surface at |
| 34 | + * `/api/v1/mcp` builds its bridge in `packages/runtime` |
| 35 | + * (`domains/mcp.ts` → `buildMcpBridge`), whose `listSkills` is a separate read |
| 36 | + * that this file cannot reach and that is NOT fixed by this change. |
| 37 | + * |
| 38 | + * --------------------------------------------------------------------------- |
| 39 | + * Reverse verification, direction predicted BEFORE running |
| 40 | + * --------------------------------------------------------------------------- |
| 41 | + * Ordinary red, on the consumer. The reversion is behavioural: `listSkills` |
| 42 | + * goes back to `diagnosedList(metadataService, 'skill')`, ignoring the merged |
| 43 | + * read. Predicted, written down before running: **6 red / 2 green**. |
| 44 | + * |
| 45 | + * The two predicted GREEN are invariant pins, green in both directions on |
| 46 | + * purpose: |
| 47 | + * - *"no merged read: the bridge reads exactly as it did before"* — that case |
| 48 | + * IS the pre-fix behaviour, so it must not move; |
| 49 | + * - *"a degraded verdict still reaches the operator"* — the warn came from |
| 50 | + * `listDiagnosed` before this change and still does. It would go red if a |
| 51 | + * future change spent the #6504 contract while switching layers, which is |
| 52 | + * the specific regression this fix had to avoid. |
| 53 | + * |
| 54 | + * MEASURED: **5 red / 3 green** — the prediction was wrong, and the third green |
| 55 | + * was a defective assertion rather than a third invariant. The case then read |
| 56 | + * *"the un-merged `list()` is not consulted"*, which is true in BOTH directions: |
| 57 | + * `diagnosedList` prefers `listDiagnosed` whenever the service has it, so the |
| 58 | + * un-merged path never touches `list` either. It was rewritten to assert |
| 59 | + * PROVENANCE (the service holds a projectable skill, the merged read holds |
| 60 | + * none, and nothing is bridged), which discriminates. Re-measured after that |
| 61 | + * rewrite: **6 red / 2 green**, as recorded in the PR body. |
| 62 | + * |
| 63 | + * The doubles declare metadata reads only — no engine write verb — so there is |
| 64 | + * no `delete`/`update` dispatch for `check:engine-double-contract` to scan. |
| 65 | + */ |
| 66 | + |
| 67 | +import { describe, it, expect, vi } from 'vitest'; |
| 68 | +import type { IMetadataService, Logger } from '@objectstack/spec/contracts'; |
| 69 | +import { MCPServerRuntime } from './mcp-server-runtime.js'; |
| 70 | +import type { McpMergedMetadataRead } from './mcp-server-runtime.js'; |
| 71 | + |
| 72 | +type AnyRecord = Record<string, any>; |
| 73 | + |
| 74 | +/** See the sibling outage suite: typed because the TEST_DEBT ratchet reads it. */ |
| 75 | +type MockLogger = Logger & { |
| 76 | + debug: ReturnType<typeof vi.fn>; |
| 77 | + info: ReturnType<typeof vi.fn>; |
| 78 | + warn: ReturnType<typeof vi.fn>; |
| 79 | + error: ReturnType<typeof vi.fn>; |
| 80 | +}; |
| 81 | + |
| 82 | +const makeLogger = (): MockLogger => |
| 83 | + ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }) as unknown as MockLogger; |
| 84 | + |
| 85 | +const infoLines = (logger: MockLogger): string => |
| 86 | + logger.info.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); |
| 87 | +const warnLines = (logger: MockLogger): string => |
| 88 | + logger.warn.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); |
| 89 | + |
| 90 | +const LOADER_FAILURE = 'database: connect ECONNREFUSED 10.0.0.5:5432'; |
| 91 | + |
| 92 | +/** |
| 93 | + * The packaged skill as the registry/loader layer holds it: authored inactive, |
| 94 | + * so it projects to NO prompt. |
| 95 | + */ |
| 96 | +const PACKAGED_SKILL = { |
| 97 | + name: 'case_management', |
| 98 | + label: 'Case Management', |
| 99 | + instructions: 'Handle the support case lifecycle.', |
| 100 | + active: false, |
| 101 | +}; |
| 102 | + |
| 103 | +/** |
| 104 | + * The same skill as the protocol's merged read answers AFTER a runtime |
| 105 | + * `PUT /api/v1/meta/skill/case_management` with `{active:true}` — the overlay |
| 106 | + * row won its slot, so the row is now projectable. |
| 107 | + */ |
| 108 | +const OVERRIDDEN_SKILL = { ...PACKAGED_SKILL, active: true }; |
| 109 | + |
| 110 | +/** |
| 111 | + * Build a metadata-service double. Every required member throws, so a path that |
| 112 | + * reaches one this change should not touch fails loudly rather than resolving |
| 113 | + * an empty array and looking like the very absence under test. |
| 114 | + */ |
| 115 | +function makeService(overrides: AnyRecord): IMetadataService { |
| 116 | + const unexpected = (member: string) => async (): Promise<never> => { |
| 117 | + throw new Error(`double: ${member}() should not be called by this surface`); |
| 118 | + }; |
| 119 | + return { |
| 120 | + register: unexpected('register'), |
| 121 | + get: unexpected('get'), |
| 122 | + list: unexpected('list'), |
| 123 | + unregister: unexpected('unregister'), |
| 124 | + exists: unexpected('exists'), |
| 125 | + listNames: unexpected('listNames'), |
| 126 | + getObject: unexpected('getObject'), |
| 127 | + listObjects: unexpected('listObjects'), |
| 128 | + ...overrides, |
| 129 | + } as unknown as IMetadataService; |
| 130 | +} |
| 131 | + |
| 132 | +/** A healthy service holding the packaged (inactive) row. */ |
| 133 | +const packagedService = (items: unknown[] = [PACKAGED_SKILL]) => |
| 134 | + makeService({ |
| 135 | + list: vi.fn(async () => items), |
| 136 | + listDiagnosed: vi.fn(async () => ({ items, degraded: false, errors: [] })), |
| 137 | + }); |
| 138 | + |
| 139 | +/** A service whose loader set is short — the #6504 verdict is `degraded`. */ |
| 140 | +const degradedService = (items: unknown[] = [PACKAGED_SKILL]) => |
| 141 | + makeService({ |
| 142 | + list: vi.fn(async () => items), |
| 143 | + listDiagnosed: vi.fn(async () => ({ items, degraded: true, errors: [LOADER_FAILURE] })), |
| 144 | + }); |
| 145 | + |
| 146 | +/** A service predating #6504: no `listDiagnosed` to probe. */ |
| 147 | +const undiagnosableService = (items: unknown[] = [PACKAGED_SKILL]) => |
| 148 | + makeService({ list: vi.fn(async () => items) }); |
| 149 | + |
| 150 | +/** The protocol's merged listing, in its native `{ type, items }` envelope. */ |
| 151 | +const mergedRead = (items: unknown[]): McpMergedMetadataRead => ({ |
| 152 | + getMetaItems: vi.fn(async ({ type }: { type: string }) => ({ type, items })), |
| 153 | +}); |
| 154 | + |
| 155 | +const bridge = async ( |
| 156 | + service: IMetadataService, |
| 157 | + logger: MockLogger, |
| 158 | + merged?: McpMergedMetadataRead, |
| 159 | +): Promise<void> => { |
| 160 | + const runtime = new MCPServerRuntime({ name: 'merged-skill-read', version: '0.0.0', logger }); |
| 161 | + await runtime.bridgePrompts(service, merged); |
| 162 | +}; |
| 163 | + |
| 164 | +describe('#8328 — the skill read goes through the protocol\'s merged listing', () => { |
| 165 | + it('THE DEFECT: a runtime override that only the merged read can see reaches the prompt surface', async () => { |
| 166 | + const logger = makeLogger(); |
| 167 | + // The registry/loader layer still holds `active:false`; only the merged |
| 168 | + // read carries the overlay's `active:true`. Before this fix the bridge read |
| 169 | + // the former and registered nothing. |
| 170 | + await bridge(packagedService(), logger, mergedRead([OVERRIDDEN_SKILL])); |
| 171 | + |
| 172 | + expect(infoLines(logger)).toMatch(/Bridged 1 skill prompts/); |
| 173 | + }); |
| 174 | + |
| 175 | + it('the metadata service\'s own listing contributes NO items', async () => { |
| 176 | + const logger = makeLogger(); |
| 177 | + // Provenance, asserted the only way that discriminates: the service holds a |
| 178 | + // perfectly projectable skill and the merged read holds none. If any item |
| 179 | + // still reached the surface it came from the layer this fix stopped |
| 180 | + // reading. |
| 181 | + // |
| 182 | + // An earlier draft asserted `list()` was never called, which is TRUE in |
| 183 | + // both directions and therefore measures nothing: `diagnosedList` prefers |
| 184 | + // `listDiagnosed` when the service has it, so the un-merged path reads that |
| 185 | + // member instead. Kept as a secondary assertion, not the discriminator. |
| 186 | + const service = packagedService([{ ...PACKAGED_SKILL, name: 'stale_skill', active: true }]); |
| 187 | + await bridge(service, logger, mergedRead([])); |
| 188 | + |
| 189 | + expect(infoLines(logger)).toMatch(/Bridged 0 skill prompts/); |
| 190 | + expect(service.list).not.toHaveBeenCalled(); |
| 191 | + }); |
| 192 | + |
| 193 | + it('asks the merged read for the `skill` type specifically', async () => { |
| 194 | + const logger = makeLogger(); |
| 195 | + const merged = mergedRead([OVERRIDDEN_SKILL]); |
| 196 | + await bridge(packagedService(), logger, merged); |
| 197 | + |
| 198 | + expect(merged.getMetaItems).toHaveBeenCalledWith({ type: 'skill' }); |
| 199 | + }); |
| 200 | + |
| 201 | + it('accepts a bare array from a host whose merged read is not enveloped', async () => { |
| 202 | + const logger = makeLogger(); |
| 203 | + const merged = { getMetaItems: vi.fn(async () => [OVERRIDDEN_SKILL]) }; |
| 204 | + await bridge(packagedService(), logger, merged); |
| 205 | + |
| 206 | + expect(infoLines(logger)).toMatch(/Bridged 1 skill prompts/); |
| 207 | + }); |
| 208 | + |
| 209 | + it('an override that DEACTIVATES a packaged skill retires its prompt', async () => { |
| 210 | + const logger = makeLogger(); |
| 211 | + // The mirror of the repro, and the case that proves the merged read is the |
| 212 | + // authority rather than merely an additional source: the registry says |
| 213 | + // active, the overlay says inactive, and the surface follows the overlay. |
| 214 | + const active = { ...PACKAGED_SKILL, active: true }; |
| 215 | + await bridge( |
| 216 | + packagedService([active]), |
| 217 | + logger, |
| 218 | + mergedRead([{ ...active, active: false }]), |
| 219 | + ); |
| 220 | + |
| 221 | + expect(infoLines(logger)).toMatch(/Bridged 0 skill prompts/); |
| 222 | + }); |
| 223 | + |
| 224 | + it('a merged read that FAILS never falls back to the un-merged listing', async () => { |
| 225 | + const logger = makeLogger(); |
| 226 | + const service = packagedService(); |
| 227 | + const merged = { |
| 228 | + getMetaItems: vi.fn(async () => { |
| 229 | + throw new Error('sys_metadata unreadable'); |
| 230 | + }), |
| 231 | + }; |
| 232 | + await bridge(service, logger, merged); |
| 233 | + |
| 234 | + // Falling back would answer with registry rows in the shape of merged ones |
| 235 | + // — this defect, restored silently at exactly the moment an overlay is most |
| 236 | + // likely to be the thing being missed. |
| 237 | + expect(service.list).not.toHaveBeenCalled(); |
| 238 | + expect(warnLines(logger)).toMatch(/Could not read skill metadata/); |
| 239 | + expect(infoLines(logger)).not.toMatch(/Bridged \d+ skill prompts/); |
| 240 | + }); |
| 241 | + |
| 242 | + it('#6504 SURVIVES: a degraded verdict still reaches the operator through the merged read', async () => { |
| 243 | + const logger = makeLogger(); |
| 244 | + // `getMetaItems` cannot express this — it swallows a MetadataService read |
| 245 | + // failure into its own catch — so the verdict is asked of `listDiagnosed` |
| 246 | + // alongside it. Losing this was the live risk of the layer change. |
| 247 | + await bridge(degradedService(), logger, mergedRead([OVERRIDDEN_SKILL])); |
| 248 | + |
| 249 | + const lines = warnLines(logger); |
| 250 | + expect(lines).toMatch(/INCOMPLETE/); |
| 251 | + expect(lines).toMatch(/missing, NOT undeclared/); |
| 252 | + expect(logger.warn.mock.calls[0]![1].errors).toEqual([LOADER_FAILURE]); |
| 253 | + }); |
| 254 | + |
| 255 | + it('no merged read: the bridge reads exactly as it did before #8328', async () => { |
| 256 | + const logger = makeLogger(); |
| 257 | + // A host assembled without the metadata protocol has no merged read to |
| 258 | + // give. `undefined` means "this host cannot merge", never "merging was |
| 259 | + // skipped", so the pre-#8328 read is the honest answer rather than a |
| 260 | + // regression — and a service predating #6504 keeps working too. |
| 261 | + const service = undiagnosableService([{ ...PACKAGED_SKILL, active: true }]); |
| 262 | + await bridge(service, logger, undefined); |
| 263 | + |
| 264 | + expect(service.list).toHaveBeenCalledWith('skill'); |
| 265 | + expect(infoLines(logger)).toMatch(/Bridged 1 skill prompts/); |
| 266 | + }); |
| 267 | +}); |
0 commit comments