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:
parent
6274c0e2be
commit
0b36424820
|
|
@ -513,29 +513,48 @@ data: {"type":"server.heartbeat","ts":1713001234567}
|
||||||
|
|
||||||
## 6. Question
|
## 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`
|
### `GET /question`
|
||||||
|
|
||||||
列出所有待处理的问题请求。
|
列出所有待处理的问题请求,返回 opencode 格式的数组。
|
||||||
|
|
||||||
**Response 200:**
|
**Response 200:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
[
|
||||||
"questions": [
|
{
|
||||||
{
|
"id": "req-2",
|
||||||
"request_id": "req-2",
|
"sessionID": "uuid-1",
|
||||||
"session_id": "uuid-1",
|
"questions": [
|
||||||
"mcp_server_name": "my-server",
|
{
|
||||||
"message": "Please provide your API key",
|
"question": "Which library should we use?",
|
||||||
"mode": "form",
|
"header": "Library",
|
||||||
"requested_schema": { "type": "object", "properties": { "key": { "type": "string" } } }
|
"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`
|
### `POST /question/:requestID/reply`
|
||||||
|
|
||||||
回复问题请求。
|
回复问题请求。
|
||||||
|
|
@ -544,11 +563,15 @@ csc 通过 `control_request { subtype: "elicitation" }` 实现 MCP elicitation
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"action": "accept",
|
"answers": [
|
||||||
"content": { "key": "sk-xxx" }
|
["date-fns"],
|
||||||
|
["option1", "option2"]
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`answers` 是二维数组,外层索引对应 `questions` 数组顺序,内层是每个问题选中的 `label` 列表。
|
||||||
|
|
||||||
**Response 200:**
|
**Response 200:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
@ -557,15 +580,7 @@ csc 通过 `control_request { subtype: "elicitation" }` 实现 MCP elicitation
|
||||||
|
|
||||||
### `POST /question/:requestID/reject`
|
### `POST /question/:requestID/reject`
|
||||||
|
|
||||||
拒绝问题请求。
|
拒绝问题请求。无需请求体。
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"action": "decline"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response 200:**
|
**Response 200:**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2743,7 +2743,7 @@ function runHeadlessStreaming(
|
||||||
uuid: randomUUID(),
|
uuid: randomUUID(),
|
||||||
})
|
})
|
||||||
void run()
|
void run()
|
||||||
} else {
|
} else if (!process.env.CSC_SERVE_MODE) {
|
||||||
// Wait for any in-flight push suggestion before closing the output stream.
|
// Wait for any in-flight push suggestion before closing the output stream.
|
||||||
if (suggestionState.inflightPromise) {
|
if (suggestionState.inflightPromise) {
|
||||||
await Promise.race([suggestionState.inflightPromise, sleep(5000)])
|
await Promise.race([suggestionState.inflightPromise, sleep(5000)])
|
||||||
|
|
@ -4296,7 +4296,7 @@ function runHeadlessStreaming(
|
||||||
}
|
}
|
||||||
inputClosed = true
|
inputClosed = true
|
||||||
cronScheduler?.stop()
|
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
|
// If a push-suggestion is in-flight, wait for it to emit before closing
|
||||||
// the output stream (5 s safety timeout to prevent hanging).
|
// the output stream (5 s safety timeout to prevent hanging).
|
||||||
if (suggestionState.inflightPromise) {
|
if (suggestionState.inflightPromise) {
|
||||||
|
|
|
||||||
287
src/server/__tests__/questionRoutes.test.ts
Normal file
287
src/server/__tests__/questionRoutes.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -2,31 +2,219 @@ import { Hono } from 'hono'
|
||||||
import type { SessionManager } from '../sessionManager.js'
|
import type { SessionManager } from '../sessionManager.js'
|
||||||
import { notFound } from '../errors.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 {
|
export function createQuestionRoutes(sessionManager: SessionManager): Hono {
|
||||||
return new Hono()
|
return new Hono()
|
||||||
.get('/question', c => {
|
.get('/question', c => {
|
||||||
const questions = sessionManager.getAllPendingQuestions()
|
const result: QuestionRequest[] = []
|
||||||
return c.json({ questions })
|
|
||||||
|
// 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 => {
|
.post('/question/:requestID/reply', async c => {
|
||||||
const requestId = c.req.param('requestID')
|
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<{
|
// 1. Try elicitation question first
|
||||||
action: 'accept'
|
const qFound = sessionManager.findQuestionAcrossSessions(requestId)
|
||||||
content?: Record<string, unknown>
|
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)
|
// 2. Try AskUserQuestionTool permission
|
||||||
return c.json({ resolved: true })
|
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 => {
|
.post('/question/:requestID/reject', async c => {
|
||||||
const requestId = c.req.param('requestID')
|
const requestId = c.req.param('requestID')
|
||||||
const found = sessionManager.findQuestionAcrossSessions(requestId)
|
|
||||||
if (!found) throw notFound('question request not found')
|
|
||||||
|
|
||||||
found.handle.replyQuestion(requestId, 'decline')
|
// 1. Try elicitation question first
|
||||||
return c.json({ resolved: true })
|
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')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,33 @@ import {
|
||||||
} from '../errors.js'
|
} from '../errors.js'
|
||||||
import { listSessionsImpl } from '../../utils/listSessionsImpl.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(
|
function ssePrompt(
|
||||||
handle: import('../sessionHandle.js').SessionHandle,
|
handle: import('../sessionHandle.js').SessionHandle,
|
||||||
id: string,
|
id: string,
|
||||||
|
|
@ -355,8 +382,7 @@ export function createSessionRoutes(
|
||||||
})
|
})
|
||||||
.post('/session/:sessionID/prompt', async c => {
|
.post('/session/:sessionID/prompt', async c => {
|
||||||
const id = c.req.param('sessionID')
|
const id = c.req.param('sessionID')
|
||||||
const handle = sessionManager.getSession(id)
|
const handle = await getOrCreateSession(sessionManager, id)
|
||||||
if (!handle) throw notFound('session not found')
|
|
||||||
|
|
||||||
const body = await c.req.json<{
|
const body = await c.req.json<{
|
||||||
content?: string
|
content?: string
|
||||||
|
|
@ -382,10 +408,7 @@ export function createSessionRoutes(
|
||||||
.post('/session/:sessionID/prompt_async', async c => {
|
.post('/session/:sessionID/prompt_async', async c => {
|
||||||
const id = c.req.param('sessionID')
|
const id = c.req.param('sessionID')
|
||||||
|
|
||||||
const handle = sessionManager.getSession(id)
|
const handle = await getOrCreateSession(sessionManager, id)
|
||||||
if (!handle) {
|
|
||||||
throw notFound('session not found')
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await c.req.json<{
|
const body = await c.req.json<{
|
||||||
content?: string
|
content?: string
|
||||||
|
|
@ -422,15 +445,13 @@ export function createSessionRoutes(
|
||||||
})
|
})
|
||||||
.post('/session/:sessionID/abort', async c => {
|
.post('/session/:sessionID/abort', async c => {
|
||||||
const id = c.req.param('sessionID')
|
const id = c.req.param('sessionID')
|
||||||
const handle = sessionManager.getSession(id)
|
const handle = await getOrCreateSession(sessionManager, id)
|
||||||
if (!handle) throw notFound('session not found')
|
|
||||||
await handle.abort()
|
await handle.abort()
|
||||||
return c.json({ aborted: true })
|
return c.json({ aborted: true })
|
||||||
})
|
})
|
||||||
.post('/session/:sessionID/shell', async c => {
|
.post('/session/:sessionID/shell', async c => {
|
||||||
const id = c.req.param('sessionID')
|
const id = c.req.param('sessionID')
|
||||||
const handle = sessionManager.getSession(id)
|
const handle = await getOrCreateSession(sessionManager, id)
|
||||||
if (!handle) throw notFound('session not found')
|
|
||||||
|
|
||||||
const body = await c.req.json<{ command: string }>()
|
const body = await c.req.json<{ command: string }>()
|
||||||
if (!body.command) throw badRequest('command is required')
|
if (!body.command) throw badRequest('command is required')
|
||||||
|
|
@ -438,8 +459,7 @@ export function createSessionRoutes(
|
||||||
})
|
})
|
||||||
.post('/session/:sessionID/command', async c => {
|
.post('/session/:sessionID/command', async c => {
|
||||||
const id = c.req.param('sessionID')
|
const id = c.req.param('sessionID')
|
||||||
const handle = sessionManager.getSession(id)
|
const handle = await getOrCreateSession(sessionManager, id)
|
||||||
if (!handle) throw notFound('session not found')
|
|
||||||
|
|
||||||
const body = await c.req.json<{ command: string }>()
|
const body = await c.req.json<{ command: string }>()
|
||||||
if (!body.command) throw badRequest('command is required')
|
if (!body.command) throw badRequest('command is required')
|
||||||
|
|
@ -447,8 +467,7 @@ export function createSessionRoutes(
|
||||||
})
|
})
|
||||||
.post('/session/:sessionID/command_async', async c => {
|
.post('/session/:sessionID/command_async', async c => {
|
||||||
const id = c.req.param('sessionID')
|
const id = c.req.param('sessionID')
|
||||||
const handle = sessionManager.getSession(id)
|
const handle = await getOrCreateSession(sessionManager, id)
|
||||||
if (!handle) throw notFound('session not found')
|
|
||||||
|
|
||||||
const body = await c.req.json<{ command: string }>()
|
const body = await c.req.json<{ command: string }>()
|
||||||
if (!body.command) throw badRequest('command is required')
|
if (!body.command) throw badRequest('command is required')
|
||||||
|
|
|
||||||
|
|
@ -252,6 +252,7 @@ export class SessionHandle {
|
||||||
const env: NodeJS.ProcessEnv = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
||||||
|
CSC_SERVE_MODE: '1',
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.opts.systemPrompt) {
|
if (this.opts.systemPrompt) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user