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).
This commit is contained in:
7Sageer 2026-06-26 16:15:08 +08:00 committed by GitHub
parent 820d77ab4c
commit e736349a7c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 2422 additions and 42 deletions

View file

@ -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.

1
.gitignore vendored
View file

@ -15,6 +15,7 @@ coverage/
plugins/cdn/
.worktrees/
.kimi-code/local.toml
.kimi-sandbox/
Dockerfile
docker-compose.yml

View file

@ -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<void> {
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<string> {
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);
}

View file

@ -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<string> = 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<string> = new Set([
'.ssh',
'.gnupg',
'.aws',
'.kube',
'.docker',
]);
const SENSITIVE_FILE_NAMES: ReadonlySet<string> = 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<string> = 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;
}

View file

@ -0,0 +1,3 @@
export * from './packager';
export * from './scanner';
export * from './types';

View file

@ -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<FeedbackArchive> {
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<FeedbackArchive> {
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<void>((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');
}

View file

@ -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<FeedbackCodebaseScanResult> {
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<boolean> {
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<CollectedFiles> {
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<CollectedFiles> {
const files: FeedbackCodebaseFile[] = [];
let exceedsLimit: FeedbackCodebaseLimitExceeded | undefined;
let stopped = false;
let totalSize = 0;
async function walk(dir: string): Promise<void> {
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<FeedbackCodebaseFile | null> {
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('/');
}

View file

@ -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;
}

View file

@ -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<boolean> {
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<boolean> {
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<boolean> {
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<FeedbackArchive>,
): Promise<boolean> {
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<FeedbackArchive> {
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<string | undefined> {
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<FeedbackCodebaseScanResult | undefined> {
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<void> {
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);
},
};
}

View file

@ -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<CreateFeedbackUploadUrlResult>;
completeUpload(input: CompleteFeedbackUploadUrlInput): Promise<void>;
}
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<void> {
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<CompletedUploadPart[]> {
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<void> {
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<CompletedUploadPart> {
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<CompletedUploadPart> {
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<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

View file

@ -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<voi
return;
}
const content = await promptFeedbackInput(host);
if (content === undefined) {
// Stage 1: collect the free-form feedback text.
const input = await promptFeedbackInput(host);
if (input === undefined) {
host.showStatus(FEEDBACK_STATUS_CANCELLED);
return;
}
// Stage 2: ask whether to attach diagnostics (logs / codebase).
const level = await promptFeedbackAttachment(host);
if (level === undefined) {
host.showStatus(FEEDBACK_STATUS_CANCELLED);
return;
}
const version = withFeedbackVersionPrefix(host.state.appState.version);
const spinner = host.showLoginProgressSpinner(FEEDBACK_STATUS_SUBMITTING);
const res = await host.harness.auth.submitFeedback({
content,
content: input.value,
sessionId: host.state.appState.sessionId,
version: withFeedbackVersionPrefix(host.state.appState.version),
version,
os: `${osType()} ${osRelease()}`,
model: host.state.appState.model.length > 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);
}
}
// ---------------------------------------------------------------------------

View file

@ -57,16 +57,58 @@ export function promptLogoutProviderSelection(
});
}
export function promptFeedbackInput(host: SlashCommandHost): Promise<string | undefined> {
export interface FeedbackPromptResult {
readonly value: string;
}
export function promptFeedbackInput(host: SlashCommandHost): Promise<FeedbackPromptResult | undefined> {
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<FeedbackAttachmentLevel | undefined> {
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,

View file

@ -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}`));
}
}
}

View file

@ -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, '…'))];

View file

@ -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 {

View file

@ -1836,6 +1836,9 @@ export class KimiTUI {
spinner.setText(currentTheme.fg(tone, `${symbol} ${finalLabel}`));
this.state.ui.requestRender();
},
setLabel: (nextLabel) => {
spinner.setLabel(nextLabel);
},
};
}

View file

@ -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;

View file

@ -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<Response>((_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();
});
});

View file

@ -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);
});
});

View file

@ -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<typeof import('#/tui/commands/prompts')>();
return { ...actual, promptFeedbackInput: vi.fn() };
return {
...actual,
promptFeedbackInput: vi.fn(),
promptFeedbackAttachment: vi.fn(),
};
});
vi.mock('../../src/feedback/codebase', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/feedback/codebase')>();
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<void>;
promptFeedbackInput(): Promise<string | undefined>;
promptFeedbackInput(): Promise<FeedbackPromptResult | undefined>;
}
interface ModelSelectorDriver extends MessageDriver {
@ -218,6 +239,12 @@ function makeHarness(session = makeSession(), overrides: Record<string, unknown>
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<string, unknown>
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<string> {
return dir;
}
async function makeExportedSessionZip(content = 'session zip'): Promise<string> {
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<string, never>;
}>((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<void>((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<string, unknown>]>;
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,

View file

@ -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 })),
},
};
}

View file

@ -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

View file

@ -103,7 +103,7 @@ Prompt 模式在目标完成时以退出码 `0` 退出,在目标阻塞时以 `
| `/mcp` | — | 列出当前会话中的 MCP server 及连接状态 | 是 |
| `/plugins` | — | 打开交互式 plugin 管理器 | 是 |
| `/version` | — | 显示 Kimi Code CLI 版本号 | 是 |
| `/feedback` | — | 提交反馈以改进 Kimi Code CLI | 是 |
| `/feedback` | — | 提交反馈,可附加诊断日志和代码库上下文 | 是 |
## 退出

View file

@ -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<string, unknown>;
}
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<KimiOAuthLoginOptions, 'provisionConfig'>;
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<KimiAuthCreateFeedbackUploadUrlResult> {
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<FetchCompleteFeedbackUploadResult> {
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,
{

View file

@ -80,6 +80,12 @@ export type {
} from '@moonshot-ai/agent-core';
export type {
KimiAuthCompleteFeedbackUploadInput,
KimiAuthCompleteFeedbackUploadPart,
KimiAuthCreateFeedbackUploadUrlInput,
KimiAuthCreateFeedbackUploadUrlOk,
KimiAuthCreateFeedbackUploadUrlResult,
KimiAuthFeedbackUploadPart,
KimiAuthLoginResult,
KimiAuthLogoutResult,
KimiAuthSubmitFeedbackInput,

View file

@ -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<FetchMock>(async () => new Response('', { status: 200 }));
const fetchMock = vi.fn<FetchMock>(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<FetchMock>(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',
});
});

View file

@ -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,

View file

@ -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<FetchCreateFeedbackUploadUrlResult> {
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<FetchCompleteFeedbackUploadResult> {
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<string, unknown>;
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<string, unknown>;
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<string, unknown>)[key];
}
function readNumberField(payload: unknown, key: string): number | undefined {
if (typeof payload !== 'object' || payload === null) return undefined;
const value = (payload as Record<string, unknown>)[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<string, unknown>)[key];
return typeof value === 'string' && value.length > 0 ? value : undefined;
}

View file

@ -15,10 +15,13 @@ export interface SubmitFeedbackBody {
readonly version: string;
readonly os: string;
readonly model: string | null;
readonly contact?: string;
readonly info?: Record<string, unknown>;
}
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<string, unknown>;
const value = record['feedback_id'] ?? record['id'];
return typeof value === 'number' && Number.isInteger(value) ? value : undefined;
}

View file

@ -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<TConfig = unknown> {
readonly baseUrl?: string | undefined;
} = {},
): Promise<FetchSubmitFeedbackResult> {
return this.withAccessToken(
providerName,
options,
(accessToken) => fetchSubmitFeedback(managedFeedbackUrl(options.baseUrl), accessToken, body),
);
}
private async withAccessToken<T>(
providerName: string | undefined,
options: {
readonly oauthRef?: KimiOAuthTokenRef | undefined;
readonly baseUrl?: string | undefined;
},
run: (accessToken: string) => Promise<T>,
): Promise<T | { readonly kind: 'error'; readonly message: string }> {
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<TConfig = unknown> {
}
}
async createFeedbackUploadUrl(
body: CreateFeedbackUploadUrlBody,
providerName?: string | undefined,
options: {
readonly oauthRef?: KimiOAuthTokenRef | undefined;
readonly baseUrl?: string | undefined;
} = {},
): Promise<FetchCreateFeedbackUploadUrlResult> {
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<FetchCompleteFeedbackUploadResult> {
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 {

View file

@ -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<string, string>);
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"' },
],
});
});
});

View file

@ -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);