mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
feat(task-chat): approve harness-proposed plans (Codex plan mode, Claude ExitPlanMode) (#19291)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents run through the native runner. Codex and Claude Code each have their own plan mode, and each produces a proposed-plan document. > - Paperclip already has a planning work mode, an issue `plan` document, and a `request_confirmation` approval gate for plans. > - These parts were not connected. The Codex proposed-plan item was transcript text only. The server path never put Codex into plan mode. Claude was never put into plan mode, and its ExitPlanMode plan was dropped. A planning run could close its task as done with no approval. > - This pull request turns a harness's own proposed plan into the issue's Plan document and the existing approval gate, for Codex and Claude. > - The benefit is that users approve or send back the agent's real plan from the task chat. Approve resumes the agent. Request changes sends the reason back to the agent. ## Linked Issues or Issue Description Refs #19252 **Subsystem affected** Native runner provider events (Codex and ACPX Claude), the runner Codex provider (Rust), server native plan synchronization, and the task chat UI. **Problem or motivation** Codex and Claude both produce a proposed-plan document in their native plan mode. Paperclip did not use it. In live tests on the server path, a Codex planning run was not in Codex plan mode, saved its own `implementation-plan` document, and marked the task done without approval. Claude planning runs were not in Claude plan mode, and the ExitPlanMode plan text never reached Paperclip. **Proposed solution** Add a canonical `plan.proposed` event for a harness's proposed plan. Put Codex and Claude into their native plan mode for planning runs. Save a completed proposal as the issue `plan` document with the existing `request_confirmation` gate. Agents can also propose a plan on a standard task; the work mode stays standard. **Alternatives considered** Reuse `plan.updated`. Its schema is the within-turn checklist and is not the durable Plan document, so a separate event keeps the two clear. Stronger prompt text without native plan mode. Live tests showed that the model kept its own document workflow. **Roadmap alignment** This reuses the issue plan document, planning work mode and approval gate that already exist. It adds one canonical provider event. ## What Changed - `packages/paperclip-runner/protocol/schemas/*`: add the `plan.proposed` event (`paperclip.plan.proposed.v1`: `proposalId`, `status`, `markdown` up to 60k characters, `source`). Regenerate the schema bundle, validators and manifest. - `packages/paperclip-runner/src/provider-events.ts`: map the Codex `plan` item and a `<proposed_plan>` block in a completed Codex message to `plan.proposed`. Add `claudePlanProposalEvent`. - `packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs`: send Codex's native `collaborationMode: plan` on `turn/start` for a read-only (planning) Codex run. Before this change the server path never sent it. - `runner-core/src/acpx_event_payload.rs`: admit `plan.proposed` as ACPX rich display activity. - `drivers/acpx/codex-runtime-adapter.ts`: set Claude Code to native plan mode for a read-only run. Capture Claude's ExitPlanMode plan from its permission request, record it, and decline the native "Ready to code?" prompt. - `drivers/acpx/codex-acpx-driver.ts`, `cli/acpx-runtime-sidecar.ts`: emit the captured proposal as a rich event. Complete that turn instead of reporting it as cancelled. - `backends/codex-native-backend.ts`: per-harness plan-mode instructions, and a short opt-in hint for Claude on standard tasks. - `server/src/services/native-runtime/native-session-executor.ts`: synchronize a completed `plan.proposed` into the `plan` document and a `request_confirmation` in planning and standard mode. Do not change the issue status during the run (that revoked the run's lease). The run finalizer turns the pending confirmation into the review wait. - `server/src/services/heartbeat-run-summary.ts`: keep a `<proposed_plan>` block out of the posted reply. - UI: new `plan_proposal` provider family. The task chat shows a live "Writing plan" card while a proposal streams and a one-line record when it completes. Resolved plan reviews keep a receipt ("Approved Plan v1", "Requested changes to Plan v1: reason"). Plan previews skip section headings. - Storybook: `Tasks and conversations/.../Task chat/Proposed plan approval` shows the journey. - `doc/SPEC-implementation.md`: describe harness-proposed plan approval. ## Verification - `cargo test -p paperclip-runner-core --lib` (new: `read_only_codex_turns_use_native_plan_collaboration_mode`) - `cd packages/paperclip-runner && npx vitest run src/provider-events.test.ts src/drivers/acpx/codex-runtime-adapter.test.ts` - `cd server && npx vitest run src/__tests__/native-plan-proposal-sync.test.ts src/__tests__/heartbeat-run-summary.test.ts` - `cd ui && npx vitest run src/components/transcript/native-run-events.test.ts src/components/task-chat` - Live test on a local worktree instance with real harnesses: - Codex (`gpt-5.6-sol`), planning task: Codex asked one question, then produced a native plan item. The task showed Plan v1 with Approve plan / Request changes and moved to in review. Approve changed the work mode to standard. Codex then implemented the plan and its tests passed. - Codex, Request changes with a reason: Codex woke in planning mode and produced Plan v2 that includes the change. - Claude (`claude-opus-5-5`), planning task: Claude was in plan mode. ExitPlanMode became Plan v1 and the turn completed. Approve resumed Claude in standard mode, and Claude implemented the plan. - Claude, standard task that asks for a plan first: Claude stopped before code changes and asked for plan approval. The work mode stayed standard. - Storybook: open `Proposed plan approval` and check each stage. ## Risks - Codex planning runs now really use Codex plan mode on the server path. Codex can ask a clarifying question before it proposes. This matches Codex plan mode. - Claude planning runs now use Claude plan mode. A declined ExitPlanMode ends the Claude turn; the runner reports it as completed. Claude's tool row for that request records a rejection from Claude's side. - Codex does not stream the proposed plan on the server path yet, so the live card shows a placeholder until the plan completes. - A `<proposed_plan>` block in any completed Codex message on a standard task now asks for approval. This is the documented opt-in path. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use (shell, file edits, browser). Long context. No extended thinking mode was used. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1b0f78100d
commit
22281d4b3a
51 files changed
+2015
-116
No files matched your search
@@ -452,6 +452,7 @@ retain their titles and default to no generation request.
|
||||
- `standard`: normal autonomous execution. Agents may investigate, edit files, create artifacts, and complete the task.
|
||||
- `ask`: answer-only execution. Agents may use tools for investigation or temporary scratch work, but the deliverable is an issue-thread answer; they must not write implementation code or produce an implementation plan.
|
||||
- `planning`: plan-only execution. Agents create or revise the plan without implementation work. Accepting a fresh confirmation for the issue's current `plan` revision atomically changes this mode to `standard`, so the continuation may implement the approved plan on the source issue.
|
||||
- Harness-proposed plans: a native run's own proposed-plan surface (Codex plan collaboration mode `plan` item or `<proposed_plan>` block, Claude Code ExitPlanMode) emits the canonical `plan.proposed` event. A completed proposal is saved as the issue's `plan` document and gated by a `request_confirmation` bound to that revision, in `planning` and `standard` work mode alike. A `planning` run starts the harness in its native plan mode. Synchronization never changes the issue status mid-run; the run finalizer turns the pending confirmation into the review wait. An agent-initiated proposal on a `standard` issue leaves the work mode unchanged.
|
||||
- Work mode is explicit persisted state. Titles, descriptions, and requested deliverables never select or change it. A `standard` task may deliver a requested plan, including a canonical `plan` document, and complete without entering `planning` mode. Existing explicit approval requirements still apply.
|
||||
- `billing_code` text null
|
||||
- `assignee_adapter_overrides` jsonb null
|
||||
|
||||
@@ -607,6 +607,7 @@ export interface ServerAdapterModule {
|
||||
|
||||
export type ProviderActivityFamily =
|
||||
| "plan"
|
||||
| "plan_proposal"
|
||||
| "tool_execution"
|
||||
| "research"
|
||||
| "delegation"
|
||||
|
||||
@@ -55,17 +55,17 @@
|
||||
{
|
||||
"path": "schemas/event-v2.schema.json",
|
||||
"id": "https://paperclip.dev/schemas/prp/v2/event.schema.json",
|
||||
"sha256": "d7aef6f8ce3acf700462b30f52b38153a8a1b4ee86782cfa2991fff258c9e907"
|
||||
"sha256": "d7459d9fc186f142b0ea648fd116a2bee52cf7ea71001946be6e2991ccd2ed55"
|
||||
},
|
||||
{
|
||||
"path": "schemas/event-v3.schema.json",
|
||||
"id": "https://paperclip.dev/schemas/prp/v3/event.schema.json",
|
||||
"sha256": "cee13d1d3c5de444722af5d83e2c96dbba17b1aef8eb6d4c036e9c71fb717f47"
|
||||
"sha256": "f600e7445e3db04eb394c2d9bb6ab85130f6944b47636372fe9f46f4f8a18939"
|
||||
},
|
||||
{
|
||||
"path": "schemas/event.schema.json",
|
||||
"id": "https://paperclip.dev/schemas/prp/v1/event.schema.json",
|
||||
"sha256": "67611b6d3adbd5731d94d01963e5a35924b7424063851bf60e394fe392685995"
|
||||
"sha256": "8fd8437758a6e75006a8806bd5e64558393f4d5777febf4e64fbb60c1f4b86e6"
|
||||
},
|
||||
{
|
||||
"path": "schemas/fixture.schema.json",
|
||||
@@ -90,7 +90,7 @@
|
||||
{
|
||||
"path": "schemas/provider-event.schema.json",
|
||||
"id": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json",
|
||||
"sha256": "e8cdfbe17f2c780a6010896d01be8c6dfb9a5f3727d13814fe4f703bb7fcdbc4"
|
||||
"sha256": "fcb55885300fd19fdcae26a754a7a3b3ccd76f6e05251367bec4c5b88d12f6b1"
|
||||
},
|
||||
{
|
||||
"path": "schemas/provider-failure.schema.json",
|
||||
|
||||
@@ -266,6 +266,7 @@
|
||||
"eventType": {
|
||||
"enum": [
|
||||
"plan.updated",
|
||||
"plan.proposed",
|
||||
"tool.execution.started",
|
||||
"tool.execution.progressed",
|
||||
"tool.execution.completed",
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"runtime.phase.changed", "sandbox.metric", "workspace.ready", "workspace.change.updated", "workspace.diff.recorded", "workspace.file.referenced",
|
||||
"auth.credentials.updated",
|
||||
"runtime.activity.recorded",
|
||||
"harness.starting", "harness.ready", "harness.exited", "harness.diagnostic", "plan.updated",
|
||||
"harness.starting", "harness.ready", "harness.exited", "harness.diagnostic", "plan.updated", "plan.proposed",
|
||||
"tool.execution.started", "tool.execution.progressed", "tool.execution.completed", "research.started", "research.progressed", "research.completed",
|
||||
"delegation.started", "delegation.updated", "delegation.completed", "model.route.changed", "model.verification.updated", "context.compacted",
|
||||
"artifact.viewed", "artifact.generated", "review.mode.changed", "hook.started", "hook.completed", "memory.citation.referenced",
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
"harness.exited",
|
||||
"harness.diagnostic",
|
||||
"plan.updated",
|
||||
"plan.proposed",
|
||||
"tool.execution.started",
|
||||
"tool.execution.progressed",
|
||||
"tool.execution.completed",
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"auth.credentials.updated",
|
||||
"runtime.activity.recorded",
|
||||
"harness.starting", "harness.ready", "harness.exited", "harness.diagnostic",
|
||||
"plan.updated",
|
||||
"plan.updated", "plan.proposed",
|
||||
"tool.execution.started", "tool.execution.progressed", "tool.execution.completed",
|
||||
"research.started", "research.progressed", "research.completed",
|
||||
"delegation.started", "delegation.updated", "delegation.completed",
|
||||
@@ -73,6 +73,7 @@
|
||||
"allOf": [
|
||||
{ "if": { "properties": { "eventType": { "const": "auth.credentials.updated" } } }, "then": { "properties": { "sourceKind": { "const": "runner" }, "priority": { "const": 0 }, "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/auth-credentials.schema.json" } } } },
|
||||
{ "if": { "properties": { "eventType": { "const": "plan.updated" } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/plan" } } } },
|
||||
{ "if": { "properties": { "eventType": { "const": "plan.proposed" } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/planProposal" } } } },
|
||||
{ "if": { "properties": { "eventType": { "enum": ["tool.execution.started", "tool.execution.progressed", "tool.execution.completed"] } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/toolExecution" } } } },
|
||||
{ "if": { "properties": { "eventType": { "enum": ["research.started", "research.progressed", "research.completed"] } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/research" } } } },
|
||||
{ "if": { "properties": { "eventType": { "enum": ["delegation.started", "delegation.updated", "delegation.completed"] } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/delegation" } } } },
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"title": "Provider-neutral runner activity payload",
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/plan" },
|
||||
{ "$ref": "#/$defs/planProposal" },
|
||||
{ "$ref": "#/$defs/toolExecution" },
|
||||
{ "$ref": "#/$defs/research" },
|
||||
{ "$ref": "#/$defs/delegation" },
|
||||
@@ -42,6 +43,19 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"planProposal": {
|
||||
"type": "object",
|
||||
"description": "A provider-authored proposed plan document awaiting human approval (Codex plan item, Claude ExitPlanMode). Paperclip may synchronize a completed proposal into the issue's durable Plan document; it is distinct from the within-turn plan.updated checklist.",
|
||||
"required": ["schema", "proposalId", "status", "markdown", "source"],
|
||||
"properties": {
|
||||
"schema": { "const": "paperclip.plan.proposed.v1" },
|
||||
"proposalId": { "$ref": "#/$defs/id" },
|
||||
"status": { "enum": ["streaming", "completed"] },
|
||||
"markdown": { "type": "string", "maxLength": 60000 },
|
||||
"source": { "enum": ["codex_plan_item", "claude_exit_plan_mode"] }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"toolExecution": {
|
||||
"type": "object",
|
||||
"required": ["schema", "executionId", "transport", "operation", "status", "output", "outputBytes", "outputTruncated", "outputDigest"],
|
||||
|
||||
@@ -222,6 +222,7 @@ pub fn decode_acpx_event(
|
||||
fn rich_event_schema(event_type: &str) -> Option<&'static str> {
|
||||
Some(match event_type {
|
||||
"plan.updated" => "plan",
|
||||
"plan.proposed" => "planProposal",
|
||||
"tool.execution.started" | "tool.execution.progressed" | "tool.execution.completed" => {
|
||||
"toolExecution"
|
||||
}
|
||||
@@ -277,7 +278,7 @@ fn rich_event_validators(
|
||||
if !internal_refs(&schema) { return Err("provider-event schema may use only internal references".to_owned()); }
|
||||
let definitions = schema.get("$defs").and_then(Value::as_object).ok_or_else(|| "provider-event schema has no definitions".to_owned())?;
|
||||
let mut validators = BTreeMap::new();
|
||||
for name in ["plan", "toolExecution", "research", "delegation", "modelRoute", "modelVerification", "contextCompacted", "artifactViewed", "artifactGenerated", "reviewMode", "hook", "memoryCitation", "safetyReview", "terminalInput", "wait", "providerNotice"] {
|
||||
for name in ["plan", "planProposal", "toolExecution", "research", "delegation", "modelRoute", "modelVerification", "contextCompacted", "artifactViewed", "artifactGenerated", "reviewMode", "hook", "memoryCitation", "safetyReview", "terminalInput", "wait", "providerNotice"] {
|
||||
if !definitions.contains_key(name) { return Err("provider-event schema is missing an admitted definition".to_owned()); }
|
||||
let selected = serde_json::json!({"$schema":"https://json-schema.org/draft/2020-12/schema", "$ref":format!("#/$defs/{name}"), "$defs":definitions});
|
||||
let validator = jsonschema::validator_for(&selected).map_err(|_| "embedded provider-event schema cannot compile".to_owned())?;
|
||||
|
||||
@@ -451,6 +451,40 @@ pub struct CodexProviderConfig {
|
||||
pub conversation_mode: Option<String>,
|
||||
}
|
||||
|
||||
/// Codex's native plan collaboration mode for a read-only Codex run. The
|
||||
/// preset keeps Codex's built-in plan-mode instructions (`developer_instructions`
|
||||
/// null) and the run's own model and effort.
|
||||
fn codex_plan_collaboration_mode(
|
||||
config: &CodexProviderConfig,
|
||||
observed_model: Option<&str>,
|
||||
effort: Option<&Value>,
|
||||
) -> Result<Option<Value>, LocalRunnerError> {
|
||||
if config.provider != "codex" || !config.read_only {
|
||||
return Ok(None);
|
||||
}
|
||||
// A model-less launch uses the model Codex reported for the thread. A
|
||||
// planning run must never silently fall back to default mode.
|
||||
let model = config
|
||||
.model
|
||||
.as_deref()
|
||||
.or(observed_model)
|
||||
.map(str::trim)
|
||||
.filter(|model| !model.is_empty())
|
||||
.ok_or_else(|| {
|
||||
LocalRunnerError::invalid(
|
||||
"planning_mode_unsupported: Codex reported no model for native plan collaboration mode",
|
||||
)
|
||||
})?;
|
||||
Ok(Some(json!({
|
||||
"mode": "plan",
|
||||
"settings": {
|
||||
"model": model,
|
||||
"reasoning_effort": effort.cloned().unwrap_or(Value::Null),
|
||||
"developer_instructions": Value::Null,
|
||||
},
|
||||
})))
|
||||
}
|
||||
|
||||
/// Explicit per-turn skill selection. The controller resolves assigned skill
|
||||
/// names to paths on the provider filesystem before submitting the command.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
@@ -2012,6 +2046,10 @@ impl CodexProvider {
|
||||
"Codex turn text is empty or exceeds the 1 MiB limit",
|
||||
));
|
||||
}
|
||||
// Resolve the planning preset before any turn state changes, so a
|
||||
// local planning error never leaves an ambiguous turn start behind.
|
||||
let collaboration_mode =
|
||||
codex_plan_collaboration_mode(&self.config, self.observed_model.as_deref(), effort)?;
|
||||
self.rollover_settled_turn_epoch_if_needed()?;
|
||||
// Preserve the prior durable result until a replacement turn identity
|
||||
// is accepted. A rejected, ambiguous, or transport-failed attempt does
|
||||
@@ -2059,6 +2097,12 @@ impl CodexProvider {
|
||||
} else {
|
||||
turn_params_object.insert("permissions".to_owned(), json!(self.permission_profile));
|
||||
}
|
||||
// A read-only (Planning) Codex run uses Codex's native plan
|
||||
// collaboration mode, so a finished proposal arrives as Codex's own
|
||||
// `plan` item for Paperclip's Plan document and approval gate.
|
||||
if let Some(collaboration_mode) = collaboration_mode {
|
||||
turn_params_object.insert("collaborationMode".to_owned(), collaboration_mode);
|
||||
}
|
||||
let result = match self.request_classified("turn/start", turn_params) {
|
||||
Ok(result) => result,
|
||||
Err(ProviderRequestError::Rejected(error)) => {
|
||||
@@ -4564,6 +4608,53 @@ fn codex_question_response(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn read_only_codex_turns_use_native_plan_collaboration_mode() {
|
||||
let mut config = CodexProviderConfig {
|
||||
provider: "codex".to_owned(),
|
||||
driver: "codex_app_server".to_owned(),
|
||||
provider_version: "fixture".to_owned(),
|
||||
command: PathBuf::from("/bin/sh"),
|
||||
args: vec![],
|
||||
cwd: "/tmp".to_owned(),
|
||||
model: Some("gpt-5.6-sol".to_owned()),
|
||||
provider_session_id: None,
|
||||
instructions: String::new(),
|
||||
approval_policy: "never".to_owned(),
|
||||
externally_sandboxed: false,
|
||||
read_only: true,
|
||||
include_skill_instructions: None,
|
||||
conversation_mode: None,
|
||||
};
|
||||
assert_eq!(
|
||||
super::codex_plan_collaboration_mode(&config, None, Some(&json!("high"))).unwrap(),
|
||||
Some(
|
||||
json!({"mode": "plan", "settings": {"model": "gpt-5.6-sol", "reasoning_effort": "high", "developer_instructions": null}})
|
||||
),
|
||||
);
|
||||
// A model-less launch uses the model Codex reported for the thread.
|
||||
config.model = None;
|
||||
assert_eq!(
|
||||
super::codex_plan_collaboration_mode(&config, Some("gpt-5.6-luna"), None).unwrap(),
|
||||
Some(
|
||||
json!({"mode": "plan", "settings": {"model": "gpt-5.6-luna", "reasoning_effort": null, "developer_instructions": null}})
|
||||
),
|
||||
);
|
||||
// With no model at all, planning fails loudly instead of running in default mode.
|
||||
assert!(super::codex_plan_collaboration_mode(&config, None, None).is_err());
|
||||
config.read_only = false;
|
||||
assert_eq!(
|
||||
super::codex_plan_collaboration_mode(&config, None, None).unwrap(),
|
||||
None
|
||||
);
|
||||
config.read_only = true;
|
||||
config.provider = "opencode".to_owned();
|
||||
assert_eq!(
|
||||
super::codex_plan_collaboration_mode(&config, None, None).unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2640,6 +2640,24 @@ fn current_timestamp() -> Result<String, DurableRunnerError> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn keeps_a_long_plan_proposal_whole_past_the_display_preview_bound() {
|
||||
use super::*;
|
||||
let markdown = format!("# Plan\n{}", "- step\n".repeat(2_000).trim_end());
|
||||
assert!(markdown.len() > 4096);
|
||||
let payload = json!({
|
||||
"schema": "paperclip.plan.proposed.v1",
|
||||
"proposalId": "plan-1",
|
||||
"status": "completed",
|
||||
"markdown": markdown,
|
||||
"source": "codex_plan_item",
|
||||
});
|
||||
let stored =
|
||||
preserve_bounded_display_content("plan.proposed", &payload, sanitize_value(&payload))
|
||||
.unwrap();
|
||||
assert_eq!(stored["markdown"].as_str(), Some(markdown.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn governance_display_records_survive_the_durable_writer_unchanged() {
|
||||
use super::*;
|
||||
|
||||
@@ -3,7 +3,7 @@ use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::acpx_event_payload::{AcpxRuntimeEventKind, AcpxTurnStatus};
|
||||
use crate::acpx_provider_state::{is_reserved_terminal_operation, AcpxProviderStateEvent};
|
||||
use crate::durable::{redact_text, sanitize_value, EventPriority};
|
||||
use crate::durable::{redact_sensitive_text_values, redact_text, sanitize_value, EventPriority};
|
||||
use crate::local_runner::LocalRunnerError;
|
||||
use crate::provider_bridge::{semantic_value_digest, ToolResult};
|
||||
use crate::stable_identity::{
|
||||
@@ -551,6 +551,71 @@ fn require_projected_turn(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Matches the `plan.proposed` schema bound: a proposed-plan document stays
|
||||
/// complete through this boundary so the TypeScript mapper can save it.
|
||||
const MAX_PLAN_PROPOSAL_CHARS: usize = 60_000;
|
||||
|
||||
/// Splits one `<proposed_plan>` block out of a message. The tags must sit on
|
||||
/// their own lines outside fenced code, so quoting or documenting the tag
|
||||
/// never creates a proposal. Mirrors `splitProposedPlanBlock` in TypeScript.
|
||||
fn proposed_plan_block(value: &str) -> Option<String> {
|
||||
let lines: Vec<&str> = value.split('\n').collect();
|
||||
let mut fence: Option<char> = None;
|
||||
let mut start: Option<usize> = None;
|
||||
for (index, line) in lines.iter().enumerate() {
|
||||
let trimmed = line.trim();
|
||||
let marker = trimmed.chars().next().filter(|first| {
|
||||
(*first == '`' || *first == '~') && trimmed.starts_with(&first.to_string().repeat(3))
|
||||
});
|
||||
// Fences are tracked inside the block too, so a fenced example tag in
|
||||
// the plan never opens or closes it.
|
||||
if let Some(marker) = marker {
|
||||
fence = match fence {
|
||||
None => Some(marker),
|
||||
Some(open) if open == marker => None,
|
||||
Some(open) => Some(open),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if fence.is_some() {
|
||||
continue;
|
||||
}
|
||||
if start.is_none() && trimmed.eq_ignore_ascii_case("<proposed_plan>") {
|
||||
start = Some(index);
|
||||
} else if let Some(open) = start {
|
||||
if trimmed.eq_ignore_ascii_case("</proposed_plan>") {
|
||||
return Some(lines[open + 1..index].join("\n").trim().to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A proposed-plan document, redacted and bounded by the `plan.proposed`
|
||||
/// schema rather than the short display bound, which would cut it.
|
||||
fn plan_proposal_markdown(value: &str) -> String {
|
||||
// Only a wrapper around the whole document is removed. Literal tags inside
|
||||
// the plan (an inline or fenced example) stay part of its text.
|
||||
let trimmed = value.trim();
|
||||
let mut lines: Vec<&str> = trimmed.split('\n').collect();
|
||||
if lines
|
||||
.first()
|
||||
.is_some_and(|line| line.trim().eq_ignore_ascii_case("<proposed_plan>"))
|
||||
{
|
||||
lines.remove(0);
|
||||
if lines
|
||||
.last()
|
||||
.is_some_and(|line| line.trim().eq_ignore_ascii_case("</proposed_plan>"))
|
||||
{
|
||||
lines.pop();
|
||||
}
|
||||
}
|
||||
redact_sensitive_text_values(lines.join("\n").trim())
|
||||
.chars()
|
||||
.take(MAX_PLAN_PROPOSAL_CHARS)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn bounded_text(value: &str, max_chars: usize) -> String {
|
||||
let redacted = redact_text(value);
|
||||
if redacted.chars().count() <= max_chars {
|
||||
@@ -1217,6 +1282,41 @@ pub fn normalize_codex_notification_in(
|
||||
let item_type = string(provider_item.get("type"));
|
||||
let provider_phase = string(provider_item.get("phase"));
|
||||
let completed = method == "item/completed";
|
||||
// A proposed plan travels as a schema-bounded rich event, so the
|
||||
// durable display bound never cuts the document.
|
||||
let proposal = match (item_type, method) {
|
||||
("plan", "item/started") => Some((false, String::new())),
|
||||
("plan", "item/completed") => Some((
|
||||
true,
|
||||
plan_proposal_markdown(string(provider_item.get("text"))),
|
||||
)),
|
||||
("agentMessage", "item/completed")
|
||||
if provider_phase.is_empty() || provider_phase == "final_answer" =>
|
||||
{
|
||||
proposed_plan_block(string(provider_item.get("text")))
|
||||
.map(|block| (true, plan_proposal_markdown(&block)))
|
||||
.filter(|(_, markdown)| !markdown.is_empty())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some((proposal_completed, markdown)) = proposal {
|
||||
push(
|
||||
&mut events,
|
||||
"plan.proposed",
|
||||
if proposal_completed {
|
||||
EventPriority::P1
|
||||
} else {
|
||||
EventPriority::P2
|
||||
},
|
||||
json!({
|
||||
"schema": "paperclip.plan.proposed.v1",
|
||||
"proposalId": item_id,
|
||||
"status": if proposal_completed { "completed" } else { "streaming" },
|
||||
"markdown": markdown,
|
||||
"source": "codex_plan_item",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if item_type == "fileChange" {
|
||||
let file_changes =
|
||||
crate::tool_file_changes::codex_file_changes(provider_item.get("changes"), cwd);
|
||||
@@ -2662,6 +2762,93 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_complete_long_plan_proposals_as_rich_events() {
|
||||
let body = format!("# Plan\n{}", "- step\n".repeat(1_000).trim_end());
|
||||
let message = format!("Ready for review.\n<proposed_plan>\n{body}\n</proposed_plan>");
|
||||
assert!(message.chars().count() > MAX_TEXT_CHARS);
|
||||
// A message carries prose around the block; a plan item is the
|
||||
// wrapped document itself.
|
||||
let plan_item = format!("<proposed_plan>\n{body}\n</proposed_plan>");
|
||||
for (kind, text) in [("agentMessage", &message), ("plan", &plan_item)] {
|
||||
let events = normalize_codex_notification(
|
||||
"item/completed",
|
||||
&json!({"item": {"id": "item-1", "type": kind, "phase": "final_answer", "text": text}}),
|
||||
);
|
||||
let proposal = events
|
||||
.iter()
|
||||
.find(|event| event.event_type == "plan.proposed")
|
||||
.unwrap_or_else(|| panic!("{kind} emits a proposal"));
|
||||
assert_eq!(
|
||||
proposal.payload["markdown"].as_str(),
|
||||
Some(body.as_str()),
|
||||
"{kind}"
|
||||
);
|
||||
assert_eq!(proposal.payload["proposalId"], "item-1");
|
||||
assert_eq!(proposal.payload["status"], "completed");
|
||||
}
|
||||
let started = normalize_codex_notification(
|
||||
"item/started",
|
||||
&json!({"item": {"id": "plan-1", "type": "plan", "text": ""}}),
|
||||
);
|
||||
assert_eq!(started[0].event_type, "plan.proposed");
|
||||
assert_eq!(started[0].payload["status"], "streaming");
|
||||
// Quoted, fenced or non-final mentions never become proposals.
|
||||
for (phase, text) in [
|
||||
(
|
||||
"final_answer",
|
||||
"Use:\n```\n<proposed_plan>\n# P\n</proposed_plan>\n```",
|
||||
),
|
||||
(
|
||||
"final_answer",
|
||||
"Inline <proposed_plan>x</proposed_plan> mention.",
|
||||
),
|
||||
("commentary", "<proposed_plan>\n# P\n</proposed_plan>"),
|
||||
] {
|
||||
let events = normalize_codex_notification(
|
||||
"item/completed",
|
||||
&json!({"item": {"id": "m", "type": "agentMessage", "phase": phase, "text": text}}),
|
||||
);
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.all(|event| event.event_type != "plan.proposed"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_a_plan_whole_when_it_contains_a_fenced_closing_tag_example() {
|
||||
let body =
|
||||
"# Plan\n\n1. Document:\n```\n<proposed_plan>\nexample\n</proposed_plan>\n```\n2. Ship";
|
||||
let events = normalize_codex_notification(
|
||||
"item/completed",
|
||||
&json!({"item": {"id": "msg-9", "type": "agentMessage", "phase": "final_answer",
|
||||
"text": format!("Ready.\n<proposed_plan>\n{body}\n</proposed_plan>")}}),
|
||||
);
|
||||
let proposal = events
|
||||
.iter()
|
||||
.find(|event| event.event_type == "plan.proposed")
|
||||
.unwrap();
|
||||
assert_eq!(proposal.payload["markdown"].as_str(), Some(body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_literal_proposed_plan_tags_inside_a_plan_body() {
|
||||
let body = "# Plan\n\n1. Format: `<proposed_plan>x</proposed_plan>`\n\n```\n<proposed_plan>\nsample\n</proposed_plan>\n```";
|
||||
let events = normalize_codex_notification(
|
||||
"item/completed",
|
||||
&json!({"item": {"id": "plan-2", "type": "plan", "text": format!("<proposed_plan>\n{body}\n</proposed_plan>")}}),
|
||||
);
|
||||
assert_eq!(events[0].payload["markdown"].as_str(), Some(body));
|
||||
let bare = normalize_codex_notification(
|
||||
"item/completed",
|
||||
&json!({"item": {"id": "plan-3", "type": "plan", "text": body}}),
|
||||
);
|
||||
assert_eq!(bare[0].payload["markdown"].as_str(), Some(body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_codex_plan_without_retaining_native_envelope() {
|
||||
let events = normalize_codex_notification(
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"attention": "Continuation and attention routing lifecycle.",
|
||||
"work": "Recorded work-assessment evidence.",
|
||||
"issue": "Issue-status decision proposal outcome and application evidence.",
|
||||
"plan": "Complete provider-authored within-turn checklist snapshots, separate from durable Paperclip Plan documents.",
|
||||
"plan": "Complete provider-authored within-turn checklist snapshots, and provider-proposed plan documents that Paperclip may synchronize into the durable Plan document for approval.",
|
||||
"tool": "Provider-neutral process, MCP, dynamic, and built-in execution activity.",
|
||||
"research": "Provider-reported search, page-open, and in-page research activity.",
|
||||
"delegation": "Child-agent delegation lifecycle and aggregate status.",
|
||||
|
||||
@@ -6,7 +6,7 @@ Status: canonical explanatory contract for the Paperclip runner V1 surface.
|
||||
|
||||
This document keeps three independent meanings of **group** separate. PRP families describe wire evidence and controller commands; capability placement decides who owns an operation; behavioral eval groups organize the 106 scenario corpus. None of the three axes can be used as a substitute for another.
|
||||
|
||||
The generated totals are **107 PRP events in 32 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **70 reconciled semantic operations** (19 always, 51 optional), and **106 scenarios in 16 behavior groups**.
|
||||
The generated totals are **108 PRP events in 32 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **70 reconciled semantic operations** (19 always, 51 optional), and **106 scenarios in 16 behavior groups**.
|
||||
|
||||
## Axis 1: PRP v1 event and command families
|
||||
|
||||
@@ -22,7 +22,7 @@ PRP records ordered, replayable execution evidence. It is not the model's Paperc
|
||||
| `workspace` | Workspace readiness. | `workspace.ready`<br>`workspace.change.updated`<br>`workspace.diff.recorded`<br>`workspace.file.referenced` | 4 |
|
||||
| `auth` | Content-free harness credential changes observed by the runner. | `auth.credentials.updated` | 1 |
|
||||
| `harness` | Provider harness startup, readiness, exit, and diagnostics. | `harness.starting`<br>`harness.ready`<br>`harness.exited`<br>`harness.diagnostic` | 4 |
|
||||
| `plan` | Complete provider-authored within-turn checklist snapshots, separate from durable Paperclip Plan documents. | `plan.updated` | 1 |
|
||||
| `plan` | Complete provider-authored within-turn checklist snapshots, and provider-proposed plan documents that Paperclip may synchronize into the durable Plan document for approval. | `plan.updated`<br>`plan.proposed` | 2 |
|
||||
| `tool` | Provider-neutral process, MCP, dynamic, and built-in execution activity. | `tool.execution.started`<br>`tool.execution.progressed`<br>`tool.execution.completed` | 3 |
|
||||
| `research` | Provider-reported search, page-open, and in-page research activity. | `research.started`<br>`research.progressed`<br>`research.completed` | 3 |
|
||||
| `delegation` | Child-agent delegation lifecycle and aggregate status. | `delegation.started`<br>`delegation.updated`<br>`delegation.completed` | 3 |
|
||||
|
||||
@@ -142,17 +142,23 @@ function createTransportBackedNativeSessionBackend(
|
||||
);
|
||||
}
|
||||
|
||||
const constraints = [
|
||||
...(supportsCollaborativePlanning &&
|
||||
const planMode = supportsCollaborativePlanning &&
|
||||
"executionMode" in input &&
|
||||
input.executionMode === "plan"
|
||||
input.executionMode === "plan";
|
||||
const claudeAcpx = input.provider.kind === "acpx" && input.provider.agent === "claude";
|
||||
const constraints = [
|
||||
...(planMode
|
||||
? [
|
||||
"Use native plan collaboration mode and do not modify workspace files.",
|
||||
"Treat the supplied Paperclip planning context as the canonical pinned base revision.",
|
||||
"Complete one structured provider plan item; Paperclip will synchronize it after completion.",
|
||||
"Keep the final response to a short synchronization summary instead of repeating the full plan.",
|
||||
...planProposalConstraints(input.provider.kind, claudeAcpx),
|
||||
]
|
||||
: []),
|
||||
: claudeAcpx && input.task.workMode === "standard"
|
||||
? [
|
||||
// Agents may opt into a plan approval gate on Standard tasks too.
|
||||
"If the task asks for a plan first, or the change is risky enough that the user should agree on the approach, you may use EnterPlanMode and then ExitPlanMode with the complete plan. Paperclip saves it as the task's Plan document, asks the user to approve it and ends this turn; continue after approval.",
|
||||
]
|
||||
: []),
|
||||
...nativeTaskConstraints(input),
|
||||
];
|
||||
|
||||
@@ -277,3 +283,27 @@ export function createCodexNativeSessionBackend(
|
||||
}
|
||||
return createTransportBackedNativeSessionBackend(input, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* How a planning run hands its proposal to Paperclip. The harness's own
|
||||
* proposed-plan surface becomes the task's Plan document and approval gate, so
|
||||
* the proposal must land before the completion report.
|
||||
*/
|
||||
function planProposalConstraints(providerKind: string, claudeAcpx: boolean): string[] {
|
||||
if (claudeAcpx) {
|
||||
return [
|
||||
"Claude Code is in plan mode. When the plan is ready, call ExitPlanMode with the complete plan as your last action. Paperclip saves it as the task's Plan document, asks the user to approve it and ends this turn.",
|
||||
"Do not write the plan with document tools and do not call the semantic completion tool before ExitPlanMode.",
|
||||
];
|
||||
}
|
||||
if (providerKind === "codex") {
|
||||
return [
|
||||
"Present the complete plan once, in a message, as a single <proposed_plan>...</proposed_plan> block. Paperclip saves that block as the task's Plan document and asks the user to approve it, so do not write the plan with document tools.",
|
||||
"Present the plan before calling the semantic completion tool, then keep the final response to a short note that the plan is waiting for approval.",
|
||||
];
|
||||
}
|
||||
return [
|
||||
"Complete one structured provider plan item; Paperclip will synchronize it after completion.",
|
||||
"Keep the final response to a short synchronization summary instead of repeating the full plan.",
|
||||
];
|
||||
}
|
||||
@@ -68,7 +68,8 @@ describe("canonical operation-groups specification", () => {
|
||||
expect(input.source.behaviorGroups).toHaveLength(16);
|
||||
expect(input.scenarios).toHaveLength(106);
|
||||
expect(new Set(input.scenarios.map((scenario) => scenario.id)).size).toBe(106);
|
||||
expect(input.eventTypes).toHaveLength(107);
|
||||
expect(input.eventTypes).toHaveLength(108);
|
||||
expect(input.eventTypes).toContain("plan.proposed");
|
||||
expect(input.eventTypes).toContain("runtime.activity.recorded");
|
||||
expect(input.source.prpEventFamilyDescriptions.runtime).toBe("Runner phase transitions and run-scoped reported activity.");
|
||||
expect(input.commandTypes).toHaveLength(18);
|
||||
|
||||
@@ -99,7 +99,7 @@ export interface OperationGroupsInput {
|
||||
catalogIndexSource: string;
|
||||
}
|
||||
|
||||
const EXPECTED_EVENT_COUNT = 107;
|
||||
const EXPECTED_EVENT_COUNT = 108;
|
||||
const EXPECTED_COMMAND_COUNT = 18;
|
||||
const EXPECTED_SCENARIO_COUNT = 106;
|
||||
const EXPECTED_BEHAVIOR_GROUP_COUNT = 16;
|
||||
|
||||
@@ -30,7 +30,7 @@ import type {
|
||||
|
||||
import { acpxProfileClientCapabilities, bindAcpxExtensionTurn, validateAcpxRichEvent, createAcpxProfileExtensionAdapter, type AcpxExtensionInput } from "../drivers/acpx/profile-extensions.js";
|
||||
import type { PaperclipQuestionSet } from "../contracts/question-set.js";
|
||||
import { acpxToolDetailFromRuntimeEvent, acpxToolInputPath, boundedAcpMeta, canonicalProviderEventsFromAcpxRuntimeEvent, type AcpRuntimeEventShape, createAcpxToolEventNormalizer, createGrokMessageNormalizer } from "../provider-events.js";
|
||||
import { acpxToolDetailFromRuntimeEvent, acpxToolInputPath, boundedAcpMeta, canonicalProviderEventsFromAcpxRuntimeEvent, claudePlanProposalEvent, type AcpRuntimeEventShape, createAcpxToolEventNormalizer, createGrokMessageNormalizer } from "../provider-events.js";
|
||||
import { acpxOpaqueToolItemId, createClaudeSubagentLineageTracker } from "../claude-subagent-lineage.js";
|
||||
import { claudeHookCanonicalEvent } from "../governance-events.js";
|
||||
import { createPiMessageProjection, type PiProjectedMessageEvent } from "../drivers/acpx/pi-message-projection.js";
|
||||
@@ -447,6 +447,13 @@ async function dispatch(
|
||||
text: boundedText(request.params.message, "message", 1024 * 1024),
|
||||
onExtensionRequest: extensions.onExtensionRequest,
|
||||
onExtensionNotification: extensions.onExtensionNotification,
|
||||
onPlanProposal: (proposal) => {
|
||||
if (turnId !== currentTurnId || host !== activeHost) return;
|
||||
const event = claudePlanProposalEvent(proposal);
|
||||
validateAcpxRichEvent(event);
|
||||
emit("runtime.rich_event", { ...event }, currentTurnId);
|
||||
planProposalTurns.add(currentTurnId);
|
||||
},
|
||||
onElicitation: (providerRequest, context) =>
|
||||
waitForInput(currentTurnId, providerRequest, context),
|
||||
onPermissionRequest: (providerRequest, context) =>
|
||||
@@ -666,6 +673,9 @@ async function dispatch(
|
||||
throw new Error("unreachable ACPX sidecar command");
|
||||
}
|
||||
|
||||
/** Turns whose native plan approval was declined after capturing the proposal. */
|
||||
const planProposalTurns = new Set<string>();
|
||||
|
||||
async function pumpTurn(
|
||||
currentTurnId: string,
|
||||
runtimeTurn: AcpxRuntimeTurn,
|
||||
@@ -778,6 +788,11 @@ async function pumpTurn(
|
||||
rejectTurnWaiters(currentTurnId, "ACPX turn became terminal");
|
||||
if (turnId === currentTurnId) turnId = null;
|
||||
}
|
||||
// Declining Claude's native plan approval interrupts its turn. The captured
|
||||
// proposal is this turn's deliverable, so the turn completes normally.
|
||||
if (planProposalTurns.delete(currentTurnId) && terminal.status === "cancelled") {
|
||||
terminal = { status: "completed", stopReason: "end_turn" };
|
||||
}
|
||||
// A terminal frame is also runnerd's permission to recycle this provider.
|
||||
// Publish it only after no callback can inherit this turn's mutable binding.
|
||||
emit("runtime.turn_terminal", terminal, currentTurnId);
|
||||
|
||||
@@ -53,6 +53,7 @@ import type {
|
||||
import { validatePrpStructuredRunResult } from "../../protocol/replay-contract.js";
|
||||
import {
|
||||
canonicalProviderEventsFromAcpxRuntimeEvent,
|
||||
claudePlanProposalEvent,
|
||||
createAcpxToolEventNormalizer,
|
||||
createGrokMessageNormalizer,
|
||||
} from "../../provider-events.js";
|
||||
@@ -84,6 +85,7 @@ import {
|
||||
import {
|
||||
ACPX_TURN_CANCELLATION_SHUTDOWN_BOUND_MS,
|
||||
AcpxRuntimeHost,
|
||||
type AcpxPlanProposal,
|
||||
type AcpxRetainedCleanupFailure,
|
||||
type AcpxRuntimeTurn,
|
||||
type OpenAcpxRuntimeHostOptions,
|
||||
@@ -883,6 +885,17 @@ class CodexAcpxSession implements HarnessSession {
|
||||
return { steering: capability.steering, queuedFollowUp: capability.queuedFollowUp === true };
|
||||
}
|
||||
|
||||
readonly #planProposalTurns = new Set<string>();
|
||||
|
||||
#recordPlanProposal(turnId: string, proposal: AcpxPlanProposal): void {
|
||||
if (this.#activeTurnId !== turnId) return;
|
||||
const event = claudePlanProposalEvent(proposal);
|
||||
validateAcpxRichEvent(event);
|
||||
if (this.#emit(event.eventType, event.payload, { turnId, itemId: event.itemId })) {
|
||||
this.#planProposalTurns.add(turnId);
|
||||
}
|
||||
}
|
||||
|
||||
async startTurn(input: {
|
||||
message: NativeUserMessage;
|
||||
}): Promise<{ turnId: string }> {
|
||||
@@ -947,6 +960,7 @@ class CodexAcpxSession implements HarnessSession {
|
||||
requestId: `${safeId(this.#input.runId, "run")}:${turnId}`,
|
||||
onExtensionRequest: extensions.onExtensionRequest,
|
||||
onExtensionNotification: extensions.onExtensionNotification,
|
||||
onPlanProposal: (proposal) => this.#recordPlanProposal(turnId, proposal),
|
||||
onPermissionRequest: (request, context) =>
|
||||
this.#handlePermission(turnId, request, context, toolEvidence),
|
||||
onElicitation: (request, context) =>
|
||||
@@ -1544,6 +1558,15 @@ class CodexAcpxSession implements HarnessSession {
|
||||
"turn.completed",
|
||||
{ status: "completed", stopReason: result.stopReason ?? null },
|
||||
);
|
||||
} else if (result.status === "cancelled" && this.#planProposalTurns.has(turnId)) {
|
||||
// Declining Claude's native plan approval interrupts its turn. The
|
||||
// proposal is this turn's deliverable, so it completes normally.
|
||||
this.#publishTerminal(
|
||||
turnId,
|
||||
canonicalJson({ status: "completed", semanticResult: null }),
|
||||
"turn.completed",
|
||||
{ status: "completed", stopReason: "end_turn" },
|
||||
);
|
||||
} else if (result.status === "cancelled") {
|
||||
const reaffirmedSemanticResult =
|
||||
this.#pendingSemanticTransfer?.turnId === turnId
|
||||
|
||||
@@ -17,7 +17,7 @@ import { decodeAcpxRuntimeHandleState } from "acpx/runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { VerifiedAcpxCommandLease } from "./installation-integrity.js";
|
||||
import { openCodexAcpxRuntime } from "./codex-runtime-adapter.js";
|
||||
import { claudeExitPlanModeProposal, openCodexAcpxRuntime } from "./codex-runtime-adapter.js";
|
||||
import { cursorInstructionBinding } from "./cursor-instructions.js";
|
||||
import { persistedAcpxTurnUsage } from "./usage-accounting.js";
|
||||
import { createAcpxCommandLeaseOwner } from "./command-lease-owner.js";
|
||||
@@ -227,6 +227,56 @@ describe("Codex ACPX runtime adapter", () => {
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("captures Claude's ExitPlanMode plan for Paperclip approval and declines the native prompt", async () => {
|
||||
const pending = pendingExtensionTurn("plan-turn");
|
||||
const runtime = fakeRuntime();
|
||||
vi.mocked(runtime.startTurn).mockReturnValue(pending.turn);
|
||||
const command = fakeCommand();
|
||||
vi.mocked(command.spawn).mockReturnValue(fakeChild());
|
||||
let exit!: () => void;
|
||||
const providerExit = new Promise<void>(resolve => { exit = resolve; });
|
||||
let created!: AcpRuntimeOptions;
|
||||
const options = openOptions(command);
|
||||
options.profile = { ...options.profile, agent: "claude" };
|
||||
options.permissionMode = "approve-all";
|
||||
const port = await openCodexAcpxRuntime(options, {
|
||||
createRegistry: () => registry(), createStore: () => store(),
|
||||
awaitProviderOwnership: providerOwnershipEstablished, awaitProviderExit: () => providerExit,
|
||||
createRuntime: value => { created = value; return runtimeWithProvider(runtime, value); },
|
||||
});
|
||||
const onPlanProposal = vi.fn();
|
||||
const handler = vi.fn();
|
||||
const turn = port.startTurn({ text: "Plan it", requestId: "plan-turn", onPermissionRequest: handler, onPlanProposal });
|
||||
await turn.promptStarted;
|
||||
const exitPlanMode = {
|
||||
sessionId: "backend-1",
|
||||
raw: {
|
||||
sessionId: "backend-1",
|
||||
toolCall: { toolCallId: "toolu_plan", title: "Approve Plan", kind: "switch_mode", rawInput: { plan: "# Plan\n\n1. Ship it" } },
|
||||
},
|
||||
inferredKind: "other",
|
||||
};
|
||||
await expect(created.onPermissionRequest!(exitPlanMode as never, { signal: new AbortController().signal }))
|
||||
.resolves.toEqual({ outcome: "reject_once" });
|
||||
expect(onPlanProposal).toHaveBeenCalledWith({ toolCallId: "toolu_plan", markdown: "# Plan\n\n1. Ship it" });
|
||||
// Other tools keep the normal approve-all policy.
|
||||
await expect(created.onPermissionRequest!({ sessionId: "backend-1", raw: { sessionId: "backend-1", toolCall: { kind: "read" } }, inferredKind: "read" } as never, { signal: new AbortController().signal }))
|
||||
.resolves.toEqual({ outcome: "allow_once" });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
pending.settle();
|
||||
await turn.result;
|
||||
exit();
|
||||
await port.close({ reason: "test finished" });
|
||||
});
|
||||
|
||||
it("recognises only a switch_mode request that carries plan text as a Claude plan proposal", () => {
|
||||
expect(claudeExitPlanModeProposal({ raw: { toolCall: { toolCallId: "t1", kind: "switch_mode", rawInput: { plan: " # P " } } } }))
|
||||
.toEqual({ toolCallId: "t1", markdown: "# P" });
|
||||
expect(claudeExitPlanModeProposal({ raw: { toolCall: { kind: "switch_mode", rawInput: {} } } })).toBeNull();
|
||||
expect(claudeExitPlanModeProposal({ raw: { toolCall: { kind: "edit", rawInput: { plan: "x" } } } })).toBeNull();
|
||||
expect(claudeExitPlanModeProposal({})).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects preconfigured Codex before creating a provider runtime", async () => {
|
||||
const createRuntime = vi.fn();
|
||||
const options = openOptions(fakeCommand());
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "acpx/runtime";
|
||||
|
||||
import type {
|
||||
AcpxPlanProposal,
|
||||
AcpxRuntimeGoalCapability,
|
||||
AcpxRuntimeGoalSnapshot,
|
||||
AcpxRuntimeSteeringCapability,
|
||||
@@ -105,6 +106,7 @@ interface AcpxRuntimeExtensionTurn {
|
||||
promptStarted: Promise<void>;
|
||||
onRequest?: AcpRuntimeOptions["onExtensionRequest"];
|
||||
onNotification?: AcpRuntimeOptions["onExtensionNotification"];
|
||||
onPlanProposal?: (proposal: AcpxPlanProposal) => void;
|
||||
}
|
||||
|
||||
interface AcpxRuntimeExtensionBoundary {
|
||||
@@ -113,6 +115,23 @@ interface AcpxRuntimeExtensionBoundary {
|
||||
steering: AcpxRuntimeSteeringCapability | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code's ExitPlanMode permission request (claude-agent-acp titles it
|
||||
* "Approve Plan" with ACP kind `switch_mode`) carries the proposed plan as
|
||||
* `toolCall.rawInput.plan`. Returns null for every other request.
|
||||
*/
|
||||
export function claudeExitPlanModeProposal(request: { raw?: unknown }): AcpxPlanProposal | null {
|
||||
const raw = request.raw && typeof request.raw === "object" ? request.raw as Record<string, unknown> : {};
|
||||
const toolCall = raw.toolCall && typeof raw.toolCall === "object" ? raw.toolCall as Record<string, unknown> : {};
|
||||
if (toolCall.kind !== "switch_mode") return null;
|
||||
const input = toolCall.rawInput && typeof toolCall.rawInput === "object" ? toolCall.rawInput as Record<string, unknown> : {};
|
||||
const markdown = typeof input.plan === "string" ? input.plan.trim() : "";
|
||||
if (!markdown) return null;
|
||||
const toolCallId = typeof toolCall.toolCallId === "string" && toolCall.toolCallId.trim()
|
||||
? toolCall.toolCallId.trim() : "claude-exit-plan-mode";
|
||||
return { toolCallId, markdown };
|
||||
}
|
||||
|
||||
export class AcpxProviderProcessLostError extends Error {
|
||||
readonly code = "ACPX_PROVIDER_PROCESS_LOST";
|
||||
constructor(message = "The owned ACPX provider exited during its active turn") {
|
||||
@@ -420,6 +439,16 @@ export async function openQualifiedAcpxRuntime(
|
||||
}
|
||||
if ((permissionBoundary.hasAdmittedTurn && !extensionBoundary.active)
|
||||
|| extensionBoundary.active?.signal.aborted || context.signal.aborted) return { outcome: "cancel" };
|
||||
if (options.profile.agent === "claude") {
|
||||
const plan = claudeExitPlanModeProposal(request);
|
||||
if (plan !== null) {
|
||||
// Paperclip owns plan approval. Record the proposal for the issue's
|
||||
// Plan document and approval gate, then decline Claude's native
|
||||
// "Ready to code?" prompt so the turn ends without implementing.
|
||||
extensionBoundary.active?.onPlanProposal?.(plan);
|
||||
return { outcome: "reject_once" };
|
||||
}
|
||||
}
|
||||
if (options.profile.agent === "kimi" && options.readOnly === true
|
||||
&& !kimiReadOnlyPermissionAllowed(request, runnerOwnedMcpServerNames)) return { outcome: "reject_once" };
|
||||
const disposition = decideAcpxPermission(
|
||||
@@ -627,6 +656,14 @@ export async function openQualifiedAcpxRuntime(
|
||||
modeBinding?.assertReady();
|
||||
piThinking?.assertReady();
|
||||
effortAdmission?.assertReady();
|
||||
// A read-only (Planning) Claude run uses Claude Code's native plan mode, so
|
||||
// the model proposes through ExitPlanMode instead of editing.
|
||||
if (options.profile.agent === "claude" && options.readOnly === true && !preconfigured) {
|
||||
if (!runtime.setConfigOption) throw new Error("Claude plan mode requires native mode configuration");
|
||||
const spawnsBeforeControl = commandLaunches.count;
|
||||
await runtime.setConfigOption({ handle, key: "mode", value: "plan" });
|
||||
if (commandLaunches.count > spawnsBeforeControl) await commandLaunches.refreshConsumedCommand?.();
|
||||
}
|
||||
// A provider can answer only after the verified sentinel is armed, but do
|
||||
// not admit the session until the owner has observed that exact handoff.
|
||||
await children.verifyLifetimeOwnership();
|
||||
@@ -1495,6 +1532,7 @@ function runtimePort(
|
||||
signal: input.signal ? AbortSignal.any([controller.signal, input.signal, approval.signal]) : AbortSignal.any([controller.signal, approval.signal]),
|
||||
promptStarted: Promise.resolve(),
|
||||
onRequest: input.onExtensionRequest, onNotification: input.onExtensionNotification,
|
||||
onPlanProposal: input.onPlanProposal,
|
||||
};
|
||||
extensionBoundary.active = extensionTurn;
|
||||
promptUsage?.begin(input.requestId, identity.backendSessionId);
|
||||
|
||||
@@ -124,6 +124,12 @@ export interface AcpxRuntimeGoalSnapshot {
|
||||
updatedAt?: number | string | null;
|
||||
}
|
||||
|
||||
/** A harness-native proposed plan captured for Paperclip's approval gate. */
|
||||
export interface AcpxPlanProposal {
|
||||
toolCallId: string;
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
export interface AcpxRuntimeTurnInput {
|
||||
text: string;
|
||||
requestId: string;
|
||||
@@ -132,6 +138,8 @@ export interface AcpxRuntimeTurnInput {
|
||||
onPermissionRequest?: AcpRuntimeOptions["onPermissionRequest"];
|
||||
onExtensionRequest?: AcpRuntimeOptions["onExtensionRequest"];
|
||||
onExtensionNotification?: AcpRuntimeOptions["onExtensionNotification"];
|
||||
/** Receives a harness-native proposed plan that Paperclip, not the harness, approves. */
|
||||
onPlanProposal?: (proposal: AcpxPlanProposal) => void;
|
||||
/** Receives the typed AIR failure behind ACPX's generic terminal error. */
|
||||
onTerminalSessionFailure?: (failure: AcpxTerminalSessionFailure) => void;
|
||||
}
|
||||
@@ -917,6 +925,7 @@ export class AcpxRuntimeHost {
|
||||
...(input.onPermissionRequest ? { onPermissionRequest: input.onPermissionRequest } : {}),
|
||||
...(input.onExtensionRequest ? { onExtensionRequest: input.onExtensionRequest } : {}),
|
||||
...(input.onExtensionNotification ? { onExtensionNotification: input.onExtensionNotification } : {}),
|
||||
...(input.onPlanProposal ? { onPlanProposal: input.onPlanProposal } : {}),
|
||||
...(input.onTerminalSessionFailure ? { onTerminalSessionFailure: input.onTerminalSessionFailure } : {}),
|
||||
});
|
||||
this.#activeTurn = turn;
|
||||
|
||||
@@ -36,7 +36,9 @@ export function record(value: unknown): Record<string, unknown> {
|
||||
* so the compatibility Codex facade must preserve the item as activity
|
||||
* without trying to infer a second result from its text.
|
||||
*/
|
||||
export const RUNNERD_CANONICAL_ITEM = Symbol(
|
||||
// Registered by name so dependency-free modules (provider-events) can test
|
||||
// it without importing the Codex driver graph.
|
||||
export const RUNNERD_CANONICAL_ITEM = Symbol.for(
|
||||
"paperclip.runnerd.canonical-item",
|
||||
);
|
||||
|
||||
|
||||
@@ -197,6 +197,11 @@ function publicProviderPayload(
|
||||
};
|
||||
}),
|
||||
};
|
||||
case "plan_proposal":
|
||||
return {
|
||||
...common(["schema", "proposalId", "status", "source"]),
|
||||
markdown: clamp(typeof source.markdown === "string" ? source.markdown : "", 60_000),
|
||||
};
|
||||
case "tool_execution":
|
||||
return common(["schema", "executionId", "transport", "operation", "name", "target", "namespace", "readOnly", "status", "durationMs", "exitCode", "progress", "output", "outputBytes", "outputTruncated", "outputDigest"]);
|
||||
case "research":
|
||||
|
||||
@@ -227,7 +227,7 @@ export type CapabilityThreadItem =
|
||||
kind: "provider_activity";
|
||||
id: string;
|
||||
at: string;
|
||||
family: "plan" | "tool_execution" | "research" | "delegation" | "model_identity" | "context" | "artifact" | "review" | "hook" | "memory" | "safety" | "terminal" | "wait" | "provider_notice";
|
||||
family: "plan" | "plan_proposal" | "tool_execution" | "research" | "delegation" | "model_identity" | "context" | "artifact" | "review" | "hook" | "memory" | "safety" | "terminal" | "wait" | "provider_notice";
|
||||
eventType: string;
|
||||
status: "running" | "completed" | "failed" | "interrupted" | "informational";
|
||||
title: string;
|
||||
|
||||
@@ -1822,6 +1822,9 @@ export const providerEventSchema = {
|
||||
{
|
||||
"$ref": "#/$defs/plan"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/planProposal"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/toolExecution"
|
||||
},
|
||||
@@ -1980,6 +1983,42 @@ export const providerEventSchema = {
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"planProposal": {
|
||||
"type": "object",
|
||||
"description": "A provider-authored proposed plan document awaiting human approval (Codex plan item, Claude ExitPlanMode). Paperclip may synchronize a completed proposal into the issue's durable Plan document; it is distinct from the within-turn plan.updated checklist.",
|
||||
"required": [
|
||||
"schema",
|
||||
"proposalId",
|
||||
"status",
|
||||
"markdown",
|
||||
"source"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {
|
||||
"const": "paperclip.plan.proposed.v1"
|
||||
},
|
||||
"proposalId": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"streaming",
|
||||
"completed"
|
||||
]
|
||||
},
|
||||
"markdown": {
|
||||
"type": "string",
|
||||
"maxLength": 60000
|
||||
},
|
||||
"source": {
|
||||
"enum": [
|
||||
"codex_plan_item",
|
||||
"claude_exit_plan_mode"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"toolExecution": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -4958,6 +4997,7 @@ export const eventSchema = {
|
||||
"harness.exited",
|
||||
"harness.diagnostic",
|
||||
"plan.updated",
|
||||
"plan.proposed",
|
||||
"tool.execution.started",
|
||||
"tool.execution.progressed",
|
||||
"tool.execution.completed",
|
||||
@@ -5127,6 +5167,22 @@ export const eventSchema = {
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"eventType": {
|
||||
"const": "plan.proposed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"payload": {
|
||||
"$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/planProposal"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
@@ -5723,6 +5779,7 @@ export const eventV2Schema = {
|
||||
"harness.exited",
|
||||
"harness.diagnostic",
|
||||
"plan.updated",
|
||||
"plan.proposed",
|
||||
"tool.execution.started",
|
||||
"tool.execution.progressed",
|
||||
"tool.execution.completed",
|
||||
@@ -6055,6 +6112,7 @@ export const eventV3Schema = {
|
||||
"harness.exited",
|
||||
"harness.diagnostic",
|
||||
"plan.updated",
|
||||
"plan.proposed",
|
||||
"tool.execution.started",
|
||||
"tool.execution.progressed",
|
||||
"tool.execution.completed",
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
import equalModule from "ajv/dist/runtime/equal.js";
|
||||
import ucs2LengthModule from "ajv/dist/runtime/ucs2length.js";
|
||||
|
||||
"use strict";export const authCredentialsValidator: unknown = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/auth-credentials.schema.json","title":"PRP credential update notification","description":"Content-free evidence of changed credentials for the harness-auth binding in this normalized session. Credential-store detection does not prove an OAuth refresh succeeded.","type":"object","required":["schema","provider","bindingId","revision","detection"],"properties":{"schema":{"const":"paperclip.auth.credentials.updated.v1"},"provider":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"bindingId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"revision":{"type":"integer","minimum":1,"maximum":9007199254740991},"detection":{"enum":["credential_store","native_hook"]}},"additionalProperties":false};const func1 = typeof ucs2LengthModule === "function" ? ucs2LengthModule : ucs2LengthModule.default;const pattern4 = new RegExp("^[A-Za-z0-9][A-Za-z0-9._:-]*$", "u");function validate20(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/auth-credentials.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate20.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.provider === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "provider"},message:"must have required property '"+"provider"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.bindingId === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "bindingId"},message:"must have required property '"+"bindingId"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.revision === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "revision"},message:"must have required property '"+"revision"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.detection === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "detection"},message:"must have required property '"+"detection"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}for(const key0 in data){if(!(((((key0 === "schema") || (key0 === "provider")) || (key0 === "bindingId")) || (key0 === "revision")) || (key0 === "detection"))){const err5 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.schema !== undefined){if("paperclip.auth.credentials.updated.v1" !== data.schema){const err6 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.auth.credentials.updated.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.provider !== undefined){let data1 = data.provider;if(typeof data1 === "string"){if(func1(data1) > 160){const err7 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(func1(data1) < 1){const err8 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(!pattern4.test(data1)){const err9 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}else {const err10 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.bindingId !== undefined){let data2 = data.binLine truncated
|
||||
"use strict";export const authCredentialsValidator: unknown = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/auth-credentials.schema.json","title":"PRP credential update notification","description":"Content-free evidence of changed credentials for the harness-auth binding in this normalized session. Credential-store detection does not prove an OAuth refresh succeeded.","type":"object","required":["schema","provider","bindingId","revision","detection"],"properties":{"schema":{"const":"paperclip.auth.credentials.updated.v1"},"provider":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"bindingId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"revision":{"type":"integer","minimum":1,"maximum":9007199254740991},"detection":{"enum":["credential_store","native_hook"]}},"additionalProperties":false};const func1 = typeof ucs2LengthModule === "function" ? ucs2LengthModule : ucs2LengthModule.default;const pattern4 = new RegExp("^[A-Za-z0-9][A-Za-z0-9._:-]*$", "u");function validate20(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/auth-credentials.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate20.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.provider === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "provider"},message:"must have required property '"+"provider"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.bindingId === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "bindingId"},message:"must have required property '"+"bindingId"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.revision === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "revision"},message:"must have required property '"+"revision"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.detection === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "detection"},message:"must have required property '"+"detection"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}for(const key0 in data){if(!(((((key0 === "schema") || (key0 === "provider")) || (key0 === "bindingId")) || (key0 === "revision")) || (key0 === "detection"))){const err5 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.schema !== undefined){if("paperclip.auth.credentials.updated.v1" !== data.schema){const err6 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.auth.credentials.updated.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.provider !== undefined){let data1 = data.provider;if(typeof data1 === "string"){if(func1(data1) > 160){const err7 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(func1(data1) < 1){const err8 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(!pattern4.test(data1)){const err9 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}else {const err10 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.bindingId !== undefined){let data2 = data.binLine truncated
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { RUNNERD_CANONICAL_ITEM } from "./drivers/codex/codex-driver-values.js";
|
||||
import {
|
||||
CODEX_THREAD_ITEM_CLASSIFICATION,
|
||||
claudePlanProposalEvent,
|
||||
PROVIDER_EVENT_FAMILIES,
|
||||
canonicalProviderEventsFromAcpxRuntimeEvent,
|
||||
canonicalProviderEventsFromCodex,
|
||||
@@ -202,7 +204,7 @@ describe("provider-neutral events", () => {
|
||||
expect(Object.values(CODEX_THREAD_ITEM_CLASSIFICATION)).not.toContain(
|
||||
"unclassified",
|
||||
);
|
||||
expect(CODEX_THREAD_ITEM_CLASSIFICATION.plan).toBe("existing_transcript");
|
||||
expect(CODEX_THREAD_ITEM_CLASSIFICATION.plan).toBe("plan");
|
||||
});
|
||||
|
||||
it("negotiates every declared family and preserves explicit unsupported states", () => {
|
||||
@@ -435,16 +437,131 @@ describe("provider-neutral events", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("treats Codex proposed-plan text as transcript content, not a checklist", () => {
|
||||
it("maps the Codex proposed-plan item to plan.proposed, not a checklist", () => {
|
||||
expect(
|
||||
canonicalProviderEventsFromCodex("item/started", {
|
||||
item: { id: "proposed-plan-1", type: "plan", text: "" },
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
eventType: "plan.proposed",
|
||||
itemId: "proposed-plan-1",
|
||||
payload: {
|
||||
schema: "paperclip.plan.proposed.v1",
|
||||
proposalId: "proposed-plan-1",
|
||||
status: "streaming",
|
||||
markdown: "",
|
||||
source: "codex_plan_item",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
canonicalProviderEventsFromCodex("item/completed", {
|
||||
item: {
|
||||
id: "proposed-plan-1",
|
||||
type: "plan",
|
||||
text: "# Proposed implementation\n\nShip it.",
|
||||
text: "<proposed_plan>\n# Proposed implementation\n\nShip it.\n</proposed_plan>",
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
eventType: "plan.proposed",
|
||||
itemId: "proposed-plan-1",
|
||||
payload: {
|
||||
schema: "paperclip.plan.proposed.v1",
|
||||
proposalId: "proposed-plan-1",
|
||||
status: "completed",
|
||||
markdown: "# Proposed implementation\n\nShip it.",
|
||||
source: "codex_plan_item",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads a <proposed_plan> block in a completed Codex message as a proposal", () => {
|
||||
expect(
|
||||
canonicalProviderEventsFromCodex("item/completed", {
|
||||
item: { id: "msg-1", type: "agentMessage", text: "Plan below.\n<proposed_plan>\n# Fix\n1. Ship\n</proposed_plan>" },
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
eventType: "plan.proposed",
|
||||
itemId: "msg-1",
|
||||
payload: {
|
||||
schema: "paperclip.plan.proposed.v1",
|
||||
proposalId: "msg-1",
|
||||
status: "completed",
|
||||
markdown: "# Fix\n1. Ship",
|
||||
source: "codex_plan_item",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
canonicalProviderEventsFromCodex("item/completed", {
|
||||
item: { id: "msg-2", type: "agentMessage", text: "No plan here." },
|
||||
}),
|
||||
).toEqual([]);
|
||||
// Quoting or documenting the tag, or a non-final message, is not a proposal.
|
||||
expect(
|
||||
canonicalProviderEventsFromCodex("item/completed", {
|
||||
item: { id: "msg-3", type: "agentMessage", text: "Use this format:\n```\n<proposed_plan>\n# Plan\n</proposed_plan>\n```" },
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
canonicalProviderEventsFromCodex("item/completed", {
|
||||
item: { id: "msg-4", type: "agentMessage", text: "Inline <proposed_plan>x</proposed_plan> mention." },
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
canonicalProviderEventsFromCodex("item/completed", {
|
||||
item: { id: "msg-5", type: "agentMessage", phase: "commentary", text: "<proposed_plan>\n# Plan\n</proposed_plan>" },
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves runnerd-forwarded items to the proposal Rust already emitted", () => {
|
||||
const item = { id: "plan-1", type: "plan", text: "<proposed_plan>\n# Cut\n", [RUNNERD_CANONICAL_ITEM]: true };
|
||||
expect(canonicalProviderEventsFromCodex("item/completed", { item })).toEqual([]);
|
||||
const message = {
|
||||
id: "msg-1", type: "agentMessage", phase: "final_answer",
|
||||
text: "<proposed_plan>\n# Plan\n</proposed_plan>", [RUNNERD_CANONICAL_ITEM]: true,
|
||||
};
|
||||
expect(canonicalProviderEventsFromCodex("item/completed", { item: message })).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a plan whole when it contains a fenced closing-tag example", () => {
|
||||
const body = "# Plan\n\n1. Document the format:\n```\n<proposed_plan>\nexample\n</proposed_plan>\n```\n2. Ship";
|
||||
expect(
|
||||
canonicalProviderEventsFromCodex("item/completed", {
|
||||
item: { id: "msg-9", type: "agentMessage", text: `Ready.\n<proposed_plan>\n${body}\n</proposed_plan>` },
|
||||
})[0]?.payload.markdown,
|
||||
).toBe(body);
|
||||
});
|
||||
|
||||
it("keeps literal proposed_plan tags inside a plan body", () => {
|
||||
const body = "# Plan\n\n1. Teach the format: `<proposed_plan>example</proposed_plan>`\n\n```\n<proposed_plan>\nsample\n</proposed_plan>\n```";
|
||||
expect(claudePlanProposalEvent({ toolCallId: "toolu_1", markdown: body }).payload.markdown).toBe(body);
|
||||
expect(
|
||||
canonicalProviderEventsFromCodex("item/completed", {
|
||||
item: { id: "plan-2", type: "plan", text: `<proposed_plan>\n${body}\n</proposed_plan>` },
|
||||
})[0]?.payload.markdown,
|
||||
).toBe(body);
|
||||
});
|
||||
|
||||
it("maps a captured Claude ExitPlanMode plan to a completed proposal", () => {
|
||||
expect(
|
||||
claudePlanProposalEvent({ toolCallId: "toolu_01", markdown: "# Plan\n\n1. Fix it" }),
|
||||
).toEqual({
|
||||
eventType: "plan.proposed",
|
||||
itemId: "toolu_01",
|
||||
payload: {
|
||||
schema: "paperclip.plan.proposed.v1",
|
||||
proposalId: "toolu_01",
|
||||
status: "completed",
|
||||
markdown: "# Plan\n\n1. Fix it",
|
||||
source: "claude_exit_plan_mode",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses an empty native plan as a stable clearing snapshot", () => {
|
||||
|
||||
@@ -64,9 +64,10 @@ export const CODEX_THREAD_ITEM_CLASSIFICATION = {
|
||||
userMessage: "existing_transcript",
|
||||
hookPrompt: "hook",
|
||||
agentMessage: "existing_transcript",
|
||||
// Codex's `plan` ThreadItem is proposed-plan document text. The live
|
||||
// execution checklist is a separate `turn/plan/updated` notification.
|
||||
plan: "existing_transcript",
|
||||
// Codex's `plan` ThreadItem is proposed-plan document text (canonical
|
||||
// `plan.proposed`). The live execution checklist is a separate
|
||||
// `turn/plan/updated` notification (canonical `plan.updated`).
|
||||
plan: "plan",
|
||||
reasoning: "existing_reasoning",
|
||||
commandExecution: "tool_execution",
|
||||
fileChange: "existing_workspace_change",
|
||||
@@ -94,6 +95,7 @@ export const CANONICAL_PROVIDER_EVENT_TYPES = [
|
||||
"auth.credentials.updated",
|
||||
"harness.diagnostic",
|
||||
"plan.updated",
|
||||
"plan.proposed",
|
||||
"tool.execution.started",
|
||||
"tool.execution.progressed",
|
||||
"tool.execution.completed",
|
||||
@@ -823,6 +825,91 @@ function turnPlanPayload(
|
||||
};
|
||||
}
|
||||
|
||||
// Matches the provider-event schema; keeps the event under the 256 KiB payload cap.
|
||||
/** Same registered Symbol as `RUNNERD_CANONICAL_ITEM` in the Codex driver values. */
|
||||
const RUNNERD_CANONICAL_ITEM_KEY = Symbol.for("paperclip.runnerd.canonical-item");
|
||||
|
||||
const PLAN_PROPOSAL_MAX_MARKDOWN = 60_000;
|
||||
|
||||
/**
|
||||
* A provider-authored proposed plan document. Unlike the `plan.updated`
|
||||
* checklist, a completed proposal is the candidate text for the issue's
|
||||
* durable Plan document and its approval gate.
|
||||
*/
|
||||
export function planProposalPayload(
|
||||
proposalId: string,
|
||||
status: "streaming" | "completed",
|
||||
markdown: string,
|
||||
source: "codex_plan_item" | "claude_exit_plan_mode",
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
schema: "paperclip.plan.proposed.v1",
|
||||
proposalId,
|
||||
status,
|
||||
markdown: unwrapProposedPlan(markdown).slice(0, PLAN_PROPOSAL_MAX_MARKDOWN),
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a message around one `<proposed_plan>` block. The tags must sit on
|
||||
* their own lines outside fenced code, so quoting or documenting the tag never
|
||||
* creates a proposal.
|
||||
*/
|
||||
export function splitProposedPlanBlock(value: string): { plan: string | null; rest: string; unclosedStart: number | null } {
|
||||
const lines = value.split("\n");
|
||||
let fence: string | null = null;
|
||||
let start = -1;
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const trimmed = lines[index]!.trim();
|
||||
// Fences are tracked inside the block too, so a fenced example tag in
|
||||
// the plan never opens or closes it.
|
||||
const marker = /^(`{3,}|~{3,})/.exec(trimmed)?.[1];
|
||||
if (marker) {
|
||||
fence = fence === null ? marker[0]! : (trimmed.startsWith(fence) ? null : fence);
|
||||
continue;
|
||||
}
|
||||
if (fence !== null) continue;
|
||||
if (start < 0 && /^<proposed_plan>$/i.test(trimmed)) start = index;
|
||||
else if (start >= 0 && /^<\/proposed_plan>$/i.test(trimmed)) {
|
||||
return {
|
||||
plan: lines.slice(start + 1, index).join("\n").trim(),
|
||||
rest: [...lines.slice(0, start), ...lines.slice(index + 1)].join("\n").trim(),
|
||||
unclosedStart: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
// An opening tag outside fences that never closes: a cut-off preview.
|
||||
return {
|
||||
plan: null,
|
||||
rest: value,
|
||||
unclosedStart: start < 0 ? null : lines.slice(0, start).join("\n").length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Claude Code's ExitPlanMode plan, captured at its permission request. */
|
||||
export function claudePlanProposalEvent(proposal: {
|
||||
toolCallId: string;
|
||||
markdown: string;
|
||||
}): CanonicalProviderEvent {
|
||||
const itemId = safeId(proposal.toolCallId, "claude-plan-proposal");
|
||||
return {
|
||||
eventType: "plan.proposed",
|
||||
itemId,
|
||||
payload: planProposalPayload(itemId, "completed", proposal.markdown, "claude_exit_plan_mode"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Codex wraps plan-mode output in `<proposed_plan>` tags; keep only the document. */
|
||||
function unwrapProposedPlan(markdown: string): string {
|
||||
// Only a wrapper around the whole document is removed. Literal tags inside
|
||||
// the plan (an inline or fenced example) stay part of its text.
|
||||
const trimmed = markdown.trim();
|
||||
const open = /^<proposed_plan>[ \t]*(?:\n|$)/i.exec(trimmed);
|
||||
if (!open) return trimmed;
|
||||
return trimmed.slice(open[0].length).replace(/(?:^|\n)[ \t]*<\/proposed_plan>[ \t]*$/i, "").trim();
|
||||
}
|
||||
|
||||
function researchSources(
|
||||
item: Record<string, unknown>,
|
||||
researchId: string,
|
||||
@@ -928,6 +1015,32 @@ export function canonicalProviderEventsFromCodex(
|
||||
},
|
||||
];
|
||||
}
|
||||
// Codex plan mode turns a `<proposed_plan>` block into its own plan item;
|
||||
// agents can also write the block in an ordinary message to ask for plan
|
||||
// approval, so read it there as well.
|
||||
// On the runnerd path Rust already emitted the proposal from the full item;
|
||||
// its forwarded item text is a short display preview and must not repeat it.
|
||||
const runnerdCanonical = (item as Record<PropertyKey, unknown>)[RUNNERD_CANONICAL_ITEM_KEY] === true;
|
||||
if (!runnerdCanonical && completed && type === "agentMessage" && (!text(item.phase) || text(item.phase) === "final_answer")) {
|
||||
const { plan } = splitProposedPlanBlock(text(item.text));
|
||||
if (plan) {
|
||||
return [{ eventType: "plan.proposed", payload: planProposalPayload(itemId, "completed", plan, "codex_plan_item"), itemId }];
|
||||
}
|
||||
}
|
||||
if (!runnerdCanonical && (method === "item/started" || completed) && type === "plan") {
|
||||
return [
|
||||
{
|
||||
eventType: "plan.proposed",
|
||||
payload: planProposalPayload(
|
||||
itemId,
|
||||
completed ? "completed" : "streaming",
|
||||
completed ? text(item.text) : "",
|
||||
"codex_plan_item",
|
||||
),
|
||||
itemId,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (
|
||||
(method === "item/started" || completed) &&
|
||||
["commandExecution", "mcpToolCall", "dynamicToolCall"].includes(type)
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
isExternalChatPresentationContext,
|
||||
isChatOriginPublicationCandidate,
|
||||
mergeHeartbeatRunResultJson,
|
||||
completedPlanProposalIds,
|
||||
readCompletedAssistantMessageCandidate,
|
||||
resolveHeartbeatRunResponse,
|
||||
selectHeartbeatRunFinalAgentMessage,
|
||||
@@ -177,6 +178,29 @@ describe("readCompletedAssistantMessageCandidate", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("leaves a recorded proposed plan to its Plan document and posts only the surrounding reply", () => {
|
||||
const candidate = (text: string, proposed = true) => readCompletedAssistantMessageCandidate({
|
||||
seq: 3,
|
||||
prpEvent: { sourceEventId: "runner:plan", itemId: "msg-1", payload: { kind: "agentMessage", channel: "final", text } },
|
||||
proposedItemIds: proposed ? new Set(["msg-1"]) : new Set(),
|
||||
});
|
||||
const withPlan = "Plan ready for approval.\n<proposed_plan>\n# Fix\n1. Ship\n</proposed_plan>";
|
||||
expect(candidate(withPlan)?.text).toBe("Plan ready for approval.");
|
||||
expect(candidate("<proposed_plan>\n# Fix\n</proposed_plan>")).toBeNull();
|
||||
// Without a recorded proposal the reply keeps the plan text.
|
||||
expect(candidate(withPlan, false)?.text).toBe(withPlan);
|
||||
const documented = "Use this format:\n```\n<proposed_plan>\n# Plan\n</proposed_plan>\n```";
|
||||
expect(candidate(documented)?.text).toBe(documented);
|
||||
expect(candidate("Plan ready.\n<proposed_plan>\n# Fix\n1. Ship…[truncated]")?.text).toBe("Plan ready.");
|
||||
// A fenced example in a cut-off preview keeps its text.
|
||||
const fencedPreview = "Format:\n```\n<proposed_plan>\n# Plan…[truncated]";
|
||||
expect(candidate(fencedPreview)?.text).toBe(fencedPreview);
|
||||
expect(completedPlanProposalIds([
|
||||
{ payload: { prpEvent: { payload: { proposalId: "msg-1", status: "completed" } } } },
|
||||
{ payload: { prpEvent: { payload: { proposalId: "msg-2", status: "streaming" } } } },
|
||||
])).toEqual(new Set(["msg-1"]));
|
||||
});
|
||||
|
||||
it("keeps the persisted PRP v1 assistant_message alias readable", () => {
|
||||
expect(
|
||||
readCompletedAssistantMessageCandidate({
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
documentRevisions,
|
||||
documents,
|
||||
goals,
|
||||
heartbeatRuns,
|
||||
issueDocuments,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import { synchronizeCompletedProviderPlan } from "../services/native-runtime/native-session-executor.js";
|
||||
import { documentService } from "../services/documents.js";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
|
||||
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_PLAN_PROPOSAL_TEST_DATABASE_URL");
|
||||
const embeddedPostgresSupport = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
const PLAN = "# Stop duplicate webhook deliveries\n\n1. Add a unique index.\n2. Upsert on the event id.";
|
||||
|
||||
describeEmbeddedPostgres("harness-proposed plan synchronization", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!external) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plan-proposal-sync-");
|
||||
db = createDb(external ?? tempDb!.connectionString, { maxConnections: 6 });
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issueThreadInteractions);
|
||||
await db.delete(issueDocuments);
|
||||
await db.delete(documentRevisions);
|
||||
await db.delete(documents);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(issues);
|
||||
await db.delete(goals);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db?.$client.end({ timeout: 1 });
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seed(workMode: "standard" | "planning" | "ask") {
|
||||
const companyId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Paperclip", issuePrefix: "PPS", requireBoardApprovalForNewAgents: false });
|
||||
await db.insert(goals).values({ id: goalId, companyId, title: "Ship", level: "task", status: "active" });
|
||||
await db.insert(agents).values({
|
||||
id: agentId, companyId, name: "Claude", role: "engineer", status: "active",
|
||||
adapterType: "claude_local", adapterConfig: {}, runtimeConfig: {}, permissions: {},
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId, companyId, goalId, title: "Fix duplicate deliveries", status: "in_progress",
|
||||
priority: "medium", assigneeAgentId: agentId, workMode,
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", contextSnapshot: { issueId } });
|
||||
const binding = { runId, issueId, companyId, agentId };
|
||||
return { companyId, issueId, agentId, runId, binding };
|
||||
}
|
||||
|
||||
function proposalEvent(markdown: string, status: "streaming" | "completed" = "completed") {
|
||||
return {
|
||||
sourceEventId: `event-${randomUUID()}`,
|
||||
turnId: "turn-1",
|
||||
eventType: "plan.proposed",
|
||||
payload: {
|
||||
schema: "paperclip.plan.proposed.v1",
|
||||
proposalId: "toolu_plan",
|
||||
status,
|
||||
markdown,
|
||||
source: "claude_exit_plan_mode",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("saves an agent-initiated proposal on a Standard task as the Plan document and asks for approval", async () => {
|
||||
const { issueId, binding } = await seed("standard");
|
||||
const execution = { binding, provider: { kind: "acpx" }, task: { workMode: "standard" } } as never;
|
||||
|
||||
const result = await synchronizeCompletedProviderPlan({ db, execution, event: proposalEvent(PLAN) });
|
||||
|
||||
expect(result).toMatchObject({ status: "synchronized", planId: "toolu_plan", documentRevision: 1 });
|
||||
expect((await documentService(db).getIssueDocumentByKey(issueId, "plan"))?.body).toBe(PLAN);
|
||||
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issueId));
|
||||
expect(interaction).toMatchObject({ kind: "request_confirmation", status: "pending", sourceRunId: binding.runId });
|
||||
expect(interaction?.payload).toMatchObject({
|
||||
acceptLabel: "Approve plan",
|
||||
rejectLabel: "Request changes",
|
||||
target: { type: "issue_document", key: "plan", revisionNumber: 1 },
|
||||
});
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
// The agent opted into a plan gate; the task's work mode stays Standard.
|
||||
// The run finalizer turns the pending confirmation into the review wait.
|
||||
expect(issue).toMatchObject({ status: "in_progress", workMode: "standard" });
|
||||
});
|
||||
|
||||
it("revises an existing Plan document from its current revision", async () => {
|
||||
const { issueId, binding } = await seed("standard");
|
||||
await documentService(db).upsertIssueDocument({
|
||||
issueId, key: "plan", title: "Plan", format: "markdown", body: "# Old plan", createdByAgentId: binding.agentId,
|
||||
});
|
||||
const execution = { binding, provider: { kind: "acpx" }, task: { workMode: "standard" } } as never;
|
||||
|
||||
const result = await synchronizeCompletedProviderPlan({ db, execution, event: proposalEvent(PLAN) });
|
||||
|
||||
expect(result).toMatchObject({ status: "synchronized", documentRevision: 2 });
|
||||
expect((await documentService(db).getIssueDocumentByKey(issueId, "plan"))?.body).toBe(PLAN);
|
||||
});
|
||||
|
||||
it("lets a planning run's full proposal supersede its own earlier checklist sync", async () => {
|
||||
const { issueId, binding } = await seed("planning");
|
||||
const execution = {
|
||||
binding,
|
||||
provider: { kind: "codex" },
|
||||
task: { workMode: "planning" },
|
||||
executionMode: "plan",
|
||||
planningContext: { documentId: null, baseRevisionId: null, baseRevisionNumber: 0, markdown: "", sha256: "none", reviewContext: {} },
|
||||
} as never;
|
||||
const checklist = await synchronizeCompletedProviderPlan({
|
||||
db,
|
||||
execution,
|
||||
event: {
|
||||
sourceEventId: "checklist-1",
|
||||
eventType: "plan.updated",
|
||||
payload: { schema: "paperclip.plan.updated.v1", planId: "turn-1", revision: 1, complete: true, steps: [{ stepId: "s1", body: "Inspect", status: "completed" }] },
|
||||
},
|
||||
});
|
||||
expect(checklist).toMatchObject({ status: "synchronized", documentRevision: 1 });
|
||||
|
||||
const proposal = await synchronizeCompletedProviderPlan({ db, execution, event: proposalEvent(PLAN) });
|
||||
|
||||
expect(proposal).toMatchObject({ status: "synchronized", documentRevision: 2 });
|
||||
expect((await documentService(db).getIssueDocumentByKey(issueId, "plan"))?.body).toBe(PLAN);
|
||||
const pending = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.status, "pending"));
|
||||
expect(pending.map((row) => (row.payload as { target?: { revisionNumber?: number } }).target?.revisionNumber)).toContain(2);
|
||||
});
|
||||
|
||||
it("still refuses a proposal when someone else edited the plan after the pinned base", async () => {
|
||||
const { issueId, binding } = await seed("planning");
|
||||
await documentService(db).upsertIssueDocument({
|
||||
issueId, key: "plan", title: "Plan", format: "markdown", body: "# Human edit", createdByUserId: "local-board",
|
||||
});
|
||||
const execution = {
|
||||
binding,
|
||||
provider: { kind: "codex" },
|
||||
task: { workMode: "planning" },
|
||||
executionMode: "plan",
|
||||
planningContext: { documentId: null, baseRevisionId: null, baseRevisionNumber: 0, markdown: "", sha256: "none", reviewContext: {} },
|
||||
} as never;
|
||||
|
||||
const result = await synchronizeCompletedProviderPlan({ db, execution, event: proposalEvent(PLAN) });
|
||||
|
||||
expect(result).toMatchObject({ status: "conflict" });
|
||||
expect((await documentService(db).getIssueDocumentByKey(issueId, "plan"))?.body).toBe("# Human edit");
|
||||
});
|
||||
|
||||
it("never writes the Plan document from an Ask-mode run", async () => {
|
||||
const { issueId, binding } = await seed("ask");
|
||||
const execution = { binding, provider: { kind: "acpx" }, task: { workMode: "ask" } } as never;
|
||||
|
||||
expect(await synchronizeCompletedProviderPlan({ db, execution, event: proposalEvent(PLAN) })).toBeNull();
|
||||
expect(await documentService(db).getIssueDocumentByKey(issueId, "plan")).toBeNull();
|
||||
expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issueId))).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores streaming proposals and Standard-mode checklists", async () => {
|
||||
const { issueId, binding } = await seed("standard");
|
||||
const execution = { binding, provider: { kind: "codex" }, task: { workMode: "standard" } } as never;
|
||||
|
||||
expect(await synchronizeCompletedProviderPlan({ db, execution, event: proposalEvent("# Partial", "streaming") })).toBeNull();
|
||||
expect(await synchronizeCompletedProviderPlan({
|
||||
db,
|
||||
execution,
|
||||
event: {
|
||||
sourceEventId: "checklist-1",
|
||||
eventType: "plan.updated",
|
||||
payload: { schema: "paperclip.plan.updated.v1", planId: "turn-1", revision: 1, complete: true, steps: [{ stepId: "s1", body: "Done", status: "completed" }] },
|
||||
},
|
||||
})).toBeNull();
|
||||
expect(await documentService(db).getIssueDocumentByKey(issueId, "plan")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -227,6 +227,7 @@ export const PRP_V1_EVENT_TYPES = new Set([
|
||||
"harness.exited",
|
||||
"harness.diagnostic",
|
||||
"plan.updated",
|
||||
"plan.proposed",
|
||||
"tool.execution.started",
|
||||
"tool.execution.progressed",
|
||||
"tool.execution.completed",
|
||||
|
||||
@@ -29,6 +29,53 @@ function readNumericField(record: Record<string, unknown>, key: string) {
|
||||
return key in record ? (record[key] ?? null) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a message around one `<proposed_plan>` block. The tags must sit on
|
||||
* their own lines outside fenced code, so quoting or documenting the tag never
|
||||
* creates a proposal.
|
||||
*/
|
||||
function splitProposedPlanBlock(value: string): { plan: string | null; rest: string; unclosedStart: number | null } {
|
||||
const lines = value.split("\n");
|
||||
let fence: string | null = null;
|
||||
let start = -1;
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const trimmed = lines[index]!.trim();
|
||||
// Fences are tracked inside the block too, so a fenced example tag in
|
||||
// the plan never opens or closes it.
|
||||
const marker = /^(`{3,}|~{3,})/.exec(trimmed)?.[1];
|
||||
if (marker) {
|
||||
fence = fence === null ? marker[0]! : (trimmed.startsWith(fence) ? null : fence);
|
||||
continue;
|
||||
}
|
||||
if (fence !== null) continue;
|
||||
if (start < 0 && /^<proposed_plan>$/i.test(trimmed)) start = index;
|
||||
else if (start >= 0 && /^<\/proposed_plan>$/i.test(trimmed)) {
|
||||
return {
|
||||
plan: lines.slice(start + 1, index).join("\n").trim(),
|
||||
rest: [...lines.slice(0, start), ...lines.slice(index + 1)].join("\n").trim(),
|
||||
unclosedStart: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
// An opening tag outside fences that never closes: a cut-off preview.
|
||||
return {
|
||||
plan: null,
|
||||
rest: value,
|
||||
unclosedStart: start < 0 ? null : lines.slice(0, start).join("\n").length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The posted reply leaves a proposal to its Plan document. A stored message
|
||||
* preview can be cut inside the block; its cut-off remainder is hidden too.
|
||||
*/
|
||||
function withoutProposedPlanBlock(value: string): string {
|
||||
const { plan, rest, unclosedStart } = splitProposedPlanBlock(value);
|
||||
if (plan !== null) return rest;
|
||||
if (!value.endsWith("…[truncated]") || unclosedStart === null) return value;
|
||||
return value.slice(0, unclosedStart).trim();
|
||||
}
|
||||
|
||||
function readCommentText(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
return value.trim().length > 0 ? value : null;
|
||||
@@ -229,9 +276,26 @@ export function isChatOriginPublicationCandidate(contextSnapshot: unknown): bool
|
||||
* solely for the compatibility fallback selected by the resolver. Other
|
||||
* channels and item kinds remain activity, never durable reply candidates.
|
||||
*/
|
||||
/**
|
||||
* Item ids whose `<proposed_plan>` block became a completed `plan.proposed`
|
||||
* event. Only those replies leave the plan to its Plan document.
|
||||
*/
|
||||
export function completedPlanProposalIds(rows: readonly { payload: unknown }[]): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const payload = record(record(record(row.payload).prpEvent).payload);
|
||||
if (payload.status === "completed" && typeof payload.proposalId === "string") {
|
||||
ids.add(payload.proposalId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function readCompletedAssistantMessageCandidate(input: {
|
||||
seq: number;
|
||||
prpEvent: unknown;
|
||||
/** Item ids with a recorded proposal; other replies keep their plan text. */
|
||||
proposedItemIds?: ReadonlySet<string>;
|
||||
}): CompletedFinalAgentMessageCandidate | null {
|
||||
const prpEvent = record(input.prpEvent);
|
||||
const payload = record(prpEvent.payload);
|
||||
@@ -244,7 +308,15 @@ export function readCompletedAssistantMessageCandidate(input: {
|
||||
if (payload.channel !== "final" && payload.channel !== "unknown") {
|
||||
return null;
|
||||
}
|
||||
const text = readCommentText(payload.text);
|
||||
// A harness's `<proposed_plan>` block that became a recorded proposal is the
|
||||
// task's Plan document and approval card; that reply keeps only the
|
||||
// surrounding prose. Without a recorded proposal the reply keeps the plan.
|
||||
const itemId = typeof prpEvent.itemId === "string" ? prpEvent.itemId
|
||||
: typeof payload.itemId === "string" ? payload.itemId : null;
|
||||
const proposed = itemId !== null && input.proposedItemIds?.has(itemId) === true;
|
||||
const text = readCommentText(
|
||||
typeof payload.text === "string" && proposed ? withoutProposedPlanBlock(payload.text) : payload.text,
|
||||
);
|
||||
if (!text) return null;
|
||||
return {
|
||||
seq: input.seq,
|
||||
|
||||
@@ -121,6 +121,7 @@ import {
|
||||
import {
|
||||
findHeartbeatRunCompletionComment,
|
||||
hasAcceptedSemanticResult,
|
||||
completedPlanProposalIds,
|
||||
readCompletedAssistantMessageCandidate,
|
||||
selectHeartbeatRunFinalAgentMessage,
|
||||
type RunPresentationDecision,
|
||||
@@ -2336,11 +2337,23 @@ export function createHeartbeatLifecycle(db: Db, dependencies: HeartbeatLifecycl
|
||||
sourceEventId: string | null;
|
||||
channel: "final" | "unknown";
|
||||
}> = [];
|
||||
const proposedItemIds = completedPlanProposalIds(await db
|
||||
.select({ payload: heartbeatRunEvents.payload })
|
||||
.from(heartbeatRunEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRunEvents.companyId, companyId),
|
||||
eq(heartbeatRunEvents.runId, runId),
|
||||
eq(heartbeatRunEvents.eventType, "plan.proposed"),
|
||||
),
|
||||
)
|
||||
.limit(200));
|
||||
for (const row of rows) {
|
||||
const prpEvent = parseObject(parseObject(row.payload).prpEvent);
|
||||
const candidate = readCompletedAssistantMessageCandidate({
|
||||
seq: row.seq,
|
||||
prpEvent,
|
||||
proposedItemIds,
|
||||
});
|
||||
if (candidate) candidates.push(candidate);
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ import {
|
||||
findHeartbeatRunCompletionComment,
|
||||
isExternalChatPresentationContext,
|
||||
readAcceptedTaskContinuationSummary,
|
||||
completedPlanProposalIds,
|
||||
readCompletedAssistantMessageCandidate,
|
||||
resolveHeartbeatRunResponse,
|
||||
selectHeartbeatRunFinalAgentMessage,
|
||||
@@ -1019,9 +1020,15 @@ async function recoverCommittedNativeChatResponseInScope(
|
||||
sql`${heartbeatRunEvents.payload} #>> '{prpEvent,payload,kind}' = 'agentMessage'`,
|
||||
sql`${heartbeatRunEvents.payload} #>> '{prpEvent,payload,channel}' = 'final'`,
|
||||
)).orderBy(desc(heartbeatRunEvents.seq)).limit(200);
|
||||
const proposedItemIds = completedPlanProposalIds(await tx.select({ payload: heartbeatRunEvents.payload })
|
||||
.from(heartbeatRunEvents).where(and(
|
||||
eq(heartbeatRunEvents.companyId, input.companyId),
|
||||
eq(heartbeatRunEvents.runId, run.id),
|
||||
eq(heartbeatRunEvents.eventType, "plan.proposed"),
|
||||
)).limit(200));
|
||||
const finalAgentMessage = selectHeartbeatRunFinalAgentMessage({
|
||||
candidates: rows.flatMap((row) => {
|
||||
const candidate = readCompletedAssistantMessageCandidate({ seq: row.seq, prpEvent: record(row.payload).prpEvent });
|
||||
const candidate = readCompletedAssistantMessageCandidate({ seq: row.seq, prpEvent: record(row.payload).prpEvent, proposedItemIds });
|
||||
return candidate ? [candidate] : [];
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1593,6 +1593,35 @@ export function continuingPendingInteractionIds(
|
||||
.map((response) => response.interactionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* A planning run can synchronize its completed checklist before the harness's
|
||||
* full proposal arrives. The proposal then supersedes that same-run revision
|
||||
* (its pending review expires as stale); a revision written by anyone else
|
||||
* since the pinned base still fails the revision check.
|
||||
*/
|
||||
async function proposalBaseRevisionId(input: {
|
||||
db: Db;
|
||||
issueId: string;
|
||||
runId: string;
|
||||
pinnedRevisionId: string | null;
|
||||
proposal: boolean;
|
||||
}): Promise<string | null> {
|
||||
if (!input.proposal) return input.pinnedRevisionId;
|
||||
const latest = await documentService(input.db).getIssueDocumentByKey(input.issueId, "plan");
|
||||
if (!latest?.latestRevisionId || latest.latestRevisionId === input.pinnedRevisionId) {
|
||||
return input.pinnedRevisionId;
|
||||
}
|
||||
const latestRevision = await input.db
|
||||
.select({ createdByRunId: documentRevisions.createdByRunId })
|
||||
.from(documentRevisions)
|
||||
.where(eq(documentRevisions.id, latest.latestRevisionId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return latestRevision?.createdByRunId === input.runId
|
||||
? latest.latestRevisionId
|
||||
: input.pinnedRevisionId;
|
||||
}
|
||||
|
||||
export async function synchronizeCompletedProviderPlan(input: {
|
||||
db: Db;
|
||||
execution: NativeExecutionInput;
|
||||
@@ -1603,25 +1632,55 @@ export async function synchronizeCompletedProviderPlan(input: {
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
}): Promise<PlanSynchronization | null> {
|
||||
if (
|
||||
input.event.eventType !== "plan.updated" ||
|
||||
input.event.payload.complete !== true
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
!("executionMode" in input.execution) ||
|
||||
input.execution.executionMode !== "plan"
|
||||
)
|
||||
return null;
|
||||
const planningContext = input.execution.planningContext;
|
||||
if (!planningContext) return null;
|
||||
const planId =
|
||||
typeof input.event.payload.planId === "string"
|
||||
const planMode =
|
||||
"executionMode" in input.execution &&
|
||||
input.execution.executionMode === "plan";
|
||||
// A harness's own proposed-plan document (Codex plan item, Claude
|
||||
// ExitPlanMode) asks for approval in any work mode: in Planning it is the
|
||||
// planning deliverable, and in Standard the agent opted to stop and ask.
|
||||
// A within-turn checklist only stands in for the plan in Planning mode.
|
||||
const proposal =
|
||||
input.event.eventType === "plan.proposed" &&
|
||||
input.event.payload.status === "completed";
|
||||
const completedChecklist =
|
||||
input.event.eventType === "plan.updated" &&
|
||||
input.event.payload.complete === true;
|
||||
if (!proposal && !(completedChecklist && planMode)) return null;
|
||||
// Only plan-capable work modes may write the Plan document. Ask and skill
|
||||
// test runs answer in the thread; a proposal there must not become a plan.
|
||||
const workMode = input.execution.task.workMode;
|
||||
if (proposal && workMode !== "standard" && workMode !== "planning") return null;
|
||||
const planningContext =
|
||||
"executionMode" in input.execution && input.execution.executionMode === "plan"
|
||||
? input.execution.planningContext
|
||||
: null;
|
||||
if (planMode && !planningContext) return null;
|
||||
const baseRevisionId = planningContext
|
||||
? await proposalBaseRevisionId({
|
||||
db: input.db,
|
||||
issueId: input.execution.binding.issueId,
|
||||
runId: input.execution.binding.runId,
|
||||
pinnedRevisionId: planningContext.baseRevisionId,
|
||||
proposal,
|
||||
})
|
||||
: ((
|
||||
await documentService(input.db).getIssueDocumentByKey(
|
||||
input.execution.binding.issueId,
|
||||
"plan",
|
||||
)
|
||||
)?.latestRevisionId ?? null);
|
||||
const planId = proposal
|
||||
? typeof input.event.payload.proposalId === "string"
|
||||
? input.event.payload.proposalId
|
||||
: ""
|
||||
: typeof input.event.payload.planId === "string"
|
||||
? input.event.payload.planId
|
||||
: "";
|
||||
const providerRevision = Number.isSafeInteger(input.event.payload.revision)
|
||||
? Number(input.event.payload.revision)
|
||||
: 0;
|
||||
const providerRevision = proposal
|
||||
? 1
|
||||
: Number.isSafeInteger(input.event.payload.revision)
|
||||
? Number(input.event.payload.revision)
|
||||
: 0;
|
||||
const body = providerPlanMarkdown(input.event.payload);
|
||||
const digest = createHash("sha256").update(body).digest("hex");
|
||||
if (!planId || providerRevision < 1 || !body) {
|
||||
@@ -1630,7 +1689,7 @@ export async function synchronizeCompletedProviderPlan(input: {
|
||||
planId,
|
||||
providerRevision,
|
||||
status: "invalid",
|
||||
baseRevisionId: planningContext.baseRevisionId,
|
||||
baseRevisionId: baseRevisionId,
|
||||
digest,
|
||||
documentRevision: null,
|
||||
currentRevisionId: null,
|
||||
@@ -1693,7 +1752,7 @@ export async function synchronizeCompletedProviderPlan(input: {
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
body,
|
||||
baseRevisionId: planningContext.baseRevisionId,
|
||||
baseRevisionId: baseRevisionId,
|
||||
changeSummary: provenance,
|
||||
createdByAgentId: input.execution.binding.agentId,
|
||||
createdByRunId: input.execution.binding.runId,
|
||||
@@ -1714,7 +1773,7 @@ export async function synchronizeCompletedProviderPlan(input: {
|
||||
planId,
|
||||
providerRevision,
|
||||
status: "conflict",
|
||||
baseRevisionId: planningContext.baseRevisionId,
|
||||
baseRevisionId: baseRevisionId,
|
||||
digest,
|
||||
documentRevision: latest?.latestRevisionNumber ?? null,
|
||||
currentRevisionId: latest?.latestRevisionId ?? null,
|
||||
@@ -1731,7 +1790,7 @@ export async function synchronizeCompletedProviderPlan(input: {
|
||||
planId,
|
||||
providerRevision,
|
||||
status: "conflict",
|
||||
baseRevisionId: planningContext.baseRevisionId,
|
||||
baseRevisionId: baseRevisionId,
|
||||
digest,
|
||||
documentRevision: current?.latestRevisionNumber ?? null,
|
||||
currentRevisionId: current?.latestRevisionId ?? null,
|
||||
@@ -1739,7 +1798,6 @@ export async function synchronizeCompletedProviderPlan(input: {
|
||||
};
|
||||
}
|
||||
let confirmationId: string;
|
||||
let confirmationPending = false;
|
||||
try {
|
||||
const confirmation = await issueThreadInteractionService(input.db).create(
|
||||
{
|
||||
@@ -1779,62 +1837,29 @@ export async function synchronizeCompletedProviderPlan(input: {
|
||||
},
|
||||
);
|
||||
confirmationId = confirmation.id;
|
||||
confirmationPending = confirmation.status === "pending";
|
||||
} catch {
|
||||
return {
|
||||
eventId: input.event.sourceEventId,
|
||||
planId,
|
||||
providerRevision,
|
||||
status: "approval_failed",
|
||||
baseRevisionId: planningContext.baseRevisionId,
|
||||
baseRevisionId: baseRevisionId,
|
||||
digest,
|
||||
documentRevision: revision.revisionNumber,
|
||||
currentRevisionId: revision.id,
|
||||
confirmationId: null,
|
||||
};
|
||||
}
|
||||
if (confirmationPending) {
|
||||
try {
|
||||
const currentIssue = await input.db
|
||||
.select({ status: issues.status })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, input.execution.binding.issueId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (currentIssue && currentIssue.status !== "in_review") {
|
||||
await issueService(input.db).update(input.execution.binding.issueId, {
|
||||
status: "in_review",
|
||||
actorAgentId: input.execution.binding.agentId,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
const settledIssue = await input.db
|
||||
.select({ status: issues.status })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, input.execution.binding.issueId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (settledIssue?.status !== "in_review") {
|
||||
return {
|
||||
eventId: input.event.sourceEventId,
|
||||
planId,
|
||||
providerRevision,
|
||||
status: "approval_failed",
|
||||
baseRevisionId: planningContext.baseRevisionId,
|
||||
digest,
|
||||
documentRevision: revision.revisionNumber,
|
||||
currentRevisionId: revision.id,
|
||||
confirmationId,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
// Synchronization runs while the native run still owns the task. Changing
|
||||
// the task status here would revoke the run's execution lease; the pending
|
||||
// confirmation is a governance gate that the run finalizer turns into the
|
||||
// review wait.
|
||||
return {
|
||||
eventId: input.event.sourceEventId,
|
||||
planId,
|
||||
providerRevision,
|
||||
status,
|
||||
baseRevisionId: planningContext.baseRevisionId,
|
||||
baseRevisionId: baseRevisionId,
|
||||
digest,
|
||||
documentRevision: revision.revisionNumber,
|
||||
currentRevisionId: revision.id,
|
||||
|
||||
@@ -694,7 +694,8 @@ function parsePrpEvent(
|
||||
const failure = piRuntimeFailureKey(event, payload);
|
||||
if (failure !== null && failure === state.previousPiRuntimeFailure) return [];
|
||||
state.previousPiRuntimeFailure = failure;
|
||||
const family = eventType.startsWith("plan.") ? "plan"
|
||||
const family = eventType === "plan.proposed" ? "plan_proposal"
|
||||
: eventType.startsWith("plan.") ? "plan"
|
||||
: eventType.startsWith("tool.execution.") ? "tool_execution"
|
||||
: eventType.startsWith("research.") ? "research"
|
||||
: eventType.startsWith("delegation.") ? "delegation"
|
||||
@@ -710,11 +711,11 @@ function parsePrpEvent(
|
||||
: eventType.startsWith("provider.notice.") ? "provider_notice" : null;
|
||||
if (family !== null) {
|
||||
const rawStatus = text(payload.status);
|
||||
const status = (family === "plan" && payload.complete === false) || rawStatus === "running" || rawStatus === "pending" || eventType.endsWith("started") || eventType.endsWith("progressed") ? "running"
|
||||
const status = (family === "plan" && payload.complete === false) || rawStatus === "streaming" || rawStatus === "running" || rawStatus === "pending" || eventType.endsWith("started") || eventType.endsWith("progressed") ? "running"
|
||||
: rawStatus === "failed" || rawStatus === "denied" ? "failed"
|
||||
: rawStatus === "interrupted" || rawStatus === "cancelled" ? "interrupted"
|
||||
: (family === "plan" && payload.complete === true) || eventType.endsWith("completed") || rawStatus === "completed" ? "completed" : "informational";
|
||||
const title = ({ plan: "Plan", tool_execution: "Tool execution", research: "Research", delegation: "Delegation", model_identity: "Model", context: "Context", artifact: "Artifact", review: "Review mode", hook: "Hook", memory: "Memory citation", safety: "Safety review", terminal: "Terminal input", wait: "Intentional wait", provider_notice: "Provider notice" } as const)[family];
|
||||
const title = ({ plan: "Plan", plan_proposal: "Plan", tool_execution: "Tool execution", research: "Research", delegation: "Delegation", model_identity: "Model", context: "Context", artifact: "Artifact", review: "Review mode", hook: "Hook", memory: "Memory citation", safety: "Safety review", terminal: "Terminal input", wait: "Intentional wait", provider_notice: "Provider notice" } as const)[family];
|
||||
const summary = family === "model_identity"
|
||||
? text(payload.summary, text(payload.effectiveModel, text(payload.requestedModel, text(payload.provider, eventType))))
|
||||
: text(payload.summary, text(payload.name, text(payload.query, eventType)));
|
||||
|
||||
@@ -361,6 +361,17 @@ function PlanReviewPreview({
|
||||
);
|
||||
}
|
||||
|
||||
/** Plan approvals read as decisions on a named plan revision, not a generic confirmation. */
|
||||
function planReviewReceiptSummary(interaction: IssueThreadInteraction, reason: string | null): string | null {
|
||||
if (interaction.kind !== "request_confirmation") return null;
|
||||
const target = interaction.payload.target;
|
||||
if (target?.type !== "issue_document" || target.key !== "plan") return null;
|
||||
const plan = target.revisionNumber == null ? "the plan" : `Plan v${target.revisionNumber}`;
|
||||
if (interaction.status === "accepted") return `Approved ${plan}`;
|
||||
if (interaction.status === "rejected") return reason ? `Requested changes to ${plan}: ${reason}` : `Requested changes to ${plan}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function receiptReason(interaction: IssueThreadInteraction): string | null {
|
||||
const result = interaction.result;
|
||||
if (!result) return null;
|
||||
@@ -621,7 +632,8 @@ function ReceiptDisclosure({
|
||||
interaction.kind === "ask_user_questions" &&
|
||||
interaction.status === "answered"
|
||||
? "Questions answered"
|
||||
: buildIssueThreadInteractionSummary(interaction);
|
||||
: planReviewReceiptSummary(interaction, answerReason)
|
||||
?? buildIssueThreadInteractionSummary(interaction);
|
||||
return (
|
||||
<details
|
||||
className="group"
|
||||
|
||||
@@ -161,20 +161,30 @@ export function TaskChatInteractionCard({
|
||||
: interaction.payload.target?.revisionNumber ===
|
||||
planDocument.latestRevisionNumber);
|
||||
if (isResolvedPlanReview && !coordinatorEnabled) {
|
||||
if (resolvedPlanMatchesDocument && showPlanPreview && planDocument) {
|
||||
return (
|
||||
<div
|
||||
id={`interaction-${interaction.id}`}
|
||||
data-testid="task-chat-interaction"
|
||||
>
|
||||
// Keep the decision visible: the approved or sent-back plan stays as a
|
||||
// card while it is current, and the receipt records who decided and why.
|
||||
return (
|
||||
<div
|
||||
id={`interaction-${interaction.id}`}
|
||||
className="w-full space-y-2"
|
||||
data-testid="task-chat-interaction"
|
||||
>
|
||||
{resolvedPlanMatchesDocument && showPlanPreview && planDocument ? (
|
||||
<TaskChatPlanPreviewCard
|
||||
source={{ kind: "saved", document: planDocument }}
|
||||
testId="plan-review-preview"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
) : null}
|
||||
<TaskChatCompactInteractionCard
|
||||
interaction={interaction}
|
||||
planDocument={planDocument}
|
||||
showPlanPreview={false}
|
||||
presentation="timeline"
|
||||
draftKey={draftKey}
|
||||
{...cardProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (presentation === "timeline" && interaction.status !== "pending") {
|
||||
return (
|
||||
|
||||
@@ -70,9 +70,23 @@ describe("TaskChatPlanPreviewCard", () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("extracts the Markdown H1 and the first three meaningful plan lines", () => {
|
||||
it("extracts the Markdown H1 and the first three meaningful plan lines, skipping section headings", () => {
|
||||
expect(planPreviewContent("# Ship it\n\n---\n## Build\n- **First** step\n2. Use [the docs](https://example.com)\n```\nFourth"))
|
||||
.toEqual({ title: "Ship it", preview: ["Build", "First step", "Use the docs"] });
|
||||
.toEqual({ title: "Ship it", preview: ["First step", "Use the docs", "Fourth"] });
|
||||
});
|
||||
|
||||
it("previews a streaming proposed-plan document from its markdown", () => {
|
||||
const proposal: TaskChatProviderActivityItem = {
|
||||
...livePlan([]),
|
||||
family: "plan_proposal",
|
||||
eventType: "plan.proposed",
|
||||
output: "# Stop duplicates\n\n## Steps\n1. Add a unique index",
|
||||
};
|
||||
act(() => root.render(<ThemeProvider><TaskChatPlanPreviewCard source={{ kind: "live", activity: proposal }} /></ThemeProvider>));
|
||||
expect(container.textContent).toContain("Writing plan");
|
||||
expect(container.textContent).toContain("Stop duplicates");
|
||||
expect(container.textContent).toContain("Add a unique index");
|
||||
expect(container.textContent).not.toContain("Steps");
|
||||
});
|
||||
|
||||
it("renders a saved revision as a Plan-pane link", () => {
|
||||
|
||||
@@ -19,7 +19,8 @@ export function planPreviewContent(markdown: string) {
|
||||
? lines[headingIndex]!.replace(/^#\s+/, "").trim()
|
||||
: "Plan";
|
||||
const preview = lines
|
||||
.filter((line, index) => index !== headingIndex && !/^(```|~~~|---+$|\*\*\*+$|___+$)/.test(line))
|
||||
// Section headings ("## Steps") are structure, not plan content.
|
||||
.filter((line, index) => index !== headingIndex && !/^#{1,6}\s+/.test(line) && !/^(```|~~~|---+$|\*\*\*+$|___+$)/.test(line))
|
||||
.map((line) => line
|
||||
.replace(/^#{1,6}\s+/, "")
|
||||
.replace(/^[-*+]\s+/, "")
|
||||
@@ -56,6 +57,11 @@ export interface TaskChatPlanPreviewCardProps {
|
||||
}
|
||||
|
||||
function livePlanContent(activity: TaskChatProviderActivityItem) {
|
||||
// A proposed-plan document streams as markdown; a checklist streams as steps.
|
||||
if (activity.output?.trim()) {
|
||||
const fromMarkdown = planPreviewContent(activity.output);
|
||||
if (fromMarkdown.title !== "Plan" || fromMarkdown.preview.length > 0) return fromMarkdown;
|
||||
}
|
||||
const title = activity.title.trim() && activity.title.trim().toLowerCase() !== "plan"
|
||||
? activity.title.trim()
|
||||
: "Plan";
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import { MemoryRouter } from "@/lib/router";
|
||||
import { TaskChatProtocolCard } from "./TaskChatProtocolCard";
|
||||
import type { TaskChatProtocolItem } from "./task-chat-model";
|
||||
|
||||
function proposal(output: string): TaskChatProtocolItem {
|
||||
return {
|
||||
id: "run-1:provider:plan_proposal:toolu_plan:0",
|
||||
kind: "protocol",
|
||||
surface: "provider_activity",
|
||||
family: "plan_proposal",
|
||||
eventType: "plan.proposed",
|
||||
status: "completed",
|
||||
title: "Plan",
|
||||
summary: "Proposed a plan",
|
||||
details: [],
|
||||
steps: [],
|
||||
links: [],
|
||||
children: [],
|
||||
output,
|
||||
};
|
||||
}
|
||||
|
||||
describe("TaskChatProtocolCard plan proposal", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
function render(item: TaskChatProtocolItem) {
|
||||
act(() => root.render(<MemoryRouter><ThemeProvider><TaskChatProtocolCard item={item} /></ThemeProvider></MemoryRouter>));
|
||||
}
|
||||
|
||||
it("keeps a completed plan proposal readable behind its one-line record", () => {
|
||||
render(proposal("# Stop duplicates\n\n1. Add a unique index\n2. Upsert on the event id"));
|
||||
const card = container.querySelector("[data-testid='task-chat-plan-proposal']");
|
||||
expect(card?.tagName).toBe("DETAILS");
|
||||
expect(card?.textContent).toContain("Proposed a plan · Stop duplicates");
|
||||
expect(container.querySelector("[data-testid='task-chat-plan-proposal-body']")?.textContent)
|
||||
.toContain("Upsert on the event id");
|
||||
});
|
||||
|
||||
it("keeps images in a harness-written plan inert", () => {
|
||||
render(proposal("# Plan\n\n\n\n1. Ship"));
|
||||
const body = container.querySelector("[data-testid='task-chat-plan-proposal-body']");
|
||||
expect(body?.querySelector("img[src='https://tracker.example/pixel.png']")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,7 @@ import type {
|
||||
import { QuestionForm, QuestionResponseSummary } from "./QuestionForm";
|
||||
import { TaskChatComposerTakeoverHeader, TaskChatRequestHistoryContext } from "./TaskChatComposerTakeoverContext";
|
||||
import { RichWorkProductCard } from "./RichWorkProductCard";
|
||||
import { planPreviewContent, TaskChatPlanPreviewCard } from "./TaskChatPlanPreviewCard";
|
||||
import { TaskChatDiffCounts, TaskChatDiffView } from "./TaskChatDiffView";
|
||||
import { parseUnifiedDiffLines } from "./diff-lines";
|
||||
import { useArtifactsV1Enabled } from "@/hooks/useArtifactsV1Enabled";
|
||||
@@ -69,6 +70,7 @@ export interface TaskChatProtocolCardProps {
|
||||
|
||||
const FAMILY_ICON = {
|
||||
plan: GitBranch,
|
||||
plan_proposal: FileText,
|
||||
tool_execution: TerminalSquare,
|
||||
research: FileText,
|
||||
delegation: Bot,
|
||||
@@ -260,11 +262,39 @@ function PlanSteps({ steps }: { steps: TaskChatProtocolStep[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A harness's proposed plan. While it streams it is the live plan card; once
|
||||
* complete, the synchronized Plan document and its approval card carry it, so
|
||||
* the turn keeps only a one-line record.
|
||||
*/
|
||||
function PlanProposalCard({ item }: { item: TaskChatProviderActivityItem }) {
|
||||
if (item.status === "running") {
|
||||
return <TaskChatPlanPreviewCard source={{ kind: "live", activity: item }} testId="task-chat-plan-proposal" />;
|
||||
}
|
||||
const title = item.output ? planPreviewContent(item.output).title : "Plan";
|
||||
// The saved Plan document normally carries the proposal, but a conflicting
|
||||
// edit can keep it out. The proposal itself stays readable here.
|
||||
return (
|
||||
<details className="group" data-testid="task-chat-plan-proposal">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2 rounded-sm px-1 py-1 text-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<FileText aria-hidden className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="min-w-0 truncate">Proposed a plan · <span className="text-foreground">{title}</span></span>
|
||||
</summary>
|
||||
{item.output ? (
|
||||
<div className="mt-1 rounded-md border border-border bg-muted/25 px-4 py-3 text-sm" data-testid="task-chat-plan-proposal-body">
|
||||
<MarkdownBody mediaMode="reference">{item.output}</MarkdownBody>
|
||||
</div>
|
||||
) : null}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderActivityCard({
|
||||
item,
|
||||
}: {
|
||||
item: TaskChatProviderActivityItem;
|
||||
}) {
|
||||
if (item.family === "plan_proposal") return <PlanProposalCard item={item} />;
|
||||
const Icon = FAMILY_ICON[item.family];
|
||||
const hasDetails =
|
||||
item.details.length > 0 ||
|
||||
|
||||
@@ -144,6 +144,8 @@ export function providerActivityPresentation(item: TaskChatProviderActivityItem)
|
||||
detail: [activityIsIdentity ? undefined : boundedActivity, source].filter(Boolean).join(" · ") || undefined,
|
||||
};
|
||||
}
|
||||
case "plan_proposal":
|
||||
return { icon: FileText, runningLabel: "Writing a plan", completedLabel: "Proposed a plan", failedLabel: "Plan proposal failed", interruptedLabel: "Plan proposal stopped", detail };
|
||||
case "plan":
|
||||
return { icon: ListChecks, runningLabel: "Updating the plan", completedLabel: "Updated the plan", failedLabel: "Plan update failed", interruptedLabel: "Plan update stopped", detail };
|
||||
case "delegation": {
|
||||
|
||||
@@ -35,6 +35,7 @@ export type { PaperclipQuestionResponse, PaperclipQuestionSet };
|
||||
|
||||
export type TaskChatProviderActivityFamily =
|
||||
| "plan"
|
||||
| "plan_proposal"
|
||||
| "tool_execution"
|
||||
| "research"
|
||||
| "delegation"
|
||||
|
||||
@@ -37,7 +37,7 @@ const debugOnly = (surface: string, rationale: string): TaskProtocolSurfaceRegis
|
||||
});
|
||||
|
||||
const providerEvents = [
|
||||
"plan.updated",
|
||||
"plan.updated", "plan.proposed",
|
||||
"tool.execution.started", "tool.execution.progressed", "tool.execution.completed",
|
||||
"research.started", "research.progressed", "research.completed",
|
||||
"delegation.started", "delegation.updated", "delegation.completed",
|
||||
|
||||
@@ -282,6 +282,7 @@ const PROVIDER_DETAIL_KEYS: Record<
|
||||
readonly string[]
|
||||
> = {
|
||||
plan: ["revision", "syncStatus", "documentRevision", "complete"],
|
||||
plan_proposal: ["status", "source"],
|
||||
tool_execution: [
|
||||
"transport",
|
||||
"operation",
|
||||
@@ -347,6 +348,7 @@ function providerActivityKey(
|
||||
): string {
|
||||
const idKeys = [
|
||||
"planId",
|
||||
"proposalId",
|
||||
"executionId",
|
||||
"researchId",
|
||||
"delegationId",
|
||||
@@ -478,10 +480,12 @@ function providerActivityItem(
|
||||
const rawOutput =
|
||||
entry.family === "tool_execution"
|
||||
? stringValue(entry.payload.output)
|
||||
: undefined;
|
||||
: entry.family === "plan_proposal"
|
||||
? stringValue(entry.payload.markdown)
|
||||
: undefined;
|
||||
const outputTruncated =
|
||||
entry.payload.outputTruncated === true ||
|
||||
Boolean(rawOutput && rawOutput.length > PROTOCOL_OUTPUT_MAX);
|
||||
Boolean(rawOutput && entry.family !== "plan_proposal" && rawOutput.length > PROTOCOL_OUTPUT_MAX);
|
||||
const summary =
|
||||
entry.summary && entry.summary !== entry.eventType
|
||||
? entry.summary
|
||||
@@ -503,7 +507,10 @@ function providerActivityItem(
|
||||
steps,
|
||||
links,
|
||||
children,
|
||||
output: rawOutput ? rawOutput.slice(0, PROTOCOL_OUTPUT_MAX) : undefined,
|
||||
// A proposed plan is a document, bounded by its own 60k event schema.
|
||||
output: rawOutput
|
||||
? rawOutput.slice(0, entry.family === "plan_proposal" ? 60_000 : PROTOCOL_OUTPUT_MAX)
|
||||
: undefined,
|
||||
outputTruncated,
|
||||
transcriptIndex: index,
|
||||
startedAt: entry.ts,
|
||||
@@ -2015,6 +2022,15 @@ export function buildTurnTimelineRows(
|
||||
} else if (item.kind === "plan_document") {
|
||||
current = null;
|
||||
rows.push(item);
|
||||
} else if (
|
||||
item.kind === "protocol" &&
|
||||
item.surface === "provider_activity" &&
|
||||
item.family === "plan_proposal"
|
||||
) {
|
||||
// A proposed plan is a deliverable, so it gets its own row instead of
|
||||
// folding into the collapsed activity phase.
|
||||
current = null;
|
||||
rows.push(item);
|
||||
} else if (
|
||||
item.kind === "protocol" &&
|
||||
item.surface === "provider_activity" &&
|
||||
|
||||
@@ -169,6 +169,116 @@ describe("native plan presentation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("native proposed-plan presentation", () => {
|
||||
it("streams Codex plan-item deltas into one live proposal and settles on the completed document", () => {
|
||||
const transcript = nativeRunEventsToTranscript([
|
||||
itemEvent(1, "item.started", "plan-item-1", { kind: "plan", channel: "detail", text: "" }),
|
||||
itemEvent(2, "item.delta", "plan-item-1", { kind: "plan", channel: "detail", text: "<proposed_plan>\n# Stop duplicates\n" }),
|
||||
itemEvent(3, "item.delta", "plan-item-1", { kind: "plan", channel: "detail", text: "\n1. Add a unique index" }),
|
||||
event(4, "plan.proposed", {
|
||||
schema: "paperclip.plan.proposed.v1",
|
||||
proposalId: "plan-item-1",
|
||||
status: "completed",
|
||||
markdown: "# Stop duplicates\n\n1. Add a unique index\n2. Upsert",
|
||||
source: "codex_plan_item",
|
||||
}),
|
||||
]);
|
||||
const proposals = transcript.filter((entry) => entry.kind === "provider_activity" && entry.family === "plan_proposal");
|
||||
expect(proposals).toEqual([
|
||||
expect.objectContaining({ status: "running", payload: expect.objectContaining({ markdown: "# Stop duplicates" }) }),
|
||||
expect.objectContaining({ status: "running", payload: expect.objectContaining({ markdown: "# Stop duplicates\n\n1. Add a unique index" }) }),
|
||||
expect.objectContaining({ status: "completed", summary: "Proposed a plan" }),
|
||||
]);
|
||||
|
||||
const live = transcriptToTaskChatItems(transcript.slice(0, -1), { runId: RUN_ID, agentName: "Codex", running: true })
|
||||
.filter((item) => item.kind === "protocol" && item.surface === "provider_activity" && item.family === "plan_proposal");
|
||||
expect(live).toHaveLength(1);
|
||||
expect(live[0]).toMatchObject({ status: "running", output: "# Stop duplicates\n\n1. Add a unique index" });
|
||||
|
||||
const settled = transcriptToTaskChatItems(transcript, { runId: RUN_ID, agentName: "Codex", running: false })
|
||||
.filter((item) => item.kind === "protocol" && item.surface === "provider_activity" && item.family === "plan_proposal");
|
||||
expect(settled).toHaveLength(1);
|
||||
expect(settled[0]).toMatchObject({ status: "completed", output: "# Stop duplicates\n\n1. Add a unique index\n2. Upsert" });
|
||||
});
|
||||
|
||||
it("shows a Codex message's <proposed_plan> block only through the proposal card", () => {
|
||||
const transcript = nativeRunEventsToTranscript([
|
||||
itemEvent(1, "item.completed", "msg-1", {
|
||||
kind: "agentMessage",
|
||||
channel: "final",
|
||||
text: "The plan is ready for your approval.\n<proposed_plan>\n# Fix\n1. Ship\n</proposed_plan>",
|
||||
}),
|
||||
event(2, "plan.proposed", {
|
||||
schema: "paperclip.plan.proposed.v1",
|
||||
proposalId: "msg-1",
|
||||
status: "completed",
|
||||
markdown: "# Fix\n1. Ship",
|
||||
source: "codex_plan_item",
|
||||
}),
|
||||
]);
|
||||
expect(transcript).toEqual([
|
||||
expect.objectContaining({ kind: "assistant", text: "The plan is ready for your approval." }),
|
||||
expect.objectContaining({ kind: "provider_activity", family: "plan_proposal", status: "completed" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a message's plan text when no proposal was recorded for it", () => {
|
||||
const text = "Draft plan below.\n<proposed_plan>\n# Fix\n1. Ship\n</proposed_plan>";
|
||||
const transcript = nativeRunEventsToTranscript([
|
||||
itemEvent(1, "item.completed", "msg-old", { kind: "agentMessage", channel: "final", text }),
|
||||
]);
|
||||
expect(transcript).toEqual([expect.objectContaining({ kind: "assistant", text })]);
|
||||
});
|
||||
|
||||
it("hides the cut-off remainder of a proposal in a shortened message preview", () => {
|
||||
const transcript = nativeRunEventsToTranscript([
|
||||
itemEvent(1, "item.completed", "msg-1", {
|
||||
kind: "agentMessage",
|
||||
channel: "final",
|
||||
text: "Plan ready.\n<proposed_plan>\n# Fix\n1. Ship…[truncated]",
|
||||
}),
|
||||
event(2, "plan.proposed", {
|
||||
schema: "paperclip.plan.proposed.v1", proposalId: "msg-1", status: "completed",
|
||||
markdown: "# Fix\n1. Ship\n2. Verify", source: "codex_plan_item",
|
||||
}),
|
||||
]);
|
||||
expect(transcript[0]).toMatchObject({ kind: "assistant", text: "Plan ready." });
|
||||
|
||||
const fenced = "Format:\n```\n<proposed_plan>\n# Plan…[truncated]";
|
||||
const fencedTranscript = nativeRunEventsToTranscript([
|
||||
itemEvent(1, "item.completed", "msg-2", { kind: "agentMessage", channel: "final", text: fenced }),
|
||||
event(2, "plan.proposed", {
|
||||
schema: "paperclip.plan.proposed.v1", proposalId: "msg-2", status: "completed",
|
||||
markdown: "# Other", source: "codex_plan_item",
|
||||
}),
|
||||
]);
|
||||
expect(fencedTranscript[0]).toMatchObject({ kind: "assistant", text: fenced });
|
||||
});
|
||||
|
||||
it("shows a completed proposal's example tags unchanged on its card", () => {
|
||||
const markdown = "# Plan\n\n1. Format: `<proposed_plan>x</proposed_plan>`";
|
||||
const transcript = nativeRunEventsToTranscript([event(1, "plan.proposed", {
|
||||
schema: "paperclip.plan.proposed.v1", proposalId: "p-1", status: "completed", markdown, source: "claude_exit_plan_mode",
|
||||
})]);
|
||||
expect(transcript[0]).toMatchObject({ family: "plan_proposal", payload: expect.objectContaining({ markdown }) });
|
||||
});
|
||||
|
||||
it("keeps a Claude ExitPlanMode proposal out of the turn checklist", () => {
|
||||
const transcript = nativeRunEventsToTranscript([event(1, "plan.proposed", {
|
||||
schema: "paperclip.plan.proposed.v1",
|
||||
proposalId: "toolu_plan",
|
||||
status: "completed",
|
||||
markdown: "# Plan\n\n1. Ship it",
|
||||
source: "claude_exit_plan_mode",
|
||||
})]);
|
||||
expect(transcript).toEqual([
|
||||
expect.objectContaining({ kind: "provider_activity", family: "plan_proposal", status: "completed" }),
|
||||
]);
|
||||
const items = transcriptToTaskChatItems(transcript, { runId: RUN_ID, agentName: "Claude", running: false });
|
||||
expect(items.some((item) => item.kind === "protocol" && item.surface === "provider_activity" && item.family === "plan")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nativeRunEventsToTranscript", () => {
|
||||
describe("accepted response-wake authority", () => {
|
||||
function fixture() {
|
||||
|
||||
@@ -93,6 +93,80 @@ function isItemIdentityEvent(eventType: string): boolean {
|
||||
|| eventType === "item.completed";
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex plan mode wraps a streaming plan item in `<proposed_plan>` tags. Only
|
||||
* that outer wrapper is removed; example tags inside the plan stay text.
|
||||
*/
|
||||
function proposedPlanMarkdown(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
const open = /^<proposed_plan>[ \t]*(?:\n|$)/i.exec(trimmed);
|
||||
if (!open) return trimmed;
|
||||
return trimmed.slice(open[0].length).replace(/(?:^|\n)[ \t]*<\/proposed_plan>[ \t]*$/i, "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a message around one `<proposed_plan>` block. The tags must sit on
|
||||
* their own lines outside fenced code, so quoting or documenting the tag never
|
||||
* creates a proposal.
|
||||
*/
|
||||
function splitProposedPlanBlock(value: string): { plan: string | null; rest: string; unclosedStart: number | null } {
|
||||
const lines = value.split("\n");
|
||||
let fence: string | null = null;
|
||||
let start = -1;
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const trimmed = lines[index]!.trim();
|
||||
// Fences are tracked inside the block too, so a fenced example tag in
|
||||
// the plan never opens or closes it.
|
||||
const marker = /^(`{3,}|~{3,})/.exec(trimmed)?.[1];
|
||||
if (marker) {
|
||||
fence = fence === null ? marker[0]! : (trimmed.startsWith(fence) ? null : fence);
|
||||
continue;
|
||||
}
|
||||
if (fence !== null) continue;
|
||||
if (start < 0 && /^<proposed_plan>$/i.test(trimmed)) start = index;
|
||||
else if (start >= 0 && /^<\/proposed_plan>$/i.test(trimmed)) {
|
||||
return {
|
||||
plan: lines.slice(start + 1, index).join("\n").trim(),
|
||||
rest: [...lines.slice(0, start), ...lines.slice(index + 1)].join("\n").trim(),
|
||||
unclosedStart: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
// An opening tag outside fences that never closes: a cut-off preview.
|
||||
return {
|
||||
plan: null,
|
||||
rest: value,
|
||||
unclosedStart: start < 0 ? null : lines.slice(0, start).join("\n").length,
|
||||
};
|
||||
}
|
||||
|
||||
function withoutProposedPlanBlock(value: string): string {
|
||||
const { plan, rest, unclosedStart } = splitProposedPlanBlock(value);
|
||||
if (plan !== null) return rest;
|
||||
// A stored preview can end inside the block; hide its cut-off remainder.
|
||||
return unclosedStart === null ? value : value.slice(0, unclosedStart).trim();
|
||||
}
|
||||
|
||||
function planProposalEntry(
|
||||
ts: string,
|
||||
eventType: string,
|
||||
proposalId: string,
|
||||
markdown: string,
|
||||
completed: boolean,
|
||||
payload: Record<string, unknown>,
|
||||
): TranscriptEntry {
|
||||
return {
|
||||
kind: "provider_activity",
|
||||
ts,
|
||||
family: "plan_proposal",
|
||||
eventType,
|
||||
status: completed ? "completed" : "running",
|
||||
title: "Plan",
|
||||
summary: completed ? "Proposed a plan" : "Writing a plan",
|
||||
payload: { ...payload, proposalId, markdown, status: completed ? "completed" : "streaming" },
|
||||
};
|
||||
}
|
||||
|
||||
const TOOL_EXECUTION_SCHEMA = "paperclip.tool.execution.v1";
|
||||
const RUN_RESULT_SCHEMA = "paperclip.run_result.v1";
|
||||
const RUN_TERMINAL_SCHEMA = "paperclip.prp.terminal.v1";
|
||||
@@ -815,6 +889,15 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
|
||||
// An edit call reports an optimistic patch when it starts and the exact one
|
||||
// when it lands. The row shows the latest one, wherever it first appears.
|
||||
const fileChangesByExecutionId = new Map<string, unknown[]>();
|
||||
// Messages whose `<proposed_plan>` block became a completed proposal show
|
||||
// it through the proposal card; any other message keeps its text.
|
||||
const proposedMessageIds = new Set<string>();
|
||||
for (const event of orderedEvents) {
|
||||
if (event.eventType !== "plan.proposed") continue;
|
||||
const payload = record(record(event.payload?.prpEvent)?.payload);
|
||||
const proposalId = text(payload?.proposalId);
|
||||
if (proposalId && payload?.status === "completed") proposedMessageIds.add(proposalId);
|
||||
}
|
||||
for (const event of orderedEvents) {
|
||||
if (!event.eventType.startsWith("tool.execution.")) continue;
|
||||
const payload = record(record(event.payload?.prpEvent)?.payload);
|
||||
@@ -872,6 +955,8 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
|
||||
}
|
||||
|
||||
const itemIdentityById = new Map<string, ItemIdentity>();
|
||||
const proposalMarkdownById = new Map<string, string>();
|
||||
const completedProposalIds = new Set<string>();
|
||||
for (const event of orderedEvents) {
|
||||
const envelope = record(event.payload?.prpEvent);
|
||||
if (
|
||||
@@ -906,7 +991,9 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
|
||||
}
|
||||
|
||||
if (event.eventType === "item.completed" && isAssistantItemKind(itemKind)) {
|
||||
const value = normalizedItemText(payload);
|
||||
// A `<proposed_plan>` block is shown by its plan proposal card instead.
|
||||
const raw = normalizedItemText(payload) ?? "";
|
||||
const value = itemId && proposedMessageIds.has(itemId) ? withoutProposedPlanBlock(raw) : raw;
|
||||
if (!value) continue;
|
||||
const channel = itemIdentity.assistantChannel;
|
||||
entries.push({ kind: "assistant", ts, text: value, channel, ...(itemId ? { itemId } : {}) });
|
||||
@@ -1032,6 +1119,29 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
|
||||
continue;
|
||||
}
|
||||
|
||||
// A proposed plan document (Codex plan item, Claude ExitPlanMode) is the
|
||||
// candidate for the task's Plan document, not the turn checklist.
|
||||
if (event.eventType === "plan.proposed" && payload.schema === "paperclip.plan.proposed.v1" && text(payload.proposalId)) {
|
||||
const proposalId = text(payload.proposalId)!;
|
||||
const completed = payload.status === "completed";
|
||||
// A completed proposal is already the unwrapped document; show it as is.
|
||||
const markdown = text(payload.markdown) ?? proposedPlanMarkdown(proposalMarkdownById.get(proposalId) ?? "");
|
||||
if (completed) completedProposalIds.add(proposalId);
|
||||
proposalMarkdownById.set(proposalId, markdown);
|
||||
entries.push(planProposalEntry(ts, event.eventType, proposalId, markdown, completed, payload));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Codex streams the proposed plan as plan-item deltas before the
|
||||
// completed plan.proposed event carries the whole document.
|
||||
if (event.eventType === "item.delta" && itemKind === "plan" && itemId) {
|
||||
if (completedProposalIds.has(itemId)) continue;
|
||||
const accumulated = (proposalMarkdownById.get(itemId) ?? "") + (normalizedItemText(payload) ?? "");
|
||||
proposalMarkdownById.set(itemId, accumulated);
|
||||
entries.push(planProposalEntry(ts, "plan.proposed", itemId, proposedPlanMarkdown(accumulated), false, {}));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Governance records (hooks, automatic approval reviews) keep their full
|
||||
// payload so the task chat can show the decision, reason and target.
|
||||
if ((event.eventType === "hook.started" || event.eventType === "hook.completed")
|
||||
|
||||
@@ -104,7 +104,7 @@ export interface IssueChatTranscriptEntry {
|
||||
cachedTokens?: number;
|
||||
costUsd?: number;
|
||||
changeType?: "add" | "remove" | "context" | "hunk" | "file_header" | "truncation";
|
||||
family?: "plan" | "tool_execution" | "research" | "delegation" | "model_identity" | "context" | "artifact" | "review" | "hook" | "memory" | "safety" | "terminal" | "wait" | "verification" | "provider_notice";
|
||||
family?: "plan" | "plan_proposal" | "tool_execution" | "research" | "delegation" | "model_identity" | "context" | "artifact" | "review" | "hook" | "memory" | "safety" | "terminal" | "wait" | "verification" | "provider_notice";
|
||||
eventType?: string;
|
||||
status?: "running" | "completed" | "failed" | "interrupted" | "informational" | "pending" | "resolved" | "expired" | "cancelled";
|
||||
title?: string;
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { IssueDocument, IssueThreadInteraction, IssueWorkMode } from "@paperclipai/shared";
|
||||
import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
|
||||
import { TaskChatComposerDock } from "@/components/task-chat/TaskChatComposerDock";
|
||||
import { TaskChatInteractionCard } from "@/components/task-chat/TaskChatInteractionCard";
|
||||
import { TaskChatPlanPreviewCard } from "@/components/task-chat/TaskChatPlanPreviewCard";
|
||||
import {
|
||||
TaskChatTurnStatusIsland,
|
||||
taskChatTurnPlanSegment,
|
||||
} from "@/components/task-chat/TaskChatTurnStatusIsland";
|
||||
import type { TaskChatProtocolStep, TaskChatProviderActivityItem } from "@/components/task-chat/task-chat-model";
|
||||
import { issueThreadInteractionFixtureMeta } from "@/fixtures/issueThreadInteractionFixtures";
|
||||
import { composerAgentAppearance, composerAgents } from "../../prototypes/composer-model-picker/fixtures";
|
||||
import { storybookAgentMap, storybookIssueDocuments } from "../../fixtures/paperclipData";
|
||||
|
||||
/*
|
||||
* Proposed plans that can be approved (harness signal #9).
|
||||
*
|
||||
* A task in Plan mode runs the harness in its own plan mode (Codex
|
||||
* collaborationMode "plan", Claude permission mode "plan"). The harness's
|
||||
* proposed-plan document (Codex `plan` item, Claude ExitPlanMode) becomes the
|
||||
* issue's `plan` document, and the existing plan request_confirmation drives the
|
||||
* Approve / Request changes decision in the composer takeover.
|
||||
*/
|
||||
|
||||
const ISSUE_ID = issueThreadInteractionFixtureMeta.issueId;
|
||||
|
||||
const PROPOSED_PLAN = [
|
||||
"# Stop duplicate webhook deliveries",
|
||||
"",
|
||||
"## Findings",
|
||||
"- The delivery worker retries on any 5xx, including after the receiver already committed the event.",
|
||||
"- `deliveries.event_id` has no unique constraint, so a retry inserts a second delivery row.",
|
||||
"",
|
||||
"## Steps",
|
||||
"1. Add a unique index on `(endpoint_id, event_id)` with a migration that keeps the newest row.",
|
||||
"2. Make `enqueueDelivery` an upsert keyed on the event id.",
|
||||
"3. Send an `Idempotency-Key` header so receivers can dedupe on their side.",
|
||||
"4. Add a regression test that retries a delivery after a 502.",
|
||||
"",
|
||||
"## Out of scope",
|
||||
"- Changing the retry backoff schedule.",
|
||||
].join("\n");
|
||||
|
||||
/** The proposal as it streams: the first N non-empty lines of the markdown. */
|
||||
function partialPlan(lineCount: number) {
|
||||
return PROPOSED_PLAN.split("\n").filter(Boolean).slice(0, lineCount).join("\n\n");
|
||||
}
|
||||
|
||||
const planDocument: IssueDocument = {
|
||||
...storybookIssueDocuments[0]!,
|
||||
issueId: ISSUE_ID,
|
||||
body: PROPOSED_PLAN,
|
||||
latestRevisionId: "revision-plan-1",
|
||||
latestRevisionNumber: 1,
|
||||
};
|
||||
|
||||
function liveDraft(lineCount: number): TaskChatProviderActivityItem {
|
||||
return {
|
||||
id: "provider-activity:plan-proposal",
|
||||
kind: "protocol",
|
||||
surface: "provider_activity",
|
||||
family: "plan",
|
||||
eventType: "plan.proposed",
|
||||
status: "running",
|
||||
title: "Plan",
|
||||
details: [],
|
||||
steps: [],
|
||||
links: [],
|
||||
children: [],
|
||||
output: partialPlan(lineCount),
|
||||
};
|
||||
}
|
||||
|
||||
const basePlanConfirmation = {
|
||||
id: "interaction-proposed-plan",
|
||||
companyId: issueThreadInteractionFixtureMeta.companyId,
|
||||
issueId: ISSUE_ID,
|
||||
kind: "request_confirmation" as const,
|
||||
title: "Review plan revision 1",
|
||||
summary: "Review the synchronized Paperclip plan.",
|
||||
status: "pending" as const,
|
||||
continuationPolicy: "wake_assignee" as const,
|
||||
sourceRunId: "run-plan-1",
|
||||
createdByAgentId: "agent-codex",
|
||||
createdByUserId: null,
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: null,
|
||||
createdAt: new Date("2026-11-05T15:02:00.000Z"),
|
||||
updatedAt: new Date("2026-11-05T15:02:00.000Z"),
|
||||
resolvedAt: null,
|
||||
payload: {
|
||||
version: 1 as const,
|
||||
prompt: "Approve plan revision 1?",
|
||||
detailsMarkdown: "The completed provider plan has been synchronized to the canonical Plan document.",
|
||||
acceptLabel: "Approve plan",
|
||||
rejectLabel: "Request changes",
|
||||
rejectRequiresReason: true,
|
||||
supersedeOnUserComment: false,
|
||||
target: {
|
||||
type: "issue_document" as const,
|
||||
issueId: ISSUE_ID,
|
||||
documentId: planDocument.id,
|
||||
key: "plan",
|
||||
revisionId: "revision-plan-1",
|
||||
revisionNumber: 1,
|
||||
label: "Plan v1",
|
||||
},
|
||||
},
|
||||
result: null,
|
||||
} as IssueThreadInteraction;
|
||||
|
||||
const acceptedPlanConfirmation = {
|
||||
...basePlanConfirmation,
|
||||
status: "accepted",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-11-05T15:04:00.000Z"),
|
||||
result: { version: 1, outcome: "accepted" },
|
||||
} as IssueThreadInteraction;
|
||||
|
||||
const rejectedPlanConfirmation = {
|
||||
...basePlanConfirmation,
|
||||
status: "rejected",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-11-05T15:04:00.000Z"),
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "rejected",
|
||||
reason: "Keep the old rows. Dedupe at read time instead of deleting history in the migration.",
|
||||
},
|
||||
} as IssueThreadInteraction;
|
||||
|
||||
const IMPLEMENT_STEPS: TaskChatProtocolStep[] = [
|
||||
"Add a unique index on (endpoint_id, event_id)",
|
||||
"Make enqueueDelivery an upsert keyed on the event id",
|
||||
"Send an Idempotency-Key header",
|
||||
"Add a regression test for retried deliveries",
|
||||
].map((label, index) => ({ id: `impl-${index}`, label, status: index === 0 ? "in_progress" : "pending" }));
|
||||
|
||||
const agentMap = new Map(composerAgents.map((agent) => [agent.id, {
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
appearance: composerAgentAppearance(agent.id),
|
||||
}]));
|
||||
const assignees = composerAgents.map((agent) => ({ id: `agent:${agent.id}`, label: agent.name }));
|
||||
|
||||
function UserMessage({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="ml-auto max-w-[80%] rounded-lg bg-muted px-3 py-2 text-sm text-foreground">{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentText({ children }: { children: ReactNode }) {
|
||||
return <p className="text-sm leading-6 text-foreground">{children}</p>;
|
||||
}
|
||||
|
||||
function Thread({ children }: { children: ReactNode }) {
|
||||
return <div className="flex flex-col gap-4 px-1 pb-4">{children}</div>;
|
||||
}
|
||||
|
||||
/** Production composer dock + composer, optionally with the plan decision takeover. */
|
||||
function Dock({
|
||||
children,
|
||||
initialMode = "planning",
|
||||
review = false,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
initialMode?: IssueWorkMode;
|
||||
review?: boolean;
|
||||
}) {
|
||||
const [workMode, setWorkMode] = useState<IssueWorkMode>(initialMode);
|
||||
const [open, setOpen] = useState(review);
|
||||
const takeoverContent = (
|
||||
<TaskChatInteractionCard
|
||||
item={{ id: `interaction:${basePlanConfirmation.id}`, kind: "interaction", interaction: basePlanConfirmation }}
|
||||
presentation="takeover"
|
||||
planDocument={planDocument}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
onAcceptInteraction={async () => setOpen(false)}
|
||||
onRejectInteraction={async () => setOpen(false)}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<TaskChatComposerDock mobile={false} streamlined={false}>
|
||||
{children}
|
||||
<TaskChatComposer
|
||||
onAdd={async () => {}}
|
||||
onStop={async () => {}}
|
||||
workMode={workMode}
|
||||
onWorkModeChange={setWorkMode}
|
||||
enableReassign
|
||||
currentAssigneeValue="agent:codex"
|
||||
reassignOptions={assignees}
|
||||
agentMap={agentMap}
|
||||
issueStatus={review ? "in_review" : "in_progress"}
|
||||
takeover={open ? {
|
||||
id: basePlanConfirmation.id,
|
||||
label: "Review plan",
|
||||
pendingCount: 1,
|
||||
content: takeoverContent,
|
||||
onDismiss: () => setOpen(false),
|
||||
onSkip: () => setOpen(false),
|
||||
hideSkip: true,
|
||||
} : null}
|
||||
/>
|
||||
</TaskChatComposerDock>
|
||||
);
|
||||
}
|
||||
|
||||
function Stage({ step, title, rule, children }: { step: number; title: string; rule: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{step}. {title}</h3>
|
||||
<p className="text-xs text-muted-foreground">{rule}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-background p-3">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Page({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-6 text-foreground">
|
||||
<div className="mx-auto w-full max-w-2xl">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ASK = "Webhook receivers are seeing the same event twice after timeouts. Plan the fix first.";
|
||||
|
||||
function Entered() {
|
||||
return (
|
||||
<>
|
||||
<Thread><UserMessage>{ASK}</UserMessage></Thread>
|
||||
<Dock initialMode="planning" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Drafting() {
|
||||
return (
|
||||
<>
|
||||
<Thread>
|
||||
<UserMessage>{ASK}</UserMessage>
|
||||
<AgentText>Reading the delivery worker and the deliveries schema before proposing a fix.</AgentText>
|
||||
<TaskChatPlanPreviewCard source={{ kind: "live", activity: liveDraft(5) }} />
|
||||
</Thread>
|
||||
<Dock initialMode="planning" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadyForReview() {
|
||||
return (
|
||||
<>
|
||||
<Thread>
|
||||
<UserMessage>{ASK}</UserMessage>
|
||||
<TaskChatPlanPreviewCard source={{ kind: "saved", document: planDocument }} testId="plan-review-preview" />
|
||||
<AgentText>Plan saved to the task. Waiting for your approval before changing any code.</AgentText>
|
||||
</Thread>
|
||||
<Dock initialMode="planning" review />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangesRequested() {
|
||||
return (
|
||||
<>
|
||||
<Thread>
|
||||
<UserMessage>{ASK}</UserMessage>
|
||||
<TaskChatPlanPreviewCard source={{ kind: "saved", document: planDocument }} />
|
||||
<TaskChatInteractionCard
|
||||
item={{ id: `interaction:${rejectedPlanConfirmation.id}`, kind: "interaction", interaction: rejectedPlanConfirmation }}
|
||||
planDocument={{ ...planDocument, latestRevisionId: "revision-plan-2", latestRevisionNumber: 2 }}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
/>
|
||||
<TaskChatPlanPreviewCard source={{ kind: "live", activity: liveDraft(3) }} />
|
||||
</Thread>
|
||||
<Dock initialMode="planning" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Approved() {
|
||||
return (
|
||||
<>
|
||||
<Thread>
|
||||
<UserMessage>{ASK}</UserMessage>
|
||||
<TaskChatInteractionCard
|
||||
item={{ id: `interaction:${acceptedPlanConfirmation.id}`, kind: "interaction", interaction: acceptedPlanConfirmation }}
|
||||
planDocument={planDocument}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
/>
|
||||
<AgentText>Starting on the approved plan.</AgentText>
|
||||
</Thread>
|
||||
<Dock initialMode="standard">
|
||||
<TaskChatTurnStatusIsland model={{ segments: [taskChatTurnPlanSegment(IMPLEMENT_STEPS)] }} />
|
||||
</Dock>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Proposed: the agent's own switch into plan mode in a Standard task (Claude
|
||||
* EnterPlanMode, or a Paperclip plan request from any harness). Prototype row.
|
||||
*/
|
||||
function ModeSwitchRow({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span aria-hidden className="h-px flex-1 bg-border" />
|
||||
<span>{children}</span>
|
||||
<span aria-hidden className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const OPT_IN_ASK = "Fix the duplicate webhook deliveries.";
|
||||
|
||||
function AgentOptsIn() {
|
||||
return (
|
||||
<>
|
||||
<Thread>
|
||||
<UserMessage>{OPT_IN_ASK}</UserMessage>
|
||||
<AgentText>The fix needs a migration that rewrites delivery history, so I'll propose a plan before changing anything.</AgentText>
|
||||
<ModeSwitchRow>Codie switched to plan mode · read-only until you decide</ModeSwitchRow>
|
||||
<TaskChatPlanPreviewCard source={{ kind: "saved", document: planDocument }} />
|
||||
</Thread>
|
||||
<Dock initialMode="standard" review />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Journey() {
|
||||
return (
|
||||
<Page>
|
||||
<div className="flex flex-col gap-10">
|
||||
<header>
|
||||
<h2 className="text-lg font-semibold">Proposed plan approval</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Plan mode on the task puts Codex or Claude into its own plan mode. The harness's proposed plan becomes
|
||||
the task's Plan document, and you approve it or request changes from the composer.
|
||||
</p>
|
||||
</header>
|
||||
<Stage step={1} title="Plan mode is on" rule="The task's work mode is Planning (composer add menu → Plan mode). The yellow chip shows it; the run is read-only.">
|
||||
<Entered />
|
||||
</Stage>
|
||||
<Stage step={2} title="Agent drafts the plan" rule="The harness's proposed-plan stream (Codex plan item, Claude ExitPlanMode) shows as a live plan card instead of plain transcript text.">
|
||||
<Drafting />
|
||||
</Stage>
|
||||
<Stage step={3} title="Ready for review" rule="The finished proposal is saved as Plan v1. The composer is taken over by Approve plan / Request changes; the task is In review.">
|
||||
<ReadyForReview />
|
||||
</Stage>
|
||||
<Stage step={4} title="Changes requested" rule="Request changes needs a reason. The agent wakes in Plan mode with the reason and drafts revision 2.">
|
||||
<ChangesRequested />
|
||||
</Stage>
|
||||
<Stage step={5} title="Approved" rule="Approve flips the task to Standard and wakes the agent to implement. The run's checklist takes over above the composer.">
|
||||
<Approved />
|
||||
</Stage>
|
||||
<Stage step={6} title="Agent opts in on a Standard task" rule="No Plan mode chip: the agent chose to stop and ask. Same plan document and approval; the task stays Standard, so Approve just resumes it.">
|
||||
<AgentOptsIn />
|
||||
</Stage>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
id: "product-task-chat-proposed-plan",
|
||||
title: "Tasks and conversations/Task creation and lifecycle/Product/Task chat/Proposed plan approval",
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A harness-native proposed plan (Codex `plan` item, Claude ExitPlanMode) becomes the task's Plan document and is approved or sent back from the composer takeover.",
|
||||
},
|
||||
},
|
||||
paperclip: {
|
||||
"capabilities": [
|
||||
"tasks/discussion",
|
||||
"runs-adapters/runs"
|
||||
],
|
||||
"implementation": "mixed",
|
||||
"boundary": "fixtures",
|
||||
"notes": "Production plan preview card, interaction card, composer dock, composer takeover and turn-status island with fixture plan data. Transcript message bubbles and the agent mode-switch divider are simplified stand-ins. Provider event delivery and the approval round-trip are not exercised."
|
||||
},
|
||||
},
|
||||
} satisfies Meta;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const FullJourney: Story = { render: () => <Journey /> };
|
||||
export const PlanModeOn: Story = { render: () => <Page><Entered /></Page> };
|
||||
export const DraftingPlan: Story = { render: () => <Page><Drafting /></Page> };
|
||||
export const ReadyForReviewTakeover: Story = { render: () => <Page><ReadyForReview /></Page> };
|
||||
export const ChangesRequestedRevision: Story = { render: () => <Page><ChangesRequested /></Page> };
|
||||
export const AgentOptsInStandardTask: Story = { render: () => <Page><AgentOptsIn /></Page> };
|
||||
export const ApprovedImplementing: Story = { render: () => <Page><Approved /></Page> };
|
||||
Reference in new issue
Block a user