Skip to content

Commit afeee49

Browse files
Merge pull request #1104 from corbitsdev/cl-9907-first-reply-bubble
hub: a run's first reply becomes a chat bubble (CL-9907)
2 parents e9ff856 + 9ce1c67 commit afeee49

6 files changed

Lines changed: 360 additions & 27 deletions

File tree

‎apps/hub/src/mailbox-persist.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,22 +25,22 @@ export function createHubPersistMailWithSessionEnsure<R extends readonly unknown
2525
upstream: (args: MailboxPersistArgs) => Promise<R>,
2626
): (args: MailboxPersistArgs) => Promise<R> {
2727
return async (args) => {
28-
const sender = await resolveRoutableAddress(db, args.senderAddress);
28+
let sender = await resolveRoutableAddress(db, args.senderAddress);
2929
try {
3030
const eventCollectors = eventCollectorsRef.current;
3131
if (sender !== undefined && eventCollectors !== undefined) {
3232
await ensureRunSession({ db, eventCollectors, runId: sender.id });
33+
sender = await resolveRoutableAddress(db, args.senderAddress);
3334
}
3435
} catch (err) {
3536
reportError(err, {
3637
operation: "hub.mailboxPersist.ensureRunSession",
3738
extra: { senderAddress: args.senderAddress },
3839
});
3940
}
40-
// The vendored session_mail write keys on the sender's principal, which
41-
// doesn't exist yet on a run's first outbound mail; skip it rather than
42-
// let it throw, since @corbits/mailbox's own write already persists this
43-
// frame.
41+
// The vendored session_mail write keys on the sender's session, which
42+
// may still not exist; skip it rather than let it throw, since
43+
// @corbits/mailbox's own write already persists this frame.
4444
if (sender !== undefined && sender.sessionId === null) {
4545
logger.debug("skipping vendored persistMail for run {runId}: no session yet", {
4646
runId: sender.id,

‎apps/hub/src/server.ts‎

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
type SidecarProvisionerChooser,
4646
type WsHandle,
4747
} from "@intx/hub-sessions";
48+
import { withLazyRunCollector } from "@corbits/workflows";
4849
import { generateKeyPair } from "@intx/crypto";
4950
import { timeWindowEvaluator } from "@intx/authz";
5051
import type { ConditionRegistry } from "@intx/types/authz";
@@ -273,24 +274,27 @@ export async function createHubServer({
273274
void pushCredentialReconcile(db, sidecarRouter, agentAddress, credentialCipher);
274275
};
275276

276-
const eventCollectors = createEventCollectorRegistry({
277+
const eventCollectors = withLazyRunCollector(
278+
createEventCollectorRegistry({
279+
db,
280+
onTurnFinalized(agentAddress, turn) {
281+
sidecarRouter.dispatchAgentEvent(agentAddress, {
282+
type: "turn.committed",
283+
data: {
284+
turnId: turn.turnId,
285+
status: turn.status,
286+
text: turn.text,
287+
hadReply: turn.hadReply,
288+
hadError: turn.hadError,
289+
errors: turn.errors,
290+
toolCalls: turn.toolCalls,
291+
toolErrors: turn.toolErrors,
292+
},
293+
});
294+
},
295+
}),
277296
db,
278-
onTurnFinalized(agentAddress, turn) {
279-
sidecarRouter.dispatchAgentEvent(agentAddress, {
280-
type: "turn.committed",
281-
data: {
282-
turnId: turn.turnId,
283-
status: turn.status,
284-
text: turn.text,
285-
hadReply: turn.hadReply,
286-
hadError: turn.hadError,
287-
errors: turn.errors,
288-
toolCalls: turn.toolCalls,
289-
toolErrors: turn.toolErrors,
290-
},
291-
});
292-
},
293-
});
297+
);
294298

295299
createHubSessionOrchestrator({
296300
events: sidecarRouter.events,

‎docs/agent-session-provisioning.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,7 @@ that first trigger may write the principal first.
2424
`ensureRunSession` is a true upsert, called from every seam that might be a
2525
run's first mail-routable moment: it creates the row once a principal is
2626
anchored, and re-keys an existing row onto it.
27+
28+
The hub also wraps its event collector registry with `withLazyRunCollector`,
29+
so a run's collector exists before its first inference event rather than
30+
after its first outbound mail; otherwise the first reply's parts are dropped.
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
// DB-gated. A run's first reply must reach the chat: its event collector has
2+
// to exist before the first inference event, and its first outbound mail has
3+
// to land in session_mail even though the agent_session is created mid-call.
4+
import { afterAll, beforeAll, expect, test } from "bun:test";
5+
import { eq, inArray } from "drizzle-orm";
6+
7+
import { createDB, schema, type DB } from "@intx/db";
8+
import { generateId } from "@intx/hub-common";
9+
import { createEventCollectorRegistry } from "@intx/hub-sessions";
10+
import { dbGate } from "../lib/db-gate";
11+
12+
import { createHubPersistMailWithSessionEnsure } from "../../apps/hub/src/mailbox-persist";
13+
import {
14+
ensureRunSession,
15+
withLazyRunCollector,
16+
} from "../../packages/workflows/src/launch/agent-session";
17+
18+
function dbConfigFromUrl(databaseUrl: string) {
19+
const url = new URL(databaseUrl);
20+
return {
21+
host: url.hostname,
22+
port: url.port === "" ? 5432 : Number(url.port),
23+
user: decodeURIComponent(url.username),
24+
password: decodeURIComponent(url.password),
25+
database: url.pathname.replace(/^\//, ""),
26+
};
27+
}
28+
29+
const databaseUrl = process.env["DATABASE_URL"];
30+
const describeIfDb = dbGate(databaseUrl, import.meta.path);
31+
32+
describeIfDb("first reply of a run", () => {
33+
let db: DB;
34+
35+
const tenantId = generateId("tenant");
36+
const userPrincipalId = generateId("principal");
37+
const runPrincipalId = generateId("principal");
38+
const definitionId = generateId("workflowDefinition");
39+
const runId = generateId("workflowRun");
40+
const domain = `first-reply-${tenantId}.localhost`;
41+
const sessionId = generateId("session");
42+
const unanchoredRunId = generateId("workflowRun");
43+
const unanchoredSessionId = generateId("session");
44+
const unanchoredAddress = `${unanchoredRunId}@${domain}`;
45+
const closedRunId = generateId("workflowRun");
46+
const closedSessionId = generateId("session");
47+
const closedAddress = `${closedRunId}@${domain}`;
48+
const doneRunId = generateId("workflowRun");
49+
const doneSessionId = generateId("session");
50+
const doneAddress = `${doneRunId}@${domain}`;
51+
const donePrincipalId = generateId("principal");
52+
const closedPrincipalId = generateId("principal");
53+
const runIds = [runId, unanchoredRunId, closedRunId, doneRunId];
54+
const sessionIds = [sessionId, unanchoredSessionId, closedSessionId, doneSessionId];
55+
const address = `${runId}@${domain}`;
56+
57+
beforeAll(async () => {
58+
if (databaseUrl === undefined) return;
59+
db = createDB(dbConfigFromUrl(databaseUrl));
60+
61+
await db.db.insert(schema.tenant).values({
62+
id: tenantId,
63+
name: "First Reply Test Tenant",
64+
slug: `first-reply-${tenantId.replaceAll("_", "-")}`,
65+
domain,
66+
parentId: null,
67+
config: null,
68+
});
69+
await db.db.insert(schema.principal).values([
70+
{ id: userPrincipalId, tenantId, kind: "user", refId: "usr_test", status: "active" },
71+
{ id: runPrincipalId, tenantId, kind: "workflow", refId: runId, status: "active" },
72+
{ id: donePrincipalId, tenantId, kind: "workflow", refId: doneRunId, status: "active" },
73+
{ id: closedPrincipalId, tenantId, kind: "workflow", refId: closedRunId, status: "active" },
74+
]);
75+
await db.db.insert(schema.workflowDefinition).values({
76+
id: definitionId,
77+
tenantId,
78+
creatorPrincipalId: userPrincipalId,
79+
assetId: null,
80+
name: "first-reply-test-definition",
81+
});
82+
const seedRun = async (
83+
id: string,
84+
session: string,
85+
principalId: string | null,
86+
runAddress: string,
87+
) => {
88+
await db.db.insert(schema.workflowRun).values({
89+
id,
90+
definitionId,
91+
anchorRunId: id,
92+
tenantId,
93+
principalId,
94+
address: runAddress,
95+
status: "running",
96+
});
97+
await db.db.insert(schema.workflowRunLaunchSpec).values({
98+
anchorRunId: id,
99+
sessionId: session,
100+
deploymentDomain: domain,
101+
sourceAuthorityPrincipalId: userPrincipalId,
102+
frozenApprovalBundle: {},
103+
sourceOfferingIds: [],
104+
defaultSourceOfferingId: "off_test",
105+
deployContent: { systemPrompt: "" },
106+
});
107+
};
108+
await seedRun(runId, sessionId, runPrincipalId, address);
109+
await seedRun(unanchoredRunId, unanchoredSessionId, null, unanchoredAddress);
110+
await seedRun(doneRunId, doneSessionId, donePrincipalId, doneAddress);
111+
await seedRun(closedRunId, closedSessionId, closedPrincipalId, closedAddress);
112+
});
113+
114+
afterAll(async () => {
115+
if (databaseUrl === undefined) return;
116+
await db.db.delete(schema.inferenceTurn).where(eq(schema.inferenceTurn.tenantId, tenantId));
117+
await db.db.delete(schema.agentSession).where(inArray(schema.agentSession.id, sessionIds));
118+
await db.db
119+
.delete(schema.workflowRunLaunchSpec)
120+
.where(inArray(schema.workflowRunLaunchSpec.anchorRunId, runIds));
121+
await db.db.delete(schema.workflowRun).where(inArray(schema.workflowRun.id, runIds));
122+
await db.db
123+
.delete(schema.workflowDefinition)
124+
.where(eq(schema.workflowDefinition.id, definitionId));
125+
await db.db
126+
.delete(schema.principal)
127+
.where(
128+
inArray(schema.principal.id, [
129+
runPrincipalId,
130+
closedPrincipalId,
131+
donePrincipalId,
132+
userPrincipalId,
133+
]),
134+
);
135+
await db.db.delete(schema.tenant).where(eq(schema.tenant.id, tenantId));
136+
});
137+
138+
async function waitFor(done: () => boolean | Promise<boolean>): Promise<void> {
139+
for (let i = 0; i < 50 && !(await done()); i++) await Bun.sleep(20);
140+
}
141+
const start = (seq: number) => ({ type: "inference.start", seq, data: { model: "m" } }) as const;
142+
const turnsFor = (session: string) =>
143+
db.db.query.inferenceTurn.findMany({ where: eq(schema.inferenceTurn.sessionId, session) });
144+
145+
test("persists the run's first turn although no collector existed yet", async () => {
146+
const lazy = withLazyRunCollector(createEventCollectorRegistry({ db: db.db }), db.db);
147+
148+
lazy.dispatch(address, start(1));
149+
await Bun.sleep(300);
150+
151+
expect(lazy.has(address)).toBe(true);
152+
expect(await turnsFor(sessionId)).toHaveLength(1);
153+
});
154+
155+
test("creates no collector until the run has a session, then retries on the next event", async () => {
156+
const lazy = withLazyRunCollector(createEventCollectorRegistry({ db: db.db }), db.db);
157+
158+
lazy.dispatch(unanchoredAddress, start(1));
159+
await Bun.sleep(300);
160+
expect(lazy.has(unanchoredAddress)).toBe(false);
161+
162+
const [principal] = await db.db
163+
.insert(schema.principal)
164+
.values({
165+
id: generateId("principal"),
166+
tenantId,
167+
kind: "workflow",
168+
refId: unanchoredRunId,
169+
status: "active",
170+
})
171+
.returning();
172+
await db.db
173+
.update(schema.workflowRun)
174+
.set({ principalId: principal!.id })
175+
.where(eq(schema.workflowRun.id, unanchoredRunId));
176+
177+
lazy.dispatch(unanchoredAddress, start(2));
178+
await Bun.sleep(300);
179+
expect(lazy.has(unanchoredAddress)).toBe(true);
180+
expect(await turnsFor(unanchoredSessionId)).toHaveLength(1);
181+
182+
await db.db.delete(schema.inferenceTurn).where(eq(schema.inferenceTurn.tenantId, tenantId));
183+
await db.db.delete(schema.agentSession).where(eq(schema.agentSession.id, unanchoredSessionId));
184+
await db.db
185+
.update(schema.workflowRun)
186+
.set({ principalId: null })
187+
.where(eq(schema.workflowRun.id, unanchoredRunId));
188+
await db.db.delete(schema.principal).where(eq(schema.principal.id, principal!.id));
189+
});
190+
191+
test("recreates the collector after an abandon so a reconnected run keeps persisting", async () => {
192+
const lazy = withLazyRunCollector(createEventCollectorRegistry({ db: db.db }), db.db);
193+
194+
lazy.dispatch(closedAddress, start(1));
195+
await waitFor(() => lazy.has(closedAddress));
196+
lazy.abandon(closedAddress);
197+
lazy.dispatch(closedAddress, start(2));
198+
await waitFor(() => lazy.has(closedAddress));
199+
200+
expect(lazy.has(closedAddress)).toBe(true);
201+
await waitFor(async () => (await turnsFor(closedSessionId)).length === 2);
202+
expect(await turnsFor(closedSessionId)).toHaveLength(2);
203+
});
204+
205+
test("keeps the reply of a run whose done follows its start immediately", async () => {
206+
const lazy = withLazyRunCollector(createEventCollectorRegistry({ db: db.db }), db.db);
207+
208+
lazy.dispatch(doneAddress, start(1));
209+
lazy.dispatch(doneAddress, { type: "reactor.done", seq: 2, data: {} });
210+
await waitFor(async () => (await turnsFor(doneSessionId)).length === 1);
211+
212+
expect(await turnsFor(doneSessionId)).toHaveLength(1);
213+
});
214+
215+
test("does not recreate the collector for mail after the run is done", async () => {
216+
const lazy = withLazyRunCollector(createEventCollectorRegistry({ db: db.db }), db.db);
217+
218+
lazy.dispatch(doneAddress, start(3));
219+
lazy.dispatch(doneAddress, { type: "reactor.done", seq: 4, data: {} });
220+
await waitFor(async () => (await turnsFor(doneSessionId)).length === 2);
221+
await Bun.sleep(100);
222+
await ensureRunSession({ db: db.db, eventCollectors: lazy, runId: doneRunId });
223+
224+
expect(lazy.has(doneAddress)).toBe(false);
225+
});
226+
227+
test("persists the run's first outbound mail although its session is created mid-call", async () => {
228+
await db.db.delete(schema.agentSession).where(eq(schema.agentSession.id, sessionId));
229+
const upstreamCalls: unknown[] = [];
230+
const persist = createHubPersistMailWithSessionEnsure(
231+
db.db,
232+
{ current: { create: () => {}, has: () => true } },
233+
async (args) => {
234+
upstreamCalls.push(args);
235+
return [];
236+
},
237+
);
238+
239+
await persist({ senderAddress: address } as Parameters<typeof persist>[0]);
240+
241+
expect(upstreamCalls).toHaveLength(1);
242+
});
243+
});

‎packages/workflows/src/index.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,8 @@ export {
1313
type WorkflowCatalogEntry,
1414
type TriggerFieldsValidation,
1515
} from "./catalog";
16-
export { ensureRunSession, type EventCollectorPort } from "./launch/agent-session";
16+
export {
17+
ensureRunSession,
18+
withLazyRunCollector,
19+
type EventCollectorPort,
20+
} from "./launch/agent-session";

0 commit comments

Comments
 (0)