From b89fc1a4fbe8c0c3933659cb86b325c82731cf8f Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 8 Jul 2026 23:33:29 +0800 Subject: [PATCH 01/80] docs(changelog): sync 0.23.3 and shorten OAuth error entry (#1509) --- apps/kimi-code/CHANGELOG.md | 2 +- docs/en/release-notes/changelog.md | 6 ++++++ docs/zh/release-notes/changelog.md | 6 ++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/CHANGELOG.md b/apps/kimi-code/CHANGELOG.md index 9bde39c4a..2a3fc5857 100644 --- a/apps/kimi-code/CHANGELOG.md +++ b/apps/kimi-code/CHANGELOG.md @@ -4,7 +4,7 @@ ### Patch Changes -- [#1506](https://github.com/MoonshotAI/kimi-code/pull/1506) [`e83511a`](https://github.com/MoonshotAI/kimi-code/commit/e83511a7118652a67676bbcfd41148907ad7b8de) Thanks [@7Sageer](https://github.com/7Sageer)! - Fix a misleading "OAuth login expired" message shown when a model is not available for the current account; the CLI now shows the provider's actual error. +- [#1506](https://github.com/MoonshotAI/kimi-code/pull/1506) [`e83511a`](https://github.com/MoonshotAI/kimi-code/commit/e83511a7118652a67676bbcfd41148907ad7b8de) Thanks [@7Sageer](https://github.com/7Sageer)! - Fix a misleading "OAuth login expired" message shown when a model is not available for the current account. ## 0.23.2 diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 7b98cd3f5..d0549df1c 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -6,6 +6,12 @@ outline: 2 This page documents the changes in each Kimi Code CLI release. +## 0.23.3 (2026-07-08) + +### Bug Fixes + +- Fix a misleading "OAuth login expired" message shown when a model is not available for the current account. + ## 0.23.2 (2026-07-08) ### Features diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index bce292cfc..444699fdc 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -6,6 +6,12 @@ outline: 2 本页记录 Kimi Code CLI 每个版本的变更内容。 +## 0.23.3(2026-07-08) + +### 修复 + +- 修复当前账户无法使用某模型时错误显示“OAuth 登录已过期”的问题。 + ## 0.23.2(2026-07-08) ### 新功能 From 735922c291ec3d32d60da6af053f75e1c6179f92 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 9 Jul 2026 12:30:15 +0800 Subject: [PATCH 02/80] feat(kimi-web): add status-aware browser notifications (#1479) * feat(kimi-web): add approval notification storage key and i18n copy * feat(kimi-web): add approval notification helpers and tests * feat(kimi-web): wire approval notifications and guard completion alerts * fix(kimi-web): extract shouldNotifyCompletion helper and add tests * feat(kimi-web): add approval notification settings toggle * chore(kimi-web): add changeset and tidy notification module comment - Align approval notification tag with spec (kimi-approval-${approvalId}) - Update module header to describe all three notification kinds * fix(kimi-web): make notifications fire reliably - Key completion notification tags by turn (sid + promptId) and question tags by request id, so a stale notification left in the notification center no longer swallows every follow-up alert in the same session - Suppress notifications only while the window is actually focused, not merely visible (document.hasFocus() on top of visibilityState) - Play the attention sound when a tool needs approval, matching the completion and question sounds * chore(kimi-web): simplify changeset --- .changeset/web-approval-notifications.md | 5 + apps/kimi-web/src/App.vue | 2 + .../components/settings/SettingsDialog.vue | 15 ++ .../src/composables/client/useNotification.ts | 114 +++++++++++---- .../client/useSoundNotification.ts | 16 ++- .../src/composables/useKimiWebClient.ts | 75 +++++++--- apps/kimi-web/src/i18n/locales/en/settings.ts | 5 +- apps/kimi-web/src/i18n/locales/zh/settings.ts | 5 +- apps/kimi-web/src/lib/storage.ts | 1 + apps/kimi-web/test/notification-logic.test.ts | 130 +++++++++++++++++- 10 files changed, 320 insertions(+), 48 deletions(-) create mode 100644 .changeset/web-approval-notifications.md diff --git a/.changeset/web-approval-notifications.md b/.changeset/web-approval-notifications.md new file mode 100644 index 000000000..4ffab64dc --- /dev/null +++ b/.changeset/web-approval-notifications.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Add notifications when a tool needs approval, and improve notification reliability. diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index eb9131b48..f41127fb7 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -898,6 +898,7 @@ function openPr(url: string): void { :account-model="client.defaultModel.value" :notify="client.notifyOnComplete.value" :notify-question="client.notifyOnQuestion.value" + :notify-approval="client.notifyOnApproval.value" :notify-permission="client.notifyPermission.value" :sound="client.soundOnComplete.value" :conversation-toc="client.conversationToc.value" @@ -910,6 +911,7 @@ function openPr(url: string): void { @set-ui-font-size="client.setUiFontSize($event)" @set-notify="client.setNotifyOnComplete($event)" @set-notify-question="client.setNotifyOnQuestion($event)" + @set-notify-approval="client.setNotifyOnApproval($event)" @set-sound="client.setSoundOnComplete($event)" @set-conversation-toc="client.setConversationToc($event)" @update-config="handleUpdateConfig($event)" diff --git a/apps/kimi-web/src/components/settings/SettingsDialog.vue b/apps/kimi-web/src/components/settings/SettingsDialog.vue index 44d7cf3ca..8f3493a6f 100644 --- a/apps/kimi-web/src/components/settings/SettingsDialog.vue +++ b/apps/kimi-web/src/components/settings/SettingsDialog.vue @@ -32,6 +32,8 @@ const props = defineProps<{ notify: boolean; /** Browser-notification-on-question (needs answer) preference. */ notifyQuestion: boolean; + /** Browser-notification-on-approval preference. */ + notifyApproval: boolean; /** OS permission state ('default' | 'granted' | 'denied') for the hint. */ notifyPermission?: string; /** Play-a-sound-on-completion preference. */ @@ -54,6 +56,7 @@ const emit = defineEmits<{ setUiFontSize: [size: number]; setNotify: [on: boolean]; setNotifyQuestion: [on: boolean]; + setNotifyApproval: [on: boolean]; setSound: [on: boolean]; setConversationToc: [on: boolean]; login: []; @@ -417,6 +420,18 @@ function archiveTime(iso: string): string { @update:model-value="emit('setNotifyQuestion', $event)" /> +
+ + {{ t('settings.notifyOnApproval') }} + {{ t('settings.notifyDenied') }} + + +
{{ t('settings.soundOnComplete') }} ( typeof Notification !== 'undefined' ? Notification.permission : 'denied', ); @@ -61,20 +71,42 @@ function setNotifyOnQuestion(on: boolean): Promise { return setNotifyPref(notifyOnQuestion, STORAGE_KEYS.notifyOnQuestion, on); } -export interface NotifyCompletionCtx { - /** True when the target session is the active one and the page is visible — - in which case we suppress the notification. */ - isActiveAndVisible: boolean; +/** Enable/disable approval notifications. Off by default. */ +function setNotifyOnApproval(on: boolean): Promise { + return setNotifyPref(notifyOnApproval, STORAGE_KEYS.notifyOnApproval, on); +} + +export interface NotifyBaseCtx { + /** True when the user is actually watching the target session: it is the + active session, the page is visible, and the window has focus — in which + case we suppress the notification. */ + isUserWatching: boolean; /** Session title used as the completion notification body and a question-body fallback. */ sessionTitle: string; /** Called when the user clicks the notification (e.g. select the session). */ onClick: () => void; } -export interface NotifyQuestionCtx extends NotifyCompletionCtx { +export interface NotifyCompletionCtx extends NotifyBaseCtx { + /** Prompt id of the finished turn; keys the dedup tag so every turn fires its + own notification while a replayed idle event for the same turn stays + collapsed. Falls back to a per-call unique tag when absent. */ + promptId?: string; +} + +export interface NotifyQuestionCtx extends NotifyBaseCtx { /** Short preview of the question, used as the notification body. Falls back to the session title, then to a generic line when empty. */ questionPreview: string; + /** Unique question request id; used to deduplicate notifications per request. */ + questionId: string; +} + +export interface NotifyApprovalCtx extends NotifyBaseCtx { + /** Tool call name needing approval, used as the notification body. */ + toolName: string; + /** Unique approval request id; used to deduplicate notifications per request. */ + approvalId: string; } export interface NotificationCopy { @@ -111,12 +143,29 @@ export function questionNotificationCopy( }; } +export function approvalNotificationCopy( + sessionTitle: string, + toolName: string, +): NotificationCopy { + return { + title: i18n.global.t('settings.notifyApprovalTitle'), + body: firstText( + toolName, + sessionTitle, + i18n.global.t('settings.notifyApprovalFallback'), + ), + }; +} + /** Shared permission gate + fire. `enabled` is the caller's per-kind preference; - `copy` and `tag` let each kind carry its own text and a per-kind dedup tag - so a completion and a question don't collapse into one notification. */ + `copy` and `tag` let each kind carry its own text and a per-turn/per-request + dedup tag: repeats of the same turn or request collapse into one + notification, while distinct ones each fire (same-tag notifications replace + silently — renotify is unreliable across platforms — so the tag must change + whenever a new alert should pop). */ function maybeNotify( enabled: boolean, - ctx: NotifyCompletionCtx, + ctx: NotifyBaseCtx, copy: NotificationCopy, tag: string, ): void { @@ -135,8 +184,8 @@ function maybeNotify( fire(ctx, copy, tag); } -function fire(ctx: NotifyCompletionCtx, copy: NotificationCopy, tag: string): void { - if (ctx.isActiveAndVisible) return; +function fire(ctx: NotifyBaseCtx, copy: NotificationCopy, tag: string): void { + if (ctx.isUserWatching) return; try { const n = new Notification(copy.title, { body: copy.body, tag, icon: NOTIFICATION_ICON }); n.onclick = () => { @@ -154,24 +203,38 @@ function fire(ctx: NotifyCompletionCtx, copy: NotificationCopy, tag: string): vo } /** Fire a completion notification for a finished session, but only when the - caller says the user isn't already looking at it. */ + caller says the user isn't already looking at it. The tag carries the turn's + prompt id: same-tag notifications replace silently, so without it a stale + notification left in the notification center would swallow every later + turn's alert for that session. */ function maybeNotifyCompletion(sid: string, ctx: NotifyCompletionCtx): void { maybeNotify( notifyOnComplete.value, ctx, completionNotificationCopy(ctx.sessionTitle), - `kimi-complete-${sid}`, + `kimi-complete-${sid}-${ctx.promptId ?? Date.now()}`, ); } /** Fire a notification when a session asks a question, but only when the user explicitly opted into question notifications and isn't already looking. */ -function maybeNotifyQuestion(sid: string, ctx: NotifyQuestionCtx): void { +function maybeNotifyQuestion(ctx: NotifyQuestionCtx): void { maybeNotify( notifyOnQuestion.value, ctx, questionNotificationCopy(ctx.sessionTitle, ctx.questionPreview), - `kimi-question-${sid}`, + `kimi-question-${ctx.questionId}`, + ); +} + +/** Fire a notification when a tool needs approval, but only when the user + explicitly opted into approval notifications and isn't already looking. */ +function maybeNotifyApproval(ctx: NotifyApprovalCtx): void { + maybeNotify( + notifyOnApproval.value, + ctx, + approvalNotificationCopy(ctx.sessionTitle, ctx.toolName), + `kimi-approval-${ctx.approvalId}`, ); } @@ -179,10 +242,13 @@ export function useNotification() { return { notifyOnComplete, notifyOnQuestion, + notifyOnApproval, notifyPermission, setNotifyOnComplete, setNotifyOnQuestion, + setNotifyOnApproval, maybeNotifyCompletion, maybeNotifyQuestion, + maybeNotifyApproval, }; } diff --git a/apps/kimi-web/src/composables/client/useSoundNotification.ts b/apps/kimi-web/src/composables/client/useSoundNotification.ts index db58f6b64..3016c0c6d 100644 --- a/apps/kimi-web/src/composables/client/useSoundNotification.ts +++ b/apps/kimi-web/src/composables/client/useSoundNotification.ts @@ -1,7 +1,9 @@ // apps/kimi-web/src/composables/client/useSoundNotification.ts -// Browser "turn completed" sound: a persisted on/off preference plus a short -// chime synthesized with the WebAudio API (no audio asset, no permission -// prompt). Pure UI action module — it never reads rawState or calls the API. +// Browser attention sound: a persisted on/off preference plus a short chime +// synthesized with the WebAudio API (no audio asset, no permission prompt). +// One chime covers every "the agent needs you" moment — a finished turn, a +// question waiting for an answer, a tool needing approval. Pure UI action +// module — it never reads rawState or calls the API. // // Why the eager "unlock": the sound is most useful when the tab is in the // background (so you hear it while doing something else). But an AudioContext @@ -161,11 +163,19 @@ function maybePlayQuestionSound(): void { playChime(); } +/** Play the attention sound when a tool needs approval, whenever the + preference is on. Same chime as completion: it means "the agent needs you". */ +function maybePlayApprovalSound(): void { + if (!soundOnComplete.value) return; + playChime(); +} + export function useSoundNotification() { return { soundOnComplete, setSoundOnComplete, maybePlayCompletionSound, maybePlayQuestionSound, + maybePlayApprovalSound, }; } diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 2f7e822fc..9b61944bb 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -30,7 +30,7 @@ import { } from '../lib/storage'; import { createEventBatcher, isRenderEvent } from './client/eventBatcher'; import { useAppearance } from './client/useAppearance'; -import { useNotification } from './client/useNotification'; +import { useNotification, shouldNotifyCompletion } from './client/useNotification'; import { useSoundNotification } from './client/useSoundNotification'; import { useTaskPoller } from './client/useTaskPoller'; import { useModelProviderState } from './client/useModelProviderState'; @@ -860,6 +860,11 @@ function processEvent(appEvent: AppEvent, meta: { sessionId: string; seq: number if (appEvent.type === 'questionRequested') { onQuestionRequested(appEvent.sessionId, appEvent.question); } + + // The agent needs approval for a tool call — surface it so the user comes back. + if (appEvent.type === 'approvalRequested') { + onApprovalRequested(appEvent.sessionId, appEvent.approval); + } } const enqueueEvent = createEventBatcher( @@ -2315,10 +2320,27 @@ const workspaceState = useWorkspaceState(rawState, { fileDiffLoading, }); +/** True when the user is actually watching this session: it is the active + session, the page is visible, and the window has focus. Focus matters on + top of visibility: a window that lost focus to another app often stays + (partially) visible on screen, but the user is working elsewhere and would + miss the moment without a notification. */ +function isUserWatching(sid: string): boolean { + return ( + sid === rawState.activeSessionId && + typeof document !== 'undefined' && + document.visibilityState === 'visible' && + document.hasFocus() + ); +} + function onSessionIdle(sid: string, status: 'idle' | 'aborted'): void { // The turn finished — this session no longer has a prompt in flight. inFlightPromptSessions.delete(sid); rawState.sendingBySession = { ...rawState.sendingBySession, [sid]: false }; + // Capture before the cleanup below drops it — it keys the completion + // notification's dedup tag so each finished turn alerts once. + const finishedPromptId = rawState.promptIdBySession[sid]; // Drop any cached prompt_id so a later skill activation (which has no // prompt_id) doesn't accidentally reuse this stale id for :abort. if (rawState.promptIdBySession[sid] !== undefined) { @@ -2343,16 +2365,20 @@ function onSessionIdle(sid: string, status: 'idle' | 'aborted'): void { } // Browser notification when the user isn't watching this session. - notification.maybeNotifyCompletion(sid, { - isActiveAndVisible: - sid === rawState.activeSessionId && - typeof document !== 'undefined' && - document.visibilityState === 'visible', - sessionTitle: rawState.sessions.find((s) => s.id === sid)?.title ?? '', - onClick: () => { - void workspaceState.selectSession(sid); - }, - }); + // Only real completions notify; aborted turns and turns that ended up + // blocked on approval/question do not fire the generic "Turn finished" alert. + const hasPendingApproval = (rawState.approvalsBySession[sid] ?? []).length > 0; + const hasPendingQuestion = (rawState.questionsBySession[sid] ?? []).length > 0; + if (shouldNotifyCompletion(status, hasPendingApproval, hasPendingQuestion)) { + notification.maybeNotifyCompletion(sid, { + isUserWatching: isUserWatching(sid), + sessionTitle: rawState.sessions.find((s) => s.id === sid)?.title ?? '', + promptId: finishedPromptId, + onClick: () => { + void workspaceState.selectSession(sid); + }, + }); + } // Completion sound — only for real completions (aborted/cancelled turns stay // silent). Plays regardless of visibility so it also reaches a backgrounded tab. @@ -2391,13 +2417,11 @@ function onQuestionRequested(sid: string, question: AppQuestionRequest): void { header && questionText ? `${header}: ${questionText}` : questionText || header; // Browser notification when the user isn't watching this session. - notification.maybeNotifyQuestion(sid, { - isActiveAndVisible: - sid === rawState.activeSessionId && - typeof document !== 'undefined' && - document.visibilityState === 'visible', + notification.maybeNotifyQuestion({ + isUserWatching: isUserWatching(sid), sessionTitle: rawState.sessions.find((s) => s.id === sid)?.title ?? '', questionPreview: preview, + questionId: question.questionId, onClick: () => { void workspaceState.selectSession(sid); }, @@ -2408,6 +2432,23 @@ function onQuestionRequested(sid: string, question: AppQuestionRequest): void { sound.maybePlayQuestionSound(); } +function onApprovalRequested(sid: string, approval: AppApprovalRequest): void { + // Browser notification when the user isn't watching this session. + notification.maybeNotifyApproval({ + isUserWatching: isUserWatching(sid), + sessionTitle: rawState.sessions.find((s) => s.id === sid)?.title ?? '', + toolName: approval.toolName, + approvalId: approval.approvalId, + onClick: () => { + void workspaceState.selectSession(sid); + }, + }); + + // Attention sound — plays regardless of visibility so it also reaches a + // backgrounded tab (same as the completion sound). + sound.maybePlayApprovalSound(); +} + // --------------------------------------------------------------------------- // Composable return // --------------------------------------------------------------------------- @@ -2501,9 +2542,11 @@ export function useKimiWebClient() { setAccent: appearance.setAccent, notifyOnComplete: notification.notifyOnComplete, notifyOnQuestion: notification.notifyOnQuestion, + notifyOnApproval: notification.notifyOnApproval, notifyPermission: notification.notifyPermission, setNotifyOnComplete: notification.setNotifyOnComplete, setNotifyOnQuestion: notification.setNotifyOnQuestion, + setNotifyOnApproval: notification.setNotifyOnApproval, soundOnComplete: sound.soundOnComplete, setSoundOnComplete: sound.setSoundOnComplete, onboarded, diff --git a/apps/kimi-web/src/i18n/locales/en/settings.ts b/apps/kimi-web/src/i18n/locales/en/settings.ts index ec2c8d770..c8aa5a56f 100644 --- a/apps/kimi-web/src/i18n/locales/en/settings.ts +++ b/apps/kimi-web/src/i18n/locales/en/settings.ts @@ -12,12 +12,15 @@ export default { notifications: 'Notifications', notifyOnComplete: 'Notify when a turn completes', notifyOnQuestion: 'Notify when a question needs an answer', - soundOnComplete: 'Play a sound when a turn completes or needs an answer', + notifyOnApproval: 'Notify when a tool needs approval', + soundOnComplete: 'Play a sound when a turn completes, needs an answer, or needs approval', notifyDenied: 'Blocked in browser settings', notifyTitle: 'Kimi Code · Turn finished', notifyQuestionTitle: 'Kimi Code · Needs answer', + notifyApprovalTitle: 'Kimi Code · Approval required', notifyFallback: 'View result', notifyQuestionFallback: 'A question is waiting for your answer', + notifyApprovalFallback: 'A tool needs your approval', account: 'Account', uiFontSize: 'Font size', agentDefaults: 'Agent defaults', diff --git a/apps/kimi-web/src/i18n/locales/zh/settings.ts b/apps/kimi-web/src/i18n/locales/zh/settings.ts index a06a14bdd..d20d6bfa6 100644 --- a/apps/kimi-web/src/i18n/locales/zh/settings.ts +++ b/apps/kimi-web/src/i18n/locales/zh/settings.ts @@ -12,12 +12,15 @@ export default { notifications: '通知', notifyOnComplete: '会话完成时通知', notifyOnQuestion: '待回答时通知', - soundOnComplete: '会话完成或待回答时播放提示音', + notifyOnApproval: '待审批时通知', + soundOnComplete: '会话完成、待回答或待审批时播放提示音', notifyDenied: '已在浏览器设置中被阻止', notifyTitle: 'Kimi Code · 回合完成', notifyQuestionTitle: 'Kimi Code · 待回答', + notifyApprovalTitle: 'Kimi Code · 等待审批', notifyFallback: '点击查看结果', notifyQuestionFallback: '有提问等待你回答', + notifyApprovalFallback: '有工具等待你审批', account: '账户', uiFontSize: '字体大小', agentDefaults: 'Agent 默认值', diff --git a/apps/kimi-web/src/lib/storage.ts b/apps/kimi-web/src/lib/storage.ts index a6d0a1f31..36dab4c61 100644 --- a/apps/kimi-web/src/lib/storage.ts +++ b/apps/kimi-web/src/lib/storage.ts @@ -32,6 +32,7 @@ export const STORAGE_KEYS = { conversationToc: 'kimi-web.beta-toc', notifyOnComplete: 'kimi-web.notify-on-complete', notifyOnQuestion: 'kimi-web.notify-on-question', + notifyOnApproval: 'kimi-web.notify-on-approval', soundOnComplete: 'kimi-web.sound-on-complete', inputHistory: 'kimi-web.input-history', // cross-file diff --git a/apps/kimi-web/test/notification-logic.test.ts b/apps/kimi-web/test/notification-logic.test.ts index f10a31d7d..5b13de8fc 100644 --- a/apps/kimi-web/test/notification-logic.test.ts +++ b/apps/kimi-web/test/notification-logic.test.ts @@ -2,8 +2,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { i18n } from '../src/i18n'; import { STORAGE_KEYS, safeGetString } from '../src/lib/storage'; import { + approvalNotificationCopy, completionNotificationCopy, questionNotificationCopy, + shouldNotifyCompletion, useNotification, } from '../src/composables/client/useNotification'; @@ -41,11 +43,17 @@ function installStorage(storage: Storage): void { // Singleton — module-level refs + setters. The OS Notification API is absent in // the test env, so the *enable* path is a no-op; the disable path and the // load-from-storage defaults are what we exercise here. -const { notifyOnComplete, notifyOnQuestion, setNotifyOnComplete, setNotifyOnQuestion } = useNotification(); -// Captured at import (before beforeEach touches the refs), so these reflect the -// load-from-storage defaults when nothing has been stored yet. +const { + notifyOnComplete, + notifyOnQuestion, + notifyOnApproval, + setNotifyOnComplete, + setNotifyOnQuestion, + setNotifyOnApproval, +} = useNotification(); const importedCompleteDefault = notifyOnComplete.value; const importedQuestionDefault = notifyOnQuestion.value; +const importedApprovalDefault = notifyOnApproval.value; describe('useNotification preferences', () => { beforeEach(() => { @@ -64,6 +72,10 @@ describe('useNotification preferences', () => { expect(importedQuestionDefault).toBe(false); }); + it('approval notifications default to off', () => { + expect(importedApprovalDefault).toBe(false); + }); + it('disabling question notifications persists "0" and updates the ref', () => { void setNotifyOnQuestion(false); expect(notifyOnQuestion.value).toBe(false); @@ -75,6 +87,12 @@ describe('useNotification preferences', () => { expect(notifyOnComplete.value).toBe(false); expect(safeGetString(STORAGE_KEYS.notifyOnComplete)).toBe('0'); }); + + it('disabling approval notifications persists "0" and updates the ref', () => { + void setNotifyOnApproval(false); + expect(notifyOnApproval.value).toBe(false); + expect(safeGetString(STORAGE_KEYS.notifyOnApproval)).toBe('0'); + }); }); describe('notification copy', () => { @@ -110,6 +128,32 @@ describe('notification copy', () => { }); }); + it('uses tool name in approval notifications', () => { + expect(approvalNotificationCopy('Refactor auth flow', 'bash')).toEqual({ + title: 'Kimi Code · Approval required', + body: 'bash', + }); + }); + + it('falls back to session title and then generic approval line', () => { + expect(approvalNotificationCopy('Refactor auth flow', ' ')).toEqual({ + title: 'Kimi Code · Approval required', + body: 'Refactor auth flow', + }); + expect(approvalNotificationCopy(' ', ' ')).toEqual({ + title: 'Kimi Code · Approval required', + body: 'A tool needs your approval', + }); + }); + + it('localizes approval notification copy', () => { + i18n.global.locale.value = 'zh'; + expect(approvalNotificationCopy('', '')).toEqual({ + title: 'Kimi Code · 等待审批', + body: '有工具等待你审批', + }); + }); + it('localizes the notification copy', () => { i18n.global.locale.value = 'zh'; @@ -123,3 +167,83 @@ describe('notification copy', () => { }); }); }); + +describe('shouldNotifyCompletion', () => { + it('returns true only for idle + no pending approval + no pending question', () => { + expect(shouldNotifyCompletion('idle', false, false)).toBe(true); + }); + + it('returns false for aborted', () => { + expect(shouldNotifyCompletion('aborted', false, false)).toBe(false); + }); + + it('returns false when pending approval exists', () => { + expect(shouldNotifyCompletion('idle', true, false)).toBe(false); + }); + + it('returns false when pending question exists', () => { + expect(shouldNotifyCompletion('idle', false, true)).toBe(false); + }); +}); + +// Same-tag notifications replace silently (renotify is unreliable), so the tag +// must be unique per turn/request for follow-up alerts in a session to pop. +describe('notification tags', () => { + class FakeNotification { + static permission = 'granted'; + static fired: Array<{ title: string; tag?: string }> = []; + onclick: (() => void) | null = null; + constructor(title: string, options?: { body?: string; tag?: string; icon?: string }) { + FakeNotification.fired.push({ title, tag: options?.tag }); + } + close(): void {} + } + + const { maybeNotifyCompletion, maybeNotifyQuestion, maybeNotifyApproval } = useNotification(); + const base = { isUserWatching: false, sessionTitle: 'T', onClick: () => {} }; + + beforeEach(() => { + FakeNotification.fired = []; + (globalThis as Record).Notification = FakeNotification; + notifyOnComplete.value = true; + notifyOnQuestion.value = true; + notifyOnApproval.value = true; + }); + + afterEach(() => { + delete (globalThis as Record).Notification; + notifyOnComplete.value = true; + notifyOnQuestion.value = false; + notifyOnApproval.value = false; + }); + + it('completion tags carry the prompt id so each turn in a session alerts', () => { + maybeNotifyCompletion('s1', { ...base, promptId: 'p1' }); + maybeNotifyCompletion('s1', { ...base, promptId: 'p2' }); + expect(FakeNotification.fired.map((f) => f.tag)).toEqual([ + 'kimi-complete-s1-p1', + 'kimi-complete-s1-p2', + ]); + }); + + it('a replayed idle event for the same turn keeps the same tag', () => { + maybeNotifyCompletion('s1', { ...base, promptId: 'p1' }); + maybeNotifyCompletion('s1', { ...base, promptId: 'p1' }); + expect(FakeNotification.fired).toHaveLength(2); + expect(FakeNotification.fired[0]?.tag).toBe(FakeNotification.fired[1]?.tag); + }); + + it('question and approval tags are per-request', () => { + maybeNotifyQuestion({ ...base, questionPreview: 'q', questionId: 'q1' }); + maybeNotifyApproval({ ...base, toolName: 'bash', approvalId: 'a1' }); + expect(FakeNotification.fired.map((f) => f.tag)).toEqual([ + 'kimi-question-q1', + 'kimi-approval-a1', + ]); + }); + + it('suppresses the notification while the user is watching the session', () => { + maybeNotifyCompletion('s1', { ...base, isUserWatching: true, promptId: 'p1' }); + expect(FakeNotification.fired).toHaveLength(0); + }); +}); From ad30a1c6328327729221f9f5fc700b621dfef779 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:55:58 +0800 Subject: [PATCH 03/80] style(web): polish web UI typography and controls (#1502) * style(web): polish sidebar and tool typography - Use UI font and medium weight for sidebar and composer controls - Add reusable shortcut and tool output blocks - Cap long tool output at 50 lines with a scrollbar - Update sidebar show-more copy and muted styling * style(web): refine workspace picker sizing * style(web): align composer mode menus * style(web): tune list and question typography * style(web): reuse shortcut keys in approvals * style(web): size workspace picker from content * feat(web): localize chat status labels * style(web): refine composer toolbar controls * style(web): use complete Inter variable font * style(web): tune sidebar workspace typography * style(web): polish composer and workspace picker * style(web): refine markdown and thinking typography * chore: add web UI polish changeset * fix(web): pin Inter package for Nix build * style(web): polish goal tool calls * style: polish goal mode display * fix: layer latest message pill below menus * style: align goal strip content --- .changeset/web-ui-polish.md | 5 + apps/kimi-web/package.json | 3 +- apps/kimi-web/src/components/SessionRow.vue | 12 +- apps/kimi-web/src/components/Sidebar.vue | 19 +- .../src/components/WorkspaceGroup.vue | 9 +- .../src/components/chat/ApprovalCard.vue | 19 +- .../kimi-web/src/components/chat/ChatDock.vue | 1 + .../src/components/chat/ChatHeader.vue | 24 +- .../kimi-web/src/components/chat/Composer.vue | 400 ++++++++++++++---- .../src/components/chat/ConversationPane.vue | 171 +++++++- .../src/components/chat/GoalStrip.vue | 125 ++++-- .../kimi-web/src/components/chat/Markdown.vue | 21 +- .../src/components/chat/QuestionCard.vue | 22 +- .../src/components/chat/ThinkingBlock.vue | 6 +- .../src/components/chat/ThinkingPanel.vue | 3 +- apps/kimi-web/src/components/chat/ToolRow.vue | 21 +- .../components/chat/tool-calls/EditTool.vue | 16 +- .../chat/tool-calls/GenericTool.vue | 16 +- .../chat/tool-calls/ToolOutputBlock.vue | 42 ++ .../components/mobile/MobileSwitcherSheet.vue | 9 +- apps/kimi-web/src/components/ui/MenuItem.vue | 4 +- .../src/components/ui/ShortcutKey.vue | 24 ++ .../src/composables/useKimiWebClient.ts | 2 +- apps/kimi-web/src/i18n/locales/en/header.ts | 5 + apps/kimi-web/src/i18n/locales/en/sidebar.ts | 4 +- apps/kimi-web/src/i18n/locales/en/status.ts | 9 + apps/kimi-web/src/i18n/locales/en/tools.ts | 15 + apps/kimi-web/src/i18n/locales/zh/composer.ts | 4 +- apps/kimi-web/src/i18n/locales/zh/header.ts | 5 + apps/kimi-web/src/i18n/locales/zh/sidebar.ts | 4 +- apps/kimi-web/src/i18n/locales/zh/status.ts | 13 +- apps/kimi-web/src/i18n/locales/zh/tools.ts | 15 + apps/kimi-web/src/lib/icons.ts | 15 + apps/kimi-web/src/lib/toolMeta.ts | 68 +++ apps/kimi-web/src/main.ts | 3 +- apps/kimi-web/src/style.css | 23 +- apps/kimi-web/src/views/DesignSystemView.vue | 28 +- flake.nix | 2 +- pnpm-lock.yaml | 10 +- 39 files changed, 941 insertions(+), 256 deletions(-) create mode 100644 .changeset/web-ui-polish.md create mode 100644 apps/kimi-web/src/components/chat/tool-calls/ToolOutputBlock.vue create mode 100644 apps/kimi-web/src/components/ui/ShortcutKey.vue diff --git a/.changeset/web-ui-polish.md b/.changeset/web-ui-polish.md new file mode 100644 index 000000000..5a829b6c1 --- /dev/null +++ b/.changeset/web-ui-polish.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Polish the chat UI with Inter typography, localized labels, and tighter sidebar, composer, and menu styling. diff --git a/apps/kimi-web/package.json b/apps/kimi-web/package.json index febbb8626..f7d2f2370 100644 --- a/apps/kimi-web/package.json +++ b/apps/kimi-web/package.json @@ -13,7 +13,8 @@ "check:style": "node scripts/check-style.mjs" }, "dependencies": { - "@fontsource-variable/inter": "^5.2.8", + "@chenglou/pretext": "0.0.8", + "@fontsource-variable/inter": "5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", diff --git a/apps/kimi-web/src/components/SessionRow.vue b/apps/kimi-web/src/components/SessionRow.vue index 5d1b8a6c3..2291a2681 100644 --- a/apps/kimi-web/src/components/SessionRow.vue +++ b/apps/kimi-web/src/components/SessionRow.vue @@ -341,8 +341,9 @@ defineExpose({ closeMenu }); .t { color: inherit; - font-size: var(--ui-font-size); - font-weight: var(--weight-regular); + font-size: var(--text-base); + font-weight: 450; + line-height: var(--leading-tight); flex: 1; min-width: 0; overflow: hidden; @@ -353,7 +354,10 @@ defineExpose({ closeMenu }); .ts { color: var(--color-text-faint); font-size: var(--text-xs); - font-family: var(--font-mono); + font-family: var(--font-ui); + font-weight: 475; + font-variant-numeric: tabular-nums; + text-align: right; } /* Trailing action slot: time and kebab share one grid cell (grid-area:1/1). @@ -366,7 +370,7 @@ defineExpose({ closeMenu }); display: inline-grid; flex: none; align-items: center; - justify-items: center; + justify-items: end; } .act .ts, .act .kebab { grid-area: 1 / 1; } diff --git a/apps/kimi-web/src/components/Sidebar.vue b/apps/kimi-web/src/components/Sidebar.vue index 920eb2492..987465dbb 100644 --- a/apps/kimi-web/src/components/Sidebar.vue +++ b/apps/kimi-web/src/components/Sidebar.vue @@ -23,6 +23,7 @@ import IconButton from './ui/IconButton.vue'; import Icon from './ui/Icon.vue'; import Menu from './ui/Menu.vue'; import MenuItem from './ui/MenuItem.vue'; +import ShortcutKey from './ui/ShortcutKey.vue'; import { useConfirmDialog } from '../composables/useConfirmDialog'; const { t } = useI18n(); @@ -599,7 +600,10 @@ onBeforeUnmount(() => { @@ -911,6 +915,7 @@ onBeforeUnmount(() => { color: var(--color-text); font-family: var(--font-ui); font-size: var(--ui-font-size); + font-weight: var(--weight-medium); cursor: pointer; text-align: left; } @@ -950,15 +955,25 @@ onBeforeUnmount(() => { flex: none; } .search-input { + display: inline-flex; + align-items: center; + gap: var(--space-1); flex: 1; min-width: 0; color: var(--color-text); - font-family: var(--mono); + font-family: var(--font-ui); font-size: var(--ui-font-size); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.search-input > span { + overflow: hidden; + text-overflow: ellipsis; +} +.search-label { + font-weight: var(--weight-medium); +} /* Sessions */ .sessions { diff --git a/apps/kimi-web/src/components/WorkspaceGroup.vue b/apps/kimi-web/src/components/WorkspaceGroup.vue index 729707b8e..9e9731929 100644 --- a/apps/kimi-web/src/components/WorkspaceGroup.vue +++ b/apps/kimi-web/src/components/WorkspaceGroup.vue @@ -265,7 +265,7 @@ function onHeaderDragStart(event: DragEvent): void { .gh-name { font-size: var(--ui-font-size-lg); - font-weight: var(--weight-medium); + font-weight: 550; color: var(--color-text); flex: 1; min-width: 0; @@ -276,6 +276,7 @@ function onHeaderDragStart(event: DragEvent): void { } .gh-path { color: var(--color-text-faint); + font-weight: 425; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -317,10 +318,10 @@ function onHeaderDragStart(event: DragEvent): void { .gh-more.open { color: var(--color-text); background: var(--color-line); } .group-empty { - padding: var(--space-1) var(--space-2) var(--space-1) calc(var(--sb-pad-x) + var(--sb-gutter) + var(--sb-gap)); + padding: var(--space-1) var(--space-2) var(--space-1) calc(var(--sb-pad-x) - var(--space-2) + var(--sb-gutter) + var(--sb-gap)); font-size: var(--text-xs); color: var(--color-text-faint); - font-family: var(--font-mono); + font-family: var(--font-ui); } /* Show-more / show-less — a session-row-shaped compact list control (§07). The empty lead slot mirrors a session row's status gutter, so the label text lands @@ -338,7 +339,7 @@ function onHeaderDragStart(event: DragEvent): void { border: none; border-radius: var(--radius-md); background: transparent; - color: var(--color-text); + color: var(--color-text-muted); font-family: var(--font-ui); font-size: var(--text-xs); text-align: left; diff --git a/apps/kimi-web/src/components/chat/ApprovalCard.vue b/apps/kimi-web/src/components/chat/ApprovalCard.vue index 0448ff261..48047bccb 100644 --- a/apps/kimi-web/src/components/chat/ApprovalCard.vue +++ b/apps/kimi-web/src/components/chat/ApprovalCard.vue @@ -10,6 +10,7 @@ import Badge from '../ui/Badge.vue'; import Button from '../ui/Button.vue'; import IconButton from '../ui/IconButton.vue'; import Icon from '../ui/Icon.vue'; +import ShortcutKey from '../ui/ShortcutKey.vue'; import Tooltip from '../ui/Tooltip.vue'; const props = defineProps<{ @@ -314,20 +315,20 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown)); :loading="pendingAction === `option:${opt.label}`" :disabled="busy" @click="approveOption(opt.label)" - >{{ opt.label }}[{{ i + 1 }}] + >{{ opt.label }}[{{ i + 1 }}] - - - + + +
- - - - + + + +
@@ -554,7 +555,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown)); width: 100%; } .plan-actions { flex-wrap: wrap; } -.k { margin-left: var(--space-2); font: var(--text-xs) var(--font-mono); opacity: .7; } +.k { opacity: .75; } /* ========================================================================= MOBILE (≤640px): the card spans the full chat column, inner previews scroll diff --git a/apps/kimi-web/src/components/chat/ChatDock.vue b/apps/kimi-web/src/components/chat/ChatDock.vue index 4078c1d14..8da9067d2 100644 --- a/apps/kimi-web/src/components/chat/ChatDock.vue +++ b/apps/kimi-web/src/components/chat/ChatDock.vue @@ -251,6 +251,7 @@ defineExpose({ loadForEdit, loadAttachmentsForEdit, focus }); :plan-mode="planMode" :swarm-mode="swarmMode" :goal-mode="goalMode" + :goal="goal" :activation-badges="activationBadges" :models="models" :starred-ids="starredIds" diff --git a/apps/kimi-web/src/components/chat/ChatHeader.vue b/apps/kimi-web/src/components/chat/ChatHeader.vue index 66b603bab..5de3154b9 100644 --- a/apps/kimi-web/src/components/chat/ChatHeader.vue +++ b/apps/kimi-web/src/components/chat/ChatHeader.vue @@ -51,6 +51,25 @@ const behind = computed(() => props.behind ?? 0); const adds = computed(() => props.gitDiffStats?.totalAdditions ?? 0); const dels = computed(() => props.gitDiffStats?.totalDeletions ?? 0); const hasLineStats = computed(() => adds.value > 0 || dels.value > 0); +const PR_STATE_LABEL_KEYS: Record = { + open: 'header.prStatusOpen', + closed: 'header.prStatusClosed', + merged: 'header.prStatusMerged', + draft: 'header.prStatusDraft', +}; + +function normalizedPrState(state: string): string { + return state.trim().toLowerCase().replaceAll('_', '-'); +} + +function prStateClass(state: string): string { + const stateClass = normalizedPrState(state); + return PR_STATE_LABEL_KEYS[stateClass] ? `pr-${stateClass}` : 'pr-unknown'; +} + +function prStateLabel(state: string): string { + return t(PR_STATE_LABEL_KEYS[normalizedPrState(state)] ?? 'header.prStatusUnknown'); +} // --------------------------------------------------------------------------- // More-menu (kebab dropdown) @@ -295,11 +314,11 @@ async function startArchive(): Promise { v-if="pr" type="button" class="ch-pill ch-pr" - :class="`pr-${pr.state}`" + :class="prStateClass(pr.state)" @click="pr && emit('openPr', pr.url)" > - PR #{{ pr.number }} · {{ pr.state }} + PR #{{ pr.number }} · {{ prStateLabel(pr.state) }} @@ -420,6 +439,7 @@ async function startArchive(): Promise { .ch-pr.pr-merged { color: var(--color-done); border-color: var(--color-done-bd); background: var(--color-done-soft); } .ch-pr.pr-closed { color: var(--color-danger); border-color: var(--color-danger-bd); background: var(--color-danger-soft); } .ch-pr.pr-draft { color: var(--color-text-muted); border-color: var(--color-line-strong); background: var(--color-surface-sunken); } +.ch-pr.pr-unknown { color: var(--color-text-muted); border-color: var(--color-line-strong); background: var(--color-surface-sunken); } .ch-pr:hover { border-color: var(--color-line-strong); } /* Fixed more-menu, anchored to the kebab trigger. Surface / items come from diff --git a/apps/kimi-web/src/components/chat/Composer.vue b/apps/kimi-web/src/components/chat/Composer.vue index e8069b0ec..92031302e 100644 --- a/apps/kimi-web/src/components/chat/Composer.vue +++ b/apps/kimi-web/src/components/chat/Composer.vue @@ -1,5 +1,6 @@ + + + + diff --git a/apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue b/apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue index da5d73cbd..4a87b0cc0 100644 --- a/apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue +++ b/apps/kimi-web/src/components/mobile/MobileSwitcherSheet.vue @@ -394,7 +394,7 @@ async function onDeleteWorkspace(ws: WorkspaceView): Promise { } .mgh-name { font-size: var(--ui-font-size-lg); - font-weight: 500; + font-weight: 550; color: var(--color-text); overflow: hidden; text-overflow: ellipsis; @@ -402,6 +402,7 @@ async function onDeleteWorkspace(ws: WorkspaceView): Promise { } .mgh-path { font-size: var(--text-base); + font-weight: 425; color: var(--color-text-faint); overflow: hidden; text-overflow: ellipsis; @@ -430,12 +431,14 @@ async function onDeleteWorkspace(ws: WorkspaceView): Promise { .srow .m { flex: 1; min-width: 0; } .srow .m .t { font-size: var(--text-base); + font-weight: 450; + line-height: var(--leading-tight); color: var(--color-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.srow.cur .m .t { font-weight: 500; color: var(--color-accent-hover); } +.srow.cur .m .t { color: var(--color-accent-hover); } /* Running indicator — pulse dot in the indent gutter left of the title, mirroring the desktop SessionRow (.t.run::before). */ @@ -471,6 +474,8 @@ async function onDeleteWorkspace(ws: WorkspaceView): Promise { } .srow .m .s { font-size: var(--text-base); + font-weight: 475; + font-variant-numeric: tabular-nums; color: var(--color-text-faint); margin-top: 1px; overflow: hidden; diff --git a/apps/kimi-web/src/components/ui/MenuItem.vue b/apps/kimi-web/src/components/ui/MenuItem.vue index a44bb31d9..cc9cf0d69 100644 --- a/apps/kimi-web/src/components/ui/MenuItem.vue +++ b/apps/kimi-web/src/components/ui/MenuItem.vue @@ -38,9 +38,9 @@ defineEmits<{ click: [event: MouseEvent] }>(); border: none; border-radius: var(--radius-sm); background: transparent; - color: var(--color-text-muted); + color: var(--color-text); font-family: var(--font-ui); - font-size: var(--text-sm); + font-size: var(--text-base); text-align: left; cursor: pointer; transition: background var(--duration-base), color var(--duration-base); diff --git a/apps/kimi-web/src/components/ui/ShortcutKey.vue b/apps/kimi-web/src/components/ui/ShortcutKey.vue new file mode 100644 index 000000000..67c87a182 --- /dev/null +++ b/apps/kimi-web/src/components/ui/ShortcutKey.vue @@ -0,0 +1,24 @@ + + + + diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 9b61944bb..0e2b7adf5 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -1380,7 +1380,7 @@ function formatTime(iso: string, _status: string): string { const diffD = diffMs / 86400000; if (diffD < 7) return `${Math.round(diffD)}d`; if (diffD < 30) return `${Math.round(diffD / 7)}w`; - if (diffD < 365) return `${Math.round(diffD / 30)}m`; + if (diffD < 365) return `${Math.round(diffD / 30)}mo`; return `${Math.round(diffD / 365)}y`; } catch { return iso; diff --git a/apps/kimi-web/src/i18n/locales/en/header.ts b/apps/kimi-web/src/i18n/locales/en/header.ts index bf046bee4..4273de01d 100644 --- a/apps/kimi-web/src/i18n/locales/en/header.ts +++ b/apps/kimi-web/src/i18n/locales/en/header.ts @@ -10,6 +10,11 @@ export default { gitTooltip: 'Open Files > Changed', detached: 'detached', openPr: 'Open pull request', + prStatusOpen: 'open', + prStatusClosed: 'closed', + prStatusMerged: 'merged', + prStatusDraft: 'draft', + prStatusUnknown: 'unknown', options: 'Options', copySessionId: 'Copy Session ID', renameSession: 'Rename', diff --git a/apps/kimi-web/src/i18n/locales/en/sidebar.ts b/apps/kimi-web/src/i18n/locales/en/sidebar.ts index 3bb705ca3..70adc2e5e 100644 --- a/apps/kimi-web/src/i18n/locales/en/sidebar.ts +++ b/apps/kimi-web/src/i18n/locales/en/sidebar.ts @@ -31,9 +31,9 @@ export default { language: 'Language', daemon: 'Daemon', noSessions: 'No conversations yet', - showMore: 'Load more ({count})', + showMore: 'Load {count} more conversations', showLess: 'Show less', - showAll: 'Show all ({count})', + showAll: 'Show {count} more conversations', loadingMore: 'Loading…', collapseSidebar: 'Collapse sidebar', expandSidebar: 'Expand sidebar', diff --git a/apps/kimi-web/src/i18n/locales/en/status.ts b/apps/kimi-web/src/i18n/locales/en/status.ts index fc4ca8cc4..8b7f02278 100644 --- a/apps/kimi-web/src/i18n/locales/en/status.ts +++ b/apps/kimi-web/src/i18n/locales/en/status.ts @@ -12,23 +12,32 @@ export default { permissionYoloDesc: 'Auto-approve tool actions, but agent may still ask questions', // Plan mode pill planLabel: 'Plan', + planDesc: 'Have the agent make a plan before changing files', planOn: 'on', planOff: 'off', planTooltip: 'Toggle plan mode (research before editing)', // Mode selector (Plan / Goal / Swarm) modesLabel: 'Mode', goalLabel: 'Goal', + goalDesc: 'Track one objective until it is complete', swarmLabel: 'Swarm', + swarmDesc: 'Run parallel agents for broader exploration', modeOff: 'Off', goalPlaceholder: 'What should the agent achieve?', goalStart: 'Start', goalPause: 'Pause', goalResume: 'Resume', goalCancel: 'Cancel', + goalStatusActive: 'Active', + goalStatusPaused: 'Paused', + goalStatusBlocked: 'Blocked', + goalStatusComplete: 'Complete', modeNotSupported: 'Not supported', // Thinking selector thinkingLabel: 'thinking', thinkingTooltip: 'Toggle thinking mode', + thinkingOn: 'On', + thinkingOff: 'Off', starredModels: 'Starred', moreModels: 'More models…', // Status panel diff --git a/apps/kimi-web/src/i18n/locales/en/tools.ts b/apps/kimi-web/src/i18n/locales/en/tools.ts index aaddeb334..13cc18211 100644 --- a/apps/kimi-web/src/i18n/locales/en/tools.ts +++ b/apps/kimi-web/src/i18n/locales/en/tools.ts @@ -13,6 +13,10 @@ export default { task: 'Task', swarm: 'Swarm', ask_user: 'Question', + goal_create: 'Start Goal', + goal_get: 'Read Goal', + goal_budget: 'Set Goal Budget', + goal_update: 'Update Goal', }, swarm: { progress: '{done} / {total}', @@ -32,6 +36,17 @@ export default { created: 'created', todos: '{count} items', }, + goal: { + objectiveWithCriterion: '{objective} · {criterion}', + status: 'Status: {status}', + budget: '{value} {unit}', + turns: '{value} turns', + tokens: '{value} tokens', + milliseconds: '{value} ms', + seconds: '{value} sec', + minutes: '{value} min', + hours: '{value} hr', + }, group: { title: '{count} tool call | {count} tool calls', running: 'running', diff --git a/apps/kimi-web/src/i18n/locales/zh/composer.ts b/apps/kimi-web/src/i18n/locales/zh/composer.ts index 6c1ec1926..719f7adb7 100644 --- a/apps/kimi-web/src/i18n/locales/zh/composer.ts +++ b/apps/kimi-web/src/i18n/locales/zh/composer.ts @@ -22,7 +22,7 @@ export default { emptyConversationTitle: 'Kimi Code', emptyConversation: '还没有消息 —— 在下方输入开始对话', quickStartPlaceholder: '输入消息开始新对话…', - thinkingSuffix: ' · thinking', - thinkingSuffixEffort: ' · thinking: {level}', + thinkingSuffix: ' · 思考', + thinkingSuffixEffort: ' · 思考: {level}', } as const; diff --git a/apps/kimi-web/src/i18n/locales/zh/header.ts b/apps/kimi-web/src/i18n/locales/zh/header.ts index 543cece6c..687845609 100644 --- a/apps/kimi-web/src/i18n/locales/zh/header.ts +++ b/apps/kimi-web/src/i18n/locales/zh/header.ts @@ -10,6 +10,11 @@ export default { gitTooltip: '打开「文件 > 改动」', detached: '游离', openPr: '打开 Pull Request', + prStatusOpen: '已打开', + prStatusClosed: '已关闭', + prStatusMerged: '已合并', + prStatusDraft: '草稿', + prStatusUnknown: '未知', options: '选项', copySessionId: '复制 Session ID', renameSession: '重命名', diff --git a/apps/kimi-web/src/i18n/locales/zh/sidebar.ts b/apps/kimi-web/src/i18n/locales/zh/sidebar.ts index bea56f556..9e92e43bd 100644 --- a/apps/kimi-web/src/i18n/locales/zh/sidebar.ts +++ b/apps/kimi-web/src/i18n/locales/zh/sidebar.ts @@ -31,9 +31,9 @@ export default { language: '语言', daemon: '后台', noSessions: '暂无对话', - showMore: '加载更多 ({count})', + showMore: '加载更多 {count} 个对话', showLess: '收起', - showAll: '展开 ({count})', + showAll: '展开剩余 {count} 个对话', loadingMore: '加载中…', collapseSidebar: '收起侧边栏', expandSidebar: '展开侧边栏', diff --git a/apps/kimi-web/src/i18n/locales/zh/status.ts b/apps/kimi-web/src/i18n/locales/zh/status.ts index 98287e9d5..0256b119b 100644 --- a/apps/kimi-web/src/i18n/locales/zh/status.ts +++ b/apps/kimi-web/src/i18n/locales/zh/status.ts @@ -8,27 +8,36 @@ export default { permissionAuto: '完全自主', permissionYolo: '自动通过', permissionManualDesc: '每个工具操作都需要你手动确认', - permissionAutoDesc: '完全自主运行,agent 自己做决定,不再询问', + permissionAutoDesc: '完全自主运行,智能体自己做决定,不再询问', permissionYoloDesc: '自动批准工具操作,但遇到关键问题仍会询问', // 计划模式 planLabel: '计划', + planDesc: '先让智能体梳理计划,再修改文件', planOn: '开', planOff: '关', planTooltip: '切换计划模式(先调研再修改)', // 模式选择器(计划 / 目标 / Swarm) modesLabel: '模式', goalLabel: '目标', + goalDesc: '持续跟踪一个目标,直到任务完成', swarmLabel: 'Swarm', + swarmDesc: '并行运行多个智能体,适合大范围探索', modeOff: '未启用', - goalPlaceholder: '让 Agent 完成什么目标?', + goalPlaceholder: '让智能体完成什么目标?', goalStart: '开始', goalPause: '暂停', goalResume: '继续', goalCancel: '取消', + goalStatusActive: '进行中', + goalStatusPaused: '已暂停', + goalStatusBlocked: '已阻塞', + goalStatusComplete: '已完成', modeNotSupported: '暂不支持', // 思考强度选择 thinkingLabel: '思考', thinkingTooltip: '切换思考模式', + thinkingOn: '开', + thinkingOff: '关', starredModels: '收藏', moreModels: '更多模型…', // 状态面板 diff --git a/apps/kimi-web/src/i18n/locales/zh/tools.ts b/apps/kimi-web/src/i18n/locales/zh/tools.ts index 0cee01058..f9560e3da 100644 --- a/apps/kimi-web/src/i18n/locales/zh/tools.ts +++ b/apps/kimi-web/src/i18n/locales/zh/tools.ts @@ -13,6 +13,10 @@ export default { task: '任务', swarm: 'Swarm', ask_user: '提问', + goal_create: '启动目标', + goal_get: '读取目标', + goal_budget: '设置目标预算', + goal_update: '更新目标', }, swarm: { progress: '{done} / {total}', @@ -32,6 +36,17 @@ export default { created: '已创建', todos: '{count} 项', }, + goal: { + objectiveWithCriterion: '{objective} · {criterion}', + status: '状态:{status}', + budget: '{value} {unit}', + turns: '{value} 轮', + tokens: '{value} token', + milliseconds: '{value} 毫秒', + seconds: '{value} 秒', + minutes: '{value} 分钟', + hours: '{value} 小时', + }, group: { title: '{count} 个工具调用', running: '运行中', diff --git a/apps/kimi-web/src/lib/icons.ts b/apps/kimi-web/src/lib/icons.ts index e41f0b1e4..a6e1ab5d2 100644 --- a/apps/kimi-web/src/lib/icons.ts +++ b/apps/kimi-web/src/lib/icons.ts @@ -43,6 +43,7 @@ import RiExpandRightLine from '~icons/ri/expand-right-line'; import RiExternalLinkLine from '~icons/ri/external-link-line'; import RiFileAddLine from '~icons/ri/file-add-line'; import RiFileCopyLine from '~icons/ri/file-copy-line'; +import RiFileEditLine from '~icons/ri/file-edit-line'; import RiFileLine from '~icons/ri/file-line'; import RiFileTextLine from '~icons/ri/file-text-line'; import RiFlashlightLine from '~icons/ri/flashlight-line'; @@ -61,6 +62,7 @@ import RiLoginBoxLine from '~icons/ri/login-box-line'; import RiMailLine from '~icons/ri/mail-line'; import RiMessageLine from '~icons/ri/message-line'; import RiMoreLine from '~icons/ri/more-line'; +import RiPauseFill from '~icons/ri/pause-fill'; import RiPencilLine from '~icons/ri/pencil-line'; import RiPlayFill from '~icons/ri/play-fill'; import RiQuestionLine from '~icons/ri/question-line'; @@ -72,6 +74,7 @@ import RiStarFill from '~icons/ri/star-fill'; import RiStarLine from '~icons/ri/star-line'; import RiStopFill from '~icons/ri/stop-fill'; import RiSubtractLine from '~icons/ri/subtract-line'; +import RiTargetLine from '~icons/ri/target-line'; import RiTerminalBoxLine from '~icons/ri/terminal-box-line'; import RiTimeLine from '~icons/ri/time-line'; import RiToolsLine from '~icons/ri/tools-line'; @@ -104,6 +107,7 @@ import RawExpandRightLine from '~icons/ri/expand-right-line?raw'; import RawExternalLinkLine from '~icons/ri/external-link-line?raw'; import RawFileAddLine from '~icons/ri/file-add-line?raw'; import RawFileCopyLine from '~icons/ri/file-copy-line?raw'; +import RawFileEditLine from '~icons/ri/file-edit-line?raw'; import RawFileLine from '~icons/ri/file-line?raw'; import RawFileTextLine from '~icons/ri/file-text-line?raw'; import RawFlashlightLine from '~icons/ri/flashlight-line?raw'; @@ -122,6 +126,7 @@ import RawLoginBoxLine from '~icons/ri/login-box-line?raw'; import RawMailLine from '~icons/ri/mail-line?raw'; import RawMessageLine from '~icons/ri/message-line?raw'; import RawMoreLine from '~icons/ri/more-line?raw'; +import RawPauseFill from '~icons/ri/pause-fill?raw'; import RawPencilLine from '~icons/ri/pencil-line?raw'; import RawPlayFill from '~icons/ri/play-fill?raw'; import RawQuestionLine from '~icons/ri/question-line?raw'; @@ -133,6 +138,7 @@ import RawStarFill from '~icons/ri/star-fill?raw'; import RawStarLine from '~icons/ri/star-line?raw'; import RawStopFill from '~icons/ri/stop-fill?raw'; import RawSubtractLine from '~icons/ri/subtract-line?raw'; +import RawTargetLine from '~icons/ri/target-line?raw'; import RawTerminalBoxLine from '~icons/ri/terminal-box-line?raw'; import RawTimeLine from '~icons/ri/time-line?raw'; import RawToolsLine from '~icons/ri/tools-line?raw'; @@ -177,6 +183,7 @@ export type IconName = | 'folder-solid' | 'file' | 'file-text' + | 'file-edit' | 'file-plus' | 'file-off' | 'image-off' @@ -197,6 +204,8 @@ export type IconName = | 'alert-triangle' | 'clock' | 'sparkles' + | 'target' + | 'pause' | 'play' | 'stop' | 'star' @@ -256,6 +265,7 @@ export const ICONS: Record = { 'folder-solid': entry(RiFolderFill, RawFolderFill), file: entry(RiFileLine, RawFileLine), 'file-text': entry(RiFileTextLine, RawFileTextLine), + 'file-edit': entry(RiFileEditLine, RawFileEditLine), 'file-plus': entry(RiFileAddLine, RawFileAddLine), 'file-off': entry(RiFileLine, RawFileLine), 'image-off': entry(RiImageLine, RawImageLine), @@ -276,6 +286,8 @@ export const ICONS: Record = { 'alert-triangle': entry(RiAlertLine, RawAlertLine), clock: entry(RiTimeLine, RawTimeLine), sparkles: entry(RiSparklingLine, RawSparklingLine), + target: entry(RiTargetLine, RawTargetLine), + pause: entry(RiPauseFill, RawPauseFill), play: entry(RiPlayFill, RawPlayFill), stop: entry(RiStopFill, RawStopFill), star: entry(RiStarFill, RawStarFill), @@ -353,6 +365,7 @@ export const ICON_GROUPS: ReadonlyArray 'folder-solid', 'file', 'file-text', + 'file-edit', 'file-plus', 'file-off', 'image-off', @@ -365,6 +378,7 @@ export const ICON_GROUPS: ReadonlyArray 'check-list', 'bolt', 'git-pull-request', + 'target', 'calendar-schedule', 'calendar-todo', 'calendar-close', @@ -379,6 +393,7 @@ export const ICON_GROUPS: ReadonlyArray 'alert-triangle', 'clock', 'sparkles', + 'pause', 'play', 'stop', 'star', diff --git a/apps/kimi-web/src/lib/toolMeta.ts b/apps/kimi-web/src/lib/toolMeta.ts index 6ecfd4c00..a2b011132 100644 --- a/apps/kimi-web/src/lib/toolMeta.ts +++ b/apps/kimi-web/src/lib/toolMeta.ts @@ -25,6 +25,10 @@ const TOOL_LABEL_KEYS: Record = { task: 'tools.label.task', agentswarm: 'tools.label.swarm', askuserquestion: 'tools.label.ask_user', + creategoal: 'tools.label.goal_create', + getgoal: 'tools.label.goal_get', + setgoalbudget: 'tools.label.goal_budget', + updategoal: 'tools.label.goal_update', }; // --------------------------------------------------------------------------- @@ -60,6 +64,10 @@ const NAME_ALIASES: Record = { subagent: 'task', websearch: 'search', web_search: 'search', + create_goal: 'creategoal', + get_goal: 'getgoal', + set_goal_budget: 'setgoalbudget', + update_goal: 'updategoal', }; export function normalizeToolName(name: string): string { @@ -93,6 +101,10 @@ const TOOL_GLYPH: Record = { task: 'sparkles', agentswarm: 'git-pull-request', askuserquestion: 'help-circle', + creategoal: 'target', + getgoal: 'target', + setgoalbudget: 'target', + updategoal: 'target', // Cron scheduling tools share a calendar motif: schedule / list / cancel. croncreate: 'calendar-schedule', cronlist: 'calendar-todo', @@ -182,6 +194,41 @@ function filePath(d: Record): string | undefined { return str(d.path) ?? str(d.file_path) ?? str(d.filePath) ?? str(d.filename); } +const GOAL_STATUS_KEYS: Record = { + active: 'status.goalStatusActive', + blocked: 'status.goalStatusBlocked', + complete: 'status.goalStatusComplete', +}; + +function goalStatusLabel(value: unknown): string | undefined { + const status = str(value); + if (!status) return undefined; + const key = GOAL_STATUS_KEYS[status]; + return key ? t(key) : status; +} + +function goalBudgetSummary(d: Record): string | undefined { + const value = num(d.value); + const unit = str(d.unit); + if (value === undefined || !unit) return undefined; + switch (unit) { + case 'turns': + return t('tools.goal.turns', { value }); + case 'tokens': + return t('tools.goal.tokens', { value }); + case 'milliseconds': + return t('tools.goal.milliseconds', { value }); + case 'seconds': + return t('tools.goal.seconds', { value }); + case 'minutes': + return t('tools.goal.minutes', { value }); + case 'hours': + return t('tools.goal.hours', { value }); + default: + return t('tools.goal.budget', { value, unit }); + } +} + const BASH_MAX = 64; /** @@ -255,6 +302,27 @@ export function toolSummary(name: string, arg: string, full = false): string { if (items) return c(t('tools.chip.todos', { count: items.length })); return fallback(); } + case 'creategoal': { + if (full) return fallback(); + const objective = str(d.objective); + const criterion = str(d.completionCriterion); + if (objective && criterion) return c(t('tools.goal.objectiveWithCriterion', { objective, criterion })); + return objective ? c(objective) : fallback(); + } + case 'getgoal': { + if (full) return fallback(); + return ''; + } + case 'setgoalbudget': { + if (full) return fallback(); + const summary = goalBudgetSummary(d); + return summary ? c(summary) : fallback(); + } + case 'updategoal': { + if (full) return fallback(); + const status = goalStatusLabel(d.status); + return status ? c(t('tools.goal.status', { status })) : fallback(); + } default: return fallback(); } diff --git a/apps/kimi-web/src/main.ts b/apps/kimi-web/src/main.ts index d546f7ae6..55475a234 100644 --- a/apps/kimi-web/src/main.ts +++ b/apps/kimi-web/src/main.ts @@ -2,7 +2,8 @@ import { createApp } from 'vue'; import App from './App.vue'; import i18n from './i18n'; import { installClientErrorCapture } from './debug/trace'; -import '@fontsource-variable/inter/wght.css'; +import '@fontsource-variable/inter/opsz.css'; +import '@fontsource-variable/inter/opsz-italic.css'; import '@fontsource-variable/jetbrains-mono/wght.css'; import './style.css'; diff --git a/apps/kimi-web/src/style.css b/apps/kimi-web/src/style.css index dda829b72..1f9ae2e50 100644 --- a/apps/kimi-web/src/style.css +++ b/apps/kimi-web/src/style.css @@ -206,9 +206,8 @@ summary { --content-font-size: calc(var(--base-ui-font-size) + 1px); --sidebar-ui-font-size: calc(var(--base-ui-font-size) + 1px); --mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; - /* Body/UI font follows the design-system canonical token (system UI font - first; Inter intentionally NOT in the body chain — it stays reserved for - --font-display headings). Mirrors --font-ui so the two can never drift. */ + /* Body/UI font follows the design-system canonical token. Mirrors --font-ui + so the legacy alias can never drift from the current text face. */ --sans: var(--font-ui); color-scheme: light dark; } @@ -454,14 +453,15 @@ html[data-color-scheme="dark"][data-accent="mono"] { --duration-slow: 260ms; /* -- type families ------------------------------------------------------- */ - /* UI/body use the platform's native UI font first (design-system §02); - Inter is intentionally NOT in this chain — it is reserved for - --font-display (optional headings / brand wordmarks) below. */ - --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", - "Hiragino Sans GB", "Microsoft YaHei", "Source Han Sans SC", "Noto Sans SC", - Roboto, Ubuntu, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", - "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; - --font-display: "Inter Variable", "Inter", var(--font-ui); + /* UI/body use self-hosted Inter first. CJK and platform system UI families + stay late in the fallback chain so Latin glyphs resolve to Inter while + Chinese text can still fall through to native CJK fonts. */ + --font-ui: "Inter Variable", "Inter", "Helvetica Neue", Arial, + "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Source Han Sans SC", + "Noto Sans SC", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + Ubuntu, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", + "Segoe UI Symbol", "Noto Color Emoji"; + --font-display: var(--font-ui); --font-mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; @@ -643,6 +643,7 @@ body { font-size: var(--ui-font-size); font-weight: 400; line-height: 1.6; + font-optical-sizing: auto; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-rendering: auto; diff --git a/apps/kimi-web/src/views/DesignSystemView.vue b/apps/kimi-web/src/views/DesignSystemView.vue index 7053cfce0..358d777e2 100644 --- a/apps/kimi-web/src/views/DesignSystemView.vue +++ b/apps/kimi-web/src/views/DesignSystemView.vue @@ -227,18 +227,19 @@ onUnmounted(() => {

All disabled controls use opacity:.5 + cursor:not-allowed uniformly; do not separately grey out or recolor.

Font families

-

Kimi Web uses two font families: --font-ui (UI and body, system fonts first) and --font-mono (code and monospace). Components always reference the variables; do not hard-code font names.

+

Kimi Web uses two font families: --font-ui (UI and body, Inter first) and --font-mono (code and monospace). Components always reference the variables; do not hard-code font names.

-

--font-ui · UI & body (system fonts first)

-

Body and UI use each platform's native UI font — close to the system feel, comfortable for long text and CJK. Fallback chain:

-
--font-ui
--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI",
+            

--font-ui · UI & body (Inter first)

+

Body and UI use self-hosted Inter as the primary face. CJK and platform system UI fonts sit late in the fallback chain so Latin glyphs resolve to Inter while Chinese text can fall through to native CJK fonts:

+
--font-ui
--font-ui: "Inter Variable", "Inter", "Helvetica Neue", Arial,
       "PingFang SC", "Microsoft YaHei", "Noto Sans SC",
-      "Helvetica Neue", Arial, sans-serif,
+      -apple-system, BlinkMacSystemFont, "Segoe UI",
+      Roboto, Ubuntu, sans-serif,
       "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji";
    -
  • System UI fonts first: SF Pro on macOS / iOS, Segoe UI on Windows.
  • -
  • CJK next: PingFang SC (macOS) / Microsoft YaHei (Windows) / Noto Sans SC (Linux).
  • -
  • Helvetica Neue / Arial / sans-serif as generic fallbacks; emoji fonts at the end.
  • +
  • Inter first: self-hosted Latin UI and body text, loaded through the optical-size normal and italic variable faces.
  • +
  • Western fallbacks next: Helvetica Neue / Arial for environments where Inter cannot load.
  • +
  • CJK and system UI fallbacks late: PingFang SC / Microsoft YaHei / Noto Sans SC, then platform UI fonts and emoji fonts.

--font-mono · Code & monospace

@@ -251,8 +252,8 @@ onUnmounted(() => { FontSourceBundledUsage JetBrains Mono@fontsource-variable/jetbrains-mono✓ self-hostedmonospace / code (--font-mono) - Inter@fontsource-variable/inter✓ self-hostedoptional: page titles / brand wordmark (--font-display) - System UI / CJK fontsoperating system—body / UI (--font-ui), not bundled + Inter@fontsource-variable/inter/opsz.css + opsz-italic.css✓ self-hostedUI / body / display (--font-ui, --font-display), wght 100-900, opsz 14-32, normal + italic + System UI / CJK fontsoperating system—late fallback for UI / body, not bundled
@@ -262,8 +263,9 @@ onUnmounted(() => {

Usage rules

  • Components always use var(--font-ui) / var(--font-mono); do not hard-code font names like 'Inter' / 'JetBrains Mono'.
  • -
  • Body / UI use --font-ui (system fonts first); code / monospace use --font-mono (JetBrains Mono).
  • -
  • Inter is used only for headings / brand scenarios that need a unified look (optional --font-display); it is no longer the body default.
  • +
  • Body / UI use --font-ui (Inter first); code / monospace use --font-mono (JetBrains Mono).
  • +
  • Inter is loaded from the complete optical-size variable faces, including normal and italic styles; font-optical-sizing: auto is enabled globally.
  • +
  • CJK and platform system UI fonts stay late in the --font-ui fallback chain, after Inter and Western fallbacks.

Type scale & weight

@@ -281,7 +283,7 @@ onUnmounted(() => { - + diff --git a/flake.nix b/flake.nix index bdb4158d0..c914a4d56 100644 --- a/flake.nix +++ b/flake.nix @@ -152,7 +152,7 @@ inherit (finalAttrs) pname version src pnpmWorkspaces; inherit pnpm; fetcherVersion = 3; - hash = "sha256-RPjCWL7NqDSKgpHGL16zPlUOfjWN2rkaDY/4GFAD8VA="; + hash = "sha256-hUn5Srn3HnEEzU5DLxgjIzFjI0ukM3iSP4QagftEXdE="; }; nativeBuildInputs = [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7396c9c5c..dab48ecc9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,8 +166,11 @@ importers: apps/kimi-web: dependencies: + '@chenglou/pretext': + specifier: 0.0.8 + version: 0.0.8 '@fontsource-variable/inter': - specifier: ^5.2.8 + specifier: 5.2.8 version: 5.2.8 '@fontsource-variable/jetbrains-mono': specifier: ^5.2.8 @@ -995,6 +998,9 @@ packages: '@chenglou/pretext@0.0.5': resolution: {integrity: sha512-A8GZN10REdFGsyuiUgLV8jjPDDFMg5GmgxGWV0I3igxBOnzj+jgz2VMmVD7g+SFyoctfeqHFxbNatKSzVRWtRg==} + '@chenglou/pretext@0.0.8': + resolution: {integrity: sha512-yqm2GMxnPI7VHcHwe84P8ZF0JK/2d2DMKPqMN+s95jQhwDMYYXKVFVJUMEaVWckQStdsjdLav/0Vu+d9YbtGxA==} + '@chevrotain/types@11.1.2': resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} @@ -8077,6 +8083,8 @@ snapshots: '@chenglou/pretext@0.0.5': {} + '@chenglou/pretext@0.0.8': {} + '@chevrotain/types@11.1.2': {} '@colors/colors@1.5.0': From 9fb19154accf6b6f7abfbf7a9820ccda517bc87e Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:54:50 +0800 Subject: [PATCH 04/80] fix: keep prompt goals running until terminal (#1516) * fix: keep prompt goals running until terminal * fix: reject invalid prompt goal commands * fix: ignore stale prompt goal status checks --- .changeset/fix-prompt-goal-mode.md | 5 + apps/kimi-code/src/cli/goal-prompt.ts | 7 +- apps/kimi-code/src/cli/run-prompt.ts | 70 +++++++++--- apps/kimi-code/test/cli/goal-prompt.test.ts | 116 ++++++++++++++++++++ 4 files changed, 182 insertions(+), 16 deletions(-) create mode 100644 .changeset/fix-prompt-goal-mode.md diff --git a/.changeset/fix-prompt-goal-mode.md b/.changeset/fix-prompt-goal-mode.md new file mode 100644 index 000000000..0aa7146f1 --- /dev/null +++ b/.changeset/fix-prompt-goal-mode.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix prompt-mode goals so they run until completion and report invalid goal commands before sending prompts. diff --git a/apps/kimi-code/src/cli/goal-prompt.ts b/apps/kimi-code/src/cli/goal-prompt.ts index 5ab0cfe85..57f223ad4 100644 --- a/apps/kimi-code/src/cli/goal-prompt.ts +++ b/apps/kimi-code/src/cli/goal-prompt.ts @@ -46,13 +46,18 @@ const GOAL_PREFIX = /^\/goal(\s|$)/; * Parses a headless prompt into a goal-create request, or `undefined` when the * prompt is not a `/goal` create command (so the caller runs it as a normal * prompt). Non-create goal subcommands are not supported headless and fall - * through to normal prompt handling. + * through to normal prompt handling. Malformed create commands throw instead of + * falling through, so validation errors are reported before anything is sent to + * the model. */ export function parseHeadlessGoalCreate(prompt: string): HeadlessGoalCreate | undefined { const trimmed = prompt.trim(); if (!GOAL_PREFIX.test(trimmed)) return undefined; const args = trimmed.replace(/^\/goal/, '').trim(); const parsed = parseGoalCommand(args); + if (parsed.kind === 'error') { + throw new Error(parsed.message); + } if (parsed.kind !== 'create') return undefined; return { objective: parsed.objective, replace: parsed.replace }; } diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index 100368203..3bb3a705f 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -234,7 +234,7 @@ async function runHeadlessGoal( try { // The objective is sent as the normal prompt; goal continuation keeps the // turn alive until a terminal state is reached. - await runPromptTurn(session, goal.objective, outputFormat, stdout, stderr); + await runPromptTurn(session, goal.objective, outputFormat, stdout, stderr, true); } finally { unsubscribeGoalEvents(); const snapshot = completedSnapshot ?? (await session.getGoal()).goal; @@ -432,9 +432,11 @@ function runPromptTurn( outputFormat: PromptOutputFormat, stdout: PromptOutput, stderr: PromptOutput, + waitForGoalTerminal = false, ): Promise { let activeTurnId: number | undefined; let activeAgentId: string | undefined; + let latestStartedTurnId: number | undefined; const outputWriter = outputFormat === 'stream-json' ? new PromptJsonWriter(stdout) @@ -469,6 +471,18 @@ function runPromptTurn( } activeTurnId = event.turnId; activeAgentId = event.agentId; + latestStartedTurnId = event.turnId; + return; + } + if ( + waitForGoalTerminal && + event.type === 'goal.updated' && + event.agentId === PROMPT_MAIN_AGENT_ID && + activeTurnId === undefined && + event.snapshot !== null && + event.snapshot.status !== 'active' + ) { + void finishCompletedTurn(); return; } if ( @@ -515,19 +529,29 @@ function runPromptTurn( return; case 'turn.ended': if (event.reason === 'completed') { - void (async () => { - // Flush the buffered assistant message before draining background - // tasks: in stream-json mode the final message is only emitted by - // finish(), so a long background wait would otherwise withhold the - // main turn's result until the drain settles. - outputWriter.flushAssistant(); - try { - await session.waitForBackgroundTasksOnPrint(); - } catch (error) { - log.warn('waitForBackgroundTasksOnPrint failed', { error }); - } - finish(); - })(); + outputWriter.flushAssistant(); + if (waitForGoalTerminal) { + const completedTurnId = event.turnId; + activeTurnId = undefined; + activeAgentId = undefined; + void (async () => { + try { + const { goal } = await session.getGoal(); + if ( + activeTurnId !== undefined || + latestStartedTurnId !== completedTurnId + ) { + return; + } + if (goal?.status === 'active') return; + await finishCompletedTurn(); + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))); + } + })(); + return; + } + void finishCompletedTurn(); return; } finish(new Error(formatTurnEndedFailure(event))); @@ -560,6 +584,20 @@ function runPromptTurn( session.prompt(prompt).catch((error: unknown) => { finish(error instanceof Error ? error : new Error(String(error))); }); + + async function finishCompletedTurn(): Promise { + // Flush the buffered assistant message before draining background tasks: + // in stream-json mode the final message is only emitted by finish(), so a + // long background wait would otherwise withhold the main turn's result + // until the drain settles. + outputWriter.flushAssistant(); + try { + await session.waitForBackgroundTasksOnPrint(); + } catch (error) { + log.warn('waitForBackgroundTasksOnPrint failed', { error }); + } + finish(); + } }); } @@ -610,7 +648,9 @@ class PromptTranscriptWriter implements PromptTurnWriter { writeToolResult(): void {} - flushAssistant(): void {} + flushAssistant(): void { + this.assistantWriter.finish(); + } discardAssistant(): void {} diff --git a/apps/kimi-code/test/cli/goal-prompt.test.ts b/apps/kimi-code/test/cli/goal-prompt.test.ts index 04780bd26..8d75c4721 100644 --- a/apps/kimi-code/test/cli/goal-prompt.test.ts +++ b/apps/kimi-code/test/cli/goal-prompt.test.ts @@ -46,6 +46,12 @@ describe('parseHeadlessGoalCreate', () => { expect(parseHeadlessGoalCreate('/goal status')).toBeUndefined(); expect(parseHeadlessGoalCreate('/goal pause')).toBeUndefined(); }); + + it('rejects malformed goal create prompts instead of falling through', () => { + expect(() => parseHeadlessGoalCreate(`/goal ${'x'.repeat(4001)}`)).toThrow( + 'Goal objective is too long', + ); + }); }); describe('goal summary', () => { @@ -97,6 +103,7 @@ const mocks = vi.hoisted(() => { handler(mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); } }), + waitForBackgroundTasksOnPrint: vi.fn(async () => {}), }; return { session, @@ -164,6 +171,8 @@ describe('runPrompt headless goal mode', () => { mocks.experimentalFeatures = [{ id: 'micro_compaction', enabled: true }]; mocks.sessions = []; mocks.session.createGoal.mockClear(); + mocks.session.prompt.mockClear(); + mocks.session.waitForBackgroundTasksOnPrint.mockClear(); mocks.session.getStatus.mockResolvedValue({ permission: 'auto', model: 'k2' } as never); mocks.session.getGoal.mockResolvedValue({ goal: snapshot({ status: 'complete' }) } as never); }); @@ -243,6 +252,113 @@ describe('runPrompt headless goal mode', () => { expect(mocks.session.prompt).toHaveBeenCalledWith('Ship feature X'); }); + it('keeps listening across continuation turns until the goal is terminal', async () => { + const active = snapshot({ status: 'active', turnsUsed: 1, tokensUsed: 80 }); + const completed = snapshot({ status: 'complete', turnsUsed: 2, tokensUsed: 160 }); + mocks.session.getGoal.mockResolvedValueOnce({ goal: active } as never); + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 1, delta: '1' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); + } + await Promise.resolve(); + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ + type: 'turn.started', + turnId: 2, + origin: { kind: 'system_trigger', name: 'goal_continuation' }, + }), + ); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 2, delta: '2' })); + handler( + mocks.mainEvent({ + type: 'goal.updated', + snapshot: completed, + change: { kind: 'completion', status: 'complete' }, + }), + ); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 2, reason: 'completed' })); + } + }); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts(), 'test', { + stdout, + stderr, + process: { once: () => {}, off: () => {}, exit: () => undefined as never }, + }); + + expect(stdout.text()).toBe('• 1\n\n• 2\n\n'); + expect(stderr.text()).toContain('Goal [complete]'); + expect(stderr.text()).toContain('turns: 2'); + }); + + it('ignores stale goal checks once a continuation turn has started', async () => { + const completed = snapshot({ status: 'complete', turnsUsed: 2, tokensUsed: 160 }); + let resolveFirstGoal: ((value: { goal: null }) => void) | undefined; + const firstGoal = new Promise<{ goal: null }>((resolve) => { + resolveFirstGoal = resolve; + }); + mocks.session.getGoal + .mockImplementationOnce(() => firstGoal as never) + .mockResolvedValue({ goal: null } as never); + mocks.session.prompt.mockImplementationOnce(async () => { + const emit = (event: Record) => { + for (const handler of [...mocks.eventHandlers]) { + handler(mocks.mainEvent(event)); + } + }; + emit({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }); + emit({ type: 'assistant.delta', turnId: 1, delta: '1' }); + emit({ type: 'turn.ended', turnId: 1, reason: 'completed' }); + emit({ + type: 'turn.started', + turnId: 2, + origin: { kind: 'system_trigger', name: 'goal_continuation' }, + }); + emit({ type: 'assistant.delta', turnId: 2, delta: '2' }); + emit({ + type: 'goal.updated', + snapshot: completed, + change: { kind: 'completion', status: 'complete' }, + }); + resolveFirstGoal?.({ goal: null }); + await Promise.resolve(); + emit({ type: 'assistant.delta', turnId: 2, delta: ' tail' }); + emit({ type: 'turn.ended', turnId: 2, reason: 'completed' }); + }); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts(), 'test', { + stdout, + stderr, + process: { once: () => {}, off: () => {}, exit: () => undefined as never }, + }); + + expect(stdout.text()).toBe('• 1\n\n• 2 tail\n\n'); + expect(stderr.text()).toContain('Goal [complete]'); + }); + + it('does not send an invalid goal create prompt as a normal prompt', async () => { + const stdout = writer(); + const stderr = writer(); + + await expect( + runPrompt(opts({ prompt: `/goal ${'x'.repeat(4001)}` }), 'test', { + stdout, + stderr, + process: { once: () => {}, off: () => {}, exit: () => undefined as never }, + }), + ).rejects.toThrow('Goal objective is too long'); + + expect(mocks.session.createGoal).not.toHaveBeenCalled(); + expect(mocks.session.prompt).not.toHaveBeenCalled(); + }); + it('validates the resumed session model before creating a headless goal', async () => { mocks.sessions = [{ id: 'ses_goal', workDir: process.cwd() }]; mocks.session.getStatus.mockResolvedValueOnce({ permission: 'auto', model: '' } as never); From 173bdfdab1f484ed79927aeaac7dc8116d3fd346 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:46:57 +0800 Subject: [PATCH 05/80] fix: resume sessions with missing workdir (#1517) --- .changeset/fix-missing-workdir-resume.md | 5 +++++ packages/agent-core/src/agent/config/index.ts | 2 +- .../test/agent/config-state.test.ts | 19 +++++++++++++++++++ .../test/tools/fixtures/fake-kaos.ts | 6 +++--- 4 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-missing-workdir-resume.md diff --git a/.changeset/fix-missing-workdir-resume.md b/.changeset/fix-missing-workdir-resume.md new file mode 100644 index 000000000..187152cf0 --- /dev/null +++ b/.changeset/fix-missing-workdir-resume.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix resuming sessions whose original working directory no longer exists. diff --git a/packages/agent-core/src/agent/config/index.ts b/packages/agent-core/src/agent/config/index.ts index 0eb7c1db1..d4724b6fc 100644 --- a/packages/agent-core/src/agent/config/index.ts +++ b/packages/agent-core/src/agent/config/index.ts @@ -48,7 +48,7 @@ export class ConfigState { }); if (changed.cwd) { this._cwd = changed.cwd; - void this.agent.kaos.chdir(changed.cwd); + this.agent.setKaos(this.agent.kaos.withCwd(changed.cwd)); } if (changed.modelAlias) { this._modelAlias = changed.modelAlias; diff --git a/packages/agent-core/test/agent/config-state.test.ts b/packages/agent-core/test/agent/config-state.test.ts index 32b127950..7a0d169f0 100644 --- a/packages/agent-core/test/agent/config-state.test.ts +++ b/packages/agent-core/test/agent/config-state.test.ts @@ -4,8 +4,27 @@ import { emptyUsage } from '@moonshot-ai/kosong'; import { ProviderManager } from '../../src/session/provider-manager'; import type { KimiConfig } from '../../src/config'; import { testAgent } from './harness'; +import { createFakeKaos } from '../tools/fixtures/fake-kaos'; describe('ConfigState model capabilities', () => { + it('updates the agent cwd without requiring the directory to exist', () => { + const chdir = vi.fn(async () => { + throw Object.assign(new Error('missing workspace'), { code: 'ENOENT' }); + }); + const ctx = testAgent({ + kaos: createFakeKaos({ + getcwd: () => '/workspace', + chdir, + }), + }); + + ctx.agent.config.update({ cwd: '/tmp/missing-workdir' }); + + expect(ctx.agent.config.cwd).toBe('/tmp/missing-workdir'); + expect(ctx.agent.kaos.getcwd()).toBe('/tmp/missing-workdir'); + expect(chdir).not.toHaveBeenCalled(); + }); + it('computes provider and model capabilities from ProviderManager metadata', () => { const ctx = testAgent({ providerManager: new ProviderManager({ diff --git a/packages/agent-core/test/tools/fixtures/fake-kaos.ts b/packages/agent-core/test/tools/fixtures/fake-kaos.ts index 78613d33a..4384ce348 100644 --- a/packages/agent-core/test/tools/fixtures/fake-kaos.ts +++ b/packages/agent-core/test/tools/fixtures/fake-kaos.ts @@ -32,9 +32,9 @@ export function createFakeKaos( overrides?: Partial, envLayers: readonly Record[] = [], ): Kaos { - // Hold cwd in a closure so `chdir` (which `config.update({cwd})` now - // routes through) can mutate it and later `getcwd()` calls see the - // update — mirroring real-kaos semantics without needing a backing fs. + // Hold cwd in a closure so tests that call `chdir` directly can mutate it + // and later `getcwd()` calls see the update — mirroring real-kaos semantics + // without needing a backing fs. let cwd = overrides?.getcwd?.() ?? '/workspace'; const base: Kaos = { name: 'fake', From fe9479d89a22760a5fbe4ef659c0be556c5c3cfd Mon Sep 17 00:00:00 2001 From: Kai Date: Thu, 9 Jul 2026 17:06:19 +0800 Subject: [PATCH 06/80] fix: rewrite repeated tool call reminders to redirect instead of prohibit (#1518) The r1/r2/r3 reminders injected into repeated tool results led with prohibition verdicts and, in r2, echoed the repeated tool name and full arguments back into the context, reinforcing the very pattern they were meant to break. Rewrite them to state the situation factually and hand the model a concrete next action: an expectation-setting sentence for the next call (r1), a forced decision menu of falsify / ask-user / conclude (r2), and a final hand-off summary without further tool calls (r3). Detection, thresholds (3/5/8/12), force-stop, and telemetry are unchanged. --- .../agent-core/src/agent/turn/tool-dedup.ts | 34 ++++++++----------- .../test/agent/turn/tool-dedup.test.ts | 34 +++++++++---------- 2 files changed, 32 insertions(+), 36 deletions(-) diff --git a/packages/agent-core/src/agent/turn/tool-dedup.ts b/packages/agent-core/src/agent/turn/tool-dedup.ts index 31170f8cf..605408777 100644 --- a/packages/agent-core/src/agent/turn/tool-dedup.ts +++ b/packages/agent-core/src/agent/turn/tool-dedup.ts @@ -7,32 +7,28 @@ import { canonicalTelemetryArgs } from './canonical-args'; const REMINDER_TEXT_1 = '\n\n\n' + - 'You are repeating the exact same tool call with identical parameters.' + - ' Please carefully analyze the previous result. If the task is not yet complete,' + - ' try a different method or parameters instead of repeating the same call.' + + 'The same tool call has been repeated several times in a row. ' + + 'Before making your next call, write one sentence stating what new information you expect it to produce. ' + + 'Then act on that sentence: if it names something this result does not already give you, choose the action that best provides it; otherwise, continue with the evidence you already have.' + '\n'; -function makeReminderText2(toolName: string, repeatCount: number, args: unknown): string { - const argsStr = canonicalTelemetryArgs(args); +function makeReminderText2(repeatCount: number): string { return ( '\n\n\n' + - 'You have repeatedly called the same tool with identical parameters many times.\n' + - 'Repeated tool call detected:\n' + - `- tool: ${toolName}\n` + - `- repeated_times: ${String(repeatCount)}\n` + - `- arguments: ${argsStr}\n` + - 'The previous repeated calls did not make progress. Do not call this exact same tool with the exact same arguments again.\n' + - 'Carefully inspect the latest tool result and choose a different next action, different parameters, or finish the task if enough evidence has been gathered.' + + `The same tool call has now been issued ${String(repeatCount)} times in a row. ` + + 'Choose exactly one of the following and state your choice before acting:\n' + + '(1) Falsification check: run the cheapest test that could conclusively disprove your current approach, if such a test exists.\n' + + '(2) Missing input: tell the user precisely what information or decision you need to proceed, and ask for it.\n' + + '(3) Conclude: deliver your best result based on the evidence already gathered, listing anything that remains uncertain.' + '\n' ); } const REMINDER_TEXT_3 = '\n\n\n' + - 'You are stuck in a dead end and have repeatedly made the same function call without progress.\n' + - 'Stop all function calls immediately. Do not call any tool in your next response.\n' + - 'In analysis, review the current execution state and identify why progress is blocked.\n' + - 'Then return a text-only summary to the user that reports the current problem, what has already been tried, and what information or decision is needed next.' + + 'Write your final response now, without any further tool calls. ' + + 'Cover: the current blocker, each approach you have tried and what it established, and the specific information or decision you need from the user to unblock progress. ' + + 'Text only.' + '\n'; const REPEAT_REMINDER_1_START = 3; @@ -105,8 +101,8 @@ const DEDUP_PLACEHOLDER_RESULT: ExecutableToolResult = { output: '' }; * - Cross-step dedup: when the exact same call is repeated consecutively * across steps, the result returned to the model is suffixed with a system * reminder once the streak hits 3. The reminder escalates as the streak - * grows: r1 (gentle nudge) from streak 3, r2 (concrete repeat report) from - * streak 5, r3 (dead-end stop instruction) from streak 8. From streak 12 + * grows: r1 (expectation-setting nudge) from streak 3, r2 (forced decision + * menu) from streak 5, r3 (final hand-off instruction) from streak 8. From streak 12 * onward the turn is force-stopped via `{ stopTurn: true }` so the loop * cannot keep spinning on the same call. Force-stop does not flip a * successful tool result into an error — the underlying tool's `isError` @@ -239,7 +235,7 @@ export class ToolCallDeduplicator { finalResult = appendReminder(result, REMINDER_TEXT_3); action = 'r3'; } else if (streak >= REPEAT_REMINDER_2_START) { - finalResult = appendReminder(result, makeReminderText2(toolName, streak, args)); + finalResult = appendReminder(result, makeReminderText2(streak)); action = 'r2'; } else if (streak >= REPEAT_REMINDER_1_START) { finalResult = appendReminder(result, REMINDER_TEXT_1); diff --git a/packages/agent-core/test/agent/turn/tool-dedup.test.ts b/packages/agent-core/test/agent/turn/tool-dedup.test.ts index cff03b9bb..087831645 100644 --- a/packages/agent-core/test/agent/turn/tool-dedup.test.ts +++ b/packages/agent-core/test/agent/turn/tool-dedup.test.ts @@ -116,8 +116,8 @@ describe('ToolCallDeduplicator', () => { dedup.endStep(); } expect(last!.output as string).toContain(''); - expect(last!.output as string).toContain('repeating the exact same tool call'); - expect(last!.output as string).not.toContain('repeated_times'); + expect(last!.output as string).toContain('what new information you expect'); + expect(last!.output as string).not.toContain('Choose exactly one'); }); it('keeps injecting reminder1 at 4 consecutive', async () => { @@ -129,7 +129,7 @@ describe('ToolCallDeduplicator', () => { dedup.endStep(); } expect(last!.output as string).toContain(''); - expect(last!.output as string).toContain('repeating the exact same tool call'); + expect(last!.output as string).toContain('what new information you expect'); }); it('injects reminder2 at exactly 5 consecutive', async () => { @@ -141,9 +141,9 @@ describe('ToolCallDeduplicator', () => { dedup.endStep(); } expect(last!.output as string).toContain(''); - expect(last!.output as string).toContain('repeated_times: 5'); - expect(last!.output as string).toContain('tool: Read'); - expect(last!.output as string).toContain('arguments:'); + expect(last!.output as string).toContain('issued 5 times in a row'); + expect(last!.output as string).toContain('Choose exactly one of the following'); + expect(last!.output as string).toContain('Falsification check'); }); it.each([6, 7])('keeps injecting reminder2 at %i consecutive', async (streak) => { @@ -155,8 +155,8 @@ describe('ToolCallDeduplicator', () => { dedup.endStep(); } expect(last!.output as string).toContain(''); - expect(last!.output as string).toContain(`repeated_times: ${String(streak)}`); - expect(last!.output as string).toContain('tool: Read'); + expect(last!.output as string).toContain(`issued ${String(streak)} times in a row`); + expect(last!.output as string).toContain('Choose exactly one of the following'); }); it('injects the dead-end reminder at exactly 8 consecutive', async () => { @@ -168,7 +168,7 @@ describe('ToolCallDeduplicator', () => { dedup.endStep(); } expect(last!.output as string).toContain(''); - expect(last!.output as string).toContain('stuck in a dead end'); + expect(last!.output as string).toContain('without any further tool calls'); }); it('resets streak when a different call is interleaved', async () => { @@ -214,9 +214,9 @@ describe('ToolCallDeduplicator', () => { dedup.endStep(); expect(original.output as string).toContain(''); - expect(original.output as string).toContain('repeating the exact same tool call'); + expect(original.output as string).toContain('what new information you expect'); expect(finalDup.output as string).toContain(''); - expect(finalDup.output as string).toContain('repeating the exact same tool call'); + expect(finalDup.output as string).toContain('what new information you expect'); }); it('same-step spam alone does not trigger reminder', async () => { @@ -273,7 +273,7 @@ describe('ToolCallDeduplicator', () => { const arr = final.output as Array<{ type: string; text: string }>; expect(arr).toHaveLength(1); expect(arr[0]!.type).toBe('text'); - expect(arr[0]!.text).toBe('hello' + makeReminderText2('X', 5, { a: 1 })); + expect(arr[0]!.text).toBe('hello' + makeReminderText2(5)); }); it('pushes a new text part when trailing part is non-text', async () => { @@ -408,8 +408,8 @@ describe('ToolCallDeduplicator', () => { const dedup = new ToolCallDeduplicator(); const last = await runStreak(dedup, 8); expect(last.output as string).toContain(''); - expect(last.output as string).toContain('stuck in a dead end'); - expect(last.output as string).toContain('Stop all function calls immediately'); + expect(last.output as string).toContain('Write your final response now'); + expect(last.output as string).toContain('without any further tool calls'); // 8 is the reminder threshold, not yet force-stop. expect(last.isError).toBeUndefined(); expect(stopTurnOf(last)).toBeUndefined(); @@ -420,7 +420,7 @@ describe('ToolCallDeduplicator', () => { async (streak) => { const dedup = new ToolCallDeduplicator(); const last = await runStreak(dedup, streak); - expect(last.output as string).toContain('stuck in a dead end'); + expect(last.output as string).toContain('Write your final response now'); expect(last.isError).toBeUndefined(); expect(stopTurnOf(last)).toBeUndefined(); }, @@ -429,7 +429,7 @@ describe('ToolCallDeduplicator', () => { it('force-stops the turn at exactly 12 consecutive without marking the tool failed', async () => { const dedup = new ToolCallDeduplicator(); const last = await runStreak(dedup, 12); - expect(last.output as string).toContain('stuck in a dead end'); + expect(last.output as string).toContain('Write your final response now'); // The underlying tool succeeded — force-stop must not flip it to error. expect(last.isError).toBeUndefined(); expect(stopTurnOf(last)).toBe(true); @@ -459,7 +459,7 @@ describe('ToolCallDeduplicator', () => { // The underlying tool was an error — that must survive force-stop. expect(last!.isError).toBe(true); expect(stopTurnOf(last!)).toBe(true); - expect(last!.output as string).toContain('stuck in a dead end'); + expect(last!.output as string).toContain('Write your final response now'); }); }); From b91099ed7a2590d1afa4d6e3675671da52b7661c Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 9 Jul 2026 17:44:59 +0800 Subject: [PATCH 07/80] feat: display Extra Usage fuel pack balance in /usage and /status (#1501) * feat(oauth): parse boosterWallet extra usage from /usages * feat(oauth): expose extraUsage on AuthManagedUsageResult * feat(kimi-code): render Extra Usage section in /usage panel * fix(kimi-code): address Task 3 review feedback for extra usage section * feat(kimi-code): render Extra Usage section in /status panel * feat(kimi-code): wire extraUsage into /usage and /status commands * chore(extra-usage): address final review findings for fuel pack feature - Update changeset to cover both kimi-code and kimi-code-sdk packages - Add parser clamp tests and toolkit null-case test - Replace 'as never' casts in usage-panel tests - Wrap long import line in status-panel * chore: temporarily log /usages raw response for debugging * fix(oauth): accept BOOSTER balance type and drop debug log * fix(oauth): drop reset hint from Extra Usage and revert periodEnd parsing * fix(oauth): treat missing amountLeft as zero extra usage and drop debug log * revert: keep missing amountLeft defaulting to 0 (fully used) * feat(extra-usage): show monthly cap usage bar and balance in /usage and /status * fix(extra-usage): show balance and unlimited marker when no monthly cap * fix(extra-usage): show monthly used with unlimited marker and balance * fix(extra-usage): label Used and use English Unlimited * feat(extra-usage): render balance, monthly used and monthly limit as labeled rows * fix(extra-usage): move Balance row to the bottom * fix(extra-usage): format currency values with two decimals for column alignment * fix(extra-usage): right-align currency values so numbers line up * fix(extra-usage): align currency symbol and decimal point in usage rows --- .changeset/expose-extrausage-toolkit.md | 6 + apps/kimi-code/src/tui/commands/info.ts | 2 +- .../tui/components/messages/status-panel.ts | 17 ++- .../tui/components/messages/usage-panel.ts | 111 +++++++++++++- .../components/messages/status-panel.test.ts | 38 +++++ .../components/messages/usage-panel.test.ts | 138 +++++++++++++++++- packages/oauth/src/managed-usage.ts | 69 ++++++++- packages/oauth/src/toolkit.ts | 2 + packages/oauth/test/managed-usage.test.ts | 72 ++++++++- packages/oauth/test/toolkit.test.ts | 73 +++++++++ 10 files changed, 517 insertions(+), 11 deletions(-) create mode 100644 .changeset/expose-extrausage-toolkit.md diff --git a/.changeset/expose-extrausage-toolkit.md b/.changeset/expose-extrausage-toolkit.md new file mode 100644 index 000000000..d31c53ee5 --- /dev/null +++ b/.changeset/expose-extrausage-toolkit.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/kimi-code-sdk": patch +--- + +Display Extra Usage (fuel pack) balance in `/usage` and `/status` commands. diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index 51ccd3fc1..fd5d397f4 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -213,5 +213,5 @@ async function loadManagedUsageReport(host: SlashCommandHost): Promise 0) { + lines.push(''); + lines.push(...extraSection); + } + return lines; } diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts index 23cef0b27..195860bc3 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -30,9 +30,19 @@ export interface ManagedUsageRow { readonly resetHint?: string; } +export interface BoosterWalletInfo { + readonly balanceCents: number; + readonly totalCents: number; + readonly monthlyChargeLimitEnabled: boolean; + readonly monthlyChargeLimitCents: number; + readonly monthlyUsedCents: number; + readonly currency: string; +} + export interface ManagedUsageReport { readonly summary: ManagedUsageRow | null; readonly limits: readonly ManagedUsageRow[]; + readonly extraUsage?: BoosterWalletInfo | null; } export interface UsageReportOptions { @@ -121,8 +131,7 @@ function buildManagedUsageSection( r.limit > 0 ? Math.max(0, Math.min(r.used / r.limit, 1)) : 0; const labelWidth = Math.max(10, ...rows.map((r) => r.label.length)); const pctWidth = Math.max(...rows.map((r) => `${Math.round(usedRatio(r) * 100)}% used`.length)); - const severityColor = (sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' => - sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; + const out: string[] = [accent('Plan usage')]; for (const row of rows) { const ratioUsed = usedRatio(row); @@ -136,6 +145,91 @@ function buildManagedUsageSection( return out; } +function severityColor(sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' { + return sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; +} + +function currencySymbol(currency: string): string { + switch (currency.toUpperCase()) { + case 'CNY': + return '¥'; + case 'USD': + return '$'; + default: + return ''; + } +} + +interface CurrencyParts { + readonly symbol: string; + readonly number: string; +} + +function formatCurrencyParts(cents: number, currency: string): CurrencyParts { + const symbol = currencySymbol(currency); + const main = cents / 100; + const formatted = main.toFixed(2); + return symbol.length > 0 + ? { symbol, number: formatted } + : { symbol: '', number: `${formatted} ${currency}` }; +} + +export function buildExtraUsageSection( + extraUsage: BoosterWalletInfo | undefined | null, + accent: Colorize, + value: Colorize, + muted: Colorize, +): string[] { + if (extraUsage === undefined || extraUsage === null) return []; + + const hasMonthlyLimit = + extraUsage.monthlyChargeLimitEnabled && extraUsage.monthlyChargeLimitCents > 0; + + const balance = formatCurrencyParts(extraUsage.balanceCents, extraUsage.currency); + const used = formatCurrencyParts(extraUsage.monthlyUsedCents, extraUsage.currency); + const rows: Array<{ label: string; symbol: string; number: string }> = []; + let barLine: string | null = null; + + if (hasMonthlyLimit) { + const ratio = Math.max( + 0, + Math.min(extraUsage.monthlyUsedCents / extraUsage.monthlyChargeLimitCents, 1), + ); + const bar = renderProgressBar(ratio, 20); + barLine = ` ${currentTheme.fg(severityColor(ratioSeverity(ratio)), bar)}`; + const limit = formatCurrencyParts(extraUsage.monthlyChargeLimitCents, extraUsage.currency); + rows.push({ label: 'Used this month', ...used }); + rows.push({ label: 'Monthly limit', ...limit }); + rows.push({ label: 'Balance', ...balance }); + } else { + rows.push({ label: 'Used this month', ...used }); + rows.push({ label: 'Monthly limit', symbol: '', number: 'Unlimited' }); + rows.push({ label: 'Balance', ...balance }); + } + + // `Used this month` is the longest label; size the column to the widest label + // so the currency symbol starts in the same column on every row. + const labelWidth = Math.max(...rows.map((r) => r.label.length)); + // Right-align the numeric part of currency rows against each other so the + // decimal points line up (e.g. `¥ 50.00` / `¥200.00`). Text-only rows such as + // `Unlimited` carry no currency symbol, so they must not widen the numeric + // column — otherwise money values get padded with stray spaces. + const numberWidth = Math.max( + 0, + ...rows.filter((r) => r.symbol.length > 0).map((r) => visibleWidth(r.number)), + ); + const row = (label: string, symbol: string, number: string): string => { + const cell = symbol.length > 0 ? symbol + number.padStart(numberWidth, ' ') : number; + return ` ${muted(label.padEnd(labelWidth, ' '))} ${value(cell)}`; + }; + + const lines: string[] = [accent('Extra Usage')]; + if (barLine !== null) lines.push(barLine); + for (const r of rows) lines.push(row(r.label, r.symbol, r.number)); + + return lines; +} + export function buildManagedUsageReportLines(options: ManagedUsageReportLineOptions): string[] { const accent = (text: string) => currentTheme.boldFg('primary', text); const value = (text: string) => currentTheme.fg('text', text); @@ -157,8 +251,6 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] { const value = (text: string) => currentTheme.fg('text', text); const muted = (text: string) => currentTheme.fg('textDim', text); const errorStyle = (text: string) => currentTheme.fg('error', text); - const severityColor = (sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' => - sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; const lines: string[] = [ accent('Session usage'), @@ -197,6 +289,17 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] { lines.push(...managedSection); } + const extraSection = buildExtraUsageSection( + options.managedUsage?.extraUsage, + accent, + value, + muted, + ); + if (extraSection.length > 0) { + lines.push(''); + lines.push(...extraSection); + } + return lines; } diff --git a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts index 7d27be8e2..041860896 100644 --- a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts @@ -68,6 +68,44 @@ describe('status panel report lines', () => { expect(output).not.toContain('Runtime'); }); + it('formats extra usage section in status report', () => { + const lines = buildStatusReportLines({ + version: '1.2.3', + model: 'k2', + workDir: '/tmp/project', + sessionId: 'ses-1', + sessionTitle: null, + thinkingEffort: 'off', + permissionMode: 'manual', + planMode: false, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + availableModels: {}, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + balanceCents: 15000, + totalCents: 20000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 20000, + monthlyUsedCents: 5000, + currency: 'USD', + }, + }, + }).map(strip); + + const output = lines.join('\n'); + expect(output).toContain('Extra Usage'); + expect(output).toContain('Balance'); + expect(output).toContain('150.00'); + expect(output).toContain('Used this month'); + expect(output).toContain('50.00'); + expect(output).toContain('Monthly limit'); + expect(output).toContain('200.00'); + }); + it('falls back to app state and shows status load errors as warnings', () => { const lines = buildStatusReportLines({ version: '1.2.3', diff --git a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts index ff39cb7e6..cf2598f82 100644 --- a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts @@ -24,7 +24,7 @@ describe('UsagePanelComponent', () => { output: 250, }, }, - } as never, + }, contextUsage: 0.25, contextTokens: 2500, maxContextTokens: 10000, @@ -48,6 +48,142 @@ describe('UsagePanelComponent', () => { expect(lines.join('\n')).toContain('resets tomorrow'); }); + it('formats extra usage with a monthly limit', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + balanceCents: 10000, + totalCents: 20000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 20000, + monthlyUsedCents: 5000, + currency: 'USD', + }, + }, + }).map(strip); + + const output = lines.join('\n'); + expect(lines).toContain('Extra Usage'); + expect(output).toContain('Balance'); + expect(output).toContain('100.00'); + expect(output).toContain('Used this month'); + expect(output).toContain('50.00'); + expect(output).toContain('Monthly limit'); + expect(output).toContain('200.00'); + // bar row contains block glyphs but no percentage text + expect(output).toContain('░'); + }); + + it('formats extra usage without a monthly limit and omits the progress bar', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + balanceCents: 18208, + totalCents: 40000, + monthlyChargeLimitEnabled: false, + monthlyChargeLimitCents: 0, + monthlyUsedCents: 21792, + currency: 'CNY', + }, + }, + }).map(strip); + + const output = lines.join('\n'); + expect(lines).toContain('Extra Usage'); + expect(output).toContain('Balance'); + expect(output).toContain('¥182.08'); + expect(output).toContain('Used this month'); + expect(output).toContain('¥217.92'); + expect(output).toContain('Monthly limit'); + expect(output).toContain('Unlimited'); + expect(output).not.toContain('░'); + expect(output).not.toContain('█'); + }); + + it('omits the extra usage section when extraUsage is omitted or null', () => { + for (const extraUsage of [undefined, null]) { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { summary: null, limits: [], extraUsage }, + }).map(strip); + + expect(lines).not.toContain('Extra Usage'); + } + }); + + it('formats extra usage with CNY currency', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + balanceCents: 10000, + totalCents: 20000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 20000, + monthlyUsedCents: 5000, + currency: 'CNY', + }, + }, + }).map(strip); + + const output = lines.join('\n'); + expect(output).toContain('Balance'); + expect(output).toContain('100.00'); + expect(output).toContain('Used this month'); + expect(output).toContain('50.00'); + expect(output).toContain('Monthly limit'); + expect(output).toContain('200.00'); + }); + + it('aligns the currency symbol and decimal point across extra usage rows', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + balanceCents: 15901, + totalCents: 300000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 300000, + monthlyUsedCents: 24099, + currency: 'CNY', + }, + }, + }).map(strip); + + const extraRows = lines.filter((line) => line.includes('¥')); + expect(extraRows).toHaveLength(3); + // The currency symbol stays in one column... + expect(new Set(extraRows.map((line) => line.indexOf('¥'))).size).toBe(1); + // ...and the right-aligned numeric parts end in the same column, so the + // decimal points line up across rows. + expect(new Set(extraRows.map((line) => line.length)).size).toBe(1); + }); + it('wraps preformatted usage lines in a bordered panel', () => { const component = new UsagePanelComponent(() => ['Session usage'], 'primary'); const output = component.render(80).map(strip); diff --git a/packages/oauth/src/managed-usage.ts b/packages/oauth/src/managed-usage.ts index 534104a20..928d9008c 100644 --- a/packages/oauth/src/managed-usage.ts +++ b/packages/oauth/src/managed-usage.ts @@ -45,14 +45,78 @@ export interface UsageRow { readonly resetHint?: string | undefined; } +export interface BoosterWalletInfo { + /** Remaining balance in whole cents (from balance.amountLeft). */ + readonly balanceCents: number; + /** Total balance in whole cents (from balance.amount). */ + readonly totalCents: number; + /** Whether the user enabled a monthly spending cap. */ + readonly monthlyChargeLimitEnabled: boolean; + /** Monthly spending cap in whole cents; 0 means unlimited. */ + readonly monthlyChargeLimitCents: number; + /** Monthly spend so far in whole cents. */ + readonly monthlyUsedCents: number; + /** ISO currency code, e.g. USD / CNY. */ + readonly currency: string; +} + export interface ParsedManagedUsage { readonly summary: UsageRow | null; readonly limits: UsageRow[]; + readonly extraUsage: BoosterWalletInfo | null; +} + +const FIXED_POINT_CENTS = 1_000_000; + +function fixedPointToCents(value: number): number { + const cents = value / FIXED_POINT_CENTS; + if (cents > 0 && cents < 1) return 1; + return Math.round(cents); +} + +function parseMoney(raw: unknown): { cents: number; currency: string } | null { + if (!isRecord(raw)) return null; + const cents = toInt(raw['priceInCents']); + if (cents === null) return null; + const currency = typeof raw['currency'] === 'string' ? raw['currency'] : ''; + return { cents, currency }; +} + +function parseBoosterWallet(raw: unknown): BoosterWalletInfo | null { + if (!isRecord(raw)) return null; + const balance = raw['balance']; + if (!isRecord(balance)) return null; + if (balance['type'] !== 'BOOSTER') return null; + const amountRaw = toInt(balance['amount']); + if (amountRaw === null || amountRaw <= 0) return null; + const totalCents = fixedPointToCents(amountRaw); + const amountLeftRaw = toInt(balance['amountLeft']); + const balanceCents = amountLeftRaw !== null ? fixedPointToCents(amountLeftRaw) : 0; + + const monthlyLimit = parseMoney(raw['monthlyChargeLimit']); + const monthlyUsed = parseMoney(raw['monthlyUsed']); + const monthlyChargeLimitEnabled = raw['monthlyChargeLimitEnabled'] === true; + + const currency = + monthlyLimit && monthlyLimit.currency.length > 0 + ? monthlyLimit.currency + : monthlyUsed && monthlyUsed.currency.length > 0 + ? monthlyUsed.currency + : 'USD'; + + return { + balanceCents, + totalCents, + monthlyChargeLimitEnabled, + monthlyChargeLimitCents: monthlyLimit?.cents ?? 0, + monthlyUsedCents: monthlyUsed?.cents ?? 0, + currency, + }; } export function parseManagedUsagePayload(payload: unknown): ParsedManagedUsage { if (typeof payload !== 'object' || payload === null) { - return { summary: null, limits: [] }; + return { summary: null, limits: [], extraUsage: null }; } const rec = payload as Record; const summary = toUsageRow(rec['usage'], 'Weekly limit'); @@ -71,7 +135,8 @@ export function parseManagedUsagePayload(payload: unknown): ParsedManagedUsage { if (row !== null) limits.push(row); } } - return { summary, limits }; + const extraUsage = parseBoosterWallet(rec['boosterWallet']); + return { summary, limits, extraUsage }; } function toUsageRow(raw: unknown, defaultLabel: string): UsageRow | null { diff --git a/packages/oauth/src/toolkit.ts b/packages/oauth/src/toolkit.ts index 94dca02bf..0229db7b0 100644 --- a/packages/oauth/src/toolkit.ts +++ b/packages/oauth/src/toolkit.ts @@ -97,6 +97,7 @@ export type AuthManagedUsageResult = readonly kind: 'ok'; readonly summary: ParsedManagedUsage['summary']; readonly limits: ParsedManagedUsage['limits']; + readonly extraUsage: ParsedManagedUsage['extraUsage']; } | FetchManagedUsageError; @@ -291,6 +292,7 @@ export class KimiOAuthToolkit { kind: 'ok', summary: result.parsed.summary, limits: result.parsed.limits, + extraUsage: result.parsed.extraUsage, }; } catch (error) { return { diff --git a/packages/oauth/test/managed-usage.test.ts b/packages/oauth/test/managed-usage.test.ts index 98d33ee17..f390653bc 100644 --- a/packages/oauth/test/managed-usage.test.ts +++ b/packages/oauth/test/managed-usage.test.ts @@ -25,8 +25,8 @@ describe('isManagedKimiCode', () => { describe('parseManagedUsagePayload', () => { it('returns empty when payload is not an object', () => { - expect(parseManagedUsagePayload(null)).toEqual({ summary: null, limits: [] }); - expect(parseManagedUsagePayload('nope')).toEqual({ summary: null, limits: [] }); + expect(parseManagedUsagePayload(null)).toEqual({ summary: null, limits: [], extraUsage: null }); + expect(parseManagedUsagePayload('nope')).toEqual({ summary: null, limits: [], extraUsage: null }); }); it('extracts a summary from the `usage` object', () => { @@ -74,6 +74,73 @@ describe('parseManagedUsagePayload', () => { const parsed = parseManagedUsagePayload({ usage: { used: 1, limit: 10, resetAt: future } }); expect(parsed.summary?.resetHint).toMatch(/resets in/); }); + + it('extracts extra usage from boosterWallet.balance', () => { + const parsed = parseManagedUsagePayload({ + usage: { used: 40, limit: 1000, name: 'Weekly limit' }, + boosterWallet: { + id: 'wallet_1', + balance: { + type: 'BOOSTER', + amount: '20000000000', + amountLeft: '10000000000', + unit: 'UNIT_CURRENCY', + }, + monthlyChargeLimitEnabled: true, + monthlyChargeLimit: { currency: 'USD', priceInCents: '20000' }, + monthlyUsed: { currency: 'USD', priceInCents: '5000' }, + }, + }); + expect(parsed.extraUsage).toEqual({ + balanceCents: 10000, + totalCents: 20000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 20000, + monthlyUsedCents: 5000, + currency: 'USD', + }); + }); + + it('treats missing amountLeft as zero balance', () => { + const parsed = parseManagedUsagePayload({ + usage: { used: 1, limit: 10 }, + boosterWallet: { balance: { type: 'BOOSTER', amount: '20000000000' } }, + }); + expect(parsed.extraUsage).toMatchObject({ totalCents: 20000, balanceCents: 0 }); + }); + + it('defaults monthly limit fields when absent', () => { + const parsed = parseManagedUsagePayload({ + usage: { used: 1, limit: 10 }, + boosterWallet: { + balance: { type: 'BOOSTER', amount: '20000000000', amountLeft: '20000000000' }, + }, + }); + expect(parsed.extraUsage).toEqual({ + balanceCents: 20000, + totalCents: 20000, + monthlyChargeLimitEnabled: false, + monthlyChargeLimitCents: 0, + monthlyUsedCents: 0, + currency: 'USD', + }); + }); + + it('returns null extra usage when boosterWallet is missing or invalid', () => { + expect(parseManagedUsagePayload({ usage: { used: 1, limit: 10 } }).extraUsage).toBeNull(); + expect( + parseManagedUsagePayload({ + usage: { used: 1, limit: 10 }, + boosterWallet: { balance: { type: 'OTHER', amount: '100', amountLeft: '50' } }, + }).extraUsage, + ).toBeNull(); + expect( + parseManagedUsagePayload({ + usage: { used: 1, limit: 10 }, + boosterWallet: { balance: { type: 'BOOSTER', amount: '0', amountLeft: '0' } }, + }).extraUsage, + ).toBeNull(); + }); }); describe('fetchManagedUsage', () => { @@ -92,6 +159,7 @@ describe('fetchManagedUsage', () => { parsed: { summary: { label: 'Weekly limit', used: 1, limit: 10 }, limits: [], + extraUsage: null, }, }); diff --git a/packages/oauth/test/toolkit.test.ts b/packages/oauth/test/toolkit.test.ts index 73e091644..6ceaae417 100644 --- a/packages/oauth/test/toolkit.test.ts +++ b/packages/oauth/test/toolkit.test.ts @@ -567,6 +567,79 @@ describe('KimiOAuthToolkit', () => { expect((await storage.load(storageName))?.accessToken).toBe('fresh-access'); }); + it('propagates extraUsage from the managed usage response', async () => { + const storage = new MemoryTokenStorage(); + storage.tokens.set('kimi-code', token('access-1')); + const fetchImpl = vi.fn(async (_input: unknown, _init?: RequestInit) => + new Response( + JSON.stringify({ + usage: { used: 10, limit: 100, name: 'Weekly limit' }, + limits: [], + boosterWallet: { + balance: { + type: 'BOOSTER', + amount: '20000000000', + amountLeft: '10000000000', + }, + monthlyChargeLimitEnabled: true, + monthlyChargeLimit: { currency: 'USD', priceInCents: '20000' }, + monthlyUsed: { currency: 'USD', priceInCents: '5000' }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) as unknown as typeof fetch; + vi.stubGlobal('fetch', fetchImpl); + const toolkit = new KimiOAuthToolkit({ + homeDir: join('/tmp', 'kimi-oauth-toolkit-test'), + identity: TEST_IDENTITY, + storage, + now: () => 100, + }); + + await expect(toolkit.getManagedUsage()).resolves.toMatchObject({ + kind: 'ok', + summary: { label: 'Weekly limit', used: 10, limit: 100 }, + limits: [], + extraUsage: { + balanceCents: 10000, + totalCents: 20000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 20000, + monthlyUsedCents: 5000, + currency: 'USD', + }, + }); + }); + + it('returns null extraUsage when the payload has no boosterWallet', async () => { + const storage = new MemoryTokenStorage(); + storage.tokens.set('kimi-code', token('access-1')); + const fetchImpl = vi.fn(async (_input: unknown, _init?: RequestInit) => + new Response( + JSON.stringify({ + usage: { used: 10, limit: 100, name: 'Weekly limit' }, + limits: [], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) as unknown as typeof fetch; + vi.stubGlobal('fetch', fetchImpl); + const toolkit = new KimiOAuthToolkit({ + homeDir: join('/tmp', 'kimi-oauth-toolkit-test'), + identity: TEST_IDENTITY, + storage, + now: () => 100, + }); + + await expect(toolkit.getManagedUsage()).resolves.toMatchObject({ + kind: 'ok', + summary: { label: 'Weekly limit', used: 10, limit: 100 }, + limits: [], + extraUsage: null, + }); + }); + it('removes managed config on logout when an adapter supports cleanup', async () => { const storage = new MemoryTokenStorage(); storage.tokens.set('kimi-code', token('access-1')); From 1bf2c9afee4643fbf6755f0b92fd60aa14240501 Mon Sep 17 00:00:00 2001 From: Kai Date: Thu, 9 Jul 2026 18:05:14 +0800 Subject: [PATCH 08/80] feat: keep image-heavy sessions within provider request-size limits (#1508) * feat(kosong): classify HTTP 413 request-body-too-large as a dedicated error type * feat(agent-core): lower default image downscale cap to 2000px and make it configurable * feat(agent-core): strip media to text markers and retry when the compaction request is too large * feat(agent-core): cap model-initiated image reads with a configurable byte budget * feat(agent-core): resend with degraded media when the provider rejects the request body as too large * test(agent-core): add explicit timeouts to encode-heavy image budget tests * feat: add WebP decoding support with wasm integration - Introduced a new WebP decoding module using @jsquash/webp's wasm decoder. - Implemented functions to decode WebP images and check for animated WebP formats. - Updated image compression tests to include scenarios for WebP handling, including encoding and decoding. - Enhanced error handling for API request size limits to accommodate various error messages. - Updated pnpm lockfile to include new dependencies for WebP encoding and decoding. * chore(changeset): consolidate this PR's entries into one * fix(nix): update pnpmDeps hash for merged lockfile * feat(agent-core): refuse HEIC/HEIF reads with platform-matched conversion guidance --- .changeset/image-request-size-limits.md | 6 + .../editor-keyboard-image-paste.test.ts | 6 +- docs/en/configuration/config-files.md | 14 +- docs/en/configuration/env-vars.md | 2 + docs/zh/configuration/config-files.md | 14 +- docs/zh/configuration/env-vars.md | 2 + flake.nix | 2 +- packages/agent-core/package.json | 1 + .../scripts/generate-webp-dec-wasm.mjs | 36 ++ .../agent-core/src/agent/compaction/full.ts | 59 ++- .../agent-core/src/agent/context/index.ts | 13 + .../agent-core/src/agent/context/projector.ts | 55 +++ .../agent-core/src/agent/records/types.ts | 5 +- packages/agent-core/src/agent/turn/index.ts | 1 + packages/agent-core/src/config/schema.ts | 22 ++ packages/agent-core/src/config/toml.ts | 12 + packages/agent-core/src/loop/llm.ts | 6 +- packages/agent-core/src/loop/run-turn.ts | 22 +- packages/agent-core/src/loop/turn-step.ts | 130 +++++-- packages/agent-core/src/rpc/core-impl.ts | 5 + .../src/tools/builtin/file/read-media.ts | 61 +++- .../src/tools/support/image-compress.ts | 248 ++++++++++--- .../src/tools/support/webp-dec-wasm.ts | 7 + .../src/tools/support/webp-decode.ts | 86 +++++ .../compaction/compaction-scenarios.test.ts | 27 ++ .../test/agent/compaction/full.test.ts | 137 +++++++ .../test/agent/context/projector.test.ts | 66 +++- packages/agent-core/test/agent/turn.test.ts | 72 ++++ .../agent-core/test/config/configs.test.ts | 22 ++ .../loop/tool-exchange-fallback.e2e.test.ts | 176 ++++++++- .../test/tools/image-compress.test.ts | 343 ++++++++++++++++-- .../agent-core/test/tools/read-media.test.ts | 189 +++++++++- packages/kosong/src/errors.ts | 47 +++ packages/kosong/src/index.ts | 2 + packages/kosong/test/errors.test.ts | 64 ++++ pnpm-lock.yaml | 94 +---- 36 files changed, 1839 insertions(+), 215 deletions(-) create mode 100644 .changeset/image-request-size-limits.md create mode 100644 packages/agent-core/scripts/generate-webp-dec-wasm.mjs create mode 100644 packages/agent-core/src/tools/support/webp-dec-wasm.ts create mode 100644 packages/agent-core/src/tools/support/webp-decode.ts diff --git a/.changeset/image-request-size-limits.md b/.changeset/image-request-size-limits.md new file mode 100644 index 000000000..076b0ef78 --- /dev/null +++ b/.changeset/image-request-size-limits.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Keep image-heavy sessions within provider request-size limits: model-read images now honor a 256 KB per-image budget and a 2000px downscale cap (configurable via `[image]` in config.toml or `KIMI_IMAGE_*` env vars), oversized WebP is compressed as well, HEIC/HEIF reads are refused with a platform-matched conversion command instead of poisoning the session, and a request-too-large rejection (HTTP 413) now recovers automatically — the request and /compact both retry with older media replaced by text markers instead of failing the session. + diff --git a/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts b/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts index 0fe14bc09..73e05b7ef 100644 --- a/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts +++ b/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts @@ -141,14 +141,14 @@ describe('clipboard image paste compression', () => { if (att?.kind !== 'image') throw new Error('expected image attachment'); // Stored metadata reflects the compressed size. - expect(Math.max(att.width, att.height)).toBeLessThanOrEqual(3000); - expect(att.placeholder).toContain('3000×1500'); + expect(Math.max(att.width, att.height)).toBeLessThanOrEqual(2000); + expect(att.placeholder).toContain('2000×1000'); // The stored bytes decode to the compressed dimensions — the thumbnail and // the submitted image both read from these bytes, so they cannot diverge. const dims = parseImageMeta(att.bytes); expect(dims).not.toBeNull(); - expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(3000); + expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(2000); }); it('records and persists the pre-compression original for an oversized paste', async () => { diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index f7e893555..303886e36 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -87,11 +87,12 @@ Fields in the config file fall into two categories: **top-level scalars** that d | `thinking` | `table` | — | Default parameters for Thinking mode → [`thinking`](#thinking) | | `loop_control` | `table` | — | Agent loop control parameters → [`loop_control`](#loop_control) | | `background` | `table` | — | Background task runtime parameters → [`background`](#background) | +| `image` | `table` | — | Image compression parameters → [`image`](#image) | | `services` | `table` | — | Built-in external service configuration → [`services`](#services) | | `permission` | `table` | — | Initial permission rules → [`permission`](#permission) | | `hooks` | `array
TokenValueUsage
--font-ui-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC"…UI & body (system fonts first)
--font-ui"Inter Variable", "Inter", "Helvetica Neue", Arial…UI & body (Inter first)
--font-monoJetBrains Mono…code, tool names, line numbers, diffs
--base-ui-font-size14px user preferenceroot setting that drives UI, reading body, and sidebar font sizes
--content-font-sizecalc(base + 1px)chat Markdown, message bubbles, composer
` | — | Lifecycle hooks; see [Hooks](../customization/hooks.md) | -The following sections cover each of the nested tables in turn: `providers`, `models`, `thinking`, `loop_control`, `background`, `services`, and `permission`. +The following sections cover each of the nested tables in turn: `providers`, `models`, `thinking`, `loop_control`, `background`, `image`, `services`, and `permission`. ## `providers` @@ -202,6 +203,17 @@ You can also switch models temporarily without touching the config file — by s In print mode (`kimi -p ""`), Kimi Code runs a single non-interactive turn and exits as soon as the main agent finishes. If you launch background tasks (for example, concurrent subagents via `Agent(run_in_background=true)`) and need them to run to completion, set `keep_alive_on_exit = true`: the process then waits for every background task to reach a terminal state before exiting, bounded by `print_wait_ceiling_s`. Without it, the single turn ending tears background tasks down with the process. +## `image` + +`image` controls how images are compressed before being sent to the model, across every ingestion point (pasted images, `ReadMediaFile` reads, images in MCP tool results, and so on). + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `max_edge_px` | `integer` | `2000` | Longest-edge ceiling in pixels. Larger images are scaled down proportionally to fit; raising it preserves more detail at the cost of larger request bodies | +| `read_byte_budget` | `integer` | `262144` (256 KB) | Per-image byte budget for images the model reads for itself (`ReadMediaFile` default reads). It bounds the accumulated request-body size when the model keeps screenshotting and reading images; fine detail stays reachable through the `region` parameter, which reads a crop back at full fidelity (`region` and `full_resolution` are not subject to this budget) | + +`max_edge_px` can be overridden by the `KIMI_IMAGE_MAX_EDGE_PX` environment variable and `read_byte_budget` by `KIMI_IMAGE_READ_BYTE_BUDGET`; both take higher priority than `config.toml`. + @@ -792,8 +793,29 @@ function openPr(url: string): void { @edit-message="handleEditMessage" /> + + + + + + + + .side { grid-column: 1; } +.side-handle { grid-column: 2; } +.app:not(.mobile) > .con { grid-column: 3; } +.preview-handle { grid-column: 4; } + +/* Sidebar toggle — floating button pinned to the top-left corner. On macOS + desktop it is resident (rendered in both states beside the traffic lights); + on Windows/web it only appears while the sidebar is collapsed (the collapse + button lives inside the sidebar header). While collapsed the conversation + header pads left so its content clears the button (global block below). */ +.sidebar-toggle-btn { + position: absolute; + /* Vertically centered in the 48px conversation header. */ + top: 11px; + left: 16px; + z-index: var(--z-sticky); + /* Fade in on appearance (Windows/web: only rendered while collapsed, so + this plays as the sidebar finishes sliding away). macOS disables it. */ + animation: sidebar-toggle-btn-in 0.18s var(--ease-out) 0.12s backwards; + /* Floats over the macOS-desktop window-drag header; keep it clickable. */ + -webkit-app-region: no-drag; } -/* The collapsed rail occupies track 1; keep the main pane pinned to the - conversation track even though the sidebar/handle are display:none. */ -.app.sidebar-collapsed > .con { - grid-column: 3; +/* macOS desktop (hidden title bar): resident beside the floating traffic + lights (green light's right edge ≈ 68px; 72 keeps a gap that matches the + lights' own 8px rhythm); no entrance animation since it never appears. */ +.app.macos-desktop .sidebar-toggle-btn { + left: 72px; + animation: none; +} +@keyframes sidebar-toggle-btn-in { + from { opacity: 0; } +} + +/* Internal-build tag pinned to the app's bottom-right corner (desktop app + only — the component renders nothing elsewhere). Informational: never + intercepts pointer input. */ +.internal-build-fab { + position: absolute; + right: var(--space-3); + bottom: var(--space-3); + z-index: var(--z-sticky); + pointer-events: none; } /* Mobile single-column shell: slim top bar (auto) over the full-width @@ -1208,4 +1267,19 @@ function openPr(url: string): void { one continuous line across the layout. */ --panel-head-h: 48px; } + +/* Sidebar collapsed (desktop): the conversation header pads left so its + content clears the floating sidebar toggle (.sidebar-toggle-btn) — and the + macOS traffic lights on desktop builds. Animated in step with the sidebar + width transition. Cross-component rule (ChatHeader renders the header), so + it lives in this global block. */ +.app:not(.mobile) .chat-header { + transition: padding-left 0.28s cubic-bezier(0.4, 0, 0.2, 1); +} +.app.sidebar-collapsed .chat-header { + padding-left: 52px; +} +.app.sidebar-collapsed.macos-desktop .chat-header { + padding-left: 108px; +} diff --git a/apps/kimi-web/src/components/InternalBuildBanner.vue b/apps/kimi-web/src/components/InternalBuildBanner.vue index e2638aaad..a45748f7b 100644 --- a/apps/kimi-web/src/components/InternalBuildBanner.vue +++ b/apps/kimi-web/src/components/InternalBuildBanner.vue @@ -5,8 +5,8 @@ import { isDesktop } from '../lib/desktopFlag'; const { t } = useI18n(); -// True only inside the Kimi Desktop app (see desktopFlag.ts). Renders an inline -// tag meant to sit next to the "Kimi Code" brand in the sidebar header. +// True only inside the Kimi Desktop app (see desktopFlag.ts). Renders a small +// tag pinned to the app's bottom-right corner (positioned by App.vue). const show = isDesktop; diff --git a/apps/kimi-web/src/components/SessionRow.vue b/apps/kimi-web/src/components/SessionRow.vue index 2291a2681..30158be01 100644 --- a/apps/kimi-web/src/components/SessionRow.vue +++ b/apps/kimi-web/src/components/SessionRow.vue @@ -254,7 +254,7 @@ defineExpose({ closeMenu }); :label="t('sidebar.options')" @click.stop="toggleMenu($event)" > - + @@ -287,22 +287,24 @@ defineExpose({ closeMenu }); .se { /* --sb-* vars come from .side in Sidebar.vue: the title starts at --sb-pad-x + --sb-gutter + --sb-gap, exactly under the workspace name. - The row is an inset pill: a 6px horizontal margin + 10px padding lands the - leading icon at --sb-pad-x (16px), aligned with the workspace header. */ + The row is an inset pill: the .sessions container's --sb-inset padding + + the row's own padding land the leading slot at --sb-pad-x, aligned with + the workspace header. */ display: block; margin: 0; - padding: var(--space-1) var(--space-2); - border-radius: var(--radius-md); + padding: 8px var(--space-2); + border-radius: var(--radius-sm); font-family: var(--font-ui); color: var(--color-text); cursor: pointer; position: relative; } -.se:hover { background: var(--color-surface-sunken); color: var(--color-text); } +.se:hover { background: var(--sb-hover, var(--color-surface-sunken)); color: var(--color-text); } +/* Selected: neutral fill (NOT accent-tinted — selection reads as "where I + am", the accent stays reserved for actions and status). */ .se.on { - background: var(--color-accent-soft); - color: var(--color-accent-hover); - box-shadow: inset 0 0 0 1px var(--color-accent-bd); + background: var(--color-selected); + color: var(--color-text); } .row { @@ -310,9 +312,9 @@ defineExpose({ closeMenu }); align-items: center; gap: var(--sb-gap, 6px); min-width: 0; - /* Floor the row at the hover-kebab height (IconButton sm = 26px) so swapping - the timestamp for the kebab on hover doesn't grow the row. */ - min-height: 26px; + /* Row height is font-driven: title line-height (13×1.25≈16px) + 2×5px + .se padding ≈ 26px. The hover kebab is absolutely positioned (see .act) + so it never contributes to row height and can't cause hover jitter. */ } .left { @@ -341,7 +343,7 @@ defineExpose({ closeMenu }); .t { color: inherit; - font-size: var(--text-base); + font-size: var(--ui-font-size-sm); font-weight: 450; line-height: var(--leading-tight); flex: 1; @@ -356,30 +358,37 @@ defineExpose({ closeMenu }); font-size: var(--text-xs); font-family: var(--font-ui); font-weight: 475; + line-height: var(--leading-tight); font-variant-numeric: tabular-nums; text-align: right; } -/* Trailing action slot: time and kebab share one grid cell (grid-area:1/1). - Both stay in the layout and swap via `visibility` (never display:none), so - the slot width = max(time width, IconButton sm 26px) is identical in hover - and rest — the badges and title don't reflow, eliminating hover jitter. - `.act .kebab` out-specificities IconButton's own display so the hidden - default wins. */ +/* Trailing action slot: the relative time (in flow) sets the slot size; the + kebab is absolutely positioned over it and swapped via `visibility`, so it + contributes neither height (the row stays font-driven) nor width changes + (min-width reserves the kebab's footprint, the title doesn't reflow). */ .act { - display: inline-grid; + position: relative; flex: none; + display: inline-flex; align-items: center; - justify-items: end; + justify-content: flex-end; + /* Reserve the kebab's width so the trailing slot (and thus the title) never + shifts between the time and the kebab, even for short times like "2m". */ + min-width: 26px; +} +.act .kebab { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + visibility: hidden; } -.act .ts, -.act .kebab { grid-area: 1 / 1; } -.act .kebab { visibility: hidden; } .se:hover .act .kebab, .act:has(.kebab.open) .kebab { visibility: visible; } .se:hover .act .ts, .act:has(.kebab.open) .ts { visibility: hidden; } -.kebab.open { color: var(--color-text); background: var(--color-surface-sunken); } +.kebab.open { color: var(--color-text); background: var(--sb-hover, var(--color-surface-sunken)); } /* Fixed + anchored to the ⋯ button via inline style (see positionMenu); the menu is teleported to so the collapsing list's `overflow: hidden` can't clip it. */ @@ -413,15 +422,10 @@ defineExpose({ closeMenu }); .sessions .se { margin: 0; - border-radius: var(--radius-md); - /* Trim the row padding by the inset margin so the title still starts at the - same x as the workspace name (whose header has no inset). */ - padding: var(--space-1) calc(var(--sb-pad-x, 12px) - var(--space-2)); -} -.sessions .se:hover { background: var(--panel2); } -.sessions .se.on { - background: var(--color-accent-soft); - box-shadow: inset 0 0 0 1px var(--color-accent-bd); + border-radius: var(--radius-sm); + /* Trim the row padding by the container inset so the title still starts at + the same x as the workspace name (whose header has no inset). */ + padding: 8px calc(var(--sb-pad-x, 20px) - var(--sb-inset, 12px)); } .sessions .se .rename-input { border-radius: var(--radius-sm); font-family: var(--sans); } .sessions .se .kebab { border-radius: var(--radius-sm); } diff --git a/apps/kimi-web/src/components/Sidebar.vue b/apps/kimi-web/src/components/Sidebar.vue index 987465dbb..8c9127e2e 100644 --- a/apps/kimi-web/src/components/Sidebar.vue +++ b/apps/kimi-web/src/components/Sidebar.vue @@ -9,21 +9,18 @@ import { serverEndpointLabel } from '../api/config'; import { copyTextToClipboard } from '../lib/clipboard'; import { loadCollapsedWorkspaces, - loadShowWorkspacePaths, saveCollapsedWorkspaces, - saveShowWorkspacePaths, } from '../lib/storage'; import { moveInOrder, type DropPosition, type WorkspaceSortMode } from '../lib/workspaceOrder'; import type { Session, WorkspaceGroup as WorkspaceGroupType, WorkspaceView } from '../types'; import SearchSessionsDialog from './dialogs/SearchSessionsDialog.vue'; import WorkspaceGroup from './WorkspaceGroup.vue'; -import InternalBuildBanner from './InternalBuildBanner.vue'; import { isMacosDesktop } from '../lib/desktopFlag'; import IconButton from './ui/IconButton.vue'; import Icon from './ui/Icon.vue'; +import Kbd from './ui/Kbd.vue'; import Menu from './ui/Menu.vue'; import MenuItem from './ui/MenuItem.vue'; -import ShortcutKey from './ui/ShortcutKey.vue'; import { useConfirmDialog } from '../composables/useConfirmDialog'; const { t } = useI18n(); @@ -50,6 +47,12 @@ const props = withDefaults( unreadBySession?: Record; /** Width (px) of the session column, driven by the App resize handle. */ colWidth?: number; + /** True when the sidebar is collapsed: the container animates to width 0 + * (content keeps `colWidth` and is clipped), then hides itself. */ + collapsed?: boolean; + /** True while the resize handle is dragged — disables the width transition + * so the sidebar follows the pointer 1:1. */ + dragging?: boolean; }>(), { activeWorkspace: null, @@ -58,6 +61,8 @@ const props = withDefaults( pendingBySession: () => ({}), unreadBySession: () => ({}), colWidth: 220, + collapsed: false, + dragging: false, }, ); @@ -84,7 +89,7 @@ const emit = defineEmits<{ // Session search dialog (Spotlight-style; filters title + last prompt) // --------------------------------------------------------------------------- const showSearch = ref(false); -const sessionSearchShortcut = isAppleShortcutPlatform() ? '⌘K' : 'Ctrl K'; +const sessionSearchKeys = isAppleShortcutPlatform() ? ['⌘', 'K'] : ['Ctrl', 'K']; function openSearch(): void { // Sessions are loaded per-workspace (first page only); lazily drain the rest @@ -111,7 +116,7 @@ function isAppleShortcutPlatform(): boolean { return userAgentData?.platform === 'macOS' || userAgentData?.platform === 'iOS'; } -// Scroll-linked header seam: the .btn-wrap bottom border/shadow only appears +// Scroll-linked header seam: the .search-wrap bottom border/shadow only appears // once the session list has actually scrolled, so an unscrolled list shows no // abrupt boundary. const sessionsScrolled = ref(false); @@ -186,18 +191,6 @@ function onLoadMore(id: string): void { emit('loadMoreSessions', id); } -// --------------------------------------------------------------------------- -// Workspace path display (toggle in the Workspaces section header) -// --------------------------------------------------------------------------- -// Off by default so the list stays compact; turning it on reveals every -// workspace's root path as a stable subtitle (no hover-induced layout shift). -const showWorkspacePaths = ref(loadShowWorkspacePaths()); - -function toggleShowWorkspacePaths(): void { - showWorkspacePaths.value = !showWorkspacePaths.value; - saveShowWorkspacePaths(showWorkspacePaths.value); -} - // --------------------------------------------------------------------------- // Workspace drag-to-reorder // --------------------------------------------------------------------------- @@ -487,11 +480,6 @@ function chooseSortMode(mode: WorkspaceSortMode): void { closeSectionMenu(); } -function toggleShowWorkspacePathsFromMenu(): void { - toggleShowWorkspacePaths(); - closeSectionMenu(); -} - onBeforeUnmount(() => { document.removeEventListener('mousedown', onGhMenuDocClick, true); document.removeEventListener('mousedown', onWsMenuDocClick); @@ -560,54 +548,49 @@ onBeforeUnmount(() => { { {{ t('sidebar.sortRecent') }} + + + + + + + {{ name }} + {{ presetUrl(name) }} + + { text-overflow: ellipsis; white-space: nowrap; } -/* Dev-only: backend host:port appended to the title. Kept secondary so the - product name still leads. */ -.ch-endpoint { - color: var(--muted); +/* Dev-only backend pill next to the brand: shows which engine the dev proxy + forwards to (v1 / v2) and opens the switcher menu. v2 is accent-colored so + the two engines read differently at a glance. */ +.ch-backend { + flex: none; + min-width: 0; +} +.ch-backend-kind { font-family: var(--mono); - font-weight: 400; - font-size: calc(var(--ui-font-size) - 1px); + font-weight: 500; + color: var(--color-text-muted); +} +.ch-backend-kind.is-v2 { + color: var(--color-accent); +} +.ch-backend-ep { + font-family: var(--mono); + color: var(--color-text-faint); + overflow: hidden; + text-overflow: ellipsis; } -/* In narrow sidebars the product name drops out so the logo keeps its fixed - size and the action buttons remain reachable. */ +/* Responsive brand row: below 320px the pill's endpoint drops out (the v1/v2 + kind + chevron stay — the full target is one tooltip away); below 250px the + product name also drops out so the logo and action buttons keep their room. */ +@container sidebar-col (max-width: 320px) { + .ch-backend-ep { display: none; } +} @container sidebar-col (max-width: 250px) { .ch-name { display: none; } } @@ -1109,7 +1253,8 @@ onBeforeUnmount(() => { fixed positioning stays here (anchored to the ⋯ trigger / cursor). */ .ws-menu, .gh-menu, -.section-menu { +.section-menu, +.backend-menu { position: fixed; top: 0; left: 0; @@ -1124,4 +1269,15 @@ onBeforeUnmount(() => { width: 14px; } +/* Backend switcher menu rows: mono engine name + muted preset URL. */ +.backend-menu-name { + font-family: var(--mono); + font-weight: 500; +} +.backend-menu-url { + margin-left: 8px; + font-family: var(--mono); + color: var(--color-text-muted); +} + diff --git a/apps/kimi-web/src/components/settings/SettingsDialog.vue b/apps/kimi-web/src/components/settings/SettingsDialog.vue index 8f3493a6f..181e4197e 100644 --- a/apps/kimi-web/src/components/settings/SettingsDialog.vue +++ b/apps/kimi-web/src/components/settings/SettingsDialog.vue @@ -48,6 +48,8 @@ const props = defineProps<{ configSaving?: boolean; /** Server version reported by GET /api/v1/meta. */ serverVersion?: string; + /** Backend engine generation from GET /api/v1/meta ('v1' legacy, 'v2' kap-server). */ + backend?: 'v1' | 'v2'; }>(); const emit = defineEmits<{ @@ -80,6 +82,9 @@ const tabs: { id: SettingsTab; labelKey: string }[] = [ ]; const daemonEndpoint = serverEndpointLabel(); +const backendLabel = computed(() => + props.backend === 'v2' ? 'v2 (kap-server)' : 'v1 (server)', +); const permissionModes = ['manual', 'auto', 'yolo'] as const; // Reuse the Composer's permission labels (status.permission*) so the // default-permission names stay in sync with the toolbar. @@ -559,6 +564,10 @@ function archiveTime(iso: string): string { {{ t('sidebar.daemon') }} {{ daemonEndpoint }} +
+ {{ t('settings.backend') }} + {{ backendLabel }} +
{{ t('settings.serverVersion') }} {{ serverVersion || '-' }} diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 108fadd5d..078192aa1 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -677,6 +677,23 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta rawState.sessionsHasMoreByWorkspace = cleared; } + /** + * Re-read GET /meta and apply the server-self fields (version, open-in + * apps, auth bypass, backend engine). Called on first load and on every WS + * (re)connect — the latter keeps the values truthful across backend + * restarts and dev-proxy backend switches. + */ + async function refreshServerMeta(): Promise { + const m = await getKimiWebApi() + .getMeta() + .catch(() => null); + if (m === null) return; + rawState.serverVersion = m.serverVersion; + rawState.availableOpenInApps = m.openInApps; + rawState.dangerousBypassAuth = m.dangerousBypassAuth; + rawState.backend = m.backend; + } + async function load(): Promise { rawState.loading = true; // The very first load gates on /auth before anything else: a transient @@ -696,11 +713,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta // Parallel: health + meta + models await Promise.all([ api.getHealth().catch(() => null), - api.getMeta().then((m) => { - rawState.serverVersion = m.serverVersion; - rawState.availableOpenInApps = m.openInApps; - rawState.dangerousBypassAuth = m.dangerousBypassAuth; - }).catch(() => null), + refreshServerMeta(), modelProvider.loadModels(), ]); @@ -2383,6 +2396,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta updateConfig, listAllSessionsGlobal, load, + refreshServerMeta, loadWorkspaces, loadMoreSessions, loadAllSessions, diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 197000389..03fb9d4f6 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -292,6 +292,13 @@ export interface ExtendedState extends KimiClientState { * prompt and connects without a credential. */ dangerousBypassAuth: boolean; + /** + * Engine generation of the connected server: `'v2'` = kap-server / + * agent-core-v2, `'v1'` = legacy @moonshot-ai/server. Read from `/meta` + * (`backend` field; older servers omit it ⇒ v1). Drives the dev-mode + * backend badge in the Sidebar. + */ + backend: 'v1' | 'v2'; workspaceName: string; connection: ConnectionState; permission: PermissionMode; @@ -364,6 +371,7 @@ const rawState: ExtendedState = reactive({ connected: false, serverVersion: '', dangerousBypassAuth: false, + backend: 'v1', workspaceName: 'kimi-web', connection: 'disconnected' as ConnectionState, permission: loadPermissionFromStorage(), @@ -965,7 +973,13 @@ function connectEventsIfNeeded(): void { // auto-dismiss timer: iOS Safari freezes timers while a tab is // backgrounded, so the toast would otherwise linger until a manual // refresh even though the reconnect already succeeded. - if (connected) dismissWsError(); + if (connected) { + dismissWsError(); + // A (re)connect can mean the backend was restarted — or switched, when + // the dev proxy was moved to the other engine. Re-read /meta so + // serverVersion / backend never go stale. + void workspaceState.refreshServerMeta(); + } }, }); } @@ -1842,6 +1856,7 @@ const loadMoreMessagesError = computed(() => { return sid ? rawState.messagesLoadMoreErrorBySession[sid] ?? false : false; }); const serverVersion = computed(() => rawState.serverVersion); +const backend = computed<'v1' | 'v2'>(() => rawState.backend); const dangerousBypassAuth = computed(() => rawState.dangerousBypassAuth); /** @@ -2563,6 +2578,7 @@ export function useKimiWebClient() { hasMoreMessages, loadMoreMessagesError, serverVersion, + backend, dangerousBypassAuth, clearDangerousBypassAuth, initialized, diff --git a/apps/kimi-web/src/env.d.ts b/apps/kimi-web/src/env.d.ts index c2089e474..f755830f6 100644 --- a/apps/kimi-web/src/env.d.ts +++ b/apps/kimi-web/src/env.d.ts @@ -5,6 +5,12 @@ // In production builds this is still defined but unused (same-origin daemon). declare const __KIMI_DEV_PROXY_TARGET__: string; +// Injected by Vite `define` (see vite.config.ts): the named dev-proxy backend +// presets (v1 = legacy server, v2 = kap-server) for the Sidebar switcher menu. +// The live target comes from GET /__kimi-dev/backend; this is the synchronous +// initial value. Unused by the same-origin production build. +declare const __KIMI_DEV_BACKENDS__: { v1: string; v2: string }; + // Injected by Vite `define` from apps/kimi-web/package.json. declare const __KIMI_WEB_VERSION__: string; diff --git a/apps/kimi-web/src/i18n/locales/en/settings.ts b/apps/kimi-web/src/i18n/locales/en/settings.ts index c8aa5a56f..733cb83e0 100644 --- a/apps/kimi-web/src/i18n/locales/en/settings.ts +++ b/apps/kimi-web/src/i18n/locales/en/settings.ts @@ -48,6 +48,7 @@ export default { advanced: 'Advanced', build: 'Build', serverVersion: 'Server version', + backend: 'Backend', exportLog: 'Troubleshooting log', logHint: 'Enable with ?debug=1 to capture', exportLogBtn: 'Export log', diff --git a/apps/kimi-web/src/i18n/locales/en/sidebar.ts b/apps/kimi-web/src/i18n/locales/en/sidebar.ts index c33e42d01..e87c42cd5 100644 --- a/apps/kimi-web/src/i18n/locales/en/sidebar.ts +++ b/apps/kimi-web/src/i18n/locales/en/sidebar.ts @@ -29,6 +29,7 @@ export default { signIn: 'Sign in', language: 'Language', daemon: 'Daemon', + backendTitle: 'Backend {backend} · {endpoint} — click to switch', noSessions: 'No conversations yet', showMore: 'Load {count} more conversations', showLess: 'Show less', diff --git a/apps/kimi-web/src/i18n/locales/zh/settings.ts b/apps/kimi-web/src/i18n/locales/zh/settings.ts index d20d6bfa6..3ebdbbe65 100644 --- a/apps/kimi-web/src/i18n/locales/zh/settings.ts +++ b/apps/kimi-web/src/i18n/locales/zh/settings.ts @@ -48,6 +48,7 @@ export default { advanced: '高级', build: '构建', serverVersion: '服务端版本', + backend: '后端', exportLog: '故障排查日志', logHint: '加 ?debug=1 开启采集', exportLogBtn: '导出日志', diff --git a/apps/kimi-web/src/i18n/locales/zh/sidebar.ts b/apps/kimi-web/src/i18n/locales/zh/sidebar.ts index f252dbb03..dd07bc763 100644 --- a/apps/kimi-web/src/i18n/locales/zh/sidebar.ts +++ b/apps/kimi-web/src/i18n/locales/zh/sidebar.ts @@ -29,6 +29,7 @@ export default { signIn: '登录', language: '语言', daemon: '后台', + backendTitle: '后端 {backend} · {endpoint} — 点击切换', noSessions: '暂无对话', showMore: '加载更多 {count} 个对话', showLess: '收起', diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index 4b97fd7b8..53f41bbf2 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -66,6 +66,8 @@ function createState(): ExtendedState { activeSessionId: 'sess_1', connected: true, serverVersion: '', + dangerousBypassAuth: false, + backend: 'v1', workspaceName: 'kimi-web', connection: 'connected', permission: 'manual', @@ -984,6 +986,7 @@ describe('useWorkspaceState — first-load auth gate', () => { serverVersion: '0.0.0', openInApps: [], dangerousBypassAuth: false, + backend: 'v1', }); apiMock.getConfig.mockReset().mockResolvedValue({}); apiMock.listWorkspaces.mockReset().mockResolvedValue([]); @@ -1087,6 +1090,44 @@ describe('useWorkspaceState — first-load auth gate', () => { ); }); +// /meta re-read on every WS (re)connect — keeps version / backend truthful +// across backend restarts and dev-proxy backend switches. +describe('useWorkspaceState — refreshServerMeta', () => { + beforeEach(() => { + apiMock.getMeta.mockReset(); + }); + + it('applies the meta payload including the v2 backend marker', async () => { + apiMock.getMeta.mockResolvedValue({ + serverVersion: '9.9.9', + openInApps: ['finder'], + dangerousBypassAuth: true, + backend: 'v2', + }); + const state = createState(); + const ws = useWorkspaceState(state, createDeps()); + + await ws.refreshServerMeta(); + + expect(state.serverVersion).toBe('9.9.9'); + expect(state.availableOpenInApps).toEqual(['finder']); + expect(state.dangerousBypassAuth).toBe(true); + expect(state.backend).toBe('v2'); + }); + + it('keeps the previous meta when /meta fails', async () => { + apiMock.getMeta.mockRejectedValue(new Error('connection refused')); + const state = createState(); + state.backend = 'v2'; + const ws = useWorkspaceState(state, createDeps()); + + await ws.refreshServerMeta(); + + expect(state.backend).toBe('v2'); + expect(state.serverVersion).toBe(''); + }); +}); + // Regression coverage for wake/reconnect snapshot recovery. describe('useWorkspaceState — snapshot prompt recovery', () => { function promptDeps(overrides: Partial = {}): UseWorkspaceStateDeps { diff --git a/apps/kimi-web/vite.config.ts b/apps/kimi-web/vite.config.ts index 28e5ecfd6..72084b22d 100644 --- a/apps/kimi-web/vite.config.ts +++ b/apps/kimi-web/vite.config.ts @@ -1,21 +1,126 @@ -import { defineConfig } from 'vite'; +import { defineConfig, type Plugin } from 'vite'; import vue from '@vitejs/plugin-vue'; import Icons from 'unplugin-icons/vite'; import { FileSystemIconLoader } from 'unplugin-icons/loaders'; import { readFileSync } from 'node:fs'; +import type { IncomingMessage, ServerResponse } from 'node:http'; import { fileURLToPath } from 'node:url'; const webPort = Number(process.env.WEB_PORT) || 5175; -// Where the dev proxy forwards server traffic. Defaults to the local server -// (or `pnpm dev:stub`). Override to point dev at another server instance. -const serverTarget = process.env.KIMI_SERVER_URL || 'http://127.0.0.1:58627'; +// Dev-proxy backend presets: v1 is the legacy server (@moonshot-ai/server), +// v2 the kap-server engine. With KIMI_CODE_EXPERIMENTAL_MULTI_SERVER=1 both +// can run side by side — the root scripts `dev:v1` / `dev:v2` pin them to +// 58627 / 58628. Override with KIMI_BACKEND_V1_URL / KIMI_BACKEND_V2_URL. +const backendPresets = { + v1: process.env.KIMI_BACKEND_V1_URL || 'http://127.0.0.1:58627', + v2: process.env.KIMI_BACKEND_V2_URL || 'http://127.0.0.1:58628', +} as const; +type BackendName = keyof typeof backendPresets; +// Where the dev proxy forwards server traffic. Defaults to the v1 preset; +// KIMI_SERVER_URL pins the initial target (and disables nothing — the dev +// switcher can still move it at runtime). +const serverTarget = process.env.KIMI_SERVER_URL || backendPresets.v1; +// Mutable proxy target. Vite copies its proxy-options object per HTTP request +// and reads it directly per WS upgrade, so assigning `target` on the captured +// options repoints the proxy without a dev-server restart (see the plugin). +let currentBackendTarget = serverTarget; +let backendProxyOpts: { target?: unknown } | null = null; const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8')) as { version: string; }; +/** + * Dev-only backend switcher. Two endpoints let the web UI read and move the + * proxy target at runtime (the Sidebar badge menu POSTs here, then reloads): + * GET /__kimi-dev/backend → { current, presets } + * POST /__kimi-dev/backend { name } → switch to presets[name] + * Preview keeps the static proxy below — this only hooks the dev server. + */ +function backendSwitcherPlugin(): Plugin { + const sendJson = (res: ServerResponse, body: unknown): void => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(body)); + }; + const state = (): { current: string; presets: typeof backendPresets } => ({ + current: currentBackendTarget, + presets: backendPresets, + }); + const switchTo = (name: BackendName): void => { + currentBackendTarget = backendPresets[name]; + // Repoint the live proxy. NOTE: vite's vendored http-proxy has no + // `router` support — mutating the captured options object is the switch. + if (backendProxyOpts) backendProxyOpts.target = currentBackendTarget; + }; + return { + name: 'kimi-backend-switcher', + configureServer(server) { + server.middlewares.use((req: IncomingMessage, res: ServerResponse, next: () => void) => { + if (req.url !== '/__kimi-dev/backend') return next(); + if (req.method === 'GET') { + sendJson(res, state()); + return; + } + if (req.method === 'POST') { + let raw = ''; + req.on('data', (chunk: Buffer) => (raw += chunk)); + req.on('end', () => { + let name: unknown; + try { + name = (JSON.parse(raw) as { name?: unknown }).name; + } catch { + name = undefined; + } + if (name !== 'v1' && name !== 'v2') { + res.statusCode = 400; + sendJson(res, { error: 'expected { "name": "v1" | "v2" }' }); + return; + } + switchTo(name as BackendName); + sendJson(res, state()); + }); + return; + } + res.statusCode = 405; + res.end(); + }); + }, + }; +} + +// Shared proxy behavior for dev AND preview. `configure` does two things: +// 1. captures vite's live proxy-options object so the dev backend switcher +// can repoint `target` at runtime (vite's vendored http-proxy ignores +// `router`; a fresh copy of this object is consulted per HTTP request, +// and the object itself per WS upgrade); +// 2. strips the browser `Origin` header on the forwarded request. The proxy +// rewrites `Host` to the server (changeOrigin) but leaves `Origin` +// pointing at the Vite origin — and the v1 server's WS upgrade path +// rejects any present Origin whose host ≠ Host with 403. An Origin-less +// request is treated as a non-browser client by both engines (and the +// browser never needs CORS here: it talks to its own origin). +const apiProxyOptions = { + target: serverTarget, + changeOrigin: true, + ws: true, + configure: ( + proxy: { + on( + event: string, + listener: (proxyReq: { removeHeader(name: string): void }) => void, + ): unknown; + }, + options: { target?: unknown }, + ) => { + backendProxyOpts = options; + proxy.on('proxyReq', (proxyReq) => proxyReq.removeHeader('origin')); + proxy.on('proxyReqWs', (proxyReq) => proxyReq.removeHeader('origin')); + }, +}; + export default defineConfig({ plugins: [ vue(), + backendSwitcherPlugin(), Icons({ compiler: 'vue3', // Local Kimi Design System icons (24×24 outlined, fill="currentColor"), @@ -32,6 +137,8 @@ export default defineConfig({ // own same-origin URL). Unused by the same-origin production build. define: { __KIMI_DEV_PROXY_TARGET__: JSON.stringify(serverTarget), + // Named backend presets for the Sidebar switcher menu (dev only). + __KIMI_DEV_BACKENDS__: JSON.stringify(backendPresets), __KIMI_WEB_VERSION__: JSON.stringify(pkg.version), // True only for the web bundle embedded in the Kimi Desktop app (set by the // desktop-build workflow). Gates an "internal testing build" banner. When @@ -44,16 +151,17 @@ export default defineConfig({ // Same-origin dev: the browser calls Vite, Vite forwards to the server. // No CORS anywhere. The real server serves REST + WS all under /api/v1. proxy: { - '/api/v1': { target: serverTarget, changeOrigin: true, ws: true }, + '/api/v1': apiProxyOptions, }, }, // `vite preview` (the production build served locally) needs the same proxy — // bugs that only exist in production chunking (e.g. optional-peer-dep stubs) // can't be reproduced without running the built app against a server. + // Preview intentionally stays on the static target: no runtime switcher. preview: { port: Number(process.env.WEB_PREVIEW_PORT) || 4175, proxy: { - '/api/v1': { target: serverTarget, changeOrigin: true, ws: true }, + '/api/v1': apiProxyOptions, }, }, build: { diff --git a/package.json b/package.json index 965f1ca8d..09065b9ab 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "dev:desktop": "pnpm -C apps/kimi-desktop run dev", "dev:server": "pnpm -C apps/kimi-code run dev:server", "dev:kap-server": "pnpm -C apps/kimi-code run dev:kap-server", + "dev:v1": "pnpm -C apps/kimi-code run dev:server", + "dev:v2": "pnpm -C apps/kimi-code run dev:kap-server:multi", "build:plugin-marketplace": "pnpm -C apps/kimi-code run build:plugin-marketplace", "vis": "pnpm -C apps/vis run dev", "dev:docs": "pnpm -C docs install --ignore-workspace && pnpm -C docs run dev", From 2185237c2f5c5fb3cc6b44c01ac158c6e2b81fe6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 13 Jul 2026 16:16:02 +0800 Subject: [PATCH 49/80] refactor(v2): bind op definitions to models and require zod payload schemas (#1593) --- .changeset/v2-wire-op-schemas.md | 5 + .../agent-core-v2/src/activity/activityOps.ts | 22 ++- .../src/agent/contextMemory/contextOps.ts | 112 ++++++------ .../src/agent/contextSize/contextSizeOps.ts | 15 +- .../agent/contextSize/contextSizeService.ts | 6 +- .../src/agent/fullCompaction/compactionOps.ts | 39 +++-- .../fullCompaction/fullCompactionService.ts | 14 -- .../agent-core-v2/src/agent/goal/goalOps.ts | 63 ++++--- .../src/agent/goal/goalService.ts | 32 +--- .../src/agent/llmRequester/llmRequestOps.ts | 93 +++++----- .../agent/llmRequester/llmRequesterService.ts | 6 +- .../agent-core-v2/src/agent/loop/turnOps.ts | 72 ++++---- .../src/agent/mcp/mcpDiscoveryOps.ts | 39 +++-- .../agent/permissionMode/permissionModeOps.ts | 14 +- .../permissionRules/permissionRulesOps.ts | 24 ++- .../agent-core-v2/src/agent/plan/planOps.ts | 31 ++-- .../src/agent/profile/profileOps.ts | 41 +++-- .../src/agent/profile/profileService.ts | 16 +- .../replayBuilder/replayTimelineModel.ts | 73 ++++---- .../src/agent/runtime/runtimeOps.ts | 22 ++- .../agent-core-v2/src/agent/skill/skillOps.ts | 14 +- .../agent-core-v2/src/agent/swarm/swarmOps.ts | 18 +- .../agent-core-v2/src/agent/task/taskOps.ts | 22 ++- .../src/agent/task/taskService.ts | 27 +-- .../agent-core-v2/src/agent/usage/usageOps.ts | 20 ++- .../src/agent/userTool/userToolOps.ts | 50 +++--- .../src/agent/wireRecord/metadataOps.ts | 15 +- .../src/agent/wireRecord/wireRecord.ts | 64 +------ .../src/agent/wireRecord/wireRecordService.ts | 165 +----------------- .../agentLifecycle/agentLifecycleService.ts | 5 +- .../agent-core-v2/src/session/cron/cronOps.ts | 34 ++-- .../session/cron/sessionCronServiceImpl.ts | 19 +- .../src/session/todo/sessionTodoService.ts | 9 - .../agent-core-v2/src/session/todo/todoOps.ts | 16 +- packages/agent-core-v2/src/wire/model.ts | 32 ++-- packages/agent-core-v2/src/wire/op.ts | 126 ++++++++++--- packages/agent-core-v2/src/wire/types.ts | 47 +++++ .../agent-core-v2/src/wire/wireService.ts | 2 +- .../agent-core-v2/src/wire/wireServiceImpl.ts | 5 +- .../test/agent/contextMemory/stubs.ts | 15 +- .../test/agent/goal/goal.test.ts | 6 +- .../app/sessionExport/sessionExport.test.ts | 7 - .../lint/fixtures/duplicate-ops.fixture.ts | 7 +- .../test/lint/op-uniqueness.test.ts | 131 +++++++++++++- .../test/wire/store-event.test.ts | 22 ++- .../test/wire/wire-compat.test.ts | 12 +- .../test/wire/wireServiceImpl.test.ts | 18 +- 47 files changed, 871 insertions(+), 776 deletions(-) create mode 100644 .changeset/v2-wire-op-schemas.md create mode 100644 packages/agent-core-v2/src/wire/types.ts diff --git a/.changeset/v2-wire-op-schemas.md b/.changeset/v2-wire-op-schemas.md new file mode 100644 index 000000000..32e79ab97 --- /dev/null +++ b/.changeset/v2-wire-op-schemas.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Declare v2 engine wire op payloads with required zod schemas and derive their types from the schemas, with ops declared on their models and every op type registered for replay classification. diff --git a/packages/agent-core-v2/src/activity/activityOps.ts b/packages/agent-core-v2/src/activity/activityOps.ts index 3eb95a432..f7c7dc43c 100644 --- a/packages/agent-core-v2/src/activity/activityOps.ts +++ b/packages/agent-core-v2/src/activity/activityOps.ts @@ -14,8 +14,9 @@ * Consumed by the Agent-scope `agentActivityService` and (PR5) the projector. */ +import { z } from 'zod'; + import { defineModel } from '#/wire/model'; -import { defineOp } from '#/wire/op'; import type { PromptOrigin } from '#/agent/contextMemory/types'; import type { AgentLane, BackgroundActivityRef, SessionLane } from './activity'; @@ -46,10 +47,17 @@ export const LaneModel = defineModel('activityLane', () => ({ background: [], })); -export const setLane = defineOp(LaneModel, 'activity.set_lane', { +declare module '#/wire/types' { + interface TransientOpMap { + 'activity.set_lane': typeof setLane; + 'activity.set_session_lane': typeof setSessionLane; + } +} + +export const setLane = LaneModel.defineOp('activity.set_lane', { + schema: z.object({ next: z.custom() }), persist: false, - apply: (s, p: { next: LaneModelState }): LaneModelState => - laneEqual(s, p.next) ? s : p.next, + apply: (s, p) => (laneEqual(s, p.next) ? s : p.next), }); export function laneEqual(a: LaneModelState, b: LaneModelState): boolean { @@ -84,8 +92,8 @@ export const SessionLaneModel = defineModel('sessionActiv activeLeases: 0, })); -export const setSessionLane = defineOp(SessionLaneModel, 'activity.set_session_lane', { +export const setSessionLane = SessionLaneModel.defineOp('activity.set_session_lane', { + schema: z.object({ next: z.custom() }), persist: false, - apply: (s, p: { next: SessionLaneModelState }): SessionLaneModelState => - s.lane === p.next.lane && s.activeLeases === p.next.activeLeases ? s : p.next, + apply: (s, p) => (s.lane === p.next.lane && s.activeLeases === p.next.activeLeases ? s : p.next), }); diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts index d2a5d5ff9..e8ef66ff8 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts @@ -32,9 +32,10 @@ * data that was compacted away during the session. */ +import { z } from 'zod'; + import type { ContentPart } from '#/app/llmProtocol/message'; import { defineModel, type PartsTransformer } from '#/wire/model'; -import { defineOp } from '#/wire/op'; import type { PersistedRecord } from '#/wire/wireService'; import { @@ -120,63 +121,71 @@ function popSwarmModeReminder(state: ContextMessage[], _payload: unknown): Conte return resetFold(state.slice(0, -1)) as ContextMessage[]; } -export interface ContextMessagePayload { - readonly message: ContextMessage; +declare module '#/wire/types' { + interface PersistedOpMap { + 'context.append_message': typeof contextAppendMessage; + 'context.append_loop_event': typeof contextAppendLoopEvent; + 'context.clear': typeof contextClear; + 'context.apply_compaction': typeof contextApplyCompaction; + 'context.undo': typeof contextUndo; + } } -export const contextAppendMessage = defineOp(ContextModel, 'context.append_message', { - apply: (state, p: ContextMessagePayload): ContextMessage[] => - foldAppendMessage(state, p.message) as ContextMessage[], +// `ContextMessage` / `LoopRecordedEvent` are large domain unions owned by +// sibling modules; `z.custom` keeps their exact types without restating them. +const contextMessageSchema = z.custom(); +const loopRecordedEventSchema = z.custom(); + +export const contextAppendMessage = ContextModel.defineOp('context.append_message', { + schema: z.object({ message: contextMessageSchema }), + apply: (state, p) => foldAppendMessage(state, p.message) as ContextMessage[], }); -export interface ContextLoopEventPayload { - readonly event: LoopRecordedEvent; -} - -export const contextAppendLoopEvent = defineOp(ContextModel, 'context.append_loop_event', { - apply: (state, p: ContextLoopEventPayload): ContextMessage[] => - foldLoopEvent(state, p.event) as ContextMessage[], +export const contextAppendLoopEvent = ContextModel.defineOp('context.append_loop_event', { + schema: z.object({ event: loopRecordedEventSchema }), + apply: (state, p) => foldLoopEvent(state, p.event) as ContextMessage[], }); -export const contextClear = defineOp(ContextModel, 'context.clear', { - apply: (state): ContextMessage[] => - state.length === 0 ? state : (resetFold([]) as ContextMessage[]), +export const contextClear = ContextModel.defineOp('context.clear', { + schema: z.object({}), + apply: (state) => (state.length === 0 ? state : (resetFold([]) as ContextMessage[])), }); -interface ContextCompactionBasePayload { - readonly tokensBefore?: number; - readonly tokensAfter?: number; - readonly keptUserMessageCount?: number; - readonly keptHeadUserMessageCount?: number; - readonly droppedCount?: number; - readonly legacyTail?: boolean; -} +const contextCompactionBaseShape = { + tokensBefore: z.number().optional(), + tokensAfter: z.number().optional(), + keptUserMessageCount: z.number().optional(), + keptHeadUserMessageCount: z.number().optional(), + droppedCount: z.number().optional(), + legacyTail: z.boolean().optional(), +}; -export interface TextSummaryCompactionPayload extends ContextCompactionBasePayload { - readonly summary: string; - readonly compactedCount: number; - readonly contextSummary?: string; -} +const contextApplyCompactionSchema = z.union([ + z.object({ + ...contextCompactionBaseShape, + summary: z.string(), + compactedCount: z.number(), + contextSummary: z.string().optional(), + }), + z.object({ + ...contextCompactionBaseShape, + contextSummary: z.string(), + compactedCount: z.number(), + summary: z.string().optional(), + }), + z.object({ + ...contextCompactionBaseShape, + summary: contextMessageSchema, + count: z.number(), + compactedCount: z.number().optional(), + }), +]); -interface ContextSummaryCompactionPayload extends ContextCompactionBasePayload { - readonly contextSummary: string; - readonly compactedCount: number; - readonly summary?: string; -} +type ContextCompactionPayload = z.infer; -interface LegacyMessageSummaryCompactionPayload extends ContextCompactionBasePayload { - readonly summary: ContextMessage; - readonly count: number; - readonly compactedCount?: number; -} - -export type ContextCompactionPayload = - | TextSummaryCompactionPayload - | ContextSummaryCompactionPayload - | LegacyMessageSummaryCompactionPayload; - -export const contextApplyCompaction = defineOp(ContextModel, 'context.apply_compaction', { - apply: (state, p: ContextCompactionPayload): ContextMessage[] => { +export const contextApplyCompaction = ContextModel.defineOp('context.apply_compaction', { + schema: contextApplyCompactionSchema, + apply: (state, p) => { const result = buildContextCompactionShape(state, readContextCompactionShapeInput(p)); return resetFold([...result.messages]) as ContextMessage[]; }, @@ -279,10 +288,6 @@ function isContextMessage(value: unknown): value is ContextMessage { return typeof message.role === 'string' && Array.isArray(message.content); } -export interface ContextUndoPayload { - readonly count: number; -} - export interface UndoCut { readonly cutIndex: number; readonly removedCount: number; @@ -370,8 +375,9 @@ export function formatUndoUnavailableMessage( } } -export const contextUndo = defineOp(ContextModel, 'context.undo', { - apply: (state, p: ContextUndoPayload): ContextMessage[] => { +export const contextUndo = ContextModel.defineOp('context.undo', { + schema: z.object({ count: z.number() }), + apply: (state, p) => { if (p.count <= 0 || state.length === 0) return state; const cut = computeUndoCut(state, p.count); if (!isFullyUndoable(cut, p.count)) return state; diff --git a/packages/agent-core-v2/src/agent/contextSize/contextSizeOps.ts b/packages/agent-core-v2/src/agent/contextSize/contextSizeOps.ts index 629604e67..71a0eb0b0 100644 --- a/packages/agent-core-v2/src/agent/contextSize/contextSizeOps.ts +++ b/packages/agent-core-v2/src/agent/contextSize/contextSizeOps.ts @@ -22,8 +22,9 @@ * `contextSizeService`. */ +import { z } from 'zod'; + import { defineModel } from '#/wire/model'; -import { defineOp } from '#/wire/op'; export interface ContextSizeState { readonly length: number; @@ -35,14 +36,16 @@ export const ContextSizeModel = defineModel('contextSize', () tokens: 0, })); -export interface ContextSizeMeasuredPayload { - readonly length: number; - readonly tokens: number; +declare module '#/wire/types' { + interface TransientOpMap { + 'context_size.measured': typeof contextSizeMeasured; + } } -export const contextSizeMeasured = defineOp(ContextSizeModel, 'context_size.measured', { +export const contextSizeMeasured = ContextSizeModel.defineOp('context_size.measured', { + schema: z.object({ length: z.number(), tokens: z.number() }), persist: false, - apply: (s, p: ContextSizeMeasuredPayload): ContextSizeState => { + apply: (s, p) => { const length = normalizeMeasuredLength(p.length); const tokens = Math.max(0, p.tokens); if (s.length === length && s.tokens === tokens) return s; diff --git a/packages/agent-core-v2/src/agent/contextSize/contextSizeService.ts b/packages/agent-core-v2/src/agent/contextSize/contextSizeService.ts index b7ef86f0d..ad87806d1 100644 --- a/packages/agent-core-v2/src/agent/contextSize/contextSizeService.ts +++ b/packages/agent-core-v2/src/agent/contextSize/contextSizeService.ts @@ -4,9 +4,9 @@ * Owns the last measured context token count in the wire `ContextSizeModel` * (`{ length, tokens }`): reads it through `wire.getModel`, writes it through * `wire.dispatch(contextSizeMeasured(...))` (called by `llmRequester` after each - * measured exchange), and emits the `contextTokens` slice of - * `agent.status.updated` live through `wire.signal` when the measured value - * changes. `get(start?, end?)` returns `{ size, measured, estimated }` for the + * measured exchange), and derives the `contextTokens` slice of + * `agent.status.updated` from the Op's `toEvent` (published to `IEventBus` on + * dispatch) when the measured value changes. `get(start?, end?)` returns `{ size, measured, estimated }` for the * context-message range `[start, end)`, resolved like `Array.prototype.slice` * (defaulting to the whole context; negative indices count back from the end; * an inverted range is empty): `measured` diff --git a/packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts b/packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts index a4c7ad1af..9de43c597 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts @@ -27,17 +27,17 @@ * `wire.onRestored` handler (mirroring `goal`'s post-replay normalization). * * The `compaction.*` events publish to `IEventBus` (`compaction.started` via the - * `begin` Op's `toEvent`; the rest directly) and also emit live through - * `wire.signal` (legacy channel, until Phase 3); they are declared here via - * interface-merge (`error` is already declared by `mcp`, so it is not - * re-declared). The `full_compaction.*` record shapes stay declared in - * `WireRecordMap` (see `fullCompactionService.ts`) because the records still + * `begin` Op's `toEvent`; the rest directly from the service); they are + * declared here via interface-merge (`error` is already declared by `mcp`, so + * it is not re-declared). The `full_compaction.*` record shapes are registered in + * `PersistedOpMap` (`#/wire/types`, below) because the records still * ride the per-agent `wire.jsonl` log read by `wireRecord.restore()` / * `getRecords()`. Consumed by the Agent-scope `fullCompactionService`. */ +import { z } from 'zod'; + import { defineModel } from '#/wire/model'; -import { defineOp } from '#/wire/op'; import type { CompactionBlockedEvent, CompactionCancelledEvent, @@ -66,11 +66,17 @@ declare module '#/app/event/eventBus' { } } -export type FullCompactionBeginPayload = CompactionBeginData; +declare module '#/wire/types' { + interface PersistedOpMap { + 'full_compaction.begin': typeof fullCompactionBegin; + 'full_compaction.cancel': typeof fullCompactionCancel; + 'full_compaction.complete': typeof fullCompactionComplete; + } +} -export const fullCompactionBegin = defineOp(CompactionModel, 'full_compaction.begin', { - apply: (s, _p: FullCompactionBeginPayload): CompactionState => - s.phase === 'running' ? s : { phase: 'running' }, +export const fullCompactionBegin = CompactionModel.defineOp('full_compaction.begin', { + schema: z.custom(), + apply: (s) => (s.phase === 'running' ? s : { phase: 'running' }), toEvent: (p) => ({ type: 'compaction.started' as const, trigger: p.source, @@ -78,13 +84,12 @@ export const fullCompactionBegin = defineOp(CompactionModel, 'full_compaction.be }), }); -export const fullCompactionCancel = defineOp(CompactionModel, 'full_compaction.cancel', { - apply: (s): CompactionState => (s.phase === 'idle' ? s : { phase: 'idle' }), +export const fullCompactionCancel = CompactionModel.defineOp('full_compaction.cancel', { + schema: z.object({}), + apply: (s) => (s.phase === 'idle' ? s : { phase: 'idle' }), }); -export type FullCompactionCompletePayload = Record; - -export const fullCompactionComplete = defineOp(CompactionModel, 'full_compaction.complete', { - apply: (s, _p: FullCompactionCompletePayload): CompactionState => - s.phase === 'idle' ? s : { phase: 'idle' }, +export const fullCompactionComplete = CompactionModel.defineOp('full_compaction.complete', { + schema: z.object({}), + apply: (s) => (s.phase === 'idle' ? s : { phase: 'idle' }), }); diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 0ae77f7c1..d1b55d83a 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -56,7 +56,6 @@ import { fullCompactionBegin, fullCompactionCancel, fullCompactionComplete, - type FullCompactionCompletePayload, } from './compactionOps'; import { type CompactionBeginData, @@ -65,19 +64,6 @@ import { import { Emitter, type Event } from '#/_base/event'; import { OrderedHookSlot } from '#/hooks'; -// The `full_compaction.*` record shapes stay declared in `WireRecordMap` -// because the records still ride the per-agent `wire.jsonl` log read by -// `wireRecord.restore()` / `getRecords()`. fullCompaction itself no longer -// registers resumers here — its state rebuilds from the same log via -// `wire.replay` into `CompactionModel`. -declare module '#/agent/wireRecord/wireRecord' { - interface WireRecordMap { - 'full_compaction.begin': CompactionBeginData; - 'full_compaction.cancel': {}; - 'full_compaction.complete': FullCompactionCompletePayload; - } -} - export const MAX_COMPACTION_RETRY_ATTEMPTS = 5; const DEFAULT_COMPACTION_MAX_COMPLETION_TOKENS = 128 * 1024; const OVERFLOW_CONTEXT_SAFETY_RATIO = 0.85; diff --git a/packages/agent-core-v2/src/agent/goal/goalOps.ts b/packages/agent-core-v2/src/agent/goal/goalOps.ts index 65ccec79a..3fbf3f286 100644 --- a/packages/agent-core-v2/src/agent/goal/goalOps.ts +++ b/packages/agent-core-v2/src/agent/goal/goalOps.ts @@ -12,15 +12,17 @@ * when leaving `active` and carried in the `goal.update` payload; and * `wallClockResumedAt` is a live-only service field (never persisted, reset on * replay). Each `apply` returns the same reference when nothing changes so the - * wire's reference-equality gate stays quiet. The `goal.updated` signal is - * emitted live through `wire.signal` (declared here via interface-merge); - * `wire.replay` rebuilds the Model silently and the service's `wire.onRestored` + * wire's reference-equality gate stays quiet. The `goal.updated` fact is + * published live to `IEventBus` by the service (declared here via + * interface-merge); `wire.replay` rebuilds the Model silently and the + * service's `wire.onRestored` * forces a replayed `active` goal back to `paused`. Consumed by the Agent-scope * `goalService`. */ +import { z } from 'zod'; + import { defineModel } from '#/wire/model'; -import { defineOp } from '#/wire/op'; import type { GoalActor, @@ -55,14 +57,22 @@ declare module '#/app/event/eventBus' { } } -export interface GoalCreatePayload { - readonly goalId: string; - readonly objective: string; - readonly completionCriterion?: string; +declare module '#/wire/types' { + interface PersistedOpMap { + 'goal.create': typeof createGoal; + 'goal.update': typeof updateGoal; + 'goal.clear': typeof clearGoal; + forked: typeof forkGoal; + } } -export const createGoal = defineOp(GoalModel, 'goal.create', { - apply: (_s, p: GoalCreatePayload): GoalModelState => ({ +export const createGoal = GoalModel.defineOp('goal.create', { + schema: z.object({ + goalId: z.string(), + objective: z.string(), + completionCriterion: z.string().optional(), + }), + apply: (_s, p) => ({ goalId: p.goalId, objective: p.objective, completionCriterion: p.completionCriterion, @@ -74,18 +84,17 @@ export const createGoal = defineOp(GoalModel, 'goal.create', { }), }); -export interface GoalUpdatePayload { - readonly status?: GoalStatus; - readonly reason?: string; - readonly turnsUsed?: number; - readonly tokensUsed?: number; - readonly wallClockMs?: number; - readonly budgetLimits?: GoalBudgetLimits; - readonly actor?: GoalActor; -} - -export const updateGoal = defineOp(GoalModel, 'goal.update', { - apply: (s, p: GoalUpdatePayload): GoalModelState => { +export const updateGoal = GoalModel.defineOp('goal.update', { + schema: z.object({ + status: z.custom().optional(), + reason: z.string().optional(), + turnsUsed: z.number().optional(), + tokensUsed: z.number().optional(), + wallClockMs: z.number().optional(), + budgetLimits: z.custom().optional(), + actor: z.custom().optional(), + }), + apply: (s, p) => { if (s === null) return null; let next: GoalState | undefined; if (p.status !== undefined && p.status !== s.status) { @@ -111,10 +120,12 @@ export const updateGoal = defineOp(GoalModel, 'goal.update', { }, }); -export const clearGoal = defineOp(GoalModel, 'goal.clear', { - apply: (): GoalModelState => null, +export const clearGoal = GoalModel.defineOp('goal.clear', { + schema: z.object({}), + apply: () => null, }); -export const forkGoal = defineOp(GoalModel, 'forked', { - apply: (): GoalModelState => null, +export const forkGoal = GoalModel.defineOp('forked', { + schema: z.object({}), + apply: () => null, }); diff --git a/packages/agent-core-v2/src/agent/goal/goalService.ts b/packages/agent-core-v2/src/agent/goal/goalService.ts index b01aeeb55..d62040dc7 100644 --- a/packages/agent-core-v2/src/agent/goal/goalService.ts +++ b/packages/agent-core-v2/src/agent/goal/goalService.ts @@ -3,15 +3,16 @@ * * Owns the per-agent goal lifecycle; persists the goal in the `wire` * `GoalModel` (`GoalState | null`) through the `goal.create` / `goal.update` / - * `goal.clear` Ops (`wire.dispatch`), reads it through `wire.getModel`, emits - * `goal.updated` live through `wire.signal`, and forces a replayed `active` + * `goal.clear` Ops (`wire.dispatch`), reads it through `wire.getModel`, + * publishes `goal.updated` live to `IEventBus`, and forces a replayed `active` * goal back to `paused` via `wire.onRestored`. The accumulated `wallClockMs` * lives in the Model (set from each Op payload, never by `Date.now()` inside * `apply`); the `wallClockResumedAt` cursor is a live-only field, reset on * replay and (re)started on the live path. A `forked` wire Op clears the Model - * at a fork boundary; the `goal.*` record shapes stay declared in - * `WireRecordMap` because they still ride the shared wire log read by - * `getRecords()` and replayed into the Model. Injects reminders through + * at a fork boundary; the `goal.*` payload shapes are registered in + * `PersistedOpMap` (`#/wire/types`) inside `goalOps` because they still ride + * the shared wire log read by `getRecords()` and replayed into the Model. + * Injects reminders through * `contextInjector`, drives continuation turns by enqueueing `newTurn` * `StepRequest`s onto `loop` (the continuation message materializes when the * loop pops it), accounts live @@ -69,27 +70,6 @@ import type { GoalToolResult, } from './types'; -declare module '#/agent/wireRecord/wireRecord' { - interface WireRecordMap { - forked: {}; - 'goal.create': { - goalId: string; - objective: string; - completionCriterion?: string; - }; - 'goal.update': { - status?: GoalStatus; - reason?: string; - turnsUsed?: number; - tokensUsed?: number; - wallClockMs?: number; - budgetLimits?: GoalBudgetLimits; - actor?: GoalActor; - }; - 'goal.clear': {}; - } -} - const MAX_GOAL_OBJECTIVE_LENGTH = 4000; // The criterion is repeated in every goal reminder, so it is truncated instead diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts index ac7be0bf7..9634594bc 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts @@ -6,9 +6,10 @@ * the Agent-scope `llmRequester` implementation. */ +import { z } from 'zod'; + import type { ThinkingEffort } from '#/app/llmProtocol/thinkingEffort'; import { defineModel } from '#/wire/model'; -import { defineOp } from '#/wire/op'; export interface LlmRequestToolSchema { readonly name: string; @@ -25,52 +26,52 @@ export const LlmRequestTraceModel = defineModel( () => ({ seenToolsHashes: [] }), ); -export interface LlmToolsSnapshotPayload { - readonly hash: string; - readonly tools: readonly LlmRequestToolSchema[]; -} - -export const llmToolsSnapshot = defineOp( - LlmRequestTraceModel, - 'llm.tools_snapshot', - { - apply: (s, p: LlmToolsSnapshotPayload): LlmRequestTraceState => { - if (s.seenToolsHashes.includes(p.hash)) return s; - return { seenToolsHashes: [...s.seenToolsHashes, p.hash] }; - }, - }, -); - -export interface LlmRequestPayload { - readonly kind: 'loop' | 'compaction'; - readonly provider: string; - readonly model: string; - readonly modelAlias?: string; - readonly thinkingEffort?: ThinkingEffort; - readonly thinkingKeep?: string; - readonly temperature?: number; - readonly topP?: number; - readonly maxTokens?: number; - readonly betaApi?: boolean; - /** Progressive tool disclosure in effect (env flag × model capability). */ - readonly toolSelect: boolean; - readonly systemPromptHash: string; - readonly systemPrompt?: string; - readonly toolsHash: string; - readonly messageCount: number; - readonly turnStep?: string; - readonly attempt?: string; - readonly projection?: 'strict'; - readonly droppedCount?: number; -} - -export const llmRequest = defineOp(LlmRequestTraceModel, 'llm.request', { - apply: (s, _p: LlmRequestPayload): LlmRequestTraceState => s, +const llmToolEntrySchema = z.object({ + name: z.string(), + description: z.string(), + parameters: z.record(z.string(), z.unknown()), }); -declare module '#/agent/wireRecord/wireRecord' { - interface WireRecordMap { - 'llm.tools_snapshot': LlmToolsSnapshotPayload; - 'llm.request': LlmRequestPayload; +declare module '#/wire/types' { + interface PersistedOpMap { + 'llm.tools_snapshot': typeof llmToolsSnapshot; + 'llm.request': typeof llmRequest; } } + +export const llmToolsSnapshot = LlmRequestTraceModel.defineOp('llm.tools_snapshot', { + schema: z.object({ + hash: z.string(), + tools: z.array(llmToolEntrySchema).readonly(), + }), + apply: (s, p) => { + if (s.seenToolsHashes.includes(p.hash)) return s; + return { seenToolsHashes: [...s.seenToolsHashes, p.hash] }; + }, +}); + +export const llmRequest = LlmRequestTraceModel.defineOp('llm.request', { + schema: z.object({ + kind: z.enum(['loop', 'compaction']), + provider: z.string(), + model: z.string(), + modelAlias: z.string().optional(), + thinkingEffort: z.custom().optional(), + thinkingKeep: z.string().optional(), + temperature: z.number().optional(), + topP: z.number().optional(), + maxTokens: z.number().optional(), + betaApi: z.boolean().optional(), + /** Progressive tool disclosure in effect (env flag × model capability). */ + toolSelect: z.boolean(), + systemPromptHash: z.string(), + systemPrompt: z.string().optional(), + toolsHash: z.string(), + messageCount: z.number(), + turnStep: z.string().optional(), + attempt: z.string().optional(), + projection: z.literal('strict').optional(), + droppedCount: z.number().optional(), + }), + apply: (s) => s, +}); diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index e6f529f7e..7724c2e5d 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -51,6 +51,7 @@ import type { Protocol } from '#/app/protocol/protocol'; import type { ApiErrorEvent } from '#/app/telemetry/events'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentWireService } from '#/wire/tokens'; +import type { PayloadOf } from '#/wire/types'; import type { IWireService } from '#/wire/wireService'; import { THINKING_SECTION, type ThinkingConfig } from '#/agent/profile/configSection'; import { resolveThinkingKeep } from '#/agent/profile/thinking'; @@ -68,7 +69,6 @@ import { LlmRequestTraceModel, llmRequest, llmToolsSnapshot, - type LlmRequestPayload, type LlmRequestToolSchema, } from './llmRequestOps'; import { isAbortError } from '#/_base/utils/abort'; @@ -394,7 +394,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { const models = this.config.get(MODELS_SECTION); const modelConfig = input.modelAlias === undefined ? undefined : models?.[input.modelAlias]; - const payload: LlmRequestPayload = { + const payload: PayloadOf = { kind: requestKindForRecord(fields), provider: input.protocol, model: input.modelName, @@ -488,7 +488,7 @@ function toolSignature(tools: readonly Tool[]): readonly LlmRequestToolSchema[] return tools.map(({ name, description, parameters }) => ({ name, description, parameters })); } -function requestKindForRecord(fields: LLMRequestLogFields): LlmRequestPayload['kind'] { +function requestKindForRecord(fields: LLMRequestLogFields): PayloadOf['kind'] { if (fields['kind'] === 'compaction') return 'compaction'; if (fields['requestKind'] === 'full_compaction') return 'compaction'; return 'loop'; diff --git a/packages/agent-core-v2/src/agent/loop/turnOps.ts b/packages/agent-core-v2/src/agent/loop/turnOps.ts index 5516f461a..8cad5371a 100644 --- a/packages/agent-core-v2/src/agent/loop/turnOps.ts +++ b/packages/agent-core-v2/src/agent/loop/turnOps.ts @@ -19,8 +19,9 @@ * `activity` kernel (which reads the next turn id on admission). */ +import { z } from 'zod'; + import { defineModel } from '#/wire/model'; -import { defineOp } from '#/wire/op'; import type { ContentPart } from '#/app/llmProtocol/message'; import type { PromptOrigin } from '#/agent/contextMemory/types'; @@ -30,48 +31,43 @@ export interface TurnModelState { export const TurnModel = defineModel('turn', () => ({ nextTurnId: 0 }), { reducers: { - 'context.append_loop_event': (s, p: { event?: { turnId?: unknown } }): TurnModelState => { - const raw = p?.event?.turnId; - if (typeof raw !== 'string' && typeof raw !== 'number') return s; - const turnId = Number.parseInt(String(raw), 10); - if (Number.isInteger(turnId) && turnId >= s.nextTurnId) { - return { nextTurnId: turnId + 1 }; + 'context.append_loop_event': (state, { event }) => { + if (event.type === 'tool.result' || event.turnId === undefined) { + return state; } - return s; + + const turnId = Number.parseInt(event.turnId, 10); + return Number.isInteger(turnId) && turnId >= state.nextTurnId + ? { nextTurnId: turnId + 1 } + : state; }, }, }); -export interface PromptTurnPayload { - readonly input: readonly ContentPart[]; - readonly origin: PromptOrigin; -} +const turnInputShape = { + input: z.custom(), + origin: z.custom(), +}; -export const promptTurn = defineOp(TurnModel, 'turn.prompt', { - apply: (s, _p: PromptTurnPayload): TurnModelState => ({ nextTurnId: s.nextTurnId + 1 }), -}); - -export interface SteerTurnPayload { - readonly input: readonly ContentPart[]; - readonly origin: PromptOrigin; -} - -export const steerTurn = defineOp(TurnModel, 'turn.steer', { - apply: (s, _p: SteerTurnPayload): TurnModelState => s, -}); - -export interface CancelTurnPayload { - readonly turnId?: number; -} - -export const cancelTurn = defineOp(TurnModel, 'turn.cancel', { - apply: (s, _p: CancelTurnPayload): TurnModelState => s, -}); - -declare module '#/agent/wireRecord/wireRecord' { - interface WireRecordMap { - 'turn.prompt': PromptTurnPayload; - 'turn.steer': SteerTurnPayload; - 'turn.cancel': CancelTurnPayload; +declare module '#/wire/types' { + interface PersistedOpMap { + 'turn.prompt': typeof promptTurn; + 'turn.steer': typeof steerTurn; + 'turn.cancel': typeof cancelTurn; } } + +export const promptTurn = TurnModel.defineOp('turn.prompt', { + schema: z.object(turnInputShape), + apply: (s) => ({ nextTurnId: s.nextTurnId + 1 }), +}); + +export const steerTurn = TurnModel.defineOp('turn.steer', { + schema: z.object(turnInputShape), + apply: (s) => s, +}); + +export const cancelTurn = TurnModel.defineOp('turn.cancel', { + schema: z.object({ turnId: z.number().optional() }), + apply: (s) => s, +}); diff --git a/packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts b/packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts index c0cd0ce0a..4ea63a17c 100644 --- a/packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts +++ b/packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts @@ -5,8 +5,9 @@ * keyed by `${serverName}\n${hash}` entries already present in this log. */ +import { z } from 'zod'; + import { defineModel } from '#/wire/model'; -import { defineOp } from '#/wire/op'; import type { MCPToolDefinition } from './types'; export interface McpToolCollision { @@ -25,24 +26,32 @@ export const McpDiscoveryModel = defineModel('mcp.discovery', seen: [], })); -export interface McpToolsDiscoveredPayload { - readonly serverName: string; - readonly hash: string; - readonly tools: readonly MCPToolDefinition[]; - readonly enabledNames: readonly string[]; - readonly collisions?: readonly McpToolCollision[]; +const mcpToolCollisionSchema = z.object({ + qualified: z.string(), + toolName: z.string(), + collidesWith: z.union([ + z.object({ kind: z.literal('same_server'), toolName: z.string() }), + z.object({ kind: z.literal('other_server'), serverName: z.string() }), + ]), +}); + +declare module '#/wire/types' { + interface PersistedOpMap { + 'mcp.tools_discovered': typeof mcpToolsDiscovered; + } } -export const mcpToolsDiscovered = defineOp(McpDiscoveryModel, 'mcp.tools_discovered', { - apply: (s, p: McpToolsDiscoveredPayload): McpDiscoveryState => { +export const mcpToolsDiscovered = McpDiscoveryModel.defineOp('mcp.tools_discovered', { + schema: z.object({ + serverName: z.string(), + hash: z.string(), + tools: z.custom(), + enabledNames: z.array(z.string()).readonly(), + collisions: z.array(mcpToolCollisionSchema).readonly().optional(), + }), + apply: (s, p) => { const key = `${p.serverName}\n${p.hash}`; if (s.seen.includes(key)) return s; return { seen: [...s.seen, key] }; }, }); - -declare module '#/agent/wireRecord/wireRecord' { - interface WireRecordMap { - 'mcp.tools_discovered': McpToolsDiscoveredPayload; - } -} diff --git a/packages/agent-core-v2/src/agent/permissionMode/permissionModeOps.ts b/packages/agent-core-v2/src/agent/permissionMode/permissionModeOps.ts index 3b3814445..d17fb9670 100644 --- a/packages/agent-core-v2/src/agent/permissionMode/permissionModeOps.ts +++ b/packages/agent-core-v2/src/agent/permissionMode/permissionModeOps.ts @@ -9,12 +9,20 @@ * type). Consumed by the Agent-scope `permissionModeService`. */ +import { z } from 'zod'; + import type { PermissionMode } from '#/agent/permissionPolicy/types'; import { defineModel } from '#/wire/model'; -import { defineOp } from '#/wire/op'; export const PermissionModeModel = defineModel('permissionMode', () => 'manual'); -export const setMode = defineOp(PermissionModeModel, 'permission.set_mode', { - apply: (_s, p: { mode: PermissionMode }) => p.mode, +declare module '#/wire/types' { + interface PersistedOpMap { + 'permission.set_mode': typeof setMode; + } +} + +export const setMode = PermissionModeModel.defineOp('permission.set_mode', { + schema: z.object({ mode: z.custom() }), + apply: (_s, p) => p.mode, }); diff --git a/packages/agent-core-v2/src/agent/permissionRules/permissionRulesOps.ts b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesOps.ts index fcf33d8ec..2d5f38252 100644 --- a/packages/agent-core-v2/src/agent/permissionRules/permissionRulesOps.ts +++ b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesOps.ts @@ -19,8 +19,9 @@ * `permissionRulesService`. */ +import { z } from 'zod'; + import { defineModel } from '#/wire/model'; -import { defineOp } from '#/wire/op'; import type { PermissionApprovalResultRecord, PermissionRule } from './permissionRules'; @@ -34,19 +35,30 @@ export const PermissionRulesModel = defineModel('perm sessionApprovalRulePatterns: [], })); -export const addPermissionRules = defineOp(PermissionRulesModel, 'permission.rules.add', { +declare module '#/wire/types' { + interface PersistedOpMap { + 'permission.record_approval_result': typeof recordApprovalResult; + } + + interface TransientOpMap { + 'permission.rules.add': typeof addPermissionRules; + } +} + +export const addPermissionRules = PermissionRulesModel.defineOp('permission.rules.add', { + schema: z.object({ rules: z.custom() }), persist: false, - apply: (s, p: { rules: readonly PermissionRule[] }): PermissionRulesModelState => { + apply: (s, p) => { if (p.rules.length === 0) return s; return { ...s, rules: [...s.rules, ...p.rules] }; }, }); -export const recordApprovalResult = defineOp( - PermissionRulesModel, +export const recordApprovalResult = PermissionRulesModel.defineOp( 'permission.record_approval_result', { - apply: (s, p: PermissionApprovalResultRecord): PermissionRulesModelState => { + schema: z.custom(), + apply: (s, p) => { const pattern = p.sessionApprovalRule; if ( p.result.decision !== 'approved' || diff --git a/packages/agent-core-v2/src/agent/plan/planOps.ts b/packages/agent-core-v2/src/agent/plan/planOps.ts index 5aeaa507e..60e3dbd4f 100644 --- a/packages/agent-core-v2/src/agent/plan/planOps.ts +++ b/packages/agent-core-v2/src/agent/plan/planOps.ts @@ -19,8 +19,9 @@ * Consumed by the Agent-scope `planService`. */ +import { z } from 'zod'; + import { defineModel } from '#/wire/model'; -import { defineOp } from '#/wire/op'; export interface PlanState { readonly active: boolean; @@ -29,26 +30,28 @@ export interface PlanState { export const PlanModel = defineModel('plan', () => ({ active: false })); -export interface PlanModeEnterPayload { - readonly id: string; -} - -export const planModeEnter = defineOp(PlanModel, 'plan_mode.enter', { - apply: (s, p: PlanModeEnterPayload): PlanState => - s.active && s.id === p.id ? s : { active: true, id: p.id }, +export const planModeEnter = PlanModel.defineOp('plan_mode.enter', { + schema: z.object({ id: z.string() }), + apply: (s, p) => (s.active && s.id === p.id ? s : { active: true, id: p.id }), toEvent: () => ({ type: 'agent.status.updated' as const, planMode: true }), }); -export interface PlanModeIdPayload { - readonly id?: string; +declare module '#/wire/types' { + interface PersistedOpMap { + 'plan_mode.enter': typeof planModeEnter; + 'plan_mode.cancel': typeof planModeCancel; + 'plan_mode.exit': typeof planModeExit; + } } -export const planModeCancel = defineOp(PlanModel, 'plan_mode.cancel', { - apply: (s, _p: PlanModeIdPayload): PlanState => (s.active === false ? s : { active: false }), +export const planModeCancel = PlanModel.defineOp('plan_mode.cancel', { + schema: z.object({ id: z.string().optional() }), + apply: (s) => (s.active === false ? s : { active: false }), toEvent: () => ({ type: 'agent.status.updated' as const, planMode: false }), }); -export const planModeExit = defineOp(PlanModel, 'plan_mode.exit', { - apply: (s, _p: PlanModeIdPayload): PlanState => (s.active === false ? s : { active: false }), +export const planModeExit = PlanModel.defineOp('plan_mode.exit', { + schema: z.object({ id: z.string().optional() }), + apply: (s) => (s.active === false ? s : { active: false }), toEvent: () => ({ type: 'agent.status.updated' as const, planMode: false }), }); diff --git a/packages/agent-core-v2/src/agent/profile/profileOps.ts b/packages/agent-core-v2/src/agent/profile/profileOps.ts index de95ea6be..b43ee8461 100644 --- a/packages/agent-core-v2/src/agent/profile/profileOps.ts +++ b/packages/agent-core-v2/src/agent/profile/profileOps.ts @@ -28,9 +28,11 @@ * Consumed by the Agent-scope `profileService`. */ +import { z } from 'zod'; + import type { ThinkingEffort } from '#/app/llmProtocol/thinkingEffort'; import { defineModel } from '#/wire/model'; -import { defineOp } from '#/wire/op'; +import type { PayloadOf } from '#/wire/types'; import { ProfileError, ProfileErrors } from './profile'; @@ -47,17 +49,16 @@ export const ProfileModel = defineModel('profile', () => ({ systemPrompt: '', })); -export interface ConfigUpdatePayload { - readonly cwd?: string; - readonly modelAlias?: string; - readonly profileName?: string; - readonly thinkingEffort?: ThinkingEffort; - readonly thinkingLevel?: ThinkingEffort; - readonly systemPrompt?: string; -} - -export const configUpdate = defineOp(ProfileModel, 'config.update', { - apply: (s, p: ConfigUpdatePayload): ProfileModelState => { +export const configUpdate = ProfileModel.defineOp('config.update', { + schema: z.object({ + cwd: z.string().optional(), + modelAlias: z.string().optional(), + profileName: z.string().optional(), + thinkingEffort: z.custom().optional(), + thinkingLevel: z.custom().optional(), + systemPrompt: z.string().optional(), + }), + apply: (s, p) => { let next: ProfileModelState | undefined; if (p.cwd !== undefined && p.cwd !== s.cwd) { next = { ...(next ?? s), cwd: p.cwd }; @@ -79,7 +80,9 @@ export const configUpdate = defineOp(ProfileModel, 'config.update', { }, }); -function configUpdateThinkingLevel(p: ConfigUpdatePayload): ThinkingEffort | undefined { +function configUpdateThinkingLevel( + p: PayloadOf, +): ThinkingEffort | undefined { if (p.thinkingEffort !== undefined && p.thinkingLevel !== undefined) { if (p.thinkingEffort !== p.thinkingLevel) { throw new ProfileError( @@ -112,10 +115,14 @@ export const ActiveToolsModel = defineModel( () => undefined, ); -export interface SetActiveToolsPayload { - readonly names: readonly string[]; +declare module '#/wire/types' { + interface PersistedOpMap { + 'config.update': typeof configUpdate; + 'tools.set_active_tools': typeof setActiveTools; + } } -export const setActiveTools = defineOp(ActiveToolsModel, 'tools.set_active_tools', { - apply: (s, p: SetActiveToolsPayload): ActiveToolsState => (p.names === s ? s : p.names), +export const setActiveTools = ActiveToolsModel.defineOp('tools.set_active_tools', { + schema: z.object({ names: z.array(z.string()).readonly() }), + apply: (s, p) => (p.names === s ? s : p.names), }); diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 353b35b2a..f40f65cf9 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -52,6 +52,7 @@ import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; import type { ToolSource } from '#/agent/tool/toolContract'; import { IAgentWireService } from '#/wire/tokens'; +import type { PayloadOf } from '#/wire/types'; import type { IWireService } from '#/wire/wireService'; import { IEventBus } from '#/app/event/eventBus'; import { prepareSystemPromptContext } from './context'; @@ -75,18 +76,9 @@ import { ProfileModel, setActiveTools, type ActiveToolsState, - type ConfigUpdatePayload, type ProfileModelState, } from './profileOps'; -declare module '#/agent/wireRecord/wireRecord' { - interface WireRecordMap { - 'tools.set_active_tools': { - names: readonly string[]; - }; - } -} - declare module '#/app/event/eventBus' { interface DomainEventMap { // `warning` is owned by `profile` (the agents-md-oversized notice). @@ -396,8 +388,10 @@ export class AgentProfileService implements IAgentProfileService { private resolveConfigPayload( changed: Omit, - ): ConfigUpdatePayload { - const payload: { -readonly [K in keyof ConfigUpdatePayload]: ConfigUpdatePayload[K] } = {}; + ): PayloadOf { + const payload: { + -readonly [K in keyof PayloadOf]: PayloadOf[K]; + } = {}; if (changed.cwd !== undefined) payload.cwd = changed.cwd; if (changed.modelAlias !== undefined) payload.modelAlias = changed.modelAlias; if (changed.profileName !== undefined) payload.profileName = changed.profileName; diff --git a/packages/agent-core-v2/src/agent/replayBuilder/replayTimelineModel.ts b/packages/agent-core-v2/src/agent/replayBuilder/replayTimelineModel.ts index 775f03591..f90eace3e 100644 --- a/packages/agent-core-v2/src/agent/replayBuilder/replayTimelineModel.ts +++ b/packages/agent-core-v2/src/agent/replayBuilder/replayTimelineModel.ts @@ -16,85 +16,82 @@ import { contextAppendMessage, contextApplyCompaction, - type ContextCompactionPayload, - type ContextMessagePayload, } from '#/agent/contextMemory/contextOps'; import { fullCompactionBegin, fullCompactionCancel, fullCompactionComplete, - type FullCompactionBeginPayload, - type FullCompactionCompletePayload, } from '#/agent/fullCompaction/compactionOps'; -import { - clearGoal, - createGoal, - updateGoal, - type GoalCreatePayload, - type GoalUpdatePayload, -} from '#/agent/goal/goalOps'; -import { - planModeCancel, - planModeEnter, - planModeExit, - type PlanModeEnterPayload, - type PlanModeIdPayload, -} from '#/agent/plan/planOps'; -import { configUpdate, type ConfigUpdatePayload } from '#/agent/profile/profileOps'; +import { clearGoal, createGoal, updateGoal } from '#/agent/goal/goalOps'; +import { planModeCancel, planModeEnter, planModeExit } from '#/agent/plan/planOps'; +import { configUpdate } from '#/agent/profile/profileOps'; import type { PermissionMode } from '#/agent/permissionPolicy/types'; import { setMode } from '#/agent/permissionMode/permissionModeOps'; import type { PermissionApprovalResultRecord } from '#/agent/permissionRules/permissionRules'; import { recordApprovalResult } from '#/agent/permissionRules/permissionRulesOps'; import { type DerivedModelDef, defineDerivedModel } from '#/wire/model'; +import type { ModelReducers, OpPayload, OpType, PayloadOf } from '#/wire/types'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function defineDerivedTimeline any>>( +type TimelineMapperMap = { + [K in OpType]?: (payload: OpPayload) => unknown; +}; + +type TimelineEntry = { + [K in keyof M]: M[K] extends (...args: never[]) => infer E ? E : never; +}[keyof M]; + +type ErasedTimelineMapper = (payload: unknown) => E; + +function defineDerivedTimeline( name: string, - mappers: M, -): DerivedModelDef[]> { - type E = ReturnType; - const reducers: Record readonly E[]> = {}; - for (const opType of Object.keys(mappers)) { - reducers[opType] = (s, p) => [...s, mappers[opType]!(p)]; - } + mappers: M & Record, never>, +): DerivedModelDef[]> { + type E = TimelineEntry; + const entries = Object.entries(mappers) as [OpType, ErasedTimelineMapper][]; + const reducers = Object.fromEntries( + entries.map( + ([opType, mapper]) => + [opType, (state: readonly E[], payload: unknown) => [...state, mapper(payload)]] as const, + ), + ) as ModelReducers; return defineDerivedModel(name, () => [], reducers); } export const ReplayTimelineModel = defineDerivedTimeline('agent.replayTimeline', { - [contextAppendMessage.type]: (p: ContextMessagePayload) => + [contextAppendMessage.type]: (p: PayloadOf) => ({ type: contextAppendMessage.type, payload: p }) as const, - [contextApplyCompaction.type]: (p: ContextCompactionPayload) => + [contextApplyCompaction.type]: (p: PayloadOf) => ({ type: contextApplyCompaction.type, payload: p }) as const, - [fullCompactionBegin.type]: (p: FullCompactionBeginPayload) => + [fullCompactionBegin.type]: (p: PayloadOf) => ({ type: fullCompactionBegin.type, payload: p }) as const, [fullCompactionCancel.type]: () => ({ type: fullCompactionCancel.type }) as const, - [fullCompactionComplete.type]: (p: FullCompactionCompletePayload) => + [fullCompactionComplete.type]: (p: PayloadOf) => ({ type: fullCompactionComplete.type, payload: p }) as const, - [createGoal.type]: (p: GoalCreatePayload) => + [createGoal.type]: (p: PayloadOf) => ({ type: createGoal.type, payload: p }) as const, - [updateGoal.type]: (p: GoalUpdatePayload) => + [updateGoal.type]: (p: PayloadOf) => ({ type: updateGoal.type, payload: p }) as const, [clearGoal.type]: () => ({ type: clearGoal.type }) as const, - [planModeEnter.type]: (p: PlanModeEnterPayload) => + [planModeEnter.type]: (p: PayloadOf) => ({ type: planModeEnter.type, payload: p }) as const, - [planModeCancel.type]: (p: PlanModeIdPayload) => + [planModeCancel.type]: (p: PayloadOf) => ({ type: planModeCancel.type, payload: p }) as const, - [planModeExit.type]: (p: PlanModeIdPayload) => + [planModeExit.type]: (p: PayloadOf) => ({ type: planModeExit.type, payload: p }) as const, - [configUpdate.type]: (p: ConfigUpdatePayload) => + [configUpdate.type]: (p: PayloadOf) => ({ type: configUpdate.type, payload: p }) as const, [setMode.type]: (p: { mode: PermissionMode }) => diff --git a/packages/agent-core-v2/src/agent/runtime/runtimeOps.ts b/packages/agent-core-v2/src/agent/runtime/runtimeOps.ts index 5186278c2..3711c0db4 100644 --- a/packages/agent-core-v2/src/agent/runtime/runtimeOps.ts +++ b/packages/agent-core-v2/src/agent/runtime/runtimeOps.ts @@ -14,8 +14,9 @@ * by the Agent-scope `runtimeService`. */ +import { z } from 'zod'; + import { defineModel } from '#/wire/model'; -import { defineOp } from '#/wire/op'; import type { AgentPhase } from './runtime'; @@ -27,10 +28,17 @@ export const RuntimeModel = defineModel('runtime', () => ({ phase: { kind: 'idle' }, })); -export const setRuntimePhase = defineOp(RuntimeModel, 'runtime.set_phase', { +declare module '#/wire/types' { + interface TransientOpMap { + 'runtime.set_phase': typeof setRuntimePhase; + 'activity.set_snapshot': typeof setActivitySnapshot; + } +} + +export const setRuntimePhase = RuntimeModel.defineOp('runtime.set_phase', { + schema: z.object({ phase: z.custom() }), persist: false, - apply: (s, p: { phase: AgentPhase }): RuntimeModelState => - phaseEqual(s.phase, p.phase) ? s : { phase: p.phase }, + apply: (s, p) => (phaseEqual(s.phase, p.phase) ? s : { phase: p.phase }), toEvent: (p) => ({ type: 'agent.status.updated' as const, phase: p.phase }), }); @@ -105,10 +113,10 @@ export const ActivityModel = defineModel('activity', () = background: [], })); -export const setActivitySnapshot = defineOp(ActivityModel, 'activity.set_snapshot', { +export const setActivitySnapshot = ActivityModel.defineOp('activity.set_snapshot', { + schema: z.object({ next: z.custom() }), persist: false, - apply: (s, p: { next: AgentActivitySnapshot }): AgentActivitySnapshot => - snapshotEqual(s, p.next) ? s : p.next, + apply: (s, p) => (snapshotEqual(s, p.next) ? s : p.next), toEvent: (p) => ({ type: 'agent.activity.updated' as const, ...p.next }), }); diff --git a/packages/agent-core-v2/src/agent/skill/skillOps.ts b/packages/agent-core-v2/src/agent/skill/skillOps.ts index 48982bf5c..69f019df7 100644 --- a/packages/agent-core-v2/src/agent/skill/skillOps.ts +++ b/packages/agent-core-v2/src/agent/skill/skillOps.ts @@ -12,8 +12,9 @@ * Consumed by the Agent-scope `skillService`. */ +import { z } from 'zod'; + import { defineModel } from '#/wire/model'; -import { defineOp } from '#/wire/op'; import type { SkillActivationOrigin, SkillSource } from '#/agent/contextMemory/types'; @@ -32,9 +33,16 @@ declare module '#/app/event/eventBus' { export const SkillModel = defineModel('skill', () => null); -export const skillActivate = defineOp(SkillModel, 'skill.activate', { +declare module '#/wire/types' { + interface TransientOpMap { + 'skill.activate': typeof skillActivate; + } +} + +export const skillActivate = SkillModel.defineOp('skill.activate', { + schema: z.object({ origin: z.custom() }), persist: false, - apply: (s, _p: { origin: SkillActivationOrigin }) => s, + apply: (s) => s, toEvent: (p) => ({ type: 'skill.activated' as const, activationId: p.origin.activationId, diff --git a/packages/agent-core-v2/src/agent/swarm/swarmOps.ts b/packages/agent-core-v2/src/agent/swarm/swarmOps.ts index a53a41bb0..3330615a4 100644 --- a/packages/agent-core-v2/src/agent/swarm/swarmOps.ts +++ b/packages/agent-core-v2/src/agent/swarm/swarmOps.ts @@ -11,19 +11,29 @@ * Agent-scope `swarmService`. */ +import { z } from 'zod'; + import { defineModel } from '#/wire/model'; -import { defineOp } from '#/wire/op'; import type { SwarmModeTrigger } from './swarm'; export const SwarmModel = defineModel('swarm', () => null); -export const swarmEnter = defineOp(SwarmModel, 'swarm_mode.enter', { - apply: (_s, p: { trigger: SwarmModeTrigger }) => p.trigger, +declare module '#/wire/types' { + interface PersistedOpMap { + 'swarm_mode.enter': typeof swarmEnter; + 'swarm_mode.exit': typeof swarmExit; + } +} + +export const swarmEnter = SwarmModel.defineOp('swarm_mode.enter', { + schema: z.object({ trigger: z.custom() }), + apply: (_s, p) => p.trigger, toEvent: () => ({ type: 'agent.status.updated' as const, swarmMode: true }), }); -export const swarmExit = defineOp(SwarmModel, 'swarm_mode.exit', { +export const swarmExit = SwarmModel.defineOp('swarm_mode.exit', { + schema: z.object({}), apply: () => null, toEvent: () => ({ type: 'agent.status.updated' as const, swarmMode: false }), }); diff --git a/packages/agent-core-v2/src/agent/task/taskOps.ts b/packages/agent-core-v2/src/agent/task/taskOps.ts index 21e9bff4e..d7055a125 100644 --- a/packages/agent-core-v2/src/agent/task/taskOps.ts +++ b/packages/agent-core-v2/src/agent/task/taskOps.ts @@ -19,8 +19,9 @@ * Agent-scope `taskService`. */ +import { z } from 'zod'; + import { defineModel } from '#/wire/model'; -import { defineOp } from '#/wire/op'; import type { AgentTaskInfo } from './types'; @@ -35,13 +36,19 @@ declare module '#/app/event/eventBus' { } } -export interface TaskInfoPayload { - readonly info: AgentTaskInfo; +const taskInfoSchema = z.object({ info: z.custom() }); + +declare module '#/wire/types' { + interface TransientOpMap { + 'task.started': typeof taskStarted; + 'task.terminated': typeof taskTerminated; + } } -export const taskStarted = defineOp(TaskModel, 'task.started', { +export const taskStarted = TaskModel.defineOp('task.started', { + schema: taskInfoSchema, persist: false, - apply: (s, p: TaskInfoPayload): TaskModelState => { + apply: (s, p) => { const next = new Map(s); next.set(p.info.taskId, p.info); return next; @@ -49,9 +56,10 @@ export const taskStarted = defineOp(TaskModel, 'task.started', { toEvent: (p) => ({ type: 'task.started' as const, info: p.info }), }); -export const taskTerminated = defineOp(TaskModel, 'task.terminated', { +export const taskTerminated = TaskModel.defineOp('task.terminated', { + schema: taskInfoSchema, persist: false, - apply: (s, p: TaskInfoPayload): TaskModelState => { + apply: (s, p) => { const next = new Map(s); next.set(p.info.taskId, p.info); return next; diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index ac5b307b7..e738a4cab 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -45,7 +45,10 @@ import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import { IAgentWireRecordService, type WireRecord } from '#/agent/wireRecord/wireRecord'; +import { + IAgentWireRecordService, + type PersistedWireRecord, +} from '#/agent/wireRecord/wireRecord'; import { IAgentWireService } from '#/wire/tokens'; import type { IWireService } from '#/wire/wireService'; import { @@ -233,7 +236,14 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { atomicDocs, byteStore, ); - this._register(this.wire.onRestored(() => this.restoreAfterReplay())); + this._register( + this.wire.onRestored(async () => { + for (const record of wireRecord.getRecords()) { + this.markDeliveredNotificationsFromRecord(record); + } + await this.restoreAfterReplay(); + }), + ); this._register( this.eventBus.subscribe('context.spliced', (e) => { if (isCompactionSplice(e)) { @@ -251,15 +261,6 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { this.activeBackgroundTaskReminder(), ), ); - this._register( - wireRecord.hooks.onDidRestoreRecord.register( - 'task-delivered-notifications', - async (ctx, next) => { - this.markDeliveredNotificationsFromRecord(ctx.record); - await next(); - }, - ), - ); } private async restoreAfterReplay(): Promise { @@ -290,7 +291,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { } } - private markDeliveredNotificationsFromRecord(record: WireRecord): void { + private markDeliveredNotificationsFromRecord(record: PersistedWireRecord): void { for (const origin of taskOriginsFromRecord(record)) { this.markDeliveredNotification(origin); } @@ -1213,7 +1214,7 @@ function notificationKey(origin: TaskNotificationOrigin): string { return `${origin.taskId}\0${origin.status}\0${origin.notificationId}`; } -function taskOriginsFromRecord(record: WireRecord): readonly TaskNotificationOrigin[] { +function taskOriginsFromRecord(record: PersistedWireRecord): readonly TaskNotificationOrigin[] { const raw = record as { readonly type: string; readonly message?: unknown; diff --git a/packages/agent-core-v2/src/agent/usage/usageOps.ts b/packages/agent-core-v2/src/agent/usage/usageOps.ts index a36b10dae..548b50baa 100644 --- a/packages/agent-core-v2/src/agent/usage/usageOps.ts +++ b/packages/agent-core-v2/src/agent/usage/usageOps.ts @@ -14,10 +14,11 @@ * each dispatch (never on replay). Consumed by the Agent-scope `usageService`. */ +import { z } from 'zod'; + import { addUsage, type TokenUsage } from '#/app/llmProtocol/usage'; import type { AgentPhase } from '#/agent/runtime/runtime'; import { defineModel } from '#/wire/model'; -import { defineOp } from '#/wire/op'; import type { UsageStatus } from './usage'; @@ -45,14 +46,19 @@ export interface UsageModelState { export const UsageModel = defineModel('usage', () => ({ byModel: {} })); -export interface UsageRecordPayload { - readonly model: string; - readonly usage: TokenUsage; - readonly usageScope?: UsageRecordScope; +declare module '#/wire/types' { + interface PersistedOpMap { + 'usage.record': typeof recordUsage; + } } -export const recordUsage = defineOp(UsageModel, 'usage.record', { - apply: (s, p: UsageRecordPayload): UsageModelState => { +export const recordUsage = UsageModel.defineOp('usage.record', { + schema: z.object({ + model: z.string(), + usage: z.custom(), + usageScope: z.custom().optional(), + }), + apply: (s, p) => { const current = s.byModel[p.model]; return { byModel: { diff --git a/packages/agent-core-v2/src/agent/userTool/userToolOps.ts b/packages/agent-core-v2/src/agent/userTool/userToolOps.ts index 876fe8b5f..9b54e8963 100644 --- a/packages/agent-core-v2/src/agent/userTool/userToolOps.ts +++ b/packages/agent-core-v2/src/agent/userTool/userToolOps.ts @@ -18,8 +18,9 @@ * Consumed by the Agent-scope `userToolService`. */ +import { z } from 'zod'; + import { defineModel } from '#/wire/model'; -import { defineOp } from '#/wire/op'; import type { UserToolRegistration } from './userTool'; @@ -27,6 +28,13 @@ export type UserToolModelState = Map; export const UserToolModel = defineModel('userTool', () => new Map()); +declare module '#/wire/types' { + interface PersistedOpMap { + 'tools.register_user_tool': typeof registerUserTool; + 'tools.unregister_user_tool': typeof unregisterUserTool; + } +} + function equalRegistration(a: UserToolRegistration, b: UserToolRegistration): boolean { return ( a.name === b.name && @@ -35,29 +43,23 @@ function equalRegistration(a: UserToolRegistration, b: UserToolRegistration): bo ); } -export const registerUserTool = defineOp( - UserToolModel, - 'tools.register_user_tool', - { - apply: (s, p: UserToolRegistration): UserToolModelState => { - const existing = s.get(p.name); - if (existing !== undefined && equalRegistration(existing, p)) return s; - const next = new Map(s); - next.set(p.name, p); - return next; - }, +export const registerUserTool = UserToolModel.defineOp('tools.register_user_tool', { + schema: z.custom(), + apply: (s, p) => { + const existing = s.get(p.name); + if (existing !== undefined && equalRegistration(existing, p)) return s; + const next = new Map(s); + next.set(p.name, p); + return next; }, -); +}); -export const unregisterUserTool = defineOp( - UserToolModel, - 'tools.unregister_user_tool', - { - apply: (s, p: { readonly name: string }): UserToolModelState => { - if (!s.has(p.name)) return s; - const next = new Map(s); - next.delete(p.name); - return next; - }, +export const unregisterUserTool = UserToolModel.defineOp('tools.unregister_user_tool', { + schema: z.object({ name: z.string() }), + apply: (s, p) => { + if (!s.has(p.name)) return s; + const next = new Map(s); + next.delete(p.name); + return next; }, -); +}); diff --git a/packages/agent-core-v2/src/agent/wireRecord/metadataOps.ts b/packages/agent-core-v2/src/agent/wireRecord/metadataOps.ts index 86872c115..9e77139a5 100644 --- a/packages/agent-core-v2/src/agent/wireRecord/metadataOps.ts +++ b/packages/agent-core-v2/src/agent/wireRecord/metadataOps.ts @@ -9,17 +9,20 @@ * the same append path as every other Op. Scope-agnostic. */ +import { z } from 'zod'; + import { defineModel } from '#/wire/model'; -import { defineOp } from '#/wire/op'; const MetadataModel = defineModel('wire.metadata', () => null); -export interface WireMetadataPayload { - readonly protocol_version: string; - readonly created_at: number; +declare module '#/wire/types' { + interface PersistedOpMap { + metadata: typeof wireMetadata; + } } -export const wireMetadata = defineOp(MetadataModel, 'metadata', { +export const wireMetadata = MetadataModel.defineOp('metadata', { + schema: z.object({ protocol_version: z.string(), created_at: z.number() }), stamp: false, - apply: (s, _p: WireMetadataPayload): null => s, + apply: (s) => s, }); diff --git a/packages/agent-core-v2/src/agent/wireRecord/wireRecord.ts b/packages/agent-core-v2/src/agent/wireRecord/wireRecord.ts index 713cd4ff6..cb2f8b58c 100644 --- a/packages/agent-core-v2/src/agent/wireRecord/wireRecord.ts +++ b/packages/agent-core-v2/src/agent/wireRecord/wireRecord.ts @@ -1,20 +1,9 @@ -import type { ContentPart } from '#/app/llmProtocol/message'; +import { createDecorator } from '#/_base/di/instantiation'; -import { createDecorator } from "#/_base/di/instantiation"; -import type { IDisposable } from "#/_base/di/lifecycle"; -import type { Event } from '#/_base/event'; - -import type { Hooks } from '#/hooks'; import type { WireMigrationRecord } from '#/agent/wireRecord/migration/migration'; export * from '#/agent/wireRecord/migration/migration'; -export interface WireRecordMap {} - -export type WireRecord = { - [T in K]: { readonly type: T; readonly time?: number } & Readonly; -}[K]; - export interface WireRecordMetadata { readonly type: 'metadata'; readonly protocol_version: string; @@ -22,16 +11,7 @@ export interface WireRecordMetadata { readonly time?: number; } -export type PersistedWireRecord = WireRecord | WireRecordMetadata | WireMigrationRecord; - -export interface WireRecordRestoringContext { - readonly time?: number; -} - -export interface WireRecordRestoredContext { - readonly record: WireRecord; - stop: boolean; -} +export type PersistedWireRecord = WireRecordMetadata | WireMigrationRecord; export interface WireRecordRestoreOptions { readonly rewriteMigratedRecords?: boolean; @@ -41,37 +21,9 @@ export interface WireRecordRestoreResult { readonly warning?: string; } -export interface WireRecordBlobTarget { - readonly parts: readonly ContentPart[]; - replace(record: TRecord, parts: readonly ContentPart[]): TRecord; -} - -export type WireRecordBlobSelector = ( - record: TRecord, -) => Iterable>; - -export interface WireRecordRegisterOptions { - readonly blobs?: WireRecordBlobSelector>; -} - -/** - * Static construction options for `AgentWireRecordService`, supplied through a - * `SyncDescriptor` when the service is seeded into a scope. Kept separate from - * injected services so each agent scope can pin its own persistence key. - */ -export interface WireRecordServiceOptions { - /** - * Per-agent home directory used to derive the wire-log persistence key. - * Falls back to `IBootstrapService.homeDir` (the global home) when omitted. - */ - readonly homedir?: string; -} - export interface IAgentWireRecordService { readonly _serviceBrand: undefined; - readonly restoring: WireRecordRestoringContext | null; - readonly postRestoring: boolean; /** * Snapshot of every record held in memory, in order, excluding the leading * `metadata` envelope: the records seeded by {@link restore} plus every record @@ -81,24 +33,12 @@ export interface IAgentWireRecordService { * transcript). */ getRecords(): readonly PersistedWireRecord[]; - register( - type: T, - resumer: (data: WireRecord) => void | Promise, - options?: WireRecordRegisterOptions, - ): IDisposable; restore( records?: readonly PersistedWireRecord[], options?: WireRecordRestoreOptions, ): Promise; flush(): Promise; close(): Promise; - - readonly hooks: Hooks<{ - onDidRestoreRecord: WireRecordRestoredContext; - }>; - - /** Fires once after a resume's replay pass has finished (live or restored). */ - readonly onDidFinishResume: Event; } export const IAgentWireRecordService = createDecorator('agentWireRecordService'); diff --git a/packages/agent-core-v2/src/agent/wireRecord/wireRecordService.ts b/packages/agent-core-v2/src/agent/wireRecord/wireRecordService.ts index ff0c771b8..8fa579d9d 100644 --- a/packages/agent-core-v2/src/agent/wireRecord/wireRecordService.ts +++ b/packages/agent-core-v2/src/agent/wireRecord/wireRecordService.ts @@ -1,16 +1,12 @@ import { relative } from 'pathe'; import { InstantiationType } from '#/_base/di/extensions'; +import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { Disposable, toDisposable } from "#/_base/di/lifecycle"; -import { Emitter, type Event } from '#/_base/event'; -import { IAgentBlobService } from '#/agent/blob/agentBlobService'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; -import { OrderedHookSlot } from '#/hooks'; import { IAgentWireService } from '#/wire/tokens'; import type { IWireService } from '#/wire/wireService'; -import type { WireRecord, WireRecordMap } from './wireRecord'; import { AGENT_WIRE_PROTOCOL_VERSION, applyWireMigrations, @@ -22,39 +18,19 @@ import { import { IAgentWireRecordService, type PersistedWireRecord, - type WireRecordBlobSelector, type WireRecordMetadata, - type WireRecordRegisterOptions, - type WireRecordRestoredContext, type WireRecordRestoreOptions, type WireRecordRestoreResult, - type WireRecordServiceOptions, } from './wireRecord'; -type Resumer = (data: WireRecord) => void | Promise; -type BlobSelector = WireRecordBlobSelector>; - export class AgentWireRecordService extends Disposable implements IAgentWireRecordService { declare readonly _serviceBrand: undefined; - private readonly records: WireRecord[] = []; - private readonly resumers = new Map>>(); - private readonly blobSelectors = new Map< - keyof WireRecordMap, - BlobSelector[] - >(); + private readonly records: PersistedWireRecord[] = []; private readonly wireScope: string; - private _restoring: { time?: number } | null = null; - private _postRestoring = false; - readonly hooks = { - onDidRestoreRecord: new OrderedHookSlot(), - }; - private readonly _onDidFinishResume = this._register(new Emitter()); - readonly onDidFinishResume: Event = this._onDidFinishResume.event; constructor( - private readonly options: WireRecordServiceOptions = {}, + options: { readonly homedir?: string } = {}, @IBootstrapService bootstrap: IBootstrapService, - @IAgentBlobService private readonly blobStore?: IAgentBlobService, @IAppendLogStore private readonly log?: IAppendLogStore, @IAgentWireService private readonly wire?: IWireService, ) { @@ -79,48 +55,17 @@ export class AgentWireRecordService extends Disposable implements IAgentWireReco this._register( wire.onEmission((emission) => { if (emission.type === 'record' && emission.record.type !== 'metadata') { - this.records.push(emission.record as WireRecord); + this.records.push(emission.record as PersistedWireRecord); } }), ); } } - get restoring() { - return this._restoring; - } - - get postRestoring() { - return this._postRestoring; - } - getRecords(): readonly PersistedWireRecord[] { return [...this.records]; } - register( - type: T, - resumer: (data: WireRecord) => void | Promise, - options?: WireRecordRegisterOptions, - ) { - const typed = resumer as unknown as Resumer; - let set = this.resumers.get(type); - if (set === undefined) { - set = new Set(); - this.resumers.set(type, set); - } - set.add(typed); - const blobSelector = options?.blobs as BlobSelector | undefined; - const blobSet = this.registerBlobSelector(type, blobSelector); - return toDisposable(() => { - set?.delete(typed); - if (blobSelector !== undefined) { - const index = blobSet?.indexOf(blobSelector) ?? -1; - if (index !== -1) blobSet?.splice(index, 1); - } - }); - } - async restore( records?: readonly PersistedWireRecord[], options: WireRecordRestoreOptions = {}, @@ -132,7 +77,6 @@ export class AgentWireRecordService extends Disposable implements IAgentWireReco ? this.log.read(this.wireScope, WIRE_RECORD_FILENAME) : undefined); if (source === undefined) { - this.fireResumeEnded(); return {}; } @@ -144,11 +88,10 @@ export class AgentWireRecordService extends Disposable implements IAgentWireReco fromPersistence && this.log !== undefined; let migrations: readonly WireMigration[] = []; let shouldRewrite = false; - let completed = true; let warning: string | undefined; const sourceRecords: PersistedWireRecord[] = []; - for await (const record of toAsyncIterable(source)) { + for await (const record of source) { sourceRecords.push(record); } @@ -184,31 +127,19 @@ export class AgentWireRecordService extends Disposable implements IAgentWireReco } restoredRecords?.push(migratedRecord); if (migratedRecord.type === 'metadata') continue; - - if (await this.restoreRecord(await this.rehydrateRecord(migratedRecord as WireRecord))) { - completed = false; - break; - } + this.records.push(migratedRecord); } - if ( - completed && - shouldRewrite && - restoredRecords !== undefined && - this.log !== undefined - ) { + if (shouldRewrite && restoredRecords !== undefined && this.log !== undefined) { void this.log.rewrite(this.wireScope, WIRE_RECORD_FILENAME, restoredRecords); await this.log.flush(); } - if (completed) { - this.fireResumeEnded(); - } return warning === undefined ? {} : { warning }; } async flush(): Promise { - // Drain the wire service's async persist pipeline first: with a blob - // service registered, appends are queued on a microtask chain and only + // Drain the wire service's async persist pipeline first: with a model blob + // codec, appends are queued on a microtask chain and only // reach the log store once that queue settles. Flushing the log alone // would miss records still in flight. await this.wire?.flush(); @@ -218,84 +149,6 @@ export class AgentWireRecordService extends Disposable implements IAgentWireReco async close(): Promise { await this.log?.close(); } - - private async restoreRecord(record: WireRecord): Promise { - this.records.push(record); - this._restoring = { time: record.time ?? Date.now() }; - try { - const resumers = this.resumers.get(record.type); - if (resumers !== undefined) { - const currentResumers = Array.from(resumers); - for (const resumer of currentResumers) { - await resumer(record); - } - } - const context: WireRecordRestoredContext = { record, stop: false }; - await this.hooks.onDidRestoreRecord.run(context); - return context.stop; - } finally { - this._restoring = null; - } - } - - private fireResumeEnded(): void { - this._postRestoring = true; - try { - this._onDidFinishResume.fire(); - } finally { - this._postRestoring = false; - } - } - - private registerBlobSelector( - type: T, - selector: BlobSelector | undefined, - ): BlobSelector[] | undefined { - if (selector === undefined) return undefined; - - let selectors = this.blobSelectors.get(type); - if (selectors === undefined) { - selectors = []; - this.blobSelectors.set(type, selectors); - } - selectors.push(selector); - return selectors; - } - - private async rehydrateRecord( - record: WireRecord, - ): Promise> { - return this.applyBlobSelectors(record); - } - - private async applyBlobSelectors( - record: WireRecord, - ): Promise> { - const blobStore = this.blobStore; - if (blobStore === undefined) return record; - - const selectors = this.blobSelectors.get(record.type); - if (selectors === undefined) return record; - - let current = record; - for (const selector of [...selectors] as BlobSelector[]) { - for (const target of selector(current)) { - const parts = await blobStore.loadParts(target.parts); - if (parts !== target.parts) { - current = target.replace(current, parts); - } - } - } - return current; - } -} - -async function* toAsyncIterable( - source: Iterable | AsyncIterable, -): AsyncIterable { - for await (const item of source) { - yield item; - } } registerScopedService( diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 67b5113b0..c45956bd3 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -66,8 +66,9 @@ import { AgentWireRecordService, WIRE_RECORD_FILENAME, } from '#/agent/wireRecord/wireRecordService'; -import { type WireMetadataPayload, wireMetadata } from '#/agent/wireRecord/metadataOps'; +import { wireMetadata } from '#/agent/wireRecord/metadataOps'; import { IAgentWireService } from '#/wire/tokens'; +import type { PayloadOf } from '#/wire/types'; import { WireService } from '#/wire/wireServiceImpl'; import { IAgentBlobService } from '#/agent/blob/agentBlobService'; import { AgentBlobServiceImpl } from '#/agent/blob/agentBlobServiceImpl'; @@ -487,7 +488,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle } } -function freshMetadataPayload(): WireMetadataPayload { +function freshMetadataPayload(): PayloadOf { return { protocol_version: AGENT_WIRE_PROTOCOL_VERSION, created_at: Date.now(), diff --git a/packages/agent-core-v2/src/session/cron/cronOps.ts b/packages/agent-core-v2/src/session/cron/cronOps.ts index 959430cfd..9936798ff 100644 --- a/packages/agent-core-v2/src/session/cron/cronOps.ts +++ b/packages/agent-core-v2/src/session/cron/cronOps.ts @@ -18,9 +18,9 @@ */ import type { CronJobOrigin } from '@moonshot-ai/protocol'; +import { z } from 'zod'; import { defineModel } from '#/wire/model'; -import { defineOp } from '#/wire/op'; import type { CronTask } from '#/app/cron/cronTask'; @@ -34,26 +34,28 @@ declare module '#/app/event/eventBus' { } } -export interface CronAddPayload { - readonly task: CronTask; +declare module '#/wire/types' { + interface TransientOpMap { + 'cron.add': typeof cronAdd; + 'cron.delete': typeof cronDelete; + 'cron.cursor': typeof cronCursor; + } } -export const cronAdd = defineOp(CronModel, 'cron.add', { +export const cronAdd = CronModel.defineOp('cron.add', { + schema: z.object({ task: z.custom() }), persist: false, - apply: (s, p: CronAddPayload): CronModelState => { + apply: (s, p) => { const next = new Map(s); next.set(p.task.id, p.task); return next; }, }); -export interface CronDeletePayload { - readonly ids: readonly string[]; -} - -export const cronDelete = defineOp(CronModel, 'cron.delete', { +export const cronDelete = CronModel.defineOp('cron.delete', { + schema: z.object({ ids: z.array(z.string()).readonly() }), persist: false, - apply: (s, p: CronDeletePayload): CronModelState => { + apply: (s, p) => { let next: Map | undefined; for (const id of p.ids) { if (s.has(id)) { @@ -65,14 +67,10 @@ export const cronDelete = defineOp(CronModel, 'cron.delete', { }, }); -export interface CronCursorPayload { - readonly id: string; - readonly lastFiredAt: number; -} - -export const cronCursor = defineOp(CronModel, 'cron.cursor', { +export const cronCursor = CronModel.defineOp('cron.cursor', { + schema: z.object({ id: z.string(), lastFiredAt: z.number() }), persist: false, - apply: (s, p: CronCursorPayload): CronModelState => { + apply: (s, p) => { const task = s.get(p.id); if (task === undefined) return s; const next = new Map(s); diff --git a/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts b/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts index 06d279e28..85d1270bc 100644 --- a/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts +++ b/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts @@ -6,8 +6,8 @@ * (tick / coalesce / jitter / cursor), persists mutations through the * App-scoped `ICronTaskPersistence`, mirrors mutations as `cron.add` / * `cron.delete` / `cron.cursor` Ops on the main agent's `wire` (cross-scope - * borrow) so `wire.replay` can rebuild the `CronModel`, fires `cron.fired` - * through the main agent's `wire` signal channel, steers the main agent + * borrow) so `wire.replay` can rebuild the `CronModel`, publishes `cron.fired` + * to the main agent's `IEventBus`, steers the main agent * through `IAgentPromptService` when a task fires, and registers the cron * tools (`CronCreate` / `CronList` / `CronDelete`) into the main agent's * `IAgentToolRegistryService` once `IAgentLifecycleService` signals @@ -56,21 +56,6 @@ export const CRON_FIRED = 'cron_fired' as const; export const CRON_MISSED = 'cron_missed' as const; export const CRON_DELETED = 'cron_deleted' as const; -declare module '#/agent/wireRecord/wireRecord' { - interface WireRecordMap { - 'cron.add': { - task: CronTask; - }; - 'cron.delete': { - ids: readonly string[]; - }; - 'cron.cursor': { - id: string; - lastFiredAt: number; - }; - } -} - const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; const DEFAULT_POLL_INTERVAL_MS = 1_000; const MAX_COALESCE_ITERATIONS = 10_000; diff --git a/packages/agent-core-v2/src/session/todo/sessionTodoService.ts b/packages/agent-core-v2/src/session/todo/sessionTodoService.ts index d64f43223..db81f8b69 100644 --- a/packages/agent-core-v2/src/session/todo/sessionTodoService.ts +++ b/packages/agent-core-v2/src/session/todo/sessionTodoService.ts @@ -37,15 +37,6 @@ import { TodoModel, todoSet } from './todoOps'; import { TODO_LIST_TOOL_NAME, type TodoItem } from './todoItem'; import { TODO_LIST_REMINDER_VARIANT, todoListStaleReminder } from './todoListReminder'; -declare module '#/agent/wireRecord/wireRecord' { - interface WireRecordMap { - 'tools.update_store': { - key: string; - value: unknown; - }; - } -} - const MAIN_AGENT_ID = 'main'; export class SessionTodoService extends Disposable implements ISessionTodoService { diff --git a/packages/agent-core-v2/src/session/todo/todoOps.ts b/packages/agent-core-v2/src/session/todo/todoOps.ts index 12fb78e66..148d8346b 100644 --- a/packages/agent-core-v2/src/session/todo/todoOps.ts +++ b/packages/agent-core-v2/src/session/todo/todoOps.ts @@ -17,8 +17,9 @@ * replays. */ +import { z } from 'zod'; + import { defineModel } from '#/wire/model'; -import { defineOp } from '#/wire/op'; import { readTodoItems, type TodoItem } from './todoItem'; @@ -26,12 +27,13 @@ export type TodoModelState = readonly TodoItem[]; export const TodoModel = defineModel('todo', () => []); -export interface ToolStoreUpdatePayload { - readonly key: string; - readonly value: unknown; +declare module '#/wire/types' { + interface PersistedOpMap { + 'tools.update_store': typeof todoSet; + } } -export const todoSet = defineOp(TodoModel, 'tools.update_store', { - apply: (s, p: ToolStoreUpdatePayload): TodoModelState => - p.key === 'todo' ? readTodoItems(p.value) : s, +export const todoSet = TodoModel.defineOp('tools.update_store', { + schema: z.object({ key: z.string(), value: z.unknown() }), + apply: (s, p) => (p.key === 'todo' ? readTodoItems(p.value) : s), }); diff --git a/packages/agent-core-v2/src/wire/model.ts b/packages/agent-core-v2/src/wire/model.ts index 87eb72af9..d387b273d 100644 --- a/packages/agent-core-v2/src/wire/model.ts +++ b/packages/agent-core-v2/src/wire/model.ts @@ -5,8 +5,10 @@ * dehydrate large inline media before persistence and rehydrate blob references * in its state after replay. * - * A `ModelDef` is a stateless descriptor: it names a model and manufactures its - * initial state via `initial`. It never holds state itself — per-scope state + * A `ModelDef` is a stateless descriptor: it names a model, manufactures its + * initial state via `initial`, and declares the model's Ops through + * `defineOp` (the model-bound form of the primitive in `op.ts`). It never + * holds state itself — per-scope state * instances are owned by `IWireService`, and domain services read them through * `wire.getModel(model)`. The optional `blobs` codec declares both directions * of the blob offload pipeline: @@ -38,7 +40,9 @@ * applied by `WireService` after every `apply`. Scope-agnostic. */ -import type { PersistedRecord } from './wireService'; +import { bindDefineOp, type DefineOpFn } from '#/wire/op'; +import type { ModelReducers } from '#/wire/types'; +import type { PersistedRecord } from '#/wire/wireService'; export type PartsTransformer = (parts: readonly unknown[]) => Promise; @@ -51,6 +55,11 @@ export interface ModelDef { readonly name: string; readonly initial: () => S; readonly blobs?: ModelBlobCodec; + /** + * Declare an Op on this model — `defineOp(model, ...)` with the model + * bound. Preferred call style: `MyModel.defineOp('my.op', { apply })`. + */ + readonly defineOp: DefineOpFn; } export interface ModelCrossReducerEntry { @@ -67,13 +76,18 @@ export function defineModel( initial: () => S, opts?: { blobs?: ModelBlobCodec; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - reducers?: Record S>; + reducers?: ModelReducers; }, ): ModelDef { - const def: ModelDef = { name, initial, blobs: opts?.blobs }; + const def: ModelDef = { + name, + initial, + blobs: opts?.blobs, + defineOp: bindDefineOp(() => def), + }; if (opts?.reducers !== undefined) { for (const [opType, reducer] of Object.entries(opts.reducers)) { + if (reducer === undefined) continue; let list = MODEL_CROSS_REDUCERS.get(opType); if (list === undefined) { list = []; @@ -88,21 +102,19 @@ export function defineModel( export interface DerivedModelDef { readonly name: string; readonly initial: () => S; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - readonly reducers: Readonly S>>; + readonly reducers: Readonly>; readonly blobs?: ModelBlobCodec; } export function defineDerivedModel( name: string, initial: () => S, - reducers: Record S>, + reducers: ModelReducers, opts?: { blobs?: ModelBlobCodec }, ): DerivedModelDef { return { name, initial, reducers, blobs: opts?.blobs }; } - export type DeepReadonly = T extends (...args: infer A) => infer R ? (...args: A) => R : T extends ReadonlyMap diff --git a/packages/agent-core-v2/src/wire/op.ts b/packages/agent-core-v2/src/wire/op.ts index 2a871245e..20dbcf002 100644 --- a/packages/agent-core-v2/src/wire/op.ts +++ b/packages/agent-core-v2/src/wire/op.ts @@ -9,20 +9,30 @@ * `goalCreate.type`). Every Op carries a mandatory pure `apply` and may carry * an optional `toEvent` that derives an `IEventBus` fact from the payload and * the post-apply state (published by `WireService` on `dispatch`, never on - * `replay`). The descriptor's payload is erased to `any` on `Op.descriptor` (mirroring - * `OP_REGISTRY`) so `Op` stays covariant in `P` — a heterogeneous batch of Ops, - * each with a different payload type, stays assignable to the single - * `dispatch(...ops: Op[])` rest parameter, while the precise payload type - * survives on `Op.payload` for the Op's own caller. Registering a duplicate - * `type` throws `DuplicateOpError` so the global Op-type namespace stays unique. - * Descriptors may opt out of persistence (`persist: false`) for live-only - * state, or opt out of timestamp stamping (`stamp: false`) for the metadata - * envelope. Both default to the v1-compatible persisted, stamped path. - * Scope-agnostic. + * `replay`). A mandatory `schema` (zod, declared before `apply`) is the + * payload's single source of truth: `P` is inferred from it, so Op authors + * never restate payload interfaces, and it is stored on the descriptor for + * payload validation at wire boundaries; the runtime paths (`dispatch` / + * `replay`) never consult it. The descriptor's payload is erased + * to `any` on `Op.descriptor` (mirroring `OP_REGISTRY`) so `Op` stays + * covariant in `P` — a heterogeneous batch of Ops, each with a different + * payload type, stays assignable to the single `dispatch(...ops: Op[])` rest + * parameter, while the precise payload type survives on `Op.payload` for the + * Op's own caller. Registering a duplicate `type` throws `DuplicateOpError` so + * the global Op-type namespace stays unique. Payloads flow from each Op + * definition into the `types.ts` registries (which map op types to `typeof` + * the Op); registration constrains only the persistence policy — a registered + * type must honor its map, an unregistered type keeps its free `persist` + * option. Descriptors may opt out of timestamp stamping (`stamp: false`) for + * the metadata envelope. Scope-agnostic. */ -import type { ModelDef } from './model'; +import type { z } from 'zod'; + +import type { ConflictingOpType, OpPersistenceOptions, OpType } from '#/wire/types'; + import { WireError, WireErrors } from './errors'; +import type { ModelDef } from './model'; export class DuplicateOpError extends WireError { constructor(readonly type: string) { @@ -36,6 +46,13 @@ export class DuplicateOpError extends WireError { export interface OpDescriptor { readonly type: K; readonly model: ModelDef; + /** + * Zod schema for the payload — the payload type's single source of truth + * (`P` is inferred from it). Stored on the descriptor so wire boundaries + * (replay of `wire.jsonl`, record export) can validate payloads against the + * Op's declared shape. Not consulted by `dispatch` / `replay` themselves. + */ + readonly schema: z.ZodType

; readonly apply: (state: S, payload: P) => S; /** * Optional fact derivation: when present, `WireService` publishes the @@ -57,32 +74,91 @@ export interface Op { readonly type: K; readonly payload: P; // eslint-disable-next-line @typescript-eslint/no-explicit-any - readonly descriptor: OpDescriptor; + readonly descriptor: OpDescriptor; } // eslint-disable-next-line @typescript-eslint/no-explicit-any export const OP_REGISTRY = new Map>(); -export function defineOp( +interface OpBehaviorOptions { + readonly schema: z.ZodType

; + readonly apply: (state: S, payload: P) => S; + readonly toEvent?: (payload: P, state: S) => unknown; + readonly stamp?: boolean; +} + +/** + * Registry-derived constraint on a defined Op's options. A type registered in + * both maps is rejected outright; a registered type must honor its map's + * persistence policy (persisted Ops may not opt out, transient Ops must pass + * `persist: false`). Key-level only — never resolves the registry's member + * types, so Op definitions stay free of registry cycles. + */ +type RegisteredOpConstraint = K extends ConflictingOpType + ? never + : K extends OpType + ? OpPersistenceOptions + : unknown; + +type DefineOpOptions = OpBehaviorOptions & { + readonly persist?: boolean; +} & RegisteredOpConstraint; + +type DefinedOp = OpDescriptor & + ((payload: P) => Op); + +/** + * Call signature of `ModelDef.defineOp` — `defineOp` with the model bound. + * Lives here so `model.ts` can type the method without duplicating the + * registry-aware generics. + */ +export interface DefineOpFn { + ( + type: K & SingleStringLiteral, + opts: DefineOpOptions, S, P>, + ): DefinedOp; +} + +type SingleStringLiteral = {} extends Record + ? never + : K extends unknown + ? [Whole] extends [K] + ? K + : never + : never; + +/** + * Build `ModelDef.defineOp` for a model under construction. The getter defers + * the model read so `defineModel` can bind while the literal is initializing. + * The casts bypass TS's inability to re-prove the literal guard + * (`SingleStringLiteral`) on an already-validated abstract `K`; callers still + * get the full guard through `DefineOpFn`'s signature. + */ +export function bindDefineOp(getModel: () => ModelDef): DefineOpFn { + const bound = (type: string, opts: unknown): unknown => + defineOp(getModel(), type as never, opts as never); + return bound as DefineOpFn; +} + +export function defineOp( model: ModelDef, - type: K, - opts: { - apply: (state: S, payload: P) => S; - toEvent?: (payload: P, state: S) => unknown; - persist?: boolean; - stamp?: boolean; - }, -): OpDescriptor & ((payload: P) => Op) { + type: K & SingleStringLiteral, + opts: DefineOpOptions, S, P>, +): DefinedOp { if (OP_REGISTRY.has(type)) { throw new DuplicateOpError(type); } + const behavior: OpBehaviorOptions & { + readonly persist?: boolean; + } = opts; const descriptor: OpDescriptor = { type, model, - apply: opts.apply, - toEvent: opts.toEvent, - persist: opts.persist, - stamp: opts.stamp, + schema: behavior.schema, + apply: behavior.apply, + toEvent: behavior.toEvent, + persist: behavior.persist, + stamp: behavior.stamp, }; OP_REGISTRY.set(type, descriptor); const factory = (payload: P): Op => ({ type, payload, descriptor }); diff --git a/packages/agent-core-v2/src/wire/types.ts b/packages/agent-core-v2/src/wire/types.ts new file mode 100644 index 000000000..8704b4b68 --- /dev/null +++ b/packages/agent-core-v2/src/wire/types.ts @@ -0,0 +1,47 @@ +/** + * `wire` domain (L2) — augmentable Op registries and their derived + * compile-time vocabulary. + * + * Domains contribute their defined Ops to `PersistedOpMap` or `TransientOpMap` + * via module augmentation (`'my.op': typeof myOp`). The selected map + * classifies whether a live dispatch writes the Op, while `OpPayload` recovers + * each Op's payload from the Op's own type: the payload flows from the Op + * definition into the registry, never the reverse, so Op authoring stays free + * of registry cycles. Persisted input remains an open wire boundary so replay + * can continue to tolerate historical and newer record types. Scope-agnostic. + */ + +export interface PersistedOpMap {} + +export interface TransientOpMap {} + +type StringKey = Extract; + +type PersistedOpKey = StringKey; +type TransientOpKey = StringKey; + +// Everything here is key-level: the maps' member types (`typeof` an Op) are +// resolved only by `OpPayload`, never by the classification aliases — an +// intersection of the maps would normalize members and re-enter Op +// definitions, forming a type cycle. +export type ConflictingOpType = Extract; +export type PersistedOpType = Exclude; +export type TransientOpType = Exclude; +export type OpType = PersistedOpType | TransientOpType; + +/** Payload carried by a defined Op (the result of `Model.defineOp(...)`). */ +export type PayloadOf = T extends (payload: infer P) => unknown ? P : never; + +export type OpPayload = K extends PersistedOpType + ? PayloadOf + : K extends TransientOpType + ? PayloadOf + : never; + +export type ModelReducers = { + [K in OpType]?: (state: S, payload: OpPayload) => S; +}; + +export type OpPersistenceOptions = K extends PersistedOpType + ? { readonly persist?: true } + : { readonly persist: false }; diff --git a/packages/agent-core-v2/src/wire/wireService.ts b/packages/agent-core-v2/src/wire/wireService.ts index 8601d6d52..71b3cb1bd 100644 --- a/packages/agent-core-v2/src/wire/wireService.ts +++ b/packages/agent-core-v2/src/wire/wireService.ts @@ -13,7 +13,7 @@ * `tokens`, each seeded with its own persistence key. `PersistedRecord` is the * on-the-wire append-log shape (`wire.jsonl`): intentionally flat * (`{ type, ...payload }`, optional `time`) so it stays byte-compatible with the - * existing `WireRecord` journal (`{ type, time?, ...fields }`) — payload fields + * existing wire journal (`{ type, time?, ...fields }`) — payload fields * sit at the top level next to `type`, never nested under a `payload` key; the * index signature keeps it scope-agnostic and domains narrow via their Op * payload types. Scope-agnostic. diff --git a/packages/agent-core-v2/src/wire/wireServiceImpl.ts b/packages/agent-core-v2/src/wire/wireServiceImpl.ts index 721a86e25..9f908de13 100644 --- a/packages/agent-core-v2/src/wire/wireServiceImpl.ts +++ b/packages/agent-core-v2/src/wire/wireServiceImpl.ts @@ -174,13 +174,14 @@ export class WireService extends Disposable implements IWireService { this._register(inst.emitter); this.derivedModels.set(model, inst); - for (const opType of Object.keys(model.reducers)) { + for (const [opType, reducer] of Object.entries(model.reducers)) { + if (reducer === undefined) continue; let list = this.reducerIndex.get(opType); if (list === undefined) { list = []; this.reducerIndex.set(opType, list); } - list.push({ inst, reducer: model.reducers[opType]! }); + list.push({ inst, reducer }); } return { diff --git a/packages/agent-core-v2/test/agent/contextMemory/stubs.ts b/packages/agent-core-v2/test/agent/contextMemory/stubs.ts index 2426303c3..63c051665 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/stubs.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/stubs.ts @@ -7,10 +7,7 @@ * `../contextMemory/stubs`). */ -import { toDisposable } from '#/_base/di/lifecycle'; import type { ServiceRegistration } from '#/_base/di/test'; -import { Event } from '#/_base/event'; -import { createHooks } from '#/hooks'; import { buildContextCompactionShape } from '#/agent/contextMemory/compactionHandoff'; import { IAgentContextMemoryService, @@ -24,20 +21,10 @@ import { IEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord'; -/** - * A no-op `IAgentWireRecordService`. `register` returns a disposable so services that - * `_register(wireRecord.register(...))` in their constructor can be disposed - * cleanly. - */ +/** A no-op `IAgentWireRecordService`. */ export function stubWireRecord(): IAgentWireRecordService { - const hooks = createHooks(['onDidRestoreRecord']) as IAgentWireRecordService['hooks']; return { _serviceBrand: undefined, - restoring: null, - postRestoring: false, - hooks, - onDidFinishResume: Event.None as Event, - register: () => toDisposable(() => {}), restore: () => Promise.resolve({}), flush: () => Promise.resolve(), close: () => Promise.resolve(), diff --git a/packages/agent-core-v2/test/agent/goal/goal.test.ts b/packages/agent-core-v2/test/agent/goal/goal.test.ts index 8ac12d5b7..a1047afce 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -10,7 +10,7 @@ import { UpdateGoalTool, UpdateGoalToolInputSchema } from '#/agent/goal/tools/up import { IAgentLoopService, type AfterStepContext, type Turn } from '#/agent/loop/loop'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IAgentUsageService } from '#/agent/usage/usage'; -import type { PersistedWireRecord, WireRecord } from '#/agent/wireRecord/wireRecord'; +import type { PersistedWireRecord } from '#/agent/wireRecord/wireRecord'; import { type DomainEvent, IEventBus } from '#/app/event/eventBus'; import { APIConnectionError, APIStatusError } from '#/app/llmProtocol/errors'; import type { ToolCall } from '#/app/llmProtocol/message'; @@ -31,7 +31,7 @@ import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/st import { stubLoopWithHooks, type StubLoop } from '../loop/stubs'; type GoalServiceTestManager = IAgentGoalService & AgentGoalService; -type GoalRecord = Extract; +type GoalRecord = PersistedWireRecord & { type: `goal.${string}` }; type AgentEvent = DomainEvent; type GoalUpdatedEvent = Extract; type TurnEndedInput = { @@ -53,7 +53,7 @@ function goalRecords(records: readonly PersistedWireRecord[]): readonly GoalReco async function restoreGoalRecords( ctx: TestAgentContext, goals: IAgentGoalService, - records: readonly WireRecord[], + records: readonly PersistedWireRecord[], ): Promise { goals.getGoal(); await ctx.restore(records as readonly PersistedWireRecord[]); diff --git a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts index 3ef81c699..5d14bb8fd 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -436,16 +436,9 @@ function stubAgentLifecycle(agents: readonly IAgentScopeHandle[]): IAgentLifecyc function stubAgentWire(flush: () => Promise = async () => {}): IAgentWireRecordService { return { _serviceBrand: undefined, - restoring: null, - postRestoring: false, getRecords: () => [], - register: () => noopDisposable, restore: async () => ({}), flush, close: async () => {}, - hooks: { - onDidRestoreRecord: { run: async () => {} }, - } as unknown as IAgentWireRecordService['hooks'], - onDidFinishResume: noopEvent, }; } diff --git a/packages/agent-core-v2/test/lint/fixtures/duplicate-ops.fixture.ts b/packages/agent-core-v2/test/lint/fixtures/duplicate-ops.fixture.ts index 7fcb93a2e..65822696f 100644 --- a/packages/agent-core-v2/test/lint/fixtures/duplicate-ops.fixture.ts +++ b/packages/agent-core-v2/test/lint/fixtures/duplicate-ops.fixture.ts @@ -5,10 +5,11 @@ * here — it exists purely to prove the scanner flags a planted duplicate. */ +import { z } from 'zod'; + import { defineModel } from '#/wire/model'; -import { defineOp } from '#/wire/op'; const FixtureModel = defineModel('fixture', () => ({})); -defineOp(FixtureModel, 'fixture.planted', { apply: (s) => s }); -defineOp(FixtureModel, 'fixture.planted', { apply: (s) => s }); +FixtureModel.defineOp('fixture.planted', { schema: z.object({}), apply: (s) => s }); +FixtureModel.defineOp('fixture.planted', { schema: z.object({}), apply: (s) => s }); diff --git a/packages/agent-core-v2/test/lint/op-uniqueness.test.ts b/packages/agent-core-v2/test/lint/op-uniqueness.test.ts index fc833f4bc..f25dd0966 100644 --- a/packages/agent-core-v2/test/lint/op-uniqueness.test.ts +++ b/packages/agent-core-v2/test/lint/op-uniqueness.test.ts @@ -2,16 +2,132 @@ import { readdirSync, readFileSync, statSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; import { defineModel } from '#/wire/model'; -import { DuplicateOpError, defineOp } from '#/wire/op'; +import { DuplicateOpError, OP_REGISTRY } from '#/wire/op'; +import type { OpPayload } from '#/wire/types'; + +declare module '#/wire/types' { + interface PersistedOpMap { + 'test.op.persisted': typeof persistedOp; + 'test.op.conflicting': typeof persistedOp; + } + + interface TransientOpMap { + 'test.op.transient': typeof transientOp; + 'test.op.conflicting': typeof persistedOp; + } +} const __dirname = dirname(fileURLToPath(import.meta.url)); const PKG_ROOT = join(__dirname, '..', '..'); const SRC_ROOT = join(PKG_ROOT, 'src'); const FIXTURE_ROOT = join(__dirname, 'fixtures'); -const DEFINE_OP_RE = /defineOp\s*\(\s*\w+\s*,\s*['"]([^'"]+)['"]/g; +const DEFINE_OP_RE = /\.defineOp\s*\(\s*['"]([^'"]+)['"]/g; + +const testModel = defineModel('typecheck.model', () => ({ value: 0 })); + +const persistedOp = testModel.defineOp('test.op.persisted', { + schema: z.object({ value: z.number() }), + apply: (state, payload: { value: number }) => ({ value: state.value + payload.value }), +}); + +const transientOp = testModel.defineOp('test.op.transient', { + schema: z.object({ value: z.number() }), + persist: false, + apply: (_state, payload) => ({ value: payload.value }), +}); + +function typecheckRegisteredOps(): void { + // The registry recovers each Op's payload from the Op's own type. + type RegisteredPayload = OpPayload<'test.op.persisted'>; + const registeredPayload: RegisteredPayload = { value: 1 }; + persistedOp(registeredPayload); + // @ts-expect-error Op factories carry the payload from their own definition + persistedOp({ value: '1' }); + // @ts-expect-error transient Op factories carry the payload from their own definition + transientOp({ value: '1' }); + + const unregistered = testModel.defineOp('test.op.unregistered', { + schema: z.object({ by: z.number() }), + persist: false, + apply: (state, payload) => ({ + value: state.value + payload.by, + }), + toEvent: (payload) => ({ by: payload.by }), + }); + unregistered({ by: 1 }); + // @ts-expect-error unregistered Op factories retain their inferred payload + unregistered({ by: '1' }); + + const wrongPayloadSchema = z.object({ value: z.string() }); + testModel.defineOp('test.op.unregistered', { + // @ts-expect-error schemas must produce the Op's payload type + schema: wrongPayloadSchema, + apply: (state, payload: { value: number }) => ({ value: state.value + payload.value }), + }); + + const incorrectlyTransient = { + schema: z.object({ value: z.number() }), + persist: false as const, + apply: (state: { value: number }, payload: { value: number }) => ({ + value: state.value + payload.value, + }), + }; + // @ts-expect-error persisted Op types cannot opt out of persistence + testModel.defineOp('test.op.persisted', incorrectlyTransient); + + const missingTransientMarker = { + schema: z.object({ value: z.number() }), + apply: (state: { value: number }, payload: { value: number }) => ({ + value: state.value + payload.value, + }), + }; + // @ts-expect-error transient Op types require persist: false + testModel.defineOp('test.op.transient', missingTransientMarker); + + const incorrectlyPersistedTransient = { + schema: z.object({}), + persist: true as const, + apply: (state: { value: number }) => state, + }; + // @ts-expect-error transient Op types cannot opt into persistence + testModel.defineOp('test.op.transient', incorrectlyPersistedTransient); + + // @ts-expect-error the same Op type cannot belong to both registries + testModel.defineOp('test.op.conflicting', { + schema: z.object({ value: z.number() }), + apply: (_state: { value: number }, payload: { value: number }) => ({ + value: payload.value, + }), + }); + + const dynamicType: string = 'test.op.persisted'; + // @ts-expect-error Op definitions require a literal type so registry constraints cannot be bypassed + testModel.defineOp(dynamicType, { schema: z.object({}), apply: (state) => state }); + + const unionType = 'test.op.persisted' as 'test.op.persisted' | 'test.op.unregistered'; + // @ts-expect-error Op definitions reject unions that could mix registered and legacy types + testModel.defineOp(unionType, { schema: z.object({}), apply: (state) => state }); + + const templateType = 'test.op.persisted' as `test.op.${string}`; + // @ts-expect-error Op definitions reject non-literal template string types + testModel.defineOp(templateType, { + schema: z.object({}), + apply: (state: { value: number }) => state, + }); + + const brandedType = 'test.op.persisted' as string & { readonly opType: unique symbol }; + // @ts-expect-error Op definitions reject branded strings that could hide a registered type + testModel.defineOp(brandedType, { + schema: z.object({}), + apply: (state: { value: number }) => state, + }); +} + +void typecheckRegisteredOps; function walk(dir: string): string[] { const out: string[] = []; @@ -54,9 +170,14 @@ function duplicates(seen: Map): Map { describe('op-uniqueness', () => { it('defineOp throws DuplicateOpError when a type is registered twice', () => { const model = defineModel('lint.model', () => ({})); - const type = `lint.dup.${Date.now()}`; - defineOp(model, type, { apply: (s) => s }); - expect(() => defineOp(model, type, { apply: (s) => s })).toThrow(DuplicateOpError); + try { + model.defineOp('lint.duplicate.runtime', { schema: z.object({}), apply: (s) => s }); + expect(() => + model.defineOp('lint.duplicate.runtime', { schema: z.object({}), apply: (s) => s }), + ).toThrow(DuplicateOpError); + } finally { + OP_REGISTRY.delete('lint.duplicate.runtime'); + } }); it('finds no duplicate defineOp types across src/', () => { diff --git a/packages/agent-core-v2/test/wire/store-event.test.ts b/packages/agent-core-v2/test/wire/store-event.test.ts index e44d56ae5..358774c2d 100644 --- a/packages/agent-core-v2/test/wire/store-event.test.ts +++ b/packages/agent-core-v2/test/wire/store-event.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { z } from 'zod'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; @@ -10,7 +11,6 @@ import { InMemoryStorageService } from '#/persistence/backends/memory/inMemorySt import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { defineModel } from '#/wire/model'; -import { defineOp } from '#/wire/op'; import { IAgentWireService } from '#/wire/tokens'; import type { IWireService, PersistedRecord } from '#/wire/wireService'; import { WireService } from '#/wire/wireServiceImpl'; @@ -28,19 +28,23 @@ const KEY = 'store-event-test'; const CounterModel = defineModel('store-event.counter', () => ({ value: 0 })); const OtherModel = defineModel('store-event.other', () => ({ value: 0 })); -const addWithEvent = defineOp(CounterModel, 'store-event.counter.add', { - apply: (s, p: { by: number }) => ({ value: s.value + p.by }), +const addWithEvent = CounterModel.defineOp('store-event.counter.add', { + schema: z.object({ by: z.number() }), + apply: (s, p) => ({ value: s.value + p.by }), toEvent: (_p, state) => ({ type: 'store-event.added' as const, value: state.value }), }); -const addNoEvent = defineOp(CounterModel, 'store-event.counter.addNoEvent', { - apply: (s, p: { by: number }) => ({ value: s.value + p.by }), +const addNoEvent = CounterModel.defineOp('store-event.counter.addNoEvent', { + schema: z.object({ by: z.number() }), + apply: (s, p) => ({ value: s.value + p.by }), }); -const addUndefinedEvent = defineOp(CounterModel, 'store-event.counter.addUndef', { - apply: (s, p: { by: number }) => ({ value: s.value + p.by }), +const addUndefinedEvent = CounterModel.defineOp('store-event.counter.addUndef', { + schema: z.object({ by: z.number() }), + apply: (s, p) => ({ value: s.value + p.by }), toEvent: () => undefined, }); -const otherSet = defineOp(OtherModel, 'store-event.other.set', { - apply: (_s, p: { value: number }) => ({ value: p.value }), +const otherSet = OtherModel.defineOp('store-event.other.set', { + schema: z.object({ value: z.number() }), + apply: (_s, p) => ({ value: p.value }), toEvent: (p) => ({ type: 'store-event.otherSet' as const, value: p.value }), }); diff --git a/packages/agent-core-v2/test/wire/wire-compat.test.ts b/packages/agent-core-v2/test/wire/wire-compat.test.ts index 06b1f3a9c..0498d5fbc 100644 --- a/packages/agent-core-v2/test/wire/wire-compat.test.ts +++ b/packages/agent-core-v2/test/wire/wire-compat.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; +import { z } from 'zod'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; @@ -14,7 +15,6 @@ import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageSe import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { defineModel } from '#/wire/model'; -import { defineOp } from '#/wire/op'; import { IAgentWireService } from '#/wire/tokens'; import type { PersistedRecord } from '#/wire/wireService'; import { WireService } from '#/wire/wireServiceImpl'; @@ -25,11 +25,13 @@ const KEY = 'round-trip'; const CounterModel = defineModel('compat.counter', () => ({ value: 0 })); const TagsModel = defineModel('compat.tags', () => ({ tags: [] as string[] })); -const counterSet = defineOp(CounterModel, 'compat.counter.set', { - apply: (_s, p: { value: number }) => ({ value: p.value }), +const counterSet = CounterModel.defineOp('compat.counter.set', { + schema: z.object({ value: z.number() }), + apply: (_s, p) => ({ value: p.value }), }); -const tagsAdd = defineOp(TagsModel, 'compat.tags.add', { - apply: (s, p: { tag: string }) => ({ tags: [...s.tags, p.tag] }), +const tagsAdd = TagsModel.defineOp('compat.tags.add', { + schema: z.object({ tag: z.string() }), + apply: (s, p) => ({ tags: [...s.tags, p.tag] }), }); const cleanups: string[] = []; diff --git a/packages/agent-core-v2/test/wire/wireServiceImpl.test.ts b/packages/agent-core-v2/test/wire/wireServiceImpl.test.ts index 7ad75cdb3..722d389a0 100644 --- a/packages/agent-core-v2/test/wire/wireServiceImpl.test.ts +++ b/packages/agent-core-v2/test/wire/wireServiceImpl.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { z } from 'zod'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; @@ -9,7 +10,6 @@ import { InMemoryStorageService } from '#/persistence/backends/memory/inMemorySt import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { defineModel } from '#/wire/model'; -import { defineOp } from '#/wire/op'; import { IAgentWireService } from '#/wire/tokens'; import type { IWireService, PersistedRecord } from '#/wire/wireService'; import { CycleError, WireService } from '#/wire/wireServiceImpl'; @@ -24,23 +24,27 @@ const trace: string[] = []; const CounterModel = defineModel('store.counter', () => ({ value: 0 })); const OtherModel = defineModel('store.other', () => ({ value: 0 })); -const counterAdd = defineOp(CounterModel, 'store.counter.add', { - apply: (s, p: { by: number }) => { +const counterAdd = CounterModel.defineOp('store.counter.add', { + schema: z.object({ by: z.number() }), + apply: (s, p) => { trace.push('apply.counter'); return { value: s.value + p.by }; }, }); -const otherSet = defineOp(OtherModel, 'store.other.set', { - apply: (_s, p: { value: number }) => { +const otherSet = OtherModel.defineOp('store.other.set', { + schema: z.object({ value: z.number() }), + apply: (_s, p) => { trace.push('apply.other'); return { value: p.value }; }, }); -const otherInc = defineOp(OtherModel, 'store.other.inc', { +const otherInc = OtherModel.defineOp('store.other.inc', { + schema: z.object({}), apply: (s) => ({ value: s.value + 1 }), }); // Test-only op that violates the new-reference convention by mutating its input. -const mutateCounter = defineOp(CounterModel, 'store.counter.mutate', { +const mutateCounter = CounterModel.defineOp('store.counter.mutate', { + schema: z.object({}), apply: (s) => { (s as { value: number }).value = 123; return s; From 20615902c2c3776d17c6c334cedec1c8723222b1 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 13 Jul 2026 16:16:44 +0800 Subject: [PATCH 50/80] fix(tui): deliver pasted media in /skill args and Ctrl-S steer (#1588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tui): deliver pasted media in /skill and plugin command args via file tags Skill and plugin command args are a plain-text RPC channel, so pasted image/video placeholders never reached the model. Rewrite matched placeholders into tags pointing at cache-dir copies (the same convention pasted videos already use), and gate them on model media capabilities like the normal prompt path. * fix(tui): deliver pasted media when steering with Ctrl-S Ctrl-S steer dropped media: queued messages were reduced to their text and the editor draft never went through placeholder extraction, so session.steer only ever received placeholder strings. Carry the queued items' extracted parts, extract the editor draft on the spot (with the same capability gate as a normal submit), and merge everything into a part list when any item has media. * fix(tui): avoid empty text separator when steering consecutive media The '\n\n' item separator could land as a standalone whitespace-only text part between two media parts, which normalizePromptInput rejects — failing the steer after the queue and editor draft were already cleared. Only append the separator onto a trailing text part so it always merges. * fix(tui): keep blank-line separator between media and text steer items The separator was only appended onto trailing text parts, so a media item followed by a plain-text steer item lost the historical '\n\n' separation. Always append it — it merges into the adjacent text part — except between two touching media parts, where a standalone whitespace-only text part would be rejected by normalizePromptInput. * fix(tui): use escape-proof media references in /skill args Skill args are XML-escaped on render (renderSkillAttributes + expandSkillParameters), which mangled the tags into escaped text. Add a 'plain' reference style (Attached image file: (open it with ReadMediaFile)) with no tag/attribute boundary characters and use it for the skill channel; plugin command args expand verbatim and keep the tag form. * fix(tui): strip XML boundary chars from plain-style video cache names Video cache names carry the original label, which permits <>&"; in the plain (/skill) reference style those chars get XML-escaped in args and the reference no longer matches the file on disk. Strip them from the cache name in plain mode; the tag channel is unchanged (its attribute escaping already handles them). * fix(tui): surface media materialization failures as TUI errors The cache copy inside media placeholder rewriting can throw (unwritable cache dir, vanished video source) before any RPC .catch is installed, escaping the fire-and-forget slash-command dispatcher as an unhandled rejection. Catch it in sendSkillActivation, activatePluginCommand, and the Ctrl-S draft extraction, show an error, and leave queue/draft/session state untouched. --- .changeset/fix-skill-command-media.md | 5 + .changeset/fix-steer-media.md | 5 + .../src/tui/controllers/editor-keyboard.ts | 53 ++++- apps/kimi-code/src/tui/kimi-tui.ts | 112 +++++++-- apps/kimi-code/src/tui/types.ts | 12 + .../src/tui/utils/image-placeholder.ts | 112 ++++++++- .../test/tui/input/image-placeholder.test.ts | 150 +++++++++++- .../test/tui/kimi-tui-message-flow.test.ts | 219 ++++++++++++++++++ 8 files changed, 639 insertions(+), 29 deletions(-) create mode 100644 .changeset/fix-skill-command-media.md create mode 100644 .changeset/fix-steer-media.md diff --git a/.changeset/fix-skill-command-media.md b/.changeset/fix-skill-command-media.md new file mode 100644 index 000000000..60e088b13 --- /dev/null +++ b/.changeset/fix-skill-command-media.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix pasted media being dropped from /skill and plugin command arguments. diff --git a/.changeset/fix-steer-media.md b/.changeset/fix-steer-media.md new file mode 100644 index 000000000..5d0f8ac61 --- /dev/null +++ b/.changeset/fix-steer-media.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix pasted images being dropped when steering with Ctrl-S. diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 383694c07..5b6d95e19 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -15,7 +15,8 @@ import { } from '../constant/kimi-tui'; import { formatErrorMessage } from '../utils/event-payload'; import type { ImageAttachmentStore } from '../utils/image-attachment-store'; -import type { PendingExit, QueuedMessage } from '../types'; +import { extractMediaAttachments } from '../utils/image-placeholder'; +import type { PendingExit, QueuedMessage, SteerInputItem } from '../types'; import type { TUIState } from '../tui-state'; import type { BtwPanelController } from './btw-panel'; @@ -32,7 +33,12 @@ export interface EditorKeyboardHost { handleUserInput(text: string): void; readonly btwPanelController: BtwPanelController; - steerMessage(session: Session, input: string[]): void; + steerMessage(session: Session, input: readonly SteerInputItem[]): void; + validateMediaCapabilities(extraction: { + hasMedia: boolean; + imageAttachmentIds: readonly number[]; + videoAttachmentIds: readonly number[]; + }): boolean; recallLastQueued(): QueuedMessage | undefined; showError(msg: string): void; track(event: string, props?: Record): void; @@ -250,22 +256,53 @@ export class EditorKeyboardController { // after the current task instead of being injected into the turn as text. const queued = host.state.queuedMessages; const steerable = queued.filter((m) => m.mode !== 'bash'); - host.state.queuedMessages = queued.filter((m) => m.mode === 'bash'); - const parts: string[] = []; + const items: SteerInputItem[] = []; for (const m of steerable) { const trimmed = m.text.trim(); - if (trimmed.length > 0) parts.push(trimmed); + if (trimmed.length > 0) { + // Queued items carry the parts extracted when they were submitted + // (and were already capability-validated then). + items.push({ text: trimmed, parts: m.parts, imageAttachmentIds: m.imageAttachmentIds }); + } + } + let editorExtraction: ReturnType | undefined; + if (!editorIsBash && text.length > 0) { + try { + editorExtraction = extractMediaAttachments(text, this.imageStore); + } catch (error) { + // Cache copy failed (e.g. the pasted video's source vanished) — + // leave the queue and the editor draft untouched. + host.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`); + return; + } + items.push({ + text, + parts: editorExtraction.hasMedia ? editorExtraction.parts : undefined, + imageAttachmentIds: + editorExtraction.imageAttachmentIds.length > 0 + ? editorExtraction.imageAttachmentIds + : undefined, + }); } - if (!editorIsBash && text.length > 0) parts.push(text); - if (parts.length > 0) { + if (items.length > 0) { + // The editor draft is fresh input: gate it on the model's media + // capabilities before splicing the queue, so a rejection leaves the + // queue and the draft untouched. + if ( + editorExtraction !== undefined && + !host.validateMediaCapabilities(editorExtraction) + ) { + return; + } + host.state.queuedMessages = queued.filter((m) => m.mode === 'bash'); if (!editorIsBash) editor.setText(''); const session = host.session; if (host.state.appState.model.trim().length === 0 || session === undefined) { host.showError(LLM_NOT_SET_MESSAGE); } else { - host.steerMessage(session, parts); + host.steerMessage(session, items); } } host.updateQueueDisplay(); diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 62d1b2370..a05165ca2 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -123,6 +123,7 @@ import { type LivePaneState, type LoginProgressSpinnerHandle, type QueuedMessage, + type SteerInputItem, type TranscriptEntry, type TUIStartupOptions, type TUIStartupState, @@ -132,7 +133,7 @@ import { isDeadTerminalError } from './utils/dead-terminal'; import { formatErrorMessage } from './utils/event-payload'; import { pickForegroundTasks } from './utils/foreground-task'; import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; -import { extractMediaAttachments } from './utils/image-placeholder'; +import { extractMediaAttachments, rewriteMediaPlaceholders } from './utils/image-placeholder'; import { hasPatchChanges } from './utils/object-patch'; import { sessionRowsForPicker } from './utils/session-picker-rows'; import { formatBashOutputForDisplay } from './utils/shell-output'; @@ -238,6 +239,50 @@ interface SendMessageOptions { readonly hasMedia?: boolean; } +/** + * Flatten steer items into the payload `session.steer` expects: the + * historical `'\n\n'`-joined string when nothing carries media, or a + * merged part list when any item has extracted media parts (queued image + * messages, or the editor draft after placeholder extraction). + * + * Items are separated by the historical `'\n\n'`, which merges into the + * adjacent text part. The one exception is two touching media parts: a + * standalone `{type:'text',text:'\n\n'}` between them would be rejected + * by `normalizePromptInput` as an empty text part, so the separator is + * dropped there (media parts are self-delimiting anyway). + */ +function combineSteerInput(items: readonly SteerInputItem[]): string | PromptPart[] { + const hasMedia = items.some((item) => item.parts !== undefined && item.parts.length > 0); + if (!hasMedia) return items.map((item) => item.text).join('\n\n'); + const parts: PromptPart[] = []; + for (const item of items) { + const startsWithMedia = + item.parts !== undefined && item.parts.length > 0 && item.parts[0]?.type !== 'text'; + const lastIsMedia = parts.length > 0 && parts.at(-1)?.type !== 'text'; + if (parts.length > 0 && !(lastIsMedia && startsWithMedia)) { + appendSteerText(parts, '\n\n'); + } + if (item.parts !== undefined && item.parts.length > 0) { + for (const part of item.parts) { + if (part.type === 'text') appendSteerText(parts, part.text); + else parts.push(part); + } + } else { + appendSteerText(parts, item.text); + } + } + return parts; +} + +function appendSteerText(parts: PromptPart[], text: string): void { + const last = parts.at(-1); + if (last?.type === 'text') { + parts[parts.length - 1] = { type: 'text', text: last.text + text }; + return; + } + parts.push({ type: 'text', text }); +} + /** How long the one-shot "moved to background" footer hint stays visible. */ const DETACH_HINT_DISPLAY_MS = 4_000; @@ -1095,9 +1140,11 @@ export class KimiTUI { this.state.ui.requestRender(); } - private validateMediaCapabilities( - extraction: ReturnType, - ): boolean { + validateMediaCapabilities(extraction: { + hasMedia: boolean; + imageAttachmentIds: readonly number[]; + videoAttachmentIds: readonly number[]; + }): boolean { if (!extraction.hasMedia) return true; if ( extraction.imageAttachmentIds.length > 0 && @@ -1243,8 +1290,22 @@ export class KimiTUI { } sendSkillActivation(session: Session, skillName: string, skillArgs: string): void { + // Args are a plain-text channel, so pasted media can't ride along as + // inline parts. Skill args are XML-escaped on render (renderSkillAttributes + // + expandSkillParameters), so rewrite placeholders into escape-proof + // plain-text file references the model can open with ReadMediaFile. + let rewrite: ReturnType; + try { + rewrite = rewriteMediaPlaceholders(skillArgs, this.imageStore, 'plain'); + } catch (error) { + // Cache copy failed (unwritable cache dir, vanished video source…); + // nothing has been dispatched yet, so just report and keep the input. + this.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`); + return; + } + if (!this.validateMediaCapabilities(rewrite)) return; this.beginSessionRequest(); - void session.activateSkill(skillName, skillArgs).catch((error: unknown) => { + void session.activateSkill(skillName, rewrite.text).catch((error: unknown) => { const message = formatErrorMessage(error); this.failSessionRequest(`Skill "${skillName}" failed: ${message}`); }); @@ -1256,11 +1317,24 @@ export class KimiTUI { commandName: string, args: string, ): void { + // Plugin command args are expanded verbatim (no XML escaping), so the + // standard tag convention works — see + // sendSkillActivation for the escaped-channel variant. + let rewrite: ReturnType; + try { + rewrite = rewriteMediaPlaceholders(args, this.imageStore, 'tag'); + } catch (error) { + this.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`); + return; + } + if (!this.validateMediaCapabilities(rewrite)) return; this.beginSessionRequest(); - void session.activatePluginCommand(pluginId, commandName, args).catch((error: unknown) => { - const message = formatErrorMessage(error); - this.failSessionRequest(`Command "${pluginId}:${commandName}" failed: ${message}`); - }); + void session + .activatePluginCommand(pluginId, commandName, rewrite.text) + .catch((error: unknown) => { + const message = formatErrorMessage(error); + this.failSessionRequest(`Command "${pluginId}:${commandName}" failed: ${message}`); + }); } private sendMessage(session: Session, input: string, options?: SendMessageOptions): void { @@ -1275,31 +1349,35 @@ export class KimiTUI { this.sendMessageInternal(session, input, options); } - steerMessage(session: Session, input: string[]): void { + steerMessage(session: Session, input: readonly SteerInputItem[]): void { if (this.deferUserMessages || this.state.appState.isCompacting) { - for (const part of input) { - this.enqueueMessage(part); + for (const item of input) { + this.enqueueMessage(item.text, item); } return; } if (this.state.appState.streamingPhase === 'idle') { - for (const part of input) { - this.sendMessageInternal(session, part); + for (const item of input) { + this.sendMessageInternal(session, item.text, item); } return; } - for (const part of input) { + for (const item of input) { this.appendTranscriptEntry({ id: nextTranscriptId(), kind: 'user', turnId: this.streamingUI.getTurnContext().turnId, renderMode: 'plain', - content: part, + content: item.text, + imageAttachmentIds: + item.imageAttachmentIds !== undefined && item.imageAttachmentIds.length > 0 + ? item.imageAttachmentIds + : undefined, }); } - void session.steer(input.join('\n\n')).catch((error: unknown) => { + void session.steer(combineSteerInput(input)).catch((error: unknown) => { const message = formatErrorMessage(error); this.showError(`Failed to steer: ${message}`); }); diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 6dcdccdd1..e79d2c8b4 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -206,6 +206,18 @@ export interface QueuedMessage { readonly mode?: 'prompt' | 'bash'; } +/** + * One unit of Ctrl-S steer input: a queued message or the editor draft, + * with the media parts extracted at submit/paste time so images and video + * tags survive the steer path (which accepts full prompt parts, not just + * text). + */ +export interface SteerInputItem { + readonly text: string; + readonly parts?: readonly PromptPart[]; + readonly imageAttachmentIds?: readonly number[]; +} + export const INITIAL_LIVE_PANE: LivePaneState = { mode: 'idle', pendingApproval: null, diff --git a/apps/kimi-code/src/tui/utils/image-placeholder.ts b/apps/kimi-code/src/tui/utils/image-placeholder.ts index 4017c4b2d..56edcee88 100644 --- a/apps/kimi-code/src/tui/utils/image-placeholder.ts +++ b/apps/kimi-code/src/tui/utils/image-placeholder.ts @@ -18,7 +18,7 @@ */ import { randomUUID } from 'node:crypto'; -import { copyFileSync, mkdirSync } from 'node:fs'; +import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { PromptPart } from '@moonshot-ai/kimi-code-sdk'; @@ -101,6 +101,78 @@ export function extractMediaAttachments( }; } +export interface MediaTagRewriteResult { + /** Input text with resolved placeholders replaced by media references. */ + text: string; + hasMedia: boolean; + imageAttachmentIds: number[]; + videoAttachmentIds: number[]; +} + +/** + * How a resolved placeholder is rendered into command args: + * - `'tag'`: the `` convention, for channels + * that pass args through verbatim (plugin commands). + * - `'plain'`: a plain-text file reference with no XML tag/attribute + * boundary characters, for channels that XML-escape args (`/skill` + * args are escaped by both `renderSkillAttributes` and + * `expandSkillParameters`, which would mangle the tag form). + */ +export type MediaReferenceStyle = 'tag' | 'plain'; + +/** + * Rewrite media placeholders in slash-command args (`/skill:foo …`, + * plugin commands) into references pointing at cache-dir copies. Command + * args are a plain-text channel — unlike `extractMediaAttachments`, which + * inlines image parts for the prompt endpoint — so the model reaches the + * media through `ReadMediaFile` instead, the same way it already handles + * pasted videos. + * + * Surrounding text is preserved verbatim (args are user content, not + * LLM parts), and unresolved placeholders stay literal. + */ +export function rewriteMediaPlaceholders( + text: string, + store: ImageAttachmentStore, + style: MediaReferenceStyle = 'tag', +): MediaTagRewriteResult { + const imageAttachmentIds: number[] = []; + const videoAttachmentIds: number[] = []; + let cursor = 0; + let out = ''; + + PLACEHOLDER_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = PLACEHOLDER_REGEX.exec(text)) !== null) { + const [literal, kind, idStr] = match; + if (kind !== 'image' && kind !== 'video') continue; + if (idStr === undefined) continue; + const id = Number.parseInt(idStr, 10); + const attachment = store.get(id); + if (attachment === undefined) continue; // stale / user-typed — leave as text + if (attachment.kind !== kind) continue; + out += text.slice(cursor, match.index); + if (attachment.kind === 'video') { + const path = materializeVideoToCache(attachment, style === 'plain'); + out += style === 'plain' ? formatMediaReference('video', path) : formatMediaTag('video', path); + videoAttachmentIds.push(id); + } else { + const path = materializeImageToCache(attachment); + out += style === 'plain' ? formatMediaReference('image', path) : formatMediaTag('image', path); + imageAttachmentIds.push(id); + } + cursor = match.index + literal.length; + } + + const hasMedia = imageAttachmentIds.length + videoAttachmentIds.length > 0; + return { + text: hasMedia ? out + text.slice(cursor) : text, + hasMedia, + imageAttachmentIds, + videoAttachmentIds, + }; +} + function pushText(parts: PromptPart[], segment: string): void { if (segment.length === 0) return; // Keep whitespace-only segments only when they sit between non-empty @@ -123,14 +195,38 @@ function imagePartForAttachment(att: ImageAttachment): PromptPart { }; } -function materializeVideoToCache(att: VideoAttachment): string { +function materializeVideoToCache(att: VideoAttachment, escapeProofName = false): string { const cacheDir = getCacheDir(); mkdirSync(cacheDir, { recursive: true }); - const target = join(cacheDir, `${randomUUID()}-${att.label}`); + // The label permits XML boundary chars (`<>&"`); plain references go + // through skill-arg escaping, where they would no longer match the file + // on disk, so strip them from the cache name in that mode. + const label = escapeProofName ? att.label.replaceAll(/[<>&"]/g, '_') : att.label; + const target = join(cacheDir, `${randomUUID()}-${label}`); copyFileSync(att.sourcePath, target); return target; } +const IMAGE_MIME_EXTENSION: Readonly> = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/bmp': 'bmp', + 'image/tiff': 'tif', +}; + +function materializeImageToCache(att: ImageAttachment): string { + const cacheDir = getCacheDir(); + mkdirSync(cacheDir, { recursive: true }); + // ReadMediaFile sniffs the real format from the bytes, so the extension + // only needs to be a reasonable hint. + const ext = IMAGE_MIME_EXTENSION[att.mime.trim().toLowerCase()] ?? 'img'; + const target = join(cacheDir, `${randomUUID()}.${ext}`); + writeFileSync(target, att.bytes); + return target; +} + function captionForCompressedImage(att: ImageAttachment): string { const original = att.original; if (original === undefined) return ''; @@ -155,6 +251,16 @@ function formatMediaTag(tag: 'image' | 'video', path: string): string { return `<${tag} path="${escapeAttribute(path)}">`; } +/** + * Plain-text media reference for channels that XML-escape args (`/skill`). + * Free of `& < > "` (UUID image names; boundary chars stripped from video + * cache names — see materializeVideoToCache) so it survives + * `escapeXml`/`escapeXmlTags` untouched. + */ +function formatMediaReference(kind: 'image' | 'video', path: string): string { + return `Attached ${kind} file: ${path} (open it with ReadMediaFile)`; +} + function escapeAttribute(value: string): string { return value .replaceAll('&', '&') diff --git a/apps/kimi-code/test/tui/input/image-placeholder.test.ts b/apps/kimi-code/test/tui/input/image-placeholder.test.ts index e157cbf8c..477727293 100644 --- a/apps/kimi-code/test/tui/input/image-placeholder.test.ts +++ b/apps/kimi-code/test/tui/input/image-placeholder.test.ts @@ -6,7 +6,10 @@ import { describe, it, expect } from 'vitest'; import { KIMI_CODE_HOME_ENV } from '#/constant/app'; import { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; -import { extractMediaAttachments } from '#/tui/utils/image-placeholder'; +import { + extractMediaAttachments, + rewriteMediaPlaceholders, +} from '#/tui/utils/image-placeholder'; import { getCacheDir } from '#/utils/paths'; function storeWith( @@ -221,3 +224,148 @@ describe('extractMediaAttachments', () => { expect(r.parts[0]?.type).toBe('image_url'); }); }); + +describe('rewriteMediaPlaceholders', () => { + it('returns plain text untouched with hasMedia=false', () => { + const store = new ImageAttachmentStore(); + const r = rewriteMediaPlaceholders('just some args', store); + expect(r.text).toBe('just some args'); + expect(r.hasMedia).toBe(false); + expect(r.imageAttachmentIds).toEqual([]); + expect(r.videoAttachmentIds).toEqual([]); + }); + + it('rewrites an image placeholder into a cache-path image tag', () => { + const { cleanup } = setupTempCache(); + try { + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + const { store, placeholder } = storeWith(bytes); + const r = rewriteMediaPlaceholders(`look at ${placeholder} please`, store); + expect(r.hasMedia).toBe(true); + expect(r.imageAttachmentIds).toEqual([1]); + const m = /^look at <\/image> please$/.exec(r.text); + if (!m) throw new Error(`no image tag found in: ${r.text}`); + expect(m[1]!.startsWith(getCacheDir())).toBe(true); + expect(m[1]!.endsWith('.png')).toBe(true); + expect(new Uint8Array(readFileSync(m[1]!))).toEqual(bytes); + } finally { + cleanup(); + } + }); + + it('rewrites a video placeholder into a cache-path video tag', () => { + const { cleanup } = setupTempCache(); + const srcDir = makeTempDir(); + try { + const srcVideo = join(srcDir, 'clip.mov'); + writeFileSync(srcVideo, 'video-bytes'); + const store = new ImageAttachmentStore(); + const att = store.addVideo('video/quicktime', srcVideo); + const r = rewriteMediaPlaceholders(att.placeholder, store); + expect(r.hasMedia).toBe(true); + expect(r.videoAttachmentIds).toEqual([1]); + const m = /