diff --git a/docs/serve/api.md b/docs/serve/api.md index 1c049145d..f0a837a61 100644 --- a/docs/serve/api.md +++ b/docs/serve/api.md @@ -513,29 +513,48 @@ data: {"type":"server.heartbeat","ts":1713001234567} ## 6. Question -csc 通过 `control_request { subtype: "elicitation" }` 实现 MCP elicitation 交互,映射为 question 端点。 +csc 的 question API 兼容 opencode 格式,同时支持两种底层来源: + +1. **MCP Elicitation** — 通过 `control_request { subtype: "elicitation" }` 实现的 MCP 服务器用户输入请求 +2. **AskUserQuestionTool** — 内置工具的多选题交互,通过 `can_use_tool` permission 流程暴露为 question ### `GET /question` -列出所有待处理的问题请求。 +列出所有待处理的问题请求,返回 opencode 格式的数组。 **Response 200:** ```json -{ - "questions": [ - { - "request_id": "req-2", - "session_id": "uuid-1", - "mcp_server_name": "my-server", - "message": "Please provide your API key", - "mode": "form", - "requested_schema": { "type": "object", "properties": { "key": { "type": "string" } } } - } - ] -} +[ + { + "id": "req-2", + "sessionID": "uuid-1", + "questions": [ + { + "question": "Which library should we use?", + "header": "Library", + "options": [ + { "label": "date-fns", "description": "Lightweight, tree-shakeable" }, + { "label": "moment", "description": "Legacy, mutable" } + ], + "multiple": false, + "custom": false + } + ] + } +] ``` +字段说明: +- `id` — 请求唯一标识 +- `sessionID` — 所属会话 ID +- `questions` — 问题数组(1-4 个) + - `question` — 问题文本 + - `header` — 短标签(≤30 字符) + - `options` — 选项列表,每项含 `label` 和 `description` + - `multiple` — 是否允许多选 + - `custom` — 是否允许自定义输入 + ### `POST /question/:requestID/reply` 回复问题请求。 @@ -544,11 +563,15 @@ csc 通过 `control_request { subtype: "elicitation" }` 实现 MCP elicitation ```json { - "action": "accept", - "content": { "key": "sk-xxx" } + "answers": [ + ["date-fns"], + ["option1", "option2"] + ] } ``` +`answers` 是二维数组,外层索引对应 `questions` 数组顺序,内层是每个问题选中的 `label` 列表。 + **Response 200:** ```json @@ -557,15 +580,7 @@ csc 通过 `control_request { subtype: "elicitation" }` 实现 MCP elicitation ### `POST /question/:requestID/reject` -拒绝问题请求。 - -**Request Body:** - -```json -{ - "action": "decline" -} -``` +拒绝问题请求。无需请求体。 **Response 200:** diff --git a/src/cli/print.ts b/src/cli/print.ts index 5d3a1ed3c..b7d652869 100644 --- a/src/cli/print.ts +++ b/src/cli/print.ts @@ -2743,7 +2743,7 @@ function runHeadlessStreaming( uuid: randomUUID(), }) void run() - } else { + } else if (!process.env.CSC_SERVE_MODE) { // Wait for any in-flight push suggestion before closing the output stream. if (suggestionState.inflightPromise) { await Promise.race([suggestionState.inflightPromise, sleep(5000)]) @@ -4296,7 +4296,7 @@ function runHeadlessStreaming( } inputClosed = true cronScheduler?.stop() - if (!running) { + if (!running && !process.env.CSC_SERVE_MODE) { // If a push-suggestion is in-flight, wait for it to emit before closing // the output stream (5 s safety timeout to prevent hanging). if (suggestionState.inflightPromise) { diff --git a/src/server/__tests__/questionRoutes.test.ts b/src/server/__tests__/questionRoutes.test.ts new file mode 100644 index 000000000..41de99310 --- /dev/null +++ b/src/server/__tests__/questionRoutes.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, test, mock } from 'bun:test' +import { Hono } from 'hono' +import { createQuestionRoutes } from '../routes/question.js' +import { errorHandler } from '../errors.js' +import type { SessionManager } from '../sessionManager.js' +import type { SessionHandle } from '../sessionHandle.js' + +function createMockSessionManager(opts: { + questions?: Array<{ + requestId: string + sessionId: string + mcpServerName: string + message: string + mode: string + requestedSchema: Record + }> + permissions?: Array<{ + requestId: string + sessionId: string + toolName: string + toolUseId: string + input: Record + title: string + description: string + suggestions: Record[] + }> + findQuestion?: { handle: SessionHandle; question: unknown } | null + findPermission?: { handle: SessionHandle; perm: unknown } | null +}): SessionManager { + return { + getAllPendingQuestions: () => opts.questions ?? [], + getAllPendingPermissions: () => + opts.permissions?.map((p) => ({ + requestId: p.requestId, + sessionId: p.sessionId, + toolName: p.toolName, + toolUseId: p.toolUseId, + input: p.input, + title: p.title, + description: p.description, + suggestions: p.suggestions, + })) ?? [], + findQuestionAcrossSessions: () => opts.findQuestion ?? null, + findPermissionAcrossSessions: () => opts.findPermission ?? null, + } as unknown as SessionManager +} + +function createMockHandle(): SessionHandle { + return { + replyQuestion: mock(() => {}), + replyPermission: mock(() => {}), + } as unknown as SessionHandle +} + +describe('createQuestionRoutes', () => { + describe('GET /question', () => { + test('returns empty array when no questions', async () => { + const mgr = createMockSessionManager({}) + const app = createQuestionRoutes(mgr) + app.onError(errorHandler) + const res = await app.request('/question') + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual([]) + }) + + test('returns elicitation questions in opencode format', async () => { + const mgr = createMockSessionManager({ + questions: [ + { + requestId: 'req-1', + sessionId: 'sess-1', + mcpServerName: 'my-server', + message: 'Please provide API key', + mode: 'form', + requestedSchema: { + type: 'object', + properties: { key: { type: 'string' } }, + }, + }, + ], + }) + const app = createQuestionRoutes(mgr) + app.onError(errorHandler) + const res = await app.request('/question') + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toHaveLength(1) + expect(body[0].id).toBe('req-1') + expect(body[0].sessionID).toBe('sess-1') + expect(body[0].questions[0].question).toBe('Please provide API key') + expect(body[0].questions[0].header).toBe('my-server') + expect(body[0].questions[0].custom).toBe(true) + }) + + test('returns AskUserQuestionTool permissions as questions', async () => { + const mgr = createMockSessionManager({ + permissions: [ + { + requestId: 'perm-1', + sessionId: 'sess-1', + toolName: 'AskUserQuestionTool', + toolUseId: 'tu-1', + input: { + questions: [ + { + question: 'Which library?', + header: 'Library', + options: [ + { label: 'A', description: 'Option A' }, + { label: 'B', description: 'Option B' }, + ], + multiSelect: false, + }, + ], + }, + title: 'AskUserQuestionTool', + description: '', + suggestions: [], + }, + ], + }) + const app = createQuestionRoutes(mgr) + app.onError(errorHandler) + const res = await app.request('/question') + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toHaveLength(1) + expect(body[0].id).toBe('perm-1') + expect(body[0].questions[0].question).toBe('Which library?') + expect(body[0].questions[0].options).toHaveLength(2) + expect(body[0].questions[0].multiple).toBe(false) + expect(body[0].questions[0].custom).toBe(false) + }) + + test('converts enum schema to options', async () => { + const mgr = createMockSessionManager({ + questions: [ + { + requestId: 'req-1', + sessionId: 'sess-1', + mcpServerName: 'my-server', + message: 'Pick region', + mode: 'form', + requestedSchema: { + type: 'object', + properties: { + region: { + type: 'string', + enum: ['us-east-1', 'us-west-2'], + }, + }, + }, + }, + ], + }) + const app = createQuestionRoutes(mgr) + app.onError(errorHandler) + const res = await app.request('/question') + const body = await res.json() + expect(body[0].questions[0].options).toEqual([ + { label: 'us-east-1', description: '' }, + { label: 'us-west-2', description: '' }, + ]) + expect(body[0].questions[0].custom).toBe(false) + }) + }) + + describe('POST /question/:id/reply', () => { + test('replies to elicitation with parsed content', async () => { + const handle = createMockHandle() + const mgr = createMockSessionManager({ + findQuestion: { + handle, + question: { + requestId: 'req-1', + sessionId: 'sess-1', + mcpServerName: 'my-server', + message: 'Please provide API key', + mode: 'form', + requestedSchema: { + type: 'object', + properties: { key: { type: 'string' } }, + }, + }, + }, + }) + const app = createQuestionRoutes(mgr) + app.onError(errorHandler) + const res = await app.request('/question/req-1/reply', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ answers: [['my-api-key']] }), + }) + expect(res.status).toBe(200) + expect(handle.replyQuestion).toHaveBeenCalledWith( + 'req-1', + 'accept', + { key: 'my-api-key' }, + ) + }) + + test('replies to AskUserQuestionTool permission', async () => { + const handle = createMockHandle() + const mgr = createMockSessionManager({ + findPermission: { + handle, + perm: { + requestId: 'perm-1', + sessionId: 'sess-1', + toolName: 'AskUserQuestionTool', + toolUseId: 'tu-1', + input: { + questions: [ + { question: 'Which library?', header: 'Library', options: [{ label: 'A', description: '' }] }, + ], + }, + }, + }, + }) + const app = createQuestionRoutes(mgr) + app.onError(errorHandler) + const res = await app.request('/question/perm-1/reply', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ answers: [['A']] }), + }) + expect(res.status).toBe(200) + expect(handle.replyPermission).toHaveBeenCalledWith( + 'perm-1', + 'allow', + { + updatedInput: expect.objectContaining({ + answers: { 'Which library?': 'A' }, + }), + }, + ) + }) + + test('returns 404 for unknown request', async () => { + const mgr = createMockSessionManager({}) + const app = createQuestionRoutes(mgr) + app.onError(errorHandler) + const res = await app.request('/question/unknown/reply', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ answers: [[]] }), + }) + expect(res.status).toBe(404) + }) + }) + + describe('POST /question/:id/reject', () => { + test('rejects elicitation', async () => { + const handle = createMockHandle() + const mgr = createMockSessionManager({ + findQuestion: { + handle, + question: { requestId: 'req-1' }, + }, + }) + const app = createQuestionRoutes(mgr) + app.onError(errorHandler) + const res = await app.request('/question/req-1/reject', { method: 'POST' }) + expect(res.status).toBe(200) + expect(handle.replyQuestion).toHaveBeenCalledWith('req-1', 'decline') + }) + + test('rejects AskUserQuestionTool permission', async () => { + const handle = createMockHandle() + const mgr = createMockSessionManager({ + findPermission: { + handle, + perm: { + requestId: 'perm-1', + toolName: 'AskUserQuestionTool', + }, + }, + }) + const app = createQuestionRoutes(mgr) + app.onError(errorHandler) + const res = await app.request('/question/perm-1/reject', { method: 'POST' }) + expect(res.status).toBe(200) + expect(handle.replyPermission).toHaveBeenCalledWith('perm-1', 'deny') + }) + }) +}) diff --git a/src/server/routes/question.ts b/src/server/routes/question.ts index 75af140f9..e116bdd2e 100644 --- a/src/server/routes/question.ts +++ b/src/server/routes/question.ts @@ -2,31 +2,219 @@ import { Hono } from 'hono' import type { SessionManager } from '../sessionManager.js' import { notFound } from '../errors.js' +// ============================================================================ +// Opencode-compatible Question Types +// ============================================================================ + +type QuestionOption = { + label: string + description: string +} + +type QuestionInfo = { + question: string + header: string + options: QuestionOption[] + multiple?: boolean + custom?: boolean +} + +type QuestionRequest = { + id: string + sessionID: string + questions: QuestionInfo[] +} + +type QuestionReplyBody = { + answers: string[][] +} + +// ============================================================================ +// Conversion helpers +// ============================================================================ + +function convertAskUserQuestionToOpencode( + requestId: string, + sessionId: string, + input: Record, +): QuestionRequest { + const questions = (input.questions as Array<{ + question: string + header: string + options: Array<{ label: string; description: string }> + multiSelect?: boolean + }>) ?? [] + + return { + id: requestId, + sessionID: sessionId, + questions: questions.map(q => ({ + question: q.question, + header: q.header, + options: q.options.map(o => ({ + label: o.label, + description: o.description, + })), + multiple: q.multiSelect ?? false, + custom: false, + })), + } +} + +function convertElicitationToOpencode( + requestId: string, + sessionId: string, + message: string, + mcpServerName: string, + requestedSchema: Record, +): QuestionRequest { + // Try to extract enum options from schema properties for better UX + const properties = requestedSchema.properties as Record | undefined + let options: QuestionOption[] = [] + let custom = true + + if (properties && Object.keys(properties).length === 1) { + const singleProp = Object.values(properties)[0] + if (singleProp.enum && singleProp.enum.length > 0) { + options = singleProp.enum.map(label => ({ label, description: '' })) + custom = false + } + } + + return { + id: requestId, + sessionID: sessionId, + questions: [{ + question: message, + header: mcpServerName || 'MCP', + options, + multiple: false, + custom, + }], + } +} + +function buildElicitationContent( + requestedSchema: Record, + answers: string[][], +): Record { + const answerText = answers[0]?.[0] ?? '' + if (!answerText) return {} + + const properties = requestedSchema.properties as Record | undefined + if (properties && Object.keys(properties).length === 1) { + const key = Object.keys(properties)[0] + const prop = properties[key] + if (prop.type === 'string' && prop.enum && prop.enum.includes(answerText)) { + return { [key]: answerText } + } + if (prop.type === 'boolean' && (answerText === 'true' || answerText === 'false')) { + return { [key]: answerText === 'true' } + } + if (prop.type === 'number') { + const num = Number(answerText) + if (!Number.isNaN(num)) return { [key]: num } + } + return { [key]: answerText } + } + + // Fallback: try to parse as JSON for complex schemas + try { + return JSON.parse(answerText) as Record + } catch { + return { answer: answerText } + } +} + +// ============================================================================ +// Routes +// ============================================================================ + export function createQuestionRoutes(sessionManager: SessionManager): Hono { return new Hono() .get('/question', c => { - const questions = sessionManager.getAllPendingQuestions() - return c.json({ questions }) + const result: QuestionRequest[] = [] + + // 1. Elicitation questions (MCP) + for (const q of sessionManager.getAllPendingQuestions()) { + result.push(convertElicitationToOpencode( + q.requestId, + q.sessionId, + q.message, + q.mcpServerName, + q.requestedSchema, + )) + } + + // 2. AskUserQuestionTool permissions exposed as questions + for (const p of sessionManager.getAllPendingPermissions()) { + if (p.toolName === 'AskUserQuestionTool') { + result.push(convertAskUserQuestionToOpencode( + p.requestId, + p.sessionId, + p.input, + )) + } + } + + return c.json(result) }) .post('/question/:requestID/reply', async c => { const requestId = c.req.param('requestID') - const found = sessionManager.findQuestionAcrossSessions(requestId) - if (!found) throw notFound('question request not found') - const body = await c.req.json<{ - action: 'accept' - content?: Record - }>() + // 1. Try elicitation question first + const qFound = sessionManager.findQuestionAcrossSessions(requestId) + if (qFound) { + const body = await c.req.json() + const content = buildElicitationContent(qFound.question.requestedSchema, body.answers) + qFound.handle.replyQuestion(requestId, 'accept', content) + return c.json({ resolved: true }) + } - found.handle.replyQuestion(requestId, body.action, body.content) - return c.json({ resolved: true }) + // 2. Try AskUserQuestionTool permission + const pFound = sessionManager.findPermissionAcrossSessions(requestId) + if (pFound && pFound.perm.toolName === 'AskUserQuestionTool') { + const body = await c.req.json() + const input = pFound.perm.input as { + questions: Array<{ question: string }> + answers?: Record + annotations?: Record + } + + const answers: Record = {} + for (let i = 0; i < input.questions.length; i++) { + const ansLabels = body.answers[i] ?? [] + answers[input.questions[i].question] = ansLabels.join(', ') + } + + pFound.handle.replyPermission(requestId, 'allow', { + updatedInput: { + ...pFound.perm.input, + answers, + }, + }) + return c.json({ resolved: true }) + } + + throw notFound('question request not found') }) .post('/question/:requestID/reject', async c => { const requestId = c.req.param('requestID') - const found = sessionManager.findQuestionAcrossSessions(requestId) - if (!found) throw notFound('question request not found') - found.handle.replyQuestion(requestId, 'decline') - return c.json({ resolved: true }) + // 1. Try elicitation question first + const qFound = sessionManager.findQuestionAcrossSessions(requestId) + if (qFound) { + qFound.handle.replyQuestion(requestId, 'decline') + return c.json({ resolved: true }) + } + + // 2. Try AskUserQuestionTool permission + const pFound = sessionManager.findPermissionAcrossSessions(requestId) + if (pFound && pFound.perm.toolName === 'AskUserQuestionTool') { + pFound.handle.replyPermission(requestId, 'deny') + return c.json({ resolved: true }) + } + + throw notFound('question request not found') }) } diff --git a/src/server/routes/session.ts b/src/server/routes/session.ts index 719fa7157..c9c6949c0 100644 --- a/src/server/routes/session.ts +++ b/src/server/routes/session.ts @@ -11,6 +11,33 @@ import { } from '../errors.js' import { listSessionsImpl } from '../../utils/listSessionsImpl.js' +async function getOrCreateSession( + sessionManager: SessionManager, + id: string, +): Promise { + const existing = sessionManager.getSession(id) + if (existing) return existing + + try { + const handle = await sessionManager.createSession({ + resumeSessionId: id, + execPath: process.execPath, + scriptArgs: getScriptArgsForChild(), + }) + await handle.start() + return handle + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to create session' + if (msg.includes('Maximum concurrent')) { + throw tooManySessions(msg) + } + if (msg.includes('Working directory does not exist')) { + throw sessionError(msg) + } + throw notFound('session not found') + } +} + function ssePrompt( handle: import('../sessionHandle.js').SessionHandle, id: string, @@ -355,8 +382,7 @@ export function createSessionRoutes( }) .post('/session/:sessionID/prompt', async c => { const id = c.req.param('sessionID') - const handle = sessionManager.getSession(id) - if (!handle) throw notFound('session not found') + const handle = await getOrCreateSession(sessionManager, id) const body = await c.req.json<{ content?: string @@ -382,10 +408,7 @@ export function createSessionRoutes( .post('/session/:sessionID/prompt_async', async c => { const id = c.req.param('sessionID') - const handle = sessionManager.getSession(id) - if (!handle) { - throw notFound('session not found') - } + const handle = await getOrCreateSession(sessionManager, id) const body = await c.req.json<{ content?: string @@ -422,15 +445,13 @@ export function createSessionRoutes( }) .post('/session/:sessionID/abort', async c => { const id = c.req.param('sessionID') - const handle = sessionManager.getSession(id) - if (!handle) throw notFound('session not found') + const handle = await getOrCreateSession(sessionManager, id) await handle.abort() return c.json({ aborted: true }) }) .post('/session/:sessionID/shell', async c => { const id = c.req.param('sessionID') - const handle = sessionManager.getSession(id) - if (!handle) throw notFound('session not found') + const handle = await getOrCreateSession(sessionManager, id) const body = await c.req.json<{ command: string }>() if (!body.command) throw badRequest('command is required') @@ -438,8 +459,7 @@ export function createSessionRoutes( }) .post('/session/:sessionID/command', async c => { const id = c.req.param('sessionID') - const handle = sessionManager.getSession(id) - if (!handle) throw notFound('session not found') + const handle = await getOrCreateSession(sessionManager, id) const body = await c.req.json<{ command: string }>() if (!body.command) throw badRequest('command is required') @@ -447,8 +467,7 @@ export function createSessionRoutes( }) .post('/session/:sessionID/command_async', async c => { const id = c.req.param('sessionID') - const handle = sessionManager.getSession(id) - if (!handle) throw notFound('session not found') + const handle = await getOrCreateSession(sessionManager, id) const body = await c.req.json<{ command: string }>() if (!body.command) throw badRequest('command is required') diff --git a/src/server/sessionHandle.ts b/src/server/sessionHandle.ts index 9482894b6..6bec386f7 100644 --- a/src/server/sessionHandle.ts +++ b/src/server/sessionHandle.ts @@ -252,6 +252,7 @@ export class SessionHandle { const env: NodeJS.ProcessEnv = { ...process.env, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', + CSC_SERVE_MODE: '1', } if (this.opts.systemPrompt) {