This commit is contained in:
Harivansh Rathi 2026-03-14 14:53:32 -04:00
parent c79ae1e621
commit cb30fa5fd1
4 changed files with 114 additions and 123 deletions

View file

@ -24,24 +24,31 @@ function normalizeErrorMessage(error: unknown): string {
return typeof error === "string" ? error : String(error); return typeof error === "string" ? error : String(error);
} }
function readConvexSiteUrl(): string | null { type DurableChatRunEventBody =
const raw = | {
process.env.CONVEX_SITE_URL ?? items: PersistHistoryItem[];
process.env.NEXT_PUBLIC_CONVEX_SITE_URL ?? final?: {
process.env.CONVEX_URL ?? status: ConvexRunStatus;
process.env.NEXT_PUBLIC_CONVEX_URL; error?: string;
return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : null; };
} }
| {
items?: PersistHistoryItem[];
final: {
status: ConvexRunStatus;
error?: string;
};
};
function readConvexSecret(): string | null { function buildAuthHeaders(token: string): Record<string, string> {
const raw = process.env.CONVEX_SECRET; return {
return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : null; "Content-Type": "application/json",
Authorization: `Bearer ${token}`,
};
} }
export class DurableChatRunReporter { export class DurableChatRunReporter {
private readonly assistantMessageId: string; private readonly assistantMessageId: string;
private readonly convexSiteUrl: string;
private readonly convexSecret: string;
private latestAssistantMessage: AgentMessage | null = null; private latestAssistantMessage: AgentMessage | null = null;
private readonly knownToolResults = new Map< private readonly knownToolResults = new Map<
string, string,
@ -56,16 +63,15 @@ export class DurableChatRunReporter {
GatewayMessageRequest["durableRun"] GatewayMessageRequest["durableRun"]
>, >,
) { ) {
const convexSiteUrl = readConvexSiteUrl(); if (
const convexSecret = readConvexSecret(); durableRun.callbackUrl.trim().length === 0 ||
if (!convexSiteUrl || !convexSecret) { durableRun.callbackToken.trim().length === 0
) {
throw new Error( throw new Error(
"Durable chat run reporting requires CONVEX_SITE_URL/CONVEX_URL and CONVEX_SECRET", "Durable chat run reporting requires callbackUrl and callbackToken",
); );
} }
this.convexSiteUrl = convexSiteUrl; this.assistantMessageId = `run:${this.durableRun.runId}:assistant`;
this.convexSecret = convexSecret;
this.assistantMessageId = `run:${durableRun.runId}:assistant`;
} }
handleSessionEvent( handleSessionEvent(
@ -116,17 +122,11 @@ export class DurableChatRunReporter {
status = "failed"; status = "failed";
errorMessage = normalizeErrorMessage(error); errorMessage = normalizeErrorMessage(error);
} }
await this.postEvent({
const endpoint = final: {
status === "completed" status,
? "/api/chat/complete-run" ...(status === "failed" && errorMessage ? { error: errorMessage } : {}),
: status === "interrupted" },
? "/api/chat/interrupt-run"
: "/api/chat/fail-run";
await this.callConvexHttpAction(endpoint, {
runId: this.durableRun.runId,
...(status === "failed" && errorMessage ? { error: errorMessage } : {}),
}); });
} }
@ -152,12 +152,7 @@ export class DurableChatRunReporter {
const flushPromise = this.flushChain.then(async () => { const flushPromise = this.flushChain.then(async () => {
this.throwIfFlushFailed(); this.throwIfFlushFailed();
await this.callConvexHttpAction("/api/chat/run-messages", { await this.postEvent({
runId: this.durableRun.runId,
userId: this.durableRun.userId,
agentId: this.durableRun.agentId,
threadId: this.durableRun.threadId,
sessionKey: this.durableRun.sessionKey,
items, items,
}); });
}); });
@ -177,8 +172,6 @@ export class DurableChatRunReporter {
} }
private buildItems(): PersistHistoryItem[] { private buildItems(): PersistHistoryItem[] {
const items: PersistHistoryItem[] = [];
const assistantParts = const assistantParts =
this.latestAssistantMessage?.role === "assistant" this.latestAssistantMessage?.role === "assistant"
? messageContentToHistoryParts(this.latestAssistantMessage) ? messageContentToHistoryParts(this.latestAssistantMessage)
@ -201,40 +194,36 @@ export class DurableChatRunReporter {
this.latestAssistantMessage?.role === "assistant" || this.latestAssistantMessage?.role === "assistant" ||
assistantParts.length > 0 assistantParts.length > 0
) { ) {
items.push({ return [
role: "assistant", {
text: role: "assistant",
this.latestAssistantMessage?.role === "assistant" text:
? extractMessageText(this.latestAssistantMessage) || undefined this.latestAssistantMessage?.role === "assistant"
: undefined, ? extractMessageText(this.latestAssistantMessage) || undefined
partsJson: JSON.stringify(assistantParts), : undefined,
timestamp: partsJson: JSON.stringify(assistantParts),
this.latestAssistantMessage?.timestamp ?? timestamp:
firstToolResult?.timestamp ?? this.latestAssistantMessage?.timestamp ??
Date.now(), firstToolResult?.timestamp ??
idempotencyKey: this.assistantMessageId, Date.now(),
}); idempotencyKey: this.assistantMessageId,
},
];
} }
return items; return [];
} }
private async callConvexHttpAction( private async postEvent(body: DurableChatRunEventBody): Promise<void> {
path: string, const response = await fetch(this.durableRun.callbackUrl, {
body: Record<string, unknown>,
): Promise<void> {
const response = await fetch(`${this.convexSiteUrl}${path}`, {
method: "POST", method: "POST",
headers: { headers: buildAuthHeaders(this.durableRun.callbackToken),
"Content-Type": "application/json",
Authorization: `Bearer ${this.convexSecret}`,
},
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (!response.ok) { if (!response.ok) {
const text = await response.text().catch(() => ""); const text = await response.text().catch(() => "");
throw new Error( throw new Error(
`Convex HTTP action failed for ${path}: ${response.status} ${text}`, `Chat run relay failed: ${response.status} ${text}`.trim(),
); );
} }
} }

View file

@ -117,21 +117,17 @@ function readDurableRun(
} }
const runId = readString(value.runId); const runId = readString(value.runId);
const userId = readString(value.userId); const callbackUrl = readString(value.callbackUrl);
const agentId = readString(value.agentId); const callbackToken = readString(value.callbackToken);
const threadId = readString(value.threadId);
const sessionKey = readString(value.sessionKey);
if (!runId || !userId || !agentId || !threadId || !sessionKey) { if (!runId || !callbackUrl || !callbackToken) {
return undefined; return undefined;
} }
return { return {
runId, runId,
userId, callbackUrl,
agentId, callbackToken,
threadId,
sessionKey,
}; };
} }
@ -516,7 +512,6 @@ export class GatewayRuntime {
response, response,
sessionKey: managedSession.sessionKey, sessionKey: managedSession.sessionKey,
}; };
queued.resolve(result);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
this.log( this.log(
@ -540,24 +535,37 @@ export class GatewayRuntime {
error: message, error: message,
sessionKey: managedSession.sessionKey, sessionKey: managedSession.sessionKey,
}; };
queued.resolve(result);
} finally { } finally {
queued.onFinish?.(); queued.onFinish?.();
if (durableRunReporter) {
try {
await durableRunReporter.finalize(result);
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
this.log(
`[chat-run] session=${managedSession.sessionKey} finalize error: ${message}`,
);
this.emit(managedSession, {
type: "error",
sessionKey: managedSession.sessionKey,
error: message,
});
result = {
ok: false,
response: result.response,
error: message,
sessionKey: managedSession.sessionKey,
};
}
}
queued.resolve(result);
managedSession.processing = false; managedSession.processing = false;
managedSession.activeDurableRun = null; managedSession.activeDurableRun = null;
managedSession.activeAssistantMessage = null; managedSession.activeAssistantMessage = null;
managedSession.pendingToolResults = []; managedSession.pendingToolResults = [];
managedSession.lastActiveAt = Date.now(); managedSession.lastActiveAt = Date.now();
this.emitState(managedSession); this.emitState(managedSession);
if (durableRunReporter) {
await durableRunReporter.finalize(result).catch((error) => {
const message =
error instanceof Error ? error.message : String(error);
this.log(
`[chat-run] session=${managedSession.sessionKey} finalize error: ${message}`,
);
});
}
if (managedSession.queue.length > 0) { if (managedSession.queue.length > 0) {
void this.processNext(managedSession); void this.processNext(managedSession);
} }

View file

@ -28,10 +28,8 @@ export interface GatewayMessageRequest {
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
durableRun?: { durableRun?: {
runId: string; runId: string;
userId: string; callbackUrl: string;
agentId: string; callbackToken: string;
threadId: string;
sessionKey: string;
}; };
} }

View file

@ -1,9 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { DurableChatRunReporter } from "../src/core/gateway/durable-chat-run.js"; import { DurableChatRunReporter } from "../src/core/gateway/durable-chat-run.js";
const originalConvexUrl = process.env.CONVEX_URL;
const originalConvexSecret = process.env.CONVEX_SECRET;
function mockOkResponse() { function mockOkResponse() {
return { return {
ok: true, ok: true,
@ -15,31 +12,16 @@ function mockOkResponse() {
describe("DurableChatRunReporter", () => { describe("DurableChatRunReporter", () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
if (originalConvexUrl === undefined) {
delete process.env.CONVEX_URL;
} else {
process.env.CONVEX_URL = originalConvexUrl;
}
if (originalConvexSecret === undefined) {
delete process.env.CONVEX_SECRET;
} else {
process.env.CONVEX_SECRET = originalConvexSecret;
}
}); });
it("upserts a single assistant message with tool results and completes the run", async () => { it("posts assistant state to the relay and completes the run", async () => {
process.env.CONVEX_URL = "https://convex.example";
process.env.CONVEX_SECRET = "test-secret";
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(mockOkResponse()); const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(mockOkResponse());
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
const reporter = new DurableChatRunReporter({ const reporter = new DurableChatRunReporter({
runId: "run-1", runId: "run-1",
userId: "user-1", callbackUrl: "https://web.example/api/chat/runs/run-1/events",
agentId: "agent-1", callbackToken: "callback-token",
threadId: "thread-1",
sessionKey: "session-1",
}); });
const assistantMessage = { const assistantMessage = {
@ -94,24 +76,28 @@ describe("DurableChatRunReporter", () => {
expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0]?.[0]).toBe( expect(fetchMock.mock.calls[0]?.[0]).toBe(
"https://convex.example/api/chat/run-messages", "https://web.example/api/chat/runs/run-1/events",
); );
expect(fetchMock.mock.calls[1]?.[0]).toBe( expect(fetchMock.mock.calls[1]?.[0]).toBe(
"https://convex.example/api/chat/complete-run", "https://web.example/api/chat/runs/run-1/events",
); );
const runMessagesBody = JSON.parse( expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({
String(fetchMock.mock.calls[0]?.[1]?.body), Authorization: "Bearer callback-token",
) as { "Content-Type": "application/json",
});
const runMessagesCall = fetchMock.mock.calls.find((call) =>
String(call[1]?.body).includes('"items"'),
);
const runMessagesBody = JSON.parse(String(runMessagesCall?.[1]?.body)) as {
items: Array<{ items: Array<{
role: string;
idempotencyKey: string; idempotencyKey: string;
partsJson: string; partsJson: string;
}>; }>;
}; };
expect(runMessagesBody.items).toHaveLength(1); expect(runMessagesBody.items).toHaveLength(1);
expect(runMessagesBody.items[0]).toMatchObject({ expect(runMessagesBody.items[0]).toMatchObject({
role: "assistant",
idempotencyKey: "run:run-1:assistant", idempotencyKey: "run:run-1:assistant",
}); });
expect(JSON.parse(runMessagesBody.items[0]?.partsJson ?? "[]")).toEqual( expect(JSON.parse(runMessagesBody.items[0]?.partsJson ?? "[]")).toEqual(
@ -129,21 +115,24 @@ describe("DurableChatRunReporter", () => {
}), }),
]), ]),
); );
expect(
JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)),
).toMatchObject({
final: {
status: "completed",
},
});
}); });
it("marks aborted runs as interrupted", async () => { it("marks aborted runs as interrupted", async () => {
process.env.CONVEX_URL = "https://convex.example";
process.env.CONVEX_SECRET = "test-secret";
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(mockOkResponse()); const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(mockOkResponse());
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
const reporter = new DurableChatRunReporter({ const reporter = new DurableChatRunReporter({
runId: "run-2", runId: "run-2",
userId: "user-1", callbackUrl: "https://web.example/api/chat/runs/run-2/events",
agentId: "agent-1", callbackToken: "callback-token",
threadId: "thread-1",
sessionKey: "session-1",
}); });
await reporter.finalize({ await reporter.finalize({
@ -155,7 +144,14 @@ describe("DurableChatRunReporter", () => {
expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[0]).toBe( expect(fetchMock.mock.calls[0]?.[0]).toBe(
"https://convex.example/api/chat/interrupt-run", "https://web.example/api/chat/runs/run-2/events",
); );
expect(
JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)),
).toMatchObject({
final: {
status: "interrupted",
},
});
}); });
}); });