diff --git a/packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts b/packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts new file mode 100644 index 000000000..14d052204 --- /dev/null +++ b/packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts @@ -0,0 +1,254 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; + +import { streamSimple, type Context, type Model } from "@earendil-works/pi-ai"; +import { Type } from "typebox"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { compactContext } from "../../src/service/turn-system/compaction/algorithm/compact-context.js"; +import { createCheckpointSession } from "../../src/service/turn-system/compaction/execution/checkpoint-provider.js"; + +const summary = + "The synthetic file was read. Continue with the pending user request."; + +function history(model: Model<"openai-completions">): Context["messages"] { + return [ + { role: "user", content: "Read the synthetic file.", timestamp: 1 }, + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "read-1", + name: "read", + arguments: { path: "fixture.txt" }, + }, + ], + api: model.api, + provider: model.provider, + model: model.id, + stopReason: "toolUse", + timestamp: 2, + usage: { + input: 100, + output: 10, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 110, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }, + { + role: "toolResult", + toolCallId: "read-1", + toolName: "read", + content: [{ type: "text", text: "Synthetic file contents." }], + isError: false, + timestamp: 3, + }, + ]; +} + +describe("OpenAI compaction HTTP transport", () => { + let server: ReturnType; + let model: Model<"openai-completions">; + let requests: Record[]; + + beforeEach(async () => { + requests = []; + // Exercise the real SDK serializer and stream parser with a local fixture. + // This models the reported validation rule, not acceptance by a live service. + server = createServer(async (request, response) => { + let raw = ""; + for await (const chunk of request) raw += chunk; + const body = JSON.parse(raw); + requests.push(body); + if (Array.isArray(body.tools) && body.tools.length === 0) { + response.writeHead(400, { "content-type": "application/json" }).end( + JSON.stringify({ + error: { + message: "tools must not be an empty array", + type: "invalid_request_error", + param: "tools", + }, + }), + ); + return; + } + response.writeHead(200, { "content-type": "text/event-stream" }); + response.write( + `data: ${JSON.stringify({ + id: "synthetic-checkpoint", + object: "chat.completion.chunk", + model: body.model, + choices: [ + { index: 0, delta: { content: summary }, finish_reason: null }, + ], + })}\n\n`, + ); + response.end( + `data: ${JSON.stringify({ + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { + prompt_tokens: 100, + completion_tokens: 15, + total_tokens: 115, + }, + })}\n\ndata: [DONE]\n\n`, + ); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + model = { + id: "fixture-model", + name: "Fixture model", + api: "openai-completions", + provider: "custom", + baseUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1`, + reasoning: false, + input: ["text"], + contextWindow: 32_768, + maxTokens: 4096, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }; + }); + + afterEach(async () => { + if (!server?.listening) return; + server.closeAllConnections(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + }); + + it.each(["custom", "openai"])( + "compacts tool history through the %s provider without empty tools", + async (provider) => { + model = { ...model, provider }; + const result = await compactContext({ + history: history(model), + allowLegacyToolTrim: false, + limits: { + providerInputLimit: 20_000, + maxSerializedInputBytes: 100_000, + }, + // Admission is synthetic; checkpoint generation and HTTP transport are real. + measurePair: async () => ({ + before: { inputTokens: 1000, serializedBytes: 10_000 }, + after: { inputTokens: 100, serializedBytes: 1000 }, + }), + checkpoint: { + tokensBefore: 1000, + timestamp: 4, + open: () => + createCheckpointSession({ + model, + streamFn: streamSimple, + thinkingLevel: "off", + apiKey: "synthetic-key", + maxOutputTokens: 1024, + providerInputLimit: 20_000, + }), + }, + }); + expect(requests).toHaveLength(1); + expect(requests[0]).not.toHaveProperty("tools"); + expect(requests[0]).not.toHaveProperty("tool_choice"); + expect(requests[0].messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + role: "assistant", + tool_calls: [expect.objectContaining({ id: "read-1" })], + }), + expect.objectContaining({ + role: "tool", + tool_call_id: "read-1", + content: "Synthetic file contents.", + }), + ]), + ); + expect(result).toMatchObject({ + method: "llm_checkpoint", + summary, + generationAttempts: 1, + }); + expect(result.replacementMessages[0]).toMatchObject({ + role: "compactionSummary", + }); + }, + ); + + it.each([ + { label: "absent", tools: undefined }, + { label: "empty", tools: [] }, + ])("omits $label tool definitions with tool history", async ({ tools }) => { + const result = await streamSimple( + model, + { messages: history(model), tools }, + { apiKey: "synthetic-key" }, + ).result(); + expect(requests).toHaveLength(1); + expect(requests[0]).not.toHaveProperty("tools"); + expect(result.stopReason).toBe("stop"); + }); + + it.each(["explicit", "detected"])( + "omits tools with %s Anthropic cache compatibility and tool history", + async (mode) => { + model = + mode === "explicit" + ? { ...model, compat: { cacheControlFormat: "anthropic" } } + : { ...model, provider: "openrouter", id: "anthropic/fixture-model" }; + const result = await streamSimple( + model, + { messages: history(model) }, + { + apiKey: "synthetic-key", + cacheRetention: "none", + }, + ).result(); + expect(result.stopReason).toBe("stop"); + expect(requests).toHaveLength(1); + expect(requests[0]).not.toHaveProperty("tools"); + + await streamSimple( + model, + { + messages: [{ role: "user", content: "Hello", timestamp: 1 }], + tools: [], + }, + { + apiKey: "synthetic-key", + cacheRetention: "none", + }, + ).result(); + expect(requests[1]).not.toHaveProperty("tools"); + }, + ); + + it("preserves nonempty tool definitions on ordinary agent requests", async () => { + const result = await streamSimple( + model, + { + messages: history(model), + tools: [ + { + name: "read", + description: "Read a synthetic file", + parameters: Type.Object({ path: Type.String() }), + }, + ], + }, + { apiKey: "synthetic-key" }, + ).result(); + expect(result.stopReason).toBe("stop"); + expect(requests[0].tools).toEqual([ + expect.objectContaining({ + type: "function", + function: expect.objectContaining({ name: "read" }), + }), + ]); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index 934d0a0a2..069a37e23 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -1506,6 +1506,7 @@ "packages/local-runtime-v2/test/helpers/agent-service.ts", "packages/local-runtime-v2/test/helpers/plugin-database.ts", "packages/local-runtime-v2/test/integration/btw-settled-history.integration.test.ts", + "packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts", "packages/local-runtime-v2/test/integration/mcp/local-mcp-public-facade.integration.test.ts", "packages/local-runtime-v2/test/integration/mcp/project-mcp.integration.test.ts", "packages/local-runtime-v2/test/integration/session-stop-cascade.integration.test.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 5062817c3..71cc6b783 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -5,6 +5,7 @@ "test/windows-contract.test.mjs" ], "capability": [ + "packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts", "packages/local-runtime/test/unit/skill-directory-symlinks.test.ts", "packages/agent-core/test/unit/bash-subprocess-env.test.ts", "packages/agent-modules/permission/test/unit/permission/bash-policy-regressions.test.ts", diff --git a/third_party/pi-mono/MINIMAX_CHANGES.md b/third_party/pi-mono/MINIMAX_CHANGES.md index ecd6608b6..b16c7d488 100644 --- a/third_party/pi-mono/MINIMAX_CHANGES.md +++ b/third_party/pi-mono/MINIMAX_CHANGES.md @@ -37,6 +37,14 @@ No upstream source files are changed in the baseline import. - Upstream PR: not opened. - Validation: `packages/agent-tools/src/desktop/edit-diff-bounds.test.ts` (registered in the `capability` suite) covers an ordinary edit, a large block replacement that stays under the bound, a full rewrite at the bound that keeps its diff, and a whole-file rewrite that keeps the write while dropping the diff; `pnpm verify --profile platform` on macOS. Measured end to end through `createEditTool`, three runs each on the same machine: a 20 000-line whole-file rewrite took 167 620 / 173 523 / 167 547 ms unbounded and 200 / 192 / 193 ms bounded, with peak heap dropping from 47–60 MB to 14–15 MB; a 501-line rewrite takes 53 / 49 / 47 ms and a 1000-line rewrite 177 / 173 / 177 ms, both keeping their diff. +### 2026-09-19 — omit empty tools for OpenAI-compatible checkpoint requests + +- Reason: checkpoint requests retain tool-call history but omit tool definitions. The OpenAI Completions provider unconditionally added `tools: []` for that history, which can cause a backend to reject compaction with HTTP 400 (public issue MiniMax-AI/minimax-code#194). +- Affected package: `packages/ai` (`@earendil-works/pi-ai`), `src/providers/openai-completions.ts` and its existing empty-tools regression fixture. +- Change type: generic, upstreamable compatibility fix. Omit `tools` when no nonempty tool definitions are supplied, regardless of tool-call history or cache compatibility. Nonempty tool definitions are unchanged. Remove the obsolete history-based empty-array workaround; no compatibility settings or recovery requests are added. +- Validation: the distribution-owned `packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts` exercises `compactContext` through checkpoint generation and the real SDK against a local HTTP fixture that rejects empty tools. Regressions cover absent/empty definitions, tool history, Anthropic cache compatibility, ordinary requests, and preserved nonempty definitions. Run with `pnpm exec vitest run --config vitest.oss.config.mjs packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts` and the full `pnpm verify` profile. Offline fixtures do not establish live LiteLLM/vLLM, OpenAI, or Anthropic proxy acceptance. +- Upstream PR: not created. + ### 2026-09-19 — preserve the system role for Mistral Chat Completions - Reason: thinking-enabled custom OpenAI-compatible connections to `api.mistral.ai` emitted `developer`, which is absent from the [Mistral Chat Completions message contract](https://docs.mistral.ai/api/endpoint/chat). [OpenClaw's compatibility defaults](https://github.com/openclaw/openclaw/blob/e2bcb1614de060927121bd72de850cee3a08d308/packages/ai/src/transports/openai-completions-compat.ts#L184-L210) also disable this role for the Mistral public endpoint. diff --git a/third_party/pi-mono/packages/ai/src/providers/openai-completions.ts b/third_party/pi-mono/packages/ai/src/providers/openai-completions.ts index 4d7b2d0fa..2f52683e9 100644 --- a/third_party/pi-mono/packages/ai/src/providers/openai-completions.ts +++ b/third_party/pi-mono/packages/ai/src/providers/openai-completions.ts @@ -40,25 +40,6 @@ import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts"; import { buildBaseOptions } from "./simple-options.ts"; import { transformMessages } from "./transform-messages.ts"; -/** - * Check if conversation messages contain tool calls or tool results. - * This is needed because Anthropic (via proxy) requires the tools param - * to be present when messages include tool_calls or tool role messages. - */ -function hasToolHistory(messages: Message[]): boolean { - for (const msg of messages) { - if (msg.role === "toolResult") { - return true; - } - if (msg.role === "assistant") { - if (msg.content.some((block) => block.type === "toolCall")) { - return true; - } - } - } - return false; -} - function isTextContentBlock(block: { type: string }): block is TextContent { return block.type === "text"; } @@ -545,9 +526,6 @@ function buildParams( if (compat.zaiToolStream) { (params as any).tool_stream = true; } - } else if (hasToolHistory(context.messages)) { - // Anthropic (via LiteLLM/proxy) requires tools param when conversation has tool_calls/tool_results - params.tools = []; } if (cacheControl) { diff --git a/third_party/pi-mono/packages/ai/test/openai-completions-empty-tools.test.ts b/third_party/pi-mono/packages/ai/test/openai-completions-empty-tools.test.ts index a743351c0..aff960a37 100644 --- a/third_party/pi-mono/packages/ai/test/openai-completions-empty-tools.test.ts +++ b/third_party/pi-mono/packages/ai/test/openai-completions-empty-tools.test.ts @@ -200,7 +200,7 @@ describe("openai-completions empty tools handling", () => { expect(clientOptions.defaultHeaders?.["x-session-affinity"]).toBe("session-1"); }); - it("still emits tools: [] for Anthropic/LiteLLM proxy when conversation has tool history", async () => { + it("omits tools when conversation has tool history but no tool definitions", async () => { const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!; const model = { ...baseModel, api: "openai-completions" } as const; @@ -248,7 +248,6 @@ describe("openai-completions empty tools handling", () => { ).result(); const params = mockState.lastParams as { tools?: unknown[] }; - expect(Array.isArray(params.tools)).toBe(true); - expect(params.tools).toEqual([]); + expect(params).not.toHaveProperty("tools"); }); });