chore(runner): preserve integration work before PR split

This commit is contained in:
Dotta committed 2026-08-24 08:19:02 -05:00
1 parent 761b60b42c
commit e3df307545
101 files changed
+5865 -395

No files matched your search

+1 -1
View File
@@ -617,7 +617,7 @@ export type TranscriptEntry =
| { kind: "provider_activity"; ts: string; family: ProviderActivityFamily; eventType: string; status: ProviderActivityStatus; title: string; summary: string; payload: Record<string, unknown> }
| { kind: "workspace_change"; ts: string; changeSetId: string; revision: number; source: "harness_reported" | "runner_verified"; complete: boolean; files: TranscriptWorkspaceChangeFile[]; totals: { files: number; additions: number | null; deletions: number | null }; patchArtifactRef: string | null }
| { kind: "workspace_file_reference"; ts: string; referenceId: string; source: "harness_reported" | "runner_verified"; path: string; displayName: string; mediaType: string | null; presentation: "document" | "code" | "image" | "generic"; line: number | null; preview: string | null; previewTruncated: boolean; contentDigest: string | null }
| { kind: "runtime_request"; ts: string; requestId: string; requestKind: "runtime" | "command_approval" | "file_approval" | "permission_approval" | "user_input" | "elicitation" | null; turnId: string | null; requestType: "permission" | "input"; status: "pending" | "resolved" | "expired" | "cancelled"; prompt: string; choices: Array<{ key: string; label: string }>; fields: Array<{ name: string; label: string; placeholder: string | null }>; questionSet?: PaperclipQuestionSet | null }
| { kind: "runtime_request"; ts: string; requestId: string; requestKind: "runtime" | "command_approval" | "file_approval" | "permission_approval" | "user_input" | "elicitation" | null; turnId: string | null; requestType: "permission" | "input"; status: "pending" | "resolved" | "expired" | "cancelled"; prompt: string; choices: Array<{ key: string; label: string }>; fields: Array<{ name: string; label: string; placeholder: string | null }>; questionSet?: PaperclipQuestionSet | null; resolvedAction?: string | null; response?: PaperclipQuestionResponse | null }
| { kind: "run_result"; ts: string; disposition: "done" | "blocked" | "needs_review" | "yielded"; summary: string; objectiveSatisfied: boolean | null; verification: TranscriptRunVerification[]; remainingWork: Array<{ description: string; blocksCompletion: boolean }>; blocker: { reasonCode: string; unblockAction: string; scope: "current_track" | "task_wide" } | null; artifacts: TranscriptRunArtifact[] }
| { kind: "run_terminal"; ts: string; turnState: "completed" | "failed" | "interrupted" | "cancelled"; runState: "succeeded" | "failed" | "cancelled"; disposition: "done" | "blocked" | "needs_review" | "yielded"; stopReason?: string };
+1
View File
@@ -74,6 +74,7 @@ export { issueApprovals } from "./issue_approvals.js";
export { issueComments } from "./issue_comments.js";
export { issueCreateIdempotencyKeys } from "./issue_create_idempotency_keys.js";
export { issueThreadInteractions } from "./issue_thread_interactions.js";
export { issueQuestionResponseDeliveries } from "./issue_question_response_deliveries.js";
export {
decisions,
decisionBundles,
@@ -0,0 +1,62 @@
import { sql } from "drizzle-orm";
import {
check,
index,
integer,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { issues } from "./issues.js";
import { issueThreadInteractions } from "./issue_thread_interactions.js";
/**
* Durable, content-free delivery state for an answered question interaction.
* The answer remains authoritative in issue_thread_interactions.result; this
* row stores only routing state and a digest of the canonical delivery
* envelope so retries cannot duplicate or silently change the message.
*/
export const issueQuestionResponseDeliveries = pgTable(
"issue_question_response_deliveries",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
interactionId: uuid("interaction_id").notNull().references(() => issueThreadInteractions.id, { onDelete: "cascade" }),
sourceRunId: uuid("source_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
targetRunId: uuid("target_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
targetTurnId: text("target_turn_id"),
correlationId: text("correlation_id").notNull(),
payloadSha256: text("payload_sha256").notNull(),
status: text("status").notNull().default("pending"),
deliveryMode: text("delivery_mode"),
attemptCount: integer("attempt_count").notNull().default(0),
lastAttemptAt: timestamp("last_attempt_at", { withTimezone: true }),
acknowledgedAt: timestamp("acknowledged_at", { withTimezone: true }),
lastErrorCode: text("last_error_code"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
interactionUq: uniqueIndex("issue_question_response_deliveries_interaction_uq").on(table.interactionId),
correlationUq: uniqueIndex("issue_question_response_deliveries_correlation_uq").on(table.correlationId),
pendingIdx: index("issue_question_response_deliveries_pending_idx").on(table.status, table.createdAt),
companyIssueIdx: index("issue_question_response_deliveries_company_issue_idx").on(
table.companyId,
table.issueId,
table.createdAt,
),
statusCheck: check(
"issue_question_response_deliveries_status_check",
sql`${table.status} IN ('pending', 'delivering', 'delivered', 'fallback_queued', 'failed')`,
),
modeCheck: check(
"issue_question_response_deliveries_mode_check",
sql`${table.deliveryMode} IS NULL OR ${table.deliveryMode} IN ('steered', 'coalesced', 'wake_fallback')`,
),
}),
);
@@ -71,6 +71,40 @@ effect. Eval `trace_completeness` treats PRP wire receipts as authoritative when
present and retains the pre-existing scalar fallback for live evidence that has
not yet emitted them.
## Provider-neutral structured input
Harness-initiated forms cross PRP as `paperclip.runtime_request.v2` with
`requestKind: "runtime"`, `type: "input"`, and an embedded
`paperclip.question_set.v1`. A submission is always
`{ "action": "submit", "response": paperclip.question_response.v1 }`.
Codex answer objects, OpenCode answer arrays, and ACP typed content exist only
inside their adapters; `origin` may retain the provider method and adapter name
for diagnostics but never provider response data.
Question and option order is significant, while answers are keyed by stable
question IDs and selections reference stable option IDs. The canonical modes
are `text`, `single_select`, and `multi_select`. Text validation is repeated at
the untrusted server edge and again against the persisted question set before a
provider receives the translated response.
Every harness adapter must add a
`paperclip.question_adapter_fixture.v1` fixture proving its native request
normalizes to the canonical shape and its canonical response can be translated
back. The shared fixture format deliberately contains both native and canonical
objects so adding a provider does not change PRP or the UI contract.
V1 runtime requests and their legacy resolutions remain accepted during the
migration. A request that contains no structured form stays on the legacy path;
once a provider supplies a form, malformed or unsupported fields fail closed
instead of silently degrading. ACPX sidecars advertise only form elicitation
and use sidecar protocol v2 `runtime.input_requested` / `input.resolve` frames.
The live lifecycle pauses and resumes the same provider turn. If the provider
process is lost first, Paperclip emits one non-replayable
`runtime_request.expired` fact and materializes an idempotent durable
`ask_user_questions` interaction using the identical question set. Explicit
cancellation and already-resolved requests never create that fallback.
Seven conformance fixtures cover artifact success, redacted denial without
fallback, stale conflict plus duplicate retry, governed target and continuation
causality, budget/cost stop, unknown optional fields, and rejection of an
@@ -8,6 +8,7 @@
"message": "Deployment input",
"requestedSchema": {
"type": "object",
"title": "Deployment input",
"required": ["environment"],
"properties": {
"environment": {
@@ -36,8 +37,7 @@
"options": [
{ "id": "option-1", "label": "Staging", "description": "Deploy to staging first." },
{ "id": "option-2", "label": "Production", "description": "Deploy directly to production." }
],
"customAnswer": { "enabled": true, "label": "Other" }
]
}]
},
"canonicalResponse": {
@@ -4,6 +4,7 @@
"nativeRequest": {
"method": "item/tool/requestUserInput",
"params": {
"title": "Deployment input",
"questions": [{
"id": "environment",
"header": "Environment",
@@ -11,8 +12,7 @@
"options": [
{ "label": "Staging", "description": "Deploy to staging first." },
{ "label": "Production", "description": "Deploy directly to production." }
],
"isOther": true
]
}]
}
},
@@ -29,8 +29,7 @@
"options": [
{ "id": "option-1", "label": "Staging", "description": "Deploy to staging first." },
{ "id": "option-2", "label": "Production", "description": "Deploy directly to production." }
],
"customAnswer": { "enabled": true, "label": "Other" }
]
}]
},
"canonicalResponse": {
@@ -6,6 +6,7 @@
"properties": {
"id": "request-1",
"sessionID": "session-1",
"title": "Deployment input",
"questions": [{
"question": "Where should we deploy?",
"header": "Environment",
@@ -13,8 +14,7 @@
{ "label": "Staging", "description": "Deploy to staging first." },
{ "label": "Production", "description": "Deploy directly to production." }
],
"multiple": false,
"custom": true
"multiple": false
}]
}
},
@@ -31,8 +31,7 @@
"options": [
{ "id": "option-1", "label": "Staging", "description": "Deploy to staging first." },
{ "id": "option-2", "label": "Production", "description": "Deploy directly to production." }
],
"customAnswer": { "enabled": true, "label": "Other" }
]
}]
},
"canonicalResponse": {
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://paperclip.dev/schemas/acpx-sidecar/v1/message.schema.json",
"$id": "https://paperclip.dev/schemas/acpx-sidecar/v2/message.schema.json",
"title": "Paperclip ACPX sidecar message",
"oneOf": [
{ "$ref": "#/$defs/request" },
@@ -11,13 +11,13 @@
"command": {
"enum": [
"initialize", "session.open", "run.attach", "turn.start", "turn.cancel",
"permission.resolve", "tool.resolve", "session.read", "session.snapshot",
"permission.resolve", "input.resolve", "tool.resolve", "session.read", "session.snapshot",
"session.suspend", "session.close"
]
},
"eventType": {
"enum": [
"runtime.event", "runtime.permission_requested", "runtime.tool_called",
"runtime.event", "runtime.permission_requested", "runtime.input_requested", "runtime.tool_called",
"runtime.turn_terminal", "runtime.process", "runtime.diagnostic"
]
},
@@ -25,7 +25,7 @@
"type": "object",
"required": ["protocolVersion", "id", "command", "params"],
"properties": {
"protocolVersion": { "const": 1 },
"protocolVersion": { "const": 2 },
"id": { "type": "integer", "minimum": 1 },
"command": { "$ref": "#/$defs/command" },
"params": { "type": "object" }
@@ -36,7 +36,7 @@
"type": "object",
"required": ["protocolVersion", "id", "ok"],
"properties": {
"protocolVersion": { "const": 1 },
"protocolVersion": { "const": 2 },
"id": { "type": "integer", "minimum": 1 },
"ok": { "type": "boolean" },
"result": { "type": "object" },
@@ -57,7 +57,7 @@
"type": "object",
"required": ["protocolVersion", "sequence", "eventType", "runId", "turnId", "payload"],
"properties": {
"protocolVersion": { "const": 1 },
"protocolVersion": { "const": 2 },
"sequence": { "type": "integer", "minimum": 1 },
"eventType": { "$ref": "#/$defs/eventType" },
"runId": { "type": ["string", "null"], "maxLength": 160 },
@@ -67,6 +67,22 @@
"customAnswer": { "$ref": "#/$defs/customAnswer" },
"textValidation": { "$ref": "#/$defs/textValidation" }
},
"allOf": [
{
"if": { "properties": { "answerMode": { "const": "text" } }, "required": ["answerMode"] },
"then": {
"properties": { "options": { "type": "array", "maxItems": 0 } },
"not": { "required": ["customAnswer"] }
}
},
{
"if": { "properties": { "answerMode": { "enum": ["single_select", "multi_select"] } }, "required": ["answerMode"] },
"then": {
"required": ["options"],
"properties": { "options": { "type": "array", "minItems": 1 } }
}
}
],
"additionalProperties": false
}
},
@@ -31,6 +31,7 @@ pub struct AcpxProvider {
next_request_id: u64,
inbox: VecDeque<ProviderEvent>,
pending_tools: BTreeMap<String, PendingTool>,
pending_input_requests: BTreeMap<String, String>,
config: AcpxProviderConfig,
tools: Vec<AuthorizedTool>,
catalog_revision: u64,
@@ -62,6 +63,7 @@ impl AcpxProvider {
next_request_id: 1,
inbox: VecDeque::new(),
pending_tools: BTreeMap::new(),
pending_input_requests: BTreeMap::new(),
config: config.clone(),
tools,
catalog_revision: 1,
@@ -213,6 +215,7 @@ impl AcpxProvider {
event_type,
GeneratedAcpxSidecarEventType::RuntimeToolCalled
| GeneratedAcpxSidecarEventType::RuntimePermissionRequested
| GeneratedAcpxSidecarEventType::RuntimeInputRequested
| GeneratedAcpxSidecarEventType::RuntimeTurnTerminal
| GeneratedAcpxSidecarEventType::RuntimeEvent
) {
@@ -283,6 +286,45 @@ impl AcpxProvider {
}),
}))
}
GeneratedAcpxSidecarEventType::RuntimeInputRequested => {
let request_id = required_text(&payload, "requestId", "input request")?;
let question_set = payload.get("questionSet").cloned().ok_or_else(|| {
LocalRunnerError::invalid("ACPX input request omitted its question set")
})?;
validate_question_set(&question_set)?;
let turn_id = message
.get("turnId")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
if self
.pending_input_requests
.insert(request_id.clone(), turn_id.clone())
.is_some()
{
return Err(LocalRunnerError::invalid(
"ACPX reused a pending input request id",
));
}
Ok(Some(ProviderEvent::RuntimeRequest {
request: json!({
"schema": "paperclip.runtime_request.v2",
"requestKind": "runtime",
"requestId": request_id,
"type": "input",
"status": "pending",
"prompt": question_set.get("title").and_then(Value::as_str).unwrap_or("Additional information needed"),
"input": question_set,
"origin": payload.get("origin").cloned().unwrap_or_else(|| json!({
"adapter": "acpx-runtime-sidecar",
"provider": "acpx",
"method": "elicitation/create",
})),
"turnId": turn_id,
"itemId": request_id,
}),
}))
}
GeneratedAcpxSidecarEventType::RuntimeTurnTerminal => {
let turn_id = message
.get("turnId")
@@ -292,6 +334,8 @@ impl AcpxProvider {
if self.active_turn_id.as_deref() == Some(turn_id.as_str()) {
self.active_turn_id = None;
}
self.pending_input_requests
.retain(|_, pending_turn_id| pending_turn_id != &turn_id);
let status = payload
.get("status")
.and_then(Value::as_str)
@@ -380,7 +424,10 @@ impl Provider for AcpxProvider {
run_id: &str,
tools: Vec<AuthorizedTool>,
) -> Result<(), LocalRunnerError> {
if self.active_turn_id.is_some() || !self.pending_tools.is_empty() {
if self.active_turn_id.is_some()
|| !self.pending_tools.is_empty()
|| !self.pending_input_requests.is_empty()
{
return Err(LocalRunnerError::invalid(
"cannot attach an ACPX run while work is active",
));
@@ -503,6 +550,23 @@ impl Provider for AcpxProvider {
turn_id: &str,
resolution: &Value,
) -> Result<(), LocalRunnerError> {
if let Some(pending_turn_id) = self.pending_input_requests.get(request_id) {
if pending_turn_id != turn_id {
return Err(LocalRunnerError::invalid(
"ACPX input resolution named a stale turn",
));
}
self.request(
GeneratedAcpxSidecarCommand::InputResolve,
json!({
"requestId": request_id,
"turnId": turn_id,
"resolution": resolution,
}),
)?;
self.pending_input_requests.remove(request_id);
return Ok(());
}
let action = resolution
.get("action")
.and_then(Value::as_str)
@@ -615,6 +679,69 @@ fn required_text(value: &Value, key: &str, label: &str) -> Result<String, LocalR
.ok_or_else(|| LocalRunnerError::invalid(format!("ACPX sidecar omitted {label} identity")))
}
fn validate_question_set(value: &Value) -> Result<(), LocalRunnerError> {
let schema: Value = serde_json::from_str(include_str!(
"../../../../protocol/schemas/question-set.schema.json"
))
.map_err(|_| LocalRunnerError::invalid("embedded question-set schema is invalid"))?;
let validator = jsonschema::validator_for(&schema)
.map_err(|_| LocalRunnerError::invalid("embedded question-set schema cannot compile"))?;
if !validator.is_valid(value) {
return Err(LocalRunnerError::invalid(
"ACPX input request failed the Paperclip question-set schema",
));
}
if value.get("schema").and_then(Value::as_str) != Some("paperclip.question_set.v1") {
return Err(LocalRunnerError::invalid(
"ACPX input request used an unsupported question-set schema",
));
}
let questions = value
.get("questions")
.and_then(Value::as_array)
.filter(|questions| !questions.is_empty() && questions.len() <= 64)
.ok_or_else(|| {
LocalRunnerError::invalid("ACPX input request must contain 1 through 64 questions")
})?;
let mut question_ids = std::collections::BTreeSet::new();
for question in questions {
let id = question
.get("id")
.and_then(Value::as_str)
.filter(|id| !id.is_empty() && id.len() <= 160)
.ok_or_else(|| LocalRunnerError::invalid("ACPX input question omitted a valid id"))?;
if !question_ids.insert(id) {
return Err(LocalRunnerError::invalid(
"ACPX input question ids must be unique",
));
}
if question.get("required").and_then(Value::as_bool).is_none()
|| question.get("prompt").and_then(Value::as_str).is_none()
{
return Err(LocalRunnerError::invalid(
"ACPX input question omitted required presentation fields",
));
}
let answer_mode = question.get("answerMode").and_then(Value::as_str);
if !matches!(answer_mode, Some("text" | "single_select" | "multi_select")) {
return Err(LocalRunnerError::invalid(
"ACPX input question used an unsupported answer mode",
));
}
if matches!(answer_mode, Some("single_select" | "multi_select"))
&& question
.get("options")
.and_then(Value::as_array)
.map_or(true, Vec::is_empty)
{
return Err(LocalRunnerError::invalid(
"ACPX select question omitted its options",
));
}
}
Ok(())
}
fn permission_kind(value: Option<&str>) -> String {
let kind = value.unwrap_or_default().to_ascii_lowercase();
if kind.contains("write")
@@ -10,6 +10,7 @@ struct Mode {
oversized_event: bool,
linger_after_turn_start: bool,
runtime_question: bool,
runtime_elicitation: bool,
}
fn send(value: Value) -> io::Result<()> {
@@ -26,12 +27,14 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
oversized_event: args.iter().any(|arg| arg == "--oversized-event"),
linger_after_turn_start: args.iter().any(|arg| arg == "--linger-after-turn-start"),
runtime_question: args.iter().any(|arg| arg == "--runtime-question"),
runtime_elicitation: args.iter().any(|arg| arg == "--runtime-elicitation"),
};
let mut turn_count = 0_u64;
let mut active_turn_id: Option<String> = None;
let mut planning_thread = false;
let mut pending_tool_turns = BTreeMap::<String, String>::new();
let mut pending_question_turns = BTreeMap::<String, String>::new();
let mut pending_elicitation_turns = BTreeMap::<String, String>::new();
let stdin = io::stdin();
for line in stdin.lock().lines() {
let request: Value = serde_json::from_str(&line?)?;
@@ -191,6 +194,47 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
}))?;
continue;
}
if mode.runtime_elicitation {
let request_id = format!("rpc-elicitation-{turn_count}");
pending_elicitation_turns.insert(request_id.clone(), turn_id.clone());
send(json!({
"id": request_id,
"method": "mcpServer/elicitation/request",
"params": {
"threadId": "fake-thread",
"turnId": turn_id,
"itemId": "elicitation-item",
"message": "Choose typed deployment settings.",
"requestedSchema": {
"type": "object",
"required": ["environment", "regions", "replicas", "approved"],
"properties": {
"environment": {
"type": "string",
"title": "Environment",
"oneOf": [
{"const": "staging", "title": "Staging", "description": "Deploy to staging first."},
{"const": "production", "title": "Production"}
]
},
"regions": {
"type": "array",
"title": "Regions",
"items": {"enum": ["us", "eu"]}
},
"replicas": {
"type": "integer",
"title": "Replicas",
"minimum": 1,
"maximum": 10
},
"approved": {"type": "boolean", "title": "Approved"}
}
}
}
}))?;
continue;
}
pending_tool_turns.insert(request_id.clone(), turn_id.clone());
send(json!({
"id": request_id, "method": "item/tool/call",
@@ -268,6 +312,44 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
json!({"method": "turn/completed", "params": {"threadId": "fake-thread", "turn": {"id": turn_id, "status": "completed"}}}),
)?;
}
let completed_elicitation = request
.get("id")
.and_then(Value::as_str)
.and_then(|request_id| pending_elicitation_turns.remove(request_id));
if let Some(turn_id) = completed_elicitation.filter(|_| request.get("result").is_some()) {
if request.get("result")
!= Some(&json!({
"action": "accept",
"content": {
"environment": "staging",
"regions": ["us", "eu"],
"replicas": 3,
"approved": true
},
"_meta": null
}))
{
return Err(
format!("runtime elicitation received wrong response: {}", request).into(),
);
}
send(json!({
"method": "paperclip/runResult",
"params": {
"threadId": "fake-thread",
"turnId": turn_id,
"itemId": "semantic-result",
"result": {
"schema": "paperclip.prp.result.v1",
"reportedWorkDisposition": "done",
"summary": "Fake provider completed after typed elicitation."
}
}
}))?;
send(
json!({"method": "turn/completed", "params": {"threadId": "fake-thread", "turn": {"id": turn_id, "status": "completed"}}}),
)?;
}
}
Ok(())
}
@@ -663,6 +663,12 @@ fn codex_question_set(method: &str, params: &Value) -> Option<Value> {
.take(128)
.enumerate()
.map(|(option_index, option)| {
let option_id = option
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(|value| bounded_string(value, 160))
.unwrap_or_else(|| format!("option-{}", option_index + 1));
let label = option
.get("label")
.or_else(|| option.get("value"))
@@ -671,7 +677,7 @@ fn codex_question_set(method: &str, params: &Value) -> Option<Value> {
.map(|value| bounded_string(value, 1_000))
.unwrap_or_else(|| format!("Option {}", option_index + 1));
let mut normalized = json!({
"id": format!("option-{}", option_index + 1),
"id": option_id,
"label": label,
});
if let Some(description) =
@@ -712,6 +718,18 @@ fn codex_question_set(method: &str, params: &Value) -> Option<Value> {
}
if !options.is_empty() {
normalized["options"] = json!(options);
} else if question.get("minLength").is_some() || question.get("maxLength").is_some()
{
let mut validation = serde_json::Map::new();
if let Some(value) = question.get("minLength").and_then(Value::as_u64) {
validation.insert("minLength".to_owned(), json!(value));
}
if let Some(value) = question.get("maxLength").and_then(Value::as_u64) {
validation.insert("maxLength".to_owned(), json!(value));
}
if !validation.is_empty() {
normalized["textValidation"] = Value::Object(validation);
}
}
if question
.get("isOther")
@@ -769,12 +787,18 @@ fn codex_question_set(method: &str, params: &Value) -> Option<Value> {
} else {
option_values.iter().enumerate().map(|(index, value)| {
let one_of = select_schema.get("oneOf").and_then(Value::as_array).and_then(|items| items.get(index));
json!({
let mut option = json!({
"id": format!("option-{}", index + 1),
"label": one_of.and_then(|item| item.get("title")).and_then(Value::as_str)
.map(str::to_owned).unwrap_or_else(|| value.as_str().map(str::to_owned).unwrap_or_else(|| value.to_string())),
"description": one_of.and_then(|item| item.get("description")).and_then(Value::as_str),
})
});
if let Some(description) = one_of
.and_then(|item| item.get("description"))
.and_then(Value::as_str)
{
option["description"] = json!(bounded_string(description, 4_000));
}
option
}).collect::<Vec<_>>()
};
let answer_mode = if property_type == "array" && !options.is_empty() {
@@ -786,38 +810,109 @@ fn codex_question_set(method: &str, params: &Value) -> Option<Value> {
};
let mut question = json!({
"id": name,
"header": property.get("title").and_then(Value::as_str),
"prompt": property.get("title").and_then(Value::as_str).unwrap_or(name),
"helpText": property.get("description").and_then(Value::as_str),
"required": required.iter().any(|entry| entry.as_str() == Some(name)),
"answerMode": answer_mode,
});
if let Some(title) = property.get("title").and_then(Value::as_str) {
question["header"] = json!(bounded_string(title, 1_000));
}
if let Some(description) = property.get("description").and_then(Value::as_str) {
question["helpText"] = json!(bounded_string(description, 4_000));
}
if !options.is_empty() {
question["options"] = json!(options);
} else {
question["textValidation"] = json!({
"inputType": match property_type { "number" => "number", "integer" => "integer", _ => "text" },
"minLength": property.get("minLength"),
"maxLength": property.get("maxLength"),
"minimum": property.get("minimum"),
"maximum": property.get("maximum"),
"pattern": property.get("pattern"),
});
let mut validation = serde_json::Map::new();
validation.insert("inputType".to_owned(), json!(match property_type { "number" => "number", "integer" => "integer", _ => "text" }));
for key in ["minLength", "maxLength", "minimum", "maximum", "pattern"] {
if let Some(value) = property.get(key) {
validation.insert(key.to_owned(), value.clone());
}
}
question["textValidation"] = Value::Object(validation);
}
question
})
.collect::<Vec<_>>();
return Some(json!({
let mut normalized = json!({
"schema": "paperclip.question_set.v1",
"title": "A tool needs your input",
"description": params.get("message").and_then(Value::as_str),
"submitLabel": "Submit",
"questions": questions,
}));
});
if let Some(description) = params.get("message").and_then(Value::as_str) {
normalized["description"] = json!(bounded_string(description, 4_000));
}
return Some(normalized);
}
None
}
fn has_codex_question_form(method: &str, params: &Value) -> bool {
match method {
"item/tool/requestUserInput" | "tool/requestUserInput" => params.get("questions").is_some(),
"mcpServer/elicitation/request" => {
params.get("requestedSchema").is_some() || params.get("schema").is_some()
}
_ => false,
}
}
fn validate_codex_question_set(value: &Value) -> Result<(), LocalRunnerError> {
let schema: Value = serde_json::from_str(include_str!(
"../../../../protocol/schemas/question-set.schema.json"
))
.map_err(|_| LocalRunnerError::invalid("embedded question-set schema is invalid"))?;
let validator = jsonschema::validator_for(&schema)
.map_err(|_| LocalRunnerError::invalid("embedded question-set schema cannot compile"))?;
if !validator.is_valid(value) {
return Err(LocalRunnerError::invalid(
"provider input request failed the Paperclip question-set schema",
));
}
let mut question_ids = std::collections::BTreeSet::new();
for question in value
.get("questions")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let question_id = question
.get("id")
.and_then(Value::as_str)
.unwrap_or_default();
if !question_ids.insert(question_id) {
return Err(LocalRunnerError::invalid(
"provider input question ids must be unique",
));
}
let mut option_ids = std::collections::BTreeSet::new();
for option in question
.get("options")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let option_id = option.get("id").and_then(Value::as_str).unwrap_or_default();
if !option_ids.insert(option_id) {
return Err(LocalRunnerError::invalid(
"provider input option ids must be unique within a question",
));
}
}
if let Some(pattern) = question
.pointer("/textValidation/pattern")
.and_then(Value::as_str)
{
jsonschema::validator_for(&json!({"type": "string", "pattern": pattern})).map_err(
|_| LocalRunnerError::invalid("provider input contained an invalid text pattern"),
)?;
}
}
Ok(())
}
fn canonical_runtime_request(
kind: ProviderKind,
request_id: &str,
@@ -1386,9 +1481,18 @@ impl CodexProvider {
"provider reused a pending runtime request id",
));
}
let question_set = codex_question_set(method, &params)
let native_question_set = codex_question_set(method, &params);
if native_question_set.is_none() && has_codex_question_form(method, &params) {
return Err(LocalRunnerError::invalid(
"provider input request contained an unsupported question form",
));
}
let question_set = native_question_set
.or_else(|| params.pointer("/request/input").cloned())
.unwrap_or(Value::Null);
if !question_set.is_null() {
validate_codex_question_set(&question_set)?;
}
let request = params.get("request").cloned().unwrap_or_else(|| {
if !question_set.is_null() {
canonical_runtime_request(self.kind, &request_id, method, &params, &question_set)
@@ -1679,6 +1783,67 @@ impl Provider for CodexProvider {
mod provider_trace_tests {
use super::*;
#[test]
fn normalizes_and_answers_the_dot_185_three_question_frame() {
let question_set = codex_question_set(
"item/tool/requestUserInput",
&json!({
"title": "Implementation choices",
"questions": [
{"id":"scope","header":"Scope","question":"Which scope?","options":[{"label":"Focused"},{"label":"Broad"}]},
{"id":"compatibility","header":"Compatibility","question":"Keep v1?","options":[{"label":"Yes"},{"label":"No"}]},
{"id":"notes","header":"Notes","question":"Anything else?"}
]
}),
)
.unwrap();
assert_eq!(question_set["questions"].as_array().unwrap().len(), 3);
assert_eq!(
question_set["questions"][0]["options"][0]["label"],
"Focused"
);
let response = json!({
"schema": "paperclip.question_response.v1",
"answers": {
"scope": {"selectedOptionIds":["option-1"]},
"compatibility": {"selectedOptionIds":["option-1"]},
"notes": {"text":"Preserve the shared UI."}
}
});
assert_eq!(
canonical_codex_answers(&question_set, &response),
json!({
"scope": {"answers":["Focused"]},
"compatibility": {"answers":["Yes"]},
"notes": {"answers":["Preserve the shared UI."]}
})
);
}
#[test]
fn mcp_form_normalization_omits_absent_optional_values() {
let question_set = codex_question_set(
"mcpServer/elicitation/request",
&json!({
"requestedSchema": {
"type": "object",
"properties": {"notes": {"type": "string"}}
}
}),
)
.unwrap();
let schema: Value = serde_json::from_str(include_str!(
"../../../../protocol/schemas/question-set.schema.json"
))
.unwrap();
assert!(jsonschema::validator_for(&schema)
.unwrap()
.is_valid(&question_set));
assert!(question_set["questions"][0].get("header").is_none());
assert!(question_set.get("description").is_none());
}
#[test]
fn recovers_the_latest_active_codex_turn_from_thread_read() {
assert_eq!(
@@ -804,7 +804,7 @@ fn process_command_and_provider(
let resolution = command
.pointer("/payload/resolution")
.unwrap_or(&command["payload"]);
{
let (adapter, request_type) = {
let pending = state
.pending_provider_runtime_requests
.get(request_id)
@@ -819,7 +819,11 @@ fn process_command_and_provider(
));
}
validate_runtime_request_resolution(&pending.request, resolution)?;
}
(
pending.request.pointer("/origin/adapter").and_then(Value::as_str).map(str::to_owned),
pending.request.get("type").and_then(Value::as_str).map(str::to_owned),
)
};
provider
.as_mut()
.ok_or_else(|| DurableRunnerError::invalid("request.resolve requires a provider"))?
@@ -840,6 +844,9 @@ fn process_command_and_provider(
"requestId": request_id,
"turnId": turn_id,
"action": resolution.get("action"),
"response": resolution.get("response"),
"adapter": adapter,
"requestType": request_type,
}),
Some(&state.item_id.clone()),
)?;
@@ -1494,6 +1501,17 @@ fn validate_runtime_request_resolution(
let response = resolution.get("response").ok_or_else(|| {
DurableRunnerError::invalid("structured input submission requires response")
})?;
let response_schema: Value = serde_json::from_str(include_str!(
"../../../../../protocol/schemas/question-response.schema.json"
))
.map_err(|_| DurableRunnerError::invalid("embedded question-response schema is invalid"))?;
let response_validator = jsonschema::validator_for(&response_schema)
.map_err(|_| DurableRunnerError::invalid("embedded question-response schema cannot compile"))?;
if !response_validator.is_valid(response) {
return Err(DurableRunnerError::invalid(
"structured input response failed paperclip.question_response.v1 schema validation",
));
}
if response.get("schema").and_then(Value::as_str) != Some("paperclip.question_response.v1") {
return Err(DurableRunnerError::invalid(
"structured input response must use paperclip.question_response.v1",
@@ -1505,6 +1523,18 @@ fn validate_runtime_request_resolution(
let questions = request.pointer("/input/questions").and_then(Value::as_array).ok_or_else(|| {
DurableRunnerError::invalid("persisted runtime request has no question set")
})?;
let question_set = request.get("input").unwrap_or(&Value::Null);
let question_set_schema: Value = serde_json::from_str(include_str!(
"../../../../../protocol/schemas/question-set.schema.json"
))
.map_err(|_| DurableRunnerError::invalid("embedded question-set schema is invalid"))?;
let question_set_validator = jsonschema::validator_for(&question_set_schema)
.map_err(|_| DurableRunnerError::invalid("embedded question-set schema cannot compile"))?;
if !question_set_validator.is_valid(question_set) {
return Err(DurableRunnerError::invalid(
"persisted runtime question set failed schema validation",
));
}
for answer_id in answers.keys() {
if !questions.iter().any(|question| question.get("id").and_then(Value::as_str) == Some(answer_id)) {
return Err(DurableRunnerError::invalid(format!(
@@ -1528,12 +1558,34 @@ fn validate_runtime_request_resolution(
let answer = answer.and_then(Value::as_object).ok_or_else(|| {
DurableRunnerError::invalid(format!("answer {question_id} must be an object"))
})?;
if answer.get("selectedOptionIds").is_some_and(|value| !value.is_array()) {
return Err(DurableRunnerError::invalid(format!(
"answer {question_id} selectedOptionIds must be an array"
)));
}
if answer.get("text").is_some_and(|value| !value.is_string())
|| answer.get("customText").is_some_and(|value| !value.is_string())
{
return Err(DurableRunnerError::invalid(format!(
"answer {question_id} text values must be strings"
)));
}
let selected = answer.get("selectedOptionIds").and_then(Value::as_array).cloned().unwrap_or_default();
if selected.iter().any(|value| value.as_str().is_none()) {
return Err(DurableRunnerError::invalid(format!(
"answer {question_id} selectedOptionIds must contain strings"
)));
}
let mut unique_selected = std::collections::BTreeSet::new();
if selected
.iter()
.filter_map(Value::as_str)
.any(|option_id| !unique_selected.insert(option_id))
{
return Err(DurableRunnerError::invalid(format!(
"answer {question_id} selectedOptionIds cannot contain duplicates"
)));
}
let mode = question.get("answerMode").and_then(Value::as_str).unwrap_or("");
let text = answer.get("text").and_then(Value::as_str);
let custom = answer.get("customText").and_then(Value::as_str);
@@ -1554,6 +1606,14 @@ fn validate_runtime_request_resolution(
"single-select answer {question_id} selected multiple options"
)));
}
if mode == "single_select"
&& !selected.is_empty()
&& custom.is_some_and(|value| !value.trim().is_empty())
{
return Err(DurableRunnerError::invalid(format!(
"single-select answer {question_id} cannot combine an option and custom text"
)));
}
let options = question.get("options").and_then(Value::as_array).cloned().unwrap_or_default();
for option_id in selected.iter().filter_map(Value::as_str) {
if !options.iter().any(|option| option.get("id").and_then(Value::as_str) == Some(option_id)) {
@@ -1578,6 +1638,11 @@ fn validate_runtime_request_resolution(
}
let bounded = if mode == "text" { text } else { custom };
if let Some(value) = bounded {
if value.chars().count() > 100_000 {
return Err(DurableRunnerError::invalid(format!(
"answer {question_id} exceeds the maximum text size"
)));
}
let validation = question.get("textValidation").unwrap_or(&Value::Null);
if let Some(minimum) = validation.get("minLength").and_then(Value::as_u64) {
if value.chars().count() < minimum as usize {
@@ -1589,11 +1654,29 @@ fn validate_runtime_request_resolution(
return Err(DurableRunnerError::invalid(format!("answer {question_id} is too long")));
}
}
if let Some(pattern) = validation.get("pattern").and_then(Value::as_str) {
let pattern_schema = json!({"type": "string", "pattern": pattern});
let pattern_validator = jsonschema::validator_for(&pattern_schema).map_err(|_| {
DurableRunnerError::invalid(format!(
"question {question_id} contains an invalid text pattern"
))
})?;
if !pattern_validator.is_valid(&json!(value)) {
return Err(DurableRunnerError::invalid(format!(
"answer {question_id} does not match its required format"
)));
}
}
if let Some(input_type) = validation.get("inputType").and_then(Value::as_str) {
if matches!(input_type, "number" | "integer") {
let numeric = value.parse::<f64>().map_err(|_| DurableRunnerError::invalid(format!(
"answer {question_id} must be a valid {input_type}"
)))?;
if !numeric.is_finite() {
return Err(DurableRunnerError::invalid(format!(
"answer {question_id} must be a finite {input_type}"
)));
}
if input_type == "integer" && numeric.fract() != 0.0 {
return Err(DurableRunnerError::invalid(format!("answer {question_id} must be an integer")));
}
@@ -1633,6 +1716,8 @@ fn expire_provider_runtime_requests_after_provider_loss(
"requestKind": request.request_kind,
"reason": "provider_process_lost",
"replayAllowed": false,
"adapter": request.request.pointer("/origin/adapter").and_then(Value::as_str),
"requestType": request.request.get("type").and_then(Value::as_str),
"request": request.request,
}),
Some(&state.item_id.clone()),
@@ -1676,6 +1761,8 @@ fn cancel_pending_runtime_requests_on_terminal(
"turnId": request.turn_id,
"requestKind": request.request_kind,
"reason": reason,
"adapter": request.request.pointer("/origin/adapter").and_then(Value::as_str),
"requestType": request.request.get("type").and_then(Value::as_str),
}),
Some(&state.item_id.clone()),
)?;
@@ -1924,6 +2011,55 @@ mod tests {
);
}
#[test]
fn structured_input_revalidates_patterns_and_finite_numbers() {
let request = |validation: Value| {
json!({
"schema": "paperclip.runtime_request.v2",
"requestKind": "runtime",
"requestId": "input-1",
"type": "input",
"status": "pending",
"prompt": "Answer",
"input": {
"schema": "paperclip.question_set.v1",
"questions": [{
"id": "value",
"prompt": "Value?",
"required": true,
"answerMode": "text",
"textValidation": validation
}]
}
})
};
let response = |value: &str| {
json!({
"action": "submit",
"response": {
"schema": "paperclip.question_response.v1",
"answers": {"value": {"text": value}}
}
})
};
assert!(validate_runtime_request_resolution(
&request(json!({"pattern": "^[A-Z]{2}$"})),
&response("US"),
)
.is_ok());
assert!(validate_runtime_request_resolution(
&request(json!({"pattern": "^[A-Z]{2}$"})),
&response("us"),
)
.is_err());
assert!(validate_runtime_request_resolution(
&request(json!({"inputType": "number"})),
&response("NaN"),
)
.is_err());
}
#[test]
fn warm_idle_clock_expires_only_safe_inactive_states() {
let root = temporary_root("warm-idle-clock");
@@ -2031,6 +2167,63 @@ mod tests {
let _ = fs::remove_dir_all(root);
}
#[test]
fn provider_loss_expires_a_canonical_input_once_with_the_complete_question_set() {
let root = temporary_root("canonical-input-loss");
let runner_config = config(&root);
let store = DurableStateStore::new(&root).unwrap();
let (mut state, _) = store.load_or_create(&runner_config).unwrap();
let request = json!({
"schema": "paperclip.runtime_request.v2",
"requestKind": "runtime",
"requestId": "input-1",
"type": "input",
"status": "pending",
"prompt": "Which target?",
"input": {
"schema": "paperclip.question_set.v1",
"questions": [{
"id": "target",
"prompt": "Which target?",
"required": true,
"answerMode": "single_select",
"options": [{"id": "staging", "label": "Staging"}]
}]
},
"origin": {"adapter": "codex-app-server"}
});
state.pending_provider_runtime_requests.insert(
"input-1".to_owned(),
PendingProviderRuntimeRequest {
request_id: "input-1".to_owned(),
turn_id: state.turn_id.clone(),
request_kind: "runtime".to_owned(),
created_at_unix_ms: 1,
request: request.clone(),
},
);
expire_provider_runtime_requests_after_provider_loss(&mut state, &runner_config).unwrap();
expire_provider_runtime_requests_after_provider_loss(&mut state, &runner_config).unwrap();
let expirations = state
.outbox
.iter()
.filter(|event| event.envelope["payload"]["eventType"] == "runtime_request.expired")
.collect::<Vec<_>>();
assert_eq!(expirations.len(), 1);
assert_eq!(expirations[0].envelope["payload"]["payload"]["request"], request);
assert_eq!(
expirations[0].envelope["payload"]["payload"]["reason"],
"provider_process_lost"
);
assert_eq!(
expirations[0].envelope["payload"]["payload"]["replayAllowed"],
false
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn oversized_provider_frame_persists_a_recoverable_failure() {
let root = temporary_root("oversized-provider-frame");
@@ -1543,7 +1543,7 @@ fn execute_command_effect(
.or_else(|| payload.get("action"))
.and_then(Value::as_str)
.unwrap_or("cancel");
if !["accept", "accept_for_session", "decline", "cancel"].contains(&action) {
if !["accept", "accept_for_session", "decline", "cancel", "submit"].contains(&action) {
return Ok((
"rejected".to_owned(),
0,
@@ -1,6 +1,6 @@
// Generated by scripts/generate-acpx-sidecar-contract.mjs. Do not edit.
pub const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION: u64 = 1;
pub const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION: u64 = 2;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GeneratedAcpxSidecarCommand {
@@ -10,6 +10,7 @@ pub enum GeneratedAcpxSidecarCommand {
TurnStart,
TurnCancel,
PermissionResolve,
InputResolve,
ToolResolve,
SessionRead,
SessionSnapshot,
@@ -26,6 +27,7 @@ impl GeneratedAcpxSidecarCommand {
Self::TurnStart => "turn.start",
Self::TurnCancel => "turn.cancel",
Self::PermissionResolve => "permission.resolve",
Self::InputResolve => "input.resolve",
Self::ToolResolve => "tool.resolve",
Self::SessionRead => "session.read",
Self::SessionSnapshot => "session.snapshot",
@@ -41,6 +43,8 @@ pub enum GeneratedAcpxSidecarEventType {
RuntimeEvent,
#[serde(rename = "runtime.permission_requested")]
RuntimePermissionRequested,
#[serde(rename = "runtime.input_requested")]
RuntimeInputRequested,
#[serde(rename = "runtime.tool_called")]
RuntimeToolCalled,
#[serde(rename = "runtime.turn_terminal")]
@@ -6,11 +6,12 @@ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const schema = JSON.parse(await readFile(resolve(root, "protocol/schemas/acpx-sidecar.schema.json"), "utf8"));
const commands = schema.$defs.command.enum;
const events = schema.$defs.eventType.enum;
const protocolVersion = schema.$defs.request.properties.protocolVersion.const;
const quote = (value) => JSON.stringify(value);
const rustVariant = (value) => value.split(/[._-]/).map((part) => part[0].toUpperCase() + part.slice(1)).join("");
const typescript = `// Generated by scripts/generate-acpx-sidecar-contract.mjs. Do not edit.\n\nexport const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION = 1 as const;\nexport const GENERATED_ACPX_SIDECAR_COMMANDS = [\n${commands.map((value) => ` ${quote(value)},`).join("\n")}\n] as const;\nexport type GeneratedAcpxSidecarCommand = (typeof GENERATED_ACPX_SIDECAR_COMMANDS)[number];\n\nexport const GENERATED_ACPX_SIDECAR_EVENT_TYPES = [\n${events.map((value) => ` ${quote(value)},`).join("\n")}\n] as const;\nexport type GeneratedAcpxSidecarEventType = (typeof GENERATED_ACPX_SIDECAR_EVENT_TYPES)[number];\n`;
const rust = `// Generated by scripts/generate-acpx-sidecar-contract.mjs. Do not edit.\n\npub const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION: u64 = 1;\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq)]\npub enum GeneratedAcpxSidecarCommand {\n${commands.map((value) => ` ${rustVariant(value)},`).join("\n")}\n}\n\nimpl GeneratedAcpxSidecarCommand {\n pub const fn as_str(self) -> &'static str {\n match self {\n${commands.map((value) => ` Self::${rustVariant(value)} => ${quote(value)},`).join("\n")}\n }\n }\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize)]\npub enum GeneratedAcpxSidecarEventType {\n${events.map((value) => ` #[serde(rename = ${quote(value)})]\n ${rustVariant(value)},`).join("\n")}\n}\n`;
const typescript = `// Generated by scripts/generate-acpx-sidecar-contract.mjs. Do not edit.\n\nexport const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION = ${protocolVersion} as const;\nexport const GENERATED_ACPX_SIDECAR_COMMANDS = [\n${commands.map((value) => ` ${quote(value)},`).join("\n")}\n] as const;\nexport type GeneratedAcpxSidecarCommand = (typeof GENERATED_ACPX_SIDECAR_COMMANDS)[number];\n\nexport const GENERATED_ACPX_SIDECAR_EVENT_TYPES = [\n${events.map((value) => ` ${quote(value)},`).join("\n")}\n] as const;\nexport type GeneratedAcpxSidecarEventType = (typeof GENERATED_ACPX_SIDECAR_EVENT_TYPES)[number];\n`;
const rust = `// Generated by scripts/generate-acpx-sidecar-contract.mjs. Do not edit.\n\npub const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION: u64 = ${protocolVersion};\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq)]\npub enum GeneratedAcpxSidecarCommand {\n${commands.map((value) => ` ${rustVariant(value)},`).join("\n")}\n}\n\nimpl GeneratedAcpxSidecarCommand {\n pub const fn as_str(self) -> &'static str {\n match self {\n${commands.map((value) => ` Self::${rustVariant(value)} => ${quote(value)},`).join("\n")}\n }\n }\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize)]\npub enum GeneratedAcpxSidecarEventType {\n${events.map((value) => ` #[serde(rename = ${quote(value)})]\n ${rustVariant(value)},`).join("\n")}\n}\n`;
const outputs = [
[resolve(root, "src/drivers/acpx/generated-sidecar-contract.ts"), typescript],
@@ -168,4 +168,102 @@ describe("HarnessDriverBackend", () => {
},
]);
});
it("emits one non-replayable input expiration before propagating provider loss", async () => {
const questionSet = {
schema: "paperclip.question_set.v1" as const,
questions: [{ id: "target", prompt: "Which target?", required: true, answerMode: "text" as const }],
};
class LostProviderSession extends FakeHarnessSession {
override async *events() {
yield prpEvent(1, "runtime_request.created", { request: {
schema: "paperclip.runtime_request.v2",
requestKind: "runtime",
requestId: "input-1",
type: "input",
status: "pending",
prompt: "Which target?",
input: questionSet,
turnId: "turn-1",
itemId: "input-1",
} });
throw new Error("provider transport lost");
}
override async snapshot(): Promise<PersistedHarnessSession> {
return { driverKind: "fake", driverSessionId: "driver-1", lastSourceSequence: 1 };
}
}
const backend = new HarnessDriverBackend({ ...driver, async openSession() { return new LostProviderSession(); } });
const session = await backend.openSession({
identity: { runId: "run-1", sessionId: "session-1", companyId: "company-1", issueId: "issue-1", agentId: "agent-1" },
workingDirectory: "/workspace",
});
const iterator = session.events()[Symbol.asyncIterator]();
await expect(iterator.next()).resolves.toMatchObject({ value: { eventType: "runtime_request.created" } });
await expect(iterator.next()).resolves.toMatchObject({ value: {
eventType: "runtime_request.expired",
sourceSeq: 2,
payload: {
requestId: "input-1",
reason: "provider_process_lost",
replayAllowed: false,
request: { input: questionSet },
},
} });
await expect(iterator.next()).rejects.toThrow("provider transport lost");
});
it("does not synthesize a fallback after the input was already resolved", async () => {
class ResolvedThenLostSession extends FakeHarnessSession {
override async *events() {
yield prpEvent(1, "runtime_request.created", { request: {
schema: "paperclip.runtime_request.v2",
requestKind: "runtime",
requestId: "input-1",
type: "input",
status: "pending",
prompt: "Which target?",
input: { schema: "paperclip.question_set.v1", questions: [{ id: "target", prompt: "Which target?", required: true, answerMode: "text" }] },
} });
yield prpEvent(2, "runtime_request.resolved", { requestId: "input-1", action: "submit" });
throw new Error("provider transport lost after resolution");
}
}
const backend = new HarnessDriverBackend({ ...driver, async openSession() { return new ResolvedThenLostSession(); } });
const session = await backend.openSession({
identity: { runId: "run-1", sessionId: "session-1", companyId: "company-1", issueId: "issue-1", agentId: "agent-1" },
workingDirectory: "/workspace",
});
const iterator = session.events()[Symbol.asyncIterator]();
await iterator.next();
await iterator.next();
await expect(iterator.next()).rejects.toThrow("provider transport lost after resolution");
});
it("does not synthesize a fallback after explicit run cancellation", async () => {
class CancelledProviderSession extends FakeHarnessSession {
override async *events() {
yield prpEvent(1, "runtime_request.created", { request: {
schema: "paperclip.runtime_request.v2",
requestKind: "runtime",
requestId: "input-1",
type: "input",
status: "pending",
prompt: "Which target?",
input: { schema: "paperclip.question_set.v1", questions: [{ id: "target", prompt: "Which target?", required: true, answerMode: "text" }] },
} });
throw new Error("provider stopped after cancellation");
}
async interrupt() {}
}
const backend = new HarnessDriverBackend({ ...driver, async openSession() { return new CancelledProviderSession(); } });
const session = await backend.openSession({
identity: { runId: "run-1", sessionId: "session-1", companyId: "company-1", issueId: "issue-1", agentId: "agent-1" },
workingDirectory: "/workspace",
});
const iterator = session.events()[Symbol.asyncIterator]();
await expect(iterator.next()).resolves.toMatchObject({ value: { eventType: "runtime_request.created" } });
await session.cancel({ reason: "operator cancelled the run" });
await expect(iterator.next()).rejects.toThrow("provider stopped after cancellation");
});
});
@@ -96,6 +96,7 @@ class HarnessNativeSession implements NativeSession {
#input: OpenNativeSessionInput;
readonly #session: HarnessSession;
#terminal: PrpTerminalState | null = null;
#explicitlyCancelled = false;
constructor(input: OpenNativeSessionInput, session: HarnessSession) {
this.#input = structuredClone(input);
@@ -132,28 +133,96 @@ class HarnessNativeSession implements NativeSession {
await this.#session.attachRun({ runId: input.identity.runId });
this.#input = { ...this.#input, identity: structuredClone(input.identity) };
this.#terminal = null;
this.#explicitlyCancelled = false;
}
async *events(): AsyncIterable<PrpEvent> {
for await (const event of this.#session.events()) {
if (event.eventType === "run.terminal") {
this.#terminal = structuredClone(event.payload as PrpTerminalState);
} else if (["turn.completed", "turn.failed", "turn.interrupted", "turn.cancelled"].includes(event.eventType)) {
const snapshot = await this.#session.snapshot();
const disposition = snapshot.semanticResult?.result.reportedWorkDisposition ?? "yielded";
this.#terminal = {
schema: "paperclip.prp.terminal.v1",
turnTerminalState:
event.eventType === "turn.completed" ? "completed"
: event.eventType === "turn.failed" ? "failed"
: event.eventType === "turn.interrupted" ? "interrupted"
: "cancelled",
runTerminalState: event.eventType === "turn.completed" ? "succeeded" : event.eventType === "turn.failed" ? "failed" : "cancelled",
reportedWorkDisposition: disposition,
let sourceInstanceId: string | null = null;
let lastSourceSequence = 0;
let sawTerminal = false;
let streamFailure: unknown = null;
const observedPendingInputs = new Map<string, Record<string, unknown>>();
try {
for await (const event of this.#session.events()) {
sourceInstanceId = event.sourceInstanceId;
lastSourceSequence = Math.max(lastSourceSequence, event.sourceSeq);
if (event.eventType === "runtime_request.created") {
const request = plainRecord(event.payload.request);
if (
request?.schema === "paperclip.runtime_request.v2"
&& request.type === "input"
&& typeof request.requestId === "string"
) observedPendingInputs.set(request.requestId, structuredClone(request));
} else if (["runtime_request.resolved", "runtime_request.cancelled", "runtime_request.expired"].includes(event.eventType)) {
const requestId = typeof event.payload.requestId === "string" ? event.payload.requestId : null;
if (requestId) observedPendingInputs.delete(requestId);
}
if (event.eventType === "run.terminal") {
sawTerminal = true;
this.#terminal = structuredClone(event.payload as PrpTerminalState);
} else if (["turn.completed", "turn.failed", "turn.interrupted", "turn.cancelled"].includes(event.eventType)) {
sawTerminal = true;
const snapshot = await this.#session.snapshot();
const disposition = snapshot.semanticResult?.result.reportedWorkDisposition ?? "yielded";
this.#terminal = {
schema: "paperclip.prp.terminal.v1",
turnTerminalState:
event.eventType === "turn.completed" ? "completed"
: event.eventType === "turn.failed" ? "failed"
: event.eventType === "turn.interrupted" ? "interrupted"
: "cancelled",
runTerminalState: event.eventType === "turn.completed" ? "succeeded" : event.eventType === "turn.failed" ? "failed" : "cancelled",
reportedWorkDisposition: disposition,
};
}
yield structuredClone(event);
}
} catch (error) {
streamFailure = error;
}
// The provider can disappear while its native RPC is awaiting the user.
// Emit one canonical terminal fact before propagating the stream failure;
// the control plane can then materialize the durable continuation without
// ever trying to replay the dead provider request.
if (!sawTerminal && !this.#explicitlyCancelled && sourceInstanceId) {
const snapshot = await this.#session.snapshot().catch(() => null);
for (const request of observedPendingInputs.values()) {
const sourceSeq = Math.max(lastSourceSequence, snapshot?.lastSourceSequence ?? 0) + 1;
lastSourceSequence = sourceSeq;
const requestId = String(request.requestId);
const turnId = typeof request.turnId === "string" ? request.turnId : undefined;
const itemId = typeof request.itemId === "string" ? request.itemId : requestId;
yield {
schema: "paperclip.prp.event.v1",
sourceEventId: `${sourceInstanceId}:${this.#input.identity.runId}:${sourceSeq}`,
sourceSeq,
sourceInstanceId,
sourceKind: "runner",
runId: this.#input.identity.runId,
normalizedSessionId: this.#input.identity.sessionId,
...(turnId ? { turnId } : {}),
itemId,
eventType: "runtime_request.expired",
schemaVersion: 1,
priority: 0,
emittedAt: new Date().toISOString(),
payload: {
requestId,
...(turnId ? { turnId } : {}),
requestKind: "runtime",
reason: "provider_process_lost",
replayAllowed: false,
requestType: "input",
...(plainRecord(request.origin)?.adapter
? { adapter: plainRecord(request.origin)?.adapter }
: {}),
request: structuredClone(request),
},
};
}
yield structuredClone(event);
}
if (streamFailure) throw streamFailure;
}
startTurn(input: Parameters<HarnessSession["startTurn"]>[0]) {
@@ -171,6 +240,7 @@ class HarnessNativeSession implements NativeSession {
}
async cancel(input: { reason: string }) {
this.#explicitlyCancelled = true;
if (this.#session.interrupt !== undefined) {
await this.#session.interrupt({ reason: input.reason });
}
@@ -238,6 +308,12 @@ class HarnessNativeSession implements NativeSession {
}
}
function plainRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
export function createHarnessDriverBackend(driver: HarnessDriver): NativeSessionBackend {
return new HarnessDriverBackend(driver);
}
@@ -4,13 +4,20 @@ import { readFileSync } from "node:fs";
import { createInterface } from "node:readline";
import { isAbsolute, relative, resolve } from "node:path";
import type { AcpPermissionDecision, AcpRuntimeEvent, AcpRuntimeStatus } from "acpx/runtime";
import type {
AcpElicitationRequest,
AcpElicitationResponse,
AcpPermissionDecision,
AcpRuntimeEvent,
AcpRuntimeStatus,
} from "acpx/runtime";
import {
PRP_BLOCK_TOOL_NAME,
PRP_COMPLETION_TOOL_NAME,
} from "../contracts/completion-result.js";
import { AcpxRuntimeHost } from "../drivers/acpx/acpx-runtime-host.js";
import { normalizeAcpFormElicitation } from "../drivers/acpx/acp-question-adapter.js";
import { resolveQualifiedAcpxProfile, type QualifiedAcpxAgent } from "../drivers/acpx/qualified-profiles.js";
import {
ACPX_SIDECAR_MAX_FRAME_BYTES,
@@ -40,6 +47,10 @@ let turnId: string | null = null;
let sequence = 0;
let closing = false;
const permissions = new Map<string, PendingResolution<AcpPermissionDecision | undefined>>();
const inputs = new Map<string, PendingResolution<AcpElicitationResponse> & {
request: AcpElicitationRequest;
questionSet: import("../contracts/question-set.js").PaperclipQuestionSet;
}>();
const tools = new Map<string, PendingResolution<unknown>>();
const lines = createInterface({ input: process.stdin, crlfDelay: Infinity, terminal: false });
@@ -152,6 +163,30 @@ async function dispatch(request: AcpxSidecarRequest): Promise<Record<string, unk
pending.settle(parsePermissionDecision(request.params.decision));
return { resolved: true };
}
if (request.command === "input.resolve") {
const requestId = requiredText(request.params.requestId, "requestId");
const pending = inputs.get(requestId);
if (!pending || pending.turnId !== requiredText(request.params.turnId, "turnId")) {
throw new Error("input request is stale or unknown");
}
const resolution = record(request.params.resolution);
const action = requiredText(resolution.action, "resolution.action");
let elicitationResponse: AcpElicitationResponse;
if (action === "submit") {
const normalized = normalizeAcpFormElicitation(pending.request);
if (!normalized) throw new Error("ACP input request is no longer a form elicitation");
elicitationResponse = normalized.accept(resolution.response);
} else if (action === "decline") {
elicitationResponse = { action: "decline" };
} else if (action === "cancel") {
elicitationResponse = { action: "cancel" };
} else {
throw new Error("unsupported ACP input resolution action");
}
inputs.delete(requestId);
pending.settle(elicitationResponse);
return { resolved: true };
}
if (request.command === "tool.resolve") {
const callId = requiredText(request.params.callId, "callId");
const pending = tools.get(callId);
@@ -190,6 +225,7 @@ async function openRuntime(params: AcpxSidecarOpenParams): Promise<AcpxRuntimeHo
dynamicTools: params.tools,
dynamicToolHandler: (call) => waitForTool(call),
onPermissionRequest: (context) => waitForPermission(context.request, context.signal),
onElicitation: (context) => waitForInput(context.request, context.requestId, context.signal),
onSpawn: async (identity) => emit("runtime.process", {
role: "acp_agent",
...identity,
@@ -275,6 +311,50 @@ function waitForPermission(request: import("acpx/runtime").AcpPermissionRequest,
});
}
function waitForInput(
request: AcpElicitationRequest,
nativeRequestId: string | number | null,
signal: AbortSignal,
): Promise<AcpElicitationResponse> {
if (!turnId || signal.aborted) return Promise.resolve({ action: "cancel" });
let normalized: ReturnType<typeof normalizeAcpFormElicitation>;
try {
normalized = normalizeAcpFormElicitation(request);
} catch (error) {
diagnostic("runtime_input_rejected", safeMessage(error));
return Promise.resolve({ action: "decline" });
}
if (!normalized) return Promise.resolve({ action: "decline" });
const requestId = requiredText(String(nativeRequestId), "elicitation request id");
if (inputs.has(requestId)) return Promise.resolve({ action: "decline" });
emit("runtime.input_requested", {
requestId,
questionSet: normalized.questionSet,
origin: {
adapter: "acpx-runtime-sidecar",
provider: "acpx",
method: "elicitation/create",
},
});
return new Promise((settle, reject) => {
const abort = () => {
if (!inputs.delete(requestId)) return;
settle({ action: "cancel" });
};
signal.addEventListener("abort", abort, { once: true });
inputs.set(requestId, {
turnId: turnId!,
request,
questionSet: normalized.questionSet,
settle: (response) => {
signal.removeEventListener("abort", abort);
settle(response);
},
reject,
});
});
}
function sanitizeRuntimeEvent(event: AcpRuntimeEvent): Record<string, unknown> {
const runtimeType = text(record(event).type);
if (!["text_delta", "status", "tool_call", "error", "done"].includes(runtimeType)) {
@@ -480,6 +560,10 @@ function rejectTurnWaiters(terminalTurnId: string, message: string): void {
tools.delete(id);
pending.reject(new Error(message));
}
for (const [id, pending] of inputs) if (pending.turnId === terminalTurnId) {
inputs.delete(id);
pending.settle({ action: "cancel" });
}
}
async function shutdown(reason: string): Promise<void> {
@@ -105,6 +105,7 @@ async function open(params: Record<string, unknown>, resume: boolean): Promise<R
});
}
eventPump = pumpEvents(session);
void eventPump.catch((error) => failProxy(error));
return threadResponse(session.ids().providerSessionId ?? session.ids().driverSessionId);
}
@@ -184,14 +185,19 @@ async function pumpEvents(opened: HarnessSession): Promise<void> {
if (!requestId || !turnId || !opened.resolveRuntimeRequest) {
throw new Error("OpenCode runtime request is not resolvable");
}
const controllerResponse = record(await requestController("paperclip/runtimeRequest", {
request,
}));
await opened.resolveRuntimeRequest({
requestId,
turnId,
resolution: record(controllerResponse.resolution) as HarnessRuntimeRequestResolution,
});
// Keep consuming OpenCode SSE while the controller waits for the user.
// If the underlying provider disappears, the stream can then fail the
// proxy and runnerd will expire the still-pending canonical request.
void (async () => {
const controllerResponse = record(await requestController("paperclip/runtimeRequest", {
request,
}));
await opened.resolveRuntimeRequest!({
requestId,
turnId,
resolution: record(controllerResponse.resolution) as HarnessRuntimeRequestResolution,
});
})().catch((error) => failProxy(error));
} else if (["turn.completed", "turn.failed", "turn.interrupted", "turn.cancelled"].includes(event.eventType)) {
const status = event.eventType.slice("turn.".length);
send({ method: "turn/completed", params: { threadId: opened.ids().driverSessionId, turnId: event.turnId, turn: { id: event.turnId, status } } });
@@ -266,13 +272,18 @@ input.on("line", (line) => {
});
let shutdownPromise: Promise<void> | null = null;
function shutdown(): Promise<void> {
function failProxy(error: unknown): void {
process.stderr.write(`[opencode] fatal proxy error: ${error instanceof Error ? error.message : String(error)}\n`);
void shutdown(1);
}
function shutdown(exitCode = 0): Promise<void> {
shutdownPromise ??= (async () => {
for (const waiter of pending.values()) waiter.reject(new Error("OpenCode proxy is shutting down"));
pending.clear();
await session?.close({ reason: "proxy_shutdown", force: true }).catch(() => {});
await eventPump?.catch(() => {});
})().finally(() => process.exit(0));
})().finally(() => process.exit(exitCode));
return shutdownPromise;
}
@@ -299,14 +299,23 @@ export type HarnessRuntimeRequestOutcome = {
itemId: string;
action?: HarnessRuntimeRequestAction;
reason?: string;
/** Enables content-free lifecycle counters partitioned by adapter. */
adapter?: string;
requestType?: "input" | "permission";
/** Canonical submitted answers retained for durable replay and audit UI. */
response?: PaperclipQuestionResponse;
};
export function harnessRuntimeRequestOutcome(
request: Pick<
HarnessRuntimeRequest,
"requestId" | "requestKind" | "turnId" | "itemId"
"requestId" | "requestKind" | "turnId" | "itemId" | "origin" | "input"
>,
outcome: { action?: HarnessRuntimeRequestAction | null; reason?: string | null } = {},
outcome: {
action?: HarnessRuntimeRequestAction | null;
reason?: string | null;
response?: PaperclipQuestionResponse | null;
} = {},
): HarnessRuntimeRequestOutcome {
return {
requestId: request.requestId,
@@ -315,6 +324,13 @@ export function harnessRuntimeRequestOutcome(
itemId: request.itemId,
...(outcome.action ? { action: outcome.action } : {}),
...(outcome.reason ? { reason: outcome.reason } : {}),
...(outcome.response ? { response: structuredClone(outcome.response) } : {}),
...(request.input
? {
...(request.origin?.adapter ? { adapter: request.origin.adapter } : {}),
requestType: "input" as const,
}
: {}),
};
}
@@ -0,0 +1,61 @@
import { readFile } from "node:fs/promises";
import type { AcpElicitationRequest } from "acpx/runtime";
import { describe, expect, it } from "vitest";
import { normalizeAcpFormElicitation } from "../drivers/acpx/acp-question-adapter.js";
import { normalizeCodexQuestionSet } from "../drivers/codex/codex-app-server-driver.js";
import { normalizeOpenCodeQuestionSet } from "../drivers/opencode/opencode-server-driver.js";
import { parsePaperclipQuestionResponse, parsePaperclipQuestionSet } from "./question-set.js";
interface Fixture {
schema: "paperclip.question_adapter_fixture.v1";
adapter: "codex" | "opencode" | "acpx";
nativeRequest: Record<string, unknown>;
canonicalQuestionSet: unknown;
canonicalResponse: unknown;
nativeResponse: unknown;
}
async function fixture(adapter: Fixture["adapter"]): Promise<Fixture> {
return JSON.parse(await readFile(new URL(`../../protocol/fixtures/questions/${adapter}.json`, import.meta.url), "utf8")) as Fixture;
}
describe("question adapter conformance fixtures", () => {
it("normalizes equivalent Codex, OpenCode, and ACPX requests identically", async () => {
const [codex, opencode, acpx] = await Promise.all([
fixture("codex"),
fixture("opencode"),
fixture("acpx"),
]);
const codexNative = codex.nativeRequest;
const codexQuestionSet = normalizeCodexQuestionSet(
String(codexNative.method),
codexNative.params as Record<string, unknown>,
);
const opencodeProperties = opencode.nativeRequest.properties as Record<string, unknown>;
const opencodeQuestionSet = normalizeOpenCodeQuestionSet(
(opencodeProperties.questions as unknown[]).map((value) => value as Record<string, unknown>),
opencodeProperties,
);
const acpxQuestionSet = normalizeAcpFormElicitation(
(acpx.nativeRequest.params as Record<string, unknown>) as AcpElicitationRequest,
)?.questionSet;
const expected = parsePaperclipQuestionSet(codex.canonicalQuestionSet);
expect(codexQuestionSet).toEqual(expected);
expect(opencodeQuestionSet).toEqual(expected);
expect(acpxQuestionSet).toEqual(expected);
expect(parsePaperclipQuestionSet(opencode.canonicalQuestionSet)).toEqual(expected);
expect(parsePaperclipQuestionSet(acpx.canonicalQuestionSet)).toEqual(expected);
expect(parsePaperclipQuestionResponse(expected, codex.canonicalResponse)).toEqual(codex.canonicalResponse);
});
it("converts the canonical fixture response back into typed ACP content", async () => {
const acpx = await fixture("acpx");
const normalized = normalizeAcpFormElicitation(
(acpx.nativeRequest.params as Record<string, unknown>) as AcpElicitationRequest,
);
expect(normalized?.accept(acpx.canonicalResponse)).toEqual(acpx.nativeResponse);
});
});
@@ -63,6 +63,13 @@ describe("Paperclip question-set contract", () => {
expect(() => parsePaperclipQuestionResponse(questionSet, {
answers: { environment: { answers: ["Staging"] } },
})).toThrow(/paperclip.question_response.v1/);
expect(() => parsePaperclipQuestionResponse(questionSet, {
schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA,
answers: {
environment: { answers: ["Staging"] },
replicas: { text: "3" },
},
})).toThrow(/canonical response contract/);
});
it("applies typed numeric validation before an adapter sees the answer", () => {
@@ -92,6 +92,18 @@ function record(value: unknown): Record<string, unknown> | null {
: null;
}
function rejectUnknownKeys(
value: Record<string, unknown>,
allowed: readonly string[],
path: string,
): void {
const allowedKeys = new Set(allowed);
const unknown = Object.keys(value).find((key) => !allowedKeys.has(key));
if (unknown !== undefined) {
throw new PaperclipQuestionValidationError(`${path}/${unknown}`, "is not part of the canonical response contract");
}
}
function requiredText(value: unknown, path: string, maxLength = 4_000): string {
if (typeof value !== "string" || value.length === 0 || value.length > maxLength) {
throw new PaperclipQuestionValidationError(path, `must be a non-empty string of at most ${maxLength} characters`);
@@ -139,6 +151,9 @@ export function parsePaperclipQuestionSet(value: unknown): PaperclipQuestionSet
if (typeof question.required !== "boolean") {
throw new PaperclipQuestionValidationError(`${path}/required`, "must be boolean");
}
if (Array.isArray(question.options) && question.options.length > 128) {
throw new PaperclipQuestionValidationError(`${path}/options`, "cannot contain more than 128 options");
}
const options: PaperclipQuestionOption[] | undefined = Array.isArray(question.options)
? question.options.map((rawOption, optionIndex) => {
const optionPath = `${path}/options/${optionIndex}`;
@@ -266,6 +281,7 @@ export function parsePaperclipQuestionResponse(
if (response === null || response.schema !== PAPERCLIP_QUESTION_RESPONSE_SCHEMA) {
throw new PaperclipQuestionValidationError("/response", `must use ${PAPERCLIP_QUESTION_RESPONSE_SCHEMA}`);
}
rejectUnknownKeys(response, ["schema", "answers"], "/response");
const rawAnswers = record(response.answers);
if (rawAnswers === null) throw new PaperclipQuestionValidationError("/response/answers", "must be an object keyed by question ID");
const questions = new Map(questionSet.questions.map((question) => [question.id, question]));
@@ -282,6 +298,7 @@ export function parsePaperclipQuestionResponse(
}
const answer = record(raw);
if (answer === null) throw new PaperclipQuestionValidationError(path, "must be an object");
rejectUnknownKeys(answer, ["selectedOptionIds", "text", "customText"], path);
const selectedOptionIds = answer.selectedOptionIds === undefined
? undefined
: Array.isArray(answer.selectedOptionIds) && answer.selectedOptionIds.every((entry) => typeof entry === "string")
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import type { AcpElicitationRequest } from "acpx/runtime";
import { normalizeAcpFormElicitation } from "./acp-question-adapter.js";
describe("ACP form question adapter", () => {
it("normalizes all supported field modes and restores typed ACP content", () => {
const normalized = normalizeAcpFormElicitation({
mode: "form",
sessionId: "session-1",
message: "Choose deployment settings.",
requestedSchema: {
type: "object",
title: "Deployment",
required: ["name", "region", "features", "confirmed", "replicas"],
properties: {
name: { type: "string", title: "Name", minLength: 2, maxLength: 20 },
region: {
type: "string",
title: "Region",
oneOf: [
{ const: "us-east-1", title: "Virginia", description: "Lowest latency for US East." },
{ const: "eu-west-1", title: "Ireland" },
],
},
features: {
type: "array",
title: "Features",
items: { anyOf: [
{ const: "tracing", title: "Tracing" },
{ const: "backups", title: "Backups" },
] },
},
confirmed: { type: "boolean", title: "Confirm" },
replicas: { type: "integer", title: "Replicas", minimum: 1, maximum: 10 },
},
},
} as AcpElicitationRequest);
expect(normalized).not.toBeNull();
const questionSet = normalized!.questionSet;
expect(questionSet.title).toBe("Deployment");
expect(questionSet.description).toContain("Choose deployment settings.");
expect(questionSet.questions.map((question) => question.answerMode)).toEqual([
"text",
"single_select",
"multi_select",
"single_select",
"text",
]);
expect(questionSet.questions[1]?.options?.[0]).toMatchObject({
label: "Virginia",
description: "Lowest latency for US East.",
});
expect(questionSet.questions[4]?.textValidation).toMatchObject({
inputType: "integer",
minimum: 1,
maximum: 10,
});
const [name, region, features, confirmed, replicas] = questionSet.questions;
const response = normalized!.accept({
schema: "paperclip.question_response.v1",
answers: {
[name!.id]: { text: "paperclip" },
[region!.id]: { selectedOptionIds: [region!.options![1]!.id] },
[features!.id]: { selectedOptionIds: features!.options!.map((option) => option.id) },
[confirmed!.id]: { selectedOptionIds: [confirmed!.options![0]!.id] },
[replicas!.id]: { text: "3" },
},
});
expect(response).toEqual({
action: "accept",
content: {
name: "paperclip",
region: "eu-west-1",
features: ["tracing", "backups"],
confirmed: true,
replicas: 3,
},
});
});
it("rejects invalid numeric answers before they reach ACP", () => {
const normalized = normalizeAcpFormElicitation({
mode: "form",
requestId: "request-1",
message: "How many?",
requestedSchema: {
type: "object",
required: ["count"],
properties: { count: { type: "integer", minimum: 1 } },
},
} as AcpElicitationRequest)!;
expect(() => normalized.accept({
schema: "paperclip.question_response.v1",
answers: { [normalized.questionSet.questions[0]!.id]: { text: "1.5" } },
})).toThrow(/must be a valid integer/);
});
it("does not advertise a question shape for URL elicitation", () => {
expect(normalizeAcpFormElicitation({
mode: "url",
requestId: "request-1",
message: "Authenticate",
elicitationId: "auth-1",
url: "https://example.invalid",
} as AcpElicitationRequest)).toBeNull();
});
});
@@ -0,0 +1,296 @@
import { createHash } from "node:crypto";
import type {
AcpElicitationRequest,
AcpElicitationResponse,
} from "acpx/runtime";
import {
PAPERCLIP_QUESTION_SET_SCHEMA,
parsePaperclipQuestionSet,
parsePaperclipQuestionResponse,
type PaperclipQuestion,
type PaperclipQuestionOption,
type PaperclipQuestionResponse,
type PaperclipQuestionSet,
} from "../../contracts/question-set.js";
interface AcpFieldBinding {
propertyName: string;
property: Record<string, unknown>;
question: PaperclipQuestion;
optionValues: Map<string, string>;
}
export interface NormalizedAcpForm {
questionSet: PaperclipQuestionSet;
/** Convert a validated Paperclip response back into typed ACP content. */
accept(response: unknown): AcpElicitationResponse;
}
/**
* ACP remains private to this adapter. Only the normalized question set is
* allowed to cross the Paperclip runtime-request boundary.
*/
export function normalizeAcpFormElicitation(
request: AcpElicitationRequest,
): NormalizedAcpForm | null {
const rawRequest = record(request);
if (rawRequest.mode !== "form") return null;
const schema = record(rawRequest.requestedSchema);
const properties = record(schema.properties);
const required = new Set(
Array.isArray(schema.required)
? schema.required.filter((value): value is string => typeof value === "string")
: [],
);
const bindings = Object.entries(properties).map(([propertyName, value], index) =>
normalizeField(propertyName, value, index, required.has(propertyName)),
);
if (bindings.length === 0) {
throw new Error("ACP form elicitation must define at least one supported property");
}
const title = optionalText(schema.title) ?? "Additional information needed";
const descriptions = [optionalText(rawRequest.message), optionalText(schema.description)]
.filter((value) => value !== title)
.filter((value, position, all): value is string => Boolean(value) && all.indexOf(value) === position);
const questionSet = parsePaperclipQuestionSet({
schema: PAPERCLIP_QUESTION_SET_SCHEMA,
title,
...(descriptions.length > 0 ? { description: descriptions.join("\n\n") } : {}),
submitLabel: "Submit answers",
questions: bindings.map((binding) => binding.question),
});
return {
questionSet,
accept(response: unknown): AcpElicitationResponse {
const parsed = parsePaperclipQuestionResponse(questionSet, response);
return {
action: "accept",
content: acpContent(bindings, parsed),
};
},
};
}
function normalizeField(
propertyName: string,
value: unknown,
index: number,
required: boolean,
): AcpFieldBinding {
const property = record(value);
const type = text(property.type);
const id = stableId("field", propertyName, index);
const header = optionalText(property.title) ?? propertyName;
const prompt = optionalText(property.description) ?? header;
const base = {
id,
header,
prompt,
required,
};
if (type === "string") {
const nativeOptions = enumOptions(property.oneOf ?? property.anyOf, property.enum);
if (nativeOptions.length > 0) {
const normalized = normalizeOptions(nativeOptions);
return {
propertyName,
property,
optionValues: normalized.values,
question: {
...base,
answerMode: "single_select",
options: normalized.options,
},
};
}
return {
propertyName,
property,
optionValues: new Map(),
question: {
...base,
answerMode: "text",
textValidation: {
inputType: "text",
...(finiteNonNegativeInteger(property.minLength) !== undefined
? { minLength: finiteNonNegativeInteger(property.minLength) }
: {}),
...(finiteNonNegativeInteger(property.maxLength) !== undefined
? { maxLength: finiteNonNegativeInteger(property.maxLength) }
: {}),
...(optionalText(property.pattern) ? { pattern: optionalText(property.pattern) } : {}),
},
},
};
}
if (type === "number" || type === "integer") {
return {
propertyName,
property,
optionValues: new Map(),
question: {
...base,
answerMode: "text",
textValidation: {
inputType: type,
...(finiteNumber(property.minimum) !== undefined ? { minimum: finiteNumber(property.minimum) } : {}),
...(finiteNumber(property.maximum) !== undefined ? { maximum: finiteNumber(property.maximum) } : {}),
},
},
};
}
if (type === "boolean") {
const options = [
{ value: "true", label: "Yes" },
{ value: "false", label: "No" },
];
const normalized = normalizeOptions(options);
return {
propertyName,
property,
optionValues: normalized.values,
question: {
...base,
answerMode: "single_select",
options: normalized.options,
},
};
}
if (type === "array") {
const items = record(property.items);
const nativeOptions = enumOptions(items.anyOf ?? items.oneOf, items.enum);
if (nativeOptions.length === 0) {
throw new Error(`ACP multi-select property ${propertyName} must define enum or anyOf options`);
}
const normalized = normalizeOptions(nativeOptions);
return {
propertyName,
property,
optionValues: normalized.values,
question: {
...base,
answerMode: "multi_select",
options: normalized.options,
},
};
}
throw new Error(`Unsupported ACP elicitation property type ${JSON.stringify(type)} for ${propertyName}`);
}
function acpContent(
bindings: AcpFieldBinding[],
response: PaperclipQuestionResponse,
): Record<string, string | number | boolean | string[]> {
const content: Record<string, string | number | boolean | string[]> = {};
for (const binding of bindings) {
const answer = response.answers[binding.question.id];
if (!answer) continue;
const type = text(binding.property.type);
if (type === "string" && binding.question.answerMode === "text") {
if (answer.text !== undefined) content[binding.propertyName] = answer.text;
continue;
}
if (type === "number" || type === "integer") {
if (answer.text !== undefined) content[binding.propertyName] = Number(answer.text);
continue;
}
if (type === "boolean") {
const selected = answer.selectedOptionIds?.[0];
if (selected !== undefined) content[binding.propertyName] = binding.optionValues.get(selected) === "true";
continue;
}
const selectedValues = (answer.selectedOptionIds ?? []).map((id) => {
const value = binding.optionValues.get(id);
if (value === undefined) throw new Error(`ACP option ${id} is no longer available`);
return value;
});
if (type === "array") content[binding.propertyName] = selectedValues;
else if (selectedValues[0] !== undefined) content[binding.propertyName] = selectedValues[0];
}
return content;
}
function enumOptions(
titled: unknown,
values: unknown,
): Array<{ value: string; label: string; description?: string }> {
if (Array.isArray(titled)) {
return titled.map((entry, index) => {
const option = record(entry);
const value = requiredText(option.const, `ACP enum option ${index} const`);
return {
value,
label: optionalText(option.title) ?? value,
...(optionalText(option.description) ? { description: optionalText(option.description) } : {}),
};
});
}
if (Array.isArray(values)) {
return values.map((value, index) => {
const native = requiredText(value, `ACP enum option ${index}`);
return { value: native, label: native };
});
}
return [];
}
function normalizeOptions(
nativeOptions: Array<{ value: string; label: string; description?: string }>,
): { options: PaperclipQuestionOption[]; values: Map<string, string> } {
const values = new Map<string, string>();
const options = nativeOptions.map((option, index): PaperclipQuestionOption => {
const id = stableId("option", option.value, index);
if (values.has(id)) throw new Error(`ACP elicitation option identity collision for ${option.value}`);
values.set(id, option.value);
return {
id,
label: option.label,
...(option.description ? { description: option.description } : {}),
};
});
return { options, values };
}
function stableId(prefix: string, value: string, index: number): string {
if (prefix === "field" && value.length > 0 && value.length <= 160) return value;
if (prefix === "option") return `option-${index + 1}`;
const readable = value.trim().replace(/[^A-Za-z0-9._:-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 96);
const digest = createHash("sha256").update(value).digest("hex").slice(0, 12);
return `${prefix}-${index + 1}-${readable || "value"}-${digest}`;
}
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function text(value: unknown): string {
return typeof value === "string" ? value : "";
}
function optionalText(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function requiredText(value: unknown, field: string): string {
const result = optionalText(value);
if (!result) throw new Error(`${field} is required`);
return result;
}
function finiteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function finiteNonNegativeInteger(value: unknown): number | undefined {
return Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : undefined;
}
@@ -2,7 +2,12 @@ import { createHash, randomBytes } from "node:crypto";
import { readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import type { AcpPermissionDecision, AcpPermissionRequest, AcpRuntimeEvent } from "acpx/runtime";
import type {
AcpElicitationResponse,
AcpPermissionDecision,
AcpPermissionRequest,
AcpRuntimeEvent,
} from "acpx/runtime";
import {
createCodexTaskEnvelope,
@@ -42,9 +47,12 @@ import {
AcpxRuntimeHost,
type AcpxRuntimeFactory,
type AcpxRuntimeIdentity,
type AcpxRuntimeElicitationContext,
type AcpxRuntimePermissionContext,
type AcpxRuntimeUsage,
} from "./acpx-runtime-host.js";
import { normalizeAcpFormElicitation } from "./acp-question-adapter.js";
import { PAPERCLIP_RUNTIME_REQUEST_SCHEMA_V2 } from "../../contracts/question-set.js";
import {
ACPX_DRIVER_KIND,
QUALIFIED_ACPX_VERSION,
@@ -106,6 +114,12 @@ interface PendingPermission {
settle: (decision: AcpPermissionDecision | undefined) => void;
}
interface PendingInput {
request: HarnessRuntimeRequest;
accept: NonNullable<ReturnType<typeof normalizeAcpFormElicitation>>;
settle: (response: AcpElicitationResponse) => void;
}
export class AcpxRuntimeDriver implements HarnessDriver {
readonly #options: AcpxRuntimeDriverOptions;
@@ -217,6 +231,10 @@ export class AcpxRuntimeDriver implements HarnessDriver {
if (!session) return Promise.resolve({ outcome: "reject_once" });
return session.requestPermission(context);
},
onElicitation: (context) => {
if (!session) return Promise.resolve({ action: "cancel" });
return session.requestInput(context);
},
runtimeFactory: this.#options.runtimeFactory,
onSpawn: this.#options.onSpawn,
onUsage: (usage) => session?.acceptUsage(usage),
@@ -259,6 +277,7 @@ class AcpxHarnessSession implements HarnessSession {
readonly #transcript: PrpEvent[] = [];
readonly #terminalTurns = new Map<string, string>();
readonly #pendingPermissions = new Map<string, PendingPermission>();
readonly #pendingInputs = new Map<string, PendingInput>();
readonly #emittedFileReferences = new Set<string>();
#sourceSequence: number;
#activeTurnId: string | null;
@@ -322,7 +341,9 @@ class AcpxHarnessSession implements HarnessSession {
events(): AsyncIterable<PrpEvent> { return this.#events; }
attachRun(input: { runId: string }): void {
if (this.#activeTurnId !== null || this.#pendingPermissions.size > 0) throw new Error("acpx_run_attach_busy");
if (this.#activeTurnId !== null || this.#pendingPermissions.size > 0 || this.#pendingInputs.size > 0) {
throw new Error("acpx_run_attach_busy");
}
if (!input.runId.trim()) throw new Error("acpx_run_attach_invalid");
this.#runId = input.runId;
this.#result = null;
@@ -363,7 +384,8 @@ class AcpxHarnessSession implements HarnessSession {
}
pendingRuntimeRequests(): HarnessRuntimeRequest[] {
return [...this.#pendingPermissions.values()].map((pending) => structuredClone(pending.request));
return [...this.#pendingPermissions.values(), ...this.#pendingInputs.values()]
.map((pending) => structuredClone(pending.request));
}
async resolveRuntimeRequest(input: {
@@ -371,19 +393,48 @@ class AcpxHarnessSession implements HarnessSession {
turnId: string;
resolution: HarnessRuntimeRequestResolution;
}): Promise<void> {
const pending = this.#pendingPermissions.get(input.requestId);
const pendingInput = this.#pendingInputs.get(input.requestId);
const pendingPermission = this.#pendingPermissions.get(input.requestId);
const pending = pendingInput ?? pendingPermission;
if (!pending) throw new HarnessCapabilityUnavailableError("runtime request resolution", `request ${input.requestId} is no longer pending`);
if (pending.request.turnId !== input.turnId || this.#activeTurnId !== input.turnId) {
throw new HarnessStaleTurnError(input.turnId);
}
const resolution = parseHarnessRuntimeRequestResolution(pending.request.requestKind, input.resolution);
const resolution = parseHarnessRuntimeRequestResolution(
pending.request.requestKind,
input.resolution,
pending.request.input,
);
if (pendingInput) {
this.#pendingInputs.delete(input.requestId);
this.#emit("runtime_request.resolved", harnessRuntimeRequestOutcome(pending.request, {
action: resolution.action,
...(resolution.action === "submit" && "response" in resolution
? { response: resolution.response }
: {}),
}), {
turnId: input.turnId,
itemId: pending.request.itemId,
});
if (resolution.action === "submit" && "response" in resolution) {
pendingInput.settle(pendingInput.accept.accept(resolution.response));
} else if (resolution.action === "decline") {
pendingInput.settle({ action: "decline" });
} else {
pendingInput.settle({ action: "cancel" });
}
return;
}
if (!pendingPermission) {
throw new HarnessCapabilityUnavailableError("runtime request resolution", `request ${input.requestId} is no longer pending`);
}
const decision = permissionDecision(resolution.action);
this.#pendingPermissions.delete(input.requestId);
this.#emit("runtime_request.resolved", harnessRuntimeRequestOutcome(pending.request, { action: resolution.action }), {
turnId: input.turnId,
itemId: pending.request.itemId,
});
pending.settle(decision);
pendingPermission.settle(decision);
}
async requestPermission(context: AcpxRuntimePermissionContext): Promise<AcpPermissionDecision | undefined> {
@@ -437,6 +488,76 @@ class AcpxHarnessSession implements HarnessSession {
});
}
async requestInput(context: AcpxRuntimeElicitationContext): Promise<AcpElicitationResponse> {
const turnId = this.#activeTurnId;
if (!turnId || context.signal.aborted) return { action: "cancel" };
let normalized: ReturnType<typeof normalizeAcpFormElicitation>;
try {
normalized = normalizeAcpFormElicitation(context.request);
} catch (error) {
this.#emit("harness.diagnostic", {
code: "runtime_input_rejected",
adapter: "acpx-runtime",
reason: redact(String(error)).slice(0, 4_000),
}, { turnId });
return { action: "decline" };
}
if (!normalized) return { action: "decline" };
const requestId = safeId(String(context.requestId), `input-${randomBytes(8).toString("hex")}`);
if (this.#pendingInputs.has(requestId)) return { action: "decline" };
const runtimeRequest: HarnessRuntimeRequest = {
requestId,
requestKind: "elicitation",
method: "elicitation/create",
turnId,
itemId: requestId,
status: "pending",
prompt: normalized.questionSet.title ?? "Additional information needed",
details: {},
input: normalized.questionSet,
origin: {
adapter: "acpx-runtime",
provider: "acpx",
method: "elicitation/create",
},
};
this.#emit("runtime_request.created", {
request: {
schema: PAPERCLIP_RUNTIME_REQUEST_SCHEMA_V2,
requestKind: "runtime",
requestId,
type: "input",
status: "pending",
prompt: runtimeRequest.prompt,
input: normalized.questionSet,
origin: runtimeRequest.origin,
turnId,
itemId: requestId,
},
}, { turnId, itemId: requestId });
return new Promise((settle) => {
const abort = () => {
const pending = this.#pendingInputs.get(requestId);
if (!pending) return;
this.#pendingInputs.delete(requestId);
this.#emit("runtime_request.cancelled", harnessRuntimeRequestOutcome(runtimeRequest, { reason: "provider_cancelled" }), {
turnId,
itemId: requestId,
});
settle({ action: "cancel" });
};
context.signal.addEventListener("abort", abort, { once: true });
this.#pendingInputs.set(requestId, {
request: runtimeRequest,
accept: normalized,
settle: (response) => {
context.signal.removeEventListener("abort", abort);
settle(response);
},
});
});
}
async dispatchTool(call: { tool: string; callId: string; arguments: unknown }): Promise<unknown> {
const turnId = this.#activeTurnId;
if (!turnId) throw new Error("ACPX semantic tool call is not bound to an active turn");
@@ -584,6 +705,14 @@ class AcpxHarnessSession implements HarnessSession {
pending.settle({ outcome: "cancel" });
}
this.#pendingPermissions.clear();
for (const pending of this.#pendingInputs.values()) {
this.#emit("runtime_request.cancelled", harnessRuntimeRequestOutcome(pending.request, { reason: "session_closed" }), {
turnId: pending.request.turnId,
itemId: pending.request.itemId,
});
pending.settle({ action: "cancel" });
}
this.#pendingInputs.clear();
this.#events.close();
await this.#host.close({ reason: input.reason, discardPersistentState: false });
}
@@ -149,6 +149,7 @@ describe("AcpxRuntimeHost", () => {
await mkdir(workspace);
let captured: AcpRuntimeOptions | null = null;
let ensureInput: Parameters<AcpRuntime["ensureSession"]>[0] | null = null;
let piElicitation: unknown = null;
const usages: unknown[] = [];
const handle: AcpRuntimeHandle = {
sessionKey: "session-key",
@@ -196,6 +197,10 @@ describe("AcpxRuntimeHost", () => {
DATABASE_URL: "database-must-not-pass",
},
dynamicToolHandler: async () => ({ ok: true }),
onElicitation: async (context) => {
piElicitation = context.request;
return { action: "accept", content: { environment: "staging", replicas: 3 } };
},
onUsage: (usage) => usages.push(usage),
runtimeFactory,
});
@@ -224,6 +229,61 @@ describe("AcpxRuntimeHost", () => {
});
const catalog = await catalogResponse.json() as { result: { tools: Array<{ name: string }> } };
expect(catalog.result.tools.map((tool) => tool.name)).toContain("get_task_context");
expect(catalog.result.tools.map((tool) => tool.name)).not.toContain("__paperclip_runtime_input");
const inputResponse = await fetch(childEnvironment.PAPERCLIP_RUNNER_BRIDGE_URL!, {
method: "POST",
headers: {
authorization: `Bearer ${childEnvironment.PAPERCLIP_RUNNER_BRIDGE_TOKEN}`,
"content-type": "application/json",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: "pi-input-1",
method: "tools/call",
params: {
name: "__paperclip_runtime_input",
arguments: {
title: "Deployment",
description: "Choose deployment settings.",
questions: [
{
id: "environment",
header: "Environment",
prompt: "Where should we deploy?",
answerMode: "single_select",
options: [
{ id: "staging", label: "Staging" },
{ id: "production", label: "Production" },
],
},
{
id: "replicas",
prompt: "How many replicas?",
answerMode: "integer",
minimum: 1,
maximum: 10,
},
],
},
},
}),
});
expect(inputResponse.ok).toBe(true);
expect(await inputResponse.json()).toMatchObject({
result: { content: [{ type: "text", text: expect.stringContaining("staging") }] },
});
expect(piElicitation).toMatchObject({
mode: "form",
message: "Choose deployment settings.",
requestedSchema: {
title: "Deployment",
required: ["environment", "replicas"],
properties: {
environment: { type: "string", title: "Environment" },
replicas: { type: "integer", minimum: 1, maximum: 10 },
},
},
});
expect(JSON.stringify(childEnvironment)).not.toContain("paperclip-must-not-pass");
expect(JSON.stringify(childEnvironment)).not.toContain("database-must-not-pass");
expect(JSON.stringify(ensureInput)).not.toContain("openrouter-canary-secret");
@@ -277,6 +337,7 @@ describe("AcpxRuntimeHost", () => {
PAPERCLIP_PROVIDER_TRACE_PATH: tracePath,
},
dynamicToolHandler: async () => ({}),
onElicitation: async () => ({ action: "cancel" }),
runtimeFactory: (options) => {
capturedOptions = options;
return {
@@ -328,7 +389,7 @@ describe("AcpxRuntimeHost", () => {
"semantic_tool.paperclip_finish",
);
expect(environment.PAPERCLIP_ACPX_ISOLATED_CONTEXT).toBe("1");
expect(options.elicitationModes).toEqual(agent === "codex" ? [] : ["form", "url"]);
expect(options.elicitationModes).toEqual(agent === "codex" ? [] : ["form"]);
expect(ensureInput).toMatchObject({
sessionOptions: { systemPrompt: { append: systemInstructions } },
});
@@ -11,6 +11,9 @@ import {
createRuntimeStore,
type AcpPermissionDecision,
type AcpPermissionRequest,
type AcpElicitationHandler,
type AcpElicitationRequest,
type AcpElicitationResponse,
type AcpRuntime,
type AcpRuntimeEvent,
type AcpRuntimeHandle,
@@ -37,6 +40,8 @@ import {
} from "../../contracts/provider-trace-file-sink.js";
const PI_PERMISSION_TOOL = "__paperclip_runtime_permission";
const PI_INPUT_TOOL = "__paperclip_runtime_input";
const HUMAN_RUNTIME_REQUEST_TIMEOUT_MS = 24 * 60 * 60 * 1_000;
type DynamicToolHandler = (call: {
tool: string;
@@ -65,6 +70,12 @@ export interface AcpxRuntimePermissionContext {
signal: AbortSignal;
}
export interface AcpxRuntimeElicitationContext {
request: AcpElicitationRequest;
requestId: string | number | null;
signal: AbortSignal;
}
export type AcpxRuntimeFactory = (options: AcpRuntimeOptions) => AcpRuntime;
interface SecureAcpRuntimeOptions extends AcpRuntimeOptions {
@@ -106,6 +117,9 @@ export interface AcpxRuntimeHostOptions {
onPermissionRequest?: (
context: AcpxRuntimePermissionContext,
) => Promise<AcpPermissionDecision | undefined>;
onElicitation?: (
context: AcpxRuntimeElicitationContext,
) => Promise<AcpElicitationResponse>;
onSpawn?: (meta: AcpxRuntimeProcessIdentity) => Promise<void>;
onUsage?: (usage: AcpxRuntimeUsage) => void;
onDiagnostic?: (message: string) => void;
@@ -153,6 +167,7 @@ export class AcpxRuntimeHost {
readonly #agentProcessIdentity: AcpxRuntimeProcessIdentity | null;
readonly #trace: ProviderTraceFileSink | null;
readonly #traceState: { latestInboundFrameId: number | null };
readonly #onElicitation?: AcpxRuntimeHostOptions["onElicitation"];
#activeTurn: AcpRuntimeTurn | null = null;
#closed = false;
@@ -166,6 +181,7 @@ export class AcpxRuntimeHost {
agentProcessIdentity: AcpxRuntimeProcessIdentity | null;
trace: ProviderTraceFileSink | null;
traceState: { latestInboundFrameId: number | null };
onElicitation?: AcpxRuntimeHostOptions["onElicitation"];
}) {
this.#runtime = input.runtime;
this.#handle = input.handle;
@@ -176,6 +192,7 @@ export class AcpxRuntimeHost {
this.#agentProcessIdentity = input.agentProcessIdentity;
this.#trace = input.trace;
this.#traceState = input.traceState;
this.#onElicitation = input.onElicitation;
}
static async open(options: AcpxRuntimeHostOptions): Promise<AcpxRuntimeHost> {
@@ -234,11 +251,21 @@ export class AcpxRuntimeHost {
inputClass: { enum: ["workspace_mutation", "command"] },
},
},
}] : [],
}, ...(options.onElicitation ? [{
name: PI_INPUT_TOOL,
description: "Runner-internal Pi structured input gate.",
inputSchema: piInputToolSchema(),
}] : [])] : [],
handler: async (call) => {
if (call.tool !== PI_PERMISSION_TOOL) return options.dynamicToolHandler(call);
return requestPiPermission(options, call.callId, call.arguments, call.signal, piPermissionRules);
if (call.tool === PI_PERMISSION_TOOL) {
return requestPiPermission(options, call.callId, call.arguments, call.signal, piPermissionRules);
}
if (call.tool === PI_INPUT_TOOL) {
return requestPiInput(options, call.callId, call.arguments, call.signal);
}
return options.dynamicToolHandler(call);
},
privateToolTimeoutMs: HUMAN_RUNTIME_REQUEST_TIMEOUT_MS,
});
const agentEnvironment = sanitizedAgentEnvironment(options.environment ?? process.env, {
HOME: homeDir,
@@ -313,7 +340,7 @@ export class AcpxRuntimeHost {
// With no generic elicitation capability advertised, Codex-ACP uses the
// governed permission channel where the runner-owned server identity is
// correlated and external tool calls remain subject to policy.
elicitationModes: options.agent === "codex" ? [] : ["form", "url"],
elicitationModes: options.agent === "codex" || !options.onElicitation ? [] : ["form"],
onPermissionRequest: async (request, context) => {
// Claude Code and Codex ask their ACP client to authorize MCP
// invocations,
@@ -427,6 +454,7 @@ export class AcpxRuntimeHost {
agentProcessIdentity,
trace,
traceState,
onElicitation: options.onElicitation,
});
} catch (error) {
if (handle) {
@@ -476,6 +504,13 @@ export class AcpxRuntimeHost {
mode: "prompt",
requestId: input.requestId,
signal: input.signal,
...(this.#onElicitation ? {
onElicitation: ((request, context) => this.#onElicitation!({
request,
requestId: context.requestId,
signal: context.signal,
})) satisfies AcpElicitationHandler,
} : {}),
});
this.#activeTurn = turn;
void turn.result.finally(() => {
@@ -759,6 +794,132 @@ function resolvePackageJsonFromEntrypoint(packageName: string): string {
return fileURLToPath(new URL(`../../../node_modules/${packageName}/package.json`, import.meta.url));
}
function piInputToolSchema(): Record<string, unknown> {
return {
type: "object",
additionalProperties: false,
required: ["title", "questions"],
properties: {
title: { type: "string", minLength: 1, maxLength: 1_000 },
description: { type: "string", maxLength: 4_000 },
questions: {
type: "array",
minItems: 1,
maxItems: 32,
items: {
type: "object",
additionalProperties: false,
required: ["id", "prompt", "answerMode"],
properties: {
id: { type: "string", minLength: 1, maxLength: 160, pattern: "^[A-Za-z0-9_.:-]+$" },
header: { type: "string", maxLength: 1_000 },
prompt: { type: "string", minLength: 1, maxLength: 4_000 },
helpText: { type: "string", maxLength: 4_000 },
required: { type: "boolean" },
answerMode: { enum: ["text", "single_select", "multi_select", "boolean", "integer", "number"] },
options: {
type: "array",
minItems: 1,
maxItems: 128,
items: {
type: "object",
additionalProperties: false,
required: ["id", "label"],
properties: {
id: { type: "string", minLength: 1, maxLength: 160 },
label: { type: "string", minLength: 1, maxLength: 1_000 },
description: { type: "string", maxLength: 4_000 },
},
},
},
minLength: { type: "integer", minimum: 0, maximum: 1_000_000 },
maxLength: { type: "integer", minimum: 0, maximum: 1_000_000 },
minimum: { type: "number" },
maximum: { type: "number" },
pattern: { type: "string", maxLength: 1_000 },
},
},
},
},
};
}
async function requestPiInput(
options: AcpxRuntimeHostOptions,
callId: string,
input: unknown,
signal: AbortSignal,
): Promise<AcpElicitationResponse> {
if (!options.onElicitation || signal.aborted) return { action: "cancel" };
const value = piRecord(input);
const rawQuestions = Array.isArray(value.questions) ? value.questions.slice(0, 32) : [];
if (rawQuestions.length === 0) return { action: "decline" };
const required: string[] = [];
const properties = Object.fromEntries(rawQuestions.map((candidate, index) => {
const question = piRecord(candidate);
const id = piText(question.id) || `question-${index + 1}`;
if (question.required !== false) required.push(id);
const answerMode = piText(question.answerMode);
const nativeOptions = Array.isArray(question.options) ? question.options.map(piRecord) : [];
const options = nativeOptions.map((option, optionIndex) => ({
const: piText(option.id) || `option-${optionIndex + 1}`,
title: piText(option.label) || `Option ${optionIndex + 1}`,
...(piText(option.description) ? { description: piText(option.description) } : {}),
}));
let property: Record<string, unknown>;
if (answerMode === "single_select" || answerMode === "multi_select") {
if (options.length === 0) throw new Error(`Pi input question ${id} requires options`);
property = answerMode === "multi_select"
? { type: "array", items: { type: "string", oneOf: options } }
: { type: "string", oneOf: options };
} else if (answerMode === "boolean") {
property = { type: "boolean" };
} else if (answerMode === "integer" || answerMode === "number") {
property = {
type: answerMode,
...(typeof question.minimum === "number" ? { minimum: question.minimum } : {}),
...(typeof question.maximum === "number" ? { maximum: question.maximum } : {}),
};
} else {
property = {
type: "string",
...(typeof question.minLength === "number" ? { minLength: question.minLength } : {}),
...(typeof question.maxLength === "number" ? { maxLength: question.maxLength } : {}),
...(piText(question.pattern) ? { pattern: piText(question.pattern) } : {}),
};
}
return [id, {
...property,
title: piText(question.header) || piText(question.prompt) || id,
...(piText(question.helpText) || piText(question.prompt)
? { description: piText(question.helpText) || piText(question.prompt) }
: {}),
}];
}));
const request = {
mode: "form",
sessionId: options.normalizedSessionId,
message: piText(value.description) || piText(value.title) || "Pi needs your input",
requestedSchema: {
type: "object",
title: piText(value.title) || "Pi needs your input",
properties,
...(required.length > 0 ? { required } : {}),
},
} as AcpElicitationRequest;
return options.onElicitation({ request, requestId: callId, signal });
}
function piRecord(value: unknown): Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function piText(value: unknown): string {
return typeof value === "string" ? value.slice(0, 4_000) : "";
}
async function requestPiPermission(
options: AcpxRuntimeHostOptions,
callId: string,
@@ -1,6 +1,6 @@
// Generated by scripts/generate-acpx-sidecar-contract.mjs. Do not edit.
export const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION = 1 as const;
export const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION = 2 as const;
export const GENERATED_ACPX_SIDECAR_COMMANDS = [
"initialize",
"session.open",
@@ -8,6 +8,7 @@ export const GENERATED_ACPX_SIDECAR_COMMANDS = [
"turn.start",
"turn.cancel",
"permission.resolve",
"input.resolve",
"tool.resolve",
"session.read",
"session.snapshot",
@@ -19,6 +20,7 @@ export type GeneratedAcpxSidecarCommand = (typeof GENERATED_ACPX_SIDECAR_COMMAND
export const GENERATED_ACPX_SIDECAR_EVENT_TYPES = [
"runtime.event",
"runtime.permission_requested",
"runtime.input_requested",
"runtime.tool_called",
"runtime.turn_terminal",
"runtime.process",
@@ -37,6 +37,13 @@ describe("paperclip Pi extension", () => {
}), { status: 200 });
}
if (request.method === "tools/call") {
if ((request.params as Record<string, unknown>)?.name === "__paperclip_runtime_input") {
return new Response(JSON.stringify({
jsonrpc: "2.0",
id: request.id,
result: { content: [{ type: "text", text: JSON.stringify({ action: "accept", content: { environment: "staging" } }) }] },
}), { status: 200 });
}
return new Response(JSON.stringify({
jsonrpc: "2.0",
id: request.id,
@@ -57,8 +64,28 @@ describe("paperclip Pi extension", () => {
},
} as never);
expect(tools.map((tool) => tool.name)).toEqual(["paperclip_finish"]);
expect(tools.map((tool) => tool.name)).toEqual(["request_user_input", "paperclip_finish"]);
expect(JSON.stringify(tools)).not.toContain("__paperclip_runtime_permission");
const inputResult = await (tools[0]!.execute as (
callId: string,
input: Record<string, unknown>,
signal: AbortSignal,
) => Promise<Record<string, unknown>>)("input-1", {
title: "Deployment",
questions: [{
id: "environment",
prompt: "Where should we deploy?",
answerMode: "single_select",
options: [{ id: "staging", label: "Staging" }],
}],
}, new AbortController().signal);
expect(inputResult).toMatchObject({
content: [{ type: "text", text: expect.stringContaining("staging") }],
});
expect(requests).toContainEqual(expect.objectContaining({
method: "tools/call",
params: expect.objectContaining({ name: "__paperclip_runtime_input" }),
}));
expect(await toolCallHandler!({ toolName: "read", toolCallId: "read-1", input: { path: "/etc/passwd" } })).toMatchObject({ block: true });
expect(await toolCallHandler!({ toolName: "bash", toolCallId: "bash-1", input: { command: "printf hello" } })).toBeUndefined();
expect(await toolCallHandler!({ toolName: "bash", toolCallId: "bash-relative", input: { command: "mkdir -p nested/dir && printf hello > nested/dir/file.txt" } })).toBeUndefined();
@@ -5,6 +5,8 @@ import { dirname, isAbsolute, relative, resolve } from "node:path";
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
const PRIVATE_PERMISSION_TOOL = "__paperclip_runtime_permission";
const PRIVATE_INPUT_TOOL = "__paperclip_runtime_input";
const MODEL_INPUT_TOOL = "request_user_input";
const BUILTIN_READ_TOOLS = new Set(["read", "grep", "find", "ls"]);
const BUILTIN_GOVERNED_TOOLS = new Set(["write", "edit", "bash"]);
const MAX_RESPONSE_BYTES = 1_048_576;
@@ -40,6 +42,7 @@ export default async function paperclipPiExtension(pi: ExtensionAPI): Promise<vo
const rpc = createMcpClient(endpoint, token);
let sessionAllowsGovernedTools = false;
await initializeMcpClient(rpc, "paperclip-pi-extension");
registerRuntimeInputTool(pi, rpc);
await registerMcpTools(pi, rpc, { privatePermissionTool: true, prefix: "" });
const assignedUrl = process.env.PAPERCLIP_NATIVE_MCP_URL?.trim();
const assignedToken = process.env.PAPERCLIP_NATIVE_MCP_TOKEN?.trim();
@@ -75,6 +78,69 @@ export default async function paperclipPiExtension(pi: ExtensionAPI): Promise<vo
});
}
function registerRuntimeInputTool(pi: ExtensionAPI, rpc: ReturnType<typeof createMcpClient>): void {
pi.registerTool({
name: MODEL_INPUT_TOOL,
label: "Ask · user input",
description: "Pause the current turn and ask the user one or more structured questions. Use text, single-select, multi-select, boolean, integer, or number fields as appropriate.",
parameters: {
type: "object",
additionalProperties: false,
required: ["title", "questions"],
properties: {
title: { type: "string", minLength: 1, maxLength: 1_000 },
description: { type: "string", maxLength: 4_000 },
questions: {
type: "array",
minItems: 1,
maxItems: 32,
items: {
type: "object",
additionalProperties: false,
required: ["id", "prompt", "answerMode"],
properties: {
id: { type: "string", minLength: 1, maxLength: 160, pattern: "^[A-Za-z0-9_.:-]+$" },
header: { type: "string", maxLength: 1_000 },
prompt: { type: "string", minLength: 1, maxLength: 4_000 },
helpText: { type: "string", maxLength: 4_000 },
required: { type: "boolean" },
answerMode: { enum: ["text", "single_select", "multi_select", "boolean", "integer", "number"] },
options: {
type: "array",
minItems: 1,
maxItems: 128,
items: {
type: "object",
additionalProperties: false,
required: ["id", "label"],
properties: {
id: { type: "string", minLength: 1, maxLength: 160 },
label: { type: "string", minLength: 1, maxLength: 1_000 },
description: { type: "string", maxLength: 4_000 },
},
},
},
minLength: { type: "integer", minimum: 0, maximum: 1_000_000 },
maxLength: { type: "integer", minimum: 0, maximum: 1_000_000 },
minimum: { type: "number" },
maximum: { type: "number" },
pattern: { type: "string", maxLength: 1_000 },
},
},
},
},
} as ToolDefinition["parameters"],
async execute(toolCallId, input, signal) {
const response = await rpcWithCancellation(rpc, PRIVATE_INPUT_TOOL, toolCallId, input, signal);
if (response.result?.isError) throw new Error(safeResultText(response) || "Paperclip input request failed");
return {
content: [{ type: "text" as const, text: safeResultText(response) || JSON.stringify({ action: "cancel" }) }],
details: { operationId: PRIVATE_INPUT_TOOL, registeredName: MODEL_INPUT_TOOL, callId: toolCallId },
};
},
});
}
async function initializeMcpClient(rpc: ReturnType<typeof createMcpClient>, clientName: string): Promise<void> {
await rpc("initialize", { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: clientName, version: "1" } }, `${clientName}-initialize`);
await rpc("notifications/initialized", {}, null);
@@ -83,7 +149,11 @@ async function initializeMcpClient(rpc: ReturnType<typeof createMcpClient>, clie
async function registerMcpTools(pi: ExtensionAPI, rpc: ReturnType<typeof createMcpClient>, options: { privatePermissionTool: boolean; prefix: string }): Promise<void> {
const catalog = await rpc("tools/list", {}, "paperclip-pi-tools");
for (const tool of catalog.result?.tools ?? []) {
if (!safeTool(tool) || (options.privatePermissionTool && tool.name === PRIVATE_PERMISSION_TOOL)) continue;
if (
!safeTool(tool) ||
(options.privatePermissionTool && tool.name === PRIVATE_PERMISSION_TOOL) ||
(options.prefix === "" && tool.name === MODEL_INPUT_TOOL)
) continue;
const registeredName = `${options.prefix}${tool.name}`;
pi.registerTool({
name: registeredName,
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { ProcessCodexAppServerTransport } from "./app-server-transport.js";
import { ProcessCodexAppServerTransport, redactCodexDiagnostic } from "./app-server-transport.js";
function nodeTransport(
source: string,
@@ -15,6 +15,13 @@ function nodeTransport(
}
describe("Codex app-server transport limits", () => {
it("redacts real Basic credentials without corrupting ordinary question copy", () => {
expect(redactCodexDiagnostic("Authorization: Basic dXNlcjpwYXNz"))
.toBe("Authorization: Basic [REDACTED]");
expect(redactCodexDiagnostic("Basic API foundation"))
.toBe("Basic API foundation");
});
it("reports restart-safe process-group ownership", async () => {
const transport = nodeTransport("process.stdin.resume()", { processGroup: true });
const info = transport.processInfo();
@@ -1,4 +1,5 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { HarnessRuntimeRequestResolution } from "../../contracts/harness-driver.js";
export interface CodexRpcNotification {
method: string;
@@ -35,6 +36,15 @@ export interface CodexAppServerTransport {
notify(method: string, params?: Record<string, unknown>): void;
notifications(): AsyncIterable<CodexRpcNotification>;
setServerRequestHandler(handler: CodexServerRequestHandler): void;
/**
* Optional provider-neutral resolution path used when a transport has
* already normalized a native server request behind another PRP boundary.
*/
resolveRuntimeRequest?(input: {
requestId: string;
turnId: string;
resolution: HarnessRuntimeRequestResolution;
}): Promise<void>;
close(): Promise<void>;
processInfo?(): CodexTransportProcessInfo;
attachRun?(input: {
@@ -224,7 +234,17 @@ export function redactCodexDiagnostic(message: string): string {
return message
.replaceAll(/\u001b\[[0-?]*[ -/]*[@-~]/g, "")
.replace(/Bearer\s+[A-Za-z0-9._~+\/-]+/gi, "Bearer [REDACTED]")
.replace(/Basic\s+[A-Za-z0-9+/=]+/gi, "Basic [REDACTED]")
.replace(/Basic\s+([A-Za-z0-9+/=]+)/gi, (match, encoded: string) => {
try {
// Only redact an actual RFC 7617 credential. Treating every word after
// “Basic” as base64 corrupted ordinary question copy such as
// “Basic API” before it entered the Paperclip protocol.
const decoded = Buffer.from(encoded, "base64").toString("utf8");
return decoded.includes(":") ? "Basic [REDACTED]" : match;
} catch {
return match;
}
})
.replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi, "$1[REDACTED]@")
.replace(
/([?&](?:api[_-]?key|token|secret|password)=)[^&#\s]+/gi,
@@ -47,19 +47,28 @@ import {
class TestQueue<T> implements AsyncIterable<T> {
values: T[] = [];
waiters: Array<(value: IteratorResult<T>) => void> = [];
waiters: Array<{
resolve: (value: IteratorResult<T>) => void;
reject: (error: Error) => void;
}> = [];
closed = false;
error: Error | null = null;
push(value: T): void {
const waiter = this.waiters.shift();
if (waiter) waiter({ value, done: false });
if (waiter) waiter.resolve({ value, done: false });
else this.values.push(value);
}
close(): void {
this.closed = true;
for (const waiter of this.waiters.splice(0))
waiter({ value: undefined, done: true });
waiter.resolve({ value: undefined, done: true });
}
fail(error: Error): void {
this.error = error;
for (const waiter of this.waiters.splice(0)) waiter.reject(error);
}
[Symbol.asyncIterator](): AsyncIterator<T> {
@@ -67,8 +76,9 @@ class TestQueue<T> implements AsyncIterable<T> {
next: async () => {
const value = this.values.shift();
if (value) return { value, done: false };
if (this.error) throw this.error;
if (this.closed) return { value: undefined, done: true };
return new Promise((resolve) => this.waiters.push(resolve));
return new Promise((resolve, reject) => this.waiters.push({ resolve, reject }));
},
};
}
@@ -1555,6 +1565,13 @@ describe("Codex app-server Codex driver", () => {
const { turnId } = await session.startTurn({
message: { role: "user", text: "Ask the deployment questions." },
});
const resolvedEvent = (async () => {
for await (const event of session.events()) {
if (event.eventType === "runtime_request.resolved"
&& event.payload.requestId === "dot-185-request") return event;
}
return null;
})();
const nativeResponse = transport.invoke({
id: "dot-185-request",
method: "item/tool/requestUserInput",
@@ -1629,6 +1646,59 @@ describe("Codex app-server Codex driver", () => {
notes: { answers: ["Ship during the maintenance window."] },
},
});
expect((await resolvedEvent)?.payload).toMatchObject({
action: "submit",
response: {
schema: "paperclip.question_response.v1",
answers: {
environment: { selectedOptionIds: ["option-1"] },
regions: { selectedOptionIds: ["option-1", "option-2"] },
notes: { text: "Ship during the maintenance window." },
},
},
});
await session.close({ reason: "fixture complete" });
});
it("rejects an explicit malformed Codex form without falling back to v1", async () => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport]).openSession({
runId: "run-malformed-input",
normalizedSessionId: "normalized-malformed-input",
workingDirectory: "/workspace",
});
const { turnId } = await session.startTurn({
message: { role: "user", text: "Ask a malformed question." },
});
const diagnostic = (async () => {
for await (const event of session.events()) {
if (event.eventType === "harness.diagnostic" && event.payload.code === "runtime_input_rejected") {
return event;
}
}
return null;
})();
await expect(transport.invoke({
id: "malformed-input",
method: "item/tool/requestUserInput",
params: {
threadId: "thread-1",
turnId,
itemId: "malformed-item",
questions: [
{ id: "duplicate", question: "First?" },
{ id: "duplicate", question: "Second?" },
],
},
})).resolves.toEqual({ answers: {} });
expect(session.pendingRuntimeRequests?.()).toEqual([]);
expect(await diagnostic).toEqual(expect.objectContaining({
eventType: "harness.diagnostic",
payload: expect.objectContaining({
code: "runtime_input_rejected",
adapter: "codex-app-server",
}),
}));
await session.close({ reason: "fixture complete" });
});
@@ -1718,6 +1788,65 @@ describe("Codex app-server Codex driver", () => {
for (const event of terminal) expect(validatePrpEvent(event).ok).toBe(true);
});
it("expires a canonical input with its full question set when the provider transport is lost", async () => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport]).openSession({
runId: "run-provider-loss",
normalizedSessionId: "normalized-provider-loss",
workingDirectory: "/workspace",
});
const events: PrpEvent[] = [];
const consume = (async () => {
for await (const event of session.events()) events.push(event);
})();
const { turnId } = await session.startTurn({
message: { role: "user", text: "Ask before continuing." },
});
const pending = transport.invoke({
id: "lost-input",
method: "item/tool/requestUserInput",
params: {
threadId: "thread-1",
turnId,
itemId: "lost-input-item",
questions: [{
id: "environment",
header: "Environment",
question: "Where should we deploy?",
options: [{ label: "Staging" }, { label: "Production" }],
}],
},
});
await new Promise((resolve) => setTimeout(resolve, 0));
transport.queue.fail(new Error("provider exited"));
expect(await pending).toEqual({ answers: {} });
await consume;
const expired = events.find((event) => event.eventType === "runtime_request.expired");
expect(expired).toMatchObject({
turnId,
itemId: "lost-input-item",
payload: {
requestId: "lost-input",
reason: "provider_process_lost",
replayAllowed: false,
requestType: "input",
request: {
schema: "paperclip.runtime_request.v2",
requestKind: "runtime",
requestId: "lost-input",
type: "input",
input: {
schema: "paperclip.question_set.v1",
questions: [expect.objectContaining({ id: "environment" })],
},
},
},
});
expect(events.filter((event) => event.eventType === "runtime_request.cancelled")).toHaveLength(0);
expect(validatePrpEvent(expired!)).toMatchObject({ ok: true });
});
it("redacts browser-visible request details and diagnostics from the fixture markers", async () => {
const fixture = await loadLiveConsoleConformanceFixture(
liveConsoleFixturePath,
@@ -29,6 +29,7 @@ import {
PAPERCLIP_QUESTION_SET_SCHEMA,
PAPERCLIP_RUNTIME_REQUEST_SCHEMA_V2,
parseHarnessRuntimeRequestResolution,
parsePaperclipQuestionSet,
} from "../../contracts/harness-driver.js";
import {
CODEX_BLOCK_RESULT_PROVIDER_INPUT_SCHEMA,
@@ -1344,11 +1345,19 @@ class CodexHarnessSession implements HarnessSession {
pending.request.input,
);
const response = runtimeRequestResponse(pending.request, resolution);
await this.#transport.resolveRuntimeRequest?.({
requestId: input.requestId,
turnId: input.turnId,
resolution,
});
this.#pendingRuntimeRequests.delete(input.requestId);
this.#emit(
"runtime_request.resolved",
harnessRuntimeRequestOutcome(pending.request, {
action: resolution.action,
...(resolution.action === "submit" && "response" in resolution
? { response: resolution.response }
: {}),
}),
{ turnId: input.turnId, itemId: pending.request.itemId },
);
@@ -1567,6 +1576,7 @@ class CodexHarnessSession implements HarnessSession {
code: "notification_transport_failed",
message: redactCodexDiagnostic(String(error)),
});
this.#expirePendingInputRequestsAfterProviderLoss();
this.#failProtocol(
"notification_transport_failed",
"Provider notification transport failed closed.",
@@ -2237,7 +2247,31 @@ class CodexHarnessSession implements HarnessSession {
);
return safeRequestResponse(request.method);
}
const input = codexQuestionSet(request.method, request.params);
let input: PaperclipQuestionSet | null = null;
try {
input = normalizeCodexQuestionSet(request.method, request.params);
} catch {
this.#emit("harness.diagnostic", {
code: "runtime_input_rejected",
adapter: "codex-app-server",
method: request.method,
reason: "The provider input request contained an invalid question form.",
}, { turnId: requestTurnId, itemId: String(request.id) });
return safeRequestResponse(request.method);
}
if (
(requestKind === "user_input" || requestKind === "elicitation") &&
input === null &&
hasCodexQuestionForm(request.method, request.params)
) {
this.#emit("harness.diagnostic", {
code: "runtime_input_rejected",
adapter: "codex-app-server",
method: request.method,
reason: "The provider input request did not contain a supported question form.",
}, { turnId: requestTurnId, itemId: String(request.id) });
return safeRequestResponse(request.method);
}
const runtimeRequest: HarnessRuntimeRequest = {
requestId,
requestKind,
@@ -2510,6 +2544,28 @@ class CodexHarnessSession implements HarnessSession {
this.#pendingRuntimeRequests.clear();
}
#expirePendingInputRequestsAfterProviderLoss(): void {
for (const [requestId, pending] of this.#pendingRuntimeRequests) {
if (pending.request.input === undefined) continue;
this.#emit(
"runtime_request.expired",
{
requestId: pending.request.requestId,
turnId: pending.request.turnId,
requestKind: pending.request.requestKind,
reason: "provider_process_lost",
replayAllowed: false,
adapter: pending.request.origin?.adapter,
requestType: "input",
request: runtimeRequestProtocolPayload(pending.request),
},
{ turnId: pending.request.turnId, itemId: pending.request.itemId },
);
pending.settle(safeRequestResponse(pending.request.method, "cancel"));
this.#pendingRuntimeRequests.delete(requestId);
}
}
#notificationNamesActiveTurn(turnId: string, kind: string): boolean {
if (
this.#protocolFailed ||
@@ -2773,6 +2829,22 @@ function runtimeRequestKind(method: string): HarnessRuntimeRequestKind | null {
return null;
}
/**
* During the v1 migration, input requests that predate structured form data
* remain opaque runtime requests. Once a provider supplies a native form,
* however, a malformed/unsupported form must fail closed instead of silently
* degrading back to the legacy textarea presentation.
*/
function hasCodexQuestionForm(method: string, params: Record<string, unknown>): boolean {
if (method === "item/tool/requestUserInput" || method === "tool/requestUserInput") {
return "questions" in params;
}
if (method === "mcpServer/elicitation/request") {
return "requestedSchema" in params || "schema" in params;
}
return false;
}
function runtimeRequestPrompt(
kind: HarnessRuntimeRequestKind,
params: Record<string, unknown>,
@@ -2828,7 +2900,7 @@ function jsonSchemaOptions(schema: Record<string, unknown>): NonNullable<Papercl
}
/** Codex-native requests are converted once, before they enter PRP. */
function codexQuestionSet(method: string, params: Record<string, unknown>): PaperclipQuestionSet | null {
export function normalizeCodexQuestionSet(method: string, params: Record<string, unknown>): PaperclipQuestionSet | null {
if (method === "item/tool/requestUserInput" || method === "tool/requestUserInput") {
if (!Array.isArray(params.questions) || params.questions.length === 0) return null;
const questions = params.questions.slice(0, 64).map((rawQuestion, index): PaperclipQuestion => {
@@ -2855,13 +2927,13 @@ function codexQuestionSet(method: string, params: Record<string, unknown>): Pape
: {}),
};
});
return {
return parsePaperclipQuestionSet({
schema: PAPERCLIP_QUESTION_SET_SCHEMA,
title: text(params.title, "Codex needs your input"),
...(text(params.description).length > 0 ? { description: boundedText(text(params.description)) } : {}),
submitLabel: text(params.submitLabel, "Submit answers"),
questions,
};
});
}
if (method !== "mcpServer/elicitation/request") return null;
const requestedSchema = record(params.requestedSchema ?? params.schema);
@@ -2900,13 +2972,13 @@ function codexQuestionSet(method: string, params: Record<string, unknown>): Pape
};
});
if (questions.length === 0) return null;
return {
return parsePaperclipQuestionSet({
schema: PAPERCLIP_QUESTION_SET_SCHEMA,
title: "A tool needs your input",
...(text(params.message).length > 0 ? { description: boundedText(text(params.message)) } : {}),
submitLabel: "Submit",
questions,
};
});
}
function runtimeRequestProtocolPayload(request: HarnessRuntimeRequest): Record<string, unknown> {
@@ -133,6 +133,28 @@ describe("OpenCode MCP bridge", () => {
expect(observed).toHaveBeenCalledOnce();
});
it("allows human-gated private calls to outlive ordinary tool timeouts", async () => {
const bridge = await startOpenCodeMcpBridge({
tools: [{ name: "documents.read", inputSchema: { type: "object" } }],
privateTools: [{ name: "__paperclip_runtime_input", inputSchema: { type: "object" } }],
timeoutMs: 20,
privateToolTimeoutMs: 200,
handler: async ({ tool }) => {
await new Promise((resolve) => setTimeout(resolve, 50));
return { tool, resolved: true };
},
});
bridges.push(bridge);
const response = await rpc(bridge, {
id: "human-input",
method: "tools/call",
params: { name: "__paperclip_runtime_input", arguments: {} },
});
expect(await response.json()).toMatchObject({
result: { content: [{ text: expect.stringContaining("\"resolved\":true") }] },
});
});
it("honors MCP cancellation notifications", async () => {
const started = vi.fn();
const bridge = await startOpenCodeMcpBridge({
@@ -28,6 +28,8 @@ export interface OpenCodeMcpBridgeOptions {
privateTools?: readonly Readonly<Record<string, unknown>>[];
handler: (call: OpenCodeMcpCall) => Promise<unknown>;
timeoutMs?: number;
/** Human-gated private operations may remain pending much longer than model tool calls. */
privateToolTimeoutMs?: number;
maxBodyBytes?: number;
secret?: string;
}
@@ -70,6 +72,8 @@ export async function startOpenCodeMcpBridge(
controllers,
handler: options.handler,
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
privateToolTimeoutMs: options.privateToolTimeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
privateToolNames: new Set(privateTools.map((tool) => tool.name)),
maxBodyBytes: options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES,
});
});
@@ -138,6 +142,8 @@ async function handleRequest(
controllers: Map<string, AbortController>;
handler: OpenCodeMcpBridgeOptions["handler"];
timeoutMs: number;
privateToolTimeoutMs: number;
privateToolNames: ReadonlySet<string>;
maxBodyBytes: number;
},
): Promise<void> {
@@ -236,7 +242,7 @@ async function handleRequest(
const execution = existing?.promise ?? withCancellationAndTimeout(
context.handler({ tool, callId, arguments: structuredClone(args), signal: controller!.signal }),
controller!,
context.timeoutMs,
context.privateToolNames.has(tool) ? context.privateToolTimeoutMs : context.timeoutMs,
);
if (!existing) {
if (context.calls.size >= 2_048) context.calls.delete(context.calls.keys().next().value!);
@@ -375,6 +375,8 @@ describe("OpenCodeServerDriver", () => {
},
},
});
await new Promise((resolveDelay) => setTimeout(resolveDelay, 20));
expect(session.pendingRuntimeRequests?.()).toHaveLength(1);
await session.resolveRuntimeRequest?.({
requestId: "question-native-1",
turnId,
@@ -383,7 +385,7 @@ describe("OpenCodeServerDriver", () => {
response: {
schema: "paperclip.question_response.v1",
answers: {
environment: { selectedOptionIds: ["option-1"] },
environment: { customText: "Canary" },
regions: { selectedOptionIds: ["option-1", "option-2"] },
},
},
@@ -392,11 +394,87 @@ describe("OpenCodeServerDriver", () => {
const reply = JSON.parse(await readFile(join(root, "native-question", "data", "fake-question-reply.json"), "utf8"));
expect(reply).toEqual({
url: expect.stringContaining(`directory=${encodeURIComponent(workspace)}`),
body: { answers: [["Staging"], ["US", "EU"]] },
body: { answers: [["Canary"], ["US", "EU"]] },
});
const terminalQuestionEvents = (await session.transcript?.())?.events.filter((event) =>
event.payload.requestId === "question-native-1"
&& ["runtime_request.resolved", "runtime_request.cancelled"].includes(event.eventType)
) ?? [];
expect(terminalQuestionEvents).toHaveLength(1);
expect(terminalQuestionEvents[0]?.payload).toMatchObject({
action: "submit",
response: {
schema: "paperclip.question_response.v1",
answers: {
environment: { customText: "Canary" },
regions: { selectedOptionIds: ["option-1", "option-2"] },
},
},
});
await session.close({ reason: "test" });
});
it("recovers a missed pending question from the list endpoint and rejects it", async () => {
await chmod(fixture, 0o755);
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-question-recovery-"));
const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-question-recovery-workspace-"));
roots.push(root, workspace);
const normalizedSessionId = "question-recovery";
const driver = new OpenCodeServerDriver({
model: "openrouter/deepseek/deepseek-v4-flash-0731",
runtimeDirectory: root,
command: fixture,
environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" },
});
const seed = await driver.openSession({
runId: "run-question-recovery",
normalizedSessionId,
workingDirectory: workspace,
});
const snapshot = await seed.snapshot();
await seed.close({ reason: "simulate reconnect" });
const dataRoot = join(root, normalizedSessionId, "data");
await mkdir(dataRoot, { recursive: true });
await writeFile(join(dataRoot, "fake-pending-question.json"), JSON.stringify({
id: "question-native-1",
sessionID: "ses_fake_1",
questions: [{
id: "environment",
header: "Environment",
question: "Where should we deploy?",
options: [{ label: "Staging" }, { label: "Production" }],
custom: true,
}],
}));
const recovered = await driver.recoverSession?.({
...snapshot,
activeTurnId: "turn-recovered-question",
});
expect(recovered).toMatchObject({ recovered: true });
const session = recovered!.session!;
const iterator = session.events()[Symbol.asyncIterator]();
let created: Awaited<ReturnType<typeof iterator.next>>["value"] | null = null;
for (let count = 0; count < 20; count += 1) {
const next = await iterator.next();
if (next.done) break;
if (next.value.eventType === "runtime_request.created") {
created = next.value;
break;
}
}
expect(created).toMatchObject({
payload: { request: { requestId: "question-native-1", type: "input" } },
});
await session.resolveRuntimeRequest?.({
requestId: "question-native-1",
turnId: "turn-recovered-question",
resolution: { action: "decline" },
});
expect(session.pendingRuntimeRequests?.()).toEqual([]);
await session.close({ reason: "recovery test complete" });
});
it("clears a stale active turn that already has a persisted terminal fingerprint", async () => {
await chmod(fixture, 0o755);
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-stale-turn-"));
@@ -33,6 +33,7 @@ import {
PAPERCLIP_RUNTIME_REQUEST_SCHEMA_V2,
harnessRuntimeRequestOutcome,
parseHarnessRuntimeRequestResolution,
parsePaperclipQuestionSet,
} from "../../contracts/harness-driver.js";
import type { NativeSessionCapabilities, NativeUserMessage } from "../../contracts/types.js";
import type { NativeRuntimeContextSnapshot } from "../../contracts/runtime-context.js";
@@ -281,6 +282,7 @@ class OpenCodeHarnessSession implements HarnessSession {
readonly #pendingRuntimeRequests = new Map<string, {
request: HarnessRuntimeRequest;
nativeQuestions: Record<string, unknown>[];
submittedResponse?: PaperclipQuestionResponse;
}>();
#activeTraceFrameId: number | null = null;
#activeTraceEmittedEventIds: string[] = [];
@@ -419,11 +421,9 @@ class OpenCodeHarnessSession implements HarnessSession {
// at this HTTP boundary.
providerID,
modelID,
// Paperclip owns durable human-input governance. OpenCode's built-in
// `question` tool waits inside the provider process and cannot produce
// a PRP interaction card or an authorized response wake, so exposing
// it alongside Paperclip's canonical interaction tools creates a
// competing, invisible control plane.
// This exact OpenCode version passed the native-question conformance
// suite. question.asked is adapted into PRP v2 and its reply/reject API
// remains private to this driver.
tools: { question: true },
...(this.#sendFullContext ? { system: this.#systemInstructions } : {}),
parts: [{ type: "text", text: prompt }],
@@ -460,6 +460,10 @@ class OpenCodeHarnessSession implements HarnessSession {
const base = `/question/${encodeURIComponent(input.requestId)}`;
const workspace = `directory=${encodeURIComponent(this.#workingDirectory)}`;
if (resolution.action === "submit" && "response" in resolution) {
// OpenCode can broadcast question.replied before the reply HTTP request
// returns. Retain the canonical response before crossing that boundary
// so the racing terminal event carries the same durable answer record.
pending.submittedResponse = structuredClone(resolution.response);
await api(this.#fetch, this.#runtime, `${base}/reply?${workspace}`, {
method: "POST",
body: JSON.stringify({ answers: openCodeAnswers(pending, resolution.response) }),
@@ -468,15 +472,20 @@ class OpenCodeHarnessSession implements HarnessSession {
await api(this.#fetch, this.#runtime, `${base}/reply?${workspace}`, {
method: "POST",
body: JSON.stringify({ answers: pending.nativeQuestions.map((question, index) =>
resolution.answers[text(question.id, `question-${index + 1}`)]?.answers ?? [],
resolution.answers[openCodeQuestionId(question, index)]?.answers ?? [],
) }),
});
} else {
await api(this.#fetch, this.#runtime, `${base}/reject?${workspace}`, { method: "POST" });
}
this.#pendingRuntimeRequests.delete(input.requestId);
// question.replied/question.rejected can race the HTTP response on the SSE
// stream. The first terminal fact wins; the echo must not emit a duplicate.
if (!this.#pendingRuntimeRequests.delete(input.requestId)) return;
this.#emit("runtime_request.resolved", harnessRuntimeRequestOutcome(pending.request, {
action: resolution.action,
...(resolution.action === "submit" && "response" in resolution
? { response: resolution.response }
: {}),
}), { turnId: input.turnId, itemId: pending.request.itemId });
}
@@ -610,8 +619,26 @@ class OpenCodeHarnessSession implements HarnessSession {
if (!turnId) return;
const requestId = text(properties.id, text(properties.requestID, text(properties.requestId)));
const nativeQuestions = Array.isArray(properties.questions) ? properties.questions.map(record).slice(0, 64) : [];
if (!requestId || nativeQuestions.length === 0 || this.#pendingRuntimeRequests.has(requestId)) return;
const input = openCodeQuestionSet(nativeQuestions);
if (!requestId || nativeQuestions.length === 0) {
this.#emit("harness.diagnostic", {
code: "runtime_input_rejected",
adapter: "opencode-server",
reason: "OpenCode emitted a question event without a request id or supported questions.",
}, { turnId, ...(requestId ? { itemId: requestId } : {}) });
return;
}
if (this.#pendingRuntimeRequests.has(requestId)) return;
let input: PaperclipQuestionSet;
try {
input = normalizeOpenCodeQuestionSet(nativeQuestions, properties);
} catch {
this.#emit("harness.diagnostic", {
code: "runtime_input_rejected",
adapter: "opencode-server",
reason: "OpenCode emitted a malformed or ambiguous question set.",
}, { turnId, itemId: requestId });
return;
}
const request: HarnessRuntimeRequest = {
requestId,
requestKind: "user_input",
@@ -776,6 +803,23 @@ class OpenCodeHarnessSession implements HarnessSession {
this.#acceptQuestion(properties);
return;
}
if ((type === "question.replied" || type === "question.rejected") && turnId) {
const requestId = text(properties.id, text(properties.requestID, text(properties.requestId)));
const pending = this.#pendingRuntimeRequests.get(requestId);
if (!pending) return;
this.#pendingRuntimeRequests.delete(requestId);
this.#emit(
type === "question.replied" ? "runtime_request.resolved" : "runtime_request.cancelled",
harnessRuntimeRequestOutcome(
pending.request,
type === "question.replied"
? { action: "submit", response: pending.submittedResponse }
: { reason: "provider_rejected" },
),
{ turnId, itemId: pending.request.itemId },
);
return;
}
if (type === "message.part.updated" && turnId) {
const part = record(properties.part);
const messageId = text(part.messageID, text(part.messageId));
@@ -1161,8 +1205,11 @@ async function startRuntime(input: {
const version = text(record(health).version);
if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error("OpenCode health response omitted a semantic version");
const qualifiedComparison = compareVersion(version, QUALIFIED_OPENCODE_VERSION);
if (qualifiedComparison < 0) throw new Error(`OpenCode ${version} is older than required ${QUALIFIED_OPENCODE_VERSION}`);
if (qualifiedComparison > 0) input.options.onDiagnostic?.(`OpenCode ${version} is newer than qualified ${QUALIFIED_OPENCODE_VERSION}.`);
if (qualifiedComparison !== 0) {
throw new Error(
`OpenCode ${version} is not the question-conformance-qualified ${QUALIFIED_OPENCODE_VERSION}`,
);
}
return {
baseUrl,
authHeader,
@@ -1200,7 +1247,10 @@ async function startRuntime(input: {
}
}
function openCodeQuestionSet(nativeQuestions: Record<string, unknown>[]): PaperclipQuestionSet {
export function normalizeOpenCodeQuestionSet(
nativeQuestions: Record<string, unknown>[],
metadata: Record<string, unknown> = {},
): PaperclipQuestionSet {
const questions = nativeQuestions.map((question, index): PaperclipQuestion => {
const options = (Array.isArray(question.options) ? question.options : []).map(record).slice(0, 128).map((option, optionIndex) => ({
id: text(option.id, `option-${optionIndex + 1}`).slice(0, 160),
@@ -1208,7 +1258,7 @@ function openCodeQuestionSet(nativeQuestions: Record<string, unknown>[]): Paperc
...(text(option.description) ? { description: text(option.description).slice(0, 4_000) } : {}),
}));
return {
id: text(question.id, `question-${index + 1}`).slice(0, 160),
id: openCodeQuestionId(question, index),
...(text(question.header) ? { header: text(question.header).slice(0, 1_000) } : {}),
prompt: text(question.question, text(question.prompt, `Question ${index + 1}`)).slice(0, 4_000),
...(text(question.description) ? { helpText: text(question.description).slice(0, 4_000) } : {}),
@@ -1220,12 +1270,13 @@ function openCodeQuestionSet(nativeQuestions: Record<string, unknown>[]): Paperc
: {}),
};
});
return {
return parsePaperclipQuestionSet({
schema: PAPERCLIP_QUESTION_SET_SCHEMA,
title: "OpenCode needs your input",
submitLabel: "Submit answers",
title: text(metadata.title, "OpenCode needs your input").slice(0, 1_000),
...(text(metadata.description) ? { description: text(metadata.description).slice(0, 4_000) } : {}),
submitLabel: text(metadata.submitLabel, "Submit answers").slice(0, 200),
questions,
};
});
}
function openCodeAnswers(
@@ -1233,7 +1284,7 @@ function openCodeAnswers(
response: PaperclipQuestionResponse,
): string[][] {
return pending.nativeQuestions.map((nativeQuestion, index) => {
const questionId = text(nativeQuestion.id, `question-${index + 1}`);
const questionId = openCodeQuestionId(nativeQuestion, index);
const question = pending.request.input?.questions.find((candidate) => candidate.id === questionId);
const answer = response.answers[questionId];
if (!question || !answer) return [];
@@ -1246,6 +1297,16 @@ function openCodeAnswers(
});
}
function openCodeQuestionId(question: Record<string, unknown>, index: number): string {
const nativeId = text(question.id).trim();
if (nativeId) return nativeId.slice(0, 160);
const header = text(question.header).trim().toLowerCase()
.replace(/[^a-z0-9._:-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 160);
return header || `question-${index + 1}`;
}
async function api(fetcher: typeof globalThis.fetch, runtime: OpenCodeRuntime, path: string, init: RequestInit = {}): Promise<unknown> {
const timeout = AbortSignal.timeout(20_000);
const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
@@ -231,6 +231,141 @@ it("runs the lab provider boundary through authenticated durable PRP", async ()
});
}, 30_000);
it("bridges a runnerd-native question into the server request handler and resolves it canonically", async () => {
const bundle = createCapabilityRunnerdCodexTransport({
runnerBinary: defaultCapabilityRunnerdBinary(),
codexCommand: fakeCodex,
codexArgs: ["--runtime-question"],
});
let bridgedRequest: { method: string; params: Record<string, unknown> } | null = null;
bundle.transport.setServerRequestHandler(async (request) => {
if (request.method !== "item/tool/requestUserInput") {
return { success: true, contentItems: [] };
}
bridgedRequest = { method: request.method, params: request.params };
await bundle.transport.resolveRuntimeRequest?.({
requestId: String(request.id),
turnId: String(request.params.turnId),
resolution: {
action: "submit",
response: {
schema: "paperclip.question_response.v1",
answers: {
environment: { selectedOptionIds: ["option-1"] },
regions: { selectedOptionIds: ["option-1", "option-2"] },
notes: { text: "Ship during the maintenance window." },
},
},
},
});
return { answers: {} };
});
try {
await bundle.transport.request("initialize", {});
await bundle.transport.request("thread/start", {
cwd: tmpdir(),
dynamicTools: [
{
name: "get_task_context",
description: "Read the active task.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
},
],
});
await bundle.transport.request("turn/start", {
input: [{ type: "text", text: "Ask the deployment questions." }],
});
const methods: string[] = [];
for await (const notification of bundle.transport.notifications()) {
methods.push(notification.method);
if (notification.method === "turn/completed") break;
}
expect(bridgedRequest).toMatchObject({
method: "item/tool/requestUserInput",
params: {
threadId: "fake-thread",
questions: [
expect.objectContaining({ id: "environment", isOther: true }),
expect.objectContaining({ id: "regions", multiSelect: true }),
expect.objectContaining({ id: "notes", required: false }),
],
},
});
expect(methods).toContain("turn/completed");
} finally {
await bundle.transport.close();
}
}, 30_000);
it("bridges a runnerd-native form elicitation and preserves typed provider content", async () => {
const bundle = createCapabilityRunnerdCodexTransport({
runnerBinary: defaultCapabilityRunnerdBinary(),
codexCommand: fakeCodex,
codexArgs: ["--runtime-elicitation"],
});
let bridgedRequest: { method: string; params: Record<string, unknown> } | null = null;
bundle.transport.setServerRequestHandler(async (request) => {
if (request.method !== "mcpServer/elicitation/request") {
return { success: true, contentItems: [] };
}
bridgedRequest = { method: request.method, params: request.params };
await bundle.transport.resolveRuntimeRequest?.({
requestId: String(request.id),
turnId: String(request.params.turnId),
resolution: {
action: "submit",
response: {
schema: "paperclip.question_response.v1",
answers: {
environment: { selectedOptionIds: ["option-1"] },
regions: { selectedOptionIds: ["option-1", "option-2"] },
replicas: { text: "3" },
approved: { selectedOptionIds: ["true"] },
},
},
},
});
return { action: "accept", content: {}, _meta: null };
});
try {
await bundle.transport.request("initialize", {});
await bundle.transport.request("thread/start", {
cwd: tmpdir(),
dynamicTools: [{
name: "get_task_context",
description: "Read the active task.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
}],
});
await bundle.transport.request("turn/start", {
input: [{ type: "text", text: "Request typed deployment settings." }],
});
const methods: string[] = [];
for await (const notification of bundle.transport.notifications()) {
methods.push(notification.method);
if (notification.method === "turn/completed") break;
}
expect(bridgedRequest).toMatchObject({
method: "mcpServer/elicitation/request",
params: {
message: "Choose typed deployment settings.",
requestedSchema: {
required: ["approved", "environment", "regions", "replicas"],
properties: {
environment: expect.objectContaining({ type: "string" }),
regions: expect.objectContaining({ type: "array" }),
replicas: expect.objectContaining({ type: "integer", minimum: 1, maximum: 10 }),
approved: expect.objectContaining({ type: "boolean" }),
},
},
},
});
expect(methods).toContain("turn/completed");
} finally {
await bundle.transport.close();
}
}, 30_000);
it("captures exact provider frames and correlates Rust and TypeScript interpretation stages", async () => {
const traceDirectory = await mkdtemp(
join(tmpdir(), "runnerd-provider-trace-"),
@@ -26,6 +26,7 @@ import {
createIsolatedCodexAppServerArgs,
} from "../drivers/codex/codex-app-server-driver.js";
import type { DurableRecoveryIdentity } from "../contracts/durable-recovery.js";
import type { HarnessRuntimeRequestResolution } from "../contracts/harness-driver.js";
import {
DurablePrpControlPlane,
durableRecoveryInternals,
@@ -90,6 +91,120 @@ function recoveredControlPlaneIdentity(
return structuredClone(identity);
}
function bridgedCodexQuestionParams(
request: Record<string, unknown>,
method: string,
threadId: string,
turnId: string,
): Record<string, unknown> | null {
const questionSet = record(request.input);
if (
questionSet.schema !== "paperclip.question_set.v1" ||
!Array.isArray(questionSet.questions) ||
questionSet.questions.length === 0
) return null;
const common = {
threadId,
turnId,
itemId: typeof request.itemId === "string" ? request.itemId : String(request.requestId ?? "runtime-input"),
};
if (method === "mcpServer/elicitation/request") {
const required: string[] = [];
const properties = Object.fromEntries(questionSet.questions.map((candidate, index) => {
const question = record(candidate);
const id = typeof question.id === "string" ? question.id : `question-${index + 1}`;
if (question.required !== false) required.push(id);
const validation = record(question.textValidation);
const options = Array.isArray(question.options)
? question.options.map((candidateOption) => {
const option = record(candidateOption);
return {
const: typeof option.id === "string" ? option.id : "option",
title: typeof option.label === "string" ? option.label : "Option",
...(typeof option.description === "string" ? { description: option.description } : {}),
};
})
: [];
const isBoolean = question.answerMode === "single_select"
&& options.length === 2
&& options[0]?.const === "true"
&& options[1]?.const === "false";
const inputType = validation.inputType === "integer" || validation.inputType === "number"
? validation.inputType
: "string";
const scalarSchema = isBoolean
? { type: "boolean" }
: options.length > 0
? { type: "string", oneOf: options }
: {
type: inputType,
...(typeof validation.minLength === "number" ? { minLength: validation.minLength } : {}),
...(typeof validation.maxLength === "number" ? { maxLength: validation.maxLength } : {}),
...(typeof validation.minimum === "number" ? { minimum: validation.minimum } : {}),
...(typeof validation.maximum === "number" ? { maximum: validation.maximum } : {}),
...(typeof validation.pattern === "string" ? { pattern: validation.pattern } : {}),
};
return [id, {
...(question.answerMode === "multi_select"
? { type: "array", items: scalarSchema }
: scalarSchema),
...(typeof question.header === "string"
? { title: question.header }
: typeof question.prompt === "string"
? { title: question.prompt }
: {}),
...(typeof question.helpText === "string" ? { description: question.helpText } : {}),
}];
}));
return {
...common,
message: typeof questionSet.description === "string"
? questionSet.description
: typeof questionSet.title === "string"
? questionSet.title
: "A tool needs your input",
requestedSchema: {
type: "object",
properties,
...(required.length > 0 ? { required } : {}),
},
};
}
return {
...common,
...(typeof questionSet.title === "string" ? { title: questionSet.title } : {}),
...(typeof questionSet.description === "string" ? { description: questionSet.description } : {}),
...(typeof questionSet.submitLabel === "string" ? { submitLabel: questionSet.submitLabel } : {}),
questions: questionSet.questions.map((candidate, index) => {
const question = record(candidate);
const validation = record(question.textValidation);
return {
id: typeof question.id === "string" ? question.id : `question-${index + 1}`,
...(typeof question.header === "string" ? { header: question.header } : {}),
question: typeof question.prompt === "string" ? question.prompt : `Question ${index + 1}`,
...(typeof question.helpText === "string" ? { description: question.helpText } : {}),
required: question.required !== false,
...(question.answerMode === "multi_select" ? { multiSelect: true } : {}),
...(Array.isArray(question.options)
? {
options: question.options.map((candidateOption, optionIndex) => {
const option = record(candidateOption);
return {
id: typeof option.id === "string" ? option.id : `option-${optionIndex + 1}`,
label: typeof option.label === "string" ? option.label : `Option ${optionIndex + 1}`,
...(typeof option.description === "string" ? { description: option.description } : {}),
};
}),
}
: {}),
...(record(question.customAnswer).enabled === true ? { isOther: true } : {}),
...(typeof validation.minLength === "number" ? { minLength: validation.minLength } : {}),
...(typeof validation.maxLength === "number" ? { maxLength: validation.maxLength } : {}),
};
}),
};
}
export interface CapabilityRunnerdProcessEvidence {
runnerPid: number | null;
runnerProcessGroupId: number | null;
@@ -665,6 +780,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
#traceRehydrationSpoolOverflow = false;
#pendingTraceRehydrations: PendingTraceRehydration[] = [];
#pendingDriverTraceInterpretations: PendingDriverTraceInterpretation[] = [];
readonly #bridgedRuntimeInputs = new Map<string, { durableTurnId: string }>();
constructor(readonly options: CapabilityRunnerdCodexTransportOptions) {
this.#ownsRoot = options.stateDirectory === undefined;
@@ -819,6 +935,28 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
this.#handler = handler;
}
async resolveRuntimeRequest(input: {
requestId: string;
turnId: string;
resolution: HarnessRuntimeRequestResolution;
}): Promise<void> {
const pending = this.#bridgedRuntimeInputs.get(input.requestId);
if (!pending) throw new Error(`PRP runtime request ${input.requestId} is no longer pending`);
const commandId = `command_runtime_input_${createHash("sha256")
.update(`${input.requestId}:${pending.durableTurnId}`)
.digest("hex")
.slice(0, 24)}`;
await this.#command(
"request.resolve",
{
requestId: input.requestId,
turnId: pending.durableTurnId,
resolution: input.resolution,
},
commandId,
);
}
recordTraceInterpretation(input: CodexTraceInterpretation): void {
const tracePath = this.options.environment?.PAPERCLIP_PROVIDER_TRACE_PATH;
if (!tracePath) return;
@@ -1592,6 +1730,51 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
this.#publish();
}
}
if (event.eventType === "runtime_request.created") {
const request = record(record(event.envelope.payload).payload).request;
const normalizedRequest = record(request);
const requestId = typeof normalizedRequest.requestId === "string"
? normalizedRequest.requestId
: "";
const origin = record(normalizedRequest.origin);
const method = typeof origin.method === "string" ? origin.method : "";
const params = bridgedCodexQuestionParams(normalizedRequest, method, this.#threadId, this.#turnId);
if (
requestId &&
params &&
(method === "item/tool/requestUserInput" ||
method === "tool/requestUserInput" ||
method === "mcpServer/elicitation/request") &&
!this.#bridgedRuntimeInputs.has(requestId)
) {
this.#bridgedRuntimeInputs.set(requestId, {
durableTurnId: typeof event.envelope.turnId === "string"
? event.envelope.turnId
: this.#durableTurnId,
});
void this.#handler({
id: requestId,
method,
params,
paperclipTrace: {
sourceEventId: event.sourceEventId,
sourceEventType: event.eventType,
},
}).catch((error) => {
this.#failTransport(error instanceof Error ? error : new Error(String(error)));
});
}
continue;
}
if (
event.eventType === "runtime_request.resolved" ||
event.eventType === "runtime_request.cancelled" ||
event.eventType === "runtime_request.expired"
) {
const requestId = record(record(event.envelope.payload).payload).requestId;
if (typeof requestId === "string") this.#bridgedRuntimeInputs.delete(requestId);
continue;
}
const eventPayload = record(event.envelope.payload).payload;
const sessionUpdatePayload = record(eventPayload);
const canonicalMethod = (
@@ -2465,6 +2465,59 @@ export const questionSetSchema = {
"$ref": "#/$defs/textValidation"
}
},
"allOf": [
{
"if": {
"properties": {
"answerMode": {
"const": "text"
}
},
"required": [
"answerMode"
]
},
"then": {
"properties": {
"options": {
"type": "array",
"maxItems": 0
}
},
"not": {
"required": [
"customAnswer"
]
}
}
},
{
"if": {
"properties": {
"answerMode": {
"enum": [
"single_select",
"multi_select"
]
}
},
"required": [
"answerMode"
]
},
"then": {
"required": [
"options"
],
"properties": {
"options": {
"type": "array",
"minItems": 1
}
}
}
}
],
"additionalProperties": false
}
},
@@ -4,4 +4,4 @@
import equalModule from "ajv/dist/runtime/equal.js";
import ucs2LengthModule from "ajv/dist/runtime/ucs2length.js";
"use strict";export const fixtureValidator = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/fixture.schema.json","title":"PRP scripted replay fixture","type":"object","required":["schema","fixtureVersion","protocolVersion","name","description","identity","capabilities","commands","events","result"],"properties":{"schema":{"const":"paperclip.prp.fixture.v1"},"fixtureVersion":{"const":1},"protocolVersion":{"const":1},"name":{"type":"string","minLength":1,"maxLength":120},"description":{"type":"string","minLength":1,"maxLength":1000},"identity":{"$ref":"https://paperclip.dev/schemas/prp/v1/identity.schema.json"},"capabilities":{"$ref":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json"},"commands":{"type":"array","items":{"$ref":"https://paperclip.dev/schemas/prp/v1/command.schema.json"}},"events":{"type":"array","minItems":1,"items":{"$ref":"https://paperclip.dev/schemas/prp/v1/event.schema.json"}},"requests":{"type":"array","items":{"$ref":"https://paperclip.dev/schemas/prp/v1/request.schema.json"}},"result":{"$ref":"https://paperclip.dev/schemas/prp/v1/result.schema.json"}},"additionalProperties":true};const schema41 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json","title":"PRP negotiated capabilities","type":"object","required":["schema","sessionReusePolicy","driver","steer","interrupt","resume","runtimeRequests","structuredResult","typedEvents"],"properties":{"schema":{"const":"paperclip.prp.capabilities.v1"},"sessionReusePolicy":{"enum":["new_per_run","reuse_per_issue","reuse_per_workspace"]},"driver":{"type":"object","required":["kind","version"],"properties":{"kind":{"type":"string","minLength":1,"maxLength":80},"version":{"type":"string","minLength":1,"maxLength":80}},"additionalProperties":true},"steer":{"type":"boolean"},"interrupt":{"type":"boolean"},"resume":{"type":"boolean"},"runtimeRequests":{"type":"boolean"},"structuredResult":{"type":"boolean"},"typedEvents":{"type":"boolean"},"typedEventFamilies":{"type":"array","maxItems":64,"items":{"type":"object","required":["family","version","availability","detailLevel"],"properties":{"family":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.-]*$"},"version":{"const":1},"availability":{"enum":["available","unsupported","policy_disabled"]},"detailLevel":{"enum":["summary","structured"]}},"additionalProperties":false}},"semanticTools":{"type":"object","required":["schema","schemaVersion","operations"],"properties":{"schema":{"const":"paperclip.prp.semantic_tools.v1"},"schemaVersion":{"const":1},"operations":{"type":"array","items":{"type":"object","required":["operationId","version","availability","redactionDisposition"],"properties":{"operationId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"version":{"const":1},"availability":{"enum":["available","denied","unsupported"]},"redactionDisposition":{"const":"digest_only"},"requiredClaims":{"type":"array","items":{"type":"string","minLength":1,"maxLength":160},"uniqueItems":true},"reasonCode":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.:-]*$"}},"additionalProperties":true}}},"additionalProperties":true},"unsupported":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true}},"additionalProperties":true};const schema42 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/command.schema.json","title":"PRP runner command","type":"object","required":["schema","commandId","controllerSeq","type","issuedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.command.v1"},"commandId":{"type":"string","minLength":1,"maxLength":160},"controllerSeq":{"type":"integer","minimum":1},"type":{"enum":["run.prepare","run.attach","session.open","turn.start","turn.steer","turn.interrupt","turn.stop","request.resolve","interaction.receipt","semantic_tool.result","session.snapshot","session.close","session.budget.increase","session.destroy","run.cancel","runner.drain","runner.suspend","runner.shutdown"]},"issuedAt":{"type":"string","format":"date-time"},"deadlineAt":{"type":"string","format":"date-time"},"precondition":{"type":"object","properties":{"runnerState":{"type":"array","items":{"type":"string"}},"runState":{"type":"array","items":{"type":"string"}},"sessionState":{"type":"array","items":{"type":"string"}},"activeTurnId":{"type":["string","null"]}},"additionalProperties":true},"payload":{"type":"object","additionalProperties":true}},"additionalProperties":true};const schema195 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/result.schema.json","title":"PRP structured run result","type":"object","required":["schema","reportedWorkDisposition","summary","completionClaim","evidence","verification","attentionRequests","artifaLine truncated
"use strict";export const fixtureValidator = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/fixture.schema.json","title":"PRP scripted replay fixture","type":"object","required":["schema","fixtureVersion","protocolVersion","name","description","identity","capabilities","commands","events","result"],"properties":{"schema":{"const":"paperclip.prp.fixture.v1"},"fixtureVersion":{"const":1},"protocolVersion":{"const":1},"name":{"type":"string","minLength":1,"maxLength":120},"description":{"type":"string","minLength":1,"maxLength":1000},"identity":{"$ref":"https://paperclip.dev/schemas/prp/v1/identity.schema.json"},"capabilities":{"$ref":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json"},"commands":{"type":"array","items":{"$ref":"https://paperclip.dev/schemas/prp/v1/command.schema.json"}},"events":{"type":"array","minItems":1,"items":{"$ref":"https://paperclip.dev/schemas/prp/v1/event.schema.json"}},"requests":{"type":"array","items":{"$ref":"https://paperclip.dev/schemas/prp/v1/request.schema.json"}},"result":{"$ref":"https://paperclip.dev/schemas/prp/v1/result.schema.json"}},"additionalProperties":true};const schema41 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json","title":"PRP negotiated capabilities","type":"object","required":["schema","sessionReusePolicy","driver","steer","interrupt","resume","runtimeRequests","structuredResult","typedEvents"],"properties":{"schema":{"const":"paperclip.prp.capabilities.v1"},"sessionReusePolicy":{"enum":["new_per_run","reuse_per_issue","reuse_per_workspace"]},"driver":{"type":"object","required":["kind","version"],"properties":{"kind":{"type":"string","minLength":1,"maxLength":80},"version":{"type":"string","minLength":1,"maxLength":80}},"additionalProperties":true},"steer":{"type":"boolean"},"interrupt":{"type":"boolean"},"resume":{"type":"boolean"},"runtimeRequests":{"type":"boolean"},"structuredResult":{"type":"boolean"},"typedEvents":{"type":"boolean"},"typedEventFamilies":{"type":"array","maxItems":64,"items":{"type":"object","required":["family","version","availability","detailLevel"],"properties":{"family":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.-]*$"},"version":{"const":1},"availability":{"enum":["available","unsupported","policy_disabled"]},"detailLevel":{"enum":["summary","structured"]}},"additionalProperties":false}},"semanticTools":{"type":"object","required":["schema","schemaVersion","operations"],"properties":{"schema":{"const":"paperclip.prp.semantic_tools.v1"},"schemaVersion":{"const":1},"operations":{"type":"array","items":{"type":"object","required":["operationId","version","availability","redactionDisposition"],"properties":{"operationId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"version":{"const":1},"availability":{"enum":["available","denied","unsupported"]},"redactionDisposition":{"const":"digest_only"},"requiredClaims":{"type":"array","items":{"type":"string","minLength":1,"maxLength":160},"uniqueItems":true},"reasonCode":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.:-]*$"}},"additionalProperties":true}}},"additionalProperties":true},"unsupported":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true}},"additionalProperties":true};const schema42 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/command.schema.json","title":"PRP runner command","type":"object","required":["schema","commandId","controllerSeq","type","issuedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.command.v1"},"commandId":{"type":"string","minLength":1,"maxLength":160},"controllerSeq":{"type":"integer","minimum":1},"type":{"enum":["run.prepare","run.attach","session.open","turn.start","turn.steer","turn.interrupt","turn.stop","request.resolve","interaction.receipt","semantic_tool.result","session.snapshot","session.close","session.budget.increase","session.destroy","run.cancel","runner.drain","runner.suspend","runner.shutdown"]},"issuedAt":{"type":"string","format":"date-time"},"deadlineAt":{"type":"string","format":"date-time"},"precondition":{"type":"object","properties":{"runnerState":{"type":"array","items":{"type":"string"}},"runState":{"type":"array","items":{"type":"string"}},"sessionState":{"type":"array","items":{"type":"string"}},"activeTurnId":{"type":["string","null"]}},"additionalProperties":true},"payload":{"type":"object","additionalProperties":true}},"additionalProperties":true};const schema195 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/result.schema.json","title":"PRP structured run result","type":"object","required":["schema","reportedWorkDisposition","summary","completionClaim","evidence","verification","attentionRequests","artifaLine truncated
@@ -16,7 +16,36 @@ const runtimeConfig = JSON.parse(await readFile(join(process.env.XDG_CONFIG_HOME
const mcp = runtimeConfig.mcp?.paperclip;
let mcpRequestId = 1;
const mcpEvidence = { tools: [], calls: [] };
let pendingQuestion = null;
function nativeQuestion() {
return {
id: "question-native-1",
sessionID: session.id,
questions: [
{
id: "environment",
header: "Environment",
question: "Where should we deploy?",
options: [
{ label: "Staging", description: "Deploy to staging first." },
{ label: "Production", description: "Deploy directly to production." }
],
custom: true
},
{
id: "regions",
header: "Regions",
question: "Which regions?",
options: [{ label: "US" }, { label: "EU" }],
multiple: true
}
]
};
}
let pendingQuestion = await readFile(join(process.env.XDG_DATA_HOME, "fake-pending-question.json"), "utf8")
.then((value) => JSON.parse(value))
.catch(() => null);
await mkdir(process.env.XDG_DATA_HOME, { recursive: true });
await writeFile(join(process.env.XDG_DATA_HOME, "fake-environment.json"), JSON.stringify({
@@ -119,16 +148,24 @@ const server = createServer(async (request, response) => {
request.on("end", async () => {
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
await writeFile(join(process.env.XDG_DATA_HOME, "fake-question-reply.json"), JSON.stringify({ url: request.url, body }));
const repliedQuestion = pendingQuestion;
pendingQuestion = null;
json(response, 200, true);
setTimeout(() => emit({ type: "session.idle", id: "event-question-idle", properties: { sessionID: session.id } }), 10);
emit({ type: "question.replied", id: "event-question-replied", properties: repliedQuestion });
setTimeout(() => {
json(response, 200, true);
emit({ type: "session.idle", id: "event-question-idle", properties: { sessionID: session.id } });
}, 10);
});
return;
}
if (request.method === "POST" && request.url?.startsWith("/question/question-native-1/reject?")) {
const rejectedQuestion = pendingQuestion;
pendingQuestion = null;
json(response, 200, true);
setTimeout(() => emit({ type: "session.idle", id: "event-question-rejected-idle", properties: { sessionID: session.id } }), 10);
emit({ type: "question.rejected", id: "event-question-rejected", properties: rejectedQuestion });
setTimeout(() => {
json(response, 200, true);
emit({ type: "session.idle", id: "event-question-rejected-idle", properties: { sessionID: session.id } });
}, 10);
return;
}
if (request.method === "POST" && request.url === `/session/${session.id}/abort`) return json(response, 200, true);
@@ -145,29 +182,8 @@ const server = createServer(async (request, response) => {
await callFirstPaperclipTool();
const parsedPrompt = JSON.parse(promptPayload.parts?.[0]?.text ?? "{}");
if (String(parsedPrompt.message ?? "").includes("native-question")) {
pendingQuestion = {
id: "question-native-1",
sessionID: session.id,
questions: [
{
id: "environment",
header: "Environment",
question: "Where should we deploy?",
options: [
{ label: "Staging", description: "Deploy to staging first." },
{ label: "Production", description: "Deploy directly to production." }
],
custom: true
},
{
id: "regions",
header: "Regions",
question: "Which regions?",
options: [{ label: "US" }, { label: "EU" }],
multiple: true
}
]
};
pendingQuestion = nativeQuestion();
emit({ type: "question.asked", id: "event-question-native-1", properties: pendingQuestion });
emit({ type: "question.asked", id: "event-question-native-1", properties: pendingQuestion });
return;
}
+3
View File
@@ -1079,6 +1079,9 @@ export type {
SuggestTasksResult,
AskUserQuestionsQuestionOption,
AskUserQuestionsQuestion,
PaperclipQuestionSetOption,
PaperclipQuestionSetQuestion,
PaperclipQuestionSetPayload,
AskUserQuestionsPayload,
AskUserQuestionsAnswer,
AskUserQuestionsResult,
+3
View File
@@ -703,6 +703,9 @@ export type {
SuggestTasksResult,
AskUserQuestionsQuestionOption,
AskUserQuestionsQuestion,
PaperclipQuestionSetOption,
PaperclipQuestionSetQuestion,
PaperclipQuestionSetPayload,
AskUserQuestionsPayload,
AskUserQuestionsAnswer,
AskUserQuestionsResult,
+45
View File
@@ -1139,12 +1139,57 @@ export interface AskUserQuestionsQuestion {
options: AskUserQuestionsQuestionOption[];
}
/**
* Provider-neutral presentation retained when a live harness question has to
* fall back to the durable issue interaction lifecycle. This intentionally
* mirrors `paperclip.question_set.v1` without making the shared package depend
* on a particular runner implementation.
*/
export interface PaperclipQuestionSetOption {
id: string;
label: string;
description?: string;
}
export interface PaperclipQuestionSetQuestion {
id: string;
header?: string;
prompt: string;
helpText?: string;
required: boolean;
answerMode: "single_select" | "multi_select" | "text";
options?: PaperclipQuestionSetOption[];
customAnswer?: {
enabled: true;
label?: string;
placeholder?: string;
};
textValidation?: {
minLength?: number;
maxLength?: number;
pattern?: string;
inputType?: "text" | "number" | "integer";
minimum?: number;
maximum?: number;
};
}
export interface PaperclipQuestionSetPayload {
schema: "paperclip.question_set.v1";
title?: string;
description?: string;
submitLabel?: string;
questions: PaperclipQuestionSetQuestion[];
}
export interface AskUserQuestionsPayload {
version: 1;
title?: string | null;
submitLabel?: string | null;
supersedeOnUserComment?: boolean;
questions: AskUserQuestionsQuestion[];
/** Exact presentation for a recovered harness request. */
questionSet?: PaperclipQuestionSetPayload;
}
export interface AskUserQuestionsAnswer {
+50 -11
View File
@@ -826,9 +826,9 @@ export const suggestTasksResultSchema = z.object({
});
export const askUserQuestionsQuestionOptionSchema = z.object({
id: z.string().trim().min(1).max(120),
label: z.string().trim().min(1).max(120),
description: z.string().trim().max(500).nullable().optional(),
id: z.string().trim().min(1).max(160),
label: z.string().trim().min(1).max(1000),
description: z.string().trim().max(4000).nullable().optional(),
freeText: z
.boolean()
.optional()
@@ -838,12 +838,49 @@ export const askUserQuestionsQuestionOptionSchema = z.object({
});
export const askUserQuestionsQuestionSchema = z.object({
id: z.string().trim().min(1).max(120),
prompt: z.string().trim().min(1).max(500),
helpText: z.string().trim().max(1000).nullable().optional(),
id: z.string().trim().min(1).max(160),
prompt: z.string().trim().min(1).max(4000),
helpText: z.string().trim().max(4000).nullable().optional(),
selectionMode: z.enum(["single", "multi"]),
required: z.boolean().optional(),
options: z.array(askUserQuestionsQuestionOptionSchema).min(1).max(10),
options: z.array(askUserQuestionsQuestionOptionSchema).min(1).max(128),
});
const paperclipQuestionOptionSchema = z.object({
id: z.string().min(1).max(160),
label: z.string().min(1).max(1000),
description: z.string().max(4000).optional(),
});
const paperclipQuestionSchema = z.object({
id: z.string().min(1).max(160),
header: z.string().max(1000).optional(),
prompt: z.string().min(1).max(4000),
helpText: z.string().max(4000).optional(),
required: z.boolean(),
answerMode: z.enum(["single_select", "multi_select", "text"]),
options: z.array(paperclipQuestionOptionSchema).max(128).optional(),
customAnswer: z.object({
enabled: z.literal(true),
label: z.string().max(1000).optional(),
placeholder: z.string().max(1000).optional(),
}).optional(),
textValidation: z.object({
minLength: z.number().int().min(0).max(100000).optional(),
maxLength: z.number().int().min(0).max(100000).optional(),
pattern: z.string().max(1000).optional(),
inputType: z.enum(["text", "number", "integer"]).optional(),
minimum: z.number().finite().optional(),
maximum: z.number().finite().optional(),
}).optional(),
});
const paperclipQuestionSetSchema = z.object({
schema: z.literal("paperclip.question_set.v1"),
title: z.string().max(1000).optional(),
description: z.string().max(4000).optional(),
submitLabel: z.string().max(200).optional(),
questions: z.array(paperclipQuestionSchema).min(1).max(64),
});
export const askUserQuestionsPayloadSchema = z.object({
@@ -851,7 +888,9 @@ export const askUserQuestionsPayloadSchema = z.object({
title: z.string().trim().max(240).nullable().optional(),
submitLabel: z.string().trim().max(120).nullable().optional(),
supersedeOnUserComment: z.boolean().optional(),
questions: z.array(askUserQuestionsQuestionSchema).min(1).max(10),
questions: z.array(askUserQuestionsQuestionSchema).min(1).max(64),
/** Exact canonical presentation retained for a recovered harness request. */
questionSet: paperclipQuestionSetSchema.optional(),
}).superRefine((value, ctx) => {
const seenQuestionIds = new Set<string>();
for (const [questionIndex, question] of value.questions.entries()) {
@@ -890,8 +929,8 @@ export const askUserQuestionsPayloadSchema = z.object({
});
export const askUserQuestionsAnswerSchema = z.object({
questionId: z.string().trim().min(1).max(120),
optionIds: z.array(z.string().trim().min(1).max(120)).max(20),
questionId: z.string().trim().min(1).max(160),
optionIds: z.array(z.string().trim().min(1).max(160)).max(128),
otherText: multilineTextSchema.pipe(z.string().trim().max(4000)).nullable().optional(),
});
@@ -899,7 +938,7 @@ export const askUserQuestionsResultSchema = z.object({
version: z.literal(1),
outcome: z.enum(["withdrawn", "issue_closed", "addressee_deleted"]).optional(),
reason: z.string().trim().max(4000).nullable().optional(),
answers: z.array(askUserQuestionsAnswerSchema).max(20),
answers: z.array(askUserQuestionsAnswerSchema).max(64),
cancelled: z.literal(true).optional(),
cancellationReason: z.string().trim().max(4000).nullable().optional(),
expirationReason: z.enum(["superseded_by_comment", "superseded_by_newer_interaction"]).optional(),
@@ -222,7 +222,14 @@ describe("P6-19 native interaction bridge", () => {
kind: "ask_user_questions",
response: {
status: "answered",
result: { version: 1, answers: [{ questionId: "choice", optionIds: ["safe"] }] },
result: {
version: 1,
answers: [{ questionId: "choice", optionIds: ["safe"] }],
summaryMarkdown: [
"Resolved questions and answers:",
"- Which path?: Safe",
].join("\n"),
},
},
},
{
+18 -1
View File
@@ -45,8 +45,9 @@ describe("redaction", () => {
});
it("redacts jwt-looking values even when key name is not sensitive", () => {
const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
const input = {
session: "aaa.bbb.ccc",
session: jwt,
normal: "plain",
};
@@ -56,6 +57,22 @@ describe("redaction", () => {
expect(result.normal).toBe("plain");
});
it("preserves Paperclip protocol schema identifiers", () => {
expect(sanitizeRecord({
schema: "paperclip.question_set.v1",
nested: {
schema: "paperclip.question_response.v1",
runtimeSchema: "paperclip.runtime_request.v2",
},
})).toEqual({
schema: "paperclip.question_set.v1",
nested: {
schema: "paperclip.question_response.v1",
runtimeSchema: "paperclip.runtime_request.v2",
},
});
});
it("redacts payload objects while preserving null", () => {
expect(redactEventPayload(null)).toBeNull();
expect(redactEventPayload({ password: "hunter2", safe: "value" })).toEqual({
+20
View File
@@ -59,6 +59,7 @@ import {
issueThreadInteractionService,
issueService,
instanceSettingsService,
questionResponseDeliveryService,
reconcileBuiltInAgentsOnStartup,
reconcileCodexLocalManagedHomesOnStartup,
reconcilePersistedRuntimeServicesOnStartup,
@@ -1114,6 +1115,9 @@ export async function startServer(): Promise<StartedServer> {
const ENVIRONMENT_LEASE_CLEANUP_SWEEP_BACKOFF_MS = 5 * 60 * 1000;
const environmentLeaseCleanupHeartbeat =
heartbeat ?? heartbeatService(db as any, { pluginWorkerManager });
const questionResponseDeliveries = questionResponseDeliveryService(db as any, {
heartbeat: environmentLeaseCleanupHeartbeat,
});
const runEnvironmentLeaseCleanupSweep = (backoffMs: number) =>
environmentLeaseCleanupHeartbeat
.sweepPendingCleanupLeases({ backoffMs })
@@ -1130,6 +1134,14 @@ export async function startServer(): Promise<StartedServer> {
trackHeartbeatSchedulerWork(runEnvironmentLeaseCleanupSweep(ENVIRONMENT_LEASE_CLEANUP_SWEEP_BACKOFF_MS));
};
await questionResponseDeliveries.sweepPending().then((result) => {
if (result.scanned > 0) {
logger.info(result, "startup question-response delivery sweep completed");
}
}).catch((err) => {
logger.error({ err }, "startup question-response delivery sweep failed");
});
if (heartbeat) {
const secretProposals = createSecretProposalsService(db as any);
const decisionExecutor = decisionService(db as any, decisionServiceOptions);
@@ -1540,6 +1552,14 @@ export async function startServer(): Promise<StartedServer> {
logger.error({ err }, "periodic secret proposal expiry sweep failed");
}));
trackHeartbeatSchedulerWork(questionResponseDeliveries.sweepPending()
.then((result) => {
if (result.scanned > 0) logger.info(result, "periodic question-response delivery sweep completed");
})
.catch((err) => {
logger.error({ err }, "periodic question-response delivery sweep failed");
}));
if (heartbeatSchedulerStopped) return;
if (!(await heartbeat.resolveSchedulingSuppression()).suppressed) {
// Periodically reap orphaned runs (5-min staleness threshold) and make sure
+6 -1
View File
@@ -12,7 +12,12 @@ const AUDIT_SURFACE_PAYLOAD_KEY_RE = /^surface$/;
const COMMAND_PAYLOAD_KEY_RE =
/(^command$|^cmd$|command[-_]?line|resolved[-_]?command|PAPERCLIP_RESOLVED_COMMAND)/i;
const COMMAND_ARGS_PAYLOAD_KEY_RE = /^(commandArgs|command_?args|argv)$/i;
const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/;
// A protocol identifier such as `paperclip.question_set.v1` has the same
// broad three-segment shape as a JWT. Real JSON web tokens encode JSON in the
// first two segments (and therefore conventionally begin with `eyJ`) and have
// a non-trivial signature. Keep the value guard specific enough that durable
// protocol discriminators survive sanitization and replay.
const JWT_VALUE_RE = /^eyJ[A-Za-z0-9_-]{4,}\.eyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{8,}(?:\.[A-Za-z0-9_-]{8,})?$/;
const CLI_SECRET_FLAG_RE = new RegExp(String.raw`^-{1,2}${SECRET_FIELD_NAME_PATTERN}$`, "i");
const JSON_SECRET_FIELD_TEXT_RE = new RegExp(
String.raw`((?:"|')?${SECRET_FIELD_NAME_PATTERN}(?:"|')?\s*:\s*(?:"|'))[^"'` + "`" + String.raw`\r\n]+((?:"|'))`,
+9 -7
View File
@@ -139,6 +139,7 @@ import {
logActivity,
publishActivity,
projectService,
questionResponseDeliveryService,
routineService,
workProductService,
} from "../services/index.js";
@@ -2878,6 +2879,7 @@ export function issueRoutes(
const heartbeat = heartbeatService(db, {
pluginWorkerManager: opts.pluginWorkerManager,
});
const questionResponseDeliveries = questionResponseDeliveryService(db, { heartbeat });
const enqueueStalledReviewDecisionWakeup = opts.stalledReviewDecisionEnqueueWakeup ?? heartbeat.wakeup;
const enqueueRecoveryActionWakeup = opts.recoveryActionEnqueueWakeup ?? heartbeat.wakeup;
const feedback = feedbackService(db);
@@ -12226,13 +12228,13 @@ export function issueRoutes(
},
});
await queueResolvedInteractionContinuationWakeup({
db,
heartbeat,
issue,
interaction,
actor,
source: "issue.interaction.respond",
await questionResponseDeliveries.deliver(interaction.id).catch((err) => {
logger.warn({
err,
companyId: issue.companyId,
issueId: issue.id,
interactionId: interaction.id,
}, "synchronous question response delivery failed; durable outbox will retry");
});
res.json(interaction);
+1
View File
@@ -42,6 +42,7 @@ export {
type IssueFilters,
} from "./issues.js";
export { issueThreadInteractionService } from "./issue-thread-interactions.js";
export { questionResponseDeliveryService } from "./question-response-delivery.js";
export {
assertIssueReviewVerdictActorAllowed,
type IssueReviewVerdictActor,
@@ -9,6 +9,7 @@ import {
heartbeatRuns,
issueComments,
issueDocuments,
issueQuestionResponseDeliveries,
issueThreadInteractions,
issues,
toolActionRequests,
@@ -71,6 +72,7 @@ import {
isIssueReviewVerdictInteraction,
} from "./issue-review-policy.js";
import { issueService, runWorkspaceIsFinalized } from "./issues.js";
import { questionResponseDeliveryValues } from "./question-response-delivery.js";
import {
assertIssueThreadInteractionResolverAudience,
canonicalizeStoredResolverPolicy,
@@ -3377,30 +3379,36 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
answers: input.answers,
});
const [updated] = await db
.update(issueThreadInteractions)
.set({
status: "answered",
result: {
version: 1,
answers: normalizedAnswers,
summaryMarkdown: input.summaryMarkdown ?? null,
},
resolvedByAgentId: actor.agentId ?? null,
resolvedByRunId: actor.runId ?? null,
resolvedByUserId: actor.userId ?? null,
resolvedAt: new Date(),
updatedAt: new Date(),
})
.where(and(
eq(issueThreadInteractions.id, interactionId),
eq(issueThreadInteractions.status, "pending"),
))
.returning();
const updated = await db.transaction(async (tx) => {
const resolvedAt = new Date();
const [row] = await tx
.update(issueThreadInteractions)
.set({
status: "answered",
result: {
version: 1,
answers: normalizedAnswers,
summaryMarkdown: input.summaryMarkdown ?? null,
},
resolvedByAgentId: actor.agentId ?? null,
resolvedByRunId: actor.runId ?? null,
resolvedByUserId: actor.userId ?? null,
resolvedAt,
updatedAt: resolvedAt,
})
.where(and(
eq(issueThreadInteractions.id, interactionId),
eq(issueThreadInteractions.status, "pending"),
))
.returning();
if (!updated) {
throw interactionAlreadyResolvedError();
}
if (!row) throw interactionAlreadyResolvedError();
const answered = hydrateInteraction(row) as AskUserQuestionsInteraction;
await tx.insert(issueQuestionResponseDeliveries).values(
questionResponseDeliveryValues(answered),
);
return row;
});
await touchIssue(db, issue.id);
const answered = hydrateInteraction(updated);
@@ -16,6 +16,7 @@ import { issueThreadInteractionService } from "../issue-thread-interactions.js";
import { commitNativeStatusDecision } from "./status-decision-committer.js";
import { nativeSha256 } from "./canonical.js";
import { recordNativeAttentionAssessment } from "./work-assessments.js";
import { formatDurableQuestionResponseSummary } from "../question-response-delivery.js";
import {
NATIVE_STATUS_ARBITER_POLICY_VERSION,
type NativeAuthoritativeIssueStatus,
@@ -319,12 +320,16 @@ export async function materializeNativeInteractionResponses(input: {
if (resolution.reasonCode !== "attention_resolved_from_context") {
throw new NativeInteractionBridgeError("native_attention_resolution_invalid", "Answered interaction has no continuation policy");
}
const result = structuredClone(interaction.result) as unknown as Record<string, unknown>;
const summaryMarkdown = formatDurableQuestionResponseSummary(interaction);
responses.push({
interactionId: interaction.id,
kind: interaction.kind,
response: {
status: interaction.status,
result: structuredClone(interaction.result) as unknown as Record<string, unknown>,
result: summaryMarkdown
? { ...result, summaryMarkdown }
: result,
},
});
continue;
@@ -27,6 +27,7 @@ vi.mock("@paperclipai/paperclip-runner", () => ({
CAPABILITY_SEMANTIC_TOOL_CATALOG: [],
createNativeSessionBackend: state.createBackend,
executeNativeSession: state.execute,
parsePaperclipQuestionSet: (value: unknown) => value,
}));
import {
@@ -42,10 +43,113 @@ import {
nativeSessionRecoveryProjection,
nativeGovernedWaitResult,
providerPlanMarkdown,
runtimeInputLifecycleMetric,
runtimeQuestionFallbackFromEvent,
semanticProviderPlanMarkdown,
steerNativeSession,
} from "./native-session-executor.js";
describe("runtime question fallback", () => {
const questionSet = {
schema: "paperclip.question_set.v1" as const,
title: "Configure deployment",
description: "These answers are required before work can continue.",
submitLabel: "Continue",
questions: [
{
id: "region",
prompt: "Which region?",
required: true,
answerMode: "single_select" as const,
options: [{ id: "us", label: "US" }, { id: "eu", label: "Europe" }],
},
{
id: "replicas",
prompt: "How many replicas?",
required: true,
answerMode: "text" as const,
textValidation: { inputType: "integer" as const, minimum: 1 },
},
],
};
it("materializes one idempotent durable interaction only after provider loss", () => {
const fallback = runtimeQuestionFallbackFromEvent({
eventType: "runtime_request.expired",
runId: "00000000-0000-4000-8000-000000000001",
payload: {
requestId: "elicitation-1",
reason: "provider_process_lost",
replayAllowed: false,
request: {
schema: "paperclip.runtime_request.v2",
requestKind: "runtime",
requestId: "elicitation-1",
type: "input",
status: "pending",
prompt: "Configure deployment",
input: questionSet,
},
},
});
expect(fallback).toMatchObject({
kind: "ask_user_questions",
idempotencyKey: "runtime-input-fallback:v1:00000000-0000-4000-8000-000000000001:elicitation-1",
sourceRunId: "00000000-0000-4000-8000-000000000001",
continuationPolicy: "wake_assignee",
payload: {
questionSet,
supersedeOnUserComment: false,
questions: [
{ id: "region", selectionMode: "single", options: [{ id: "us", label: "US" }, { id: "eu", label: "Europe" }] },
{ id: "replicas", selectionMode: "single", options: [{ id: "__paperclip_text__", freeText: true }] },
],
},
});
});
it.each([
["runtime_request.resolved", "provider_process_lost", false],
["runtime_request.cancelled", "provider_process_lost", false],
["runtime_request.expired", "explicit_cancellation", false],
["runtime_request.expired", "provider_process_lost", true],
])("does not fall back for %s / %s / replay=%s", (eventType, reason, replayAllowed) => {
expect(runtimeQuestionFallbackFromEvent({
eventType: eventType as never,
runId: "00000000-0000-4000-8000-000000000001",
payload: {
reason,
replayAllowed,
request: {
schema: "paperclip.runtime_request.v2",
requestKind: "runtime",
requestId: "elicitation-1",
type: "input",
input: questionSet,
},
},
})).toBeNull();
});
it("emits content-free lifecycle metric dimensions", () => {
expect(runtimeInputLifecycleMetric({
eventType: "runtime_request.created",
payload: {
request: {
type: "input",
requestId: "input-1",
origin: { adapter: "codex-app-server" },
input: questionSet,
},
},
})).toEqual({
outcome: "normalized",
adapter: "codex-app-server",
requestId: "input-1",
});
});
});
describe("native provider bootstrap environment", () => {
it("inherits the host executable and credential-home context", () => {
expect(buildNativeProviderEnvironment({}, {
@@ -9,13 +9,16 @@ import type {
NativeExecutionInput,
NativeSession,
NativeSessionBackend,
PaperclipQuestionSet,
PersistedNativeSession,
PrpEvent,
PrpStructuredRunResult,
} from "../../vendor/paperclip-runner/index.js";
import {
createNativeSessionBackend,
createRunnerdCodexTransport,
executeNativeSession,
parsePaperclipQuestionSet,
} from "../../vendor/paperclip-runner/index.js";
import type { Db } from "@paperclipai/db";
import { and, desc, eq, inArray, or, sql } from "drizzle-orm";
@@ -117,6 +120,128 @@ type PlanSynchronization = {
confirmationId: string | null;
};
type RuntimeQuestionFallback = {
kind: "ask_user_questions";
idempotencyKey: string;
sourceRunId: string;
title: string | null;
summary: string | null;
continuationPolicy: "wake_assignee";
payload: {
version: 1;
title?: string;
submitLabel?: string;
supersedeOnUserComment: false;
questionSet: PaperclipQuestionSet;
questions: Array<{
id: string;
prompt: string;
helpText?: string;
selectionMode: "single" | "multi";
required: boolean;
options: Array<{ id: string; label: string; description?: string; freeText?: boolean }>;
}>;
};
};
/** Translate only provider-loss expirations; cancellations and resolved inputs never fall back. */
export function runtimeQuestionFallbackFromEvent(
event: Pick<PrpEvent, "eventType" | "payload" | "runId">,
): RuntimeQuestionFallback | null {
if (event.eventType !== "runtime_request.expired") return null;
const payload = record(event.payload);
if (payload.reason !== "provider_process_lost" || payload.replayAllowed !== false) return null;
const request = record(payload.request);
if (
request.schema !== "paperclip.runtime_request.v2"
|| request.requestKind !== "runtime"
|| request.type !== "input"
|| typeof request.requestId !== "string"
) return null;
let questionSet: PaperclipQuestionSet;
try {
questionSet = parsePaperclipQuestionSet(request.input);
} catch {
return null;
}
const questions = questionSet.questions.map((question) => ({
id: question.id,
prompt: question.prompt,
...(question.helpText ? { helpText: question.helpText } : {}),
selectionMode: question.answerMode === "multi_select" ? "multi" as const : "single" as const,
required: question.required,
options: question.answerMode === "text"
? [{
id: "__paperclip_text__",
label: question.textValidation?.inputType === "integer"
? "Enter an integer"
: question.textValidation?.inputType === "number"
? "Enter a number"
: "Enter your answer",
freeText: true,
}]
: (question.options ?? []).map((option) => ({
id: option.id,
label: option.label,
...(option.description ? { description: option.description } : {}),
})),
}));
return {
kind: "ask_user_questions",
idempotencyKey: `runtime-input-fallback:v1:${event.runId}:${request.requestId}`,
sourceRunId: event.runId,
title: questionSet.title?.slice(0, 240) ?? null,
summary: questionSet.description?.slice(0, 1000) ?? null,
continuationPolicy: "wake_assignee",
payload: {
version: 1,
...(questionSet.title ? { title: questionSet.title.slice(0, 240) } : {}),
...(questionSet.submitLabel ? { submitLabel: questionSet.submitLabel.slice(0, 120) } : {}),
supersedeOnUserComment: false,
questionSet,
questions,
},
};
}
export function runtimeInputLifecycleMetric(
event: Pick<PrpEvent, "eventType" | "payload">,
): { outcome: "normalized" | "rejected" | "resolved" | "expired" | "cancelled"; adapter: string; requestId: string | null } | null {
const payload = record(event.payload);
const request = record(payload.request);
if (event.eventType === "runtime_request.created" && request.type === "input") {
const origin = record(request.origin);
return {
outcome: "normalized",
adapter: typeof origin.adapter === "string" ? origin.adapter : "unknown",
requestId: typeof request.requestId === "string" ? request.requestId : null,
};
}
if (event.eventType === "harness.diagnostic" && payload.code === "runtime_input_rejected") {
return {
outcome: "rejected",
adapter: typeof payload.adapter === "string" ? payload.adapter : "unknown",
requestId: null,
};
}
const terminalOutcome = event.eventType === "runtime_request.resolved" ? "resolved"
: event.eventType === "runtime_request.expired" ? "expired"
: event.eventType === "runtime_request.cancelled" ? "cancelled"
: null;
const requestType = payload.requestType ?? request.type;
if (!terminalOutcome || requestType !== "input") return null;
const origin = record(request.origin);
return {
outcome: terminalOutcome,
adapter: typeof payload.adapter === "string"
? payload.adapter
: typeof origin.adapter === "string" ? origin.adapter : "unknown",
requestId: typeof payload.requestId === "string"
? payload.requestId
: typeof request.requestId === "string" ? request.requestId : null,
};
}
export function providerPlanMarkdown(payload: Record<string, unknown>): string {
const completedMarkdown = typeof payload.markdown === "string" ? payload.markdown.trim() : "";
if (completedMarkdown) return completedMarkdown.slice(0, 256_000);
@@ -1082,6 +1207,38 @@ export async function executePaperclipNativeSession(input: {
}, {
onCommittedEvent: async (event) => {
if (input.onLog) await input.onLog("stdout", `${JSON.stringify({ type: "paperclip.prp.event", event })}\n`);
const inputMetric = runtimeInputLifecycleMetric(event);
if (inputMetric && input.onLog) {
await input.onLog("stdout", `${JSON.stringify({
type: "paperclip.runtime_input.metric",
...inputMetric,
})}\n`);
}
const questionFallback = runtimeQuestionFallbackFromEvent(event);
if (questionFallback) {
const interaction = await issueThreadInteractionService(input.db).create(
{
id: input.execution.binding.issueId,
companyId: input.execution.binding.companyId,
},
questionFallback as never,
{
agentId: input.execution.binding.agentId,
runId: input.execution.binding.runId,
systemId: "native-runtime-question-fallback",
},
);
if (input.onLog) {
const origin = record(record(record(event.payload).request).origin);
await input.onLog("stdout", `${JSON.stringify({
type: "paperclip.runtime_input.metric",
outcome: "fallback_materialized",
requestId: record(event.payload).requestId,
interactionId: interaction.id,
adapter: typeof origin.adapter === "string" ? origin.adapter : "unknown",
})}\n`);
}
}
await recordPlanSynchronization(event as {
sourceEventId: string;
turnId?: string;
@@ -0,0 +1,532 @@
import { and, asc, eq, inArray, isNull, lte, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
heartbeatRuns,
issueQuestionResponseDeliveries,
issues,
issueThreadInteractions,
} from "@paperclipai/db";
import type {
AskUserQuestionsInteraction,
PaperclipQuestionSetPayload,
} from "@paperclipai/shared";
import type {
PaperclipQuestionResponse,
} from "../vendor/paperclip-runner/index.js";
import { getTelemetryClient } from "../telemetry.js";
import { logger } from "../middleware/logger.js";
import { logActivity } from "./activity-log.js";
import type { heartbeatService } from "./heartbeat.js";
import { nativeSha256 } from "./native-runtime/canonical.js";
import {
NativeSessionSteeringError,
steerNativeSession,
} from "./native-runtime/native-session-executor.js";
const DELIVERY_CLAIM_STALE_MS = 30_000;
const MAX_DELIVERY_ATTEMPTS = 5;
const DELIVERY_CORRELATION_PREFIX = "question-response:";
type QuestionInteractionRow = typeof issueThreadInteractions.$inferSelect;
type DeliveryRow = typeof issueQuestionResponseDeliveries.$inferSelect;
type Heartbeat = Pick<ReturnType<typeof heartbeatService>, "wakeup">;
export interface QuestionResponseDeliveryEnvelope {
schema: "paperclip.question_response_delivery.v1";
interactionId: string;
sourceRunId: string | null;
questionSet: PaperclipQuestionSetPayload;
response: PaperclipQuestionResponse;
}
export interface QuestionResponseDeliveryOutcome {
deliveryId: string;
status: DeliveryRow["status"];
mode: DeliveryRow["deliveryMode"];
targetRunId: string | null;
targetTurnId: string | null;
duplicate: boolean;
}
export interface QuestionResponseDeliveryServiceOptions {
heartbeat: Heartbeat;
steer?: typeof steerNativeSession;
now?: () => Date;
}
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function compactLine(value: unknown): string | null {
if (typeof value !== "string") return null;
const normalized = value.replace(/\s+/g, " ").trim();
return normalized.length > 0 ? normalized : null;
}
function canonicalQuestionSet(interaction: Pick<AskUserQuestionsInteraction, "title" | "payload">): PaperclipQuestionSetPayload {
if (interaction.payload.questionSet) return structuredClone(interaction.payload.questionSet);
return {
schema: "paperclip.question_set.v1",
...(interaction.title ? { title: interaction.title } : {}),
...(interaction.payload.submitLabel ? { submitLabel: interaction.payload.submitLabel } : {}),
questions: interaction.payload.questions.map((question) => {
const customOption = question.options.find((option) => option.freeText === true);
return {
id: question.id,
prompt: question.prompt,
...(question.helpText ? { helpText: question.helpText } : {}),
required: question.required === true,
answerMode: question.selectionMode === "multi" ? "multi_select" as const : "single_select" as const,
options: question.options
.filter((option) => option.freeText !== true)
.map((option) => ({
id: option.id,
label: option.label,
...(option.description ? { description: option.description } : {}),
})),
...(customOption
? {
customAnswer: {
enabled: true as const,
label: customOption.label,
...(customOption.description ? { placeholder: customOption.description } : {}),
},
}
: {}),
};
}),
};
}
export function buildQuestionResponseDeliveryEnvelope(
interaction: AskUserQuestionsInteraction,
): QuestionResponseDeliveryEnvelope {
if (interaction.status !== "answered" || !interaction.result || interaction.result.cancelled === true) {
throw new Error("question_response_interaction_not_answered");
}
const questionSet = canonicalQuestionSet(interaction);
const questionById = new Map(questionSet.questions.map((question) => [question.id, question]));
const response: PaperclipQuestionResponse = {
schema: "paperclip.question_response.v1",
answers: Object.fromEntries(interaction.result.answers.map((answer) => {
const question = questionById.get(answer.questionId);
return [answer.questionId, question?.answerMode === "text"
? { ...(answer.otherText ? { text: answer.otherText } : {}) }
: {
selectedOptionIds: answer.optionIds,
...(answer.otherText ? { customText: answer.otherText } : {}),
}];
})),
};
return {
schema: "paperclip.question_response_delivery.v1",
interactionId: interaction.id,
sourceRunId: interaction.sourceRunId ?? null,
questionSet,
response,
};
}
function questionAnswerLines(envelope: QuestionResponseDeliveryEnvelope): string[] {
const lines: string[] = [];
for (const question of envelope.questionSet.questions) {
const answer = envelope.response.answers[question.id];
if (!answer) continue;
const optionLabelById = new Map((question.options ?? []).map((option) => [option.id, option.label]));
const values = (answer.selectedOptionIds ?? []).map((optionId) => optionLabelById.get(optionId) ?? optionId);
const text = compactLine(answer.text);
const customText = compactLine(answer.customText);
if (text) values.push(text);
if (customText) values.push(customText);
const header = compactLine(question.header);
const prompt = compactLine(question.prompt);
const label = header && prompt && header !== prompt
? `${header} — ${prompt}`
: header ?? prompt ?? question.id;
lines.push(`- ${label}: ${values.join(", ") || "No answer"}`);
}
return lines;
}
export function formatQuestionResponseSummary(envelope: QuestionResponseDeliveryEnvelope): string {
const lines = questionAnswerLines(envelope);
return lines.length > 0
? ["Resolved questions and answers:", ...lines].join("\n")
: "Resolved questions and answers.";
}
export function formatDurableQuestionResponseSummary(interaction: AskUserQuestionsInteraction): string {
const existing = compactLine(interaction.result?.summaryMarkdown);
return existing ?? formatQuestionResponseSummary(buildQuestionResponseDeliveryEnvelope(interaction));
}
export function formatQuestionResponseSteeringMessage(envelope: QuestionResponseDeliveryEnvelope): string {
const lines = questionAnswerLines(envelope);
return lines.length > 0
? ["Answered questions", "", ...lines].join("\n")
: "Answered questions";
}
function hydrateQuestionInteraction(row: QuestionInteractionRow): AskUserQuestionsInteraction {
return {
...row,
kind: "ask_user_questions",
status: row.status as AskUserQuestionsInteraction["status"],
continuationPolicy: row.continuationPolicy as AskUserQuestionsInteraction["continuationPolicy"],
resolverPolicy: row.effectiveResolverPolicy,
requestedResolverPolicy: row.requestedResolverPolicy,
effectiveResolverPolicy: row.effectiveResolverPolicy,
resolverPolicyProvenance: row.resolverPolicyProvenance,
effectiveResolverPolicySource: row.effectiveResolverPolicySource,
legacyResolverPolicyAliases: { requested: null, effective: null },
payload: row.payload as AskUserQuestionsInteraction["payload"],
result: row.result as AskUserQuestionsInteraction["result"],
};
}
export function questionResponseDeliveryValues(interaction: AskUserQuestionsInteraction) {
const envelope = buildQuestionResponseDeliveryEnvelope(interaction);
return {
companyId: interaction.companyId,
issueId: interaction.issueId,
interactionId: interaction.id,
sourceRunId: interaction.sourceRunId ?? null,
correlationId: `${DELIVERY_CORRELATION_PREFIX}${interaction.id}`,
payloadSha256: nativeSha256(envelope),
};
}
function issueIdFromRun(run: Pick<typeof heartbeatRuns.$inferSelect, "contextSnapshot">) {
const context = record(run.contextSnapshot);
return compactLine(context.issueId) ?? compactLine(context.taskId);
}
function actorForInteraction(interaction: QuestionInteractionRow) {
if (interaction.resolvedByUserId) {
return { actorType: "user" as const, actorId: interaction.resolvedByUserId };
}
if (interaction.resolvedByAgentId) {
return { actorType: "agent" as const, actorId: interaction.resolvedByAgentId };
}
return { actorType: "system" as const, actorId: "question-response-outbox" };
}
export function questionResponseDeliveryService(
db: Db,
options: QuestionResponseDeliveryServiceOptions,
) {
const steer = options.steer ?? steerNativeSession;
const now = options.now ?? (() => new Date());
async function claim(interactionId: string): Promise<DeliveryRow | null> {
const claimAt = now();
return db.transaction(async (tx) => {
const current = await tx.select()
.from(issueQuestionResponseDeliveries)
.where(eq(issueQuestionResponseDeliveries.interactionId, interactionId))
.for("update")
.limit(1)
.then((rows) => rows[0] ?? null);
if (!current || ["delivered", "fallback_queued", "failed"].includes(current.status)) return null;
if (
current.status === "delivering"
&& current.lastAttemptAt
&& current.lastAttemptAt.getTime() > claimAt.getTime() - DELIVERY_CLAIM_STALE_MS
) return null;
return tx.update(issueQuestionResponseDeliveries).set({
status: "delivering",
attemptCount: sql`${issueQuestionResponseDeliveries.attemptCount} + 1`,
lastAttemptAt: claimAt,
updatedAt: claimAt,
}).where(eq(issueQuestionResponseDeliveries.id, current.id))
.returning()
.then((rows) => rows[0] ?? null);
});
}
async function terminalOutcome(interactionId: string): Promise<QuestionResponseDeliveryOutcome | null> {
const row = await db.select().from(issueQuestionResponseDeliveries)
.where(eq(issueQuestionResponseDeliveries.interactionId, interactionId))
.limit(1)
.then((rows) => rows[0] ?? null);
if (!row || !["delivered", "fallback_queued", "failed"].includes(row.status)) return null;
return {
deliveryId: row.id,
status: row.status,
mode: row.deliveryMode,
targetRunId: row.targetRunId,
targetTurnId: row.targetTurnId,
duplicate: true,
};
}
async function recordTerminal(input: {
delivery: DeliveryRow;
interaction: QuestionInteractionRow;
status: "delivered" | "fallback_queued" | "failed";
mode: "steered" | "coalesced" | "wake_fallback" | null;
targetRunId: string | null;
targetTurnId?: string | null;
adapter: string;
errorCode?: string | null;
}): Promise<QuestionResponseDeliveryOutcome> {
const at = now();
const updated = await db.update(issueQuestionResponseDeliveries).set({
status: input.status,
deliveryMode: input.mode,
targetRunId: input.targetRunId,
targetTurnId: input.targetTurnId ?? null,
acknowledgedAt: input.status === "failed" ? null : at,
lastErrorCode: input.errorCode ?? null,
updatedAt: at,
}).where(and(
eq(issueQuestionResponseDeliveries.id, input.delivery.id),
eq(issueQuestionResponseDeliveries.status, "delivering"),
)).returning().then((rows) => rows[0] ?? null);
const result = updated ?? await db.select().from(issueQuestionResponseDeliveries)
.where(eq(issueQuestionResponseDeliveries.id, input.delivery.id))
.limit(1)
.then((rows) => rows[0] ?? input.delivery);
if (updated) {
await logActivity(db, {
companyId: input.interaction.companyId,
actorType: "system",
actorId: "question-response-delivery",
agentId: input.interaction.resolvedByAgentId,
runId: input.targetRunId,
action: input.status === "failed"
? "issue.question_response_delivery_failed"
: "issue.question_response_delivered",
entityType: "issue",
entityId: input.interaction.issueId,
details: {
deliveryId: result.id,
interactionId: input.interaction.id,
sourceRunId: input.interaction.sourceRunId,
targetRunId: input.targetRunId,
targetTurnId: input.targetTurnId ?? null,
correlationId: result.correlationId,
payloadSha256: result.payloadSha256,
deliveryStatus: input.status,
deliveryMode: input.mode,
adapter: input.adapter,
errorCode: input.errorCode ?? null,
},
});
getTelemetryClient()?.trackDynamic("question_response.delivery", {
adapter: input.adapter,
outcome: input.mode ?? "failed",
});
}
return {
deliveryId: result.id,
status: result.status,
mode: result.deliveryMode,
targetRunId: result.targetRunId,
targetTurnId: result.targetTurnId,
duplicate: !updated,
};
}
async function releaseForRetry(delivery: DeliveryRow, errorCode: string) {
const at = now();
const exhausted = delivery.attemptCount >= MAX_DELIVERY_ATTEMPTS;
await db.update(issueQuestionResponseDeliveries).set({
status: exhausted ? "failed" : "pending",
lastErrorCode: errorCode,
updatedAt: at,
}).where(and(
eq(issueQuestionResponseDeliveries.id, delivery.id),
eq(issueQuestionResponseDeliveries.status, "delivering"),
));
return exhausted;
}
async function deliver(interactionId: string): Promise<QuestionResponseDeliveryOutcome | null> {
const claimed = await claim(interactionId);
if (!claimed) return terminalOutcome(interactionId);
const interaction = await db.select().from(issueThreadInteractions)
.where(and(
eq(issueThreadInteractions.id, interactionId),
eq(issueThreadInteractions.companyId, claimed.companyId),
eq(issueThreadInteractions.issueId, claimed.issueId),
))
.limit(1)
.then((rows) => rows[0] ?? null);
if (!interaction || interaction.kind !== "ask_user_questions" || interaction.status !== "answered") {
return recordTerminal({
delivery: claimed,
interaction: interaction ?? ({
id: interactionId,
companyId: claimed.companyId,
issueId: claimed.issueId,
sourceRunId: claimed.sourceRunId,
resolvedByAgentId: null,
} as QuestionInteractionRow),
status: "failed",
mode: null,
targetRunId: null,
adapter: "unknown",
errorCode: "question_response_interaction_invalid",
});
}
const [issue, agent] = await Promise.all([
db.select().from(issues).where(and(
eq(issues.id, interaction.issueId),
eq(issues.companyId, interaction.companyId),
)).limit(1).then((rows) => rows[0] ?? null),
interaction.createdByAgentId
? db.select({ adapterType: agents.adapterType }).from(agents)
.where(and(eq(agents.id, interaction.createdByAgentId), eq(agents.companyId, interaction.companyId)))
.limit(1).then((rows) => rows[0] ?? null)
: Promise.resolve(null),
]);
const adapter = agent?.adapterType ?? "unknown";
if (!issue || !issue.assigneeAgentId || issue.status === "done" || issue.status === "cancelled") {
return recordTerminal({
delivery: claimed,
interaction,
status: "failed",
mode: null,
targetRunId: null,
adapter,
errorCode: !issue ? "question_response_issue_missing" : "question_response_target_unavailable",
});
}
const liveRuns = await db.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, interaction.companyId),
eq(heartbeatRuns.agentId, issue.assigneeAgentId),
inArray(heartbeatRuns.status, ["queued", "running", "scheduled_retry"]),
)).orderBy(asc(heartbeatRuns.createdAt));
const issueRuns = liveRuns.filter((run) => issueIdFromRun(run) === interaction.issueId);
const successorRunning = issueRuns.find((run) =>
run.status === "running" && run.id !== interaction.sourceRunId,
) ?? null;
const queuedSuccessor = issueRuns.find((run) =>
(run.status === "queued" || run.status === "scheduled_retry") && run.id !== interaction.sourceRunId,
) ?? null;
const envelope = buildQuestionResponseDeliveryEnvelope(hydrateQuestionInteraction(interaction));
let steeringErrorCode: string | null = null;
if (successorRunning?.runtimeMode === "native") {
try {
const acknowledgement = await steer({
runId: successorRunning.id,
message: formatQuestionResponseSteeringMessage(envelope),
correlationId: claimed.correlationId,
});
return recordTerminal({
delivery: claimed,
interaction,
status: "delivered",
mode: "steered",
targetRunId: successorRunning.id,
targetTurnId: acknowledgement.turnId,
adapter: successorRunning.driverKind ?? adapter,
});
} catch (error) {
steeringErrorCode = error instanceof NativeSessionSteeringError
? error.code
: "steering_rejected";
}
} else if (successorRunning) {
steeringErrorCode = "steering_unsupported";
}
const actor = actorForInteraction(interaction);
try {
const wakeRun = await options.heartbeat.wakeup(issue.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: {
issueId: issue.id,
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
sourceCommentId: interaction.sourceCommentId,
sourceRunId: interaction.sourceRunId,
mutation: "interaction",
},
idempotencyKey: `interaction:${interaction.id}:${interaction.status}`,
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: {
issueId: issue.id,
taskId: issue.id,
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
sourceCommentId: interaction.sourceCommentId,
sourceRunId: interaction.sourceRunId,
wakeReason: "issue_commented",
source: "issue.interaction.respond",
},
});
const coalesced = Boolean(queuedSuccessor && wakeRun?.id === queuedSuccessor.id);
return recordTerminal({
delivery: claimed,
interaction,
status: coalesced ? "delivered" : "fallback_queued",
mode: coalesced ? "coalesced" : "wake_fallback",
targetRunId: wakeRun?.id ?? queuedSuccessor?.id ?? null,
adapter: wakeRun?.driverKind ?? adapter,
errorCode: steeringErrorCode,
});
} catch (error) {
const errorCode = error instanceof Error && compactLine(error.message)
? compactLine(error.message)!.slice(0, 160)
: "question_response_wake_failed";
const exhausted = await releaseForRetry(claimed, errorCode);
logger.warn({
err: error,
deliveryId: claimed.id,
interactionId,
attemptCount: claimed.attemptCount,
exhausted,
}, "question response delivery will retry after wake failure");
if (!exhausted) return null;
return terminalOutcome(interactionId);
}
}
async function sweepPending(limit = 50) {
const sweepAt = now();
const staleAt = new Date(sweepAt.getTime() - DELIVERY_CLAIM_STALE_MS);
await db.update(issueQuestionResponseDeliveries).set({
status: "pending",
updatedAt: sweepAt,
}).where(and(
eq(issueQuestionResponseDeliveries.status, "delivering"),
or(
isNull(issueQuestionResponseDeliveries.lastAttemptAt),
lte(issueQuestionResponseDeliveries.lastAttemptAt, staleAt),
),
));
const ids = await db.select({ interactionId: issueQuestionResponseDeliveries.interactionId })
.from(issueQuestionResponseDeliveries)
.where(eq(issueQuestionResponseDeliveries.status, "pending"))
.orderBy(asc(issueQuestionResponseDeliveries.createdAt))
.limit(limit)
.then((rows) => rows.map((row) => row.interactionId));
const counts = { scanned: ids.length, steered: 0, coalesced: 0, wakeFallback: 0, failed: 0 };
for (const id of ids) {
const outcome = await deliver(id);
if (outcome?.mode === "steered") counts.steered += 1;
else if (outcome?.mode === "coalesced") counts.coalesced += 1;
else if (outcome?.mode === "wake_fallback") counts.wakeFallback += 1;
else if (outcome?.status === "failed") counts.failed += 1;
}
return counts;
}
return { deliver, sweepPending };
}
@@ -223,6 +223,35 @@ describe("paperclip runner transcript projection", () => {
})]);
expect(event("runtime_request.resolved", { requestId: "request-1" }))
.toEqual([expect.objectContaining({ kind: "runtime_request", status: "resolved", prompt: "Allow command?", requestKind: "command_approval", turnId: "turn-1" })]);
expect(event("runtime_request.created", { request: { requestId: "request-2", requestKind: "runtime", type: "input", status: "pending", prompt: "Choose", input: { schema: "paperclip.question_set.v1", questions: [{ id: "environment", prompt: "Where?", required: true, answerMode: "single_select", options: [{ id: "staging", label: "Staging" }] }] } } }))
.toEqual([expect.objectContaining({ kind: "runtime_request", requestId: "request-2", status: "pending", requestType: "input" })]);
expect(event("runtime_request.resolved", {
requestId: "request-2",
action: "submit",
response: {
schema: "paperclip.question_response.v1",
answers: { environment: { selectedOptionIds: ["staging"] } },
},
})).toEqual([expect.objectContaining({
kind: "runtime_request",
requestId: "request-2",
status: "resolved",
prompt: "Choose",
requestType: "input",
resolvedAction: "submit",
response: {
schema: "paperclip.question_response.v1",
answers: { environment: { selectedOptionIds: ["staging"] } },
},
})]);
expect(event("runtime_request.created", { request: { requestId: "request-redacted", requestKind: "runtime", type: "input", status: "pending", prompt: "Choose", input: { schema: "***REDACTED***", questions: [{ id: "environment", prompt: "Where?", required: true, answerMode: "single_select", options: [{ id: "staging", label: "Staging" }] }] } } }))
.toEqual([expect.objectContaining({ kind: "runtime_request", requestId: "request-redacted", questionSet: expect.objectContaining({ schema: "paperclip.question_set.v1" }) })]);
expect(event("runtime_request.expired", { requestId: "request-redacted", reason: "provider_process_lost" }))
.toEqual([expect.objectContaining({ kind: "runtime_request", requestId: "request-redacted", status: "expired", prompt: "Choose", requestType: "input" })]);
expect(event("runtime_request.created", { request: { requestId: "request-cancel", requestKind: "runtime", type: "input", status: "pending", prompt: "Cancel me", input: { schema: "paperclip.question_set.v1", questions: [{ id: "reason", prompt: "Why?", required: false, answerMode: "text" }] } } }))
.toEqual([expect.objectContaining({ requestId: "request-cancel", status: "pending" })]);
expect(event("runtime_request.resolved", { requestId: "request-cancel", action: "cancel" }))
.toEqual([expect.objectContaining({ requestId: "request-cancel", status: "cancelled", resolvedAction: "cancel" })]);
expect(event("run.terminal", { turnTerminalState: "interrupted", runTerminalState: "cancelled", reportedWorkDisposition: "yielded", stopReason: { code: "user_stop" } }))
.toEqual([expect.objectContaining({ kind: "run_terminal", turnState: "interrupted", runState: "cancelled", disposition: "yielded", stopReason: "user_stop" })]);
});
+48 -5
View File
@@ -1,4 +1,4 @@
import type { PaperclipQuestion, PaperclipQuestionSet, TranscriptEntry } from "@paperclipai/adapter-utils";
import type { PaperclipQuestion, PaperclipQuestionResponse, PaperclipQuestionSet, TranscriptEntry } from "@paperclipai/adapter-utils";
import type { UIAdapterModule } from "../types";
import { parseCodexStdoutLine, buildPaperclipRunnerConfig } from "@paperclipai/adapter-codex-local/ui";
import { CodexLocalConfigFields } from "../codex-local/config-fields";
@@ -414,10 +414,14 @@ function runtimeRequestEntry(
if (!requestId) return null;
const previous = state.runtimeRequests.get(requestId);
const suffix = eventType.split(".").at(-1);
const resolvedAction = nullableText(request.action) ?? nullableText(payload.action) ?? previous?.resolvedAction ?? null;
const rawStatus = text(request.status, suffix);
const status = rawStatus === "resolved" || rawStatus === "expired" || rawStatus === "cancelled"
const lifecycleStatus = rawStatus === "resolved" || rawStatus === "expired" || rawStatus === "cancelled"
? rawStatus
: "pending";
const status = lifecycleStatus === "resolved" && (resolvedAction === "cancel" || resolvedAction === "decline")
? "cancelled"
: lifecycleStatus;
const rawKind = text(request.requestKind, previous?.requestKind ?? undefined);
const requestKind = rawKind === "runtime"
? "runtime"
@@ -474,6 +478,9 @@ function runtimeRequestEntry(
.filter((field) => field.name && field.label)
.slice(0, 16);
const questionSet = parseQuestionSet(request.input) ?? previous?.questionSet ?? null;
const response = parseQuestionResponse(request.response ?? payload.response)
?? previous?.response
?? null;
const entry: Extract<TranscriptEntry, { kind: "runtime_request" }> = {
kind: "runtime_request",
ts,
@@ -486,14 +493,45 @@ function runtimeRequestEntry(
choices,
fields,
questionSet,
resolvedAction,
response,
};
state.runtimeRequests.set(requestId, entry);
return entry;
}
function parseQuestionResponse(value: unknown): PaperclipQuestionResponse | null {
const response = record(value);
if (response.schema !== "paperclip.question_response.v1") return null;
const rawAnswers = record(response.answers);
const answers: PaperclipQuestionResponse["answers"] = {};
for (const [questionId, rawAnswer] of Object.entries(rawAnswers).slice(0, 64)) {
const answer = record(rawAnswer);
const selectedOptionIds = (Array.isArray(answer.selectedOptionIds) ? answer.selectedOptionIds : [])
.filter((optionId): optionId is string => typeof optionId === "string")
.slice(0, 128)
.map((optionId) => optionId.slice(0, 160));
const textAnswer = nullableText(answer.text)?.slice(0, 12_000);
const customText = nullableText(answer.customText)?.slice(0, 12_000);
answers[questionId.slice(0, 160)] = {
...(selectedOptionIds.length > 0 ? { selectedOptionIds } : {}),
...(textAnswer != null ? { text: textAnswer } : {}),
...(customText != null ? { customText } : {}),
};
}
return { schema: "paperclip.question_response.v1", answers };
}
function parseQuestionSet(value: unknown): PaperclipQuestionSet | null {
const input = record(value);
if (input.schema !== "paperclip.question_set.v1" || !Array.isArray(input.questions) || input.questions.length === 0) return null;
if (!Array.isArray(input.questions) || input.questions.length === 0) return null;
// Early v2 events passed through a broad JWT redactor that replaced the
// dotted schema discriminator while leaving the bounded question set
// intact. Recover those already-persisted requests on replay; reject other
// explicit schema families so this remains a narrow migration path.
if (input.schema !== undefined
&& input.schema !== "paperclip.question_set.v1"
&& input.schema !== "***REDACTED***") return null;
const questions = input.questions.map(record).slice(0, 64).map((question, questionIndex): PaperclipQuestion => {
const answerMode: PaperclipQuestion["answerMode"] = question.answerMode === "single_select" || question.answerMode === "multi_select" ? question.answerMode : "text";
const options = (Array.isArray(question.options) ? question.options : []).map(record).slice(0, 128).map((option, optionIndex) => ({
@@ -503,7 +541,11 @@ function parseQuestionSet(value: unknown): PaperclipQuestionSet | null {
}));
const customAnswer = record(question.customAnswer);
const validation = record(question.textValidation);
return {
const inputType: NonNullable<PaperclipQuestion["textValidation"]>["inputType"] =
validation.inputType === "number" || validation.inputType === "integer" || validation.inputType === "text"
? validation.inputType
: undefined;
const parsedQuestion: PaperclipQuestion = {
id: text(question.id, `question-${questionIndex + 1}`).slice(0, 160),
...(nullableText(question.header) ? { header: text(question.header).slice(0, 1_000) } : {}),
prompt: text(question.prompt, `Question ${questionIndex + 1}`).slice(0, 4_000),
@@ -520,11 +562,12 @@ function parseQuestionSet(value: unknown): PaperclipQuestionSet | null {
...(typeof validation.minLength === "number" ? { minLength: validation.minLength } : {}),
...(typeof validation.maxLength === "number" ? { maxLength: validation.maxLength } : {}),
...(typeof validation.pattern === "string" ? { pattern: validation.pattern.slice(0, 1_000) } : {}),
...(validation.inputType === "number" || validation.inputType === "integer" || validation.inputType === "text" ? { inputType: validation.inputType } : {}),
...(inputType ? { inputType } : {}),
...(typeof validation.minimum === "number" ? { minimum: validation.minimum } : {}),
...(typeof validation.maximum === "number" ? { maximum: validation.maximum } : {}),
} } : {}),
};
return parsedQuestion;
});
return {
schema: "paperclip.question_set.v1",
+1 -1
View File
@@ -936,7 +936,7 @@ interface FileViewerBodyProps {
onFallbackToProject: null | (() => void);
}
function FileViewerBody({
export function FileViewerBody({
resolveQuery,
contentQuery,
elapsedMs,
+19 -2
View File
@@ -4,6 +4,7 @@ import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import { PropertiesPanel } from "./PropertiesPanel";
const mockInstanceSettingsApi = vi.hoisted(() => ({
@@ -19,6 +20,7 @@ const mockPanelState = vi.hoisted(() => ({
panelContentMode: "padded" as const,
panelVisible: true,
}));
const mockSetPanelVisible = vi.hoisted(() => vi.fn());
vi.mock("../context/PanelContext", () => ({
usePanel: () => ({
@@ -27,7 +29,7 @@ vi.mock("../context/PanelContext", () => ({
panelVisible: mockPanelState.panelVisible,
openPanel: vi.fn(),
closePanel: vi.fn(),
setPanelVisible: vi.fn(),
setPanelVisible: mockSetPanelVisible,
togglePanelVisible: vi.fn(),
}),
}));
@@ -52,7 +54,9 @@ describe("PropertiesPanel", () => {
flushSync(() => {
root!.render(
<QueryClientProvider client={queryClient}>
<PropertiesPanel />
<TooltipProvider>
<PropertiesPanel />
</TooltipProvider>
</QueryClientProvider>,
);
});
@@ -63,6 +67,7 @@ describe("PropertiesPanel", () => {
container = document.createElement("div");
document.body.appendChild(container);
window.localStorage.clear();
mockSetPanelVisible.mockClear();
});
afterEach(() => {
@@ -119,6 +124,18 @@ describe("PropertiesPanel", () => {
expect(inner!.style.minWidth).toBe("322px");
});
it("uses the pressed side-panel toggle instead of an X to hide the open pane", async () => {
await renderPanel();
const toggle = container.querySelector<HTMLButtonElement>('[aria-label="Toggle side panel"]');
expect(toggle).not.toBeNull();
expect(toggle!.getAttribute("aria-pressed")).toBe("true");
expect(toggle!.querySelector(".lucide-panel-right-close")).not.toBeNull();
expect(container.querySelector('[aria-label="Hide side panel"]')).toBeNull();
toggle!.click();
expect(mockSetPanelVisible).toHaveBeenCalledWith(false);
});
it("restores a remembered width from localStorage (clamped to the minimum)", async () => {
window.localStorage.setItem("taskChatRedesign.propertiesPaneWidth", "300");
await renderPanel();
+1 -1
View File
@@ -375,7 +375,7 @@ function ResizablePropertiesPanel({
if (next) handleMaximize();
else handleRestore();
}}
onClose={() => setPanelVisible(false)}
onToggle={() => setPanelVisible(false)}
/>
)}
footer={<div id={PROPERTIES_PANE_FOOTER_SLOT_ID} />}
+73
View File
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ThemeProvider } from "@/context/ThemeContext";
import { TaskChatThread } from "./TaskChatThread";
import type { IssueDocument, IssueQueuedCommentQueue, IssueThreadInteraction } from "@paperclipai/shared";
import { heartbeatsApi } from "@/api/heartbeats";
const transcriptState = vi.hoisted(() => ({ transcriptByRun: new Map() }));
const sidebarState = vi.hoisted(() => ({ isMobile: false }));
@@ -55,6 +56,7 @@ afterEach(() => {
root = null;
container.remove();
localStorage.clear();
vi.restoreAllMocks();
});
function render(ui: ReactElement) {
@@ -1016,6 +1018,77 @@ describe("TaskChatThread live transcript", () => {
}
});
it("resolves a visible canonical input even while run adapter metadata is stale", async () => {
transcriptState.transcriptByRun.set("run-input", [{
kind: "runtime_request",
ts: "2026-08-23T20:00:00.000Z",
requestId: "question-1",
requestKind: "runtime",
turnId: "turn-1",
requestType: "input",
status: "pending",
prompt: "Codex needs your input.",
choices: [],
fields: [],
questionSet: {
schema: "paperclip.question_set.v1",
questions: [{
id: "goal",
prompt: "What should the server do?",
required: true,
answerMode: "single_select",
options: [{ id: "api", label: "Starter API" }],
}],
},
}]);
const resolveRuntimeRequest = vi.spyOn(heartbeatsApi, "resolveRuntimeRequest").mockResolvedValue({} as never);
render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
issueStatus="in_progress"
activeRun={{
id: "run-input",
status: "running",
invocationSource: "issue",
triggerDetail: null,
startedAt: "2026-08-23T20:00:00.000Z",
finishedAt: null,
createdAt: "2026-08-23T20:00:00.000Z",
agentId: "agent-1",
agentName: "Runner",
// Reproduces the lag that caused DOT-202: the transcript already has
// a Paperclip request while the linked-run adapter classification is
// still stale.
adapterType: "codex_local",
}}
/>,
);
const option = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Starter API"));
await act(async () => option?.click());
const submit = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Submit answers");
await act(async () => submit?.click());
expect(resolveRuntimeRequest).toHaveBeenCalledWith({
runId: "run-input",
requestId: "question-1",
turnId: "turn-1",
requestKind: "runtime",
resolution: {
action: "submit",
response: {
schema: "paperclip.question_response.v1",
answers: { goal: { selectedOptionIds: ["api"] } },
},
},
});
expect(container.textContent).not.toContain("no longer attached");
});
it("keeps the transcript mounted through run settle until the settled turn renders (PAP-462 B4)", () => {
transcriptState.transcriptByRun.set("run-1", [
{
+7 -18
View File
@@ -40,6 +40,7 @@ import type {
TaskChatRuntimeRequestItem,
TaskChatTurnItem,
} from "@/components/task-chat/task-chat-model";
import { latestPendingRuntimeRequest } from "@/components/task-chat/task-chat-model";
import { TaskChatInteractionCard } from "@/components/task-chat/TaskChatInteractionCard";
import {
interactionThreadAnchorMs,
@@ -1209,26 +1210,15 @@ export function TaskChatThread(props: TaskChatThreadProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
[tailRunId, tailContentKey, tailStreaming, tailAgentName],
);
const pendingRuntimeRequest =
tailItems.find(
(item): item is TaskChatRuntimeRequestItem =>
item.kind === "protocol" &&
item.surface === "runtime_request" &&
item.status === "pending",
) ?? null;
const pendingRuntimeRequest = latestPendingRuntimeRequest(tailItems);
const handleRuntimeRequestDecision = useCallback(
async (
item: TaskChatRuntimeRequestItem,
decision: TaskChatRuntimeRequestDecision,
) => {
if (
!paperclipRunnerTail ||
item.runId !== tailRunId ||
!item.turnId ||
!item.requestKind
) {
if (!item.turnId || !item.requestKind) {
throw new Error(
"This runtime request is no longer attached to the active Paperclip runner turn.",
"This runtime request is missing the provider turn identity needed to resolve it.",
);
}
let resolution: RuntimeRequestResolution;
@@ -1261,11 +1251,11 @@ export function TaskChatThread(props: TaskChatThreadProps) {
resolution,
});
},
[paperclipRunnerTail, tailRunId],
[],
);
const runtimeComposerDisabledReason =
composerDisabledReason ??
(paperclipRunnerTail && pendingRuntimeRequest
(pendingRuntimeRequest
? "Resolve the runtime request before sending another message."
: undefined);
const assigneeUsesPaperclipRunner = Boolean(
@@ -1446,7 +1436,6 @@ export function TaskChatThread(props: TaskChatThreadProps) {
<TaskChatThreadView
items={items}
header={threadHeaderWithBlockers}
onRuntimeRequestDecision={handleRuntimeRequestDecision}
renderInteraction={renderInteraction}
renderBrief={
issueBrief
@@ -1460,7 +1449,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
<>
{tailRunId || optimisticRunnerStartup ? (
<div data-testid="task-chat-live-transcript">
{paperclipRunnerTail || optimisticRunnerStartup ? (
{paperclipRunnerTail || pendingRuntimeRequest || optimisticRunnerStartup ? (
<TaskChatRunnerTurn
runId={tailRunId}
agentName={visibleTailAgentName}
@@ -333,13 +333,13 @@ function DocumentRow({
const location = useLocation();
const Chevron = expanded ? ChevronDown : ChevronRight;
useEffect(() => {
if (openRequestId === undefined) return;
if (onOpen || openRequestId === undefined) return;
setExpanded(true);
}, [openRequestId]);
}, [onOpen, openRequestId]);
useEffect(() => {
if (openRequestId === undefined || !expanded) return;
if (onOpen || openRequestId === undefined || !expanded) return;
headerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}, [expanded, openRequestId]);
}, [expanded, onOpen, openRequestId]);
if (onOpen) {
return (
<button
+28
View File
@@ -0,0 +1,28 @@
# Portable side panel
The components exported by `@/components/side-panel` are controlled, domain-free navigation primitives. They do not import task data, APIs, routing, feature flags, or persistence.
## Composition boundary
- `SidePanelFrame` renders docked, sheet, or embedded chrome and applies the selected content layout.
- `SidePanelTabs` and `SidePanelTab` render projected visual tab data and own keyboard navigation, close focus recovery, overflow visibility, and pointer/keyboard reordering.
- `SidePanelLauncher` renders caller-provided sections from the same model as either a plus-button popover or the empty panel body.
- `useSidePanelTabs<TPayload>` owns serializable open/select/close/reorder/reset state. Its optional `onStateChange` callback is the persistence boundary; it never reads storage itself.
- The host adapter owns payload validation, data queries, URL synchronization, content rendering, and persistence scope.
Never put React nodes or fetched content in a persisted `SidePanelTabRecord`. Project icons and live status into `SidePanelTabItem` at render time.
## Registering another page
1. Define a serializable payload union for that page, such as `details | activity | history`.
2. Build stable `SidePanelTabRecord<PagePayload>` descriptors and initialize `useSidePanelTabs` with validated state.
3. Project descriptors into `SidePanelTabItem` values for the tab strip.
4. Render active content in the page adapter by switching on `payload.kind`.
5. Supply the same launcher sections to the plus popover and empty panel.
6. If state is persisted, version and scope it in the adapter and pass writes through `onStateChange`.
## Accessibility and review
The tab strip uses the tabs pattern with roving focus, Arrow/Home/End navigation, named close actions, middle-click close, and live reorder/close announcements. Closing the active tab chooses the right neighbor, then the left; closing the final tab sends focus to the launcher. Reduced-motion preferences disable component transitions and smooth overflow scrolling.
Review `Navigation/Side Panel` in Storybook before changing production adapters. Compare light/dark, minimum/default/wide/maximized, mobile, overflow, empty, launcher, unavailable-resource, and task-composition stories. Tune spacing, radius, contrast, icon size, and motion centrally through the side-panel tokens in `ui/src/index.css`.
@@ -0,0 +1,61 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import { SidePanelFrame, SidePanelToggleButton, SidePanelWindowControls } from "./SidePanelFrame";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("side-panel shell controls", () => {
let root: Root;
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("exposes controlled presentation, visibility, maximize, and content layout state", async () => {
await act(async () => root.render(
<SidePanelFrame presentation="sheet" open={false} maximized resizing contentMode="full-bleed">
Body
</SidePanelFrame>,
));
const frame = container.querySelector("section")!;
expect(frame.getAttribute("data-presentation")).toBe("sheet");
expect(frame.getAttribute("data-maximized")).toBe("true");
expect(frame.getAttribute("data-resizing")).toBe("true");
expect(frame.getAttribute("aria-hidden")).toBe("true");
expect(frame.firstElementChild?.className).toContain("overflow-hidden");
});
it("reports toggle, maximize, restore, and panel-toggle actions", async () => {
const onToggle = vi.fn();
const onMaximizedChange = vi.fn();
const onWindowToggle = vi.fn();
await act(async () => root.render(
<TooltipProvider>
<SidePanelToggleButton open={false} onToggle={onToggle} shortcut="]" />
<SidePanelWindowControls maximized={false} onMaximizedChange={onMaximizedChange} onToggle={onWindowToggle} />
</TooltipProvider>,
));
const toggleButtons = container.querySelectorAll<HTMLButtonElement>('[aria-label="Toggle side panel"]');
expect(toggleButtons).toHaveLength(2);
expect(toggleButtons[0]?.getAttribute("aria-pressed")).toBe("false");
expect(toggleButtons[1]?.getAttribute("aria-pressed")).toBe("true");
await act(async () => toggleButtons[0]?.click());
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="Maximize side panel"]')?.click());
await act(async () => toggleButtons[1]?.click());
expect(onToggle).toHaveBeenCalledOnce();
expect(onMaximizedChange).toHaveBeenCalledWith(true);
expect(onWindowToggle).toHaveBeenCalledOnce();
});
});
@@ -1,5 +1,5 @@
import type { CSSProperties, ReactNode } from "react";
import { Maximize2, Minimize2, PanelRightClose, PanelRightOpen, X } from "lucide-react";
import { Maximize2, Minimize2, PanelRightClose, PanelRightOpen } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
@@ -55,7 +55,7 @@ export function SidePanelFrame({
style={style}
>
{(header || trailingControls) ? (
<header className="flex h-12 min-w-0 shrink-0 items-center gap-1 border-b border-border px-2">
<header className="flex h-(--side-panel-header-height) min-w-0 shrink-0 items-center gap-1 border-b border-border px-2">
<div className="flex min-w-0 flex-1 self-stretch">{header}</div>
{trailingControls ? (
<div className="flex shrink-0 items-center gap-1">{trailingControls}</div>
@@ -115,11 +115,11 @@ export function SidePanelToggleButton({
export function SidePanelWindowControls({
maximized,
onMaximizedChange,
onClose,
onToggle,
}: {
maximized: boolean;
onMaximizedChange: (maximized: boolean) => void;
onClose: () => void;
onToggle: () => void;
}) {
return (
<>
@@ -133,16 +133,7 @@ export function SidePanelWindowControls({
>
{maximized ? <Minimize2 aria-hidden /> : <Maximize2 aria-hidden />}
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={onClose}
aria-label="Hide side panel"
title="Hide side panel"
>
<X aria-hidden />
</Button>
<SidePanelToggleButton open onToggle={onToggle} />
</>
);
}
@@ -0,0 +1,65 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FileText } from "lucide-react";
import { SidePanelLauncher } from "./SidePanelLauncher";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
(globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver;
describe("SidePanelLauncher", () => {
let root: Root;
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("renders grouped loading, failure, disabled, and already-open states", async () => {
await act(async () => root.render(
<SidePanelLauncher
sections={[
{ id: "open", label: "Open", items: [{ id: "plan", label: "Plan", icon: <FileText />, alreadyOpen: true }] },
{ id: "loading", label: "Recent", items: [], loading: true },
{ id: "failed", label: "Remote", items: [], error: "Recent files unavailable." },
{ id: "disabled", items: [{ id: "files", label: "Files", disabled: true, disabledReason: "No workspace." }] },
]}
onSelect={() => {}}
/>,
));
expect(container.textContent).toContain("Loading…");
expect(container.textContent).toContain("Recent files unavailable.");
expect(container.textContent).toContain("No workspace.");
expect(container.querySelector('[aria-label="Already open"]')).not.toBeNull();
expect(container.querySelector('[aria-disabled="true"]')).not.toBeNull();
});
it("selects enabled caller-provided items", async () => {
const onSelect = vi.fn();
await act(async () => root.render(
<SidePanelLauncher
sections={[{ id: "docs", items: [{ id: "plan", label: "Plan" }] }]}
onSelect={onSelect}
/>,
));
const option = container.querySelector<HTMLElement>('[role="option"]')!;
await act(async () => option.click());
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: "plan" }));
});
});
@@ -120,7 +120,7 @@ export function SidePanelLauncher({
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent align="start" className={cn("w-80 overflow-hidden p-0", className)}>
<PopoverContent align="start" className={cn("w-(--side-panel-launcher-width) overflow-hidden p-0", className)}>
<div className="sr-only">
<h2>{title}</h2>
<p>{description}</p>
@@ -44,11 +44,11 @@ export function SidePanelTab({
data-side-panel-tab-wrapper={id}
data-active={active ? "true" : "false"}
className={cn(
"group/side-panel-tab relative flex h-9 min-w-0 shrink-0 items-center rounded-xl border border-transparent",
"group/side-panel-tab relative flex h-(--side-panel-tab-height) min-w-0 shrink-0 items-center rounded-(--side-panel-tab-radius) border border-transparent",
"side-panel-tab-motion",
active
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/55 hover:text-foreground",
? "bg-(--side-panel-tab-active-bg) text-accent-foreground"
: "text-muted-foreground hover:bg-(--side-panel-tab-hover-bg) hover:text-foreground",
disabled && "opacity-50",
className,
)}
@@ -71,14 +71,14 @@ export function SidePanelTab({
onAuxClick={onAuxClick}
onKeyDown={onKeyDown}
className={cn(
"flex h-full min-w-0 items-center gap-1.5 rounded-xl py-1.5 pl-2 text-sm font-medium outline-none",
"flex h-full min-w-0 items-center gap-1.5 rounded-(--side-panel-tab-radius) py-1.5 pl-2 text-sm font-medium outline-none",
closable ? "pr-7" : "pr-2.5",
"focus-visible:ring-2 focus-visible:ring-ring/60",
dragHandleProps?.className,
)}
>
{icon ? <span className="flex size-4 shrink-0 items-center justify-center [&_svg]:size-4">{icon}</span> : null}
<span className="max-w-40 truncate">{label}</span>
<span className="max-w-(--side-panel-tab-label-max-width) truncate">{label}</span>
{status ? <span className="flex shrink-0 items-center">{status}</span> : null}
</button>
</TooltipTrigger>
@@ -7,6 +7,16 @@ import { FileText, SlidersHorizontal } from "lucide-react";
import { TooltipProvider } from "@/components/ui/tooltip";
import { SidePanelTabs } from "./SidePanelTabs";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
(globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver;
describe("SidePanelTabs", () => {
let container: HTMLDivElement;
let root: Root;
@@ -16,6 +26,10 @@ describe("SidePanelTabs", () => {
document.body.appendChild(container);
root = createRoot(container);
Element.prototype.scrollIntoView = vi.fn();
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
callback(0);
return 1;
});
});
afterEach(() => {
@@ -24,7 +38,12 @@ describe("SidePanelTabs", () => {
vi.restoreAllMocks();
});
function renderTabs(activeTabId = "properties", onCloseTab = vi.fn()) {
function renderTabs(
activeTabId = "properties",
onCloseTab = vi.fn(),
onActiveTabChange = vi.fn(),
onReorderTabs = vi.fn(),
) {
const tabs = [
{ id: "properties", type: "view", label: "Properties", icon: <SlidersHorizontal />, closable: true },
{ id: "document:plan", type: "document", label: "Plan", icon: <FileText />, closable: true },
@@ -35,15 +54,15 @@ describe("SidePanelTabs", () => {
<SidePanelTabs
tabs={tabs}
activeTabId={activeTabId}
onActiveTabChange={vi.fn()}
onActiveTabChange={onActiveTabChange}
onCloseTab={onCloseTab}
onReorderTabs={vi.fn()}
onReorderTabs={onReorderTabs}
onAddTab={vi.fn()}
/>
</TooltipProvider>,
);
});
return { tabs, onCloseTab };
return { tabs, onCloseTab, onActiveTabChange, onReorderTabs };
}
it("renders accessible tabs and the anchored add action", () => {
@@ -68,4 +87,37 @@ describe("SidePanelTabs", () => {
act(() => tab?.dispatchEvent(new MouseEvent("auxclick", { bubbles: true, button: 1 })));
expect(onCloseTab).toHaveBeenCalledWith("document:plan");
});
it("navigates with Arrow, Home, and End keys", () => {
const onActiveTabChange = vi.fn();
renderTabs("properties", vi.fn(), onActiveTabChange);
const properties = container.querySelector<HTMLButtonElement>('[data-side-panel-tab-target="properties"]')!;
act(() => properties.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowRight" })));
expect(onActiveTabChange).toHaveBeenCalledWith("document:plan");
const plan = container.querySelector<HTMLButtonElement>('[data-side-panel-tab-target="document:plan"]')!;
act(() => plan.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Home" })));
expect(onActiveTabChange).toHaveBeenLastCalledWith("properties");
});
it("announces and performs keyboard reordering", () => {
const onReorderTabs = vi.fn();
renderTabs("document:plan", vi.fn(), vi.fn(), onReorderTabs);
const plan = container.querySelector<HTMLButtonElement>('[data-side-panel-tab-target="document:plan"]')!;
act(() => plan.dispatchEvent(new KeyboardEvent("keydown", {
bubbles: true,
key: "ArrowLeft",
altKey: true,
shiftKey: true,
})));
expect(onReorderTabs).toHaveBeenCalledWith(["document:plan", "properties"]);
expect(container.querySelector('[aria-live="polite"]')?.textContent).toContain("Moved Plan to position 1 of 2");
});
it("recovers focus to the right neighbor after close", () => {
renderTabs("properties");
const close = container.querySelector<HTMLButtonElement>('button[aria-label="Close Properties"]')!;
act(() => close.click());
expect(document.activeElement).toBe(container.querySelector('[data-side-panel-tab-target="document:plan"]'));
});
});
@@ -118,7 +118,9 @@ export function SidePanelTabs({
useEffect(() => {
if (!activeTabId) return;
const element = findTabElement(activeTabId, "wrapper");
element?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
const reducedMotion = typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
element?.scrollIntoView({ behavior: reducedMotion ? "auto" : "smooth", block: "nearest", inline: "nearest" });
// `findTabElement` only reads the committed tab DOM for this active id.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId]);
@@ -226,7 +228,7 @@ export function SidePanelTabs({
onClick={onAddTab}
aria-label={addLabel}
title={addLabel}
className="shrink-0 rounded-xl"
className="shrink-0 rounded-(--side-panel-control-radius)"
>
<Plus aria-hidden />
</Button>
@@ -0,0 +1,57 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useSidePanelTabs } from "./use-side-panel-tabs";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("useSidePanelTabs", () => {
const mounted: Array<ReturnType<typeof createRoot>> = [];
afterEach(() => {
for (const root of mounted) act(() => root.unmount());
mounted.length = 0;
});
it("reports initial and updated serializable state to the persistence boundary", async () => {
const onStateChange = vi.fn();
function Harness() {
const controller = useSidePanelTabs({
initialState: {
tabs: [{ id: "details", type: "details", label: "Details", payload: { source: "fixture" } }],
activeTabId: "details",
},
onStateChange,
});
return (
<button
type="button"
onClick={() => controller.openTab({
id: "history",
type: "history",
label: "History",
payload: { source: "fixture" },
})}
>
Open history
</button>
);
}
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
mounted.push(root);
await act(async () => root.render(<Harness />));
expect(onStateChange).toHaveBeenLastCalledWith(expect.objectContaining({ activeTabId: "details" }));
await act(async () => container.querySelector("button")?.click());
expect(onStateChange).toHaveBeenLastCalledWith(expect.objectContaining({
activeTabId: "history",
tabs: expect.arrayContaining([expect.objectContaining({ id: "history" })]),
}));
container.remove();
});
});
@@ -59,4 +59,29 @@ describe("sidePanelTabsReducer", () => {
activeTabId: "missing",
})).toEqual({ tabs: [tab("a"), tab("b")], activeTabId: "a" });
});
it("closes other tabs and preserves the selected tab", () => {
expect(sidePanelTabsReducer(state(["a", "b", "c"], "a"), {
type: "close-others",
tabId: "b",
})).toEqual({ tabs: [tab("b")], activeTabId: "b" });
});
it("updates serializable metadata without allowing the stable id to change", () => {
expect(sidePanelTabsReducer(state(["a"]), {
type: "update",
tabId: "a",
patch: { id: "changed", label: "Renamed", payload: { value: "updated" } },
})).toEqual({
tabs: [{ ...tab("a"), label: "Renamed", payload: { value: "updated" } }],
activeTabId: "a",
});
});
it("normalizes reset state", () => {
expect(sidePanelTabsReducer(state(["a"]), {
type: "reset",
state: { tabs: [tab("b"), tab("b")], activeTabId: "missing" },
})).toEqual({ tabs: [tab("b")], activeTabId: "b" });
});
});
+8 -2
View File
@@ -27,6 +27,9 @@ function answerHasValue(answer: Answer | undefined): boolean {
function answerError(question: Question, answer: Answer | undefined): string | null {
if (question.required && !answerHasValue(answer)) return "This question is required.";
if (question.answerMode !== "text" && answer?.customText !== undefined && !answer.customText.trim()) {
return "Enter a custom answer.";
}
const value = question.answerMode === "text" ? answer?.text : answer?.customText;
if (value == null || value.length === 0) return null;
const validation = question.textValidation;
@@ -115,7 +118,10 @@ export function QuestionResponseSummary({
const values = [...selectedLabels, answer?.text, answer?.customText].filter((value): value is string => Boolean(value));
return (
<div key={question.id}>
<dt className="text-xs font-medium text-muted-foreground">{question.header ?? question.prompt}</dt>
<dt>
{question.header ? <span className="block text-xs font-medium text-muted-foreground">{question.header}</span> : null}
<span className="block text-sm text-foreground">{question.prompt}</span>
</dt>
<dd className="mt-0.5 text-foreground">{values.length > 0 ? values.join(", ") : "No answer"}</dd>
</div>
);
@@ -175,7 +181,7 @@ export function QuestionForm({
updateAnswer({
...answer,
...(!multiple && active ? { selectedOptionIds: [] } : {}),
...(!active ? { customText: undefined } : {}),
...(active ? { customText: answer.customText ?? "" } : { customText: undefined }),
});
}
@@ -20,6 +20,7 @@ import {
X,
} from "lucide-react";
import type { IssueDocument } from "@paperclipai/shared";
import type { PaperclipQuestionResponse, PaperclipQuestionSet } from "@paperclipai/adapter-utils";
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
import { MarkdownBody } from "@/components/MarkdownBody";
import { Button } from "@/components/ui/button";
@@ -33,7 +34,6 @@ import {
collectSuggestedTaskClientKeys,
getCheckboxConfirmationSelectedLabels,
getItemVerdictProgress,
getQuestionAnswerLabels,
normalizeRequestConfirmationTargetHref,
type AskUserQuestionsAnswer,
type AskUserQuestionsInteraction,
@@ -46,7 +46,7 @@ import {
type SuggestedTaskTreeNode,
} from "@/lib/issue-thread-interactions";
import { cn } from "@/lib/utils";
import { QuestionForm } from "./QuestionForm";
import { QuestionForm, QuestionResponseSummary } from "./QuestionForm";
import { TaskChatPlanPreviewCard } from "./TaskChatPlanPreviewCard";
type SharedInteractionProps = Omit<
@@ -220,19 +220,9 @@ function ResolvedInteraction({ interaction }: { interaction: IssueThreadInteract
let detail: ReactNode = null;
if (interaction.kind === "ask_user_questions" && interaction.result?.answers) {
detail = (
<ul className="mt-2 space-y-1.5">
{interaction.payload.questions.map((question) => {
const labels = getQuestionAnswerLabels({ question, answers: interaction.result?.answers ?? [] });
return (
<li key={question.id} className="text-xs text-muted-foreground">
<span className="text-foreground">{question.prompt}</span>
<span className="block truncate">{labels.length > 0 ? labels.join(", ") : "No answer"}</span>
</li>
);
})}
</ul>
);
const questionSet = questionSetForInteraction(interaction);
const response = questionResponseForInteraction(interaction, questionSet);
detail = response ? <div className="mt-2"><QuestionResponseSummary questionSet={questionSet} response={response} /></div> : null;
} else if (interaction.kind === "request_checkbox_confirmation") {
const labels = getCheckboxConfirmationSelectedLabels({ payload: interaction.payload, result: interaction.result });
detail = labels.length > 0 ? <p className="mt-1 text-xs text-muted-foreground">{labels.join(", ")}</p> : null;
@@ -260,19 +250,10 @@ function ResolvedInteraction({ interaction }: { interaction: IssueThreadInteract
);
}
function AskUserQuestionsCard({
interaction,
onSubmitInteractionAnswers,
onCancelInteraction,
errorMessage,
}: {
interaction: AskUserQuestionsInteraction;
onSubmitInteractionAnswers?: SharedInteractionProps["onSubmitInteractionAnswers"];
onCancelInteraction?: SharedInteractionProps["onCancelInteraction"];
errorMessage: (error: unknown) => string;
}) {
const questionSet = {
schema: "paperclip.question_set.v1" as const,
function questionSetForInteraction(interaction: AskUserQuestionsInteraction): PaperclipQuestionSet {
if (interaction.payload.questionSet) return interaction.payload.questionSet;
return {
schema: "paperclip.question_set.v1",
...(interaction.title ? { title: interaction.title } : {}),
...(interaction.payload.submitLabel ? { submitLabel: interaction.payload.submitLabel } : {}),
questions: interaction.payload.questions.map((question) => {
@@ -292,26 +273,57 @@ function AskUserQuestionsCard({
};
}),
};
const initialResponse = interaction.result?.answers ? {
schema: "paperclip.question_response.v1" as const,
answers: Object.fromEntries(interaction.result.answers.map((answer) => [answer.questionId, {
selectedOptionIds: answer.optionIds,
...(answer.otherText ? { customText: answer.otherText } : {}),
}])),
} : null;
}
function questionResponseForInteraction(
interaction: AskUserQuestionsInteraction,
questionSet: PaperclipQuestionSet,
): PaperclipQuestionResponse | null {
if (!interaction.result?.answers) return null;
return {
schema: "paperclip.question_response.v1",
answers: Object.fromEntries(interaction.result.answers.map((answer) => {
const question = questionSet.questions.find((candidate) => candidate.id === answer.questionId);
return [answer.questionId, question?.answerMode === "text"
? { ...(answer.otherText ? { text: answer.otherText } : {}) }
: {
selectedOptionIds: answer.optionIds,
...(answer.otherText ? { customText: answer.otherText } : {}),
}];
})),
};
}
function AskUserQuestionsCard({
interaction,
onSubmitInteractionAnswers,
onCancelInteraction,
errorMessage,
}: {
interaction: AskUserQuestionsInteraction;
onSubmitInteractionAnswers?: SharedInteractionProps["onSubmitInteractionAnswers"];
onCancelInteraction?: SharedInteractionProps["onCancelInteraction"];
errorMessage: (error: unknown) => string;
}) {
const questionSet = questionSetForInteraction(interaction);
const initialResponse = questionResponseForInteraction(interaction, questionSet);
return (
<QuestionForm
id={interaction.id}
questionSet={questionSet}
initialResponse={initialResponse}
implicitCustomAnswer
implicitCustomAnswer={interaction.payload.questionSet === undefined}
disabled={!onSubmitInteractionAnswers}
onSubmit={async (response) => {
const answers: AskUserQuestionsAnswer[] = questionSet.questions.map((question) => ({
questionId: question.id,
optionIds: response.answers[question.id]?.selectedOptionIds ?? [],
...(response.answers[question.id]?.customText?.trim() ? { otherText: response.answers[question.id]?.customText?.trim() } : {}),
}));
const answers: AskUserQuestionsAnswer[] = questionSet.questions.map((question) => {
const answer = response.answers[question.id];
const otherText = question.answerMode === "text" ? answer?.text?.trim() : answer?.customText?.trim();
return {
questionId: question.id,
optionIds: question.answerMode === "text" ? [] : answer?.selectedOptionIds ?? [],
...(otherText ? { otherText } : {}),
};
});
try {
await onSubmitInteractionAnswers?.(interaction, answers);
} catch (error) {
@@ -200,6 +200,68 @@ describe("TaskChatInteractionCard", () => {
]);
});
it("reuses the question form for a recovered harness text request", async () => {
const submit = vi.fn();
const recovered = structuredClone(pendingAskUserQuestionsInteraction);
recovered.id = "recovered-runtime-question";
recovered.title = "Configure deployment";
recovered.payload = {
version: 1,
supersedeOnUserComment: false,
submitLabel: "Continue",
questions: [{
id: "replicas",
prompt: "How many replicas?",
selectionMode: "single",
required: true,
options: [{ id: "__paperclip_text__", label: "Enter an integer", freeText: true }],
}],
questionSet: {
schema: "paperclip.question_set.v1",
title: "Configure deployment",
description: "The provider disconnected while waiting for this answer.",
submitLabel: "Continue",
questions: [{
id: "replicas",
header: "Replicas",
prompt: "How many replicas?",
required: true,
answerMode: "text",
textValidation: { inputType: "integer", minimum: 1, maximum: 10 },
}],
},
};
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<TaskChatInteractionCard
item={interactionItem(recovered)}
onSubmitInteractionAnswers={submit}
/>
</ThemeProvider>
</TooltipProvider>,
);
});
expect(container.textContent).toContain("The provider disconnected while waiting for this answer.");
expect(container.textContent).toContain("Write an answer · Required");
const textarea = container.querySelector("textarea")!;
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
setter?.call(textarea, "3");
textarea.dispatchEvent(new Event("input", { bubbles: true }));
});
const send = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Continue");
await act(async () => send?.click());
expect(submit).toHaveBeenCalledWith(recovered, [{
questionId: "replicas",
optionIds: [],
otherText: "3",
}]);
});
it("paginates item verdicts instead of expanding the whole review set", async () => {
flushSync(() => {
root.render(
@@ -168,4 +168,166 @@ describe("TaskChatProtocolCard", () => {
expect(onDecision).toHaveBeenCalledWith({ action: "submit", values: { environment: "production" } });
expect(container.textContent).toContain("Submitting…");
});
it("submits the canonical response from a v2 harness question set", async () => {
const onDecision = vi.fn().mockResolvedValue(undefined);
renderCard(root, {
id: "canonical-input-request",
kind: "protocol",
surface: "runtime_request",
runId: "run-1",
requestId: "request-canonical-input",
requestKind: "runtime",
turnId: "turn-1",
requestType: "input",
status: "pending",
prompt: "Codex needs your input.",
choices: [],
fields: [],
questionSet: {
schema: "paperclip.question_set.v1",
title: "Deployment input",
submitLabel: "Continue",
questions: [
{
id: "environment",
header: "Environment",
prompt: "Where should we deploy?",
required: true,
answerMode: "single_select",
options: [
{ id: "staging", label: "Staging" },
{ id: "production", label: "Production" },
],
},
{
id: "regions",
header: "Regions",
prompt: "Which regions should receive the release?",
required: false,
answerMode: "multi_select",
options: [{ id: "us", label: "US" }, { id: "eu", label: "EU" }],
},
{
id: "notes",
header: "Notes",
prompt: "Anything else we should know?",
required: false,
answerMode: "text",
},
],
},
}, onDecision);
expect(container.textContent).toContain("Deployment input");
expect(container.textContent).toContain("Question 1 of 3");
const production = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("Production"));
await act(async () => production?.click());
let next = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Next");
await act(async () => next?.click());
expect(container.textContent).toContain("Which regions should receive the release?");
next = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Next");
await act(async () => next?.click());
expect(container.textContent).toContain("Anything else we should know?");
const submit = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Continue");
await act(async () => submit?.click());
expect(onDecision).toHaveBeenCalledWith({
action: "submit",
response: {
schema: "paperclip.question_response.v1",
answers: { environment: { selectedOptionIds: ["production"] } },
},
});
});
it("renders the original questions and canonical answers after resolution", () => {
renderCard(root, {
id: "resolved-input-request",
kind: "protocol",
surface: "runtime_request",
runId: "run-1",
requestId: "request-resolved-input",
requestKind: "runtime",
turnId: "turn-1",
requestType: "input",
status: "resolved",
prompt: "Codex needs your input.",
choices: [],
fields: [],
resolvedAction: "submit",
questionSet: {
schema: "paperclip.question_set.v1",
title: "Server setup",
questions: [{
id: "style",
header: "Code style",
prompt: "Which module style should the server use?",
required: true,
answerMode: "single_select",
options: [{ id: "esm", label: "TypeScript ESM" }],
}],
},
response: {
schema: "paperclip.question_response.v1",
answers: { style: { selectedOptionIds: ["esm"] } },
},
});
expect(container.querySelector('[data-testid="task-chat-runtime-request-history"]')).not.toBeNull();
expect(container.textContent).toContain("Which module style should the server use?");
expect(container.textContent).toContain("TypeScript ESM");
expect(container.querySelector("textarea")).toBeNull();
});
it("requires text when an explicit custom multi-select answer is active", async () => {
renderCard(root, {
id: "custom-input-request",
kind: "protocol",
surface: "runtime_request",
runId: "run-1",
requestId: "request-custom-input",
requestKind: "runtime",
turnId: "turn-1",
requestType: "input",
status: "pending",
prompt: "Choose regions.",
choices: [],
fields: [],
questionSet: {
schema: "paperclip.question_set.v1",
questions: [
{
id: "regions",
prompt: "Which regions?",
required: true,
answerMode: "multi_select",
options: [{ id: "us", label: "US" }],
customAnswer: { enabled: true, label: "Another region" },
},
{
id: "notes",
prompt: "Notes?",
required: false,
answerMode: "text",
},
],
},
}, vi.fn());
const us = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.trim() === "US");
const custom = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Another region");
await act(async () => us?.click());
await act(async () => custom?.click());
const next = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Next");
expect(next?.disabled).toBe(true);
expect(container.textContent).toContain("Enter a custom answer.");
const textarea = container.querySelector("textarea")!;
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
setter?.call(textarea, "ap-south");
textarea.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(next?.disabled).toBe(false);
});
});
@@ -31,7 +31,7 @@ import type {
TaskChatWorkspaceChangeItem,
TaskChatWorkspaceFileItem,
} from "./task-chat-model";
import { QuestionForm } from "./QuestionForm";
import { QuestionForm, QuestionResponseSummary } from "./QuestionForm";
export interface TaskChatProtocolCardProps {
item: TaskChatProtocolItem;
@@ -331,6 +331,26 @@ function RuntimeRequestCard({ item, onDecision }: { item: TaskChatRuntimeRequest
onSubmit={(response) => submit({ action: "submit", response })}
onCancel={() => submit({ action: "cancel" })}
/>
) : item.requestType === "input" && item.status !== "pending" && item.questionSet ? (
<div className="flex flex-col gap-2" data-testid="task-chat-runtime-request-history">
{item.response ? (
<QuestionResponseSummary questionSet={item.questionSet} response={item.response} />
) : (
<div className="flex flex-col gap-2 text-sm">
{item.questionSet.questions.map((question) => (
<div key={question.id}>
{question.header ? <p className="text-xs font-medium text-muted-foreground">{question.header}</p> : null}
<p className="text-foreground">{question.prompt}</p>
</div>
))}
<p className="text-xs text-muted-foreground">
{item.status === "resolved"
? "Answer details were not recorded by this older runtime."
: `No answers were submitted; this request was ${item.status}.`}
</p>
</div>
)}
</div>
) : item.requestType === "input" && item.status === "pending" ? (
<form
className="flex flex-col gap-3"
@@ -387,6 +387,36 @@ describe("TaskChatRunnerTurn", () => {
expect(onDecision).toHaveBeenCalledWith(expect.objectContaining({ requestId: "request-1" }), { action: "accept" });
});
it("keeps a resolved question and answer visible outside folded activity", () => {
render([{
id: "resolved-request",
kind: "protocol",
surface: "runtime_request",
runId: "run-1",
requestId: "request-1",
requestKind: "runtime",
turnId: "turn-1",
requestType: "input",
status: "resolved",
prompt: "Codex needs your input.",
choices: [],
fields: [],
questionSet: {
schema: "paperclip.question_set.v1",
questions: [{ id: "goal", prompt: "What should the server do?", required: true, answerMode: "text" }],
},
response: {
schema: "paperclip.question_response.v1",
answers: { goal: { text: "Serve a small JSON API." } },
},
}], "succeeded");
const history = container.querySelector('[data-testid="task-chat-runtime-request-history"]');
expect(history?.closest(".tc-turn-fold")).toBeNull();
expect(history?.textContent).toContain("What should the server do?");
expect(history?.textContent).toContain("Serve a small JSON API.");
});
it("expands live semantic activity from the current activity row", () => {
render([
{ id: "thinking-empty", kind: "thinking", lines: [], streaming: false, lifecycleOnly: true },
@@ -15,6 +15,7 @@ import type {
TaskChatThinkingItem,
TaskChatToolItem,
} from "./task-chat-model";
import { latestPendingRuntimeRequest, latestRuntimeRequests } from "./task-chat-model";
import { TaskChatAgentIdentity } from "./TaskChatBubble";
import { TaskChatProtocolActivityRow } from "./TaskChatProtocolActivityRow";
import { TaskChatProtocolCard } from "./TaskChatProtocolCard";
@@ -387,12 +388,8 @@ export function TaskChatRunnerTurn({
const open = disclosure.runId === runId && disclosure.terminal === terminal ? disclosure.open : false;
const controlsId = `task-chat-run-activity-${useId().replaceAll(":", "")}`;
const activityItems = paperclipRunnerActivityItems(items);
const pendingRuntimeRequest = lastOf<TaskChatRuntimeRequestItem>(
items,
(item): item is TaskChatRuntimeRequestItem => item.kind === "protocol"
&& item.surface === "runtime_request"
&& item.status === "pending",
);
const pendingRuntimeRequest = latestPendingRuntimeRequest(items);
const resolvedRuntimeRequests = latestRuntimeRequests(items).filter((request) => request.status !== "pending");
const narration = latestFoldedNarration(items);
const livePlan = latestPlanActivity(activityItems);
const livePlanRevision = livePlan ? providerPlanRevision(livePlan) : null;
@@ -457,6 +454,11 @@ export function TaskChatRunnerTurn({
<TaskChatProtocolCard item={pendingRuntimeRequest} onRuntimeRequestDecision={onRuntimeRequestDecision} />
</div>
) : null}
{resolvedRuntimeRequests.map((request) => (
<div className="py-1" key={request.id}>
<TaskChatProtocolCard item={request} />
</div>
))}
{final ? (
<div className="tc-enter-bubble w-full" data-testid="task-chat-final-response">
<div className="break-words px-1 py-2 text-sm text-foreground" data-testid="task-chat-agent-bubble">
@@ -133,6 +133,35 @@ describe("TaskChatTurn", () => {
expect(fold()?.getAttribute("data-folded")).toBe("true");
});
it("keeps settled runtime question history outside the Worked fold", () => {
renderTurn({
...SETTLED,
items: [
SETTLED.items[0]!,
{
id: "question-history",
kind: "protocol",
surface: "runtime_request",
runId: "run-1",
requestId: "question-1",
requestKind: "runtime",
turnId: "turn-1",
requestType: "input",
status: "resolved",
prompt: "Choose an environment",
choices: [],
fields: [],
},
],
});
expect(fold()?.textContent).toContain("c1");
expect(fold()?.textContent).not.toContain("question-history");
const history = container.querySelector('[data-testid="task-chat-turn-persistent-history"]');
expect(history?.textContent).toContain("question-history");
expect(history?.closest(".tc-turn-fold")).toBeNull();
});
it("toggles open on summary click", () => {
renderTurn(SETTLED);
flushSync(() => summaryBtn()!.click());
+16 -2
View File
@@ -59,6 +59,13 @@ export function turnSummaryText(summary: TaskChatTurnItem["summary"]): string {
*/
export function TaskChatTurn({ item, renderChild, timestampPrefix, leading }: TaskChatTurnProps) {
const parentRow = !item.settled && item.liveStatus != null;
const persistentItems = item.settled
? item.items.filter((child) => child.kind === "protocol" && child.surface === "runtime_request")
: [];
const persistentItemIds = new Set(persistentItems.map((child) => child.id));
const foldedItems = persistentItems.length > 0
? item.items.filter((child) => !persistentItemIds.has(child.id))
: item.items;
// Parent-row live turns and settled turns start as their one-line header;
// only the headerless legacy live turn starts expanded.
const [open, setOpen] = useState(() => !item.settled && item.liveStatus == null);
@@ -76,7 +83,7 @@ export function TaskChatTurn({ item, renderChild, timestampPrefix, leading }: Ta
if (item.settled && !wasParentRow) setOpen(false);
}
const expandable = item.items.length > 0;
const expandable = foldedItems.length > 0;
const folded = (item.settled || parentRow) && !open;
const SummaryIcon = item.summary.failed ? X : Check;
@@ -151,12 +158,19 @@ export function TaskChatTurn({ item, renderChild, timestampPrefix, leading }: Ta
<div className="tc-turn-fold" data-folded={folded ? "true" : "false"} aria-hidden={folded}>
<div>
<div className="flex flex-col gap-2 pt-1">
{item.items.map((child) => (
{foldedItems.map((child) => (
<div key={child.id}>{renderChild(child)}</div>
))}
</div>
</div>
</div>
{persistentItems.length > 0 ? (
<div className="flex flex-col gap-2 pt-2" data-testid="task-chat-turn-persistent-history">
{persistentItems.map((child) => (
<div key={child.id}>{renderChild(child)}</div>
))}
</div>
) : null}
</div>
);
}
@@ -91,6 +91,8 @@ describe("TaskMessageScroller", () => {
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
document.documentElement.style.removeProperty("--motion-scrollbar-idle-delay");
vi.useRealTimers();
vi.unstubAllGlobals();
});
@@ -110,6 +112,26 @@ describe("TaskMessageScroller", () => {
expect(frame?.className).toBe("relative min-h-0 flex-1");
});
it("shows the scrollbar only while scroll activity is recent", () => {
vi.useFakeTimers();
document.documentElement.style.setProperty("--motion-scrollbar-idle-delay", "600ms");
render();
const el = scroller();
fakeGeometry(el);
expect(el.className).toContain("scrollbar-while-scrolling");
expect(el.getAttribute("data-scroll-active")).toBeNull();
el.scrollTop = 100;
el.dispatchEvent(new Event("scroll", { bubbles: true }));
expect(el.getAttribute("data-scroll-active")).toBe("true");
vi.advanceTimersByTime(599);
expect(el.getAttribute("data-scroll-active")).toBe("true");
vi.advanceTimersByTime(1);
expect(el.getAttribute("data-scroll-active")).toBeNull();
});
it("auto-follows content instantly while pinned", async () => {
render(1);
const el = scroller();
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { ArrowDown } from "lucide-react";
import { parseCssTimeMs } from "./motion-tokens";
const PIN_THRESHOLD_PX = 48;
@@ -44,8 +45,28 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
const ref = useRef<HTMLDivElement>(null);
const pinnedRef = useRef(true);
const easingRef = useRef(false);
const scrollbarIdleTimerRef = useRef<number | null>(null);
const scrollbarIdleDelayRef = useRef<number | null>(null);
const [pillPhase, setPillPhase] = useState<PillPhase>("hidden");
const showScrollbarWhileScrolling = useCallback(() => {
const el = ref.current;
if (!el) return;
el.dataset.scrollActive = "true";
if (scrollbarIdleTimerRef.current !== null) {
window.clearTimeout(scrollbarIdleTimerRef.current);
}
const idleDelay = scrollbarIdleDelayRef.current ?? parseCssTimeMs(
getComputedStyle(document.documentElement).getPropertyValue("--motion-scrollbar-idle-delay"),
);
scrollbarIdleDelayRef.current = idleDelay;
scrollbarIdleTimerRef.current = window.setTimeout(() => {
delete el.dataset.scrollActive;
scrollbarIdleTimerRef.current = null;
scrollbarIdleDelayRef.current = null;
}, idleDelay);
}, []);
const showPill = useCallback(() => {
setPillPhase("in");
}, []);
@@ -69,6 +90,7 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
}, []);
const handleScroll = useCallback(() => {
showScrollbarWhileScrolling();
const pinned = isPinned();
if (easingRef.current) {
// Smooth re-follow in flight: intermediate scroll events must not
@@ -83,7 +105,7 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
pinnedRef.current = pinned;
if (pinned) hidePill();
else showPill();
}, [isPinned, hidePill, showPill]);
}, [isPinned, hidePill, showPill, showScrollbarWhileScrolling]);
const handleJumpToLatest = useCallback(() => {
const el = ref.current;
@@ -125,6 +147,12 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
};
}, [showPill]);
useEffect(() => () => {
if (scrollbarIdleTimerRef.current !== null) {
window.clearTimeout(scrollbarIdleTimerRef.current);
}
}, []);
// Follow new content only when already pinned; otherwise hold position.
useLayoutEffect(() => {
if (pinnedRef.current) scrollToBottom();
@@ -143,7 +171,7 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
// absolute inset-0 (not h-full): the viewport must equal the flex-sized
// wrapper exactly — percentage heights don't reliably resolve against
// flex-determined block heights, which let the thread overflow the page.
className={cn("absolute inset-0 overflow-y-auto", className)}
className={cn("scrollbar-while-scrolling absolute inset-0 overflow-y-auto", className)}
data-testid="task-chat-scroller"
>
{children}
+2 -1
View File
@@ -63,8 +63,9 @@ export const MOTION_TOKENS: MotionTokenDef[] = [
{ name: "--motion-interstitial-dwell", group: "States", kind: "time", min: 0, max: 10000, step: 100 },
{ name: "--motion-scroll-pill-enter", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-scroll-pill-exit", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-scrollbar-idle-delay", group: "States", kind: "time", min: 0, max: 2000, step: 10 },
{ name: "--motion-pane-glide", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-side-panel-tab", group: "States", kind: "time", min: 0, max: 1000, step: 10 },
{ name: "--motion-side-panel-tab", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
];
/** Common easing presets offered by the tweak panel's easing picker. */
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import {
latestPendingRuntimeRequest,
type TaskChatRuntimeRequestItem,
} from "./task-chat-model";
function runtimeRequest(
requestId: string,
status: TaskChatRuntimeRequestItem["status"],
): TaskChatRuntimeRequestItem {
return {
id: `${requestId}:${status}`,
kind: "protocol",
surface: "runtime_request",
runId: "run-1",
requestId,
requestKind: "runtime",
turnId: "turn-1",
requestType: "input",
status,
prompt: "Choose",
choices: [],
fields: [],
};
}
describe("latestPendingRuntimeRequest", () => {
it("suppresses a stale pending card after every terminal lifecycle outcome", () => {
for (const status of ["resolved", "expired", "cancelled"] as const) {
expect(latestPendingRuntimeRequest([
runtimeRequest("request-1", "pending"),
runtimeRequest("request-1", status),
])).toBeNull();
}
});
it("retains another request that is still pending", () => {
expect(latestPendingRuntimeRequest([
runtimeRequest("request-1", "pending"),
runtimeRequest("request-2", "pending"),
runtimeRequest("request-2", "resolved"),
])).toMatchObject({ requestId: "request-1", status: "pending" });
});
});
@@ -366,6 +366,8 @@ export interface TaskChatRuntimeRequestItem {
choices: Array<{ key: string; label: string }>;
fields: Array<{ name: string; label: string; placeholder: string | null }>;
questionSet?: PaperclipQuestionSet | null;
resolvedAction?: string | null;
response?: PaperclipQuestionResponse | null;
}
export type TaskChatRuntimeRequestDecision =
@@ -481,6 +483,38 @@ export type TaskChatItem =
| TaskChatBriefItem
| TaskChatProtocolItem;
/** Latest lifecycle state wins for each request; terminal entries suppress stale pending cards. */
export function latestPendingRuntimeRequest(
items: readonly TaskChatItem[],
): TaskChatRuntimeRequestItem | null {
const seen = new Set<string>();
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (item.kind !== "protocol" || item.surface !== "runtime_request") continue;
const key = `${item.runId}:${item.requestId}`;
if (seen.has(key)) continue;
seen.add(key);
if (item.status === "pending") return item;
}
return null;
}
/** One latest lifecycle entry per request, in the order each request began. */
export function latestRuntimeRequests(
items: readonly TaskChatItem[],
): TaskChatRuntimeRequestItem[] {
const latest = new Map<string, { order: number; item: TaskChatRuntimeRequestItem }>();
for (const [order, item] of items.entries()) {
if (item.kind !== "protocol" || item.surface !== "runtime_request") continue;
const key = `${item.runId}:${item.requestId}`;
const existing = latest.get(key);
latest.set(key, { order: existing?.order ?? order, item });
}
return [...latest.values()]
.sort((left, right) => left.order - right.order)
.map(({ item }) => item);
}
/** A structured plan entry (ACP PlanEntry) for the Plans tab. */
export interface TaskChatPlanEntry {
id: string;
@@ -18,6 +18,7 @@ import type {
TaskChatProtocolItem,
TaskChatProtocolStep,
} from "./task-chat-model";
import { latestRuntimeRequests } from "./task-chat-model";
import { isGenericToolName, mcpToolSegment, toolTaxonomy } from "./tool-taxonomy";
const TERMINAL_STATUSES = new Set([
@@ -589,6 +590,8 @@ export function transcriptToTaskChatItems(
choices: entry.choices,
fields: entry.fields,
questionSet: entry.questionSet,
resolvedAction: entry.resolvedAction,
response: entry.response,
};
const existingIndex = protocolIndexByKey.get(key);
if (existingIndex == null) {
@@ -736,7 +739,14 @@ export function transcriptToTaskChatItems(
* run log / classic transcript.
*/
export function settledRunChildren(parsed: readonly TaskChatItem[]): TaskChatTurnChildItem[] {
return buildActivityPhases(parsed, false);
const runtimeRequests = latestRuntimeRequests(parsed);
const activity = buildActivityPhases(parsed.filter((item) =>
item.kind !== "protocol" || item.surface !== "runtime_request"
), false);
// Questions and their answers are issue-thread history, not diagnostic run
// activity. Keep them as direct turn children so TaskChatTurn can preserve
// them outside the collapsed Worked disclosure.
return [...activity, ...runtimeRequests];
}
/**
@@ -0,0 +1,252 @@
// @vitest-environment jsdom
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue, IssueDocument } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import {
taskPanelPropertiesTab,
writeTaskSidePanelState,
} from "@/lib/task-side-panel-state";
import { TaskSidePanel } from "./TaskSidePanel";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
(globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver;
const fixture = vi.hoisted(() => ({
documents: [] as IssueDocument[],
plan: null as IssueDocument | null,
}));
const routeFixture = vi.hoisted(() => ({
location: {
pathname: "/issues/PAP-1",
search: "",
hash: "",
state: null as unknown,
},
navigate: vi.fn(),
}));
vi.mock("@/hooks/useIssueDocuments", () => ({
useIssueDocuments: () => ({ data: fixture.documents }),
}));
vi.mock("@/hooks/useIssuePlanDocument", () => ({
useIssuePlanDocument: () => ({ data: fixture.plan }),
}));
vi.mock("@/lib/router", () => ({
useLocation: () => routeFixture.location,
useNavigate: () => routeFixture.navigate,
}));
vi.mock("@/components/IssueProperties", () => ({
IssueProperties: () => <div>Properties content</div>,
}));
vi.mock("@/components/issue-properties/IssuePropertiesArtifactsTab", () => ({
IssuePropertiesArtifactsTab: () => <div>Artifacts content</div>,
}));
vi.mock("@/components/issue-properties/IssuePropertiesPlansTab", () => ({
IssuePropertiesPlansTab: () => <div>Plan content</div>,
}));
vi.mock("@/components/WorkspaceFileBrowser", () => ({
WorkspaceFileBrowser: () => <div>Files browser</div>,
}));
vi.mock("./TaskDocumentPanel", () => ({
TaskDocumentPanel: ({ documentKey }: { documentKey: string }) => <div>{`Document ${documentKey}`}</div>,
}));
vi.mock("./TaskWorkspaceFilePanel", () => ({
TaskWorkspaceFilePanel: () => <div>Workspace file</div>,
}));
function issue(overrides: Partial<Issue> = {}): Issue {
return {
id: "task-1",
companyId: "company-1",
title: "Portable tabs",
workMode: "agent",
...overrides,
} as Issue;
}
function issueDocument(key: string, title: string): IssueDocument {
return {
id: `document-${key}`,
issueId: "task-1",
key,
title,
body: `# ${title}`,
latestRevisionNumber: 1,
createdAt: "2026-08-23T12:00:00.000Z",
updatedAt: "2026-08-23T12:00:00.000Z",
} as unknown as IssueDocument;
}
describe("TaskSidePanel", () => {
let root: Root;
let container: HTMLDivElement;
let queryClient: QueryClient;
beforeEach(() => {
window.localStorage.clear();
fixture.documents = [];
fixture.plan = null;
routeFixture.location.search = "";
window.history.replaceState(null, "", routeFixture.location.pathname);
routeFixture.navigate.mockReset();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
Element.prototype.scrollIntoView = vi.fn();
HTMLElement.prototype.scrollTo = vi.fn();
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
callback(0);
return 1;
});
});
afterEach(() => {
act(() => root.unmount());
queryClient.clear();
container.remove();
vi.restoreAllMocks();
});
async function render(node: ReactNode) {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>{node}</TooltipProvider>
</QueryClientProvider>,
);
});
}
function panel(props: Partial<Parameters<typeof TaskSidePanel>[0]> = {}) {
return (
<TaskSidePanel
issue={issue()}
accountScope="user-1"
onUpdate={() => {}}
fileTabsEnabled={false}
inline
{...props}
/>
);
}
it("opens Properties on first visit", async () => {
await render(panel());
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Properties");
expect(container.textContent).toContain("Properties content");
});
it("opens and selects Plan for a planning task", async () => {
await render(panel({ issue: issue({ workMode: "planning" }) }));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Plan");
expect(container.textContent).toContain("Plan content");
});
it("opens a newly materialized plan until the user has interacted", async () => {
await render(panel());
fixture.plan = issueDocument("plan", "Plan");
fixture.documents = [fixture.plan];
await render(panel());
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Plan");
});
it("does not auto-open a plan after manual tab interaction", async () => {
await render(panel());
await act(async () => container.querySelector<HTMLButtonElement>('#side-panel-tab-properties')?.click());
fixture.plan = issueDocument("plan", "Plan");
fixture.documents = [fixture.plan];
await render(panel());
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Properties");
expect(container.querySelector('[data-side-panel-tab-target="document:plan"]')).toBeNull();
});
it("lets a persisted intentionally empty state win over defaults", async () => {
writeTaskSidePanelState("user-1", "company-1", "task-1", {
state: { tabs: [], activeTabId: null },
launcherOpen: false,
userInteracted: true,
autoPlanHandled: true,
updatedAt: 1,
});
await render(panel({ issue: issue({ workMode: "planning" }) }));
expect(container.querySelector('[role="tab"]')).toBeNull();
expect(container.getElementsByTagName("input")[0]?.getAttribute("aria-label")).toBe("Search tabs and resources…");
});
it("opens an ordinary document deep link in a deduplicated document tab", async () => {
fixture.documents = [issueDocument("brief", "Implementation brief")];
await render(panel({ documentDeepLink: { requestId: 1, documentKey: "brief" } }));
expect(container.querySelectorAll('[data-side-panel-tab-target="document:brief"]')).toHaveLength(1);
expect(container.textContent).toContain("Document brief");
await render(panel({ documentDeepLink: { requestId: 2, documentKey: "brief" } }));
expect(container.querySelectorAll('[data-side-panel-tab-target="document:brief"]')).toHaveLength(1);
});
it("keeps Files out of the launcher when the experiment is disabled", async () => {
writeTaskSidePanelState("user-1", "company-1", "task-1", {
state: { tabs: [], activeTabId: null },
launcherOpen: false,
userInteracted: true,
autoPlanHandled: true,
updatedAt: 1,
});
await render(panel());
expect(Array.from(container.querySelectorAll('[role="option"]')).some((item) => item.textContent?.includes("Files"))).toBe(false);
});
it("restores a persisted Properties tab", async () => {
writeTaskSidePanelState("user-1", "company-1", "task-1", {
state: { tabs: [taskPanelPropertiesTab()], activeTabId: "properties" },
launcherOpen: false,
userInteracted: true,
autoPlanHandled: true,
updatedAt: 1,
});
await render(panel({ issue: issue({ workMode: "planning" }) }));
expect(container.querySelectorAll('[role="tab"]')).toHaveLength(1);
expect(container.textContent).toContain("Properties content");
});
it("opens a URL-backed workspace file tab outside file-viewer provider ancestry", async () => {
routeFixture.location.search = "?file=ui%2Fsrc%2FApp.tsx&line=7&workspace=project";
window.history.replaceState(null, "", `${routeFixture.location.pathname}${routeFixture.location.search}`);
await render(panel({ fileTabsEnabled: true }));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("App.tsx");
expect(container.textContent).toContain("Workspace file");
});
it("clears file-viewer URL state when switching to a non-file tab", async () => {
routeFixture.location.search = "?file=ui%2Fsrc%2FApp.tsx&line=7&workspace=project";
window.history.replaceState(null, "", `${routeFixture.location.pathname}${routeFixture.location.search}`);
await render(panel({ fileTabsEnabled: true }));
await act(async () => container.querySelector<HTMLButtonElement>("#side-panel-tab-properties")?.click());
expect(routeFixture.navigate).toHaveBeenCalledWith(
expect.objectContaining({ pathname: "/issues/PAP-1", search: "" }),
expect.objectContaining({ replace: false }),
);
});
});
@@ -30,6 +30,7 @@ import { IssuePropertiesArtifactsTab } from "@/components/issue-properties/Issue
import { IssuePropertiesPlansTab } from "@/components/issue-properties/IssuePropertiesPlansTab";
import {
SidePanelLauncher,
SidePanelToggleButton,
SidePanelTabs,
useSidePanelTabs,
type SidePanelLauncherItem,
@@ -38,12 +39,23 @@ import {
type SidePanelTabRecord,
} from "@/components/side-panel";
import { Button } from "@/components/ui/button";
import { useFileViewer } from "@/context/FileViewerContext";
import {
FILE_VIEWER_NAVIGATE_OPTIONS,
getCurrentFileViewerSearch,
readBrowseStateFromSearch,
readFileViewerStateFromSearch,
shouldNavigateFileViewerSearch,
writeBrowseStateToSearch,
writeFileViewerStateToSearch,
type FileViewerBrowseState,
type FileViewerUrlState,
} from "@/context/FileViewerContext";
import { useIssueDocuments } from "@/hooks/useIssueDocuments";
import type { IssueExternalObjectGroup } from "@/hooks/useIssueExternalObjects";
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
import { documentDisplayTitle } from "@/lib/issue-artifacts";
import { queryKeys } from "@/lib/queryKeys";
import { useLocation, useNavigate } from "@/lib/router";
import {
readTaskSidePanelState,
taskPanelArtifactsTab,
@@ -74,6 +86,7 @@ export interface TaskSidePanelProps {
checkingMonitorNow?: boolean;
fileTabsEnabled: boolean;
documentDeepLink?: { requestId: number; documentKey: string } | null;
onRequestClose?: () => void;
}
function tabIcon(tab: SidePanelTabRecord<TaskSidePanelTabPayload>): ReactNode {
@@ -90,6 +103,86 @@ function selectorForWorkspaceKind(kind: "execution_workspace" | "project_workspa
return kind === "execution_workspace" ? "execution" as const : "project" as const;
}
/**
* The desktop panel is rendered beside the route outlet, so task file routing
* deliberately lives in this adapter instead of depending on provider ancestry.
*/
function useTaskSidePanelFileRouting() {
const location = useLocation();
const navigate = useNavigate();
const state = useMemo(() => readFileViewerStateFromSearch(location.search), [location.search]);
const browseState = useMemo(() => readBrowseStateFromSearch(location.search), [location.search]);
const navigateSearch = useCallback((nextSearch: string, replace = false) => {
if (!shouldNavigateFileViewerSearch(nextSearch, location.search)) return;
navigate(
{ pathname: location.pathname, hash: location.hash, search: nextSearch },
{ ...FILE_VIEWER_NAVIGATE_OPTIONS, replace, state: location.state },
);
}, [location.hash, location.pathname, location.search, location.state, navigate]);
const open = useCallback((ref: FileViewerUrlState, options?: {
fromBrowse?: boolean;
browseState?: Partial<FileViewerBrowseState>;
}) => {
let nextSearch = writeFileViewerStateToSearch(location.search, ref);
if (options?.fromBrowse) {
const params = new URLSearchParams(nextSearch);
const previous = new URLSearchParams(location.search);
params.set("browse", "1");
const query = Object.prototype.hasOwnProperty.call(options.browseState ?? {}, "q")
? options.browseState?.q
: previous.get("q");
const folder = Object.prototype.hasOwnProperty.call(options.browseState ?? {}, "folderPath")
? options.browseState?.folderPath
: previous.get("folder");
if (query) params.set("q", query);
else params.delete("q");
if (folder) params.set("folder", folder);
else params.delete("folder");
nextSearch = params.toString() ? `?${params.toString()}` : "";
}
navigateSearch(nextSearch);
}, [location.search, navigateSearch]);
const openBrowse = useCallback((options?: { q?: string }) => {
const params = new URLSearchParams(location.search);
params.delete("file");
params.delete("line");
params.delete("column");
params.delete("folder");
params.set("browse", "1");
if (options?.q) params.set("q", options.q);
else params.delete("q");
navigateSearch(params.toString() ? `?${params.toString()}` : "");
}, [location.search, navigateSearch]);
const updateBrowseState = useCallback((next: Partial<FileViewerBrowseState>) => {
navigateSearch(writeBrowseStateToSearch(location.search, next), true);
}, [location.search, navigateSearch]);
const close = useCallback(() => {
const currentSearch = getCurrentFileViewerSearch(location.search);
const params = new URLSearchParams(writeFileViewerStateToSearch(currentSearch, null).replace(/^\?/, ""));
params.delete("browse");
params.delete("q");
params.delete("folder");
navigateSearch(params.toString() ? `?${params.toString()}` : "");
}, [location.search, navigateSearch]);
return {
state,
browse: browseState !== null,
query: browseState?.q ?? null,
folderPath: browseState?.folderPath ?? null,
browseProjectId: browseState?.projectId ?? null,
browseWorkspaceId: browseState?.workspaceId ?? null,
open,
openBrowse,
updateBrowseState,
close,
};
}
export function TaskSidePanel({
issue,
accountScope,
@@ -106,8 +199,9 @@ export function TaskSidePanel({
checkingMonitorNow = false,
fileTabsEnabled,
documentDeepLink,
onRequestClose,
}: TaskSidePanelProps) {
const viewer = useFileViewer();
const viewer = useTaskSidePanelFileRouting();
const { data: documents = [] } = useIssueDocuments(issue.id);
const { data: planDocument } = useIssuePlanDocument(issue.id);
const restoredRef = useRef(
@@ -166,13 +260,32 @@ export function TaskSidePanel({
// Existing URL-backed workspace links remain the external integration API.
useEffect(() => {
if (!fileTabsEnabled || !viewer) return;
if (!fileTabsEnabled) return;
if (viewer.state) {
controller.openTab(taskPanelWorkspaceFileTab(viewer.state));
} else if (viewer.browse) {
controller.openTab(taskPanelFilesTab());
controller.updateTab("files", {
payload: {
kind: "files-browser",
query: viewer.query,
folderPath: viewer.folderPath,
projectId: viewer.browseProjectId,
workspaceId: viewer.browseWorkspaceId,
},
});
}
}, [controller.openTab, fileTabsEnabled, viewer?.browse, viewer?.state]);
}, [
controller.openTab,
controller.updateTab,
fileTabsEnabled,
viewer.browse,
viewer.browseProjectId,
viewer.browseWorkspaceId,
viewer.folderPath,
viewer.query,
viewer.state,
]);
const recentFilesQuery = useQuery({
queryKey: queryKeys.issues.fileResources(issue.id, {
@@ -202,7 +315,7 @@ export function TaskSidePanel({
markInteracted();
controller.selectTab(tabId);
const tab = controller.tabs.find((candidate) => candidate.id === tabId);
if (!viewer || !tab) return;
if (!tab) return;
if (tab.payload.kind === "workspace-file") {
viewer.open(tab.payload);
} else if (tab.payload.kind === "files-browser") {
@@ -228,7 +341,7 @@ export function TaskSidePanel({
markInteracted();
const tab = controller.tabs.find((candidate) => candidate.id === tabId);
controller.closeTab(tabId);
if (tabId === controller.activeTabId && viewer && (tab?.payload.kind === "workspace-file" || tab?.payload.kind === "files-browser")) {
if (tabId === controller.activeTabId && (tab?.payload.kind === "workspace-file" || tab?.payload.kind === "files-browser")) {
viewer.close();
}
}
@@ -330,7 +443,7 @@ export function TaskSidePanel({
else if (item.id === "artifacts") controller.openTab(taskPanelArtifactsTab());
else if (item.id === "files") {
controller.openTab(taskPanelFilesTab());
viewer?.openBrowse();
viewer.openBrowse();
} else if (item.id.startsWith("document:")) {
const key = item.id.slice("document:".length);
controller.openTab(taskPanelDocumentTab(key, item.label));
@@ -357,7 +470,7 @@ export function TaskSidePanel({
open={launcherOpen}
onOpenChange={setLauncherOpen}
trigger={(
<Button type="button" variant="ghost" size="icon-sm" className="shrink-0 rounded-xl" aria-label="Open a new tab">
<Button type="button" variant="ghost" size="icon-sm" className="shrink-0 rounded-(--side-panel-control-radius)" aria-label="Open a new tab">
<Plus aria-hidden />
</Button>
)}
@@ -416,15 +529,18 @@ export function TaskSidePanel({
issueId={issue.id}
companyId={issue.companyId}
onOpen={openWorkspaceFile}
onBrowseStateChange={(next) => controller.updateTab(activeTab.id, {
payload: {
kind: "files-browser",
query: next.q,
folderPath: next.folderPath,
projectId: next.projectId,
workspaceId: next.workspaceId,
},
})}
onBrowseStateChange={(next) => {
controller.updateTab(activeTab.id, {
payload: {
kind: "files-browser",
query: next.q,
folderPath: next.folderPath,
projectId: next.projectId,
workspaceId: next.workspaceId,
},
});
viewer.updateBrowseState(next);
}}
initialQuery={activeTab.payload.query}
initialFolderPath={activeTab.payload.folderPath}
initialProjectId={activeTab.payload.projectId}
@@ -434,14 +550,28 @@ export function TaskSidePanel({
/>
);
} else {
content = <TaskWorkspaceFilePanel issueId={issue.id} payload={activeTab.payload} />;
const filePayload = activeTab.payload;
content = (
<TaskWorkspaceFilePanel
issueId={issue.id}
payload={filePayload}
onFallbackToProject={filePayload.workspace !== "project" && !filePayload.projectId && !filePayload.workspaceId
? () => openWorkspaceFile({ ...filePayload, workspace: "project" })
: undefined}
/>
);
}
const contentMode = activeTab?.contentMode ?? "full-bleed";
return (
<div className="flex h-full min-h-0 flex-col">
{paneHeaderSlot ? createPortal(tabStrip, paneHeaderSlot) : (
<div className="flex h-12 shrink-0 items-center border-b border-border px-2">{tabStrip}</div>
<div className="flex h-(--side-panel-header-height) shrink-0 items-center gap-1 border-b border-border px-2">
{tabStrip}
{onRequestClose ? (
<SidePanelToggleButton open onToggle={onRequestClose} />
) : null}
</div>
)}
<div
ref={bodyRef}
@@ -1,8 +1,8 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, Check, Copy, Download, Loader2, RefreshCw } from "lucide-react";
import { Check, Copy, Download } from "lucide-react";
import { fileResourcesApi } from "@/api/file-resources";
import { FileContentViewer, FileViewerMetadataRow } from "@/components/FileViewerSheet";
import { FileViewerBody, FileViewerMetadataRow } from "@/components/FileViewerSheet";
import { Button } from "@/components/ui/button";
import type { FileViewerUrlState } from "@/context/FileViewerContext";
import { copyTextToClipboard } from "@/lib/clipboard";
@@ -11,8 +11,17 @@ import type { TaskSidePanelTabPayload } from "@/lib/task-side-panel-state";
type WorkspaceFilePayload = Extract<TaskSidePanelTabPayload, { kind: "workspace-file" }>;
export function TaskWorkspaceFilePanel({ issueId, payload }: { issueId: string; payload: WorkspaceFilePayload }) {
export function TaskWorkspaceFilePanel({
issueId,
payload,
onFallbackToProject,
}: {
issueId: string;
payload: WorkspaceFilePayload;
onFallbackToProject?: () => void;
}) {
const [copied, setCopied] = useState(false);
const [announcement, setAnnouncement] = useState("");
const state: FileViewerUrlState = {
path: payload.path,
workspace: payload.workspace,
@@ -73,44 +82,20 @@ export function TaskWorkspaceFilePanel({ issueId, payload }: { issueId: string;
</div>
</header>
<div className="min-h-0 flex-1 overflow-hidden">
{resourceQuery.isLoading || (resource?.capabilities.preview && contentQuery.isLoading) ? (
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground" role="status">
<Loader2 className="size-4 animate-spin" aria-hidden />
Loading file preview…
</div>
) : resourceQuery.isError || contentQuery.isError ? (
<div className="flex items-start gap-3 p-6 text-sm" role="alert">
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden />
<div className="space-y-3">
<div>
<p className="font-medium">File is unavailable</p>
<p className="text-muted-foreground">The workspace may have moved, expired, or no longer contain this path.</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
void resourceQuery.refetch();
if (resource?.capabilities.preview) void contentQuery.refetch();
}}
>
<RefreshCw aria-hidden />
Retry
</Button>
</div>
</div>
) : contentQuery.data ? (
<FileContentViewer content={contentQuery.data} highlightedLine={payload.line} />
) : (
<div className="flex items-start gap-3 p-6 text-sm">
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden />
<div>
<p className="font-medium">Preview not available</p>
<p className="text-muted-foreground">Use Download when this workspace permits it.</p>
</div>
</div>
)}
<div className="sr-only" aria-live="polite">{announcement}</div>
<FileViewerBody
resolveQuery={resourceQuery}
contentQuery={contentQuery}
elapsedMs={0}
canPreview={resource?.capabilities.preview ?? false}
highlightedLine={payload.line}
onRetry={() => {
void resourceQuery.refetch();
if (resource?.capabilities.preview) void contentQuery.refetch();
}}
onSetAnnouncement={setAnnouncement}
onFallbackToProject={onFallbackToProject ?? null}
/>
</div>
</div>
);
+48 -1
View File
@@ -91,6 +91,10 @@
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--scrollbar-thumb: oklch(0.7 0 0);
--scrollbar-thumb-hover: oklch(0.6 0 0);
--scrollbar-size: 8px;
--scrollbar-radius: 999px;
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
@@ -250,11 +254,23 @@
--motion-interstitial-dwell: 4000ms; /* interstitial update hold: minimum before the next swaps in, and how long a finished update sits before sliding away */
--motion-scroll-pill-enter: 200ms; /* scroll-to-latest pill entry */
--motion-scroll-pill-exit: 400ms; /* scroll-to-latest pill exit */
--motion-scrollbar-idle-delay: 600ms; /* keep the task-thread thumb visible briefly after scrolling settles */
--motion-ease-scroll-pill-in: cubic-bezier(0.23, 1, 0.32, 1);
--motion-ease-scroll-pill-out: cubic-bezier(0.7, 0, 0.84, 0);
--motion-pane-glide: 320ms; /* properties-pane maximize/restore glide */
--motion-side-panel-tab: var(--motion-duration-fast);
/* Portable side-panel geometry and hierarchy. These stay centralized so
task, project, agent, and future hosts render the same Codex-like chrome. */
--side-panel-header-height: var(--sz-48px);
--side-panel-tab-height: var(--sz-36px);
--side-panel-tab-label-max-width: var(--sz-100px);
--side-panel-tab-radius: var(--radius-xl);
--side-panel-control-radius: var(--radius-xl);
--side-panel-launcher-width: var(--sz-320px);
--side-panel-tab-active-bg: color-mix(in oklab, var(--accent) 88%, var(--background));
--side-panel-tab-hover-bg: color-mix(in oklab, var(--accent) 58%, transparent);
/* Composer mode-chip hues (v5–v7 decisions): agent + plan reuse the task
status hues; ask is the v5 blue. Consumed as `--sc` seeds through the
.status-chip color-mix recipe. */
@@ -308,6 +324,8 @@
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--scrollbar-thumb: oklch(0.4 0 0);
--scrollbar-thumb-hover: oklch(0.5 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
@@ -477,6 +495,32 @@
background: transparent !important;
}
/* Task thread: keep the gutter available without painting a persistent rail.
Scroll events temporarily add data-scroll-active; hovering alone stays idle. */
.scrollbar-while-scrolling {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
.scrollbar-while-scrolling::-webkit-scrollbar {
width: var(--scrollbar-size);
height: var(--scrollbar-size);
background: transparent;
}
.scrollbar-while-scrolling::-webkit-scrollbar-track,
.scrollbar-while-scrolling::-webkit-scrollbar-thumb {
background: transparent;
}
.scrollbar-while-scrolling[data-scroll-active="true"] {
scrollbar-color: var(--scrollbar-thumb) transparent;
}
.scrollbar-while-scrolling[data-scroll-active="true"]::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: var(--scrollbar-radius);
}
.scrollbar-while-scrolling[data-scroll-active="true"]::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
/* The task-chat shell owns its desktop scroll viewport. Release Layout's
otherwise-useful reserved main scrollbar gutter so that viewport can meet
the properties-pane border instead of stopping one scrollbar-width early. */
@@ -569,6 +613,8 @@
/* --motion-interstitial-dwell is deliberately NOT zeroed: it is a pacing
hold, not a movement — zeroing it would make the interstitial line churn
faster under reduced motion. Swaps themselves snap (line-scroll: 0). */
/* --motion-scrollbar-idle-delay is also a visibility hold, not movement;
keeping it lets reduced-motion users see the thumb while scrolling. */
--motion-scroll-pill-enter: 0ms;
--motion-scroll-pill-exit: 0ms;
--motion-pane-glide: 0ms;
@@ -728,7 +774,8 @@
.tc-turn-fold,
.tc-turn-metrics,
.tc-line-scroll-inner,
.tc-pane-glide { transition: none; }
.tc-pane-glide,
.side-panel-tab-motion { transition: none; }
/* The pill keyframes carry the X-centering; with animation:none restore it. */
.tc-scroll-pill-in,
.tc-scroll-pill-out { transform: translate(-50%, 0); }
+28
View File
@@ -255,6 +255,34 @@ export function buildIssueThreadInteractionSummary(
return count === 1 ? "Asked 1 question" : `Asked ${count} questions`;
}
/** Readable model input for a durable answer delivered into a successor run. */
export function buildAnsweredQuestionsDeliveryText(
interaction: AskUserQuestionsInteraction,
): string {
const questionSet = interaction.payload.questionSet;
const legacyQuestionById = new Map(
interaction.payload.questions.map((question) => [question.id, question] as const),
);
const canonicalQuestionById = new Map(
(questionSet?.questions ?? []).map((question) => [question.id, question] as const),
);
const lines = (interaction.result?.answers ?? []).map((answer) => {
const canonical = canonicalQuestionById.get(answer.questionId);
const legacy = legacyQuestionById.get(answer.questionId);
const optionLabels = new Map(
(canonical?.options ?? legacy?.options ?? []).map((option) => [option.id, option.label] as const),
);
const values = answer.optionIds.map((optionId) => optionLabels.get(optionId) ?? optionId);
if (answer.otherText?.trim()) values.push(answer.otherText.trim());
const prompt = canonical?.prompt ?? legacy?.prompt ?? answer.questionId;
const label = canonical?.header && canonical.header !== prompt
? `${canonical.header} — ${prompt}`
: prompt;
return `- ${label}: ${values.join(", ") || "No answer"}`;
});
return ["Answered questions", ...(lines.length > 0 ? ["", ...lines] : [])].join("\n");
}
export function buildSuggestedTaskTree(
tasks: readonly SuggestedTaskDraft[],
): SuggestedTaskTreeNode[] {
+28 -1
View File
@@ -1171,6 +1171,33 @@ describe("IssueDetail", () => {
).toBe(false);
});
it("shows the main-pane side-panel toggle only while the panel is closed", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue());
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
expect(container.querySelector('button[aria-label="Toggle side panel"]')).toBeNull();
mockPanelState.panelVisible = false;
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
expect(
container.querySelector('button[aria-label="Toggle side panel"]')?.getAttribute("aria-pressed"),
).toBe("false");
});
it("opens a closed desktop pane and routes an ordinary document to its own tab on direct load", async () => {
mockPanelState.panelVisible = false;
mockLocation.hash = "#document-qa-evidence";
@@ -1360,7 +1387,7 @@ describe("IssueDetail", () => {
}));
});
const panel = document.querySelector('[data-slot="sheet-content"]');
const panel = document.querySelector('[data-testid="mobile-task-side-panel"]');
expect(panel).not.toBeNull();
expect(panel?.className).toContain("max-h-(--sz-85dvh)");
expect(panel?.textContent).toContain("Task side panel");
+65 -13
View File
@@ -174,6 +174,7 @@ import { Textarea } from "@/components/ui/textarea";
import { formatIssueActivityAction } from "@/lib/activity-format";
import { copyTextToClipboard } from "../lib/clipboard";
import { buildIssuePropertiesPanelKey } from "../lib/issue-properties-panel-key";
import { buildAnsweredQuestionsDeliveryText } from "../lib/issue-thread-interactions";
import { resolveIssueDocumentDeepLink } from "../lib/issue-document-deep-link";
import { buildIssueSiblingNavigation, shouldRenderRichSubIssuesSection } from "../lib/issue-detail-subissues";
import { filterIssueDescendants } from "../lib/issue-tree";
@@ -1332,7 +1333,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
}
}
return comments.map((comment) => {
const projectedComments = comments.map((comment) => {
const meta = runMetaByCommentId.get(comment.id);
const inputPlacement = inputPlacementByCommentId.get(comment.id);
const nextComment: IssueDetailComment = {
@@ -1391,8 +1392,49 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
}
return nextComment;
});
const interactionById = new Map(interactions.map((interaction) => [interaction.id, interaction] as const));
const runById = new Map(resolvedLinkedRuns.map((run) => [run.runId, run] as const));
const deliveredInteractionIds = new Set<string>();
const deliveryComments = resolvedActivity.flatMap((event): IssueDetailComment[] => {
if (event.action !== "issue.question_response_delivered") return [];
const details = event.details ?? {};
const interactionId = typeof details["interactionId"] === "string" ? details["interactionId"] : null;
const targetRunId = typeof details["targetRunId"] === "string" ? details["targetRunId"] : null;
const deliveryMode = typeof details["deliveryMode"] === "string" ? details["deliveryMode"] : null;
if (!interactionId || !targetRunId || deliveredInteractionIds.has(interactionId)) return [];
const interaction = interactionById.get(interactionId);
if (!interaction || interaction.kind !== "ask_user_questions" || interaction.status !== "answered") return [];
deliveredInteractionIds.add(interactionId);
const targetRun = runById.get(targetRunId);
const eventAt = event.createdAt instanceof Date ? event.createdAt : new Date(event.createdAt);
const runAtValue = targetRun?.startedAt ?? targetRun?.createdAt ?? null;
const runAt = runAtValue ? new Date(runAtValue) : null;
const anchorAt = deliveryMode === "steered" || !runAt || Number.isNaN(runAt.getTime())
? eventAt
: runAt;
return [{
id: `question-response:${interactionId}`,
companyId: interaction.companyId,
issueId: interaction.issueId,
authorType: interaction.resolvedByAgentId ? "agent" : "user",
authorAgentId: interaction.resolvedByAgentId ?? null,
authorUserId: interaction.resolvedByUserId ?? null,
createdByRunId: interaction.resolvedByRunId ?? null,
body: buildAnsweredQuestionsDeliveryText(interaction),
presentation: null,
metadata: null,
createdAt: anchorAt,
updatedAt: anchorAt,
consumedByRunId: targetRunId,
...(deliveryMode === "steered" ? { steeredIntoRunId: targetRunId } : {}),
conversationAnchorAt: anchorAt,
conversationAnchorSequence: 0,
}];
});
return [...projectedComments, ...deliveryComments];
}, [
comments,
interactions,
liveRunIds,
locallyQueuedCommentRunIds,
queuedCommentReason,
@@ -4982,17 +5024,21 @@ export function IssueDetail() {
>
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
</Button>
<SidePanelToggleButton
open={panelVisible && !suppressPanelForFirstTask}
onToggle={() => {
if (suppressPanelForFirstTask && issue?.id) {
setFirstTaskPanelOverrideIssueId(issue.id);
}
setPanelVisible(!(panelVisible && !suppressPanelForFirstTask));
}}
shortcut="]"
className="shrink-0"
/>
{panelVisible && !suppressPanelForFirstTask ? null : (
<TooltipProvider>
<SidePanelToggleButton
open={false}
onToggle={() => {
if (suppressPanelForFirstTask && issue?.id) {
setFirstTaskPanelOverrideIssueId(issue.id);
}
setPanelVisible(true);
}}
shortcut="]"
className="shrink-0"
/>
</TooltipProvider>
)}
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
<PopoverTrigger asChild>
@@ -5866,6 +5912,7 @@ export function IssueDetail() {
<Sheet open={mobilePropsOpen} onOpenChange={setMobilePropsOpen}>
<SheetContent
side={taskChatShellEnabled ? "bottom" : documentDeepLink?.documentKey === "plan" ? "right" : "bottom"}
showCloseButton={!taskChatShellEnabled}
className={cn(
taskChatShellEnabled
? "h-(--sz-85dvh) max-h-(--sz-85dvh) gap-0 p-0 pb-(--sz-safe-bottom)"
@@ -5873,7 +5920,11 @@ export function IssueDetail() {
? "inset-0 h-dvh w-screen max-w-none gap-0 border-0 p-0 sm:max-w-none"
: "max-h-(--sz-85dvh) pb-(--sz-safe-bottom)",
)}
data-testid={!taskChatShellEnabled && documentDeepLink?.documentKey === "plan" ? "mobile-plan-panel" : undefined}
data-testid={taskChatShellEnabled
? "mobile-task-side-panel"
: documentDeepLink?.documentKey === "plan"
? "mobile-plan-panel"
: undefined}
>
{taskChatShellEnabled ? (
<>
@@ -5897,6 +5948,7 @@ export function IssueDetail() {
checkingMonitorNow={checkIssueMonitorNow.isPending}
fileTabsEnabled={fileViewerEnabled}
documentDeepLink={documentDeepLink?.issueId === issue.id ? documentDeepLink : null}
onRequestClose={() => setMobilePropsOpen(false)}
/>
</>
) : (
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more