sandbox-agent/research/acp/missing-features-spec/01-questions.md
2026-02-11 14:47:41 +00:00

205 lines
6.4 KiB
Markdown

# Feature 1: Questions Subsystem
**Implementation approach:** ACP extension (`_sandboxagent/session/request_question`)
## Summary
v1 had a full question subsystem: agent requests a question from the user, client replies with an answer or rejection, and the system tracks question status. v2 has partial stub implementation in mock only.
## Current v2 State
- `_sandboxagent/session/request_question` is declared as a constant in `acp_runtime/mod.rs:33`
- Advertised in capability injection (`extensions.sessionRequestQuestion: true`)
- **Mock agent** (`acp_runtime/mock.rs:174-203`) emits questions when prompt contains "question"
- **No real agent handler** in the runtime for routing question requests/responses between real agent processes and clients
- Mock response handling exists (`mock.rs:377-415`) but the runtime lacks the general forwarding path
## v1 Types (exact, from `universal-agent-schema/src/lib.rs`)
```rust
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct QuestionEventData {
pub question_id: String,
pub prompt: String,
pub options: Vec<String>,
pub response: Option<String>,
pub status: QuestionStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum QuestionStatus {
Requested,
Answered,
Rejected,
}
```
## v1 HTTP Types (exact, from `router.rs`)
```rust
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct QuestionReplyRequest {
pub answers: Vec<Vec<String>>,
}
#[derive(Debug, Clone)]
pub(crate) struct PendingQuestionInfo {
pub session_id: String,
pub question_id: String,
pub prompt: String,
pub options: Vec<String>,
}
#[derive(Debug, Clone)]
struct PendingQuestion {
prompt: String,
options: Vec<String>,
}
```
## v1 HTTP Endpoints (from `router.rs`)
```
POST /v1/sessions/{session_id}/questions/{question_id}/reply -> 204 No Content
POST /v1/sessions/{session_id}/questions/{question_id}/reject -> 204 No Content
```
### `reply_question` handler
```rust
async fn reply_question(
State(state): State<Arc<AppState>>,
Path((session_id, question_id)): Path<(String, String)>,
Json(request): Json<QuestionReplyRequest>,
) -> Result<StatusCode, ApiError> {
state.session_manager
.reply_question(&session_id, &question_id, request.answers)
.await?;
Ok(StatusCode::NO_CONTENT)
}
```
### `reject_question` handler
```rust
async fn reject_question(
State(state): State<Arc<AppState>>,
Path((session_id, question_id)): Path<(String, String)>,
) -> Result<StatusCode, ApiError> {
state.session_manager
.reject_question(&session_id, &question_id)
.await?;
Ok(StatusCode::NO_CONTENT)
}
```
## v1 SessionManager Methods (exact)
### `reply_question`
Key flow:
1. Look up session, take pending question by `question_id`
2. Extract first answer: `answers.first().and_then(|inner| inner.first()).cloned()`
3. Per-agent forwarding:
- **OpenCode**: `opencode_question_reply(&agent_session_id, question_id, answers)`
- **Claude**: If linked to a permission (AskUserQuestion/ExitPlanMode), send `claude_control_response_line` with `"allow"` and `updatedInput`; otherwise send `claude_tool_result_line`
- Others: TODO
4. Emit `QuestionResolved` event with `status: Answered`
### `reject_question`
Key flow:
1. Look up session, take pending question
2. Per-agent forwarding:
- **OpenCode**: `opencode_question_reject(&agent_session_id, question_id)`
- **Claude**: If linked to permission, send `claude_control_response_line` with `"deny"`; otherwise send `claude_tool_result_line` with `is_error: true`
- Others: TODO
3. Emit `QuestionResolved` event with `status: Rejected`
## v1 Event Flow
1. Agent emits `question.requested` event with `QuestionEventData { status: Requested, question_id, prompt, options }`
2. Client renders question UI
3. Client calls `POST /v1/sessions/{id}/questions/{qid}/reply` with `{ answers: [["selected"]] }` or `POST .../reject`
4. System emits `question.resolved` event with `QuestionEventData { status: Answered, response: Some("...") }` or `{ status: Rejected }`
## v1 Agent Capability
```rust
AgentId::Claude => AgentCapabilities { questions: true, ... },
AgentId::Codex => AgentCapabilities { questions: false, ... },
AgentId::Opencode => AgentCapabilities { questions: false, ... },
AgentId::Amp => AgentCapabilities { questions: false, ... },
AgentId::Mock => AgentCapabilities { questions: true, ... },
```
## Implementation Plan
### ACP Extension Design
The question flow maps to ACP's bidirectional request/response:
1. **Agent -> Runtime:** Agent process sends `_sandboxagent/session/request_question` as a JSON-RPC request
2. **Runtime -> Client:** Runtime forwards as a client-directed request in the SSE stream
3. **Client -> Runtime:** Client POSTs a JSON-RPC response (answered/rejected)
4. **Runtime -> Agent:** Runtime forwards the response back to the agent process stdin
### Payload Shape
Agent request:
```json
{
"jsonrpc": "2.0",
"id": "q-1",
"method": "_sandboxagent/session/request_question",
"params": {
"sessionId": "...",
"questionId": "uuid",
"prompt": "Which option?",
"options": [["option-a", "Option A"], ["option-b", "Option B"]]
}
}
```
Client response (answered):
```json
{
"jsonrpc": "2.0",
"id": "q-1",
"result": {
"status": "answered",
"answers": [["option-a"]]
}
}
```
Client response (rejected):
```json
{
"jsonrpc": "2.0",
"id": "q-1",
"result": {
"status": "rejected"
}
}
```
### Files to Modify
| File | Change |
|------|--------|
| `server/packages/sandbox-agent/src/acp_runtime/mod.rs` | Add real request/response forwarding for `_sandboxagent/session/request_question` (currently only mock) |
| `server/packages/sandbox-agent/src/acp_runtime/mock.rs` | Already has mock implementation; verify alignment with final payload shape |
| `sdks/typescript/src/client.ts` | Add `onQuestion()` callback and `replyQuestion()` / `rejectQuestion()` methods |
| `frontend/packages/inspector/` | Add question rendering in inspector UI |
### Docs to Update
| Doc | Change |
|-----|--------|
| `docs/openapi.json` | N/A (ACP extension, not HTTP endpoint) |
| `docs/sdks/typescript.mdx` | Document `onQuestion` / `replyQuestion` / `rejectQuestion` SDK methods |
| `docs/inspector.mdx` | Document question rendering in inspector |
| `research/acp/spec.md` | Update extension methods list |