From 0c6b4b6e0078625b1e00a1dccc2500f093b9664a Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Tue, 21 Apr 2026 12:02:52 -0500 Subject: [PATCH 1/3] Add GET /api/v1/plans/:id/snapshot endpoint Single endpoint returning everything an agent needs: plan metadata, current content, comment threads with comments, references, and collaborators. Eliminates the need for agents to make 3-4 separate API calls (show + comments + versions + references). Key design decisions: - Backward compatible: created_by remains a string, created_by_user added as structured {id, name} object across plan and thread JSON - Efficient: strips markdown once for all threads instead of N times, sorts comments in memory to leverage eager loading - anchor_occurrence included in snapshot threads for precise text targeting - author_id added to comment JSON for agent user correlation - user_json helper with nil safety Amp-Thread-ID: https://ampcode.com/threads/T-019db096-9dc9-7543-ba51-f5baa78005f2 Co-authored-by: Amp --- .../coplan/api/v1/plans_controller.rb | 99 ++++++++++++++++--- engine/config/routes.rb | 1 + spec/requests/api/v1/plans_spec.rb | 55 +++++++++++ 3 files changed, 139 insertions(+), 16 deletions(-) diff --git a/engine/app/controllers/coplan/api/v1/plans_controller.rb b/engine/app/controllers/coplan/api/v1/plans_controller.rb index eed1bb8..d5ab650 100644 --- a/engine/app/controllers/coplan/api/v1/plans_controller.rb +++ b/engine/app/controllers/coplan/api/v1/plans_controller.rb @@ -2,8 +2,8 @@ module CoPlan module Api module V1 class PlansController < BaseController - before_action :set_plan, only: [:show, :update, :versions, :comments] - before_action :authorize_plan_access!, only: [:show, :update, :versions, :comments] + before_action :set_plan, only: [:show, :update, :versions, :comments, :snapshot] + before_action :authorize_plan_access!, only: [:show, :update, :versions, :comments, :snapshot] def index plans = Plan @@ -19,17 +19,7 @@ def show render json: plan_json(@plan).merge( current_content: @plan.current_content, current_revision: @plan.current_revision, - references: @plan.references.map { |r| - { - id: r.id, - key: r.key, - url: r.url, - title: r.title, - reference_type: r.reference_type, - source: r.source, - target_plan_id: r.target_plan_id - } - } + references: @plan.references.map { |r| reference_json(r) } ) end @@ -121,6 +111,20 @@ def comments render json: threads.map { |t| thread_json(t) } end + def snapshot + threads = @plan.comment_threads.includes(:comments, :created_by_user).order(created_at: :desc) + references = @plan.references.order(created_at: :desc) + collaborators = @plan.plan_collaborators.includes(:user) + + render json: plan_json(@plan).merge( + current_content: @plan.current_content, + current_revision: @plan.current_revision, + comment_threads: snapshot_threads_json(threads), + references: references.map { |r| reference_json(r) }, + collaborators: collaborators.map { |c| collaborator_json(c) } + ) + end + private def plan_json(plan) @@ -132,7 +136,8 @@ def plan_json(plan) tags: plan.tag_names, plan_type_id: plan.plan_type_id, plan_type_name: plan.plan_type&.name, - created_by: plan.created_by_user.name, + created_by: plan.created_by_user&.name, + created_by_user: user_json(plan.created_by_user), created_at: plan.created_at, updated_at: plan.updated_at } @@ -149,6 +154,58 @@ def version_json(version) } end + def reference_json(ref) + { + id: ref.id, + key: ref.key, + url: ref.url, + title: ref.title, + reference_type: ref.reference_type, + source: ref.source, + target_plan_id: ref.target_plan_id + } + end + + def collaborator_json(collaborator) + { + id: collaborator.id, + user: user_json(collaborator.user), + role: collaborator.role + } + end + + def snapshot_threads_json(threads) + content = @plan.current_content + stripped_data = if content.present? + stripped, pos_map = CoPlan::CommentThread.strip_markdown(content) + { stripped: stripped, pos_map: pos_map } + end + + threads.map do |t| + occurrence = compute_anchor_occurrence(t, content, stripped_data) + thread_json(t).merge(anchor_occurrence: occurrence) + end + end + + def compute_anchor_occurrence(thread, content, stripped_data) + return nil unless thread.anchored? && content.present? && thread.anchor_start.present? + return nil unless stripped_data + + stripped = stripped_data[:stripped] + pos_map = stripped_data[:pos_map] + stripped_start = pos_map.index { |raw_idx| raw_idx >= thread.anchor_start } + return nil if stripped_start.nil? + + normalized_anchor = thread.anchor_text.gsub("\t", " ") + ranges = [] + start_pos = 0 + while (idx = stripped.index(normalized_anchor, start_pos)) + ranges << idx + start_pos = idx + normalized_anchor.length + end + ranges.index { |s| s >= stripped_start } || 0 + end + def thread_json(thread) { id: thread.id, @@ -159,12 +216,14 @@ def thread_json(thread) start_line: thread.start_line, end_line: thread.end_line, out_of_date: thread.out_of_date, - created_by: thread.created_by_user.name, + created_by: thread.created_by_user&.name, + created_by_user: user_json(thread.created_by_user), created_at: thread.created_at, - comments: thread.comments.order(created_at: :asc).map { |c| + comments: thread.comments.sort_by(&:created_at).map { |c| { id: c.id, author_type: c.author_type, + author_id: c.author_id, agent_name: c.agent_name, body_markdown: c.body_markdown, created_at: c.created_at @@ -172,6 +231,14 @@ def thread_json(thread) } } end + + def user_json(user) + return nil unless user + { + id: user.id, + name: user.name + } + end end end end diff --git a/engine/config/routes.rb b/engine/config/routes.rb index eebe72d..5304f00 100644 --- a/engine/config/routes.rb +++ b/engine/config/routes.rb @@ -34,6 +34,7 @@ resources :plans, only: [:index, :show, :create, :update] do get :versions, on: :member get :comments, on: :member + get :snapshot, on: :member resource :lease, only: [:create, :update, :destroy], controller: "leases" resources :operations, only: [:create] resources :sessions, only: [:create, :show], controller: "sessions" do diff --git a/spec/requests/api/v1/plans_spec.rb b/spec/requests/api/v1/plans_spec.rb index f0d1566..68ef780 100644 --- a/spec/requests/api/v1/plans_spec.rb +++ b/spec/requests/api/v1/plans_spec.rb @@ -172,4 +172,59 @@ matching = threads.find { |t| t["id"] == thread.id } expect(matching["anchor_text"]).to eq("original roadmap text") end + + describe "GET /api/v1/plans/:id/snapshot" do + it "returns plan with all nested data in one response" do + thread = create(:comment_thread, :with_positioned_anchor, plan: plan, + plan_version: plan.current_plan_version, created_by_user: alice) + comment = create(:comment, comment_thread: thread, body_markdown: "Snapshot comment") + ref = create(:reference, plan: plan, url: "https://example.com/snapshot", title: "Snapshot Ref") + collaborator = create(:plan_collaborator, plan: plan, user: carol, role: "reviewer") + + get snapshot_api_v1_plan_path(plan), headers: headers + + expect(response).to have_http_status(:success) + body = JSON.parse(response.body) + + # Plan metadata — created_by preserved as string, created_by_user added as object + expect(body["id"]).to eq(plan.id) + expect(body["title"]).to eq("Acme Roadmap") + expect(body["current_content"]).to be_present + expect(body["current_revision"]).to be_present + expect(body["created_by"]).to eq(alice.name) + expect(body["created_by_user"]).to eq({ "id" => alice.id, "name" => alice.name }) + + # Comment threads with anchor_occurrence and structured created_by_user + expect(body["comment_threads"]).to be_a(Array) + matching_thread = body["comment_threads"].find { |t| t["id"] == thread.id } + expect(matching_thread["anchor_text"]).to eq("some anchor text") + expect(matching_thread).to have_key("anchor_occurrence") + expect(matching_thread["created_by"]).to eq(alice.name) + expect(matching_thread["created_by_user"]).to eq({ "id" => alice.id, "name" => alice.name }) + expect(matching_thread["comments"]).to be_a(Array) + matching_comment = matching_thread["comments"].find { |c| c["body_markdown"] == "Snapshot comment" } + expect(matching_comment).to be_present + expect(matching_comment).to have_key("author_id") + + # References + expect(body["references"]).to be_a(Array) + expect(body["references"].any? { |r| r["url"] == "https://example.com/snapshot" }).to be true + + # Collaborators with structured user + expect(body["collaborators"]).to be_a(Array) + matching_collab = body["collaborators"].find { |c| c.dig("user", "id") == carol.id } + expect(matching_collab["role"]).to eq("reviewer") + expect(matching_collab["user"]["name"]).to eq(carol.name) + end + + it "requires auth" do + get snapshot_api_v1_plan_path(plan) + expect(response).to have_http_status(:unauthorized) + end + + it "returns 404 for nonexistent plan" do + get snapshot_api_v1_plan_path(id: "nonexistent"), headers: headers + expect(response).to have_http_status(:not_found) + end + end end From 7eb9872c283d316a3010a486aa3fa302f6bcf48e Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Tue, 21 Apr 2026 12:20:48 -0500 Subject: [PATCH 2/3] Update agent instructions to document snapshot endpoint - Add 'Get Plan Snapshot (Recommended)' section to API reference - Update review workflow to use snapshot instead of separate calls - Update typical workflow to start with snapshot Amp-Thread-ID: https://ampcode.com/threads/T-019db096-9dc9-7543-ba51-f5baa78005f2 Co-authored-by: Amp --- .../coplan/agent_instructions/show.text.erb | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/engine/app/views/coplan/agent_instructions/show.text.erb b/engine/app/views/coplan/agent_instructions/show.text.erb index d2c663c..de0ffe7 100644 --- a/engine/app/views/coplan/agent_instructions/show.text.erb +++ b/engine/app/views/coplan/agent_instructions/show.text.erb @@ -24,6 +24,17 @@ Optional query param: `?status=considering` Returns: `id`, `title`, `status`, `current_content` (markdown), `current_revision`, `references` (array of linked resources). +### Get Plan Snapshot (Recommended) + +Returns everything about a plan in one request — use this instead of calling multiple endpoints. + +```bash +<%= @curl %> \ + "<%= @base %>/api/v1/plans/$PLAN_ID/snapshot" | jq . +``` + +Returns: plan metadata, `current_content`, `current_revision`, `comment_threads` (with nested comments), `references`, and `collaborators`. This replaces the need to call GET plan + GET comments + GET references separately. + ### Create Plan ```bash @@ -317,13 +328,14 @@ Dismiss a comment thread (plan author only — for comments that are out of scop When asked to review a plan (given a plan URL or ID), follow this workflow: -### 1. Read the Plan and Comments +### 1. Read the Plan Snapshot ```bash -<%= @curl %> "<%= @base %>/api/v1/plans/$PLAN_ID" | jq . -<%= @curl %> "<%= @base %>/api/v1/plans/$PLAN_ID/comments" | jq . +<%= @curl %> "<%= @base %>/api/v1/plans/$PLAN_ID/snapshot" | jq . ``` +This returns the plan content, all comment threads with comments, references, and collaborators in one call. + ### 2. Triage Comments Review each open comment thread and categorize it: @@ -338,7 +350,7 @@ For approved changes: acquire lease → apply operations → release lease → r ## Typical Workflow -1. **Read** the plan: `GET /api/v1/plans/:id` +1. **Read** the plan: `GET /api/v1/plans/:id/snapshot` 2. **Acquire lease**: `POST /api/v1/plans/:id/lease` 3. **Apply operations**: `POST /api/v1/plans/:id/operations` (can call multiple times while lease is held) 4. **Release lease**: `DELETE /api/v1/plans/:id/lease` From b546f0614f79b6b08ddc2e9530ff24458ee39efb Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Tue, 21 Apr 2026 12:30:03 -0500 Subject: [PATCH 3/3] Preserve fallback anchor_occurrence=0 for unresolved anchors Match CommentThread#anchor_occurrence_index behavior: return 0 (first occurrence) for anchored threads without positional data, not nil. Amp-Thread-ID: https://ampcode.com/threads/T-019db096-9dc9-7543-ba51-f5baa78005f2 Co-authored-by: Amp --- engine/app/controllers/coplan/api/v1/plans_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engine/app/controllers/coplan/api/v1/plans_controller.rb b/engine/app/controllers/coplan/api/v1/plans_controller.rb index d5ab650..8092cc4 100644 --- a/engine/app/controllers/coplan/api/v1/plans_controller.rb +++ b/engine/app/controllers/coplan/api/v1/plans_controller.rb @@ -188,8 +188,8 @@ def snapshot_threads_json(threads) end def compute_anchor_occurrence(thread, content, stripped_data) - return nil unless thread.anchored? && content.present? && thread.anchor_start.present? - return nil unless stripped_data + return nil unless thread.anchored? + return 0 unless content.present? && thread.anchor_start.present? && stripped_data stripped = stripped_data[:stripped] pos_map = stripped_data[:pos_map]