mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-20 22:26:23 +00:00
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:
parent
820d77ab4c
commit
e736349a7c
32 changed files with 2422 additions and 42 deletions
6
.changeset/feedback-attachments.md
Normal file
6
.changeset/feedback-attachments.md
Normal 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
1
.gitignore
vendored
|
|
@ -15,6 +15,7 @@ coverage/
|
|||
plugins/cdn/
|
||||
.worktrees/
|
||||
.kimi-code/local.toml
|
||||
.kimi-sandbox/
|
||||
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
|
|
|
|||
72
apps/kimi-code/src/feedback/archive.ts
Normal file
72
apps/kimi-code/src/feedback/archive.ts
Normal 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);
|
||||
}
|
||||
92
apps/kimi-code/src/feedback/codebase/filter.ts
Normal file
92
apps/kimi-code/src/feedback/codebase/filter.ts
Normal 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;
|
||||
}
|
||||
3
apps/kimi-code/src/feedback/codebase/index.ts
Normal file
3
apps/kimi-code/src/feedback/codebase/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './packager';
|
||||
export * from './scanner';
|
||||
export * from './types';
|
||||
98
apps/kimi-code/src/feedback/codebase/packager.ts
Normal file
98
apps/kimi-code/src/feedback/codebase/packager.ts
Normal 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');
|
||||
}
|
||||
217
apps/kimi-code/src/feedback/codebase/scanner.ts
Normal file
217
apps/kimi-code/src/feedback/codebase/scanner.ts
Normal 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('/');
|
||||
}
|
||||
19
apps/kimi-code/src/feedback/codebase/types.ts
Normal file
19
apps/kimi-code/src/feedback/codebase/types.ts
Normal 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;
|
||||
}
|
||||
183
apps/kimi-code/src/feedback/feedback-attachments.ts
Normal file
183
apps/kimi-code/src/feedback/feedback-attachments.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
208
apps/kimi-code/src/feedback/upload.ts
Normal file
208
apps/kimi-code/src/feedback/upload.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, '…'))];
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1836,6 +1836,9 @@ export class KimiTUI {
|
|||
spinner.setText(currentTheme.fg(tone, `${symbol} ${finalLabel}`));
|
||||
this.state.ui.requestRender();
|
||||
},
|
||||
setLabel: (nextLabel) => {
|
||||
spinner.setLabel(nextLabel);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 })),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ Prompt 模式在目标完成时以退出码 `0` 退出,在目标阻塞时以 `
|
|||
| `/mcp` | — | 列出当前会话中的 MCP server 及连接状态 | 是 |
|
||||
| `/plugins` | — | 打开交互式 plugin 管理器 | 是 |
|
||||
| `/version` | — | 显示 Kimi Code CLI 版本号 | 是 |
|
||||
| `/feedback` | — | 提交反馈以改进 Kimi Code CLI | 是 |
|
||||
| `/feedback` | — | 提交反馈,可附加诊断日志和代码库上下文 | 是 |
|
||||
|
||||
## 退出
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -80,6 +80,12 @@ export type {
|
|||
} from '@moonshot-ai/agent-core';
|
||||
|
||||
export type {
|
||||
KimiAuthCompleteFeedbackUploadInput,
|
||||
KimiAuthCompleteFeedbackUploadPart,
|
||||
KimiAuthCreateFeedbackUploadUrlInput,
|
||||
KimiAuthCreateFeedbackUploadUrlOk,
|
||||
KimiAuthCreateFeedbackUploadUrlResult,
|
||||
KimiAuthFeedbackUploadPart,
|
||||
KimiAuthLoginResult,
|
||||
KimiAuthLogoutResult,
|
||||
KimiAuthSubmitFeedbackInput,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
171
packages/oauth/src/managed-feedback-upload.ts
Normal file
171
packages/oauth/src/managed-feedback-upload.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
154
packages/oauth/test/managed-feedback-upload.test.ts
Normal file
154
packages/oauth/test/managed-feedback-upload.test.ts
Normal 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"' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue