diff --git a/.changeset/anthropic-preserved-thinking-by-default.md b/.changeset/anthropic-preserved-thinking-by-default.md new file mode 100644 index 000000000..a325ad279 --- /dev/null +++ b/.changeset/anthropic-preserved-thinking-by-default.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Preserve prior turns' thinking by default on the Anthropic provider (Claude and Kimi's Anthropic-compatible mode), matching the Kimi default. Disable with `[thinking] keep = "off"` or `KIMI_MODEL_THINKING_KEEP=off`. diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 6c865956d..055b59182 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -169,7 +169,7 @@ You can also switch models temporarily without touching the config file — by s | --- | --- | --- | --- | | `enabled` | `boolean` | `true` | Whether Thinking is enabled by default for new sessions; set to `false` to force Thinking off | | `effort` | `string` | — | Thinking effort level (for example `low`, `medium`, `high`, `xhigh`, `max`); the levels actually available depend on the model's declared `support_efforts`, and unrecognized values are ignored by the provider | -| `keep` | `string` | `"all"` | Moonshot Preserved Thinking passthrough (`thinking.keep`). `"all"` preserves previous turns' `reasoning_content`; set to an off-value (`false`/`0`/`no`/`off`/`none`/`null`) to disable. Overridden by `KIMI_MODEL_THINKING_KEEP`; applies to the `kimi` provider only, and only while Thinking is on | +| `keep` | `string` | `"all"` | Preserved Thinking passthrough. On `kimi` it is sent as `thinking.keep`; on `anthropic` (Claude and Kimi's Anthropic-compatible mode) it is sent as a `context_management` `clear_thinking_20251015` edit (enabling keep routes Anthropic requests to the beta Messages API; an off-value disables keep and returns to the standard endpoint). `"all"` preserves prior turns' reasoning (`reasoning_content` / Anthropic thinking blocks); set to an off-value (`false`/`0`/`no`/`off`/`none`/`null`) to disable. Overridden by `KIMI_MODEL_THINKING_KEEP`; only injected while Thinking is on | ### Deprecated fields diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 9b4665219..29d4247b2 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -130,7 +130,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_MODEL_TEMPERATURE` | Sampling temperature for every request; applies to the `kimi` provider only (global — independent of `KIMI_MODEL_NAME`) | Number, e.g. `0.3` | | `KIMI_MODEL_TOP_P` | Nucleus-sampling `top_p` for every request; applies to the `kimi` provider only (global) | Number, e.g. `0.95` | | `KIMI_MODEL_THINKING_EFFORT` | Force a specific thinking effort on the wire (`thinking.effort`), bypassing the model's declared `support_efforts`; applies to the `kimi` provider only, and only while Thinking is on | An effort value, e.g. `max` | -| `KIMI_MODEL_THINKING_KEEP` | Moonshot preserved-thinking passthrough (`thinking.keep`); overrides `[thinking] keep` (which defaults to `"all"`); applies to the `kimi` provider only, and only while Thinking is on | A value the API accepts, e.g. `all`; an off-value (`false`/`0`/`no`/`off`/`none`/`null`) disables it | +| `KIMI_MODEL_THINKING_KEEP` | Preserved-thinking passthrough; on `kimi` sent as `thinking.keep`, on `anthropic` (Claude and Kimi's Anthropic-compatible mode) sent as a `context_management` `clear_thinking_20251015` edit (enabling keep routes Anthropic requests to the beta Messages API); overrides `[thinking] keep` (which defaults to `"all"`); only injected while Thinking is on | A value the API accepts, e.g. `all`; an off-value (`false`/`0`/`no`/`off`/`none`/`null`) disables it | | `KIMI_CODE_NO_AUTO_UPDATE` | Fully disable the update preflight — no check, background install, or prompt. Legacy alias `KIMI_CLI_NO_AUTO_UPDATE` is also honored | Truthy: `1`/`true`/`yes`/`on` | | `KIMI_DISABLE_CRON` | Disable the scheduled-task tool (`CronCreate` rejects new schedules; existing tasks do not fire) | `1` to disable | diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 8a379a540..144dc6aac 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -169,7 +169,7 @@ display_name = "Kimi for Coding (custom)" | --- | --- | --- | --- | | `enabled` | `boolean` | `true` | 新会话是否默认开启 Thinking,设为 `false` 可强制关闭 | | `effort` | `string` | — | Thinking 强度(例如 `low`、`medium`、`high`、`xhigh`、`max`),实际可用等级取决于模型声明的 `support_efforts`,未识别的值会被供应商忽略 | -| `keep` | `string` | `"all"` | Moonshot 保留思考透传(`thinking.keep`)。`"all"` 会保留历史轮次的 `reasoning_content`;传入关值(`false`/`0`/`no`/`off`/`none`/`null`)可禁用。可被 `KIMI_MODEL_THINKING_KEEP` 覆盖;仅对 `kimi` 供应商生效,且仅在 Thinking 开启时注入 | +| `keep` | `string` | `"all"` | 保留思考透传。在 `kimi` 上以 `thinking.keep` 发送;在 `anthropic`(Claude 以及 Kimi 的 Anthropic 兼容模式)上以 `context_management` 的 `clear_thinking_20251015` 编辑发送(开启 keep 会让 Anthropic 请求走 beta Messages API;关值可禁用 keep 并回到标准端点)。`"all"` 会保留历史轮次的思考内容(`reasoning_content` / Anthropic thinking blocks);传入关值(`false`/`0`/`no`/`off`/`none`/`null`)可禁用。可被 `KIMI_MODEL_THINKING_KEEP` 覆盖;仅在 Thinking 开启时注入 | ### 已废弃字段 diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index a6d81a336..0a4a59e9b 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -130,7 +130,7 @@ kimi | `KIMI_MODEL_TEMPERATURE` | 每次请求的采样温度,仅对 `kimi` 供应商生效(全局生效,不依赖 `KIMI_MODEL_NAME`) | 数字,如 `0.3` | | `KIMI_MODEL_TOP_P` | 每次请求的核采样 `top_p`,仅对 `kimi` 供应商生效(全局生效) | 数字,如 `0.95` | | `KIMI_MODEL_THINKING_EFFORT` | 在线上强制使用指定的思考强度(`thinking.effort`),绕过模型声明的 `support_efforts`;仅对 `kimi` 供应商生效,且仅在 Thinking 开启时注入 | 思考强度值,如 `max` | -| `KIMI_MODEL_THINKING_KEEP` | Moonshot 保留思考透传(`thinking.keep`);覆盖 `[thinking] keep`(其默认值为 `"all"`);仅对 `kimi` 供应商生效,且仅在 Thinking 开启时注入 | API 接受的值,如 `all`;传入关值(`false`/`0`/`no`/`off`/`none`/`null`)可禁用 | +| `KIMI_MODEL_THINKING_KEEP` | 保留思考透传;在 `kimi` 上以 `thinking.keep` 发送,在 `anthropic`(Claude 以及 Kimi 的 Anthropic 兼容模式)上以 `context_management` 的 `clear_thinking_20251015` 编辑发送(开启 keep 会让 Anthropic 请求走 beta Messages API);覆盖 `[thinking] keep`(其默认值为 `"all"`);仅在 Thinking 开启时注入 | API 接受的值,如 `all`;传入关值(`false`/`0`/`no`/`off`/`none`/`null`)可禁用 | | `KIMI_CODE_NO_AUTO_UPDATE` | 完全禁用更新预检——不检查、不后台安装、不提示。同时兼容旧名 `KIMI_CLI_NO_AUTO_UPDATE` | 真值:`1`/`true`/`yes`/`on` | | `KIMI_DISABLE_CRON` | 禁用定时任务工具(`CronCreate` 拒绝新计划,已有任务不触发) | `1` 表示禁用 | diff --git a/packages/agent-core/src/agent/config/index.ts b/packages/agent-core/src/agent/config/index.ts index fcd47d5bb..0eb7c1db1 100644 --- a/packages/agent-core/src/agent/config/index.ts +++ b/packages/agent-core/src/agent/config/index.ts @@ -7,6 +7,7 @@ import { } from '@moonshot-ai/kosong'; import { + applyAnthropicThinkingKeep, applyKimiEnvSamplingParams, applyKimiEnvThinkingEffort, applyKimiEnvThinkingKeep, @@ -122,16 +123,20 @@ export class ConfigState { // - withThinking: preserve thinking during compaction (#464) // - sampling params: KIMI_MODEL_TEMPERATURE / KIMI_MODEL_TOP_P // - thinking.effort: KIMI_MODEL_THINKING_EFFORT (forces an effort, only while thinking is on) - // - thinking.keep: env KIMI_MODEL_THINKING_KEEP > config thinking.keep > default "all" (only while thinking is on) + // - thinking.keep: env KIMI_MODEL_THINKING_KEEP > config thinking.keep > default "all" + // (only while thinking is on). Drives Kimi's `thinking.keep` and, on the + // Anthropic path, a `context_management` `clear_thinking_20251015` edit. const provider = createProvider(this.providerConfig).withThinking(this.thinkingEffort); const withSampling = applyKimiEnvSamplingParams(provider); const withEffort = applyKimiEnvThinkingEffort(withSampling, this.thinkingEffort); - return applyKimiEnvThinkingKeep( + const configKeep = this.agent.kimiConfig?.thinking?.keep; + const withKimiKeep = applyKimiEnvThinkingKeep( withEffort, this.thinkingEffort, undefined, - this.agent.kimiConfig?.thinking?.keep, + configKeep, ); + return applyAnthropicThinkingKeep(withKimiKeep, this.thinkingEffort, undefined, configKeep); } get model(): string { diff --git a/packages/agent-core/src/config/kimi-env-params.ts b/packages/agent-core/src/config/kimi-env-params.ts index c8d79c71a..6c6ec3af6 100644 --- a/packages/agent-core/src/config/kimi-env-params.ts +++ b/packages/agent-core/src/config/kimi-env-params.ts @@ -4,6 +4,7 @@ import { KimiChatProvider, type ThinkingEffort, } from '@moonshot-ai/kosong'; +import { AnthropicChatProvider } from '@moonshot-ai/kosong/providers/anthropic'; import { parseFloatEnv } from '#/config/resolve'; @@ -77,12 +78,16 @@ function parseKeepValue(raw: string | undefined): KeepResolution { } /** - * Resolve the Moonshot Preserved Thinking passthrough (`thinking.keep`) with - * precedence env (`KIMI_MODEL_THINKING_KEEP`) > config (`thinking.keep`) > - * default `"all"`. Only meaningful while thinking is on — otherwise the API - * would receive a `thinking.keep` with no accompanying `thinking.type` it - * honors. (Compaction uses a raw provider with thinking off, so it correctly - * resolves to `undefined`.) + * Resolve the Preserved Thinking passthrough (Kimi `thinking.keep` / Anthropic + * `context_management` `clear_thinking_20251015`) with precedence env + * (`KIMI_MODEL_THINKING_KEEP`) > config (`thinking.keep`) > default `"all"`. + * Only meaningful while thinking is on — otherwise the API would receive a keep + * directive with no accompanying `thinking.type` it honors, so it resolves to + * `undefined`. Applied via `ConfigState.provider`, which is shared by the main + * loop AND full-history compaction, so compaction intentionally carries the + * same keep (and, for Anthropic, the beta endpoint) when thinking is on; + * `keep:"all"` prunes nothing and a consistent request shape maximizes KV-cache + * reuse. * * Returns `undefined` when Preserved Thinking should be disabled. */ @@ -115,3 +120,24 @@ export function applyKimiEnvThinkingKeep( if (keep === undefined) return provider; return provider.withExtraBody({ thinking: { keep } }); } + +/** + * Apply the Anthropic equivalent of Preserved Thinking — a `context_management` + * `clear_thinking_20251015` edit carrying `keep` — to an Anthropic chat + * provider. See `resolveThinkingKeep` for precedence. Non-Anthropic providers + * are returned unchanged. Applies to every Anthropic provider (Claude and + * Kimi's Anthropic-compatible mode) while thinking is on; `keep: "all"` tells + * the server to retain all prior thinking blocks (prune none), mirroring Kimi's + * `thinking.keep`. + */ +export function applyAnthropicThinkingKeep( + provider: ChatProvider, + thinkingEffort: ThinkingEffort, + env: Env = process.env, + configKeep?: string, +): ChatProvider { + if (!(provider instanceof AnthropicChatProvider)) return provider; + const keep = resolveThinkingKeep(env, configKeep, thinkingEffort); + if (keep === undefined) return provider; + return provider.withThinkingKeep(keep); +} diff --git a/packages/agent-core/test/agent/config-state.test.ts b/packages/agent-core/test/agent/config-state.test.ts index 7e609bb43..32b127950 100644 --- a/packages/agent-core/test/agent/config-state.test.ts +++ b/packages/agent-core/test/agent/config-state.test.ts @@ -398,4 +398,58 @@ describe('ConfigState.provider applies global KIMI_MODEL_* request config', () = vi.unstubAllEnvs(); } }); + + function anthropicAgentWithThinkingKeep(keep: string | undefined) { + const config: KimiConfig = { + providers: { anthropic: { type: 'anthropic', apiKey: 'test-key' } }, + models: { + 'claude-sonnet-4-6': { + provider: 'anthropic', + model: 'claude-sonnet-4-6', + maxContextSize: 200_000, + capabilities: ['thinking', 'tool_use'], + }, + }, + ...(keep !== undefined ? { thinking: { keep } } : {}), + }; + return testAgent({ + initialConfig: config, + providerManager: new ProviderManager({ config }), + }); + } + + it('injects context_management clear_thinking keep into config.provider for anthropic when thinking is on', () => { + vi.stubEnv('KIMI_MODEL_THINKING_KEEP', 'all'); + try { + const ctx = anthropicAgentWithThinkingKeep(undefined); + ctx.agent.config.update({ modelAlias: 'claude-sonnet-4-6', thinkingEffort: 'high' }); + + const provider = ctx.agent.config.provider; + const gen = Reflect.get(provider as object, '_generationKwargs') as { + contextManagement?: { edits: Array<{ type: string; keep?: string }> }; + betaFeatures?: string[]; + }; + expect(gen.contextManagement).toEqual({ + edits: [{ type: 'clear_thinking_20251015', keep: 'all' }], + }); + expect(gen.betaFeatures).toContain('context-management-2025-06-27'); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('does NOT inject context_management for anthropic when thinking is off', () => { + vi.stubEnv('KIMI_MODEL_THINKING_KEEP', 'all'); + try { + const ctx = anthropicAgentWithThinkingKeep(undefined); + ctx.agent.config.update({ modelAlias: 'claude-sonnet-4-6', thinkingEffort: 'off' }); + + const gen = Reflect.get(ctx.agent.config.provider as object, '_generationKwargs') as { + contextManagement?: unknown; + }; + expect(gen.contextManagement).toBeUndefined(); + } finally { + vi.unstubAllEnvs(); + } + }); }); diff --git a/packages/agent-core/test/config/kimi-env-params.test.ts b/packages/agent-core/test/config/kimi-env-params.test.ts index 69c11fdef..2073d4c50 100644 --- a/packages/agent-core/test/config/kimi-env-params.test.ts +++ b/packages/agent-core/test/config/kimi-env-params.test.ts @@ -1,7 +1,9 @@ import { type ChatProvider, KimiChatProvider } from '@moonshot-ai/kosong'; +import { AnthropicChatProvider } from '@moonshot-ai/kosong/providers/anthropic'; import { describe, expect, it } from 'vitest'; import { + applyAnthropicThinkingKeep, applyKimiEnvSamplingParams, applyKimiEnvThinkingEffort, applyKimiEnvThinkingKeep, @@ -151,3 +153,94 @@ describe('applyKimiEnvThinkingEffort', () => { ).toBe(stub); }); }); + +function anthropic(): AnthropicChatProvider { + return new AnthropicChatProvider({ model: 'claude-sonnet-4-6', apiKey: 'k' }); +} + +interface AnthropicKeepState { + contextManagement?: { edits: Array<{ type: string; keep?: string }> }; + betaFeatures?: string[]; +} + +function anthropicState(provider: ChatProvider): AnthropicKeepState { + return Reflect.get(provider as object, '_generationKwargs') as AnthropicKeepState; +} + +describe('applyAnthropicThinkingKeep', () => { + it('injects context_management keep="all" by default when thinking is on', () => { + const out = applyAnthropicThinkingKeep(anthropic(), 'high', {}); + expect(anthropicState(out).contextManagement).toEqual({ + edits: [{ type: 'clear_thinking_20251015', keep: 'all' }], + }); + expect(anthropicState(out).betaFeatures).toContain('context-management-2025-06-27'); + }); + + it('injects keep from env when thinking is on', () => { + const out = applyAnthropicThinkingKeep(anthropic(), 'high', { KIMI_MODEL_THINKING_KEEP: 'all' }); + expect(anthropicState(out).contextManagement?.edits[0]?.keep).toBe('all'); + }); + + it('injects keep from config when env is unset', () => { + const out = applyAnthropicThinkingKeep(anthropic(), 'high', {}, 'all'); + expect(anthropicState(out).contextManagement?.edits[0]?.keep).toBe('all'); + }); + + it('env takes precedence over config', () => { + const out = applyAnthropicThinkingKeep( + anthropic(), + 'high', + { KIMI_MODEL_THINKING_KEEP: 'all' }, + 'off', + ); + expect(anthropicState(out).contextManagement?.edits[0]?.keep).toBe('all'); + }); + + it.each(['off', 'false', '0', 'no', 'none', 'null', 'OFF', 'None'])( + 'env off-value %s disables keep even when config enables it', + (off) => { + const out = applyAnthropicThinkingKeep( + anthropic(), + 'high', + { KIMI_MODEL_THINKING_KEEP: off }, + 'all', + ); + expect(anthropicState(out).contextManagement).toBeUndefined(); + }, + ); + + it.each(['off', 'none', 'null'])('config off-value %s disables keep by default', (off) => { + const out = applyAnthropicThinkingKeep(anthropic(), 'high', {}, off); + expect(anthropicState(out).contextManagement).toBeUndefined(); + }); + + it('blank env falls through to config', () => { + const out = applyAnthropicThinkingKeep( + anthropic(), + 'high', + { KIMI_MODEL_THINKING_KEEP: ' ' }, + 'off', + ); + expect(anthropicState(out).contextManagement).toBeUndefined(); + }); + + it('does NOT inject context_management when thinking is off', () => { + const out = applyAnthropicThinkingKeep(anthropic(), 'off', { KIMI_MODEL_THINKING_KEEP: 'all' }); + expect(anthropicState(out).contextManagement).toBeUndefined(); + }); + + it('does not duplicate the context-management beta on repeated calls', () => { + const out = applyAnthropicThinkingKeep( + applyAnthropicThinkingKeep(anthropic(), 'high', {}), + 'high', + {}, + ); + const betas = anthropicState(out).betaFeatures ?? []; + expect(betas.filter((b) => b === 'context-management-2025-06-27')).toHaveLength(1); + }); + + it('leaves non-anthropic providers untouched', () => { + const stub = { name: 'stub' } as unknown as ChatProvider; + expect(applyAnthropicThinkingKeep(stub, 'high', { KIMI_MODEL_THINKING_KEEP: 'all' })).toBe(stub); + }); +}); diff --git a/packages/kosong/src/providers/anthropic.ts b/packages/kosong/src/providers/anthropic.ts index 74c99702e..2f57354f7 100644 --- a/packages/kosong/src/providers/anthropic.ts +++ b/packages/kosong/src/providers/anthropic.ts @@ -114,6 +114,17 @@ interface AnthropicGenerationKwargs { thinking?: MessageCreateParams['thinking'] | undefined; output_config?: MessageCreateParams['output_config'] | undefined; betaFeatures?: string[] | undefined; + contextManagement?: AnthropicContextManagement | undefined; +} + +/** + * Anthropic beta context-management payload (`context-management-2025-06-27`). + * Only the `clear_thinking_20251015` edit is emitted today, with `keep` + * forwarded as a string (`"all"`); the `{ type, value }` turn-count form is + * not used because the shared `[thinking] keep` config is a string. + */ +interface AnthropicContextManagement { + edits: Array<{ type: string; keep?: unknown }>; } // Anthropic's native effort values. `ThinkingEffort` is an open string, so after @@ -122,6 +133,8 @@ interface AnthropicGenerationKwargs { type AnthropicEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'; const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14'; +const CONTEXT_MANAGEMENT_BETA = 'context-management-2025-06-27'; +const CLEAR_THINKING_EDIT = 'clear_thinking_20251015'; const OPUS_VERSION_RE = /opus[.-](\d+)[.-](\d{1,2})(?!\d)/; const ADAPTIVE_MIN_VERSION = { major: 4, minor: 6 } as const; const ANTHROPIC_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { @@ -1083,6 +1096,9 @@ export class AnthropicChatProvider implements ChatProvider { if (this._generationKwargs.output_config !== undefined) { kwargs['output_config'] = this._generationKwargs.output_config; } + if (this._generationKwargs.contextManagement !== undefined) { + kwargs['context_management'] = this._generationKwargs.contextManagement; + } // Build the beta feature list. On the standard Messages API these travel // via the `anthropic-beta` header; on the beta Messages API (`betaApi`) the @@ -1289,6 +1305,35 @@ export class AnthropicChatProvider implements ChatProvider { return clone; } + withThinkingKeep(keep: string): AnthropicChatProvider { + const current = this._generationKwargs.betaFeatures ?? []; + const betaFeatures = current.includes(CONTEXT_MANAGEMENT_BETA) + ? current + : [...current, CONTEXT_MANAGEMENT_BETA]; + // Preserve any existing context-management edits (e.g. clear_tool_uses) and + // keep clear_thinking first, as Anthropic requires when combining edits. Drop + // a previous clear_thinking edit so re-applying stays idempotent. + const existingEdits = this._generationKwargs.contextManagement?.edits ?? []; + const edits = [ + { type: CLEAR_THINKING_EDIT, keep }, + ...existingEdits.filter((edit) => edit.type !== CLEAR_THINKING_EDIT), + ]; + const clone = this._withGenerationKwargs({ + contextManagement: { edits }, + betaFeatures, + }); + // clear_thinking_20251015 is honored only on the beta Messages API + // (client.beta.messages.create), so enabling keep forces the beta endpoint + // here even when the provider was constructed with betaApi: false. Setting + // `[thinking] keep` to an off-value (or KIMI_MODEL_THINKING_KEEP=off) is the + // escape hatch that disables keep and returns requests to the standard + // endpoint. This also routes adaptive models (whose withThinking would + // otherwise drop the interleaved-thinking beta and leave betaFeatures empty) + // onto the beta endpoint with a body `betas=[context-management-...]`. + clone._betaApi = true; + return clone; + } + withGenerationKwargs(kwargs: Partial): AnthropicChatProvider { return this._withGenerationKwargs(kwargs); } diff --git a/packages/kosong/test/anthropic.test.ts b/packages/kosong/test/anthropic.test.ts index 470379b83..50f245191 100644 --- a/packages/kosong/test/anthropic.test.ts +++ b/packages/kosong/test/anthropic.test.ts @@ -50,6 +50,7 @@ type AnthropicGenerationState = { | undefined; output_config?: { effort: string } | undefined; betaFeatures?: string[] | undefined; + contextManagement?: { edits: Array<{ type: string; keep?: unknown }> } | undefined; }; function getGenerationState(provider: AnthropicChatProvider): AnthropicGenerationState { @@ -218,6 +219,129 @@ describe('betaApi', () => { }); }); +describe('withThinkingKeep (context_management)', () => { + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }, + ]; + + it('forces the beta endpoint and emits context_management clear_thinking keep with the context-management beta', async () => { + // betaApi is left at its default (false); withThinkingKeep must force it on. + const provider = createProvider().withThinkingKeep('all'); + const body = await captureBetaRequestBody(provider, '', [], history); + + expect(body['context_management']).toEqual({ + edits: [{ type: 'clear_thinking_20251015', keep: 'all' }], + }); + expect(body['betas']).toContain('context-management-2025-06-27'); + expect(body['betas']).toContain('interleaved-thinking-2025-05-14'); + const headers = body['_extra_headers'] as Record | undefined; + expect(headers?.['anthropic-beta']).toBeUndefined(); + }); + + it('prepends clear_thinking before existing context-management edits and keeps them', () => { + const provider = createProvider() + .withGenerationKwargs({ + contextManagement: { + edits: [{ type: 'clear_tool_uses_20250919', keep: { type: 'tool_uses', value: 2 } }], + }, + }) + .withThinkingKeep('all'); + const state = getGenerationState(provider); + expect(state.contextManagement).toEqual({ + edits: [ + { type: 'clear_thinking_20251015', keep: 'all' }, + { type: 'clear_tool_uses_20250919', keep: { type: 'tool_uses', value: 2 } }, + ], + }); + }); + + it('emits no context_management and stays off the beta endpoint when withThinkingKeep is not applied', async () => { + const body = await captureRequestBody(createProvider(), '', [], history); + expect(body['context_management']).toBeUndefined(); + const headers = body['_extra_headers'] as Record | undefined; + expect(headers?.['anthropic-beta']).not.toContain('context-management-2025-06-27'); + }); + + it('does not duplicate the context-management beta or the clear_thinking edit across repeated calls', () => { + const provider = createProvider().withThinkingKeep('all').withThinkingKeep('all'); + const state = getGenerationState(provider); + const betas = state.betaFeatures ?? []; + expect(betas.filter((b) => b === 'context-management-2025-06-27')).toHaveLength(1); + expect(state.contextManagement).toEqual({ + edits: [{ type: 'clear_thinking_20251015', keep: 'all' }], + }); + }); + + // Capture a streaming (stream: true) beta-endpoint request by mocking + // client.beta.messages.create to return a minimal valid stream. + async function captureBetaStreamBody( + provider: AnthropicChatProvider, + history: Message[], + ): Promise> { + let capturedParams: Record | undefined; + let capturedOptions: Record | undefined; + (provider as any)._client.beta.messages.create = vi + .fn() + .mockImplementation((params: unknown, options?: unknown) => { + capturedParams = params as Record; + capturedOptions = options as Record | undefined; + return Promise.resolve( + mockStream([ + { + type: 'message_start', + message: { id: 'm', usage: { input_tokens: 1, output_tokens: 0 } }, + }, + { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'ok' } }, + { type: 'content_block_stop', index: 0 }, + { + type: 'message_delta', + delta: { stop_reason: 'end_turn' }, + usage: { output_tokens: 1 }, + }, + { type: 'message_stop' }, + ]), + ); + }); + const stream = await provider.generate('', [], history); + for await (const part of stream) void part; + if (capturedParams === undefined) { + throw new Error('Expected provider.generate() to call beta.messages.create'); + } + const result = { ...capturedParams }; + if (capturedOptions !== undefined && capturedOptions['headers'] !== undefined) { + result['_extra_headers'] = capturedOptions['headers']; + } + return result; + } + + it('emits context_management on the streaming beta endpoint too', async () => { + const provider = createStreamProvider().withThinkingKeep('all'); + const body = await captureBetaStreamBody(provider, history); + expect(body['context_management']).toEqual({ + edits: [{ type: 'clear_thinking_20251015', keep: 'all' }], + }); + expect(body['betas']).toContain('context-management-2025-06-27'); + const headers = body['_extra_headers'] as Record | undefined; + expect(headers?.['anthropic-beta']).toBeUndefined(); + }); + + it('forces the beta endpoint even when constructed with betaApi: false', async () => { + const provider = new AnthropicChatProvider({ + model: 'kimi-for-coding', + apiKey: 'test-key', + defaultMaxTokens: 1024, + stream: false, + betaApi: false, + }).withThinkingKeep('all'); + const body = await captureBetaRequestBody(provider, '', [], history); + expect(body['context_management']).toEqual({ + edits: [{ type: 'clear_thinking_20251015', keep: 'all' }], + }); + expect(body['betas']).toContain('context-management-2025-06-27'); + }); +}); + describe('AnthropicChatProvider', () => { it('does not read ANTHROPIC_API_KEY from process.env inside the adapter', () => { const previousApiKey = process.env['ANTHROPIC_API_KEY'];