feat(server/question): adapt question API to opencode-compatible format

Refactor csc serve question endpoints to match opencode/app-ai-native
expected data models and request/response shapes:

- GET /question now returns QuestionRequest[] (direct array) instead of
  { questions: [...] }
- POST /question/:id/reply accepts { answers: string[][] } mapping to
  selected option labels per question
- POST /question/:id/reject no longer requires a request body
- Support dual question sources:
  - MCP Elicitation (converted to opencode format with enum→options)
  - AskUserQuestionTool permissions exposed as questions
- Add conversion helpers: convertAskUserQuestionToOpencode,
  convertElicitationToOpencode, buildElicitationContent
- Reply to AskUserQuestionTool via replyPermission('allow') with
  constructed updatedInput.answers map

Also includes serve-mode infrastructure improvements:
- Session routes auto-create sessions on demand via getOrCreateSession
- Set CSC_SERVE_MODE env var in child process spawn
- Skip push-suggestion stream waiting in serve mode

Add comprehensive unit tests for question route opencode conversion.

Co-authored-by: CoStrict <zgsm@sangfor.com.cn>
This commit is contained in:
林凯90331 2026-05-09 17:52:43 +08:00
parent 6274c0e2be
commit 0b36424820
6 changed files with 565 additions and 55 deletions

View File

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

View File

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

View File

@ -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<string, unknown>
}>
permissions?: Array<{
requestId: string
sessionId: string
toolName: string
toolUseId: string
input: Record<string, unknown>
title: string
description: string
suggestions: Record<string, unknown>[]
}>
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')
})
})
})

View File

@ -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<string, unknown>,
): 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<string, unknown>,
): QuestionRequest {
// Try to extract enum options from schema properties for better UX
const properties = requestedSchema.properties as Record<string, { enum?: string[]; type?: string }> | 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<string, unknown>,
answers: string[][],
): Record<string, unknown> {
const answerText = answers[0]?.[0] ?? ''
if (!answerText) return {}
const properties = requestedSchema.properties as Record<string, { type?: string; enum?: string[] }> | 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<string, unknown>
} 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<string, unknown>
}>()
// 1. Try elicitation question first
const qFound = sessionManager.findQuestionAcrossSessions(requestId)
if (qFound) {
const body = await c.req.json<QuestionReplyBody>()
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<QuestionReplyBody>()
const input = pFound.perm.input as {
questions: Array<{ question: string }>
answers?: Record<string, string>
annotations?: Record<string, unknown>
}
const answers: Record<string, string> = {}
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')
})
}

View File

@ -11,6 +11,33 @@ import {
} from '../errors.js'
import { listSessionsImpl } from '../../utils/listSessionsImpl.js'
async function getOrCreateSession(
sessionManager: SessionManager,
id: string,
): Promise<import('../sessionHandle.js').SessionHandle> {
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')

View File

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