From 6864c0349751fde667939dcddc6e06336abf4955 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 29 Jul 2026 15:39:42 +0800 Subject: [PATCH 1/6] feat(kap-server): add POST /api/v1/feedback endpoint --- .../kap-server/src/protocol/rest-feedback.ts | 17 +++ packages/kap-server/src/routes/feedback.ts | 63 ++++++++ .../src/routes/registerApiV1Routes.ts | 7 + .../src/services/feedback/feedback.ts | 27 ++++ .../src/services/feedback/feedbackService.ts | 42 ++++++ packages/kap-server/src/start.ts | 4 + .../apiSurface.snapshot.test.ts.snap | 4 + packages/kap-server/test/feedback.test.ts | 140 ++++++++++++++++++ 8 files changed, 304 insertions(+) create mode 100644 packages/kap-server/src/protocol/rest-feedback.ts create mode 100644 packages/kap-server/src/routes/feedback.ts create mode 100644 packages/kap-server/src/services/feedback/feedback.ts create mode 100644 packages/kap-server/src/services/feedback/feedbackService.ts create mode 100644 packages/kap-server/test/feedback.test.ts 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..dcc5d16ffb --- /dev/null +++ b/packages/kap-server/src/protocol/rest-feedback.ts @@ -0,0 +1,17 @@ +/** + * POST /v1/feedback + * + * Wire shapes for the user feedback collection endpoint. The wire uses + * snake_case (REST convention in this repo); the payload is appended to the + * server-local JSONL feedback log as-is (see `src/services/feedback/`). + */ + +import { z } from 'zod'; + +export const feedbackSubmitBodySchema = z.object({ + message: z.string().min(1).max(20000), + rating: z.enum(['up', 'down']).optional(), + session_id: z.string().min(1).max(256).optional(), + agent_id: z.string().min(1).max(256).optional(), +}); +export type FeedbackSubmitBody = 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..55361616e5 --- /dev/null +++ b/packages/kap-server/src/routes/feedback.ts @@ -0,0 +1,63 @@ +/** + * `/feedback` route handler — user feedback collection. + * + * Thin adapter over the hand-constructed `IFeedbackService` (see + * `src/services/feedback/`): the validated wire body is appended to the + * server-local JSONL feedback log; storage failures map to `50000`. + * + * POST /feedback body: FeedbackSubmitBody 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 } from '../protocol/rest-feedback'; +import 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; +} + +export function registerFeedbackRoutes(app: FeedbackRouteHost, feedback: IFeedbackService): void { + const route = defineRoute( + { + method: 'POST', + path: '/feedback', + body: feedbackSubmitBodySchema, + success: { data: z.null() }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.INTERNAL_ERROR]: {}, + }, + description: 'Submit user feedback; appended to the server-local feedback log', + tags: ['feedback'], + }, + async (req, reply) => { + try { + await feedback.submit(req.body); + reply.send(okEnvelope(null, req.id)); + } catch (error) { + requestLog(req)?.error({ err: error }, 'feedback submit failed'); + reply.send( + errEnvelope( + ErrorCode.INTERNAL_ERROR, + error instanceof Error ? error.message : String(error), + req.id, + error instanceof Error ? error.stack : undefined, + ), + ); + } + }, + ); + app.post(route.path, route.options, route.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..59c6529873 --- /dev/null +++ b/packages/kap-server/src/services/feedback/feedback.ts @@ -0,0 +1,27 @@ +import { createDecorator } from '@moonshot-ai/agent-core-v2'; + +/** + * `IFeedbackService` — append-only sink for user feedback submitted over + * `POST /api/v1/feedback`. Each submission is persisted as one JSON line in + * `/feedback/feedback.jsonl`, stamped with a server-generated `id` + * (ULID) and `time` (epoch ms). Record fields keep the wire's snake_case + * naming so the log can be shipped to a collection backend unchanged. + */ +export interface FeedbackEntry { + readonly message: string; + readonly rating?: 'up' | 'down'; + readonly session_id?: string; + readonly agent_id?: string; +} + +export interface FeedbackRecord extends FeedbackEntry { + readonly id: string; + readonly time: number; +} + +export interface IFeedbackService { + readonly _serviceBrand: undefined; + submit(entry: FeedbackEntry): 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..29ba6a264d --- /dev/null +++ b/packages/kap-server/src/services/feedback/feedbackService.ts @@ -0,0 +1,42 @@ +/** + * `FeedbackService` — JSONL-backed implementation of `IFeedbackService`. + * Writes are serialized through a promise queue (same pattern as + * `GuiStoreService`) so concurrent submissions never interleave a line. + */ + +import { appendFile, mkdir } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import { ulid } from 'ulid'; + +import { IFeedbackService, type FeedbackEntry, type FeedbackRecord } from './feedback'; + +export class FeedbackService implements IFeedbackService { + readonly _serviceBrand: undefined; + + private readonly filePath: string; + private queue: Promise = Promise.resolve(); + + constructor(homeDir: string) { + this.filePath = join(homeDir, 'feedback', 'feedback.jsonl'); + } + + async submit(entry: FeedbackEntry): Promise { + const record: FeedbackRecord = { id: ulid(), time: Date.now(), ...entry }; + const line = `${JSON.stringify(record)}\n`; + await this.withLock(async () => { + await mkdir(dirname(this.filePath), { recursive: true, mode: 0o700 }); + await appendFile(this.filePath, line, { encoding: 'utf-8', mode: 0o600 }); + }); + return record; + } + + private withLock(fn: () => Promise): Promise { + const run = this.queue.then(fn); + this.queue = run.then( + () => undefined, + () => undefined, + ); + return run; + } +} diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 79610a3d95..8630565b73 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -62,6 +62,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, @@ -205,6 +206,7 @@ export async function startServer(opts: ServerStartOptions = {}): Promise { @@ -432,6 +435,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..17c83cb1f6 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,10 @@ 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/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..9b8762cd3b --- /dev/null +++ b/packages/kap-server/test/feedback.test.ts @@ -0,0 +1,140 @@ +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } 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 submit(api: AppLike, payload: unknown) { + return api.inject({ method: 'POST', url: '/api/v1/feedback', payload }); +} + +interface StoredRecord { + id: string; + time: number; + message: string; + rating?: string; + session_id?: string; + agent_id?: string; +} + +async function readRecords(home: string): Promise { + const text = await readFile(join(home, 'feedback', 'feedback.jsonl'), 'utf-8'); + return text + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as StoredRecord); +} + +describe('server-v2 feedback routes', () => { + let home: string | undefined; + let server: RunningServer | undefined; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-feedback-')); + server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + }); + + afterEach(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + it('accepts feedback and appends a stamped JSON line to feedback.jsonl', async () => { + const res = await submit(appOf(server as RunningServer), { message: 'great session' }); + expect(res.statusCode).toBe(200); + expect(envelopeOf(res.json()).code).toBe(0); + + const records = await readRecords(home as string); + expect(records).toHaveLength(1); + expect(records[0]?.message).toBe('great session'); + expect(typeof records[0]?.id).toBe('string'); + expect(typeof records[0]?.time).toBe('number'); + }); + + it('persists the optional rating / session_id / agent_id fields', async () => { + await submit(appOf(server as RunningServer), { + message: 'wrong result', + rating: 'down', + session_id: 's-1', + agent_id: 'a-1', + }); + + const records = await readRecords(home as string); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ + message: 'wrong result', + rating: 'down', + session_id: 's-1', + agent_id: 'a-1', + }); + }); + + it('appends multiple submissions as separate lines in submission order', async () => { + const api = appOf(server as RunningServer); + await Promise.all([submit(api, { message: 'first' }), submit(api, { message: 'second' })]); + + const records = await readRecords(home as string); + expect(records).toHaveLength(2); + expect(records.map((r) => r.message).toSorted()).toEqual(['first', 'second']); + }); + + it('rejects an empty message', async () => { + const res = await submit(appOf(server as RunningServer), { message: '' }); + expect(envelopeOf(res.json()).code).toBe(40001); + }); + + it('rejects a missing message', async () => { + const res = await submit(appOf(server as RunningServer), { rating: 'up' }); + expect(envelopeOf(res.json()).code).toBe(40001); + }); + + it.skipIf(process.platform === 'win32')('writes feedback.jsonl with 0600 permissions', async () => { + await submit(appOf(server as RunningServer), { message: 'perm check' }); + const mode = (await stat(join(home as string, 'feedback', 'feedback.jsonl'))).mode & 0o777; + expect(mode).toBe(0o600); + }); +}); From ed158b226eaf3d19d3901a5d9e68719e4e1e0d1c Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 29 Jul 2026 15:50:55 +0800 Subject: [PATCH 2/6] feat(kap-server): refine feedback payload with type and detail fields --- .../kap-server/src/protocol/rest-feedback.ts | 21 ++++++- .../src/services/feedback/feedback.ts | 10 +++- packages/kap-server/test/feedback.test.ts | 60 +++++++++++++------ 3 files changed, 70 insertions(+), 21 deletions(-) diff --git a/packages/kap-server/src/protocol/rest-feedback.ts b/packages/kap-server/src/protocol/rest-feedback.ts index dcc5d16ffb..fc8af15d5c 100644 --- a/packages/kap-server/src/protocol/rest-feedback.ts +++ b/packages/kap-server/src/protocol/rest-feedback.ts @@ -4,14 +4,31 @@ * Wire shapes for the user feedback collection endpoint. The wire uses * snake_case (REST convention in this repo); the payload is appended to the * server-local JSONL feedback log as-is (see `src/services/feedback/`). + * + * The field set mirrors the product feedback form (type / title / content / + * contact / diagnostics) and keeps `content` / `contact` / `info` aligned + * with the collection backend's metadata contract, so a future upload + * pipeline can forward records without remapping. */ import { z } from 'zod'; +/** 反馈类型:报错 / 疑似 bug、功能许愿、其他。 */ +export const feedbackTypeSchema = z.enum(['bug', 'feature', 'other']); +export type FeedbackType = z.infer; + +/** 附带诊断信息:不提供、本地日志、本地日志 + 代码仓库。 */ +export const feedbackDiagnosticsSchema = z.enum(['none', 'logs', 'logs_and_codebase']); +export type FeedbackDiagnostics = z.infer; + export const feedbackSubmitBodySchema = z.object({ - message: z.string().min(1).max(20000), - rating: z.enum(['up', 'down']).optional(), + type: feedbackTypeSchema, + content: z.string().min(1).max(20000), + title: z.string().min(1).max(256).optional(), + contact: z.string().min(1).max(256).optional(), + diagnostics: feedbackDiagnosticsSchema.optional(), session_id: z.string().min(1).max(256).optional(), agent_id: z.string().min(1).max(256).optional(), + info: z.record(z.string(), z.unknown()).optional(), }); export type FeedbackSubmitBody = z.infer; diff --git a/packages/kap-server/src/services/feedback/feedback.ts b/packages/kap-server/src/services/feedback/feedback.ts index 59c6529873..91c1978f51 100644 --- a/packages/kap-server/src/services/feedback/feedback.ts +++ b/packages/kap-server/src/services/feedback/feedback.ts @@ -1,5 +1,7 @@ import { createDecorator } from '@moonshot-ai/agent-core-v2'; +import type { FeedbackDiagnostics, FeedbackType } from '../../protocol/rest-feedback'; + /** * `IFeedbackService` — append-only sink for user feedback submitted over * `POST /api/v1/feedback`. Each submission is persisted as one JSON line in @@ -8,10 +10,14 @@ import { createDecorator } from '@moonshot-ai/agent-core-v2'; * naming so the log can be shipped to a collection backend unchanged. */ export interface FeedbackEntry { - readonly message: string; - readonly rating?: 'up' | 'down'; + readonly type: FeedbackType; + readonly content: string; + readonly title?: string; + readonly contact?: string; + readonly diagnostics?: FeedbackDiagnostics; readonly session_id?: string; readonly agent_id?: string; + readonly info?: Record; } export interface FeedbackRecord extends FeedbackEntry { diff --git a/packages/kap-server/test/feedback.test.ts b/packages/kap-server/test/feedback.test.ts index 9b8762cd3b..61d78074e4 100644 --- a/packages/kap-server/test/feedback.test.ts +++ b/packages/kap-server/test/feedback.test.ts @@ -49,10 +49,14 @@ function submit(api: AppLike, payload: unknown) { interface StoredRecord { id: string; time: number; - message: string; - rating?: string; + type: string; + content: string; + title?: string; + contact?: string; + diagnostics?: string; session_id?: string; agent_id?: string; + info?: Record; } async function readRecords(home: string): Promise { @@ -84,56 +88,78 @@ describe('server-v2 feedback routes', () => { }); it('accepts feedback and appends a stamped JSON line to feedback.jsonl', async () => { - const res = await submit(appOf(server as RunningServer), { message: 'great session' }); + const res = await submit(appOf(server as RunningServer), { + type: 'bug', + content: 'the session list flashes on open', + }); expect(res.statusCode).toBe(200); expect(envelopeOf(res.json()).code).toBe(0); const records = await readRecords(home as string); expect(records).toHaveLength(1); - expect(records[0]?.message).toBe('great session'); + expect(records[0]).toMatchObject({ type: 'bug', content: 'the session list flashes on open' }); expect(typeof records[0]?.id).toBe('string'); expect(typeof records[0]?.time).toBe('number'); }); - it('persists the optional rating / session_id / agent_id fields', async () => { + it('persists the optional detail fields', async () => { await submit(appOf(server as RunningServer), { - message: 'wrong result', - rating: 'down', + type: 'feature', + content: 'bring back the skills section under general settings', + title: 'move skills settings back', + contact: 'user@example.com', + diagnostics: 'logs', session_id: 's-1', agent_id: 'a-1', + info: { surface: 'settings', channel: 'web' }, }); const records = await readRecords(home as string); expect(records).toHaveLength(1); expect(records[0]).toMatchObject({ - message: 'wrong result', - rating: 'down', + type: 'feature', + content: 'bring back the skills section under general settings', + title: 'move skills settings back', + contact: 'user@example.com', + diagnostics: 'logs', session_id: 's-1', agent_id: 'a-1', + info: { surface: 'settings', channel: 'web' }, }); }); - it('appends multiple submissions as separate lines in submission order', async () => { + it('appends multiple submissions as separate lines', async () => { const api = appOf(server as RunningServer); - await Promise.all([submit(api, { message: 'first' }), submit(api, { message: 'second' })]); + await Promise.all([ + submit(api, { type: 'bug', content: 'first' }), + submit(api, { type: 'other', content: 'second' }), + ]); const records = await readRecords(home as string); expect(records).toHaveLength(2); - expect(records.map((r) => r.message).toSorted()).toEqual(['first', 'second']); + expect(records.map((r) => r.content).toSorted()).toEqual(['first', 'second']); }); - it('rejects an empty message', async () => { - const res = await submit(appOf(server as RunningServer), { message: '' }); + it('rejects an empty content', async () => { + const res = await submit(appOf(server as RunningServer), { type: 'bug', content: '' }); expect(envelopeOf(res.json()).code).toBe(40001); }); - it('rejects a missing message', async () => { - const res = await submit(appOf(server as RunningServer), { rating: 'up' }); + it('rejects a missing content', async () => { + const res = await submit(appOf(server as RunningServer), { type: 'bug' }); + expect(envelopeOf(res.json()).code).toBe(40001); + }); + + it('rejects an unknown feedback type', async () => { + const res = await submit(appOf(server as RunningServer), { + type: 'praise', + content: 'love it', + }); expect(envelopeOf(res.json()).code).toBe(40001); }); it.skipIf(process.platform === 'win32')('writes feedback.jsonl with 0600 permissions', async () => { - await submit(appOf(server as RunningServer), { message: 'perm check' }); + await submit(appOf(server as RunningServer), { type: 'other', content: 'perm check' }); const mode = (await stat(join(home as string, 'feedback', 'feedback.jsonl'))).mode & 0o777; expect(mode).toBe(0o600); }); From 51229c5a39c4dc25ccd40f410343d844eecfd37a Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 29 Jul 2026 15:53:13 +0800 Subject: [PATCH 3/6] feat(kap-server): make feedback type optional --- packages/kap-server/src/protocol/rest-feedback.ts | 2 +- .../kap-server/src/services/feedback/feedback.ts | 2 +- packages/kap-server/test/feedback.test.ts | 12 +++++++++++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/kap-server/src/protocol/rest-feedback.ts b/packages/kap-server/src/protocol/rest-feedback.ts index fc8af15d5c..7a0da2431a 100644 --- a/packages/kap-server/src/protocol/rest-feedback.ts +++ b/packages/kap-server/src/protocol/rest-feedback.ts @@ -22,7 +22,7 @@ export const feedbackDiagnosticsSchema = z.enum(['none', 'logs', 'logs_and_codeb export type FeedbackDiagnostics = z.infer; export const feedbackSubmitBodySchema = z.object({ - type: feedbackTypeSchema, + type: feedbackTypeSchema.optional(), content: z.string().min(1).max(20000), title: z.string().min(1).max(256).optional(), contact: z.string().min(1).max(256).optional(), diff --git a/packages/kap-server/src/services/feedback/feedback.ts b/packages/kap-server/src/services/feedback/feedback.ts index 91c1978f51..37c52f730b 100644 --- a/packages/kap-server/src/services/feedback/feedback.ts +++ b/packages/kap-server/src/services/feedback/feedback.ts @@ -10,7 +10,7 @@ import type { FeedbackDiagnostics, FeedbackType } from '../../protocol/rest-feed * naming so the log can be shipped to a collection backend unchanged. */ export interface FeedbackEntry { - readonly type: FeedbackType; + readonly type?: FeedbackType; readonly content: string; readonly title?: string; readonly contact?: string; diff --git a/packages/kap-server/test/feedback.test.ts b/packages/kap-server/test/feedback.test.ts index 61d78074e4..f18d6f2f5a 100644 --- a/packages/kap-server/test/feedback.test.ts +++ b/packages/kap-server/test/feedback.test.ts @@ -49,7 +49,7 @@ function submit(api: AppLike, payload: unknown) { interface StoredRecord { id: string; time: number; - type: string; + type?: string; content: string; title?: string; contact?: string; @@ -102,6 +102,16 @@ describe('server-v2 feedback routes', () => { expect(typeof records[0]?.time).toBe('number'); }); + it('accepts feedback without a type', async () => { + const res = await submit(appOf(server as RunningServer), { content: 'quick note from the cli' }); + expect(envelopeOf(res.json()).code).toBe(0); + + const records = await readRecords(home as string); + expect(records).toHaveLength(1); + expect(records[0]?.content).toBe('quick note from the cli'); + expect(records[0]?.type).toBeUndefined(); + }); + it('persists the optional detail fields', async () => { await submit(appOf(server as RunningServer), { type: 'feature', From 2f7623c8fb49819327ccc010de9fd1afe6ef8667 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 29 Jul 2026 15:56:51 +0800 Subject: [PATCH 4/6] docs(kap-server): english-only comments in the feedback protocol --- packages/kap-server/src/protocol/rest-feedback.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kap-server/src/protocol/rest-feedback.ts b/packages/kap-server/src/protocol/rest-feedback.ts index 7a0da2431a..9f340e1cdd 100644 --- a/packages/kap-server/src/protocol/rest-feedback.ts +++ b/packages/kap-server/src/protocol/rest-feedback.ts @@ -13,11 +13,11 @@ import { z } from 'zod'; -/** 反馈类型:报错 / 疑似 bug、功能许愿、其他。 */ +/** 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; From f21ff66e5bbe013ce8e789796675f96ddcf1ee40 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 29 Jul 2026 17:28:35 +0800 Subject: [PATCH 5/6] feat(kap-server): forward feedback to the managed collection backend --- .../kap-server/src/protocol/rest-feedback.ts | 52 +++- packages/kap-server/src/routes/feedback.ts | 121 +++++++-- .../src/services/feedback/feedback.ts | 58 ++++- .../src/services/feedback/feedbackService.ts | 131 ++++++++-- packages/kap-server/src/start.ts | 11 +- .../apiSurface.snapshot.test.ts.snap | 8 + packages/kap-server/test/feedback.test.ts | 236 ++++++++++++------ 7 files changed, 469 insertions(+), 148 deletions(-) diff --git a/packages/kap-server/src/protocol/rest-feedback.ts b/packages/kap-server/src/protocol/rest-feedback.ts index 9f340e1cdd..39f4c91512 100644 --- a/packages/kap-server/src/protocol/rest-feedback.ts +++ b/packages/kap-server/src/protocol/rest-feedback.ts @@ -1,14 +1,14 @@ /** * POST /v1/feedback + * POST /v1/feedback/upload_url + * POST /v1/feedback/upload_complete * - * Wire shapes for the user feedback collection endpoint. The wire uses - * snake_case (REST convention in this repo); the payload is appended to the - * server-local JSONL feedback log as-is (see `src/services/feedback/`). - * - * The field set mirrors the product feedback form (type / title / content / - * contact / diagnostics) and keeps `content` / `contact` / `info` aligned - * with the collection backend's metadata contract, so a future upload - * pipeline can forward records without remapping. + * 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'; @@ -22,13 +22,45 @@ export const feedbackDiagnosticsSchema = z.enum(['none', 'logs', 'logs_and_codeb export type FeedbackDiagnostics = z.infer; export const feedbackSubmitBodySchema = z.object({ - type: feedbackTypeSchema.optional(), 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(), - session_id: z.string().min(1).max(256).optional(), agent_id: z.string().min(1).max(256).optional(), info: z.record(z.string(), z.unknown()).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 index 55361616e5..f84a6a9319 100644 --- a/packages/kap-server/src/routes/feedback.ts +++ b/packages/kap-server/src/routes/feedback.ts @@ -1,11 +1,14 @@ /** - * `/feedback` route handler — user feedback collection. + * `/feedback` route handlers — user feedback submission and attachment uploads. * - * Thin adapter over the hand-constructed `IFeedbackService` (see - * `src/services/feedback/`): the validated wire body is appended to the - * server-local JSONL feedback log; storage failures map to `50000`. + * 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: null + * 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'; @@ -14,8 +17,14 @@ import { errEnvelope, okEnvelope } from '../envelope'; import { requestLog } from '../lib/requestLog'; import { defineRoute } from '../middleware/defineRoute'; import { ErrorCode } from '../protocol/error-codes'; -import { feedbackSubmitBodySchema } from '../protocol/rest-feedback'; -import type { IFeedbackService } from '../services/feedback/feedback'; +import { + feedbackSubmitBodySchema, + feedbackSubmitResponseSchema, + feedbackUploadCompleteBodySchema, + feedbackUploadUrlBodySchema, + feedbackUploadUrlResponseSchema, +} from '../protocol/rest-feedback'; +import { FeedbackError, type IFeedbackService } from '../services/feedback/feedback'; interface FeedbackRouteHost { post( @@ -28,36 +37,108 @@ interface FeedbackRouteHost { ): 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 route = defineRoute( + 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: 'Submit user feedback; appended to the server-local feedback log', + description: 'Mark a feedback attachment upload as complete', tags: ['feedback'], }, async (req, reply) => { try { - await feedback.submit(req.body); + await feedback.completeUpload(req.body); reply.send(okEnvelope(null, req.id)); } catch (error) { - requestLog(req)?.error({ err: error }, 'feedback submit failed'); - reply.send( - errEnvelope( - ErrorCode.INTERNAL_ERROR, - error instanceof Error ? error.message : String(error), - req.id, - error instanceof Error ? error.stack : undefined, - ), - ); + sendError(req, reply, error); } }, ); - app.post(route.path, route.options, route.handler as Parameters[2]); + app.post( + uploadCompleteRoute.path, + uploadCompleteRoute.options, + uploadCompleteRoute.handler as Parameters[2], + ); } diff --git a/packages/kap-server/src/services/feedback/feedback.ts b/packages/kap-server/src/services/feedback/feedback.ts index 37c52f730b..7461425da1 100644 --- a/packages/kap-server/src/services/feedback/feedback.ts +++ b/packages/kap-server/src/services/feedback/feedback.ts @@ -3,31 +3,65 @@ import { createDecorator } from '@moonshot-ai/agent-core-v2'; import type { FeedbackDiagnostics, FeedbackType } from '../../protocol/rest-feedback'; /** - * `IFeedbackService` — append-only sink for user feedback submitted over - * `POST /api/v1/feedback`. Each submission is persisted as one JSON line in - * `/feedback/feedback.jsonl`, stamped with a server-generated `id` - * (ULID) and `time` (epoch ms). Record fields keep the wire's snake_case - * naming so the log can be shipped to a collection backend unchanged. + * `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 type?: FeedbackType; readonly content: string; - readonly title?: string; + readonly session_id: string; readonly contact?: string; + readonly type?: FeedbackType; + readonly title?: string; readonly diagnostics?: FeedbackDiagnostics; - readonly session_id?: string; readonly agent_id?: string; readonly info?: Record; } -export interface FeedbackRecord extends FeedbackEntry { - readonly id: string; - readonly time: number; +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; + 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 index 29ba6a264d..5e5951cf65 100644 --- a/packages/kap-server/src/services/feedback/feedbackService.ts +++ b/packages/kap-server/src/services/feedback/feedbackService.ts @@ -1,42 +1,123 @@ /** - * `FeedbackService` — JSONL-backed implementation of `IFeedbackService`. - * Writes are serialized through a promise queue (same pattern as - * `GuiStoreService`) so concurrent submissions never interleave a line. + * `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 + * `{kimiCodeBaseUrl}/feedback` with the managed provider's 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 { appendFile, mkdir } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; +import { release as osRelease, type as osType } from 'node:os'; -import { ulid } from 'ulid'; +import { IOAuthService, IModelService } from '@moonshot-ai/agent-core-v2'; +import { + fetchCompleteFeedbackUpload, + fetchCreateFeedbackUploadUrl, + fetchSubmitFeedback, + KIMI_CODE_PROVIDER_NAME, + kimiCodeFeedbackUrl, +} from '@moonshot-ai/kimi-code-oauth'; -import { IFeedbackService, type FeedbackEntry, type FeedbackRecord } from './feedback'; +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 version: string; +} export class FeedbackService implements IFeedbackService { readonly _serviceBrand: undefined; - private readonly filePath: string; - private queue: Promise = Promise.resolve(); + constructor(private readonly deps: FeedbackServiceDeps) {} - constructor(homeDir: string) { - this.filePath = join(homeDir, 'feedback', 'feedback.jsonl'); + async submit(entry: FeedbackEntry): Promise<{ feedbackId: number }> { + const accessToken = await this.accessToken(); + const info: Record = { + type: entry.type, + title: entry.title, + diagnostics: entry.diagnostics, + agent_id: entry.agent_id, + ...entry.info, + }; + const result = await fetchSubmitFeedback(kimiCodeFeedbackUrl(), 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 accessToken = await this.accessToken(); + const result = await fetchCreateFeedbackUploadUrl(accessToken, { + file_hash: input.file_hash, + file_name: input.file_name, + file_size: input.file_size, + feedback_id: input.feedback_id, + }); + if (result.kind === 'error') { + throw new FeedbackError('backend_error', result.message, result.status); + } + return { upload_id: result.upload_id, parts: result.parts }; } - async submit(entry: FeedbackEntry): Promise { - const record: FeedbackRecord = { id: ulid(), time: Date.now(), ...entry }; - const line = `${JSON.stringify(record)}\n`; - await this.withLock(async () => { - await mkdir(dirname(this.filePath), { recursive: true, mode: 0o700 }); - await appendFile(this.filePath, line, { encoding: 'utf-8', mode: 0o600 }); + async completeUpload(input: FeedbackUploadCompleteInput): Promise { + const accessToken = await this.accessToken(); + const result = await fetchCompleteFeedbackUpload(accessToken, { + upload_id: input.upload_id, + parts: input.parts.map((part) => ({ part_number: part.part_number, etag: part.etag })), }); - return record; + if (result.kind === 'error') { + throw new FeedbackError('backend_error', result.message, result.status); + } } - private withLock(fn: () => Promise): Promise { - const run = this.queue.then(fn); - this.queue = run.then( - () => undefined, - () => undefined, - ); - return run; + private async accessToken(): 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 tokenProvider = this.deps.oauth.resolveTokenProvider(KIMI_CODE_PROVIDER_NAME); + if (tokenProvider === undefined) { + throw new FeedbackError( + 'not_signed_in', + 'the managed Kimi Code provider is not configured; sign in before submitting feedback', + ); + } + try { + return await tokenProvider.getAccessToken(); + } 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 8630565b73..ab348c484e 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -12,6 +12,8 @@ import { hostIdentitySeed, hostRequestHeadersSeed, IConfigService, + IModelService, + IOAuthService, IProviderDiscoveryService, IWorkspaceService, logSeed, @@ -206,7 +208,6 @@ export async function startServer(opts: ServerStartOptions = {}): Promise matches the documented v2 route table and meta e "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 index f18d6f2f5a..bcadb10560 100644 --- a/packages/kap-server/test/feedback.test.ts +++ b/packages/kap-server/test/feedback.test.ts @@ -1,8 +1,9 @@ -import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { IOAuthService } from '@moonshot-ai/agent-core-v2'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; @@ -42,41 +43,66 @@ function envelopeOf(body: unknown): Envelope { return body as Envelope; } -function submit(api: AppLike, payload: unknown) { - return api.inject({ method: 'POST', url: '/api/v1/feedback', payload }); +function post(api: AppLike, url: string, payload: unknown) { + return api.inject({ method: 'POST', url, payload }); } -interface StoredRecord { - id: string; - time: number; - type?: string; - content: string; - title?: string; - contact?: string; - diagnostics?: string; - session_id?: string; - agent_id?: string; - info?: Record; +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; } -async function readRecords(home: string): Promise { - const text = await readFile(join(home, 'feedback', 'feedback.jsonl'), 'utf-8'); - return text - .split('\n') - .filter((line) => line.length > 0) - .map((line) => JSON.parse(line) as StoredRecord); +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; + const fetchMock = vi.fn(); beforeEach(async () => { + 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-')); - server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + server = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + seeds: [[IOAuthService, fakeOAuth]], + }); }); afterEach(async () => { + vi.unstubAllGlobals(); if (server !== undefined) { await server.close(); server = undefined; @@ -87,90 +113,140 @@ describe('server-v2 feedback routes', () => { } }); - it('accepts feedback and appends a stamped JSON line to feedback.jsonl', async () => { - const res = await submit(appOf(server as RunningServer), { - type: 'bug', + 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); - expect(envelopeOf(res.json()).code).toBe(0); - const records = await readRecords(home as string); - expect(records).toHaveLength(1); - expect(records[0]).toMatchObject({ type: 'bug', content: 'the session list flashes on open' }); - expect(typeof records[0]?.id).toBe('string'); - expect(typeof records[0]?.time).toBe('number'); + 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).toMatch(/\/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('accepts feedback without a type', async () => { - const res = await submit(appOf(server as RunningServer), { content: 'quick note from the cli' }); - expect(envelopeOf(res.json()).code).toBe(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 records = await readRecords(home as string); - expect(records).toHaveLength(1); - expect(records[0]?.content).toBe('quick note from the cli'); - expect(records[0]?.type).toBeUndefined(); + const call = backendCall(fetchMock); + expect('info' in call.body).toBe(false); + expect('contact' in call.body).toBe(false); }); - it('persists the optional detail fields', async () => { - await submit(appOf(server as RunningServer), { - type: 'feature', - content: 'bring back the skills section under general settings', - title: 'move skills settings back', - contact: 'user@example.com', - diagnostics: 'logs', + 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', - agent_id: 'a-1', - info: { surface: 'settings', channel: 'web' }, }); - const records = await readRecords(home as string); - expect(records).toHaveLength(1); - expect(records[0]).toMatchObject({ - type: 'feature', - content: 'bring back the skills section under general settings', - title: 'move skills settings back', - contact: 'user@example.com', - diagnostics: 'logs', + 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', - agent_id: 'a-1', - info: { surface: 'settings', channel: 'web' }, }); + + expect(envelopeOf(res.json()).code).toBe(50001); }); - it('appends multiple submissions as separate lines', async () => { - const api = appOf(server as RunningServer); - await Promise.all([ - submit(api, { type: 'bug', content: 'first' }), - submit(api, { type: 'other', content: 'second' }), + 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 records = await readRecords(home as string); - expect(records).toHaveLength(2); - expect(records.map((r) => r.content).toSorted()).toEqual(['first', 'second']); + const call = backendCall(fetchMock); + expect(call.url).toMatch(/\/feedback\/upload_url$/); + expect(call.body).toMatchObject({ + feedback_id: 7, + file_name: 'session.zip', + file_size: 64, + file_hash: 'deadbeef', + }); }); - it('rejects an empty content', async () => { - const res = await submit(appOf(server as RunningServer), { type: 'bug', content: '' }); - expect(envelopeOf(res.json()).code).toBe(40001); + 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).toMatch(/\/feedback\/upload_complete$/); + expect(call.body).toMatchObject({ + upload_id: 3, + parts: [{ part_number: 1, etag: 'etag-1' }], + }); }); it('rejects a missing content', async () => { - const res = await submit(appOf(server as RunningServer), { type: 'bug' }); + const res = await post(appOf(server as RunningServer), '/api/v1/feedback', { + session_id: 's-1', + }); expect(envelopeOf(res.json()).code).toBe(40001); }); - it('rejects an unknown feedback type', async () => { - const res = await submit(appOf(server as RunningServer), { - type: 'praise', - content: 'love it', + 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); }); - - it.skipIf(process.platform === 'win32')('writes feedback.jsonl with 0600 permissions', async () => { - await submit(appOf(server as RunningServer), { type: 'other', content: 'perm check' }); - const mode = (await stat(join(home as string, 'feedback', 'feedback.jsonl'))).mode & 0o777; - expect(mode).toBe(0o600); - }); }); From c7f704182f59406758fb79f62ce892bb5f5b531e Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 29 Jul 2026 18:09:43 +0800 Subject: [PATCH 6/6] Resolve feedback endpoint from provider config --- .../kap-server/src/protocol/rest-feedback.ts | 15 ++++- .../src/services/feedback/feedbackService.ts | 63 +++++++++++++------ packages/kap-server/src/start.ts | 2 + packages/kap-server/test/feedback.test.ts | 52 +++++++++++++-- 4 files changed, 107 insertions(+), 25 deletions(-) diff --git a/packages/kap-server/src/protocol/rest-feedback.ts b/packages/kap-server/src/protocol/rest-feedback.ts index 39f4c91512..d11fd72872 100644 --- a/packages/kap-server/src/protocol/rest-feedback.ts +++ b/packages/kap-server/src/protocol/rest-feedback.ts @@ -21,6 +21,19 @@ export type FeedbackType = z.infer; 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), @@ -29,7 +42,7 @@ export const feedbackSubmitBodySchema = z.object({ contact: z.string().min(1).max(256).optional(), diagnostics: feedbackDiagnosticsSchema.optional(), agent_id: z.string().min(1).max(256).optional(), - info: z.record(z.string(), z.unknown()).optional(), + info: feedbackInfoSchema.optional(), }); export type FeedbackSubmitBody = z.infer; diff --git a/packages/kap-server/src/services/feedback/feedbackService.ts b/packages/kap-server/src/services/feedback/feedbackService.ts index 5e5951cf65..d40254130c 100644 --- a/packages/kap-server/src/services/feedback/feedbackService.ts +++ b/packages/kap-server/src/services/feedback/feedbackService.ts @@ -1,8 +1,8 @@ /** * `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 - * `{kimiCodeBaseUrl}/feedback` with the managed provider's OAuth access token, + * 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. @@ -10,13 +10,14 @@ import { release as osRelease, type as osType } from 'node:os'; -import { IOAuthService, IModelService } from '@moonshot-ai/agent-core-v2'; +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 { @@ -37,16 +38,22 @@ 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 accessToken = await this.accessToken(); + const auth = await this.runtimeAuth(); const info: Record = { type: entry.type, title: entry.title, @@ -54,7 +61,7 @@ export class FeedbackService implements IFeedbackService { agent_id: entry.agent_id, ...entry.info, }; - const result = await fetchSubmitFeedback(kimiCodeFeedbackUrl(), accessToken, { + const result = await fetchSubmitFeedback(kimiCodeFeedbackUrl(auth.baseUrl), auth.accessToken, { session_id: entry.session_id, content: entry.content, version: `${FEEDBACK_VERSION_PREFIX}${this.deps.version}`, @@ -70,13 +77,17 @@ export class FeedbackService implements IFeedbackService { } async createUploadUrl(input: FeedbackUploadUrlInput): Promise { - const accessToken = await this.accessToken(); - const result = await fetchCreateFeedbackUploadUrl(accessToken, { - file_hash: input.file_hash, - file_name: input.file_name, - file_size: input.file_size, - feedback_id: input.feedback_id, - }); + 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); } @@ -84,17 +95,21 @@ export class FeedbackService implements IFeedbackService { } async completeUpload(input: FeedbackUploadCompleteInput): Promise { - const accessToken = await this.accessToken(); - const result = await fetchCompleteFeedbackUpload(accessToken, { - upload_id: input.upload_id, - parts: input.parts.map((part) => ({ part_number: part.part_number, etag: part.etag })), - }); + 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 accessToken(): Promise { + private async runtimeAuth(): Promise { const status = await this.deps.oauth .status(KIMI_CODE_PROVIDER_NAME) .catch(() => ({ loggedIn: false }) as { loggedIn: boolean }); @@ -104,7 +119,15 @@ export class FeedbackService implements IFeedbackService { 'not signed in to the managed Kimi Code provider; sign in before submitting feedback', ); } - const tokenProvider = this.deps.oauth.resolveTokenProvider(KIMI_CODE_PROVIDER_NAME); + 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', @@ -112,7 +135,7 @@ export class FeedbackService implements IFeedbackService { ); } try { - return await tokenProvider.getAccessToken(); + return { accessToken: await tokenProvider.getAccessToken(), baseUrl: auth.baseUrl }; } catch (error) { throw new FeedbackError( 'not_signed_in', diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index ab348c484e..406566b3cb 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -15,6 +15,7 @@ import { IModelService, IOAuthService, IProviderDiscoveryService, + IProviderService, IWorkspaceService, logSeed, resolveConfigPath, @@ -286,6 +287,7 @@ export async function startServer(opts: ServerStartOptions = {}): Promise { 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); @@ -92,6 +101,16 @@ describe('server-v2 feedback routes', () => { }), } 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, @@ -103,6 +122,9 @@ describe('server-v2 feedback routes', () => { 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; @@ -133,7 +155,7 @@ describe('server-v2 feedback routes', () => { expect(fetchMock).toHaveBeenCalledTimes(1); const call = backendCall(fetchMock); - expect(call.url).toMatch(/\/feedback$/); + 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({ @@ -166,6 +188,18 @@ describe('server-v2 feedback routes', () => { 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', { @@ -211,7 +245,8 @@ describe('server-v2 feedback routes', () => { ]); const call = backendCall(fetchMock); - expect(call.url).toMatch(/\/feedback\/upload_url$/); + 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', @@ -229,7 +264,8 @@ describe('server-v2 feedback routes', () => { expect(envelopeOf(res.json()).code).toBe(0); const call = backendCall(fetchMock); - expect(call.url).toMatch(/\/feedback\/upload_complete$/); + 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' }], @@ -250,3 +286,11 @@ describe('server-v2 feedback routes', () => { 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; + } +}