From 27db6ee0e5cb0752a29324eed01a6600fb12ade7 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Tue, 30 Jun 2026 20:18:46 +0800 Subject: [PATCH 01/34] fix(runtime): recover missing replies after SSE disconnect --- src/main/runtime-sse-ipc.test.ts | 62 ++++++++++ src/main/runtime-sse-ipc.ts | 6 +- .../src/store/chat-store-runtime.test.ts | 42 ++++++- src/renderer/src/store/chat-store-runtime.ts | 107 +++++++++++++++++- 4 files changed, 213 insertions(+), 4 deletions(-) diff --git a/src/main/runtime-sse-ipc.test.ts b/src/main/runtime-sse-ipc.test.ts index 572088c6e..051a91d5e 100644 --- a/src/main/runtime-sse-ipc.test.ts +++ b/src/main/runtime-sse-ipc.test.ts @@ -64,6 +64,9 @@ describe('runtime-sse-ipc', () => { if (chunk === '__ERROR__') { throw new Error('Network Disruption') } + if (chunk === '__TERMINATED__') { + throw new Error('terminated') + } return { done: false, value: enc.encode(chunk) } } } @@ -167,4 +170,63 @@ describe('runtime-sse-ipc', () => { expect(allEvents[2].seq).toBe(3) expect(allEvents[2].text).toBe('bye') }) + + it('treats terminated stream reads as reconnectable SSE disconnects', async () => { + registerRuntimeSseIpc({ + ipcMain: mockIpcMain, + store: mockStore, + ensureRuntime: mockEnsureRuntime, + logError: mockLogError + }) + + const startHandler = handlers.get('runtime:sse:start') + expect(startHandler).toBeDefined() + + const stream1 = mockReadableStream([ + 'id: 7\ndata: {"text": "partial"}\n\n', + '__TERMINATED__' + ]) + const stream2 = mockReadableStream([ + 'id: 8\ndata: {"text": "final"}\n\n' + ]) + + mockFetch.mockImplementation(async () => { + const callCount = mockFetch.mock.calls.length + if (callCount === 1) { + return { ok: true, status: 200, body: stream1 } + } + if (callCount === 2) { + return { ok: true, status: 200, body: stream2 } + } + return { ok: false, status: 400 } + }) + + const startRes = await startHandler!(mockEvent, { + threadId: 'thread-terminated', + sinceSeq: 0 + }) + + await vi.advanceTimersByTimeAsync(750) + + expect(mockFetch).toHaveBeenCalledTimes(3) + expect(mockFetch.mock.calls[1][0].toString()).toContain('since_seq=7') + expect(mockEvent.sender.send).not.toHaveBeenCalledWith( + 'runtime:sse-error', + expect.objectContaining({ streamId: startRes.streamId, message: 'terminated' }) + ) + expect(mockLogError).not.toHaveBeenCalledWith( + 'sse', + expect.stringContaining('SSE stream error'), + expect.objectContaining({ message: 'terminated' }) + ) + + const stopHandler = handlers.get('runtime:sse:stop') + expect(stopHandler).toBeDefined() + await stopHandler!(mockEvent, startRes.streamId) + + const allEvents = mockEvent.sender.send.mock.calls + .filter((call: any) => call[0] === 'runtime:sse-event') + .flatMap((call: any) => call[1].events) + expect(allEvents.map((event: any) => event.seq)).toEqual([7, 8]) + }) }) diff --git a/src/main/runtime-sse-ipc.ts b/src/main/runtime-sse-ipc.ts index 3af3e7db2..152747c7d 100644 --- a/src/main/runtime-sse-ipc.ts +++ b/src/main/runtime-sse-ipc.ts @@ -111,6 +111,10 @@ function isFatalSseStatus(status: number | undefined): boolean { return typeof status === 'number' && status >= 400 && status < 500 && status !== 408 && status !== 429 } +function isTransientSseErrorMessage(message: string): boolean { + return /sse start timeout|fetch failed|network|terminated|aborted|socket|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|UND_ERR/i.test(message) +} + async function fetchSseWithStartTimeout( url: URL, headers: Record, @@ -285,7 +289,7 @@ export function registerRuntimeSseIpc(options: { } catch (e) { if (state.stoppedByClient || ac.signal.aborted) return const msg = e instanceof Error ? e.message : String(e) - if (/sse start timeout/i.test(msg) || /fetch failed/i.test(msg) || /network/i.test(msg)) { + if (isTransientSseErrorMessage(msg)) { await sleepWithAbort(reconnectDelayMs, ac.signal) reconnectDelayMs = Math.min(reconnectDelayMs * 2, SSE_RECONNECT_MAX_MS) continue diff --git a/src/renderer/src/store/chat-store-runtime.test.ts b/src/renderer/src/store/chat-store-runtime.test.ts index 17f21e44d..882305789 100644 --- a/src/renderer/src/store/chat-store-runtime.test.ts +++ b/src/renderer/src/store/chat-store-runtime.test.ts @@ -39,7 +39,9 @@ function makeSinkHarness(overrides: Partial = {}): { watchTurnCompletion: {}, unreadThreadIds: {}, queuedMessages: [], - threads: [] + threads: [], + refreshThreads: vi.fn(async () => undefined), + drainQueuedMessages: vi.fn(async () => undefined) } as unknown as ChatState state = { ...state, ...overrides } const get = (): ChatState => state @@ -139,6 +141,44 @@ describe('thread event sink binding', () => { expect(getState().lastSeq).toBe(500) }) + + it('reconciles a completed turn from persisted detail when live assistant text was missed', async () => { + const getThreadDetail = vi.fn(async () => ({ + blocks: [ + { kind: 'user' as const, id: 'user-current', turnId: 'turn-current', text: 'check the workspace' }, + { kind: 'assistant' as const, id: 'assistant-current', turnId: 'turn-current', text: 'Workspace is /tmp/project.' } + ], + latestSeq: 42, + threadStatus: 'completed' + })) + const { getState, set, get } = makeSinkHarness({ + activeThreadId: 'thread-current', + blocks: [{ kind: 'user', id: 'user-current', turnId: 'turn-current', text: 'check the workspace' }], + liveAssistant: '', + lastSeq: 10, + busy: true, + currentTurnId: 'turn-current', + currentTurnUserId: 'user-current' + }) + const sink = buildThreadEventSink(set, get, { + threadId: 'thread-current', + getThreadDetail + }) + + sink.onTurnComplete() + await Promise.resolve() + await Promise.resolve() + + expect(getThreadDetail).toHaveBeenCalledWith('thread-current') + expect(getState().busy).toBe(false) + expect(getState().lastSeq).toBe(42) + expect(getState().blocks).toContainEqual({ + kind: 'assistant', + id: 'assistant-current', + turnId: 'turn-current', + text: 'Workspace is /tmp/project.' + }) + }) }) describe('busy watchdog re-arming on live ticks (#goal-recovering-banner)', () => { diff --git a/src/renderer/src/store/chat-store-runtime.ts b/src/renderer/src/store/chat-store-runtime.ts index f84861472..ccf9ec6b5 100644 --- a/src/renderer/src/store/chat-store-runtime.ts +++ b/src/renderer/src/store/chat-store-runtime.ts @@ -1,4 +1,5 @@ import type { + AgentProvider, ChatBlock, CompactionBlock, NormalizedThread, @@ -18,7 +19,7 @@ import { isClawWorkspacePath, isInternalTemporaryWorkspace, normalizeWorkspaceRo import type { ClawImChannelV1 } from '@shared/app-settings' import { isBackgroundShellNoticeUserMessage } from '@shared/background-shell-notice' import type { ChatState } from './chat-store-types' -import { isClawThread } from './chat-store-helpers' +import { hydrateBlockModelLabels, isClawThread } from './chat-store-helpers' import { collectAssistantTextForTurn, isOptimisticUserBlockId, @@ -258,7 +259,13 @@ export function clearWatchedCompletionNotification(threadId: string): void { } function notifyTurnComplete(threadId: string | null, state: ChatState, dedupeKey: string): void { - if (!threadId || typeof window.kunGui?.showTurnCompleteNotification !== 'function') return + if ( + !threadId || + typeof window === 'undefined' || + typeof window.kunGui?.showTurnCompleteNotification !== 'function' + ) { + return + } if (!rememberCompletionNotificationKey(dedupeKey)) return const threadTitle = @@ -594,6 +601,85 @@ export type ThreadEventSinkBinding = { * live bubble. */ sinceSeq?: number + getThreadDetail?: AgentProvider['getThreadDetail'] +} + +function assistantTextAfterUser(blocks: ChatBlock[], userBlockId: string): string { + const userIndex = blocks.findIndex((block) => block.kind === 'user' && block.id === userBlockId) + if (userIndex < 0) return '' + const parts: string[] = [] + for (let index = userIndex + 1; index < blocks.length; index += 1) { + const block = blocks[index] + if (block.kind === 'user') break + if (block.kind === 'assistant' && block.text.trim()) parts.push(block.text.trim()) + } + return parts.join('\n\n').trim() +} + +function hasAssistantTextForCompletedTurn( + state: Pick, + turnId: string | null | undefined, + userBlockId: string | null | undefined +): boolean { + if (state.liveAssistant.trim()) return true + const normalizedTurnId = turnId?.trim() + if (normalizedTurnId) { + return state.blocks.some( + (block) => block.kind === 'assistant' && block.turnId === normalizedTurnId && block.text.trim() + ) + } + const normalizedUserBlockId = userBlockId?.trim() + return normalizedUserBlockId ? !!assistantTextAfterUser(state.blocks, normalizedUserBlockId) : false +} + +async function reconcileCompletedTurnFromThreadDetail(input: { + threadId: string | null | undefined + turnId: string | null | undefined + userBlockId: string | null | undefined + loadThreadDetail: AgentProvider['getThreadDetail'] + set: (partial: Partial | ((state: ChatState) => Partial)) => void + get: () => ChatState +}): Promise { + const threadId = input.threadId?.trim() + if (!threadId) return + if (hasAssistantTextForCompletedTurn(input.get(), input.turnId, input.userBlockId)) return + + try { + const detail = await input.loadThreadDetail(threadId) + const loaded = hydrateBlockModelLabels(threadId, detail.blocks) + const hasPersistedCompletion = + hasAssistantTextForCompletedTurn( + { blocks: loaded, liveAssistant: '' } as Pick, + input.turnId, + input.userBlockId + ) || detail.latestSeq > input.get().lastSeq + if (!hasPersistedCompletion) return + + input.set((state) => { + if (state.activeThreadId !== threadId) return {} + if (state.busy) return {} + if (state.currentTurnId && state.currentTurnId !== input.turnId) return {} + if (hasAssistantTextForCompletedTurn(state, input.turnId, input.userBlockId)) return {} + + const busy = threadSnapshotLooksRunning(loaded, detail.threadStatus) + const blocks = busy ? loaded : settlePendingRuntimeWorkAfterInterrupt(loaded) + return { + blocks, + lastSeq: Math.max(state.lastSeq, detail.latestSeq), + liveReasoning: '', + liveAssistant: '', + activeThreadGoal: detail.goal ?? state.activeThreadGoal, + activeThreadTodos: detail.todos ?? state.activeThreadTodos, + error: clearRuntimeStreamRecoveringError(state.error) + } + }) + } catch (error) { + if (typeof window === 'undefined') return + void window.kunGui?.logError?.('turn-completion-reconcile', 'Failed to reconcile completed turn', { + message: error instanceof Error ? error.message : String(error), + threadId + }).catch(() => undefined) + } } export function buildThreadEventSink( @@ -603,6 +689,7 @@ export function buildThreadEventSink( ): ThreadEventSink { const boundThreadId = binding.threadId?.trim() ?? '' let appliedDeltaSeqFloor = binding.sinceSeq ?? 0 + const loadThreadDetail = binding.getThreadDetail ?? ((threadId: string) => getProvider().getThreadDetail(threadId)) const isCurrentStream = (): boolean => { if (binding.signal?.aborted) return false return !boundThreadId || get().activeThreadId === boundThreadId @@ -1139,6 +1226,12 @@ export function buildThreadEventSink( const completedState = get() const completedThreadId = completedState.activeThreadId const completedTurnId = completedState.currentTurnId + const completedUserBlockId = completedState.currentTurnUserId + const shouldReconcileCompletion = !hasAssistantTextForCompletedTurn( + completedState, + completedTurnId, + completedUserBlockId + ) const completedKey = completedState.currentTurnId ? `turn:${completedState.currentTurnId}` : `active:${completedThreadId ?? 'unknown'}:${completedState.lastSeq}` @@ -1181,6 +1274,16 @@ export function buildThreadEventSink( notifyWriteWorkspaceFileRefresh(get) notifySddChatTranscriptMirror(get) syncTurnCompletionPoll(set, get) + if (shouldReconcileCompletion) { + void reconcileCompletedTurnFromThreadDetail({ + threadId: completedThreadId, + turnId: completedTurnId, + userBlockId: completedUserBlockId, + loadThreadDetail, + set, + get + }) + } void get().refreshThreads() // Release worktree when the turn finishes and there are no queued // follow-ups that would start a new turn in the same thread. From e6aee4bc6bfbc6031e2a6ac2017fbb77955fab2d Mon Sep 17 00:00:00 2001 From: musnow Date: Tue, 30 Jun 2026 20:51:08 +0800 Subject: [PATCH 02/34] fix(settings): add visible background to log file path display --- src/renderer/src/components/settings-section-general.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/settings-section-general.tsx b/src/renderer/src/components/settings-section-general.tsx index 14d3498ff..08e82105e 100644 --- a/src/renderer/src/components/settings-section-general.tsx +++ b/src/renderer/src/components/settings-section-general.tsx @@ -653,7 +653,7 @@ export function GeneralSettingsSection({ ctx }: { ctx: Record }): R control={
{logPath ? ( - + {compactHomePath(logPath)} ) : ( From b355bc19b1286d471e5f33849ad24375053a8a95 Mon Sep 17 00:00:00 2001 From: musnow Date: Tue, 30 Jun 2026 20:51:23 +0800 Subject: [PATCH 03/34] feat(settings): enable unused Git checkpoint cleanup by default --- src/main/settings-store.test.ts | 5 +++-- src/renderer/src/locales/en/settings.json | 2 +- src/renderer/src/locales/zh/settings.json | 2 +- src/shared/app-settings-types.ts | 6 +++--- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/main/settings-store.test.ts b/src/main/settings-store.test.ts index bda167787..e497da9c5 100644 --- a/src/main/settings-store.test.ts +++ b/src/main/settings-store.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { DEFAULT_APPROVAL_POLICY, + DEFAULT_CHECKPOINT_CLEANUP_ENABLED, DEFAULT_CHECKPOINT_CLEANUP_INTERVAL_DAYS, defaultKunRuntimeSettings, defaultModelProviderSettings @@ -21,8 +22,8 @@ describe('JsonSettingsStore', () => { expect(loaded.guiUpdate.channel).toBe(DEFAULT_GUI_UPDATE_CHANNEL) expect(loaded.agents.kun.approvalPolicy).toBe(DEFAULT_APPROVAL_POLICY) expect(loaded.checkpointCleanup.intervalDays).toBe(DEFAULT_CHECKPOINT_CLEANUP_INTERVAL_DAYS) - // Checkpoint cleanup deletes data, so it must be opt-in (disabled by default). - expect(loaded.checkpointCleanup.enabled).toBe(false) + // Checkpoint cleanup is enabled by default to keep stale checkpoints from accumulating. + expect(loaded.checkpointCleanup.enabled).toBe(DEFAULT_CHECKPOINT_CLEANUP_ENABLED) expect(loaded.appBehavior).toEqual({ openAtLogin: false, startMinimized: false, diff --git a/src/renderer/src/locales/en/settings.json b/src/renderer/src/locales/en/settings.json index e47d488e2..48e355a59 100644 --- a/src/renderer/src/locales/en/settings.json +++ b/src/renderer/src/locales/en/settings.json @@ -1163,7 +1163,7 @@ "logRetentionSeven": "7 days", "gitCheckpointTitle": "Git checkpoint management", "checkpointCleanupEnabled": "Automatically clean up unused checkpoints", - "checkpointCleanupEnabledDesc": "When enabled, periodically delete Git checkpoint directories no longer referenced by any chat. Off by default.", + "checkpointCleanupEnabledDesc": "When enabled, periodically delete Git checkpoint directories no longer referenced by any chat. On by default.", "checkpointCleanupInterval": "Unused Git checkpoint cleanup", "checkpointCleanupIntervalDesc": "Delete Git checkpoint directories that are no longer referenced by any chat on this schedule.", "checkpointCleanupInterval1": "Every 1 day", diff --git a/src/renderer/src/locales/zh/settings.json b/src/renderer/src/locales/zh/settings.json index 982d2c4dd..02592f869 100644 --- a/src/renderer/src/locales/zh/settings.json +++ b/src/renderer/src/locales/zh/settings.json @@ -1163,7 +1163,7 @@ "logRetentionSeven": "7 天", "gitCheckpointTitle": "Git checkpoint 管理", "checkpointCleanupEnabled": "自动清理未使用的 checkpoint", - "checkpointCleanupEnabledDesc": "开启后,定期删除不再被任何会话引用的 Git checkpoint 目录。默认关闭。", + "checkpointCleanupEnabledDesc": "开启后,定期删除不再被任何会话引用的 Git checkpoint 目录。默认开启。", "checkpointCleanupInterval": "未使用 Git checkpoint 清理", "checkpointCleanupIntervalDesc": "按所选周期删除不再被任何会话引用的 Git checkpoint 目录。", "checkpointCleanupInterval1": "每 1 天", diff --git a/src/shared/app-settings-types.ts b/src/shared/app-settings-types.ts index 07b18d2a4..af8d7a5df 100644 --- a/src/shared/app-settings-types.ts +++ b/src/shared/app-settings-types.ts @@ -119,9 +119,9 @@ export const DEFAULT_LOG_RETENTION_DAYS = 3 export const CHECKPOINT_CLEANUP_INTERVAL_DAYS = [1, 2, 3, 5, 10] as const export type CheckpointCleanupIntervalDays = (typeof CHECKPOINT_CLEANUP_INTERVAL_DAYS)[number] export const DEFAULT_CHECKPOINT_CLEANUP_INTERVAL_DAYS: CheckpointCleanupIntervalDays = 3 -// Checkpoint cleanup deletes data, so it is opt-in: disabled until the user -// explicitly enables it in settings. -export const DEFAULT_CHECKPOINT_CLEANUP_ENABLED = false +// Checkpoint cleanup is enabled by default so stale Git checkpoint directories +// do not accumulate. Users who want to keep every checkpoint can opt out in settings. +export const DEFAULT_CHECKPOINT_CLEANUP_ENABLED = true export const DEFAULT_CURSOR_SPOTLIGHT_COLOR = '#85c1f1' export const DEFAULT_WEIXIN_BRIDGE_RPC_URL = 'http://127.0.0.1:18790/api/v1/admin/rpc' export const DEFAULT_MODEL_PROVIDER_ID = 'deepseek' From 81d65b1b87941e2df8d5bbd9f4b1617f02d5802a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:38:51 +0800 Subject: [PATCH 04/34] fix(workspace): run git inspection in selected directory (#678) --- .../workspace/local-workspace-inspector.ts | 13 +++-- kun/tests/local-workspace-inspector.test.ts | 48 +++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 kun/tests/local-workspace-inspector.test.ts diff --git a/kun/src/adapters/workspace/local-workspace-inspector.ts b/kun/src/adapters/workspace/local-workspace-inspector.ts index 5ddab0fe9..9bdf866de 100644 --- a/kun/src/adapters/workspace/local-workspace-inspector.ts +++ b/kun/src/adapters/workspace/local-workspace-inspector.ts @@ -1,6 +1,6 @@ import { existsSync } from 'node:fs' import { resolve } from 'node:path' -import { execFile } from 'node:child_process' +import { execFile, type ExecFileOptions } from 'node:child_process' import { promisify } from 'node:util' import type { WorkspaceInspector } from '../../ports/workspace-inspector.js' import type { WorkspaceStatus } from '../../contracts/workspace.js' @@ -16,12 +16,17 @@ const execFileAsync = promisify(execFile) export class LocalWorkspaceInspector implements WorkspaceInspector { private readonly exec: ( file: string, - args: string[] + args: string[], + options?: ExecFileOptions ) => Promise<{ stdout: string; stderr: string }> constructor( options: { - exec?: (file: string, args: string[]) => Promise<{ stdout: string; stderr: string }> + exec?: ( + file: string, + args: string[], + options?: ExecFileOptions + ) => Promise<{ stdout: string; stderr: string }> } = {} ) { this.exec = options.exec ?? execFileAsync @@ -80,7 +85,7 @@ export class LocalWorkspaceInspector implements WorkspaceInspector { } private async runGit(cwd: string, args: string[]): Promise { - const { stdout } = await this.exec('git', args) + const { stdout } = await this.exec('git', args, { cwd }) return stdout.trim() } } diff --git a/kun/tests/local-workspace-inspector.test.ts b/kun/tests/local-workspace-inspector.test.ts new file mode 100644 index 000000000..8f1eb961d --- /dev/null +++ b/kun/tests/local-workspace-inspector.test.ts @@ -0,0 +1,48 @@ +import type { ExecFileOptions } from 'node:child_process' +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { LocalWorkspaceInspector } from '../src/adapters/workspace/local-workspace-inspector.js' + +describe('LocalWorkspaceInspector', () => { + it('runs git commands in the selected workspace', async () => { + const root = await mkdtemp(join(tmpdir(), 'kun-workspace-')) + const workspace = join(root, 'packages', 'app') + await mkdir(workspace, { recursive: true }) + + const calls: Array<{ args: string[]; cwd?: string }> = [] + const inspector = new LocalWorkspaceInspector({ + exec: async (_file: string, args: string[], options?: ExecFileOptions) => { + calls.push({ args, cwd: typeof options?.cwd === 'string' ? options.cwd : undefined }) + const command = args.join(' ') + if (command === 'rev-parse --is-inside-work-tree') return { stdout: 'true\n', stderr: '' } + if (command === 'rev-parse --abbrev-ref HEAD') return { stdout: 'develop\n', stderr: '' } + if (command === 'rev-parse HEAD') return { stdout: 'abc123\n', stderr: '' } + if (command === 'status --porcelain') return { stdout: ' M src/app.ts\n', stderr: '' } + throw new Error(`unexpected git command: ${command}`) + } + }) + + try { + const status = await inspector.status(workspace) + + expect(status).toMatchObject({ + path: workspace, + isGitRepository: true, + branch: 'develop', + headSha: 'abc123', + isDirty: true, + fileChangeCount: 1 + }) + expect(calls).toEqual([ + { args: ['rev-parse', '--is-inside-work-tree'], cwd: workspace }, + { args: ['rev-parse', '--abbrev-ref', 'HEAD'], cwd: workspace }, + { args: ['rev-parse', 'HEAD'], cwd: workspace }, + { args: ['status', '--porcelain'], cwd: workspace } + ]) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) From 4a63b9e8448a64da92c015311206db87dee9a192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BB=97=20Huy=20Du?= Date: Wed, 1 Jul 2026 21:39:27 +0700 Subject: [PATCH 05/34] fix(renderer): preserve Vietnamese grapheme clusters in streamed output (#660) --- .../chat/StreamdownAssistant.test.ts | 18 +++++- .../components/chat/StreamdownAssistant.tsx | 58 +++++++++++++++++-- src/renderer/src/styles/base-shell.css | 3 +- src/renderer/src/styles/markdown-code.css | 2 +- 4 files changed, 73 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/components/chat/StreamdownAssistant.test.ts b/src/renderer/src/components/chat/StreamdownAssistant.test.ts index 038ac8517..c2271b7ed 100644 --- a/src/renderer/src/components/chat/StreamdownAssistant.test.ts +++ b/src/renderer/src/components/chat/StreamdownAssistant.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { nextVisibleLength } from './StreamdownAssistant' +import { nextVisibleLength, visibleTextForTypewriter } from './StreamdownAssistant' describe('nextVisibleLength', () => { it('stays put when caught up', () => { @@ -31,3 +31,19 @@ describe('nextVisibleLength', () => { expect(current).toBe(target) }) }) + +describe('visibleTextForTypewriter', () => { + it('does not split decomposed Vietnamese accents mid-stream', () => { + const text = `Pha\u0302n ti\u0301ch` + + expect(visibleTextForTypewriter(text, 3)).toBe(`Pha\u0302`) + expect(visibleTextForTypewriter(text, 8)).toBe(`Pha\u0302n ti\u0301`) + }) + + it('keeps joined emoji intact', () => { + const text = '👩‍💻 demo' + + expect(visibleTextForTypewriter(text, 1)).toBe('👩‍💻') + expect(visibleTextForTypewriter(text, 2)).toBe('👩‍💻') + }) +}) diff --git a/src/renderer/src/components/chat/StreamdownAssistant.tsx b/src/renderer/src/components/chat/StreamdownAssistant.tsx index 5c249cfde..fea25e177 100644 --- a/src/renderer/src/components/chat/StreamdownAssistant.tsx +++ b/src/renderer/src/components/chat/StreamdownAssistant.tsx @@ -17,6 +17,12 @@ const CATCHUP_DIVISOR = 8 * thread, burst from a fast model) drains as fast typing instead of a * near-instant wall of text. */ const MAX_STEP_PER_FRAME = 32 +const COMBINING_MARK_REGEX = /\p{Mark}/u +const VARIATION_SELECTOR_REGEX = /\p{Variation_Selector}/u +const graphemeSegmenter = + typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function' + ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) + : null export function nextVisibleLength(current: number, target: number): number { if (current === target) return current @@ -26,6 +32,52 @@ export function nextVisibleLength(current: number, target: number): number { return current + Math.min(MAX_STEP_PER_FRAME, Math.max(1, Math.ceil(backlog / CATCHUP_DIVISOR))) } +function fallbackBoundary(text: string, length: number): number { + let boundary = length + const previousCode = text.charCodeAt(boundary - 1) + if (previousCode >= 0xd800 && previousCode <= 0xdbff && boundary < text.length) { + boundary += 1 + } + + while (boundary < text.length) { + const codePoint = text.codePointAt(boundary) + if (codePoint == null) break + const char = String.fromCodePoint(codePoint) + if (COMBINING_MARK_REGEX.test(char) || VARIATION_SELECTOR_REGEX.test(char)) { + boundary += char.length + continue + } + if (codePoint === 0x200d) { + boundary += 1 + const joinedCodePoint = text.codePointAt(boundary) + if (joinedCodePoint == null) break + boundary += String.fromCodePoint(joinedCodePoint).length + continue + } + break + } + + return boundary +} + +function nextTextBoundary(text: string, visibleLength: number): number { + const length = Math.max(0, Math.min(visibleLength, text.length)) + if (length === 0 || length === text.length) return length + + if (graphemeSegmenter) { + for (const segment of graphemeSegmenter.segment(text)) { + const boundary = segment.index + segment.segment.length + if (boundary >= length) return boundary + } + } + + return fallbackBoundary(text, length) +} + +export function visibleTextForTypewriter(text: string, visibleLength: number): string { + return text.slice(0, nextTextBoundary(text, visibleLength)) +} + /** * Paces streaming text so it reveals sequentially, decoupled from SSE * chunk sizes. Without this, one bursty chunk spans several markdown @@ -51,11 +103,7 @@ function useTypewriterText(text: string, streaming: boolean): string { }, [streaming]) if (!streaming) return text - let length = Math.min(visibleLength, text.length) - // Don't cut a surrogate pair in half mid-reveal. - const code = text.charCodeAt(length - 1) - if (code >= 0xd800 && code <= 0xdbff) length += 1 - return text.slice(0, length) + return visibleTextForTypewriter(text, visibleLength) } const rehypePlugins = [ diff --git a/src/renderer/src/styles/base-shell.css b/src/renderer/src/styles/base-shell.css index 7f4262245..be4e840c4 100644 --- a/src/renderer/src/styles/base-shell.css +++ b/src/renderer/src/styles/base-shell.css @@ -855,7 +855,8 @@ body { position: relative; isolation: isolate; font-family: - 'SF Pro Text', 'PingFang SC', 'Noto Sans SC', 'Helvetica Neue', Arial, sans-serif; + -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', 'Helvetica Neue', Arial, 'Noto Sans', + 'PingFang SC', 'Hiragino Sans GB', 'Noto Sans SC', 'Microsoft YaHei', sans-serif; background-color: var(--ds-bg-main); color: var(--ds-text); user-select: none; diff --git a/src/renderer/src/styles/markdown-code.css b/src/renderer/src/styles/markdown-code.css index ec4073b86..5c6caacf7 100644 --- a/src/renderer/src/styles/markdown-code.css +++ b/src/renderer/src/styles/markdown-code.css @@ -29,7 +29,7 @@ .ds-markdown h2, .ds-markdown h3 { margin: 0 0 0.42rem; - font-family: 'SF Pro Display', 'PingFang SC', 'Noto Sans SC', sans-serif; + font-family: 'SF Pro Display', 'Segoe UI', 'Noto Sans', 'PingFang SC', 'Noto Sans SC', sans-serif; font-weight: 600; letter-spacing: 0; line-height: 1.2; From 5b6facc3fb1e9b23cf1dbb537492e14465da9e19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:39:36 +0800 Subject: [PATCH 06/34] fix(checkpoint): prevent partial restore data loss (#661) * fix(checkpoints): bound git-checkpoint disk usage and make storage dir user-configurable (#651) - skip oversized untracked files + total untracked budget (root cause of multi-GB per-checkpoint copies) - per-thread checkpoint cap with oldest-eviction - user-configurable checkpoint directory (e.g. another drive) via settings - thread storage options through IPC create/restore + startup cleanup * fix(checkpoint): refuse partial-checkpoint restore to prevent untracked data loss (P0-01) * fix(checkpoint): fail closed when rescue snapshots are incomplete --- src/main/index.ts | 10 +- src/main/ipc/app-ipc-schemas.ts | 9 +- src/main/ipc/register-app-ipc-handlers.ts | 19 +- .../services/git-checkpoint-service.test.ts | 157 +++++++++++ src/main/services/git-checkpoint-service.ts | 246 +++++++++++++++--- .../components/settings-section-general.tsx | 30 +++ src/renderer/src/locales/en/common.json | 2 + src/renderer/src/locales/en/settings.json | 5 + src/renderer/src/locales/zh/common.json | 2 + src/renderer/src/locales/zh/settings.json | 5 + .../store/chat-store-maintenance-actions.ts | 31 ++- src/shared/app-settings-normalize.ts | 10 +- src/shared/app-settings-types.ts | 9 + src/shared/git-checkpoint.ts | 8 + src/shared/kun-gui-api.ts | 1 + 15 files changed, 505 insertions(+), 39 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 4e8238176..093da8211 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -72,6 +72,7 @@ import { waitForKunStartupSettled, type KunUnexpectedExitInfo } from './kun-process' +import { expandHomePath } from './settings-store' import { RestartBudget, type KunRuntimeStatus } from './kun-runtime-supervisor' import { configureLogger, logError, logWarn, pruneOnStartup } from './logger' import { cleanupUnusedGitCheckpointsIfDue } from './services/git-checkpoint-service' @@ -249,8 +250,15 @@ async function runCheckpointCleanupIfDue(settings: AppSettingsV1): Promise const runtime = resolveKunRuntimeSettings(settings) const dataDir = resolveKunDataDir(runtime) const intervalDays = settings.checkpointCleanup.intervalDays + const checkpointsRoot = settings.checkpointCleanup.directory?.trim() + ? expandHomePath(settings.checkpointCleanup.directory.trim()) + : undefined try { - const cleanup = await cleanupUnusedGitCheckpointsIfDue({ dataDir, intervalDays }) + const cleanup = await cleanupUnusedGitCheckpointsIfDue({ + dataDir, + intervalDays, + ...(checkpointsRoot ? { checkpointsRoot } : {}) + }) if (!cleanup.due) return const { result } = cleanup console.info( diff --git a/src/main/ipc/app-ipc-schemas.ts b/src/main/ipc/app-ipc-schemas.ts index e1407d4cf..852cbde74 100644 --- a/src/main/ipc/app-ipc-schemas.ts +++ b/src/main/ipc/app-ipc-schemas.ts @@ -534,7 +534,11 @@ const checkpointCleanupPatchSchema = z.object({ z.literal(3), z.literal(5), z.literal(10) - ]).optional() + ]).optional(), + // Issue #651: user-configurable checkpoint storage directory (e.g. another + // drive) + per-thread retention cap. Empty string clears the override. + directory: z.string().max(4096).optional(), + maxPerThread: z.number().int().min(1).max(100).optional() }).strict() const notificationsPatchSchema = z.object({ @@ -1440,7 +1444,8 @@ export const gitCheckpointCreatePayloadSchema = z export const gitCheckpointRestorePayloadSchema = z .object({ - checkpointId: trimmedString(MAX_ID_LENGTH * 4) + checkpointId: trimmedString(MAX_ID_LENGTH * 4), + allowPartialRestore: z.boolean().optional() }) .strict() diff --git a/src/main/ipc/register-app-ipc-handlers.ts b/src/main/ipc/register-app-ipc-handlers.ts index c865def1a..415dfba94 100644 --- a/src/main/ipc/register-app-ipc-handlers.ts +++ b/src/main/ipc/register-app-ipc-handlers.ts @@ -127,7 +127,7 @@ import { removeGitBranchWorktree, switchGitBranch } from '../services/git-service' -import { createGitCheckpoint, restoreGitCheckpoint } from '../services/git-checkpoint-service' +import { createGitCheckpoint, restoreGitCheckpoint, type GitCheckpointStorageOptions } from '../services/git-checkpoint-service' import { abortMerge, abortRebase, @@ -1011,6 +1011,16 @@ export function registerAppIpcHandlers(options: RegisterAppIpcHandlersOptions): return expandHomePath(runtime.dataDir?.trim() || DEFAULT_KUN_DATA_DIR) } + // Map the user's checkpoint settings (issue #651) to the service storage + // options: an optional directory override (e.g. another drive) and the + // per-thread retention cap. Home-relative paths are expanded. + const resolveCheckpointStorageOptions = ( + cfg: { directory?: string; maxPerThread?: number } + ): GitCheckpointStorageOptions => ({ + ...(cfg.directory?.trim() ? { checkpointsRoot: expandHomePath(cfg.directory.trim()) } : {}), + ...(cfg.maxPerThread !== undefined ? { maxPerThread: cfg.maxPerThread } : {}) + }) + ipcMain.handle('kun:sessions:detect-legacy', async () => detectLegacySessions({ homeDir: homedir(), destDataDir: await resolveKunThreadsDataDir() }) ) @@ -1071,17 +1081,22 @@ export function registerAppIpcHandlers(options: RegisterAppIpcHandlersOptions): ) ipcMain.handle('git:checkpoint:create', async (_, payload: unknown) => { const request = parseIpcPayload('git:checkpoint:create', gitCheckpointCreatePayloadSchema, payload) + const settings = await store.load() return createGitCheckpoint({ dataDir: await resolveKunThreadsDataDir(), workspaceRoot: request.workspaceRoot, - threadId: request.threadId + threadId: request.threadId, + storage: resolveCheckpointStorageOptions(settings.checkpointCleanup) }) }) ipcMain.handle('git:checkpoint:restore', async (_, payload: unknown) => { const request = parseIpcPayload('git:checkpoint:restore', gitCheckpointRestorePayloadSchema, payload) + const settings = await store.load() return restoreGitCheckpoint({ dataDir: await resolveKunThreadsDataDir(), checkpointId: request.checkpointId, + ...(request.allowPartialRestore ? { allowPartialRestore: true } : {}), + storage: resolveCheckpointStorageOptions(settings.checkpointCleanup), // Bridge the main-process runtimeRequest into the shape restoreGitCheckpoint // expects ((path, {method, body}) => {ok,status,body}). On a transport-level // failure (runtime not up, connection refused) we return a non-ok result so diff --git a/src/main/services/git-checkpoint-service.test.ts b/src/main/services/git-checkpoint-service.test.ts index 8d60eec95..10742f79f 100644 --- a/src/main/services/git-checkpoint-service.test.ts +++ b/src/main/services/git-checkpoint-service.test.ts @@ -417,3 +417,160 @@ describe('git checkpoint service', () => { expect(second.result.deletedIds).toEqual(['gcp_second']) }) }) + +describe('git checkpoint storage limits (issue #651)', () => { + it('stores checkpoints under a user-configured directory (e.g. another drive)', async () => { + const customRoot = join(sandbox, 'other-drive', 'kun-checkpoints') + const checkpoint = await createGitCheckpoint({ + dataDir, + workspaceRoot: repoRoot, + threadId: 'thr_1', + storage: { checkpointsRoot: customRoot } + }) + expect(checkpoint.ok).toBe(true) + if (!checkpoint.ok) throw new Error(checkpoint.message) + await expect(stat(join(customRoot, checkpoint.checkpointId, 'metadata.json'))).resolves.toBeTruthy() + // Nothing should have been written under the default data dir location. + await expect(stat(join(dataDir, 'git-checkpoints', checkpoint.checkpointId))).rejects.toBeTruthy() + }) + + it('skips untracked files larger than the per-file cap and records them', async () => { + await writeFile(join(repoRoot, 'small.txt'), 'tiny') + await writeFile(join(repoRoot, 'huge.bin'), Buffer.alloc(2_000_000, 1)) + const checkpoint = await createGitCheckpoint({ + dataDir, + workspaceRoot: repoRoot, + threadId: 'thr_1', + storage: { maxUntrackedFileBytes: 1_000_000 } + }) + expect(checkpoint.ok).toBe(true) + if (!checkpoint.ok) throw new Error(checkpoint.message) + const dir = join(dataDir, 'git-checkpoints', checkpoint.checkpointId) + const metadata = JSON.parse(await readFile(join(dir, 'metadata.json'), 'utf-8')) as { + untrackedFiles: string[]; skippedUntracked?: string[] + } + expect(metadata.untrackedFiles).toContain('small.txt') + expect(metadata.skippedUntracked).toContain('huge.bin') + await expect(stat(join(dir, 'untracked', 'huge.bin'))).rejects.toBeTruthy() + await expect(stat(join(dir, 'untracked', 'small.txt'))).resolves.toBeTruthy() + }) + + it('stops snapshotting untracked files once the total budget is hit', async () => { + await writeFile(join(repoRoot, 'a.bin'), Buffer.alloc(600_000, 1)) + await writeFile(join(repoRoot, 'b.bin'), Buffer.alloc(600_000, 1)) + const checkpoint = await createGitCheckpoint({ + dataDir, + workspaceRoot: repoRoot, + threadId: 'thr_1', + storage: { maxUntrackedFileBytes: 1_000_000, maxUntrackedTotalBytes: 1_000_000 } + }) + if (!checkpoint.ok) throw new Error(checkpoint.message) + const dir = join(dataDir, 'git-checkpoints', checkpoint.checkpointId) + const metadata = JSON.parse(await readFile(join(dir, 'metadata.json'), 'utf-8')) as { + untrackedFiles: string[]; skippedUntracked?: string[] + } + // One file fits the 1MB budget, the second is skipped. + expect(metadata.untrackedFiles.length).toBe(1) + expect(metadata.skippedUntracked?.length).toBe(1) + }) + + it('marks a checkpoint with skipped untracked files as partial and refuses to restore it (no data loss)', async () => { + // A large untracked file is skipped by the size cap, so the checkpoint is + // partial. Restoring would `git clean -fd` the never-captured file, so the + // restore must be refused unless the caller opts in. + await writeFile(join(repoRoot, 'huge.bin'), Buffer.alloc(2_000_000, 1)) + const checkpoint = await createGitCheckpoint({ + dataDir, + workspaceRoot: repoRoot, + threadId: 'thr_partial', + storage: { maxUntrackedFileBytes: 1_000_000 } + }) + if (!checkpoint.ok) throw new Error(checkpoint.message) + const dir = join(dataDir, 'git-checkpoints', checkpoint.checkpointId) + const metadata = JSON.parse(await readFile(join(dir, 'metadata.json'), 'utf-8')) as { completeness?: string } + expect(metadata.completeness).toBe('partial') + + const restored = await restoreGitCheckpoint({ dataDir, checkpointId: checkpoint.checkpointId }) + expect(restored.ok).toBe(false) + if (restored.ok) throw new Error('expected partial restore to be refused') + expect(restored.reason).toBe('partial') + expect('skippedUntracked' in restored && restored.skippedUntracked).toContain('huge.bin') + // The destructive ops never ran: the skipped file is byte-for-byte intact. + expect((await stat(join(repoRoot, 'huge.bin'))).size).toBe(2_000_000) + }) + + it('marks a fully-captured checkpoint as complete', async () => { + await writeFile(join(repoRoot, 'small.txt'), 'tiny') + const checkpoint = await createGitCheckpoint({ dataDir, workspaceRoot: repoRoot, threadId: 'thr_complete' }) + if (!checkpoint.ok) throw new Error(checkpoint.message) + const dir = join(dataDir, 'git-checkpoints', checkpoint.checkpointId) + const metadata = JSON.parse(await readFile(join(dir, 'metadata.json'), 'utf-8')) as { completeness?: string } + expect(metadata.completeness).toBe('complete') + }) + + it('restores a partial checkpoint only when the bounded rescue is complete', async () => { + await writeFile(join(repoRoot, 'huge.bin'), Buffer.alloc(2_000_000, 7)) + const checkpoint = await createGitCheckpoint({ + dataDir, + workspaceRoot: repoRoot, + threadId: 'thr_partial_ok', + storage: { maxUntrackedFileBytes: 1_000_000 } + }) + if (!checkpoint.ok) throw new Error(checkpoint.message) + + const restored = await restoreGitCheckpoint({ + dataDir, + checkpointId: checkpoint.checkpointId, + allowPartialRestore: true + }) + expect(restored.ok).toBe(true) + if (!restored.ok) throw new Error(restored.message) + expect(restored.rescueCheckpointId).toMatch(/^gcp_/) + // The file exceeds the original checkpoint's custom cap but fits the normal + // bounded rescue policy, so it remains recoverable. + const rescueUntracked = join(dataDir, 'git-checkpoints', restored.rescueCheckpointId as string, 'untracked', 'huge.bin') + expect((await stat(rescueUntracked)).size).toBe(2_000_000) + }) + + it('fails closed before reset/clean when the rescue snapshot is partial', async () => { + await writeFile(join(repoRoot, 'huge.bin'), Buffer.alloc(6_000_000, 9)) + const checkpoint = await createGitCheckpoint({ + dataDir, + workspaceRoot: repoRoot, + threadId: 'thr_partial_rescue', + storage: { maxUntrackedFileBytes: 1_000_000 } + }) + if (!checkpoint.ok) throw new Error(checkpoint.message) + + const restored = await restoreGitCheckpoint({ + dataDir, + checkpointId: checkpoint.checkpointId, + allowPartialRestore: true + }) + expect(restored.ok).toBe(false) + if (restored.ok) throw new Error('expected incomplete rescue to refuse restore') + expect(restored.reason).toBe('partial') + expect((await stat(join(repoRoot, 'huge.bin'))).size).toBe(6_000_000) + }) + + it('prunes oldest checkpoints beyond the per-thread cap', async () => { + const ids: string[] = [] + for (let i = 0; i < 4; i += 1) { + const cp = await createGitCheckpoint({ + dataDir, + workspaceRoot: repoRoot, + threadId: 'thr_cap', + checkpointId: `gcp_${1000 + i}_fixed-${i}`, + storage: { maxPerThread: 2 } + }) + if (!cp.ok) throw new Error(cp.message) + ids.push(cp.checkpointId) + } + const root = join(dataDir, 'git-checkpoints') + // Only the two newest survive; the two oldest are pruned. + await expect(stat(join(root, ids[0]))).rejects.toBeTruthy() + await expect(stat(join(root, ids[1]))).rejects.toBeTruthy() + await expect(stat(join(root, ids[2]))).resolves.toBeTruthy() + await expect(stat(join(root, ids[3]))).resolves.toBeTruthy() + }) +}) diff --git a/src/main/services/git-checkpoint-service.ts b/src/main/services/git-checkpoint-service.ts index 562681eaa..823f598e7 100644 --- a/src/main/services/git-checkpoint-service.ts +++ b/src/main/services/git-checkpoint-service.ts @@ -17,8 +17,38 @@ type GitCheckpointMetadata = { currentBranch: string | null createdAt: string untrackedFiles: string[] + /** Untracked files deliberately NOT snapshotted (too large / over budget). */ + skippedUntracked?: string[] + /** + * Whether the snapshot captured every untracked file. `partial` means some + * untracked files were skipped (see `skippedUntracked`); restoring a partial + * checkpoint can destroy those never-captured files, so restore refuses a + * partial checkpoint unless the caller explicitly opts in. + */ + completeness?: 'complete' | 'partial' } +/** + * Snapshot policy that bounds checkpoint disk usage (issue #651). Untracked + * files are physically copied, so a workspace full of large untracked artifacts + * (AI models, node_modules, build output) could balloon the checkpoint store by + * gigabytes per message. These caps + the per-thread retention limit stop that. + */ +export type GitCheckpointStorageOptions = { + /** Override the checkpoints root (e.g. point it at another drive). */ + checkpointsRoot?: string + /** Skip snapshotting any single untracked file larger than this. Default 5 MiB. */ + maxUntrackedFileBytes?: number + /** Stop snapshotting untracked files once this cumulative size is reached. Default 50 MiB. */ + maxUntrackedTotalBytes?: number + /** Keep at most this many checkpoints per thread (newest first). Default 5. */ + maxPerThread?: number +} + +const DEFAULT_MAX_UNTRACKED_FILE_BYTES = 5 * 1_024 * 1_024 +const DEFAULT_MAX_UNTRACKED_TOTAL_BYTES = 50 * 1_024 * 1_024 +const DEFAULT_MAX_CHECKPOINTS_PER_THREAD = 5 + export type GitCheckpointCleanupResult = { scanned: number kept: number @@ -56,24 +86,38 @@ function restoreFailure(error: unknown): Extract { @@ -97,9 +141,9 @@ async function assertNoUnmerged(repositoryRoot: string): Promise { } } -async function readMetadata(dataDir: string, checkpointId: string): Promise { +async function readMetadata(root: string, checkpointId: string): Promise { try { - const raw = await readFile(metadataPath(dataDir, checkpointId), 'utf-8') + const raw = await readFile(metadataPath(root, checkpointId), 'utf-8') return JSON.parse(raw) as GitCheckpointMetadata } catch { return null @@ -133,13 +177,13 @@ async function writeHeadBundle(repositoryRoot: string, path: string): Promise { const head = metadata.head.trim() if (await commitExists(repositoryRoot, head)) return head - const bundlePath = checkpointHeadBundlePath(dataDir, metadata.checkpointId) + const bundlePath = checkpointHeadBundlePath(root, metadata.checkpointId) if (await fileExists(bundlePath)) { await runGit(repositoryRoot, ['bundle', 'unbundle', bundlePath], 30_000) if (await commitExists(repositoryRoot, head)) return head @@ -329,9 +373,9 @@ async function collectReferencedCheckpointIds(dataDir: string): Promise { +async function readCleanupState(root: string): Promise { try { - const raw = await readFile(checkpointCleanupStatePath(dataDir), 'utf-8') + const raw = await readFile(checkpointCleanupStatePath(root), 'utf-8') const parsed = JSON.parse(raw) as GitCheckpointCleanupState return typeof parsed === 'object' && parsed !== null ? parsed : {} } catch { @@ -339,10 +383,9 @@ async function readCleanupState(dataDir: string): Promise { - const root = checkpointRootDir(dataDir) +async function writeCleanupState(root: string, state: GitCheckpointCleanupState): Promise { await mkdir(root, { recursive: true }) - await writeFile(checkpointCleanupStatePath(dataDir), JSON.stringify(state, null, 2), 'utf-8') + await writeFile(checkpointCleanupStatePath(root), JSON.stringify(state, null, 2), 'utf-8') } function isCheckpointCleanupDue(lastRunAt: string | undefined, intervalDays: number, now: Date): boolean { @@ -362,12 +405,13 @@ const CHECKPOINT_CLEANUP_GRACE_MS = 10 * 60 * 1_000 export async function cleanupUnusedGitCheckpoints(params: { dataDir: string + checkpointsRoot?: string graceMs?: number now?: Date }): Promise { const graceMs = params.graceMs ?? CHECKPOINT_CLEANUP_GRACE_MS const nowMs = (params.now ?? new Date()).getTime() - const root = checkpointRootDir(params.dataDir) + const root = resolveCheckpointsRoot(params.dataDir, params.checkpointsRoot) const referenced = await collectReferencedCheckpointIds(params.dataDir) const result: GitCheckpointCleanupResult = { scanned: 0, @@ -422,19 +466,26 @@ export async function cleanupUnusedGitCheckpoints(params: { export async function cleanupUnusedGitCheckpointsIfDue(params: { dataDir: string + checkpointsRoot?: string intervalDays: number now?: Date graceMs?: number }): Promise { const now = params.now ?? new Date() - const state = await readCleanupState(params.dataDir) + const root = resolveCheckpointsRoot(params.dataDir, params.checkpointsRoot) + const state = await readCleanupState(root) const lastRunAt = typeof state.lastRunAt === 'string' ? state.lastRunAt : undefined if (!isCheckpointCleanupDue(lastRunAt, params.intervalDays, now)) { return { due: false, lastRunAt: lastRunAt ?? null } } - const result = await cleanupUnusedGitCheckpoints({ dataDir: params.dataDir, now, graceMs: params.graceMs }) + const result = await cleanupUnusedGitCheckpoints({ + dataDir: params.dataDir, + ...(params.checkpointsRoot ? { checkpointsRoot: params.checkpointsRoot } : {}), + now, + ...(params.graceMs !== undefined ? { graceMs: params.graceMs } : {}) + }) const nextLastRunAt = now.toISOString() - await writeCleanupState(params.dataDir, { lastRunAt: nextLastRunAt }) + await writeCleanupState(root, { lastRunAt: nextLastRunAt }) return { due: true, lastRunAt: nextLastRunAt, result } } @@ -443,11 +494,16 @@ export async function createGitCheckpoint(params: { workspaceRoot: string threadId: string checkpointId?: string + storage?: GitCheckpointStorageOptions }): Promise { const workspaceRoot = params.workspaceRoot.trim() if (!workspaceRoot) { return { ok: false, reason: 'no_workspace', message: 'No working directory selected.' } } + const root = resolveCheckpointsRoot(params.dataDir, params.storage?.checkpointsRoot) + const maxFileBytes = params.storage?.maxUntrackedFileBytes ?? DEFAULT_MAX_UNTRACKED_FILE_BYTES + const maxTotalBytes = params.storage?.maxUntrackedTotalBytes ?? DEFAULT_MAX_UNTRACKED_TOTAL_BYTES + const maxPerThread = params.storage?.maxPerThread ?? DEFAULT_MAX_CHECKPOINTS_PER_THREAD try { const repositoryRoot = await resolveRepositoryRoot(workspaceRoot) if (!repositoryRoot) { @@ -456,26 +512,47 @@ export async function createGitCheckpoint(params: { await assertNoUnmerged(repositoryRoot) const checkpointId = params.checkpointId?.trim() || `gcp_${Date.now()}_${randomUUID()}` - const dir = checkpointDir(params.dataDir, checkpointId) + const dir = checkpointDir(root, checkpointId) await rm(dir, { recursive: true, force: true }) await mkdir(join(dir, 'untracked'), { recursive: true }) const head = (await runGit(repositoryRoot, ['rev-parse', 'HEAD'])).stdout.trim() - await writeHeadBundle(repositoryRoot, checkpointHeadBundlePath(params.dataDir, checkpointId)) + await writeHeadBundle(repositoryRoot, checkpointHeadBundlePath(root, checkpointId)) const currentBranchRaw = (await runGit(repositoryRoot, ['branch', '--show-current'])).stdout.trim() const currentBranch = currentBranchRaw || null - const untrackedFiles = splitNul( + const candidateUntracked = splitNul( (await runGit(repositoryRoot, ['ls-files', '--others', '--exclude-standard', '-z'])).stdout ) await writePatch(repositoryRoot, ['diff', '--binary'], join(dir, 'unstaged.patch')) await writePatch(repositoryRoot, ['diff', '--cached', '--binary'], join(dir, 'staged.patch')) - for (const relativePath of untrackedFiles) { + // Bounded untracked snapshot (issue #651): copying every untracked file in + // full each turn is what ballooned the store by GBs. Skip files over the + // per-file cap and stop once the cumulative budget is hit; record what was + // skipped so the model/user know the snapshot is partial. + const untrackedFiles: string[] = [] + const skippedUntracked: string[] = [] + let copiedBytes = 0 + for (const relativePath of candidateUntracked) { const from = join(repositoryRoot, relativePath) + let size = 0 + try { + const info = await stat(from) + if (info.isDirectory()) continue + size = info.size + } catch { + continue + } + if (size > maxFileBytes || copiedBytes + size > maxTotalBytes) { + skippedUntracked.push(relativePath) + continue + } const to = join(dir, 'untracked', relativePath) await mkdir(dirname(to), { recursive: true }) await cp(from, to, { recursive: true, force: true, errorOnExist: false }) + copiedBytes += size + untrackedFiles.push(relativePath) } const metadata: GitCheckpointMetadata = { @@ -485,9 +562,13 @@ export async function createGitCheckpoint(params: { head, currentBranch, createdAt: new Date().toISOString(), - untrackedFiles + untrackedFiles, + ...(skippedUntracked.length ? { skippedUntracked } : {}), + completeness: skippedUntracked.length ? 'partial' : 'complete' } await writeFile(join(dir, 'metadata.json'), JSON.stringify(metadata, null, 2), 'utf-8') + // Bound per-thread retention so an active thread cannot grow unboundedly. + await pruneThreadCheckpoints(root, params.threadId, maxPerThread, checkpointId).catch(() => undefined) return { ok: true, checkpointId, repositoryRoot, head, currentBranch } } catch (error) { const failure = checkpointFailure(error) @@ -498,9 +579,69 @@ export async function createGitCheckpoint(params: { } } +/** + * Keep at most `max` checkpoints for a thread (issue #651, per-thread cap). + * Oldest checkpoints (by createdAt, falling back to the `gcp__` name) are + * removed first; `keepId` (the just-created checkpoint) is always retained. + */ +export async function pruneThreadCheckpoints( + root: string, + threadId: string, + max: number, + keepId?: string +): Promise<{ deleted: string[] }> { + if (max <= 0) return { deleted: [] } + let entries: Dirent[] + try { + entries = await readdir(root, { withFileTypes: true }) + } catch { + return { deleted: [] } + } + const owned: Array<{ id: string; order: number }> = [] + for (const entry of entries) { + if (!entry.isDirectory()) continue + const metadata = await readMetadata(root, entry.name) + if (!metadata || metadata.threadId !== threadId) continue + const createdMs = Date.parse(metadata.createdAt) + const order = Number.isFinite(createdMs) ? createdMs : checkpointNameTimestamp(entry.name) + owned.push({ id: entry.name, order }) + } + // Newest first; keep the first `max`, delete the rest (never the keepId). + owned.sort((a, b) => b.order - a.order) + const deleted: string[] = [] + for (let i = 0; i < owned.length; i += 1) { + const { id } = owned[i] + if (i < max || id === keepId) continue + try { + await rm(checkpointDir(root, id), { recursive: true, force: true }) + deleted.push(id) + } catch { + // best-effort + } + } + return { deleted } +} + +/** Extract the `gcp__` creation epoch for ordering fallback. */ +function checkpointNameTimestamp(name: string): number { + const match = name.match(/^gcp_(\d+)_/) + return match ? Number(match[1]) : 0 +} + export async function restoreGitCheckpoint(params: { dataDir: string checkpointId: string + storage?: GitCheckpointStorageOptions + /** + * Opt-in to restoring a PARTIAL checkpoint (one whose snapshot skipped some + * untracked files because they were over the size budget). A partial restore + * runs `git clean -fd`, which deletes those never-captured files; without this + * flag the restore is refused so the user does not silently lose data. When + * enabled, a complete rescue checkpoint is taken first so the cleaned files + * remain recoverable. The restore still fails closed when the configured + * checkpoint budget cannot capture that rescue. + */ + allowPartialRestore?: boolean /** * Optional runtime bridge used to verify that no thread is mid-turn before * running the destructive `git reset --hard` / `git clean -fd`. When omitted @@ -511,14 +652,32 @@ export async function restoreGitCheckpoint(params: { runtimeRequest?: (path: string, init: { method?: string; body?: string }) => Promise<{ ok: boolean; status: number; body: string }> }): Promise { const checkpointId = params.checkpointId.trim() - const metadata = await readMetadata(params.dataDir, checkpointId) + const root = resolveCheckpointsRoot(params.dataDir, params.storage?.checkpointsRoot) + const metadata = await readMetadata(root, checkpointId) if (!metadata) { return { ok: false, reason: 'not_found', message: `Git checkpoint not found: ${checkpointId}` } } + + // Partial-checkpoint data-loss guard (P0-01). If the snapshot skipped any + // untracked file, the upcoming `git clean -fd` would delete those files with + // no snapshot to restore them. Refuse unless the caller explicitly opts in. + const skippedUntracked = metadata.skippedUntracked ?? [] + const isPartial = metadata.completeness === 'partial' || skippedUntracked.length > 0 + if (isPartial && !params.allowPartialRestore) { + return { + ok: false, + reason: 'partial', + message: + `This checkpoint is partial: ${skippedUntracked.length} untracked file(s) were too large to snapshot and are NOT stored in it. ` + + 'Restoring would permanently delete them. Re-run with allowPartialRestore to proceed (a full rescue checkpoint will be taken first).', + skippedUntracked + } + } + try { const repositoryRoot = metadata.repositoryRoot await assertNoUnmerged(repositoryRoot) - const targetRef = await resolveCheckpointTarget(repositoryRoot, params.dataDir, metadata) + const targetRef = await resolveCheckpointTarget(repositoryRoot, root, metadata) // Busy guard: a checkpoint restore runs `git reset --hard` + `git clean // -fd`, which would destroy files the agent is actively editing. Before @@ -560,12 +719,35 @@ export async function restoreGitCheckpoint(params: { } } + // The rescue checkpoint is the safety net for `reset --hard` + `clean -fd`. + // Never bypass the storage budget here: an unbounded rescue reintroduces the + // disk-exhaustion failure this service is meant to prevent. Instead require a + // COMPLETE rescue and fail closed before the first destructive git command. const rescue = await createGitCheckpoint({ dataDir: params.dataDir, + storage: params.storage, workspaceRoot: repositoryRoot, threadId: `${metadata.threadId}:rollback-rescue` }) - const rescueCheckpointId = rescue.ok ? rescue.checkpointId : null + if (!rescue.ok) { + return { + ok: false, + reason: rescue.reason, + message: `Cannot safely restore checkpoint because the rescue snapshot failed: ${rescue.message}` + } + } + const rescueMetadata = await readMetadata(root, rescue.checkpointId) + if (!rescueMetadata || rescueMetadata.completeness !== 'complete') { + return { + ok: false, + reason: 'partial', + message: + 'Cannot safely restore checkpoint because the current workspace does not fit the configured rescue snapshot limits. ' + + 'Increase the checkpoint limits or move/remove the oversized untracked files, then retry.', + skippedUntracked: rescueMetadata?.skippedUntracked ?? [] + } + } + const rescueCheckpointId = rescue.checkpointId await runGit(repositoryRoot, ['reset', '--hard'], 30_000) await runGit(repositoryRoot, ['clean', '-fd'], 30_000) @@ -577,7 +759,7 @@ export async function restoreGitCheckpoint(params: { await runGit(repositoryRoot, ['reset', '--hard', targetRef], 30_000) await runGit(repositoryRoot, ['clean', '-fd'], 30_000) - const dir = checkpointDir(params.dataDir, checkpointId) + const dir = checkpointDir(root, checkpointId) await applyPatchIfPresent(repositoryRoot, join(dir, 'staged.patch'), true) await applyPatchIfPresent(repositoryRoot, join(dir, 'unstaged.patch'), false) diff --git a/src/renderer/src/components/settings-section-general.tsx b/src/renderer/src/components/settings-section-general.tsx index 08e82105e..d845b8ca2 100644 --- a/src/renderer/src/components/settings-section-general.tsx +++ b/src/renderer/src/components/settings-section-general.tsx @@ -614,6 +614,36 @@ export function GeneralSettingsSection({ ctx }: { ctx: Record }): R } /> + update({ checkpointCleanup: { directory: e.target.value } })} + /> + } + /> + { + const n = Number(e.target.value) + update({ checkpointCleanup: { maxPerThread: Number.isFinite(n) ? Math.max(1, Math.min(100, Math.floor(n))) : 5 } }) + }} + /> + } + /> diff --git a/src/renderer/src/locales/en/common.json b/src/renderer/src/locales/en/common.json index e74afab3e..b8470b562 100644 --- a/src/renderer/src/locales/en/common.json +++ b/src/renderer/src/locales/en/common.json @@ -1510,6 +1510,8 @@ "rollbackWorkspaceBusyError": "Cannot roll back the workspace while the agent is running.", "rollbackWorkspaceConfirm": "Roll back the Git commit for this response?", "rollbackWorkspaceConfirmDetail": "This will run git reset --hard and git clean -fd in your workspace. Any uncommitted edits, untracked files, and commits added since this checkpoint will be permanently lost. The chat conversation will be kept as-is.", + "rollbackWorkspacePartialConfirm": "This checkpoint is partial — restore anyway?", + "rollbackWorkspacePartialConfirmDetail": "Some untracked files were too large to capture in this checkpoint and are not stored in it: {{files}}. Restoring will delete them. A full rescue checkpoint will be taken first so you can recover them. Continue?", "sessionForked": "Forked thread", "sessionForkedFrom": "Forked from {{title}}", "sessionForkedFromCompact": "from {{title}}", diff --git a/src/renderer/src/locales/en/settings.json b/src/renderer/src/locales/en/settings.json index 48e355a59..50c167b96 100644 --- a/src/renderer/src/locales/en/settings.json +++ b/src/renderer/src/locales/en/settings.json @@ -1171,6 +1171,11 @@ "checkpointCleanupInterval3": "Every 3 days", "checkpointCleanupInterval5": "Every 5 days", "checkpointCleanupInterval10": "Every 10 days", + "checkpointDirectory": "Checkpoint storage directory", + "checkpointDirectoryDesc": "Custom location for Git checkpoints (point it at another drive to avoid filling the system drive). Leave blank to use the default data dir/git-checkpoints.", + "checkpointDirectoryPlaceholder": "e.g. D:\\kun-checkpoints or ~/kun-checkpoints", + "checkpointMaxPerThread": "Max checkpoints per chat", + "checkpointMaxPerThreadDesc": "Keep at most this many checkpoints per chat; older ones are deleted automatically to prevent unbounded disk growth. Default 5.", "loading": "Loading…", "loadFailed": "Could not load settings: {{message}}", "preloadBridgeError": "The app bridge is not available. Restart the application or check the preload path.", diff --git a/src/renderer/src/locales/zh/common.json b/src/renderer/src/locales/zh/common.json index 74186ac71..711302e86 100644 --- a/src/renderer/src/locales/zh/common.json +++ b/src/renderer/src/locales/zh/common.json @@ -1510,6 +1510,8 @@ "rollbackWorkspaceBusyError": "Agent 正在运行时不能回滚工作区。", "rollbackWorkspaceConfirm": "确定回滚这条回答对应的 Git 提交吗?", "rollbackWorkspaceConfirmDetail": "此操作将在您的工作区执行 git reset --hard 和 git clean -fd。所有未提交的修改、未跟踪的文件以及该检查点之后的提交都将被永久丢失。当前对话内容会保留不变。", + "rollbackWorkspacePartialConfirm": "该检查点不完整,仍要回滚吗?", + "rollbackWorkspacePartialConfirmDetail": "部分未跟踪文件过大,未被该检查点保存:{{files}}。回滚会删除它们。系统会先创建一个完整的救援检查点以便恢复。是否继续?", "sessionForked": "分叉会话", "sessionForkedFrom": "分叉自 {{title}}", "sessionForkedFromCompact": "来自 {{title}}", diff --git a/src/renderer/src/locales/zh/settings.json b/src/renderer/src/locales/zh/settings.json index 02592f869..38f63cd00 100644 --- a/src/renderer/src/locales/zh/settings.json +++ b/src/renderer/src/locales/zh/settings.json @@ -1171,6 +1171,11 @@ "checkpointCleanupInterval3": "每 3 天", "checkpointCleanupInterval5": "每 5 天", "checkpointCleanupInterval10": "每 10 天", + "checkpointDirectory": "Checkpoint 存储目录", + "checkpointDirectoryDesc": "自定义 Git checkpoint 的存储位置(可指向其他磁盘以避免占满系统盘)。留空则使用默认 数据目录/git-checkpoints。", + "checkpointDirectoryPlaceholder": "例如 D:\\kun-checkpoints 或 ~/kun-checkpoints", + "checkpointMaxPerThread": "每会话最多 checkpoint 数", + "checkpointMaxPerThreadDesc": "每个会话最多保留的 checkpoint 数量,超出后自动删除最旧的,防止磁盘无限增长。默认 5。", "loading": "加载中…", "loadFailed": "无法加载设置:{{message}}", "preloadBridgeError": "应用桥接不可用。请重启应用或检查预加载脚本路径。", diff --git a/src/renderer/src/store/chat-store-maintenance-actions.ts b/src/renderer/src/store/chat-store-maintenance-actions.ts index e7d585885..5e0332d11 100644 --- a/src/renderer/src/store/chat-store-maintenance-actions.ts +++ b/src/renderer/src/store/chat-store-maintenance-actions.ts @@ -797,11 +797,40 @@ export function createMaintenanceActions( return } const { activeThreadId, workspaceRoot } = get() - const restored = await window.kunGui.restoreGitCheckpoint({ checkpointId: targetCheckpointId }).catch((error) => ({ + let restored = await window.kunGui.restoreGitCheckpoint({ checkpointId: targetCheckpointId }).catch((error) => ({ ok: false as const, reason: 'error' as const, message: error instanceof Error ? error.message : String(error) })) + // A partial checkpoint skipped some untracked files (too large to capture). + // Restoring would delete them, so the main process refuses unless the user + // opts in. Surface the at-risk files and, on confirmation, retry with the + // opt-in (the main process then takes a full rescue checkpoint first). + if (!restored.ok && restored.reason === 'partial') { + const skipped = 'skippedUntracked' in restored && Array.isArray(restored.skippedUntracked) + ? restored.skippedUntracked + : [] + const preview = skipped.slice(0, 10).join(', ') + (skipped.length > 10 ? ` … (+${skipped.length - 10})` : '') + const proceed = await confirmDialog( + i18n.t('common:rollbackWorkspacePartialConfirm'), + i18n.t('common:rollbackWorkspacePartialConfirmDetail', { files: preview }) + ) + if (!proceed) { + set({ error: null }) + return + } + if (get().busy) { + set({ error: i18n.t('common:rollbackWorkspaceBusyError') }) + return + } + restored = await window.kunGui + .restoreGitCheckpoint({ checkpointId: targetCheckpointId, allowPartialRestore: true }) + .catch((error) => ({ + ok: false as const, + reason: 'error' as const, + message: error instanceof Error ? error.message : String(error) + })) + } if (!restored.ok) { set({ error: restored.message }) return diff --git a/src/shared/app-settings-normalize.ts b/src/shared/app-settings-normalize.ts index 99f6f55ca..482c90096 100644 --- a/src/shared/app-settings-normalize.ts +++ b/src/shared/app-settings-normalize.ts @@ -134,11 +134,19 @@ export function normalizeCheckpointCleanupSettings( settings?: Partial ): CheckpointCleanupConfigV1 { const intervalDays = normalizeCheckpointCleanupIntervalDays(settings?.intervalDays) + const directory = typeof settings?.directory === 'string' ? settings.directory.trim() : '' + const maxPerThread = typeof settings?.maxPerThread === 'number' && Number.isFinite(settings.maxPerThread) + ? Math.max(1, Math.min(100, Math.floor(settings.maxPerThread))) + : undefined return { enabled: typeof settings?.enabled === 'boolean' ? settings.enabled : DEFAULT_CHECKPOINT_CLEANUP_ENABLED, intervalDays: CHECKPOINT_CLEANUP_INTERVAL_DAYS.includes(intervalDays) ? intervalDays - : DEFAULT_CHECKPOINT_CLEANUP_INTERVAL_DAYS + : DEFAULT_CHECKPOINT_CLEANUP_INTERVAL_DAYS, + // Only include the optional storage overrides when explicitly set so + // existing settings snapshots (which omit them) stay byte-for-byte equal. + ...(directory ? { directory } : {}), + ...(maxPerThread !== undefined ? { maxPerThread } : {}) } } diff --git a/src/shared/app-settings-types.ts b/src/shared/app-settings-types.ts index af8d7a5df..3dadc6037 100644 --- a/src/shared/app-settings-types.ts +++ b/src/shared/app-settings-types.ts @@ -622,6 +622,15 @@ export type LogConfigV1 = { export type CheckpointCleanupConfigV1 = { enabled: boolean intervalDays: CheckpointCleanupIntervalDays + /** + * Optional override for the Git checkpoint storage directory (issue #651). + * Lets users point checkpoints at another drive with more free space instead + * of filling the system drive under the Kun data dir. Absent = default + * (`/git-checkpoints`). + */ + directory?: string + /** Keep at most this many checkpoints per thread (oldest pruned). Absent = default 5. */ + maxPerThread?: number } export type NotificationConfigV1 = { diff --git a/src/shared/git-checkpoint.ts b/src/shared/git-checkpoint.ts index 37429f904..82f68eafe 100644 --- a/src/shared/git-checkpoint.ts +++ b/src/shared/git-checkpoint.ts @@ -29,6 +29,14 @@ export type GitCheckpointRestoreResult = | 'git_unavailable' | 'not_found' | 'conflict' + | 'partial' | 'error' message: string + /** + * Present when `reason === 'partial'`: untracked files that existed at + * checkpoint time but were NOT snapshotted (over the size budget). + * Restoring would `git clean` them with no way to bring them back, so the + * restore is refused unless the caller opts in with `allowPartialRestore`. + */ + skippedUntracked?: string[] } diff --git a/src/shared/kun-gui-api.ts b/src/shared/kun-gui-api.ts index 308531a42..8285a9361 100644 --- a/src/shared/kun-gui-api.ts +++ b/src/shared/kun-gui-api.ts @@ -388,6 +388,7 @@ export type KunGuiApi = { }) => Promise restoreGitCheckpoint: (params: { checkpointId: string + allowPartialRestore?: boolean }) => Promise checkoutGitBranchWorktree: (workspaceRoot: string, branch: string) => Promise createGitBranchWorktree: (workspaceRoot: string, branch: string) => Promise From 0bd65f97844dc2c95148b47145d5120dc2590f1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:39:49 +0800 Subject: [PATCH 07/34] fix(usage): align cache hit rate across session views (#662) --- src/renderer/src/components/SessionHeader.tsx | 4 ++-- .../src/components/chat/FloatingComposer.tsx | 4 ++-- src/renderer/src/hooks/use-thread-usage.test.ts | 11 ++++++++++- src/renderer/src/hooks/use-thread-usage.ts | 16 ++++++++++++++++ 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/SessionHeader.tsx b/src/renderer/src/components/SessionHeader.tsx index ed2454135..4f0cf4a3b 100644 --- a/src/renderer/src/components/SessionHeader.tsx +++ b/src/renderer/src/components/SessionHeader.tsx @@ -10,7 +10,7 @@ import { formatCacheMissReason, formatCost, formatPercent, - primaryCacheHitRate, + cumulativeCacheHitRate, useThreadUsage } from '../hooks/use-thread-usage' @@ -214,7 +214,7 @@ export function SessionHeader({ compact = false, className = '' }: Props): React } )} > - {t('sessionUsageCache', { cache: formatPercent(primaryCacheHitRate(threadUsage)) })} + {t('sessionUsageCache', { cache: formatPercent(cumulativeCacheHitRate(threadUsage)) })} ) : null} diff --git a/src/renderer/src/components/chat/FloatingComposer.tsx b/src/renderer/src/components/chat/FloatingComposer.tsx index 52a566f6d..07a81dcc7 100644 --- a/src/renderer/src/components/chat/FloatingComposer.tsx +++ b/src/renderer/src/components/chat/FloatingComposer.tsx @@ -82,7 +82,7 @@ import { formatCompactNumber, formatCost, formatPercent, - primaryCacheHitRate, + cumulativeCacheHitRate, useThreadUsageState } from '../../hooks/use-thread-usage' import { buildContextCapacity, estimateBlockTokens } from '../../lib/context-capacity' @@ -2485,7 +2485,7 @@ export function FloatingComposer({ · {t('sessionUsageCache', { - cache: formatPercent(primaryCacheHitRate(threadUsage)) + cache: formatPercent(cumulativeCacheHitRate(threadUsage)) })} diff --git a/src/renderer/src/hooks/use-thread-usage.test.ts b/src/renderer/src/hooks/use-thread-usage.test.ts index c7f6f8455..13debc6e2 100644 --- a/src/renderer/src/hooks/use-thread-usage.test.ts +++ b/src/renderer/src/hooks/use-thread-usage.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { formatCost, loadThreadUsage, primaryCacheHitRate } from './use-thread-usage' +import { cumulativeCacheHitRate, formatCost, loadThreadUsage, primaryCacheHitRate } from './use-thread-usage' type RuntimeRequest = (path: string, method?: string) => Promise<{ ok: boolean; status: number; body: string }> @@ -40,6 +40,15 @@ describe('thread usage formatting', () => { expect(primaryCacheHitRate({ cacheHitRate: null, lastTurnCacheHitRate: null })).toBeNull() }) + it('derives the cumulative cache rate from tokens to match the overall usage panel (#654)', () => { + // Last-turn rate could be 0 while the thread cumulative is non-zero — the + // chip must show the token-derived cumulative so it matches the overall panel. + expect(cumulativeCacheHitRate({ cachedTokens: 41, cacheMissTokens: 959, cacheHitRate: 0 })).toBeCloseTo(0.041, 3) + // Falls back to the provided rate when there is no token telemetry. + expect(cumulativeCacheHitRate({ cachedTokens: 0, cacheMissTokens: 0, cacheHitRate: 0.8 })).toBe(0.8) + expect(cumulativeCacheHitRate({ cachedTokens: 0, cacheMissTokens: 0, cacheHitRate: null })).toBeNull() + }) + it('keeps cache hit rate unknown for cachedTokens-only thread usage buckets', async () => { const runtimeRequest = vi.fn(async (path) => { if (path === threadUsagePath('thr_cached_only')) { diff --git a/src/renderer/src/hooks/use-thread-usage.ts b/src/renderer/src/hooks/use-thread-usage.ts index 530eeb16d..1358e8c6f 100644 --- a/src/renderer/src/hooks/use-thread-usage.ts +++ b/src/renderer/src/hooks/use-thread-usage.ts @@ -88,6 +88,22 @@ export function primaryCacheHitRate( return usage.lastTurnCacheHitRate ?? usage.cacheHitRate } +/** + * Cumulative thread cache hit rate derived from token counts — the SAME formula + * the overall usage panel uses (cachedTokens / (cachedTokens + cacheMissTokens)). + * This keeps the conversation bottom bar consistent with the overall stats + * (issue #654): the last-turn rate could read 0% while the thread cumulative is + * non-zero. Falls back to the backend-provided `cacheHitRate` when no token + * telemetry is available. + */ +export function cumulativeCacheHitRate( + usage: Pick +): number | null { + const total = usage.cachedTokens + usage.cacheMissTokens + if (total > 0) return usage.cachedTokens / total + return usage.cacheHitRate +} + export function formatCacheMissReason(reason: string): string { switch (reason) { case 'cold_request': From fb1218b59111edec9e736230babe58471fcc4d8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:39:57 +0800 Subject: [PATCH 08/34] fix(provider): correct OpenCode Go DeepSeek v4 profiles (#664) --- src/shared/app-settings-provider.test.ts | 30 ++++++++++++++++++++++++ src/shared/model-provider-presets.ts | 10 ++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/shared/app-settings-provider.test.ts b/src/shared/app-settings-provider.test.ts index 3519d34bf..4b201d38a 100644 --- a/src/shared/app-settings-provider.test.ts +++ b/src/shared/app-settings-provider.test.ts @@ -1219,4 +1219,34 @@ describe('provider presets', () => { expect(resolved.modelProfiles['minimax-m3'].endpointFormat).toBe('messages') expect(resolved.modelProfiles['glm-5.1'].endpointFormat).toBeUndefined() }) + + it('keeps OpenCode Go DeepSeek v4 profiles aligned with DeepSeek defaults (#658)', () => { + const preset = getModelProviderPreset('opencode-go') + expect(preset).not.toBeNull() + const profile = modelProviderPresetProfile(preset!, 'sk-opencode') + + for (const modelId of ['deepseek-v4-pro', 'deepseek-v4-flash']) { + expect(profile.modelProfiles[modelId]).toMatchObject({ + contextWindowTokens: 1_000_000, + reasoning: { + supportedEfforts: ['off', 'high', 'max'], + defaultEffort: 'max', + requestProtocol: 'deepseek-chat-completions' + } + }) + } + + const resolved = resolveKunRuntimeSettings({ + ...settings(), + provider: { + ...defaultModelProviderSettings(), + providers: [...defaultModelProviderSettings().providers, profile] + }, + agents: { + kun: { ...defaultKunRuntimeSettings(), providerId: profile.id, model: 'deepseek-v4-pro' } + } + }) + expect(resolved.modelProfiles['deepseek-v4-pro']).toEqual(profile.modelProfiles['deepseek-v4-pro']) + expect(resolved.modelProfiles['deepseek-v4-flash']).toEqual(profile.modelProfiles['deepseek-v4-flash']) + }) }) diff --git a/src/shared/model-provider-presets.ts b/src/shared/model-provider-presets.ts index c011a40c5..118a341d4 100644 --- a/src/shared/model-provider-presets.ts +++ b/src/shared/model-provider-presets.ts @@ -157,6 +157,12 @@ const GLM_REASONING: ModelProviderReasoningCapabilityV1 = { requestProtocol: 'glm-chat-completions' } +const DEEPSEEK_REASONING: ModelProviderReasoningCapabilityV1 = { + supportedEfforts: ['off', 'high', 'max'], + defaultEffort: 'max', + requestProtocol: 'deepseek-chat-completions' +} + // 通义千问 / 混元 / 豆包的「思考」开关各家用私有 body 字段,无法用现有 requestProtocol 精确映射, // 这里统一按「内置推理」建模(requestProtocol: 'none'):只展示 effort 开关、不向上游发送特定协议字段,避免请求被拒。 const QWEN_REASONING: ModelProviderReasoningCapabilityV1 = { @@ -350,8 +356,8 @@ export const MODEL_PROVIDER_PRESETS: ModelProviderPreset[] = [ 'kimi-k2.7': textChatProfile(131_072), 'kimi-k2.7-code': textChatProfile(131_072), 'kimi-k2.6': textChatProfile(131_072), - 'deepseek-v4-pro': textChatProfile(131_072), - 'deepseek-v4-flash': textChatProfile(131_072), + 'deepseek-v4-pro': textChatProfile(1_000_000, DEEPSEEK_REASONING), + 'deepseek-v4-flash': textChatProfile(1_000_000, DEEPSEEK_REASONING), 'mimo-v2.5': textChatProfile(131_072), 'mimo-v2.5-pro': textChatProfile(131_072), 'mimo-v2-pro': textChatProfile(131_072), From 627dd9e9f4bdfc429e4b41859f9d932de97e1a6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:40:08 +0800 Subject: [PATCH 09/34] fix(sdd): make requirement workspace explicit (#666) --- src/renderer/src/components/Workbench.tsx | 43 +++++++++++--------- src/renderer/src/components/chat/Sidebar.tsx | 12 +++++- src/renderer/src/sdd/sdd-draft-store.test.ts | 10 +++++ src/renderer/src/sdd/sdd-draft-store.ts | 10 +++++ 4 files changed, 55 insertions(+), 20 deletions(-) diff --git a/src/renderer/src/components/Workbench.tsx b/src/renderer/src/components/Workbench.tsx index d733ab94a..c0097c330 100644 --- a/src/renderer/src/components/Workbench.tsx +++ b/src/renderer/src/components/Workbench.tsx @@ -60,11 +60,17 @@ import { composeWritePrompt } from '../write/quoted-selection' import { resolveWriteAgentPreset } from '../write/agent-presets' import { useWriteWorkspaceStore } from '../write/write-workspace-store' import { isWriteThreadId } from '../write/write-thread-registry' -import { buildSddDraftId, createSddDraft, forgetRememberedSddDraft, useSddDraftStore } from '../sdd/sdd-draft-store' +import { + buildSddDraftId, + createSddDraft, + forgetRememberedSddDraft, + resolveSddRequirementWorkspace, + useSddDraftStore +} from '../sdd/sdd-draft-store' import type { SddDraft, SddDraftSaveStatus } from '../sdd/sdd-draft-store' import { listSddDraftHistory, titleFromSddDraftContent } from '../sdd/sdd-draft-history' import { saveActiveSddDraftToDisk } from '../sdd/sdd-draft-actions' -import { restoreRememberedSddDraft, restoreSddDraft } from '../sdd/sdd-draft-restore' +import { restoreSddDraft } from '../sdd/sdd-draft-restore' import { composeSddAssistantPrompt } from '../sdd/sdd-assistant-prompt' import { frameworkById } from '../sdd/pm-skill-frameworks' import { collectSddDraftImages, withAttachmentIds, type SddDraftImageReference } from '../sdd/sdd-draft-images' @@ -90,7 +96,7 @@ import { CODE_PANEL_PREFERRED, useWorkbenchLayout } from './workbench-layout' import { useWorkbenchPlanController } from './workbench-plan-controller' import { prepareImageAttachmentUpload } from '../lib/image-attachment-upload' import { isChatAttachmentUploadEnabled } from '../lib/attachment-upload-availability' -import { normalizeWorkspaceRoot } from '../lib/workspace-path' +import { isConversationWorkspacePath, normalizeWorkspaceRoot } from '../lib/workspace-path' import { useKeyboardShortcutSettings } from '../lib/keyboard-shortcut-settings' import { collectComposerChangeSummary } from '../lib/composer-change-summary' import { formatWorkspacePickerError } from '../lib/format-workspace-picker-error' @@ -406,6 +412,7 @@ export function Workbench(): ReactElement { route, pluginHostRoute, workspaceRoot, + conversationWorkspaceRoot, runtimeConnection, setRoute, openCode, @@ -471,6 +478,7 @@ export function Workbench(): ReactElement { route: s.route, pluginHostRoute: s.pluginHostRoute, workspaceRoot: s.workspaceRoot, + conversationWorkspaceRoot: s.conversationWorkspaceRoot, runtimeConnection: s.runtimeConnection, setRoute: s.setRoute, openCode: s.openCode, @@ -1506,30 +1514,27 @@ export function Workbench(): ReactElement { } const startNewSddRequirement = async (): Promise => { - const activeCodeWorkspace = activeThreadId - ? normalizeWorkspaceRoot(codeThreads.find((thread) => thread.id === activeThreadId)?.workspace ?? '') - : '' - let targetWorkspace = activeCodeWorkspace || normalizeWorkspaceRoot(workspaceRoot) - if (!targetWorkspace) { - const picked = await chooseWorkspace({ selectThreadAfter: false }) - targetWorkspace = normalizeWorkspaceRoot(picked ?? useChatStore.getState().workspaceRoot) + const suggestedWorkspace = resolveSddRequirementWorkspace(codeThreads, activeThreadId, workspaceRoot) + let targetWorkspace = '' + try { + const picked = await window.kunGui.pickWorkspaceDirectory(suggestedWorkspace || undefined) + if (picked.canceled || !picked.path) return + targetWorkspace = normalizeWorkspaceRoot(picked.path) + } catch (error) { + setError(formatWorkspacePickerError(error)) + return } if (!targetWorkspace) { setError(t('workspaceRequiredToCreateThread')) return } - const restored = await restoreRememberedSddDraft({ - workspaceRoot: targetWorkspace, - readWorkspaceFile: window.kunGui.readWorkspaceFile - }) - if (restored.kind === 'restored') { - await openSddRequirementDraft(restored.draft, restored.content, { - lastSavedContent: restored.lastSavedContent, - saveStatus: restored.saveStatus - }) + if (isConversationWorkspacePath(targetWorkspace, conversationWorkspaceRoot)) { + setError(t('workspaceInsideConversationDir')) return } + if (useSddDraftStore.getState().activeDraft && !await saveActiveSddDraftToDisk()) return + const draftUuid = globalThis.crypto?.randomUUID?.() ?? `draft-${Date.now()}` const draft = createSddDraft({ id: draftUuid, workspaceRoot: targetWorkspace }) const initialContent = [ diff --git a/src/renderer/src/components/chat/Sidebar.tsx b/src/renderer/src/components/chat/Sidebar.tsx index dc8fe65ea..e8fe32b3f 100644 --- a/src/renderer/src/components/chat/Sidebar.tsx +++ b/src/renderer/src/components/chat/Sidebar.tsx @@ -15,7 +15,7 @@ import { } from 'lucide-react' import type { NormalizedThread } from '../../agent/types' import { useChatStore, type SettingsRouteSection } from '../../store/chat-store' -import type { SddDraft } from '../../sdd/sdd-draft-store' +import { resolveSddRequirementWorkspace, type SddDraft } from '../../sdd/sdd-draft-store' import type { ClawImChannelV1, } from '@shared/app-settings' @@ -29,6 +29,7 @@ import { ConnectPhoneSidebarPanel } from './ConnectPhoneView' import { SidebarProjectsSection } from './SidebarProjectsSection' import { SidebarConversationsSection } from './SidebarConversationsSection' import { WorkspaceModeTabs } from './WorkspaceModeTabs' +import { workspaceLabelFromPath } from '../../lib/workspace-label' import { SidebarCommandRow, SidebarFrame, @@ -128,6 +129,7 @@ export function Sidebar({ const deleteClawChannel = useChatStore((s) => s.deleteClawChannel) const resetClawChannelSession = useChatStore((s) => s.resetClawChannelSession) const [imDialogMode, setImDialogMode] = useState(null) + const requirementWorkspace = resolveSddRequirementWorkspace(threads, activeThreadId, workspaceRoot) const activeClawChannel = useMemo( () => clawChannels.find((channel) => channel.id === activeClawChannelId) ?? clawChannels[0] ?? null, @@ -210,6 +212,14 @@ export function Sidebar({ disabled={!runtimeReady} disabledHint={t('runtimeActionNeedsConnection')} variant="accent" + trailing={requirementWorkspace ? ( + + {workspaceLabelFromPath(requirementWorkspace)} + + ) : null} /> ) : null} diff --git a/src/renderer/src/sdd/sdd-draft-store.test.ts b/src/renderer/src/sdd/sdd-draft-store.test.ts index 3733344b6..44012deb7 100644 --- a/src/renderer/src/sdd/sdd-draft-store.test.ts +++ b/src/renderer/src/sdd/sdd-draft-store.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createSddDraft, forgetRememberedSddDraft, + resolveSddRequirementWorkspace, readRememberedSddDraftContent, readRememberedSddDraft, useSddDraftStore @@ -29,6 +30,15 @@ function createMemoryStorage(): Storage { } describe('sdd-draft-store', () => { + it('prefers the active thread workspace when creating a requirement', () => { + expect(resolveSddRequirementWorkspace( + [{ id: 'thread-1', workspace: '/projects/active' }], + 'thread-1', + '/projects/default' + )).toBe('/projects/active') + expect(resolveSddRequirementWorkspace([], null, '/projects/default/')).toBe('/projects/default') + }) + beforeEach(() => { vi.stubGlobal('localStorage', createMemoryStorage()) vi.stubGlobal('window', { diff --git a/src/renderer/src/sdd/sdd-draft-store.ts b/src/renderer/src/sdd/sdd-draft-store.ts index 1c505bf8e..97578d7d7 100644 --- a/src/renderer/src/sdd/sdd-draft-store.ts +++ b/src/renderer/src/sdd/sdd-draft-store.ts @@ -211,6 +211,16 @@ export function createSddDraft(options: { } } +export function resolveSddRequirementWorkspace( + threads: readonly { id: string; workspace?: string }[], + activeThreadId: string | null, + fallbackWorkspace: string +): string { + return normalizeWorkspaceRoot( + threads.find((thread) => thread.id === activeThreadId)?.workspace ?? fallbackWorkspace + ) +} + export function rememberSddDraft(draft: SddDraft): void { const normalized = normalizeDraft(draft) if (!normalized) return From 6e45a66214acfaa6849dbe6353442cae5f2d4c8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:40:15 +0800 Subject: [PATCH 10/34] fix(attachments): enforce upload safety boundaries (#667) --- kun/src/attachments/attachment-store.ts | 4 ++++ kun/src/server/read-json-body.ts | 29 +++++++++++++++++++++++-- kun/src/server/routes/attachments.ts | 13 ++++++++++- kun/tests/attachment-store.test.ts | 21 ++++++++++++++++++ kun/tests/read-json-body.test.ts | 21 ++++++++++++++++++ 5 files changed, 85 insertions(+), 3 deletions(-) diff --git a/kun/src/attachments/attachment-store.ts b/kun/src/attachments/attachment-store.ts index 3543da8e2..ec0ba62fa 100644 --- a/kun/src/attachments/attachment-store.ts +++ b/kun/src/attachments/attachment-store.ts @@ -5,6 +5,8 @@ import type { AttachmentsCapabilityConfig } from '../contracts/capabilities.js' import type { AttachmentDiagnostics, AttachmentMetadata, AttachmentTextFallback } from '../contracts/attachments.js' import { AttachmentMetadata as AttachmentMetadataSchema } from '../contracts/attachments.js' +const ATTACHMENT_ID_PATTERN = /^att_[0-9a-f]{24}$/ + export type AttachmentContent = AttachmentMetadata & { data: Buffer } @@ -95,6 +97,7 @@ export class FileAttachmentStore implements AttachmentStore { } async get(id: string): Promise { + if (!ATTACHMENT_ID_PATTERN.test(id)) return null try { return AttachmentMetadataSchema.parse(JSON.parse(await readFile(this.metadataPath(id), 'utf8'))) } catch { @@ -103,6 +106,7 @@ export class FileAttachmentStore implements AttachmentStore { } async resolveContent(id: string, scope: { threadId?: string; workspace?: string }): Promise { + if (!ATTACHMENT_ID_PATTERN.test(id)) throw new Error(`invalid attachment id: ${id}`) const metadata = await this.get(id) if (!metadata) throw new Error(`attachment not found: ${id}`) if (!isAuthorized(metadata, scope)) throw new Error(`attachment is not authorized for this turn: ${id}`) diff --git a/kun/src/server/read-json-body.ts b/kun/src/server/read-json-body.ts index 2177c2a3c..c678bafc8 100644 --- a/kun/src/server/read-json-body.ts +++ b/kun/src/server/read-json-body.ts @@ -5,9 +5,27 @@ export type ReadJsonBodyResult = | { ok: true; value: unknown } | { ok: false; response: JsonResponse } -export async function readJsonBody(request: Request): Promise { +const DEFAULT_MAX_JSON_BODY_BYTES = 32 * 1024 * 1024 + +export async function readJsonBody(request: Request, maxBytes = DEFAULT_MAX_JSON_BODY_BYTES): Promise { if (request.body === null) return { ok: true, value: {} } - const text = await request.text() + const declaredLength = Number(request.headers.get('content-length')) + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) return bodyTooLarge(maxBytes) + + const reader = request.body.getReader() + const chunks: Uint8Array[] = [] + let totalBytes = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + totalBytes += value.byteLength + if (totalBytes > maxBytes) { + await reader.cancel().catch(() => undefined) + return bodyTooLarge(maxBytes) + } + chunks.push(value) + } + const text = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8') if (!text) return { ok: true, value: {} } try { return { ok: true, value: JSON.parse(text) } @@ -20,3 +38,10 @@ export async function readJsonBody(request: Request): Promise { }) }) + it('rejects attachment ids that could escape the store directory', async () => { + const store = createStore() + await expect(store.get('../outside')).resolves.toBeNull() + await expect(store.resolveContent('..\\outside', {})).rejects.toThrow(/invalid attachment id/) + }) + it('rejects unsupported MIME, size, and dimensions', async () => { await expect(createStore().create({ name: 'bad.txt', @@ -172,6 +178,21 @@ describe('Attachment store and multimodal input', () => { expect(await readJson(diagnostics)).toMatchObject({ enabled: true, count: 1 }) }) + it('rejects malformed base64 attachment uploads', async () => { + const h = buildHarness() + h.runtime.attachmentStore = createStore() + const response = await dispatchRequest( + h.router, + new Request('http://localhost/v1/attachments', { + method: 'POST', + headers: { authorization: 'Bearer tok-1', 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'shot.png', dataBase64: 'not-base64!' }) + }) + ) + expect(response.status).toBe(400) + expect(await readJson(response)).toMatchObject({ message: 'attachment data is not valid base64' }) + }) + it('resolves image attachments for vision models and text fallbacks for text-only models', async () => { const store = createStore() const workspace = join(dir, 'workspace') diff --git a/kun/tests/read-json-body.test.ts b/kun/tests/read-json-body.test.ts index 9ddf0071f..e37c4d920 100644 --- a/kun/tests/read-json-body.test.ts +++ b/kun/tests/read-json-body.test.ts @@ -35,4 +35,25 @@ describe('readJsonBody', () => { message: 'invalid JSON body' }) }) + + it('rejects a declared body that exceeds the configured byte limit', async () => { + const result = await readJsonBody(new Request('http://localhost/v1/demo', { + method: 'POST', + headers: { 'content-length': '128' }, + body: '{}' + }), 32) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.response.status).toBe(413) + }) + + it('rejects a streamed body that exceeds the configured byte limit', async () => { + const result = await readJsonBody(new Request('http://localhost/v1/demo', { + method: 'POST', + body: JSON.stringify({ text: 'x'.repeat(128) }) + }), 32) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.response.status).toBe(413) + }) }) From 03c5f81e95814e6420dae8cd3a313334420a5f7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:40:25 +0800 Subject: [PATCH 11/34] feat(artifacts): offload oversized tool output (#669) --- kun/src/adapters/tool/artifact-tool.test.ts | 54 +++ kun/src/adapters/tool/artifact-tool.ts | 69 ++++ kun/src/adapters/tool/local-tool-host.test.ts | 31 ++ kun/src/adapters/tool/local-tool-host.ts | 32 +- kun/src/artifacts/artifact-store.test.ts | 192 +++++++++ kun/src/artifacts/artifact-store.ts | 384 ++++++++++++++++++ kun/src/artifacts/artifact-summary.test.ts | 42 ++ kun/src/artifacts/artifact-summary.ts | 77 ++++ kun/src/delegation/child-agent-executor.ts | 3 + kun/src/loop/agent-loop.ts | 3 + kun/src/ports/tool-host.ts | 3 + kun/src/server/runtime-factory.ts | 12 + 12 files changed, 901 insertions(+), 1 deletion(-) create mode 100644 kun/src/adapters/tool/artifact-tool.test.ts create mode 100644 kun/src/adapters/tool/artifact-tool.ts create mode 100644 kun/src/artifacts/artifact-store.test.ts create mode 100644 kun/src/artifacts/artifact-store.ts create mode 100644 kun/src/artifacts/artifact-summary.test.ts create mode 100644 kun/src/artifacts/artifact-summary.ts diff --git a/kun/src/adapters/tool/artifact-tool.test.ts b/kun/src/adapters/tool/artifact-tool.test.ts new file mode 100644 index 000000000..fa46a53af --- /dev/null +++ b/kun/src/adapters/tool/artifact-tool.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest' +import { createReadArtifactTool } from './artifact-tool.js' +import { InMemoryArtifactStore } from '../../artifacts/artifact-store.js' +import type { ToolHostContext } from '../../ports/tool-host.js' + +function context(artifactStore?: ToolHostContext['artifactStore']): ToolHostContext { + return { + threadId: 't', turnId: 'tn', workspace: '/ws', approvalPolicy: 'auto', + abortSignal: new AbortController().signal, awaitApproval: vi.fn(async () => 'allow' as const), + ...(artifactStore ? { artifactStore } : {}) + } +} + +describe('read_artifact tool', () => { + it('reads full content + metadata by id', async () => { + const store = new InMemoryArtifactStore(() => 't0') + const { meta } = await store.put({ content: 'hello world', source: 'mcp', origin: 'docs' }) + const tool = createReadArtifactTool() + const result = await tool.execute({ artifactId: meta.id }, context(store)) + expect(result.output).toMatchObject({ content: 'hello world', source: 'mcp', origin: 'docs' }) + }) + + it('reads a line range', async () => { + const store = new InMemoryArtifactStore() + const { meta } = await store.put({ content: 'l1\nl2\nl3\nl4' }) + const tool = createReadArtifactTool() + const result = await tool.execute({ artifactId: meta.id, startLine: 2, endLine: 3 }, context(store)) + expect(result.output).toMatchObject({ content: 'l2\nl3', range: { startLine: 2, endLine: 3 } }) + }) + + it('bounds a no-range read and returns a cursor for a large artifact', async () => { + const { ARTIFACT_MAX_READ_BYTES } = await import('../../artifacts/artifact-store.js') + const store = new InMemoryArtifactStore(() => 't0') + const { meta } = await store.put({ content: 'y'.repeat(ARTIFACT_MAX_READ_BYTES + 1_000) }) + const tool = createReadArtifactTool() + const result = await tool.execute({ artifactId: meta.id }, context(store)) + const out = result.output as Record + expect(Buffer.byteLength(String(out.content), 'utf8')).toBe(ARTIFACT_MAX_READ_BYTES) + expect(out.truncated).toBe(true) + expect(out.nextOffset).toBe(ARTIFACT_MAX_READ_BYTES) + }) + + it('errors for an unknown artifact', async () => { + const tool = createReadArtifactTool() + const result = await tool.execute({ artifactId: 'art_missing' }, context(new InMemoryArtifactStore())) + expect(result.isError).toBe(true) + }) + + it('errors when the artifact store is unavailable', async () => { + const tool = createReadArtifactTool() + const result = await tool.execute({ artifactId: 'art_x' }, context()) + expect(result.isError).toBe(true) + }) +}) diff --git a/kun/src/adapters/tool/artifact-tool.ts b/kun/src/adapters/tool/artifact-tool.ts new file mode 100644 index 000000000..9dacf77c6 --- /dev/null +++ b/kun/src/adapters/tool/artifact-tool.ts @@ -0,0 +1,69 @@ +/** + * Agent-callable artifact reader (P0 #5). + * + * Large tool results are offloaded to the content-addressed artifact store and + * the model only sees a bounded summary + an artifact id. This tool lets the + * model pull the rest on demand — the full content, a byte range, or a line + * range — instead of forcing every big payload into context. Read-only. + */ + +import { LocalToolHost, type LocalTool } from './local-tool-host.js' +import { readArtifactBounded } from '../../artifacts/artifact-store.js' + +export function createReadArtifactTool(): LocalTool { + return LocalToolHost.defineTool({ + name: 'read_artifact', + description: + 'Read a stored artifact (large tool output) by id. Optionally fetch only a slice: ' + + 'startLine/endLine for a 1-indexed inclusive line range, or offset/length for a UTF-8 byte range. ' + + 'Use the artifact id returned by a previous tool result (e.g. stdoutArtifactId).', + inputSchema: { + type: 'object', + properties: { + artifactId: { type: 'string' }, + offset: { type: 'number' }, + length: { type: 'number' }, + startLine: { type: 'number' }, + endLine: { type: 'number' } + }, + required: ['artifactId'], + additionalProperties: false + }, + policy: 'auto', + execute: async (args, context) => { + if (!context.artifactStore) { + return { output: { error: 'artifact store is not available in this runtime' }, isError: true } + } + const artifactId = typeof args.artifactId === 'string' ? args.artifactId.trim() : '' + if (!artifactId) return { output: { error: 'artifactId is required' }, isError: true } + const meta = await context.artifactStore.stat(artifactId) + if (!meta) return { output: { error: `artifact not found: ${artifactId}` }, isError: true } + const range = { + ...(typeof args.offset === 'number' ? { offset: args.offset } : {}), + ...(typeof args.length === 'number' ? { length: args.length } : {}), + ...(typeof args.startLine === 'number' ? { startLine: args.startLine } : {}), + ...(typeof args.endLine === 'number' ? { endLine: args.endLine } : {}) + } + // Always read through the bounded reader: a request with no range, or a + // range larger than the cap, is clamped to <=1 MiB / <=2000 lines and a + // cursor is returned so the model pages rather than pulling a huge result + // into context. + const bounded = await readArtifactBounded(context.artifactStore, artifactId, meta, range) + if (bounded === null) return { output: { error: `artifact content not found: ${artifactId}` }, isError: true } + return { + output: { + artifactId, + byteSize: meta.byteSize, + lineCount: meta.lineCount, + ...(meta.source ? { source: meta.source } : {}), + ...(meta.origin ? { origin: meta.origin } : {}), + range: bounded.range, + truncated: bounded.truncated, + ...(bounded.nextOffset !== undefined ? { nextOffset: bounded.nextOffset } : {}), + ...(bounded.nextStartLine !== undefined ? { nextStartLine: bounded.nextStartLine } : {}), + content: bounded.content + } + } + } + }) +} diff --git a/kun/src/adapters/tool/local-tool-host.test.ts b/kun/src/adapters/tool/local-tool-host.test.ts index adfe4f2b6..918277bf4 100644 --- a/kun/src/adapters/tool/local-tool-host.test.ts +++ b/kun/src/adapters/tool/local-tool-host.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { LocalToolHost, echoTool } from './local-tool-host.js' import type { ToolHostContext } from '../../ports/tool-host.js' +import { InMemoryArtifactStore } from '../../artifacts/artifact-store.js' describe('LocalToolHost approval policy', () => { it('asks before auto tools when approval policy is always', async () => { @@ -26,4 +27,34 @@ describe('LocalToolHost approval policy', () => { expect(awaitApproval).toHaveBeenCalledTimes(1) expect(result.approved).toBe(false) }) + + it('offloads oversized successful tool output to the artifact store', async () => { + const artifactStore = new InMemoryArtifactStore() + const host = new LocalToolHost({ tools: [LocalToolHost.defineTool({ + name: 'large_output', + description: 'returns a large payload', + inputSchema: { type: 'object' }, + execute: async () => ({ output: 'x'.repeat(140 * 1024) }) + })] }) + const result = await host.execute( + { callId: 'call_large', toolName: 'large_output', arguments: {} }, + { + threadId: 'thread_1', + turnId: 'turn_1', + workspace: '/tmp/workspace', + approvalPolicy: 'auto', + sandboxMode: 'danger-full-access', + artifactStore, + abortSignal: new AbortController().signal, + awaitApproval: vi.fn(async () => 'allow' as const) + } + ) + expect(result.item).toMatchObject({ + kind: 'tool_result', + output: { artifactId: expect.stringMatching(/^art_/), truncated: true } + }) + if (result.item.kind !== 'tool_result') throw new Error('expected tool result') + const artifactId = String((result.item.output as Record).artifactId) + expect(await artifactStore.get(artifactId)).toHaveLength(140 * 1024) + }) }) diff --git a/kun/src/adapters/tool/local-tool-host.ts b/kun/src/adapters/tool/local-tool-host.ts index a80070ed8..6066c3e08 100644 --- a/kun/src/adapters/tool/local-tool-host.ts +++ b/kun/src/adapters/tool/local-tool-host.ts @@ -230,7 +230,7 @@ export class LocalToolHost implements ToolHost { } } const rateLimited = normalizeRateLimitedToolOutput(hookedResult.output) - const output = rateLimited.rateLimited ? rateLimited.output : hookedResult.output + let output = rateLimited.rateLimited ? rateLimited.output : hookedResult.output const isError = hookedResult.isError || rateLimited.isError this.readTracker.observeToolResult({ context, @@ -238,6 +238,7 @@ export class LocalToolHost implements ToolHost { output, isError }) + if (!isError) output = await offloadLargeToolOutput(output, activeCall.toolName, context) const item = makeToolResultItem({ id: `item_${activeCall.callId}`, turnId: context.turnId, @@ -340,6 +341,35 @@ export class LocalToolHost implements ToolHost { } } +const ARTIFACT_OUTPUT_THRESHOLD_BYTES = 128 * 1024 + +async function offloadLargeToolOutput( + output: unknown, + toolName: string, + context: ToolHostContext +): Promise { + if (!context.artifactStore) return output + let content: string + try { + content = typeof output === 'string' ? output : JSON.stringify(output) + } catch { + return output + } + if (Buffer.byteLength(content, 'utf8') <= ARTIFACT_OUTPUT_THRESHOLD_BYTES) return output + try { + const stored = await context.artifactStore.put({ content, source: 'tool', origin: toolName }) + return { + artifactId: stored.meta.id, + byteSize: stored.meta.byteSize, + lineCount: stored.meta.lineCount, + truncated: stored.summary.truncated, + preview: stored.summary.inline + } + } catch { + return output + } +} + function hookContext( context: ToolHostContext ): Pick { diff --git a/kun/src/artifacts/artifact-store.test.ts b/kun/src/artifacts/artifact-store.test.ts new file mode 100644 index 000000000..23dd12460 --- /dev/null +++ b/kun/src/artifacts/artifact-store.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { FileArtifactStore, InMemoryArtifactStore, type ArtifactStore } from './artifact-store.js' + +function runStoreContract(name: string, make: () => Promise<{ store: ArtifactStore; cleanup?: () => Promise }>) { + describe(name, () => { + it('stores content and returns a bounded summary + stable id', async () => { + const { store, cleanup } = await make() + try { + const big = 'x'.repeat(10_000) + const result = await store.put({ content: big, maxInlineChars: 200, source: 'mcp', origin: 'docs/lookup' }) + expect(result.meta.byteSize).toBe(10_000) + expect(result.summary.truncated).toBe(true) + expect(result.summary.inline.length).toBeLessThan(400) + expect(result.meta.id).toBe(result.summary.artifactId) + expect(await store.get(result.meta.id)).toBe(big) + } finally { + await cleanup?.() + } + }) + + it('dedupes identical content by hash', async () => { + const { store, cleanup } = await make() + try { + const a = await store.put({ content: 'same' }) + const b = await store.put({ content: 'same' }) + expect(a.meta.id).toBe(b.meta.id) + expect(b.deduped).toBe(true) + } finally { + await cleanup?.() + } + }) + + it('reads a line range on demand', async () => { + const { store, cleanup } = await make() + try { + const content = ['l1', 'l2', 'l3', 'l4', 'l5'].join('\n') + const { meta } = await store.put({ content }) + expect(await store.readRange(meta.id, { startLine: 2, endLine: 4 })).toBe('l2\nl3\nl4') + } finally { + await cleanup?.() + } + }) + + it('reads a byte range on demand', async () => { + const { store, cleanup } = await make() + try { + const { meta } = await store.put({ content: 'abcdefghij' }) + expect(await store.readRange(meta.id, { offset: 2, length: 3 })).toBe('cde') + } finally { + await cleanup?.() + } + }) + + it('returns null for an unknown id', async () => { + const { store, cleanup } = await make() + try { + expect(await store.get('art_missing')).toBeNull() + expect(await store.readRange('art_missing', {})).toBeNull() + expect(await store.stat('art_missing')).toBeNull() + } finally { + await cleanup?.() + } + }) + + it('records source metadata', async () => { + const { store, cleanup } = await make() + try { + const { meta } = await store.put({ content: 'hi', source: 'web', origin: 'web_fetch' }) + const stat = await store.stat(meta.id) + expect(stat).toMatchObject({ source: 'web', origin: 'web_fetch' }) + } finally { + await cleanup?.() + } + }) + }) +} + +runStoreContract('InMemoryArtifactStore', async () => ({ + store: new InMemoryArtifactStore(() => '2026-06-29T00:00:00.000Z') +})) + +runStoreContract('FileArtifactStore', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kun-artifacts-')) + return { + store: new FileArtifactStore(dir, () => '2026-06-29T00:00:00.000Z'), + cleanup: () => rm(dir, { recursive: true, force: true }) + } +}) + +describe('FileArtifactStore streaming reads', () => { + it('seeks a byte range and a line window from a large artifact without loading it whole', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kun-artifacts-')) + try { + const store = new FileArtifactStore(dir, () => 't0') + const lines = Array.from({ length: 100_000 }, (_, i) => `line ${i + 1}`) + const content = lines.join('\n') + const { meta } = await store.put({ content }) + // Line window stops early — only the selected lines come back. + expect(await store.readRange(meta.id, { startLine: 5, endLine: 7 })).toBe('line 5\nline 6\nline 7') + // Byte range seek. + expect(await store.readRange(meta.id, { offset: 0, length: 6 })).toBe('line 1') + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('returns the trailing line when no final newline and the range covers it', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kun-artifacts-')) + try { + const store = new FileArtifactStore(dir, () => 't0') + const { meta } = await store.put({ content: 'a\nb\nc' }) + expect(await store.readRange(meta.id, { startLine: 3, endLine: 3 })).toBe('c') + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('stitches a multibyte UTF-8 char split across a 64KiB read boundary (P2-04)', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kun-artifacts-')) + try { + const store = new FileArtifactStore(dir, () => 't0') + // Pad so a 3-byte char (世) straddles the 65536-byte chunk boundary, then + // read the line that contains it; without incremental decoding the char + // would surface as replacement characters. + const head = 'a'.repeat(65_535) + const content = `${head}世界\nsecond line` + const { meta } = await store.put({ content }) + const firstLine = await store.readRange(meta.id, { startLine: 1, endLine: 1 }) + expect(firstLine).toBe(`${head}世界`) + expect(firstLine).not.toContain('\uFFFD') + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) + +describe('readArtifactBounded', () => { + it('pages UTF-8 text without replacement characters or skipped bytes', async () => { + const { readArtifactBounded } = await import('./artifact-store.js') + const store = new InMemoryArtifactStore(() => 't0') + const { meta } = await store.put({ content: '中文测试' }) + const pages: string[] = [] + let offset = 0 + while (offset < meta.byteSize) { + const page = await readArtifactBounded(store, meta.id, meta, { offset, length: 2 }) + expect(page).not.toBeNull() + pages.push(page!.content) + expect(page!.content).not.toContain('�') + if (!page!.nextOffset) break + expect(page!.nextOffset).toBeGreaterThan(offset) + offset = page!.nextOffset + } + expect(pages.join('')).toBe('中文测试') + }) + + it('clamps a no-range read to the byte cap and returns a cursor', async () => { + const { readArtifactBounded, ARTIFACT_MAX_READ_BYTES } = await import('./artifact-store.js') + const store = new InMemoryArtifactStore(() => 't0') + const big = 'x'.repeat(ARTIFACT_MAX_READ_BYTES + 5_000) + const { meta } = await store.put({ content: big }) + const result = await readArtifactBounded(store, meta.id, meta, {}) + expect(result).not.toBeNull() + expect(Buffer.byteLength(result!.content, 'utf8')).toBe(ARTIFACT_MAX_READ_BYTES) + expect(result!.truncated).toBe(true) + expect(result!.nextOffset).toBe(ARTIFACT_MAX_READ_BYTES) + }) + + it('clamps an oversized line range to the line cap and returns a line cursor', async () => { + const { readArtifactBounded, ARTIFACT_MAX_READ_LINES } = await import('./artifact-store.js') + const store = new InMemoryArtifactStore(() => 't0') + const content = Array.from({ length: ARTIFACT_MAX_READ_LINES + 500 }, (_, i) => `l${i + 1}`).join('\n') + const { meta } = await store.put({ content }) + const result = await readArtifactBounded(store, meta.id, meta, { startLine: 1, endLine: ARTIFACT_MAX_READ_LINES + 500 }) + expect(result).not.toBeNull() + expect(result!.range.endLine).toBe(ARTIFACT_MAX_READ_LINES) + expect(result!.truncated).toBe(true) + expect(result!.nextStartLine).toBe(ARTIFACT_MAX_READ_LINES + 1) + }) + + it('reports not-truncated and no cursor when the whole artifact fits', async () => { + const { readArtifactBounded } = await import('./artifact-store.js') + const store = new InMemoryArtifactStore(() => 't0') + const { meta } = await store.put({ content: 'small content' }) + const result = await readArtifactBounded(store, meta.id, meta, {}) + expect(result!.content).toBe('small content') + expect(result!.truncated).toBe(false) + expect(result!.nextOffset).toBeUndefined() + }) +}) diff --git a/kun/src/artifacts/artifact-store.ts b/kun/src/artifacts/artifact-store.ts new file mode 100644 index 000000000..df6fd760f --- /dev/null +++ b/kun/src/artifacts/artifact-store.ts @@ -0,0 +1,384 @@ +/** + * Content-addressed Artifact Store (P0 #5). + * + * A unified mechanism for large tool results — MCP payloads, browser output, + * big JSON, remote logs, attachments — so the model only ever sees a bounded + * summary + a stable artifact id, and can fetch specific byte/line ranges on + * demand. Content is addressed by hash, so identical results dedupe. Two + * implementations: an in-memory store (tests / ephemeral runtime) and a + * file-backed store (persistent, survives restart for replay/audit). + */ + +import { mkdir, readFile, readdir, rename, rm, writeFile, stat as fsStat, open as fsOpen } from 'node:fs/promises' +import { join } from 'node:path' +import { StringDecoder } from 'node:string_decoder' +import { artifactId, summarizeForModel, type ArtifactSummary } from './artifact-summary.js' + +export type ArtifactSourceKind = 'mcp' | 'web' | 'bash' | 'attachment' | 'remote-log' | 'tool' | 'other' + +export type StoredArtifactMeta = { + id: string + byteSize: number + lineCount: number + mimeType?: string + source?: ArtifactSourceKind + /** Tool / origin label, e.g. an MCP tool name or `web_fetch`. */ + origin?: string + createdAt: string +} + +export type PutArtifactInput = { + content: string + mimeType?: string + source?: ArtifactSourceKind + origin?: string + /** Inline preview budget handed to the model. Default 4000. */ + maxInlineChars?: number +} + +export type PutArtifactResult = { + meta: StoredArtifactMeta + summary: ArtifactSummary + /** True when this content already existed (deduped by hash). */ + deduped: boolean +} + +export type ReadRangeOptions = { + /** Byte offset (UTF-8) for a raw slice. */ + offset?: number + length?: number + /** 1-indexed inclusive line range (takes precedence over byte offset). */ + startLine?: number + endLine?: number +} + +export interface ArtifactStore { + put(input: PutArtifactInput): Promise + get(id: string): Promise + readRange(id: string, options: ReadRangeOptions): Promise + stat(id: string): Promise +} + +const DEFAULT_MAX_INLINE = 4_000 + +/** Hard cap on any single artifact read: bytes and lines (P0 #5). A read that + * would exceed these is clamped and a cursor is returned so the caller pages. */ +export const ARTIFACT_MAX_READ_BYTES = 1_048_576 +export const ARTIFACT_MAX_READ_LINES = 2_000 + +/** Artifact ids are content hashes; reject anything else so an id can never + * escape the store directory (path traversal) when used in a file path. */ +const ARTIFACT_ID_PATTERN = /^art_[0-9a-f]{1,64}$/ + +export function isValidArtifactId(id: string): boolean { + return ARTIFACT_ID_PATTERN.test(id) +} + +export type BoundedArtifactRead = { + content: string + range: ReadRangeOptions + /** True when more content remains beyond what was returned. */ + truncated: boolean + /** Byte cursor for the next page (byte-range reads). */ + nextOffset?: number + /** Line cursor for the next page (line-range reads). */ + nextStartLine?: number +} + +/** + * Read an artifact with a HARD upper bound (P0 #5). Any request — including one + * with no range, or a range larger than the cap — is clamped to at most + * {@link ARTIFACT_MAX_READ_BYTES} / {@link ARTIFACT_MAX_READ_LINES}, and a + * cursor (`nextOffset` / `nextStartLine`) is returned when content remains so a + * caller can page instead of pulling a multi-GB artifact into memory/context. + * Byte accounting is derived from the requested window + the artifact size (not + * the decoded string) so the cursor is exact even when a byte slice lands on a + * multibyte UTF-8 boundary. + */ +export async function readArtifactBounded( + store: ArtifactStore, + id: string, + meta: StoredArtifactMeta, + requested: ReadRangeOptions +): Promise { + const lineMode = requested.startLine !== undefined || requested.endLine !== undefined + if (lineMode) { + const startLine = Math.max(1, requested.startLine ?? 1) + const requestedEnd = requested.endLine ?? startLine + ARTIFACT_MAX_READ_LINES - 1 + const endLine = Math.min(requestedEnd, startLine + ARTIFACT_MAX_READ_LINES - 1) + const range: ReadRangeOptions = { startLine, endLine } + const content = await store.readRange(id, range) + if (content === null) return null + const truncated = endLine < meta.lineCount + return { content, range, truncated, ...(truncated ? { nextStartLine: endLine + 1 } : {}) } + } + const offset = Math.max(0, requested.offset ?? 0) + const requestedLen = requested.length ?? ARTIFACT_MAX_READ_BYTES + const length = Math.min(Math.max(0, requestedLen), ARTIFACT_MAX_READ_BYTES) + const content = await store.readRange(id, { offset, length }) + if (content === null) return null + const bytesConsumed = Buffer.byteLength(content, 'utf8') + const range: ReadRangeOptions = { offset, length: bytesConsumed } + const truncated = offset + bytesConsumed < meta.byteSize + return { content, range, truncated, ...(truncated ? { nextOffset: offset + bytesConsumed } : {}) } +} + +function buildMeta(input: PutArtifactInput, id: string, nowIso: () => string): StoredArtifactMeta { + const byteSize = Buffer.byteLength(input.content, 'utf8') + const lineCount = input.content.length === 0 ? 0 : input.content.split('\n').length + return { + id, + byteSize, + lineCount, + ...(input.mimeType ? { mimeType: input.mimeType } : {}), + ...(input.source ? { source: input.source } : {}), + ...(input.origin ? { origin: input.origin } : {}), + createdAt: nowIso() + } +} + +function sliceContent(content: string, options: ReadRangeOptions): string { + if (options.startLine !== undefined || options.endLine !== undefined) { + const lines = content.split('\n') + const start = Math.max(1, options.startLine ?? 1) + const end = Math.min(lines.length, options.endLine ?? lines.length) + if (start > end) return '' + return lines.slice(start - 1, end).join('\n') + } + if (options.offset !== undefined || options.length !== undefined) { + const buffer = Buffer.from(content, 'utf8') + const offset = Math.max(0, options.offset ?? 0) + const length = options.length !== undefined ? Math.max(0, options.length) : buffer.length - offset + return decodeUtf8Window(buffer.subarray(offset, offset + length + 3), length) + } + return content +} + +export class InMemoryArtifactStore implements ArtifactStore { + private readonly contents = new Map() + private readonly metas = new Map() + + constructor(private readonly nowIso: () => string = () => new Date().toISOString()) {} + + async put(input: PutArtifactInput): Promise { + const id = artifactId(input.content) + const summary = summarizeForModel({ + content: input.content, + maxInlineChars: input.maxInlineChars ?? DEFAULT_MAX_INLINE + }) + const deduped = this.contents.has(id) + if (!deduped) { + this.contents.set(id, input.content) + this.metas.set(id, buildMeta(input, id, this.nowIso)) + } + return { meta: this.metas.get(id)!, summary, deduped } + } + + async get(id: string): Promise { + return this.contents.get(id) ?? null + } + + async readRange(id: string, options: ReadRangeOptions): Promise { + const content = this.contents.get(id) + if (content === undefined) return null + return sliceContent(content, options) + } + + async stat(id: string): Promise { + return this.metas.get(id) ?? null + } +} + +export class FileArtifactStore implements ArtifactStore { + private ready?: Promise + + constructor( + private readonly dir: string, + private readonly nowIso: () => string = () => new Date().toISOString(), + private readonly limits: { maxTotalBytes?: number; maxArtifacts?: number } = {} + ) {} + + private async ensureDir(): Promise { + if (!this.ready) this.ready = mkdir(this.dir, { recursive: true }).then(() => undefined) + return this.ready + } + + private contentPath(id: string): string { + return join(this.dir, `${id}.bin`) + } + + private metaPath(id: string): string { + return join(this.dir, `${id}.json`) + } + + async put(input: PutArtifactInput): Promise { + await this.ensureDir() + const id = artifactId(input.content) + const summary = summarizeForModel({ + content: input.content, + maxInlineChars: input.maxInlineChars ?? DEFAULT_MAX_INLINE + }) + let deduped = true + try { + await Promise.all([fsStat(this.contentPath(id)), fsStat(this.metaPath(id))]) + } catch { + deduped = false + } + let meta: StoredArtifactMeta + if (!deduped) { + meta = buildMeta(input, id, this.nowIso) + await this.enforceQuota(meta.byteSize) + const suffix = `${process.pid}.${Date.now()}.tmp` + const contentTemporaryPath = `${this.contentPath(id)}.${suffix}` + const metaTemporaryPath = `${this.metaPath(id)}.${suffix}` + try { + await writeFile(contentTemporaryPath, input.content, 'utf8') + await writeFile(metaTemporaryPath, JSON.stringify(meta), 'utf8') + await rename(contentTemporaryPath, this.contentPath(id)) + await rename(metaTemporaryPath, this.metaPath(id)) + } finally { + await Promise.all([ + rm(contentTemporaryPath, { force: true }), + rm(metaTemporaryPath, { force: true }) + ]).catch(() => undefined) + } + } else { + meta = (await this.stat(id)) ?? buildMeta(input, id, this.nowIso) + } + return { meta, summary, deduped } + } + + async get(id: string): Promise { + if (!isValidArtifactId(id)) return null + try { + return await readFile(this.contentPath(id), 'utf8') + } catch { + return null + } + } + + async readRange(id: string, options: ReadRangeOptions): Promise { + if (!isValidArtifactId(id)) return null + if (options.startLine !== undefined || options.endLine !== undefined) { + return this.readLineRange(id, options.startLine, options.endLine) + } + if (options.offset !== undefined || options.length !== undefined) { + return this.readByteRange(id, options.offset ?? 0, options.length) + } + return this.get(id) + } + + /** True seek read of a byte window — never loads the whole artifact. */ + private async readByteRange(id: string, offset: number, length?: number): Promise { + let handle: import('node:fs/promises').FileHandle | undefined + try { + handle = await fsOpen(this.contentPath(id), 'r') + const { size } = await handle.stat() + const start = Math.max(0, Math.min(offset, size)) + const want = length !== undefined ? Math.max(0, length) : size - start + const count = Math.min(want + 3, size - start) + if (count <= 0) return '' + const buffer = Buffer.alloc(count) + await handle.read(buffer, 0, count, start) + return decodeUtf8Window(buffer, want) + } catch { + return null + } finally { + await handle?.close().catch(() => undefined) + } + } + + /** + * Stream a 1-indexed inclusive line window, stopping once `endLine` is read — + * so a small slice of a multi-GB artifact stays cheap. Only the selected + * lines are buffered. + */ + private async readLineRange(id: string, startLine?: number, endLine?: number): Promise { + const start = Math.max(1, startLine ?? 1) + const end = endLine ?? Number.MAX_SAFE_INTEGER + if (start > end) return '' + let handle: import('node:fs/promises').FileHandle | undefined + try { + handle = await fsOpen(this.contentPath(id), 'r') + } catch { + return null + } + try { + const collected: string[] = [] + let lineNo = 1 + let pending = '' + // Decode incrementally so a multibyte UTF-8 sequence split across a 64KiB + // read boundary is buffered and stitched, not turned into replacement + // characters (P2-04). + const decoder = new StringDecoder('utf8') + const chunk = Buffer.alloc(64 * 1_024) + for (;;) { + const { bytesRead } = await handle.read(chunk, 0, chunk.length, null) + if (bytesRead === 0) break + pending += decoder.write(chunk.subarray(0, bytesRead)) + let nl = pending.indexOf('\n') + while (nl !== -1) { + const line = pending.slice(0, nl) + if (lineNo >= start && lineNo <= end) collected.push(line) + lineNo += 1 + if (lineNo > end) return collected.join('\n') + pending = pending.slice(nl + 1) + nl = pending.indexOf('\n') + } + } + // Flush any bytes the decoder was holding for an incomplete sequence. + pending += decoder.end() + // Trailing line without a final newline. + if (lineNo >= start && lineNo <= end && pending.length > 0) collected.push(pending) + return collected.join('\n') + } catch { + return null + } finally { + await handle.close().catch(() => undefined) + } + } + + async stat(id: string): Promise { + if (!isValidArtifactId(id)) return null + try { + return JSON.parse(await readFile(this.metaPath(id), 'utf8')) as StoredArtifactMeta + } catch { + return null + } + } + + private async enforceQuota(incomingBytes: number): Promise { + const maxTotalBytes = this.limits.maxTotalBytes ?? 512 * 1024 * 1024 + const maxArtifacts = this.limits.maxArtifacts ?? 2_000 + if (incomingBytes > maxTotalBytes) throw new Error(`artifact exceeds ${maxTotalBytes} byte store quota`) + const entries = await readdir(this.dir).catch(() => []) + const metas = (await Promise.all(entries + .filter((entry) => entry.endsWith('.json')) + .map(async (entry) => { + try { + return JSON.parse(await readFile(join(this.dir, entry), 'utf8')) as StoredArtifactMeta + } catch { + return null + } + }))) + .filter((meta): meta is StoredArtifactMeta => Boolean(meta && isValidArtifactId(meta.id))) + const totalBytes = metas.reduce((sum, meta) => sum + meta.byteSize, 0) + if (totalBytes + incomingBytes > maxTotalBytes || metas.length + 1 > maxArtifacts) { + throw new Error('artifact store quota exceeded; remove unneeded artifacts before retrying') + } + } +} + +function decodeUtf8Window(buffer: Buffer, requestedBytes: number): string { + const decode = (bytes: Buffer): string => { + const decoder = new StringDecoder('utf8') + return decoder.write(bytes) + } + const bounded = buffer.subarray(0, Math.min(requestedBytes, buffer.length)) + const text = decode(bounded) + if (text || bounded.length === 0 || buffer.length <= bounded.length) return text + // Very small ranges can end before the first multibyte code point completes. + // Include that one complete character so the cursor advances without losing + // bytes or looping forever; the overrun is at most three bytes. + return decode(buffer.subarray(0, Math.min(requestedBytes + 3, buffer.length))) +} diff --git a/kun/src/artifacts/artifact-summary.test.ts b/kun/src/artifacts/artifact-summary.test.ts new file mode 100644 index 000000000..376ffe237 --- /dev/null +++ b/kun/src/artifacts/artifact-summary.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { artifactId, summarizeForModel } from './artifact-summary.js' + +describe('artifactId', () => { + it('is content-addressed and stable', () => { + expect(artifactId('hello')).toBe(artifactId('hello')) + expect(artifactId('hello')).not.toBe(artifactId('world')) + expect(artifactId('hello')).toMatch(/^art_[0-9a-f]{24}$/) + }) +}) + +describe('summarizeForModel', () => { + it('returns content verbatim when within budget', () => { + const result = summarizeForModel({ content: 'short output', maxInlineChars: 100 }) + expect(result.truncated).toBe(false) + expect(result.inline).toBe('short output') + expect(result.lineCount).toBe(1) + }) + + it('truncates with head + tail + reference marker when oversized', () => { + const content = `${'H'.repeat(500)}\n${'T'.repeat(500)}` + const result = summarizeForModel({ content, maxInlineChars: 200 }) + expect(result.truncated).toBe(true) + expect(result.inline).toContain(`[artifact ${result.artifactId}:`) + expect(result.inline).toContain('elided') + // Keeps a head and a tail excerpt. + expect(result.inline.startsWith('H')).toBe(true) + expect(result.inline.trimEnd().endsWith('T')).toBe(true) + // The byte size reflects the full content, not the preview. + expect(result.byteSize).toBe(Buffer.byteLength(content, 'utf8')) + }) + + it('keeps the preview within roughly the requested budget', () => { + const result = summarizeForModel({ content: 'x'.repeat(10_000), maxInlineChars: 300 }) + // head + marker + tail; allow the marker line + newlines as overhead. + expect(result.inline.length).toBeLessThanOrEqual(400) + }) + + it('counts lines on the full content', () => { + expect(summarizeForModel({ content: 'a\nb\nc', maxInlineChars: 100 }).lineCount).toBe(3) + }) +}) diff --git a/kun/src/artifacts/artifact-summary.ts b/kun/src/artifacts/artifact-summary.ts new file mode 100644 index 000000000..3cb9f9c53 --- /dev/null +++ b/kun/src/artifacts/artifact-summary.ts @@ -0,0 +1,77 @@ +/** + * Content-addressed artifact summarization (P0 #5). + * + * Bash output already has truncation, but MCP results, browser output, large + * JSON, and attachments do not share a mechanism. Large results should be + * stored by content hash and the model should see only a bounded preview plus + * a stable reference, fetching specific slices on demand. This module is the + * pure core: a content-address id + a head/tail-bounded preview with an + * explicit reference marker. The byte store itself is a thin layer on top. + */ + +import { createHash } from 'node:crypto' + +export function artifactId(content: string | Buffer): string { + const hash = createHash('sha256').update(content).digest('hex') + return `art_${hash.slice(0, 24)}` +} + +export type ArtifactSummary = { + artifactId: string + byteSize: number + lineCount: number + /** The bounded preview handed to the model. */ + inline: string + /** True when `inline` is a head/tail excerpt rather than the full content. */ + truncated: boolean +} + +/** + * Produce a model-facing summary of a (possibly huge) text result. When the + * content fits within `maxInlineChars` it is returned verbatim. Otherwise a + * head + tail excerpt is returned with a reference marker telling the model how + * many bytes/lines were elided and how to fetch the full artifact by id. + */ +export function summarizeForModel(input: { + content: string + maxInlineChars: number + /** Fraction of the budget devoted to the head excerpt (rest is tail). Default 0.7. */ + headRatio?: number +}): ArtifactSummary { + const content = input.content + const byteSize = Buffer.byteLength(content, 'utf8') + const lineCount = content.length === 0 ? 0 : content.split('\n').length + const id = artifactId(content) + const maxInline = Math.max(0, input.maxInlineChars) + + if (content.length <= maxInline) { + return { artifactId: id, byteSize, lineCount, inline: content, truncated: false } + } + + const headRatio = clampRatio(input.headRatio ?? 0.7) + // Reserve room for the marker so the final inline stays within budget. + const markerPlaceholder = referenceMarker(id, 0, 0) + const budget = Math.max(0, maxInline - markerPlaceholder.length) + const headChars = Math.floor(budget * headRatio) + const tailChars = Math.max(0, budget - headChars) + const head = content.slice(0, headChars) + const tail = tailChars > 0 ? content.slice(content.length - tailChars) : '' + const omittedChars = content.length - head.length - tail.length + const omittedLines = Math.max(0, lineCount - countLines(head) - countLines(tail)) + const marker = referenceMarker(id, omittedChars, omittedLines) + const inline = tail ? `${head}\n${marker}\n${tail}` : `${head}\n${marker}` + return { artifactId: id, byteSize, lineCount, inline, truncated: true } +} + +function referenceMarker(id: string, omittedChars: number, omittedLines: number): string { + return `[artifact ${id}: ${omittedChars} char(s) / ${omittedLines} line(s) elided — fetch the full artifact or a specific range by id]` +} + +function countLines(text: string): number { + return text.length === 0 ? 0 : text.split('\n').length +} + +function clampRatio(value: number): number { + if (!Number.isFinite(value)) return 0.7 + return Math.min(0.95, Math.max(0.05, value)) +} diff --git a/kun/src/delegation/child-agent-executor.ts b/kun/src/delegation/child-agent-executor.ts index 95e9c3ad5..52610ce95 100644 --- a/kun/src/delegation/child-agent-executor.ts +++ b/kun/src/delegation/child-agent-executor.ts @@ -16,6 +16,7 @@ import { InflightTracker } from '../loop/inflight-tracker.js' import { SteeringQueue } from '../loop/steering-queue.js' import type { TokenEconomyConfig } from '../loop/token-economy.js' import type { MemoryStore } from '../memory/memory-store.js' +import type { ArtifactStore } from '../artifacts/artifact-store.js' import type { ModelClient } from '../ports/model-client.js' import { RandomIdGenerator } from '../ports/id-generator.js' import type { SessionStore } from '../ports/session-store.js' @@ -43,6 +44,7 @@ export type ChildAgentExecutorOptions = { modelCapabilities?: (model: string) => ModelCapabilityMetadata skillRuntime?: SkillRuntime memoryStore?: MemoryStore + artifactStore?: ArtifactStore /** * Persistence wiring. When the main runtime's stores + event recorder are * supplied, the child runs as a persisted `relation: 'side'` thread on the @@ -159,6 +161,7 @@ export function createChildAgentExecutor(options: ChildAgentExecutorOptions): Ch ...(options.modelCapabilities ? { modelCapabilities: options.modelCapabilities } : {}), ...(options.skillRuntime ? { skillRuntime: options.skillRuntime } : {}), ...(options.memoryStore ? { memoryStore: options.memoryStore } : {}), + ...(options.artifactStore ? { artifactStore: options.artifactStore } : {}), ...(options.contextCompaction ? { contextCompaction: options.contextCompaction } : {}), ...(options.tokenEconomy ? { tokenEconomy: options.tokenEconomy } : {}), ...(options.runtime?.toolStorm ? { toolStorm: options.runtime.toolStorm } : {}), diff --git a/kun/src/loop/agent-loop.ts b/kun/src/loop/agent-loop.ts index 40ed79672..2b80730ba 100644 --- a/kun/src/loop/agent-loop.ts +++ b/kun/src/loop/agent-loop.ts @@ -63,6 +63,7 @@ import type { SkillRuntime } from '../skills/skill-runtime.js' import type { AttachmentContent, AttachmentStore } from '../attachments/attachment-store.js' import type { ModelInputAttachment, ModelTextAttachmentFallback } from '../ports/model-client.js' import type { MemoryStore } from '../memory/memory-store.js' +import type { ArtifactStore } from '../artifacts/artifact-store.js' import { hasHooksForPhase, runObserverHooks, @@ -672,6 +673,7 @@ export type AgentLoopOptions = { skillRuntime?: SkillRuntime attachmentStore?: AttachmentStore memoryStore?: MemoryStore + artifactStore?: ArtifactStore /** Kun runtime data root for sandbox-safe background shell output reads. */ runtimeDataDir?: string tokenEconomy?: TokenEconomyConfig @@ -2225,6 +2227,7 @@ export class AgentLoop { approvalPolicy: input.approvalPolicy, sandboxMode: input.sandboxMode, ...(this.opts.runtimeDataDir ? { runtimeDataDir: this.opts.runtimeDataDir } : {}), + ...(this.opts.artifactStore ? { artifactStore: this.opts.artifactStore } : {}), abortSignal: input.signal, awaitApproval: async (approval) => { await this.opts.events.record({ diff --git a/kun/src/ports/tool-host.ts b/kun/src/ports/tool-host.ts index 95765cc56..808d0f4fa 100644 --- a/kun/src/ports/tool-host.ts +++ b/kun/src/ports/tool-host.ts @@ -2,6 +2,7 @@ import type { ApprovalPolicy, SandboxMode } from '../contracts/policy.js' import type { ApprovalRequest } from '../domain/approval.js' import type { TurnItem } from '../contracts/items.js' import type { ModelCapabilityMetadata } from '../contracts/capabilities.js' +import type { ArtifactStore } from '../artifacts/artifact-store.js' import type { UserInputRequest, UserInputResolution @@ -93,6 +94,8 @@ export type ToolHostContext = { sandboxMode?: SandboxMode /** Kun runtime data root; used to allow sandbox-safe reads of background shell output files. */ runtimeDataDir?: string + /** Store used to offload oversized tool results from model context. */ + artifactStore?: ArtifactStore abortSignal: AbortSignal /** Resolves a pending approval with the user's decision. */ awaitApproval: (approval: ApprovalRequest) => Promise<'allow' | 'deny'> diff --git a/kun/src/server/runtime-factory.ts b/kun/src/server/runtime-factory.ts index 761116428..f30aa120b 100644 --- a/kun/src/server/runtime-factory.ts +++ b/kun/src/server/runtime-factory.ts @@ -16,6 +16,8 @@ import { createAgentSdkRuntime } from '../runtime/agent-sdk/agent-sdk-runtime-fa import { buildGoalLocalTools } from '../adapters/tool/goal-tools.js' import { buildTodoLocalTools } from '../adapters/tool/todo-tools.js' import { LocalToolHost, buildDefaultLocalTools } from '../adapters/tool/local-tool-host.js' +import { createReadArtifactTool } from '../adapters/tool/artifact-tool.js' +import { FileArtifactStore } from '../artifacts/artifact-store.js' import { buildMcpToolProviders } from '../adapters/tool/mcp-tool-provider.js' import { buildMemoryToolProviders } from '../adapters/tool/memory-tool-provider.js' import { buildSkillToolProviders } from '../adapters/tool/skill-tool-provider.js' @@ -167,6 +169,7 @@ export async function createKunServeRuntime( ] }) const threadService = new ThreadService({ threadStore, sessionStore, events, ids, nowIso }) + const artifactStore = new FileArtifactStore(join(options.dataDir, 'artifacts'), nowIso) const modelProfiles = modelContextProfilesFromConfig({ contextCompaction: options.contextCompaction, models: options.models @@ -309,6 +312,13 @@ export async function createKunServeRuntime( available: true, tools: withBackgroundShellTools(buildDefaultLocalTools()) }, + { + id: 'artifacts', + kind: 'built-in' as const, + enabled: true, + available: true, + tools: [createReadArtifactTool()] + }, ...mcpProviders.providers, ...webProviders.providers, ...buildMemoryToolProviders(memoryStore), @@ -359,6 +369,7 @@ export async function createKunServeRuntime( events, ...(options.runtime ? { runtime: options.runtime } : {}), ...(memoryStore ? { memoryStore } : {}), + artifactStore, nowIso }), recordExternalUsage: (threadId, usage) => { @@ -521,6 +532,7 @@ export async function createKunServeRuntime( ...(options.runtime?.toolArgumentRepair ? { toolArgumentRepair: options.runtime.toolArgumentRepair } : {}), ...(resolvedHooks.length ? { hooks: resolvedHooks } : {}), ...(attachmentStore ? { attachmentStore } : {}), + artifactStore, ...(memoryStore ? { memoryStore } : {}), runtimeDataDir: options.dataDir, onPlanWritten: async ({ threadId, planId, relativePath, markdown }) => { From cfee02103e3d27965b86fc185adf51f99079793c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:40:34 +0800 Subject: [PATCH 12/34] feat(cli): stream headless runs as jsonl (#670) --- kun/src/cli/agent-cli.ts | 21 ++++++++++++++++++--- kun/tests/cli-agent.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/kun/src/cli/agent-cli.ts b/kun/src/cli/agent-cli.ts index 54b0b11d8..85829b435 100644 --- a/kun/src/cli/agent-cli.ts +++ b/kun/src/cli/agent-cli.ts @@ -43,6 +43,7 @@ Common options: --model Model id --approval-policy

on-request | untrusted | never | auto | suggest --json Emit machine-readable JSON where supported + --jsonl Stream one machine-readable event per line for kun run Exec options: --list-tools Print available tools @@ -110,6 +111,11 @@ export async function runAgentCommand( async function runOneShot(argv: readonly string[], io: CliIo): Promise { const parsed = parseSharedOptions(argv, io) if (!parsed.ok) return writeParseError(parsed, io, 'kun run') + const jsonl = hasFlag(argv, 'jsonl') + if (parsed.json && jsonl) { + io.stderr.write('kun run: --json and --jsonl are mutually exclusive\n') + return ServeExitCode.usage + } const prompt = stringFlag(argv, ['prompt', 'p']) ?? positionals(argv).join(' ').trim() if (!prompt) { io.stderr.write('kun run: missing prompt\n') @@ -126,13 +132,16 @@ async function runOneShot(argv: readonly string[], io: CliIo): Promise { approvalPolicy: parsed.options.approvalPolicy, sandboxMode: parsed.options.sandboxMode }) + if (jsonl) writeJsonLine(io.stdout, { type: 'run_started', threadId: thread.id }) const turn = await runtime.turnService.startTurn({ threadId: thread.id, request: { prompt, model: parsed.options.model, mode: 'agent' } }) let streamed = false - const unsubscribe = parsed.json ? undefined : runtime.eventBus.subscribe(thread.id, (event) => { - if (event.kind === 'assistant_text_delta' && event.item.kind === 'assistant_text') { + const unsubscribe = runtime.eventBus.subscribe(thread.id, (event) => { + if (jsonl) { + writeJsonLine(io.stdout, { type: 'runtime_event', event }) + } else if (!parsed.json && event.kind === 'assistant_text_delta' && event.item.kind === 'assistant_text') { streamed = true io.stdout.write(event.item.text) } @@ -140,7 +149,9 @@ async function runOneShot(argv: readonly string[], io: CliIo): Promise { const status = await runtime.runTurn(thread.id, turn.turnId) unsubscribe?.() const items = await runtime.sessionStore.loadItems(thread.id) - if (parsed.json) { + if (jsonl) { + writeJsonLine(io.stdout, { type: 'run_finished', threadId: thread.id, turnId: turn.turnId, status }) + } else if (parsed.json) { io.stdout.write(JSON.stringify({ threadId: thread.id, turnId: turn.turnId, status, items }) + '\n') } else { if (!streamed) { @@ -158,6 +169,10 @@ async function runOneShot(argv: readonly string[], io: CliIo): Promise { } } +function writeJsonLine(output: WritableLike, value: unknown): void { + output.write(`${JSON.stringify(value)}\n`) +} + async function runChat(argv: readonly string[], io: CliIo): Promise { const parsed = parseSharedOptions(argv, io) if (!parsed.ok) return writeParseError(parsed, io, 'kun chat') diff --git a/kun/tests/cli-agent.test.ts b/kun/tests/cli-agent.test.ts index 96fc952a4..964875cb3 100644 --- a/kun/tests/cli-agent.test.ts +++ b/kun/tests/cli-agent.test.ts @@ -240,6 +240,31 @@ describe('Kun agent CLI commands', () => { expect(parsed.items.some((item) => item.kind === 'assistant_text')).toBe(true) }) + it('streams a stable JSONL envelope for headless runs', async () => { + const c = capture({ createRuntime: fakeRuntime() }) + const code = await runAgentCommand('run', [ + '--data-dir', + dataDir, + '--prompt', + 'hello', + '--jsonl' + ], c.io) + + expect(code).toBe(ServeExitCode.ok) + const lines = c.stdout.trim().split('\n').map((line) => JSON.parse(line) as Record) + expect(lines[0]).toMatchObject({ type: 'run_started' }) + expect(lines.at(-1)).toMatchObject({ type: 'run_finished', status: 'completed' }) + }) + + it('rejects combining JSON and JSONL output modes', async () => { + const c = capture({ createRuntime: fakeRuntime() }) + const code = await runAgentCommand('run', [ + '--data-dir', dataDir, '--prompt', 'hello', '--json', '--jsonl' + ], c.io) + expect(code).toBe(ServeExitCode.usage) + expect(c.stderr).toContain('mutually exclusive') + }) + it('returns runtime failures from one-shot runs', async () => { const c = capture({ createRuntime: fakeRuntime({ throwRun: true }) }) const code = await runAgentCommand('run', [ From 93c2c0702d281e4911c8a0f541187db264a39902 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:40:43 +0800 Subject: [PATCH 13/34] fix(marketplace): pin and validate MCP package installs (#673) --- .../components/PluginMarketplaceView.test.ts | 38 ++++- .../src/components/PluginMarketplaceView.tsx | 148 ++++++++++++++++-- 2 files changed, 170 insertions(+), 16 deletions(-) diff --git a/src/renderer/src/components/PluginMarketplaceView.test.ts b/src/renderer/src/components/PluginMarketplaceView.test.ts index fc1dd4f68..ebd3792a2 100644 --- a/src/renderer/src/components/PluginMarketplaceView.test.ts +++ b/src/renderer/src/components/PluginMarketplaceView.test.ts @@ -4,6 +4,8 @@ import { buildMcpConfig, buildRemoteMcpConfig, customMcpConfigFragment, + auditMcpConfigSupplyChain, + auditMarketplaceInstall, isAllowedDocsUrl, isHttpsUrl, mcpConfigHasServer, @@ -84,7 +86,7 @@ describe('PluginMarketplaceView MCP config helpers', () => { const merged = mergeMcpJsonConfig( existing, - buildMcpConfig('playwright', 'npx', ['-y', '@playwright/mcp@latest']) + buildMcpConfig('playwright', 'npx', ['-y', '@playwright/mcp@0.0.77']) ) const parsed = JSON.parse(merged.text) as Record @@ -95,14 +97,14 @@ describe('PluginMarketplaceView MCP config helpers', () => { enabled: true, transport: 'stdio', command: 'npx', - args: ['-y', '@playwright/mcp@latest'], + args: ['-y', '@playwright/mcp@0.0.77'], trustScope: 'user' }) expect(mcpConfigHasServer(merged.text, 'playwright')).toBe(true) }) it('detects duplicate MCP servers instead of appending old-style snippets', () => { - const fragment = buildMcpConfig('context7', 'npx', ['-y', '@upstash/context7-mcp@latest']) + const fragment = buildMcpConfig('context7', 'npx', ['-y', '@upstash/context7-mcp@3.2.2']) const first = mergeMcpJsonConfig('', fragment) const second = mergeMcpJsonConfig(first.text, fragment) @@ -111,6 +113,36 @@ describe('PluginMarketplaceView MCP config helpers', () => { expect(JSON.parse(second.text).servers.context7).toMatchObject({ command: 'npx' }) }) + it.each([ + ['npx', '@upstash/context7-mcp@latest'], + ['npx.cmd', '@upstash/context7-mcp@3.2'], + ['C:\\Program Files\\nodejs\\npx.cmd', '@upstash/context7-mcp@^3.2.2'] + ])('rejects non-exact package versions for %s', (command, packageSpec) => { + const audit = auditMcpConfigSupplyChain(buildMcpConfig('context7', command, ['-y', packageSpec])) + expect(audit.ok).toBe(false) + expect(audit.errors.join('\n')).toMatch(/exact npm package version/) + }) + + it.each(['npx', 'npx.cmd', 'C:\\Program Files\\nodejs\\npx.cmd'])('allows exact package versions for %s', (command) => { + const audit = auditMcpConfigSupplyChain(buildMcpConfig('context7', command, ['-y', '@upstash/context7-mcp@3.2.2'])) + expect(audit.ok).toBe(true) + expect(audit.permissions).toEqual(expect.arrayContaining(['command', 'file', 'network'])) + }) + + it('audits a marketplace MCP install before writing config', () => { + const audit = auditMarketplaceInstall({ + id: 'context7', + kind: 'mcp', + title: 'Context7', + description: 'Use Context7', + group: 'recommended', + mcpConfig: () => buildMcpConfig('context7', 'npx', ['-y', '@upstash/context7-mcp@3.2.2']), + supplyChain: { source: 'mcp', permissions: ['command', 'network'] } + }, '/workspace') + expect(audit.ok).toBe(true) + expect(audit.permissions).toEqual(expect.arrayContaining(['command', 'file', 'network'])) + }) + it('accepts custom JSON as either a single server or a Kun config fragment', () => { expect(customMcpConfigFragment( 'docs', diff --git a/src/renderer/src/components/PluginMarketplaceView.tsx b/src/renderer/src/components/PluginMarketplaceView.tsx index df06a6b91..fb1e92ec2 100644 --- a/src/renderer/src/components/PluginMarketplaceView.tsx +++ b/src/renderer/src/components/PluginMarketplaceView.tsx @@ -62,11 +62,27 @@ type MarketplaceItem = { serverIds?: string[] mcpConfig?: (workspaceRoot: string) => JsonRecord oauth?: OAuthConnectorInfo + supplyChain?: SupplyChainInfo skillInstructions?: string } type JsonRecord = Record +type SupplyChainPermission = 'file' | 'command' | 'network' | 'secret' + +type SupplyChainInfo = { + source: 'mcp' | 'remote-mcp' | 'skill' + permissions: SupplyChainPermission[] + packageName?: string + version?: string +} + +type MarketplaceInstallAudit = { + ok: boolean + permissions: SupplyChainPermission[] + errors: string[] +} + type OAuthConnectorInfo = { docsUrl: string permissionKeys: string[] @@ -249,6 +265,90 @@ function buildRemoteMcpServer(url: string): JsonRecord { } } +const PERMISSION_LABELS: Record = { + file: 'File', + command: 'Command', + network: 'Network', + secret: 'Secret' +} + +function uniquePermissions(permissions: readonly SupplyChainPermission[]): SupplyChainPermission[] { + return [...new Set(permissions)] +} + +export function auditMcpConfigSupplyChain(config: JsonRecord): MarketplaceInstallAudit { + const permissions: SupplyChainPermission[] = ['network'] + const errors: string[] = [] + const servers = isJsonRecord(config.servers) ? config.servers : {} + for (const [id, server] of Object.entries(servers)) { + if (!isJsonRecord(server)) { + errors.push(`MCP server "${id}" must be an object.`) + continue + } + if (typeof server.command === 'string' && server.command.trim()) { + permissions.push('command', 'file') + const command = executableBasename(server.command) + const args = Array.isArray(server.args) ? server.args.filter((arg): arg is string => typeof arg === 'string') : [] + if (command === 'npx') { + const spec = npxPackageSpec(args) + if (!spec) { + errors.push(`MCP server "${id}" uses npx without an exact package version.`) + } else if (!isExactNpmPackageSpec(spec)) { + errors.push(`MCP server "${id}" must pin an exact npm package version (got "${spec}").`) + } + } + } + if (isJsonRecord(server.env) && Object.keys(server.env).length > 0) { + permissions.push('secret') + } + if (typeof server.url === 'string') { + permissions.push('network') + if (!isHttpsUrl(server.url)) errors.push(`MCP server "${id}" URL must use HTTPS.`) + } + } + return { ok: errors.length === 0, permissions: uniquePermissions(permissions), errors } +} + +export function auditMarketplaceInstall(item: MarketplaceItem, workspaceRoot: string): MarketplaceInstallAudit { + const configuredPermissions = item.supplyChain?.permissions ?? [] + const config = item.mcpConfig?.(workspaceRoot) + if (!config) { + return { + ok: false, + permissions: uniquePermissions(configuredPermissions), + errors: ['MCP config is missing.'] + } + } + const audit = auditMcpConfigSupplyChain(config) + return { + ok: audit.ok, + permissions: uniquePermissions([...configuredPermissions, ...audit.permissions]), + errors: audit.errors + } +} + +function executableBasename(command: string): string { + const normalized = command.trim().replace(/^['"]|['"]$/g, '').replace(/\\/g, '/') + const basename = normalized.slice(normalized.lastIndexOf('/') + 1).toLowerCase() + return basename.replace(/\.(?:cmd|exe)$/i, '') +} + +function npxPackageSpec(args: readonly string[]): string | null { + for (const arg of args) { + if (!arg || arg.startsWith('-')) continue + return arg + } + return null +} + +function isExactNpmPackageSpec(spec: string): boolean { + if (spec.endsWith('@latest') || spec.includes('*') || spec.includes('^') || spec.includes('~')) return false + const at = spec.lastIndexOf('@') + if (at <= 0) return false + const version = spec.slice(at + 1) + return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version) +} + export function buildMcpConfig( id: string, command: string, @@ -594,8 +694,9 @@ const RECOMMENDED_ITEMS: MarketplaceItem[] = [ buildMcpConfig( 'playwright', 'npx', - ['-y', '@playwright/mcp@latest'] - ) + ['-y', '@playwright/mcp@0.0.77'] + ), + supplyChain: { source: 'mcp', packageName: '@playwright/mcp', version: '0.0.77', permissions: ['command', 'network', 'file'] } }, { id: 'github', @@ -607,8 +708,9 @@ const RECOMMENDED_ITEMS: MarketplaceItem[] = [ buildMcpConfig( 'github', 'npx', - ['-y', '@modelcontextprotocol/server-github'] - ) + ['-y', '@modelcontextprotocol/server-github@2025.4.8'] + ), + supplyChain: { source: 'mcp', packageName: '@modelcontextprotocol/server-github', version: '2025.4.8', permissions: ['command', 'network', 'secret'] } }, { id: 'vercel', @@ -635,6 +737,7 @@ const RECOMMENDED_ITEMS: MarketplaceItem[] = [ ], noteKey: 'pluginOAuthVercelNote' }, + supplyChain: { source: 'remote-mcp', permissions: ['network', 'secret'] }, mcpConfig: () => buildRemoteMcpConfig({ vercel: 'https://mcp.vercel.com' @@ -669,6 +772,7 @@ const RECOMMENDED_ITEMS: MarketplaceItem[] = [ ], noteKey: 'pluginOAuthGoogleNote' }, + supplyChain: { source: 'remote-mcp', permissions: ['network', 'secret', 'file'] }, mcpConfig: () => buildRemoteMcpConfig(GOOGLE_WORKSPACE_MCP_SERVERS) }, @@ -682,8 +786,9 @@ const RECOMMENDED_ITEMS: MarketplaceItem[] = [ buildMcpConfig( 'context7', 'npx', - ['-y', '@upstash/context7-mcp@latest'] - ) + ['-y', '@upstash/context7-mcp@3.2.2'] + ), + supplyChain: { source: 'mcp', packageName: '@upstash/context7-mcp', version: '3.2.2', permissions: ['command', 'network'] } }, { id: 'sequential-thinking', @@ -695,8 +800,9 @@ const RECOMMENDED_ITEMS: MarketplaceItem[] = [ buildMcpConfig( 'sequential-thinking', 'npx', - ['-y', '@modelcontextprotocol/server-sequential-thinking'] - ) + ['-y', '@modelcontextprotocol/server-sequential-thinking@2025.12.18'] + ), + supplyChain: { source: 'mcp', packageName: '@modelcontextprotocol/server-sequential-thinking', version: '2025.12.18', permissions: ['command'] } }, { id: 'memory', @@ -708,8 +814,9 @@ const RECOMMENDED_ITEMS: MarketplaceItem[] = [ buildMcpConfig( 'memory', 'npx', - ['-y', '@modelcontextprotocol/server-memory'] - ) + ['-y', '@modelcontextprotocol/server-memory@2026.1.26'] + ), + supplyChain: { source: 'mcp', packageName: '@modelcontextprotocol/server-memory', version: '2026.1.26', permissions: ['command', 'file'] } }, { id: 'brave-search', @@ -721,9 +828,10 @@ const RECOMMENDED_ITEMS: MarketplaceItem[] = [ buildMcpConfig( 'brave-search', 'npx', - ['-y', '@modelcontextprotocol/server-brave-search'], + ['-y', '@modelcontextprotocol/server-brave-search@0.6.2'], { env: { BRAVE_API_KEY: '' } } - ) + ), + supplyChain: { source: 'mcp', packageName: '@modelcontextprotocol/server-brave-search', version: '0.6.2', permissions: ['command', 'network', 'secret'] } }, { id: 'code-review', @@ -731,6 +839,7 @@ const RECOMMENDED_ITEMS: MarketplaceItem[] = [ titleKey: 'pluginSkillReviewTitle', descriptionKey: 'pluginSkillReviewDesc', group: 'recommended', + supplyChain: { source: 'skill', permissions: ['file'] }, skillInstructions: 'Use this skill when reviewing a code change. Prioritize correctness, regressions, security, performance, and missing tests. Lead with concrete findings and file references.' }, @@ -740,6 +849,7 @@ const RECOMMENDED_ITEMS: MarketplaceItem[] = [ titleKey: 'pluginSkillFrontendTitle', descriptionKey: 'pluginSkillFrontendDesc', group: 'recommended', + supplyChain: { source: 'skill', permissions: ['file'] }, skillInstructions: 'Use this skill when improving UI. Preserve the product style, check responsive states, avoid generic layouts, and verify the result visually before handing it back.' }, @@ -749,6 +859,7 @@ const RECOMMENDED_ITEMS: MarketplaceItem[] = [ titleKey: 'pluginSkillBugTitle', descriptionKey: 'pluginSkillBugDesc', group: 'recommended', + supplyChain: { source: 'skill', permissions: ['file', 'command'] }, skillInstructions: 'Use this skill when investigating bugs. Reproduce or narrow the symptom, trace the data flow, identify the smallest fix, and add focused verification where possible.' }, @@ -758,6 +869,7 @@ const RECOMMENDED_ITEMS: MarketplaceItem[] = [ titleKey: 'pluginSkillReleaseTitle', descriptionKey: 'pluginSkillReleaseDesc', group: 'recommended', + supplyChain: { source: 'skill', permissions: ['file'] }, skillInstructions: 'Use this skill when preparing release notes. Group user-facing changes by outcome, call out migrations or risks, and keep wording concise and scannable.' } @@ -1053,6 +1165,8 @@ export function PluginMarketplaceView({ leftSidebarCollapsed, onToggleLeftSideba const installMcpItem = async (item: MarketplaceItem): Promise => { if (!item.mcpConfig) return + const audit = auditMarketplaceInstall(item, workspaceRoot) + if (!audit.ok) throw new Error(audit.errors.join('\n')) await appendMcpConfig(item.id, item.mcpConfig(workspaceRoot)) } @@ -1130,7 +1244,10 @@ export function PluginMarketplaceView({ leftSidebarCollapsed, onToggleLeftSideba .map((arg) => arg.trim()) .filter(Boolean) ) - await appendMcpConfig(id, customMcpConfigFragment(id, customConfig, fallback)) + const fragment = customMcpConfigFragment(id, customConfig, fallback) + const audit = auditMcpConfigSupplyChain(fragment) + if (!audit.ok) throw new Error(audit.errors.join('\n')) + await appendMcpConfig(id, fragment) } else { if (!selectedSkillRoot?.path) { setNotice({ tone: 'error', message: t('pluginSkillRootMissing') }) @@ -1898,6 +2015,11 @@ function PluginSection({ {t('pluginOAuthBadge')} ) : null} + {item.supplyChain?.permissions.length ? ( + + {item.supplyChain.permissions.map((permission) => PERMISSION_LABELS[permission]).join(' / ')} + + ) : null}

{itemDescription(item, t)} From 331b77a1dfbad80851189ff36c8441a1122c579a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:40:52 +0800 Subject: [PATCH 14/34] fix(skills): preserve project and global root scopes (#679) --- src/main/kun-process.test.ts | 4 +++- src/main/kun-process.ts | 17 ++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/main/kun-process.test.ts b/src/main/kun-process.test.ts index b902c4b45..a68966c69 100644 --- a/src/main/kun-process.test.ts +++ b/src/main/kun-process.test.ts @@ -841,7 +841,9 @@ describe('syncGuiManagedKunConfig', () => { expect(parsed.capabilities.skills.enabled).toBe(true) expect(parsed.capabilities.skills.legacySkillMd).toBe(true) expect(parsed.capabilities.skills.roots).toEqual(expect.arrayContaining([ - join(workspaceRoot, '.codex', 'skills'), + join(workspaceRoot, '.codex', 'skills') + ])) + expect(parsed.capabilities.skills.globalRoots).toEqual(expect.arrayContaining([ extraRoot ])) }) diff --git a/src/main/kun-process.ts b/src/main/kun-process.ts index f83751c43..095cd18ac 100644 --- a/src/main/kun-process.ts +++ b/src/main/kun-process.ts @@ -643,9 +643,20 @@ async function skillCapabilityConfigForRuntime( path.length > 0 && !managed.has(comparableSkillRootPath(path)) && !isCodexPluginCacheRoot(path)) + const manualGlobalExisting = stringArrayValue(existing.globalRoots) + .map(normalizeSkillRootPath) + .filter((path) => + path.length > 0 && + !managed.has(comparableSkillRootPath(path)) && + !isCodexPluginCacheRoot(path)) + const guiRoots = await guiSkillRootsForRuntime(settings) const roots = uniqueStrings([ ...manualExisting, - ...(await guiSkillRootsForRuntime(settings)).map((root) => root.path) + ...guiRoots.filter((root) => root.scope === 'project').map((root) => root.path) + ]) + const globalRoots = uniqueStrings([ + ...manualGlobalExisting, + ...guiRoots.filter((root) => root.scope === 'global').map((root) => root.path) ]) return { ...existing, @@ -653,11 +664,11 @@ async function skillCapabilityConfigForRuntime( // enable toggle, so a persisted `enabled: false` is only ever the schema // default leaking onto disk — it must not permanently suppress discovered // skills. An explicit `true` still forces on even with no roots. - enabled: roots.length > 0 || existing.enabled === true, + enabled: roots.length > 0 || globalRoots.length > 0 || existing.enabled === true, roots, workspaceRoots: guiSkillWorkspaceRootsForRuntime(settings), // #149: Pass global skill roots from settings (e.g. ~/.kun/skills) - globalRoots: existing.globalRoots ?? [], + globalRoots, // Skills the user disabled in the GUI. Forwarded so the runtime drops them // from discovery — without this they stay loadable via load_skill and keep // appearing in the catalog despite the GUI toggle (#392). From 598eafbdf40be5a0740a7e1906db751da1e15f16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:41:01 +0800 Subject: [PATCH 15/34] feat(git): add configurable branch prefix (#680) --- src/main/ipc/app-ipc-schemas.ts | 1 + src/main/settings-store.ts | 4 ++ .../src/components/chat/GitBranchPicker.tsx | 52 ++++++++++++++----- .../components/settings-section-worktree.tsx | 14 +++++ src/renderer/src/components/settings-utils.ts | 3 ++ src/renderer/src/locales/en/settings.json | 2 + src/renderer/src/locales/zh/settings.json | 2 + src/shared/app-settings-normalize.ts | 19 +++++++ src/shared/app-settings-types.ts | 3 ++ src/shared/app-settings.test.ts | 18 +++++++ 10 files changed, 105 insertions(+), 13 deletions(-) diff --git a/src/main/ipc/app-ipc-schemas.ts b/src/main/ipc/app-ipc-schemas.ts index 852cbde74..ed9a9741c 100644 --- a/src/main/ipc/app-ipc-schemas.ts +++ b/src/main/ipc/app-ipc-schemas.ts @@ -1368,6 +1368,7 @@ const settingsPatchObjectSchema = z.object({ conversationWorkspaceRoot: defaultPathSchema, log: logPatchSchema.optional(), checkpointCleanup: checkpointCleanupPatchSchema.optional(), + gitBranchPrefix: trimmedString(128).or(z.literal('')).optional(), notifications: notificationsPatchSchema.optional(), appBehavior: appBehaviorPatchSchema.optional(), keyboardShortcuts: keyboardShortcutsPatchSchema.optional(), diff --git a/src/main/settings-store.ts b/src/main/settings-store.ts index 0721a8428..9cfc26e2c 100644 --- a/src/main/settings-store.ts +++ b/src/main/settings-store.ts @@ -9,6 +9,7 @@ import { DEFAULT_CHECKPOINT_CLEANUP_ENABLED, DEFAULT_CHECKPOINT_CLEANUP_INTERVAL_DAYS, DEFAULT_CURSOR_SPOTLIGHT_COLOR, + DEFAULT_GIT_BRANCH_PREFIX, DEFAULT_LOG_RETENTION_DAYS, DEFAULT_WRITE_WORKSPACE_ROOT, defaultClawSettings, @@ -31,6 +32,7 @@ import { DEFAULT_UI_FONT_SCALE, normalizeAppBehaviorSettings, normalizeCheckpointCleanupSettings, + normalizeGitBranchPrefix, normalizeKeyboardShortcuts, migrateLegacyAppSettings, normalizeAppSettings, @@ -241,6 +243,7 @@ const defaultSettings = (): AppSettingsV1 => ({ enabled: DEFAULT_CHECKPOINT_CLEANUP_ENABLED, intervalDays: DEFAULT_CHECKPOINT_CLEANUP_INTERVAL_DAYS }, + gitBranchPrefix: DEFAULT_GIT_BRANCH_PREFIX, notifications: { turnComplete: true }, @@ -273,6 +276,7 @@ function buildMergedSettings(parsed: Partial): AppSettingsV1 { ...defaults.checkpointCleanup, ...migrated.checkpointCleanup }), + gitBranchPrefix: normalizeGitBranchPrefix(migrated.gitBranchPrefix), notifications: { ...defaults.notifications, ...migrated.notifications }, appBehavior: mergeAppBehaviorSettings(defaults.appBehavior, migrated.appBehavior), keyboardShortcuts: normalizeKeyboardShortcuts(migrated.keyboardShortcuts), diff --git a/src/renderer/src/components/chat/GitBranchPicker.tsx b/src/renderer/src/components/chat/GitBranchPicker.tsx index e58a96cd3..c9f0fccb2 100644 --- a/src/renderer/src/components/chat/GitBranchPicker.tsx +++ b/src/renderer/src/components/chat/GitBranchPicker.tsx @@ -2,8 +2,16 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } import { createPortal } from 'react-dom' import { AlertCircle, Check, ChevronDown, GitBranch, GitFork, Loader2, Plus, Search } from 'lucide-react' import { useTranslation } from 'react-i18next' +import { + applyGitBranchPrefix, + DEFAULT_GIT_BRANCH_PREFIX, + normalizeGitBranchPrefix, + type AppSettingsV1 +} from '@shared/app-settings' import type { GitBranchesResult, GitBranchRow } from '@shared/git-branches' import { getProvider } from '../../agent/registry' +import { rendererRuntimeClient } from '../../agent/runtime-client' +import { SETTINGS_CHANGED_EVENT } from '../../lib/keyboard-shortcut-settings' import { middleEllipsize } from '../../lib/middle-ellipsize' import { forgetThreadWorktree, @@ -46,6 +54,7 @@ export function GitBranchPicker({ workspaceRoot }: Props): ReactElement | null { const [actingKind, setActingKind] = useState<'switch' | 'worktree' | null>(null) const [error, setError] = useState(null) const [tooltip, setTooltip] = useState(null) + const [branchPrefix, setBranchPrefix] = useState(DEFAULT_GIT_BRANCH_PREFIX) const wrapRef = useRef(null) const inputRef = useRef(null) @@ -77,6 +86,22 @@ export function GitBranchPicker({ workspaceRoot }: Props): ReactElement | null { void load() }, [load]) + useEffect(() => { + let cancelled = false + const apply = (settings: Pick): void => { + if (!cancelled) setBranchPrefix(normalizeGitBranchPrefix(settings.gitBranchPrefix)) + } + void rendererRuntimeClient.getSettings().then(apply).catch(() => undefined) + const onSettingsChanged = (event: Event): void => { + apply((event as CustomEvent).detail) + } + window.addEventListener(SETTINGS_CHANGED_EVENT, onSettingsChanged) + return () => { + cancelled = true + window.removeEventListener(SETTINGS_CHANGED_EVENT, onSettingsChanged) + } + }, []) + useEffect(() => { if (!open) return void load() @@ -106,18 +131,19 @@ export function GitBranchPicker({ workspaceRoot }: Props): ReactElement | null { }, [branches, query]) const trimmedQuery = query.trim() - const exactBranchExists = branches.some((branch) => branch.name === trimmedQuery) - const switchTargetRow = exactBranchExists - ? branches.find((branch) => branch.name === trimmedQuery) ?? null - : null - const canCreate = trimmedQuery.length > 0 && !exactBranchExists + const createBranchName = applyGitBranchPrefix(trimmedQuery, branchPrefix) + const switchTargetRow = branches.find((branch) => branch.name === trimmedQuery) + ?? branches.find((branch) => branch.name === createBranchName) + ?? null + const canCreate = createBranchName.length > 0 && !switchTargetRow const canCreateWorktree = trimmedQuery.length > 0 && (canCreate || Boolean(switchTargetRow)) const currentBranch = result?.ok ? result.currentBranch : null const label = currentBranch || (result?.ok ? t('gitDetached') : t('gitBranchUnavailable')) - const footerBranchLabel = middleEllipsize(trimmedQuery, BRANCH_FOOTER_LABEL_MAX_LENGTH) + const footerBranchLabel = middleEllipsize(createBranchName, BRANCH_FOOTER_LABEL_MAX_LENGTH) const footerCreateLabel = t('gitCreateNamedBranch', { branch: footerBranchLabel }) - const footerCreateTitle = t('gitCreateNamedBranch', { branch: trimmedQuery }) - const footerWorktreeTitle = t('gitNewBranchWorktree', { branch: trimmedQuery }) + const footerCreateTitle = t('gitCreateNamedBranch', { branch: createBranchName }) + const footerWorktreeBranch = canCreate ? createBranchName : switchTargetRow?.name ?? trimmedQuery + const footerWorktreeTitle = t('gitNewBranchWorktree', { branch: footerWorktreeBranch }) const showTooltip = useCallback((text: string, clientX: number, clientY: number): void => { if (!text.trim()) return setTooltip({ text, ...branchTooltipPosition(clientX, clientY) }) @@ -230,7 +256,7 @@ export function GitBranchPicker({ workspaceRoot }: Props): ReactElement | null { } const createAndSwitchBranch = async (): Promise => { - const branch = query.trim() + const branch = createBranchName if (!root || !branch) return setActingBranch(branch) setActingKind('switch') @@ -280,7 +306,7 @@ export function GitBranchPicker({ workspaceRoot }: Props): ReactElement | null { } const createBranchWorktree = async (): Promise => { - const branch = query.trim() + const branch = createBranchName if (!root || !branch) return setActingBranch(branch) setActingKind('worktree') @@ -469,7 +495,7 @@ export function GitBranchPicker({ workspaceRoot }: Props): ReactElement | null { void createAndSwitchBranch() }} > - {actingBranch === trimmedQuery && actingKind === 'switch' ? ( + {actingBranch === createBranchName && actingKind === 'switch' ? ( ) : ( @@ -495,11 +521,11 @@ export function GitBranchPicker({ workspaceRoot }: Props): ReactElement | null { if (canCreate) { void createBranchWorktree() } else { - void checkoutBranchWorktree(trimmedQuery) + void checkoutBranchWorktree(footerWorktreeBranch) } }} > - {actingBranch === trimmedQuery && actingKind === 'worktree' ? ( + {actingBranch === footerWorktreeBranch && actingKind === 'worktree' ? ( ) : ( diff --git a/src/renderer/src/components/settings-section-worktree.tsx b/src/renderer/src/components/settings-section-worktree.tsx index aecd27087..626cd6e17 100644 --- a/src/renderer/src/components/settings-section-worktree.tsx +++ b/src/renderer/src/components/settings-section-worktree.tsx @@ -3,6 +3,7 @@ import type { ReactElement } from 'react' import { GitBranch, Loader2, RefreshCw, Trash2 } from 'lucide-react' import type { NormalizedThread } from '../agent/types' import type { GitBranchWorktreeRow, GitBranchWorktreesResult } from '@shared/git-branches' +import { DEFAULT_GIT_BRANCH_PREFIX } from '@shared/app-settings' import { readThreadWorktreeRegistry } from '../lib/thread-worktree-registry' import { SettingsCard, SettingRow } from './settings-controls' @@ -99,6 +100,19 @@ export function WorktreeSettingsSection({ ctx }: { ctx: Record }): return ( + ctx.update({ gitBranchPrefix: event.target.value })} + /> + } + /> { }) }) +describe('git branch prefix', () => { + it('normalizes separators and applies the default prefix once', () => { + expect(normalizeGitBranchPrefix(' feature ')).toBe('feature/') + expect(normalizeGitBranchPrefix('team\\')).toBe('team/') + expect(normalizeGitBranchPrefix(undefined)).toBe(DEFAULT_GIT_BRANCH_PREFIX) + expect(applyGitBranchPrefix('fix/workspace', 'codex/')).toBe('codex/fix/workspace') + expect(applyGitBranchPrefix('codex/fix/workspace', 'codex/')).toBe('codex/fix/workspace') + }) + + it('allows branch prefixes to be disabled', () => { + expect(normalizeGitBranchPrefix('')).toBe('') + expect(applyGitBranchPrefix('fix/workspace', '')).toBe('fix/workspace') + }) +}) + describe('model endpoint format inference', () => { it('treats /completions custom endpoints as Chat Completions-shaped', () => { expect(inferModelEndpointFormatFromUrl('https://api.example.com/custom/completions')).toBe('chat_completions') From f6adf4b8f1c0b910aa42ac07f932798646032ecf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:41:11 +0800 Subject: [PATCH 16/34] feat(cache): detect hit-rate regressions (#681) --- kun/src/cache/cache-regression.test.ts | 81 +++++++++++++ kun/src/cache/cache-regression.ts | 154 +++++++++++++++++++++++++ kun/src/services/usage-service.test.ts | 66 +++++++++++ kun/src/services/usage-service.ts | 81 ++++++++++++- 4 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 kun/src/cache/cache-regression.test.ts create mode 100644 kun/src/cache/cache-regression.ts diff --git a/kun/src/cache/cache-regression.test.ts b/kun/src/cache/cache-regression.test.ts new file mode 100644 index 000000000..61345dfc6 --- /dev/null +++ b/kun/src/cache/cache-regression.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { analyzeCacheRegression, explainCacheRegression } from './cache-regression.js' + +describe('analyzeCacheRegression', () => { + it('reports no regression when the hit rate holds steady', () => { + const report = analyzeCacheRegression({ + current: 0.9, + baseline: [0.92, 0.91, 0.93], + reasons: [] + }) + expect(report.severity).toBe('none') + expect(report.explanation).toBeNull() + }) + + it('classifies a catastrophic drop as a cliff and attributes the prefix change', () => { + const report = analyzeCacheRegression({ + current: 0.05, + baseline: [0.95, 0.93, 0.94], + reasons: ['stable_prefix_changed', 'provider_cache_miss'] + }) + expect(report.severity).toBe('cliff') + expect(report.primaryReason).toBe('stable_prefix_changed') + expect(report.dropPercentagePoints).toBeGreaterThan(80) + expect(report.explanation).toContain('Cache hit rate dropped') + expect(report.explanation).toContain('stable system prefix changed') + }) + + it('classifies a mid-size drop as major and a small one as minor', () => { + expect(analyzeCacheRegression({ current: 0.6, baseline: [0.9, 0.9], reasons: [] }).severity).toBe('major') + expect(analyzeCacheRegression({ current: 0.81, baseline: [0.9, 0.92], reasons: [] }).severity).toBe('minor') + }) + + it('prefers the most decisive reason when several are present', () => { + const report = analyzeCacheRegression({ + current: 0.1, + baseline: [0.9, 0.9], + reasons: ['provider_cache_miss', 'tool_catalog_changed', 'cache_ttl_unknown'] + }) + expect(report.primaryReason).toBe('tool_catalog_changed') + }) + + it('stays silent until enough baseline samples exist', () => { + const report = analyzeCacheRegression({ + current: 0.0, + baseline: [0.95], + reasons: ['stable_prefix_changed'], + minBaselineSamples: 2 + }) + expect(report.severity).toBe('none') + expect(report.explanation).toBeNull() + }) + + it('ignores non-rate samples and null current values', () => { + expect(analyzeCacheRegression({ current: null, baseline: [0.9, 0.9], reasons: [] }).severity).toBe('none') + const report = analyzeCacheRegression({ + current: 0.1, + baseline: [null, 0.9, Number.NaN as unknown as number, 0.9], + reasons: ['stable_prefix_changed'] + }) + expect(report.severity).toBe('cliff') + expect(report.baselineHitRate).toBeCloseTo(0.9, 5) + }) + + it('uses a median baseline so a single cold-start outlier does not fake a regression', () => { + // [0.9, 0.0(cold start), 0.9] → median 0.9; current 0.85 is a 5pp dip, not a drop. + const report = analyzeCacheRegression({ current: 0.85, baseline: [0.9, 0.0, 0.9], reasons: [] }) + expect(report.baselineHitRate).toBeCloseTo(0.9, 5) + expect(report.severity).toBe('none') + }) +}) +describe('explainCacheRegression', () => { + it('formats a percentage-point drop with the attributed cause', () => { + const text = explainCacheRegression({ baseline: 0.92, current: 0.08, primaryReason: 'tool_catalog_changed' }) + expect(text).toBe('Cache hit rate dropped from 92% to 8% (-84pp). Likely cause: the tool catalog changed (MCP/Skill tools), which invalidated the cached prefix.') + }) + + it('omits the cause clause when no reason is known', () => { + const text = explainCacheRegression({ baseline: 0.8, current: 0.5, primaryReason: null }) + expect(text).toBe('Cache hit rate dropped from 80% to 50% (-30pp).') + }) +}) diff --git a/kun/src/cache/cache-regression.ts b/kun/src/cache/cache-regression.ts new file mode 100644 index 000000000..6c2fe8f2c --- /dev/null +++ b/kun/src/cache/cache-regression.ts @@ -0,0 +1,154 @@ +import type { CacheMissReason } from './cache-diagnostics.js' + +/** + * Trend-based cache-hit-rate regression analysis. + * + * {@link diagnoseCacheUsage} explains the per-turn miss reasons, but it does + * not know whether the *rate itself dropped* relative to how the thread had + * been performing. This module compares the current cacheable hit rate against + * a rolling baseline of recent turns, classifies the severity of any drop, and + * attributes it to the most likely cause so the GUI can say, for example: + * "Cache hit rate dropped from 92% to 8% (-84pp). Likely cause: the stable + * system prefix changed." + */ + +export type CacheRegressionSeverity = 'none' | 'minor' | 'major' | 'cliff' + +export type CacheRegressionReport = { + severity: CacheRegressionSeverity + baselineHitRate: number | null + currentHitRate: number | null + /** Drop in percentage points (baseline - current) * 100, rounded to 1dp. */ + dropPercentagePoints: number | null + /** The miss reason most likely responsible for the drop, when known. */ + primaryReason: CacheMissReason | null + /** Human-readable explanation, only set when severity is not 'none'. */ + explanation: string | null +} + +// Absolute drop thresholds in hit-rate fraction (0..1). +const MINOR_DROP = 0.08 +const MAJOR_DROP = 0.2 +const CLIFF_DROP = 0.4 + +/** + * Reasons ordered by how decisively they explain a cache regression. The first + * present reason wins, so a prefix change (which invalidates the whole cached + * prefix) is reported ahead of a generic provider miss. + */ +const REASON_PRIORITY: readonly CacheMissReason[] = [ + 'stable_prefix_changed', + 'tool_catalog_changed', + 'skills_changed', + 'model_changed', + 'provider_changed', + 'endpoint_changed', + 'provider_cache_miss', + 'cache_ttl_unknown', + 'cold_request', + 'provider_metrics_unavailable' +] + +const REASON_TEXT: Record = { + cold_request: 'this was the first request in the thread, so there was no warm cache yet', + model_changed: 'the model changed, which starts a new provider cache', + provider_changed: 'the provider changed, which starts a new provider cache', + endpoint_changed: 'the endpoint format changed, which starts a new provider cache', + stable_prefix_changed: 'the stable system prefix changed and invalidated the cached prefix', + tool_catalog_changed: 'the tool catalog changed (MCP/Skill tools), which invalidated the cached prefix', + skills_changed: 'the active Skill set changed, which invalidated the cached prefix', + cache_ttl_unknown: 'the provider cache TTL likely expired before this turn', + provider_cache_miss: 'the provider reported a full cache miss for this turn', + provider_metrics_unavailable: 'the provider did not report cache metrics, so the cause cannot be confirmed' +} + +export function analyzeCacheRegression(input: { + current: number | null + baseline: readonly (number | null)[] + reasons?: readonly CacheMissReason[] + /** Minimum number of usable baseline samples before reporting a drop. */ + minBaselineSamples?: number +}): CacheRegressionReport { + const minSamples = Math.max(1, input.minBaselineSamples ?? 1) + const samples = input.baseline.filter((value): value is number => isRate(value)) + const current = isRate(input.current) ? input.current : null + // Median is robust to a single cold-start zero or one anomalous spike, so a + // lone outlier in the window cannot drag the baseline and fake a regression. + const baseline = samples.length > 0 ? median(samples) : null + const primaryReason = pickPrimaryReason(input.reasons ?? []) + + if (current === null || baseline === null || samples.length < minSamples) { + return { + severity: 'none', + baselineHitRate: baseline, + currentHitRate: current, + dropPercentagePoints: null, + primaryReason, + explanation: null + } + } + + const drop = baseline - current + const severity = classifyDrop(drop) + const dropPercentagePoints = round1(drop * 100) + if (severity === 'none') { + return { severity, baselineHitRate: baseline, currentHitRate: current, dropPercentagePoints, primaryReason, explanation: null } + } + return { + severity, + baselineHitRate: baseline, + currentHitRate: current, + dropPercentagePoints, + primaryReason, + explanation: explainCacheRegression({ baseline, current, primaryReason }) + } +} + +export function explainCacheRegression(input: { + baseline: number + current: number + primaryReason: CacheMissReason | null +}): string { + const drop = round1((input.baseline - input.current) * 100) + const head = `Cache hit rate dropped from ${pct(input.baseline)} to ${pct(input.current)} (-${drop}pp).` + const cause = input.primaryReason ? ` Likely cause: ${REASON_TEXT[input.primaryReason]}.` : '' + return `${head}${cause}` +} + +function classifyDrop(drop: number): CacheRegressionSeverity { + if (drop >= CLIFF_DROP) return 'cliff' + if (drop >= MAJOR_DROP) return 'major' + if (drop >= MINOR_DROP) return 'minor' + return 'none' +} + +function pickPrimaryReason(reasons: readonly CacheMissReason[]): CacheMissReason | null { + for (const reason of REASON_PRIORITY) { + if (reasons.includes(reason)) return reason + } + return null +} + +function isRate(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1 +} + +/** Ordinal severity for cooldown comparisons; higher means a worse drop. */ +export function cacheRegressionSeverityRank(severity: CacheRegressionSeverity): number { + return { none: 0, minor: 1, major: 2, cliff: 3 }[severity] +} + +/** Median of the sample window — robust to a single cold-start or outlier. */ +function median(values: readonly number[]): number { + const sorted = [...values].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid] +} + +function round1(value: number): number { + return Math.round(value * 10) / 10 +} + +function pct(rate: number): string { + return `${Math.round(rate * 100)}%` +} diff --git a/kun/src/services/usage-service.test.ts b/kun/src/services/usage-service.test.ts index 7bc9aa7cc..27c2295c9 100644 --- a/kun/src/services/usage-service.test.ts +++ b/kun/src/services/usage-service.test.ts @@ -30,6 +30,72 @@ describe('usage cache diagnostics', () => { expect(current.cacheMissReasons).toContain('cold_request') }) + it('explains a hit-rate regression once a thread has a warm baseline', () => { + const usage = new UsageService() + const warm = (hit: number, miss: number) => ({ + promptTokens: hit + miss, + completionTokens: 10, + totalTokens: hit + miss + 10, + cacheHitTokens: hit, + cacheMissTokens: miss, + cacheHitRate: hit / (hit + miss), + turns: 1 + }) + + // Two warm turns at ~90% establish the baseline (no regression yet). + usage.record('thread-r', warm(900, 100), signature) + usage.record('thread-r', warm(900, 100), signature) + // A prefix change collapses the hit rate — should be explained. + const dropped = usage.record('thread-r', warm(50, 950), { + ...signature, + prefixFingerprint: 'prefix-b' + }) + + expect(dropped.cacheMissReasons).toContain('stable_prefix_changed') + expect(dropped.cacheSuggestions?.some((s) => /Cache hit rate dropped/.test(s))).toBe(true) + expect(dropped.cacheSuggestions?.some((s) => /stable system prefix changed/.test(s))).toBe(true) + }) + + it('does not re-announce the same regression every turn (cooldown)', () => { + const usage = new UsageService() + const warm = (hit: number, miss: number) => ({ + promptTokens: hit + miss, + completionTokens: 10, + totalTokens: hit + miss + 10, + cacheHitTokens: hit, + cacheMissTokens: miss, + cacheHitRate: hit / (hit + miss), + turns: 1 + }) + usage.record('thread-c', warm(900, 100), signature) + usage.record('thread-c', warm(900, 100), signature) + const first = usage.record('thread-c', warm(50, 950), { ...signature, prefixFingerprint: 'prefix-b' }) + const second = usage.record('thread-c', warm(50, 950), { ...signature, prefixFingerprint: 'prefix-b' }) + + expect(first.cacheSuggestions?.some((s) => /Cache hit rate dropped/.test(s))).toBe(true) + // The very next turn at the same low rate must NOT repeat the announcement. + expect(second.cacheSuggestions?.some((s) => /Cache hit rate dropped/.test(s))).toBe(false) + }) + + it('starts a fresh baseline when the model changes (no cross-model false regression)', () => { + const usage = new UsageService() + const warm = (hit: number, miss: number) => ({ + promptTokens: hit + miss, + completionTokens: 10, + totalTokens: hit + miss + 10, + cacheHitTokens: hit, + cacheMissTokens: miss, + cacheHitRate: hit / (hit + miss), + turns: 1 + }) + usage.record('thread-m', warm(900, 100), signature) + usage.record('thread-m', warm(900, 100), signature) + // Switch model: the first turn on model-b is cold and has a low hit rate, + // but must not be reported as a regression against model-a's baseline. + const switched = usage.record('thread-m', warm(50, 950), { ...signature, model: 'model-b' }) + expect(switched.cacheSuggestions?.some((s) => /Cache hit rate dropped/.test(s))).toBe(false) + }) + it('surfaces the latest-turn cache diagnostic fields in thread usage', () => { const records: ThreadUsageRecord[] = [ { diff --git a/kun/src/services/usage-service.ts b/kun/src/services/usage-service.ts index 3b92b3b99..ef63f94c1 100644 --- a/kun/src/services/usage-service.ts +++ b/kun/src/services/usage-service.ts @@ -4,6 +4,7 @@ import { diagnoseCacheUsage, type CacheRequestSignature } from '../cache/cache-diagnostics.js' +import { analyzeCacheRegression, cacheRegressionSeverityRank } from '../cache/cache-regression.js' import type { DailyUsageBucket, DailyUsageCounters, @@ -24,6 +25,18 @@ export class UsageService { private readonly counter = new UsageCounter() private readonly cache = new CacheTelemetry() private readonly cacheSignatures = new Map() + /** + * Rolling cacheable-hit-rate history keyed by thread + provider/model/endpoint + * so a model or provider switch starts a FRESH baseline instead of polluting + * the previous one. Cold-start and outliers are absorbed by the analyzer's + * median baseline. + */ + private readonly cacheHitHistory = new Map() + /** + * Cooldown state per history key so one regression isn't re-announced every + * turn: we only re-emit when the cooldown window elapses or severity worsens. + */ + private readonly cacheRegressionCooldown = new Map() record( threadId: string, @@ -50,6 +63,7 @@ export class UsageService { this.cache.reset(threadId) this.cache.ingest(threadId, seeded) this.cacheSignatures.delete(threadId) + this.clearCacheHistory(threadId) return seeded } @@ -70,6 +84,23 @@ export class UsageService { this.cache.reset(threadId) if (threadId === undefined) this.cacheSignatures.clear() else this.cacheSignatures.delete(threadId) + if (threadId === undefined) { + this.cacheHitHistory.clear() + this.cacheRegressionCooldown.clear() + } else { + this.clearCacheHistory(threadId) + } + } + + /** Drop all signature-keyed cache history + cooldown rows for one thread. */ + private clearCacheHistory(threadId: string): void { + const prefix = `${threadId}::` + for (const key of this.cacheHitHistory.keys()) { + if (key === threadId || key.startsWith(prefix)) this.cacheHitHistory.delete(key) + } + for (const key of this.cacheRegressionCooldown.keys()) { + if (key === threadId || key.startsWith(prefix)) this.cacheRegressionCooldown.delete(key) + } } private withCacheDiagnostics( @@ -86,18 +117,66 @@ export class UsageService { ...signature, activeSkillIds: [...signature.activeSkillIds] }) + // Trend-based regression: compare this turn's cacheable hit rate against + // the thread's recent baseline FOR THE SAME provider/model/endpoint so we + // can explain a *drop* (not just the per-turn miss reasons). A cooldown + // stops the same regression from being re-announced every turn. + const historyKey = cacheHistoryKey(threadId, signature) + const history = this.cacheHitHistory.get(historyKey) ?? [] + const regression = analyzeCacheRegression({ + current: diagnostic.cacheableTokenHitRate, + baseline: history, + reasons: diagnostic.reasons, + minBaselineSamples: 2 + }) + const cooldown = this.cacheRegressionCooldown.get(historyKey) ?? { severityRank: 0, turnsSinceEmit: CACHE_REGRESSION_COOLDOWN_TURNS } + cooldown.turnsSinceEmit += 1 + const severityRank = cacheRegressionSeverityRank(regression.severity) + const shouldAnnounce = Boolean(regression.explanation) && ( + cooldown.turnsSinceEmit >= CACHE_REGRESSION_COOLDOWN_TURNS || severityRank > cooldown.severityRank + ) + const suggestions = shouldAnnounce && regression.explanation + ? [regression.explanation, ...diagnostic.suggestions] + : diagnostic.suggestions + if (shouldAnnounce) { + this.cacheRegressionCooldown.set(historyKey, { severityRank, turnsSinceEmit: 0 }) + } else if (regression.severity === 'none') { + // Recovered — clear cooldown so the next genuine drop is announced promptly. + this.cacheRegressionCooldown.set(historyKey, { severityRank: 0, turnsSinceEmit: cooldown.turnsSinceEmit }) + } else { + this.cacheRegressionCooldown.set(historyKey, cooldown) + } + if (typeof diagnostic.cacheableTokenHitRate === 'number') { + this.cacheHitHistory.set(historyKey, [...history, diagnostic.cacheableTokenHitRate].slice(-CACHE_HIT_HISTORY_LIMIT)) + } return { ...usage, cacheableTokenHitRate: diagnostic.cacheableTokenHitRate, totalInputTokenHitRate: diagnostic.totalInputTokenHitRate, cacheMissReasons: diagnostic.reasons, - cacheSuggestions: diagnostic.suggestions + cacheSuggestions: [...new Set(suggestions)] } } } export const MAX_DAILY_USAGE_DAYS = 370 +/** Rolling window of recent cacheable-hit-rate samples kept per thread. */ +const CACHE_HIT_HISTORY_LIMIT = 20 + +/** Turns to wait before re-announcing the same cache regression severity. */ +const CACHE_REGRESSION_COOLDOWN_TURNS = 5 + +/** + * History/cooldown key: per thread AND per provider/model/endpoint so a model + * or provider switch starts a fresh baseline instead of polluting the prior + * one. The prefix fingerprint is intentionally excluded — a prefix change is a + * regression *cause* we want to detect, not a reason to reset the baseline. + */ +function cacheHistoryKey(threadId: string, signature: CacheRequestSignature): string { + return `${threadId}::${signature.providerId}::${signature.model}::${signature.endpointFormat}` +} + const DATE_RE = /^\d{4}-\d{2}-\d{2}$/ export class UsageValidationError extends Error { From 18adf2bbad13f602bde6f005f1a2609701e46e6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:41:19 +0800 Subject: [PATCH 17/34] feat(write): expose runtime skills in assistant (#682) --- src/renderer/src/components/Workbench.tsx | 2 + .../write/WriteAssistantPanel.test.ts | 78 +++++++++++++++++++ .../components/write/WriteAssistantPanel.tsx | 9 +++ 3 files changed, 89 insertions(+) create mode 100644 src/renderer/src/components/write/WriteAssistantPanel.test.ts diff --git a/src/renderer/src/components/Workbench.tsx b/src/renderer/src/components/Workbench.tsx index c0097c330..aeb882ee4 100644 --- a/src/renderer/src/components/Workbench.tsx +++ b/src/renderer/src/components/Workbench.tsx @@ -2409,6 +2409,8 @@ export function Workbench(): ReactElement { composerProviderId={resolvedWriteAssistantProviderId} composerPickList={writeAssistantPickList} composerModelGroups={composerModelGroups} + skillCommands={runtimeSkills} + disabledSkillIds={disabledSkillIds} composerReasoningEffort={composerReasoningEffort} setComposerModel={setWriteAssistantModel} setComposerReasoningEffort={setComposerReasoningEffort} diff --git a/src/renderer/src/components/write/WriteAssistantPanel.test.ts b/src/renderer/src/components/write/WriteAssistantPanel.test.ts new file mode 100644 index 000000000..d8ba9eb9c --- /dev/null +++ b/src/renderer/src/components/write/WriteAssistantPanel.test.ts @@ -0,0 +1,78 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import '../../i18n' +import { useChatStore } from '../../store/chat-store' +import { useWriteWorkspaceStore } from '../../write/write-workspace-store' +import { WriteAssistantPanel } from './WriteAssistantPanel' + +describe('WriteAssistantPanel', () => { + it('forwards enabled runtime Skills to the compact composer', () => { + useChatStore.setState({ + activeThreadId: 'thr_write', + activeThreadGoal: null, + route: 'write', + workspaceRoot: '/workspace', + threads: [] + }) + useWriteWorkspaceStore.setState({ + workspaceRoot: '/workspace', + activeFilePath: '/workspace/draft.md' + }) + + const html = renderToStaticMarkup(createElement(WriteAssistantPanel, { + input: '/style', + setInput: () => undefined, + mode: 'agent', + setMode: () => undefined, + busy: false, + runtimeConnection: 'ready', + activeThreadId: 'thr_write', + blocks: [], + liveReasoning: '', + liveAssistant: '', + composerModel: '', + composerPickList: [], + composerReasoningEffort: 'max', + setComposerModel: () => undefined, + setComposerReasoningEffort: () => undefined, + queuedMessages: [], + removeQueuedMessage: () => undefined, + skillCommands: [ + { + id: 'style-guide', + name: 'Style Guide', + description: 'Apply the project writing style', + root: '/workspace/.codex/skills/style-guide', + scope: 'project', + legacy: true, + version: '1', + triggers: { commands: [], fileTypes: [], promptPatterns: [] }, + allowedTools: [] + }, + { + id: 'disabled-skill', + name: 'Disabled Skill', + root: '/workspace/.codex/skills/disabled-skill', + scope: 'project', + legacy: true, + version: '1', + triggers: { commands: [], fileTypes: [], promptPatterns: [] }, + allowedTools: [] + } + ], + disabledSkillIds: ['disabled-skill'], + onSend: () => undefined, + onInterrupt: () => undefined, + onRetryConnection: () => undefined, + onOpenSettings: () => undefined, + onNewConversation: () => undefined, + onPickWorkspace: () => undefined, + onCollapse: () => undefined + })) + + expect(html).toContain('Style Guide') + expect(html).toContain('/skill:style-guide') + expect(html).not.toContain('Disabled Skill') + }) +}) diff --git a/src/renderer/src/components/write/WriteAssistantPanel.tsx b/src/renderer/src/components/write/WriteAssistantPanel.tsx index b671629d9..a6f2e5477 100644 --- a/src/renderer/src/components/write/WriteAssistantPanel.tsx +++ b/src/renderer/src/components/write/WriteAssistantPanel.tsx @@ -12,6 +12,7 @@ import { } from 'lucide-react' import { useTranslation } from 'react-i18next' import type { AttachmentReference, RuntimeConnectionStatus, ChatBlock } from '../../agent/types' +import type { CoreRuntimeSkillJson } from '../../agent/kun-contract' import type { QueuedUserMessage } from '../../store/chat-store-types' import type { ModelProviderModelGroup } from '@shared/kun-gui-api' import { @@ -37,6 +38,8 @@ type Props = { composerProviderId?: string composerPickList: string[] composerModelGroups?: ModelProviderModelGroup[] + skillCommands?: CoreRuntimeSkillJson[] + disabledSkillIds?: string[] composerReasoningEffort: ComposerReasoningEffort setComposerModel: (modelId: string, providerId?: string) => void setComposerReasoningEffort: (effort: ComposerReasoningEffort) => void @@ -60,6 +63,8 @@ type Props = { className?: string } +const EMPTY_SKILL_COMMANDS: CoreRuntimeSkillJson[] = [] + export function WriteAssistantPanel({ input, setInput, @@ -75,6 +80,8 @@ export function WriteAssistantPanel({ composerProviderId, composerPickList, composerModelGroups = [], + skillCommands = EMPTY_SKILL_COMMANDS, + disabledSkillIds, composerReasoningEffort, setComposerModel, setComposerReasoningEffort, @@ -309,6 +316,8 @@ export function WriteAssistantPanel({ composerProviderId={composerProviderId} composerPickList={composerPickList} composerModelGroups={composerModelGroups} + skillCommands={skillCommands} + disabledSkillIds={disabledSkillIds} composerReasoningEffort={composerReasoningEffort} onComposerModelChange={setComposerModel} onComposerReasoningEffortChange={setComposerReasoningEffort} From 795656e480622948603d355641a4b426b10ddbe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:41:33 +0800 Subject: [PATCH 18/34] fix(chat): improve branch conversation navigation (#683) --- .../src/components/chat/MessageTimeline.tsx | 51 ++++++++++++++++++- .../chat/MessageTimeline.turn-rail.test.ts | 25 +++++++++ src/renderer/src/locales/en/common.json | 38 +++++++------- src/renderer/src/locales/zh/common.json | 38 +++++++------- src/renderer/src/styles/base-shell.css | 5 +- 5 files changed, 116 insertions(+), 41 deletions(-) create mode 100644 src/renderer/src/components/chat/MessageTimeline.turn-rail.test.ts diff --git a/src/renderer/src/components/chat/MessageTimeline.tsx b/src/renderer/src/components/chat/MessageTimeline.tsx index 791542d81..4be7f566a 100644 --- a/src/renderer/src/components/chat/MessageTimeline.tsx +++ b/src/renderer/src/components/chat/MessageTimeline.tsx @@ -70,6 +70,19 @@ export function liveTurnProgressClass(hasActiveGoal: boolean): string { : 'flex w-fit max-w-full items-center gap-2 py-0.5 text-[14px] font-medium text-ds-muted' } +export function activeTimelineTurnKey( + positions: readonly { key: string; top: number }[], + threshold = 96 +): string | null { + if (positions.length === 0) return null + let active = positions[0].key + for (const position of positions) { + if (position.top > threshold) break + active = position.key + } + return active +} + function blockScrollStamp(block: ChatBlock | undefined): string { if (!block) return '' switch (block.kind) { @@ -184,6 +197,7 @@ export function MessageTimeline({ const endRef = useRef(null) const containerRef = useRef(null) const turnRefMap = useRef(new Map()) + const [activeTurnKey, setActiveTurnKey] = useState(null) const turns = useMemo(() => groupTurns(blocks), [blocks]) const latestBlock = blocks[blocks.length - 1] @@ -247,6 +261,39 @@ export function MessageTimeline({ ? Math.max(0, activeThread.forkedFromTurnCount) : undefined + useEffect(() => { + const container = containerRef.current + if (!container || visibleTurnAnchors.length === 0) { + setActiveTurnKey(null) + return + } + let frame: number | null = null + const update = (): void => { + frame = null + if (container.scrollHeight - container.scrollTop - container.clientHeight <= 2) { + setActiveTurnKey(visibleTurnAnchors.at(-1)?.key ?? null) + return + } + const containerTop = container.getBoundingClientRect().top + const positions = visibleTurnAnchors.flatMap((anchor) => { + const node = turnRefMap.current.get(anchor.key) + return node ? [{ key: anchor.key, top: node.getBoundingClientRect().top - containerTop }] : [] + }) + setActiveTurnKey(activeTimelineTurnKey(positions)) + } + const schedule = (): void => { + if (frame === null) frame = window.requestAnimationFrame(update) + } + container.addEventListener('scroll', schedule, { passive: true }) + window.addEventListener('resize', schedule) + schedule() + return () => { + container.removeEventListener('scroll', schedule) + window.removeEventListener('resize', schedule) + if (frame !== null) window.cancelAnimationFrame(frame) + } + }, [visibleTurnAnchors]) + // Tick a clock while a turn is running so the live "Worked for Xs" updates. const [tickNow, setTickNow] = useState(() => Date.now()) useEffect(() => { @@ -259,6 +306,7 @@ export function MessageTimeline({ const jumpToTurn = (key: string): void => { const target = turnRefMap.current.get(key) if (!target) return + setActiveTurnKey(key) target.scrollIntoView({ behavior: 'smooth', block: 'start' }) } @@ -274,9 +322,10 @@ export function MessageTimeline({