From 6a6f63b20f4b8f9539466a4f7035a15a4481cfc8 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 25 Jun 2026 20:45:47 +0800 Subject: [PATCH 1/7] feat(feedback): support attaching logs and codebase Add an attachment picker to /feedback (none / logs / logs + codebase). Codebase uploads scan the working directory with sensitive files excluded and are sent through a new multipart upload API on the oauth/node-sdk layers. --- .gitignore | 1 + .../src/feedback/codebase-upload/attach.ts | 233 +++++++++++++ .../src/feedback/codebase-upload/filter.ts | 88 +++++ .../src/feedback/codebase-upload/index.ts | 4 + .../src/feedback/codebase-upload/packager.ts | 174 ++++++++++ .../src/feedback/codebase-upload/scanner.ts | 217 ++++++++++++ .../src/feedback/codebase-upload/types.ts | 60 ++++ apps/kimi-code/src/tui/commands/info.ts | 158 ++++++++- apps/kimi-code/src/tui/commands/prompts.ts | 46 ++- .../tui/components/dialogs/choice-picker.ts | 9 +- .../dialogs/feedback-input-dialog.ts | 14 +- apps/kimi-code/src/tui/constant/feedback.ts | 7 + apps/kimi-code/src/tui/kimi-tui.ts | 3 + apps/kimi-code/src/tui/types.ts | 1 + .../codebase-upload/codebase-upload.test.ts | 325 ++++++++++++++++++ .../components/dialogs/choice-picker.test.ts | 34 +- .../test/tui/kimi-tui-message-flow.test.ts | 185 +++++++++- .../kimi-code/test/tui/message-replay.test.ts | 2 +- packages/node-sdk/src/auth.ts | 61 ++++ packages/node-sdk/src/index.ts | 3 + packages/node-sdk/test/auth-facade.test.ts | 27 +- packages/oauth/src/index.ts | 15 + packages/oauth/src/managed-feedback-upload.ts | 171 +++++++++ packages/oauth/src/managed-feedback.ts | 29 +- packages/oauth/src/toolkit.ts | 58 +++- .../test/managed-feedback-upload.test.ts | 154 +++++++++ packages/oauth/test/managed-feedback.test.ts | 39 ++- 27 files changed, 2078 insertions(+), 40 deletions(-) create mode 100644 apps/kimi-code/src/feedback/codebase-upload/attach.ts create mode 100644 apps/kimi-code/src/feedback/codebase-upload/filter.ts create mode 100644 apps/kimi-code/src/feedback/codebase-upload/index.ts create mode 100644 apps/kimi-code/src/feedback/codebase-upload/packager.ts create mode 100644 apps/kimi-code/src/feedback/codebase-upload/scanner.ts create mode 100644 apps/kimi-code/src/feedback/codebase-upload/types.ts create mode 100644 apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts create mode 100644 packages/oauth/src/managed-feedback-upload.ts create mode 100644 packages/oauth/test/managed-feedback-upload.test.ts diff --git a/.gitignore b/.gitignore index ae25ce57b6..46dca949ef 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ coverage/ plugins/cdn/ .worktrees/ .kimi-code/local.toml +.kimi-sandbox/ Dockerfile docker-compose.yml diff --git a/apps/kimi-code/src/feedback/codebase-upload/attach.ts b/apps/kimi-code/src/feedback/codebase-upload/attach.ts new file mode 100644 index 0000000000..3cb3030009 --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase-upload/attach.ts @@ -0,0 +1,233 @@ +import { createReadStream } from 'node:fs'; +import { mkdir, rm } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { Readable } from 'node:stream'; + +import { getCacheDir } from '../../utils/paths'; +import { packageBundle, packageCodebase, packageSessionFiles, type PackageBundleInput } from './packager'; +import type { + CompletedUploadPart, + FeedbackCodebaseArchive, + FeedbackCodebaseScanResult, + FeedbackUploadPart, + FeedbackUploadUrlApi, +} from './types'; + +export const CODEBASE_ARCHIVE_FILENAME = 'repo.zip'; +export const SESSION_ARCHIVE_FILENAME = 'session.zip'; +export const BUNDLE_ARCHIVE_FILENAME = 'feedback-bundle.zip'; + +const MAX_ARCHIVE_SIZE = 524_288_000; // 500 MiB, matches the backend limit. +const DEFAULT_CONCURRENCY = 3; +const DEFAULT_MAX_RETRIES = 3; +const DEFAULT_PART_TIMEOUT_MS = 60_000; +const RETRY_BASE_DELAY_MS = 1_000; + +export async function packageCurrentCodebase( + scan: FeedbackCodebaseScanResult, +): Promise { + const archivePath = defaultArchivePath(CODEBASE_ARCHIVE_FILENAME, scan.fingerprint); + await mkdir(dirname(archivePath), { recursive: true }); + return packageCodebase(scan, archivePath); +} + +export async function packageCurrentSession(sessionDir: string): Promise { + const archivePath = defaultArchivePath(SESSION_ARCHIVE_FILENAME, sessionDir); + await mkdir(dirname(archivePath), { recursive: true }); + return packageSessionFiles(sessionDir, archivePath); +} + +export async function packageCurrentBundle(input: PackageBundleInput): Promise { + const fingerprint = `${input.codebase?.fingerprint ?? 'none'}-${input.sessionDir ?? 'none'}`; + const archivePath = defaultArchivePath(BUNDLE_ARCHIVE_FILENAME, fingerprint); + await mkdir(dirname(archivePath), { recursive: true }); + return packageBundle(input, archivePath); +} + +export async function removePackagedCodebaseArchive(archive: FeedbackCodebaseArchive): Promise { + await rm(archive.path, { force: true }); +} + +export interface UploadPackagedCodebaseOptions { + /** Zip entry name sent to the backend (defaults to `repo.zip`). */ + readonly filename?: string; + /** Abort a single part PUT if it does not complete within this many milliseconds. */ + readonly timeoutMs?: number; + /** Number of parts to upload concurrently (defaults to 3). */ + readonly concurrency?: number; + /** Per-part retry attempts after the first failure (defaults to 3). */ + readonly maxRetries?: number; + /** Called after each part finishes with the cumulative uploaded bytes. */ + readonly onProgress?: (uploadedBytes: number, totalBytes: number) => void; +} + +export async function uploadPackagedCodebase( + api: FeedbackUploadUrlApi, + archive: FeedbackCodebaseArchive, + feedbackId: number, + options: UploadPackagedCodebaseOptions = {}, +): Promise { + if (archive.size > MAX_ARCHIVE_SIZE) { + throw new Error( + `Failed to upload codebase archive: size ${archive.size} exceeds maximum allowed size ${MAX_ARCHIVE_SIZE}.`, + ); + } + const created = await api.createUploadUrl({ + feedbackId, + filename: options.filename ?? CODEBASE_ARCHIVE_FILENAME, + size: archive.size, + sha256: archive.sha256, + }); + const completed = await uploadParts(archive.path, created.parts, archive.size, options); + await api.completeUpload({ uploadId: created.uploadId, parts: completed }); +} + +export function buildFeedbackCodebaseInfo( + scan: FeedbackCodebaseScanResult, + archive: FeedbackCodebaseArchive, +): Record { + return { + codebase: { + file_name: CODEBASE_ARCHIVE_FILENAME, + file_size: archive.size, + sha256: archive.sha256, + fingerprint: archive.fingerprint, + file_count: archive.fileCount, + truncated: scan.exceedsLimit !== undefined, + }, + }; +} + +interface PartLayout { + readonly part: FeedbackUploadPart; + readonly start: number; +} + +function layoutParts(parts: readonly FeedbackUploadPart[]): PartLayout[] { + const sorted = parts.toSorted((a, b) => a.partNumber - b.partNumber); + let offset = 0; + return sorted.map((part) => { + const start = offset; + offset += part.size; + return { part, start }; + }); +} + +async function uploadParts( + filePath: string, + parts: readonly FeedbackUploadPart[], + totalBytes: number, + options: UploadPackagedCodebaseOptions, +): Promise { + const layout = layoutParts(parts); + const results: CompletedUploadPart[] = Array.from({ length: layout.length }); + const concurrency = Math.max(1, Math.min(options.concurrency ?? DEFAULT_CONCURRENCY, layout.length)); + let nextIndex = 0; + let uploadedBytes = 0; + + async function worker(): Promise { + while (true) { + const index = nextIndex; + nextIndex += 1; + if (index >= layout.length) return; + const entry = layout[index]; + if (entry === undefined) return; + const completed = await uploadOnePartWithRetry(filePath, entry, options); + results[index] = completed; + uploadedBytes += entry.part.size; + options.onProgress?.(uploadedBytes, totalBytes); + } + } + + await Promise.all(Array.from({ length: concurrency }, () => worker())); + return results; +} + +async function uploadOnePartWithRetry( + filePath: string, + layout: PartLayout, + options: UploadPackagedCodebaseOptions, +): Promise { + const maxRetries = Math.max(0, options.maxRetries ?? DEFAULT_MAX_RETRIES); + let lastError: unknown; + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + try { + return await uploadOnePart(filePath, layout, options); + } catch (error) { + lastError = error; + if (attempt === maxRetries || !isRetryable(error)) break; + await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt); + } + } + throw lastError; +} + +async function uploadOnePart( + filePath: string, + layout: PartLayout, + options: UploadPackagedCodebaseOptions, +): Promise { + const { part, start } = layout; + const timeoutMs = options.timeoutMs ?? DEFAULT_PART_TIMEOUT_MS; + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(); + }, timeoutMs); + const stream = createReadStream(filePath, { start, end: start + part.size - 1 }); + try { + const res = await fetch(part.url, { + method: 'PUT', + body: Readable.toWeb(stream), + headers: { 'Content-Length': String(part.size) }, + duplex: 'half', + signal: controller.signal, + } as RequestInit); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new UploadPartHttpError(part.partNumber, res.status, text); + } + const etag = res.headers.get('etag'); + if (etag === null || etag.length === 0) { + throw new Error(`Failed to upload part ${part.partNumber}: missing ETag in response.`); + } + return { partNumber: part.partNumber, etag }; + } catch (error) { + stream.destroy(); + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`Failed to upload part ${part.partNumber}: upload timed out.`, { cause: error }); + } + throw error; + } finally { + clearTimeout(timer); + } +} + +class UploadPartHttpError extends Error { + constructor( + readonly partNumber: number, + readonly status: number, + readonly responseBody: string, + ) { + super( + `Failed to upload part ${partNumber}: HTTP ${String(status)}${responseBody.length > 0 ? ` ${responseBody}` : ''}`, + ); + } +} + +function isRetryable(error: unknown): boolean { + if (error instanceof UploadPartHttpError) { + return error.status >= 500 || error.status === 408 || error.status === 429; + } + // Network errors and timeouts are retryable. + return true; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function defaultArchivePath(filename: string, fingerprint: string): string { + return join(getCacheDir(), 'feedback-uploads', fingerprint.slice(0, 12), filename); +} diff --git a/apps/kimi-code/src/feedback/codebase-upload/filter.ts b/apps/kimi-code/src/feedback/codebase-upload/filter.ts new file mode 100644 index 0000000000..902e7be8a0 --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase-upload/filter.ts @@ -0,0 +1,88 @@ +export const DEFAULT_MAX_FILES = 50000; +export const DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024; +// Upper bound for the compressed codebase archive, aligned with the backend's +// per-upload limit. The scanner uses cumulative raw file size as a conservative +// estimate so the resulting zip stays within this bound. +export const DEFAULT_MAX_ARCHIVE_SIZE = 500 * 1024 * 1024; + +const IGNORED_DIR_NAMES: ReadonlySet = new Set([ + '.git', + '.hg', + '.svn', + 'node_modules', + 'dist', + 'build', + 'out', + '.next', + '.nuxt', + '.turbo', + '.cache', + '.parcel-cache', + 'coverage', + '.nyc_output', + 'target', + '__pycache__', + '.pytest_cache', + '.mypy_cache', + '.venv', + 'venv', + 'env', + '.idea', +]); + +const SENSITIVE_DIR_NAMES: ReadonlySet = new Set([ + '.ssh', + '.gnupg', + '.aws', + '.kube', + '.docker', +]); + +const SENSITIVE_FILE_NAMES: ReadonlySet = new Set([ + '.env', + 'id_rsa', + 'id_dsa', + 'id_ecdsa', + 'id_ed25519', + 'credentials.json', + 'service-account.json', + 'serviceAccount.json', + '.netrc', + '.htpasswd', + '.pypirc', +]); + +const SENSITIVE_FILE_SUFFIXES: readonly string[] = [ + '.pem', + '.key', + '.p12', + '.pfx', + '.jks', + '.keystore', +]; + +const ENV_FILE_ALLOWED_SUFFIXES: ReadonlySet = new Set(['.example', '.sample', '.template']); + +export function isIgnoredDirName(name: string): boolean { + return IGNORED_DIR_NAMES.has(name); +} + +export function isSensitivePath(relativePath: string): boolean { + const segments = relativePath.split('/'); + for (let i = 0; i < segments.length - 1; i += 1) { + const segment = segments[i]; + if (segment !== undefined && SENSITIVE_DIR_NAMES.has(segment)) return true; + } + + const base = segments.at(-1); + if (base === undefined || base.length === 0) return false; + if (SENSITIVE_FILE_NAMES.has(base)) return true; + if (SENSITIVE_FILE_SUFFIXES.some((suffix) => base.endsWith(suffix))) return true; + + if (base.startsWith('.env.')) { + const suffix = base.slice('.env'.length); + return !ENV_FILE_ALLOWED_SUFFIXES.has(suffix); + } + + return false; +} diff --git a/apps/kimi-code/src/feedback/codebase-upload/index.ts b/apps/kimi-code/src/feedback/codebase-upload/index.ts new file mode 100644 index 0000000000..26c1154e2c --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase-upload/index.ts @@ -0,0 +1,4 @@ +export * from './attach'; +export * from './packager'; +export * from './scanner'; +export * from './types'; diff --git a/apps/kimi-code/src/feedback/codebase-upload/packager.ts b/apps/kimi-code/src/feedback/codebase-upload/packager.ts new file mode 100644 index 0000000000..2a68734d04 --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase-upload/packager.ts @@ -0,0 +1,174 @@ +import { createHash } from 'node:crypto'; +import { createWriteStream } from 'node:fs'; +import { lstat, mkdir, readdir, stat } from 'node:fs/promises'; +import { dirname, join, relative } from 'node:path'; + +import { ZipFile } from 'yazl'; + +import type { + FeedbackCodebaseArchive, + FeedbackCodebaseFile, + FeedbackCodebaseScanResult, +} from './types'; + +interface PackageEntry { + readonly absolutePath: string; + readonly archivePath: string; + readonly size: number; + readonly mtimeMs: number; +} + +export interface PackageBundleInput { + /** Codebase files are placed under the `codebase/` prefix in the zip. */ + readonly codebase?: FeedbackCodebaseScanResult; + /** Session directory files are placed under the `session/` prefix in the zip. */ + readonly sessionDir?: string; +} + +/** + * Pack the scanned codebase into a zip, with files placed at the zip root. + */ +export async function packageCodebase( + scan: FeedbackCodebaseScanResult, + archivePath: string, +): Promise { + const entries: PackageEntry[] = scan.files.map((file) => ({ + absolutePath: file.absolutePath, + archivePath: file.path, + size: file.size, + mtimeMs: file.mtimeMs, + })); + return packageEntries(entries, archivePath); +} + +/** + * Pack the current session directory (state.json, agents//wire.jsonl, + * logs/kimi-code.log) into a zip, with files placed at the zip root. + */ +export async function packageSessionFiles( + sessionDir: string, + archivePath: string, +): Promise { + const files = await collectDirFiles(sessionDir); + const entries: PackageEntry[] = files.map((file) => ({ + absolutePath: file.absolutePath, + archivePath: file.path, + size: file.size, + mtimeMs: file.mtimeMs, + })); + return packageEntries(entries, archivePath); +} + +/** + * Pack a merged bundle: codebase under `codebase/` and session files under + * `session/`. Used when the user opts to upload both logs and codebase. + */ +export async function packageBundle( + input: PackageBundleInput, + archivePath: string, +): Promise { + const codebaseEntries: PackageEntry[] = + input.codebase === undefined + ? [] + : input.codebase.files.map((file) => ({ + absolutePath: file.absolutePath, + archivePath: `codebase/${file.path}`, + size: file.size, + mtimeMs: file.mtimeMs, + })); + const sessionEntries: PackageEntry[] = + input.sessionDir === undefined + ? [] + : (await collectDirFiles(input.sessionDir)).map((file) => ({ + absolutePath: file.absolutePath, + archivePath: `session/${file.path}`, + size: file.size, + mtimeMs: file.mtimeMs, + })); + return packageEntries([...codebaseEntries, ...sessionEntries], archivePath); +} + +async function packageEntries( + entries: readonly PackageEntry[], + archivePath: string, +): Promise { + await mkdir(dirname(archivePath), { recursive: true }); + + const zip = new ZipFile(); + const hash = createHash('sha256'); + const output = createWriteStream(archivePath); + + const done = new Promise((resolvePromise, rejectPromise) => { + output.on('finish', resolvePromise); + output.on('error', rejectPromise); + zip.outputStream.on('error', rejectPromise); + }); + + zip.outputStream.on('data', (chunk: Buffer) => { + hash.update(chunk); + }); + zip.outputStream.pipe(output); + + for (const entry of entries) { + zip.addFile(entry.absolutePath, entry.archivePath, { + mtime: new Date(entry.mtimeMs), + mode: 0o100644, + }); + } + zip.end(); + await done; + + const archiveStat = await stat(archivePath); + return { + path: archivePath, + size: archiveStat.size, + sha256: hash.digest('hex'), + fingerprint: fingerprintEntries(entries), + fileCount: entries.length, + }; +} + +async function collectDirFiles(dir: string): Promise { + const results: FeedbackCodebaseFile[] = []; + + async function walk(current: string): Promise { + const entries = await readdir(current, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isSymbolicLink()) continue; + const absolutePath = join(current, entry.name); + if (entry.isDirectory()) { + await walk(absolutePath); + continue; + } + if (!entry.isFile()) continue; + const fileStat = await lstat(absolutePath).catch(() => null); + if (fileStat === null || !fileStat.isFile()) continue; + results.push({ + path: toPosixPath(relative(dir, absolutePath)), + absolutePath, + size: fileStat.size, + mtimeMs: fileStat.mtimeMs, + }); + } + } + + await walk(dir); + return results.toSorted((a, b) => a.path.localeCompare(b.path)); +} + +function fingerprintEntries(entries: readonly PackageEntry[]): string { + const hash = createHash('sha256'); + for (const entry of entries) { + hash.update(entry.archivePath); + hash.update('\0'); + hash.update(String(entry.size)); + hash.update('\0'); + hash.update(String(Math.trunc(entry.mtimeMs))); + hash.update('\n'); + } + return hash.digest('hex'); +} + +function toPosixPath(value: string): string { + return value.split('\\').join('/'); +} diff --git a/apps/kimi-code/src/feedback/codebase-upload/scanner.ts b/apps/kimi-code/src/feedback/codebase-upload/scanner.ts new file mode 100644 index 0000000000..6df4202172 --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase-upload/scanner.ts @@ -0,0 +1,217 @@ +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { lstat, readdir } from 'node:fs/promises'; +import { join, relative, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import { + DEFAULT_MAX_ARCHIVE_SIZE, + DEFAULT_MAX_FILES, + DEFAULT_MAX_FILE_SIZE, + isIgnoredDirName, + isSensitivePath, +} from './filter'; +import type { + FeedbackCodebaseFile, + FeedbackCodebaseLimitExceeded, + FeedbackCodebaseScanResult, +} from './types'; + +const execFileAsync = promisify(execFile); + +export interface ScanCodebaseLimits { + readonly maxFiles: number; + readonly maxFileSize: number; + readonly maxArchiveSize: number; +} + +export interface ScanCodebaseOptions { + readonly limits?: { + readonly maxFiles?: number; + readonly maxFileSize?: number; + readonly maxArchiveSize?: number; + }; + readonly signal?: AbortSignal; +} + +interface CollectedFiles { + readonly files: FeedbackCodebaseFile[]; + readonly exceedsLimit?: FeedbackCodebaseLimitExceeded; +} + +export async function scanCodebase( + rootInput: string, + options: ScanCodebaseOptions = {}, +): Promise { + const root = resolve(rootInput); + const limits = resolveLimits(options.limits); + throwIfAborted(options.signal); + const usedGitIgnore = await isInsideGitWorkTree(root); + const collected = usedGitIgnore + ? await scanWithGit(root, limits, options.signal) + : await scanWithoutFilter(root, limits, options.signal); + const sortedFiles = collected.files.toSorted((a, b) => a.path.localeCompare(b.path)); + + return { + root, + files: sortedFiles, + fingerprint: fingerprintFiles(sortedFiles), + usedGitIgnore, + exceedsLimit: collected.exceedsLimit, + }; +} + +function resolveLimits(limits: ScanCodebaseOptions['limits']): ScanCodebaseLimits { + return { + maxFiles: limits?.maxFiles ?? DEFAULT_MAX_FILES, + maxFileSize: limits?.maxFileSize ?? DEFAULT_MAX_FILE_SIZE, + maxArchiveSize: limits?.maxArchiveSize ?? DEFAULT_MAX_ARCHIVE_SIZE, + }; +} + +async function isInsideGitWorkTree(root: string): Promise { + try { + const { stdout } = await execFileAsync('git', ['-C', root, 'rev-parse', '--is-inside-work-tree']); + return stdout.trim() === 'true'; + } catch { + return false; + } +} + +async function scanWithGit( + root: string, + limits: ScanCodebaseLimits, + signal?: AbortSignal, +): Promise { + const { stdout } = await execFileAsync( + 'git', + ['-C', root, 'ls-files', '-co', '--exclude-standard', '-z'], + { encoding: 'buffer', maxBuffer: 1024 * 1024 * 64, signal }, + ); + + throwIfAborted(signal); + const relativePaths = splitNull(stdout); + const files: FeedbackCodebaseFile[] = []; + let exceedsLimit: FeedbackCodebaseLimitExceeded | undefined; + let totalSize = 0; + + for (const relativePath of relativePaths) { + throwIfAborted(signal); + if (files.length >= limits.maxFiles) { + exceedsLimit = { reason: 'file-count', limit: limits.maxFiles }; + break; + } + if (isSensitivePath(relativePath)) continue; + const file = await statFile(root, relativePath); + if (file) { + if (file.size > limits.maxFileSize) continue; + if (totalSize + file.size > limits.maxArchiveSize) { + exceedsLimit = { reason: 'total-size', limit: limits.maxArchiveSize }; + break; + } + files.push(file); + totalSize += file.size; + } + } + + return { files, exceedsLimit }; +} + +async function scanWithoutFilter( + root: string, + limits: ScanCodebaseLimits, + signal?: AbortSignal, +): Promise { + const files: FeedbackCodebaseFile[] = []; + let exceedsLimit: FeedbackCodebaseLimitExceeded | undefined; + let stopped = false; + let totalSize = 0; + + async function walk(dir: string): Promise { + if (stopped) return; + throwIfAborted(signal); + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (stopped) return; + throwIfAborted(signal); + if (files.length >= limits.maxFiles) { + exceedsLimit = { reason: 'file-count', limit: limits.maxFiles }; + stopped = true; + return; + } + if (entry.isSymbolicLink()) continue; + const absolutePath = join(dir, entry.name); + if (entry.isDirectory()) { + if (isIgnoredDirName(entry.name)) continue; + await walk(absolutePath); + if (stopped) return; + continue; + } + if (!entry.isFile()) continue; + const relativePath = toPosixPath(relative(root, absolutePath)); + if (isSensitivePath(relativePath)) continue; + const file = await statFile(root, relativePath); + if (file) { + if (file.size > limits.maxFileSize) continue; + if (totalSize + file.size > limits.maxArchiveSize) { + exceedsLimit = { reason: 'total-size', limit: limits.maxArchiveSize }; + stopped = true; + return; + } + files.push(file); + totalSize += file.size; + } + } + } + + await walk(root); + return { files, exceedsLimit }; +} + +async function statFile(root: string, relativePath: string): Promise { + const absolutePath = resolve(root, relativePath); + // A tracked file can be deleted from the working tree but still listed by + // `git ls-files`; lstat then throws ENOENT. Treat unreadable/vanished paths + // like any other non-regular entry so one bad path does not abort the scan. + const stat = await lstat(absolutePath).catch(() => null); + if (stat === null || stat.isSymbolicLink() || !stat.isFile()) return null; + + return { + path: toPosixPath(relativePath), + absolutePath, + size: stat.size, + mtimeMs: stat.mtimeMs, + }; +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + const error = new Error('Codebase scan aborted.'); + error.name = 'AbortError'; + throw error; + } +} + +function fingerprintFiles(files: readonly FeedbackCodebaseFile[]): string { + const hash = createHash('sha256'); + for (const file of files) { + hash.update(file.path); + hash.update('\0'); + hash.update(String(file.size)); + hash.update('\0'); + hash.update(String(Math.trunc(file.mtimeMs))); + hash.update('\n'); + } + return hash.digest('hex'); +} + +function splitNull(buffer: Buffer): string[] { + return buffer + .toString('utf8') + .split('\0') + .filter((item) => item.length > 0); +} + +function toPosixPath(value: string): string { + return value.split('\\').join('/'); +} diff --git a/apps/kimi-code/src/feedback/codebase-upload/types.ts b/apps/kimi-code/src/feedback/codebase-upload/types.ts new file mode 100644 index 0000000000..18447ddc7a --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase-upload/types.ts @@ -0,0 +1,60 @@ +export interface FeedbackCodebaseFile { + readonly path: string; + readonly absolutePath: string; + readonly size: number; + readonly mtimeMs: number; +} + +export interface FeedbackCodebaseLimitExceeded { + readonly reason: 'file-count' | 'total-size'; + readonly limit: number; +} + +export interface FeedbackCodebaseScanResult { + readonly root: string; + readonly files: readonly FeedbackCodebaseFile[]; + readonly fingerprint: string; + readonly usedGitIgnore: boolean; + readonly exceedsLimit?: FeedbackCodebaseLimitExceeded; +} + +export interface FeedbackCodebaseArchive { + readonly path: string; + readonly size: number; + readonly sha256: string; + readonly fingerprint: string; + readonly fileCount: number; +} + +export interface CreateFeedbackUploadUrlInput { + readonly feedbackId: number; + readonly filename: string; + readonly size: number; + readonly sha256: string; +} + +export interface FeedbackUploadPart { + readonly partNumber: number; + readonly url: string; + readonly size: number; +} + +export interface CreateFeedbackUploadUrlResult { + readonly uploadId: number; + readonly parts: readonly FeedbackUploadPart[]; +} + +export interface CompletedUploadPart { + readonly partNumber: number; + readonly etag: string; +} + +export interface CompleteFeedbackUploadUrlInput { + readonly uploadId: number; + readonly parts: readonly CompletedUploadPart[]; +} + +export interface FeedbackUploadUrlApi { + createUploadUrl(input: CreateFeedbackUploadUrlInput): Promise; + completeUpload(input: CompleteFeedbackUploadUrlInput): Promise; +} diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index 73cc998249..a6f5d71d77 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -1,4 +1,6 @@ +import { appendFile, mkdir } from 'node:fs/promises'; import { release as osRelease, type as osType } from 'node:os'; +import { join } from 'node:path'; import type { McpServerInfo, SessionStatus, SessionUsage } from '@moonshot-ai/kimi-code-sdk'; @@ -12,20 +14,38 @@ import { FEEDBACK_STATUS_NOT_SIGNED_IN, FEEDBACK_STATUS_SUBMITTING, FEEDBACK_STATUS_SUCCESS, + FEEDBACK_STATUS_UPLOADING, + FEEDBACK_STATUS_UPLOAD_FAILED, FEEDBACK_TELEMETRY_EVENT, + feedbackIdLine, feedbackSessionLine, withFeedbackVersionPrefix, } from '../constant/feedback'; import { isManagedUsageProvider } from '../constant/kimi-tui'; import { formatErrorMessage } from '../utils/event-payload'; +import { getLogDir } from '#/utils/paths'; +import { + packageCurrentBundle, + packageCurrentSession, + removePackagedCodebaseArchive, + scanCodebase, + uploadPackagedCodebase, + BUNDLE_ARCHIVE_FILENAME, + SESSION_ARCHIVE_FILENAME, + type FeedbackCodebaseArchive, + type FeedbackCodebaseScanResult, + type FeedbackUploadUrlApi, +} from '../../feedback/codebase-upload'; import { openUrl } from '#/utils/open-url'; -import { promptFeedbackInput } from './prompts'; +import { promptFeedbackAttachment, promptFeedbackInput, type FeedbackAttachmentLevel } from './prompts'; import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- // Feedback // --------------------------------------------------------------------------- +const CODEBASE_SCAN_TIMEOUT_MS = 3000; + export async function handleFeedbackCommand(host: SlashCommandHost): Promise { const fallback = (reason: string): void => { host.showStatus(reason); @@ -39,30 +59,146 @@ export async function handleFeedbackCommand(host: SlashCommandHost): Promise 0 ? host.state.appState.model : null, }); - if (res.kind === 'ok') { - spinner.stop({ ok: true, label: FEEDBACK_STATUS_SUCCESS }); - host.showStatus(feedbackSessionLine(host.state.appState.sessionId)); - host.track(FEEDBACK_TELEMETRY_EVENT); + if (res.kind !== 'ok') { + spinner.stop({ ok: false, label: res.message }); + fallback(FEEDBACK_STATUS_FALLBACK); return; } - spinner.stop({ ok: false, label: res.message }); - fallback(FEEDBACK_STATUS_FALLBACK); + // Await the upload before returning so the command cannot outlive the + // upload task; the spinner stays up for the whole submit + upload cycle + // so the outcome is deterministic. + let uploadFailed = false; + if (level !== 'none') { + spinner.setLabel(FEEDBACK_STATUS_UPLOADING); + try { + await uploadFeedbackAttachment(host, level, res.feedbackId); + } catch (error) { + uploadFailed = true; + await logFeedbackUploadError(error); + } + } + + spinner.stop({ ok: true, label: FEEDBACK_STATUS_SUCCESS }); + host.showStatus(feedbackSessionLine(host.state.appState.sessionId)); + host.showStatus(feedbackIdLine(res.feedbackId)); + host.track(FEEDBACK_TELEMETRY_EVENT); + if (uploadFailed) { + host.showStatus(FEEDBACK_STATUS_UPLOAD_FAILED); + } +} + +async function uploadFeedbackAttachment( + host: SlashCommandHost, + level: Exclude, + feedbackId: number, +): Promise { + const api = createFeedbackUploadApi(host); + let archive: FeedbackCodebaseArchive | undefined; + try { + if (level === 'logs') { + const sessionDir = await resolveCurrentSessionDir(host); + if (sessionDir === undefined) { + throw new Error('cannot locate the current session directory'); + } + archive = await packageCurrentSession(sessionDir); + await uploadPackagedCodebase(api, archive, feedbackId, { filename: SESSION_ARCHIVE_FILENAME }); + } else { + const [sessionDir, scan] = await Promise.all([ + resolveCurrentSessionDir(host), + scanCodebaseForFeedback(host.state.appState.workDir), + ]); + archive = await packageCurrentBundle({ codebase: scan, sessionDir }); + await uploadPackagedCodebase(api, archive, feedbackId, { filename: BUNDLE_ARCHIVE_FILENAME }); + } + } finally { + if (archive !== undefined) { + await removePackagedCodebaseArchive(archive).catch(() => {}); + } + } +} + +async function resolveCurrentSessionDir(host: SlashCommandHost): Promise { + try { + const sessions = await host.harness.listSessions({ workDir: host.state.appState.workDir }); + return sessions.find((session) => session.id === host.state.appState.sessionId)?.sessionDir; + } catch { + return undefined; + } +} + +async function scanCodebaseForFeedback(workDir: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(); + }, CODEBASE_SCAN_TIMEOUT_MS); + try { + return await scanCodebase(workDir, { signal: controller.signal }); + } catch (error) { + await logFeedbackUploadError(error); + return undefined; + } finally { + clearTimeout(timer); + } +} + +async function logFeedbackUploadError(error: unknown): Promise { + try { + const logDir = getLogDir(); + await mkdir(logDir, { recursive: true }); + const message = error instanceof Error ? (error.stack ?? error.message) : String(error); + await appendFile(join(logDir, 'feedback-upload.log'), `${new Date().toISOString()} ${message}\n`); + } catch { + // best-effort logging only + } +} + +function createFeedbackUploadApi(host: SlashCommandHost): FeedbackUploadUrlApi { + return { + async createUploadUrl(input) { + const res = await host.harness.auth.createFeedbackUploadUrl(input); + if (res.kind !== 'ok') throw new Error(res.message); + return { + uploadId: res.upload_id, + parts: res.parts.map((part) => ({ + partNumber: part.part_number, + url: part.url, + size: part.size, + })), + }; + }, + async completeUpload(input) { + const res = await host.harness.auth.completeFeedbackUpload({ + uploadId: input.uploadId, + parts: input.parts.map((part) => ({ partNumber: part.partNumber, etag: part.etag })), + }); + if (res.kind !== 'ok') throw new Error(res.message); + }, + }; } // --------------------------------------------------------------------------- diff --git a/apps/kimi-code/src/tui/commands/prompts.ts b/apps/kimi-code/src/tui/commands/prompts.ts index 67fd89a29b..0bdbb88994 100644 --- a/apps/kimi-code/src/tui/commands/prompts.ts +++ b/apps/kimi-code/src/tui/commands/prompts.ts @@ -57,16 +57,58 @@ export function promptLogoutProviderSelection( }); } -export function promptFeedbackInput(host: SlashCommandHost): Promise { +export interface FeedbackPromptResult { + readonly value: string; +} + +export function promptFeedbackInput(host: SlashCommandHost): Promise { return new Promise((resolve) => { const dialog = new FeedbackInputDialogComponent((result: FeedbackInputDialogResult) => { host.restoreEditor(); - resolve(result.kind === 'ok' ? result.value : undefined); + resolve(result.kind === 'ok' ? { value: result.value } : undefined); }); host.mountEditorReplacement(dialog); }); } +export type FeedbackAttachmentLevel = 'none' | 'logs' | 'logs+codebase'; + +const FEEDBACK_ATTACHMENT_OPTIONS: readonly ChoiceOption[] = [ + { value: 'none', label: 'No attachment', description: 'Text feedback only' }, + { + value: 'logs', + label: 'Logs only', + description: 'Upload wire events and diagnostic logs from this session', + }, + { + value: 'logs+codebase', + label: 'Logs + codebase', + description: + 'Include your codebase for deeper diagnosis. Sensitive files are automatically excluded — e.g. .env, config files, secret keys. We use attachments only for diagnosis and never share them.', + descriptionTone: 'warning', + }, +]; + +export function promptFeedbackAttachment( + host: SlashCommandHost, +): Promise { + return new Promise((resolve) => { + const picker = new ChoicePickerComponent({ + title: 'Share diagnostic info to help us investigate?', + options: FEEDBACK_ATTACHMENT_OPTIONS, + onSelect: (value) => { + host.restoreEditor(); + resolve(value as FeedbackAttachmentLevel); + }, + onCancel: () => { + host.restoreEditor(); + resolve(undefined); + }, + }); + host.mountEditorReplacement(picker); + }); +} + export function promptApiKey( host: SlashCommandHost, platformName: string, diff --git a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts index fb5094af1e..c85339d909 100644 --- a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts @@ -17,7 +17,7 @@ import { type Focusable, } from '@earendil-works/pi-tui'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; -import { currentTheme } from '#/tui/theme'; +import { currentTheme, type ColorToken } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; import { SearchableList } from '#/tui/utils/searchable-list'; @@ -30,6 +30,9 @@ export interface ChoiceOption { readonly tone?: 'danger'; /** Optional explanatory text shown below the label. */ readonly description?: string | undefined; + /** Color token applied to the description while this option is selected, drawing + * attention to important details. Falls back to `textMuted` when unset or not selected. */ + readonly descriptionTone?: ColorToken; } export interface ChoicePickerOptions { @@ -174,8 +177,10 @@ export class ChoicePickerComponent extends Container implements Focusable { lines.push(line); if (opt.description !== undefined && opt.description.length > 0) { const descriptionWidth = Math.max(1, width - 4); + const descriptionColor = + isSelected && opt.descriptionTone !== undefined ? opt.descriptionTone : 'textMuted'; for (const descLine of wrapDescription(opt.description, descriptionWidth)) { - lines.push(currentTheme.fg('textMuted', ` ${descLine}`)); + lines.push(currentTheme.fg(descriptionColor, ` ${descLine}`)); } } } diff --git a/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts index 3fefe86c03..38ac3bd9ef 100644 --- a/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts @@ -5,6 +5,10 @@ * Geometry mirrors `DeviceCodeBox` so the chrome stays consistent with * the OAuth login flow. The box embeds a `pi-tui` Input for the actual * text entry; cursor visibility tracks the dialog's `focused` flag. + * + * This is stage 1 of the feedback flow: it collects the free-form text + * only. Whether to attach diagnostic logs / codebase is decided in a + * follow-up stage (see `promptFeedbackAttachment`). */ import { @@ -83,7 +87,15 @@ export class FeedbackInputDialogComponent extends Container implements Focusable const footerLine = truncateToWidth(footerStyled, innerWidth, '…'); const inputLine = this.input.render(innerWidth)[0] ?? '> '; - const contentLines: string[] = [titleLine, '', subtitleLine, '', inputLine, '', footerLine]; + const contentLines: string[] = [ + titleLine, + '', + subtitleLine, + '', + inputLine, + '', + footerLine, + ]; if (safeWidth < 4) { return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, '…'))]; diff --git a/apps/kimi-code/src/tui/constant/feedback.ts b/apps/kimi-code/src/tui/constant/feedback.ts index 9e8d621f58..d72a62def0 100644 --- a/apps/kimi-code/src/tui/constant/feedback.ts +++ b/apps/kimi-code/src/tui/constant/feedback.ts @@ -16,12 +16,15 @@ export { } from '#/constant/app'; export const FEEDBACK_STATUS_SUBMITTING = 'Submitting feedback…'; +export const FEEDBACK_STATUS_UPLOADING = 'Uploading attachments, this could take a few minutes…'; export const FEEDBACK_STATUS_SUCCESS = 'Feedback submitted, thank you!'; export const FEEDBACK_STATUS_CANCELLED = 'Feedback cancelled.'; export const FEEDBACK_STATUS_NETWORK_ERROR = 'Network error, failed to submit feedback.'; export const FEEDBACK_STATUS_FALLBACK = 'Opening GitHub Issues as fallback…'; export const FEEDBACK_STATUS_NOT_SIGNED_IN = "You're not signed in. Opening GitHub Issues for feedback…"; +export const FEEDBACK_STATUS_UPLOAD_FAILED = + 'Feedback sent; attachment upload failed — see feedback-upload.log.'; export function feedbackHttpErrorMessage(status: number): string { return `Failed to submit feedback (HTTP ${String(status)}).`; @@ -31,6 +34,10 @@ export function feedbackSessionLine(sessionId: string): string { return `Session: ${sessionId}`; } +export function feedbackIdLine(feedbackId: number): string { + return `Feedback ID: ${String(feedbackId)}`; +} + // Hint shown beneath session-level error messages in the TUI to point users // at the `/export-debug-zip` workflow so they can share diagnostics with us. export function errorReportHintLine(): string { diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 409a9eb2bd..83da4b5987 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1836,6 +1836,9 @@ export class KimiTUI { spinner.setText(currentTheme.fg(tone, `${symbol} ${finalLabel}`)); this.state.ui.requestRender(); }, + setLabel: (nextLabel) => { + spinner.setLabel(nextLabel); + }, }; } diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 985e576e46..6d96eb2d57 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -223,6 +223,7 @@ export interface PendingExit { export interface LoginProgressSpinnerHandle { stop(opts: { ok: boolean; label: string }): void; + setLabel(label: string): void; } export type ProgressSpinnerHandle = LoginProgressSpinnerHandle; diff --git a/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts b/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts new file mode 100644 index 0000000000..624a184c09 --- /dev/null +++ b/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts @@ -0,0 +1,325 @@ +import { execFile } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { scanCodebase, packageSessionFiles, uploadPackagedCodebase } from '../../../src/feedback/codebase-upload'; + +const execFileAsync = promisify(execFile); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe('uploadPackagedCodebase', () => { + it('requests upload parts, PUTs each part, and completes with etags', async () => { + const workRoot = await mkdtemp(join(tmpdir(), 'feedback-upload-direct-')); + const archivePath = join(workRoot, 'repo.zip'); + await writeFile(archivePath, 'hello'); + + const fetchMock = vi.fn( + async () => new Response('', { status: 200, headers: { ETag: '"etag-1"' } }), + ); + vi.stubGlobal('fetch', fetchMock); + const api = { + createUploadUrl: vi.fn(async () => ({ + uploadId: 28, + parts: [{ partNumber: 1, url: 'https://example.test/part1', size: 5 }], + })), + completeUpload: vi.fn(async () => {}), + }; + + try { + await uploadPackagedCodebase( + api, + { + path: archivePath, + size: 5, + sha256: 'hash', + fingerprint: 'fingerprint', + fileCount: 1, + }, + 3, + ); + + expect(api.createUploadUrl).toHaveBeenCalledWith({ + feedbackId: 3, + filename: 'repo.zip', + size: 5, + sha256: 'hash', + }); + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe('https://example.test/part1'); + expect(init.method).toBe('PUT'); + expect(init.body).toBeInstanceOf(ReadableStream); + expect((init as { duplex?: string }).duplex).toBe('half'); + expect(new Headers(init.headers).get('content-length')).toBe('5'); + // Drain the stream so the underlying file handle is released. + expect(await new Response(init.body as ReadableStream).text()).toBe('hello'); + expect(api.completeUpload).toHaveBeenCalledWith({ + uploadId: 28, + parts: [{ partNumber: 1, etag: '"etag-1"' }], + }); + } finally { + await rm(workRoot, { recursive: true, force: true }); + } + }); + + it('aborts a stalled part PUT and does not mark upload complete', async () => { + const workRoot = await mkdtemp(join(tmpdir(), 'feedback-upload-stalled-')); + const archivePath = join(workRoot, 'repo.zip'); + await writeFile(archivePath, 'hello'); + + const fetchMock = vi.fn((_url: string, init?: RequestInit) => + new Promise((_resolve, reject) => { + const signal = init?.signal; + if (signal?.aborted) { + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + return; + } + signal?.addEventListener( + 'abort', + () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), + { once: true }, + ); + }), + ); + vi.stubGlobal('fetch', fetchMock); + const api = { + createUploadUrl: vi.fn(async () => ({ + uploadId: 28, + parts: [{ partNumber: 1, url: 'https://example.test/part1', size: 5 }], + })), + completeUpload: vi.fn(async () => {}), + }; + + vi.useFakeTimers(); + try { + const upload = uploadPackagedCodebase( + api, + { + path: archivePath, + size: 5, + sha256: 'hash', + fingerprint: 'fingerprint', + fileCount: 1, + }, + 3, + { timeoutMs: 25, maxRetries: 0 }, + ); + const expectation = expect(upload).rejects.toThrow(/timed out/); + await vi.advanceTimersByTimeAsync(25); + await expectation; + expect(api.completeUpload).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + await rm(workRoot, { recursive: true, force: true }); + } + }); + + it('retries a failed part and completes once it succeeds', async () => { + const workRoot = await mkdtemp(join(tmpdir(), 'feedback-upload-retry-')); + const archivePath = join(workRoot, 'repo.zip'); + await writeFile(archivePath, 'hello'); + + let attempt = 0; + const fetchMock = vi.fn(async () => { + attempt += 1; + if (attempt === 1) return new Response('server error', { status: 500 }); + return new Response('', { status: 200, headers: { ETag: '"etag-1"' } }); + }); + vi.stubGlobal('fetch', fetchMock); + const api = { + createUploadUrl: vi.fn(async () => ({ + uploadId: 28, + parts: [{ partNumber: 1, url: 'https://example.test/part1', size: 5 }], + })), + completeUpload: vi.fn(async () => {}), + }; + + vi.useFakeTimers(); + try { + const upload = uploadPackagedCodebase( + api, + { + path: archivePath, + size: 5, + sha256: 'hash', + fingerprint: 'fingerprint', + fileCount: 1, + }, + 3, + { timeoutMs: 10_000 }, + ); + await vi.advanceTimersByTimeAsync(1_000); + await upload; + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(api.completeUpload).toHaveBeenCalledWith({ + uploadId: 28, + parts: [{ partNumber: 1, etag: '"etag-1"' }], + }); + } finally { + vi.useRealTimers(); + await rm(workRoot, { recursive: true, force: true }); + } + }); +}); + +describe('packageSessionFiles', () => { + it('recursively packs every file under the session directory', async () => { + const sessionDir = await mkdtemp(join(tmpdir(), 'feedback-session-')); + const archivePath = join(tmpdir(), 'feedback-session-test.zip'); + try { + await mkdir(join(sessionDir, 'agents', 'main'), { recursive: true }); + await mkdir(join(sessionDir, 'logs')); + await writeFile(join(sessionDir, 'state.json'), '{}'); + await writeFile(join(sessionDir, 'agents', 'main', 'wire.jsonl'), 'events'); + await writeFile(join(sessionDir, 'logs', 'kimi-code.log'), 'logs'); + + const archive = await packageSessionFiles(sessionDir, archivePath); + expect(archive.fileCount).toBe(3); + expect(archive.size).toBeGreaterThan(0); + expect(archive.sha256).toMatch(/^[0-9a-f]{64}$/); + } finally { + await rm(sessionDir, { recursive: true, force: true }); + await rm(archivePath, { force: true }); + } + }); +}); + +describe('scanCodebase filtering', () => { + it('rejects when the scan signal is already aborted', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-aborted-')); + const controller = new AbortController(); + controller.abort(); + try { + await expect(scanCodebase(root, { signal: controller.signal })).rejects.toMatchObject({ + name: 'AbortError', + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('skips dependency and build directories outside a git work tree', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-no-git-')); + try { + await mkdir(join(root, 'node_modules', 'pkg'), { recursive: true }); + await mkdir(join(root, 'dist')); + await writeFile(join(root, 'node_modules', 'pkg', 'index.js'), 'module.exports = 1;\n'); + await writeFile(join(root, 'dist', 'bundle.js'), 'built\n'); + await writeFile(join(root, 'keep.ts'), 'export const keep = 1;\n'); + + const scan = await scanCodebase(root); + expect(scan.usedGitIgnore).toBe(false); + expect(scan.files.map((file) => file.path)).toEqual(['keep.ts']); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('filters sensitive files even when tracked by git', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-git-')); + try { + await writeFile(join(root, '.env'), 'SECRET=1\n'); + await writeFile(join(root, 'id_rsa'), 'private-key\n'); + await writeFile(join(root, 'app.ts'), 'export const app = 1;\n'); + await execFileAsync('git', ['init'], { cwd: root }); + await execFileAsync('git', ['add', '-A'], { cwd: root }); + + const scan = await scanCodebase(root); + expect(scan.usedGitIgnore).toBe(true); + const paths = scan.files.map((file) => file.path); + expect(paths).toContain('app.ts'); + expect(paths).not.toContain('.env'); + expect(paths).not.toContain('id_rsa'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('filters sensitive files by glob outside a git work tree', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-sensitive-')); + try { + await mkdir(join(root, '.ssh')); + await writeFile(join(root, '.env.production'), 'SECRET=1\n'); + await writeFile(join(root, 'tls.pem'), 'cert\n'); + await writeFile(join(root, '.ssh', 'config'), 'Host *\n'); + await writeFile(join(root, 'keep.ts'), 'export const keep = 1;\n'); + + const scan = await scanCodebase(root); + expect(scan.files.map((file) => file.path)).toEqual(['keep.ts']); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('skips individual files larger than the per-file limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-large-file-')); + try { + await writeFile(join(root, 'big.bin'), randomBytes(256)); + await writeFile(join(root, 'small.txt'), 'hello\n'); + + const scan = await scanCodebase(root, { limits: { maxFileSize: 128 } }); + expect(scan.files.map((file) => file.path)).toEqual(['small.txt']); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('skips tracked files that were deleted from the working tree', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-deleted-')); + try { + await writeFile(join(root, 'keep.ts'), 'export const keep = 1;\n'); + await writeFile(join(root, 'deleted.ts'), 'export const gone = 1;\n'); + await execFileAsync('git', ['init'], { cwd: root }); + await execFileAsync('git', ['add', '-A'], { cwd: root }); + // Remove only from the working tree; the index still lists it, so + // `git ls-files` reports a path that no longer exists on disk. + await rm(join(root, 'deleted.ts')); + + const scan = await scanCodebase(root); + expect(scan.usedGitIgnore).toBe(true); + expect(scan.files.map((file) => file.path)).toEqual(['keep.ts']); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('marks exceedsLimit when file count reaches the limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-limit-')); + try { + await writeFile(join(root, 'a.txt'), 'a\n'); + await writeFile(join(root, 'b.txt'), 'b\n'); + await writeFile(join(root, 'c.txt'), 'c\n'); + + const scan = await scanCodebase(root, { limits: { maxFiles: 2 } }); + expect(scan.files).toHaveLength(2); + expect(scan.exceedsLimit).toEqual({ reason: 'file-count', limit: 2 }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('marks exceedsLimit when cumulative file size reaches the archive limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-total-size-')); + try { + await writeFile(join(root, 'a.txt'), 'a'.repeat(100)); + await writeFile(join(root, 'b.txt'), 'b'.repeat(100)); + await writeFile(join(root, 'c.txt'), 'c'.repeat(100)); + + // 250 bytes fits any two files (200) but not the third (300). + const scan = await scanCodebase(root, { limits: { maxArchiveSize: 250 } }); + expect(scan.files).toHaveLength(2); + expect(scan.exceedsLimit).toEqual({ reason: 'total-size', limit: 250 }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts index 367a855785..ec590e252c 100644 --- a/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it, vi } from 'vitest'; -import { ChoicePickerComponent } from '#/tui/components/dialogs/choice-picker'; +import { ChoicePickerComponent, type ChoiceOption } from '#/tui/components/dialogs/choice-picker'; import { EditorSelectorComponent } from '#/tui/components/dialogs/editor-selector'; import { PermissionSelectorComponent } from '#/tui/components/dialogs/permission-selector'; import { SettingsSelectorComponent } from '#/tui/components/dialogs/settings-selector'; import { ThemeSelectorComponent } from '#/tui/components/dialogs/theme-selector'; import { UpdatePreferenceSelectorComponent } from '#/tui/components/dialogs/update-preference-selector'; +import { currentTheme } from '#/tui/theme'; import { darkColors } from '#/tui/theme/colors'; const ANSI_SGR = /\[[0-9;]*m/g; @@ -144,4 +145,35 @@ describe('ChoicePickerComponent', () => { picker.handleInput(' '); expect(onSelect).toHaveBeenCalledWith('a'); }); + + it('renders the selected option description in descriptionTone, others in textMuted', () => { + const options: ChoiceOption[] = [ + { value: 'none', label: 'No attachment', description: 'Text feedback only' }, + { + value: 'logs+codebase', + label: 'Logs + codebase', + description: 'Include your codebase for deeper diagnosis.', + descriptionTone: 'warning', + }, + ]; + + const renderDescLine = (currentValue: string): string | undefined => { + const picker = new ChoicePickerComponent({ + title: 'Share diagnostic info?', + options, + currentValue, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + return picker.render(120).find((line) => strip(line).includes('Include your codebase')); + }; + + const warningLine = currentTheme.fg('warning', ' Include your codebase for deeper diagnosis.'); + const mutedLine = currentTheme.fg('textMuted', ' Include your codebase for deeper diagnosis.'); + + // Selected option: description uses the configured tone. + expect(renderDescLine('logs+codebase')).toBe(warningLine); + // Unselected option: description falls back to textMuted. + expect(renderDescLine('none')).toBe(mutedLine); + }); }); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index df4b794f21..ebefa839dc 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -32,15 +32,40 @@ import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui import type { StreamingUIController } from '#/tui/controllers/streaming-ui'; import { handleFeedbackCommand } from '#/tui/commands/info'; import { + packageCurrentBundle, + packageCurrentCodebase, + packageCurrentSession, + scanCodebase, + uploadPackagedCodebase, +} from '../../src/feedback/codebase-upload'; +import { + promptFeedbackAttachment, promptFeedbackInput, runModelSelector, + type FeedbackPromptResult, } from '#/tui/commands/prompts'; import type { QueuedMessage } from '#/tui/types'; import type { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; vi.mock('#/tui/commands/prompts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, promptFeedbackInput: vi.fn() }; + return { + ...actual, + promptFeedbackInput: vi.fn(), + promptFeedbackAttachment: vi.fn(), + }; +}); + +vi.mock('../../src/feedback/codebase-upload', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + scanCodebase: vi.fn().mockResolvedValue(undefined), + packageCurrentCodebase: vi.fn(), + packageCurrentBundle: vi.fn(), + packageCurrentSession: vi.fn(), + uploadPackagedCodebase: vi.fn(), + }; }); // /feedback falls back to opening GitHub Issues in a browser when not signed in @@ -73,7 +98,7 @@ interface MessageDriver { interface FeedbackDriver extends MessageDriver { handleFeedbackCommand(): Promise; - promptFeedbackInput(): Promise; + promptFeedbackInput(): Promise; } interface ModelSelectorDriver extends MessageDriver { @@ -234,8 +259,11 @@ function makeHarness(session = makeSession(), overrides: Record logout: vi.fn(), getManagedUsage: vi.fn(), submitFeedback: vi.fn( - async (): Promise<{ kind: 'ok' } | { kind: 'error'; status?: number; message: string }> => ({ + async (): Promise< + { kind: 'ok'; feedbackId: number } | { kind: 'error'; status?: number; message: string } + > => ({ kind: 'ok', + feedbackId: 3, }), ), }, @@ -472,8 +500,9 @@ command = "vim" }, ); const feedbackDriver = driver as unknown as FeedbackDriver; - vi.mocked(promptFeedbackInput).mockImplementation(async () => 'useful feedback'); - harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok' }); + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'none'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); harness.track.mockClear(); await handleFeedbackCommand(feedbackDriver as any); @@ -487,6 +516,149 @@ command = "vim" }), ); expect(harness.track).toHaveBeenCalledWith('feedback_submitted', undefined); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback ID: 3'); + }); + + it('waits for the codebase upload to finish before returning', async () => { + const { driver, harness } = await makeDriver( + makeSession(), + { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 100, + provider: 'managed:kimi-code', + }, + }, + })), + }, + ); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + + vi.mocked(scanCodebase).mockResolvedValueOnce({ + root: '/tmp/proj-a', + files: [{ path: 'keep.ts', size: 4 }], + fingerprint: 'fp-123', + usedGitIgnore: false, + } as any); + vi.mocked(packageCurrentBundle).mockResolvedValueOnce({ + path: '/tmp/fake-archive.zip', + size: 4, + sha256: 'hash-123', + fingerprint: 'fp-123', + fileCount: 1, + }); + + let resolveUpload!: () => void; + vi.mocked(uploadPackagedCodebase).mockImplementationOnce( + () => new Promise((resolve) => { resolveUpload = resolve; }), + ); + + let settled = false; + const command = handleFeedbackCommand(feedbackDriver as any).then(() => { + settled = true; + }); + + // The command reaches the upload and blocks on it; it must not return + // while the upload is still in flight. + await vi.waitFor(() => { + expect(uploadPackagedCodebase).toHaveBeenCalledOnce(); + }); + expect(settled).toBe(false); + + resolveUpload(); + await command; + expect(settled).toBe(true); + expect(harness.auth.submitFeedback).toHaveBeenCalledOnce(); + }); + + it('tells the user when feedback is sent but codebase packaging fails', async () => { + const { driver, harness } = await makeDriver( + makeSession(), + { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 100, + provider: 'managed:kimi-code', + }, + }, + })), + }, + ); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(scanCodebase).mockReset(); + vi.mocked(packageCurrentBundle).mockReset(); + vi.mocked(uploadPackagedCodebase).mockReset(); + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + + vi.mocked(scanCodebase).mockResolvedValueOnce({ + root: '/tmp/proj-a', + files: [{ path: 'keep.ts', size: 4 }], + fingerprint: 'fp-123', + usedGitIgnore: false, + } as any); + vi.mocked(packageCurrentBundle).mockRejectedValueOnce(new Error('zip failed')); + + await handleFeedbackCommand(feedbackDriver as any); + + const calls = harness.auth.submitFeedback.mock.calls as unknown as Array<[Record]>; + expect(calls[0]?.[0]).not.toHaveProperty('info'); + expect(uploadPackagedCodebase).not.toHaveBeenCalled(); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback ID: 3'); + expect(transcript).toContain('attachment upload failed'); + }); + + it('tells the user when the codebase upload fails', async () => { + const { driver, harness } = await makeDriver( + makeSession(), + { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 100, + provider: 'managed:kimi-code', + }, + }, + })), + }, + ); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + + vi.mocked(scanCodebase).mockResolvedValueOnce({ + root: '/tmp/proj-a', + files: [{ path: 'keep.ts', size: 4 }], + fingerprint: 'fp-123', + usedGitIgnore: false, + } as any); + vi.mocked(packageCurrentBundle).mockResolvedValueOnce({ + path: '/tmp/fake-archive.zip', + size: 4, + sha256: 'hash-123', + fingerprint: 'fp-123', + fileCount: 1, + }); + vi.mocked(uploadPackagedCodebase).mockRejectedValueOnce(new Error('upload failed')); + + await handleFeedbackCommand(feedbackDriver as any); + + expect(harness.auth.submitFeedback).toHaveBeenCalledOnce(); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback ID: 3'); + expect(transcript).toContain('attachment upload failed'); }); it('shows feedback API error messages without replacing them with HTTP status text', async () => { @@ -505,7 +677,8 @@ command = "vim" }, ); const feedbackDriver = driver as unknown as FeedbackDriver; - vi.mocked(promptFeedbackInput).mockImplementation(async () => 'useful feedback'); + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'none'); harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'error', status: 500, diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index a915696817..7ffd6fddef 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -230,7 +230,7 @@ function makeHarness(initialSession: Session) { login: vi.fn(), logout: vi.fn(), getManagedUsage: vi.fn(), - submitFeedback: vi.fn(async () => ({ kind: 'ok' })), + submitFeedback: vi.fn(async () => ({ kind: 'ok', feedbackId: 3 })), }, }; } diff --git a/packages/node-sdk/src/auth.ts b/packages/node-sdk/src/auth.ts index 014954dd73..b2d582ff9a 100644 --- a/packages/node-sdk/src/auth.ts +++ b/packages/node-sdk/src/auth.ts @@ -16,6 +16,8 @@ import { type AuthManagedUsageResult, type AuthStatus, type BearerTokenProvider, + type FetchCompleteFeedbackUploadResult, + type FetchCreateFeedbackUploadUrlResult, type FetchSubmitFeedbackResult, type KimiHostIdentity, type KimiOAuthLoginOptions, @@ -31,6 +33,25 @@ export interface KimiAuthSubmitFeedbackInput { readonly version: string; readonly os: string; readonly model: string | null; + readonly contact?: string; + readonly info?: Record; +} + +export interface KimiAuthCreateFeedbackUploadUrlInput { + readonly feedbackId: number; + readonly filename: string; + readonly size: number; + readonly sha256: string; +} + +export interface KimiAuthCompleteFeedbackUploadPart { + readonly partNumber: number; + readonly etag: string; +} + +export interface KimiAuthCompleteFeedbackUploadInput { + readonly uploadId: number; + readonly parts: readonly KimiAuthCompleteFeedbackUploadPart[]; } export type KimiAuthLoginOptions = Omit; @@ -149,6 +170,46 @@ export class KimiAuthFacade { version: input.version, os: input.os, model: input.model, + contact: input.contact, + info: input.info, + }, + providerName, + { + oauthRef: auth.oauthRef, + baseUrl: auth.baseUrl, + }, + ); + } + + async createFeedbackUploadUrl( + input: KimiAuthCreateFeedbackUploadUrlInput, + providerName?: string | undefined, + ): Promise { + const auth = this.resolveRuntimeManagedAuth(providerName); + return this.toolkit.createFeedbackUploadUrl( + { + file_hash: input.sha256, + file_name: input.filename, + file_size: input.size, + feedback_id: input.feedbackId, + }, + providerName, + { + oauthRef: auth.oauthRef, + baseUrl: auth.baseUrl, + }, + ); + } + + async completeFeedbackUpload( + input: KimiAuthCompleteFeedbackUploadInput, + providerName?: string | undefined, + ): Promise { + const auth = this.resolveRuntimeManagedAuth(providerName); + return this.toolkit.completeFeedbackUpload( + { + upload_id: input.uploadId, + parts: input.parts.map((part) => ({ part_number: part.partNumber, etag: part.etag })), }, providerName, { diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index ca3a6bfe66..df7369bcd9 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -80,6 +80,9 @@ export type { } from '@moonshot-ai/agent-core'; export type { + KimiAuthCompleteFeedbackUploadInput, + KimiAuthCompleteFeedbackUploadPart, + KimiAuthCreateFeedbackUploadUrlInput, KimiAuthLoginResult, KimiAuthLogoutResult, KimiAuthSubmitFeedbackInput, diff --git a/packages/node-sdk/test/auth-facade.test.ts b/packages/node-sdk/test/auth-facade.test.ts index 56d6ade5b5..ca6345af9c 100644 --- a/packages/node-sdk/test/auth-facade.test.ts +++ b/packages/node-sdk/test/auth-facade.test.ts @@ -704,7 +704,10 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev. { status: 200, headers: { 'Content-Type': 'application/json' } }, ); } - return new Response('', { status: 200 }); + return new Response(JSON.stringify({ feedback_id: 3 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); }); vi.stubGlobal('fetch', fetchMock); const harness = createKimiHarness({ homeDir }); @@ -721,7 +724,7 @@ oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev. os: 'Darwin 25.3.0', model: 'kimi-code/kimi-for-coding', }), - ).resolves.toEqual({ kind: 'ok' }); + ).resolves.toEqual({ kind: 'ok', feedbackId: 3 }); expect(fetchMock.mock.calls[0]?.[0]).toBe(`${baseUrl}/usages`); expect(fetchMock.mock.calls[1]?.[0]).toBe(`${baseUrl}/feedback`); @@ -769,7 +772,10 @@ oauth = { storage = "file", key = "${configuredOauthKey}", oauth_host = "https:/ { status: 200, headers: { 'Content-Type': 'application/json' } }, ); } - return new Response('', { status: 200 }); + return new Response(JSON.stringify({ feedback_id: 3 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); }); vi.stubGlobal('fetch', fetchMock); const harness = createKimiHarness({ homeDir }); @@ -802,7 +808,7 @@ oauth = { storage = "file", key = "${configuredOauthKey}", oauth_host = "https:/ os: 'Darwin 25.3.0', model: 'kimi-code/kimi-for-coding', }), - ).resolves.toEqual({ kind: 'ok' }); + ).resolves.toEqual({ kind: 'ok', feedbackId: 3 }); expect(fetchMock.mock.calls[0]?.[0]).toBe(`${envBaseUrl}/usages`); expect(fetchMock.mock.calls[1]?.[0]).toBe(`${envBaseUrl}/feedback`); @@ -813,7 +819,12 @@ oauth = { storage = "file", key = "${configuredOauthKey}", oauth_host = "https:/ it('submitFeedback maps camelCase input to snake_case body and posts with bearer auth', async () => { await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); - const fetchMock = vi.fn(async () => new Response('', { status: 200 })); + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ feedback_id: 3 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); vi.stubGlobal('fetch', fetchMock); const harness = createKimiHarness({ homeDir }); @@ -823,9 +834,11 @@ oauth = { storage = "file", key = "${configuredOauthKey}", oauth_host = "https:/ version: 'kimi-code-0.1.1', os: 'Darwin 25.3.0', model: 'kimi-code/kimi-for-coding', + contact: 'test@example.com', + info: { codebase: { file_name: 'repo.zip' } }, }); - expect(result).toEqual({ kind: 'ok' }); + expect(result).toEqual({ kind: 'ok', feedbackId: 3 }); const calls = fetchMock.mock.calls as unknown as [string, RequestInit?][]; const [url, init] = calls[0]!; @@ -842,6 +855,8 @@ oauth = { storage = "file", key = "${configuredOauthKey}", oauth_host = "https:/ version: 'kimi-code-0.1.1', os: 'Darwin 25.3.0', model: 'kimi-code/kimi-for-coding', + contact: 'test@example.com', + info: { codebase: { file_name: 'repo.zip' } }, }); }); diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 2c515393da..88631bcbfb 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -96,6 +96,21 @@ export type { SubmitFeedbackBody, } from './managed-feedback'; +export { + fetchCompleteFeedbackUpload, + fetchCreateFeedbackUploadUrl, + kimiCodeFeedbackUploadCompleteUrl, + kimiCodeFeedbackUploadUrl, +} from './managed-feedback-upload'; +export type { + CompleteFeedbackUploadBody, + CreateFeedbackUploadUrlBody, + CreateFeedbackUploadUrlResponse, + FetchCompleteFeedbackUploadResult, + FetchCreateFeedbackUploadUrlResult, + FetchFeedbackUploadError, +} from './managed-feedback-upload'; + export { applyOpenPlatformConfig, capabilitiesForModel, diff --git a/packages/oauth/src/managed-feedback-upload.ts b/packages/oauth/src/managed-feedback-upload.ts new file mode 100644 index 0000000000..0ae0c7e37a --- /dev/null +++ b/packages/oauth/src/managed-feedback-upload.ts @@ -0,0 +1,171 @@ +import { readApiErrorMessage } from './api-error'; +import { kimiCodeBaseUrl } from './managed-usage'; + +export interface CreateFeedbackUploadUrlBody { + readonly file_hash: string; + readonly file_name: string; + readonly file_size: number; + readonly feedback_id: number; +} + +export interface FeedbackUploadPart { + readonly part_number: number; + readonly url: string; + readonly method: string; + readonly size: number; +} + +export interface CreateFeedbackUploadUrlResponse { + readonly upload_id: number; + readonly parts: readonly FeedbackUploadPart[]; +} + +export interface CompleteFeedbackUploadPart { + readonly part_number: number; + readonly etag: string; +} + +export interface CompleteFeedbackUploadBody { + readonly upload_id: number; + readonly parts: readonly CompleteFeedbackUploadPart[]; +} + +export interface FetchFeedbackUploadError { + readonly kind: 'error'; + readonly status?: number; + readonly message: string; +} + +export interface FetchCompleteFeedbackUploadOk { + readonly kind: 'ok'; +} + +export type FetchCreateFeedbackUploadUrlResult = + | ({ readonly kind: 'ok' } & CreateFeedbackUploadUrlResponse) + | FetchFeedbackUploadError; + +export type FetchCompleteFeedbackUploadResult = + | FetchCompleteFeedbackUploadOk + | FetchFeedbackUploadError; + +export function kimiCodeFeedbackUploadUrl(baseUrl?: string): string { + return `${feedbackBaseUrl(baseUrl)}/feedback/upload_url`; +} + +export function kimiCodeFeedbackUploadCompleteUrl(baseUrl?: string): string { + return `${feedbackBaseUrl(baseUrl)}/feedback/upload_complete`; +} + +export async function fetchCreateFeedbackUploadUrl( + accessToken: string, + body: CreateFeedbackUploadUrlBody, + opts: { timeoutMs?: number; baseUrl?: string } = {}, +): Promise { + const result = await postJson(kimiCodeFeedbackUploadUrl(opts.baseUrl), accessToken, body, opts); + if (result.kind === 'error') return result; + const parsed = readUpload(result.payload); + if (parsed === undefined) { + return { kind: 'error', message: 'Feedback upload request failed: missing upload id or parts.' }; + } + return { kind: 'ok', upload_id: parsed.uploadId, parts: parsed.parts }; +} + +export async function fetchCompleteFeedbackUpload( + accessToken: string, + body: CompleteFeedbackUploadBody, + opts: { timeoutMs?: number; baseUrl?: string } = {}, +): Promise { + const result = await postJson(kimiCodeFeedbackUploadCompleteUrl(opts.baseUrl), accessToken, body, opts); + if (result.kind === 'error') return result; + return { kind: 'ok' }; +} + +async function postJson( + url: string, + accessToken: string, + body: unknown, + opts: { timeoutMs?: number }, +): Promise<{ readonly kind: 'ok'; readonly payload: unknown } | FetchFeedbackUploadError> { + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(); + }, opts.timeoutMs ?? 8000); + try { + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + if (!res.ok) { + return { + kind: 'error', + status: res.status, + message: await readApiErrorMessage(res, `Feedback upload request failed: HTTP ${res.status}`), + }; + } + const text = await res.text(); + return { kind: 'ok', payload: text.length > 0 ? JSON.parse(text) : {} }; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return { kind: 'error', message: 'Feedback upload request timed out.' }; + } + const msg = error instanceof Error ? error.message : String(error); + return { kind: 'error', message: `Feedback upload request failed: ${msg}` }; + } finally { + clearTimeout(timer); + } +} + +function feedbackBaseUrl(baseUrl?: string): string { + return (baseUrl ?? kimiCodeBaseUrl()).replace(/\/+$/, ''); +} + +function readUpload( + payload: unknown, +): { readonly uploadId: number; readonly parts: FeedbackUploadPart[] } | undefined { + const upload = readRecord(payload, 'upload'); + if (typeof upload !== 'object' || upload === null) return undefined; + const record = upload as Record; + const uploadId = readNumberField(record, 'id'); + const partsRaw = record['parts']; + if (!Array.isArray(partsRaw) || partsRaw.length === 0) return undefined; + const parts: FeedbackUploadPart[] = []; + for (const item of partsRaw) { + const part = readPart(item); + if (part === undefined) return undefined; + parts.push(part); + } + return uploadId === undefined ? undefined : { uploadId, parts }; +} + +function readPart(item: unknown): FeedbackUploadPart | undefined { + if (typeof item !== 'object' || item === null) return undefined; + const record = item as Record; + const partNumber = readNumberField(record, 'part_number'); + const url = readStringField(record, 'url'); + const size = readNumberField(record, 'size'); + if (partNumber === undefined || url === undefined || size === undefined) return undefined; + return { part_number: partNumber, url, method: readStringField(record, 'method') ?? 'PUT', size }; +} + +function readRecord(payload: unknown, key: string): unknown { + if (typeof payload !== 'object' || payload === null) return undefined; + return (payload as Record)[key]; +} + +function readNumberField(payload: unknown, key: string): number | undefined { + if (typeof payload !== 'object' || payload === null) return undefined; + const value = (payload as Record)[key]; + return typeof value === 'number' && Number.isInteger(value) ? value : undefined; +} + +function readStringField(payload: unknown, key: string): string | undefined { + if (typeof payload !== 'object' || payload === null) return undefined; + const value = (payload as Record)[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} diff --git a/packages/oauth/src/managed-feedback.ts b/packages/oauth/src/managed-feedback.ts index 15c5a65cc4..0dcfca0b25 100644 --- a/packages/oauth/src/managed-feedback.ts +++ b/packages/oauth/src/managed-feedback.ts @@ -15,10 +15,13 @@ export interface SubmitFeedbackBody { readonly version: string; readonly os: string; readonly model: string | null; + readonly contact?: string; + readonly info?: Record; } export interface FetchSubmitFeedbackOk { readonly kind: 'ok'; + readonly feedbackId: number; } export interface FetchSubmitFeedbackError { @@ -29,8 +32,8 @@ export interface FetchSubmitFeedbackError { export type FetchSubmitFeedbackResult = FetchSubmitFeedbackOk | FetchSubmitFeedbackError; -export function kimiCodeFeedbackUrl(): string { - return `${kimiCodeBaseUrl().replace(/\/+$/, '')}/feedback`; +export function kimiCodeFeedbackUrl(baseUrl?: string): string { + return `${(baseUrl ?? kimiCodeBaseUrl()).replace(/\/+$/, '')}/feedback`; } export async function fetchSubmitFeedback( @@ -64,7 +67,11 @@ export async function fetchSubmitFeedback( ), }; } - return { kind: 'ok' }; + const feedbackId = parseFeedbackId(await res.json()); + if (feedbackId === undefined) { + return { kind: 'error', message: 'Failed to submit feedback: missing feedback_id.' }; + } + return { kind: 'ok', feedbackId }; } catch (error) { if (error instanceof Error && error.name === 'AbortError') { return { kind: 'error', message: 'Failed to submit feedback: request timed out.' }; @@ -75,3 +82,19 @@ export async function fetchSubmitFeedback( clearTimeout(timer); } } + +function parseFeedbackId(payload: unknown): number | undefined { + const direct = readFeedbackId(payload); + if (direct !== undefined) return direct; + if (typeof payload === 'object' && payload !== null && 'data' in payload) { + return readFeedbackId((payload as { readonly data: unknown }).data); + } + return undefined; +} + +function readFeedbackId(payload: unknown): number | undefined { + if (typeof payload !== 'object' || payload === null) return undefined; + const record = payload as Record; + const value = record['feedback_id'] ?? record['id']; + return typeof value === 'number' && Number.isInteger(value) ? value : undefined; +} diff --git a/packages/oauth/src/toolkit.ts b/packages/oauth/src/toolkit.ts index e5726b677a..28a7a19c37 100644 --- a/packages/oauth/src/toolkit.ts +++ b/packages/oauth/src/toolkit.ts @@ -10,6 +10,14 @@ import { type FetchSubmitFeedbackResult, type SubmitFeedbackBody, } from './managed-feedback'; +import { + fetchCompleteFeedbackUpload, + fetchCreateFeedbackUploadUrl, + type CompleteFeedbackUploadBody, + type CreateFeedbackUploadUrlBody, + type FetchCompleteFeedbackUploadResult, + type FetchCreateFeedbackUploadUrlResult, +} from './managed-feedback-upload'; import { KIMI_CODE_OAUTH_KEY, KIMI_CODE_PROVIDER_NAME, @@ -293,12 +301,27 @@ export class KimiOAuthToolkit { readonly baseUrl?: string | undefined; } = {}, ): Promise { + return this.withAccessToken( + providerName, + options, + (accessToken) => fetchSubmitFeedback(managedFeedbackUrl(options.baseUrl), accessToken, body), + ); + } + + private async withAccessToken( + providerName: string | undefined, + options: { + readonly oauthRef?: KimiOAuthTokenRef | undefined; + readonly baseUrl?: string | undefined; + }, + run: (accessToken: string) => Promise, + ): Promise { const name = providerName ?? KIMI_CODE_PROVIDER_NAME; try { const accessToken = await this.ensureFresh(name, { oauthRef: options.oauthRef ?? this.defaultOAuthRef(options.baseUrl), }); - return await fetchSubmitFeedback(managedFeedbackUrl(options.baseUrl), accessToken, body); + return await run(accessToken); } catch (error) { return { kind: 'error', @@ -307,6 +330,36 @@ export class KimiOAuthToolkit { } } + async createFeedbackUploadUrl( + body: CreateFeedbackUploadUrlBody, + providerName?: string | undefined, + options: { + readonly oauthRef?: KimiOAuthTokenRef | undefined; + readonly baseUrl?: string | undefined; + } = {}, + ): Promise { + return this.withAccessToken( + providerName, + options, + (accessToken) => fetchCreateFeedbackUploadUrl(accessToken, body, { baseUrl: options.baseUrl }), + ); + } + + async completeFeedbackUpload( + body: CompleteFeedbackUploadBody, + providerName?: string | undefined, + options: { + readonly oauthRef?: KimiOAuthTokenRef | undefined; + readonly baseUrl?: string | undefined; + } = {}, + ): Promise { + return this.withAccessToken( + providerName, + options, + (accessToken) => fetchCompleteFeedbackUpload(accessToken, body, { baseUrl: options.baseUrl }), + ); + } + managerFor( providerName: string, oauthKey = KIMI_CODE_OAUTH_KEY, @@ -399,8 +452,7 @@ function managedUsageUrl(baseUrl: string | undefined): string { } function managedFeedbackUrl(baseUrl: string | undefined): string { - if (baseUrl === undefined) return kimiCodeFeedbackUrl(); - return `${baseUrl.replace(/\/+$/, '')}/feedback`; + return kimiCodeFeedbackUrl(baseUrl); } function normalizeOAuthHost(oauthHost: string): string { diff --git a/packages/oauth/test/managed-feedback-upload.test.ts b/packages/oauth/test/managed-feedback-upload.test.ts new file mode 100644 index 0000000000..c33e8c36fd --- /dev/null +++ b/packages/oauth/test/managed-feedback-upload.test.ts @@ -0,0 +1,154 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + fetchCompleteFeedbackUpload, + fetchCreateFeedbackUploadUrl, + kimiCodeFeedbackUploadCompleteUrl, + kimiCodeFeedbackUploadUrl, + type CreateFeedbackUploadUrlBody, +} from '../src/managed-feedback-upload'; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +const SAMPLE_BODY: CreateFeedbackUploadUrlBody = { + file_hash: 'e4d649659ca70729a510ef58f4cd062890020a1038eead5f411451fce62df415', + file_name: 'repo.zip', + file_size: 123, + feedback_id: 3, +}; + +describe('kimiCodeFeedbackUploadUrl', () => { + it('uses the feedback upload_url path', () => { + expect(kimiCodeFeedbackUploadUrl()).toBe('https://api.kimi.com/coding/v1/feedback/upload_url'); + }); +}); + +describe('kimiCodeFeedbackUploadCompleteUrl', () => { + it('uses the feedback upload_complete path', () => { + expect(kimiCodeFeedbackUploadCompleteUrl()).toBe( + 'https://api.kimi.com/coding/v1/feedback/upload_complete', + ); + }); +}); + +describe('fetchCreateFeedbackUploadUrl', () => { + it('POSTs JSON body with bearer auth and parses upload parts', async () => { + const fetchMock = vi.fn(async () => + new Response( + JSON.stringify({ + code: 0, + upload: { + id: 28, + upload_id: 'tos-multipart-id', + part_size: 8, + total_parts: 1, + parts: [ + { part_number: 1, url: 'https://example.test/part1', method: 'PUT', size: 123 }, + ], + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await fetchCreateFeedbackUploadUrl('access-token', SAMPLE_BODY); + + expect(result).toEqual({ + kind: 'ok', + upload_id: 28, + parts: [{ part_number: 1, url: 'https://example.test/part1', method: 'PUT', size: 123 }], + }); + + const calls = fetchMock.mock.calls as unknown as [string, RequestInit?][]; + const [calledUrl, init] = calls[0]!; + expect(calledUrl).toBe('https://api.kimi.com/coding/v1/feedback/upload_url'); + expect(init?.method).toBe('POST'); + + const headers = new Headers((init?.headers ?? {}) as Record); + expect(headers.get('authorization')).toBe('Bearer access-token'); + expect(JSON.parse(init?.body as string)).toEqual(SAMPLE_BODY); + }); + + it('returns an error when the response omits parts', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ code: 0, upload: { id: 28 } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); + + const result = await fetchCreateFeedbackUploadUrl('access-token', SAMPLE_BODY); + + expect(result).toEqual({ + kind: 'error', + message: 'Feedback upload request failed: missing upload id or parts.', + }); + }); + + it('returns an error when a part is missing required fields', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response( + JSON.stringify({ + code: 0, + upload: { id: 28, parts: [{ part_number: 1 }] }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ), + ); + + const result = await fetchCreateFeedbackUploadUrl('access-token', SAMPLE_BODY); + + expect(result).toEqual({ + kind: 'error', + message: 'Feedback upload request failed: missing upload id or parts.', + }); + }); + + it('returns an error with status when the server responds 401', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 401 }))); + + const result = await fetchCreateFeedbackUploadUrl('access-token', SAMPLE_BODY); + + expect(result.kind).toBe('error'); + if (result.kind !== 'error') return; + expect(result.status).toBe(401); + expect(result.message).toMatch(/401/); + }); +}); + +describe('fetchCompleteFeedbackUpload', () => { + it('POSTs upload_id and parts with bearer auth', async () => { + const fetchMock = vi.fn(async () => new Response('{}', { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + const result = await fetchCompleteFeedbackUpload('access-token', { + upload_id: 28, + parts: [ + { part_number: 1, etag: '"etag-1"' }, + { part_number: 2, etag: '"etag-2"' }, + ], + }); + + expect(result).toEqual({ kind: 'ok' }); + const calls = fetchMock.mock.calls as unknown as [string, RequestInit?][]; + const [calledUrl, init] = calls[0]!; + expect(calledUrl).toBe('https://api.kimi.com/coding/v1/feedback/upload_complete'); + expect(JSON.parse(init?.body as string)).toEqual({ + upload_id: 28, + parts: [ + { part_number: 1, etag: '"etag-1"' }, + { part_number: 2, etag: '"etag-2"' }, + ], + }); + }); +}); diff --git a/packages/oauth/test/managed-feedback.test.ts b/packages/oauth/test/managed-feedback.test.ts index 499945c218..9e618c9df3 100644 --- a/packages/oauth/test/managed-feedback.test.ts +++ b/packages/oauth/test/managed-feedback.test.ts @@ -17,6 +17,8 @@ const SAMPLE_BODY: SubmitFeedbackBody = { version: 'kimi-code-0.1.1', os: 'Darwin 25.3.0', model: 'kimi-code/kimi-for-coding', + contact: 'test@example.com', + info: { tool: 'kimi-code-cli', env: 'test' }, }; describe('kimiCodeFeedbackUrl', () => { @@ -31,8 +33,13 @@ describe('kimiCodeFeedbackUrl', () => { }); describe('fetchSubmitFeedback', () => { - it('POSTs JSON body with bearer auth and returns ok on 200', async () => { - const fetchMock = vi.fn(async () => new Response('', { status: 200 })); + it('POSTs JSON body with bearer auth and returns feedback_id on 200', async () => { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ feedback_id: 3 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); vi.stubGlobal('fetch', fetchMock); const result = await fetchSubmitFeedback( @@ -41,7 +48,7 @@ describe('fetchSubmitFeedback', () => { SAMPLE_BODY, ); - expect(result).toEqual({ kind: 'ok' }); + expect(result).toEqual({ kind: 'ok', feedbackId: 3 }); const calls = fetchMock.mock.calls as unknown as [string, RequestInit?][]; const [calledUrl, init] = calls[0]!; @@ -56,8 +63,32 @@ describe('fetchSubmitFeedback', () => { expect(JSON.parse(init?.body as string)).toEqual(SAMPLE_BODY); }); + it('returns an error when the server omits feedback_id', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); + + const result = await fetchSubmitFeedback('https://api.example/feedback', 'access-token', SAMPLE_BODY); + + expect(result).toEqual({ + kind: 'error', + message: 'Failed to submit feedback: missing feedback_id.', + }); + }); + it('preserves the kimi-code- version prefix in the request body', async () => { - const fetchMock = vi.fn(async () => new Response('', { status: 200 })); + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ feedback_id: 3 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); vi.stubGlobal('fetch', fetchMock); await fetchSubmitFeedback('https://api.example/feedback', 'tok', SAMPLE_BODY); From 7c1489e9324e33d09210a390365831d4e19e62db Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 25 Jun 2026 21:36:34 +0800 Subject: [PATCH 2/7] fix(feedback): fall back to logs when codebase scan fails --- .../src/feedback/codebase-upload/packager.ts | 6 ++- .../codebase-upload/codebase-upload.test.ts | 13 ++++- .../test/tui/kimi-tui-message-flow.test.ts | 47 +++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/apps/kimi-code/src/feedback/codebase-upload/packager.ts b/apps/kimi-code/src/feedback/codebase-upload/packager.ts index 2a68734d04..1b2f64f667 100644 --- a/apps/kimi-code/src/feedback/codebase-upload/packager.ts +++ b/apps/kimi-code/src/feedback/codebase-upload/packager.ts @@ -85,7 +85,11 @@ export async function packageBundle( size: file.size, mtimeMs: file.mtimeMs, })); - return packageEntries([...codebaseEntries, ...sessionEntries], archivePath); + const entries = [...codebaseEntries, ...sessionEntries]; + if (entries.length === 0) { + throw new Error('Cannot package an empty feedback bundle.'); + } + return packageEntries(entries, archivePath); } async function packageEntries( diff --git a/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts b/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts index 624a184c09..9c4a47781c 100644 --- a/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts +++ b/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts @@ -7,7 +7,7 @@ import { promisify } from 'node:util'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { scanCodebase, packageSessionFiles, uploadPackagedCodebase } from '../../../src/feedback/codebase-upload'; +import { scanCodebase, packageBundle, packageSessionFiles, uploadPackagedCodebase } from '../../../src/feedback/codebase-upload'; const execFileAsync = promisify(execFile); @@ -193,6 +193,17 @@ describe('packageSessionFiles', () => { }); }); +describe('packageBundle', () => { + it('rejects empty bundles instead of uploading an empty archive', async () => { + const archivePath = join(tmpdir(), 'feedback-empty-bundle.zip'); + try { + await expect(packageBundle({}, archivePath)).rejects.toThrow(/empty/i); + } finally { + await rm(archivePath, { force: true }); + } + }); +}); + describe('scanCodebase filtering', () => { it('rejects when the scan signal is already aborted', async () => { const root = await mkdtemp(join(tmpdir(), 'feedback-scan-aborted-')); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index ebefa839dc..4b691b24f7 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -577,6 +577,53 @@ command = "vim" expect(harness.auth.submitFeedback).toHaveBeenCalledOnce(); }); + it('uploads session logs when codebase scanning fails but the session directory is available', async () => { + const { driver, harness } = await makeDriver( + makeSession(), + { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 100, + provider: 'managed:kimi-code', + }, + }, + })), + }, + ); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(scanCodebase).mockReset(); + vi.mocked(packageCurrentBundle).mockReset(); + vi.mocked(uploadPackagedCodebase).mockReset(); + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + harness.listSessions.mockResolvedValueOnce([{ id: 'ses-1', sessionDir: '/tmp/session-a' } as any]); + const archive = { + path: '/tmp/fake-archive.zip', + size: 4, + sha256: 'hash-123', + fingerprint: 'fp-123', + fileCount: 1, + }; + vi.mocked(scanCodebase).mockRejectedValueOnce(new Error('scan failed')); + vi.mocked(packageCurrentBundle).mockResolvedValueOnce(archive); + + await handleFeedbackCommand(feedbackDriver as any); + + expect(packageCurrentBundle).toHaveBeenCalledWith({ codebase: undefined, sessionDir: '/tmp/session-a' }); + expect(uploadPackagedCodebase).toHaveBeenCalledWith( + expect.any(Object), + archive, + 3, + { filename: 'feedback-bundle.zip' }, + ); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback ID: 3'); + expect(transcript).not.toContain('attachment upload failed'); + }); + it('tells the user when feedback is sent but codebase packaging fails', async () => { const { driver, harness } = await makeDriver( makeSession(), From 70de68b6557639d7a51cf2d784d2e7040b50d66e Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 26 Jun 2026 10:53:22 +0800 Subject: [PATCH 3/7] tiny fix --- .changeset/feedback-attachments.md | 6 +++ .../src/feedback/codebase-upload/attach.ts | 2 +- .../src/feedback/codebase-upload/filter.ts | 4 ++ .../src/feedback/codebase-upload/types.ts | 1 + apps/kimi-code/src/tui/commands/info.ts | 1 + .../codebase-upload/codebase-upload.test.ts | 50 +++++++++++++++++-- 6 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 .changeset/feedback-attachments.md diff --git a/.changeset/feedback-attachments.md b/.changeset/feedback-attachments.md new file mode 100644 index 0000000000..4c5589db19 --- /dev/null +++ b/.changeset/feedback-attachments.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": minor +"@moonshot-ai/kimi-code-sdk": minor +--- + +Add optional feedback attachments for diagnostic logs and codebase context. diff --git a/apps/kimi-code/src/feedback/codebase-upload/attach.ts b/apps/kimi-code/src/feedback/codebase-upload/attach.ts index 3cb3030009..bca998d8cb 100644 --- a/apps/kimi-code/src/feedback/codebase-upload/attach.ts +++ b/apps/kimi-code/src/feedback/codebase-upload/attach.ts @@ -176,7 +176,7 @@ async function uploadOnePart( const stream = createReadStream(filePath, { start, end: start + part.size - 1 }); try { const res = await fetch(part.url, { - method: 'PUT', + method: part.method, body: Readable.toWeb(stream), headers: { 'Content-Length': String(part.size) }, duplex: 'half', diff --git a/apps/kimi-code/src/feedback/codebase-upload/filter.ts b/apps/kimi-code/src/feedback/codebase-upload/filter.ts index 902e7be8a0..1df5976e91 100644 --- a/apps/kimi-code/src/feedback/codebase-upload/filter.ts +++ b/apps/kimi-code/src/feedback/codebase-upload/filter.ts @@ -50,6 +50,10 @@ const SENSITIVE_FILE_NAMES: ReadonlySet = new Set([ '.netrc', '.htpasswd', '.pypirc', + '.npmrc', + '.envrc', + '.yarnrc', + '.yarnrc.yml', ]); const SENSITIVE_FILE_SUFFIXES: readonly string[] = [ diff --git a/apps/kimi-code/src/feedback/codebase-upload/types.ts b/apps/kimi-code/src/feedback/codebase-upload/types.ts index 18447ddc7a..5377ed834c 100644 --- a/apps/kimi-code/src/feedback/codebase-upload/types.ts +++ b/apps/kimi-code/src/feedback/codebase-upload/types.ts @@ -36,6 +36,7 @@ export interface CreateFeedbackUploadUrlInput { export interface FeedbackUploadPart { readonly partNumber: number; readonly url: string; + readonly method: string; readonly size: number; } diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index a6f5d71d77..c55a9f2420 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -187,6 +187,7 @@ function createFeedbackUploadApi(host: SlashCommandHost): FeedbackUploadUrlApi { parts: res.parts.map((part) => ({ partNumber: part.part_number, url: part.url, + method: part.method, size: part.size, })), }; diff --git a/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts b/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts index 9c4a47781c..cb2701ebe3 100644 --- a/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts +++ b/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts @@ -29,7 +29,7 @@ describe('uploadPackagedCodebase', () => { const api = { createUploadUrl: vi.fn(async () => ({ uploadId: 28, - parts: [{ partNumber: 1, url: 'https://example.test/part1', size: 5 }], + parts: [{ partNumber: 1, url: 'https://example.test/part1', method: 'PUT', size: 5 }], })), completeUpload: vi.fn(async () => {}), }; @@ -71,6 +71,44 @@ describe('uploadPackagedCodebase', () => { } }); + it('uses the backend-provided part upload method', async () => { + const workRoot = await mkdtemp(join(tmpdir(), 'feedback-upload-method-')); + const archivePath = join(workRoot, 'repo.zip'); + await writeFile(archivePath, 'hello'); + + const fetchMock = vi.fn( + async () => new Response('', { status: 200, headers: { ETag: '"etag-1"' } }), + ); + vi.stubGlobal('fetch', fetchMock); + const api = { + createUploadUrl: vi.fn(async () => ({ + uploadId: 28, + parts: [{ partNumber: 1, url: 'https://example.test/part1', method: 'POST', size: 5 }], + })), + completeUpload: vi.fn(async () => {}), + }; + + try { + await uploadPackagedCodebase( + api, + { + path: archivePath, + size: 5, + sha256: 'hash', + fingerprint: 'fingerprint', + fileCount: 1, + }, + 3, + ); + + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(init.method).toBe('POST'); + expect(await new Response(init.body as ReadableStream).text()).toBe('hello'); + } finally { + await rm(workRoot, { recursive: true, force: true }); + } + }); + it('aborts a stalled part PUT and does not mark upload complete', async () => { const workRoot = await mkdtemp(join(tmpdir(), 'feedback-upload-stalled-')); const archivePath = join(workRoot, 'repo.zip'); @@ -94,7 +132,7 @@ describe('uploadPackagedCodebase', () => { const api = { createUploadUrl: vi.fn(async () => ({ uploadId: 28, - parts: [{ partNumber: 1, url: 'https://example.test/part1', size: 5 }], + parts: [{ partNumber: 1, url: 'https://example.test/part1', method: 'PUT', size: 5 }], })), completeUpload: vi.fn(async () => {}), }; @@ -138,7 +176,7 @@ describe('uploadPackagedCodebase', () => { const api = { createUploadUrl: vi.fn(async () => ({ uploadId: 28, - parts: [{ partNumber: 1, url: 'https://example.test/part1', size: 5 }], + parts: [{ partNumber: 1, url: 'https://example.test/part1', method: 'PUT', size: 5 }], })), completeUpload: vi.fn(async () => {}), }; @@ -239,6 +277,9 @@ describe('scanCodebase filtering', () => { const root = await mkdtemp(join(tmpdir(), 'feedback-scan-git-')); try { await writeFile(join(root, '.env'), 'SECRET=1\n'); + await writeFile(join(root, '.envrc'), 'export AWS_SECRET_ACCESS_KEY=secret\n'); + await writeFile(join(root, '.npmrc'), '//registry.npmjs.org/:_authToken=secret\n'); + await writeFile(join(root, '.yarnrc.yml'), 'npmAuthToken: secret\n'); await writeFile(join(root, 'id_rsa'), 'private-key\n'); await writeFile(join(root, 'app.ts'), 'export const app = 1;\n'); await execFileAsync('git', ['init'], { cwd: root }); @@ -249,6 +290,9 @@ describe('scanCodebase filtering', () => { const paths = scan.files.map((file) => file.path); expect(paths).toContain('app.ts'); expect(paths).not.toContain('.env'); + expect(paths).not.toContain('.envrc'); + expect(paths).not.toContain('.npmrc'); + expect(paths).not.toContain('.yarnrc.yml'); expect(paths).not.toContain('id_rsa'); } finally { await rm(root, { recursive: true, force: true }); From 25dc3d9929671a595ab7f7771f8946e4f0d4bf2d Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 26 Jun 2026 13:15:22 +0800 Subject: [PATCH 4/7] fix(feedback): make diagnostic uploads partial-safe --- .../src/feedback/codebase-upload/attach.ts | 88 ++++++---- .../src/feedback/codebase-upload/packager.ts | 105 +++++------- .../src/feedback/codebase-upload/types.ts | 2 + apps/kimi-code/src/tui/commands/info.ts | 96 +++++++---- .../codebase-upload/codebase-upload.test.ts | 73 +++++++- .../test/tui/kimi-tui-message-flow.test.ts | 158 ++++++++++++++---- 6 files changed, 352 insertions(+), 170 deletions(-) diff --git a/apps/kimi-code/src/feedback/codebase-upload/attach.ts b/apps/kimi-code/src/feedback/codebase-upload/attach.ts index bca998d8cb..488809e46f 100644 --- a/apps/kimi-code/src/feedback/codebase-upload/attach.ts +++ b/apps/kimi-code/src/feedback/codebase-upload/attach.ts @@ -1,10 +1,10 @@ import { createReadStream } from 'node:fs'; -import { mkdir, rm } from 'node:fs/promises'; +import { mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { Readable } from 'node:stream'; import { getCacheDir } from '../../utils/paths'; -import { packageBundle, packageCodebase, packageSessionFiles, type PackageBundleInput } from './packager'; +import { packageCodebase, packageSessionFiles } from './packager'; import type { CompletedUploadPart, FeedbackCodebaseArchive, @@ -15,9 +15,9 @@ import type { export const CODEBASE_ARCHIVE_FILENAME = 'repo.zip'; export const SESSION_ARCHIVE_FILENAME = 'session.zip'; -export const BUNDLE_ARCHIVE_FILENAME = 'feedback-bundle.zip'; const MAX_ARCHIVE_SIZE = 524_288_000; // 500 MiB, matches the backend limit. +const STALE_ARCHIVE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours. const DEFAULT_CONCURRENCY = 3; const DEFAULT_MAX_RETRIES = 3; const DEFAULT_PART_TIMEOUT_MS = 60_000; @@ -26,25 +26,22 @@ const RETRY_BASE_DELAY_MS = 1_000; export async function packageCurrentCodebase( scan: FeedbackCodebaseScanResult, ): Promise { - const archivePath = defaultArchivePath(CODEBASE_ARCHIVE_FILENAME, scan.fingerprint); - await mkdir(dirname(archivePath), { recursive: true }); - return packageCodebase(scan, archivePath); + const archivePath = await createArchivePath(CODEBASE_ARCHIVE_FILENAME); + const archive = await packageCodebase(scan, archivePath); + return { ...archive, cleanupDir: archivePathCleanupDir(archivePath) }; } export async function packageCurrentSession(sessionDir: string): Promise { - const archivePath = defaultArchivePath(SESSION_ARCHIVE_FILENAME, sessionDir); - await mkdir(dirname(archivePath), { recursive: true }); - return packageSessionFiles(sessionDir, archivePath); -} - -export async function packageCurrentBundle(input: PackageBundleInput): Promise { - const fingerprint = `${input.codebase?.fingerprint ?? 'none'}-${input.sessionDir ?? 'none'}`; - const archivePath = defaultArchivePath(BUNDLE_ARCHIVE_FILENAME, fingerprint); - await mkdir(dirname(archivePath), { recursive: true }); - return packageBundle(input, archivePath); + const archivePath = await createArchivePath(SESSION_ARCHIVE_FILENAME); + const archive = await packageSessionFiles(sessionDir, archivePath); + return { ...archive, cleanupDir: archivePathCleanupDir(archivePath) }; } export async function removePackagedCodebaseArchive(archive: FeedbackCodebaseArchive): Promise { + if (archive.cleanupDir !== undefined) { + await rm(archive.cleanupDir, { recursive: true, force: true }); + return; + } await rm(archive.path, { force: true }); } @@ -82,22 +79,6 @@ export async function uploadPackagedCodebase( await api.completeUpload({ uploadId: created.uploadId, parts: completed }); } -export function buildFeedbackCodebaseInfo( - scan: FeedbackCodebaseScanResult, - archive: FeedbackCodebaseArchive, -): Record { - return { - codebase: { - file_name: CODEBASE_ARCHIVE_FILENAME, - file_size: archive.size, - sha256: archive.sha256, - fingerprint: archive.fingerprint, - file_count: archive.fileCount, - truncated: scan.exceedsLimit !== undefined, - }, - }; -} - interface PartLayout { readonly part: FeedbackUploadPart; readonly start: number; @@ -228,6 +209,45 @@ function sleep(ms: number): Promise { }); } -function defaultArchivePath(filename: string, fingerprint: string): string { - return join(getCacheDir(), 'feedback-uploads', fingerprint.slice(0, 12), filename); +/** + * Remove feedback-upload archive directories older than 24 hours. Packaging + * cleans up its own archive on success and on failure, but a killed process + * or an empty parent dir can still leave leftovers behind; this is a + * best-effort backstop so the cache dir does not grow without bound. + * + * `dir` is injectable for tests; production callers leave it as the default. + */ +export async function removeStaleFeedbackUploads( + options: { readonly now?: number; readonly dir?: string } = {}, +): Promise { + const now = options.now ?? Date.now(); + const dir = options.dir ?? join(getCacheDir(), 'feedback-uploads'); + const entries = await readdir(dir, { withFileTypes: true }).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + }); + if (entries === null) return; + + const cutoff = now - STALE_ARCHIVE_MAX_AGE_MS; + await Promise.all( + entries.map(async (entry) => { + if (!entry.isDirectory() && !entry.isSymbolicLink()) return; + const target = join(dir, entry.name); + const targetStat = await stat(target).catch(() => null); + if (targetStat === null || targetStat.mtimeMs >= cutoff) return; + await rm(target, { recursive: true, force: true }).catch(() => {}); + }), + ); +} + +async function createArchivePath(filename: string): Promise { + await removeStaleFeedbackUploads(); + const root = join(getCacheDir(), 'feedback-uploads'); + await mkdir(root, { recursive: true }); + const dir = await mkdtemp(join(root, 'upload-')); + return join(dir, filename); +} + +function archivePathCleanupDir(archivePath: string): string { + return dirname(archivePath); } diff --git a/apps/kimi-code/src/feedback/codebase-upload/packager.ts b/apps/kimi-code/src/feedback/codebase-upload/packager.ts index 1b2f64f667..0bf2828739 100644 --- a/apps/kimi-code/src/feedback/codebase-upload/packager.ts +++ b/apps/kimi-code/src/feedback/codebase-upload/packager.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; import { createWriteStream } from 'node:fs'; -import { lstat, mkdir, readdir, stat } from 'node:fs/promises'; +import { lstat, mkdir, readdir, rm, stat } from 'node:fs/promises'; import { dirname, join, relative } from 'node:path'; import { ZipFile } from 'yazl'; @@ -18,13 +18,6 @@ interface PackageEntry { readonly mtimeMs: number; } -export interface PackageBundleInput { - /** Codebase files are placed under the `codebase/` prefix in the zip. */ - readonly codebase?: FeedbackCodebaseScanResult; - /** Session directory files are placed under the `session/` prefix in the zip. */ - readonly sessionDir?: string; -} - /** * Pack the scanned codebase into a zip, with files placed at the zip root. */ @@ -59,77 +52,57 @@ export async function packageSessionFiles( return packageEntries(entries, archivePath); } -/** - * Pack a merged bundle: codebase under `codebase/` and session files under - * `session/`. Used when the user opts to upload both logs and codebase. - */ -export async function packageBundle( - input: PackageBundleInput, - archivePath: string, -): Promise { - const codebaseEntries: PackageEntry[] = - input.codebase === undefined - ? [] - : input.codebase.files.map((file) => ({ - absolutePath: file.absolutePath, - archivePath: `codebase/${file.path}`, - size: file.size, - mtimeMs: file.mtimeMs, - })); - const sessionEntries: PackageEntry[] = - input.sessionDir === undefined - ? [] - : (await collectDirFiles(input.sessionDir)).map((file) => ({ - absolutePath: file.absolutePath, - archivePath: `session/${file.path}`, - size: file.size, - mtimeMs: file.mtimeMs, - })); - const entries = [...codebaseEntries, ...sessionEntries]; - if (entries.length === 0) { - throw new Error('Cannot package an empty feedback bundle.'); - } - return packageEntries(entries, archivePath); -} - async function packageEntries( entries: readonly PackageEntry[], archivePath: string, ): Promise { + if (entries.length === 0) { + throw new Error('Cannot package an empty feedback archive.'); + } await mkdir(dirname(archivePath), { recursive: true }); const zip = new ZipFile(); const hash = createHash('sha256'); const output = createWriteStream(archivePath); - const done = new Promise((resolvePromise, rejectPromise) => { - output.on('finish', resolvePromise); - output.on('error', rejectPromise); - zip.outputStream.on('error', rejectPromise); - }); - - zip.outputStream.on('data', (chunk: Buffer) => { - hash.update(chunk); - }); - zip.outputStream.pipe(output); + try { + const done = new Promise((resolvePromise, rejectPromise) => { + output.on('finish', resolvePromise); + output.on('error', rejectPromise); + zip.outputStream.on('error', rejectPromise); + }); - for (const entry of entries) { - zip.addFile(entry.absolutePath, entry.archivePath, { - mtime: new Date(entry.mtimeMs), - mode: 0o100644, + zip.outputStream.on('data', (chunk: Buffer) => { + hash.update(chunk); }); + zip.outputStream.pipe(output); + + for (const entry of entries) { + zip.addFile(entry.absolutePath, entry.archivePath, { + mtime: new Date(entry.mtimeMs), + mode: 0o100644, + }); + } + zip.end(); + await done; + + const archiveStat = await stat(archivePath); + return { + path: archivePath, + size: archiveStat.size, + sha256: hash.digest('hex'), + fingerprint: fingerprintEntries(entries), + fileCount: entries.length, + }; + } catch (error) { + // A failed zip (e.g. a source file vanished or became unreadable between + // scan and packaging) would otherwise leave a partial archive behind in + // the cache dir. Destroy the stream so the handle is released before we + // remove the file, then best-effort delete it. + output.destroy(); + await rm(archivePath, { force: true }).catch(() => {}); + throw error; } - zip.end(); - await done; - - const archiveStat = await stat(archivePath); - return { - path: archivePath, - size: archiveStat.size, - sha256: hash.digest('hex'), - fingerprint: fingerprintEntries(entries), - fileCount: entries.length, - }; } async function collectDirFiles(dir: string): Promise { diff --git a/apps/kimi-code/src/feedback/codebase-upload/types.ts b/apps/kimi-code/src/feedback/codebase-upload/types.ts index 5377ed834c..ef8ec3324e 100644 --- a/apps/kimi-code/src/feedback/codebase-upload/types.ts +++ b/apps/kimi-code/src/feedback/codebase-upload/types.ts @@ -24,6 +24,8 @@ export interface FeedbackCodebaseArchive { readonly sha256: string; readonly fingerprint: string; readonly fileCount: number; + /** Directory created exclusively for this archive and safe to remove after upload. */ + readonly cleanupDir?: string; } export interface CreateFeedbackUploadUrlInput { diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index c55a9f2420..6556e9dd68 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -14,7 +14,6 @@ import { FEEDBACK_STATUS_NOT_SIGNED_IN, FEEDBACK_STATUS_SUBMITTING, FEEDBACK_STATUS_SUCCESS, - FEEDBACK_STATUS_UPLOADING, FEEDBACK_STATUS_UPLOAD_FAILED, FEEDBACK_TELEMETRY_EVENT, feedbackIdLine, @@ -25,19 +24,19 @@ import { isManagedUsageProvider } from '../constant/kimi-tui'; import { formatErrorMessage } from '../utils/event-payload'; import { getLogDir } from '#/utils/paths'; import { - packageCurrentBundle, + packageCurrentCodebase, packageCurrentSession, removePackagedCodebaseArchive, scanCodebase, uploadPackagedCodebase, - BUNDLE_ARCHIVE_FILENAME, + CODEBASE_ARCHIVE_FILENAME, SESSION_ARCHIVE_FILENAME, type FeedbackCodebaseArchive, type FeedbackCodebaseScanResult, type FeedbackUploadUrlApi, } from '../../feedback/codebase-upload'; import { openUrl } from '#/utils/open-url'; -import { promptFeedbackAttachment, promptFeedbackInput, type FeedbackAttachmentLevel } from './prompts'; +import { promptFeedbackAttachment, promptFeedbackInput } from './prompts'; import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- @@ -89,52 +88,79 @@ export async function handleFeedbackCommand(host: SlashCommandHost): Promise, + api: FeedbackUploadUrlApi, feedbackId: number, -): Promise { - const api = createFeedbackUploadApi(host); + knownSessionDir?: string, +): Promise { + const sessionDir = knownSessionDir ?? (await resolveCurrentSessionDir(host)); + if (sessionDir === undefined) { + await logFeedbackUploadError(new Error('cannot locate the current session directory')); + return false; + } + let archive: FeedbackCodebaseArchive | undefined; try { - if (level === 'logs') { - const sessionDir = await resolveCurrentSessionDir(host); - if (sessionDir === undefined) { - throw new Error('cannot locate the current session directory'); - } - archive = await packageCurrentSession(sessionDir); - await uploadPackagedCodebase(api, archive, feedbackId, { filename: SESSION_ARCHIVE_FILENAME }); - } else { - const [sessionDir, scan] = await Promise.all([ - resolveCurrentSessionDir(host), - scanCodebaseForFeedback(host.state.appState.workDir), - ]); - archive = await packageCurrentBundle({ codebase: scan, sessionDir }); - await uploadPackagedCodebase(api, archive, feedbackId, { filename: BUNDLE_ARCHIVE_FILENAME }); + archive = await packageCurrentSession(sessionDir); + await uploadPackagedCodebase(api, archive, feedbackId, { filename: SESSION_ARCHIVE_FILENAME }); + return true; + } catch (error) { + await logFeedbackUploadError(error); + return false; + } finally { + if (archive !== undefined) { + await removePackagedCodebaseArchive(archive).catch(() => {}); } + } +} + +async function prepareAndUploadCodebaseArchive( + api: FeedbackUploadUrlApi, + feedbackId: number, + scan: FeedbackCodebaseScanResult | undefined, +): Promise { + if (scan === undefined) return false; + + let archive: FeedbackCodebaseArchive | undefined; + try { + archive = await packageCurrentCodebase(scan); + await uploadPackagedCodebase(api, archive, feedbackId, { filename: CODEBASE_ARCHIVE_FILENAME }); + return true; + } catch (error) { + await logFeedbackUploadError(error); + return false; } finally { if (archive !== undefined) { await removePackagedCodebaseArchive(archive).catch(() => {}); diff --git a/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts b/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts index cb2701ebe3..609e5cd98d 100644 --- a/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts +++ b/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts @@ -1,13 +1,19 @@ import { execFile } from 'node:child_process'; import { randomBytes } from 'node:crypto'; -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, mkdir, rm, stat, utimes, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { scanCodebase, packageBundle, packageSessionFiles, uploadPackagedCodebase } from '../../../src/feedback/codebase-upload'; +import { + packageCodebase, + packageSessionFiles, + removeStaleFeedbackUploads, + scanCodebase, + uploadPackagedCodebase, +} from '../../../src/feedback/codebase-upload'; const execFileAsync = promisify(execFile); @@ -209,6 +215,28 @@ describe('uploadPackagedCodebase', () => { }); }); +describe('packageCodebase', () => { + it('rejects empty codebase archives instead of uploading an empty zip', async () => { + const archivePath = join(tmpdir(), 'feedback-empty-codebase.zip'); + try { + await expect( + packageCodebase( + { + root: tmpdir(), + files: [], + fingerprint: 'empty-codebase', + usedGitIgnore: false, + }, + archivePath, + ), + ).rejects.toThrow(/empty/i); + await expect(stat(archivePath)).rejects.toThrow(); + } finally { + await rm(archivePath, { force: true }); + } + }); +}); + describe('packageSessionFiles', () => { it('recursively packs every file under the session directory', async () => { const sessionDir = await mkdtemp(join(tmpdir(), 'feedback-session-')); @@ -229,14 +257,15 @@ describe('packageSessionFiles', () => { await rm(archivePath, { force: true }); } }); -}); -describe('packageBundle', () => { - it('rejects empty bundles instead of uploading an empty archive', async () => { - const archivePath = join(tmpdir(), 'feedback-empty-bundle.zip'); + it('rejects empty session archives instead of uploading an empty zip', async () => { + const sessionDir = await mkdtemp(join(tmpdir(), 'feedback-empty-session-')); + const archivePath = join(tmpdir(), 'feedback-empty-session.zip'); try { - await expect(packageBundle({}, archivePath)).rejects.toThrow(/empty/i); + await expect(packageSessionFiles(sessionDir, archivePath)).rejects.toThrow(/empty/i); + await expect(stat(archivePath)).rejects.toThrow(); } finally { + await rm(sessionDir, { recursive: true, force: true }); await rm(archivePath, { force: true }); } }); @@ -378,3 +407,33 @@ describe('scanCodebase filtering', () => { } }); }); + +describe('removeStaleFeedbackUploads', () => { + it('removes archive dirs older than the cutoff and keeps recent ones', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-uploads-gc-')); + try { + const staleDir = join(root, 'stale'); + const freshDir = join(root, 'fresh'); + await mkdir(staleDir); + await mkdir(freshDir); + await writeFile(join(staleDir, 'repo.zip'), 'old'); + await writeFile(join(freshDir, 'repo.zip'), 'new'); + + const now = Date.now(); + const twoDaysAgoSec = (now - 2 * 24 * 60 * 60 * 1000) / 1000; + await utimes(staleDir, twoDaysAgoSec, twoDaysAgoSec); + + await removeStaleFeedbackUploads({ now, dir: root }); + + await expect(stat(staleDir)).rejects.toThrow(); + await expect(stat(freshDir)).resolves.toBeDefined(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('is a no-op when the cache dir does not exist', async () => { + const missing = join(tmpdir(), 'feedback-uploads-gc-missing-' + String(Date.now())); + await expect(removeStaleFeedbackUploads({ dir: missing })).resolves.toBeUndefined(); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 4b691b24f7..ff42e31ca2 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -32,7 +32,6 @@ import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui import type { StreamingUIController } from '#/tui/controllers/streaming-ui'; import { handleFeedbackCommand } from '#/tui/commands/info'; import { - packageCurrentBundle, packageCurrentCodebase, packageCurrentSession, scanCodebase, @@ -62,7 +61,6 @@ vi.mock('../../src/feedback/codebase-upload', async (importOriginal) => { ...actual, scanCodebase: vi.fn().mockResolvedValue(undefined), packageCurrentCodebase: vi.fn(), - packageCurrentBundle: vi.fn(), packageCurrentSession: vi.fn(), uploadPackagedCodebase: vi.fn(), }; @@ -520,6 +518,60 @@ command = "vim" expect(transcript).toContain('Feedback ID: 3'); }); + it('submits text feedback before preparing requested attachments', async () => { + const { driver, harness } = await makeDriver( + makeSession(), + { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 100, + provider: 'managed:kimi-code', + }, + }, + })), + }, + ); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + harness.listSessions.mockResolvedValueOnce([{ id: 'ses-1', sessionDir: '/tmp/session-a' }] as never); + + const archive = { + path: '/tmp/fake-session.zip', + size: 4, + sha256: 'hash-session', + fingerprint: 'fp-session', + fileCount: 1, + }; + let resolvePackage!: (value: typeof archive) => void; + const packageBlocked = new Promise((resolve) => { + resolvePackage = resolve; + }); + vi.mocked(packageCurrentSession).mockImplementationOnce(() => packageBlocked); + + let settled = false; + const command = handleFeedbackCommand(feedbackDriver as any).then(() => { + settled = true; + }); + + await vi.waitFor(() => { + expect(packageCurrentSession).toHaveBeenCalledWith('/tmp/session-a'); + }); + expect(harness.auth.submitFeedback).toHaveBeenCalledWith( + expect.objectContaining({ content: 'useful feedback' }), + ); + expect(harness.auth.submitFeedback.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(packageCurrentSession).mock.invocationCallOrder[0]!, + ); + expect(settled).toBe(false); + + resolvePackage(archive); + await command; + }); + it('waits for the codebase upload to finish before returning', async () => { const { driver, harness } = await makeDriver( makeSession(), @@ -536,9 +588,16 @@ command = "vim" }, ); const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(scanCodebase).mockReset(); + vi.mocked(packageCurrentSession).mockReset(); + vi.mocked(packageCurrentCodebase).mockReset(); + vi.mocked(uploadPackagedCodebase).mockReset(); vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + harness.listSessions.mockResolvedValueOnce([ + { id: 'ses-1', sessionDir: '/tmp/session-a' }, + ] as never); vi.mocked(scanCodebase).mockResolvedValueOnce({ root: '/tmp/proj-a', @@ -546,35 +605,61 @@ command = "vim" fingerprint: 'fp-123', usedGitIgnore: false, } as any); - vi.mocked(packageCurrentBundle).mockResolvedValueOnce({ - path: '/tmp/fake-archive.zip', + vi.mocked(packageCurrentSession).mockResolvedValueOnce({ + path: '/tmp/fake-session.zip', + size: 4, + sha256: 'hash-session', + fingerprint: 'fp-session', + fileCount: 1, + }); + vi.mocked(packageCurrentCodebase).mockResolvedValueOnce({ + path: '/tmp/fake-codebase.zip', size: 4, sha256: 'hash-123', fingerprint: 'fp-123', fileCount: 1, }); - let resolveUpload!: () => void; - vi.mocked(uploadPackagedCodebase).mockImplementationOnce( - () => new Promise((resolve) => { resolveUpload = resolve; }), - ); + let resolveCodebaseUpload!: () => void; + const codebaseUploadBlocked = new Promise((resolve) => { + resolveCodebaseUpload = resolve; + }); + vi.mocked(uploadPackagedCodebase).mockImplementation((_api, archive) => { + if (archive.path === '/tmp/fake-session.zip') return Promise.resolve(); + return codebaseUploadBlocked; + }); let settled = false; const command = handleFeedbackCommand(feedbackDriver as any).then(() => { settled = true; }); - // The command reaches the upload and blocks on it; it must not return - // while the upload is still in flight. await vi.waitFor(() => { - expect(uploadPackagedCodebase).toHaveBeenCalledOnce(); + expect(uploadPackagedCodebase).toHaveBeenCalledTimes(2); }); expect(settled).toBe(false); - resolveUpload(); + resolveCodebaseUpload(); await command; expect(settled).toBe(true); - expect(harness.auth.submitFeedback).toHaveBeenCalledOnce(); + // Session archive is uploaded first (small/reliable), then the codebase. + expect(uploadPackagedCodebase).toHaveBeenNthCalledWith( + 1, + expect.any(Object), + expect.objectContaining({ path: '/tmp/fake-session.zip' }), + 3, + { filename: 'session.zip' }, + ); + expect(uploadPackagedCodebase).toHaveBeenNthCalledWith( + 2, + expect.any(Object), + expect.objectContaining({ path: '/tmp/fake-codebase.zip' }), + 3, + { filename: 'repo.zip' }, + ); + expect(harness.auth.submitFeedback).toHaveBeenCalledWith( + expect.not.objectContaining({ info: expect.anything() }), + ); }); it('uploads session logs when codebase scanning fails but the session directory is available', async () => { @@ -594,34 +679,36 @@ command = "vim" ); const feedbackDriver = driver as unknown as FeedbackDriver; vi.mocked(scanCodebase).mockReset(); - vi.mocked(packageCurrentBundle).mockReset(); + vi.mocked(packageCurrentSession).mockReset(); + vi.mocked(packageCurrentCodebase).mockReset(); vi.mocked(uploadPackagedCodebase).mockReset(); vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); - harness.listSessions.mockResolvedValueOnce([{ id: 'ses-1', sessionDir: '/tmp/session-a' } as any]); + harness.listSessions.mockResolvedValueOnce([{ id: 'ses-1', sessionDir: '/tmp/session-a' }] as never); const archive = { - path: '/tmp/fake-archive.zip', + path: '/tmp/fake-session.zip', size: 4, - sha256: 'hash-123', - fingerprint: 'fp-123', + sha256: 'hash-session', + fingerprint: 'fp-session', fileCount: 1, }; vi.mocked(scanCodebase).mockRejectedValueOnce(new Error('scan failed')); - vi.mocked(packageCurrentBundle).mockResolvedValueOnce(archive); + vi.mocked(packageCurrentSession).mockResolvedValueOnce(archive); await handleFeedbackCommand(feedbackDriver as any); - expect(packageCurrentBundle).toHaveBeenCalledWith({ codebase: undefined, sessionDir: '/tmp/session-a' }); + expect(packageCurrentSession).toHaveBeenCalledWith('/tmp/session-a'); + expect(packageCurrentCodebase).not.toHaveBeenCalled(); expect(uploadPackagedCodebase).toHaveBeenCalledWith( expect.any(Object), archive, 3, - { filename: 'feedback-bundle.zip' }, + { filename: 'session.zip' }, ); const transcript = stripSgr(renderTranscript(driver)); expect(transcript).toContain('Feedback ID: 3'); - expect(transcript).not.toContain('attachment upload failed'); + expect(transcript).toContain('attachment upload failed'); }); it('tells the user when feedback is sent but codebase packaging fails', async () => { @@ -641,11 +728,20 @@ command = "vim" ); const feedbackDriver = driver as unknown as FeedbackDriver; vi.mocked(scanCodebase).mockReset(); - vi.mocked(packageCurrentBundle).mockReset(); + vi.mocked(packageCurrentCodebase).mockReset(); + vi.mocked(packageCurrentSession).mockReset(); vi.mocked(uploadPackagedCodebase).mockReset(); vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + harness.listSessions.mockResolvedValueOnce([{ id: 'ses-1', sessionDir: '/tmp/session-a' }] as never); + const sessionArchive = { + path: '/tmp/fake-session.zip', + size: 4, + sha256: 'hash-session', + fingerprint: 'fp-session', + fileCount: 1, + }; vi.mocked(scanCodebase).mockResolvedValueOnce({ root: '/tmp/proj-a', @@ -653,13 +749,19 @@ command = "vim" fingerprint: 'fp-123', usedGitIgnore: false, } as any); - vi.mocked(packageCurrentBundle).mockRejectedValueOnce(new Error('zip failed')); + vi.mocked(packageCurrentSession).mockResolvedValueOnce(sessionArchive); + vi.mocked(packageCurrentCodebase).mockRejectedValueOnce(new Error('zip failed')); await handleFeedbackCommand(feedbackDriver as any); const calls = harness.auth.submitFeedback.mock.calls as unknown as Array<[Record]>; - expect(calls[0]?.[0]).not.toHaveProperty('info'); - expect(uploadPackagedCodebase).not.toHaveBeenCalled(); + expect(calls[0]?.[0]?.['info']).toBeUndefined(); + expect(uploadPackagedCodebase).toHaveBeenCalledWith( + expect.any(Object), + sessionArchive, + 3, + { filename: 'session.zip' }, + ); const transcript = stripSgr(renderTranscript(driver)); expect(transcript).toContain('Feedback ID: 3'); expect(transcript).toContain('attachment upload failed'); @@ -691,8 +793,8 @@ command = "vim" fingerprint: 'fp-123', usedGitIgnore: false, } as any); - vi.mocked(packageCurrentBundle).mockResolvedValueOnce({ - path: '/tmp/fake-archive.zip', + vi.mocked(packageCurrentCodebase).mockResolvedValueOnce({ + path: '/tmp/fake-codebase.zip', size: 4, sha256: 'hash-123', fingerprint: 'fp-123', From aad09740051f06fa8ab9280158e2dab62bd89656 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 26 Jun 2026 15:02:38 +0800 Subject: [PATCH 5/7] refactor(feedback): reuse harness session export and normalize upload url types --- .../src/feedback/codebase-upload/attach.ts | 12 +- .../src/feedback/codebase-upload/packager.ts | 60 +-------- apps/kimi-code/src/tui/commands/info.ts | 48 +++++-- .../codebase-upload/codebase-upload.test.ts | 34 ----- .../test/tui/kimi-tui-message-flow.test.ts | 123 ++++++++++-------- packages/node-sdk/src/auth.ts | 34 ++++- packages/node-sdk/src/index.ts | 3 + packages/node-sdk/test/auth-facade.test.ts | 55 ++++++++ 8 files changed, 205 insertions(+), 164 deletions(-) diff --git a/apps/kimi-code/src/feedback/codebase-upload/attach.ts b/apps/kimi-code/src/feedback/codebase-upload/attach.ts index 488809e46f..24c188dc40 100644 --- a/apps/kimi-code/src/feedback/codebase-upload/attach.ts +++ b/apps/kimi-code/src/feedback/codebase-upload/attach.ts @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path'; import { Readable } from 'node:stream'; import { getCacheDir } from '../../utils/paths'; -import { packageCodebase, packageSessionFiles } from './packager'; +import { packageCodebase } from './packager'; import type { CompletedUploadPart, FeedbackCodebaseArchive, @@ -31,10 +31,12 @@ export async function packageCurrentCodebase( return { ...archive, cleanupDir: archivePathCleanupDir(archivePath) }; } -export async function packageCurrentSession(sessionDir: string): Promise { - const archivePath = await createArchivePath(SESSION_ARCHIVE_FILENAME); - const archive = await packageSessionFiles(sessionDir, archivePath); - return { ...archive, cleanupDir: archivePathCleanupDir(archivePath) }; +export async function createFeedbackArchivePath(filename: string): Promise<{ + readonly archivePath: string; + readonly cleanupDir: string; +}> { + const archivePath = await createArchivePath(filename); + return { archivePath, cleanupDir: archivePathCleanupDir(archivePath) }; } export async function removePackagedCodebaseArchive(archive: FeedbackCodebaseArchive): Promise { diff --git a/apps/kimi-code/src/feedback/codebase-upload/packager.ts b/apps/kimi-code/src/feedback/codebase-upload/packager.ts index 0bf2828739..6c25280302 100644 --- a/apps/kimi-code/src/feedback/codebase-upload/packager.ts +++ b/apps/kimi-code/src/feedback/codebase-upload/packager.ts @@ -1,15 +1,11 @@ import { createHash } from 'node:crypto'; import { createWriteStream } from 'node:fs'; -import { lstat, mkdir, readdir, rm, stat } from 'node:fs/promises'; -import { dirname, join, relative } from 'node:path'; +import { mkdir, rm, stat } from 'node:fs/promises'; +import { dirname } from 'node:path'; import { ZipFile } from 'yazl'; -import type { - FeedbackCodebaseArchive, - FeedbackCodebaseFile, - FeedbackCodebaseScanResult, -} from './types'; +import type { FeedbackCodebaseArchive, FeedbackCodebaseScanResult } from './types'; interface PackageEntry { readonly absolutePath: string; @@ -34,24 +30,6 @@ export async function packageCodebase( return packageEntries(entries, archivePath); } -/** - * Pack the current session directory (state.json, agents//wire.jsonl, - * logs/kimi-code.log) into a zip, with files placed at the zip root. - */ -export async function packageSessionFiles( - sessionDir: string, - archivePath: string, -): Promise { - const files = await collectDirFiles(sessionDir); - const entries: PackageEntry[] = files.map((file) => ({ - absolutePath: file.absolutePath, - archivePath: file.path, - size: file.size, - mtimeMs: file.mtimeMs, - })); - return packageEntries(entries, archivePath); -} - async function packageEntries( entries: readonly PackageEntry[], archivePath: string, @@ -105,34 +83,6 @@ async function packageEntries( } } -async function collectDirFiles(dir: string): Promise { - const results: FeedbackCodebaseFile[] = []; - - async function walk(current: string): Promise { - const entries = await readdir(current, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isSymbolicLink()) continue; - const absolutePath = join(current, entry.name); - if (entry.isDirectory()) { - await walk(absolutePath); - continue; - } - if (!entry.isFile()) continue; - const fileStat = await lstat(absolutePath).catch(() => null); - if (fileStat === null || !fileStat.isFile()) continue; - results.push({ - path: toPosixPath(relative(dir, absolutePath)), - absolutePath, - size: fileStat.size, - mtimeMs: fileStat.mtimeMs, - }); - } - } - - await walk(dir); - return results.toSorted((a, b) => a.path.localeCompare(b.path)); -} - function fingerprintEntries(entries: readonly PackageEntry[]): string { const hash = createHash('sha256'); for (const entry of entries) { @@ -145,7 +95,3 @@ function fingerprintEntries(entries: readonly PackageEntry[]): string { } return hash.digest('hex'); } - -function toPosixPath(value: string): string { - return value.split('\\').join('/'); -} diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index 6556e9dd68..a640977fe4 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -1,4 +1,5 @@ -import { appendFile, mkdir } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { appendFile, mkdir, readFile, rm, stat } from 'node:fs/promises'; import { release as osRelease, type as osType } from 'node:os'; import { join } from 'node:path'; @@ -22,10 +23,12 @@ import { } from '../constant/feedback'; import { isManagedUsageProvider } from '../constant/kimi-tui'; import { formatErrorMessage } from '../utils/event-payload'; +import { detectInstallSource } from '#/cli/update/source'; +import { detectShellEnvironment } from '#/utils/process/shell-env'; import { getLogDir } from '#/utils/paths'; import { + createFeedbackArchivePath, packageCurrentCodebase, - packageCurrentSession, removePackagedCodebaseArchive, scanCodebase, uploadPackagedCodebase, @@ -131,21 +134,43 @@ async function prepareAndUploadSessionArchive( return false; } - let archive: FeedbackCodebaseArchive | undefined; + const target = await createFeedbackArchivePath(SESSION_ARCHIVE_FILENAME); try { - archive = await packageCurrentSession(sessionDir); + const exported = await host.harness.exportSession({ + id: host.state.appState.sessionId, + outputPath: target.archivePath, + includeGlobalLog: true, + version: host.state.appState.version, + installSource: await detectInstallSource(), + shellEnv: detectShellEnvironment(), + }); + const archive = await archiveFromExportedSession(exported.zipPath, target.cleanupDir); await uploadPackagedCodebase(api, archive, feedbackId, { filename: SESSION_ARCHIVE_FILENAME }); return true; } catch (error) { await logFeedbackUploadError(error); return false; } finally { - if (archive !== undefined) { - await removePackagedCodebaseArchive(archive).catch(() => {}); - } + await rm(target.cleanupDir, { recursive: true, force: true }).catch(() => {}); } } +async function archiveFromExportedSession( + zipPath: string, + cleanupDir: string, +): Promise { + const data = await readFile(zipPath); + const archiveStat = await stat(zipPath); + return { + path: zipPath, + size: archiveStat.size, + sha256: createHash('sha256').update(data).digest('hex'), + fingerprint: createHash('sha256').update(data).digest('hex'), + fileCount: 1, + cleanupDir, + }; +} + async function prepareAndUploadCodebaseArchive( api: FeedbackUploadUrlApi, feedbackId: number, @@ -209,13 +234,8 @@ function createFeedbackUploadApi(host: SlashCommandHost): FeedbackUploadUrlApi { const res = await host.harness.auth.createFeedbackUploadUrl(input); if (res.kind !== 'ok') throw new Error(res.message); return { - uploadId: res.upload_id, - parts: res.parts.map((part) => ({ - partNumber: part.part_number, - url: part.url, - method: part.method, - size: part.size, - })), + uploadId: res.uploadId, + parts: res.parts, }; }, async completeUpload(input) { diff --git a/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts b/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts index 609e5cd98d..83cf5dc6ea 100644 --- a/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts +++ b/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts @@ -9,7 +9,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { packageCodebase, - packageSessionFiles, removeStaleFeedbackUploads, scanCodebase, uploadPackagedCodebase, @@ -237,39 +236,6 @@ describe('packageCodebase', () => { }); }); -describe('packageSessionFiles', () => { - it('recursively packs every file under the session directory', async () => { - const sessionDir = await mkdtemp(join(tmpdir(), 'feedback-session-')); - const archivePath = join(tmpdir(), 'feedback-session-test.zip'); - try { - await mkdir(join(sessionDir, 'agents', 'main'), { recursive: true }); - await mkdir(join(sessionDir, 'logs')); - await writeFile(join(sessionDir, 'state.json'), '{}'); - await writeFile(join(sessionDir, 'agents', 'main', 'wire.jsonl'), 'events'); - await writeFile(join(sessionDir, 'logs', 'kimi-code.log'), 'logs'); - - const archive = await packageSessionFiles(sessionDir, archivePath); - expect(archive.fileCount).toBe(3); - expect(archive.size).toBeGreaterThan(0); - expect(archive.sha256).toMatch(/^[0-9a-f]{64}$/); - } finally { - await rm(sessionDir, { recursive: true, force: true }); - await rm(archivePath, { force: true }); - } - }); - - it('rejects empty session archives instead of uploading an empty zip', async () => { - const sessionDir = await mkdtemp(join(tmpdir(), 'feedback-empty-session-')); - const archivePath = join(tmpdir(), 'feedback-empty-session.zip'); - try { - await expect(packageSessionFiles(sessionDir, archivePath)).rejects.toThrow(/empty/i); - await expect(stat(archivePath)).rejects.toThrow(); - } finally { - await rm(sessionDir, { recursive: true, force: true }); - await rm(archivePath, { force: true }); - } - }); -}); describe('scanCodebase filtering', () => { it('rejects when the scan signal is already aborted', async () => { diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index ff42e31ca2..41f0f98196 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -33,7 +33,6 @@ import type { StreamingUIController } from '#/tui/controllers/streaming-ui'; import { handleFeedbackCommand } from '#/tui/commands/info'; import { packageCurrentCodebase, - packageCurrentSession, scanCodebase, uploadPackagedCodebase, } from '../../src/feedback/codebase-upload'; @@ -61,7 +60,6 @@ vi.mock('../../src/feedback/codebase-upload', async (importOriginal) => { ...actual, scanCodebase: vi.fn().mockResolvedValue(undefined), packageCurrentCodebase: vi.fn(), - packageCurrentSession: vi.fn(), uploadPackagedCodebase: vi.fn(), }; }); @@ -241,6 +239,12 @@ function makeHarness(session = makeSession(), overrides: Record resumeSession: vi.fn(async () => session), forkSession: vi.fn(async () => session), listSessions: vi.fn(async () => []), + exportSession: vi.fn(async () => ({ + zipPath: '/tmp/fake-session.zip', + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + })), close: vi.fn(async () => {}), track: vi.fn(), setTelemetryContext: vi.fn(), @@ -355,6 +359,14 @@ async function makeTempHome(): Promise { return dir; } +async function makeExportedSessionZip(content = 'session zip'): Promise { + const dir = await mkdtemp(join(tmpdir(), 'kimi-code-feedback-export-')); + tempDirs.push(dir); + const zipPath = join(dir, 'session.zip'); + await writeFile(zipPath, content); + return zipPath; +} + afterEach(async () => { resetCapabilitiesCache(); for (const dir of tempDirs.splice(0)) { @@ -539,18 +551,24 @@ command = "vim" harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); harness.listSessions.mockResolvedValueOnce([{ id: 'ses-1', sessionDir: '/tmp/session-a' }] as never); - const archive = { - path: '/tmp/fake-session.zip', - size: 4, - sha256: 'hash-session', - fingerprint: 'fp-session', - fileCount: 1, - }; - let resolvePackage!: (value: typeof archive) => void; - const packageBlocked = new Promise((resolve) => { - resolvePackage = resolve; + const zipPath = await makeExportedSessionZip(); + let resolveExport!: () => void; + const exportBlocked = new Promise<{ + zipPath: string; + entries: string[]; + sessionDir: string; + manifest: Record; + }>((resolve) => { + resolveExport = () => { + resolve({ + zipPath, + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + }); + }; }); - vi.mocked(packageCurrentSession).mockImplementationOnce(() => packageBlocked); + harness.exportSession.mockImplementationOnce(() => exportBlocked); let settled = false; const command = handleFeedbackCommand(feedbackDriver as any).then(() => { @@ -558,17 +576,23 @@ command = "vim" }); await vi.waitFor(() => { - expect(packageCurrentSession).toHaveBeenCalledWith('/tmp/session-a'); + expect(harness.exportSession).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'ses-1', + includeGlobalLog: true, + version: '0.0.0-test', + }), + ); }); expect(harness.auth.submitFeedback).toHaveBeenCalledWith( expect.objectContaining({ content: 'useful feedback' }), ); expect(harness.auth.submitFeedback.mock.invocationCallOrder[0]).toBeLessThan( - vi.mocked(packageCurrentSession).mock.invocationCallOrder[0]!, + harness.exportSession.mock.invocationCallOrder[0]!, ); expect(settled).toBe(false); - resolvePackage(archive); + resolveExport(); await command; }); @@ -589,7 +613,7 @@ command = "vim" ); const feedbackDriver = driver as unknown as FeedbackDriver; vi.mocked(scanCodebase).mockReset(); - vi.mocked(packageCurrentSession).mockReset(); + harness.exportSession.mockReset(); vi.mocked(packageCurrentCodebase).mockReset(); vi.mocked(uploadPackagedCodebase).mockReset(); vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); @@ -605,12 +629,12 @@ command = "vim" fingerprint: 'fp-123', usedGitIgnore: false, } as any); - vi.mocked(packageCurrentSession).mockResolvedValueOnce({ - path: '/tmp/fake-session.zip', - size: 4, - sha256: 'hash-session', - fingerprint: 'fp-session', - fileCount: 1, + const sessionZipPath = await makeExportedSessionZip(); + harness.exportSession.mockResolvedValueOnce({ + zipPath: sessionZipPath, + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, }); vi.mocked(packageCurrentCodebase).mockResolvedValueOnce({ path: '/tmp/fake-codebase.zip', @@ -625,7 +649,7 @@ command = "vim" resolveCodebaseUpload = resolve; }); vi.mocked(uploadPackagedCodebase).mockImplementation((_api, archive) => { - if (archive.path === '/tmp/fake-session.zip') return Promise.resolve(); + if (archive.path === sessionZipPath) return Promise.resolve(); return codebaseUploadBlocked; }); @@ -642,16 +666,13 @@ command = "vim" resolveCodebaseUpload(); await command; expect(settled).toBe(true); - // Session archive is uploaded first (small/reliable), then the codebase. - expect(uploadPackagedCodebase).toHaveBeenNthCalledWith( - 1, + expect(uploadPackagedCodebase).toHaveBeenCalledWith( expect.any(Object), - expect.objectContaining({ path: '/tmp/fake-session.zip' }), + expect.objectContaining({ path: sessionZipPath }), 3, { filename: 'session.zip' }, ); - expect(uploadPackagedCodebase).toHaveBeenNthCalledWith( - 2, + expect(uploadPackagedCodebase).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ path: '/tmp/fake-codebase.zip' }), 3, @@ -679,30 +700,31 @@ command = "vim" ); const feedbackDriver = driver as unknown as FeedbackDriver; vi.mocked(scanCodebase).mockReset(); - vi.mocked(packageCurrentSession).mockReset(); + harness.exportSession.mockReset(); vi.mocked(packageCurrentCodebase).mockReset(); vi.mocked(uploadPackagedCodebase).mockReset(); vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); harness.listSessions.mockResolvedValueOnce([{ id: 'ses-1', sessionDir: '/tmp/session-a' }] as never); - const archive = { - path: '/tmp/fake-session.zip', - size: 4, - sha256: 'hash-session', - fingerprint: 'fp-session', - fileCount: 1, - }; + const sessionZipPath = await makeExportedSessionZip(); vi.mocked(scanCodebase).mockRejectedValueOnce(new Error('scan failed')); - vi.mocked(packageCurrentSession).mockResolvedValueOnce(archive); + harness.exportSession.mockResolvedValueOnce({ + zipPath: sessionZipPath, + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + }); await handleFeedbackCommand(feedbackDriver as any); - expect(packageCurrentSession).toHaveBeenCalledWith('/tmp/session-a'); + expect(harness.exportSession).toHaveBeenCalledWith( + expect.objectContaining({ id: 'ses-1', includeGlobalLog: true }), + ); expect(packageCurrentCodebase).not.toHaveBeenCalled(); expect(uploadPackagedCodebase).toHaveBeenCalledWith( expect.any(Object), - archive, + expect.objectContaining({ path: sessionZipPath }), 3, { filename: 'session.zip' }, ); @@ -729,19 +751,13 @@ command = "vim" const feedbackDriver = driver as unknown as FeedbackDriver; vi.mocked(scanCodebase).mockReset(); vi.mocked(packageCurrentCodebase).mockReset(); - vi.mocked(packageCurrentSession).mockReset(); + harness.exportSession.mockReset(); vi.mocked(uploadPackagedCodebase).mockReset(); vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); harness.listSessions.mockResolvedValueOnce([{ id: 'ses-1', sessionDir: '/tmp/session-a' }] as never); - const sessionArchive = { - path: '/tmp/fake-session.zip', - size: 4, - sha256: 'hash-session', - fingerprint: 'fp-session', - fileCount: 1, - }; + const sessionZipPath = await makeExportedSessionZip(); vi.mocked(scanCodebase).mockResolvedValueOnce({ root: '/tmp/proj-a', @@ -749,7 +765,12 @@ command = "vim" fingerprint: 'fp-123', usedGitIgnore: false, } as any); - vi.mocked(packageCurrentSession).mockResolvedValueOnce(sessionArchive); + harness.exportSession.mockResolvedValueOnce({ + zipPath: sessionZipPath, + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + }); vi.mocked(packageCurrentCodebase).mockRejectedValueOnce(new Error('zip failed')); await handleFeedbackCommand(feedbackDriver as any); @@ -758,7 +779,7 @@ command = "vim" expect(calls[0]?.[0]?.['info']).toBeUndefined(); expect(uploadPackagedCodebase).toHaveBeenCalledWith( expect.any(Object), - sessionArchive, + expect.objectContaining({ path: sessionZipPath }), 3, { filename: 'session.zip' }, ); diff --git a/packages/node-sdk/src/auth.ts b/packages/node-sdk/src/auth.ts index b2d582ff9a..4f02747f88 100644 --- a/packages/node-sdk/src/auth.ts +++ b/packages/node-sdk/src/auth.ts @@ -17,7 +17,7 @@ import { type AuthStatus, type BearerTokenProvider, type FetchCompleteFeedbackUploadResult, - type FetchCreateFeedbackUploadUrlResult, + type FetchFeedbackUploadError, type FetchSubmitFeedbackResult, type KimiHostIdentity, type KimiOAuthLoginOptions, @@ -54,6 +54,23 @@ export interface KimiAuthCompleteFeedbackUploadInput { readonly parts: readonly KimiAuthCompleteFeedbackUploadPart[]; } +export interface KimiAuthFeedbackUploadPart { + readonly partNumber: number; + readonly url: string; + readonly method: string; + readonly size: number; +} + +export interface KimiAuthCreateFeedbackUploadUrlOk { + readonly kind: 'ok'; + readonly uploadId: number; + readonly parts: readonly KimiAuthFeedbackUploadPart[]; +} + +export type KimiAuthCreateFeedbackUploadUrlResult = + | KimiAuthCreateFeedbackUploadUrlOk + | FetchFeedbackUploadError; + export type KimiAuthLoginOptions = Omit; export interface KimiAuthLoginResult { @@ -184,9 +201,9 @@ export class KimiAuthFacade { async createFeedbackUploadUrl( input: KimiAuthCreateFeedbackUploadUrlInput, providerName?: string | undefined, - ): Promise { + ): Promise { const auth = this.resolveRuntimeManagedAuth(providerName); - return this.toolkit.createFeedbackUploadUrl( + const result = await this.toolkit.createFeedbackUploadUrl( { file_hash: input.sha256, file_name: input.filename, @@ -199,6 +216,17 @@ export class KimiAuthFacade { baseUrl: auth.baseUrl, }, ); + if (result.kind !== 'ok') return result; + return { + kind: 'ok', + uploadId: result.upload_id, + parts: result.parts.map((part) => ({ + partNumber: part.part_number, + url: part.url, + method: part.method, + size: part.size, + })), + }; } async completeFeedbackUpload( diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index df7369bcd9..2435356859 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -83,6 +83,9 @@ export type { KimiAuthCompleteFeedbackUploadInput, KimiAuthCompleteFeedbackUploadPart, KimiAuthCreateFeedbackUploadUrlInput, + KimiAuthCreateFeedbackUploadUrlOk, + KimiAuthCreateFeedbackUploadUrlResult, + KimiAuthFeedbackUploadPart, KimiAuthLoginResult, KimiAuthLogoutResult, KimiAuthSubmitFeedbackInput, diff --git a/packages/node-sdk/test/auth-facade.test.ts b/packages/node-sdk/test/auth-facade.test.ts index ca6345af9c..978bd25780 100644 --- a/packages/node-sdk/test/auth-facade.test.ts +++ b/packages/node-sdk/test/auth-facade.test.ts @@ -860,6 +860,61 @@ oauth = { storage = "file", key = "${configuredOauthKey}", oauth_host = "https:/ }); }); + it('createFeedbackUploadUrl maps SDK input and returns camelCase upload parts', async () => { + await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); + const fetchMock = vi.fn(async () => + new Response( + JSON.stringify({ + upload: { + id: 28, + parts: [ + { + part_number: 1, + url: 'https://upload.example.test/part-1', + method: 'PUT', + size: 1024, + }, + ], + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const harness = createKimiHarness({ homeDir }); + const result = await harness.auth.createFeedbackUploadUrl({ + feedbackId: 3, + filename: 'session.zip', + size: 1024, + sha256: 'abc123', + }); + + expect(result).toEqual({ + kind: 'ok', + uploadId: 28, + parts: [ + { + partNumber: 1, + url: 'https://upload.example.test/part-1', + method: 'PUT', + size: 1024, + }, + ], + }); + + const calls = fetchMock.mock.calls as unknown as [string, RequestInit?][]; + const [url, init] = calls[0]!; + expect(url).toBe('https://api.kimi.com/coding/v1/feedback/upload_url'); + expect(init?.method).toBe('POST'); + expect(JSON.parse(init?.body as string)).toEqual({ + feedback_id: 3, + file_name: 'session.zip', + file_size: 1024, + file_hash: 'abc123', + }); + }); + it('submitFeedback surfaces HTTP errors without throwing', async () => { await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); vi.stubGlobal( From 19a23ddb162146bef21bbcbba1fdf5eec6ad2147 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 26 Jun 2026 15:02:48 +0800 Subject: [PATCH 6/7] docs(slash-commands): note optional feedback attachments --- docs/en/reference/slash-commands.md | 2 +- docs/zh/reference/slash-commands.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index d95b63dda9..f74812c0e0 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -105,7 +105,7 @@ Prompt mode exits with code `0` when the goal completes, `3` when it blocks, and | `/mcp` | — | List MCP servers and their connection status in the current session | Yes | | `/plugins` | — | Open the interactive plugin manager | Yes | | `/version` | — | Display the Kimi Code CLI version number | Yes | -| `/feedback` | — | Submit feedback to help improve Kimi Code CLI | Yes | +| `/feedback` | — | Submit feedback with optional diagnostic logs and codebase context | Yes | ## Exit diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index d92d903b74..2180108350 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -103,7 +103,7 @@ Prompt 模式在目标完成时以退出码 `0` 退出,在目标阻塞时以 ` | `/mcp` | — | 列出当前会话中的 MCP server 及连接状态 | 是 | | `/plugins` | — | 打开交互式 plugin 管理器 | 是 | | `/version` | — | 显示 Kimi Code CLI 版本号 | 是 | -| `/feedback` | — | 提交反馈以改进 Kimi Code CLI | 是 | +| `/feedback` | — | 提交反馈,可附加诊断日志和代码库上下文 | 是 | ## 退出 From 9c632c942c9d5309b0d1c105f6ec60eeba6329a8 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 26 Jun 2026 16:04:50 +0800 Subject: [PATCH 7/7] refactor(feedback): reorganize feedback upload modules Move the attachment orchestration out of tui/commands/info.ts into a dedicated feedback/feedback-attachments.ts, and split the former codebase-upload/attach.ts into a generic multipart uploader (feedback/upload.ts) and an archive lifecycle module (feedback/archive.ts). Both session and codebase archives now flow through a single upload lifecycle, which also removes the temp-dir leak that occurred when codebase packaging failed. Rename FeedbackCodebaseArchive to FeedbackArchive and the codebase-upload/ directory to codebase/ so module boundaries match their actual responsibilities (scan + package only). --- apps/kimi-code/src/feedback/archive.ts | 72 +++++++ .../src/feedback/codebase-upload/types.ts | 63 ------ .../{codebase-upload => codebase}/filter.ts | 0 .../{codebase-upload => codebase}/index.ts | 1 - .../{codebase-upload => codebase}/packager.ts | 7 +- .../{codebase-upload => codebase}/scanner.ts | 0 apps/kimi-code/src/feedback/codebase/types.ts | 19 ++ .../src/feedback/feedback-attachments.ts | 183 ++++++++++++++++++ .../{codebase-upload/attach.ts => upload.ts} | 127 ++++-------- apps/kimi-code/src/tui/commands/info.ts | 168 +--------------- .../codebase-upload/codebase-upload.test.ts | 29 +-- .../test/tui/kimi-tui-message-flow.test.ts | 52 ++--- 12 files changed, 361 insertions(+), 360 deletions(-) create mode 100644 apps/kimi-code/src/feedback/archive.ts delete mode 100644 apps/kimi-code/src/feedback/codebase-upload/types.ts rename apps/kimi-code/src/feedback/{codebase-upload => codebase}/filter.ts (100%) rename apps/kimi-code/src/feedback/{codebase-upload => codebase}/index.ts (75%) rename apps/kimi-code/src/feedback/{codebase-upload => codebase}/packager.ts (94%) rename apps/kimi-code/src/feedback/{codebase-upload => codebase}/scanner.ts (100%) create mode 100644 apps/kimi-code/src/feedback/codebase/types.ts create mode 100644 apps/kimi-code/src/feedback/feedback-attachments.ts rename apps/kimi-code/src/feedback/{codebase-upload/attach.ts => upload.ts} (58%) diff --git a/apps/kimi-code/src/feedback/archive.ts b/apps/kimi-code/src/feedback/archive.ts new file mode 100644 index 0000000000..439f68d1be --- /dev/null +++ b/apps/kimi-code/src/feedback/archive.ts @@ -0,0 +1,72 @@ +import { mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import { getCacheDir } from '../utils/paths'; + +const STALE_ARCHIVE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours. + +/** + * A file produced for a feedback attachment upload. Both the session log + * archive and the codebase archive share this shape; the generic uploader + * consumes it without caring how the file was produced. + */ +export interface FeedbackArchive { + readonly path: string; + readonly size: number; + readonly sha256: string; + readonly fingerprint: string; + readonly fileCount: number; + /** Directory created exclusively for this archive and safe to remove after upload. */ + readonly cleanupDir?: string; +} + +export async function createFeedbackArchivePath(filename: string): Promise<{ + readonly archivePath: string; + readonly cleanupDir: string; +}> { + const archivePath = await createArchivePath(filename); + return { archivePath, cleanupDir: archivePathCleanupDir(archivePath) }; +} + +/** + * Remove feedback-upload archive directories older than 24 hours. Packaging + * cleans up its own archive on success and on failure, but a killed process + * or an empty parent dir can still leave leftovers behind; this is a + * best-effort backstop so the cache dir does not grow without bound. + * + * `dir` is injectable for tests; production callers leave it as the default. + */ +export async function removeStaleFeedbackUploads( + options: { readonly now?: number; readonly dir?: string } = {}, +): Promise { + const now = options.now ?? Date.now(); + const dir = options.dir ?? join(getCacheDir(), 'feedback-uploads'); + const entries = await readdir(dir, { withFileTypes: true }).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + }); + if (entries === null) return; + + const cutoff = now - STALE_ARCHIVE_MAX_AGE_MS; + await Promise.all( + entries.map(async (entry) => { + if (!entry.isDirectory() && !entry.isSymbolicLink()) return; + const target = join(dir, entry.name); + const targetStat = await stat(target).catch(() => null); + if (targetStat === null || targetStat.mtimeMs >= cutoff) return; + await rm(target, { recursive: true, force: true }).catch(() => {}); + }), + ); +} + +async function createArchivePath(filename: string): Promise { + await removeStaleFeedbackUploads(); + const root = join(getCacheDir(), 'feedback-uploads'); + await mkdir(root, { recursive: true }); + const dir = await mkdtemp(join(root, 'upload-')); + return join(dir, filename); +} + +function archivePathCleanupDir(archivePath: string): string { + return dirname(archivePath); +} diff --git a/apps/kimi-code/src/feedback/codebase-upload/types.ts b/apps/kimi-code/src/feedback/codebase-upload/types.ts deleted file mode 100644 index ef8ec3324e..0000000000 --- a/apps/kimi-code/src/feedback/codebase-upload/types.ts +++ /dev/null @@ -1,63 +0,0 @@ -export interface FeedbackCodebaseFile { - readonly path: string; - readonly absolutePath: string; - readonly size: number; - readonly mtimeMs: number; -} - -export interface FeedbackCodebaseLimitExceeded { - readonly reason: 'file-count' | 'total-size'; - readonly limit: number; -} - -export interface FeedbackCodebaseScanResult { - readonly root: string; - readonly files: readonly FeedbackCodebaseFile[]; - readonly fingerprint: string; - readonly usedGitIgnore: boolean; - readonly exceedsLimit?: FeedbackCodebaseLimitExceeded; -} - -export interface FeedbackCodebaseArchive { - readonly path: string; - readonly size: number; - readonly sha256: string; - readonly fingerprint: string; - readonly fileCount: number; - /** Directory created exclusively for this archive and safe to remove after upload. */ - readonly cleanupDir?: string; -} - -export interface CreateFeedbackUploadUrlInput { - readonly feedbackId: number; - readonly filename: string; - readonly size: number; - readonly sha256: string; -} - -export interface FeedbackUploadPart { - readonly partNumber: number; - readonly url: string; - readonly method: string; - readonly size: number; -} - -export interface CreateFeedbackUploadUrlResult { - readonly uploadId: number; - readonly parts: readonly FeedbackUploadPart[]; -} - -export interface CompletedUploadPart { - readonly partNumber: number; - readonly etag: string; -} - -export interface CompleteFeedbackUploadUrlInput { - readonly uploadId: number; - readonly parts: readonly CompletedUploadPart[]; -} - -export interface FeedbackUploadUrlApi { - createUploadUrl(input: CreateFeedbackUploadUrlInput): Promise; - completeUpload(input: CompleteFeedbackUploadUrlInput): Promise; -} diff --git a/apps/kimi-code/src/feedback/codebase-upload/filter.ts b/apps/kimi-code/src/feedback/codebase/filter.ts similarity index 100% rename from apps/kimi-code/src/feedback/codebase-upload/filter.ts rename to apps/kimi-code/src/feedback/codebase/filter.ts diff --git a/apps/kimi-code/src/feedback/codebase-upload/index.ts b/apps/kimi-code/src/feedback/codebase/index.ts similarity index 75% rename from apps/kimi-code/src/feedback/codebase-upload/index.ts rename to apps/kimi-code/src/feedback/codebase/index.ts index 26c1154e2c..7ef51870dd 100644 --- a/apps/kimi-code/src/feedback/codebase-upload/index.ts +++ b/apps/kimi-code/src/feedback/codebase/index.ts @@ -1,4 +1,3 @@ -export * from './attach'; export * from './packager'; export * from './scanner'; export * from './types'; diff --git a/apps/kimi-code/src/feedback/codebase-upload/packager.ts b/apps/kimi-code/src/feedback/codebase/packager.ts similarity index 94% rename from apps/kimi-code/src/feedback/codebase-upload/packager.ts rename to apps/kimi-code/src/feedback/codebase/packager.ts index 6c25280302..cd4bf2c66e 100644 --- a/apps/kimi-code/src/feedback/codebase-upload/packager.ts +++ b/apps/kimi-code/src/feedback/codebase/packager.ts @@ -5,7 +5,8 @@ import { dirname } from 'node:path'; import { ZipFile } from 'yazl'; -import type { FeedbackCodebaseArchive, FeedbackCodebaseScanResult } from './types'; +import type { FeedbackArchive } from '../archive'; +import type { FeedbackCodebaseScanResult } from './types'; interface PackageEntry { readonly absolutePath: string; @@ -20,7 +21,7 @@ interface PackageEntry { export async function packageCodebase( scan: FeedbackCodebaseScanResult, archivePath: string, -): Promise { +): Promise { const entries: PackageEntry[] = scan.files.map((file) => ({ absolutePath: file.absolutePath, archivePath: file.path, @@ -33,7 +34,7 @@ export async function packageCodebase( async function packageEntries( entries: readonly PackageEntry[], archivePath: string, -): Promise { +): Promise { if (entries.length === 0) { throw new Error('Cannot package an empty feedback archive.'); } diff --git a/apps/kimi-code/src/feedback/codebase-upload/scanner.ts b/apps/kimi-code/src/feedback/codebase/scanner.ts similarity index 100% rename from apps/kimi-code/src/feedback/codebase-upload/scanner.ts rename to apps/kimi-code/src/feedback/codebase/scanner.ts diff --git a/apps/kimi-code/src/feedback/codebase/types.ts b/apps/kimi-code/src/feedback/codebase/types.ts new file mode 100644 index 0000000000..a611d93a3f --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase/types.ts @@ -0,0 +1,19 @@ +export interface FeedbackCodebaseFile { + readonly path: string; + readonly absolutePath: string; + readonly size: number; + readonly mtimeMs: number; +} + +export interface FeedbackCodebaseLimitExceeded { + readonly reason: 'file-count' | 'total-size'; + readonly limit: number; +} + +export interface FeedbackCodebaseScanResult { + readonly root: string; + readonly files: readonly FeedbackCodebaseFile[]; + readonly fingerprint: string; + readonly usedGitIgnore: boolean; + readonly exceedsLimit?: FeedbackCodebaseLimitExceeded; +} diff --git a/apps/kimi-code/src/feedback/feedback-attachments.ts b/apps/kimi-code/src/feedback/feedback-attachments.ts new file mode 100644 index 0000000000..c372e4d118 --- /dev/null +++ b/apps/kimi-code/src/feedback/feedback-attachments.ts @@ -0,0 +1,183 @@ +import { createHash } from 'node:crypto'; +import { appendFile, mkdir, readFile, rm, stat } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { detectInstallSource } from '#/cli/update/source'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import type { FeedbackAttachmentLevel } from '#/tui/commands/prompts'; +import { getLogDir } from '#/utils/paths'; +import { detectShellEnvironment } from '#/utils/process/shell-env'; + +import { createFeedbackArchivePath, type FeedbackArchive } from './archive'; +import { packageCodebase, scanCodebase, type FeedbackCodebaseScanResult } from './codebase'; +import { uploadArchive, type FeedbackUploadUrlApi } from './upload'; + +export const CODEBASE_ARCHIVE_FILENAME = 'repo.zip'; +export const SESSION_ARCHIVE_FILENAME = 'session.zip'; + +const CODEBASE_SCAN_TIMEOUT_MS = 3000; + +/** + * Stage 3 of the `/feedback` flow: prepare and upload each requested attachment + * independently. Attachment failures are non-fatal because the text feedback + * already exists, but any requested artifact that cannot be prepared/uploaded + * is reported as a partial attachment failure instead of silently downgrading + * the request. + * + * Returns `true` when at least one requested attachment failed so the caller + * can surface a partial-failure status. + */ +export async function submitFeedbackWithAttachments( + host: SlashCommandHost, + feedbackId: number, + level: FeedbackAttachmentLevel, +): Promise { + const api = createFeedbackUploadApi(host); + + if (level === 'logs') { + const uploaded = await prepareAndUploadSessionArchive(host, api, feedbackId); + return !uploaded; + } + if (level === 'logs+codebase') { + const [sessionDir, scan] = await Promise.all([ + resolveCurrentSessionDir(host), + scanCodebaseForFeedback(host.state.appState.workDir), + ]); + const [uploadedSession, uploadedCodebase] = await Promise.all([ + prepareAndUploadSessionArchive(host, api, feedbackId, sessionDir), + prepareAndUploadCodebaseArchive(api, feedbackId, scan), + ]); + return !uploadedSession || !uploadedCodebase; + } + return false; +} + +async function prepareAndUploadSessionArchive( + host: SlashCommandHost, + api: FeedbackUploadUrlApi, + feedbackId: number, + knownSessionDir?: string, +): Promise { + const sessionDir = knownSessionDir ?? (await resolveCurrentSessionDir(host)); + if (sessionDir === undefined) { + await logFeedbackUploadError(new Error('cannot locate the current session directory')); + return false; + } + return uploadProducedArchive(api, feedbackId, SESSION_ARCHIVE_FILENAME, async (archivePath) => { + const exported = await host.harness.exportSession({ + id: host.state.appState.sessionId, + outputPath: archivePath, + includeGlobalLog: true, + version: host.state.appState.version, + installSource: await detectInstallSource(), + shellEnv: detectShellEnvironment(), + }); + return archiveFromExportedSession(exported.zipPath); + }); +} + +async function prepareAndUploadCodebaseArchive( + api: FeedbackUploadUrlApi, + feedbackId: number, + scan: FeedbackCodebaseScanResult | undefined, +): Promise { + if (scan === undefined) return false; + return uploadProducedArchive(api, feedbackId, CODEBASE_ARCHIVE_FILENAME, (archivePath) => + packageCodebase(scan, archivePath), + ); +} + +/** + * Shared lifecycle for a single attachment: create a temp archive path, let + * `produce` write the archive to it, upload it, then always remove the temp + * directory — even when `produce` or the upload throws. Both the session log + * archive and the codebase archive flow through here so their cleanup and + * error handling cannot drift apart. + */ +async function uploadProducedArchive( + api: FeedbackUploadUrlApi, + feedbackId: number, + filename: string, + produce: (archivePath: string) => Promise, +): Promise { + const { archivePath, cleanupDir } = await createFeedbackArchivePath(filename); + try { + const archive = await produce(archivePath); + await uploadArchive(api, { ...archive, cleanupDir }, feedbackId, { filename }); + return true; + } catch (error) { + await logFeedbackUploadError(error); + return false; + } finally { + await rm(cleanupDir, { recursive: true, force: true }).catch(() => {}); + } +} + +async function archiveFromExportedSession(zipPath: string): Promise { + const data = await readFile(zipPath); + const archiveStat = await stat(zipPath); + return { + path: zipPath, + size: archiveStat.size, + sha256: createHash('sha256').update(data).digest('hex'), + fingerprint: createHash('sha256').update(data).digest('hex'), + fileCount: 1, + }; +} + +async function resolveCurrentSessionDir(host: SlashCommandHost): Promise { + try { + const sessions = await host.harness.listSessions({ workDir: host.state.appState.workDir }); + return sessions.find((session) => session.id === host.state.appState.sessionId)?.sessionDir; + } catch { + return undefined; + } +} + +async function scanCodebaseForFeedback( + workDir: string, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(); + }, CODEBASE_SCAN_TIMEOUT_MS); + try { + return await scanCodebase(workDir, { signal: controller.signal }); + } catch (error) { + await logFeedbackUploadError(error); + return undefined; + } finally { + clearTimeout(timer); + } +} + +async function logFeedbackUploadError(error: unknown): Promise { + try { + const logDir = getLogDir(); + await mkdir(logDir, { recursive: true }); + const message = error instanceof Error ? (error.stack ?? error.message) : String(error); + await appendFile(join(logDir, 'feedback-upload.log'), `${new Date().toISOString()} ${message}\n`); + } catch { + // best-effort logging only + } +} + +function createFeedbackUploadApi(host: SlashCommandHost): FeedbackUploadUrlApi { + return { + async createUploadUrl(input) { + const res = await host.harness.auth.createFeedbackUploadUrl(input); + if (res.kind !== 'ok') throw new Error(res.message); + return { + uploadId: res.uploadId, + parts: res.parts, + }; + }, + async completeUpload(input) { + const res = await host.harness.auth.completeFeedbackUpload({ + uploadId: input.uploadId, + parts: input.parts.map((part) => ({ partNumber: part.partNumber, etag: part.etag })), + }); + if (res.kind !== 'ok') throw new Error(res.message); + }, + }; +} diff --git a/apps/kimi-code/src/feedback/codebase-upload/attach.ts b/apps/kimi-code/src/feedback/upload.ts similarity index 58% rename from apps/kimi-code/src/feedback/codebase-upload/attach.ts rename to apps/kimi-code/src/feedback/upload.ts index 24c188dc40..629fc6a6f2 100644 --- a/apps/kimi-code/src/feedback/codebase-upload/attach.ts +++ b/apps/kimi-code/src/feedback/upload.ts @@ -1,55 +1,51 @@ import { createReadStream } from 'node:fs'; -import { mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; import { Readable } from 'node:stream'; -import { getCacheDir } from '../../utils/paths'; -import { packageCodebase } from './packager'; -import type { - CompletedUploadPart, - FeedbackCodebaseArchive, - FeedbackCodebaseScanResult, - FeedbackUploadPart, - FeedbackUploadUrlApi, -} from './types'; - -export const CODEBASE_ARCHIVE_FILENAME = 'repo.zip'; -export const SESSION_ARCHIVE_FILENAME = 'session.zip'; +import type { FeedbackArchive } from './archive'; const MAX_ARCHIVE_SIZE = 524_288_000; // 500 MiB, matches the backend limit. -const STALE_ARCHIVE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours. const DEFAULT_CONCURRENCY = 3; const DEFAULT_MAX_RETRIES = 3; const DEFAULT_PART_TIMEOUT_MS = 60_000; const RETRY_BASE_DELAY_MS = 1_000; -export async function packageCurrentCodebase( - scan: FeedbackCodebaseScanResult, -): Promise { - const archivePath = await createArchivePath(CODEBASE_ARCHIVE_FILENAME); - const archive = await packageCodebase(scan, archivePath); - return { ...archive, cleanupDir: archivePathCleanupDir(archivePath) }; +export interface FeedbackUploadPart { + readonly partNumber: number; + readonly url: string; + readonly method: string; + readonly size: number; } -export async function createFeedbackArchivePath(filename: string): Promise<{ - readonly archivePath: string; - readonly cleanupDir: string; -}> { - const archivePath = await createArchivePath(filename); - return { archivePath, cleanupDir: archivePathCleanupDir(archivePath) }; +export interface CreateFeedbackUploadUrlInput { + readonly feedbackId: number; + readonly filename: string; + readonly size: number; + readonly sha256: string; } -export async function removePackagedCodebaseArchive(archive: FeedbackCodebaseArchive): Promise { - if (archive.cleanupDir !== undefined) { - await rm(archive.cleanupDir, { recursive: true, force: true }); - return; - } - await rm(archive.path, { force: true }); +export interface CreateFeedbackUploadUrlResult { + readonly uploadId: number; + readonly parts: readonly FeedbackUploadPart[]; +} + +export interface CompletedUploadPart { + readonly partNumber: number; + readonly etag: string; } -export interface UploadPackagedCodebaseOptions { - /** Zip entry name sent to the backend (defaults to `repo.zip`). */ - readonly filename?: string; +export interface CompleteFeedbackUploadUrlInput { + readonly uploadId: number; + readonly parts: readonly CompletedUploadPart[]; +} + +export interface FeedbackUploadUrlApi { + createUploadUrl(input: CreateFeedbackUploadUrlInput): Promise; + completeUpload(input: CompleteFeedbackUploadUrlInput): Promise; +} + +export interface UploadArchiveOptions { + /** Zip entry name sent to the backend. */ + readonly filename: string; /** Abort a single part PUT if it does not complete within this many milliseconds. */ readonly timeoutMs?: number; /** Number of parts to upload concurrently (defaults to 3). */ @@ -60,20 +56,20 @@ export interface UploadPackagedCodebaseOptions { readonly onProgress?: (uploadedBytes: number, totalBytes: number) => void; } -export async function uploadPackagedCodebase( +export async function uploadArchive( api: FeedbackUploadUrlApi, - archive: FeedbackCodebaseArchive, + archive: FeedbackArchive, feedbackId: number, - options: UploadPackagedCodebaseOptions = {}, + options: UploadArchiveOptions, ): Promise { if (archive.size > MAX_ARCHIVE_SIZE) { throw new Error( - `Failed to upload codebase archive: size ${archive.size} exceeds maximum allowed size ${MAX_ARCHIVE_SIZE}.`, + `Failed to upload archive: size ${archive.size} exceeds maximum allowed size ${MAX_ARCHIVE_SIZE}.`, ); } const created = await api.createUploadUrl({ feedbackId, - filename: options.filename ?? CODEBASE_ARCHIVE_FILENAME, + filename: options.filename, size: archive.size, sha256: archive.sha256, }); @@ -100,7 +96,7 @@ async function uploadParts( filePath: string, parts: readonly FeedbackUploadPart[], totalBytes: number, - options: UploadPackagedCodebaseOptions, + options: UploadArchiveOptions, ): Promise { const layout = layoutParts(parts); const results: CompletedUploadPart[] = Array.from({ length: layout.length }); @@ -129,7 +125,7 @@ async function uploadParts( async function uploadOnePartWithRetry( filePath: string, layout: PartLayout, - options: UploadPackagedCodebaseOptions, + options: UploadArchiveOptions, ): Promise { const maxRetries = Math.max(0, options.maxRetries ?? DEFAULT_MAX_RETRIES); let lastError: unknown; @@ -148,7 +144,7 @@ async function uploadOnePartWithRetry( async function uploadOnePart( filePath: string, layout: PartLayout, - options: UploadPackagedCodebaseOptions, + options: UploadArchiveOptions, ): Promise { const { part, start } = layout; const timeoutMs = options.timeoutMs ?? DEFAULT_PART_TIMEOUT_MS; @@ -210,46 +206,3 @@ function sleep(ms: number): Promise { setTimeout(resolve, ms); }); } - -/** - * Remove feedback-upload archive directories older than 24 hours. Packaging - * cleans up its own archive on success and on failure, but a killed process - * or an empty parent dir can still leave leftovers behind; this is a - * best-effort backstop so the cache dir does not grow without bound. - * - * `dir` is injectable for tests; production callers leave it as the default. - */ -export async function removeStaleFeedbackUploads( - options: { readonly now?: number; readonly dir?: string } = {}, -): Promise { - const now = options.now ?? Date.now(); - const dir = options.dir ?? join(getCacheDir(), 'feedback-uploads'); - const entries = await readdir(dir, { withFileTypes: true }).catch((error: unknown) => { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; - throw error; - }); - if (entries === null) return; - - const cutoff = now - STALE_ARCHIVE_MAX_AGE_MS; - await Promise.all( - entries.map(async (entry) => { - if (!entry.isDirectory() && !entry.isSymbolicLink()) return; - const target = join(dir, entry.name); - const targetStat = await stat(target).catch(() => null); - if (targetStat === null || targetStat.mtimeMs >= cutoff) return; - await rm(target, { recursive: true, force: true }).catch(() => {}); - }), - ); -} - -async function createArchivePath(filename: string): Promise { - await removeStaleFeedbackUploads(); - const root = join(getCacheDir(), 'feedback-uploads'); - await mkdir(root, { recursive: true }); - const dir = await mkdtemp(join(root, 'upload-')); - return join(dir, filename); -} - -function archivePathCleanupDir(archivePath: string): string { - return dirname(archivePath); -} diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index a640977fe4..d54f9fb2ab 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -1,7 +1,4 @@ -import { createHash } from 'node:crypto'; -import { appendFile, mkdir, readFile, rm, stat } from 'node:fs/promises'; import { release as osRelease, type as osType } from 'node:os'; -import { join } from 'node:path'; import type { McpServerInfo, SessionStatus, SessionUsage } from '@moonshot-ai/kimi-code-sdk'; @@ -22,22 +19,8 @@ import { withFeedbackVersionPrefix, } from '../constant/feedback'; import { isManagedUsageProvider } from '../constant/kimi-tui'; +import { submitFeedbackWithAttachments } from '../../feedback/feedback-attachments'; import { formatErrorMessage } from '../utils/event-payload'; -import { detectInstallSource } from '#/cli/update/source'; -import { detectShellEnvironment } from '#/utils/process/shell-env'; -import { getLogDir } from '#/utils/paths'; -import { - createFeedbackArchivePath, - packageCurrentCodebase, - removePackagedCodebaseArchive, - scanCodebase, - uploadPackagedCodebase, - CODEBASE_ARCHIVE_FILENAME, - SESSION_ARCHIVE_FILENAME, - type FeedbackCodebaseArchive, - type FeedbackCodebaseScanResult, - type FeedbackUploadUrlApi, -} from '../../feedback/codebase-upload'; import { openUrl } from '#/utils/open-url'; import { promptFeedbackAttachment, promptFeedbackInput } from './prompts'; import type { SlashCommandHost } from './dispatch'; @@ -46,8 +29,6 @@ import type { SlashCommandHost } from './dispatch'; // Feedback // --------------------------------------------------------------------------- -const CODEBASE_SCAN_TIMEOUT_MS = 3000; - export async function handleFeedbackCommand(host: SlashCommandHost): Promise { const fallback = (reason: string): void => { host.showStatus(reason); @@ -92,26 +73,7 @@ export async function handleFeedbackCommand(host: SlashCommandHost): Promise { - const sessionDir = knownSessionDir ?? (await resolveCurrentSessionDir(host)); - if (sessionDir === undefined) { - await logFeedbackUploadError(new Error('cannot locate the current session directory')); - return false; - } - - const target = await createFeedbackArchivePath(SESSION_ARCHIVE_FILENAME); - try { - const exported = await host.harness.exportSession({ - id: host.state.appState.sessionId, - outputPath: target.archivePath, - includeGlobalLog: true, - version: host.state.appState.version, - installSource: await detectInstallSource(), - shellEnv: detectShellEnvironment(), - }); - const archive = await archiveFromExportedSession(exported.zipPath, target.cleanupDir); - await uploadPackagedCodebase(api, archive, feedbackId, { filename: SESSION_ARCHIVE_FILENAME }); - return true; - } catch (error) { - await logFeedbackUploadError(error); - return false; - } finally { - await rm(target.cleanupDir, { recursive: true, force: true }).catch(() => {}); - } -} - -async function archiveFromExportedSession( - zipPath: string, - cleanupDir: string, -): Promise { - const data = await readFile(zipPath); - const archiveStat = await stat(zipPath); - return { - path: zipPath, - size: archiveStat.size, - sha256: createHash('sha256').update(data).digest('hex'), - fingerprint: createHash('sha256').update(data).digest('hex'), - fileCount: 1, - cleanupDir, - }; -} - -async function prepareAndUploadCodebaseArchive( - api: FeedbackUploadUrlApi, - feedbackId: number, - scan: FeedbackCodebaseScanResult | undefined, -): Promise { - if (scan === undefined) return false; - - let archive: FeedbackCodebaseArchive | undefined; - try { - archive = await packageCurrentCodebase(scan); - await uploadPackagedCodebase(api, archive, feedbackId, { filename: CODEBASE_ARCHIVE_FILENAME }); - return true; - } catch (error) { - await logFeedbackUploadError(error); - return false; - } finally { - if (archive !== undefined) { - await removePackagedCodebaseArchive(archive).catch(() => {}); - } - } -} - -async function resolveCurrentSessionDir(host: SlashCommandHost): Promise { - try { - const sessions = await host.harness.listSessions({ workDir: host.state.appState.workDir }); - return sessions.find((session) => session.id === host.state.appState.sessionId)?.sessionDir; - } catch { - return undefined; - } -} - -async function scanCodebaseForFeedback(workDir: string): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => { - controller.abort(); - }, CODEBASE_SCAN_TIMEOUT_MS); - try { - return await scanCodebase(workDir, { signal: controller.signal }); - } catch (error) { - await logFeedbackUploadError(error); - return undefined; - } finally { - clearTimeout(timer); - } -} - -async function logFeedbackUploadError(error: unknown): Promise { - try { - const logDir = getLogDir(); - await mkdir(logDir, { recursive: true }); - const message = error instanceof Error ? (error.stack ?? error.message) : String(error); - await appendFile(join(logDir, 'feedback-upload.log'), `${new Date().toISOString()} ${message}\n`); - } catch { - // best-effort logging only - } -} - -function createFeedbackUploadApi(host: SlashCommandHost): FeedbackUploadUrlApi { - return { - async createUploadUrl(input) { - const res = await host.harness.auth.createFeedbackUploadUrl(input); - if (res.kind !== 'ok') throw new Error(res.message); - return { - uploadId: res.uploadId, - parts: res.parts, - }; - }, - async completeUpload(input) { - const res = await host.harness.auth.completeFeedbackUpload({ - uploadId: input.uploadId, - parts: input.parts.map((part) => ({ partNumber: part.partNumber, etag: part.etag })), - }); - if (res.kind !== 'ok') throw new Error(res.message); - }, - }; -} - // --------------------------------------------------------------------------- // Info commands // --------------------------------------------------------------------------- diff --git a/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts b/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts index 83cf5dc6ea..8903d16268 100644 --- a/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts +++ b/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts @@ -7,12 +7,9 @@ import { promisify } from 'node:util'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { - packageCodebase, - removeStaleFeedbackUploads, - scanCodebase, - uploadPackagedCodebase, -} from '../../../src/feedback/codebase-upload'; +import { removeStaleFeedbackUploads } from '../../../src/feedback/archive'; +import { packageCodebase, scanCodebase } from '../../../src/feedback/codebase'; +import { uploadArchive } from '../../../src/feedback/upload'; const execFileAsync = promisify(execFile); @@ -21,7 +18,7 @@ afterEach(() => { vi.useRealTimers(); }); -describe('uploadPackagedCodebase', () => { +describe('uploadArchive', () => { it('requests upload parts, PUTs each part, and completes with etags', async () => { const workRoot = await mkdtemp(join(tmpdir(), 'feedback-upload-direct-')); const archivePath = join(workRoot, 'repo.zip'); @@ -40,7 +37,7 @@ describe('uploadPackagedCodebase', () => { }; try { - await uploadPackagedCodebase( + await uploadArchive( api, { path: archivePath, @@ -50,6 +47,7 @@ describe('uploadPackagedCodebase', () => { fileCount: 1, }, 3, + { filename: 'repo.zip' }, ); expect(api.createUploadUrl).toHaveBeenCalledWith({ @@ -94,7 +92,7 @@ describe('uploadPackagedCodebase', () => { }; try { - await uploadPackagedCodebase( + await uploadArchive( api, { path: archivePath, @@ -104,6 +102,7 @@ describe('uploadPackagedCodebase', () => { fileCount: 1, }, 3, + { filename: 'repo.zip' }, ); const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; @@ -128,7 +127,9 @@ describe('uploadPackagedCodebase', () => { } signal?.addEventListener( 'abort', - () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), + () => { + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + }, { once: true }, ); }), @@ -144,7 +145,7 @@ describe('uploadPackagedCodebase', () => { vi.useFakeTimers(); try { - const upload = uploadPackagedCodebase( + const upload = uploadArchive( api, { path: archivePath, @@ -154,7 +155,7 @@ describe('uploadPackagedCodebase', () => { fileCount: 1, }, 3, - { timeoutMs: 25, maxRetries: 0 }, + { filename: 'repo.zip', timeoutMs: 25, maxRetries: 0 }, ); const expectation = expect(upload).rejects.toThrow(/timed out/); await vi.advanceTimersByTimeAsync(25); @@ -188,7 +189,7 @@ describe('uploadPackagedCodebase', () => { vi.useFakeTimers(); try { - const upload = uploadPackagedCodebase( + const upload = uploadArchive( api, { path: archivePath, @@ -198,7 +199,7 @@ describe('uploadPackagedCodebase', () => { fileCount: 1, }, 3, - { timeoutMs: 10_000 }, + { filename: 'repo.zip', timeoutMs: 10_000 }, ); await vi.advanceTimersByTimeAsync(1_000); await upload; diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 41f0f98196..9c02d4d11a 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -31,11 +31,8 @@ import { import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; import type { StreamingUIController } from '#/tui/controllers/streaming-ui'; import { handleFeedbackCommand } from '#/tui/commands/info'; -import { - packageCurrentCodebase, - scanCodebase, - uploadPackagedCodebase, -} from '../../src/feedback/codebase-upload'; +import { packageCodebase, scanCodebase } from '../../src/feedback/codebase'; +import { uploadArchive } from '../../src/feedback/upload'; import { promptFeedbackAttachment, promptFeedbackInput, @@ -54,16 +51,19 @@ vi.mock('#/tui/commands/prompts', async (importOriginal) => { }; }); -vi.mock('../../src/feedback/codebase-upload', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../src/feedback/codebase', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, scanCodebase: vi.fn().mockResolvedValue(undefined), - packageCurrentCodebase: vi.fn(), - uploadPackagedCodebase: vi.fn(), + packageCodebase: vi.fn(), }; }); +vi.mock('../../src/feedback/upload', () => ({ + uploadArchive: vi.fn(), +})); + // /feedback falls back to opening GitHub Issues in a browser when not signed in // or when submission fails — stub it out so the test suite never spawns a // browser window. @@ -614,8 +614,8 @@ command = "vim" const feedbackDriver = driver as unknown as FeedbackDriver; vi.mocked(scanCodebase).mockReset(); harness.exportSession.mockReset(); - vi.mocked(packageCurrentCodebase).mockReset(); - vi.mocked(uploadPackagedCodebase).mockReset(); + vi.mocked(packageCodebase).mockReset(); + vi.mocked(uploadArchive).mockReset(); vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); @@ -636,7 +636,7 @@ command = "vim" sessionDir: '/tmp/session-a', manifest: {}, }); - vi.mocked(packageCurrentCodebase).mockResolvedValueOnce({ + vi.mocked(packageCodebase).mockResolvedValueOnce({ path: '/tmp/fake-codebase.zip', size: 4, sha256: 'hash-123', @@ -648,7 +648,7 @@ command = "vim" const codebaseUploadBlocked = new Promise((resolve) => { resolveCodebaseUpload = resolve; }); - vi.mocked(uploadPackagedCodebase).mockImplementation((_api, archive) => { + vi.mocked(uploadArchive).mockImplementation((_api, archive) => { if (archive.path === sessionZipPath) return Promise.resolve(); return codebaseUploadBlocked; }); @@ -659,20 +659,20 @@ command = "vim" }); await vi.waitFor(() => { - expect(uploadPackagedCodebase).toHaveBeenCalledTimes(2); + expect(uploadArchive).toHaveBeenCalledTimes(2); }); expect(settled).toBe(false); resolveCodebaseUpload(); await command; expect(settled).toBe(true); - expect(uploadPackagedCodebase).toHaveBeenCalledWith( + expect(uploadArchive).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ path: sessionZipPath }), 3, { filename: 'session.zip' }, ); - expect(uploadPackagedCodebase).toHaveBeenCalledWith( + expect(uploadArchive).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ path: '/tmp/fake-codebase.zip' }), 3, @@ -701,8 +701,8 @@ command = "vim" const feedbackDriver = driver as unknown as FeedbackDriver; vi.mocked(scanCodebase).mockReset(); harness.exportSession.mockReset(); - vi.mocked(packageCurrentCodebase).mockReset(); - vi.mocked(uploadPackagedCodebase).mockReset(); + vi.mocked(packageCodebase).mockReset(); + vi.mocked(uploadArchive).mockReset(); vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); @@ -721,8 +721,8 @@ command = "vim" expect(harness.exportSession).toHaveBeenCalledWith( expect.objectContaining({ id: 'ses-1', includeGlobalLog: true }), ); - expect(packageCurrentCodebase).not.toHaveBeenCalled(); - expect(uploadPackagedCodebase).toHaveBeenCalledWith( + expect(packageCodebase).not.toHaveBeenCalled(); + expect(uploadArchive).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ path: sessionZipPath }), 3, @@ -750,9 +750,9 @@ command = "vim" ); const feedbackDriver = driver as unknown as FeedbackDriver; vi.mocked(scanCodebase).mockReset(); - vi.mocked(packageCurrentCodebase).mockReset(); + vi.mocked(packageCodebase).mockReset(); harness.exportSession.mockReset(); - vi.mocked(uploadPackagedCodebase).mockReset(); + vi.mocked(uploadArchive).mockReset(); vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); @@ -771,13 +771,13 @@ command = "vim" sessionDir: '/tmp/session-a', manifest: {}, }); - vi.mocked(packageCurrentCodebase).mockRejectedValueOnce(new Error('zip failed')); + vi.mocked(packageCodebase).mockRejectedValueOnce(new Error('zip failed')); await handleFeedbackCommand(feedbackDriver as any); const calls = harness.auth.submitFeedback.mock.calls as unknown as Array<[Record]>; expect(calls[0]?.[0]?.['info']).toBeUndefined(); - expect(uploadPackagedCodebase).toHaveBeenCalledWith( + expect(uploadArchive).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ path: sessionZipPath }), 3, @@ -814,14 +814,14 @@ command = "vim" fingerprint: 'fp-123', usedGitIgnore: false, } as any); - vi.mocked(packageCurrentCodebase).mockResolvedValueOnce({ + vi.mocked(packageCodebase).mockResolvedValueOnce({ path: '/tmp/fake-codebase.zip', size: 4, sha256: 'hash-123', fingerprint: 'fp-123', fileCount: 1, }); - vi.mocked(uploadPackagedCodebase).mockRejectedValueOnce(new Error('upload failed')); + vi.mocked(uploadArchive).mockRejectedValueOnce(new Error('upload failed')); await handleFeedbackCommand(feedbackDriver as any);