/// /** * A message added while a dialog turn is already running is dropped, and how * `addMessage` decides a turn is still running depends on who is running it. * * A dialog is durable and runs in more than one replica, so a turn started in * one tab is visible in another as `pending` being true. For a turn belonging * to another replica the only signal available is `internal.lastActivity`, a * heartbeat refreshed on every durable write of the turn, and `REQUEST_TIMEOUT` * is the bound past which that replica is presumed gone. Nothing distinguishes * a crashed peer from a slow one, so the bound stays. * * For a turn this replica is running there is nothing to detect: it knows the * turn is alive, because it is the one running it. The four cases below pin * that split. The second is the one that used to be wrong: a turn making no * durable writes for longer than the bound had the replica running it declare * the turn dead and start a second request against itself. A single model call * that runs that long produces such a turn. So does a tool call, which nothing * bounds, and so does a round of several, which run one after another and write * their results only once they have all returned. * * The two peer cases put `pending` back to true with no turn running locally. * That the write reaches the dialog's own durable cell is what the * within-the-bound case proves, by dropping a message that a dialog reading * `pending` as false would have taken; the past-the-bound case rests on it. */ import { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; import { expect } from "@std/expect"; import { Identity } from "@commonfabric/identity"; import { StorageManager } from "@commonfabric/runner/storage/cache.deno"; import { clearMockResponses, enableMockMode, loadConversationFixture, setMockResponseGate, } from "@commonfabric/llm/client"; import type { BuiltInLLMMessage, JSONSchema } from "@commonfabric/api"; import { defer } from "@commonfabric/utils/defer"; import { createBuilder } from "../src/builder/factory.ts"; import { createTrustedBuilder } from "./support/trusted-builder.ts"; import { Runtime } from "../src/runtime.ts"; import type { IExtendedStorageTransaction } from "../src/storage/interface.ts"; import { LLMMessageSchema } from "../src/builtins/llm-schemas.ts"; import { llmDialogTestHelpers } from "../src/builtins/llm-dialog.ts"; const { REQUEST_TIMEOUT } = llmDialogTestHelpers; const signer = await Identity.fromPassphrase("test operator"); const space = signer.did(); enableMockMode(); const resultSchema = { type: "object", properties: { addMessage: { ...LLMMessageSchema, asCell: ["stream"] }, pending: { type: "boolean" }, messages: { type: "array", items: { type: "object", additionalProperties: true }, }, }, required: ["addMessage"], } as const satisfies JSONSchema; describe("llmDialog drops messages added during a running turn", () => { let storageManager: ReturnType; let runtime: Runtime; let tx: IExtendedStorageTransaction; let Cell: ReturnType["commonfabric"]["Cell"]; let pattern: ReturnType["commonfabric"]["pattern"]; let llmDialog: ReturnType["commonfabric"]["llmDialog"]; beforeEach(() => { clearMockResponses(); clock.reset(); storageManager = StorageManager.emulate({ as: signer }); runtime = new Runtime({ apiUrl: new URL(import.meta.url), storageManager, }); tx = runtime.edit(); const { commonfabric } = createTrustedBuilder(runtime); ({ pattern, llmDialog, Cell } = commonfabric); }); afterEach(async () => { await tx.commit().settled; await runtime.idle(); await runtime?.dispose(); await storageManager?.close(); }); // Start the dialog and return its result cell. The caller sends messages // through `addMessage` and reads `messages` and `pending` back. The setup // commit is awaited so a failure to write the dialog into place is reported // here rather than as whatever the rest of the case does without it. const startDialog = async (cause: string) => { const testPattern = pattern( () => { const messages = Cell.of([]); const dialog = llmDialog({ messages }); return { addMessage: dialog.addMessage, pending: dialog.pending, messages, }; }, false, resultSchema, ); const resultCell = runtime.getCell(space, cause, resultSchema, tx); const result = runtime.run(tx, testPattern, {}, resultCell); const { error } = await tx.commit().settled; if (error) throw error; return result; }; // deno-lint-ignore no-explicit-any const contentsOf = (result: any) => (result.withTx().key("messages").get() ?? []).map( (message: BuiltInLLMMessage) => message.content, ); it("drops while its own turn runs within the staleness bound", async () => { loadConversationFixture({ description: "one answer for the held turn, one a second request would " + "take if the dropped message started one", responses: [ { type: "sendRequest", response: { role: "assistant", content: "Hi there!", id: "r1" }, }, { type: "sendRequest", response: { role: "assistant", content: "Second answer", id: "r2" }, }, ], }); const result = await startDialog("llmDialog-message-drop-own-fresh-turn"); let requestsSeen = 0; const requestReached = defer(); const held = defer(); setMockResponseGate(() => { requestsSeen++; requestReached.resolve(); return held.promise; }); const addMessage = await result.key("addMessage").pull(); addMessage.send({ role: "user", content: "First" }); await requestReached.promise; // The ordinary case, and what the whole `pending` block was there for // before any of this: a second message arrives while the turn that is // running is young enough that the heartbeat would have caught it too. addMessage.send({ role: "user", content: "Second" }); await clock.settle(); held.resolve(); await runtime.settled(); expect(contentsOf(result)).toEqual(["First", "Hi there!"]); expect(requestsSeen).toBe(1); expect(result.withTx().key("pending").get()).toBe(false); }); it("keeps dropping while its own turn runs past the staleness bound", async () => { loadConversationFixture({ description: "one answer for the held turn, one a second request would " + "take if the dropped message started one", responses: [ { type: "sendRequest", response: { role: "assistant", content: "Hi there!", id: "r1" }, }, { type: "sendRequest", response: { role: "assistant", content: "Second answer", id: "r2" }, }, ], }); const result = await startDialog("llmDialog-message-drop-own-turn"); // Hold the model's answer so the turn stays genuinely in flight, and count // the requests that reach the model. A dropped message starts no request, // so the count is what separates the two outcomes. let requestsSeen = 0; const requestReached = defer(); const held = defer(); setMockResponseGate(() => { requestsSeen++; requestReached.resolve(); return held.promise; }); const addMessage = await result.key("addMessage").pull(); addMessage.send({ role: "user", content: "First" }); // The gate is entered from inside the turn, so this is the turn announcing // that it is running rather than a guess that it has started by now. await requestReached.promise; expect(result.withTx().key("pending").get()).toBe(true); // Move logical time past the staleness bound while the turn is held. This // is the shape of a long tool call: the turn is running and writing // nothing durable, so the heartbeat it would refresh stands still and goes // stale under a replica that is plainly alive. const before = Date.now(); await clock.tick(REQUEST_TIMEOUT + 60_000); expect(Date.now() - before).toBeGreaterThan(REQUEST_TIMEOUT); addMessage.send({ role: "user", content: "Second" }); // Drain reactive work to a fixpoint, so a second request this message was // going to start has started by the time the gate count is read. await clock.settle(); expect(requestsSeen).toBe(1); held.resolve(); await runtime.settled(); // The dropped message never reached the conversation, and the held turn // finished as the only turn there was. expect(contentsOf(result)).toEqual(["First", "Hi there!"]); expect(requestsSeen).toBe(1); expect(result.withTx().key("pending").get()).toBe(false); }); it("drops while another replica's turn is within the staleness bound", async () => { loadConversationFixture({ description: "one answer, plus one a second request would take", responses: [ { type: "sendRequest", response: { role: "assistant", content: "Hi there!", id: "r1" }, }, { type: "sendRequest", response: { role: "assistant", content: "Second answer", id: "r2" }, }, ], }); const result = await startDialog("llmDialog-message-drop-live-peer"); const addMessage = await result.key("addMessage").pull(); addMessage.send({ role: "user", content: "First" }); await runtime.settled(); expect(contentsOf(result)).toEqual(["First", "Hi there!"]); // The finished turn left a fresh heartbeat behind. Putting `pending` back // to true is what this replica sees when another one starts a turn: the // flag is durable, and only the replica running the turn holds anything // more than the heartbeat. let requestsSeen = 0; setMockResponseGate(() => { requestsSeen++; return Promise.resolve(); }); const pendingTx = runtime.edit(); result.withTx(pendingTx).key("pending").set(true); await pendingTx.commit().settled; await runtime.settled(); addMessage.send({ role: "user", content: "Second" }); await runtime.settled(); expect(contentsOf(result)).toEqual(["First", "Hi there!"]); expect(requestsSeen).toBe(0); }); it("accepts once another replica's turn passes the staleness bound", async () => { loadConversationFixture({ description: "one answer, then the answer to the message that takes " + "over from the presumed-gone replica", responses: [ { type: "sendRequest", response: { role: "assistant", content: "Hi there!", id: "r1" }, }, { type: "sendRequest", response: { role: "assistant", content: "Second answer", id: "r2" }, }, ], }); const result = await startDialog("llmDialog-message-drop-gone-peer"); const addMessage = await result.key("addMessage").pull(); addMessage.send({ role: "user", content: "First" }); await runtime.settled(); expect(contentsOf(result)).toEqual(["First", "Hi there!"]); let requestsSeen = 0; setMockResponseGate(() => { requestsSeen++; return Promise.resolve(); }); const pendingTx = runtime.edit(); result.withTx(pendingTx).key("pending").set(true); await pendingTx.commit().settled; await runtime.settled(); // No replica refreshed the heartbeat for longer than the bound, so the one // that started the turn is presumed gone and the message is taken. This // replica is running no turn of its own, so it has nothing better to go on. await clock.tick(REQUEST_TIMEOUT + 60_000); addMessage.send({ role: "user", content: "Second" }); await runtime.settled(); expect(contentsOf(result)).toEqual([ "First", "Hi there!", "Second", "Second answer", ]); expect(requestsSeen).toBe(1); }); });