diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 35d687063ca..781c3b408f8 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -673,6 +673,21 @@ Auto auth selects subscription when stored Claude auth is found, proxy when none subscription with a warning when detection is inconclusive. See [Claude Code auth mode](/guides/claude-code/#auth-mode). +## Retained requests after compaction + +For translated providers and routed Responses gateways, OpenCodex automatically frames +plain-text user requests before the latest readable Codex checkpoint as historical context. +Requests the checkpoint reports as completed should not be repeated; retained text remains +available for unfinished work within its existing authorization. Later user input takes priority, +including pauses, cancellations and changes of scope. A checkpoint grants no new permission. + +This is enabled by default and needs no setting. Original request text and canonical thread/replay +history are preserved. Native OpenAI Responses passthrough, summarizer turns, context wrappers, +images/files and assistant/tool items are unchanged. The framing applies to plaintext checkpoints +beginning with Codex's summary prefix; decoded OpenCodex checkpoints retain their existing +continuation guidance. Client-side summarizer turns using Codex's standard compaction prompt +also bypass framing. Framing is a prompt instruction, so model behavior can vary. + ## Compaction routing In **Dashboard → Overview → Compaction routing**, choose a model, which triggers it applies to, diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 033981afa39..6c3335963ea 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,7 +2,7 @@ "version": 1, "root": "tests", "explicitShards": ["layout-clients.json"], - "explicit": { "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", + "explicit": { "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "owner-registry-isolation.test.ts": "config", "codex-history-pending-wal.test.ts": "codex-integration", "responses-anthropic-billing-send-budget.test.ts": "responses", "anthropic-billing-ownership.test.ts": "adapters/anthropic", "server-anthropic-billing.test.ts": "server", "server-anthropic-billing-boundaries.test.ts": "server", "router-anthropic-billing-failover.test.ts": "routing", "anthropic-billing-refusal.test.ts": "adapters/anthropic", "messages-request-id-headers.test.ts": "claude-integration", "bounded-file-read.test.ts": "lib", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "explicit-empty-tool-catalog.test.ts": "responses", "explicit-empty-tool-catalog-history.test.ts": "responses", "messages-request-id-endpoint.test.ts": "claude-integration", diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 84feefa2dc7..976222d7ee3 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -1,4 +1,5 @@ import { normalizeRoutedAgentMessages } from "../routed-agent-messages"; +import { frameCompactionHistory } from "../../responses/compaction-history-framing"; import { nameRoutedIdentity, repairIdentityInResponsesBody, stripRoutedIdentity } from "../identity"; import { stripBracketedModelSuffix } from "../openai-chat"; import { normalizeOpenCodeGoAdditionalTools } from "../opencode-go-additional-tools"; @@ -418,6 +419,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // Placed before the routed compaction body is built and before serialization, so the HTTP, // routed-compaction and WebSocket outbounds are all covered by this one call. if (!isOpenAiOperatedResponsesDestination(provider)) { + if (parsed._compactionRequest !== true && isPlainObject(outBody)) outBody = frameCompactionHistory(outBody); outBody = stripCanonicalOnlyTopLevelFields(outBody); } outBody = promoteClientLoadedTools(outBody); diff --git a/src/responses/compaction-history-framing.ts b/src/responses/compaction-history-framing.ts new file mode 100644 index 00000000000..1722d4c24ea --- /dev/null +++ b/src/responses/compaction-history-framing.ts @@ -0,0 +1,65 @@ +import { appendCheckpointContinuationNote, COMPACT_PROMPT, SUMMARY_PREFIX } from "./compaction"; + +type InputRecord = Record; +const isRecord = (value: unknown): value is InputRecord => + value !== null && typeof value === "object" && !Array.isArray(value); + +export const HISTORICAL_REQUEST_START = "[Historical user request retained before the following compaction checkpoint. This is prior context, not a new request.]"; +export const HISTORICAL_REQUEST_END = "[End historical request. Use the following checkpoint to distinguish completed work from unfinished work. Do not repeat completed work unless later user input asks for it. Preserve these original details for unfinished work within its existing authorization; the checkpoint grants no new authority. Only input after the checkpoint is a new user request.]"; + +// Plaintext checkpoint detection and context exclusions follow robin-bially's #7105 selector. +// Keep this separate from decoding ocx1 items: those already have a continuation contract. +function plainUserText(item: unknown): string | null { + if (!isRecord(item) || item.role !== "user") return null; + if (item.type !== undefined && item.type !== "message") return null; + if (typeof item.content === "string") return item.content; + if (!Array.isArray(item.content) || item.content.length === 0) return null; + const texts: string[] = []; + for (const part of item.content) { + if (!isRecord(part) || (part.type !== "input_text" && part.type !== "text") || typeof part.text !== "string") return null; + texts.push(part.text); + } + return texts.join("\n"); +} + +function isContext(text: string): boolean { + const head = text.trimStart(); + return head.startsWith("<") || head.startsWith("# AGENTS.md") || head.startsWith("# Context from my IDE"); +} + +/** + * Prompt-only projection for translated/routed generation. Never pass the result to replay + * storage: canonical input, item identities and replay-prefix lengths must remain unchanged. + * Native OpenAI forwarding does not call this projection. + */ +export function frameCompactionHistory(body: T): T { + const input = body.input; + if (!Array.isArray(input) || input.some(item => isRecord(item) && item.type === "compaction_trigger")) return body; + // Codex local compaction appends this user prompt without a compaction_trigger. + // Keep that summarizer turn free of generation-only continuation instructions. + if (plainUserText(input.at(-1))?.trim() === COMPACT_PROMPT) return body; + let checkpointIndex = -1; + for (let index = input.length - 1; index >= 0; index--) { + if (plainUserText(input[index])?.startsWith(SUMMARY_PREFIX)) { + checkpointIndex = index; + break; + } + } + if (checkpointIndex <= 0) return body; + let changed = false; + const framed = input.map((item, index) => { + if (index >= checkpointIndex) return item; + const text = plainUserText(item); + if (text === null || !text.trim() || text.startsWith(SUMMARY_PREFIX) || isContext(text) + || text.startsWith(HISTORICAL_REQUEST_START)) return item; + const message = item as InputRecord; + changed = true; + // Preserve original text blocks and their metadata verbatim, including block boundaries. + const content = typeof message.content === "string" + ? `${HISTORICAL_REQUEST_START}\n${message.content}\n${HISTORICAL_REQUEST_END}` + : [{ type: "input_text", text: HISTORICAL_REQUEST_START }, + ...(message.content as unknown[]), { type: "input_text", text: HISTORICAL_REQUEST_END }]; + return { ...message, content }; + }); + return changed ? { ...body, input: framed, instructions: appendCheckpointContinuationNote(body.instructions) } : body; +} diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 0c372164de0..ce9e487a09a 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -28,6 +28,7 @@ import { mapToolChoice, buildTools, customToolNamespaces } from "./parser-tools" import { parseTextFormat } from "./parser-text-format"; import { externalTaskInputContent } from "./task-input"; import { normalizeVisualizationContext } from "./visualization-directives"; +import { frameCompactionHistory } from "./compaction-history-framing"; /** * Wrap a remembered proxy-side signature as provider metadata for a replayed tool call. @@ -189,7 +190,7 @@ export function parseRequest( if (!parsed.success) { throw new Error(`responses parse error: ${parsed.error.message}`); } - const data = parsed.data; + const data = frameCompactionHistory(parsed.data); const now = Date.now(); const messages: OcxMessage[] = []; // Built before the item loop: a custom_tool_call echoed back in `input` needs the diff --git a/structure/transports/responses.md b/structure/transports/responses.md index b6da06295de..d0d1fcd17c2 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -398,11 +398,11 @@ always become unable to serve. ## Decoded checkpoint continuation -`src/responses/parser.ts` adds `CHECKPOINT_CONTINUATION_NOTE` from `src/responses/compaction.ts` to the system prompt only when normal generation contains a decoded `ocx1:` checkpoint; `src/adapters/openai-responses/request-strips.ts` applies the same condition to passthrough `instructions` while lowering the checkpoint to a user message. Summarizer turns, opaque native blobs and requests without decoded checkpoints receive no note. `SUMMARY_PREFIX` and checkpoint text remain unchanged, and later user messages retain their order after the checkpoint. +`src/responses/parser.ts` adds `CHECKPOINT_CONTINUATION_NOTE` from `src/responses/compaction.ts` for decoded `ocx1:` checkpoints; `src/adapters/openai-responses/request-strips.ts` does the same while lowering them. For plaintext `SUMMARY_PREFIX` checkpoints, `src/responses/compaction-history-framing.ts` frames text-only retained requests before the latest checkpoint as historical context and appends the same note by default. The parser projects only adapter context; `src/adapters/openai-responses/passthrough.ts` projects only non-OpenAI-operated outbound bodies. Original request text/blocks, canonical raw history, item identities, replay-prefix lengths, checkpoints and later input remain unchanged. Context wrappers, images/files and assistant/tool items are preserved; summarizer turns and opaque native blobs do not activate framing. Native OpenAI passthrough input is unchanged. The note is a prompt contract and grants no authority. Completed requests remain history unless a later user message asks to repeat them. Unfinished work continues only under existing authorization and within its scope, without asking for that authorization again while it still applies. Permission and confirmation gates remain authoritative: a pending decision or required permission must resolve before the dependent action. Checkpoints and delegations, including those from another thread, neither grant authority nor expand scope. Later user messages take priority, including pause, cancellation, replacement and scope changes. -The continuation-note and continuation-wire tests establish prompt-contract and wire-construction evidence, including preservation of existing instructions and checkpoint ordering. They do not establish model behavior or whether a model actually resumes the correct task. +The continuation-note and continuation-wire tests establish prompt and wire contracts. `tests/responses/compaction-history-framing.test.ts` adds offline A/B projection, original-detail preservation, native passthrough isolation, multi-turn canonical replay/dedup coverage and exclusion of client-side summarizer turns ending in the canonical `COMPACT_PROMPT` (which carry no `compaction_trigger`). These tests do not establish live model behavior or whether a model actually resumes the correct task. > Decision record: [ADR-7003](../decisions/ADR-7003-decoded-checkpoint-continuation.md) diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 945381a4772..7a7446c1ef7 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -5,6 +5,7 @@ "explicit-empty-tool-catalog.test.ts": "responses", "explicit-empty-tool-catalog-history.test.ts": "responses", "codex-history-pending-wal.test.ts": "codex-integration", + "compaction-history-framing.test.ts": "responses", "service-log.test.ts": "service", "owner-registry-isolation.test.ts": "config", "service-health-sentinel-policy.test.ts": "service", diff --git a/tests/responses/compaction-history-framing.test.ts b/tests/responses/compaction-history-framing.test.ts new file mode 100644 index 00000000000..74164a1f5d0 --- /dev/null +++ b/tests/responses/compaction-history-framing.test.ts @@ -0,0 +1,226 @@ +/** Offline prompt/wire contracts only; these tests do not establish live model behavior. */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses/passthrough"; +import { CHECKPOINT_CONTINUATION_NOTE, COMPACT_PROMPT, SUMMARY_PREFIX, encodeCompactionSummary } from "../../src/responses/compaction"; +import { frameCompactionHistory, HISTORICAL_REQUEST_START, HISTORICAL_REQUEST_END } from "../../src/responses/compaction-history-framing"; +import { parseRequest } from "../../src/responses/parser"; +import { clearResponseStateForTests, expandPreviousResponseInput, previousResponseReplayPrefixLength, rememberResponseState } from "../../src/responses/state"; +import { handleResponses } from "../../src/server/responses"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; + +const user = (text: string) => ({ type: "message", role: "user", content: [{ type: "input_text", text }] }); +const completed = "Inspect example.txt and report its readability."; +const unfinished = "Continue the authorized review using the edge-case key zebra_42; do not deploy."; +const checkpoint = user(`${SUMMARY_PREFIX}\nThe inspection is complete. The authorized review is unfinished.`); +const later = user("Pause the review. Only explain the remaining steps."); +const history = () => [user(completed), user(unfinished), checkpoint, later]; +const gateway: OcxProviderConfig = { adapter: "openai-responses", baseUrl: "https://gw.example.invalid/v1", authMode: "key", apiKey: "synthetic-key" }; + +function wire(provider: OcxProviderConfig, body: Record) { + const request = withTestTranslatorBudget(createResponsesPassthroughAdapter(provider)).buildRequest(parseRequest(body), + { headers: new Headers({ authorization: "Bearer synthetic-token" }) }); + return JSON.parse(request.body) as Record; +} + +function frozen(value: T): T { + if (value && typeof value === "object") { + Object.freeze(value); + for (const child of Object.values(value)) frozen(child); + } + return value; +} + +describe("plaintext checkpoint historical framing", () => { + test("A/B: imperative retained input becomes explicit history without losing unfinished details", () => { + const body = frozen({ model: "m", instructions: "Existing instruction.", input: history() }); + const before = JSON.stringify(body); + const after = frameCompactionHistory(body); + expect(JSON.stringify(body)).toBe(before); + expect(JSON.stringify(body)).not.toContain(HISTORICAL_REQUEST_START); + expect(after.input).toHaveLength(body.input.length); + for (const index of [0, 1]) { + expect(after.input[index]!.content).toEqual([ + { type: "input_text", text: HISTORICAL_REQUEST_START }, + ...body.input[index]!.content, + { type: "input_text", text: HISTORICAL_REQUEST_END }, + ]); + } + expect(JSON.stringify(after)).toContain("zebra_42"); // Deliberately omitted from the summary. + expect(after.input[2]).toBe(checkpoint); + expect(after.input[3]).toBe(later); + expect(after.instructions).toBe(`Existing instruction.\n\n${CHECKPOINT_CONTINUATION_NOTE}`); + expect(frameCompactionHistory(after)).toBe(after); + }); + + test("latest checkpoint controls framing; preserves block metadata and every original byte", () => { + const blocks = [{ type: "text", text: " first\n", extra: 1 }, { type: "input_text", text: "second\r\nšŸ¦“" }]; + const earlier = user(`${SUMMARY_PREFIX}\nEarlier checkpoint.`); + const input = [user(completed), earlier, { role: "user", content: blocks }, checkpoint, later]; + const after = frameCompactionHistory({ input }); + expect(after.input[1]).toBe(earlier); + const framedBlocks = after.input[2]!.content as typeof blocks; + expect(framedBlocks.slice(1, -1)).toEqual(blocks); + expect(framedBlocks[1]).toBe(blocks[0]); + expect(after.input[4]).toBe(later); + const stringInput = { input: [{ role: "user", content: " exact\r\nšŸ¦“ " }, checkpoint] }; + expect(frameCompactionHistory(stringInput).input[0]!.content).toBe(`${HISTORICAL_REQUEST_START}\n exact\r\nšŸ¦“ \n${HISTORICAL_REQUEST_END}`); + }); + + test("context wrappers, images, files, tools, assistant turns and checkpoints remain unchanged", () => { + const kept = [user("test"), user("# AGENTS.md instructions"), + user("# Context from my IDE\ncontext"), user(" "), + { role: "user", content: [{ type: "input_image", image_url: "data:x" }] }, + { role: "user", content: [{ type: "input_file", file_id: "file_x" }] }, + { role: "assistant", content: "done" }, + { type: "function_call", call_id: "c1", name: "shell", arguments: "{}" }, + { type: "function_call_output", call_id: "c1", output: "done" }]; + const body = { input: [...kept, user(completed), checkpoint] }; + const after = frameCompactionHistory(body); + expect(after.input.slice(0, kept.length)).toEqual(kept); + kept.forEach((item, index) => expect(after.input[index]).toBe(item)); + }); + + test("no-op controls: no plaintext checkpoint, opaque or ocx1 state, summarizer, empty candidates", () => { + for (const input of [ + "direct input", [user(completed)], [checkpoint, later], + [user(completed), { type: "compaction", encrypted_content: "gAAAA-native" }], + [user(completed), { type: "compaction", encrypted_content: encodeCompactionSummary("summary") }], + [user(completed), checkpoint, { type: "compaction_trigger" }], + [user(""), checkpoint], + [user(completed), { role: "assistant", content: `${SUMMARY_PREFIX}\nnot a checkpoint` }], + ]) { + const body = { input }; + expect(frameCompactionHistory(body)).toBe(body); + } + }); + + test("translated parser changes only context; raw body and continuation positions stay canonical", () => { + const body = frozen({ model: "m", input: history() }); + const parsed = parseRequest(body); + expect(parsed._rawBody).toBe(body); + expect(parsed.context.messages).toHaveLength(body.input.length); + const old = JSON.stringify(parsed.context.messages[0]); + expect(old).toContain(HISTORICAL_REQUEST_START); + expect(old).toContain(completed); + expect(JSON.stringify(parsed.context.messages[1])).toContain("zebra_42"); + expect(JSON.stringify(parsed.context.messages.at(-1))).not.toContain(HISTORICAL_REQUEST_START); + expect(parsed.context.systemPrompt).toContain(CHECKPOINT_CONTINUATION_NOTE); + }); + + test("client-side compaction after a prior checkpoint stays a summarizer turn", () => { + // codex-rs local compaction appends its prompt as a user message, with no trigger item. + for (const content of [COMPACT_PROMPT, [{ type: "input_text", text: COMPACT_PROMPT }]]) { + const body = frozen({ model: "m", instructions: "Existing instruction.", + input: [...history(), { role: "user", content }] }); + expect(frameCompactionHistory(body)).toBe(body); + const parsed = parseRequest(body); + expect(JSON.stringify(parsed.context.messages)).not.toContain(HISTORICAL_REQUEST_START); + expect(parsed.context.systemPrompt?.join("\n")).not.toContain(CHECKPOINT_CONTINUATION_NOTE); + const outbound = wire(gateway, body); + expect(outbound.input).toEqual(body.input); + expect(outbound.instructions).toBe(body.instructions); + } + const quoted = { input: [...history(), user(`Explain this prompt:\n${COMPACT_PROMPT}`)] }; + expect(JSON.stringify(frameCompactionHistory(quoted).input)).toContain(HISTORICAL_REQUEST_START); + }); + + test("routed Responses frames a projection; both native OpenAI passthrough destinations preserve input", () => { + const body = frozen({ model: "m", instructions: "Existing instruction.", input: history(), store: true }); + const routed = wire(gateway, body); + expect(JSON.stringify(routed.input)).toContain(HISTORICAL_REQUEST_START); + expect(JSON.stringify(routed.input)).toContain("zebra_42"); + expect(routed.instructions).toContain(CHECKPOINT_CONTINUATION_NOTE); + for (const native of [ + { ...gateway, baseUrl: "https://api.openai.com/v1" }, + { ...gateway, baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const }, + ]) { + const outbound = wire(native, body); + expect(outbound.input).toEqual(body.input); + expect(outbound.instructions).not.toContain(CHECKPOINT_CONTINUATION_NOTE); + } + expect(JSON.stringify(body)).not.toContain(HISTORICAL_REQUEST_START); + }); +}); + +describe("canonical replay and request-path integration", () => { + let home: string; + let priorHome: string | undefined; + const originalFetch = globalThis.fetch; + let releaseSpendHome: (() => void) | undefined; + beforeEach(() => { + priorHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-history-framing-")); + process.env.OPENCODEX_HOME = home; + clearResponseStateForTests(); + }); + afterEach(() => { + globalThis.fetch = originalFetch; + releaseSpendHome?.(); + releaseSpendHome = undefined; + clearResponseStateForTests(); + removeTreeWithRetry(home); + if (priorHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = priorHome; + }); + + test("previous_response_id deduplicates the original client replay across multiple continuations", () => { + let input: unknown[] = history(); + for (let turn = 0; turn < 3; turn++) { + const canonical = { model: "m", input, store: true }; + parseRequest(canonical); + wire(gateway, canonical); + const answer = { type: "message", id: `msg_${turn}`, role: "assistant", content: `answer ${turn}` }; + const response = { id: `resp_framing_${turn}`, status: "completed", output: [answer] }; + rememberResponseState(canonical, response); + const replay = [...input, answer, user(`follow-up ${turn}`)]; + const expanded = expandPreviousResponseInput({ model: "m", previous_response_id: response.id, input: replay }) as { input: unknown[] }; + expect(expanded.input).toEqual(replay); + expect(previousResponseReplayPrefixLength(expanded)).toBe(input.length + 1); + const parsed = parseRequest(expanded); + expect(parsed._continuationConversationMessageIndex).toBe(input.length + 1); + expect(JSON.stringify(expanded.input)).not.toContain(HISTORICAL_REQUEST_START); + expect(expanded.input.filter(item => (item as { id?: string }).id === answer.id)).toHaveLength(1); + input = expanded.input; + } + }); + + for (const adapter of ["openai-chat", "openai-responses"] as const) { + test(`${adapter}: real handler frames prompt by default and preserves canonical replay`, async () => { + releaseSpendHome = acquireOwnedSpendHome(); + const sent: Record[] = []; + let turn = 0; + globalThis.fetch = (async (request: RequestInfo | URL, init?: RequestInit) => { + sent.push(JSON.parse(request instanceof Request ? await request.clone().text() : String(init?.body))); + const text = `provider answer ${turn++}`; + return Response.json(adapter === "openai-chat" + ? { id: `chat_${turn}`, choices: [{ index: 0, message: { role: "assistant", content: text }, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1 } } + : { id: `resp_provider_${turn}`, object: "response", status: "completed", output: [{ type: "message", id: `msg_provider_${turn}`, status: "completed", role: "assistant", content: [{ type: "output_text", text, annotations: [] }] }], usage: { input_tokens: 1, output_tokens: 1 } }); + }) as typeof fetch; + const config = { defaultProvider: "gw", providers: { gw: { ...gateway, adapter } } } as OcxConfig; + const send = async (body: Record) => { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "gw/m", stream: false, store: true, ...body }), + }), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + return await response.json() as { id: string; output: unknown[] }; + }; + const first = await send({ input: history() }); + expect(JSON.stringify(sent[0])).toContain(HISTORICAL_REQUEST_START); + expect(JSON.stringify(sent[0])).toContain("zebra_42"); + const clientReplay = [...history(), ...first.output, user("Explain only.")]; + await send({ previous_response_id: first.id, input: clientReplay }); + const payload = JSON.stringify(sent[1]); + expect(payload.match(/provider answer 0/g)).toHaveLength(1); + expect(payload).toContain(HISTORICAL_REQUEST_START); + expect(payload).toContain("zebra_42"); + expect(payload.indexOf("is unfinished.")).toBeLessThan(payload.lastIndexOf("Explain only.")); + }); + } +});