Harden approved pull request tool

78fc8bcda609 · AtlantisPleb · · parent c7b05741ed38

Harden approved pull request tool

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified docs/pull-requests.md
  • modified lib/openagents/pull_requests.ex
  • modified lib/openagents/tools/conversation_execution_context.ex
  • modified lib/openagents/tools/open_pull_request.ex
  • modified test/openagents/tools/open_pull_request_test.exs
  • modified test/openagents_web/controllers/chat_turn_controller_test.exs

Diff

6 files changed, +454 -17

docs/pull-requests.md modified +3 -1

@@ -57,7 +57,9 @@ The tool requires a separate, explicit person approval for opening the pull requ

57 57
58 58
The tool creates a draft pull request by default. Repeating the tool call for the same open source and base branches returns the existing pull request. If a later accepted publication advances the same source branch, the tool updates the existing pull request with the new publication receipt and commit.
59 59
60
The result includes the pull request number, state, draft state, source and base refs, commit IDs, and receipt references. It does not include access tokens, workspace host paths, or other secrets.
60
The tool requires the distinct `pull_request.write` authority. Its result includes the repository path, pull request URL and number, state, draft state, source and base refs, commit IDs, check and mergeability states, and receipt references. If repository policy disables pull requests, the result includes the protected publication branch and a compare URL so you can continue the review manually.
61
62
The pull request body preserves the redacted description and appends server-derived publication metadata. This metadata records the changed-file counts, compare URL, and every publication receipt used to create or refresh the pull request. The result and body do not include access tokens, workspace host paths, or other secrets.
61 63
62 64
## Browser views
63 65
lib/openagents/pull_requests.ex modified +56 -7

@@ -11,6 +11,7 @@ defmodule OpenAgents.PullRequests do

11 11
  alias OpenAgents.Repositories
12 12
  alias OpenAgents.Repositories.Repository
13 13
  alias OpenAgents.Repositories.RepositoryPublication
14
  alias OpenAgents.Tools.Redaction
14 15
15 16
  def list(%Repository{id: id}) do
16 17
    Repo.all(

@@ -95,7 +96,12 @@ defmodule OpenAgents.PullRequests do

95 96
96 97
          %PullRequest{repository_publication_id: id} = pull_request
97 98
          when id == publication.id ->
98
            Repo.preload(pull_request, [:issue, :head_repository, :repository_publication])
99
            Repo.preload(pull_request, [
100
              :issue,
101
              :head_repository,
102
              :repository_publication,
103
              :repository
104
            ])
99 105
100 106
          %PullRequest{} = pull_request ->
101 107
            refresh_from_publication(pull_request, publication, attrs, actor, head_sha, base_sha)

@@ -121,7 +127,7 @@ defmodule OpenAgents.PullRequests do

121 127
      publication.branch == publication.repository.default_branch ->
122 128
        {:error, :publication_branch_refused}
123 129
124
      not String.starts_with?(publication.branch || "", "openagents/chat/") ->
130
      publication.branch != "openagents/chat/#{publication.conversation_id}" ->
125 131
        {:error, :publication_branch_refused}
126 132
127 133
      true ->

@@ -173,7 +179,7 @@ defmodule OpenAgents.PullRequests do

173 179
  defp create_from_publication(publication, attrs, actor, head_sha, base_sha) do
174 180
    repository = publication.repository
175 181
176
    with {:ok, issue} <- Issues.create_issue(repository, issue_attrs(attrs), actor),
182
    with {:ok, issue} <- Issues.create_issue(repository, issue_attrs(attrs, publication), actor),
177 183
         {:ok, pull_request} <-
178 184
           %PullRequest{}
179 185
           |> PullRequest.changeset(%{

@@ -190,7 +196,7 @@ defmodule OpenAgents.PullRequests do

190 196
             draft: Map.get(attrs, "draft", true)
191 197
           })
192 198
           |> Repo.insert() do
193
      Repo.preload(pull_request, [:issue, :head_repository, :repository_publication])
199
      Repo.preload(pull_request, [:issue, :head_repository, :repository_publication, :repository])
194 200
    else
195 201
      {:error, reason} -> Repo.rollback(reason)
196 202
    end

@@ -198,9 +204,15 @@ defmodule OpenAgents.PullRequests do

198 204
199 205
  defp refresh_from_publication(pull_request, publication, attrs, actor, head_sha, base_sha) do
200 206
    pull_request = Repo.preload(pull_request, [:issue, :repository_publication])
207
    previous_publication = pull_request.repository_publication
201 208
202 209
    with :ok <- validate_existing_publication_scope(pull_request, publication, actor),
203
         {:ok, issue} <- Issues.update_issue(pull_request.issue, issue_attrs(attrs), actor),
210
         {:ok, issue} <-
211
           Issues.update_issue(
212
             pull_request.issue,
213
             issue_attrs(attrs, publication, [previous_publication.id]),
214
             actor
215
           ),
204 216
         {:ok, updated} <-
205 217
           pull_request
206 218
           |> PullRequest.changeset(%{

@@ -211,7 +223,10 @@ defmodule OpenAgents.PullRequests do

211 223
             conversation_id: publication.conversation_id
212 224
           })
213 225
           |> Repo.update() do
214
      %{Repo.preload(updated, [:head_repository, :repository_publication]) | issue: issue}
226
      %{
227
        Repo.preload(updated, [:head_repository, :repository_publication, :repository])
228
        | issue: issue
229
      }
215 230
    else
216 231
      {:error, reason} -> Repo.rollback(reason)
217 232
    end

@@ -236,7 +251,41 @@ defmodule OpenAgents.PullRequests do

236 251
    end
237 252
  end
238 253
239
  defp issue_attrs(attrs), do: Map.take(attrs, ["title", "body"])
254
  defp issue_attrs(attrs, publication, previous_publication_ids \\ []) do
255
    body =
256
      attrs
257
      |> Map.get("body", "")
258
      |> Redaction.redact_text()
259
      |> String.trim()
260
261
    publication_ids = Enum.uniq(previous_publication_ids ++ [publication.id])
262
    result = publication.result || %{}
263
    summary = result["summary"] || %{}
264
    compare_url = result["compare_url"]
265
266
    receipt_lines =
267
      Enum.map_join(publication_ids, "\n", fn id ->
268
        "- `repository-publication:#{id}`"
269
      end)
270
271
    trusted = """
272
    ## OpenAgents publication
273
274
    - Changed files: #{summary["files_changed"] || "unknown"}
275
    - Insertions: #{summary["insertions"] || "unknown"}
276
    - Deletions: #{summary["deletions"] || "unknown"}
277
    - Compare: #{compare_url || "unavailable"}
278
279
    Publication receipts:
280
281
    #{receipt_lines}
282
    """
283
284
    %{
285
      "title" => Map.fetch!(attrs, "title"),
286
      "body" => Enum.reject([body, String.trim(trusted)], &(&1 == "")) |> Enum.join("\n\n")
287
    }
288
  end
240 289
241 290
  defp pull_request_update_attrs(attrs, state) do
242 291
    %{state: state}
lib/openagents/tools/conversation_execution_context.ex modified +1

@@ -23,6 +23,7 @@ defmodule OpenAgents.Tools.ConversationExecutionContext do

23 23
                 "memory.write",
24 24
                 "module.discover",
25 25
                 "repository.read",
26
                 "pull_request.write",
26 27
                 "repository.write",
27 28
                 "scv.deploy",
28 29
                 "work.delegate"
lib/openagents/tools/open_pull_request.ex modified +50 -7

@@ -36,7 +36,7 @@ defmodule OpenAgents.Tools.OpenPullRequest do

36 36
      output_schema: %{"type" => "object", "properties" => %{}, "additionalProperties" => true},
37 37
      side_effect: :external_effect,
38 38
      required_scope: "browser_conversation",
39
      required_authority: "repository.write",
39
      required_authority: "pull_request.write",
40 40
      executor: %{
41 41
        id: "openagents.forge.pull_requests",
42 42
        disclosure: "the OpenAgents pull request service, using an accepted Forge publication"

@@ -49,7 +49,7 @@ defmodule OpenAgents.Tools.OpenPullRequest do

49 49
        "consent" => "approved_publication_pull_request"
50 50
      },
51 51
      module_metadata:
52
        Metadata.first_party("repository.write", "browser_conversation",
52
        Metadata.first_party("pull_request.write", "browser_conversation",
53 53
          effect: :external_effect,
54 54
          privacy: "browser_conversation",
55 55
          residency: "host",

@@ -82,8 +82,16 @@ defmodule OpenAgents.Tools.OpenPullRequest do

82 82
  @impl true
83 83
  def execute(%{"publication_receipt_ref" => receipt_ref} = arguments, context) do
84 84
    with {:ok, publication_id} <- parse_receipt_ref(receipt_ref),
85
         %RepositoryPublication{} = publication <- Repo.get(RepositoryPublication, publication_id),
86
         :ok <- validate_context(publication, context),
85
         %RepositoryPublication{} = publication <- Repo.get(RepositoryPublication, publication_id) do
86
      execute_publication(publication, arguments, context, receipt_ref)
87
    else
88
      nil -> {:error, :publication_receipt_not_found}
89
      {:error, reason} -> {:error, reason}
90
    end
91
  end
92
93
  defp execute_publication(publication, arguments, context, receipt_ref) do
94
    with :ok <- validate_context(publication, context),
87 95
         %User{} = actor <- Repo.get(User, context.owner_user_id),
88 96
         {:ok, pull_request} <- PullRequests.open_from_publication(publication, arguments, actor) do
89 97
      result = result(pull_request)

@@ -98,9 +106,14 @@ defmodule OpenAgents.Tools.OpenPullRequest do

98 106
         ]
99 107
       }}
100 108
    else
101
      nil -> {:error, :publication_receipt_not_found}
102
      {:error, reason} -> {:error, reason}
103
      _ -> {:error, :publication_scope_mismatch}
109
      {:error, :pull_requests_disabled} ->
110
        disabled_result(publication)
111
112
      {:error, reason} ->
113
        {:error, reason}
114
115
      _ ->
116
        {:error, :publication_scope_mismatch}
104 117
    end
105 118
  end
106 119

@@ -135,6 +148,8 @@ defmodule OpenAgents.Tools.OpenPullRequest do

135 148
  end
136 149
137 150
  defp result(pull_request) do
151
    repository = pull_request.repository
152
138 153
    %{
139 154
      "schema" => "openagents.pull_request_opened.v1",
140 155
      "id" => pull_request.id,

@@ -142,8 +157,12 @@ defmodule OpenAgents.Tools.OpenPullRequest do

142 157
      "state" => pull_request.state,
143 158
      "draft" => pull_request.draft,
144 159
      "title" => pull_request.issue.title,
160
      "repository" => "#{repository.owner}/#{repository.name}",
161
      "url" => "/#{repository.owner}/#{repository.name}/pulls/#{pull_request.issue.number}",
145 162
      "head" => %{"ref" => pull_request.head_ref, "oid" => pull_request.head_sha},
146 163
      "base" => %{"ref" => pull_request.base_ref, "oid" => pull_request.base_sha},
164
      "checks" => %{"state" => "unknown"},
165
      "mergeability" => %{"state" => "unknown"},
147 166
      "publication_receipt_ref" =>
148 167
        "repository-publication:#{pull_request.repository_publication_id}",
149 168
      "receipt" => %{

@@ -152,4 +171,28 @@ defmodule OpenAgents.Tools.OpenPullRequest do

152 171
      }
153 172
    }
154 173
  end
174
175
  defp disabled_result(publication) do
176
    repository = Repo.get!(OpenAgents.Repositories.Repository, publication.repository_id)
177
178
    compare_url =
179
      get_in(publication.result || %{}, ["compare_url"]) ||
180
        "/#{repository.owner}/#{repository.name}/compare/#{repository.default_branch}...#{publication.branch}"
181
182
    {:ok,
183
     %ExecutionResult{
184
       status: "refused",
185
       error: %{
186
         "code" => "pull_requests_disabled",
187
         "message" => "Pull requests are disabled for this repository."
188
       },
189
       result: %{
190
         "schema" => "openagents.pull_request_disabled.v1",
191
         "repository" => "#{repository.owner}/#{repository.name}",
192
         "protected_branch" => publication.branch,
193
         "compare_url" => compare_url
194
       },
195
       target_receipt_refs: ["repository-publication:#{publication.id}"]
196
     }}
197
  end
155 198
end
test/openagents/tools/open_pull_request_test.exs modified +266 -2

@@ -1,6 +1,7 @@

1 1
defmodule OpenAgents.Tools.OpenPullRequestTest do
2 2
  use OpenAgents.DataCase, async: false
3 3
4
  alias OpenAgents.Chat.OpenRouter
4 5
  alias OpenAgents.Forge.WAL
5 6
  alias OpenAgents.PullRequests.PullRequest
6 7
  alias OpenAgents.Repositories.RepositoryPublication

@@ -31,7 +32,7 @@ defmodule OpenAgents.Tools.OpenPullRequestTest do

31 32
    context = %ExecutionContext{
32 33
      scope: "browser_conversation",
33 34
      scope_ref: "conversation:#{conversation_id}",
34
      authorities: MapSet.new(["repository.write"]),
35
      authorities: MapSet.new(["pull_request.write"]),
35 36
      surface: "text",
36 37
      owner_user_id: user.id,
37 38
      owner_visitor_id: user.id,

@@ -91,8 +92,17 @@ defmodule OpenAgents.Tools.OpenPullRequestTest do

91 92
    assert opened["result"]["state"] == "open"
92 93
    assert opened["result"]["draft"]
93 94
    assert opened["result"]["head"]["ref"] =~ "openagents/chat/"
95
    assert opened["result"]["repository"]
96
    assert opened["result"]["url"] =~ "/pulls/"
97
    assert opened["result"]["checks"]["state"] == "unknown"
94 98
    assert length(Repo.all(PullRequest)) == 1
95 99
100
    stored = Repo.one!(PullRequest) |> Repo.preload(:issue)
101
    assert stored.issue.body =~ "## OpenAgents publication"
102
    assert stored.issue.body =~ "Changed files: 2"
103
    assert stored.issue.body =~ "repository-publication:"
104
    assert stored.issue.body =~ "/compare/main...openagents/chat/"
105
96 106
    retry_call = %{call | call_id: "open-2"}
97 107
    assert {:ok, retried} = Runner.run(snapshot, retry_call, context)
98 108
    assert retried["result"]["id"] == opened["result"]["id"]

@@ -150,6 +160,26 @@ defmodule OpenAgents.Tools.OpenPullRequestTest do

150 160
    assert stored.head_sha == next_oid
151 161
    assert stored.repository_publication_id == later.id
152 162
    assert stored.issue.title == "Updated pull request"
163
    assert stored.issue.body =~ "repository-publication:#{publication.id}"
164
    assert stored.issue.body =~ "repository-publication:#{later.id}"
165
  end
166
167
  test "refuses a publication branch that only shares the chat prefix", %{
168
    context: context,
169
    publication: publication
170
  } do
171
    invalid_branch = "#{publication.branch}/extra"
172
173
    publication
174
    |> Ecto.Changeset.change(branch: invalid_branch)
175
    |> Repo.update!()
176
177
    {:ok, snapshot} = Registry.build([OpenPullRequest])
178
    context = approve(context)
179
180
    assert {:ok, refused} = Runner.run(snapshot, call("open-invalid-branch", context), context)
181
    assert refused["status"] == "refused"
182
    assert refused["error"]["code"] == "publication_branch_refused"
153 183
  end
154 184
155 185
  test "refuses another account, conversation, or workspace", %{

@@ -207,6 +237,8 @@ defmodule OpenAgents.Tools.OpenPullRequestTest do

207 237
    assert {:ok, disabled} = Runner.run(snapshot, call, context)
208 238
    assert disabled["status"] == "refused"
209 239
    assert disabled["error"]["code"] == "pull_requests_disabled"
240
    assert disabled["result"]["protected_branch"] == publication.branch
241
    assert disabled["result"]["repository"]
210 242
211 243
    repository
212 244
    |> then(&Repo.get!(OpenAgents.Repositories.Repository, &1.id))

@@ -227,6 +259,231 @@ defmodule OpenAgents.Tools.OpenPullRequestTest do

227 259
    assert stale["error"]["code"] == "publication_receipt_stale"
228 260
  end
229 261
262
  test "Ox Alpha completes an approved pull request through the Responses tool loop", %{
263
    context: context,
264
    publication: publication
265
  } do
266
    arguments =
267
      Jason.encode!(%{
268
        "publication_receipt_ref" => "repository-publication:#{publication.id}",
269
        "title" => "Open the published workspace",
270
        "body" => "Review the approved workspace publication.",
271
        "draft" => true
272
      })
273
274
    provider_output = [
275
      %{
276
        "type" => "reasoning",
277
        "id" => "rs_open_pr",
278
        "status" => "completed",
279
        "summary" => [
280
          %{"type" => "summary_text", "text" => "Open the approved publication for review."}
281
        ],
282
        "encrypted_content" => "encrypted-open-pr-reasoning"
283
      },
284
      %{
285
        "type" => "function_call",
286
        "id" => "fc_open_pr",
287
        "call_id" => "call_open_pr",
288
        "name" => "open_pull_request",
289
        "arguments" => arguments,
290
        "status" => "completed"
291
      }
292
    ]
293
294
    Req.Test.expect(__MODULE__, fn conn ->
295
      assert conn.request_path == "/api/v1/responses"
296
      assert Enum.map(conn.body_params["tools"], & &1["name"]) == ["open_pull_request"]
297
298
      body =
299
        sse(%{
300
          "type" => "response.created",
301
          "response" => %{
302
            "id" => "resp_open_pr",
303
            "object" => "response",
304
            "status" => "in_progress",
305
            "model" => "stealth/ox-alpha",
306
            "output" => []
307
          }
308
        }) <>
309
          sse(%{
310
            "type" => "response.in_progress",
311
            "response" => %{
312
              "id" => "resp_open_pr",
313
              "object" => "response",
314
              "status" => "in_progress",
315
              "model" => "stealth/ox-alpha",
316
              "output" => []
317
            }
318
          }) <>
319
          sse(%{
320
            "type" => "response.output_item.added",
321
            "response_id" => "resp_open_pr",
322
            "output_index" => 0,
323
            "item" => %{
324
              "type" => "reasoning",
325
              "id" => "rs_open_pr",
326
              "status" => "in_progress",
327
              "summary" => []
328
            }
329
          }) <>
330
          sse(%{
331
            "type" => "response.reasoning_summary_text.delta",
332
            "response_id" => "resp_open_pr",
333
            "item_id" => "rs_open_pr",
334
            "output_index" => 0,
335
            "summary_index" => 0,
336
            "delta" => "Open the approved publication for review."
337
          }) <>
338
          sse(%{
339
            "type" => "response.output_item.done",
340
            "response_id" => "resp_open_pr",
341
            "output_index" => 0,
342
            "item" => Enum.at(provider_output, 0)
343
          }) <>
344
          sse(%{
345
            "type" => "response.output_item.added",
346
            "response_id" => "resp_open_pr",
347
            "output_index" => 1,
348
            "item" => %{
349
              "type" => "function_call",
350
              "id" => "fc_open_pr",
351
              "call_id" => "call_open_pr",
352
              "name" => "open_pull_request",
353
              "arguments" => "",
354
              "status" => "in_progress"
355
            }
356
          }) <>
357
          sse(%{
358
            "type" => "response.function_call_arguments.delta",
359
            "response_id" => "resp_open_pr",
360
            "item_id" => "fc_open_pr",
361
            "output_index" => 1,
362
            "delta" => arguments
363
          }) <>
364
          sse(%{
365
            "type" => "response.function_call_arguments.done",
366
            "response_id" => "resp_open_pr",
367
            "item_id" => "fc_open_pr",
368
            "output_index" => 1,
369
            "arguments" => arguments
370
          }) <>
371
          sse(%{
372
            "type" => "response.output_item.done",
373
            "response_id" => "resp_open_pr",
374
            "output_index" => 1,
375
            "item" => Enum.at(provider_output, 1)
376
          }) <>
377
          sse(%{
378
            "type" => "response.completed",
379
            "response" => %{
380
              "id" => "resp_open_pr",
381
              "object" => "response",
382
              "status" => "completed",
383
              "model" => "stealth/ox-alpha",
384
              "output" => provider_output
385
            }
386
          }) <> "data: [DONE]\n\n"
387
388
      conn
389
      |> Plug.Conn.put_resp_content_type("text/event-stream")
390
      |> Plug.Conn.send_resp(200, body)
391
    end)
392
393
    Req.Test.expect(__MODULE__, fn conn ->
394
      [user_input | replayed_output] = conn.body_params["input"]
395
396
      assert user_input["content"] == [
397
               %{"type" => "input_text", "text" => "Open the approved publication."}
398
             ]
399
400
      assert Enum.take(replayed_output, 2) == provider_output
401
402
      assert %{
403
               "type" => "function_call_output",
404
               "call_id" => "call_open_pr",
405
               "output" => tool_output
406
             } = List.last(replayed_output)
407
408
      assert %{
409
               "status" => "succeeded",
410
               "result" => %{
411
                 "repository" => _,
412
                 "url" => url,
413
                 "head" => %{"oid" => _}
414
               },
415
               "target_receipt_refs" => receipt_refs
416
             } = Jason.decode!(tool_output)
417
418
      assert url =~ "/pulls/"
419
      assert "repository-publication:#{publication.id}" in receipt_refs
420
421
      body =
422
        sse(%{
423
          "type" => "response.content_part.delta",
424
          "delta" => "I opened the approved draft pull request."
425
        }) <>
426
          sse(%{
427
            "type" => "response.completed",
428
            "response" => %{
429
              "object" => "response",
430
              "status" => "completed",
431
              "model" => "stealth/ox-alpha",
432
              "output" => [
433
                %{
434
                  "type" => "message",
435
                  "id" => "msg_open_pr_complete",
436
                  "role" => "assistant",
437
                  "status" => "completed",
438
                  "content" => [
439
                    %{
440
                      "type" => "output_text",
441
                      "text" => "I opened the approved draft pull request.",
442
                      "annotations" => []
443
                    }
444
                  ]
445
                }
446
              ]
447
            }
448
          }) <> "data: [DONE]\n\n"
449
450
      conn
451
      |> Plug.Conn.put_resp_content_type("text/event-stream")
452
      |> Plug.Conn.send_resp(200, body)
453
    end)
454
455
    parent = self()
456
    assert {:ok, snapshot} = Registry.build([OpenPullRequest])
457
458
    assert {:ok, %{"assistant_content" => "I opened the approved draft pull request."}} =
459
             OpenRouter.stream(
460
               %{
461
                 "model" => "stealth/ox-alpha",
462
                 "messages" => [
463
                   %{"role" => "user", "content" => "Open the approved publication."}
464
                 ]
465
               },
466
               &send(parent, {:openrouter_event, &1}),
467
               api_key: "test-openrouter-key",
468
               tool_registry_snapshot: snapshot,
469
               tool_execution_context: approve(context),
470
               request_options: [plug: {Req.Test, __MODULE__}]
471
             )
472
473
    assert_receive {:openrouter_event,
474
                    {:tool_call_started,
475
                     %{"call_id" => "call_open_pr", "name" => "open_pull_request"}}}
476
477
    assert_receive {:openrouter_event,
478
                    {:tool_call_completed,
479
                     %{"call_id" => "call_open_pr", "output" => tool_output}}}
480
481
    assert %{"status" => "succeeded", "result" => %{"url" => url}} =
482
             Jason.decode!(tool_output)
483
484
    assert url =~ "/pulls/"
485
  end
486
230 487
  defp approve(context) do
231 488
    receipt = OpenPullRequest.approval_receipt(context.scope_ref, "approval:open-pull-request")
232 489
    %{context | approval_receipts: [receipt]}

@@ -273,7 +530,12 @@ defmodule OpenAgents.Tools.OpenPullRequestTest do

273 530
      published_oid: oid,
274 531
      state: "accepted",
275 532
      wal_seq: wal_seq,
276
      result: %{"receipt" => %{"wal_seq" => wal_seq, "oid" => oid}}
533
      result: %{
534
        "compare_url" =>
535
          "/#{repository.owner}/#{repository.name}/compare/#{repository.default_branch}...#{branch}",
536
        "summary" => %{"files_changed" => 2, "insertions" => 4, "deletions" => 1},
537
        "receipt" => %{"wal_seq" => wal_seq, "oid" => oid}
538
      }
277 539
    })
278 540
    |> Repo.insert!()
279 541
  end

@@ -296,4 +558,6 @@ defmodule OpenAgents.Tools.OpenPullRequestTest do

296 558
297 559
    assert {:ok, _generation} = WAL.cas_index(storage_key, :none, index)
298 560
  end
561
562
  defp sse(event), do: "data: #{Jason.encode!(event)}\n\n"
299 563
end
test/openagents_web/controllers/chat_turn_controller_test.exs modified +78

@@ -106,6 +106,84 @@ defmodule OpenAgentsWeb.ChatTurnControllerTest do

106 106
    refute inspect(events) =~ "/private/var/lib/openagents"
107 107
  end
108 108
109
  test "pull request results retain browser and API lifecycle parity", %{conn: conn} do
110
    key = "chat-pull-request-events"
111
    user = github_user("api-token-" <> key)
112
113
    result = %{
114
      "repository" => "OpenAgentsInc/openagents.com",
115
      "url" => "/OpenAgentsInc/openagents.com/pulls/17",
116
      "head" => %{
117
        "ref" => "openagents/chat/8cc3bd8d-08cc-4c94-85e1-f269421ddf14",
118
        "oid" => String.duplicate("b", 40)
119
      },
120
      "checks" => %{"state" => "unknown"}
121
    }
122
123
    streamer = fn _request, callback, _options ->
124
      callback.(
125
        {:tool_call_started,
126
         %{
127
           "call_id" => "call-open-pr",
128
           "name" => "open_pull_request",
129
           "arguments" =>
130
             ~s({"publication_receipt_ref":"repository-publication:17","title":"Review changes","draft":true})
131
         }}
132
      )
133
134
      callback.(
135
        {:tool_call_completed,
136
         %{
137
           "call_id" => "call-open-pr",
138
           "output" => %{
139
             "schema" => "sarah.tool_outcome.v1",
140
             "status" => "succeeded",
141
             "result" => result,
142
             "target_receipt_refs" => ["repository-publication:17", "pull-request:17"],
143
             "started_at" => "2026-08-22T19:43:28.000Z",
144
             "completed_at" => "2026-08-22T19:43:28.010Z"
145
           }
146
         }}
147
      )
148
149
      {:ok, %{"assistant_content" => "Opened the draft pull request."}}
150
    end
151
152
    assert {:ok, %{"id" => run_id}} =
153
             OpenAgents.Chat.AccountTurns.submit(user, "Open a pull request.",
154
               subscriber: self(),
155
               streamer: streamer
156
             )
157
158
    assert_receive {:account_chat_completed, ^run_id, {:ok, _completion}}
159
160
    events =
161
      conn
162
      |> put_chat_api_token(key)
163
      |> get(~p"/api/v3/chat/events")
164
      |> json_response(200)
165
      |> Map.fetch!("events")
166
167
    completed =
168
      Enum.find_value(events, fn
169
        %{"type" => "tool_call_completed", "tool_call" => tool_call} -> tool_call
170
        _event -> nil
171
      end)
172
173
    [browser_tool] =
174
      OpenAgents.Chat.AccountTurns.list_messages(user) |> List.last() |> Map.fetch!(:tool_calls)
175
176
    assert completed["name"] == "open_pull_request"
177
    assert completed["status"] == browser_tool.status
178
    assert completed["receipt_refs"] == browser_tool.receipt_refs
179
    assert completed["output"] == browser_tool.output
180
181
    decoded_output = Jason.decode!(completed["output"])
182
    assert decoded_output == result
183
    assert decoded_output["url"] =~ "/pulls/17"
184
    assert decoded_output["head"]["oid"] == String.duplicate("b", 40)
185
  end
186
109 187
  test "chat API requires a bearer token", %{conn: conn} do
110 188
    assert conn |> get(~p"/api/v3/chat/events") |> json_response(401) == %{
111 189
             "error" => "invalid_api_token"

This page updates live while a promote is in flight · changelog