Files
agents-marketplace/tests/unit/supplier-chat-session-rollback.test.ts
david 843ac7cbdd feat(supplier): stateful-upstream contract for chat-session (OpenClaw)
Two opt-in envs preparing the openclaw supplier on inference-proxy:

OPENAI_SESSION_PASSTHROUGH=1 — chat-session turns speak the stateful-
upstream contract: the escrow ref is sent as the OpenAI `user` field
(OpenClaw keys one persistent agent session per `user`, so browser/
tool state survives across turns) and only the current turn's delta
goes upstream — the upstream session carries history, and re-sending
the full transcript would duplicate it into the upstream context every
turn (OpenClaw renders ALL request messages into the new agent turn).
The local transcript still accumulates fully for the receipt. Without
`user`, OpenClaw creates an ephemeral session per request (uuid key),
so default-off stays safe for stateless providers.

OPENAI_MODEL_OVERRIDE — fixed upstream model id sent instead of the
advert's buyer-facing model. OpenClaw's chat-completions endpoint 400s
on anything but "openclaw" / "openclaw/<agentId>", while the advert
must carry a unique fleet-routable id (openclaw-web-agent).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:15:13 +02:00

171 lines
6.7 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(configOverrides?: Partial<SupplierConfig>) {
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(), ...configOverrides } as SupplierConfig,
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 — stateful upstream mode (OPENAI_SESSION_PASSTHROUGH)", () => {
it("sends delta-only messages + user=escrowRef + overridden model; transcript still accumulates fully", async () => {
const { app, record } = makeAppWithSession({
openaiSessionPassthrough: true,
openaiModelOverride: "openclaw",
});
const okFetch = () =>
vi.fn().mockResolvedValue(new Response(OK_STREAM, { status: 200, headers: { "content-type": "text/event-stream" } }));
const fetch1 = okFetch();
vi.stubGlobal("fetch", fetch1);
await request(app).post("/v1/chat/message").set("X-Escrow-Ref", ESCROW_REF).send({ content: "first" });
const fetch2 = okFetch();
vi.stubGlobal("fetch", fetch2);
await request(app).post("/v1/chat/message").set("X-Escrow-Ref", ESCROW_REF).send({ content: "second" });
// Turn 2 upstream call: only the new delta, keyed to the escrow ref, with
// the fixed upstream model — NOT the accumulated transcript / advert model.
const body2 = JSON.parse(fetch2.mock.calls[0][1].body as string);
expect(body2.messages).toEqual([{ role: "user", content: "second" }]);
expect(body2.user).toBe(ESCROW_REF);
expect(body2.model).toBe("openclaw");
// The local transcript (receipt source of truth) still has every turn.
expect(record.transcript).toEqual([
{ role: "user", content: "first" },
{ role: "assistant", content: "Hello" },
{ role: "user", content: "second" },
{ role: "assistant", content: "Hello" },
]);
});
it("keeps full-transcript + advert-model behavior when passthrough is off", async () => {
const { app } = makeAppWithSession();
const fetch1 = vi
.fn()
.mockResolvedValue(new Response(OK_STREAM, { status: 200, headers: { "content-type": "text/event-stream" } }));
vi.stubGlobal("fetch", fetch1);
await request(app).post("/v1/chat/message").set("X-Escrow-Ref", ESCROW_REF).send({ content: "first" });
const body = JSON.parse(fetch1.mock.calls[0][1].body as string);
expect(body.messages).toEqual([{ role: "user", content: "first" }]);
expect("user" in body).toBe(false);
expect(body.model).toBe("kimi");
});
});
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" },
]);
});
});