chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
@@ -0,0 +1,790 @@
import { describe, it, expect, afterEach } from "vitest";
import { SqliteAgentRunner } from "..";
import {
AbstractAgent,
BaseEvent,
EventType,
Message,
RunAgentInput,
RunErrorEvent,
RunFinishedEvent,
RunStartedEvent,
} from "@ag-ui/client";
import { EMPTY, Subscription, firstValueFrom, from } from "rxjs";
import { toArray } from "rxjs/operators";
type RunCallbacks = {
onEvent: (event: { event: BaseEvent }) => void;
onNewMessage?: (args: { message: Message }) => void;
onRunStartedEvent?: () => void;
};
const createdRunners: SqliteAgentRunner[] = [];
afterEach(() => {
while (createdRunners.length > 0) {
const runner = createdRunners.pop();
runner?.close();
}
});
function createRunner(): SqliteAgentRunner {
const runner = new SqliteAgentRunner();
createdRunners.push(runner);
return runner;
}
interface EmitAgentOptions {
events?: BaseEvent[];
emitDefaultRunStarted?: boolean;
includeRunFinished?: boolean;
runFinishedEvent?: RunFinishedEvent;
afterEvent?: (args: {
event: BaseEvent;
index: number;
}) => void | Promise<void>;
}
class EmitAgent extends AbstractAgent {
constructor(private readonly options: EmitAgentOptions = {}) {
super();
}
async runAgent(input: RunAgentInput, callbacks: RunCallbacks): Promise<void> {
const {
emitDefaultRunStarted = true,
includeRunFinished = true,
runFinishedEvent,
afterEvent,
} = this.options;
const scriptedEvents = this.options.events ?? [];
let index = 0;
const emit = async (event: BaseEvent) => {
callbacks.onEvent({ event });
if (event.type === EventType.RUN_STARTED) {
callbacks.onRunStartedEvent?.();
}
await afterEvent?.({ event, index });
index += 1;
};
if (emitDefaultRunStarted) {
const runStarted: RunStartedEvent = {
type: EventType.RUN_STARTED,
threadId: input.threadId,
runId: input.runId,
parentRunId: input.parentRunId,
};
await emit(runStarted);
}
for (const event of scriptedEvents) {
await emit(event);
}
const hasRunFinishedEvent =
scriptedEvents.some((event) => event.type === EventType.RUN_FINISHED) ||
runFinishedEvent?.type === EventType.RUN_FINISHED;
if (includeRunFinished && !hasRunFinishedEvent) {
const finishEvent: RunFinishedEvent = runFinishedEvent ?? {
type: EventType.RUN_FINISHED,
threadId: input.threadId,
runId: input.runId,
};
await emit(finishEvent);
}
}
clone(): AbstractAgent {
return new EmitAgent({
...this.options,
events: this.options.events ? [...this.options.events] : undefined,
});
}
protected run(): ReturnType<AbstractAgent["run"]> {
return EMPTY;
}
protected connect(): ReturnType<AbstractAgent["connect"]> {
return EMPTY;
}
}
class ReplayAgent extends AbstractAgent {
constructor(
private readonly replayEvents: BaseEvent[],
threadId: string,
) {
super({ threadId });
}
async runAgent(): Promise<void> {
throw new Error("not used");
}
protected run(): ReturnType<AbstractAgent["run"]> {
return EMPTY;
}
protected connect(): ReturnType<AbstractAgent["connect"]> {
return from(this.replayEvents);
}
}
class RunnerConnectAgent extends AbstractAgent {
constructor(
private readonly runner: SqliteAgentRunner,
threadId: string,
) {
super({ threadId });
}
async runAgent(): Promise<void> {
throw new Error("not used");
}
protected run(): ReturnType<AbstractAgent["run"]> {
return EMPTY;
}
protected connect(
input: RunAgentInput,
): ReturnType<AbstractAgent["connect"]> {
return this.runner.connect({ threadId: input.threadId });
}
}
type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
};
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
async function collectEvents(
observable:
| ReturnType<SqliteAgentRunner["run"]>
| ReturnType<SqliteAgentRunner["connect"]>,
) {
return firstValueFrom(observable.pipe(toArray()));
}
function createRunInput({
threadId,
runId,
messages,
state,
parentRunId,
}: {
threadId: string;
runId: string;
messages: Message[];
state?: Record<string, unknown>;
parentRunId?: string | null;
}): RunAgentInput {
return {
threadId,
runId,
parentRunId: parentRunId ?? undefined,
state: state ?? {},
messages,
tools: [],
context: [],
forwardedProps: undefined,
};
}
function expectRunStartedEvent(event: BaseEvent, expectedMessages: Message[]) {
expect(event.type).toBe(EventType.RUN_STARTED);
const runStarted = event as RunStartedEvent;
expect(runStarted.input?.messages).toEqual(expectedMessages);
}
function createTextMessageEvents({
messageId,
role = "assistant",
content,
}: {
messageId: string;
role?: "assistant" | "developer" | "system" | "user";
content: string;
}): BaseEvent[] {
return [
{
type: EventType.TEXT_MESSAGE_START,
messageId,
role,
},
{
type: EventType.TEXT_MESSAGE_CONTENT,
messageId,
delta: content,
},
{
type: EventType.TEXT_MESSAGE_END,
messageId,
},
] as BaseEvent[];
}
function createToolCallEvents({
toolCallId,
parentMessageId,
toolName,
argsJson,
resultMessageId,
resultContent,
}: {
toolCallId: string;
parentMessageId: string;
toolName: string;
argsJson: string;
resultMessageId: string;
resultContent: string;
}): BaseEvent[] {
return [
{
type: EventType.TOOL_CALL_START,
toolCallId,
toolCallName: toolName,
parentMessageId,
},
{
type: EventType.TOOL_CALL_ARGS,
toolCallId,
delta: argsJson,
},
{
type: EventType.TOOL_CALL_END,
toolCallId,
},
{
type: EventType.TOOL_CALL_RESULT,
toolCallId,
messageId: resultMessageId,
content: resultContent,
role: "tool",
},
] as BaseEvent[];
}
describe("SqliteAgentRunner e2e", () => {
describe("Fresh Replay After Single Run", () => {
it("replays sanitized message history on connectAgent", async () => {
const runner = createRunner();
const threadId = "thread-fresh-replay";
const existingMessage: Message = {
id: "message-existing",
role: "user",
content: "Hello there",
};
const runEvents = await collectEvents(
runner.run({
threadId,
agent: new EmitAgent(),
input: createRunInput({
threadId,
runId: "run-0",
messages: [existingMessage],
}),
}),
);
expectRunStartedEvent(runEvents[0], [existingMessage]);
expect(runEvents.at(-1)?.type).toBe(EventType.RUN_FINISHED);
const replayEvents = await collectEvents(runner.connect({ threadId }));
const replayAgent = new ReplayAgent(replayEvents, threadId);
await replayAgent.connectAgent({ runId: "replay-run" });
expect(replayAgent.messages).toEqual([existingMessage]);
});
});
describe("New Messages on Subsequent Runs", () => {
it("merges new message IDs without duplicating history", async () => {
const runner = createRunner();
const threadId = "thread-subsequent-runs";
const existingMessage: Message = {
id: "msg-existing",
role: "user",
content: "First turn",
};
const initialRunEvents = await collectEvents(
runner.run({
threadId,
agent: new EmitAgent(),
input: createRunInput({
threadId,
runId: "run-0",
messages: [existingMessage],
}),
}),
);
expectRunStartedEvent(initialRunEvents[0], [existingMessage]);
const newMessage: Message = {
id: "msg-new",
role: "user",
content: "Second turn",
};
const secondRunEvents = await collectEvents(
runner.run({
threadId,
agent: new EmitAgent(),
input: createRunInput({
threadId,
runId: "run-1",
messages: [existingMessage, newMessage],
}),
}),
);
expectRunStartedEvent(secondRunEvents[0], [newMessage]);
const replayEvents = await collectEvents(runner.connect({ threadId }));
const replayAgent = new ReplayAgent(replayEvents, threadId);
await replayAgent.connectAgent({ runId: "replay-run" });
expect(replayAgent.messages).toEqual([existingMessage, newMessage]);
expect(
new Set(replayAgent.messages.map((message) => message.id)).size,
).toBe(replayAgent.messages.length);
});
});
describe("Fresh Agent Connection After Prior Runs", () => {
it("hydrates a brand-new agent via connect()", async () => {
const runner = createRunner();
const threadId = "thread-new-agent-connection";
const existingMessage: Message = {
id: "existing-connection",
role: "user",
content: "Persist me",
};
const runEvents = await collectEvents(
runner.run({
threadId,
agent: new EmitAgent(),
input: createRunInput({
threadId,
runId: "run-0",
messages: [existingMessage],
}),
}),
);
expectRunStartedEvent(runEvents[0], [existingMessage]);
const connectingAgent = new RunnerConnectAgent(runner, threadId);
await connectingAgent.connectAgent({ runId: "connect-run" });
expect(connectingAgent.messages).toEqual([existingMessage]);
});
});
describe("Mixed Roles and Tool Results", () => {
it("preserves agent-emitted tool events alongside heterogeneous inputs", async () => {
const runner = createRunner();
const threadId = "thread-mixed-roles";
const systemMessage: Message = {
id: "sys-1",
role: "system",
content: "Global directive",
};
const developerMessage: Message = {
id: "dev-1",
role: "developer",
content: "Internal guidance",
};
const userMessage: Message = {
id: "user-1",
role: "user",
content: "Need the weather",
};
const baseMessages = [systemMessage, developerMessage, userMessage];
const assistantMessageId = "assistant-1";
const toolCallId = "tool-call-1";
const toolMessageId = "tool-msg-1";
const agentEvents: BaseEvent[] = [
...createTextMessageEvents({
messageId: assistantMessageId,
content: "Calling the weather tool",
}),
...createToolCallEvents({
toolCallId,
parentMessageId: assistantMessageId,
toolName: "getWeather",
argsJson: '{"location":"NYC"}',
resultMessageId: toolMessageId,
resultContent: '{"temp":72}',
}),
];
const runEvents = await collectEvents(
runner.run({
threadId,
agent: new EmitAgent({ events: agentEvents }),
input: createRunInput({
threadId,
runId: "run-0",
messages: baseMessages,
}),
}),
);
expectRunStartedEvent(runEvents[0], baseMessages);
expect(
runEvents.filter((event) => event.type === EventType.TOOL_CALL_RESULT),
).toHaveLength(1);
const replayEvents = await collectEvents(runner.connect({ threadId }));
const replayAgent = new ReplayAgent(replayEvents, threadId);
await replayAgent.connectAgent({ runId: "replay-run" });
expect(replayAgent.messages).toEqual([
systemMessage,
developerMessage,
userMessage,
{
id: assistantMessageId,
role: "assistant",
content: "Calling the weather tool",
toolCalls: [
{
id: toolCallId,
type: "function",
function: {
name: "getWeather",
arguments: '{"location":"NYC"}',
},
},
],
},
{
id: toolMessageId,
role: "tool",
content: '{"temp":72}',
toolCallId,
},
]);
expect(
replayAgent.messages.filter((message) => message.role === "tool"),
).toHaveLength(1);
});
});
describe("Multiple Consecutive Runs with Agent Output", () => {
it("deduplicates input history while emitting each agent message once", async () => {
const runner = createRunner();
const threadId = "thread-multi-runs";
const systemMessage: Message = {
id: "system-shared",
role: "system",
content: "System context",
};
const userMessages: Message[] = [];
for (let index = 0; index < 3; index += 1) {
const userMessage: Message = {
id: `user-${index + 1}`,
role: "user",
content: `User message ${index + 1}`,
};
userMessages.push(userMessage);
const messagesForRun = [systemMessage, ...userMessages];
const assistantId = `assistant-${index + 1}`;
const toolCallId = `tool-call-${index + 1}`;
const toolMessageId = `tool-msg-${index + 1}`;
const events: BaseEvent[] = [
...createTextMessageEvents({
messageId: assistantId,
content: `Assistant reply ${index + 1}`,
}),
...createToolCallEvents({
toolCallId,
parentMessageId: assistantId,
toolName: `tool-${index + 1}`,
argsJson: `{"step":${index + 1}}`,
resultMessageId: toolMessageId,
resultContent: `{"ok":${index + 1}}`,
}),
];
const runEvents = await collectEvents(
runner.run({
threadId,
agent: new EmitAgent({ events }),
input: createRunInput({
threadId,
runId: `run-${index}`,
messages: messagesForRun,
}),
}),
);
if (index === 0) {
expectRunStartedEvent(runEvents[0], messagesForRun);
} else {
expectRunStartedEvent(runEvents[0], [userMessage]);
}
expect(runEvents.at(-1)?.type).toBe(EventType.RUN_FINISHED);
}
const replayEvents = await collectEvents(runner.connect({ threadId }));
const replayAgent = new ReplayAgent(replayEvents, threadId);
await replayAgent.connectAgent({ runId: "replay-final" });
const finalMessages = replayAgent.messages;
expect(new Set(finalMessages.map((message) => message.id)).size).toBe(
finalMessages.length,
);
const roleCounts = finalMessages.reduce<Record<string, number>>(
(counts, message) => {
counts[message.role] = (counts[message.role] ?? 0) + 1;
return counts;
},
{},
);
expect(roleCounts.system).toBe(1);
expect(roleCounts.user).toBe(3);
expect(roleCounts.assistant).toBe(3);
expect(roleCounts.tool).toBe(3);
});
});
describe("Agent-Provided RUN_STARTED input", () => {
it("forwards the agent-specified payload without sanitizing", async () => {
const runner = createRunner();
const threadId = "thread-custom-run-started";
const runId = "run-0";
const customMessages: Message[] = [
{
id: "custom-user",
role: "user",
content: "Pre-sent content",
},
];
const customInput: RunAgentInput = {
threadId,
runId,
parentRunId: undefined,
state: { injected: true },
messages: customMessages,
tools: [],
context: [],
forwardedProps: { source: "agent" },
};
const customRunStarted: RunStartedEvent = {
type: EventType.RUN_STARTED,
threadId,
runId,
parentRunId: null,
input: customInput,
};
const agentEvents: BaseEvent[] = [
customRunStarted,
...createTextMessageEvents({
messageId: "agent-message",
content: "Custom start acknowledged",
}),
];
const runEvents = await collectEvents(
runner.run({
threadId,
agent: new EmitAgent({
events: agentEvents,
emitDefaultRunStarted: false,
}),
input: createRunInput({
threadId,
runId,
messages: [],
}),
}),
);
expect(runEvents[0]).toEqual(customRunStarted);
expect(
runEvents.filter((event) => event.type === EventType.RUN_FINISHED),
).toHaveLength(1);
const replayEvents = await collectEvents(runner.connect({ threadId }));
const replayAgent = new ReplayAgent(replayEvents, threadId);
await replayAgent.connectAgent({ runId: "replay-run" });
expect(
replayAgent.messages.find((message) => message.id === "custom-user"),
).toEqual(customMessages[0]);
});
});
describe("Concurrent Connections During Run", () => {
it("streams in-flight events to live subscribers and persists final history", async () => {
const runner = createRunner();
const threadId = "thread-concurrency";
const runId = "run-live";
const initialMessage: Message = {
id: "initial-user",
role: "user",
content: "Start run",
};
const runStartedSignal = createDeferred<void>();
const resumeSignal = createDeferred<void>();
const agent = new EmitAgent({
events: [
...createTextMessageEvents({
messageId: "assistant-live",
content: "Streaming content",
}),
],
afterEvent: async ({ event }) => {
if (event.type === EventType.RUN_STARTED) {
runStartedSignal.resolve();
await resumeSignal.promise;
}
},
});
const runEvents: BaseEvent[] = [];
const run$ = runner.run({
threadId,
agent,
input: createRunInput({
threadId,
runId,
messages: [initialMessage],
}),
});
let runSubscription: Subscription;
const runCompletion = new Promise<void>((resolve, reject) => {
runSubscription = run$.subscribe({
next: (event) => runEvents.push(event),
error: (error) => {
runSubscription.unsubscribe();
reject(error);
},
complete: () => {
runSubscription.unsubscribe();
resolve();
},
});
});
await runStartedSignal.promise;
const liveEvents: BaseEvent[] = [];
const connect$ = runner.connect({ threadId });
let connectSubscription: Subscription;
const connectCompletion = new Promise<void>((resolve, reject) => {
connectSubscription = connect$.subscribe({
next: (event) => liveEvents.push(event),
error: (error) => {
connectSubscription.unsubscribe();
reject(error);
},
complete: () => {
connectSubscription.unsubscribe();
resolve();
},
});
});
resumeSignal.resolve();
await Promise.all([runCompletion, connectCompletion]);
expectRunStartedEvent(runEvents[0], [initialMessage]);
expect(runEvents.at(-1)?.type).toBe(EventType.RUN_FINISHED);
expect(liveEvents).toEqual(runEvents);
const persistedEvents = await collectEvents(runner.connect({ threadId }));
expect(persistedEvents).toEqual(runEvents);
const replayAgent = new ReplayAgent(persistedEvents, threadId);
await replayAgent.connectAgent({ runId: "replay-run" });
expect(replayAgent.messages.map((message) => message.id)).toEqual([
initialMessage.id,
"assistant-live",
]);
});
});
describe("Error Handling", () => {
it("propagates RUN_ERROR while retaining input history", async () => {
const runner = createRunner();
const threadId = "thread-run-error";
const userMessage: Message = {
id: "error-user",
role: "user",
content: "Trigger error",
};
const runErrorEvent: RunErrorEvent = {
type: EventType.RUN_ERROR,
message: "Agent failure",
};
const runEvents = await collectEvents(
runner.run({
threadId,
agent: new EmitAgent({
events: [runErrorEvent],
includeRunFinished: false,
}),
input: createRunInput({
threadId,
runId: "run-error",
messages: [userMessage],
}),
}),
);
expectRunStartedEvent(runEvents[0], [userMessage]);
expect(runEvents.at(-1)).toEqual(runErrorEvent);
const replayEvents = await collectEvents(runner.connect({ threadId }));
const replayAgent = new ReplayAgent(replayEvents, threadId);
const capturedRunErrors: RunErrorEvent[] = [];
const result = await replayAgent.connectAgent(
{ runId: "replay-run" },
{
onRunErrorEvent: ({ event }) => {
capturedRunErrors.push(event);
},
},
);
expect(runEvents.at(-1)?.type).toBe(EventType.RUN_ERROR);
expect(capturedRunErrors).toHaveLength(1);
expect(capturedRunErrors[0]).toMatchObject(runErrorEvent);
expect(result.newMessages).toEqual([userMessage]);
expect(replayAgent.messages).toEqual([userMessage]);
});
});
});
@@ -0,0 +1,450 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { SqliteAgentRunner } from "..";
import {
AbstractAgent,
BaseEvent,
EventType,
Message,
RunAgentInput,
RunFinishedEvent,
RunStartedEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
} from "@ag-ui/client";
import { EMPTY, firstValueFrom } from "rxjs";
import { toArray } from "rxjs/operators";
import Database from "better-sqlite3";
import fs from "fs";
import os from "os";
import path from "path";
type RunCallbacks = {
onEvent: (event: { event: BaseEvent }) => void | Promise<void>;
onNewMessage?: (args: { message: Message }) => void | Promise<void>;
onRunStartedEvent?: () => void | Promise<void>;
};
class MockAgent extends AbstractAgent {
constructor(
private readonly events: BaseEvent[] = [],
private readonly emitDefaultRunStarted = true,
) {
super();
}
async runAgent(input: RunAgentInput, callbacks: RunCallbacks): Promise<void> {
if (this.emitDefaultRunStarted) {
const runStarted: RunStartedEvent = {
type: EventType.RUN_STARTED,
threadId: input.threadId,
runId: input.runId,
};
await callbacks.onEvent({ event: runStarted });
await callbacks.onRunStartedEvent?.();
}
for (const event of this.events) {
await callbacks.onEvent({ event });
}
const hasTerminalEvent = this.events.some(
(event) =>
event.type === EventType.RUN_FINISHED ||
event.type === EventType.RUN_ERROR,
);
if (!hasTerminalEvent) {
const runFinished: RunFinishedEvent = {
type: EventType.RUN_FINISHED,
threadId: input.threadId,
runId: input.runId,
};
await callbacks.onEvent({ event: runFinished });
}
}
protected run(): ReturnType<AbstractAgent["run"]> {
return EMPTY;
}
protected connect(): ReturnType<AbstractAgent["connect"]> {
return EMPTY;
}
clone(): AbstractAgent {
return new MockAgent(this.events, this.emitDefaultRunStarted);
}
}
class StoppableAgent extends AbstractAgent {
private shouldStop = false;
private eventDelay: number;
constructor(eventDelay = 5) {
super();
this.eventDelay = eventDelay;
}
async runAgent(input: RunAgentInput, callbacks: RunCallbacks): Promise<void> {
this.shouldStop = false;
let counter = 0;
const runStarted: RunStartedEvent = {
type: EventType.RUN_STARTED,
threadId: input.threadId,
runId: input.runId,
};
await callbacks.onEvent({ event: runStarted });
await callbacks.onRunStartedEvent?.();
while (!this.shouldStop && counter < 10_000) {
await new Promise((resolve) => setTimeout(resolve, this.eventDelay));
const event: BaseEvent = {
type: EventType.TEXT_MESSAGE_CONTENT,
messageId: `sqlite-stop-${counter}`,
delta: `chunk-${counter}`,
} as TextMessageContentEvent;
await callbacks.onEvent({ event });
counter += 1;
}
}
abortRun(): void {
this.shouldStop = true;
}
clone(): AbstractAgent {
return new StoppableAgent(this.eventDelay);
}
}
class OpenEventsAgent extends AbstractAgent {
private shouldStop = false;
async runAgent(input: RunAgentInput, callbacks: RunCallbacks): Promise<void> {
this.shouldStop = false;
const messageId = "open-message";
const toolCallId = "open-tool";
await callbacks.onEvent({
event: {
type: EventType.TEXT_MESSAGE_START,
messageId,
role: "assistant",
} as BaseEvent,
});
await callbacks.onEvent({
event: {
type: EventType.TEXT_MESSAGE_CONTENT,
messageId,
delta: "Partial content",
} as BaseEvent,
});
await callbacks.onEvent({
event: {
type: EventType.TOOL_CALL_START,
toolCallId,
toolCallName: "testTool",
parentMessageId: messageId,
} as BaseEvent,
});
while (!this.shouldStop) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
abortRun(): void {
this.shouldStop = true;
}
clone(): AbstractAgent {
return new OpenEventsAgent();
}
}
describe("SqliteAgentRunner", () => {
let tempDir: string;
let dbPath: string;
let runner: SqliteAgentRunner;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "sqlite-runner-test-"));
dbPath = path.join(tempDir, "test.db");
runner = new SqliteAgentRunner({ dbPath });
});
afterEach(() => {
runner.close();
if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath);
if (fs.existsSync(tempDir)) fs.rmdirSync(tempDir);
});
it("emits RUN_STARTED and agent events", async () => {
const threadId = "sqlite-basic";
const agent = new MockAgent([
{
type: EventType.TEXT_MESSAGE_START,
messageId: "msg-1",
role: "assistant",
} as TextMessageStartEvent,
{
type: EventType.TEXT_MESSAGE_CONTENT,
messageId: "msg-1",
delta: "Hello",
} as TextMessageContentEvent,
{
type: EventType.TEXT_MESSAGE_END,
messageId: "msg-1",
} as TextMessageEndEvent,
{
type: EventType.RUN_FINISHED,
threadId,
runId: "run-1",
} as RunFinishedEvent,
]);
const events = await firstValueFrom(
runner
.run({
threadId,
agent,
input: { threadId, runId: "run-1", messages: [], state: {} },
})
.pipe(toArray()),
);
expect(events.map((event) => event.type)).toEqual([
EventType.RUN_STARTED,
EventType.TEXT_MESSAGE_START,
EventType.TEXT_MESSAGE_CONTENT,
EventType.TEXT_MESSAGE_END,
EventType.RUN_FINISHED,
]);
});
it("attaches only new messages on subsequent runs", async () => {
const threadId = "sqlite-new-messages";
const existing: Message = { id: "existing", role: "user", content: "hi" };
await firstValueFrom(
runner
.run({
threadId,
agent: new MockAgent(),
input: { threadId, runId: "run-0", messages: [existing], state: {} },
})
.pipe(toArray()),
);
const newMessage: Message = {
id: "new",
role: "user",
content: "follow up",
};
const secondRun = await firstValueFrom(
runner
.run({
threadId,
agent: new MockAgent(),
input: {
threadId,
runId: "run-1",
messages: [existing, newMessage],
state: { counter: 1 },
},
})
.pipe(toArray()),
);
const runStarted = secondRun[0] as RunStartedEvent;
expect(runStarted.input?.messages?.map((m) => m.id)).toEqual(["new"]);
const db = new Database(dbPath);
const rows = db
.prepare(
"SELECT events FROM agent_runs WHERE thread_id = ? ORDER BY created_at",
)
.all(threadId) as {
events: string;
}[];
db.close();
expect(rows).toHaveLength(2);
const run1Stored = JSON.parse(rows[0].events) as BaseEvent[];
const run2Stored = JSON.parse(rows[1].events) as BaseEvent[];
const run1Started = run1Stored.find(
(event) => event.type === EventType.RUN_STARTED,
) as RunStartedEvent;
expect(run1Started.input?.messages?.map((m) => m.id)).toEqual(["existing"]);
const run2Started = run2Stored.find(
(event) => event.type === EventType.RUN_STARTED,
) as RunStartedEvent;
expect(run2Started.input?.messages?.map((m) => m.id)).toEqual(["new"]);
});
it("preserves agent-provided input", async () => {
const threadId = "sqlite-agent-input";
const providedInput: RunAgentInput = {
threadId,
runId: "run-keep",
messages: [],
state: { fromAgent: true },
};
const agent = new MockAgent(
[
{
type: EventType.RUN_STARTED,
threadId,
runId: "run-keep",
input: providedInput,
} as RunStartedEvent,
{
type: EventType.RUN_FINISHED,
threadId,
runId: "run-keep",
} as RunFinishedEvent,
],
false,
);
const events = await firstValueFrom(
runner
.run({
threadId,
agent,
input: {
threadId,
runId: "run-keep",
messages: [{ id: "ignored", role: "user", content: "hi" }],
state: {},
},
})
.pipe(toArray()),
);
expect(events.map((event) => event.type)).toEqual([
EventType.RUN_STARTED,
EventType.RUN_FINISHED,
]);
const runStarted = events[0] as RunStartedEvent;
expect(runStarted.input).toBe(providedInput);
});
it("persists events across runner instances", async () => {
const threadId = "sqlite-persist";
const agent = new MockAgent([
{
type: EventType.TEXT_MESSAGE_START,
messageId: "msg",
role: "assistant",
} as TextMessageStartEvent,
{
type: EventType.TEXT_MESSAGE_CONTENT,
messageId: "msg",
delta: "hi",
} as TextMessageContentEvent,
{
type: EventType.TEXT_MESSAGE_END,
messageId: "msg",
} as TextMessageEndEvent,
{
type: EventType.RUN_FINISHED,
threadId,
runId: "run-1",
} as RunFinishedEvent,
]);
await firstValueFrom(
runner
.run({
threadId,
agent,
input: { threadId, runId: "run-1", messages: [], state: {} },
})
.pipe(toArray()),
);
const newRunner = new SqliteAgentRunner({ dbPath });
try {
const replayed = await firstValueFrom(
newRunner.connect({ threadId }).pipe(toArray()),
);
expect(replayed[0].type).toBe(EventType.RUN_STARTED);
expect(replayed.slice(1).map((event) => event.type)).toEqual([
EventType.TEXT_MESSAGE_START,
EventType.TEXT_MESSAGE_CONTENT,
EventType.TEXT_MESSAGE_END,
EventType.RUN_FINISHED,
]);
} finally {
newRunner.close();
}
});
it("returns false when stopping a thread that is not running", async () => {
await expect(runner.stop({ threadId: "sqlite-missing" })).resolves.toBe(
false,
);
});
it("stops an active run and completes observables", async () => {
const threadId = "sqlite-stop";
const agent = new StoppableAgent(2);
const input: RunAgentInput = {
threadId,
runId: "sqlite-stop-run",
messages: [],
state: {},
};
const run$ = runner.run({ threadId, agent, input });
const collected = firstValueFrom(run$.pipe(toArray()));
await new Promise((resolve) => setTimeout(resolve, 20));
expect(await runner.isRunning({ threadId })).toBe(true);
const stopped = await runner.stop({ threadId });
expect(stopped).toBe(true);
const events = await collected;
expect(events.length).toBeGreaterThan(0);
expect(events[events.length - 1].type).toBe(EventType.RUN_FINISHED);
expect(await runner.isRunning({ threadId })).toBe(false);
});
it("closes open text and tool events when stopping", async () => {
const threadId = "sqlite-open-events";
const agent = new OpenEventsAgent();
const input: RunAgentInput = {
threadId,
runId: "sqlite-open-run",
messages: [],
state: {},
};
const run$ = runner.run({ threadId, agent, input });
const collected = firstValueFrom(run$.pipe(toArray()));
await new Promise((resolve) => setTimeout(resolve, 20));
await runner.stop({ threadId });
const events = await collected;
const endingTypes = events.slice(-4).map((event) => event.type);
expect(endingTypes).toEqual([
EventType.TEXT_MESSAGE_END,
EventType.TOOL_CALL_END,
EventType.TOOL_CALL_RESULT,
EventType.RUN_FINISHED,
]);
});
});
+1
View File
@@ -0,0 +1 @@
export * from "./sqlite-runner.js";
+517
View File
@@ -0,0 +1,517 @@
import {
AgentRunner,
finalizeRunEvents,
type AgentRunnerConnectRequest,
type AgentRunnerIsRunningRequest,
type AgentRunnerRunRequest,
type AgentRunnerStopRequest,
} from "@copilotkit/runtime/v2";
import { Observable, ReplaySubject } from "rxjs";
import {
AbstractAgent,
BaseEvent,
RunAgentInput,
EventType,
RunStartedEvent,
compactEvents,
} from "@ag-ui/client";
import Database from "better-sqlite3";
const SCHEMA_VERSION = 1;
interface AgentRunRecord {
id: number;
thread_id: string;
run_id: string;
parent_run_id: string | null;
events: BaseEvent[];
input: RunAgentInput;
created_at: number;
version: number;
}
export interface SqliteAgentRunnerOptions {
dbPath?: string;
}
interface ActiveConnectionContext {
subject: ReplaySubject<BaseEvent>;
agent?: AbstractAgent;
runSubject?: ReplaySubject<BaseEvent>;
currentEvents?: BaseEvent[];
stopRequested?: boolean;
}
// Active connections for streaming events and stop support
const ACTIVE_CONNECTIONS = new Map<string, ActiveConnectionContext>();
export class SqliteAgentRunner extends AgentRunner {
private db: any;
constructor(options: SqliteAgentRunnerOptions = {}) {
super();
const dbPath = options.dbPath ?? ":memory:";
if (!Database) {
throw new Error(
"better-sqlite3 is required for SqliteAgentRunner but was not found.\n" +
"Please install it in your project:\n" +
" npm install better-sqlite3\n" +
" or\n" +
" pnpm add better-sqlite3\n" +
" or\n" +
" yarn add better-sqlite3\n\n" +
"If you don't need persistence, use InMemoryAgentRunner instead.",
);
}
this.db = new Database(dbPath);
this.initializeSchema();
}
private initializeSchema(): void {
// Create the agent_runs table
this.db.exec(`
CREATE TABLE IF NOT EXISTS agent_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id TEXT NOT NULL,
run_id TEXT NOT NULL UNIQUE,
parent_run_id TEXT,
events TEXT NOT NULL,
input TEXT NOT NULL,
created_at INTEGER NOT NULL,
version INTEGER NOT NULL
)
`);
// Create run_state table to track active runs
this.db.exec(`
CREATE TABLE IF NOT EXISTS run_state (
thread_id TEXT PRIMARY KEY,
is_running INTEGER DEFAULT 0,
current_run_id TEXT,
updated_at INTEGER NOT NULL
)
`);
// Create indexes for efficient queries
this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_thread_id ON agent_runs(thread_id);
CREATE INDEX IF NOT EXISTS idx_parent_run_id ON agent_runs(parent_run_id);
`);
// Create schema version table
this.db.exec(`
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at INTEGER NOT NULL
)
`);
// Check and set schema version
const currentVersion = this.db
.prepare(
"SELECT version FROM schema_version ORDER BY version DESC LIMIT 1",
)
.get() as { version: number } | undefined;
if (!currentVersion || currentVersion.version < SCHEMA_VERSION) {
this.db
.prepare(
"INSERT OR REPLACE INTO schema_version (version, applied_at) VALUES (?, ?)",
)
.run(SCHEMA_VERSION, Date.now());
}
}
private storeRun(
threadId: string,
runId: string,
events: BaseEvent[],
input: RunAgentInput,
parentRunId?: string | null,
): void {
// Compact ONLY the events from this run
const compactedEvents = compactEvents(events);
const stmt = this.db.prepare(`
INSERT INTO agent_runs (thread_id, run_id, parent_run_id, events, input, created_at, version)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(
threadId,
runId,
parentRunId ?? null,
JSON.stringify(compactedEvents), // Store only this run's compacted events
JSON.stringify(input),
Date.now(),
SCHEMA_VERSION,
);
}
private getHistoricRuns(threadId: string): AgentRunRecord[] {
const stmt = this.db.prepare(`
WITH RECURSIVE run_chain AS (
-- Base case: find the root runs (those without parent)
SELECT * FROM agent_runs
WHERE thread_id = ? AND parent_run_id IS NULL
UNION ALL
-- Recursive case: find children of current level
SELECT ar.* FROM agent_runs ar
INNER JOIN run_chain rc ON ar.parent_run_id = rc.run_id
WHERE ar.thread_id = ?
)
SELECT * FROM run_chain
ORDER BY created_at ASC
`);
const rows = stmt.all(threadId, threadId) as any[];
return rows.map((row) => ({
id: row.id,
thread_id: row.thread_id,
run_id: row.run_id,
parent_run_id: row.parent_run_id,
events: JSON.parse(row.events),
input: JSON.parse(row.input),
created_at: row.created_at,
version: row.version,
}));
}
private getLatestRunId(threadId: string): string | null {
const stmt = this.db.prepare(`
SELECT run_id FROM agent_runs
WHERE thread_id = ?
ORDER BY created_at DESC
LIMIT 1
`);
const result = stmt.get(threadId) as { run_id: string } | undefined;
return result?.run_id ?? null;
}
private setRunState(
threadId: string,
isRunning: boolean,
runId?: string,
): void {
const stmt = this.db.prepare(`
INSERT OR REPLACE INTO run_state (thread_id, is_running, current_run_id, updated_at)
VALUES (?, ?, ?, ?)
`);
stmt.run(threadId, isRunning ? 1 : 0, runId ?? null, Date.now());
}
private getRunState(threadId: string): {
isRunning: boolean;
currentRunId: string | null;
} {
const stmt = this.db.prepare(`
SELECT is_running, current_run_id FROM run_state WHERE thread_id = ?
`);
const result = stmt.get(threadId) as
| { is_running: number; current_run_id: string | null }
| undefined;
return {
isRunning: result?.is_running === 1,
currentRunId: result?.current_run_id ?? null,
};
}
run(request: AgentRunnerRunRequest): Observable<BaseEvent> {
// Check if thread is already running in database
const runState = this.getRunState(request.threadId);
if (runState.isRunning) {
throw new Error("Thread already running");
}
// Mark thread as running in database
this.setRunState(request.threadId, true, request.input.runId);
// Track seen message IDs and current run events in memory for this run
const seenMessageIds = new Set<string>();
const currentRunEvents: BaseEvent[] = [];
// Get all previously seen message IDs from historic runs
const historicRuns = this.getHistoricRuns(request.threadId);
const historicMessageIds = new Set<string>();
for (const run of historicRuns) {
for (const event of run.events) {
if ("messageId" in event && typeof event.messageId === "string") {
historicMessageIds.add(event.messageId);
}
if (event.type === EventType.RUN_STARTED) {
const runStarted = event as RunStartedEvent;
const messages = runStarted.input?.messages ?? [];
for (const message of messages) {
historicMessageIds.add(message.id);
}
}
}
}
// Get or create subject for this thread's connections
const nextSubject = new ReplaySubject<BaseEvent>(Infinity);
const prevConnection = ACTIVE_CONNECTIONS.get(request.threadId);
const prevSubject = prevConnection?.subject;
// Create a subject for run() return value
const runSubject = new ReplaySubject<BaseEvent>(Infinity);
// Update the active connection for this thread
ACTIVE_CONNECTIONS.set(request.threadId, {
subject: nextSubject,
agent: request.agent,
runSubject,
currentEvents: currentRunEvents,
stopRequested: false,
});
// Helper function to run the agent and handle errors
const runAgent = async () => {
// Get parent run ID for chaining
const parentRunId = this.getLatestRunId(request.threadId);
try {
await request.agent.runAgent(request.input, {
onEvent: ({ event }) => {
let processedEvent: BaseEvent = event;
if (event.type === EventType.RUN_STARTED) {
const runStartedEvent = event as RunStartedEvent;
if (!runStartedEvent.input) {
const sanitizedMessages = request.input.messages
? request.input.messages.filter(
(message) => !historicMessageIds.has(message.id),
)
: undefined;
const updatedInput = {
...request.input,
...(sanitizedMessages !== undefined
? { messages: sanitizedMessages }
: {}),
};
processedEvent = {
...runStartedEvent,
input: updatedInput,
} as RunStartedEvent;
}
}
runSubject.next(processedEvent); // For run() return - only agent events
nextSubject.next(processedEvent); // For connect() / store - all events
currentRunEvents.push(processedEvent); // Accumulate for database storage
},
onNewMessage: ({ message }) => {
// Called for each new message
if (!seenMessageIds.has(message.id)) {
seenMessageIds.add(message.id);
}
},
onRunStartedEvent: () => {
// Mark input messages as seen without emitting duplicates
if (request.input.messages) {
for (const message of request.input.messages) {
if (!seenMessageIds.has(message.id)) {
seenMessageIds.add(message.id);
}
}
}
},
});
const connection = ACTIVE_CONNECTIONS.get(request.threadId);
const appendedEvents = finalizeRunEvents(currentRunEvents, {
stopRequested: connection?.stopRequested ?? false,
});
for (const event of appendedEvents) {
runSubject.next(event);
nextSubject.next(event);
}
// Store the run in database
this.storeRun(
request.threadId,
request.input.runId,
currentRunEvents,
request.input,
parentRunId,
);
// Mark run as complete in database
this.setRunState(request.threadId, false);
if (connection) {
connection.agent = undefined;
connection.runSubject = undefined;
connection.currentEvents = undefined;
connection.stopRequested = false;
}
// Complete the subjects
runSubject.complete();
nextSubject.complete();
ACTIVE_CONNECTIONS.delete(request.threadId);
} catch {
const connection = ACTIVE_CONNECTIONS.get(request.threadId);
const appendedEvents = finalizeRunEvents(currentRunEvents, {
stopRequested: connection?.stopRequested ?? false,
});
for (const event of appendedEvents) {
runSubject.next(event);
nextSubject.next(event);
}
// Store the run even if it failed (partial events)
if (currentRunEvents.length > 0) {
this.storeRun(
request.threadId,
request.input.runId,
currentRunEvents,
request.input,
parentRunId,
);
}
// Mark run as complete in database
this.setRunState(request.threadId, false);
if (connection) {
connection.agent = undefined;
connection.runSubject = undefined;
connection.currentEvents = undefined;
connection.stopRequested = false;
}
// Don't emit error to the subject, just complete it
// This allows subscribers to get events emitted before the error
runSubject.complete();
nextSubject.complete();
ACTIVE_CONNECTIONS.delete(request.threadId);
}
};
// Bridge previous events if they exist
if (prevSubject) {
prevSubject.subscribe({
next: (e) => nextSubject.next(e),
error: (err) => nextSubject.error(err),
complete: () => {
// Don't complete nextSubject here - it needs to stay open for new events
},
});
}
// Start the agent execution immediately (not lazily)
runAgent();
// Return the run subject (only agent events, no injected messages)
return runSubject.asObservable();
}
connect(request: AgentRunnerConnectRequest): Observable<BaseEvent> {
const connectionSubject = new ReplaySubject<BaseEvent>(Infinity);
// Load historic runs from database
const historicRuns = this.getHistoricRuns(request.threadId);
// Collect all historic events from database
const allHistoricEvents: BaseEvent[] = [];
for (const run of historicRuns) {
allHistoricEvents.push(...run.events);
}
// Compact all events together before emitting
const compactedEvents = compactEvents(allHistoricEvents);
// Emit compacted events and track message IDs
const emittedMessageIds = new Set<string>();
for (const event of compactedEvents) {
connectionSubject.next(event);
if ("messageId" in event && typeof event.messageId === "string") {
emittedMessageIds.add(event.messageId);
}
}
// Bridge active run to connection if exists
const activeConnection = ACTIVE_CONNECTIONS.get(request.threadId);
const runState = this.getRunState(request.threadId);
if (
activeConnection &&
(runState.isRunning || activeConnection.stopRequested)
) {
activeConnection.subject.subscribe({
next: (event) => {
// Skip message events that we've already emitted from historic
if (
"messageId" in event &&
typeof event.messageId === "string" &&
emittedMessageIds.has(event.messageId)
) {
return;
}
connectionSubject.next(event);
},
complete: () => connectionSubject.complete(),
error: (err) => connectionSubject.error(err),
});
} else {
// No active run, complete after historic events
connectionSubject.complete();
}
return connectionSubject.asObservable();
}
isRunning(request: AgentRunnerIsRunningRequest): Promise<boolean> {
const runState = this.getRunState(request.threadId);
return Promise.resolve(runState.isRunning);
}
stop(request: AgentRunnerStopRequest): Promise<boolean | undefined> {
const runState = this.getRunState(request.threadId);
if (!runState.isRunning) {
return Promise.resolve(false);
}
const connection = ACTIVE_CONNECTIONS.get(request.threadId);
const agent = connection?.agent;
if (!connection || !agent) {
return Promise.resolve(false);
}
if (connection.stopRequested) {
return Promise.resolve(false);
}
connection.stopRequested = true;
this.setRunState(request.threadId, false);
try {
agent.abortRun();
return Promise.resolve(true);
} catch (error) {
console.error("Failed to abort sqlite agent run", error);
connection.stopRequested = false;
this.setRunState(request.threadId, true);
return Promise.resolve(false);
}
}
/**
* Close the database connection (for cleanup)
*/
close(): void {
if (this.db) {
this.db.close();
}
}
}