diff --git a/packages/kap-server/src/protocol/rest-feedback.ts b/packages/kap-server/src/protocol/rest-feedback.ts new file mode 100644 index 0000000000..d11fd72872 --- /dev/null +++ b/packages/kap-server/src/protocol/rest-feedback.ts @@ -0,0 +1,79 @@ +/** + * POST /v1/feedback + * POST /v1/feedback/upload_url + * POST /v1/feedback/upload_complete + * + * Wire shapes for the user feedback endpoint. The wire uses snake_case (REST + * convention in this repo). Submissions are forwarded to the managed + * collection backend with the managed provider's OAuth token (see + * `src/services/feedback/`); `content` / `contact` / `info` / `session_id` + * match the backend's metadata contract, and the attachment routes mirror + * its presigned-upload flow verbatim. + */ + +import { z } from 'zod'; + +/** Feedback category: bug report, feature request, or anything else. */ +export const feedbackTypeSchema = z.enum(['bug', 'feature', 'other']); +export type FeedbackType = z.infer; + +/** Attached diagnostics: nothing extra, local logs, or local logs plus the codebase. */ +export const feedbackDiagnosticsSchema = z.enum(['none', 'logs', 'logs_and_codebase']); +export type FeedbackDiagnostics = z.infer; + +const feedbackInfoReservedKeys = ['type', 'title', 'diagnostics', 'agent_id'] as const; +const feedbackInfoSchema = z.record(z.string(), z.unknown()).superRefine((info, ctx) => { + for (const key of feedbackInfoReservedKeys) { + if (Object.hasOwn(info, key)) { + ctx.addIssue({ + code: 'custom', + message: `${key} is reserved; use the top-level field instead`, + path: [key], + }); + } + } +}); + +export const feedbackSubmitBodySchema = z.object({ + content: z.string().min(1).max(20000), + session_id: z.string().min(1).max(256), + type: feedbackTypeSchema.optional(), + title: z.string().min(1).max(256).optional(), + contact: z.string().min(1).max(256).optional(), + diagnostics: feedbackDiagnosticsSchema.optional(), + agent_id: z.string().min(1).max(256).optional(), + info: feedbackInfoSchema.optional(), +}); +export type FeedbackSubmitBody = z.infer; + +export const feedbackSubmitResponseSchema = z.object({ + feedback_id: z.number().int(), +}); +export type FeedbackSubmitResponse = z.infer; + +export const feedbackUploadUrlBodySchema = z.object({ + feedback_id: z.number().int(), + file_name: z.string().min(1).max(256), + file_size: z.number().int().nonnegative(), + file_hash: z.string().min(1).max(128), +}); +export type FeedbackUploadUrlBody = z.infer; + +export const feedbackUploadPartSchema = z.object({ + part_number: z.number().int(), + url: z.string(), + method: z.string(), + size: z.number().int(), +}); + +export const feedbackUploadUrlResponseSchema = z.object({ + upload_id: z.number().int(), + parts: z.array(feedbackUploadPartSchema), +}); +export type FeedbackUploadUrlResponse = z.infer; + +export const feedbackUploadCompleteBodySchema = z.object({ + upload_id: z.number().int(), + parts: z.array(z.object({ part_number: z.number().int(), etag: z.string().min(1) })).min(1), +}); +export type FeedbackUploadCompleteBody = z.infer; diff --git a/packages/kap-server/src/routes/feedback.ts b/packages/kap-server/src/routes/feedback.ts new file mode 100644 index 0000000000..f84a6a9319 --- /dev/null +++ b/packages/kap-server/src/routes/feedback.ts @@ -0,0 +1,144 @@ +/** + * `/feedback` route handlers — user feedback submission and attachment uploads. + * + * Thin adapters over `IFeedbackService` (see `src/services/feedback/`): the + * validated wire body is forwarded to the managed collection backend with the + * managed provider's OAuth token. `not_signed_in` maps to `40111`, backend + * failures to `50001`. + * + * POST /feedback body: FeedbackSubmitBody data: FeedbackSubmitResponse + * POST /feedback/upload_url body: FeedbackUploadUrlBody data: FeedbackUploadUrlResponse + * POST /feedback/upload_complete body: FeedbackUploadCompleteBody data: null + */ + +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { requestLog } from '../lib/requestLog'; +import { defineRoute } from '../middleware/defineRoute'; +import { ErrorCode } from '../protocol/error-codes'; +import { + feedbackSubmitBodySchema, + feedbackSubmitResponseSchema, + feedbackUploadCompleteBodySchema, + feedbackUploadUrlBodySchema, + feedbackUploadUrlResponseSchema, +} from '../protocol/rest-feedback'; +import { FeedbackError, type IFeedbackService } from '../services/feedback/feedback'; + +interface FeedbackRouteHost { + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +function sendError(req: { id: string }, reply: { send(payload: unknown): unknown }, error: unknown): void { + if (error instanceof FeedbackError) { + const code = error.reason === 'not_signed_in' ? ErrorCode.AUTH_TOKEN_MISSING : ErrorCode.INTERNAL_ERROR; + reply.send(errEnvelope(code, error.message, req.id, error.stack)); + return; + } + requestLog(req)?.error({ err: error }, 'feedback request failed'); + reply.send( + errEnvelope( + ErrorCode.INTERNAL_ERROR, + error instanceof Error ? error.message : String(error), + req.id, + error instanceof Error ? error.stack : undefined, + ), + ); +} + +export function registerFeedbackRoutes(app: FeedbackRouteHost, feedback: IFeedbackService): void { + const submitRoute = defineRoute( + { + method: 'POST', + path: '/feedback', + body: feedbackSubmitBodySchema, + success: { data: feedbackSubmitResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.AUTH_TOKEN_MISSING]: {}, + [ErrorCode.INTERNAL_ERROR]: {}, + }, + description: 'Submit user feedback; forwarded to the managed collection backend', + tags: ['feedback'], + }, + async (req, reply) => { + try { + const { feedbackId } = await feedback.submit(req.body); + reply.send(okEnvelope({ feedback_id: feedbackId }, req.id)); + } catch (error) { + sendError(req, reply, error); + } + }, + ); + app.post( + submitRoute.path, + submitRoute.options, + submitRoute.handler as Parameters[2], + ); + + const uploadUrlRoute = defineRoute( + { + method: 'POST', + path: '/feedback/upload_url', + body: feedbackUploadUrlBodySchema, + success: { data: feedbackUploadUrlResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.AUTH_TOKEN_MISSING]: {}, + [ErrorCode.INTERNAL_ERROR]: {}, + }, + description: 'Create presigned upload URLs for a feedback attachment', + tags: ['feedback'], + }, + async (req, reply) => { + try { + const result = await feedback.createUploadUrl(req.body); + reply.send(okEnvelope(result, req.id)); + } catch (error) { + sendError(req, reply, error); + } + }, + ); + app.post( + uploadUrlRoute.path, + uploadUrlRoute.options, + uploadUrlRoute.handler as Parameters[2], + ); + + const uploadCompleteRoute = defineRoute( + { + method: 'POST', + path: '/feedback/upload_complete', + body: feedbackUploadCompleteBodySchema, + success: { data: z.null() }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.AUTH_TOKEN_MISSING]: {}, + [ErrorCode.INTERNAL_ERROR]: {}, + }, + description: 'Mark a feedback attachment upload as complete', + tags: ['feedback'], + }, + async (req, reply) => { + try { + await feedback.completeUpload(req.body); + reply.send(okEnvelope(null, req.id)); + } catch (error) { + sendError(req, reply, error); + } + }, + ); + app.post( + uploadCompleteRoute.path, + uploadCompleteRoute.options, + uploadCompleteRoute.handler as Parameters[2], + ); +} diff --git a/packages/kap-server/src/routes/registerApiV1Routes.ts b/packages/kap-server/src/routes/registerApiV1Routes.ts index 307578094c..ea424292c7 100644 --- a/packages/kap-server/src/routes/registerApiV1Routes.ts +++ b/packages/kap-server/src/routes/registerApiV1Routes.ts @@ -20,10 +20,12 @@ import { registerApprovalsRoutes } from './approvals'; import { registerAuthRoute } from './auth'; import { registerConfigRoutes } from './config'; import { registerConnectionsRoutes } from './connections'; +import { registerFeedbackRoutes } from './feedback'; import { registerFilesRoutes } from './files'; import { registerFsRoutes } from './fs'; import { registerGuiStoreRoutes } from './guiStore'; import { registerMessagesRoutes } from './messages'; +import type { IFeedbackService } from '../services/feedback/feedback'; import type { IGuiStoreService } from '../services/guiStore/guiStore'; import type { ISnapshotReader } from '../services/snapshot'; import { registerDebugRoutes } from '../transport/registerDebugRoutes'; @@ -66,6 +68,7 @@ export interface RegisterApiV1RoutesOptions { readonly enableShutdown?: boolean; readonly enableTerminals?: boolean; readonly guiStore: IGuiStoreService; + readonly feedback: IFeedbackService; readonly onShutdown: () => void; readonly connectionRegistry: IConnectionRegistry; readonly broadcaster: SessionEventBroadcaster; @@ -147,6 +150,10 @@ export async function registerApiV1Routes( registerFilesRoutes(apiV1 as unknown as Parameters[0], core); registerFsRoutes(apiV1 as unknown as Parameters[0], core); registerGuiStoreRoutes(apiV1 as unknown as Parameters[0], opts.guiStore); + registerFeedbackRoutes( + apiV1 as unknown as Parameters[0], + opts.feedback, + ); registerToolsRoutes(apiV1 as unknown as Parameters[0], core); if (opts.enableTerminals !== false) { registerTerminalsRoutes( diff --git a/packages/kap-server/src/services/feedback/feedback.ts b/packages/kap-server/src/services/feedback/feedback.ts new file mode 100644 index 0000000000..7461425da1 --- /dev/null +++ b/packages/kap-server/src/services/feedback/feedback.ts @@ -0,0 +1,67 @@ +import { createDecorator } from '@moonshot-ai/agent-core-v2'; + +import type { FeedbackDiagnostics, FeedbackType } from '../../protocol/rest-feedback'; + +/** + * `IFeedbackService` — forwards user feedback to the managed collection + * backend with the managed provider's OAuth token, mirroring the CLI's + * `/feedback` flow. The submit call stamps the host version, server OS, and + * default model server-side; attachment uploads use the backend's + * presigned-URL flow (`upload_url` / `upload_complete`). + */ +export interface FeedbackEntry { + readonly content: string; + readonly session_id: string; + readonly contact?: string; + readonly type?: FeedbackType; + readonly title?: string; + readonly diagnostics?: FeedbackDiagnostics; + readonly agent_id?: string; + readonly info?: Record; +} + +export interface FeedbackUploadUrlInput { + readonly feedback_id: number; + readonly file_name: string; + readonly file_size: number; + readonly file_hash: string; +} + +export interface FeedbackUploadPart { + readonly part_number: number; + readonly url: string; + readonly method: string; + readonly size: number; +} + +export interface FeedbackUploadUrlResult { + readonly upload_id: number; + readonly parts: readonly FeedbackUploadPart[]; +} + +export interface FeedbackUploadCompleteInput { + readonly upload_id: number; + readonly parts: readonly { readonly part_number: number; readonly etag: string }[]; +} + +export type FeedbackErrorReason = 'not_signed_in' | 'backend_error'; + +export class FeedbackError extends Error { + constructor( + readonly reason: FeedbackErrorReason, + message: string, + readonly status?: number, + ) { + super(message); + this.name = 'FeedbackError'; + } +} + +export interface IFeedbackService { + readonly _serviceBrand: undefined; + submit(entry: FeedbackEntry): Promise<{ feedbackId: number }>; + createUploadUrl(input: FeedbackUploadUrlInput): Promise; + completeUpload(input: FeedbackUploadCompleteInput): Promise; +} + +export const IFeedbackService = createDecorator('feedbackService'); diff --git a/packages/kap-server/src/services/feedback/feedbackService.ts b/packages/kap-server/src/services/feedback/feedbackService.ts new file mode 100644 index 0000000000..d40254130c --- /dev/null +++ b/packages/kap-server/src/services/feedback/feedbackService.ts @@ -0,0 +1,146 @@ +/** + * `FeedbackService` — forwards feedback to the managed collection backend, + * mirroring the CLI's `/feedback` implementation (`apps/kimi-code/src/feedback/` + * on top of `@moonshot-ai/kimi-code-oauth`): the submission is POSTed to the + * managed provider's resolved feedback endpoint with its OAuth access token, + * stamped with the host version, server OS, and default model. Form-only + * fields (`type` / `title` / `diagnostics` / `agent_id`) fold into the + * backend's structured `info` bag. + */ + +import { release as osRelease, type as osType } from 'node:os'; + +import { IOAuthService, IModelService, IProviderService } from '@moonshot-ai/agent-core-v2'; +import { + fetchCompleteFeedbackUpload, + fetchCreateFeedbackUploadUrl, + fetchSubmitFeedback, + KIMI_CODE_PROVIDER_NAME, + kimiCodeFeedbackUrl, + resolveKimiCodeRuntimeAuth, +} from '@moonshot-ai/kimi-code-oauth'; + +import { + FeedbackError, + IFeedbackService, + type FeedbackEntry, + type FeedbackUploadCompleteInput, + type FeedbackUploadUrlInput, + type FeedbackUploadUrlResult, +} from './feedback'; + +/** + * Sent in the feedback `version` field so the backend can distinguish this + * TypeScript client from clients that send a bare version (mirrors the CLI). + */ +const FEEDBACK_VERSION_PREFIX = 'kimi-code-'; + +export interface FeedbackServiceDeps { + readonly oauth: IOAuthService; + readonly model: IModelService; + readonly provider: IProviderService; + readonly version: string; +} + +interface FeedbackRuntimeAuth { + readonly accessToken: string; + readonly baseUrl?: string; +} + +export class FeedbackService implements IFeedbackService { + readonly _serviceBrand: undefined; + + constructor(private readonly deps: FeedbackServiceDeps) {} + + async submit(entry: FeedbackEntry): Promise<{ feedbackId: number }> { + const auth = await this.runtimeAuth(); + const info: Record = { + type: entry.type, + title: entry.title, + diagnostics: entry.diagnostics, + agent_id: entry.agent_id, + ...entry.info, + }; + const result = await fetchSubmitFeedback(kimiCodeFeedbackUrl(auth.baseUrl), auth.accessToken, { + session_id: entry.session_id, + content: entry.content, + version: `${FEEDBACK_VERSION_PREFIX}${this.deps.version}`, + os: `${osType()} ${osRelease()}`, + model: this.deps.model.getDefaultModel() ?? null, + contact: entry.contact, + info: Object.values(info).some((value) => value !== undefined) ? info : undefined, + }); + if (result.kind === 'error') { + throw new FeedbackError('backend_error', result.message, result.status); + } + return { feedbackId: result.feedbackId }; + } + + async createUploadUrl(input: FeedbackUploadUrlInput): Promise { + const auth = await this.runtimeAuth(); + const result = await fetchCreateFeedbackUploadUrl( + auth.accessToken, + { + file_hash: input.file_hash, + file_name: input.file_name, + file_size: input.file_size, + feedback_id: input.feedback_id, + }, + { baseUrl: auth.baseUrl }, + ); + if (result.kind === 'error') { + throw new FeedbackError('backend_error', result.message, result.status); + } + return { upload_id: result.upload_id, parts: result.parts }; + } + + async completeUpload(input: FeedbackUploadCompleteInput): Promise { + const auth = await this.runtimeAuth(); + const result = await fetchCompleteFeedbackUpload( + auth.accessToken, + { + upload_id: input.upload_id, + parts: input.parts.map((part) => ({ part_number: part.part_number, etag: part.etag })), + }, + { baseUrl: auth.baseUrl }, + ); + if (result.kind === 'error') { + throw new FeedbackError('backend_error', result.message, result.status); + } + } + + private async runtimeAuth(): Promise { + const status = await this.deps.oauth + .status(KIMI_CODE_PROVIDER_NAME) + .catch(() => ({ loggedIn: false }) as { loggedIn: boolean }); + if (!status.loggedIn) { + throw new FeedbackError( + 'not_signed_in', + 'not signed in to the managed Kimi Code provider; sign in before submitting feedback', + ); + } + const configured = this.deps.provider.get(KIMI_CODE_PROVIDER_NAME); + const auth = resolveKimiCodeRuntimeAuth({ + configuredBaseUrl: configured?.baseUrl, + configuredOAuthRef: configured?.oauth, + }); + const tokenProvider = this.deps.oauth.resolveTokenProvider( + KIMI_CODE_PROVIDER_NAME, + auth.oauthRef, + ); + if (tokenProvider === undefined) { + throw new FeedbackError( + 'not_signed_in', + 'the managed Kimi Code provider is not configured; sign in before submitting feedback', + ); + } + try { + return { accessToken: await tokenProvider.getAccessToken(), baseUrl: auth.baseUrl }; + } catch (error) { + throw new FeedbackError( + 'not_signed_in', + error instanceof Error ? error.message : String(error), + ); + } + } +} diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 79610a3d95..406566b3cb 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -12,7 +12,10 @@ import { hostIdentitySeed, hostRequestHeadersSeed, IConfigService, + IModelService, + IOAuthService, IProviderDiscoveryService, + IProviderService, IWorkspaceService, logSeed, resolveConfigPath, @@ -62,6 +65,7 @@ import { createOriginHook, isOriginAllowed, parseCorsOrigins } from './middlewar import { createSecurityHeadersHook } from './middleware/securityHeaders'; import { createAuthHook } from './middleware/auth'; import { GuiStoreService } from './services/guiStore/guiStoreService'; +import { FeedbackService } from './services/feedback/feedbackService'; import { loadSnapshotConfig, SnapshotReader } from './services/snapshot'; import { initializeServerTelemetry, @@ -277,6 +281,15 @@ export async function startServer(opts: ServerStartOptions = {}): Promise { @@ -432,6 +446,7 @@ export async function startServer(opts: ServerStartOptions = {}): Promise { void close().catch((err: unknown) => logger.error({ err }, 'server close failed')); }, diff --git a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap index 2d824ef41e..bc88c7b28a 100644 --- a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap +++ b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap @@ -260,6 +260,18 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "POST", "/api/v1/debug/session/{session_id}/agent/{agent_id}/{service}/{method}", ], + [ + "POST", + "/api/v1/feedback", + ], + [ + "POST", + "/api/v1/feedback/upload_complete", + ], + [ + "POST", + "/api/v1/feedback/upload_url", + ], [ "POST", "/api/v1/files", diff --git a/packages/kap-server/test/feedback.test.ts b/packages/kap-server/test/feedback.test.ts new file mode 100644 index 0000000000..43df391f67 --- /dev/null +++ b/packages/kap-server/test/feedback.test.ts @@ -0,0 +1,296 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { IOAuthService } from '@moonshot-ai/agent-core-v2'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; + +interface InjectResponse { + statusCode: number; + json: () => unknown; +} + +interface AppLike { + inject: (req: unknown) => Promise; +} + +interface Envelope { + code: number; + msg: string; + data: T | null; + request_id: string; +} + +function appOf(r: RunningServer): AppLike { + const app = r.app as unknown as AppLike; + return { + inject(req: unknown): Promise { + const request = req as { headers?: Record }; + return app.inject({ + ...request, + headers: { + ...request.headers, + authorization: `Bearer ${r.authTokenService.getToken()}`, + }, + }); + }, + }; +} + +function envelopeOf(body: unknown): Envelope { + return body as Envelope; +} + +function post(api: AppLike, url: string, payload: unknown) { + return api.inject({ method: 'POST', url, payload }); +} + +function jsonResponse(payload: unknown, status = 200): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +interface BackendCall { + readonly url: string; + readonly method: string; + readonly authorization: string; + readonly body: Record; +} + +function backendCall(fetchMock: ReturnType, index = 0): BackendCall { + const call = fetchMock.mock.calls[index] as [string, RequestInit]; + const rawBody = call[1].body; + if (typeof rawBody !== 'string') { + throw new TypeError('expected the backend request body to be a JSON string'); + } + return { + url: call[0], + method: call[1].method ?? 'GET', + authorization: (call[1].headers as Record)['Authorization'] ?? '', + body: JSON.parse(rawBody) as Record, + }; +} + +describe('server-v2 feedback routes', () => { + let home: string | undefined; + let server: RunningServer | undefined; + let loggedIn: boolean; + let kimiCodeBaseUrl: string | undefined; + let refreshInterval: string | undefined; + let refreshOnStart: string | undefined; + const fetchMock = vi.fn(); + + beforeEach(async () => { + kimiCodeBaseUrl = process.env['KIMI_CODE_BASE_URL']; + refreshInterval = process.env['KIMI_CODE_MODEL_CATALOG_REFRESH_INTERVAL_MS']; + refreshOnStart = process.env['KIMI_CODE_MODEL_CATALOG_REFRESH_ON_START']; + delete process.env['KIMI_CODE_BASE_URL']; + process.env['KIMI_CODE_MODEL_CATALOG_REFRESH_INTERVAL_MS'] = '0'; + process.env['KIMI_CODE_MODEL_CATALOG_REFRESH_ON_START'] = '0'; + loggedIn = true; + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + const fakeOAuth = { + status: async () => ({ loggedIn }), + resolveTokenProvider: () => ({ + getAccessToken: async () => 'test-access-token', + }), + } as unknown as IOAuthService; + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-feedback-')); + await writeFile( + join(home, 'config.toml'), + [ + '[providers."managed:kimi-code"]', + 'type = "kimi"', + 'base_url = "https://example.test/managed/"', + '', + ].join('\n'), + 'utf8', + ); + server = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + seeds: [[IOAuthService, fakeOAuth]], + }); + }); + + afterEach(async () => { + vi.unstubAllGlobals(); + restoreEnv('KIMI_CODE_BASE_URL', kimiCodeBaseUrl); + restoreEnv('KIMI_CODE_MODEL_CATALOG_REFRESH_INTERVAL_MS', refreshInterval); + restoreEnv('KIMI_CODE_MODEL_CATALOG_REFRESH_ON_START', refreshOnStart); + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + it('forwards feedback to the managed backend and returns feedback_id', async () => { + fetchMock.mockResolvedValue(jsonResponse({ feedback_id: 7 })); + const res = await post(appOf(server as RunningServer), '/api/v1/feedback', { + content: 'the session list flashes on open', + session_id: 's-1', + type: 'bug', + title: 'session list flashes', + contact: 'user@example.com', + diagnostics: 'logs', + agent_id: 'a-1', + info: { surface: 'settings' }, + }); + + expect(res.statusCode).toBe(200); + const env = envelopeOf<{ feedback_id: number }>(res.json()); + expect(env.code).toBe(0); + expect(env.data?.feedback_id).toBe(7); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const call = backendCall(fetchMock); + expect(call.url).toBe('https://example.test/managed/feedback'); + expect(call.method).toBe('POST'); + expect(call.authorization).toBe('Bearer test-access-token'); + expect(call.body).toMatchObject({ + session_id: 's-1', + content: 'the session list flashes on open', + contact: 'user@example.com', + model: null, + info: { + type: 'bug', + title: 'session list flashes', + diagnostics: 'logs', + agent_id: 'a-1', + surface: 'settings', + }, + }); + expect(String(call.body['version'])).toMatch(/^kimi-code-/); + expect(typeof call.body['os']).toBe('string'); + expect(String(call.body['os']).length).toBeGreaterThan(0); + }); + + it('omits info when no detail fields are present', async () => { + fetchMock.mockResolvedValue(jsonResponse({ feedback_id: 8 })); + await post(appOf(server as RunningServer), '/api/v1/feedback', { + content: 'quick note', + session_id: 's-1', + }); + + const call = backendCall(fetchMock); + expect('info' in call.body).toBe(false); + expect('contact' in call.body).toBe(false); + }); + + it('rejects reserved info keys before forwarding feedback', async () => { + const res = await post(appOf(server as RunningServer), '/api/v1/feedback', { + content: 'the session list flashes on open', + session_id: 's-1', + type: 'bug', + info: { type: 'feature' }, + }); + + expect(envelopeOf(res.json()).code).toBe(40001); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns 40111 when not signed in and never calls the backend', async () => { + loggedIn = false; + const res = await post(appOf(server as RunningServer), '/api/v1/feedback', { + content: 'hello', + session_id: 's-1', + }); + + expect(envelopeOf(res.json()).code).toBe(40111); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('maps a backend failure to 50001', async () => { + fetchMock.mockResolvedValue(new Response('boom', { status: 500 })); + const res = await post(appOf(server as RunningServer), '/api/v1/feedback', { + content: 'hello', + session_id: 's-1', + }); + + expect(envelopeOf(res.json()).code).toBe(50001); + }); + + it('proxies upload_url to the backend', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + upload: { + id: 3, + parts: [{ part_number: 1, url: 'https://example.com/upload-part-1', method: 'PUT', size: 64 }], + }, + }), + ); + const res = await post(appOf(server as RunningServer), '/api/v1/feedback/upload_url', { + feedback_id: 7, + file_name: 'session.zip', + file_size: 64, + file_hash: 'deadbeef', + }); + + const env = envelopeOf<{ upload_id: number; parts: unknown[] }>(res.json()); + expect(env.code).toBe(0); + expect(env.data?.upload_id).toBe(3); + expect(env.data?.parts).toEqual([ + { part_number: 1, url: 'https://example.com/upload-part-1', method: 'PUT', size: 64 }, + ]); + + const call = backendCall(fetchMock); + expect(call.url).toBe('https://example.test/managed/feedback/upload_url'); + expect(call.authorization).toBe('Bearer test-access-token'); + expect(call.body).toMatchObject({ + feedback_id: 7, + file_name: 'session.zip', + file_size: 64, + file_hash: 'deadbeef', + }); + }); + + it('proxies upload_complete to the backend', async () => { + fetchMock.mockResolvedValue(jsonResponse({})); + const res = await post(appOf(server as RunningServer), '/api/v1/feedback/upload_complete', { + upload_id: 3, + parts: [{ part_number: 1, etag: 'etag-1' }], + }); + + expect(envelopeOf(res.json()).code).toBe(0); + const call = backendCall(fetchMock); + expect(call.url).toBe('https://example.test/managed/feedback/upload_complete'); + expect(call.authorization).toBe('Bearer test-access-token'); + expect(call.body).toMatchObject({ + upload_id: 3, + parts: [{ part_number: 1, etag: 'etag-1' }], + }); + }); + + it('rejects a missing content', async () => { + const res = await post(appOf(server as RunningServer), '/api/v1/feedback', { + session_id: 's-1', + }); + expect(envelopeOf(res.json()).code).toBe(40001); + }); + + it('rejects a missing session_id', async () => { + const res = await post(appOf(server as RunningServer), '/api/v1/feedback', { + content: 'hello', + }); + expect(envelopeOf(res.json()).code).toBe(40001); + }); +}); + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +}