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
|
||||
|
||||
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:**
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
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 { 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')
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user