From c48e823f616b98c7d4db47a5563010523eac30fc Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 15 Jun 2026 23:09:23 +0800 Subject: [PATCH 001/383] docs(changelog): sync 0.15.0 release notes into docs site (#793) * docs(changelog): sync 0.15.0 from apps/kimi-code/CHANGELOG.md * docs(changelog): improve Chinese translation for 0.15.0 tool-call status entry * docs(changelog): polish Chinese wording for 0.15.0 entries --- docs/en/release-notes/changelog.md | 33 ++++++++++++++++++++++++++++++ docs/zh/release-notes/changelog.md | 33 ++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 6ddf49d8b..aced4fb31 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -6,6 +6,39 @@ outline: 2 This page documents the changes in each Kimi Code CLI release. +## 0.15.0 (2026-06-15) + +### Features + +- Add an all-sessions picker view with name search, paginated browsing, and clipboard-ready resume commands for sessions in other working directories. +- Add support for legacy SSE MCP servers alongside stdio and streamable HTTP transports. + +### Bug Fixes + +- Recover resumed sessions when an interrupted tool call result was not recorded. +- Stop writing resume version markers into persisted agent metadata. +- Do not carry obsolete legacy loop, background, plan, yolo, or unknown experimental flags into migrated config files. +- Repair mismatched JSON Schema types emitted by Xcode 26.5 MCP server for Moonshot compatibility. + +### Polish + +- Keep TUI components within narrow terminal widths by wrapping, compacting, or truncating lines that could exceed the render width. +- Prompt the CLI to show one brief same-language status sentence before non-trivial tool calls. +- Extend the same-language rule to the model's reasoning, so thinking follows the user's language while keeping code and technical terms in their original form. +- Read media files using header-detected types before falling back to media extensions. +- Prioritize clearing draft editor text before Ctrl-C cancels an active stream. +- Collapse hidden directories in the workspace prompt and explain how to inspect them. +- Include the skill's directory on the loaded-skill context block so the agent can locate a skill's bundled resources (scripts, templates) after it is invoked. +- Show the all-sessions toggle hint when the current working directory has no sessions. +- Clarify that compaction summaries must be emitted in the final answer. +- Clarify AGENTS.md prompt guidance and mark truncated instruction files. + +### Refactors + +- Resolve model capabilities through a static lookup instead of instantiating a temporary provider. +- Decouple agent skill access from session-specific registry implementations. +- Optimize the npm packaging system. + ## 0.14.3 (2026-06-14) ### Polish diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 9e0cc7684..fbd2f49d7 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -6,6 +6,39 @@ outline: 2 本页记录 Kimi Code CLI 每个版本的变更内容。 +## 0.15.0(2026-06-15) + +### 新功能 + +- 新增全会话选择器视图,支持按名称搜索、分页浏览,以及为其他工作目录中的会话生成可复制的恢复命令。 +- 新增对 legacy SSE MCP server 的支持,与 stdio 和 streamable HTTP 传输方式并存。 + +### 修复 + +- 修复中断的工具调用结果未被记录时,已恢复会话无法继续使用的问题。 +- 停止将恢复版本标记写入持久化的 agent 元数据。 +- 迁移后的配置文件中不再包含已废弃的 legacy loop、background、plan、yolo 或未知的实验性 flag。 +- 修复 Xcode 26.5 MCP server 发出的 JSON Schema 类型与 Moonshot 不兼容的问题。 + +### 优化 + +- 通过换行、压缩或截断可能超出渲染宽度的行,使 TUI 组件保持在窄终端宽度内。 +- 在调用较重要的工具前,提示 CLI 以用户当前语言显示一句简短的状态说明。 +- 将同语言规则扩展到模型的推理过程,使思考内容跟随用户语言,同时保留代码和技术术语的原始形式。 +- 读取媒体文件时优先使用文件头检测到的类型,再回退到媒体扩展名。 +- 在 Ctrl-C 取消活跃流之前,优先清除草稿编辑器文本。 +- 在工作区提示词中折叠隐藏目录,并说明如何查看它们。 +- 在已加载 skill 的上下文块中包含 skill 的目录,以便 agent 在调用 skill 后能够定位其打包资源(脚本、模板)。 +- 当前工作目录没有会话时,显示全会话切换提示。 +- 明确压缩摘要必须在最终答案中输出。 +- 明确 AGENTS.md 提示词指导,并标记被截断的指令文件。 + +### 重构 + +- 通过静态查找而非实例化临时 provider 来解析模型能力。 +- 将 agent skill 访问与 session 特定的注册表实现解耦。 +- 优化 npm 打包系统。 + ## 0.14.3(2026-06-14) ### 优化 From aa1896ca749e41a67d7c4b655dcc8be830cbec82 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 16 Jun 2026 11:12:20 +0800 Subject: [PATCH 002/383] fix(kimi-code): reduce /btw panel max height to one-third of terminal (#802) --- .changeset/reduce-btw-panel-height.md | 5 +++++ apps/kimi-code/src/tui/components/panes/btw-panel.ts | 2 +- apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts | 6 +++--- 3 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 .changeset/reduce-btw-panel-height.md diff --git a/.changeset/reduce-btw-panel-height.md b/.changeset/reduce-btw-panel-height.md new file mode 100644 index 000000000..b9f62bcea --- /dev/null +++ b/.changeset/reduce-btw-panel-height.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Reduce the maximum height of the /btw side panel from half to one-third of the terminal. diff --git a/apps/kimi-code/src/tui/components/panes/btw-panel.ts b/apps/kimi-code/src/tui/components/panes/btw-panel.ts index f86f93c93..9c4936fa2 100644 --- a/apps/kimi-code/src/tui/components/panes/btw-panel.ts +++ b/apps/kimi-code/src/tui/components/panes/btw-panel.ts @@ -185,7 +185,7 @@ export class BtwPanelComponent implements Component { private collapsedBodyLimit(): number | undefined { const terminalRows = this.options.terminalRows(); if (!Number.isFinite(terminalRows) || terminalRows <= 0) return undefined; - const maxPanelLines = Math.max(MIN_COLLAPSED_PANEL_LINES, Math.floor(terminalRows / 2)); + const maxPanelLines = Math.max(MIN_COLLAPSED_PANEL_LINES, Math.floor(terminalRows / 3)); return Math.max(1, maxPanelLines - 1); } diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index cb334fad1..da8df93ce 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -1762,10 +1762,10 @@ command = "vim" expect(finalLines.at(-1)).toMatch(/^│\s+│$/); }); - it('caps /btw height to half the terminal and supports scrolling', async () => { + it('caps /btw height to one-third of the terminal and supports scrolling', async () => { const session = makeSession(); const { driver } = await makeDriver(session); - setTerminalRows(driver, 12); + setTerminalRows(driver, 15); await openBtwPanel(driver, session, 'question 1'); const panel = getMountedBtwPanel(driver); @@ -1778,7 +1778,7 @@ command = "vim" } const collapsed = panel.render(80).map(stripSgr); - expect(collapsed).toHaveLength(6); + expect(collapsed).toHaveLength(5); expect(collapsed.join('\n')).toContain('BTW ─ Esc close · ↑↓ scroll'); expect(collapsed.join('\n')).not.toContain('ctrl+o expand'); expect(collapsed.join('\n')).toContain('question 8'); From 299b9fcad4c9c4b755fae4dfae01a1dbf60aec3c Mon Sep 17 00:00:00 2001 From: 7Sageer <12210216@mail.sustech.edu.cn> Date: Tue, 16 Jun 2026 11:34:44 +0800 Subject: [PATCH 003/383] fix(agent-core): suppress close-time background notifications (#804) --- ...suppress-background-close-notifications.md | 6 +++++ packages/agent-core/src/session/index.ts | 12 +++++++--- .../test/session/lifecycle-hooks.test.ts | 24 +++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 .changeset/suppress-background-close-notifications.md diff --git a/.changeset/suppress-background-close-notifications.md b/.changeset/suppress-background-close-notifications.md new file mode 100644 index 000000000..65eee76e5 --- /dev/null +++ b/.changeset/suppress-background-close-notifications.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Prevent session shutdown from resuming the agent when stopping background tasks. diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 7258edfc8..ca8c531a9 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -346,9 +346,15 @@ export class Session { }); if (keepAliveOnExit) return; await Promise.all( - Array.from(this.readyAgents(), (agent) => - agent.background.stopAll('Session closed'), - ), + Array.from(this.readyAgents(), async (agent) => { + const activeTasks = agent.background.list(true); + await Promise.all( + activeTasks.map((task) => + agent.background.suppressTerminalNotification(task.taskId), + ), + ); + await agent.background.stopAll('Session closed'); + }), ); } diff --git a/packages/agent-core/test/session/lifecycle-hooks.test.ts b/packages/agent-core/test/session/lifecycle-hooks.test.ts index a92df206b..99b1dc1c0 100644 --- a/packages/agent-core/test/session/lifecycle-hooks.test.ts +++ b/packages/agent-core/test/session/lifecycle-hooks.test.ts @@ -131,6 +131,30 @@ describe('Session lifecycle hooks', () => { expect(agent.background.getTask(taskId)?.status).toBe('killed'); }); + it('does not steer background task notifications while closing the session', async () => { + const { sessionDir, workDir } = await hookFixture(); + const session = new Session({ + kaos: testKaos.withCwd(workDir), + id: 'session-bg-cleanup-no-steer', + homedir: sessionDir, + rpc: createSessionRpc(), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + }); + const agent = await session.createMain(); + const steerSpy = vi.spyOn(agent.turn, 'steer'); + const { proc, killSpy } = pendingProcess(); + const taskId = agent.background.registerTask( + new ProcessBackgroundTask(proc, 'sleep 60', 'exit cleanup without steer'), + ); + + await session.close(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(killSpy).toHaveBeenCalledWith('SIGTERM'); + expect(agent.background.getTask(taskId)?.status).toBe('killed'); + expect(steerSpy).not.toHaveBeenCalled(); + }); + it('keeps background tasks alive on close when keepAliveOnExit is true', async () => { const { sessionDir, workDir } = await hookFixture(); const session = new Session({ From ff332be6d364ce3d5974133deb7c76220684181a Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 16 Jun 2026 12:19:52 +0800 Subject: [PATCH 004/383] fix(tui): add top border to queue pane to separate it from todo panel (#801) --- .changeset/queue-pane-border.md | 5 +++++ apps/kimi-code/src/tui/components/panes/queue-pane.ts | 2 +- .../test/tui/components/panes/queue-pane.test.ts | 8 ++++---- 3 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 .changeset/queue-pane-border.md diff --git a/.changeset/queue-pane-border.md b/.changeset/queue-pane-border.md new file mode 100644 index 000000000..f269bc215 --- /dev/null +++ b/.changeset/queue-pane-border.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Polish queue pane styling diff --git a/apps/kimi-code/src/tui/components/panes/queue-pane.ts b/apps/kimi-code/src/tui/components/panes/queue-pane.ts index 3a255b52d..77800b97e 100644 --- a/apps/kimi-code/src/tui/components/panes/queue-pane.ts +++ b/apps/kimi-code/src/tui/components/panes/queue-pane.ts @@ -34,7 +34,7 @@ export class QueuePaneComponent extends Container { override render(width: number): string[] { const accent = (text: string) => currentTheme.fg('accent', text); const dim = (text: string) => currentTheme.fg('textDim', text); - const lines: string[] = []; + const lines: string[] = [currentTheme.fg('border', '─'.repeat(width))]; for (const item of this.messages) { const singleLine = item.text.replaceAll(/\s+/g, ' ').trim(); diff --git a/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts b/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts index 68a5fc1d8..ca276a138 100644 --- a/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts +++ b/apps/kimi-code/test/tui/components/panes/queue-pane.test.ts @@ -62,8 +62,8 @@ describe('QueuePaneComponent', () => { }); const lines = component.render(30); - expect(lines).toHaveLength(2); // message + hint - const messageLine = stripAnsi(lines[0] as string); + expect(lines).toHaveLength(3); // border + message + hint + const messageLine = stripAnsi(lines[1] as string); expect(messageLine).not.toContain('a'.repeat(30)); expect(messageLine.endsWith('…')).toBe(true); }); @@ -77,8 +77,8 @@ describe('QueuePaneComponent', () => { }); const lines = component.render(120); - expect(lines).toHaveLength(2); // message + hint - const messageLine = stripAnsi(lines[0] as string); + expect(lines).toHaveLength(3); // border + message + hint + const messageLine = stripAnsi(lines[1] as string); expect(messageLine).toContain('line one line two line three'); expect(messageLine).not.toContain('\n'); }); From b45672cdaac9959024c3ae36bf35b16a423aa1dc Mon Sep 17 00:00:00 2001 From: 7Sageer <12210216@mail.sustech.edu.cn> Date: Tue, 16 Jun 2026 13:07:33 +0800 Subject: [PATCH 005/383] fix(kaos): destroy buffered readable sources (#807) --- .changeset/destroy-buffered-readable-source.md | 6 ++++++ packages/kaos/src/internal.ts | 1 + packages/kaos/test/internal.test.ts | 9 +++++++++ 3 files changed, 16 insertions(+) create mode 100644 .changeset/destroy-buffered-readable-source.md diff --git a/.changeset/destroy-buffered-readable-source.md b/.changeset/destroy-buffered-readable-source.md new file mode 100644 index 000000000..e101f4b44 --- /dev/null +++ b/.changeset/destroy-buffered-readable-source.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kaos": patch +"@moonshot-ai/kimi-code": patch +--- + +Close wrapped output streams when buffered readers are destroyed. diff --git a/packages/kaos/src/internal.ts b/packages/kaos/src/internal.ts index fc57e9907..1a4742e18 100644 --- a/packages/kaos/src/internal.ts +++ b/packages/kaos/src/internal.ts @@ -267,6 +267,7 @@ export class BufferedReadable extends Readable { this._source.off('end', this._onEnd); this._source.off('close', this._onClose); this._source.off('error', this._onError); + this._source.destroy(); callback(error); } diff --git a/packages/kaos/test/internal.test.ts b/packages/kaos/test/internal.test.ts index 5a2712165..f38d3107c 100644 --- a/packages/kaos/test/internal.test.ts +++ b/packages/kaos/test/internal.test.ts @@ -95,6 +95,15 @@ describe('BufferedReadable', () => { expect(received).toBe(boom); expect(buffered.destroyed).toBe(true); }); + + it('destroys the underlying source when the wrapper is destroyed', () => { + const source = new PassThrough(); + const buffered = new BufferedReadable(source); + + buffered.destroy(); + + expect(source.destroyed).toBe(true); + }); }); describe('decodeTextWithErrors', () => { From d0d58219007cd9d7355f1ea8900e9777b66abda2 Mon Sep 17 00:00:00 2001 From: 7Sageer <12210216@mail.sustech.edu.cn> Date: Tue, 16 Jun 2026 14:30:49 +0800 Subject: [PATCH 006/383] fix(kosong): isolate anthropic auth environment (#790) * fix(kosong): isolate anthropic auth environment * fix(kosong): close remaining anthropic env fallbacks Pass explicit nulls into the Anthropic SDK for unused auth/base URL overrides, keep adapter-owned auth headers authoritative, and add regression coverage for Anthropic shell env leakage. * fix(kosong): block anthropic custom header env leakage * docs(kosong): explain anthropic env-isolation intent + migration Spell out that the SDK is used as a transport to arbitrary endpoints, so disabling its shell-env auto-discovery is the fix: the authToken/baseURL/header nulls are load-bearing, not redundant. Also document the behavior change (shell ANTHROPIC_* no longer read; use provider config) in the changeset. No logic change. --- .changeset/anthropic-env-auth.md | 10 +++ packages/kosong/src/providers/anthropic.ts | 65 +++++++++++++++---- packages/kosong/test/anthropic.test.ts | 43 ++++++++++++ .../kosong/test/e2e/anthropic-adapter.test.ts | 34 ++++++++-- 4 files changed, 134 insertions(+), 18 deletions(-) create mode 100644 .changeset/anthropic-env-auth.md diff --git a/.changeset/anthropic-env-auth.md b/.changeset/anthropic-env-auth.md new file mode 100644 index 000000000..d79d7dde3 --- /dev/null +++ b/.changeset/anthropic-env-auth.md @@ -0,0 +1,10 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/kosong": patch +--- + +Isolate the Anthropic adapter from ambient shell credentials. + +The adapter is used as a generic transport to *any* anthropic-compatible endpoint (`baseUrl` may point at a third-party gateway). The underlying Anthropic SDK, however, auto-discovers credentials from the shell environment by default. When the endpoint is not official this leaks: even with an explicit API key configured, the SDK would still read `ANTHROPIC_AUTH_TOKEN` from the environment and attach it as an `Authorization: Bearer` header — sending an out-of-band token (often injected by another tool, unbeknownst to the user) to the third-party `baseUrl`. The adapter now hard-disables every SDK environment auto-discovery channel (`authToken`, `baseURL`, custom headers) and uses only host-provided credentials. + +**Behavior change:** the adapter no longer reads `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_BASE_URL` / `ANTHROPIC_CUSTOM_HEADERS` from the shell. Configure credentials through provider config (`apiKey` or `[provider.env]`) instead. diff --git a/packages/kosong/src/providers/anthropic.ts b/packages/kosong/src/providers/anthropic.ts index c8154c9c7..204debf87 100644 --- a/packages/kosong/src/providers/anthropic.ts +++ b/packages/kosong/src/providers/anthropic.ts @@ -37,11 +37,7 @@ import type { ToolUseBlockParam, } from '@anthropic-ai/sdk/resources/messages/messages.js'; -import { - mergeRequestHeaders, - requireProviderApiKey, - resolveAuthBackedClient, -} from './request-auth'; +import { mergeRequestHeaders, resolveAuthBackedClient } from './request-auth'; import { normalizeToolCallIdsForProvider, sanitizeToolCallId, @@ -862,7 +858,7 @@ export class AnthropicChatProvider implements ChatProvider { private _metadata: Record | undefined; private _apiKey: string | undefined; private _baseUrl: string | undefined; - private _defaultHeaders: Record | undefined; + private _defaultHeaders: Record | undefined; private _clientFactory: ((auth: ProviderRequestAuth) => Anthropic) | undefined; private _adaptiveThinking: boolean | undefined; private _explicitMaxTokens: boolean; @@ -872,8 +868,8 @@ export class AnthropicChatProvider implements ChatProvider { this._stream = options.stream ?? true; this._metadata = options.metadata; this._adaptiveThinking = options.adaptiveThinking; - const apiKey = options.apiKey ?? process.env['ANTHROPIC_API_KEY']; - this._apiKey = apiKey === undefined || apiKey.length === 0 ? undefined : apiKey; + this._apiKey = + options.apiKey === undefined || options.apiKey.length === 0 ? undefined : options.apiKey; this._baseUrl = options.baseUrl; this._defaultHeaders = options.defaultHeaders; this._clientFactory = options.clientFactory; @@ -1073,15 +1069,62 @@ export class AnthropicChatProvider implements ChatProvider { return resolveAuthBackedClient( { cachedClient: this._client, clientFactory: this._clientFactory }, auth, - (a) => this._buildClient(requireProviderApiKey('AnthropicChatProvider', a, this._apiKey)), + (a) => this._buildClient(this._requireApiKey(a)), ); } + private _requireApiKey(auth: ProviderRequestAuth | undefined): string { + const apiKey = auth?.apiKey ?? this._apiKey; + if (apiKey === undefined || apiKey.length === 0) { + throw new ChatProviderError( + 'AnthropicChatProvider: apiKey is required. Provide it via constructor options, options.auth.apiKey on each request, or an OAuth login. The Anthropic adapter does not read shell API-key environment variables.', + ); + } + return apiKey; + } + + private _anthropicCustomHeaderEnvNames(): string[] { + const customHeaders = process.env['ANTHROPIC_CUSTOM_HEADERS']; + if (customHeaders === undefined || customHeaders.length === 0) return []; + + const names: string[] = []; + for (const line of customHeaders.split('\n')) { + const colonIndex = line.indexOf(':'); + if (colonIndex < 0) continue; + + const name = line.slice(0, colonIndex).trim().toLowerCase(); + if (name.length > 0) names.push(name); + } + return names; + } + + private _buildDefaultHeaders(apiKey: string): Record { + const defaultHeaders: Record = { authorization: null }; + for (const name of this._anthropicCustomHeaderEnvNames()) { + defaultHeaders[name] = null; + } + for (const [name, value] of Object.entries(this._defaultHeaders ?? {})) { + defaultHeaders[name.toLowerCase()] = value; + } + defaultHeaders['x-api-key'] = apiKey; + return defaultHeaders; + } + + // We use the Anthropic SDK purely as a transport to arbitrary + // anthropic-compatible endpoints (`baseUrl` may point anywhere). Left to its + // defaults the SDK auto-discovers credentials from the shell environment + // (ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, ANTHROPIC_CUSTOM_HEADERS), which + // would leak an out-of-band bearer/headers to a third-party endpoint even when + // an explicit apiKey is set. So we hard-disable every auto-discovery channel. + // These `null`s — and the nulled headers in _buildDefaultHeaders — are NOT + // redundant: removing them reintroduces credential leakage. Regression cover: + // test/e2e/anthropic-adapter.test.ts. private _buildClient(apiKey: string): Anthropic { return new Anthropic({ apiKey, - baseURL: this._baseUrl, - defaultHeaders: this._defaultHeaders, + authToken: null, + baseURL: this._baseUrl ?? null, + defaultHeaders: this._buildDefaultHeaders(apiKey), }); } diff --git a/packages/kosong/test/anthropic.test.ts b/packages/kosong/test/anthropic.test.ts index b9a10dc37..ecf459315 100644 --- a/packages/kosong/test/anthropic.test.ts +++ b/packages/kosong/test/anthropic.test.ts @@ -142,6 +142,49 @@ const B64_PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAA' + 'DUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; describe('AnthropicChatProvider', () => { + it('does not read ANTHROPIC_API_KEY from process.env inside the adapter', () => { + const previousApiKey = process.env['ANTHROPIC_API_KEY']; + process.env['ANTHROPIC_API_KEY'] = 'env-key'; + + try { + const provider = new AnthropicChatProvider({ + model: 'k25', + stream: false, + }); + + expect(Reflect.get(provider, '_apiKey')).toBeUndefined(); + expect(Reflect.get(provider, '_client')).toBeUndefined(); + } finally { + if (previousApiKey === undefined) { + delete process.env['ANTHROPIC_API_KEY']; + } else { + process.env['ANTHROPIC_API_KEY'] = previousApiKey; + } + } + }); + + it('does not read ANTHROPIC_BASE_URL from process.env inside the adapter', () => { + const previousBaseUrl = process.env['ANTHROPIC_BASE_URL']; + process.env['ANTHROPIC_BASE_URL'] = 'http://127.0.0.1:1'; + + try { + const provider = new AnthropicChatProvider({ + model: 'k25', + apiKey: 'test-key', + stream: false, + }); + const client = Reflect.get(provider, '_client') as { baseURL?: string } | undefined; + + expect(client?.baseURL).toBe('https://api.anthropic.com'); + } finally { + if (previousBaseUrl === undefined) { + delete process.env['ANTHROPIC_BASE_URL']; + } else { + process.env['ANTHROPIC_BASE_URL'] = previousBaseUrl; + } + } + }); + describe('message conversion', () => { it('simple user message with system prompt', async () => { const provider = createProvider(); diff --git a/packages/kosong/test/e2e/anthropic-adapter.test.ts b/packages/kosong/test/e2e/anthropic-adapter.test.ts index cb191759a..bbf020746 100644 --- a/packages/kosong/test/e2e/anthropic-adapter.test.ts +++ b/packages/kosong/test/e2e/anthropic-adapter.test.ts @@ -2,7 +2,6 @@ import type { Message, StreamedMessagePart, ToolCall } from '#/message'; import { AnthropicChatProvider } from '#/providers/anthropic'; import type { Tool } from '#/tool'; import type { TokenUsage } from '#/usage'; -import Anthropic from '@anthropic-ai/sdk'; import { describe, expect, it } from 'vitest'; import { createFakeProviderHarness } from './fake-provider-harness'; @@ -21,10 +20,15 @@ async function collectParts( return parts; } -function makeAnthropicProvider(): AnthropicChatProvider { +function makeAnthropicProvider( + baseUrl?: string, + defaultHeaders?: Record, +): AnthropicChatProvider { return new AnthropicChatProvider({ model: 'k25', apiKey: 'test-key', + baseUrl, + defaultHeaders, defaultMaxTokens: 1024, stream: true, }); @@ -58,8 +62,15 @@ const MUL_TOOL: Tool = { describe('e2e: Anthropic adapter bridge', () => { it('sends the adapter request body and parses streamed text, tool use, and usage', async () => { + const previousAuthToken = process.env['ANTHROPIC_AUTH_TOKEN']; + const previousCustomHeaders = process.env['ANTHROPIC_CUSTOM_HEADERS']; const harness = await createFakeProviderHarness(); + try { + process.env['ANTHROPIC_AUTH_TOKEN'] = 'env-auth-token'; + process.env['ANTHROPIC_CUSTOM_HEADERS'] = + 'Authorization: Bearer env-token\nX-Api-Key: env-key\nX-Leak: shell'; + harness.route('POST', '/v1/messages', async (request, reply) => { const body = request.bodyJson as Record; expect(request.pathname).toBe('/v1/messages'); @@ -168,11 +179,7 @@ describe('e2e: Anthropic adapter bridge', () => { }); }); - const provider = makeAnthropicProvider(); - (provider as any)._client = new Anthropic({ - apiKey: 'test-key', - baseURL: harness.baseUrl, - }); + const provider = makeAnthropicProvider(harness.baseUrl, { 'X-Configured': 'yes' }); const history: Message[] = [ { @@ -209,7 +216,20 @@ describe('e2e: Anthropic adapter bridge', () => { } satisfies TokenUsage); expect(harness.requests).toHaveLength(1); + expect(harness.requests[0]!.headers['authorization']).toBeUndefined(); + expect(harness.requests[0]!.headers['x-leak']).toBeUndefined(); + expect(harness.requests[0]!.headers['x-configured']).toBe('yes'); } finally { + if (previousAuthToken === undefined) { + delete process.env['ANTHROPIC_AUTH_TOKEN']; + } else { + process.env['ANTHROPIC_AUTH_TOKEN'] = previousAuthToken; + } + if (previousCustomHeaders === undefined) { + delete process.env['ANTHROPIC_CUSTOM_HEADERS']; + } else { + process.env['ANTHROPIC_CUSTOM_HEADERS'] = previousCustomHeaders; + } await harness.close(); } }); From 36e06152e41c06518f8b7feb2a59c1163ade7f50 Mon Sep 17 00:00:00 2001 From: 7Sageer <12210216@mail.sustech.edu.cn> Date: Tue, 16 Jun 2026 14:34:24 +0800 Subject: [PATCH 007/383] docs: simplify anthropic auth changeset (#810) --- .changeset/anthropic-env-auth.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.changeset/anthropic-env-auth.md b/.changeset/anthropic-env-auth.md index d79d7dde3..dbd88b1c3 100644 --- a/.changeset/anthropic-env-auth.md +++ b/.changeset/anthropic-env-auth.md @@ -3,8 +3,4 @@ "@moonshot-ai/kosong": patch --- -Isolate the Anthropic adapter from ambient shell credentials. - -The adapter is used as a generic transport to *any* anthropic-compatible endpoint (`baseUrl` may point at a third-party gateway). The underlying Anthropic SDK, however, auto-discovers credentials from the shell environment by default. When the endpoint is not official this leaks: even with an explicit API key configured, the SDK would still read `ANTHROPIC_AUTH_TOKEN` from the environment and attach it as an `Authorization: Bearer` header — sending an out-of-band token (often injected by another tool, unbeknownst to the user) to the third-party `baseUrl`. The adapter now hard-disables every SDK environment auto-discovery channel (`authToken`, `baseURL`, custom headers) and uses only host-provided credentials. - -**Behavior change:** the adapter no longer reads `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_BASE_URL` / `ANTHROPIC_CUSTOM_HEADERS` from the shell. Configure credentials through provider config (`apiKey` or `[provider.env]`) instead. +Stop Anthropic-compatible providers from reading ambient Anthropic shell credentials and custom headers. From 6f442bd8cde29e21526fa36c9836e2d4c282b4bf Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 16 Jun 2026 14:58:58 +0800 Subject: [PATCH 008/383] feat(kimi-code): support banner display frequency (#809) * feat(kimi-code): support banner display frequency * chore: change banner changeset to patch --- .changeset/banner-display-frequency.md | 5 + apps/kimi-code/src/constant/app.ts | 3 + .../src/tui/banner/banner-provider.ts | 221 +++++++++- apps/kimi-code/src/tui/banner/state.ts | 69 +++ apps/kimi-code/src/tui/kimi-tui.ts | 28 +- apps/kimi-code/src/tui/types.ts | 5 + apps/kimi-code/src/utils/paths.ts | 17 + .../test/tui/banner/banner-provider.test.ts | 408 +++++++++++++++++- apps/kimi-code/test/tui/banner/state.test.ts | 88 ++++ .../test/tui/components/chrome/banner.test.ts | 45 +- .../test/tui/kimi-tui-startup.test.ts | 97 +++++ 11 files changed, 937 insertions(+), 49 deletions(-) create mode 100644 .changeset/banner-display-frequency.md create mode 100644 apps/kimi-code/src/tui/banner/state.ts create mode 100644 apps/kimi-code/test/tui/banner/state.test.ts diff --git a/.changeset/banner-display-frequency.md b/.changeset/banner-display-frequency.md new file mode 100644 index 000000000..b42f7bea2 --- /dev/null +++ b/.changeset/banner-display-frequency.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add configurable banner display frequencies with local display state. diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index 9ec4223ce..ec66a4a2f 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -18,6 +18,7 @@ export const NPM_PACKAGE_NAME = '@moonshot-ai/kimi-code'; export const KIMI_CODE_HOME_ENV = 'KIMI_CODE_HOME'; export const KIMI_CODE_DATA_DIR_NAME = '.kimi-code'; export const KIMI_CODE_LOG_DIR_NAME = 'logs'; +export const KIMI_CODE_CACHE_DIR_NAME = 'cache'; export const KIMI_CODE_UPDATE_DIR_NAME = 'updates'; export const KIMI_CODE_BIN_DIR_NAME = 'bin'; export const KIMI_CODE_UPDATE_STATE_FILE_NAME = 'latest.json'; @@ -25,6 +26,8 @@ export const KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME = 'install.json'; export const KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME = 'install.lock'; export const KIMI_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME = 'rollout.log'; export const KIMI_CODE_INPUT_HISTORY_DIR_NAME = 'user-history'; +export const KIMI_CODE_BANNER_DIR_NAME = 'banner'; +export const KIMI_CODE_BANNER_STATE_FILE_NAME = 'state.json'; // Managed Kimi auth provider key shared with OAuth/SDK config. export const DEFAULT_OAUTH_PROVIDER_NAME = 'managed:kimi-code'; diff --git a/apps/kimi-code/src/tui/banner/banner-provider.ts b/apps/kimi-code/src/tui/banner/banner-provider.ts index c9707e2f7..087b02934 100644 --- a/apps/kimi-code/src/tui/banner/banner-provider.ts +++ b/apps/kimi-code/src/tui/banner/banner-provider.ts @@ -1,17 +1,25 @@ +import { createHash } from 'node:crypto'; + import { gte, valid } from 'semver'; import { KIMI_CODE_TIPS_BANNER_URL } from '#/constant/app'; -import type { BannerState } from '#/tui/types'; +import type { BannerDisplay, BannerState } from '#/tui/types'; + +import type { BannerDisplayState } from './state'; interface TipsBannerFallbackItem { + banner_id?: string | null; enabled?: boolean; banner_title?: string | null; banner_maintext?: string; banner_subtext?: string | null; banner_min_version?: string | null; + banner_display?: unknown; + banner_display_ttl_hours?: unknown; } interface TipsBannerJson { + banner_id?: string | null; banner_enabled?: boolean; banner_title?: string | null; banner_maintext?: string; @@ -19,10 +27,50 @@ interface TipsBannerJson { banner_start_time?: string | null; banner_end_time?: string | null; banner_min_version?: string | null; + banner_display?: unknown; + banner_display_ttl_hours?: unknown; banner_fallback_enabled?: boolean; banner_fallback_list?: unknown[]; } +interface BannerHashInput { + tag: string | null; + mainText: string; + subText: string | null; + startTime: string | null; + endTime: string | null; + display: BannerDisplay; + ttlHours?: number; +} + +interface BannerCandidateInput { + id: unknown; + tag: unknown; + mainText: string; + subText: unknown; + display: BannerDisplay; + ttlHours?: number; + startTime?: unknown; + endTime?: unknown; +} + +export interface SelectDisplayableBannerArgs { + json: unknown; + clientVersion: string; + now: Date; + random: () => number; + state: BannerDisplayState; +} + +interface BannerProviderLoadOptions { + state?: BannerDisplayState; + now?: Date; + random?: () => number; +} + +const HOUR_MS = 60 * 60 * 1000; +export const DEFAULT_COOLDOWN_TTL_HOURS = 24; + function normalizeTag(value: unknown): string | null { if (typeof value !== 'string') return null; const trimmed = value.trim(); @@ -63,6 +111,68 @@ function meetsMinVersion(minVersion: unknown, clientVersion: string): boolean { return gte(current, min); } +function parseBannerDisplay(value: unknown): BannerDisplay { + if (value === 'once') return 'once'; + if (value === 'cooldown') return 'cooldown'; + return 'always'; +} + +function parseBannerDisplayTtlHours(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? value + : DEFAULT_COOLDOWN_TTL_HOURS; +} + +function normalizeBannerId(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function hashBannerIdentity(input: BannerHashInput): string { + const raw = JSON.stringify([ + input.tag ?? '', + input.mainText, + input.subText ?? '', + input.startTime ?? '', + input.endTime ?? '', + input.display, + input.ttlHours ?? '', + ]); + return createHash('sha256').update(raw).digest('hex').slice(0, 32); +} + +function getBannerKey(rawBannerId: unknown, input: BannerHashInput): string { + return normalizeBannerId(rawBannerId) ?? hashBannerIdentity(input); +} + +function toBannerState(input: BannerCandidateInput): BannerState { + const tag = normalizeTag(input.tag); + const subText = normalizeText(input.subText); + const display = input.display; + const ttlHours = display === 'cooldown' ? parseBannerDisplayTtlHours(input.ttlHours) : undefined; + const startTime = normalizeText(input.startTime); + const endTime = normalizeText(input.endTime); + const key = getBannerKey(input.id, { + tag, + mainText: input.mainText, + subText, + startTime, + endTime, + display, + ttlHours, + }); + + return { + key, + tag, + mainText: input.mainText, + subText, + display, + ttlHours, + }; +} + function pickActiveBanner( json: TipsBannerJson, clientVersion: string, @@ -75,20 +185,24 @@ function pickActiveBanner( if (!isWithinWindow(start, end, now)) return null; const mainText = normalizeText(json.banner_maintext); if (mainText === null) return null; - return { - tag: normalizeTag(json.banner_title), + const display = parseBannerDisplay(json.banner_display); + return toBannerState({ + id: json.banner_id, + tag: json.banner_title, mainText, - subText: normalizeText(json.banner_subtext), - }; + subText: json.banner_subtext, + display, + ttlHours: display === 'cooldown' ? parseBannerDisplayTtlHours(json.banner_display_ttl_hours) : undefined, + startTime: json.banner_start_time, + endTime: json.banner_end_time, + }); } -function pickFallbackBanner( +function pickFallbackCandidates( json: TipsBannerJson, clientVersion: string, - now: Date, - random: () => number, -): BannerState | null { - if (json.banner_fallback_enabled !== true) return null; +): BannerState[] { + if (json.banner_fallback_enabled !== true) return []; const list = Array.isArray(json.banner_fallback_list) ? json.banner_fallback_list : []; const candidates: BannerState[] = []; for (const raw of list) { @@ -98,17 +212,59 @@ function pickFallbackBanner( if (!meetsMinVersion(item.banner_min_version, clientVersion)) continue; const mainText = normalizeText(item.banner_maintext); if (mainText === null) continue; - candidates.push({ - tag: normalizeTag(item.banner_title), - mainText, - subText: normalizeText(item.banner_subtext), - }); + const display = parseBannerDisplay(item.banner_display); + candidates.push( + toBannerState({ + id: item.banner_id, + tag: item.banner_title, + mainText, + subText: item.banner_subtext, + display, + ttlHours: display === 'cooldown' ? parseBannerDisplayTtlHours(item.banner_display_ttl_hours) : undefined, + }), + ); } + return candidates; +} + +function pickRandomCandidate(candidates: BannerState[], random: () => number): BannerState | null { if (candidates.length === 0) return null; const index = Math.floor(random() * candidates.length); return candidates[index]!; } +function pickFallbackBanner( + json: TipsBannerJson, + clientVersion: string, + random: () => number, +): BannerState | null { + return pickRandomCandidate(pickFallbackCandidates(json, clientVersion), random); +} + +function parseShownAt(value: string | undefined): Date | null { + if (value === undefined) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function getCooldownTtlHours(banner: BannerState): number { + return typeof banner.ttlHours === 'number' && Number.isFinite(banner.ttlHours) && banner.ttlHours > 0 + ? banner.ttlHours + : DEFAULT_COOLDOWN_TTL_HOURS; +} + +export function shouldDisplayBanner( + banner: BannerState, + state: BannerDisplayState, + now: Date, +): boolean { + if (banner.display === 'always') return true; + const lastShownAt = parseShownAt(state.shown[banner.key]?.lastShownAt); + if (lastShownAt === null) return true; + if (banner.display === 'once') return false; + return now.getTime() - lastShownAt.getTime() >= getCooldownTtlHours(banner) * HOUR_MS; +} + export function selectBannerState( json: unknown, clientVersion: string, @@ -118,17 +274,36 @@ export function selectBannerState( const typed = typeof json === 'object' && json !== null ? (json as TipsBannerJson) : {}; return ( pickActiveBanner(typed, clientVersion, now) ?? - pickFallbackBanner(typed, clientVersion, now, random) + pickFallbackBanner(typed, clientVersion, random) ); } +export function selectDisplayableBanner({ + json, + clientVersion, + now, + random, + state, +}: SelectDisplayableBannerArgs): BannerState | null { + const typed = typeof json === 'object' && json !== null ? (json as TipsBannerJson) : {}; + const active = pickActiveBanner(typed, clientVersion, now); + if (active !== null && shouldDisplayBanner(active, state, now)) return active; + const candidates = pickFallbackCandidates(typed, clientVersion).filter((candidate) => + shouldDisplayBanner(candidate, state, now), + ); + return pickRandomCandidate(candidates, random); +} + export class BannerProvider { constructor( private readonly clientVersion: string, private readonly url: string = KIMI_CODE_TIPS_BANNER_URL, ) {} - async load(fetchImpl: typeof fetch = fetch): Promise { + async load( + fetchImpl: typeof fetch = fetch, + options: BannerProviderLoadOptions = {}, + ): Promise { try { const controller = new AbortController(); const timeout = setTimeout(() => { @@ -138,7 +313,17 @@ export class BannerProvider { clearTimeout(timeout); if (!response.ok) return null; const json = await response.json(); - return selectBannerState(json, this.clientVersion, new Date(), Math.random); + const now = options.now ?? new Date(); + const random = options.random ?? Math.random; + return options.state === undefined + ? selectBannerState(json, this.clientVersion, now, random) + : selectDisplayableBanner({ + json, + clientVersion: this.clientVersion, + now, + random, + state: options.state, + }); } catch { return null; } diff --git a/apps/kimi-code/src/tui/banner/state.ts b/apps/kimi-code/src/tui/banner/state.ts new file mode 100644 index 000000000..77877d82a --- /dev/null +++ b/apps/kimi-code/src/tui/banner/state.ts @@ -0,0 +1,69 @@ +import { z } from 'zod'; + +import { getBannerStateFile } from '#/utils/paths'; +import { readJsonFile, writeJsonFile } from '#/utils/persistence'; + +export type BannerDisplayRecord = { + lastShownAt: string; +}; + +export type BannerDisplayState = { + version: 1; + shown: Record; +}; + +const BannerDisplayRecordSchema = z + .object({ + lastShownAt: z.string().min(1), + }) + .strict(); + +const BannerDisplayStateSchema = z.preprocess( + (value) => { + if (typeof value !== 'object' || value === null) return value; + const shown = (value as { shown?: unknown }).shown; + if (typeof shown !== 'object' || shown === null) { + return { ...(value as Record), shown: {} }; + } + + const normalizedShown: Record = {}; + for (const [key, record] of Object.entries(shown)) { + if (key.length === 0 || typeof record !== 'object' || record === null) continue; + const lastShownAt = (record as { lastShownAt?: unknown }).lastShownAt; + if (typeof lastShownAt !== 'string' || Number.isNaN(Date.parse(lastShownAt))) continue; + normalizedShown[key] = { lastShownAt }; + } + + return { ...(value as Record), shown: normalizedShown }; + }, + z + .object({ + version: z.literal(1), + shown: z.record(z.string().min(1), BannerDisplayRecordSchema), + }) + .strict(), +); + +export function emptyBannerDisplayState(): BannerDisplayState { + return { + version: 1, + shown: {}, + }; +} + +export async function readBannerDisplayState( + filePath: string = getBannerStateFile(), +): Promise { + try { + return await readJsonFile(filePath, BannerDisplayStateSchema, emptyBannerDisplayState()); + } catch { + return emptyBannerDisplayState(); + } +} + +export async function writeBannerDisplayState( + value: BannerDisplayState, + filePath: string = getBannerStateFile(), +): Promise { + await writeJsonFile(filePath, BannerDisplayStateSchema, value); +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index f29d39b8c..330f9c7f1 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -32,6 +32,7 @@ import { detectFdPath, ensureFdPath } from '#/utils/process/fd-detect'; import { quoteShellArg } from '#/utils/shell-quote'; import { BannerProvider } from './banner/banner-provider'; +import { readBannerDisplayState, writeBannerDisplayState } from './banner/state'; import { BUILTIN_SLASH_COMMANDS, buildSkillSlashCommands, @@ -414,10 +415,29 @@ export class KimiTUI { private async loadBanner(): Promise { const provider = new BannerProvider(this.state.appState.version); - this.state.appState.banner = await provider.load(); - if (this.state.appState.banner !== null) { - this.renderBanner(); - this.state.ui.requestRender(); + const displayState = await readBannerDisplayState(); + const now = new Date(); + const banner = await provider.load(fetch, { + state: displayState, + now, + }); + this.state.appState.banner = banner; + if (banner === null) return; + + this.renderBanner(); + this.state.ui.requestRender(); + + if (banner.display === 'always') return; + try { + await writeBannerDisplayState({ + version: 1, + shown: { + ...displayState.shown, + [banner.key]: { lastShownAt: now.toISOString() }, + }, + }); + } catch { + // Best-effort: banner display state should never block startup. } } diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index bbf047073..6b407f777 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -12,10 +12,15 @@ import type { NotificationsConfig, UpgradePreferences } from './config'; import type { PendingApproval, PendingQuestion } from './reverse-rpc/types'; import type { ColorToken, ThemeName } from './theme'; +export type BannerDisplay = 'always' | 'once' | 'cooldown'; + export interface BannerState { + key: string; tag: string | null; mainText: string; subText: string | null; + display: BannerDisplay; + ttlHours?: number; } export interface AppState { diff --git a/apps/kimi-code/src/utils/paths.ts b/apps/kimi-code/src/utils/paths.ts index eb58a5265..83b681f1c 100644 --- a/apps/kimi-code/src/utils/paths.ts +++ b/apps/kimi-code/src/utils/paths.ts @@ -10,7 +10,10 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { + KIMI_CODE_BANNER_DIR_NAME, + KIMI_CODE_BANNER_STATE_FILE_NAME, KIMI_CODE_BIN_DIR_NAME, + KIMI_CODE_CACHE_DIR_NAME, KIMI_CODE_DATA_DIR_NAME, KIMI_CODE_HOME_ENV, KIMI_CODE_INPUT_HISTORY_DIR_NAME, @@ -42,6 +45,13 @@ export function getLogDir(): string { return join(getDataDir(), KIMI_CODE_LOG_DIR_NAME); } +/** + * Return the CLI cache directory: `/cache/`. + */ +export function getCacheDir(): string { + return join(getDataDir(), KIMI_CODE_CACHE_DIR_NAME); +} + /** * Return the managed tools directory: `/bin/`. */ @@ -77,6 +87,13 @@ export function getUpdateRolloutLogFile(): string { return join(getDataDir(), KIMI_CODE_UPDATE_DIR_NAME, KIMI_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME); } +/** + * Return the banner display state file: `/cache/banner/state.json`. + */ +export function getBannerStateFile(): string { + return join(getCacheDir(), KIMI_CODE_BANNER_DIR_NAME, KIMI_CODE_BANNER_STATE_FILE_NAME); +} + /** * Return the user input history file for a given working directory. * Layout: `/user-history/.jsonl`. diff --git a/apps/kimi-code/test/tui/banner/banner-provider.test.ts b/apps/kimi-code/test/tui/banner/banner-provider.test.ts index a2036ab31..0fce5984a 100644 --- a/apps/kimi-code/test/tui/banner/banner-provider.test.ts +++ b/apps/kimi-code/test/tui/banner/banner-provider.test.ts @@ -1,10 +1,27 @@ import { describe, expect, it } from 'vitest'; -import { selectBannerState } from '#/tui/banner/banner-provider'; +import { + selectBannerState, + selectDisplayableBanner, + shouldDisplayBanner, +} from '#/tui/banner/banner-provider'; +import type { BannerState } from '#/tui/types'; describe('selectBannerState', () => { const now = new Date('2026-06-15T12:00:00+08:00'); + function expectAlwaysBanner( + result: BannerState | null, + expected: Pick, + ): BannerState { + expect(result).not.toBeNull(); + const banner = result!; + expect(banner).toMatchObject({ ...expected, display: 'always' }); + expect(banner.key).toEqual(expect.any(String)); + expect(banner.ttlHours).toBeUndefined(); + return banner; + } + it('returns the active banner when enabled and no time window is set', () => { const result = selectBannerState( { @@ -17,7 +34,7 @@ describe('selectBannerState', () => { now, () => 0, ); - expect(result).toEqual({ tag: 'New', mainText: 'Active', subText: 'Details' }); + expectAlwaysBanner(result, { tag: 'New', mainText: 'Active', subText: 'Details' }); }); it('returns null when the active banner is outside its time window', () => { @@ -64,7 +81,7 @@ describe('selectBannerState', () => { now, () => 0.75, ); - expect(result).toEqual({ tag: 'Tip', mainText: 'Second', subText: null }); + expectAlwaysBanner(result, { tag: 'Tip', mainText: 'Second', subText: null }); }); it('filters out fallback entries when the client version is too low', () => { @@ -81,7 +98,7 @@ describe('selectBannerState', () => { now, () => 0, ); - expect(result).toEqual({ tag: null, mainText: 'Old tip', subText: null }); + expectAlwaysBanner(result, { tag: null, mainText: 'Old tip', subText: null }); }); it('returns null when no enabled fallback entries exist', () => { @@ -112,7 +129,7 @@ describe('selectBannerState', () => { now, () => 0, ); - expect(result).toEqual({ tag: null, mainText: 'Fallback', subText: null }); + expectAlwaysBanner(result, { tag: null, mainText: 'Fallback', subText: null }); }); it('treats an empty tag as null while still showing the banner', () => { @@ -126,7 +143,7 @@ describe('selectBannerState', () => { now, () => 0, ); - expect(result).toEqual({ tag: null, mainText: 'No tag', subText: null }); + expectAlwaysBanner(result, { tag: null, mainText: 'No tag', subText: null }); }); it('makes the active banner unavailable when mainText is empty', () => { @@ -142,7 +159,7 @@ describe('selectBannerState', () => { now, () => 0, ); - expect(result).toEqual({ tag: null, mainText: 'Fallback', subText: null }); + expectAlwaysBanner(result, { tag: null, mainText: 'Fallback', subText: null }); }); it('treats missing subtext as null', () => { @@ -155,7 +172,7 @@ describe('selectBannerState', () => { now, () => 0, ); - expect(result).toEqual({ tag: null, mainText: 'Main only', subText: null }); + expectAlwaysBanner(result, { tag: null, mainText: 'Main only', subText: null }); }); it('treats empty time fields as always valid', () => { @@ -170,7 +187,7 @@ describe('selectBannerState', () => { now, () => 0, ); - expect(result).toEqual({ tag: null, mainText: 'Always on', subText: null }); + expectAlwaysBanner(result, { tag: null, mainText: 'Always on', subText: null }); }); it('falls back to UTC when timestamps have no timezone', () => { @@ -185,7 +202,7 @@ describe('selectBannerState', () => { now, () => 0, ); - expect(result).toEqual({ tag: null, mainText: 'UTC fallback', subText: null }); + expectAlwaysBanner(result, { tag: null, mainText: 'UTC fallback', subText: null }); }); it('returns null when the fallback list is empty', () => { @@ -214,4 +231,375 @@ describe('selectBannerState', () => { ); expect(result).toBeNull(); }); + + it('uses banner_id as the banner key when present', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_id: 'active-1', + banner_maintext: 'Active', + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toMatchObject({ key: 'active-1', display: 'always' }); + }); + + it('generates a stable hash key when banner_id is missing', () => { + const json = { + banner_enabled: true, + banner_title: 'New', + banner_maintext: 'Active', + banner_subtext: 'Details', + }; + + const first = selectBannerState(json, '0.14.0', now, () => 0); + const second = selectBannerState(json, '0.14.0', now, () => 0); + const changedDisplay = selectBannerState( + { + ...json, + banner_display: 'cooldown', + banner_display_ttl_hours: 72, + }, + '0.14.0', + now, + () => 0, + ); + + expect(first).not.toBeNull(); + expect(second).not.toBeNull(); + expect(changedDisplay).not.toBeNull(); + expect(first!.key).toMatch(/^[0-9a-f]{32}$/); + expect(second!.key).toBe(first!.key); + expect(changedDisplay!.key).not.toBe(first!.key); + }); + + it('parses cooldown display and ttl hours', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_id: 'active-1', + banner_maintext: 'Active', + banner_display: 'cooldown', + banner_display_ttl_hours: 72, + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toMatchObject({ + key: 'active-1', + display: 'cooldown', + ttlHours: 72, + }); + }); + + it('falls back to 24 hours when cooldown ttl is invalid', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_id: 'active-1', + banner_maintext: 'Active', + banner_display: 'cooldown', + banner_display_ttl_hours: 0, + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toMatchObject({ display: 'cooldown', ttlHours: 24 }); + }); + + it('falls back to always for unknown display values', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_id: 'active-1', + banner_maintext: 'Active', + banner_display: '24h', + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toMatchObject({ display: 'always' }); + expect(result?.ttlHours).toBeUndefined(); + }); + + it('supports fallback display and ttl fields', () => { + const result = selectBannerState( + { + banner_enabled: false, + banner_fallback_enabled: true, + banner_fallback_list: [ + { + enabled: true, + banner_id: 'fallback-1', + banner_maintext: 'Fallback', + banner_display: 'cooldown', + banner_display_ttl_hours: 168, + }, + ], + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toMatchObject({ + key: 'fallback-1', + display: 'cooldown', + ttlHours: 168, + }); + }); +}); + +describe('shouldDisplayBanner', () => { + const now = new Date('2026-06-16T12:00:00.000Z'); + + const banner: BannerState = { + key: 'always', + tag: null, + mainText: 'Always', + subText: null, + display: 'always', + }; + + it('returns true for always banners even when they were shown before', () => { + expect( + shouldDisplayBanner( + banner, + { + version: 1, + shown: { + always: { lastShownAt: '2026-06-16T11:59:59.000Z' }, + }, + }, + now, + ), + ).toBe(true); + }); + + it('returns true for once banners without a shown record', () => { + expect(shouldDisplayBanner({ ...banner, key: 'once', display: 'once' }, { version: 1, shown: {} }, now)).toBe( + true, + ); + }); + + it('returns false for once banners with a shown record', () => { + expect( + shouldDisplayBanner( + { ...banner, key: 'once', display: 'once' }, + { + version: 1, + shown: { + once: { lastShownAt: '2026-06-16T11:59:59.000Z' }, + }, + }, + now, + ), + ).toBe(false); + }); + + it('treats an invalid shown record as not shown', () => { + expect( + shouldDisplayBanner( + { ...banner, key: 'once', display: 'once' }, + { + version: 1, + shown: { + once: { lastShownAt: 'not-a-date' }, + }, + }, + now, + ), + ).toBe(true); + }); + + it('returns false during cooldown ttl', () => { + expect( + shouldDisplayBanner( + { ...banner, key: 'cooldown', display: 'cooldown', ttlHours: 24 }, + { + version: 1, + shown: { + cooldown: { lastShownAt: '2026-06-16T00:00:00.000Z' }, + }, + }, + now, + ), + ).toBe(false); + }); + + it('returns true at the cooldown ttl boundary', () => { + expect( + shouldDisplayBanner( + { ...banner, key: 'cooldown', display: 'cooldown', ttlHours: 24 }, + { + version: 1, + shown: { + cooldown: { lastShownAt: '2026-06-15T12:00:00.000Z' }, + }, + }, + now, + ), + ).toBe(true); + }); + + it('supports custom cooldown ttl values', () => { + expect( + shouldDisplayBanner( + { ...banner, key: 'cooldown', display: 'cooldown', ttlHours: 1 }, + { + version: 1, + shown: { + cooldown: { lastShownAt: '2026-06-16T11:30:00.000Z' }, + }, + }, + now, + ), + ).toBe(false); + expect( + shouldDisplayBanner( + { ...banner, key: 'cooldown', display: 'cooldown', ttlHours: 168 }, + { + version: 1, + shown: { + cooldown: { lastShownAt: '2026-06-09T12:00:01.000Z' }, + }, + }, + now, + ), + ).toBe(false); + }); +}); + +describe('selectDisplayableBanner', () => { + const now = new Date('2026-06-16T12:00:00.000Z'); + + it('falls back when the active once banner was already shown', () => { + const result = selectDisplayableBanner({ + json: { + banner_enabled: true, + banner_id: 'active', + banner_maintext: 'Active', + banner_display: 'once', + banner_fallback_enabled: true, + banner_fallback_list: [ + { + enabled: true, + banner_id: 'fallback', + banner_maintext: 'Fallback', + banner_display: 'once', + }, + ], + }, + clientVersion: '0.14.0', + now, + random: () => 0, + state: { + version: 1, + shown: { + active: { lastShownAt: '2026-06-16T00:00:00.000Z' }, + }, + }, + }); + + expect(result).toMatchObject({ key: 'fallback', display: 'once' }); + }); + + it('falls back when active cooldown is within ttl', () => { + const result = selectDisplayableBanner({ + json: { + banner_enabled: true, + banner_id: 'active', + banner_maintext: 'Active', + banner_display: 'cooldown', + banner_display_ttl_hours: 1, + banner_fallback_enabled: true, + banner_fallback_list: [ + { + enabled: true, + banner_id: 'fallback', + banner_maintext: 'Fallback', + }, + ], + }, + clientVersion: '0.14.0', + now, + random: () => 0, + state: { + version: 1, + shown: { + active: { lastShownAt: '2026-06-16T11:30:00.000Z' }, + }, + }, + }); + + expect(result).toMatchObject({ key: 'fallback', display: 'always' }); + }); + + it('returns active cooldown after ttl instead of fallback', () => { + const result = selectDisplayableBanner({ + json: { + banner_enabled: true, + banner_id: 'active', + banner_maintext: 'Active', + banner_display: 'cooldown', + banner_display_ttl_hours: 24, + banner_fallback_enabled: true, + banner_fallback_list: [ + { + enabled: true, + banner_id: 'fallback', + banner_maintext: 'Fallback', + }, + ], + }, + clientVersion: '0.14.0', + now, + random: () => 0, + state: { + version: 1, + shown: { + active: { lastShownAt: '2026-06-15T12:00:00.000Z' }, + }, + }, + }); + + expect(result).toMatchObject({ key: 'active', display: 'cooldown', ttlHours: 24 }); + }); + + it('randomly chooses only displayable fallback candidates', () => { + const result = selectDisplayableBanner({ + json: { + banner_enabled: false, + banner_fallback_enabled: true, + banner_fallback_list: [ + { + enabled: true, + banner_id: 'fallback-once', + banner_maintext: 'Fallback once', + banner_display: 'once', + }, + { + enabled: true, + banner_id: 'fallback-always', + banner_maintext: 'Fallback always', + }, + ], + }, + clientVersion: '0.14.0', + now, + random: () => 0, + state: { + version: 1, + shown: { + 'fallback-once': { lastShownAt: '2026-06-16T00:00:00.000Z' }, + }, + }, + }); + + expect(result).toMatchObject({ key: 'fallback-always', display: 'always' }); + }); }); diff --git a/apps/kimi-code/test/tui/banner/state.test.ts b/apps/kimi-code/test/tui/banner/state.test.ts new file mode 100644 index 000000000..0824e6fd9 --- /dev/null +++ b/apps/kimi-code/test/tui/banner/state.test.ts @@ -0,0 +1,88 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + emptyBannerDisplayState, + readBannerDisplayState, + writeBannerDisplayState, +} from '#/tui/banner/state'; +import { getBannerStateFile } from '#/utils/paths'; + +const originalEnv = { ...process.env }; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'kimi-banner-state-')); + process.env['KIMI_CODE_HOME'] = dir; +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + process.env = { ...originalEnv }; +}); + +describe('banner display state cache', () => { + it('returns an empty state when the file is missing', async () => { + await expect(readBannerDisplayState()).resolves.toEqual(emptyBannerDisplayState()); + }); + + it('falls back to an empty state when the file is corrupt', async () => { + mkdirSync(join(dir, 'cache', 'banner'), { recursive: true }); + writeFileSync(getBannerStateFile(), '{"broken"', 'utf-8'); + await expect(readBannerDisplayState()).resolves.toEqual(emptyBannerDisplayState()); + }); + + it('falls back to an empty state for an unknown future version', async () => { + mkdirSync(join(dir, 'cache', 'banner'), { recursive: true }); + writeFileSync( + getBannerStateFile(), + JSON.stringify({ + version: 2, + shown: {}, + }), + 'utf-8', + ); + await expect(readBannerDisplayState()).resolves.toEqual(emptyBannerDisplayState()); + }); + + it('writes and reads back the state from cache/banner/state.json', async () => { + const state = { + version: 1 as const, + shown: { + active: { lastShownAt: '2026-06-16T00:00:00.000Z' }, + }, + }; + + await writeBannerDisplayState(state); + + expect(getBannerStateFile()).toBe(join(dir, 'cache', 'banner', 'state.json')); + await expect(readBannerDisplayState()).resolves.toEqual(state); + }); + + it('drops invalid shown records when reading', async () => { + mkdirSync(join(dir, 'cache', 'banner'), { recursive: true }); + writeFileSync( + getBannerStateFile(), + JSON.stringify({ + version: 1, + shown: { + valid: { lastShownAt: '2026-06-16T00:00:00.000Z' }, + invalid: { lastShownAt: 'not-a-date' }, + malformed: { shownAt: '2026-06-16T00:00:00.000Z' }, + }, + }), + 'utf-8', + ); + + await expect(readBannerDisplayState()).resolves.toEqual({ + version: 1, + shown: { + valid: { lastShownAt: '2026-06-16T00:00:00.000Z' }, + }, + }); + }); +}); diff --git a/apps/kimi-code/test/tui/components/chrome/banner.test.ts b/apps/kimi-code/test/tui/components/chrome/banner.test.ts index 04827fb80..8f5724e5f 100644 --- a/apps/kimi-code/test/tui/components/chrome/banner.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/banner.test.ts @@ -6,11 +6,22 @@ import { BannerComponent } from '#/tui/components/chrome/banner'; import { currentTheme } from '#/tui/theme'; import type { BannerState } from '#/tui/types'; -const banner: BannerState = { +function makeBannerState(overrides: Partial = {}): BannerState { + return { + key: 'component-banner', + tag: null, + mainText: '', + subText: null, + display: 'always', + ...overrides, + }; +} + +const banner: BannerState = makeBannerState({ tag: "What's new:", mainText: 'This is the main banner message for testing purposes.', subText: 'This is a short subtext line.', -}; +}); describe('BannerComponent', () => { const previousChalkLevel = chalk.level; @@ -39,7 +50,7 @@ describe('BannerComponent', () => { }); it('renders without a tag when tag is empty', () => { - const lines = new BannerComponent({ tag: null, mainText: 'Hello', subText: null }).render(80); + const lines = new BannerComponent(makeBannerState({ mainText: 'Hello' })).render(80); expect(lines.length).toBe(2); expect(lines[0]).not.toContain('✦'); expect(lines[0]).toContain('Hello'); @@ -61,11 +72,10 @@ describe('BannerComponent', () => { it('wraps long subtext to fit available width', () => { const width = 30; - const state: BannerState = { - tag: null, + const state = makeBannerState({ mainText: 'Short', subText: 'Short subtext line one plus subtext line two for wrapping tests.', - }; + }); const lines = new BannerComponent(state).render(width); expect(lines[0]).toContain('Short'); const subContentLines = lines.filter((line) => @@ -89,11 +99,10 @@ describe('BannerComponent', () => { it('shows tag only on the first wrapped line', () => { const width = 40; - const state: BannerState = { + const state = makeBannerState({ tag: 'New:', mainText: 'This is a very long main text line that should wrap automatically.', - subText: null, - }; + }); const lines = new BannerComponent(state).render(width); const mainRows = lines.slice(0, -1); let tagCount = 0; @@ -135,21 +144,21 @@ describe('BannerComponent', () => { const width = 40; const lines = new BannerComponent(banner).render(width); expect(lines[0]).toContain('✦'); - expect(lines[0]).toContain("What's new:"); + expect(lines[0]).toContain("What's new"); for (const line of lines) { expect(visibleWidth(line)).toBeLessThanOrEqual(width); } }); it('does not render subtext when empty', () => { - const lines = new BannerComponent({ tag: 'Tip', mainText: 'Use /help', subText: null }).render(80); + const lines = new BannerComponent(makeBannerState({ tag: 'Tip', mainText: 'Use /help' })).render(80); expect(lines.length).toBe(2); expect(lines[0]).toContain('Use /help'); expect(lines[1]).toBe(''); }); it('supports explicit newlines in main text', () => { - const lines = new BannerComponent({ tag: null, mainText: 'Line 1\nLine 2', subText: null }).render(80); + const lines = new BannerComponent(makeBannerState({ mainText: 'Line 1\nLine 2' })).render(80); expect(lines.length).toBe(3); expect(lines[0]).toContain('Line 1'); expect(lines[1]).toContain('Line 2'); @@ -174,11 +183,13 @@ describe('BannerComponent', () => { it('keeps subsequent main lines indented to the main-text column and subtext aligned with the tag text', () => { const width = 20; - const lines = new BannerComponent({ - tag: 'New:', - mainText: 'Line 1 with a lot of content', - subText: 'Sub text', - }).render(width); + const lines = new BannerComponent( + makeBannerState({ + tag: 'New:', + mainText: 'Line 1 with a lot of content', + subText: 'Sub text', + }), + ).render(width); for (const line of lines) { expect(visibleWidth(line)).toBeLessThanOrEqual(width); } diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 6b73d6114..8bc6f6fa5 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -1,8 +1,13 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { log, type GoalSnapshot } from '@moonshot-ai/kimi-code-sdk'; import type { MigrationPlan } from '@moonshot-ai/migration-legacy'; import { describe, expect, it, vi } from 'vitest'; import { BannerProvider } from '#/tui/banner/banner-provider'; +import { readBannerDisplayState } from '#/tui/banner/state'; import { handleLoginCommand, handleLogoutCommand } from '#/tui/commands/auth'; import { promptPlatformSelection, promptLogoutProviderSelection } from '#/tui/commands/prompts'; import { BannerComponent } from '#/tui/components/chrome/banner'; @@ -1569,9 +1574,11 @@ describe('KimiTUI startup', () => { it('renders the banner below the welcome message after it loads', async () => { const banner = { + key: 'new-banner', tag: 'New', mainText: 'Banner main', subText: null, + display: 'always' as const, }; const loadSpy = vi.spyOn(BannerProvider.prototype, 'load').mockResolvedValue(banner); const session = makeSession({ id: 'ses-target' }); @@ -1605,6 +1612,96 @@ describe('KimiTUI startup', () => { loadSpy.mockRestore(); }); + it('writes display state after rendering a once banner', async () => { + const originalEnv = { ...process.env }; + const dir = mkdtempSync(join(tmpdir(), 'kimi-startup-banner-')); + process.env['KIMI_CODE_HOME'] = dir; + + try { + const banner = { + key: 'once-banner', + tag: null, + mainText: 'Banner main', + subText: null, + display: 'once' as const, + }; + const loadSpy = vi.spyOn(BannerProvider.prototype, 'load').mockResolvedValue(banner); + const session = makeSession({ id: 'ses-target' }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-target', workDir: '/tmp/proj-a' }]), + }); + const driver = makeDriver( + harness, + makeStartupInput({ session: 'ses-target' }), + ) as unknown as MigrateExitDriver; + + await driver.initMainTui(); + + await vi.waitFor(() => { + expect( + driver.state.transcriptContainer.children.some((child) => child instanceof BannerComponent), + ).toBe(true); + }); + + await expect(readBannerDisplayState()).resolves.toMatchObject({ + version: 1, + shown: { + 'once-banner': { + lastShownAt: expect.any(String), + }, + }, + }); + + loadSpy.mockRestore(); + } finally { + process.env = { ...originalEnv }; + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('does not write display state for an always banner', async () => { + const originalEnv = { ...process.env }; + const dir = mkdtempSync(join(tmpdir(), 'kimi-startup-banner-')); + process.env['KIMI_CODE_HOME'] = dir; + + try { + const banner = { + key: 'always-banner', + tag: null, + mainText: 'Banner main', + subText: null, + display: 'always' as const, + }; + const loadSpy = vi.spyOn(BannerProvider.prototype, 'load').mockResolvedValue(banner); + const session = makeSession({ id: 'ses-target' }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: 'ses-target', workDir: '/tmp/proj-a' }]), + }); + const driver = makeDriver( + harness, + makeStartupInput({ session: 'ses-target' }), + ) as unknown as MigrateExitDriver; + + await driver.initMainTui(); + + await vi.waitFor(() => { + expect( + driver.state.transcriptContainer.children.some((child) => child instanceof BannerComponent), + ).toBe(true); + }); + + await expect(readBannerDisplayState()).resolves.toEqual({ + version: 1, + shown: {}, + }); + + loadSpy.mockRestore(); + } finally { + process.env = { ...originalEnv }; + rmSync(dir, { recursive: true, force: true }); + } + }); + it('resumes a startup session when Windows workdir uses backslashes', async () => { const session = makeSession({ id: 'ses-target' }); const harness = makeHarness(session, { From 3e6196e6b227c66860651f4335e06973865b2714 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 16 Jun 2026 15:20:23 +0800 Subject: [PATCH 009/383] fix: build replay ranges from replay records (#805) --- .changeset/replay-range-projection.md | 6 + packages/agent-core/src/agent/index.ts | 10 +- .../agent-core/src/agent/records/index.ts | 24 ++- packages/agent-core/src/agent/replay/build.ts | 20 ++ packages/agent-core/src/agent/replay/index.ts | 76 ++++++- packages/agent-core/src/index.ts | 1 + .../test/agent/records/index.test.ts | 192 ++++++++++++++++++ 7 files changed, 309 insertions(+), 20 deletions(-) create mode 100644 .changeset/replay-range-projection.md create mode 100644 packages/agent-core/src/agent/replay/build.ts diff --git a/.changeset/replay-range-projection.md b/.changeset/replay-range-projection.md new file mode 100644 index 000000000..d25b8cfa8 --- /dev/null +++ b/.changeset/replay-range-projection.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Project session replay ranges over rendered replay records instead of raw persisted records. diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 1f048db1d..392c1d4ea 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -42,8 +42,9 @@ import { FileSystemAgentRecordPersistence, type AgentRecord, type AgentRecordPersistence, + type AgentRecordsReplayOptions, } from './records'; -import { ReplayBuilder } from './replay'; +import { ReplayBuilder, type ReplayBuilderOptions } from './replay'; import { SkillManager } from './skill'; import type { SkillRegistry } from './skill/types'; import { SwarmMode } from './swarm'; @@ -87,6 +88,7 @@ export interface AgentOptions { readonly telemetry?: TelemetryClient | undefined; readonly pluginSessionStarts?: readonly EnabledPluginSessionStart[]; readonly experimentalFlags?: ExperimentalFlagResolver; + readonly replay?: ReplayBuilderOptions; } export class Agent { @@ -182,7 +184,7 @@ export class Agent { ); this.cron = this.type === 'sub' ? null : new CronManager(this); this.goal = new GoalMode(this); - this.replayBuilder = new ReplayBuilder(this); + this.replayBuilder = new ReplayBuilder(this, options.replay); } setKaos(kaos: Kaos) { @@ -291,8 +293,8 @@ export class Agent { this.tools.setActiveTools(profile.tools); } - async resume(): Promise<{ warning?: string }> { - const result = await this.records.replay(); + async resume(options?: AgentRecordsReplayOptions): Promise<{ warning?: string }> { + const result = await this.records.replay(options); try { this.replayBuilder.postRestoring = true; this.goal.normalizeAfterReplay(); diff --git a/packages/agent-core/src/agent/records/index.ts b/packages/agent-core/src/agent/records/index.ts index c00f169fd..36f4794f7 100644 --- a/packages/agent-core/src/agent/records/index.ts +++ b/packages/agent-core/src/agent/records/index.ts @@ -127,6 +127,10 @@ export interface RestoringContext { time?: number; } +export interface AgentRecordsReplayOptions { + readonly rewriteMigratedRecords?: boolean; +} + export class AgentRecords { private _restoring: RestoringContext | null = null; private metadataInitialized = false; @@ -162,22 +166,25 @@ export class AgentRecords { this.persistence?.append(stamped); } - restore(record: AgentRecord): void { + restore(record: AgentRecord): boolean { this._restoring = { time: record.time ?? Date.now() }; try { restoreAgentRecord(this.agent, record); + return this.agent.replayBuilder.finishRestoringRecord(record.type); } finally { this._restoring = null; } } - async replay(): Promise<{ warning?: string }> { + async replay(options: AgentRecordsReplayOptions = {}): Promise<{ warning?: string }> { if (!this.persistence) throw new Error('No persistence provided for AgentRecords'); + const rewriteMigratedRecords = options.rewriteMigratedRecords ?? true; let migrations: readonly WireMigration[] = []; let hasMetadata = false; let shouldRewrite = false; let warning: string | undefined; - const replayedRecords: AgentRecord[] = []; + const replayedRecords: AgentRecord[] | undefined = rewriteMigratedRecords ? [] : undefined; + let completed = true; for await (const record of this.persistence.read()) { if (!hasMetadata) { if (record.type !== 'metadata') { @@ -204,14 +211,17 @@ export class AgentRecords { protocol_version: AGENT_WIRE_PROTOCOL_VERSION, }; } - replayedRecords.push(migratedRecord); - this.restore(migratedRecord); + replayedRecords?.push(migratedRecord); + if (this.restore(migratedRecord)) { + completed = false; + break; + } } - if (shouldRewrite) { + if (completed && shouldRewrite && replayedRecords !== undefined) { this.persistence.rewrite(replayedRecords); await this.persistence.flush(); } - if (this.agent.blobStore !== undefined) { + if (completed && this.agent.blobStore !== undefined) { for (const msg of this.agent.context.history) { await this.agent.blobStore.rehydrateParts(msg.content); } diff --git a/packages/agent-core/src/agent/replay/build.ts b/packages/agent-core/src/agent/replay/build.ts new file mode 100644 index 000000000..3181ec434 --- /dev/null +++ b/packages/agent-core/src/agent/replay/build.ts @@ -0,0 +1,20 @@ +import { LocalKaos } from '@moonshot-ai/kaos'; + +import type { AgentReplayRecord } from '../../rpc/resumed'; +import { Agent } from '../index'; +import type { AgentRecordPersistence } from '../records'; +import type { ReplayRangeOptions } from '.'; + +export async function buildReplay( + persistence: AgentRecordPersistence, + range?: ReplayRangeOptions, +): Promise { + const agent = new Agent({ + kaos: await LocalKaos.create(), + persistence, + type: 'sub', + replay: { range }, + }); + await agent.resume({ rewriteMigratedRecords: false }); + return agent.replayBuilder.buildResult(); +} diff --git a/packages/agent-core/src/agent/replay/index.ts b/packages/agent-core/src/agent/replay/index.ts index 440579a15..aea14be96 100644 --- a/packages/agent-core/src/agent/replay/index.ts +++ b/packages/agent-core/src/agent/replay/index.ts @@ -1,20 +1,38 @@ import type { Agent } from '..'; -import type { AgentReplayRecord, AgentReplayRecordPayload } from '../..'; +import type { AgentReplayRecord, AgentReplayRecordPayload } from '../../rpc/resumed'; import type { ContextMessage } from '../context'; +export interface ReplayRangeOptions { + readonly start?: number; + readonly count?: number; +} + +export interface ReplayBuilderOptions { + readonly range?: ReplayRangeOptions; +} + +const UNDO_BOUNDARY_RECORD_TYPES = new Set(['context.clear', 'context.apply_compaction']); + export class ReplayBuilder { postRestoring = false; captureLiveRecords = false; protected readonly records: AgentReplayRecord[] = []; + private frozen = false; + private segmentStart = 0; - constructor(public readonly agent: Agent) {} + constructor( + public readonly agent: Agent, + private readonly options: ReplayBuilderOptions = {}, + ) {} push(record: AgentReplayRecordPayload): void { if (this.captureLiveRecords || this.agent.records.restoring || this.postRestoring) { - this.records.push({ + if (this.frozen) return; + const stamped: AgentReplayRecord = { ...record, time: this.agent.records.restoring?.time ?? Date.now(), - }); + }; + this.records.push(stamped); } } @@ -22,6 +40,7 @@ export class ReplayBuilder { type: T, patch: Partial>, ): void { + if (this.frozen) return; if (this.agent.records.restoring) { const last = this.records.at(-1); if (last && last.type === type) { @@ -31,16 +50,55 @@ export class ReplayBuilder { } removeLastMessages(removedMessages: ReadonlySet): void { + if (this.frozen) return; if (removedMessages.size === 0) return; - for (let i = this.records.length - 1; i >= 0; i--) { - const record = this.records[i]!; - if (record.type === 'message' && removedMessages.has(record.message)) { - this.records.splice(i, 1); - } + this.removeMessagesFrom(this.records, removedMessages); + } + + finishRestoringRecord(type: string): boolean { + const range = this.options.range; + if (range === undefined) return false; + if (this.frozen) return true; + if (!UNDO_BOUNDARY_RECORD_TYPES.has(type)) return false; + if (range.start === undefined) return false; + + const start = range.start; + const nextSegmentStart = this.segmentStart + this.records.length; + if (nextSegmentStart > start) { + this.frozen = true; + return true; } + + this.segmentStart = nextSegmentStart; + this.records.splice(0); + return false; } buildResult(): readonly AgentReplayRecord[] { + const range = this.options.range; + if (range !== undefined) { + if (range.start === undefined && range.count !== undefined) { + const offset = Math.max(0, this.records.length - range.count); + return this.records.slice(offset); + } + const start = range.start ?? 0; + const offset = Math.max(0, start - this.segmentStart); + const count = range.count; + const end = count === undefined ? undefined : offset + count; + return this.records.slice(offset, end); + } return this.records; } + + private removeMessagesFrom( + records: AgentReplayRecord[], + removedMessages: ReadonlySet, + ): void { + for (let i = records.length - 1; i >= 0; i--) { + const record = records[i]!; + if (record.type === 'message' && removedMessages.has(record.message)) { + records.splice(i, 1); + } + } + } } diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index a35781ccf..3e09ec472 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -7,6 +7,7 @@ export * from './session/export'; export * from './telemetry'; export * from './errors'; export * from './plugin'; +export { buildReplay } from './agent/replay/build'; export { flushDiagnosticLogs, getRootLogger, diff --git a/packages/agent-core/test/agent/records/index.test.ts b/packages/agent-core/test/agent/records/index.test.ts index 5cd039491..56ed53a1b 100644 --- a/packages/agent-core/test/agent/records/index.test.ts +++ b/packages/agent-core/test/agent/records/index.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from 'vitest'; +import { buildReplay } from '../../../src'; import { AGENT_WIRE_PROTOCOL_VERSION, InMemoryAgentRecordPersistence, type AgentRecord, } from '../../../src/agent/records'; +import type { ContextMessage } from '../../../src/agent/context'; import { testAgent } from '../harness/agent'; describe('AgentRecords persistence metadata', () => { @@ -306,6 +308,188 @@ describe('AgentRecords persistence metadata', () => { }); }); +describe('agent replay range build', () => { + it('returns the complete replay when no range is requested', async () => { + const firstMessage = userMessage('first'); + const afterClearMessage = userMessage('after-clear'); + const persistence = new InMemoryAgentRecordPersistence([ + { type: 'metadata', protocol_version: AGENT_WIRE_PROTOCOL_VERSION, created_at: 1 }, + { type: 'context.append_message', message: firstMessage }, + { type: 'context.clear' }, + { type: 'context.append_message', message: afterClearMessage }, + ]); + + await expect(buildReplay(persistence)).resolves.toEqual([ + expect.objectContaining({ type: 'message', message: firstMessage }), + expect.objectContaining({ type: 'message', message: afterClearMessage }), + ]); + }); + + it('applies start and count to replay records instead of wire records', async () => { + const message = userMessage('hello'); + const persistence = new RecordingInMemoryAgentRecordPersistence([ + { type: 'metadata', protocol_version: AGENT_WIRE_PROTOCOL_VERSION, created_at: 1 }, + { + type: 'usage.record', + model: 'mock-model', + usage: { inputOther: 1, inputCacheRead: 0, inputCacheCreation: 0, output: 1 }, + }, + { + type: 'config.update', + cwd: process.cwd(), + thinkingLevel: 'off', + }, + { + type: 'usage.record', + model: 'mock-model', + usage: { inputOther: 2, inputCacheRead: 0, inputCacheCreation: 0, output: 1 }, + }, + { type: 'permission.set_mode', mode: 'yolo' }, + { type: 'context.append_message', message }, + ]); + + const replay = await buildReplay(persistence, { start: 1, count: 2 }); + + expect(replay).toEqual([ + expect.objectContaining({ type: 'permission_updated', mode: 'yolo' }), + expect.objectContaining({ type: 'message', message }), + ]); + expect(persistence.rewrites).toEqual([]); + }); + + it('returns the last count replay records when start is omitted', async () => { + const firstMessage = userMessage('first'); + const secondMessage = userMessage('second'); + const thirdMessage = userMessage('third'); + const persistence = new InMemoryAgentRecordPersistence([ + { type: 'metadata', protocol_version: AGENT_WIRE_PROTOCOL_VERSION, created_at: 1 }, + { type: 'context.append_message', message: firstMessage }, + { type: 'permission.set_mode', mode: 'auto' }, + { type: 'context.append_message', message: secondMessage }, + { type: 'context.append_message', message: thirdMessage }, + ]); + + await expect(buildReplay(persistence, { count: 2 })).resolves.toEqual([ + expect.objectContaining({ type: 'message', message: secondMessage }), + expect.objectContaining({ type: 'message', message: thirdMessage }), + ]); + await expect(buildReplay(persistence, { count: 10 })).resolves.toEqual([ + expect.objectContaining({ type: 'message', message: firstMessage }), + expect.objectContaining({ type: 'permission_updated', mode: 'auto' }), + expect.objectContaining({ type: 'message', message: secondMessage }), + expect.objectContaining({ type: 'message', message: thirdMessage }), + ]); + }); + + it('continues reading all segments before returning the last count replay records', async () => { + const beforeClearMessages = Array.from({ length: 50 }, (_item, index) => + userMessage(`before-clear-${String(index)}`), + ); + const afterClearMessages = Array.from({ length: 50 }, (_item, index) => + userMessage(`after-clear-${String(index)}`), + ); + const persistence = new InMemoryAgentRecordPersistence([ + { type: 'metadata', protocol_version: AGENT_WIRE_PROTOCOL_VERSION, created_at: 1 }, + ...beforeClearMessages.map((message) => ({ type: 'context.append_message' as const, message })), + { type: 'context.clear' }, + ...afterClearMessages.map((message) => ({ type: 'context.append_message' as const, message })), + ]); + + const replay = await buildReplay(persistence, { count: 10 }); + + expect(replay).toHaveLength(10); + expect(replay).toEqual( + afterClearMessages.slice(-10).map((message) => + expect.objectContaining({ type: 'message', message }), + ), + ); + }); + + it('continues reading after count so later wire records can patch captured replay records', async () => { + const persistence = new InMemoryAgentRecordPersistence([ + { type: 'metadata', protocol_version: AGENT_WIRE_PROTOCOL_VERSION, created_at: 1 }, + { type: 'full_compaction.begin', source: 'manual', instruction: 'keep facts' }, + { + type: 'context.apply_compaction', + summary: 'Compacted summary.', + compactedCount: 0, + tokensBefore: 10, + tokensAfter: 3, + }, + { type: 'permission.set_mode', mode: 'auto' }, + ]); + + await expect(buildReplay(persistence, { start: 0, count: 1 })).resolves.toEqual([ + expect.objectContaining({ + type: 'compaction', + instruction: 'keep facts', + result: { + summary: 'Compacted summary.', + compactedCount: 0, + tokensBefore: 10, + tokensAfter: 3, + }, + }), + ]); + }); + + it('does not rewrite migrated wire records while projecting', async () => { + const persistence = new RecordingInMemoryAgentRecordPersistence([ + { type: 'metadata', protocol_version: '1.0', created_at: 1 }, + { type: 'permission.set_mode', mode: 'auto' }, + ]); + + await expect(buildReplay(persistence, { start: 0, count: 1 })).resolves.toEqual([ + expect.objectContaining({ type: 'permission_updated', mode: 'auto' }), + ]); + expect(persistence.rewrites).toEqual([]); + }); + + it('keeps the start offset correct when undo removes more messages than count', async () => { + const firstMessage = userMessage('first'); + const removedBeforeStart = userMessage('removed-before-start'); + const removedAtStart = userMessage('removed-at-start'); + const removedAfterStart = userMessage('removed-after-start'); + const nextMessage = userMessage('next'); + const expectedMessage = userMessage('expected'); + const persistence = new InMemoryAgentRecordPersistence([ + { type: 'metadata', protocol_version: AGENT_WIRE_PROTOCOL_VERSION, created_at: 1 }, + { type: 'context.append_message', message: firstMessage }, + { type: 'context.append_message', message: removedBeforeStart }, + { type: 'context.append_message', message: removedAtStart }, + { type: 'context.append_message', message: removedAfterStart }, + { type: 'context.undo', count: 3 }, + { type: 'context.append_message', message: nextMessage }, + { type: 'context.append_message', message: expectedMessage }, + ]); + + await expect(buildReplay(persistence, { start: 2, count: 1 })).resolves.toEqual([ + expect.objectContaining({ type: 'message', message: expectedMessage }), + ]); + }); + + it('clamps results at undo boundaries', async () => { + const firstMessage = userMessage('first'); + const secondMessage = userMessage('second'); + const afterClearMessage = userMessage('after-clear'); + const persistence = new InMemoryAgentRecordPersistence([ + { type: 'metadata', protocol_version: AGENT_WIRE_PROTOCOL_VERSION, created_at: 1 }, + { type: 'context.append_message', message: firstMessage }, + { type: 'context.append_message', message: secondMessage }, + { type: 'context.clear' }, + { type: 'context.append_message', message: afterClearMessage }, + ]); + + await expect(buildReplay(persistence, { start: 0, count: 10 })).resolves.toEqual([ + expect.objectContaining({ type: 'message', message: firstMessage }), + expect.objectContaining({ type: 'message', message: secondMessage }), + ]); + await expect(buildReplay(persistence, { start: 2, count: 10 })).resolves.toEqual([ + expect.objectContaining({ type: 'message', message: afterClearMessage }), + ]); + }); +}); + class RecordingInMemoryAgentRecordPersistence extends InMemoryAgentRecordPersistence { readonly rewrites: AgentRecord[][] = []; @@ -314,3 +498,11 @@ class RecordingInMemoryAgentRecordPersistence extends InMemoryAgentRecordPersist super.rewrite(records); } } + +function userMessage(text: string): ContextMessage { + return { + role: 'user', + content: [{ type: 'text', text }], + toolCalls: [], + }; +} From 7b5b8188157ec902e5cd4e73545bc5ca6c52bb76 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 16 Jun 2026 16:48:27 +0800 Subject: [PATCH 010/383] fix: continue compaction while context remains blocked (#813) --- .changeset/fresh-compaction-rounds.md | 6 + .../agent-core/src/agent/compaction/full.ts | 179 +++++------- .../src/agent/compaction/strategy.ts | 35 ++- packages/agent-core/src/utils/tokens.ts | 8 + .../test/agent/compaction/full.test.ts | 264 +++++++++++------- .../test/agent/compaction/strategy.test.ts | 31 ++ .../agent-core/test/agent/harness/agent.ts | 2 - 7 files changed, 310 insertions(+), 215 deletions(-) create mode 100644 .changeset/fresh-compaction-rounds.md diff --git a/.changeset/fresh-compaction-rounds.md b/.changeset/fresh-compaction-rounds.md new file mode 100644 index 000000000..d6d6cbc81 --- /dev/null +++ b/.changeset/fresh-compaction-rounds.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Fix repeated compaction handling when context remains over the blocking threshold. diff --git a/packages/agent-core/src/agent/compaction/full.ts b/packages/agent-core/src/agent/compaction/full.ts index 23aadc90c..55bf73f1f 100644 --- a/packages/agent-core/src/agent/compaction/full.ts +++ b/packages/agent-core/src/agent/compaction/full.ts @@ -2,16 +2,15 @@ import { ErrorCodes, KimiError, isKimiError, - makeErrorPayload, toKimiErrorPayload, } from '#/errors'; import { APIEmptyResponseError, isRetryableGenerateError, type GenerateResult, - type Message, type TokenUsage, APIContextOverflowError, + createUserMessage, } from '@moonshot-ai/kosong'; import type { Agent } from '..'; @@ -30,7 +29,6 @@ import { resolveCompletionBudget, } from '../../utils/completion-budget'; import compactionInstructionTemplate from './compaction-instruction.md?raw'; -import { renderMessagesToText } from './render-messages'; import { renderTodoList, type TodoItem } from '../../tools/builtin/state/todo-list'; import type { CompactionBeginData, CompactionResult } from './types'; import { @@ -39,12 +37,6 @@ import { type CompactionStrategy, } from './strategy'; -type CompactionTelemetryTrigger = CompactionBeginData['source'] | 'manual-with-prompt' | 'unknown'; - -export interface CompactedHistory { - text: string; -} - export const MAX_COMPACTION_RETRY_ATTEMPTS = 5; class CompactionTruncatedError extends Error { @@ -58,12 +50,9 @@ export class FullCompaction { protected compactionCountInTurn = 0; protected compacting: { abortController: AbortController; - startedAt: number; - telemetryTrigger: CompactionTelemetryTrigger; promise: Promise; blockedByTurn: boolean; } | null = null; - protected _compactedHistory: CompactedHistory[] = []; protected readonly strategy: CompactionStrategy; constructor( @@ -87,10 +76,6 @@ export class FullCompaction { return this.compacting !== null; } - get compactedHistory(): readonly CompactedHistory[] { - return this._compactedHistory; - } - begin(data: Readonly): void { if (this.compacting) return; if (data.source === 'manual') { @@ -114,35 +99,20 @@ export class FullCompaction { type: 'full_compaction.begin', ...data, }); - this.startCompactionWorker(data, compactedCount); - } - - private startCompactionWorker( - data: Readonly, - compactedCount: number, - ): void { - const abortController = new AbortController(); this.agent.emitEvent({ type: 'compaction.started', trigger: data.source, instruction: data.instruction, }); - const active = { + const abortController = new AbortController(); + this.compacting = { abortController, - startedAt: Date.now(), - telemetryTrigger: compactionTelemetryTrigger(data.source, data.instruction), - promise: Promise.resolve(), + promise: this.compactionWorker(abortController.signal, data, compactedCount), blockedByTurn: false, }; - this.compacting = active; - active.promise = this.compactionWorker(abortController.signal, data, compactedCount); } cancel(): void { - this.markCanceled(); - } - - private markCanceled(): void { this.agent.replayBuilder.patchLast('compaction', { result: 'cancelled', }); @@ -160,9 +130,6 @@ export class FullCompaction { type: 'full_compaction.complete', }); this.compacting = null; - this._compactedHistory.push({ - text: renderMessagesToText(this.agent.context.history), - }); } private get tokenCountWithPending(): number { @@ -197,7 +164,6 @@ export class FullCompaction { private checkAutoCompaction(throwOnLimit: boolean = true): boolean { if (this.compacting) return true; if (!this.strategy.shouldCompact(this.tokenCountWithPending)) return false; - return this.beginAutoCompaction(throwOnLimit); } @@ -236,8 +202,55 @@ export class FullCompaction { private async compactionWorker( signal: AbortSignal, data: Readonly, - initialCompactedCount: number, + compactedCount: number, ): Promise { + try { + const finalResult = { + summary: '', + compactedCount: 1, + tokensBefore: 0, + tokensAfter: 0, + }; + + for (let round = 1; ; round++) { + const result = await this.compactionRound(round, signal, data, compactedCount); + if (!result) return; + + finalResult.summary = result.summary; + finalResult.compactedCount += result.compactedCount - 1; + finalResult.tokensBefore += result.tokensBefore - finalResult.tokensAfter; + finalResult.tokensAfter = result.tokensAfter; + + if (result.tokensBefore - result.tokensAfter < 1024) break; + if (!this.strategy.shouldBlock(result.tokensAfter)) break; + compactedCount = this.strategy.computeCompactCount(this.agent.context.history, data.source); + if (compactedCount === 0) break; + } + this.markCompleted(); + this.agent.emitEvent({ type: 'compaction.completed', result: finalResult }); + await this.agent.injection.injectGoal(); + this.triggerPostCompactHook(data, finalResult); + } catch (error) { + if (isAbortError(error)) return; + const blockedByTurn = this.compacting?.blockedByTurn === true; + this.cancel(); + this.agent.log.error('compaction failed', { error }); + if (blockedByTurn) { + throw error; + } + this.agent.emitEvent({ + type: 'error', + ...toKimiErrorPayload(error), + }); + } + } + + private async compactionRound( + round: number, + signal: AbortSignal, + data: Readonly, + initialCompactedCount: number, + ) { const startedAt = Date.now(); const originalHistory = [...this.agent.context.history]; const tokensBefore = estimateTokensForMessages(originalHistory); @@ -263,16 +276,7 @@ export class FullCompaction { const messagesToCompact = originalHistory.slice(0, compactedCount); const messages = [ ...this.agent.context.project(messagesToCompact), - { - role: 'user', - content: [ - { - type: 'text', - text: COMPACTION_INSTRUCTION(data.instruction), - }, - ], - toolCalls: [], - } satisfies Message, + createUserMessage(renderPrompt(compactionInstructionTemplate, { customInstruction: data.instruction ?? '' })), ]; try { const response = await this.agent.generate( @@ -329,55 +333,30 @@ export class FullCompaction { tokensAfter, }; - const active = this.compacting!; this.agent.telemetry.track('compaction_finished', { - trigger_type: active.telemetryTrigger, - before_tokens: result.tokensBefore, - after_tokens: result.tokensAfter, - duration_ms: Date.now() - active.startedAt, - compacted_count: result.compactedCount, - retry_count: retryCount, + tokensBefore: result.tokensBefore, + tokensAfter: result.tokensAfter, + duration: Date.now() - startedAt, + compactedCount: result.compactedCount, + retryCount, + round, ...usage, + ...data, }); - this.markCompleted(); - this.agent.emitEvent({ type: 'compaction.completed', result }); this.agent.context.applyCompaction(result); - // Compaction collapses the prefix into a summary, dropping any goal - // reminder that lived there. Re-inject it onto the fresh tail so an active - // goal does not silently fall out of context. Append-only; no-op off goal mode. - await this.agent.injection.injectGoal(); - this.triggerPostCompactHook(data, result); + return result; } catch (error) { - if (!isAbortError(error)) { - const active = this.compacting; - const blockedByTurn = active?.blockedByTurn === true; - this.agent.log.error('compaction failed', { - code: isKimiError(error) ? error.code : undefined, - error, - }); - this.markCanceled(); - if (!blockedByTurn) { - const payload = - isKimiError(error) && error.code === ErrorCodes.AUTH_LOGIN_REQUIRED - ? toKimiErrorPayload(error) - : makeErrorPayload(ErrorCodes.COMPACTION_FAILED, String(error)); - this.agent.emitEvent({ - type: 'error', - ...payload, - }); - } - this.agent.telemetry.track('compaction_failed', { - trigger_type: compactionTelemetryTrigger(data.source, data.instruction), - before_tokens: tokensBefore, - duration_ms: Date.now() - startedAt, - retry_count: retryCount, - error_type: error instanceof Error ? error.name : 'Unknown', - }); - if (blockedByTurn) { - if (isKimiError(error) && error.code === ErrorCodes.AUTH_LOGIN_REQUIRED) throw error; - throw new KimiError(ErrorCodes.COMPACTION_FAILED, String(error), { cause: error }); - } - } + if (isAbortError(error)) return; + this.agent.telemetry.track('compaction_failed', { + ...data, + tokensBefore, + duration: Date.now() - startedAt, + round, + retryCount, + errorType: error instanceof Error ? error.name : 'Unknown', + }); + if (isKimiError(error) && error.code === ErrorCodes.AUTH_LOGIN_REQUIRED) throw error; + throw new KimiError(ErrorCodes.COMPACTION_FAILED, String(error), { cause: error }); } } @@ -435,17 +414,3 @@ function extractCompactionSummary(response: GenerateResult): string { } return summary; } - -export const COMPACTION_INSTRUCTION = (customInstruction = ''): string => - renderPrompt(compactionInstructionTemplate, { customInstruction }); - -function compactionTelemetryTrigger( - trigger: CompactionBeginData['source'] | undefined, - instruction: string | undefined, -): CompactionTelemetryTrigger { - if (trigger === undefined) return 'unknown'; - if (trigger === 'manual' && instruction !== undefined && instruction.length > 0) { - return 'manual-with-prompt'; - } - return trigger; -} diff --git a/packages/agent-core/src/agent/compaction/strategy.ts b/packages/agent-core/src/agent/compaction/strategy.ts index 2f69bfb07..edf9132e0 100644 --- a/packages/agent-core/src/agent/compaction/strategy.ts +++ b/packages/agent-core/src/agent/compaction/strategy.ts @@ -73,7 +73,7 @@ export class DefaultCompactionStrategy implements CompactionStrategy { if (source === 'manual') { for (let i = messages.length - 1; i > 0; i--) { if (canSplitAfter(messages, i)) { - return i + 1; + return this.fitCompactCountToWindow(messages, i + 1); } } return 0; @@ -115,7 +115,7 @@ export class DefaultCompactionStrategy implements CompactionStrategy { } } - return bestN ?? 0; + return this.fitCompactCountToWindow(messages, bestN ?? 0); } reduceCompactOnOverflow(messages: readonly Message[]): number { @@ -138,6 +138,37 @@ export class DefaultCompactionStrategy implements CompactionStrategy { return bestN ?? messages.length; } + private fitCompactCountToWindow( + messages: readonly Message[], + compactedCount: number, + ): number { + if (this.maxSize <= 0 || compactedCount <= 0) { + return compactedCount; + } + + let compactedSize = 0; + for (let i = 0; i < compactedCount; i++) { + compactedSize += estimateTokensForMessage(messages[i]!); + } + if (compactedSize <= this.maxSize) { + return compactedCount; + } + + let bestN: number | undefined; + for (let n = compactedCount - 1; n > 0; n--) { + compactedSize -= estimateTokensForMessage(messages[n]!); + if (!canSplitAfter(messages, n - 1)) { + continue; + } + bestN = n; + if (compactedSize <= this.maxSize) { + return n; + } + } + + return bestN ?? compactedCount; + } + get checkAfterStep(): boolean { return this.config.triggerRatio !== this.config.blockRatio; } diff --git a/packages/agent-core/src/utils/tokens.ts b/packages/agent-core/src/utils/tokens.ts index 77b3b4fb9..fe567f732 100644 --- a/packages/agent-core/src/utils/tokens.ts +++ b/packages/agent-core/src/utils/tokens.ts @@ -1,5 +1,7 @@ import type { ContentPart, Message, Tool } from '@moonshot-ai/kosong'; +const messageTokenEstimateCache = new WeakMap(); + /** * Estimate token count from text using a character-based heuristic. * - ASCII (~4 chars per token) @@ -40,6 +42,11 @@ export function estimateTokensForTools(tools: readonly Tool[]): number { } export function estimateTokensForMessage(message: Message): number { + const cached = messageTokenEstimateCache.get(message); + if (cached !== undefined) { + return cached; + } + let total = estimateTokens(message.role); total += estimateTokensForContentParts(message.content); if (message.toolCalls !== undefined) { @@ -48,6 +55,7 @@ export function estimateTokensForMessage(message: Message): number { total += estimateTokens(JSON.stringify(call.arguments)); } } + messageTokenEstimateCache.set(message, total); return total; } diff --git a/packages/agent-core/test/agent/compaction/full.test.ts b/packages/agent-core/test/agent/compaction/full.test.ts index 8e5f515cc..990140e1b 100644 --- a/packages/agent-core/test/agent/compaction/full.test.ts +++ b/packages/agent-core/test/agent/compaction/full.test.ts @@ -186,10 +186,12 @@ describe('FullCompaction', () => { resolve(); }); }); + const completed = ctx.once('compaction.completed'); ctx.mockNextResponse({ type: 'text', text: 'Compacted summary.' }); await ctx.rpc.beginCompaction({ instruction: 'Keep the important test facts.' }); await compacted; + await completed; expect(ctx.newEvents()).toMatchInlineSnapshot(` [wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "old user one" } ], "toolCalls": [], "origin": { "kind": "user" } }, "time": "