Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"type-check": "tsc --noEmit",
"test": "NODE_ENV=test node --test --loader ts-node/esm tests/*.test.ts tests/**/*.test.ts",
"test": "node --loader ts-node/esm scripts/runTests.ts",
"start": "node --loader ts-node/esm src/server.ts",
"seed": "node --loader ts-node/esm scripts/seedDatabase.ts"
},
Expand All @@ -53,4 +53,4 @@
"typescript": "^5.6.3",
"typescript-eslint": "^8.48.1"
}
}
}
33 changes: 33 additions & 0 deletions scripts/runTests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const dirname = path.dirname(fileURLToPath(import.meta.url));
const testsDir = path.resolve(dirname, '../tests');

const testFiles = fs.readdirSync(testsDir, { recursive: true })
.map(file => file.toString())
.filter(file => file.endsWith('.test.ts'))
.map(file => path.join(testsDir, file));

const result = spawnSync('node', [
'--loader', 'ts-node/esm',
'--test',
...testFiles
], {
stdio: 'inherit',
env: { ...process.env, NODE_ENV: 'test' }
});

if (result.error) {
console.error('Failed to start test runner:', result.error);
process.exit(1);
}

if (result.signal) {
process.kill(process.pid, result.signal);
process.exit(1);
}

process.exit(result.status ?? 0);
5 changes: 1 addition & 4 deletions scripts/seedDatabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import { SqliteConversationStore } from "../src/stores/sqliteConversationStore.js";
import { Conversation, ConversationTurn, Entity } from "../src/types.js";
import { Conversation, ConversationTurn } from "../src/types.js";
import { randomUUID } from "crypto";

const DB_PATH = "./data/conversations.db";
Expand Down Expand Up @@ -56,9 +56,6 @@ function generateIncidentId(): string {
return `INC-${Math.floor(10000 + Math.random() * 90000)}`;
}

function generateTicketId(): string {
return `TICK-${Math.floor(1000 + Math.random() * 9000)}`;
}

interface ConversationTemplate {
name: (service: string) => string;
Expand Down
19 changes: 16 additions & 3 deletions src/engine/conversationManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,10 +316,23 @@
if (typeof value === "string") {
return value;
}

try {
return JSON.stringify(value);
} catch {
const errorCache = new Set();
return JSON.stringify(value, (key, val) => {
if (val instanceof Error) {
if (errorCache.has(val)) {
return "[Circular]";
}
errorCache.add(val);
return { name: val.name, message: val.message };
}
if (typeof val === "bigint") {
return val.toString();
}
return val;
});
} catch (err) {

Check warning on line 335 in src/engine/conversationManager.ts

View workflow job for this annotation

GitHub Actions / build

'err' is defined but never used
return String(value);
}
}
2 changes: 1 addition & 1 deletion src/engine/handlers/metric/followUpHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ export const metricFollowUpHandler: FollowUpHandler = async (
// Check if we already added it to suggestions in this turn
const alreadySuggested = suggestions.some(s =>
s.name === "describe-metrics" &&
(s.arguments.scope as any)?.service === service
(s.arguments.scope as JsonObject)?.service === service
);

if (!alreadySuggested) {
Expand Down
10 changes: 0 additions & 10 deletions src/engine/handlers/orchestration/intentHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,3 @@ export const orchestrationIntentHandler: IntentHandler = async (context): Promis
reasoning: 'No orchestration keywords detected'
};
};
function extractQuery(text: string): string {
// Simple extraction: take words after key phrases
const connectives = ['for', 'about', 'finding', 'showing'];
const words = text.split(' ');
const idx = words.findIndex(w => connectives.includes(w));
if (idx !== -1 && idx < words.length - 1) {
return words.slice(idx + 1).join(' ');
}
return text;
}
32 changes: 30 additions & 2 deletions src/stores/sqliteConversationStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,8 +296,36 @@
}
}

// Serialize turns to JSON
const turnsJson = JSON.stringify(conversation.turns);
// Safely serialize turns to JSON considering Errors, BigInts, and circular references
let turnsJson: string;
try {
const errorCache = new Set();
turnsJson = JSON.stringify(conversation.turns, (key, value) => {
if (value instanceof Error) {
if (errorCache.has(value)) {
return "[Circular]";
}
errorCache.add(value);
return {
name: value.name,
message: value.message,
stack: value.stack,
...((value as any).cause ? { cause: (value as any).cause } : {})

Check warning on line 313 in src/stores/sqliteConversationStore.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type

Check warning on line 313 in src/stores/sqliteConversationStore.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
};
}
if (typeof value === "bigint") {
return value.toString();
}
return value;
});
} catch (stringifyError) {
console.warn(`[SqliteConversationStore] Failed to serialize turns for ${chatId}, saving a fallback format:`, stringifyError);
turnsJson = JSON.stringify([{
userMessage: "[Error]",
assistantResponse: "Conversation could not be saved due to an unserializable object in the execution trace.",
timestamp: Date.now()
}]);
}

// Insert or replace conversation
this.setStmt.run(
Expand Down
41 changes: 41 additions & 0 deletions tests/sqliteConversationStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,47 @@
cleanup();
}
});

it('should safely stringify and store conversations with BigInt and Circular Errors', async () => {
const { path, cleanup } = createTempDb();

try {
const store = new SqliteConversationStore(defaultConfig, path);
const conversation = createTestConversation('chat2', 'Serialization Test');

// Construct a circular error specifically to mimic unhandled fetch exceptions
const fetchError = new Error("fetch failed");
(fetchError as any).cause = fetchError; // Create circular reference

Check warning on line 590 in tests/sqliteConversationStore.test.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type

conversation.turns.push({
userMessage: 'Trigger complex log payload',
assistantResponse: 'Fetched with errors.',
timestamp: Date.now(),
toolResults: [{
name: 'fetch-tool',
// BigInt and circular error mimic exact payloads that crash JSON.stringify
result: { count: BigInt(9007199254740991n), error: fetchError } as any

Check warning on line 599 in tests/sqliteConversationStore.test.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
}]
});

await store.set('chat2', conversation);
const retrieved = await store.get('chat2');

assert.strictEqual(retrieved?.turns.length, 2);

// Assert BigInt was safely stringified to string
const complexTurn = retrieved?.turns[1];
const complexResult = complexTurn?.toolResults?.[0].result as any;

Check warning on line 610 in tests/sqliteConversationStore.test.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type

assert.strictEqual(complexResult.count, "9007199254740991");
assert.strictEqual(complexResult.error.message, "fetch failed");
assert.strictEqual(complexResult.error.cause, "[Circular]");

await store.close();
} finally {
cleanup();
}
});
});
});

Expand Down
Loading