diff --git a/src/engine/conversationManager.ts b/src/engine/conversationManager.ts index da91e78..7257edf 100644 --- a/src/engine/conversationManager.ts +++ b/src/engine/conversationManager.ts @@ -332,7 +332,7 @@ function safeStringify(value: unknown): string { } return val; }); - } catch (err) { + } catch { return String(value); } } diff --git a/src/engine/copilotEngine.ts b/src/engine/copilotEngine.ts index 2a8aa4b..d6bc307 100644 --- a/src/engine/copilotEngine.ts +++ b/src/engine/copilotEngine.ts @@ -330,6 +330,7 @@ export class CopilotEngine { let iteration = 0; let isFirstIteration = true; let hadPlannedCalls = false; + let shouldStopForSatisfiedPlanner = false; // Step 4: Reasoning Loop while (iteration < this.maxIterations) { @@ -391,28 +392,37 @@ export class CopilotEngine { ); plannedCalls = plan.toolCalls ?? []; + if (iteration >= 3 && plannedCalls.length === 0) { + console.log( + `[Copilot][${chatId}] Follow-up planner returned no tool calls on iteration ${iteration}. Treating this as satisfied and stopping for synthesis.`, + ); + shouldStopForSatisfiedPlanner = true; + } + // Record iteration start with planned tools this.executionTracer.startIteration(trace, plannedCalls); - // Apply follow-up heuristics - const beforeHeuristics = plannedCalls.length; - const followUpSuggestions = await this.followUpEngine.applyFollowUps( - formattedResults, - chatId, - conversationTurns, - questionForPlanning, - plannedCalls, - ); - plannedCalls.push(...followUpSuggestions); - - // Record heuristic modifications - if (plannedCalls.length !== beforeHeuristics) { - this.executionTracer.recordHeuristic(trace, { - heuristicName: "followUpHeuristics", - action: "modify", - reason: "Applied context-aware follow-up heuristics", - affectedTools: plannedCalls.map((c) => c.name), - }); + if (!shouldStopForSatisfiedPlanner) { + // Apply follow-up heuristics + const beforeHeuristics = plannedCalls.length; + const followUpSuggestions = await this.followUpEngine.applyFollowUps( + formattedResults, + chatId, + conversationTurns, + questionForPlanning, + plannedCalls, + ); + plannedCalls.push(...followUpSuggestions); + + // Record heuristic modifications + if (plannedCalls.length !== beforeHeuristics) { + this.executionTracer.recordHeuristic(trace, { + heuristicName: "followUpHeuristics", + action: "modify", + reason: "Applied context-aware follow-up heuristics", + affectedTools: plannedCalls.map((c) => c.name), + }); + } } } diff --git a/src/engine/handlers/orchestration/followUpHandler.ts b/src/engine/handlers/orchestration/followUpHandler.ts index e90c179..344508f 100644 --- a/src/engine/handlers/orchestration/followUpHandler.ts +++ b/src/engine/handlers/orchestration/followUpHandler.ts @@ -2,25 +2,133 @@ import type { FollowUpHandler } from "../handlers.js"; import type { ToolCall, JsonObject, JsonValue, HandlerContext } from "../../../types.js"; import { HandlerUtils } from "../utils.js"; -const isRecord = (value: unknown): value is JsonObject => - typeof value === "object" && value !== null && !Array.isArray(value); +const ORCHESTRATION_STOP_WORDS = new Set([ + "the", + "a", + "an", + "and", + "or", + "for", + "with", + "from", + "into", + "onto", + "that", + "this", + "these", + "those", + "what", + "when", + "where", + "which", + "who", + "why", + "how", + "please", + "check", + "show", + "find", + "look", + "need", + "more", + "details", + "incident", + "incidents", + "issue", + "issues", + "alert", + "alerts", + "error", + "errors", + "failed", + "failing", + "failure", + "failures", + "service", + "services", + "plan", + "plans", + "orchestration", + "response", +]); + +const ORCHESTRATION_KEY_FIELDS = [ + "title", + "name", + "summary", + "description", + "severity", + "status", + "state", + "type", + "category", + "kind", + "source", + "component", + "operation", + "reason", + "metricName", + "metric", + "signal", + "condition", + "runbook", +] as const; -const collectPlanObjects = (result: JsonValue): JsonObject[] => { - if (Array.isArray(result)) { - return result.filter(isRecord); +class SuggestionTracker { + private seen = new Set(); + private context: HandlerContext; + + constructor(context: HandlerContext) { + this.context = context; } - if (isRecord(result)) { - if (Array.isArray(result.plans)) { - return result.plans.filter(isRecord); + add(call: ToolCall): boolean { + const scope = call.arguments?.scope as JsonObject | undefined; + const service = (scope?.service as string) ?? "_no_service_"; + const key = `${call.name}:${service}`; + + if (this.seen.has(key)) { + return false; } - if (Array.isArray(result.items)) { - return result.items.filter(isRecord); + + if (service !== "_no_service_") { + if (HandlerUtils.isDuplicateToolCall(this.context, call.name, service)) { + return false; + } } - return [result]; + + this.seen.add(key); + return true; + } + + filter(calls: ToolCall[]): ToolCall[] { + return calls.filter((call) => this.add(call)); } +} - return []; +const isRecord = (value: unknown): value is JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value); + +const collectAllRecordsDeep = (value: JsonValue): JsonObject[] => { + const records: JsonObject[] = []; + + const traverse = (current: JsonValue) => { + if (Array.isArray(current)) { + for (const item of current) { + traverse(item); + } + } else if (isRecord(current)) { + records.push(current); + for (const key in current) { + if (Object.prototype.hasOwnProperty.call(current, key)) { + traverse(current[key]); + } + } + } + }; + + traverse(value); + return records; }; const addService = (services: Set, value: unknown): void => { @@ -31,7 +139,7 @@ const addService = (services: Set, value: unknown): void => { const collectServicesFromResult = (result: JsonValue, fields: string[]): Set => { const services = new Set(); - const records = collectPlanObjects(result); + const records = collectAllRecordsDeep(result); for (const record of records) { for (const field of fields) { @@ -57,23 +165,128 @@ const collectServiceFromScope = (context: HandlerContext, toolResult: { argument return service; }; +const addKeywordsFromText = ( + text: unknown, + sink: string[], + seen: Set, + limit: number, +): void => { + if (typeof text !== "string" || !text.trim()) { + return; + } + + const matches = text.toLowerCase().match(/[a-z0-9][a-z0-9._:-]*/g) ?? []; + for (const match of matches) { + const normalized = match.replace(/^[_:.-]+|[_:.-]+$/g, ""); + if ( + normalized.length < 3 || + ORCHESTRATION_STOP_WORDS.has(normalized) || + seen.has(normalized) + ) { + continue; + } + + seen.add(normalized); + sink.push(normalized); + + if (sink.length >= limit) { + return; + } + } +}; + +const addKeywordsFromRecord = ( + record: JsonObject, + fields: readonly string[], + sink: string[], + seen: Set, + limit: number, +): void => { + for (const field of fields) { + if (sink.length >= limit) { + return; + } + + const value = record[field]; + if (typeof value === "string") { + addKeywordsFromText(value, sink, seen, limit); + continue; + } + + if (Array.isArray(value)) { + for (const item of value) { + if (sink.length >= limit) { + return; + } + addKeywordsFromText(item, sink, seen, limit); + } + continue; + } + + if (isRecord(value)) { + for (const nestedValue of Object.values(value)) { + if (sink.length >= limit) { + return; + } + addKeywordsFromText(nestedValue, sink, seen, limit); + } + } + } +}; + +const buildOrchestrationQuery = ( + userQuestion: string, + records: JsonObject[], + extraTexts: unknown[] = [], +): string => { + const keywords: string[] = []; + const seen = new Set(); + + addKeywordsFromText(userQuestion, keywords, seen, 5); + + for (const text of extraTexts) { + if (keywords.length >= 10) { + break; + } + addKeywordsFromText(text, keywords, seen, 10); + } + + for (const record of records) { + if (keywords.length >= 12) { + break; + } + addKeywordsFromRecord(record, ORCHESTRATION_KEY_FIELDS, keywords, seen, 12); + } + + return keywords.join(" ").trim() || "incident response"; +}; + export const orchestrationFollowUpHandler: FollowUpHandler = async ( context, toolResult, ): Promise => { const followUps: ToolCall[] = []; const result = toolResult.result; + const tracker = new SuggestionTracker(context); if (toolResult.name === "query-orchestration-plans") { - const plans = collectPlanObjects(result); - for (const plan of plans.slice(0, 3)) { - if (typeof plan.id !== "string" || !plan.id) continue; - followUps.push({ - name: "get-orchestration-plan", - arguments: { id: plan.id }, - }); + const plans = collectAllRecordsDeep(result); + const validPlans = plans.filter(p => typeof p.id === "string" && p.id && (p.title || p.name || p.description)); + const plansToUse = validPlans.length > 0 ? validPlans : plans.filter(p => typeof p.id === "string" && p.id); + const uniquePlanIds = new Set(); + + for (const plan of plansToUse) { + if(uniquePlanIds.size >= 3) break; + const planId = plan.id as string; + if(!uniquePlanIds.has(planId)){ + uniquePlanIds.add(planId); + followUps.push({ + name: "get-orchestration-plan", + arguments: { id: planId }, + }); + } } - return followUps; + return tracker.filter(followUps); } const services = new Set(); @@ -82,9 +295,64 @@ export const orchestrationFollowUpHandler: FollowUpHandler = async ( services.add(serviceFromScope); } - if (toolResult.name === "query-incidents" || toolResult.name === "query-alerts") { - const servicesFromResults = collectServicesFromResult(result, ["service"]); - servicesFromResults.forEach((service) => services.add(service)); + if (toolResult.name === "query-incidents" || toolResult.name === "get-incident") { + const records = collectAllRecordsDeep(result); + const servicesFromRecords = new Set(); + records.forEach(r => addService(servicesFromRecords, r["service"])); + servicesFromRecords.forEach((service) => services.add(service)); + + const firstIncidentWithTitle = records.find(r => typeof r.title === "string" && r.title); + const queryContext = buildOrchestrationQuery( + context.userQuestion, + records, + [firstIncidentWithTitle?.title], + ); + + if (servicesFromRecords.size > 0 || serviceFromScope) { + const targetService = (servicesFromRecords.values().next().value || serviceFromScope) as string; + + followUps.push({ + name: "query-orchestration-plans", + arguments: { + scope: { service: targetService }, + query: queryContext + }, + }); + } else if (records.length > 0) { + followUps.push({ + name: "query-orchestration-plans", + arguments: { + query: queryContext + }, + }); + } + } + + if (toolResult.name === "query-alerts" || toolResult.name === "get-alert") { + const records = collectAllRecordsDeep(result); + const servicesFromRecords = new Set(); + records.forEach(r => addService(servicesFromRecords, r["service"])); + servicesFromRecords.forEach((service) => services.add(service)); + + const firstAlert = records.find(r => (typeof r.name === "string" && r.name) || (typeof r.description === "string" && r.description)); + const titleOrDesc = firstAlert?.name || firstAlert?.description; + const queryContext = buildOrchestrationQuery( + context.userQuestion, + records, + [titleOrDesc], + ); + + if (servicesFromRecords.size > 0 || serviceFromScope) { + const targetService = (servicesFromRecords.values().next().value || serviceFromScope) as string; + + followUps.push({ + name: "query-orchestration-plans", + arguments: { + scope: { service: targetService }, + query: queryContext + }, + }); + } } if (toolResult.name === "query-services" || toolResult.name === "get-service") { @@ -95,27 +363,26 @@ export const orchestrationFollowUpHandler: FollowUpHandler = async ( if (services.size > 0) { const uniqueServices = [...services].slice(0, 3); for (const service of uniqueServices) { - if (HandlerUtils.isDuplicateToolCall(context, "query-orchestration-plans", service)) { - continue; + const alreadyHasQuery = followUps.some(f => f.name === "query-orchestration-plans" && (f.arguments?.scope as JsonObject)?.service === service); + if (alreadyHasQuery) { + continue; } + + const args: JsonObject = { + scope: { service } + }; + + const combinedQuery = buildOrchestrationQuery(context.userQuestion, []); + if (combinedQuery.trim()) { + args.query = combinedQuery; + } + followUps.push({ name: "query-orchestration-plans", - arguments: { - scope: { service }, - }, + arguments: args, }); } - return followUps; - } - - if (toolResult.name === "query-incidents" && Array.isArray(result) && result.length > 0) { - followUps.push({ - name: "query-orchestration-plans", - arguments: { - query: "incident response", - }, - }); } - return followUps; + return tracker.filter(followUps); }; diff --git a/src/llms/gemini.ts b/src/llms/gemini.ts index 528df78..163fc1c 100644 --- a/src/llms/gemini.ts +++ b/src/llms/gemini.ts @@ -9,7 +9,7 @@ import { } from "../types.js"; const GEMINI_MODEL = - process.env.GEMINI_MODEL || "gemini-3-flash-preview"; + process.env.GEMINI_MODEL || "gemini-3.1-pro-preview"; /** * Map internal Tool definitions to Gemini function declarations format. diff --git a/src/llms/openai.ts b/src/llms/openai.ts index 88b3001..e0abd14 100644 --- a/src/llms/openai.ts +++ b/src/llms/openai.ts @@ -12,7 +12,7 @@ import { withRetry } from "../engine/retryStrategy.js"; const OPENAI_BASE_URL = process.env.OPENAI_BASE_URL || "https://api.openai.com/v1"; -const OPENAI_MODEL = process.env.OPENAI_MODEL || "gpt-5.1"; +const OPENAI_MODEL = process.env.OPENAI_MODEL || "gpt-5-nano"; const RESPONSES_URL = `${OPENAI_BASE_URL.replace(/\/+$/, "")}/responses`; diff --git a/src/stores/sqliteConversationStore.ts b/src/stores/sqliteConversationStore.ts index 4a79ddc..3bf73de 100644 --- a/src/stores/sqliteConversationStore.ts +++ b/src/stores/sqliteConversationStore.ts @@ -30,6 +30,8 @@ interface CountRow { count: number; } +type ErrorWithCause = Error & { cause?: unknown }; + function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -302,15 +304,18 @@ export class SqliteConversationStore implements ConversationStore { const errorCache = new Set(); turnsJson = JSON.stringify(conversation.turns, (key, value) => { if (value instanceof Error) { + const errorWithCause = value as ErrorWithCause; if (errorCache.has(value)) { return "[Circular]"; } errorCache.add(value); - return { - name: value.name, - message: value.message, + return { + name: value.name, + message: value.message, stack: value.stack, - ...((value as any).cause ? { cause: (value as any).cause } : {}) + ...(errorWithCause.cause !== undefined + ? { cause: errorWithCause.cause } + : {}), }; } if (typeof value === "bigint") { diff --git a/tests/copilotEngine.planning.test.ts b/tests/copilotEngine.planning.test.ts index 4ff2cdb..bab2485 100644 --- a/tests/copilotEngine.planning.test.ts +++ b/tests/copilotEngine.planning.test.ts @@ -249,6 +249,84 @@ test('stops loop when max iterations reached', async () => { assert.equal(toolCallCount, 2, `Expected 2 tool calls but got ${toolCallCount}`); }); +test('stops and synthesizes when follow-up planner returns no tool calls on third iteration', async () => { + let plannerCalls = 0; + const llm: LlmClient = { + async chat(_messages: LlmMessage[], tools: Tool[]) { + if (tools.length) { + plannerCalls += 1; + if (plannerCalls === 1) { + return { + content: 'plan1', + toolCalls: [{ name: 'query-incidents', arguments: { limit: 1 } }], + }; + } + if (plannerCalls === 2) { + return { + content: 'plan2', + toolCalls: [{ name: 'get-incident-timeline', arguments: { id: 'INC-1' } }], + }; + } + return { + content: 'satisfied', + toolCalls: [], + }; + } + + return { + content: JSON.stringify({ conclusion: 'done', evidence: [] }), + toolCalls: [], + }; + }, + }; + + const calls: ToolCall[] = []; + const mcp: StubMcp = { + async listTools() { + return [ + { name: 'query-incidents' } as Tool, + { name: 'get-incident-timeline' } as Tool, + { name: 'query-orchestration-plans' } as Tool, + ]; + }, + async callTool(call) { + calls.push(call); + if (call.name === 'query-incidents') { + return { + name: call.name, + arguments: call.arguments, + result: [{ id: 'INC-1', status: 'open', service: 'checkout' }], + }; + } + if (call.name === 'get-incident-timeline') { + return { + name: call.name, + arguments: call.arguments, + result: [{ at: '2024-01-01T00:00:00Z', kind: 'event' }], + }; + } + if (call.name === 'query-orchestration-plans') { + return { + name: call.name, + arguments: call.arguments, + result: [{ id: 'plan-1' }], + }; + } + return { name: call.name, arguments: call.arguments, result: null }; + }, + }; + + const engine = makeEngine(llm, mcp, { maxIterations: 5 }); + await engine.answer('Investigate the open checkout incident'); + + assert.deepEqual(calls.map((call) => call.name), [ + 'query-incidents', + 'get-incident-timeline', + 'query-orchestration-plans', + ]); + assert.equal(plannerCalls, 3); +}); + test('skips placeholder args and reports missing data', async () => { const llm: LlmClient = { async chat(_messages: LlmMessage[] = [], _tools: Tool[] = [], _opts?: { chatId?: string }) { diff --git a/tests/engine/handlers/orchestration/followUpHandler.test.ts b/tests/engine/handlers/orchestration/followUpHandler.test.ts index 1c10d66..f0ebd73 100644 --- a/tests/engine/handlers/orchestration/followUpHandler.test.ts +++ b/tests/engine/handlers/orchestration/followUpHandler.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { orchestrationFollowUpHandler } from '../../../../src/engine/handlers/orchestration/followUpHandler.js'; -import type { HandlerContext, ToolCall } from '../../../../src/types.js'; +import type { HandlerContext, JsonValue, ToolCall } from '../../../../src/types.js'; test('orchestrationFollowUpHandler', async (t) => { const context: HandlerContext = { @@ -9,19 +9,62 @@ test('orchestrationFollowUpHandler', async (t) => { turnNumber: 1, conversationHistory: [], toolResults: [], - userQuestion: '' + userQuestion: 'How to handle DB error?' }; - await t.test('suggests query-orchestration-plans after finding incidents', async () => { + await t.test('suggests query-orchestration-plans after finding incidents with title and scope', async () => { const toolResult = { name: 'query-incidents', - result: [{ id: 'inc-1', service: 'payment-service' }] + result: [{ id: 'inc-1', service: 'payment-service', title: 'DB Timeout' }], + arguments: { scope: { service: 'payment-service' } } }; const followUps = await orchestrationFollowUpHandler(context, toolResult); + const planQueryCall = followUps.find((c: ToolCall) => c.name === 'query-orchestration-plans'); + assert.ok(planQueryCall); + assert.deepEqual(planQueryCall?.arguments, { + scope: { service: 'payment-service' }, + query: 'handle timeout' + }); + }); + await t.test('suggests query-orchestration-plans after finding alerts with name/description', async () => { + const toolResult = { + name: 'query-alerts', + result: [{ id: 'alt-1', service: 'auth-service', description: 'High latency detected in login path' }], + }; + const followUps = await orchestrationFollowUpHandler(context, toolResult); const planQueryCall = followUps.find((c: ToolCall) => c.name === 'query-orchestration-plans'); assert.ok(planQueryCall); - assert.deepEqual(planQueryCall?.arguments, { scope: { service: 'payment-service' } }); + assert.deepEqual(planQueryCall?.arguments, { + scope: { service: 'auth-service' }, + query: 'handle high latency detected login path' + }); + }); + + await t.test('suggests query-orchestration-plans for services found in query-services', async () => { + const toolResult = { + name: 'query-services', + result: [ + { id: 'srv-1', name: 'search-service' }, + { id: 'srv-2', name: 'cart-service' } + ] + }; + const followUps = await orchestrationFollowUpHandler(context, toolResult); + const queries = followUps.filter((c: ToolCall) => c.name === 'query-orchestration-plans'); + // It collects from both 'id' and 'name' fields, limited to 3 + assert.equal(queries.length, 3); + assert.deepEqual(queries[0].arguments, { + scope: { service: 'search-service' }, + query: 'handle' + }); + assert.deepEqual(queries[1].arguments, { + scope: { service: 'srv-1' }, + query: 'handle' + }); + assert.deepEqual(queries[2].arguments, { + scope: { service: 'cart-service' }, + query: 'handle' + }); }); await t.test('suggests generic query if incident has no service', async () => { @@ -33,7 +76,7 @@ test('orchestrationFollowUpHandler', async (t) => { const planQueryCall = followUps.find((c: ToolCall) => c.name === 'query-orchestration-plans'); assert.ok(planQueryCall); - assert.deepEqual(planQueryCall?.arguments, { query: 'incident response' }); + assert.deepEqual(planQueryCall?.arguments, { query: 'handle' }); }); await t.test('suggests get-orchestration-plan after listing plans', async () => { @@ -46,19 +89,24 @@ test('orchestrationFollowUpHandler', async (t) => { }; const followUps = await orchestrationFollowUpHandler(context, toolResult); - assert.equal(followUps.length, 2); + assert.equal(followUps.length, 1); assert.equal(followUps[0].name, 'get-orchestration-plan'); assert.deepEqual(followUps[0].arguments, { id: 'plan-1' }); }); - await t.test('limits suggestions to first 3 plans', async () => { + await t.test('prioritizes valid plans with title/name/description, else uses id-only plans', async () => { const toolResult = { name: 'query-orchestration-plans', result: [ - { id: '1' }, { id: '2' }, { id: '3' }, { id: '4' } - ] + { id: '1' }, + { id: '2', title: 'Valid Plan' }, + { id: '3' } + ] as JsonValue[] }; const followUps = await orchestrationFollowUpHandler(context, toolResult); - assert.equal(followUps.length, 3); + assert.equal(followUps.length, 1); + assert.equal(followUps[0].name, 'get-orchestration-plan'); + // Because "Valid Plan" exists, validPlans is used meaning only '2' is there to be processed by loop + assert.deepEqual(followUps[0].arguments, { id: '2' }); }); }); diff --git a/tests/gemini.test.ts b/tests/gemini.test.ts index 3c4cfdb..0958f78 100644 --- a/tests/gemini.test.ts +++ b/tests/gemini.test.ts @@ -100,7 +100,7 @@ test("gemini", async (t) => { const requestData = JSON.parse(jsonMatch[0]); assert.strictEqual( requestData.model, - "gemini-3-flash-preview", + "gemini-3.1-pro-preview", "Should log correct model name", ); assert.strictEqual( diff --git a/tests/sqliteConversationStore.test.ts b/tests/sqliteConversationStore.test.ts index 02e5124..01d88f7 100644 --- a/tests/sqliteConversationStore.test.ts +++ b/tests/sqliteConversationStore.test.ts @@ -4,7 +4,9 @@ import { mkdtempSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { SqliteConversationStore } from '../src/stores/sqliteConversationStore.js'; -import { Conversation, ConversationConfig } from '../src/types.js'; +import { Conversation, ConversationConfig, JsonObject } from '../src/types.js'; + +type ErrorWithCause = Error & { cause?: unknown }; /** * Helper to create a temporary database for testing. @@ -587,7 +589,7 @@ describe('SqliteConversationStore', () => { // Construct a circular error specifically to mimic unhandled fetch exceptions const fetchError = new Error("fetch failed"); - (fetchError as any).cause = fetchError; // Create circular reference + (fetchError as ErrorWithCause).cause = fetchError; // Create circular reference conversation.turns.push({ userMessage: 'Trigger complex log payload', @@ -596,7 +598,7 @@ describe('SqliteConversationStore', () => { toolResults: [{ name: 'fetch-tool', // BigInt and circular error mimic exact payloads that crash JSON.stringify - result: { count: BigInt(9007199254740991n), error: fetchError } as any + result: { count: BigInt(9007199254740991n), error: fetchError } as unknown as JsonObject }] }); @@ -607,11 +609,15 @@ describe('SqliteConversationStore', () => { // Assert BigInt was safely stringified to string const complexTurn = retrieved?.turns[1]; - const complexResult = complexTurn?.toolResults?.[0].result as any; + const complexResult = complexTurn?.toolResults?.[0].result as JsonObject; assert.strictEqual(complexResult.count, "9007199254740991"); - assert.strictEqual(complexResult.error.message, "fetch failed"); - assert.strictEqual(complexResult.error.cause, "[Circular]"); + assert.deepStrictEqual(complexResult.error, { + name: 'Error', + message: 'fetch failed', + stack: complexResult.error && typeof complexResult.error === 'object' && 'stack' in complexResult.error ? complexResult.error.stack : undefined, + cause: '[Circular]' + }); await store.close(); } finally {