diff --git a/components/PluginAIContextHost.test.tsx b/components/PluginAIContextHost.test.tsx index 61ad6395c..75a9a63cb 100644 --- a/components/PluginAIContextHost.test.tsx +++ b/components/PluginAIContextHost.test.tsx @@ -55,10 +55,26 @@ test('cold plugin host reaches the local fixture through the actual main provide const streamResults: Array<{ ok: boolean; statusCode?: number; allowlistRejected: boolean }> = []; const providerSyncs: Array<{ count: number; selectedPresent: boolean }> = []; const sdkErrorNames: string[] = []; + let diagnosticPhase = 'setup'; const originalConsoleError = console.error; + // node:sqlite was imported above. Node 22 delivers its ExperimentalWarning + // asynchronously through console.error. Let module warnings reach normal + // stderr before observing SDK/renderer errors; do not suppress that warning. + await new Promise((resolve) => setImmediate(resolve)); // The expected pre-fix SDK error contains request details. Keep diagnostics // categorical even on the failing-before path; never print payloads/headers. - console.error = () => { sdkErrorNames.push('SDK_OR_RENDERER_ERROR'); }; + console.error = (value: unknown) => { + let category = 'unknown'; + if (typeof value === 'string' && /not wrapped in act/.test(value)) category = 'react-act-warning'; + else if (typeof value === 'string' && /act.*environment|environment.*act/.test(value)) category = 'react-act-environment'; + else if (typeof value === 'string' && /ExperimentalWarning: SQLite is an experimental feature/.test(value)) category = 'node-sqlite-experimental'; + else if (typeof value === 'string') category = 'other-string'; + else if (value && typeof value === 'object') { + const name = (value as { name?: unknown }).name; + if (typeof name === 'string' && ['AbortError', 'AI_APICallError', 'AI_LoadAPIKeyError', 'TypeError', 'Error'].includes(name)) category = name; + } + sdkErrorNames.push(`${diagnosticPhase}:${category}`); + }; t.after(() => { console.error = originalConsoleError; }); try { server.listen(0, '127.0.0.1'); await once(server, 'listening'); @@ -149,6 +165,7 @@ test('cold plugin host reaches the local fixture through the actual main provide executeCommand: async (command: string, args: unknown) => structuredClone(await service!.contributionService.executeCommand(command, structuredClone(args))), onDidReceiveMessage: () => ({ dispose() {} }), } }); + diagnosticPhase = 'initial-selection'; await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8'))); await waitFor(() => document.getElementById('plugin-ai-note') !== null); const noteChoice = document.getElementById('plugin-ai-note') as HTMLSelectElement; @@ -162,23 +179,28 @@ test('cold plugin host reaches the local fixture through the actual main provide assert.equal(reference.value, ''); assert.equal(reference.options.length, 2); assert.notEqual(reference.options[1].value, syntheticNote.id); reference.value = reference.options[1].value; const send = () => element('compose').dispatchEvent(new panel!.window.Event('submit', { cancelable: true })); + diagnosticPhase = 'deny-content'; send(); await waitFor(() => element('status').textContent!.startsWith('Request ended')); assert.equal(counts.requests, 0); assert.equal(reviews.length, 0); contentAllowed = true; + diagnosticPhase = 'cancel-review'; send(); await waitFor(() => element('status').textContent!.startsWith('Request ended')); assert.equal(counts.requests, 0); assert.equal(reviews.length, 1); assert.equal(reviews[0].defaultId, reviews[0].cancelId); assert.match(String(reviews[0].detail), /Synthetic retry plan: add a retry button/); reviewAllowed = true; + diagnosticPhase = 'complete-stream'; send(); await waitFor(() => counts.completed === 1 || streamResults.some((result) => !result.ok)); assert.deepEqual(streamResults.at(-1), { ok: true, statusCode: 200, allowlistRejected: false }, 'Cold plugin startup must synchronize host providers before the main allowlist admits transport'); assert.ok(providerSyncs.some((sync) => sync.selectedPresent && sync.count === 2), 'Preserve other host-configured providers in the shared registry'); await waitFor(() => element('status').textContent === 'Complete'); assert.equal(counts.completed, 1); assert.match(element('response').textContent!, /Synthetic plan/); assert.match(element('usage').textContent!, /12 input tokens/); + diagnosticPhase = 'stop-stream'; send(); await waitFor(() => element('response').textContent!.startsWith('Synthetic plan: ')); element('stop').dispatchEvent(new panel.window.Event('click')); await waitFor(() => element('status').textContent === 'Stopped' && counts.aborted === 1); const stoppedText = element('response').textContent; await new Promise((resolve) => setTimeout(resolve, 50)); assert.equal(element('response').textContent, stoppedText); + diagnosticPhase = 'lock-stream'; send(); await waitFor(() => element('response').textContent!.startsWith('Synthetic plan: ')); locked = true; registerAppAppLockRuntime({ locked: true } as NonNullable>); for (const listener of lockListeners) listener({ locked: true }); diff --git a/docs/plugin-platform/ai-provider-startup.md b/docs/plugin-platform/ai-provider-startup.md index c682cb1d3..e3aa5deb2 100644 --- a/docs/plugin-platform/ai-provider-startup.md +++ b/docs/plugin-platform/ai-provider-startup.md @@ -76,3 +76,33 @@ denial. This transport correction does not redesign that policy. Native outcome, independent verification, and the sample's light-theme appearance follow-up remain separate acceptance work. No GUI launch, sandbox bypass, paid model invocation, main merge, or release was performed for this correction. + +## Node 22 CI observer correction + +The new cold-start test failed on exact head `9ca70f329` in test run +`40436198585`, Linux job `121965743101`: 15,206 passed, 1 failed, 50 skipped. +The sole failure was the empty SDK/renderer-error assertion receiving one +`SDK_OR_RENDERER_ERROR` marker. This red result is retained as evidence; it is +not an unrelated baseline failure. + +The CI runtime was Node `22.23.3`; local validation had used Node `24.19.0`. +Reproduction on the exact CI version identified the safe category +`setup:node-sqlite-experimental`, before any model operation. The existing +`node:sqlite` import schedules its ExperimentalWarning on the next tick via +`console.error`. The newly added observer was installed before that warning +drained. The same test passed on Node 24, which did not emit that warning. + +The test now lets pending module warnings reach normal stderr before installing +the SDK/renderer observer. The warning remains visible, and the zero-runtime-error +assertion is unchanged. Safe phase/error categories make future failures +diagnosable without request payloads or headers. No production behavior, timeout, +permission, or cancellation assertion changed. Both Node versions pass the +focused test; the Node 22 parallel fixture/UI/provider/preload group passes all +16 tests with concurrency 4. + +Diagnostic evidence: `/tmp/netcatty-ai-cold-ci-node22-sqlite-category.log` fails +before correction, `/tmp/netcatty-ai-cold-ci-node22-after.log` passes with the +SQLite warning still visible, and `/tmp/netcatty-ai-cold-ci-node22-parallel.log` +records the parallel check. The appearance-only sample change is independently +published as `e325742abf9fd329f1f7858eda66367e7a62357c`; its package hash is +`7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274`.