Compare commits

...
Author SHA1 Message Date
MervinPraison 414a9387a9 Merge branch 'fix/comp-ts-gaps' of https://github.com/MervinPraison/PraisonAI into fix/comp-ts-gaps
# Conflicts:
#	src/praisonai-ts/src/agent/realtime.ts
2026-09-08 16:41:45 +01:00
MervinPraisonandClaude Opus 5 2292a013a2 fix(review): concurrent connect() no longer leaks a socket
Review found two races in the realtime connect/disconnect path. The first is
confirmed by running it -- two concurrent connect() calls opened TWO sockets
where one was expected:

    expect(server.connections.length).toBe(1)
    Received: 2

connect() assigns this.ws only after the handshake resolves, so both callers
passed the isConnected() check, both opened a socket, and the slower one was
overwritten -- left open and unreachable, with nothing holding a reference to
close it.

Concurrent callers now share one in-flight attempt.

The second race is structural: teardown() nulled this.ws unconditionally, so a
disconnect() still waiting on an old socket could clear a NEW socket installed
by a reconnect, marking the agent disconnected while its connection stayed
open. teardown now bumps a generation counter and disconnect captures the
generation it belongs to, so it can only tear down what it actually owns.

3 tests, including a control (a single connect opens exactly one socket).
Mutation-checked: removing the in-flight guard fails the race test.

Full suite 2421 passed, build and webview gate exit 0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 16:40:37 +01:00
praisonai-triage-agent[bot]andMervin Praison 7eb59e416e fix(ts): isolate realtime onMessage/onAudio callbacks from throwing handlers
A throwing user callback in the response.text.delta / response.audio.delta
loops escaped handleMessage, skipping later callbacks and preventing the
wildcard emit for that frame. reportError and emit already isolated handlers;
these two loops did not. Route both through a shared invokeCallbacks helper
with the same try/catch isolation, and add a regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-07 11:58:15 +00:00
praisonai-triage-agent[bot]andMervin Praison 7efd916abd fix(ts): CI Buffer type, realtime connect/disconnect races, sqlite schema check
- minimal-ws-server: annotate `buffered: Buffer` to resolve @types/node 22.20
  generic Buffer<ArrayBufferLike> vs Buffer<ArrayBuffer> mismatch that failed
  the unit-test suite compile (the CI failure).
- RealtimeAgent: serialize connect() so concurrent callers share one handshake;
  track the in-flight socket and a connect generation so disconnect() cancels a
  handshake still in flight instead of it later marking the agent connected.
- sqlite-adapter: REQUIRED_COLUMNS now lists every column each INSERT/UPDATE
  touches, so a partial legacy schema is diagnosed on open() rather than failing
  the first write.
- Tests: concurrent connect/disconnect lifecycle, ws-fallback via injectable
  loader (ws is transitive here so absence cannot be assumed), partial-schema
  diagnosis.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-07 11:52:21 +00:00
MervinPraison 46fcfd1f5a Merge branch 'fix/comp-realtime' into fix/comp-ts-gaps 2026-09-07 12:38:31 +01:00
MervinPraisonandClaude Opus 5 befdce2191 fix(ts): wire db("sqlite:") to the DbAdapter contract
db("sqlite:./data.db") threw "not yet wired to the DbAdapter contract"
while src/index.ts:26 advertised exactly that call in the quickstart.

Adds SqliteDbAdapter implementing the full DbAdapter contract (sessions,
messages, runs, tool calls, traces, spans) on a SQLite file, with two
drivers tried in order: better-sqlite3, then Node's built-in node:sqlite.
There is no memory fallback on any path -- if no driver opens the file,
every operation rejects with a message naming each driver's failure and
the fix. Behaviour is matched to MemoryDbAdapter so swapping backends
changes only durability.

postgres:// and redis:// still throw by design: both are remote-only HTTP
transports exposing query/get/set rather than sessions/messages/runs.
Their errors now point at db("sqlite:") as the working durable option.

Also fixes a .gitignore trap: the bare `db` rule on line 18 ignores any
directory named db, so tests/unit/db/ was silently untracked -- the new
durability suite would have passed locally and never been committed.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-07 12:38:25 +01:00
MervinPraisonandClaude Opus 5 0f3a5ae120 fix(ts): RealtimeAgent.connect() opens a real WebSocket
connect() set connected = true and emitted a fabricated session.created
without opening any socket, while the Python RealtimeAgent genuinely
calls websockets.connect(). Nothing listening, an unresolvable host and a
401-refused handshake all reported success.

connect() now builds wss://api.openai.com/v1/realtime?model=<llm>,
constructs the socket, and resolves only on the open event -- error,
close-during-handshake and timeout all reject. this.ws and this.connected
are set only after open. session.created is no longer fabricated; every
emitted event is parsed off the wire, and unknown server event types pass
through verbatim. isConnected() reads the live socket readyState.

sendText/sendAudio now send the real conversation.item.create +
response.create and input_audio_buffer.append frames, and throw when not
connected rather than emitting a local echo.

The WebSocket implementation is resolved via injection, globalThis, then
the ws package through a computed specifier so it stays off every
bundler's static graph.

Replaces a test that asserted the bug: "should emit events on connect"
required the fabricated session.created, so it passed while the feature
did nothing.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-07 12:38:25 +01:00
10 changed files with 3321 additions and 125 deletions

No files matched your search

+3
View File
@@ -26,6 +26,9 @@ output
!src/praisonai-code/praisonai_code/cli/output/
!src/praisonai-bot/praisonai_bot/cli/output/
!src/praisonai-ts/src/db/
# The bare `db` rule above also swallows the SQLite adapter's test
# directory, so the durability suite would silently never be committed.
!src/praisonai-ts/tests/unit/db/
!src/praisonai-ts/src/cli/output/
.files
+66 -27
View File
@@ -115,7 +115,7 @@
"version": "3.0.189",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.189.tgz",
"integrity": "sha512-roPKojHenQm7U77kNNE0w586jP2vnjxaNx/+KFSQkJRLLAxrg7yUwZQfyOEizoTxdaltu+ZIlLRTw09X3XbGvQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.15",
@@ -133,7 +133,7 @@
"version": "4.0.50",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.50.tgz",
"integrity": "sha512-YAcB+7M1JhAYsHorTrWyldCyZihjCKr/QRXH2vFrara/+lwqNE7q5KzoucKLZ7ktFiUonhnhFhRoiymsq/2K2Q==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.15",
@@ -224,7 +224,7 @@
"version": "3.0.15",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.15.tgz",
"integrity": "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
@@ -3002,7 +3002,7 @@
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
@@ -3199,7 +3199,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@tsconfig/node10": {
@@ -3648,7 +3648,7 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz",
"integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 20"
@@ -3751,7 +3751,7 @@
"version": "6.0.277",
"resolved": "https://registry.npmjs.org/ai/-/ai-6.0.277.tgz",
"integrity": "sha512-fYlPj2QsisCH66SsEfWM8gV0ZR+6NLuRYSnVw3GKUKlvP+iIAUQR1bUJn1Y6lcAKeoEWQeZaann2ahXZY2HXtQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/gateway": "3.0.189",
@@ -3770,7 +3770,7 @@
"version": "4.0.50",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.50.tgz",
"integrity": "sha512-YAcB+7M1JhAYsHorTrWyldCyZihjCKr/QRXH2vFrara/+lwqNE7q5KzoucKLZ7ktFiUonhnhFhRoiymsq/2K2Q==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.15",
@@ -3919,6 +3919,19 @@
"node": ">= 8"
}
},
"node_modules/anymatch/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
@@ -7479,6 +7492,19 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/jest-util/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/jest-validate": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz",
@@ -7632,7 +7658,7 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"dev": true,
"devOptional": true,
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
"node_modules/json-schema-ref-resolver": {
@@ -7936,6 +7962,19 @@
"node": ">=8.6"
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@@ -8531,13 +8570,13 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
@@ -8945,6 +8984,19 @@
"node": ">=8.10.0"
}
},
"node_modules/readdirp/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
@@ -9862,19 +9914,6 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -10267,7 +10306,7 @@
"version": "6.28.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz",
"integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=18.17"
+680 -67
View File
@@ -1,10 +1,145 @@
/**
* RealtimeAgent - Real-time voice/audio interaction agent
*
*
* Python parity with praisonaiagents/agent/realtime_agent.py
* Enables real-time voice conversations using WebSocket connections.
* Enables real-time voice conversations over a real WebSocket connection.
*
* ## Why the WebSocket is loaded the way it is
*
* This module is exported from the package root, so it has to stay loadable in
* a browser/webview bundle (see `scripts/webview-gate.mjs`). A static
* `import WebSocket from 'ws'` would put an unresolvable bare specifier on that
* bundle. So the implementation is resolved at call time:
*
* 1. an explicitly injected constructor (`{ webSocket }`), for tests and for
* runtimes that ship their own,
* 2. `globalThis.WebSocket` -- browsers, React Native, and Node >= 22,
* 3. the optional `ws` package, imported through a computed specifier so no
* bundler tries to resolve it statically. `ws` is NOT a dependency of this
* package; this branch only fires if a consumer installed it themselves,
* 4. otherwise a clear error naming all three ways to fix it. It never
* pretends to have connected.
*/
// ============================================================================
// WebSocket transport abstraction
// ============================================================================
/**
* The subset of the WebSocket API this agent uses.
*
* Both the WHATWG `WebSocket` (browsers, Node >= 22) and the `ws` package
* satisfy this, which is why a single code path can drive either.
*/
export interface WebSocketLike {
readyState?: number;
send(data: string | ArrayBufferLike | ArrayBufferView): void;
close(code?: number, reason?: string): void;
addEventListener(type: string, listener: (event: any) => void): void;
removeEventListener?(type: string, listener: (event: any) => void): void;
}
/** Constructor shape for a {@link WebSocketLike}. */
export type WebSocketConstructorLike = new (
url: string,
protocolsOrOptions?: any,
options?: any
) => WebSocketLike;
interface ResolvedWebSocket {
ctor: WebSocketConstructorLike;
/** Can this implementation send custom HTTP headers on the handshake? */
supportsHeaders: boolean;
/** Human-readable origin, used in error messages only. */
source: string;
}
/** WebSocket readyState constants (avoids depending on the ctor's statics). */
const WS_OPEN = 1;
let cachedWebSocket: ResolvedWebSocket | null = null;
/** Reset the cached WebSocket resolution. Exported for tests. */
export function _resetWebSocketResolution(): void {
cachedWebSocket = null;
}
/**
* Resolve a WebSocket implementation, preferring a global one.
*
* @throws if no implementation can be found - never silently degrades.
*/
export async function resolveWebSocketImplementation(
injected?: WebSocketConstructorLike,
/**
* Loader for the optional `ws` package. Defaults to a dynamic import through
* a computed specifier. Overridable in tests so the "no implementation
* available" path can be exercised without uninstalling a transitive `ws`.
*/
wsLoader?: () => Promise<any>
): Promise<ResolvedWebSocket> {
if (injected) {
// An injected constructor is assumed to accept the same options object the
// Node/`ws` implementations do; tests assert on the headers it receives.
return { ctor: injected, supportsHeaders: true, source: 'injected WebSocket' };
}
if (cachedWebSocket) return cachedWebSocket;
const globalObj = globalThis as any;
if (typeof globalObj.WebSocket === 'function') {
// Node's global WebSocket is undici's, which accepts a non-standard
// `{ headers }` option. A browser's does not - passing an object there is
// coerced to a protocol list and throws - so browsers use OpenAI's
// documented subprotocol credentials instead.
const isNode =
typeof globalObj.process !== 'undefined' &&
!!globalObj.process?.versions?.node;
cachedWebSocket = {
ctor: globalObj.WebSocket as WebSocketConstructorLike,
supportsHeaders: isNode,
source: isNode ? 'global WebSocket (Node)' : 'global WebSocket',
};
return cachedWebSocket;
}
// Computed specifier: keeps `ws` off every bundler's static import graph.
const specifier = ['w', 's'].join('');
let loadError: any = null;
try {
const mod: any = wsLoader
? await wsLoader()
: await import(/* webpackIgnore: true */ specifier);
const ctor = mod?.WebSocket ?? mod?.default?.WebSocket ?? mod?.default ?? mod;
if (typeof ctor === 'function') {
cachedWebSocket = {
ctor: ctor as WebSocketConstructorLike,
supportsHeaders: true,
source: "'ws' package",
};
return cachedWebSocket;
}
loadError = new Error("'ws' resolved but exported no WebSocket constructor");
} catch (error: any) {
// Only a module-not-found means "not installed". Anything else is a real
// failure inside an installed `ws` and must not be reported as absence.
const code = error?.code;
if (code !== 'MODULE_NOT_FOUND' && code !== 'ERR_MODULE_NOT_FOUND') {
loadError = error;
}
}
throw new Error(
'RealtimeAgent requires a WebSocket implementation and none is available. ' +
'This runtime has no global WebSocket (Node >= 22, browsers and React Native ' +
"provide one) and the optional 'ws' package " +
(loadError ? `failed to load: ${loadError?.message ?? loadError}. ` : 'is not installed. ') +
'Fix by one of: upgrade to Node 22+, run `npm install ws`, or pass ' +
'`new RealtimeAgent({ webSocket: YourWebSocketConstructor })`.'
);
}
// ============================================================================
// Configuration Types
// ============================================================================
@@ -19,7 +154,7 @@ export interface RealtimeConfig {
inputFormat?: 'pcm16' | 'g711_ulaw' | 'g711_alaw';
/** Audio output format */
outputFormat?: 'pcm16' | 'g711_ulaw' | 'g711_alaw';
/** Sample rate in Hz */
/** Sample rate in Hz (local playback hint; not sent to the API) */
sampleRate?: number;
/** Enable voice activity detection */
vadEnabled?: boolean;
@@ -30,9 +165,11 @@ export interface RealtimeConfig {
}
/**
* Event types for realtime sessions.
* Event types the agent knows by name. The server may send others; they are
* emitted verbatim rather than dropped, which is why {@link RealtimeEventType}
* also admits arbitrary strings.
*/
export type RealtimeEventType =
export type KnownRealtimeEventType =
| 'session.created'
| 'session.updated'
| 'input_audio_buffer.append'
@@ -46,11 +183,20 @@ export type RealtimeEventType =
| 'response.text.done'
| 'error';
/**
* Event type for realtime sessions.
*
* `'*'` is a local wildcard: a handler registered for it receives every event.
* Any other string is a server event type, passed through untouched.
*/
export type RealtimeEventType = KnownRealtimeEventType | '*' | (string & {});
/**
* Realtime event.
*/
export interface RealtimeEvent {
type: RealtimeEventType;
/** The full decoded server payload for this event. */
data?: any;
timestamp?: number;
}
@@ -71,6 +217,18 @@ export interface RealtimeAgentConfig {
verbose?: boolean;
/** API key */
apiKey?: string;
/**
* Full WebSocket endpoint override (e.g. a local test server or a proxy).
* When set, the `model` query parameter is not appended and a missing API
* key is not an error.
*/
url?: string;
/** Extra handshake headers (ignored by implementations that cannot send them) */
headers?: Record<string, string>;
/** Inject a WebSocket constructor instead of resolving one */
webSocket?: WebSocketConstructorLike;
/** How long to wait for the handshake before failing. Default 30000ms. */
connectTimeoutMs?: number;
}
// ============================================================================
@@ -87,47 +245,138 @@ const DEFAULT_REALTIME_CONFIG: Required<RealtimeConfig> = {
timeout: 300,
};
const DEFAULT_ENDPOINT = 'wss://api.openai.com/v1/realtime';
const DEFAULT_CONNECT_TIMEOUT_MS = 30000;
// ============================================================================
// Helpers
// ============================================================================
/** Encode bytes as base64 without depending on any Node builtin import. */
function toBase64(bytes: Uint8Array): string {
const globalObj = globalThis as any;
if (typeof globalObj.btoa === 'function') {
let binary = '';
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}
return globalObj.btoa(binary);
}
if (typeof globalObj.Buffer !== 'undefined') {
return globalObj.Buffer.from(bytes).toString('base64');
}
throw new Error('No base64 encoder available in this runtime (no btoa, no Buffer)');
}
/** Decode base64 to bytes without depending on any Node builtin import. */
function fromBase64(value: string): Uint8Array {
const globalObj = globalThis as any;
if (typeof globalObj.atob === 'function') {
const binary = globalObj.atob(value);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
return out;
}
if (typeof globalObj.Buffer !== 'undefined') {
return new Uint8Array(globalObj.Buffer.from(value, 'base64'));
}
throw new Error('No base64 decoder available in this runtime (no atob, no Buffer)');
}
function toUint8Array(data: ArrayBuffer | Uint8Array | ArrayBufferView): Uint8Array {
if (data instanceof Uint8Array) return data;
if (data instanceof ArrayBuffer) return new Uint8Array(data);
const view = data as ArrayBufferView;
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
}
/** Turn whatever a WebSocket handed us into a string, or null if we cannot. */
function messageToString(data: any): string | null {
if (typeof data === 'string') return data;
if (data == null) return null;
if (typeof ArrayBuffer !== 'undefined' && data instanceof ArrayBuffer) {
return new TextDecoder().decode(new Uint8Array(data));
}
if (ArrayBuffer.isView(data)) {
return new TextDecoder().decode(toUint8Array(data));
}
if (Array.isArray(data)) {
// `ws` can deliver a fragmented message as an array of Buffers.
return data.map((part) => messageToString(part) ?? '').join('');
}
if (typeof data.toString === 'function') return String(data);
return null;
}
/** Best-effort description of a WebSocket error/close event, for messages. */
function describeFailure(event: any): string {
if (!event) return 'unknown error';
const parts: string[] = [];
const message = event.message ?? event.error?.message ?? event.reason;
if (message) parts.push(String(message));
if (typeof event.code === 'number') parts.push(`close code ${event.code}`);
if (parts.length === 0 && event.type) parts.push(`${event.type} event`);
return parts.length > 0 ? parts.join(', ') : 'unknown error';
}
// ============================================================================
// RealtimeAgent Class
// ============================================================================
/**
* Agent for real-time voice conversations.
*
* Uses WebSocket connections for low-latency audio streaming.
*
*
* Uses a real WebSocket connection for low-latency audio streaming.
* `connect()` resolves only once the handshake has actually completed; a bad
* key, a bad URL or a refused connection rejects, and `isConnected()` stays
* false.
*
* @example
* ```typescript
* import { RealtimeAgent } from 'praisonai';
*
*
* const agent = new RealtimeAgent({
* instructions: 'You are a helpful voice assistant',
* realtime: { voice: 'nova' }
* });
*
* // Connect to realtime session
* await agent.connect();
*
* // Send audio
* agent.sendAudio(audioBuffer);
*
* // Listen for responses
*
* // Listen for what the server actually sends
* agent.on('response.audio.delta', (event) => {
* playAudio(event.data);
* });
*
* await agent.connect(); // rejects if the handshake fails
* agent.sendAudio(audioBuffer);
* await agent.disconnect();
* ```
*/
export class RealtimeAgent {
static readonly DEFAULT_MODEL = 'gpt-4o-realtime-preview';
readonly name: string;
private readonly llm: string;
readonly llm: string;
private readonly instructions?: string;
private readonly verbose: boolean;
private readonly realtimeConfig: Required<RealtimeConfig>;
private readonly apiKey?: string;
private readonly urlOverride?: string;
private readonly extraHeaders: Record<string, string>;
private readonly webSocketImpl?: WebSocketConstructorLike;
private readonly connectTimeoutMs: number;
private connected: boolean = false;
private ws: WebSocketLike | null = null;
/** In-flight connect(), so concurrent callers share one handshake. */
private connecting: Promise<void> | null = null;
/** The socket being opened by the in-flight connect(), for cancellation. */
private connectingSocket: WebSocketLike | null = null;
/** Bumped on every teardown/disconnect so a stale handshake can abort. */
private connectGeneration: number = 0;
private eventHandlers: Map<RealtimeEventType, Array<(event: RealtimeEvent) => void>> = new Map();
private messageCallbacks: Array<(text: string) => void> = [];
private audioCallbacks: Array<(audio: Uint8Array) => void> = [];
private errorCallbacks: Array<(error: Error) => void> = [];
constructor(config: RealtimeAgentConfig) {
this.name = config.name || 'RealtimeAgent';
@@ -135,6 +384,10 @@ export class RealtimeAgent {
this.instructions = config.instructions;
this.verbose = config.verbose ?? true;
this.apiKey = config.apiKey || process.env.OPENAI_API_KEY;
this.urlOverride = config.url;
this.extraHeaders = { ...(config.headers ?? {}) };
this.webSocketImpl = config.webSocket;
this.connectTimeoutMs = config.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
// Resolve realtime configuration
if (config.realtime === undefined || config.realtime === true || config.realtime === false) {
@@ -150,96 +403,440 @@ export class RealtimeAgent {
}
}
/** The endpoint this agent will dial. Never contains the API key. */
getUrl(): string {
if (this.urlOverride) return this.urlOverride;
return `${DEFAULT_ENDPOINT}?model=${encodeURIComponent(this.llm)}`;
}
/** The `session.update` payload derived from the local config. */
private sessionPayload(): Record<string, any> {
const payload: Record<string, any> = {
voice: this.realtimeConfig.voice,
input_audio_format: this.realtimeConfig.inputFormat,
output_audio_format: this.realtimeConfig.outputFormat,
turn_detection: this.realtimeConfig.vadEnabled
? { type: 'server_vad', threshold: this.realtimeConfig.vadThreshold }
: null,
};
if (this.instructions) payload.instructions = this.instructions;
return payload;
}
// =========================================================================
// Connection
// =========================================================================
/**
* Connect to the realtime session.
*
* Resolves only after the WebSocket handshake has completed. Rejects on a
* refused connection, a non-101 response (which is what a bad API key looks
* like), a close during the handshake, or a timeout.
*/
async connect(): Promise<void> {
this.log('Connecting to realtime session...');
// Placeholder - real implementation would establish WebSocket connection
if (this.isConnected()) {
this.log('Already connected to realtime session');
return;
}
// Concurrent callers share the one in-flight handshake instead of each
// opening a socket and racing to overwrite `this.ws`.
if (this.connecting) return this.connecting;
const attempt = this.doConnect();
this.connecting = attempt;
try {
await attempt;
} finally {
if (this.connecting === attempt) this.connecting = null;
}
}
private async doConnect(): Promise<void> {
// A half-dead socket from a previous attempt must not linger. Bumping the
// generation here invalidates any earlier handshake still in flight.
this.teardown();
const generation = this.connectGeneration;
const url = this.getUrl();
const usingDefaultEndpoint = !this.urlOverride;
if (!this.apiKey && usingDefaultEndpoint) {
throw new Error(
'OPENAI_API_KEY is required to connect to the OpenAI Realtime API. ' +
'Set the environment variable, pass `{ apiKey }`, or point `{ url }` at ' +
'your own endpoint.'
);
}
const impl = await resolveWebSocketImplementation(this.webSocketImpl);
this.log(`Connecting to realtime session at ${url} ...`);
let socket: WebSocketLike;
try {
if (impl.supportsHeaders) {
const headers: Record<string, string> = { ...this.extraHeaders };
if (this.apiKey) {
headers['Authorization'] = `Bearer ${this.apiKey}`;
headers['OpenAI-Beta'] = 'realtime=v1';
}
socket = new impl.ctor(url, { headers });
} else {
// Browsers cannot set handshake headers. OpenAI's documented browser
// path carries the credentials as subprotocols instead. Note this puts
// the API key in the page; use an ephemeral key.
const protocols = ['realtime'];
if (this.apiKey) protocols.push(`openai-insecure-api-key.${this.apiKey}`);
protocols.push('openai-beta.realtime-v1');
Object.keys(this.extraHeaders).length > 0 &&
this.log(
`Note: ${impl.source} cannot send custom headers; ` +
`${Object.keys(this.extraHeaders).join(', ')} were not sent.`
);
socket = new impl.ctor(url, protocols);
}
} catch (error: any) {
throw new Error(
`Failed to create a WebSocket for ${url} using ${impl.source}: ${error?.message ?? error}`
);
}
/** Was this handshake superseded by a teardown/disconnect/newer connect? */
const superseded = () => this.connectGeneration !== generation;
// A disconnect() may have landed before the socket even existed. If so, the
// generation has already moved on; abandon this socket immediately.
if (superseded()) {
try {
socket.close();
} catch {
/* already gone */
}
throw new Error('Connection was cancelled by disconnect() during handshake');
}
// Expose the opening socket so a concurrent disconnect() can cancel it.
this.connectingSocket = socket;
try {
await new Promise<void>((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
try {
socket.close();
} catch {
/* already gone */
}
reject(new Error(`Timed out after ${this.connectTimeoutMs}ms connecting to ${url}`));
}, this.connectTimeoutMs);
if (typeof (timer as any).unref === 'function') (timer as any).unref();
const settle = (error?: Error) => {
if (settled) return false;
settled = true;
clearTimeout(timer);
if (error) reject(error);
else resolve();
return true;
};
socket.addEventListener('open', () => {
if (superseded()) {
// disconnect()/a newer connect() won the race; drop this socket.
settle(new Error('Connection superseded before handshake completed'));
try {
socket.close();
} catch {
/* already gone */
}
return;
}
settle();
});
socket.addEventListener('error', (event: any) => {
const error = new Error(
`WebSocket connection to ${url} failed: ${describeFailure(event)}`
);
if (!settle(error)) {
// Post-handshake error: report it, do not claim a connection.
this.connected = false;
this.reportError(error);
}
});
socket.addEventListener('close', (event: any) => {
if (
!settle(
new Error(
`WebSocket to ${url} closed during handshake: ${describeFailure(event)}`
)
)
) {
this.handleClose(event);
}
});
socket.addEventListener('message', (event: any) => {
this.handleMessage(event);
});
});
} finally {
if (this.connectingSocket === socket) this.connectingSocket = null;
}
// A disconnect() that arrived during the handshake must win: never mark
// ourselves connected against a socket the caller already asked us to drop.
if (superseded()) {
try {
socket.close();
} catch {
/* already gone */
}
throw new Error('Connection was cancelled by disconnect() during handshake');
}
this.ws = socket;
this.connected = true;
this.emit({ type: 'session.created', timestamp: Date.now() });
this.log('Connected to realtime session');
// Mirror the Python agent: push the session configuration immediately.
await this.sendEvent({ type: 'session.update', session: this.sessionPayload() });
this.log(`Connected to realtime session with model ${this.llm}`);
}
/**
* Disconnect from the realtime session.
*/
async disconnect(): Promise<void> {
// Cancel any handshake still in flight so it cannot mark us connected
// after the caller asked to disconnect. teardown() bumps the generation,
// which the pending doConnect() checks; awaiting its promise below makes
// disconnect() a hard barrier even when the socket did not exist yet.
const pendingConnect = this.connecting;
const pendingSocket = this.connectingSocket;
const socket = this.ws;
if (!socket && !pendingSocket && !pendingConnect) {
this.connected = false;
return;
}
this.log('Disconnecting from realtime session...');
this.connected = false;
// Invalidate the in-flight attempt first, then close its socket.
this.teardown();
if (pendingSocket) {
try {
pendingSocket.close(1000, 'client disconnect');
} catch {
/* already gone */
}
}
if (pendingConnect) {
// The pending connect() will reject (superseded); do not let that reject
// escape from disconnect().
await pendingConnect.catch(() => undefined);
}
if (!socket) return;
await new Promise<void>((resolve) => {
let done = false;
const finish = () => {
if (done) return;
done = true;
clearTimeout(timer);
resolve();
};
const timer = setTimeout(finish, 5000);
if (typeof (timer as any).unref === 'function') (timer as any).unref();
try {
socket.addEventListener('close', finish);
socket.close(1000, 'client disconnect');
} catch {
finish();
}
});
this.teardown();
}
/**
* Check if connected.
* Drop the socket reference and mark the agent disconnected.
*
* Bumps {@link connectGeneration} so any handshake still in flight is
* invalidated: it will close its socket and reject rather than flip the
* agent back to connected.
*/
private teardown(): void {
this.connectGeneration++;
this.ws = null;
this.connected = false;
this.connectingSocket = null;
}
/**
* Check if connected. Reflects the live socket state, not an intention.
*/
isConnected(): boolean {
return this.connected;
if (!this.connected || !this.ws) return false;
const readyState = this.ws.readyState;
return typeof readyState === 'number' ? readyState === WS_OPEN : true;
}
// =========================================================================
// Sending
// =========================================================================
/**
* Send a raw event over the socket.
*
* @throws if not connected - it never silently drops the event.
*/
async sendEvent(event: Record<string, any>): Promise<void> {
if (!this.isConnected() || !this.ws) {
throw new Error(
`Not connected to realtime session; cannot send "${event.type}". Call connect() first.`
);
}
this.ws.send(JSON.stringify(event));
}
/**
* Send audio data to the session.
* Send audio data to the session (base64-encoded, per the Realtime API).
*/
sendAudio(audioData: ArrayBuffer | Uint8Array): void {
if (!this.connected) {
if (!this.isConnected() || !this.ws) {
throw new Error('Not connected to realtime session');
}
this.emit({
type: 'input_audio_buffer.append',
data: audioData,
timestamp: Date.now(),
});
this.ws.send(
JSON.stringify({
type: 'input_audio_buffer.append',
audio: toBase64(toUint8Array(audioData)),
})
);
}
/**
* Commit the audio buffer for processing.
*/
commitAudio(): void {
if (!this.connected) {
if (!this.isConnected() || !this.ws) {
throw new Error('Not connected to realtime session');
}
this.emit({
type: 'input_audio_buffer.commit',
timestamp: Date.now(),
});
this.ws.send(JSON.stringify({ type: 'input_audio_buffer.commit' }));
}
/**
* Clear the audio buffer.
*/
clearAudio(): void {
if (!this.connected) {
if (!this.isConnected() || !this.ws) {
throw new Error('Not connected to realtime session');
}
this.emit({
type: 'input_audio_buffer.clear',
timestamp: Date.now(),
});
this.ws.send(JSON.stringify({ type: 'input_audio_buffer.clear' }));
}
/**
* Send a text message.
* Send a text message and request a response (Python `asend_text` parity).
*/
async sendText(text: string): Promise<void> {
if (!this.connected) {
if (!this.isConnected() || !this.ws) {
throw new Error('Not connected to realtime session');
}
this.log(`Sending text: ${text}`);
this.emit({
type: 'response.create',
data: { text },
timestamp: Date.now(),
await this.sendEvent({
type: 'conversation.item.create',
item: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text }],
},
});
await this.sendEvent({ type: 'response.create' });
}
// =========================================================================
// Receiving
// =========================================================================
private handleMessage(event: any): void {
const raw = messageToString(event?.data ?? event);
if (raw === null) return;
let parsed: any;
try {
parsed = JSON.parse(raw);
} catch {
return; // Python's receive_loop ignores non-JSON frames too.
}
if (!parsed || typeof parsed.type !== 'string') return;
const type: string = parsed.type;
// Python-parity convenience callbacks.
if (type === 'response.text.delta') {
const delta = typeof parsed.delta === 'string' ? parsed.delta : '';
this.invokeCallbacks(this.messageCallbacks, delta);
} else if (type === 'response.audio.delta') {
const delta = typeof parsed.delta === 'string' ? parsed.delta : '';
if (delta) {
let bytes: Uint8Array | null = null;
try {
bytes = fromBase64(delta);
} catch {
bytes = null;
}
if (bytes) this.invokeCallbacks(this.audioCallbacks, bytes);
}
} else if (type === 'error') {
const error = new Error(
parsed.error?.message ?? `Realtime API error: ${JSON.stringify(parsed.error ?? parsed)}`
);
this.reportError(error);
}
this.emit({ type, data: parsed, timestamp: Date.now() });
}
private handleClose(event: any): void {
const wasConnected = this.connected;
this.teardown();
if (wasConnected) {
this.log(`Realtime session closed: ${describeFailure(event)}`);
}
}
private reportError(error: Error): void {
this.invokeCallbacks(this.errorCallbacks, error);
}
/**
* Register an event handler.
* Invoke user callbacks over a snapshot of the list, isolating each one so a
* throwing handler cannot skip later handlers or stop the frame from reaching
* `emit`. Mirrors the isolation already applied to error callbacks.
*/
private invokeCallbacks<T>(callbacks: Array<(value: T) => void>, value: T): void {
for (const callback of [...callbacks]) {
try {
callback(value);
} catch {
/* a bad handler must not break the socket */
}
}
}
// =========================================================================
// Handlers
// =========================================================================
/**
* Register an event handler. Use `'*'` to receive every server event.
*/
on(eventType: RealtimeEventType, handler: (event: RealtimeEvent) => void): void {
if (!this.eventHandlers.has(eventType)) {
@@ -261,14 +858,34 @@ export class RealtimeAgent {
}
}
/** Register a callback for text deltas (Python `on_message` parity). */
onMessage(callback: (text: string) => void): void {
this.messageCallbacks.push(callback);
}
/** Register a callback for decoded audio deltas (Python `on_audio` parity). */
onAudio(callback: (audio: Uint8Array) => void): void {
this.audioCallbacks.push(callback);
}
/** Register a callback for errors (Python `on_error` parity). */
onError(callback: (error: Error) => void): void {
this.errorCallbacks.push(callback);
}
/**
* Emit an event to handlers.
*/
private emit(event: RealtimeEvent): void {
const handlers = this.eventHandlers.get(event.type);
if (handlers) {
for (const handler of handlers) {
const targets = [
...(this.eventHandlers.get(event.type) ?? []),
...(this.eventHandlers.get('*') ?? []),
];
for (const handler of targets) {
try {
handler(event);
} catch {
/* a bad handler must not break the socket */
}
}
}
@@ -281,17 +898,13 @@ export class RealtimeAgent {
}
/**
* Update the session configuration.
* Update the session configuration. Pushes `session.update` when connected.
*/
async updateConfig(config: Partial<RealtimeConfig>): Promise<void> {
Object.assign(this.realtimeConfig, config);
if (this.connected) {
this.emit({
type: 'session.updated',
data: this.realtimeConfig,
timestamp: Date.now(),
});
if (this.isConnected()) {
await this.sendEvent({ type: 'session.update', session: this.sessionPayload() });
}
}
}
+56 -25
View File
@@ -1,21 +1,41 @@
/**
* Database Module - Exports for persistence layer
*
*
* Usage (Python-like simplicity):
* import { db } from 'praisonai';
*
*
* const agent = new Agent({
* instructions: "You are helpful",
* db: db("sqlite:./data.db"), // URL-style string
* db: db("sqlite:./data.db"), // URL-style string, persists to a file
* sessionId: "my-session"
* });
*
* What is durable today:
* - db("sqlite:./data.db") persists to disk and survives the process
* - db("sqlite::memory:") real SQLite, but process-local
* - db("memory:") in-process Maps, lost when the process exits
*
* postgres:// and redis:// still throw -- see createDbAdapter below.
*/
export * from './types';
export { MemoryDbAdapter } from './memory-adapter';
export {
SqliteDbAdapter,
createSqliteDbAdapter,
SQLITE_SCHEMA_VERSION,
} from './sqlite-adapter';
export type {
SqliteDbAdapterOptions,
SqliteDriver,
SqliteDriverName,
SqliteConnection,
SqliteStatement,
} from './sqlite-adapter';
import type { DbAdapter, DbConfig } from './types';
import { MemoryDbAdapter } from './memory-adapter';
import { SqliteDbAdapter } from './sqlite-adapter';
// Default adapter instance
let defaultAdapter: DbAdapter | null = null;
@@ -83,28 +103,32 @@ export function createDbAdapter(config: DbConfig): DbAdapter {
case 'memory':
return new MemoryDbAdapter();
case 'sqlite':
// The SQLite/Postgres/Redis modules ship low-level transports
// (SQLiteAdapter, NeonPostgresAdapter, UpstashRedisAdapter) that do NOT
// yet implement the full DbAdapter session/message/run contract used by
// the Agent. Returning one here would satisfy the type at the untyped
// require() boundary but crash at runtime ("... is not a function") on
// first use. Fail loudly with a clear path forward instead of handing
// back a non-conforming object.
throw new Error(
'db("sqlite:...") is not yet wired to the DbAdapter contract. ' +
'Use db("memory:") for now, or import { createSQLiteAdapter } from ' +
'"praisonai/db/sqlite" for the low-level SQLite transport.'
);
// Implements the full DbAdapter session/message/run contract on a real
// SQLite file, so history survives the process. The database is opened
// lazily on first use (the Agent never calls connect()); if no SQLite
// driver can open the file the operation REJECTS with the reason and the
// fix. It never degrades to memory -- persistence that silently stops
// persisting is the failure this replaced.
return new SqliteDbAdapter({ filename: config.path || ':memory:' });
case 'postgres':
// Still unwired, deliberately. src/db/postgres.ts is a Neon HTTP
// transport (POST https://<host>/sql): it has no local mode, cannot be
// exercised without a live Neon endpoint, and exposes query/execute
// rather than the session/message/run contract the Agent calls. Wiring
// it up untested would trade a clear error for a runtime surprise.
throw new Error(
'db("postgres://...") is not yet wired to the DbAdapter contract. ' +
'Use db("memory:") for now, or import { createNeonPostgres } from ' +
'Use db("sqlite:./data.db") for durable local persistence, db("memory:") ' +
'for ephemeral, or import { createNeonPostgres } from ' +
'"praisonai/db/postgres" for the low-level Postgres transport.'
);
case 'redis':
// Same reasoning: src/db/redis.ts is an Upstash REST transport with
// key/value and hash operations, not sessions, messages and runs.
throw new Error(
'db("redis://...") is not yet wired to the DbAdapter contract. ' +
'Use db("memory:") for now, or import { createUpstashRedis } from ' +
'Use db("sqlite:./data.db") for durable local persistence, db("memory:") ' +
'for ephemeral, or import { createUpstashRedis } from ' +
'"praisonai/db/redis" for the low-level Redis transport.'
);
default:
@@ -131,17 +155,24 @@ export function setDefaultDbAdapter(adapter: DbAdapter): void {
/**
* Factory function for creating a database adapter
*
*
* Accepts either:
* - URL string: db("sqlite:./data.db"), db("postgres://..."), db("redis://...")
* - URL string: db("sqlite:./data.db"), db("memory:")
* - Config object: db({ type: 'sqlite', path: './data.db' })
*
*
* Examples:
* db("sqlite:./data.db") // SQLite file
* db("postgres://localhost/mydb") // PostgreSQL
* db("redis://localhost:6379") // Redis
* db("memory:") // In-memory (default)
* db() // In-memory (default)
* db("sqlite:./data.db") // SQLite file -- durable, survives restarts
* db("sqlite::memory:") // real SQLite, process-local
* db("memory:") // in-process Maps (default), lost on exit
* db() // same as db("memory:")
*
* Not yet implemented -- these parse, then throw with the reason:
* db("postgres://localhost/mydb") // throws: transport is Neon-HTTP only
* db("redis://localhost:6379") // throws: transport is Upstash-REST only
*
* The returned adapter connects lazily, so a sqlite URL is cheap to build and
* any driver problem surfaces on the first read or write -- as a rejection,
* never as a silent fall back to memory.
*/
export function db(configOrUrl: string | DbConfig = { type: 'memory' }): DbAdapter {
if (typeof configOrUrl === 'string') {
+975
View File
@@ -0,0 +1,975 @@
/**
* SQLite DbAdapter -- durable persistence for `db("sqlite:./data.db")`.
*
* This is the full {@link DbAdapter} contract (sessions, messages, runs, tool
* calls, traces, spans) on top of a real SQLite file, so a session written by
* one process is readable by the next one. `MemoryDbAdapter` dies with the
* process; this does not.
*
* NOT the same thing as `./sqlite.ts`. That module ships `SQLiteAdapter`, a
* low-level transport with its own narrower table shapes and its own method
* names (`addMessage`, `addTrace`, ...). It is kept for callers that already
* use it; it is not what the `Agent` talks to, and it silently degrades to an
* in-process Map when the native binding will not load. This adapter never
* does that -- see "Driver loading" below.
*
* ## Driver loading
*
* Two drivers can back this adapter, tried in order:
*
* 1. `better-sqlite3` -- the declared dependency. It is a native module, so
* its prebuilt binding is compiled against one specific Node ABI
* (`NODE_MODULE_VERSION`). Installed under one Node major and run under
* another, `new Database(...)` throws `ERR_DLOPEN_FAILED`.
* 2. `node:sqlite` -- Node's own SQLite, built into Node >= 22.5. No native
* build step, so no ABI to mismatch. It is flagged experimental by Node
* and prints one `ExperimentalWarning` when first loaded. Same on-disk
* format, so a file written by either driver reads in the other.
*
* If neither loads, every operation REJECTS with a message naming what each
* driver did and how to fix it. It never falls back to memory: a persistence
* layer that quietly stops persisting is the bug this module exists to fix.
*
* `PRAISONAI_SQLITE_DRIVER=better-sqlite3|node` pins the choice (useful to
* reproduce a driver-specific failure); `driver` on the constructor does the
* same per-instance.
*
* ## Schema (version 1.0)
*
* Table and column names mirror what the Python side persists
* (`praisonaiagents/db/protocol.py`, `runs/sqlite_ledger.py`) so the two are
* conceptually aligned: snake_case columns, JSON-encoded `metadata`,
* epoch-millisecond timestamps. Only the fields TypeScript's `DbAdapter`
* actually has are stored -- Python's `input_content`/`output_content`/
* `events` have no TypeScript counterpart and are not invented here.
*
* sessions(id PK, created_at, updated_at, metadata)
* messages(id PK, session_id, run_id, role, content, name, tool_call_id,
* tool_calls, created_at, metadata)
* runs(id PK, session_id, agent_name, status, started_at, completed_at,
* error, metadata, prompt_tokens, completion_tokens, total_tokens)
* tool_calls(id PK, run_id, name, arguments, result, status, started_at,
* completed_at, error)
* traces(id PK, session_id, run_id, agent_name, started_at, completed_at,
* status, metadata)
* spans(id PK, trace_id, parent_id, name, started_at, completed_at, status,
* attributes)
* praisonai_meta(key PK, value) -- holds schema_version
*
* Timestamps are epoch milliseconds (`Date.now()`), matching the `DbMessage`/
* `DbRun` types, NOT Python's float seconds.
*/
import type {
DbAdapter,
DbSession,
DbMessage,
DbRun,
DbToolCall,
DbTrace,
DbSpan,
} from './types';
/** Schema version stored in `praisonai_meta`. Mirrors Python's SCHEMA_VERSION. */
export const SQLITE_SCHEMA_VERSION = '1.0';
/** The only value types bound to a statement. `undefined` is never bound: */
/** `node:sqlite` rejects it outright and better-sqlite3 rejects it too. */
export type SqliteParam = string | number | null;
/** A prepared statement, as both drivers expose one. */
export interface SqliteStatement {
run(...params: SqliteParam[]): unknown;
get(...params: SqliteParam[]): any;
all(...params: SqliteParam[]): any[];
}
/** An open database handle, normalised across drivers. */
export interface SqliteConnection {
exec(sql: string): void;
prepare(sql: string): SqliteStatement;
close(): void;
}
/** A driver is a name plus a way to open a file. */
export interface SqliteDriver {
name: string;
open(filename: string): SqliteConnection;
}
export type SqliteDriverName = 'better-sqlite3' | 'node';
export interface SqliteDbAdapterOptions {
/** File path, or `':memory:'` for a real but process-local SQLite database. */
filename: string;
/** Pin the driver instead of trying each in turn. */
driver?: SqliteDriverName;
/**
* Override driver loading. Exists so tests can prove the failure path (an
* unusable native binding must throw, not degrade); not part of the public
* contract.
*/
driverLoader?: () => Promise<SqliteDriver>;
}
// ---------------------------------------------------------------------------
// Driver loading
// ---------------------------------------------------------------------------
/**
* better-sqlite3 defers loading its native binding until `new Database(...)`,
* so a successful `import` proves nothing. The driver is only considered
* loaded once a connection has actually opened.
*/
async function loadBetterSqlite3(): Promise<SqliteDriver> {
// @ts-ignore -- optional native dependency, ships no bundled type declarations
const mod: any = await import('better-sqlite3');
const Database = mod?.default ?? mod;
if (typeof Database !== 'function') {
throw new Error('better-sqlite3 did not export a Database constructor');
}
return {
name: 'better-sqlite3',
open(filename: string): SqliteConnection {
const handle = new Database(filename);
return {
exec: (sql: string) => handle.exec(sql),
prepare: (sql: string) => handle.prepare(sql) as SqliteStatement,
close: () => handle.close(),
};
},
};
}
/** Node's built-in SQLite (>= 22.5). No native build, so no ABI to mismatch. */
async function loadNodeSqlite(): Promise<SqliteDriver> {
const mod: any = await import('node:sqlite');
const DatabaseSync = mod?.DatabaseSync ?? mod?.default?.DatabaseSync;
if (typeof DatabaseSync !== 'function') {
throw new Error('node:sqlite did not export DatabaseSync (Node >= 22.5 required)');
}
return {
name: 'node:sqlite',
open(filename: string): SqliteConnection {
const handle = new DatabaseSync(filename);
return {
exec: (sql: string) => handle.exec(sql),
prepare: (sql: string) => {
const stmt = handle.prepare(sql);
return {
run: (...params: SqliteParam[]) => stmt.run(...params),
// node:sqlite returns undefined for no row; normalise to null so
// callers can use a single `?? null` shape across drivers.
get: (...params: SqliteParam[]) => stmt.get(...params) ?? null,
all: (...params: SqliteParam[]) => stmt.all(...params) as any[],
};
},
close: () => handle.close(),
};
},
};
}
function envDriverPreference(): SqliteDriverName | undefined {
const raw =
typeof process !== 'undefined' && process.env
? process.env.PRAISONAI_SQLITE_DRIVER
: undefined;
if (!raw) return undefined;
const value = raw.trim().toLowerCase();
if (value === 'better-sqlite3' || value === 'better') return 'better-sqlite3';
if (value === 'node' || value === 'node:sqlite' || value === 'builtin') return 'node';
return undefined;
}
function describeError(error: unknown): string {
if (error instanceof Error) return error.message.split('\n').slice(0, 3).join(' ');
return String(error);
}
/**
* Turn "no driver worked" into a message someone can act on: what was tried,
* what each one said, and the specific fix for the failure that was seen.
*/
function unavailableError(
filename: string,
failures: Array<{ driver: string; error: unknown }>,
): Error {
const joined = failures.map((f) => describeError(f.error)).join(' | ');
const hints: string[] = [];
if (/NODE_MODULE_VERSION|ERR_DLOPEN_FAILED|was compiled against/i.test(joined)) {
hints.push(
'better-sqlite3\'s native binding was built for a different Node ABI. ' +
'Run `npm rebuild better-sqlite3` (or reinstall) under the Node version you run with.',
);
}
if (/Cannot find module|MODULE_NOT_FOUND|ERR_MODULE_NOT_FOUND|ERR_UNKNOWN_BUILTIN_MODULE/i.test(joined)) {
hints.push(
'Install the driver (`npm install better-sqlite3`), or run Node >= 22.5 so the ' +
'built-in `node:sqlite` can be used.',
);
}
if (/unable to open database file|SQLITE_CANTOPEN/i.test(joined)) {
hints.push(
`SQLite could not open "${filename}". Check the parent directory exists and is writable.`,
);
}
hints.push('db("memory:") works everywhere, but does not survive the process.');
const tried = failures.map((f) => ` - ${f.driver}: ${describeError(f.error)}`).join('\n');
return new Error(
`SQLite persistence is unavailable for "${filename}". No driver could open it.\n` +
`${tried}\n` +
hints.map((h) => ` → ${h}`).join('\n'),
);
}
/**
* Open `filename` with the first driver that works.
*
* Opening is part of loading on purpose: better-sqlite3's ABI failure only
* surfaces at `new Database(...)`, so a loader that stopped at `import` would
* hand back a driver that throws on first use.
*/
async function openWithAnyDriver(
filename: string,
preference?: SqliteDriverName,
customLoader?: () => Promise<SqliteDriver>,
): Promise<{ driver: SqliteDriver; connection: SqliteConnection }> {
const loaders: Array<{ name: string; load: () => Promise<SqliteDriver> }> = customLoader
? [{ name: 'custom', load: customLoader }]
: [
{ name: 'better-sqlite3', load: loadBetterSqlite3 },
{ name: 'node:sqlite', load: loadNodeSqlite },
];
const pinned = preference ?? envDriverPreference();
const selected = pinned
? loaders.filter((l) =>
pinned === 'better-sqlite3' ? l.name === 'better-sqlite3' : l.name === 'node:sqlite',
)
: loaders;
const candidates = selected.length > 0 ? selected : loaders;
const failures: Array<{ driver: string; error: unknown }> = [];
for (const candidate of candidates) {
try {
const driver = await candidate.load();
const connection = driver.open(filename);
return { driver, connection };
} catch (error) {
failures.push({ driver: candidate.name, error });
}
}
throw unavailableError(filename, failures);
}
// ---------------------------------------------------------------------------
// Schema
// ---------------------------------------------------------------------------
const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS praisonai_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
metadata TEXT
);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
run_id TEXT,
role TEXT NOT NULL,
content TEXT,
name TEXT,
tool_call_id TEXT,
tool_calls TEXT,
created_at INTEGER NOT NULL,
metadata TEXT
);
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
CREATE TABLE IF NOT EXISTS runs (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
agent_name TEXT,
status TEXT NOT NULL,
started_at INTEGER NOT NULL,
completed_at INTEGER,
error TEXT,
metadata TEXT,
prompt_tokens INTEGER,
completion_tokens INTEGER,
total_tokens INTEGER
);
CREATE INDEX IF NOT EXISTS idx_runs_session ON runs(session_id, started_at);
CREATE TABLE IF NOT EXISTS tool_calls (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
name TEXT NOT NULL,
arguments TEXT NOT NULL,
result TEXT,
status TEXT NOT NULL,
started_at INTEGER NOT NULL,
completed_at INTEGER,
error TEXT
);
CREATE INDEX IF NOT EXISTS idx_tool_calls_run ON tool_calls(run_id, started_at);
CREATE TABLE IF NOT EXISTS traces (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
run_id TEXT,
agent_name TEXT,
started_at INTEGER NOT NULL,
completed_at INTEGER,
status TEXT NOT NULL,
metadata TEXT
);
CREATE INDEX IF NOT EXISTS idx_traces_session ON traces(session_id);
CREATE TABLE IF NOT EXISTS spans (
id TEXT PRIMARY KEY,
trace_id TEXT NOT NULL,
parent_id TEXT,
name TEXT NOT NULL,
started_at INTEGER NOT NULL,
completed_at INTEGER,
status TEXT NOT NULL,
attributes TEXT
);
CREATE INDEX IF NOT EXISTS idx_spans_trace ON spans(trace_id, started_at);
`;
/**
* Columns each table must have for this adapter's statements to work.
*
* `CREATE TABLE IF NOT EXISTS` is a no-op against a file that already holds a
* table of the same name and a different shape -- notably one written by the
* older `SQLiteAdapter` in `./sqlite.ts`, whose `runs` has `agent_id` and no
* `agent_name`. Without this check the first INSERT would fail deep in the
* driver with "table runs has no column named agent_name"; with it, the file
* is diagnosed on open.
*
* Every column named by an INSERT or UPDATE is listed, not just the NOT NULL
* core: a file carrying the core columns but missing, say, `runs.total_tokens`
* or `spans.attributes` would otherwise pass open() and fail on the first write
* that touches the missing column. The lists are kept in lockstep with the
* INSERT statements below.
*/
const REQUIRED_COLUMNS: Record<string, string[]> = {
sessions: ['id', 'created_at', 'updated_at', 'metadata'],
messages: [
'id',
'session_id',
'run_id',
'role',
'content',
'name',
'tool_call_id',
'tool_calls',
'created_at',
'metadata',
],
runs: [
'id',
'session_id',
'agent_name',
'status',
'started_at',
'completed_at',
'error',
'metadata',
'prompt_tokens',
'completion_tokens',
'total_tokens',
],
tool_calls: [
'id',
'run_id',
'name',
'arguments',
'result',
'status',
'started_at',
'completed_at',
'error',
],
traces: [
'id',
'session_id',
'run_id',
'agent_name',
'started_at',
'completed_at',
'status',
'metadata',
],
spans: [
'id',
'trace_id',
'parent_id',
'name',
'started_at',
'completed_at',
'status',
'attributes',
],
};
// ---------------------------------------------------------------------------
// Value coercion
// ---------------------------------------------------------------------------
function toJson(value: unknown): string | null {
if (value === undefined || value === null) return null;
try {
return JSON.stringify(value);
} catch {
// A metadata bag with a circular reference must not lose the whole row.
return JSON.stringify(String(value));
}
}
function fromJson(value: unknown): any | undefined {
if (value === undefined || value === null || value === '') return undefined;
try {
return JSON.parse(String(value));
} catch {
return undefined;
}
}
/** `undefined` -> `null`; both drivers reject `undefined` as a bound value. */
function text(value: string | null | undefined): string | null {
return value === undefined || value === null ? null : value;
}
function num(value: number | null | undefined): number | null {
return value === undefined || value === null ? null : value;
}
/** `null` -> `undefined`, so an optional field round-trips as it went in. */
function optText(value: unknown): string | undefined {
return value === null || value === undefined ? undefined : String(value);
}
function optNum(value: unknown): number | undefined {
return value === null || value === undefined ? undefined : Number(value);
}
// ---------------------------------------------------------------------------
// Adapter
// ---------------------------------------------------------------------------
export class SqliteDbAdapter implements DbAdapter {
private readonly filename: string;
private readonly driverPreference?: SqliteDriverName;
private readonly driverLoader?: () => Promise<SqliteDriver>;
private connection: SqliteConnection | null = null;
private driver: SqliteDriver | null = null;
private connecting: Promise<void> | null = null;
constructor(options: SqliteDbAdapterOptions | string) {
const opts = typeof options === 'string' ? { filename: options } : options;
this.filename = opts.filename || ':memory:';
this.driverPreference = opts.driver;
this.driverLoader = opts.driverLoader;
}
/** The file this adapter persists to (`':memory:'` for the in-process form). */
getFilename(): string {
return this.filename;
}
/** Which driver opened the database, once connected. */
getDriverName(): string | null {
return this.driver?.name ?? null;
}
// -- Lifecycle ------------------------------------------------------------
async connect(): Promise<void> {
if (this.connection) return;
if (!this.connecting) {
this.connecting = this.openAndMigrate().finally(() => {
this.connecting = null;
});
}
return this.connecting;
}
private async openAndMigrate(): Promise<void> {
const { driver, connection } = await openWithAnyDriver(
this.filename,
this.driverPreference,
this.driverLoader,
);
try {
if (this.filename !== ':memory:') {
try {
// WAL keeps a reader in another process from blocking a writer.
// Unsupported on some filesystems; never worth failing the open for.
connection.exec('PRAGMA journal_mode = WAL;');
} catch {
/* keep the default journal mode */
}
}
connection.exec(SCHEMA_SQL);
this.verifySchema(connection);
connection
.prepare('INSERT OR REPLACE INTO praisonai_meta (key, value) VALUES (?, ?)')
.run('schema_version', SQLITE_SCHEMA_VERSION);
} catch (error) {
try {
connection.close();
} catch {
/* already closing down */
}
throw error;
}
this.connection = connection;
this.driver = driver;
}
private verifySchema(connection: SqliteConnection): void {
for (const [table, columns] of Object.entries(REQUIRED_COLUMNS)) {
const present = new Set(
connection.prepare(`PRAGMA table_info(${table})`).all().map((row: any) => String(row.name)),
);
const missing = columns.filter((c) => !present.has(c));
if (missing.length > 0) {
throw new Error(
`SQLite file "${this.filename}" already has a "${table}" table with an incompatible ` +
`shape (missing: ${missing.join(', ')}). It was most likely written by the ` +
`low-level SQLiteAdapter in "praisonai/db/sqlite", whose tables differ. ` +
`Point db("sqlite:...") at a different file, or migrate that one.`,
);
}
}
}
async disconnect(): Promise<void> {
if (this.connecting) {
// Do not leave a half-open handle behind if disconnect races connect.
await this.connecting.catch(() => undefined);
}
if (this.connection) {
this.connection.close();
this.connection = null;
this.driver = null;
}
}
isConnected(): boolean {
return this.connection !== null;
}
/**
* The Agent never calls `connect()` -- it goes straight to `saveMessage` /
* `getMessages` -- so every operation opens on demand. When the database
* cannot be opened this rejects with {@link unavailableError}, which is the
* point: no operation ever quietly succeeds against memory.
*/
private async db(): Promise<SqliteConnection> {
if (!this.connection) await this.connect();
if (!this.connection) throw unavailableError(this.filename, []);
return this.connection;
}
// -- Sessions -------------------------------------------------------------
async createSession(session: DbSession): Promise<void> {
const db = await this.db();
db.prepare(
'INSERT OR REPLACE INTO sessions (id, created_at, updated_at, metadata) VALUES (?, ?, ?, ?)',
).run(session.id, num(session.createdAt) ?? Date.now(), num(session.updatedAt) ?? Date.now(), toJson(session.metadata));
}
async getSession(id: string): Promise<DbSession | null> {
const db = await this.db();
const row = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) ?? null;
return row ? this.rowToSession(row) : null;
}
async updateSession(id: string, updates: Partial<DbSession>): Promise<void> {
const db = await this.db();
const fields: string[] = [];
const values: SqliteParam[] = [];
if (updates.createdAt !== undefined) {
fields.push('created_at = ?');
values.push(updates.createdAt);
}
if (updates.metadata !== undefined) {
fields.push('metadata = ?');
values.push(toJson(updates.metadata));
}
// MemoryDbAdapter stamps updatedAt on every update; match that, and let an
// explicit updates.updatedAt win.
fields.push('updated_at = ?');
values.push(num(updates.updatedAt) ?? Date.now());
values.push(id);
db.prepare(`UPDATE sessions SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
async deleteSession(id: string): Promise<void> {
const db = await this.db();
db.prepare('DELETE FROM messages WHERE session_id = ?').run(id);
db.prepare('DELETE FROM sessions WHERE id = ?').run(id);
}
async listSessions(limit = 100, offset = 0): Promise<DbSession[]> {
const db = await this.db();
const rows = db
.prepare('SELECT * FROM sessions ORDER BY created_at ASC, rowid ASC LIMIT ? OFFSET ?')
.all(limit, offset);
return rows.map((row) => this.rowToSession(row));
}
private rowToSession(row: any): DbSession {
return {
id: String(row.id),
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
metadata: fromJson(row.metadata),
};
}
// -- Messages -------------------------------------------------------------
async saveMessage(message: DbMessage): Promise<void> {
const db = await this.db();
db.prepare(
'INSERT OR REPLACE INTO messages ' +
'(id, session_id, run_id, role, content, name, tool_call_id, tool_calls, created_at, metadata) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
).run(
message.id,
message.sessionId,
text(message.runId),
message.role,
message.content === undefined ? null : message.content,
text(message.name),
text(message.toolCallId),
toJson(message.toolCalls),
num(message.createdAt) ?? Date.now(),
toJson(message.metadata),
);
}
async getMessages(sessionId: string, limit?: number): Promise<DbMessage[]> {
const db = await this.db();
// MemoryDbAdapter returns the LAST `limit` messages, still in chronological
// order (`slice(-limit)`). Reproduce that: newest-first with a LIMIT, then
// reverse. rowid breaks ties, because two messages in the same turn share a
// Date.now() millisecond often enough to scramble a conversation.
if (limit !== undefined && limit > 0) {
const rows = db
.prepare(
'SELECT * FROM messages WHERE session_id = ? ORDER BY created_at DESC, rowid DESC LIMIT ?',
)
.all(sessionId, limit);
return rows.reverse().map((row) => this.rowToMessage(row));
}
const rows = db
.prepare('SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC, rowid ASC')
.all(sessionId);
return rows.map((row) => this.rowToMessage(row));
}
async deleteMessages(sessionId: string): Promise<void> {
const db = await this.db();
db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId);
}
private rowToMessage(row: any): DbMessage {
return {
id: String(row.id),
sessionId: String(row.session_id),
runId: optText(row.run_id),
role: String(row.role) as DbMessage['role'],
content: row.content === null || row.content === undefined ? null : String(row.content),
name: optText(row.name),
toolCallId: optText(row.tool_call_id),
toolCalls: fromJson(row.tool_calls),
createdAt: Number(row.created_at),
metadata: fromJson(row.metadata),
};
}
// -- Runs -----------------------------------------------------------------
async createRun(run: DbRun): Promise<void> {
const db = await this.db();
db.prepare(
'INSERT OR REPLACE INTO runs ' +
'(id, session_id, agent_name, status, started_at, completed_at, error, metadata, ' +
'prompt_tokens, completion_tokens, total_tokens) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
).run(
run.id,
run.sessionId,
text(run.agentName),
run.status,
num(run.startedAt) ?? Date.now(),
num(run.completedAt),
text(run.error),
toJson(run.metadata),
num(run.tokenUsage?.promptTokens),
num(run.tokenUsage?.completionTokens),
num(run.tokenUsage?.totalTokens),
);
}
async getRun(id: string): Promise<DbRun | null> {
const db = await this.db();
const row = db.prepare('SELECT * FROM runs WHERE id = ?').get(id) ?? null;
return row ? this.rowToRun(row) : null;
}
async updateRun(id: string, updates: Partial<DbRun>): Promise<void> {
const db = await this.db();
const fields: string[] = [];
const values: SqliteParam[] = [];
const set = (column: string, value: SqliteParam) => {
fields.push(`${column} = ?`);
values.push(value);
};
if (updates.sessionId !== undefined) set('session_id', updates.sessionId);
if (updates.agentName !== undefined) set('agent_name', text(updates.agentName));
if (updates.status !== undefined) set('status', updates.status);
if (updates.startedAt !== undefined) set('started_at', updates.startedAt);
if (updates.completedAt !== undefined) set('completed_at', num(updates.completedAt));
if (updates.error !== undefined) set('error', text(updates.error));
if (updates.metadata !== undefined) set('metadata', toJson(updates.metadata));
if (updates.tokenUsage !== undefined) {
set('prompt_tokens', num(updates.tokenUsage?.promptTokens));
set('completion_tokens', num(updates.tokenUsage?.completionTokens));
set('total_tokens', num(updates.tokenUsage?.totalTokens));
}
if (fields.length === 0) return;
values.push(id);
db.prepare(`UPDATE runs SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
async listRuns(sessionId: string, limit = 100): Promise<DbRun[]> {
const db = await this.db();
// Last `limit` runs, oldest-first -- MemoryDbAdapter's `slice(-limit)`.
const rows = db
.prepare(
'SELECT * FROM runs WHERE session_id = ? ORDER BY started_at DESC, rowid DESC LIMIT ?',
)
.all(sessionId, limit);
return rows.reverse().map((row) => this.rowToRun(row));
}
private rowToRun(row: any): DbRun {
const promptTokens = optNum(row.prompt_tokens);
const completionTokens = optNum(row.completion_tokens);
const totalTokens = optNum(row.total_tokens);
const hasUsage =
promptTokens !== undefined || completionTokens !== undefined || totalTokens !== undefined;
return {
id: String(row.id),
sessionId: String(row.session_id),
agentName: optText(row.agent_name),
status: String(row.status) as DbRun['status'],
startedAt: Number(row.started_at),
completedAt: optNum(row.completed_at),
error: optText(row.error),
metadata: fromJson(row.metadata),
tokenUsage: hasUsage
? {
promptTokens: promptTokens ?? 0,
completionTokens: completionTokens ?? 0,
totalTokens: totalTokens ?? 0,
}
: undefined,
};
}
// -- Tool calls -----------------------------------------------------------
async saveToolCall(toolCall: DbToolCall): Promise<void> {
const db = await this.db();
db.prepare(
'INSERT OR REPLACE INTO tool_calls ' +
'(id, run_id, name, arguments, result, status, started_at, completed_at, error) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
).run(
toolCall.id,
toolCall.runId,
toolCall.name,
toolCall.arguments,
text(toolCall.result),
toolCall.status,
num(toolCall.startedAt) ?? Date.now(),
num(toolCall.completedAt),
text(toolCall.error),
);
}
async getToolCalls(runId: string): Promise<DbToolCall[]> {
const db = await this.db();
const rows = db
.prepare('SELECT * FROM tool_calls WHERE run_id = ? ORDER BY started_at ASC, rowid ASC')
.all(runId);
return rows.map((row: any) => ({
id: String(row.id),
runId: String(row.run_id),
name: String(row.name),
arguments: String(row.arguments),
result: optText(row.result),
status: String(row.status) as DbToolCall['status'],
startedAt: Number(row.started_at),
completedAt: optNum(row.completed_at),
error: optText(row.error),
}));
}
// -- Traces ---------------------------------------------------------------
async createTrace(trace: DbTrace): Promise<void> {
const db = await this.db();
db.prepare(
'INSERT OR REPLACE INTO traces ' +
'(id, session_id, run_id, agent_name, started_at, completed_at, status, metadata) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
).run(
trace.id,
trace.sessionId,
text(trace.runId),
text(trace.agentName),
num(trace.startedAt) ?? Date.now(),
num(trace.completedAt),
trace.status,
toJson(trace.metadata),
);
}
async getTrace(id: string): Promise<DbTrace | null> {
const db = await this.db();
const row = db.prepare('SELECT * FROM traces WHERE id = ?').get(id) ?? null;
if (!row) return null;
return {
id: String(row.id),
sessionId: String(row.session_id),
runId: optText(row.run_id),
agentName: optText(row.agent_name),
startedAt: Number(row.started_at),
completedAt: optNum(row.completed_at),
status: String(row.status) as DbTrace['status'],
metadata: fromJson(row.metadata),
};
}
async updateTrace(id: string, updates: Partial<DbTrace>): Promise<void> {
const db = await this.db();
const fields: string[] = [];
const values: SqliteParam[] = [];
const set = (column: string, value: SqliteParam) => {
fields.push(`${column} = ?`);
values.push(value);
};
if (updates.sessionId !== undefined) set('session_id', updates.sessionId);
if (updates.runId !== undefined) set('run_id', text(updates.runId));
if (updates.agentName !== undefined) set('agent_name', text(updates.agentName));
if (updates.startedAt !== undefined) set('started_at', updates.startedAt);
if (updates.completedAt !== undefined) set('completed_at', num(updates.completedAt));
if (updates.status !== undefined) set('status', updates.status);
if (updates.metadata !== undefined) set('metadata', toJson(updates.metadata));
if (fields.length === 0) return;
values.push(id);
db.prepare(`UPDATE traces SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
// -- Spans ----------------------------------------------------------------
async createSpan(span: DbSpan): Promise<void> {
const db = await this.db();
db.prepare(
'INSERT OR REPLACE INTO spans ' +
'(id, trace_id, parent_id, name, started_at, completed_at, status, attributes) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
).run(
span.id,
span.traceId,
text(span.parentId),
span.name,
num(span.startedAt) ?? Date.now(),
num(span.completedAt),
span.status,
toJson(span.attributes),
);
}
async getSpans(traceId: string): Promise<DbSpan[]> {
const db = await this.db();
const rows = db
.prepare('SELECT * FROM spans WHERE trace_id = ? ORDER BY started_at ASC, rowid ASC')
.all(traceId);
return rows.map((row: any) => ({
id: String(row.id),
traceId: String(row.trace_id),
parentId: optText(row.parent_id),
name: String(row.name),
startedAt: Number(row.started_at),
completedAt: optNum(row.completed_at),
status: String(row.status) as DbSpan['status'],
attributes: fromJson(row.attributes),
}));
}
async updateSpan(id: string, updates: Partial<DbSpan>): Promise<void> {
const db = await this.db();
const fields: string[] = [];
const values: SqliteParam[] = [];
const set = (column: string, value: SqliteParam) => {
fields.push(`${column} = ?`);
values.push(value);
};
if (updates.traceId !== undefined) set('trace_id', updates.traceId);
if (updates.parentId !== undefined) set('parent_id', text(updates.parentId));
if (updates.name !== undefined) set('name', updates.name);
if (updates.startedAt !== undefined) set('started_at', updates.startedAt);
if (updates.completedAt !== undefined) set('completed_at', num(updates.completedAt));
if (updates.status !== undefined) set('status', updates.status);
if (updates.attributes !== undefined) set('attributes', toJson(updates.attributes));
if (fields.length === 0) return;
values.push(id);
db.prepare(`UPDATE spans SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
// -- Maintenance ----------------------------------------------------------
/** Schema version recorded in the file. */
async getSchemaVersion(): Promise<string | null> {
const db = await this.db();
const row = db.prepare('SELECT value FROM praisonai_meta WHERE key = ?').get('schema_version');
return row ? String(row.value) : null;
}
/** Drop every row, keeping the schema. Mirrors MemoryDbAdapter.clear(). */
async clear(): Promise<void> {
const db = await this.db();
db.exec(
'DELETE FROM spans; DELETE FROM traces; DELETE FROM tool_calls; ' +
'DELETE FROM runs; DELETE FROM messages; DELETE FROM sessions;',
);
}
}
/** Factory mirroring `createSQLiteAdapter` in `./sqlite.ts`. */
export function createSqliteDbAdapter(
options: SqliteDbAdapterOptions | string,
): SqliteDbAdapter {
return new SqliteDbAdapter(options);
}
@@ -0,0 +1,242 @@
/**
* A dependency-free RFC 6455 WebSocket server for tests.
*
* `ws` is not a dependency of this package, and these tests must not reach
* OpenAI, so the server side is implemented here directly on `node:http`.
* It speaks just enough of the protocol for the RealtimeAgent tests: the
* handshake, unmasked server -> client text frames, masked client -> server
* text frames, and close.
*/
import * as http from 'http';
import * as crypto from 'crypto';
import type { Socket } from 'net';
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
export interface MinimalWsConnection {
/** Handshake headers exactly as the client sent them. */
headers: http.IncomingHttpHeaders;
/** Request path including query string. */
url: string;
/** JSON messages received from the client, in order. */
received: any[];
/** Send a JSON payload to the client. */
send(payload: unknown): void;
/** Close the connection from the server side. */
close(code?: number): void;
/** True once the socket has gone away. */
closed: boolean;
}
export interface MinimalWsServerOptions {
/**
* Reject the upgrade with this HTTP status instead of completing the
* handshake - how a bad API key looks on the wire (OpenAI answers 401).
*/
rejectWithStatus?: number;
/** Echo back one of the client's requested subprotocols, if offered. */
acceptSubprotocol?: boolean;
/** Called once a connection is established. */
onConnection?: (connection: MinimalWsConnection) => void;
}
export interface MinimalWsServer {
port: number;
url: string;
connections: MinimalWsConnection[];
/** Resolves with the next connection to be established. */
nextConnection(): Promise<MinimalWsConnection>;
close(): Promise<void>;
}
function encodeTextFrame(text: string): Buffer {
const payload = Buffer.from(text, 'utf8');
let header: Buffer;
if (payload.length < 126) {
header = Buffer.from([0x81, payload.length]);
} else if (payload.length < 65536) {
header = Buffer.alloc(4);
header[0] = 0x81;
header[1] = 126;
header.writeUInt16BE(payload.length, 2);
} else {
header = Buffer.alloc(10);
header[0] = 0x81;
header[1] = 127;
header.writeBigUInt64BE(BigInt(payload.length), 2);
}
return Buffer.concat([header, payload]);
}
function encodeCloseFrame(code = 1000): Buffer {
const payload = Buffer.alloc(2);
payload.writeUInt16BE(code, 0);
return Buffer.concat([Buffer.from([0x88, payload.length]), payload]);
}
/** Pull as many complete frames as are buffered. Returns leftover bytes. */
function drainFrames(
buffer: Buffer,
onFrame: (opcode: number, payload: Buffer) => void
): Buffer {
let offset = 0;
while (buffer.length - offset >= 2) {
const first = buffer[offset];
const second = buffer[offset + 1];
const opcode = first & 0x0f;
const masked = (second & 0x80) !== 0;
let length = second & 0x7f;
let cursor = offset + 2;
if (length === 126) {
if (buffer.length < cursor + 2) break;
length = buffer.readUInt16BE(cursor);
cursor += 2;
} else if (length === 127) {
if (buffer.length < cursor + 8) break;
length = Number(buffer.readBigUInt64BE(cursor));
cursor += 8;
}
let mask: Buffer | null = null;
if (masked) {
if (buffer.length < cursor + 4) break;
mask = buffer.subarray(cursor, cursor + 4);
cursor += 4;
}
if (buffer.length < cursor + length) break;
const payload = Buffer.from(buffer.subarray(cursor, cursor + length));
if (mask) {
for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i % 4];
}
cursor += length;
offset = cursor;
onFrame(opcode, payload);
}
return buffer.subarray(offset);
}
export async function startMinimalWsServer(
options: MinimalWsServerOptions = {}
): Promise<MinimalWsServer> {
const connections: MinimalWsConnection[] = [];
const waiters: Array<(connection: MinimalWsConnection) => void> = [];
const sockets = new Set<Socket>();
const server = http.createServer((_req, res) => {
res.writeHead(426);
res.end('Upgrade required');
});
server.on('upgrade', (req, socket: Socket) => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
if (options.rejectWithStatus) {
socket.write(
`HTTP/1.1 ${options.rejectWithStatus} Unauthorized\r\n` +
'Connection: close\r\nContent-Length: 0\r\n\r\n'
);
socket.destroy();
return;
}
const key = req.headers['sec-websocket-key'];
const accept = crypto
.createHash('sha1')
.update(String(key) + WS_GUID)
.digest('base64');
const offered = String(req.headers['sec-websocket-protocol'] ?? '')
.split(',')
.map((p) => p.trim())
.filter(Boolean);
let responseHeaders =
'HTTP/1.1 101 Switching Protocols\r\n' +
'Upgrade: websocket\r\n' +
'Connection: Upgrade\r\n' +
`Sec-WebSocket-Accept: ${accept}\r\n`;
if (options.acceptSubprotocol && offered.length > 0) {
responseHeaders += `Sec-WebSocket-Protocol: ${offered[0]}\r\n`;
}
socket.write(responseHeaders + '\r\n');
const connection: MinimalWsConnection = {
headers: req.headers,
url: req.url ?? '',
received: [],
closed: false,
send(payload: unknown) {
if (socket.destroyed) return;
socket.write(encodeTextFrame(JSON.stringify(payload)));
},
close(code = 1000) {
if (socket.destroyed) return;
socket.write(encodeCloseFrame(code));
socket.end();
},
};
let buffered: Buffer = Buffer.alloc(0);
socket.on('data', (chunk: Buffer) => {
buffered = drainFrames(Buffer.concat([buffered, chunk]), (opcode, payload) => {
if (opcode === 0x8) {
connection.closed = true;
if (!socket.destroyed) {
socket.write(encodeCloseFrame(1000));
socket.end();
}
return;
}
if (opcode === 0x1) {
try {
connection.received.push(JSON.parse(payload.toString('utf8')));
} catch {
connection.received.push(payload.toString('utf8'));
}
}
});
});
socket.on('close', () => {
connection.closed = true;
});
socket.on('error', () => {
connection.closed = true;
});
connections.push(connection);
options.onConnection?.(connection);
while (waiters.length > 0) waiters.shift()!(connection);
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
return {
port,
url: `ws://127.0.0.1:${port}/v1/realtime`,
connections,
nextConnection() {
if (connections.length > 0) return Promise.resolve(connections[connections.length - 1]);
return new Promise<MinimalWsConnection>((resolve) => waiters.push(resolve));
},
close() {
for (const socket of sockets) socket.destroy();
sockets.clear();
return new Promise<void>((resolve) => server.close(() => resolve()));
},
};
}
/** A port that is listening but immediately refuses/aborts every connection. */
export async function reservedClosedPort(): Promise<number> {
const server = http.createServer();
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
await new Promise<void>((resolve) => server.close(() => resolve()));
return port;
}
@@ -0,0 +1,80 @@
/**
* Two races found in review of the realtime connect/disconnect fix.
*
* connect() assigns this.ws only AFTER the handshake resolves, and teardown()
* nulls this.ws unconditionally without checking which socket it owns. So:
*
* 1. Two concurrent connect() calls both pass the isConnected() check and both
* open sockets. The last to finish overwrites this.ws, leaving the other
* socket open and unreachable -- a leaked connection the agent can never
* close.
* 2. A disconnect() still waiting on an old socket can clear a NEW socket
* established by a reconnect, marking the agent disconnected while its
* socket stays open.
*/
import { RealtimeAgent } from '../../../src/agent/realtime';
import { startMinimalWsServer, MinimalWsServer } from '../../helpers/minimal-ws-server';
describe('RealtimeAgent connect/disconnect races', () => {
let server: MinimalWsServer;
beforeEach(async () => {
server = await startMinimalWsServer();
});
afterEach(async () => {
await server.close();
});
it('concurrent connect() calls must not leak a second socket', async () => {
const agent = new RealtimeAgent({
apiKey: 'sk-test',
url: server.url,
connectTimeoutMs: 4000,
verbose: false,
});
await Promise.all([agent.connect(), agent.connect()]);
// One agent, one live connection. A second accepted socket is one the
// agent no longer references and can never close.
expect(server.connections.length).toBe(1);
expect(agent.isConnected()).toBe(true);
await agent.disconnect();
});
it('control: a single connect() opens exactly one socket', async () => {
const agent = new RealtimeAgent({
apiKey: 'sk-test',
url: server.url,
connectTimeoutMs: 4000,
verbose: false,
});
await agent.connect();
expect(server.connections.length).toBe(1);
expect(agent.isConnected()).toBe(true);
await agent.disconnect();
});
it('a reconnect during disconnect must survive the old teardown', async () => {
const agent = new RealtimeAgent({
apiKey: 'sk-test',
url: server.url,
connectTimeoutMs: 4000,
verbose: false,
});
await agent.connect();
const closing = agent.disconnect();
await closing;
await agent.connect();
// The reconnect must be usable, not cleared by the earlier teardown.
expect(agent.isConnected()).toBe(true);
await agent.disconnect();
});
});
@@ -0,0 +1,606 @@
/**
* RealtimeAgent WebSocket behaviour.
*
* The bug these tests exist for: `connect()` used to set `connected = true`
* and emit a fabricated `session.created` without opening any socket, so an
* agent built with a deliberately invalid endpoint or key still reported
* itself connected. Every failure assertion below is paired with a control
* that proves the assertion is not vacuously true.
*
* No test here talks to OpenAI: the server side is a local, dependency-free
* RFC 6455 implementation in tests/helpers/minimal-ws-server.ts.
*/
import { RealtimeAgent, resolveWebSocketImplementation, _resetWebSocketResolution } from '../../../src/agent/realtime';
import {
startMinimalWsServer,
reservedClosedPort,
MinimalWsServer,
} from '../../helpers/minimal-ws-server';
const QUIET = { verbose: false, connectTimeoutMs: 2000 } as const;
/** Wait until `predicate` holds, or fail loudly after `timeoutMs`. */
async function waitFor(predicate: () => boolean, timeoutMs = 3000, label = 'condition'): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`Timed out waiting for ${label}`);
}
describe('RealtimeAgent - real WebSocket connection', () => {
let server: MinimalWsServer | null = null;
afterEach(async () => {
if (server) {
await server.close();
server = null;
}
});
// ==========================================================================
// Regression: the original defect
// ==========================================================================
describe('regression: an invalid endpoint must not report connected', () => {
it('rejects and stays disconnected when nothing is listening', async () => {
const port = await reservedClosedPort();
const agent = new RealtimeAgent({
...QUIET,
apiKey: 'sk-invalid',
url: `ws://127.0.0.1:${port}/v1/realtime`,
});
await expect(agent.connect()).rejects.toThrow(/failed|closed|refus/i);
expect(agent.isConnected()).toBe(false);
});
it('rejects and stays disconnected when the host does not resolve', async () => {
const agent = new RealtimeAgent({
...QUIET,
apiKey: 'sk-invalid',
url: 'ws://this-host-does-not-exist.invalid/v1/realtime',
});
await expect(agent.connect()).rejects.toThrow();
expect(agent.isConnected()).toBe(false);
});
it('rejects when the server refuses the handshake, as a bad API key does', async () => {
server = await startMinimalWsServer({ rejectWithStatus: 401 });
const agent = new RealtimeAgent({ ...QUIET, apiKey: 'sk-wrong', url: server.url });
await expect(agent.connect()).rejects.toThrow();
expect(agent.isConnected()).toBe(false);
});
// CONTROL for all three: the same agent shape against a server that does
// complete the handshake connects and reports connected.
it('CONTROL: connects and reports connected against a live server', async () => {
server = await startMinimalWsServer();
const agent = new RealtimeAgent({ ...QUIET, apiKey: 'sk-test', url: server.url });
await expect(agent.connect()).resolves.toBeUndefined();
expect(agent.isConnected()).toBe(true);
await agent.disconnect();
});
it('does not fabricate session.created - only the server can emit it', async () => {
// This server accepts the handshake and then says nothing at all.
server = await startMinimalWsServer();
const agent = new RealtimeAgent({ ...QUIET, apiKey: 'sk-test', url: server.url });
const seen: string[] = [];
agent.on('session.created', (event) => seen.push(String(event.type)));
await agent.connect();
await new Promise((resolve) => setTimeout(resolve, 100));
expect(seen).toEqual([]);
// CONTROL: when the server does send it, the handler fires.
const connection = await server.nextConnection();
connection.send({ type: 'session.created', session: { id: 'sess_123' } });
await waitFor(() => seen.length === 1, 3000, 'server-sent session.created');
expect(seen).toEqual(['session.created']);
await agent.disconnect();
});
});
// ==========================================================================
// A successful connect really opens a socket
// ==========================================================================
describe('successful connect', () => {
it('actually opens a socket the server observes, with auth headers', async () => {
server = await startMinimalWsServer();
const agent = new RealtimeAgent({
...QUIET,
apiKey: 'sk-header-check',
url: server.url,
headers: { 'X-Trace': 'abc' },
});
expect(server.connections).toHaveLength(0); // control: nothing yet
await agent.connect();
const connection = await server.nextConnection();
expect(server.connections).toHaveLength(1);
expect(connection.headers['authorization']).toBe('Bearer sk-header-check');
expect(connection.headers['openai-beta']).toBe('realtime=v1');
expect(connection.headers['x-trace']).toBe('abc');
await agent.disconnect();
});
it('sends session.update with the resolved config right after connecting', async () => {
server = await startMinimalWsServer();
const agent = new RealtimeAgent({
...QUIET,
apiKey: 'sk-test',
url: server.url,
instructions: 'Be brief',
realtime: { voice: 'nova', vadThreshold: 0.9 },
});
await agent.connect();
const connection = await server.nextConnection();
await waitFor(() => connection.received.length >= 1, 3000, 'session.update');
expect(connection.received[0]).toMatchObject({
type: 'session.update',
session: {
voice: 'nova',
input_audio_format: 'pcm16',
instructions: 'Be brief',
turn_detection: { type: 'server_vad', threshold: 0.9 },
},
});
await agent.disconnect();
});
it('emits server events verbatim, including types it does not know', async () => {
server = await startMinimalWsServer();
const agent = new RealtimeAgent({ ...QUIET, apiKey: 'sk-test', url: server.url });
const all: any[] = [];
agent.on('*', (event) => all.push(event));
const texts: string[] = [];
agent.onMessage((text) => texts.push(text));
await agent.connect();
const connection = await server.nextConnection();
connection.send({ type: 'response.text.delta', delta: 'Hel' });
connection.send({ type: 'response.text.delta', delta: 'lo' });
connection.send({ type: 'rate_limits.updated', rate_limits: [{ name: 'requests' }] });
await waitFor(() => all.length === 3, 3000, 'three server events');
expect(all.map((e) => e.type)).toEqual([
'response.text.delta',
'response.text.delta',
'rate_limits.updated',
]);
expect(all[0].data).toEqual({ type: 'response.text.delta', delta: 'Hel' });
expect(texts.join('')).toBe('Hello');
await agent.disconnect();
});
it('decodes base64 audio deltas for onAudio callbacks', async () => {
server = await startMinimalWsServer();
const agent = new RealtimeAgent({ ...QUIET, apiKey: 'sk-test', url: server.url });
const chunks: Uint8Array[] = [];
agent.onAudio((audio) => chunks.push(audio));
await agent.connect();
const connection = await server.nextConnection();
connection.send({
type: 'response.audio.delta',
delta: Buffer.from([0x01, 0x02, 0xfe]).toString('base64'),
});
await waitFor(() => chunks.length === 1, 3000, 'audio delta');
expect(Array.from(chunks[0])).toEqual([0x01, 0x02, 0xfe]);
await agent.disconnect();
});
it('a throwing onMessage handler does not skip later handlers or the wildcard emit', async () => {
server = await startMinimalWsServer();
const agent = new RealtimeAgent({ ...QUIET, apiKey: 'sk-test', url: server.url });
const reached: string[] = [];
const all: any[] = [];
agent.onMessage(() => {
throw new Error('bad handler');
});
agent.onMessage((text) => reached.push(text));
agent.on('*', (event) => all.push(event));
await agent.connect();
const connection = await server.nextConnection();
connection.send({ type: 'response.text.delta', delta: 'ok' });
await waitFor(() => all.length === 1, 3000, 'wildcard emit after a throwing handler');
expect(reached).toEqual(['ok']); // later handler still ran
expect(all[0].data).toEqual({ type: 'response.text.delta', delta: 'ok' });
await agent.disconnect();
});
it('surfaces server error events to onError handlers', async () => {
server = await startMinimalWsServer();
const agent = new RealtimeAgent({ ...QUIET, apiKey: 'sk-test', url: server.url });
const errors: Error[] = [];
agent.onError((error) => errors.push(error));
await agent.connect();
const connection = await server.nextConnection();
expect(errors).toHaveLength(0); // control
connection.send({ type: 'error', error: { message: 'invalid_request_error' } });
await waitFor(() => errors.length === 1, 3000, 'error event');
expect(errors[0].message).toContain('invalid_request_error');
await agent.disconnect();
});
});
// ==========================================================================
// Sending
// ==========================================================================
describe('sending', () => {
it('fails clearly when sending before connecting', async () => {
const agent = new RealtimeAgent({ ...QUIET, apiKey: 'sk-test', url: 'ws://127.0.0.1:9/x' });
expect(() => agent.sendAudio(new Uint8Array([1, 2, 3]))).toThrow(/Not connected/i);
expect(() => agent.commitAudio()).toThrow(/Not connected/i);
expect(() => agent.clearAudio()).toThrow(/Not connected/i);
await expect(agent.sendText('hi')).rejects.toThrow(/Not connected/i);
await expect(agent.sendEvent({ type: 'response.create' })).rejects.toThrow(/Not connected/i);
});
it('CONTROL: the same calls reach the wire once connected', async () => {
server = await startMinimalWsServer();
const agent = new RealtimeAgent({ ...QUIET, apiKey: 'sk-test', url: server.url });
await agent.connect();
const connection = await server.nextConnection();
agent.sendAudio(new Uint8Array([0xaa, 0xbb]));
agent.commitAudio();
agent.clearAudio();
await agent.sendText('hello there');
await waitFor(() => connection.received.length >= 6, 3000, 'six client frames');
const types = connection.received.map((event: any) => event.type);
expect(types).toEqual([
'session.update',
'input_audio_buffer.append',
'input_audio_buffer.commit',
'input_audio_buffer.clear',
'conversation.item.create',
'response.create',
]);
expect(connection.received[1].audio).toBe(Buffer.from([0xaa, 0xbb]).toString('base64'));
expect(connection.received[4].item).toEqual({
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'hello there' }],
});
await agent.disconnect();
});
it('sending fails again after disconnect', async () => {
server = await startMinimalWsServer();
const agent = new RealtimeAgent({ ...QUIET, apiKey: 'sk-test', url: server.url });
await agent.connect();
expect(() => agent.commitAudio()).not.toThrow(); // control
await agent.disconnect();
expect(() => agent.commitAudio()).toThrow(/Not connected/i);
});
});
// ==========================================================================
// Teardown
// ==========================================================================
describe('close', () => {
it('disconnect() closes the socket the server sees', async () => {
server = await startMinimalWsServer();
const agent = new RealtimeAgent({ ...QUIET, apiKey: 'sk-test', url: server.url });
await agent.connect();
const connection = await server.nextConnection();
expect(connection.closed).toBe(false); // control
await agent.disconnect();
expect(agent.isConnected()).toBe(false);
await waitFor(() => connection.closed, 3000, 'server-side close');
});
it('a server-initiated close flips isConnected() to false', async () => {
server = await startMinimalWsServer();
const agent = new RealtimeAgent({ ...QUIET, apiKey: 'sk-test', url: server.url });
await agent.connect();
expect(agent.isConnected()).toBe(true); // control
const connection = await server.nextConnection();
connection.close(1001);
await waitFor(() => !agent.isConnected(), 3000, 'client to notice the close');
});
it('disconnect() on a never-connected agent is a no-op', async () => {
const agent = new RealtimeAgent({ ...QUIET, apiKey: 'sk-test', url: 'ws://127.0.0.1:9/x' });
await expect(agent.disconnect()).resolves.toBeUndefined();
expect(agent.isConnected()).toBe(false);
});
it('reconnects after a disconnect', async () => {
server = await startMinimalWsServer();
const agent = new RealtimeAgent({ ...QUIET, apiKey: 'sk-test', url: server.url });
await agent.connect();
await agent.disconnect();
await agent.connect();
expect(agent.isConnected()).toBe(true);
expect(server.connections).toHaveLength(2);
await agent.disconnect();
});
});
// ==========================================================================
// Concurrent lifecycle: overlapping connect()/disconnect() must not leak
// ==========================================================================
describe('concurrent lifecycle', () => {
it('two concurrent connect() calls open exactly one socket', async () => {
server = await startMinimalWsServer();
const agent = new RealtimeAgent({ ...QUIET, apiKey: 'sk-test', url: server.url });
// Both callers race; they must share the one in-flight handshake instead
// of each opening a socket and racing to overwrite this.ws.
await Promise.all([agent.connect(), agent.connect()]);
expect(agent.isConnected()).toBe(true);
expect(server.connections).toHaveLength(1);
await agent.disconnect();
});
it('disconnect() during the handshake wins: the socket is dropped', async () => {
// A WebSocket that never fires "open" until we let it, so disconnect()
// lands squarely mid-handshake.
const listeners: Record<string, Array<(event: any) => void>> = {};
let openTrigger: (() => void) | undefined;
let closed = false;
class SlowSocket {
readyState = 0;
constructor(public url: string, public options: any) {
openTrigger = () => {
this.readyState = 1;
(listeners['open'] ?? []).forEach((fn) => fn({}));
};
}
addEventListener(type: string, fn: (event: any) => void) {
(listeners[type] ??= []).push(fn);
}
send() {}
close() {
closed = true;
this.readyState = 3;
(listeners['close'] ?? []).forEach((fn) => fn({ code: 1000 }));
}
}
const agent = new RealtimeAgent({
...QUIET,
apiKey: 'sk-test',
url: 'ws://injected/endpoint',
webSocket: SlowSocket as any,
});
const connecting = agent.connect();
// Cancel while the handshake is still pending.
await agent.disconnect();
// Now allow the handshake to "complete" late; it must not win.
openTrigger?.();
await expect(connecting).rejects.toThrow(/cancel|supersed/i);
expect(agent.isConnected()).toBe(false);
expect(closed).toBe(true);
});
});
// ==========================================================================
// Endpoint and credentials
// ==========================================================================
describe('endpoint and credentials', () => {
it('defaults to the OpenAI realtime endpoint with the model in the query', () => {
const agent = new RealtimeAgent({ ...QUIET, apiKey: 'sk-test', llm: 'gpt-4o-realtime-preview' });
expect(agent.getUrl()).toBe(
'wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview'
);
});
it('refuses to dial the OpenAI endpoint without an API key', async () => {
const previous = process.env.OPENAI_API_KEY;
delete process.env.OPENAI_API_KEY;
try {
const agent = new RealtimeAgent({ ...QUIET });
await expect(agent.connect()).rejects.toThrow(/OPENAI_API_KEY/);
expect(agent.isConnected()).toBe(false);
} finally {
if (previous !== undefined) process.env.OPENAI_API_KEY = previous;
}
});
it('CONTROL: a custom url does not require an API key', async () => {
const previous = process.env.OPENAI_API_KEY;
delete process.env.OPENAI_API_KEY;
try {
server = await startMinimalWsServer();
const agent = new RealtimeAgent({ ...QUIET, url: server.url });
await agent.connect();
expect(agent.isConnected()).toBe(true);
const connection = await server.nextConnection();
expect(connection.headers['authorization']).toBeUndefined();
await agent.disconnect();
} finally {
if (previous !== undefined) process.env.OPENAI_API_KEY = previous;
}
});
});
// ==========================================================================
// WebSocket resolution
// ==========================================================================
describe('WebSocket implementation resolution', () => {
afterEach(() => _resetWebSocketResolution());
it('prefers the global WebSocket when one exists', async () => {
_resetWebSocketResolution();
const resolved = await resolveWebSocketImplementation();
expect(resolved.ctor).toBe((globalThis as any).WebSocket);
expect(resolved.supportsHeaders).toBe(true); // Node's undici accepts { headers }
});
it('fails with an actionable error when no implementation exists', async () => {
const previous = (globalThis as any).WebSocket;
delete (globalThis as any).WebSocket;
_resetWebSocketResolution();
try {
// Force the `ws` fallback to report "not installed". `ws` is reachable
// as a transitive dependency in this repo, so absence cannot be assumed;
// the injected loader reproduces a clean runtime where it is missing.
const notInstalled = () => {
const error: any = new Error("Cannot find module 'ws'");
error.code = 'ERR_MODULE_NOT_FOUND';
return Promise.reject(error);
};
await expect(
resolveWebSocketImplementation(undefined, notInstalled)
).rejects.toThrow(/requires a WebSocket implementation/);
} finally {
(globalThis as any).WebSocket = previous;
_resetWebSocketResolution();
}
});
it('falls back to the ws package when no global WebSocket exists', async () => {
const previous = (globalThis as any).WebSocket;
delete (globalThis as any).WebSocket;
_resetWebSocketResolution();
try {
class FakeWs {
send() {}
close() {}
addEventListener() {}
}
const resolved = await resolveWebSocketImplementation(undefined, () =>
Promise.resolve({ WebSocket: FakeWs })
);
expect(resolved.ctor).toBe(FakeWs as any);
expect(resolved.source).toBe("'ws' package");
expect(resolved.supportsHeaders).toBe(true);
} finally {
(globalThis as any).WebSocket = previous;
_resetWebSocketResolution();
}
});
it('an injected constructor is used and connect() still rejects if it fails', async () => {
const previous = (globalThis as any).WebSocket;
delete (globalThis as any).WebSocket;
_resetWebSocketResolution();
try {
const listeners: Record<string, Array<(event: any) => void>> = {};
class ExplodingSocket {
readyState = 0;
constructor(public url: string, public options: any) {
setTimeout(() => {
(listeners['error'] ?? []).forEach((fn) => fn({ message: 'handshake refused' }));
}, 0);
}
addEventListener(type: string, fn: (event: any) => void) {
(listeners[type] ??= []).push(fn);
}
send() {
throw new Error('should never be called');
}
close() {}
}
const agent = new RealtimeAgent({
...QUIET,
apiKey: 'sk-test',
url: 'ws://injected/endpoint',
webSocket: ExplodingSocket as any,
});
await expect(agent.connect()).rejects.toThrow(/handshake refused/);
expect(agent.isConnected()).toBe(false);
} finally {
(globalThis as any).WebSocket = previous;
_resetWebSocketResolution();
}
});
});
// ==========================================================================
// Browser path: no handshake headers available
// ==========================================================================
describe('browser path (no header support)', () => {
it('sends OpenAI subprotocol credentials when headers cannot be set', async () => {
// A browser WebSocket cannot set handshake headers, so the agent falls
// back to OpenAI's documented subprotocol credentials. Verified here at
// the wire level only; not verified against api.openai.com.
server = await startMinimalWsServer({ acceptSubprotocol: true });
const RealWebSocket = (globalThis as any).WebSocket;
class BrowserLikeWebSocket extends RealWebSocket {}
const agent = new RealtimeAgent({
...QUIET,
apiKey: 'sk-browser',
url: server.url,
webSocket: undefined,
});
// Force the no-header branch by pretending we are not in Node.
const previousProcess = (globalThis as any).process;
(globalThis as any).process = { ...previousProcess, versions: {} };
(globalThis as any).WebSocket = BrowserLikeWebSocket;
_resetWebSocketResolution();
try {
await agent.connect();
const connection = await server.nextConnection();
expect(connection.headers['authorization']).toBeUndefined();
expect(String(connection.headers['sec-websocket-protocol'])).toContain(
'openai-insecure-api-key.sk-browser'
);
expect(String(connection.headers['sec-websocket-protocol'])).toContain(
'openai-beta.realtime-v1'
);
await agent.disconnect();
} finally {
(globalThis as any).process = previousProcess;
(globalThis as any).WebSocket = RealWebSocket;
_resetWebSocketResolution();
}
});
});
});
@@ -259,16 +259,26 @@ describe('RealtimeAgent (Python Parity)', () => {
expect(typeof agent.off).toBe('function');
});
it('should emit events on connect', async () => {
const agent = new RealtimeAgent({});
// connect() used to set connected = true and emit a fabricated
// 'session.created' without opening any socket, so this suite asserted the
// bug. Events now come only from a real server; see
// tests/unit/agent/realtime-websocket.test.ts for the full coverage.
it('should not emit session.created when the connection fails', async () => {
const agent = new RealtimeAgent({
verbose: false,
apiKey: 'sk-invalid',
url: 'ws://127.0.0.1:9/v1/realtime',
connectTimeoutMs: 2000,
});
let eventReceived = false;
agent.on('session.created', () => {
eventReceived = true;
});
await agent.connect();
expect(eventReceived).toBe(true);
await expect(agent.connect()).rejects.toThrow();
expect(eventReceived).toBe(false);
expect(agent.isConnected()).toBe(false);
});
});
@@ -0,0 +1,597 @@
/**
* Does `db("sqlite:./data.db")` actually persist?
*
* The whole point of this suite is the process boundary: every durability
* assertion reads back through a SECOND adapter instance opened on the same
* file, never through the instance that did the writing. An adapter that had
* quietly degraded to in-process Maps would pass a single-instance test and
* fail every one of these.
*
* If no SQLite driver can be loaded the durability tests are SKIPPED, and the
* skip is made impossible to read as a pass: the suite name says so, a banner
* naming the driver failure is printed, and `PRAISONAI_REQUIRE_SQLITE=1` turns
* the skip into a hard failure for CI.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { db } from '../../../src/db';
import { MemoryDbAdapter } from '../../../src/db/memory-adapter';
import {
SqliteDbAdapter,
createSqliteDbAdapter,
SQLITE_SCHEMA_VERSION,
} from '../../../src/db/sqlite-adapter';
import type { DbMessage, DbRun, DbSession } from '../../../src/db/types';
// ---------------------------------------------------------------------------
// Driver probe -- decides whether the durability tests can run at all
// ---------------------------------------------------------------------------
interface Probe {
available: boolean;
driver?: 'better-sqlite3' | 'node:sqlite';
failures: string[];
}
function firstLines(error: unknown): string {
return error instanceof Error
? error.message.split('\n').slice(0, 2).join(' ')
: String(error);
}
function probeDrivers(): Probe {
const failures: string[] = [];
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Database = require('better-sqlite3');
// The ABI mismatch surfaces here, not at require().
new Database(':memory:').close();
return { available: true, driver: 'better-sqlite3', failures };
} catch (error) {
failures.push(`better-sqlite3: ${firstLines(error)}`);
}
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { DatabaseSync } = require('node:sqlite');
new DatabaseSync(':memory:').close();
return { available: true, driver: 'node:sqlite', failures };
} catch (error) {
failures.push(`node:sqlite: ${firstLines(error)}`);
}
return { available: false, failures };
}
const probe = probeDrivers();
if (!probe.available) {
const banner =
'\n' +
'='.repeat(78) +
'\nSQLITE DURABILITY TESTS SKIPPED -- THIS IS NOT A PASS.\n' +
'No SQLite driver could open a database on this machine:\n' +
probe.failures.map((f) => ` - ${f}`).join('\n') +
'\nA NODE_MODULE_VERSION / ERR_DLOPEN_FAILED message above means the\n' +
"better-sqlite3 native binding was built for a different Node ABI:\n" +
'run `npm rebuild better-sqlite3` under the Node version in use, or run\n' +
'Node >= 22.5 so the built-in node:sqlite can take over.\n' +
'Set PRAISONAI_REQUIRE_SQLITE=1 to make this a hard failure instead.\n' +
'='.repeat(78) +
'\n';
// eslint-disable-next-line no-console
console.warn(banner);
if (process.env.PRAISONAI_REQUIRE_SQLITE === '1') {
throw new Error(
`PRAISONAI_REQUIRE_SQLITE=1 but no SQLite driver is usable: ${probe.failures.join(' | ')}`,
);
}
}
const describeSqlite = probe.available ? describe : describe.skip;
const sqliteSuiteName = probe.available
? `SqliteDbAdapter durability (driver: ${probe.driver})`
: 'SqliteDbAdapter durability -- SKIPPED, NOT PASSED (no usable SQLite driver)';
/** Open the file directly, bypassing the adapter, to inspect the real schema. */
function openRaw(file: string): { all(sql: string): any[]; close(): void } {
if (probe.driver === 'better-sqlite3') {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Database = require('better-sqlite3');
const handle = new Database(file);
return { all: (sql: string) => handle.prepare(sql).all(), close: () => handle.close() };
}
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { DatabaseSync } = require('node:sqlite');
const handle = new DatabaseSync(file);
return { all: (sql: string) => handle.prepare(sql).all(), close: () => handle.close() };
}
function execRaw(file: string, sql: string): void {
if (probe.driver === 'better-sqlite3') {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Database = require('better-sqlite3');
const handle = new Database(file);
handle.exec(sql);
handle.close();
return;
}
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { DatabaseSync } = require('node:sqlite');
const handle = new DatabaseSync(file);
handle.exec(sql);
handle.close();
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
let tmpDir: string;
let fileCounter = 0;
function tmpFile(name = 'data'): string {
fileCounter += 1;
return path.join(tmpDir, `${name}-${fileCounter}.db`);
}
function message(overrides: Partial<DbMessage> & Pick<DbMessage, 'id' | 'sessionId'>): DbMessage {
return {
role: 'user',
content: 'hello',
createdAt: Date.now(),
...overrides,
} as DbMessage;
}
beforeAll(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'praisonai-sqlite-db-'));
});
afterAll(() => {
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
});
// ---------------------------------------------------------------------------
// The real thing
// ---------------------------------------------------------------------------
describeSqlite(sqliteSuiteName, () => {
it('a session and its messages survive into a NEW adapter on the same file', async () => {
const file = tmpFile('roundtrip');
const sessionId = 'session-round-trip';
const writer = new SqliteDbAdapter({ filename: file });
await writer.connect();
const session: DbSession = {
id: sessionId,
createdAt: 1_700_000_000_000,
updatedAt: 1_700_000_000_000,
metadata: { user: 'ada', tags: ['a', 'b'] },
};
await writer.createSession(session);
await writer.saveMessage(
message({ id: 'm1', sessionId, role: 'system', content: 'You are helpful', createdAt: 1 }),
);
await writer.saveMessage(
message({ id: 'm2', sessionId, role: 'user', content: 'Hello!', createdAt: 2 }),
);
await writer.saveMessage(
message({
id: 'm3',
sessionId,
role: 'assistant',
content: 'Hi there',
createdAt: 3,
runId: 'run-1',
metadata: { finish: 'stop' },
}),
);
await writer.disconnect();
// Everything below reads through a different instance. Nothing is shared
// in process; if the file were not real, this would come back empty.
const reader = new SqliteDbAdapter({ filename: file });
const restoredSession = await reader.getSession(sessionId);
expect(restoredSession).toEqual(session);
const messages = await reader.getMessages(sessionId);
expect(messages.map((m) => [m.role, m.content])).toEqual([
['system', 'You are helpful'],
['user', 'Hello!'],
['assistant', 'Hi there'],
]);
expect(messages[2].runId).toBe('run-1');
expect(messages[2].metadata).toEqual({ finish: 'stop' });
// Fields that were never set come back undefined, not null.
expect(messages[0].runId).toBeUndefined();
expect(messages[0].metadata).toBeUndefined();
await reader.disconnect();
});
it('records a run, updates it, and lists it back from a new instance', async () => {
const file = tmpFile('runs');
const sessionId = 'session-runs';
const writer = new SqliteDbAdapter({ filename: file });
const run: DbRun = {
id: 'run-a',
sessionId,
agentName: 'researcher',
status: 'running',
startedAt: 10,
};
await writer.createRun(run);
await writer.createRun({ id: 'run-b', sessionId, status: 'pending', startedAt: 20 });
await writer.createRun({ id: 'other', sessionId: 'elsewhere', status: 'pending', startedAt: 30 });
await writer.updateRun('run-a', {
status: 'completed',
completedAt: 15,
tokenUsage: { promptTokens: 11, completionTokens: 22, totalTokens: 33 },
metadata: { model: 'gpt-4o-mini' },
});
await writer.saveToolCall({
id: 'tc-1',
runId: 'run-a',
name: 'get_weather',
arguments: '{"city":"Paris"}',
result: '20C',
status: 'completed',
startedAt: 11,
completedAt: 12,
});
await writer.disconnect();
const reader = new SqliteDbAdapter({ filename: file });
const runs = await reader.listRuns(sessionId);
expect(runs.map((r) => r.id)).toEqual(['run-a', 'run-b']); // other session excluded
const reloaded = await reader.getRun('run-a');
expect(reloaded).toEqual({
id: 'run-a',
sessionId,
agentName: 'researcher',
status: 'completed',
startedAt: 10,
completedAt: 15,
error: undefined,
metadata: { model: 'gpt-4o-mini' },
tokenUsage: { promptTokens: 11, completionTokens: 22, totalTokens: 33 },
});
const toolCalls = await reader.getToolCalls('run-a');
expect(toolCalls).toHaveLength(1);
expect(toolCalls[0].name).toBe('get_weather');
expect(toolCalls[0].result).toBe('20C');
await reader.disconnect();
});
it('persists traces and spans across instances', async () => {
const file = tmpFile('traces');
const writer = new SqliteDbAdapter({ filename: file });
await writer.createTrace({
id: 'trace-1',
sessionId: 'session-trace',
runId: 'run-1',
status: 'running',
startedAt: 5,
});
await writer.createSpan({
id: 'span-1',
traceId: 'trace-1',
name: 'llm.call',
status: 'running',
startedAt: 6,
attributes: { model: 'gpt-4o-mini' },
});
await writer.updateSpan('span-1', { status: 'completed', completedAt: 7 });
await writer.updateTrace('trace-1', { status: 'completed', completedAt: 8 });
await writer.disconnect();
const reader = new SqliteDbAdapter({ filename: file });
const trace = await reader.getTrace('trace-1');
expect(trace?.status).toBe('completed');
expect(trace?.completedAt).toBe(8);
const spans = await reader.getSpans('trace-1');
expect(spans).toHaveLength(1);
expect(spans[0].status).toBe('completed');
expect(spans[0].attributes).toEqual({ model: 'gpt-4o-mini' });
await reader.disconnect();
});
it('getMessages(limit) returns the LAST n in chronological order, like MemoryDbAdapter', async () => {
const file = tmpFile('limit');
const sessionId = 's';
const sqlite = new SqliteDbAdapter({ filename: file });
const memory = new MemoryDbAdapter();
for (let i = 1; i <= 5; i += 1) {
const m = message({ id: `m${i}`, sessionId, content: `msg-${i}`, createdAt: i });
await sqlite.saveMessage(m);
await memory.saveMessage(m);
}
const fromSqlite = (await sqlite.getMessages(sessionId, 2)).map((m) => m.content);
const fromMemory = (await memory.getMessages(sessionId, 2)).map((m) => m.content);
expect(fromSqlite).toEqual(['msg-4', 'msg-5']);
expect(fromSqlite).toEqual(fromMemory);
await sqlite.disconnect();
});
it('orders messages written in the same millisecond by insertion, not at random', async () => {
const file = tmpFile('sameclock');
const sessionId = 's';
const adapter = new SqliteDbAdapter({ filename: file });
const stamp = 1_700_000_000_000;
for (const id of ['a', 'b', 'c', 'd']) {
await adapter.saveMessage(message({ id, sessionId, content: id, createdAt: stamp }));
}
await adapter.disconnect();
const reader = new SqliteDbAdapter({ filename: file });
expect((await reader.getMessages(sessionId)).map((m) => m.content)).toEqual([
'a',
'b',
'c',
'd',
]);
expect((await reader.getMessages(sessionId, 2)).map((m) => m.content)).toEqual(['c', 'd']);
await reader.disconnect();
});
it('deletes messages and sessions durably', async () => {
const file = tmpFile('delete');
const writer = new SqliteDbAdapter({ filename: file });
await writer.createSession({ id: 's1', createdAt: 1, updatedAt: 1 });
await writer.createSession({ id: 's2', createdAt: 2, updatedAt: 2 });
await writer.saveMessage(message({ id: 'm1', sessionId: 's1', createdAt: 1 }));
await writer.saveMessage(message({ id: 'm2', sessionId: 's2', createdAt: 2 }));
await writer.deleteMessages('s1');
await writer.deleteSession('s2');
await writer.disconnect();
const reader = new SqliteDbAdapter({ filename: file });
expect(await reader.getMessages('s1')).toEqual([]);
expect(await reader.getSession('s1')).not.toBeNull();
expect(await reader.getSession('s2')).toBeNull();
expect(await reader.getMessages('s2')).toEqual([]);
expect((await reader.listSessions()).map((s) => s.id)).toEqual(['s1']);
await reader.disconnect();
});
it('updateSession stamps updatedAt and keeps createdAt, as MemoryDbAdapter does', async () => {
const file = tmpFile('update-session');
const adapter = new SqliteDbAdapter({ filename: file });
await adapter.createSession({ id: 's', createdAt: 100, updatedAt: 100 });
await adapter.updateSession('s', { metadata: { title: 'renamed' } });
const session = await adapter.getSession('s');
expect(session?.createdAt).toBe(100);
expect(session?.metadata).toEqual({ title: 'renamed' });
expect(session!.updatedAt).toBeGreaterThan(100);
await adapter.disconnect();
});
it('writes the documented schema to the file', async () => {
const file = tmpFile('schema');
const adapter = new SqliteDbAdapter({ filename: file });
await adapter.connect();
expect(await adapter.getSchemaVersion()).toBe(SQLITE_SCHEMA_VERSION);
await adapter.disconnect();
const raw = openRaw(file);
try {
const tables = raw
.all("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")
.map((r: any) => String(r.name))
.sort();
expect(tables).toEqual([
'messages',
'praisonai_meta',
'runs',
'sessions',
'spans',
'tool_calls',
'traces',
]);
const columns = (table: string) =>
raw.all(`PRAGMA table_info(${table})`).map((r: any) => String(r.name));
expect(columns('sessions')).toEqual(['id', 'created_at', 'updated_at', 'metadata']);
expect(columns('messages')).toEqual([
'id',
'session_id',
'run_id',
'role',
'content',
'name',
'tool_call_id',
'tool_calls',
'created_at',
'metadata',
]);
expect(columns('runs')).toEqual([
'id',
'session_id',
'agent_name',
'status',
'started_at',
'completed_at',
'error',
'metadata',
'prompt_tokens',
'completion_tokens',
'total_tokens',
]);
expect(columns('tool_calls')).toEqual([
'id',
'run_id',
'name',
'arguments',
'result',
'status',
'started_at',
'completed_at',
'error',
]);
expect(columns('traces')).toEqual([
'id',
'session_id',
'run_id',
'agent_name',
'started_at',
'completed_at',
'status',
'metadata',
]);
expect(columns('spans')).toEqual([
'id',
'trace_id',
'parent_id',
'name',
'started_at',
'completed_at',
'status',
'attributes',
]);
} finally {
raw.close();
}
});
it('diagnoses a file whose tables were written with an incompatible shape', async () => {
const file = tmpFile('legacy');
// The shape the low-level SQLiteAdapter in src/db/sqlite.ts writes: `runs`
// has agent_id, not agent_name. CREATE TABLE IF NOT EXISTS would leave it
// alone and the first INSERT would explode inside the driver.
execRaw(
file,
'CREATE TABLE runs (id TEXT PRIMARY KEY, session_id TEXT, agent_id TEXT, status TEXT, ' +
'input TEXT, output TEXT, error TEXT, started_at INTEGER, completed_at INTEGER, metadata TEXT);',
);
const adapter = new SqliteDbAdapter({ filename: file });
await expect(adapter.connect()).rejects.toThrow(/incompatible shape.*agent_name/s);
expect(adapter.isConnected()).toBe(false);
});
it('diagnoses a partial schema missing a column only an INSERT touches', async () => {
const file = tmpFile('partial');
// A `spans` table with the NOT NULL core but no `attributes` column. The
// core-only check used to pass this file, then the first saveSpan() would
// fail deep in the driver on "table spans has no column named attributes".
// The full-column check must catch it at open().
execRaw(
file,
'CREATE TABLE spans (id TEXT PRIMARY KEY, trace_id TEXT NOT NULL, ' +
'parent_id TEXT, name TEXT NOT NULL, started_at INTEGER NOT NULL, ' +
'completed_at INTEGER, status TEXT NOT NULL);',
);
const adapter = new SqliteDbAdapter({ filename: file });
await expect(adapter.connect()).rejects.toThrow(/incompatible shape.*attributes/s);
expect(adapter.isConnected()).toBe(false);
});
it('db("sqlite:<file>") is the durable adapter, end to end', async () => {
const file = tmpFile('factory');
const writing = db(`sqlite:${file}`);
expect(writing).toBeInstanceOf(SqliteDbAdapter);
await writing.saveMessage(message({ id: 'm1', sessionId: 'via-factory', content: 'kept' }));
await writing.disconnect();
const reading = db(`sqlite:${file}`);
const messages = await reading.getMessages('via-factory');
expect(messages.map((m) => m.content)).toEqual(['kept']);
await reading.disconnect();
});
it('sqlite ":memory:" is real SQLite but, like memory:, is process-local', async () => {
const a = createSqliteDbAdapter(':memory:');
await a.saveMessage(message({ id: 'm1', sessionId: 's', content: 'ephemeral' }));
expect((await a.getMessages('s')).map((m) => m.content)).toEqual(['ephemeral']);
const b = createSqliteDbAdapter(':memory:');
expect(await b.getMessages('s')).toEqual([]);
await a.disconnect();
await b.disconnect();
});
});
// ---------------------------------------------------------------------------
// Controls -- these run on every machine, driver or not
// ---------------------------------------------------------------------------
describe('db() controls', () => {
it('db("memory:") still works and is NOT persistent across instances', async () => {
const first = db('memory:');
expect(first).toBeInstanceOf(MemoryDbAdapter);
await first.createSession({ id: 's', createdAt: 1, updatedAt: 1 });
await first.saveMessage(message({ id: 'm1', sessionId: 's', content: 'gone soon' }));
expect((await first.getMessages('s')).map((m) => m.content)).toEqual(['gone soon']);
// A second adapter is a second set of Maps: nothing crosses between them,
// which is exactly why sqlite had to exist.
const second = db('memory:');
expect(await second.getSession('s')).toBeNull();
expect(await second.getMessages('s')).toEqual([]);
});
it('postgres:// and redis:// still throw, naming what to use instead', () => {
expect(() => db('postgres://user:pass@localhost:5432/app')).toThrow(
/not yet wired to the DbAdapter contract/,
);
expect(() => db('postgres://user:pass@localhost:5432/app')).toThrow(/db\("sqlite:\.\/data\.db"\)/);
expect(() => db('redis://localhost:6379')).toThrow(/not yet wired to the DbAdapter contract/);
});
it('an unusable driver produces a clear error and NEVER a silent memory fallback', async () => {
const file = path.join(tmpDir, 'never-created.db');
const adapter = new SqliteDbAdapter({
filename: file,
// Exactly what better-sqlite3 throws when its prebuilt binding was
// compiled against a different Node ABI.
driverLoader: async () => {
throw new Error(
"The module '/x/better_sqlite3.node' was compiled against a different Node.js " +
'version using NODE_MODULE_VERSION 141. This version of Node.js requires ' +
'NODE_MODULE_VERSION 127. (ERR_DLOPEN_FAILED)',
);
},
});
await expect(adapter.connect()).rejects.toThrow(/NODE_MODULE_VERSION/);
await expect(adapter.connect()).rejects.toThrow(/npm rebuild better-sqlite3/);
// A write must FAIL, not appear to succeed against memory...
await expect(
adapter.saveMessage(message({ id: 'm1', sessionId: 's', content: 'must not vanish' })),
).rejects.toThrow(/SQLite persistence is unavailable/);
// ...and a read must FAIL rather than return a confident empty list.
await expect(adapter.getMessages('s')).rejects.toThrow(/SQLite persistence is unavailable/);
expect(adapter.isConnected()).toBe(false);
expect(fs.existsSync(file)).toBe(false);
});
it('better-sqlite3, pinned, either works or explains how to fix its native binding', async () => {
const file = path.join(tmpDir, 'pinned-better.db');
const adapter = new SqliteDbAdapter({ filename: file, driver: 'better-sqlite3' });
let failure: Error | null = null;
try {
await adapter.connect();
} catch (error) {
failure = error as Error;
}
if (failure) {
// This is the state of this checkout today: the prebuilt binding does not
// match the running Node ABI. The contract is that the message says so
// and says what to do, instead of the adapter pretending to persist.
expect(failure.message).toMatch(/better-sqlite3/);
expect(failure.message).toMatch(/npm rebuild better-sqlite3|Install the driver/);
expect(failure.message).toMatch(/does not survive the process/);
expect(adapter.isConnected()).toBe(false);
} else {
expect(adapter.getDriverName()).toBe('better-sqlite3');
await adapter.saveMessage(message({ id: 'm1', sessionId: 's', content: 'ok' }));
expect((await adapter.getMessages('s')).map((m) => m.content)).toEqual(['ok']);
}
await adapter.disconnect();
});
});