diff --git a/README.md b/README.md index bfeea7c..d62b68e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Chat uses Pi's native API adapters and model metadata, with availability from yo ## Install -This release targets Pi 1.0.4 and CLIProxyAPI 8.0.18, with Node.js 22.19 or newer. Run CLIProxyAPI with your upstream accounts configured before connecting Pi. The extension uses Pi's host-provided packages, adds no runtime dependencies, and needs no compilation. +The extension targets Pi 1.1.0 and CLIProxyAPI 8.0.22, with Node.js 22.19 or newer. Run CLIProxyAPI with your upstream accounts configured before connecting Pi. The extension uses Pi's host-provided packages, adds no runtime dependencies, and needs no compilation. If you installed `pi-cliproxy-native`, remove it before installing the renamed package. Both register the same `cliproxyapi` provider. @@ -201,6 +201,14 @@ Management requests stay on the configured proxy and reject redirects. Account a Other chat routes retain the response-based fallback: `Retry-After` on HTTP 429 or a recognized quota HTTP 403, or `reset_seconds` in a `model_cooldown` error. Google's native adapter exposes only the structured error body. Those waits share the same seven-day deadline but cannot inspect live weekly usage. Image and video generation still do not retry. +### Compaction + +For automatic compaction and `/compact`, the extension limits medium or higher thinking to low without changing your chat thinking level. It uses Pi's default thinking budgets for these requests. If a summary hits its output cap, it retries once with more output room, up to the model's output limit. Pi's native adapters also limit output to the remaining context space. An empty or thinking-only response gets one retry. + +Pi still selects which messages to summarize and retains recent history, previous summaries, custom instructions, and file tracking. Only the final response reaches the compactor, with usage from both attempts. A second incomplete response fails compaction rather than saving a partial checkpoint. Cancellation, tool calls, and provider failures do not trigger these output retries; Pi keeps its configured transient-error retries and the extension keeps its quota handling. + +Authentication failures, exhausted quotas, and inputs that exceed the model's context window still need correction. Run `/compact` after resolving the cause. The extension does not truncate history to force a summary or switch models. CLIProxyAPI's Responses compaction endpoint is separate from Pi's text-summary workflow. + ### Aliases and model limits The chat catalog skips unknown IDs rather than guessing their capabilities. Recognized catalog owners resolve duplicate metadata IDs; ambiguous cross-family matches stay out. diff --git a/extensions/index.ts b/extensions/index.ts index 196b748..05665e5 100644 --- a/extensions/index.ts +++ b/extensions/index.ts @@ -22,6 +22,10 @@ export default async function (pi: ExtensionAPI) { throw new Error("pi-cliproxyapi.json must contain valid JSON."); } const config = parseConfig(raw, process.env.CLIPROXYAPI_BASE_URL); + const compactionSignals = new WeakSet(); + pi.on("session_before_compact", (event) => { + compactionSignals.add(event.signal); + }); let context: ExtensionContext | undefined; const waits = new Map(); pi.on("session_start", (_event, ctx) => { @@ -33,25 +37,30 @@ export default async function (pi: ExtensionAPI) { waits.clear(); }); pi.registerProvider( - createCliproxyProvider(config, undefined, (id, seconds) => { - if (!context) return; - if (seconds === undefined) waits.delete(id); - else { - if (!waits.has(id)) { - const message = `CLIProxyAPI quota limit. Retrying at ${new Date(Date.now() + seconds * 1000).toLocaleString()}. Cancel the current request to stop waiting.`; - if (context.mode === "print" || context.mode === "json") process.stderr.write(`${message}\n`); - else context.ui.notify(message, "warning"); + createCliproxyProvider( + config, + undefined, + (id, seconds) => { + if (!context) return; + if (seconds === undefined) waits.delete(id); + else { + if (!waits.has(id)) { + const message = `CLIProxyAPI quota limit. Retrying at ${new Date(Date.now() + seconds * 1000).toLocaleString()}. Cancel the current request to stop waiting.`; + if (context.mode === "print" || context.mode === "json") process.stderr.write(`${message}\n`); + else context.ui.notify(message, "warning"); + } + waits.set(id, seconds); } - waits.set(id, seconds); - } - const remaining = Math.min(...waits.values()); - context.ui.setStatus( - "cliproxyapi-quota", - waits.size - ? `CLIProxyAPI quota: retry in ${Math.floor(remaining / 3600)}h ${Math.floor(remaining / 60) % 60}m ${remaining % 60}s` - : undefined, - ); - }), + const remaining = Math.min(...waits.values()); + context.ui.setStatus( + "cliproxyapi-quota", + waits.size + ? `CLIProxyAPI quota: retry in ${Math.floor(remaining / 3600)}h ${Math.floor(remaining / 60) % 60}m ${remaining % 60}s` + : undefined, + ); + }, + compactionSignals, + ), ); registerMediaTools(pi, config); registerModelPicker(pi, config); diff --git a/package.json b/package.json index 68280a0..e414ccc 100644 --- a/package.json +++ b/package.json @@ -39,9 +39,9 @@ }, "devDependencies": { "@biomejs/biome": "^2.5.13", - "@earendil-works/pi-ai": "^1.0.4", - "@earendil-works/pi-coding-agent": "^1.0.4", - "@earendil-works/pi-tui": "^1.0.4", + "@earendil-works/pi-ai": "^1.1.0", + "@earendil-works/pi-coding-agent": "^1.1.0", + "@earendil-works/pi-tui": "^1.1.0", "@types/node": "^24.13.4", "typescript": "5.9.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d98bcd..d681f51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,14 +12,14 @@ importers: specifier: ^2.5.13 version: 2.5.13 '@earendil-works/pi-ai': - specifier: ^1.0.4 - version: 1.0.4(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3) + specifier: ^1.1.0 + version: 1.1.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3) '@earendil-works/pi-coding-agent': - specifier: ^1.0.4 - version: 1.0.4(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3) + specifier: ^1.1.0 + version: 1.1.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3) '@earendil-works/pi-tui': - specifier: ^1.0.4 - version: 1.0.4 + specifier: ^1.1.0 + version: 1.1.0 '@types/node': specifier: ^24.13.4 version: 24.13.4 @@ -179,38 +179,38 @@ packages: cpu: [x64] os: [win32] - '@earendil-works/chord@1.0.4': - resolution: {integrity: sha512-d2JZw2utoa/reoSmL07O71wsXiMDhIlnnxTb4LCNV+Oq8Evj2rGLvjVuYrlXZGbXUr0IDNfvd1RT/utCY1UhkQ==} + '@earendil-works/chord@1.1.0': + resolution: {integrity: sha512-gsHzKfyQ3t0ZIQ3cLBjJ5Vqy1TYnFyruHIV3g3BwmulCDYe5BW4yT9vaJk9QzN6YANILIO75uzuq+Gdxtzz1og==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-agent-core@1.0.4': - resolution: {integrity: sha512-/xGICE+N/+G9N7KfYFSxlE8G8fQcBlLHKiY+j1Ehz9vvb1zhvGneWosFK56d2qmA5oteafeG7yKr2nqU4ed9ig==} + '@earendil-works/pi-agent-core@1.1.0': + resolution: {integrity: sha512-aX1KZomNCPmwYnXa3OivF3VYLJ+WPUkIJlEIZTgwdOZdY/+oToWTQ334WYpQeOz9POpiYFNMJLPwIhXQ4e48kg==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-ai@1.0.4': - resolution: {integrity: sha512-/Eu5R0gfor6wcmRcVyOmulUiZmsHJ0bKu5wXXnpCoSHF3y4FnW4fgnjRlLjNza8NJWXdvW5Uew90Zukx7Sh2rw==} + '@earendil-works/pi-ai@1.1.0': + resolution: {integrity: sha512-1T7LAkc/5Bvc0v6w4vAGVdCrli0o/E0pEmYKTnixu95vSFArBjvbhS/G4ZwI0RUePgf0Imcu0VyqlM4EcXxqfw==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-codemode@1.0.4': - resolution: {integrity: sha512-e198pjBsdEQJ/RCjJ9ZeYIjRaQYWUgtoZbPCmJ57auuOsiRsBXeSZ1aiWtTah0/7gx97WIOD1XqHvgRU0gOXtg==} + '@earendil-works/pi-codemode@1.1.0': + resolution: {integrity: sha512-8Asc2AzhoNaXS1snmRFa96OhcWIlFM5k1Kuiz6p2DvIYgi5QHQccRAmDVY9oO92TzWrizpvQpconZGQ7JhlI7Q==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-coding-agent@1.0.4': - resolution: {integrity: sha512-+956nfMFHr5lDUVY/2Q4k+YzojzBuCaBXFgj0eSlXVGr7QVliVddKdc1Pz6yVg1dOlJQmb67doOVrlMsIcIdaw==} + '@earendil-works/pi-coding-agent@1.1.0': + resolution: {integrity: sha512-SeEi/4hdcHNgA9UWlefZl7ZZpm3dzi2OoxNjDHsBJ9o298LNOtbL4DGKgitlEj6uCTccvtw6f2hlCkTPVJ2RXg==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-mcp@1.0.4': - resolution: {integrity: sha512-I6SqOqrKED+XrjkkFIXY7Ze38QtXtjojPJlQsJ2v5soCPV4QI1bNxXDwQaqzZ2a+6pNN8meWrRw1qkSMbPy9QQ==} + '@earendil-works/pi-mcp@1.1.0': + resolution: {integrity: sha512-xGKwvu3SvVTeoIx8Z7UGoJprB4VK20wKORMhufcWGx61fIauhu3J+UqfB5eeoF1oE/geYBA598K6nh1SdABQkg==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-telemetry@1.0.4': - resolution: {integrity: sha512-DL2eXXtnL5/VQkuQd97fL57GXTG1/zKHtMmDsrv8tGeJ5EgYYTWvzy44dqbYL0lD0Z22v/NOwqU3PHO75HVrqA==} + '@earendil-works/pi-telemetry@1.1.0': + resolution: {integrity: sha512-8gAK05/2pPozZZz6hCLOi8x2vsqzvsmO9gG92kbvuvTPm0qMHaFlaXX98ndXsrOi5pmzha7vNLguuG4+0Rgxjw==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-tui@1.0.4': - resolution: {integrity: sha512-TgvDJohtxvG3XWQID5w6bwEyBocywdG0F9yHZtP+Ywvtiq9RgeSMbeB8quUobk7/4ko6IvFOQo45AqIqNVr8Rg==} + '@earendil-works/pi-tui@1.1.0': + resolution: {integrity: sha512-v7wkS0y2ErZvZkSfemqd9RrBZJ5x6p8Ujsv7NdJj01IXJyFM0kNL6rMdcKcvTe7EVTd6ugvDB9VPZSoaBF9QxQ==} engines: {node: '>=22.19.0'} '@esbuild/aix-ppc64@0.28.2': @@ -971,13 +971,13 @@ snapshots: '@biomejs/cli-win32-x64@2.5.13': optional: true - '@earendil-works/chord@1.0.4': + '@earendil-works/chord@1.1.0': dependencies: esbuild: 0.28.2 - '@earendil-works/pi-agent-core@1.0.4(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3)': + '@earendil-works/pi-agent-core@1.1.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3)': dependencies: - '@earendil-works/pi-ai': 1.0.4(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3) + '@earendil-works/pi-ai': 1.1.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3) typebox: 1.3.27 transitivePeerDependencies: - '@aws-sdk/credential-provider-node' @@ -992,11 +992,11 @@ snapshots: - ws - zod - '@earendil-works/pi-ai@1.0.4(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3)': + '@earendil-works/pi-ai@1.1.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3)': dependencies: '@anthropic-ai/sdk': 0.129.0 '@aws-sdk/client-bedrock-runtime': 3.1127.0 - '@earendil-works/pi-telemetry': 1.0.4 + '@earendil-works/pi-telemetry': 1.1.0 '@google/genai': 2.21.0 '@smithy/node-http-handler': 4.12.1 http-proxy-agent: 9.1.0 @@ -1017,18 +1017,18 @@ snapshots: - ws - zod - '@earendil-works/pi-codemode@1.0.4': + '@earendil-works/pi-codemode@1.1.0': dependencies: quickjs-wasi: 3.6.2 - '@earendil-works/pi-coding-agent@1.0.4(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)': + '@earendil-works/pi-coding-agent@1.1.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)': dependencies: - '@earendil-works/chord': 1.0.4 - '@earendil-works/pi-agent-core': 1.0.4(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3) - '@earendil-works/pi-ai': 1.0.4(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3) - '@earendil-works/pi-codemode': 1.0.4 - '@earendil-works/pi-mcp': 1.0.4 - '@earendil-works/pi-tui': 1.0.4 + '@earendil-works/chord': 1.1.0 + '@earendil-works/pi-agent-core': 1.1.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3) + '@earendil-works/pi-ai': 1.1.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.10.2)(ws@8.21.3) + '@earendil-works/pi-codemode': 1.1.0 + '@earendil-works/pi-mcp': 1.1.0 + '@earendil-works/pi-tui': 1.1.0 '@silvia-odwyer/photon-node': 0.3.4 brace-expansion: 5.0.12 chalk: 6.0.0 @@ -1058,13 +1058,13 @@ snapshots: - ws - zod - '@earendil-works/pi-mcp@1.0.4': + '@earendil-works/pi-mcp@1.1.0': dependencies: cross-spawn: 7.0.6 - '@earendil-works/pi-telemetry@1.0.4': {} + '@earendil-works/pi-telemetry@1.1.0': {} - '@earendil-works/pi-tui@1.0.4': + '@earendil-works/pi-tui@1.1.0': dependencies: get-east-asian-width: 1.6.0 marked: 18.0.11 diff --git a/src/compaction.ts b/src/compaction.ts new file mode 100644 index 0000000..fe23a3d --- /dev/null +++ b/src/compaction.ts @@ -0,0 +1,78 @@ +import { contentText, lazyStream, type ProviderStreams } from "@earendil-works/pi-ai"; + +export function withCompactionRecovery( + stream: ProviderStreams["streamSimple"], + signals: WeakSet, +): ProviderStreams["streamSimple"] { + return (model, context, options) => { + if (!options?.signal || !signals.has(options.signal)) return stream(model, context, options); + const signal = options.signal; + return lazyStream(model, async () => ({ + async *[Symbol.asyncIterator]() { + const limit = model.maxTokens > 0 ? model.maxTokens : 32768; + let maxTokens = Math.max(1, Math.min(options.maxTokens ?? 16384, limit)); + const reasoning = + options.reasoning && !["minimal", "low"].includes(options.reasoning) ? "low" : options.reasoning; + const run = () => + stream(model, context, { + ...options, + maxTokens, + reasoning, + // Chat-specific thinking budgets must not consume the summary's output allowance. + thinkingBudgets: undefined, + }).result(); + let response = await run(); + const retryTokens = Math.min(Math.max(maxTokens * 2, 16384), limit); + const empty = response.stopReason === "stop" && !contentText(response.content).trim(); + if ( + !signal.aborted && + !response.content.some((block) => block.type === "toolCall") && + ((response.stopReason === "length" && retryTokens > maxTokens) || empty) + ) { + maxTokens = retryTokens; + const previous = response.usage; + response = await run(); + response = { ...response, usage: structuredClone(response.usage) }; + for (const key of [ + "input", + "output", + "cacheRead", + "cacheWrite", + "cacheWrite1h", + "reasoning", + "totalTokens", + ] as const) { + if (previous[key] !== undefined) response.usage[key] = (response.usage[key] ?? 0) + previous[key]; + } + for (const key of ["input", "output", "cacheRead", "cacheWrite", "total"] as const) + response.usage.cost[key] += previous.cost[key]; + } + if (signal.aborted) { + yield { + type: "error", + reason: "aborted", + error: { ...response, stopReason: "aborted", errorMessage: "Compaction was aborted." }, + }; + return; + } + if ( + response.content.some((block) => block.type === "toolCall") || + !["stop", "length", "error"].includes(response.stopReason) || + (response.stopReason === "stop" && !contentText(response.content).trim()) + ) { + response = { + ...response, + stopReason: "error", + errorMessage: "CLIProxyAPI compaction did not return a complete text summary.", + }; + } + if (response.stopReason === "stop" || response.stopReason === "length") { + yield { type: "start", partial: { ...response, content: [], stopReason: "pending" } }; + yield { type: "done", reason: response.stopReason, message: response }; + } else { + yield { type: "error", reason: "error", error: response }; + } + }, + })); + }; +} diff --git a/src/provider.ts b/src/provider.ts index ebcaed3..514339c 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -20,6 +20,7 @@ import { import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all"; import { type CpaApi, endpoint, mapCatalog, modelRoute } from "./catalog.ts"; import { type QuotaWaitHandler, waitForClaudeQuota } from "./claude-quota.ts"; +import { withCompactionRecovery } from "./compaction.ts"; import { type Config, isRecord, PROVIDER_ID } from "./config.ts"; export type { QuotaWaitHandler } from "./claude-quota.ts"; @@ -176,7 +177,12 @@ async function waitForQuota(resetAt: number, signal?: AbortSignal, onWait?: Quot } } -function route(streams: ProviderStreams, config: Config, onWait?: QuotaWaitHandler): ProviderStreams { +function route( + streams: ProviderStreams, + config: Config, + compactionSignals: WeakSet, + onWait?: QuotaWaitHandler, +): ProviderStreams { const wrap = (stream: ProviderStreams["streamSimple"]): ProviderStreams["streamSimple"] => (selected, context, options) => { @@ -330,13 +336,17 @@ function route(streams: ProviderStreams, config: Config, onWait?: QuotaWaitHandl }, })); }; - return { stream: wrap(streams.stream), streamSimple: wrap(streams.streamSimple) }; + return { + stream: wrap(streams.stream), + streamSimple: withCompactionRecovery(wrap(streams.streamSimple), compactionSignals), + }; } export function createCliproxyProvider( config: Config, known: readonly Model[] = builtinCatalog(), onQuotaWait?: QuotaWaitHandler, + compactionSignals = new WeakSet(), ) { const standardAuth = envApiKeyAuth("CLIProxyAPI API key", ["CLIPROXYAPI_API_KEY"]); const scope = createHash("sha256") @@ -372,10 +382,10 @@ export function createCliproxyProvider( return (await discover(config, context.credential.key, context.signal, known)).models; }, api: { - "anthropic-messages": route(anthropicMessagesApi(), config, onQuotaWait), - "openai-responses": route(openAIResponsesApi(), config, onQuotaWait), - "openai-completions": route(openAICompletionsApi(), config, onQuotaWait), - "google-generative-ai": route(googleGenerativeAIApi(), config, onQuotaWait), + "anthropic-messages": route(anthropicMessagesApi(), config, compactionSignals, onQuotaWait), + "openai-responses": route(openAIResponsesApi(), config, compactionSignals, onQuotaWait), + "openai-completions": route(openAICompletionsApi(), config, compactionSignals, onQuotaWait), + "google-generative-ai": route(googleGenerativeAIApi(), config, compactionSignals, onQuotaWait), }, }); const refresh = provider.refreshModels; diff --git a/test/compaction.test.ts b/test/compaction.test.ts new file mode 100644 index 0000000..4d04107 --- /dev/null +++ b/test/compaction.test.ts @@ -0,0 +1,328 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { + type AssistantMessage, + contentText, + createAssistantMessageEventStream, + InMemoryCredentialStore, + InMemoryModelsStore, + type Model, + normalizeContext, + type SimpleStreamOptions, +} from "@earendil-works/pi-ai"; +import { + createAgentSession, + DefaultResourceLoader, + ModelRuntime, + SessionManager, + SettingsManager, +} from "@earendil-works/pi-coding-agent"; +import extension from "../extensions/index.ts"; +import { mapCatalog } from "../src/catalog.ts"; +import { withCompactionRecovery } from "../src/compaction.ts"; +import { isRecord, parseConfig } from "../src/config.ts"; +import { builtinCatalog } from "../src/provider.ts"; + +const model: Model<"openai-responses"> = { + id: "gpt-fixture", + name: "Fixture", + api: "openai-responses", + provider: "cliproxyapi", + baseUrl: "https://proxy.example/v1", + reasoning: true, + input: ["text"], + contextWindow: 128000, + maxTokens: 32768, + cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 }, +}; +const context = normalizeContext({ messages: [{ role: "user", content: "Summarize.", timestamp: 0 }] }); + +function response(overrides: Partial = {}): AssistantMessage { + return { + role: "assistant", + api: model.api, + provider: model.provider, + model: model.id, + content: [{ type: "text", text: "Complete summary." }], + stopReason: "stop", + timestamp: 0, + usage: { + input: 10, + output: 5, + cacheRead: 2, + cacheWrite: 1, + cacheWrite1h: 1, + reasoning: 3, + totalTokens: 18, + cost: { input: 1, output: 2, cacheRead: 3, cacheWrite: 4, total: 10 }, + }, + ...overrides, + }; +} + +function fixture(responses: AssistantMessage[], onCall?: () => void) { + const controller = new AbortController(); + const signals = new WeakSet([controller.signal]); + const calls: SimpleStreamOptions[] = []; + const stream = withCompactionRecovery((_model, requestContext, options) => { + assert.equal(requestContext, context); + calls.push(options ?? {}); + onCall?.(); + const result = responses[calls.length - 1]; + assert.ok(result, "Unexpected extra request"); + const stream = createAssistantMessageEventStream(); + stream.end(result); + return stream; + }, signals); + const options: SimpleStreamOptions = { + signal: controller.signal, + maxTokens: 8192, + reasoning: "max", + thinkingBudgets: { low: 30000 }, + sessionId: "fixture-routing-id", + cacheRetention: "none", + headers: { "x-fixture": "preserved" }, + env: { FIXTURE: "preserved" }, + }; + return { stream, calls, controller, options }; +} + +test("compaction retries a capped summary once with more output room and lower thinking", async () => { + const first = response({ stopReason: "length", content: [{ type: "text", text: "Partial" }] }); + const second = response(); + const original = structuredClone([first, second]); + const { stream, calls, options } = fixture([first, second]); + const output = stream(model, context, options); + const events = []; + for await (const event of output) events.push(event); + const result = await output.result(); + assert.equal(contentText(result.content), "Complete summary."); + assert.deepEqual( + events.map((event) => event.type), + ["start", "done"], + ); + assert.ok(!JSON.stringify(events).includes("Partial")); + assert.deepEqual( + calls.map((call) => call.maxTokens), + [8192, 16384], + ); + for (const call of calls) { + assert.equal(call.reasoning, "low"); + assert.equal(call.thinkingBudgets, undefined); + assert.equal(call.signal, options.signal); + assert.equal(call.headers, options.headers); + assert.equal(call.env, options.env); + assert.equal(call.sessionId, options.sessionId); + assert.equal(call.cacheRetention, "none"); + } + assert.equal(result.usage.input, 20); + assert.equal(result.usage.output, 10); + assert.equal(result.usage.totalTokens, 36); + assert.equal(result.usage.cacheWrite1h, 2); + assert.equal(result.usage.reasoning, 6); + assert.equal(result.usage.cost.total, 20); + assert.deepEqual([first, second], original); + assert.equal(options.reasoning, "max"); + assert.equal(options.maxTokens, 8192); +}); + +test("ordinary requests keep their options, result, and retry behavior", async () => { + const original = response({ stopReason: "length" }); + const { stream, calls, options } = fixture([original]); + const ordinary = { ...options, signal: new AbortController().signal }; + assert.equal(await stream(model, context, ordinary).result(), original); + assert.equal(calls.length, 1); + assert.equal(calls[0], ordinary); +}); + +test("output retries stay within the model ceiling and stop after one retry", async () => { + for (const maxTokens of [8192, 12000, 32768]) { + const { stream, calls, options } = fixture([ + response({ stopReason: "length" }), + response({ stopReason: "length" }), + ]); + const result = await stream({ ...model, maxTokens }, context, options).result(); + assert.equal(result.stopReason, "length"); + assert.equal(calls.length, maxTokens === 8192 ? 1 : 2); + assert.ok(calls.every((call) => call.maxTokens !== undefined && call.maxTokens <= maxTokens)); + } +}); + +test("empty and thinking-only summaries get one retry and cannot become checkpoints", async () => { + for (const content of [ + [], + [{ type: "text", text: " \n" }], + [{ type: "thinking", thinking: "Thinking" }], + ] as const) { + for (const recover of [false, true]) { + const empty = response({ content: [...content] }); + const { stream, calls, options } = fixture([empty, recover ? response() : empty]); + const result = await stream(model, context, options).result(); + assert.equal(result.stopReason, recover ? "stop" : "error"); + assert.equal(calls.length, 2); + if (!recover) assert.match(result.errorMessage ?? "", /complete text summary/); + } + } +}); + +test("aborts, tool calls, and provider errors do not trigger output retries", async () => { + for (const failure of [ + response({ stopReason: "error", errorMessage: "401 Unauthorized" }), + response({ stopReason: "error", errorMessage: "context_length_exceeded" }), + response({ stopReason: "error", errorMessage: "terminated" }), + response({ stopReason: "aborted", content: [] }), + response({ stopReason: "deferred", content: [] }), + response({ + stopReason: "toolUse", + content: [{ type: "toolCall", id: "1", name: "read", arguments: {} }], + }), + ]) { + const { stream, calls, options } = fixture([failure]); + const result = await stream(model, context, options).result(); + assert.equal(calls.length, 1); + assert.equal(result.stopReason, "error"); + if (failure.stopReason === "error") assert.equal(result.errorMessage, failure.errorMessage); + } + let abort = () => {}; + const { stream, calls, options, controller } = fixture([response({ stopReason: "length" })], () => abort()); + abort = () => controller.abort(); + const result = await stream(model, context, options).result(); + assert.equal(calls.length, 1); + assert.equal(result.stopReason, "aborted"); +}); + +for (const mode of ["manual", "threshold", "split", "exhausted", "empty", "transient"] as const) + test(`Pi preserves native compaction behavior: ${mode}`, { + timeout: 30000, + }, async (t) => { + const directory = await mkdtemp(join(tmpdir(), "picpa-compaction-")); + t.after(() => rm(directory, { recursive: true, force: true })); + for (const [key, value] of Object.entries({ + PI_CODING_AGENT_DIR: directory, + CLIPROXYAPI_BASE_URL: "http://localhost:8317", + })) { + const previous = process.env[key]; + process.env[key] = value; + t.after(() => { + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + }); + } + const source = builtinCatalog().find( + (entry) => entry.provider === "openai" && entry.reasoning && entry.maxTokens > 32768, + ); + assert.ok(source); + const selected = mapCatalog({ data: [{ id: source.id }] }, parseConfig({}), [source]).models[0]; + const credentials = new InMemoryCredentialStore(); + await credentials.modify("cliproxyapi", async () => ({ type: "api_key", key: "fixture-key" })); + const runtime = await ModelRuntime.create({ + modelsPath: null, + modelsStore: new InMemoryModelsStore(), + credentials, + }); + const resourceLoader = new DefaultResourceLoader({ + cwd: directory, + agentDir: directory, + noExtensions: true, + noSkills: true, + noContextFiles: true, + extensionFactories: [extension], + }); + await resourceLoader.reload(); + assert.deepEqual(resourceLoader.getExtensions().errors, []); + const bodies: Record[] = []; + t.mock.method(globalThis, "fetch", async (input: Parameters[0], init?: RequestInit) => { + assert.equal(String(input), "http://localhost:8317/v1/responses"); + assert.equal(new Headers(init?.headers).get("authorization"), "Bearer fixture-key"); + const body: unknown = JSON.parse(String(init?.body)); + assert.ok(isRecord(body)); + bodies.push(body); + if (mode === "transient" && bodies.length === 1) + return Response.json({ error: { message: "server_busy" } }, { status: 503 }); + const capped = mode === "exhausted" || (bodies.length === 1 && mode !== "empty"); + const text = mode === "empty" ? "" : capped ? "Partial" : "Checkpoint"; + const item = { + type: "message", + id: "msg_fixture", + role: "assistant", + content: [{ type: "output_text", text, annotations: [] }], + }; + const events = [ + { type: "response.created", response: { id: "resp_fixture" } }, + { type: "response.output_item.added", output_index: 0, item: { ...item, content: [] } }, + { type: "response.output_text.delta", output_index: 0, delta: text }, + { type: "response.output_item.done", output_index: 0, item }, + { + type: capped ? "response.incomplete" : "response.completed", + response: { + id: "resp_fixture", + status: capped ? "incomplete" : "completed", + output: [item], + incomplete_details: capped ? { reason: "max_output_tokens" } : undefined, + usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }, + }, + }, + ]; + return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { + headers: { "content-type": "text/event-stream" }, + }); + }); + const sessionManager = SessionManager.inMemory(directory); + sessionManager.appendMessage({ role: "user", content: "Earlier task", timestamp: 0 }); + sessionManager.appendMessage( + response({ + usage: { ...response().usage, input: selected.contextWindow, totalTokens: selected.contextWindow }, + }), + ); + sessionManager.appendMessage({ role: "user", content: "Keep this recent task", timestamp: 1 }); + if (mode === "split") sessionManager.appendMessage(response()); + const { session } = await createAgentSession({ + cwd: directory, + agentDir: directory, + modelRuntime: runtime, + model: selected, + thinkingLevel: "high", + settingsManager: SettingsManager.inMemory({ + compaction: { enabled: true, reserveTokens: 16384, keepRecentTokens: 1 }, + retry: { enabled: mode === "transient", maxRetries: 1, baseDelayMs: 0, provider: { maxRetries: 0 } }, + }), + sessionManager, + resourceLoader, + tools: [], + }); + t.after(() => session.dispose()); + await session.bindExtensions({}); + if (mode === "exhausted" || mode === "empty") { + await assert.rejects(session.compact(), mode === "exhausted" ? /token cap/ : /complete text summary/); + assert.equal(bodies.length, 2); + assert.ok(!sessionManager.getEntries().some((entry) => entry.type === "compaction")); + assert.equal(sessionManager.buildSessionContext().messages.length, 3); + return; + } + if (mode === "threshold") await session.prompt("Continue."); + else { + const result = await session.compact("Preserve file paths."); + assert.ok(result.summary.includes("Checkpoint")); + assert.ok(JSON.stringify(bodies[0]).includes("Preserve file paths.")); + } + const checkpoint = sessionManager.getEntries().find((entry) => entry.type === "compaction"); + assert.ok(checkpoint && checkpoint.type === "compaction"); + assert.equal(checkpoint.fromHook, false); + assert.equal(checkpoint.usage?.totalTokens, mode === "split" ? 45 : mode === "transient" ? 15 : 30); + const summaries = mode === "threshold" ? bodies.slice(0, -1) : bodies; + assert.deepEqual( + summaries.map((body) => body.max_output_tokens), + mode === "split" ? [13107, 26214, 8192] : mode === "transient" ? [13107, 13107] : [13107, 26214], + ); + assert.ok(summaries.every((body) => isRecord(body.reasoning) && body.reasoning.effort === "low")); + assert.equal(session.thinkingLevel, "high"); + if (mode === "threshold") { + assert.equal(bodies.length, 3); + assert.deepEqual(bodies.at(-1)?.reasoning, { effort: "high", summary: "auto" }); + } else if (mode !== "split") { + assert.equal(sessionManager.buildSessionContext().messages.at(-1)?.role, "user"); + } + }); diff --git a/test/package.test.ts b/test/package.test.ts index 0bfdbe5..79df7b1 100644 --- a/test/package.test.ts +++ b/test/package.test.ts @@ -41,6 +41,7 @@ test("npm package contains only release files and loads without checkout depende "package.json", "src/catalog.ts", "src/claude-quota.ts", + "src/compaction.ts", "src/config.ts", "src/media-defaults.ts", "src/media.ts",