From d212c78bd893c896e1f6f08a0965bfd598f357b6 Mon Sep 17 00:00:00 2001 From: EntropyParadigm Date: Tue, 16 Jun 2026 05:38:31 -0700 Subject: [PATCH 1/3] fix: handle copilot gpt-5 reasoning streams --- patches/@ai-sdk__openai@3.0.71.patch | 191 ++++++++++++++++++ pnpm-lock.yaml | 5 +- pnpm-workspace.yaml | 1 + .../ai-sdk-client/chat/chat-handler.spec.ts | 172 ++++++++++++++++ source/ai-sdk-client/chat/chat-handler.ts | 22 +- 5 files changed, 381 insertions(+), 10 deletions(-) create mode 100644 patches/@ai-sdk__openai@3.0.71.patch diff --git a/patches/@ai-sdk__openai@3.0.71.patch b/patches/@ai-sdk__openai@3.0.71.patch new file mode 100644 index 00000000..11135d78 --- /dev/null +++ b/patches/@ai-sdk__openai@3.0.71.patch @@ -0,0 +1,191 @@ +diff --git a/dist/index.js b/dist/index.js +index f27f8c01885f41b8e43035f36075e2dc7e3bc148..abf9f82706c49f265ee6999c4d02b7e1a33b3605 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -6122,7 +6122,7 @@ var OpenAIResponsesLanguageModel = class { + } + }); + } else if (value.item.type === "reasoning") { +- const activeReasoningPart = activeReasoning[value.item.id]; ++ const activeReasoningPart = activeReasoning[value.item.id] ?? { encryptedContent: null, summaryParts: {} }; + const summaryPartIndices = Object.entries( + activeReasoningPart.summaryParts + ).filter( +@@ -6253,7 +6253,7 @@ var OpenAIResponsesLanguageModel = class { + } + } else if (value.type === "response.reasoning_summary_part.added") { + if (value.summary_index > 0) { +- const activeReasoningPart = activeReasoning[value.item_id]; ++ const activeReasoningPart = activeReasoning[value.item_id] ?? (activeReasoning[value.item_id] = { encryptedContent: null, summaryParts: {} }); + activeReasoningPart.summaryParts[value.summary_index] = "active"; + for (const summaryIndex of Object.keys( + activeReasoningPart.summaryParts +@@ -6304,9 +6304,9 @@ var OpenAIResponsesLanguageModel = class { + } + } + }); +- activeReasoning[value.item_id].summaryParts[value.summary_index] = "concluded"; ++ (activeReasoning[value.item_id] ?? (activeReasoning[value.item_id] = { encryptedContent: null, summaryParts: {} })).summaryParts[value.summary_index] = "concluded"; + } else { +- activeReasoning[value.item_id].summaryParts[value.summary_index] = "can-conclude"; ++ (activeReasoning[value.item_id] ?? (activeReasoning[value.item_id] = { encryptedContent: null, summaryParts: {} })).summaryParts[value.summary_index] = "can-conclude"; + } + } else if (isResponseFinishedChunk(value)) { + finishReason = { +diff --git a/dist/index.mjs b/dist/index.mjs +index ed14861f8afe781d9af820afe82e78b3286283d9..6c31410fba40cbed351801751499a00e9acf4f99 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -6226,7 +6226,7 @@ var OpenAIResponsesLanguageModel = class { + } + }); + } else if (value.item.type === "reasoning") { +- const activeReasoningPart = activeReasoning[value.item.id]; ++ const activeReasoningPart = activeReasoning[value.item.id] ?? { encryptedContent: null, summaryParts: {} }; + const summaryPartIndices = Object.entries( + activeReasoningPart.summaryParts + ).filter( +@@ -6357,7 +6357,7 @@ var OpenAIResponsesLanguageModel = class { + } + } else if (value.type === "response.reasoning_summary_part.added") { + if (value.summary_index > 0) { +- const activeReasoningPart = activeReasoning[value.item_id]; ++ const activeReasoningPart = activeReasoning[value.item_id] ?? (activeReasoning[value.item_id] = { encryptedContent: null, summaryParts: {} }); + activeReasoningPart.summaryParts[value.summary_index] = "active"; + for (const summaryIndex of Object.keys( + activeReasoningPart.summaryParts +@@ -6408,9 +6408,9 @@ var OpenAIResponsesLanguageModel = class { + } + } + }); +- activeReasoning[value.item_id].summaryParts[value.summary_index] = "concluded"; ++ (activeReasoning[value.item_id] ?? (activeReasoning[value.item_id] = { encryptedContent: null, summaryParts: {} })).summaryParts[value.summary_index] = "concluded"; + } else { +- activeReasoning[value.item_id].summaryParts[value.summary_index] = "can-conclude"; ++ (activeReasoning[value.item_id] ?? (activeReasoning[value.item_id] = { encryptedContent: null, summaryParts: {} })).summaryParts[value.summary_index] = "can-conclude"; + } + } else if (isResponseFinishedChunk(value)) { + finishReason = { +diff --git a/dist/internal/index.js b/dist/internal/index.js +index 141bfb10833c47aab12083cf3404e0ca9612232f..53b02919260d4fa7b9e71ebe301e1c2810740735 100644 +--- a/dist/internal/index.js ++++ b/dist/internal/index.js +@@ -6391,7 +6391,7 @@ var OpenAIResponsesLanguageModel = class { + } + }); + } else if (value.item.type === "reasoning") { +- const activeReasoningPart = activeReasoning[value.item.id]; ++ const activeReasoningPart = activeReasoning[value.item.id] ?? { encryptedContent: null, summaryParts: {} }; + const summaryPartIndices = Object.entries( + activeReasoningPart.summaryParts + ).filter( +@@ -6522,7 +6522,7 @@ var OpenAIResponsesLanguageModel = class { + } + } else if (value.type === "response.reasoning_summary_part.added") { + if (value.summary_index > 0) { +- const activeReasoningPart = activeReasoning[value.item_id]; ++ const activeReasoningPart = activeReasoning[value.item_id] ?? (activeReasoning[value.item_id] = { encryptedContent: null, summaryParts: {} }); + activeReasoningPart.summaryParts[value.summary_index] = "active"; + for (const summaryIndex of Object.keys( + activeReasoningPart.summaryParts +@@ -6573,9 +6573,9 @@ var OpenAIResponsesLanguageModel = class { + } + } + }); +- activeReasoning[value.item_id].summaryParts[value.summary_index] = "concluded"; ++ (activeReasoning[value.item_id] ?? (activeReasoning[value.item_id] = { encryptedContent: null, summaryParts: {} })).summaryParts[value.summary_index] = "concluded"; + } else { +- activeReasoning[value.item_id].summaryParts[value.summary_index] = "can-conclude"; ++ (activeReasoning[value.item_id] ?? (activeReasoning[value.item_id] = { encryptedContent: null, summaryParts: {} })).summaryParts[value.summary_index] = "can-conclude"; + } + } else if (isResponseFinishedChunk(value)) { + finishReason = { +diff --git a/dist/internal/index.mjs b/dist/internal/index.mjs +index 87a8d4b8628cf9343793e496e4720a1479030b3e..19e9d0b8cca087d540cac11ecc160b561f5b1583 100644 +--- a/dist/internal/index.mjs ++++ b/dist/internal/index.mjs +@@ -6471,7 +6471,7 @@ var OpenAIResponsesLanguageModel = class { + } + }); + } else if (value.item.type === "reasoning") { +- const activeReasoningPart = activeReasoning[value.item.id]; ++ const activeReasoningPart = activeReasoning[value.item.id] ?? { encryptedContent: null, summaryParts: {} }; + const summaryPartIndices = Object.entries( + activeReasoningPart.summaryParts + ).filter( +@@ -6602,7 +6602,7 @@ var OpenAIResponsesLanguageModel = class { + } + } else if (value.type === "response.reasoning_summary_part.added") { + if (value.summary_index > 0) { +- const activeReasoningPart = activeReasoning[value.item_id]; ++ const activeReasoningPart = activeReasoning[value.item_id] ?? (activeReasoning[value.item_id] = { encryptedContent: null, summaryParts: {} }); + activeReasoningPart.summaryParts[value.summary_index] = "active"; + for (const summaryIndex of Object.keys( + activeReasoningPart.summaryParts +@@ -6653,9 +6653,9 @@ var OpenAIResponsesLanguageModel = class { + } + } + }); +- activeReasoning[value.item_id].summaryParts[value.summary_index] = "concluded"; ++ (activeReasoning[value.item_id] ?? (activeReasoning[value.item_id] = { encryptedContent: null, summaryParts: {} })).summaryParts[value.summary_index] = "concluded"; + } else { +- activeReasoning[value.item_id].summaryParts[value.summary_index] = "can-conclude"; ++ (activeReasoning[value.item_id] ?? (activeReasoning[value.item_id] = { encryptedContent: null, summaryParts: {} })).summaryParts[value.summary_index] = "can-conclude"; + } + } else if (isResponseFinishedChunk(value)) { + finishReason = { +diff --git a/src/responses/openai-responses-language-model.ts b/src/responses/openai-responses-language-model.ts +index d9ad3bd18c7bb90ee83a60b1bfcfd0acc71fa5c5..743e91c130225ebcc44e04869d858ee46f4359d3 100644 +--- a/src/responses/openai-responses-language-model.ts ++++ b/src/responses/openai-responses-language-model.ts +@@ -1773,7 +1773,10 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { + } satisfies InferSchema, + }); + } else if (value.item.type === 'reasoning') { +- const activeReasoningPart = activeReasoning[value.item.id]; ++ const activeReasoningPart = activeReasoning[value.item.id] ?? { ++ encryptedContent: null, ++ summaryParts: {}, ++ }; + + // get all active or can-conclude summary parts' ids + // to conclude ongoing reasoning parts: +@@ -1933,7 +1936,11 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { + } else if (value.type === 'response.reasoning_summary_part.added') { + // the first reasoning start is pushed in isResponseOutputItemAddedReasoningChunk + if (value.summary_index > 0) { +- const activeReasoningPart = activeReasoning[value.item_id]!; ++ const activeReasoningPart = activeReasoning[value.item_id] ?? ++ (activeReasoning[value.item_id] = { ++ encryptedContent: null, ++ summaryParts: {}, ++ }); + + activeReasoningPart.summaryParts[value.summary_index] = + 'active'; +@@ -1999,15 +2006,19 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { + }); + + // mark the summary part as concluded +- activeReasoning[value.item_id]!.summaryParts[ +- value.summary_index +- ] = 'concluded'; ++ (activeReasoning[value.item_id] ?? ++ (activeReasoning[value.item_id] = { ++ encryptedContent: null, ++ summaryParts: {}, ++ })).summaryParts[value.summary_index] = 'concluded'; + } else { + // mark the summary part as can-conclude only + // because we need to have a final summary part with the encrypted content +- activeReasoning[value.item_id]!.summaryParts[ +- value.summary_index +- ] = 'can-conclude'; ++ (activeReasoning[value.item_id] ?? ++ (activeReasoning[value.item_id] = { ++ encryptedContent: null, ++ summaryParts: {}, ++ })).summaryParts[value.summary_index] = 'can-conclude'; + } + } else if (isResponseFinishedChunk(value)) { + finishReason = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 745c36ce..2141df15 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,7 @@ overrides: tmp: '>=0.2.6' patchedDependencies: + '@ai-sdk/openai@3.0.71': df300a5d22aecda3490a73885c5d63e34d4cd056d26fc5ba202ea4121d46adbf ink@6.8.0: ae5bd7c6d28e6a9440188c3c07fc7a83597933b06090aaa44830f65f3d103b5f importers: @@ -25,7 +26,7 @@ importers: version: 3.0.80(zod@4.4.3) '@ai-sdk/openai': specifier: ^3.0.71 - version: 3.0.71(zod@4.4.3) + version: 3.0.71(patch_hash=df300a5d22aecda3490a73885c5d63e34d4cd056d26fc5ba202ea4121d46adbf)(zod@4.4.3) '@ai-sdk/openai-compatible': specifier: 2.0.41 version: 2.0.41(zod@4.4.3) @@ -3744,7 +3745,7 @@ snapshots: '@ai-sdk/provider-utils': 4.0.23(zod@4.4.3) zod: 4.4.3 - '@ai-sdk/openai@3.0.71(zod@4.4.3)': + '@ai-sdk/openai@3.0.71(patch_hash=df300a5d22aecda3490a73885c5d63e34d4cd056d26fc5ba202ea4121d46adbf)(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.10 '@ai-sdk/provider-utils': 4.0.29(zod@4.4.3) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6b3251d1..9e52cd59 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -29,4 +29,5 @@ onlyBuiltDependencies: - keytar patchedDependencies: + '@ai-sdk/openai@3.0.71': patches/@ai-sdk__openai@3.0.71.patch ink@6.8.0: patches/ink@6.8.0.patch diff --git a/source/ai-sdk-client/chat/chat-handler.spec.ts b/source/ai-sdk-client/chat/chat-handler.spec.ts index 95252a94..d765e466 100644 --- a/source/ai-sdk-client/chat/chat-handler.spec.ts +++ b/source/ai-sdk-client/chat/chat-handler.spec.ts @@ -1,4 +1,6 @@ import test from 'ava'; +import {createOpenAI} from '@ai-sdk/openai'; +import {streamText} from 'ai'; import type { AIProviderConfig, AISDKCoreTool, @@ -6,6 +8,7 @@ import type { StreamCallbacks, } from '@/types/index'; import type {LanguageModel} from 'ai'; +import {handleChat} from './chat-handler.js'; import type {ChatHandlerParams} from './chat-handler.js'; // Note: This file contains basic structure tests @@ -119,3 +122,172 @@ test('ChatHandlerParams accepts callbacks', t => { t.truthy(params.callbacks.onToolCall); t.truthy(params.callbacks.onFinish); }); + +test('handleChat returns streamed text when SDK final text is unavailable', async t => { + const streamedTokens: string[] = []; + const providerConfig: AIProviderConfig = { + name: 'TestProvider', + type: 'openai', + models: ['test-model'], + config: { + baseURL: 'https://api.test.com', + }, + }; + + const result = await handleChat({ + model: { + specificationVersion: 'v3', + provider: 'test-provider', + modelId: 'test-model', + doStream: async () => ({ + stream: new ReadableStream({ + start(controller) { + const usage = { + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, + }; + controller.enqueue({type: 'text-start', id: '0'}); + controller.enqueue({type: 'text-delta', id: '0', delta: 'ok'}); + controller.enqueue({type: 'text-end', id: '0'}); + controller.enqueue({ + type: 'finish', + finishReason: 'stop', + usage, + }); + controller.close(); + }, + }), + }), + } as LanguageModel, + currentModel: 'test-model', + providerConfig, + messages: [{role: 'user', content: 'test'}], + tools: {}, + callbacks: { + onToken: token => streamedTokens.push(token), + }, + maxRetries: 0, + }); + + t.deepEqual(streamedTokens, ['ok']); + t.is(result.choices[0]?.message.content, 'ok'); +}); + +test('OpenAI Responses parser tolerates reasoning item completion without tracked summaries', async t => { + const provider = createOpenAI({ + apiKey: 'test-key', + fetch: async () => + new Response( + [ + toSse({ + type: 'response.created', + response: { + id: 'resp_1', + created_at: 1, + model: 'gpt-5.5', + }, + }), + toSse({ + type: 'response.output_item.done', + output_index: 0, + item: { + id: 'rs_1', + type: 'reasoning', + encrypted_content: null, + }, + }), + toSse({ + type: 'response.completed', + response: { + id: 'resp_1', + usage: { + input_tokens: 1, + output_tokens: 0, + total_tokens: 1, + }, + }, + }), + 'data: [DONE]\n\n', + ].join(''), + { + status: 200, + headers: {'content-type': 'text/event-stream'}, + }, + ), + }); + + const result = streamText({ + model: provider.responses('gpt-5.5'), + prompt: 'test', + }); + + await t.notThrowsAsync(async () => { + for await (const _chunk of result.fullStream) { + // Drain the stream to exercise the Responses parser. + } + }); +}); + +test('OpenAI Responses parser tolerates summary part events without tracked reasoning state', async t => { + const provider = createOpenAI({ + apiKey: 'test-key', + fetch: async () => + new Response( + [ + toSse({ + type: 'response.created', + response: { + id: 'resp_1', + created_at: 1, + model: 'gpt-5.5', + }, + }), + toSse({ + type: 'response.reasoning_summary_part.added', + item_id: 'rs_1', + output_index: 0, + summary_index: 1, + }), + toSse({ + type: 'response.reasoning_summary_part.done', + item_id: 'rs_1', + output_index: 0, + summary_index: 1, + part: {type: 'summary_text', text: ''}, + }), + toSse({ + type: 'response.completed', + response: { + id: 'resp_1', + usage: { + input_tokens: 1, + output_tokens: 0, + total_tokens: 1, + }, + }, + }), + 'data: [DONE]\n\n', + ].join(''), + { + status: 200, + headers: {'content-type': 'text/event-stream'}, + }, + ), + }); + + const result = streamText({ + model: provider.responses('gpt-5.5'), + prompt: 'test', + }); + + await t.notThrowsAsync(async () => { + for await (const _chunk of result.fullStream) { + // Drain the stream to exercise the Responses parser. + } + }); +}); + +function toSse(value: unknown): string { + return `data: ${JSON.stringify(value)}\n\n`; +} diff --git a/source/ai-sdk-client/chat/chat-handler.ts b/source/ai-sdk-client/chat/chat-handler.ts index 5a2db6ed..8ff13d4e 100644 --- a/source/ai-sdk-client/chat/chat-handler.ts +++ b/source/ai-sdk-client/chat/chat-handler.ts @@ -116,6 +116,7 @@ export async function handleChat( // reasoning-aware nudge depends on this for the GPT-5 case where the // SDK throws AI_NoOutputGeneratedError after a reasoning-only stream. let accumulatedReasoning = ''; + let accumulatedText = ''; return await withNewCorrelationContext(async _context => { try { @@ -248,6 +249,7 @@ export async function handleChat( } break; case 'text-delta': + accumulatedText += chunk.text; tokenBuffer += chunk.text; if (!flushTimer) { flushTimer = setTimeout(flushBuffer, FLUSH_INTERVAL_MS); @@ -329,7 +331,7 @@ export async function handleChat( ? convertAISDKToolCalls(resolvedToolCalls) : []; - const content = fullText; + const content = fullText || accumulatedText; // Calculate performance metrics const finalMetrics = endMetrics(metrics); @@ -487,19 +489,23 @@ export async function handleChat( // Hand control back to the conversation loop with an empty // response so its empty-turn handling (capped recursion, // reasoning-aware nudge) takes over instead of throwing. - logger.warn('Model produced no output; returning empty response', { - model: currentModel, - correlationId, - provider: providerConfig.name, - reasoningLength: accumulatedReasoning.length, - }); + logger.warn( + 'Model produced no output; returning streamed fallback response', + { + model: currentModel, + correlationId, + provider: providerConfig.name, + responseLength: accumulatedText.length, + reasoningLength: accumulatedReasoning.length, + }, + ); callbacks.onFinish?.(); return { choices: [ { message: { role: 'assistant', - content: '', + content: accumulatedText, reasoning: accumulatedReasoning || undefined, }, }, From ddd24966581c1a654dc66c3126471c3791388517 Mon Sep 17 00:00:00 2001 From: EntropyParadigm Date: Tue, 16 Jun 2026 06:12:14 -0700 Subject: [PATCH 2/3] fix: stabilize full test gate --- source/events/sources/file-watcher.spec.ts | 74 +++++++++++-------- .../utils/shutdown/shutdown-manager.spec.ts | 1 + source/utils/shutdown/shutdown-manager.ts | 7 +- source/utils/tool-result-display.tsx | 2 +- 4 files changed, 53 insertions(+), 31 deletions(-) diff --git a/source/events/sources/file-watcher.spec.ts b/source/events/sources/file-watcher.spec.ts index b4bc1387..4d778d56 100644 --- a/source/events/sources/file-watcher.spec.ts +++ b/source/events/sources/file-watcher.spec.ts @@ -32,7 +32,7 @@ function fileSub(id: string, paths?: string[]): Subscription { async function waitFor( predicate: () => boolean, - timeoutMs = 2000, + timeoutMs = 5000, intervalMs = 25, ): Promise { const deadline = Date.now() + timeoutMs; @@ -97,6 +97,8 @@ test.serial('paths emitted are relative to the watch root', async t => { const dir = await mkdtemp(join(tmpdir(), 'fw-spec-rel-')); const sub = join(dir, 'src', 'inner'); await mkdir(sub, {recursive: true}); + const file = join(sub, 'leaf.ts'); + await writeFile(file, 'before'); const {router, events} = captureRouter(); router.subscribe(fileSub('s1')); @@ -108,7 +110,7 @@ test.serial('paths emitted are relative to the watch root', async t => { await source.start(); try { - await writeFile(join(sub, 'leaf.ts'), 'x'); + await writeFile(file, 'after'); await waitFor(() => events.some( e => @@ -134,18 +136,30 @@ test.serial('subscriptions with paths filter narrow down events', async t => { const dir = await mkdtemp(join(tmpdir(), 'fw-spec-paths-')); const {router, events} = captureRouter(); router.subscribe(fileSub('s1', ['docs/**'])); + const callbacks: Record void>> = {}; + type WatchFn = typeof import('chokidar').watch; + const fakeWatcher = { + on: (event: string, callback: (file: string) => void) => { + callbacks[event] = [...(callbacks[event] ?? []), callback]; + return fakeWatcher; + }, + once: (event: string, callback: () => void) => { + if (event === 'ready') { + setImmediate(callback); + } + return fakeWatcher; + }, + close: async () => {}, + } as unknown as import('chokidar').FSWatcher; const source = new FileWatcherSource(router, { root: dir, - usePolling: true, - pollingInterval: 50, + _watchFn: (() => fakeWatcher) as unknown as WatchFn, }); await source.start(); try { - await mkdir(join(dir, 'docs')); - await mkdir(join(dir, 'src')); - await writeFile(join(dir, 'docs', 'a.md'), 'docs'); - await writeFile(join(dir, 'src', 'a.ts'), 'src'); + callbacks.add?.forEach(callback => callback('docs/a.md')); + callbacks.add?.forEach(callback => callback('src/a.ts')); // Wait for at least one event to come through await waitFor(() => events.length > 0); @@ -192,28 +206,30 @@ test.serial('stop releases the watcher and stops emitting', async t => { }); test.serial('closes the underlying watcher if initialization fails', async t => { - let closeCalled = false; - - type WatchFn = typeof import('chokidar').watch; // ← avoids importing watch itself - - const fakeWatcher = { - on: (_e: string, _cb: unknown) => fakeWatcher, - once: (event: string, callback: (...args: unknown[]) => void) => { - if (event === 'error') { - setImmediate(() => callback(new Error('Simulated startup failure'))); - } - return fakeWatcher; - }, - close: async () => { closeCalled = true; }, - } as unknown as import('chokidar').FSWatcher; - - const {router} = captureRouter(); - const source = new FileWatcherSource(router, { - root: '.', - _watchFn: (() => fakeWatcher) as unknown as WatchFn, - }); + let closeCalled = false; + + type WatchFn = typeof import('chokidar').watch; // avoids importing watch itself + + const fakeWatcher = { + on: (_e: string, _cb: unknown) => fakeWatcher, + once: (event: string, callback: (...args: unknown[]) => void) => { + if (event === 'error') { + setImmediate(() => callback(new Error('Simulated startup failure'))); + } + return fakeWatcher; + }, + close: async () => { + closeCalled = true; + }, + } as unknown as import('chokidar').FSWatcher; + + const {router} = captureRouter(); + const source = new FileWatcherSource(router, { + root: '.', + _watchFn: (() => fakeWatcher) as unknown as WatchFn, + }); const err = await t.throwsAsync(() => source.start()); t.is(err?.message, 'Simulated startup failure'); t.true(closeCalled, 'watcher.close() must be called on startup error'); -}); \ No newline at end of file +}); diff --git a/source/utils/shutdown/shutdown-manager.spec.ts b/source/utils/shutdown/shutdown-manager.spec.ts index 5fa27a83..a7367894 100644 --- a/source/utils/shutdown/shutdown-manager.spec.ts +++ b/source/utils/shutdown/shutdown-manager.spec.ts @@ -245,6 +245,7 @@ test.serial('programmatic timeoutMs takes priority over env var', (t) => { process.env.NANOCODER_DEFAULT_SHUTDOWN_TIMEOUT = '5000'; const manager = new ShutdownManager({timeoutMs: 15000}); t.is(manager['timeoutMs'], 15000); + manager.reset(); process.env.NANOCODER_DEFAULT_SHUTDOWN_TIMEOUT = originalEnv ?? ''; }); diff --git a/source/utils/shutdown/shutdown-manager.ts b/source/utils/shutdown/shutdown-manager.ts index 13fccd0c..432b395e 100644 --- a/source/utils/shutdown/shutdown-manager.ts +++ b/source/utils/shutdown/shutdown-manager.ts @@ -81,14 +81,19 @@ export class ShutdownManager { } })(); + let timeoutId: ReturnType | undefined; const timeoutPromise = new Promise(resolve => { - setTimeout(() => { + timeoutId = setTimeout(() => { logger.warn('Shutdown timeout reached, forcing exit'); resolve(); }, this.timeoutMs); + timeoutId.unref?.(); }); await Promise.race([shutdownPromise, timeoutPromise]); + if (timeoutId) { + clearTimeout(timeoutId); + } process.exit(exitCode); } diff --git a/source/utils/tool-result-display.tsx b/source/utils/tool-result-display.tsx index ab221b51..1624087a 100644 --- a/source/utils/tool-result-display.tsx +++ b/source/utils/tool-result-display.tsx @@ -48,7 +48,7 @@ function CompactToolError({toolName}: {toolName: string}) { const {colors} = useTheme(); return ( - {'\u2692'} {toolName} failed + {'\u2692'} {toolName} failed. ); } From 7591a9312496e33a208491f2916403a8b82fe6bf Mon Sep 17 00:00:00 2001 From: EntropyParadigm Date: Tue, 16 Jun 2026 08:29:40 -0700 Subject: [PATCH 3/3] Revert "fix: stabilize full test gate" This reverts commit ddd24966581c1a654dc66c3126471c3791388517. --- source/events/sources/file-watcher.spec.ts | 74 ++++++++----------- .../utils/shutdown/shutdown-manager.spec.ts | 1 - source/utils/shutdown/shutdown-manager.ts | 7 +- source/utils/tool-result-display.tsx | 2 +- 4 files changed, 31 insertions(+), 53 deletions(-) diff --git a/source/events/sources/file-watcher.spec.ts b/source/events/sources/file-watcher.spec.ts index 4d778d56..b4bc1387 100644 --- a/source/events/sources/file-watcher.spec.ts +++ b/source/events/sources/file-watcher.spec.ts @@ -32,7 +32,7 @@ function fileSub(id: string, paths?: string[]): Subscription { async function waitFor( predicate: () => boolean, - timeoutMs = 5000, + timeoutMs = 2000, intervalMs = 25, ): Promise { const deadline = Date.now() + timeoutMs; @@ -97,8 +97,6 @@ test.serial('paths emitted are relative to the watch root', async t => { const dir = await mkdtemp(join(tmpdir(), 'fw-spec-rel-')); const sub = join(dir, 'src', 'inner'); await mkdir(sub, {recursive: true}); - const file = join(sub, 'leaf.ts'); - await writeFile(file, 'before'); const {router, events} = captureRouter(); router.subscribe(fileSub('s1')); @@ -110,7 +108,7 @@ test.serial('paths emitted are relative to the watch root', async t => { await source.start(); try { - await writeFile(file, 'after'); + await writeFile(join(sub, 'leaf.ts'), 'x'); await waitFor(() => events.some( e => @@ -136,30 +134,18 @@ test.serial('subscriptions with paths filter narrow down events', async t => { const dir = await mkdtemp(join(tmpdir(), 'fw-spec-paths-')); const {router, events} = captureRouter(); router.subscribe(fileSub('s1', ['docs/**'])); - const callbacks: Record void>> = {}; - type WatchFn = typeof import('chokidar').watch; - const fakeWatcher = { - on: (event: string, callback: (file: string) => void) => { - callbacks[event] = [...(callbacks[event] ?? []), callback]; - return fakeWatcher; - }, - once: (event: string, callback: () => void) => { - if (event === 'ready') { - setImmediate(callback); - } - return fakeWatcher; - }, - close: async () => {}, - } as unknown as import('chokidar').FSWatcher; const source = new FileWatcherSource(router, { root: dir, - _watchFn: (() => fakeWatcher) as unknown as WatchFn, + usePolling: true, + pollingInterval: 50, }); await source.start(); try { - callbacks.add?.forEach(callback => callback('docs/a.md')); - callbacks.add?.forEach(callback => callback('src/a.ts')); + await mkdir(join(dir, 'docs')); + await mkdir(join(dir, 'src')); + await writeFile(join(dir, 'docs', 'a.md'), 'docs'); + await writeFile(join(dir, 'src', 'a.ts'), 'src'); // Wait for at least one event to come through await waitFor(() => events.length > 0); @@ -206,30 +192,28 @@ test.serial('stop releases the watcher and stops emitting', async t => { }); test.serial('closes the underlying watcher if initialization fails', async t => { - let closeCalled = false; - - type WatchFn = typeof import('chokidar').watch; // avoids importing watch itself - - const fakeWatcher = { - on: (_e: string, _cb: unknown) => fakeWatcher, - once: (event: string, callback: (...args: unknown[]) => void) => { - if (event === 'error') { - setImmediate(() => callback(new Error('Simulated startup failure'))); - } - return fakeWatcher; - }, - close: async () => { - closeCalled = true; - }, - } as unknown as import('chokidar').FSWatcher; - - const {router} = captureRouter(); - const source = new FileWatcherSource(router, { - root: '.', - _watchFn: (() => fakeWatcher) as unknown as WatchFn, - }); + let closeCalled = false; + + type WatchFn = typeof import('chokidar').watch; // ← avoids importing watch itself + + const fakeWatcher = { + on: (_e: string, _cb: unknown) => fakeWatcher, + once: (event: string, callback: (...args: unknown[]) => void) => { + if (event === 'error') { + setImmediate(() => callback(new Error('Simulated startup failure'))); + } + return fakeWatcher; + }, + close: async () => { closeCalled = true; }, + } as unknown as import('chokidar').FSWatcher; + + const {router} = captureRouter(); + const source = new FileWatcherSource(router, { + root: '.', + _watchFn: (() => fakeWatcher) as unknown as WatchFn, + }); const err = await t.throwsAsync(() => source.start()); t.is(err?.message, 'Simulated startup failure'); t.true(closeCalled, 'watcher.close() must be called on startup error'); -}); +}); \ No newline at end of file diff --git a/source/utils/shutdown/shutdown-manager.spec.ts b/source/utils/shutdown/shutdown-manager.spec.ts index a7367894..5fa27a83 100644 --- a/source/utils/shutdown/shutdown-manager.spec.ts +++ b/source/utils/shutdown/shutdown-manager.spec.ts @@ -245,7 +245,6 @@ test.serial('programmatic timeoutMs takes priority over env var', (t) => { process.env.NANOCODER_DEFAULT_SHUTDOWN_TIMEOUT = '5000'; const manager = new ShutdownManager({timeoutMs: 15000}); t.is(manager['timeoutMs'], 15000); - manager.reset(); process.env.NANOCODER_DEFAULT_SHUTDOWN_TIMEOUT = originalEnv ?? ''; }); diff --git a/source/utils/shutdown/shutdown-manager.ts b/source/utils/shutdown/shutdown-manager.ts index 432b395e..13fccd0c 100644 --- a/source/utils/shutdown/shutdown-manager.ts +++ b/source/utils/shutdown/shutdown-manager.ts @@ -81,19 +81,14 @@ export class ShutdownManager { } })(); - let timeoutId: ReturnType | undefined; const timeoutPromise = new Promise(resolve => { - timeoutId = setTimeout(() => { + setTimeout(() => { logger.warn('Shutdown timeout reached, forcing exit'); resolve(); }, this.timeoutMs); - timeoutId.unref?.(); }); await Promise.race([shutdownPromise, timeoutPromise]); - if (timeoutId) { - clearTimeout(timeoutId); - } process.exit(exitCode); } diff --git a/source/utils/tool-result-display.tsx b/source/utils/tool-result-display.tsx index 1624087a..ab221b51 100644 --- a/source/utils/tool-result-display.tsx +++ b/source/utils/tool-result-display.tsx @@ -48,7 +48,7 @@ function CompactToolError({toolName}: {toolName: string}) { const {colors} = useTheme(); return ( - {'\u2692'} {toolName} failed. + {'\u2692'} {toolName} failed ); }