From e736349a7c8ff55b73e05cc0192dfaf0114745fa Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 26 Jun 2026 16:15:08 +0800 Subject: [PATCH] feat(feedback): support attaching logs and codebase (#1120) * 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. * fix(feedback): fall back to logs when codebase scan fails * tiny fix * fix(feedback): make diagnostic uploads partial-safe * refactor(feedback): reuse harness session export and normalize upload url types * docs(slash-commands): note optional feedback attachments * 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). --- .changeset/feedback-attachments.md | 6 + .gitignore | 1 + apps/kimi-code/src/feedback/archive.ts | 72 ++++ .../kimi-code/src/feedback/codebase/filter.ts | 92 ++++ apps/kimi-code/src/feedback/codebase/index.ts | 3 + .../src/feedback/codebase/packager.ts | 98 +++++ .../src/feedback/codebase/scanner.ts | 217 ++++++++++ apps/kimi-code/src/feedback/codebase/types.ts | 19 + .../src/feedback/feedback-attachments.ts | 183 ++++++++ apps/kimi-code/src/feedback/upload.ts | 208 +++++++++ apps/kimi-code/src/tui/commands/info.ts | 41 +- 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 | 406 ++++++++++++++++++ .../components/dialogs/choice-picker.test.ts | 34 +- .../test/tui/kimi-tui-message-flow.test.ts | 355 ++++++++++++++- .../kimi-code/test/tui/message-replay.test.ts | 2 +- docs/en/reference/slash-commands.md | 2 +- docs/zh/reference/slash-commands.md | 2 +- packages/node-sdk/src/auth.ts | 89 ++++ packages/node-sdk/src/index.ts | 6 + packages/node-sdk/test/auth-facade.test.ts | 82 +++- 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 +- 32 files changed, 2422 insertions(+), 42 deletions(-) create mode 100644 .changeset/feedback-attachments.md create mode 100644 apps/kimi-code/src/feedback/archive.ts create mode 100644 apps/kimi-code/src/feedback/codebase/filter.ts create mode 100644 apps/kimi-code/src/feedback/codebase/index.ts create mode 100644 apps/kimi-code/src/feedback/codebase/packager.ts create mode 100644 apps/kimi-code/src/feedback/codebase/scanner.ts create mode 100644 apps/kimi-code/src/feedback/codebase/types.ts create mode 100644 apps/kimi-code/src/feedback/feedback-attachments.ts create mode 100644 apps/kimi-code/src/feedback/upload.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/.changeset/feedback-attachments.md b/.changeset/feedback-attachments.md new file mode 100644 index 000000000..4c5589db1 --- /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/.gitignore b/.gitignore index ae25ce57b..46dca949e 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/archive.ts b/apps/kimi-code/src/feedback/archive.ts new file mode 100644 index 000000000..439f68d1b --- /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/filter.ts b/apps/kimi-code/src/feedback/codebase/filter.ts new file mode 100644 index 000000000..1df5976e9 --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase/filter.ts @@ -0,0 +1,92 @@ +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', + '.npmrc', + '.envrc', + '.yarnrc', + '.yarnrc.yml', +]); + +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/index.ts b/apps/kimi-code/src/feedback/codebase/index.ts new file mode 100644 index 000000000..7ef51870d --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase/index.ts @@ -0,0 +1,3 @@ +export * from './packager'; +export * from './scanner'; +export * from './types'; diff --git a/apps/kimi-code/src/feedback/codebase/packager.ts b/apps/kimi-code/src/feedback/codebase/packager.ts new file mode 100644 index 000000000..cd4bf2c66 --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase/packager.ts @@ -0,0 +1,98 @@ +import { createHash } from 'node:crypto'; +import { createWriteStream } from 'node:fs'; +import { mkdir, rm, stat } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { ZipFile } from 'yazl'; + +import type { FeedbackArchive } from '../archive'; +import type { FeedbackCodebaseScanResult } from './types'; + +interface PackageEntry { + readonly absolutePath: string; + readonly archivePath: string; + readonly size: number; + readonly mtimeMs: number; +} + +/** + * 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); +} + +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); + + try { + 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, + }; + } 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; + } +} + +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'); +} diff --git a/apps/kimi-code/src/feedback/codebase/scanner.ts b/apps/kimi-code/src/feedback/codebase/scanner.ts new file mode 100644 index 000000000..6df420217 --- /dev/null +++ b/apps/kimi-code/src/feedback/codebase/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/types.ts b/apps/kimi-code/src/feedback/codebase/types.ts new file mode 100644 index 000000000..a611d93a3 --- /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 000000000..c372e4d11 --- /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/upload.ts b/apps/kimi-code/src/feedback/upload.ts new file mode 100644 index 000000000..629fc6a6f --- /dev/null +++ b/apps/kimi-code/src/feedback/upload.ts @@ -0,0 +1,208 @@ +import { createReadStream } from 'node:fs'; +import { Readable } from 'node:stream'; + +import type { FeedbackArchive } from './archive'; + +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 interface FeedbackUploadPart { + readonly partNumber: number; + readonly url: string; + readonly method: string; + readonly size: number; +} + +export interface CreateFeedbackUploadUrlInput { + readonly feedbackId: number; + readonly filename: string; + readonly size: number; + readonly sha256: string; +} + +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; +} + +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). */ + 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 uploadArchive( + api: FeedbackUploadUrlApi, + archive: FeedbackArchive, + feedbackId: number, + options: UploadArchiveOptions, +): Promise { + if (archive.size > MAX_ARCHIVE_SIZE) { + throw new Error( + `Failed to upload archive: size ${archive.size} exceeds maximum allowed size ${MAX_ARCHIVE_SIZE}.`, + ); + } + const created = await api.createUploadUrl({ + feedbackId, + filename: options.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 }); +} + +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: UploadArchiveOptions, +): 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: UploadArchiveOptions, +): 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: UploadArchiveOptions, +): 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: part.method, + 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); + }); +} diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index 73cc99824..d54f9fb2a 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -12,14 +12,17 @@ import { FEEDBACK_STATUS_NOT_SIGNED_IN, FEEDBACK_STATUS_SUBMITTING, FEEDBACK_STATUS_SUCCESS, + FEEDBACK_STATUS_UPLOAD_FAILED, FEEDBACK_TELEMETRY_EVENT, + feedbackIdLine, feedbackSessionLine, withFeedbackVersionPrefix, } from '../constant/feedback'; import { isManagedUsageProvider } from '../constant/kimi-tui'; +import { submitFeedbackWithAttachments } from '../../feedback/feedback-attachments'; import { formatErrorMessage } from '../utils/event-payload'; import { openUrl } from '#/utils/open-url'; -import { promptFeedbackInput } from './prompts'; +import { promptFeedbackAttachment, promptFeedbackInput } from './prompts'; import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- @@ -39,30 +42,46 @@ 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); + // Stage 3: prepare and upload each requested attachment independently. + const attachmentFailed = await submitFeedbackWithAttachments(host, res.feedbackId, level); + + 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 (attachmentFailed) { + host.showStatus(FEEDBACK_STATUS_UPLOAD_FAILED); + } } // --------------------------------------------------------------------------- diff --git a/apps/kimi-code/src/tui/commands/prompts.ts b/apps/kimi-code/src/tui/commands/prompts.ts index 67fd89a29..0bdbb8899 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 fb5094af1..c85339d90 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 3fefe86c0..38ac3bd9e 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 9e8d621f5..d72a62def 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 409a9eb2b..83da4b598 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 985e576e4..6d96eb2d5 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 000000000..8903d1626 --- /dev/null +++ b/apps/kimi-code/test/feedback/codebase-upload/codebase-upload.test.ts @@ -0,0 +1,406 @@ +import { execFile } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +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 { removeStaleFeedbackUploads } from '../../../src/feedback/archive'; +import { packageCodebase, scanCodebase } from '../../../src/feedback/codebase'; +import { uploadArchive } from '../../../src/feedback/upload'; + +const execFileAsync = promisify(execFile); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +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'); + 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: 'PUT', size: 5 }], + })), + completeUpload: vi.fn(async () => {}), + }; + + try { + await uploadArchive( + api, + { + path: archivePath, + size: 5, + sha256: 'hash', + fingerprint: 'fingerprint', + fileCount: 1, + }, + 3, + { filename: 'repo.zip' }, + ); + + 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('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 uploadArchive( + api, + { + path: archivePath, + size: 5, + sha256: 'hash', + fingerprint: 'fingerprint', + fileCount: 1, + }, + 3, + { filename: 'repo.zip' }, + ); + + 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'); + 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', method: 'PUT', size: 5 }], + })), + completeUpload: vi.fn(async () => {}), + }; + + vi.useFakeTimers(); + try { + const upload = uploadArchive( + api, + { + path: archivePath, + size: 5, + sha256: 'hash', + fingerprint: 'fingerprint', + fileCount: 1, + }, + 3, + { filename: 'repo.zip', 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', method: 'PUT', size: 5 }], + })), + completeUpload: vi.fn(async () => {}), + }; + + vi.useFakeTimers(); + try { + const upload = uploadArchive( + api, + { + path: archivePath, + size: 5, + sha256: 'hash', + fingerprint: 'fingerprint', + fileCount: 1, + }, + 3, + { filename: 'repo.zip', 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('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('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, '.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 }); + 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('.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 }); + } + }); + + 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 }); + } + }); +}); + +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/components/dialogs/choice-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts index 367a85578..ec590e252 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 df4b794f2..9c02d4d11 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,18 +31,39 @@ 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 { packageCodebase, scanCodebase } from '../../src/feedback/codebase'; +import { uploadArchive } from '../../src/feedback/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', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + scanCodebase: vi.fn().mockResolvedValue(undefined), + 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. @@ -73,7 +94,7 @@ interface MessageDriver { interface FeedbackDriver extends MessageDriver { handleFeedbackCommand(): Promise; - promptFeedbackInput(): Promise; + promptFeedbackInput(): Promise; } interface ModelSelectorDriver extends MessageDriver { @@ -218,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(), @@ -234,8 +261,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, }), ), }, @@ -329,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)) { @@ -472,8 +510,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 +526,309 @@ command = "vim" }), ); expect(harness.track).toHaveBeenCalledWith('feedback_submitted', undefined); + const transcript = stripSgr(renderTranscript(driver)); + 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 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: {}, + }); + }; + }); + harness.exportSession.mockImplementationOnce(() => exportBlocked); + + let settled = false; + const command = handleFeedbackCommand(feedbackDriver as any).then(() => { + settled = true; + }); + + await vi.waitFor(() => { + 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( + harness.exportSession.mock.invocationCallOrder[0]!, + ); + expect(settled).toBe(false); + + resolveExport(); + await command; + }); + + 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(scanCodebase).mockReset(); + harness.exportSession.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 }); + harness.listSessions.mockResolvedValueOnce([ + { id: 'ses-1', sessionDir: '/tmp/session-a' }, + ] as never); + + vi.mocked(scanCodebase).mockResolvedValueOnce({ + root: '/tmp/proj-a', + files: [{ path: 'keep.ts', size: 4 }], + fingerprint: 'fp-123', + usedGitIgnore: false, + } as any); + const sessionZipPath = await makeExportedSessionZip(); + harness.exportSession.mockResolvedValueOnce({ + zipPath: sessionZipPath, + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + }); + vi.mocked(packageCodebase).mockResolvedValueOnce({ + path: '/tmp/fake-codebase.zip', + size: 4, + sha256: 'hash-123', + fingerprint: 'fp-123', + fileCount: 1, + }); + + let resolveCodebaseUpload!: () => void; + const codebaseUploadBlocked = new Promise((resolve) => { + resolveCodebaseUpload = resolve; + }); + vi.mocked(uploadArchive).mockImplementation((_api, archive) => { + if (archive.path === sessionZipPath) return Promise.resolve(); + return codebaseUploadBlocked; + }); + + let settled = false; + const command = handleFeedbackCommand(feedbackDriver as any).then(() => { + settled = true; + }); + + await vi.waitFor(() => { + expect(uploadArchive).toHaveBeenCalledTimes(2); + }); + expect(settled).toBe(false); + + resolveCodebaseUpload(); + await command; + expect(settled).toBe(true); + expect(uploadArchive).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ path: sessionZipPath }), + 3, + { filename: 'session.zip' }, + ); + expect(uploadArchive).toHaveBeenCalledWith( + 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 () => { + 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(); + harness.exportSession.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 }); + harness.listSessions.mockResolvedValueOnce([{ id: 'ses-1', sessionDir: '/tmp/session-a' }] as never); + const sessionZipPath = await makeExportedSessionZip(); + vi.mocked(scanCodebase).mockRejectedValueOnce(new Error('scan failed')); + harness.exportSession.mockResolvedValueOnce({ + zipPath: sessionZipPath, + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + }); + + await handleFeedbackCommand(feedbackDriver as any); + + expect(harness.exportSession).toHaveBeenCalledWith( + expect.objectContaining({ id: 'ses-1', includeGlobalLog: true }), + ); + expect(packageCodebase).not.toHaveBeenCalled(); + expect(uploadArchive).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ path: sessionZipPath }), + 3, + { filename: 'session.zip' }, + ); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback ID: 3'); + expect(transcript).toContain('attachment upload failed'); + }); + + 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(packageCodebase).mockReset(); + harness.exportSession.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 }); + harness.listSessions.mockResolvedValueOnce([{ id: 'ses-1', sessionDir: '/tmp/session-a' }] as never); + const sessionZipPath = await makeExportedSessionZip(); + + vi.mocked(scanCodebase).mockResolvedValueOnce({ + root: '/tmp/proj-a', + files: [{ path: 'keep.ts', size: 4 }], + fingerprint: 'fp-123', + usedGitIgnore: false, + } as any); + harness.exportSession.mockResolvedValueOnce({ + zipPath: sessionZipPath, + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + }); + 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(uploadArchive).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ path: sessionZipPath }), + 3, + { filename: 'session.zip' }, + ); + 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(packageCodebase).mockResolvedValueOnce({ + path: '/tmp/fake-codebase.zip', + size: 4, + sha256: 'hash-123', + fingerprint: 'fp-123', + fileCount: 1, + }); + vi.mocked(uploadArchive).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 +847,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 a91569681..7ffd6fdde 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/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index d95b63dda..f74812c0e 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 d92d903b7..218010835 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` | — | 提交反馈,可附加诊断日志和代码库上下文 | 是 | ## 退出 diff --git a/packages/node-sdk/src/auth.ts b/packages/node-sdk/src/auth.ts index 014954dd7..4f02747f8 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 FetchFeedbackUploadError, type FetchSubmitFeedbackResult, type KimiHostIdentity, type KimiOAuthLoginOptions, @@ -31,8 +33,44 @@ 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 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 { @@ -149,6 +187,57 @@ 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); + const result = await 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, + }, + ); + 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( + 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 ca3a6bfe6..243535685 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -80,6 +80,12 @@ export type { } from '@moonshot-ai/agent-core'; 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 56d6ade5b..978bd2578 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,63 @@ 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' } }, + }); + }); + + 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', }); }); diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 2c515393d..88631bcbf 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 000000000..0ae0c7e37 --- /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 15c5a65cc..0dcfca0b2 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 e5726b677..28a7a19c3 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 000000000..c33e8c36f --- /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 499945c21..9e618c9df 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);