mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
chore(runner): preserve integration work before PR split
This commit is contained in:
1 parent
761b60b42c
commit
e3df307545
101 files changed
+5865
-395
No files matched your search
@@ -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 };
|
||||
|
||||
|
||||
@@ -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, ¶ms)
|
||||
let native_question_set = codex_question_set(method, ¶ms);
|
||||
if native_question_set.is_none() && has_codex_question_form(method, ¶ms) {
|
||||
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, ¶ms, &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,
|
||||
|
||||
+5
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1079,6 +1079,9 @@ export type {
|
||||
SuggestTasksResult,
|
||||
AskUserQuestionsQuestionOption,
|
||||
AskUserQuestionsQuestion,
|
||||
PaperclipQuestionSetOption,
|
||||
PaperclipQuestionSetQuestion,
|
||||
PaperclipQuestionSetPayload,
|
||||
AskUserQuestionsPayload,
|
||||
AskUserQuestionsAnswer,
|
||||
AskUserQuestionsResult,
|
||||
|
||||
@@ -703,6 +703,9 @@ export type {
|
||||
SuggestTasksResult,
|
||||
AskUserQuestionsQuestionOption,
|
||||
AskUserQuestionsQuestion,
|
||||
PaperclipQuestionSetOption,
|
||||
PaperclipQuestionSetQuestion,
|
||||
PaperclipQuestionSetPayload,
|
||||
AskUserQuestionsPayload,
|
||||
AskUserQuestionsAnswer,
|
||||
AskUserQuestionsResult,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]+((?:"|'))`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" })]);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -936,7 +936,7 @@ interface FileViewerBodyProps {
|
||||
onFallbackToProject: null | (() => void);
|
||||
}
|
||||
|
||||
function FileViewerBody({
|
||||
export function FileViewerBody({
|
||||
resolveQuery,
|
||||
contentQuery,
|
||||
elapsedMs,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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", [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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); }
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
Reference in new issue
Block a user