Files
agents-marketplace/tests/unit/supplier-chat-session-rollback.test.ts
david 9a4f9f0699 fix(gateway,supplier): demo sessions stick to their supplier; free pinned suppliers
The demo endpoint lost its session on nearly every second message: affinity
required clients to re-echo the assistant reply byte-identically, so clients
that strip <think> blocks or reshape whitespace (Open WebUI, litellm) missed
every turn, opened a fresh escrow session on the next free single-slot
supplier, and left the old one pinned. Open WebUI's background title/tag-gen
requests each pinned another supplier — one message could exhaust all three
kimi replicas ("no available chat supplier").

- affinity: lenient assistant-content matching (think-strip + whitespace
  collapse); user/system/tool messages and tool_calls stay byte-strict;
  strict prefixes outrank lenient ones. The mirror is never rewritten with
  client text, so receipt hashes stay valid.
- demoChat: trim trailing assistant/system messages (regenerate semantics)
  instead of 400 "the last message must have role user or tool".
- supplier: roll the transcript back when the LLM call fails so retries
  don't duplicate the delta (ChatSessionStore.truncateTranscript).
- demoChat: LRU-evict a demo session when no supplier is available (supplier
  /v1/chat/end frees the slot, sweeper Accepts the escrow) and retry the
  open with selectCandidates ignoreStatusFor to bypass the ~20s-stale
  indexer status poll. Max 2 evictions per request.
- sweeper: idle janitor closes demo sessions unused for DEMO_SESSION_IDLE_MS
  (default 3 min, below the supplier's 5 min CHAT_IDLE_TIMEOUT_MS) so
  abandoned one-off sessions release their suppliers quickly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 02:29:10 +02:00

123 lines
4.5 KiB
TypeScript

/**
* supplier-chat-session-rollback.test.ts — failed-turn transcript rollback.
*
* The chat-session message handler appends the incoming delta BEFORE calling
* the LLM backend; the gateway mirror only appends after a successful turn.
* On backend failure the supplier must truncate the delta back out, so a
* retried turn doesn't duplicate messages and both transcripts stay
* hash-identical for the receipt.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import request from "supertest";
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
import type { AdvertDatum, EscrowDatum } from "../../packages/shared/src/cbor/types.js";
import type { SupplierConfig } from "../../supplier/src/config.js";
import { SupplierState } from "../../supplier/src/state.js";
import { JobStore } from "../../supplier/src/jobs.js";
import { ChatSessionStore } from "../../supplier/src/chatSession.js";
import { createApp } from "../../supplier/src/server.js";
import { buildSampleConfig } from "../fixtures/supplier-side/sample-config.js";
import { buildSupplierWalletKey } from "../fixtures/supplier-side/wallet-keys.js";
const ESCROW_REF = `${"e".repeat(64)}#0`;
function chatSessionConfig(): SupplierConfig {
return {
...buildSampleConfig(),
capabilityKind: "chat-session",
llmBackend: "openai",
ollamaUrl: "",
openaiBaseUrl: "http://up",
openaiApiKey: "",
openaiTimeoutMs: 5_000,
openaiMaxTokens: 0,
openaiReasoningDisabled: false,
chatIdleTimeoutMs: 60_000,
} as SupplierConfig;
}
function makeAppWithSession() {
const chatSessions = new ChatSessionStore();
const record = chatSessions.create({
escrowRef: ESCROW_REF,
claimedRef: { txHash: "c".repeat(64), index: 0 },
advert: { model: "kimi" } as AdvertDatum,
escrowDatum: {} as EscrowDatum,
});
const app = createApp({
chain: new MockChainProvider(),
state: new SupplierState(),
config: chatSessionConfig(),
supplierKey: buildSupplierWalletKey(),
jobs: new JobStore(),
chatSessions,
});
return { app, chatSessions, record };
}
function sseBody(frames: unknown[]): string {
return frames.map((f) => `data: ${typeof f === "string" ? f : JSON.stringify(f)}\n\n`).join("");
}
const OK_STREAM = sseBody([
{ choices: [{ delta: { content: "Hello" }, finish_reason: null }] },
{ choices: [{ delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 3, completion_tokens: 2 } },
"[DONE]",
]);
afterEach(() => {
vi.unstubAllGlobals();
});
describe("ChatSessionStore.truncateTranscript", () => {
it("rolls an appended delta back and no-ops on stale targets", () => {
const store = new ChatSessionStore();
store.create({
escrowRef: ESCROW_REF,
claimedRef: { txHash: "c".repeat(64), index: 0 },
advert: { model: "kimi" } as AdvertDatum,
escrowDatum: {} as EscrowDatum,
});
store.appendMessages(ESCROW_REF, [{ role: "user", content: "hi" }]);
store.truncateTranscript(ESCROW_REF, 0);
expect(store.get(ESCROW_REF)!.transcript).toEqual([]);
store.appendMessages(ESCROW_REF, [{ role: "user", content: "hi" }]);
store.truncateTranscript(ESCROW_REF, 5); // target beyond current length
store.truncateTranscript(ESCROW_REF, -1); // nonsense target
store.truncateTranscript("missing", 0); // unknown session
expect(store.get(ESCROW_REF)!.transcript).toHaveLength(1);
});
});
describe("POST /v1/chat/message — failed-turn rollback", () => {
it("truncates the delta on backend failure, then a retry appends exactly once", async () => {
const { app, record } = makeAppWithSession();
const delta = [{ role: "user", content: "hi" }];
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("boom")));
const failed = await request(app)
.post("/v1/chat/message")
.set("X-Escrow-Ref", ESCROW_REF)
.send({ messages: delta });
expect(failed.text).toContain('"type":"error"');
// The failed delta was rolled back — nothing to duplicate on retry.
expect(record.transcript).toEqual([]);
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(new Response(OK_STREAM, { status: 200, headers: { "content-type": "text/event-stream" } })),
);
const ok = await request(app)
.post("/v1/chat/message")
.set("X-Escrow-Ref", ESCROW_REF)
.send({ messages: delta });
expect(ok.text).toContain('"type":"done"');
expect(record.transcript).toEqual([
{ role: "user", content: "hi" },
{ role: "assistant", content: "Hello" },
]);
});
});