prompt_objects 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/CLAUDE.md +112 -44
  4. data/Gemfile.lock +31 -29
  5. data/README.md +5 -0
  6. data/frontend/index.html +5 -1
  7. data/frontend/package-lock.json +123 -0
  8. data/frontend/package.json +4 -0
  9. data/frontend/src/App.tsx +70 -71
  10. data/frontend/src/canvas/CanvasView.tsx +113 -0
  11. data/frontend/src/canvas/ForceLayout.ts +115 -0
  12. data/frontend/src/canvas/SceneManager.ts +587 -0
  13. data/frontend/src/canvas/canvasStore.ts +47 -0
  14. data/frontend/src/canvas/constants.ts +95 -0
  15. data/frontend/src/canvas/controls/CameraControls.ts +98 -0
  16. data/frontend/src/canvas/edges/MessageArc.ts +149 -0
  17. data/frontend/src/canvas/inspector/InspectorPanel.tsx +31 -0
  18. data/frontend/src/canvas/inspector/POInspector.tsx +262 -0
  19. data/frontend/src/canvas/inspector/ToolCallInspector.tsx +67 -0
  20. data/frontend/src/canvas/nodes/PONode.ts +249 -0
  21. data/frontend/src/canvas/nodes/ToolCallNode.ts +116 -0
  22. data/frontend/src/canvas/types.ts +24 -0
  23. data/frontend/src/components/ContextMenu.tsx +5 -4
  24. data/frontend/src/components/Inspector.tsx +232 -0
  25. data/frontend/src/components/MarkdownMessage.tsx +22 -20
  26. data/frontend/src/components/MethodList.tsx +90 -0
  27. data/frontend/src/components/ModelSelector.tsx +13 -14
  28. data/frontend/src/components/NotificationPanel.tsx +29 -33
  29. data/frontend/src/components/ObjectList.tsx +78 -0
  30. data/frontend/src/components/PaneSlot.tsx +30 -0
  31. data/frontend/src/components/SourcePane.tsx +202 -0
  32. data/frontend/src/components/SystemBar.tsx +74 -0
  33. data/frontend/src/components/Transcript.tsx +76 -0
  34. data/frontend/src/components/UsagePanel.tsx +27 -27
  35. data/frontend/src/components/Workspace.tsx +260 -0
  36. data/frontend/src/components/index.ts +10 -9
  37. data/frontend/src/hooks/useResize.ts +55 -0
  38. data/frontend/src/hooks/useWebSocket.ts +274 -189
  39. data/frontend/src/index.css +69 -3
  40. data/frontend/src/store/index.ts +23 -0
  41. data/frontend/src/types/index.ts +5 -0
  42. data/frontend/tailwind.config.js +28 -9
  43. data/lib/prompt_objects/capability.rb +23 -1
  44. data/lib/prompt_objects/connectors/mcp.rb +5 -22
  45. data/lib/prompt_objects/environment.rb +8 -0
  46. data/lib/prompt_objects/llm/openai_adapter.rb +22 -0
  47. data/lib/prompt_objects/mcp/tools/get_pending_requests.rb +1 -2
  48. data/lib/prompt_objects/mcp/tools/inspect_po.rb +1 -31
  49. data/lib/prompt_objects/mcp/tools/list_prompt_objects.rb +2 -8
  50. data/lib/prompt_objects/primitives/list_files.rb +1 -2
  51. data/lib/prompt_objects/prompt_object.rb +150 -6
  52. data/lib/prompt_objects/server/api/routes.rb +3 -48
  53. data/lib/prompt_objects/server/app.rb +9 -0
  54. data/lib/prompt_objects/server/public/assets/index-D1myxE0l.js +4345 -0
  55. data/lib/prompt_objects/server/public/assets/index-DdCcwC-Z.css +1 -0
  56. data/lib/prompt_objects/server/public/index.html +7 -3
  57. data/lib/prompt_objects/server/websocket_handler.rb +23 -100
  58. data/lib/prompt_objects/server.rb +6 -62
  59. data/prompt_objects.gemspec +1 -1
  60. data/templates/arc-agi-1/primitives/find_objects.rb +1 -1
  61. data/templates/arc-agi-1/primitives/grid_diff.rb +2 -2
  62. data/templates/arc-agi-1/primitives/grid_info.rb +1 -1
  63. data/templates/arc-agi-1/primitives/grid_transform.rb +1 -1
  64. data/templates/arc-agi-1/primitives/render_grid.rb +1 -0
  65. data/templates/arc-agi-1/primitives/test_solution.rb +3 -0
  66. metadata +26 -14
  67. data/frontend/src/components/CapabilitiesPanel.tsx +0 -141
  68. data/frontend/src/components/ChatPanel.tsx +0 -288
  69. data/frontend/src/components/Dashboard.tsx +0 -83
  70. data/frontend/src/components/Header.tsx +0 -141
  71. data/frontend/src/components/MessageBus.tsx +0 -56
  72. data/frontend/src/components/POCard.tsx +0 -56
  73. data/frontend/src/components/PODetail.tsx +0 -124
  74. data/frontend/src/components/PromptPanel.tsx +0 -156
  75. data/frontend/src/components/SessionsPanel.tsx +0 -174
  76. data/frontend/src/components/ThreadsSidebar.tsx +0 -163
  77. data/lib/prompt_objects/server/public/assets/index-Bkme6COu.css +0 -1
  78. data/lib/prompt_objects/server/public/assets/index-CQ7lVDF_.js +0 -77
@@ -7,18 +7,37 @@ export default {
7
7
  theme: {
8
8
  extend: {
9
9
  colors: {
10
- // Custom colors for PromptObjects
11
10
  po: {
12
- bg: '#0f0f1a',
13
- surface: '#1a1a2e',
14
- border: '#2d2d44',
15
- accent: '#7c3aed',
16
- 'accent-hover': '#9061f9',
17
- success: '#22c55e',
18
- warning: '#f59e0b',
19
- error: '#ef4444',
11
+ bg: '#1a1918',
12
+ surface: '#222120',
13
+ 'surface-2': '#2c2a28',
14
+ 'surface-3': '#363432',
15
+ border: '#3d3a37',
16
+ 'border-focus': '#5c5752',
17
+ accent: '#d4952a',
18
+ 'accent-muted': '#9a6d20',
19
+ 'accent-wash': 'rgba(212,149,42,0.08)',
20
+ 'text-primary': '#e8e2da',
21
+ 'text-secondary': '#a8a29a',
22
+ 'text-tertiary': '#78726a',
23
+ 'text-ghost': '#524e48',
24
+ 'status-idle': '#78726a',
25
+ 'status-active': '#d4952a',
26
+ 'status-calling': '#3b9a6e',
27
+ 'status-error': '#c45c4a',
28
+ 'status-delegated': '#5a8fc2',
29
+ success: '#3b9a6e',
30
+ warning: '#d4952a',
31
+ error: '#c45c4a',
20
32
  },
21
33
  },
34
+ fontFamily: {
35
+ ui: ['Geist', 'system-ui', 'sans-serif'],
36
+ mono: ['"Geist Mono"', '"IBM Plex Mono"', 'monospace'],
37
+ },
38
+ fontSize: {
39
+ '2xs': ['11px', { lineHeight: '15px' }],
40
+ },
22
41
  },
23
42
  },
24
43
  plugins: [],
@@ -27,11 +27,33 @@ module PromptObjects
27
27
  function: {
28
28
  name: name,
29
29
  description: description,
30
- parameters: parameters
30
+ parameters: sanitize_schema(parameters)
31
31
  }
32
32
  }
33
33
  end
34
34
 
35
+ private
36
+
37
+ # Ensure array-typed properties have an `items` field.
38
+ # LLM APIs (Gemini, OpenAI, Ollama) reject array schemas without items.
39
+ def sanitize_schema(schema)
40
+ return schema unless schema.is_a?(Hash)
41
+
42
+ schema = schema.dup
43
+
44
+ if schema[:type] == "array" && !schema.key?(:items) && !schema.key?("items")
45
+ schema[:items] = {}
46
+ end
47
+
48
+ if schema[:properties].is_a?(Hash)
49
+ schema[:properties] = schema[:properties].transform_values { |v| sanitize_schema(v) }
50
+ end
51
+
52
+ schema
53
+ end
54
+
55
+ public
56
+
35
57
  # Define the parameters this capability accepts.
36
58
  # Override in subclasses for specific parameter schemas.
37
59
  # @return [Hash] JSON Schema for parameters
@@ -176,20 +176,14 @@ module PromptObjects
176
176
 
177
177
  input_schema(
178
178
  type: "object",
179
- properties: {},
180
- required: []
179
+ properties: {}
181
180
  )
182
181
 
183
182
  def self.call(server_context:)
184
183
  env = server_context[:env]
185
184
 
186
185
  pos = env.registry.prompt_objects.map do |po|
187
- {
188
- name: po.name,
189
- description: po.description,
190
- state: po.state.to_s,
191
- capabilities: po.config["capabilities"] || []
192
- }
186
+ po.to_summary_hash(registry: env.registry)
193
187
  end
194
188
 
195
189
  ::MCP::Tool::Response.new([{
@@ -367,8 +361,7 @@ module PromptObjects
367
361
  type: "string",
368
362
  description: "Filter by source: tui, mcp, api, web (optional)"
369
363
  }
370
- },
371
- required: []
364
+ }
372
365
  )
373
366
 
374
367
  def self.call(po_name: nil, source: nil, server_context:)
@@ -413,8 +406,7 @@ module PromptObjects
413
406
 
414
407
  input_schema(
415
408
  type: "object",
416
- properties: {},
417
- required: []
409
+ properties: {}
418
410
  )
419
411
 
420
412
  def self.call(server_context:)
@@ -502,16 +494,7 @@ module PromptObjects
502
494
  }])
503
495
  end
504
496
 
505
- info = {
506
- name: po.name,
507
- description: po.description,
508
- state: po.state.to_s,
509
- prompt: po.body,
510
- capabilities: po.config["capabilities"] || [],
511
- config: po.config,
512
- session_id: po.instance_variable_get(:@session_id),
513
- history_length: po.history.length
514
- }
497
+ info = po.to_inspect_hash(registry: env.registry)
515
498
 
516
499
  ::MCP::Tool::Response.new([{
517
500
  type: "text",
@@ -41,6 +41,7 @@ module PromptObjects
41
41
  :current_provider, :current_model
42
42
  attr_accessor :on_po_registered # Callback for when a PO is registered
43
43
  attr_accessor :on_po_modified # Callback for when a PO is modified (capabilities changed, etc.)
44
+ attr_accessor :on_delegation_event # Callback for PO-to-PO delegation start/complete
44
45
 
45
46
  # Initialize from an environment path (with manifest) or objects directory.
46
47
  # @param env_path [String, nil] Path to environment directory (preferred)
@@ -215,6 +216,13 @@ module PromptObjects
215
216
  @on_po_modified&.call(po)
216
217
  end
217
218
 
219
+ # Notify that a PO-to-PO delegation has started or completed.
220
+ # @param event_type [Symbol] :started or :completed
221
+ # @param payload [Hash] { target:, caller:, thread_id:, tool_call_id: }
222
+ def notify_delegation(event_type, payload)
223
+ @on_delegation_event&.call(event_type, payload)
224
+ end
225
+
218
226
  # Load a prompt object by name from the objects directory.
219
227
  # @param name [String] Name of the prompt object (without .md extension)
220
228
  # @return [PromptObject]
@@ -37,7 +37,29 @@ module PromptObjects
37
37
  end
38
38
 
39
39
  raw_response = @client.chat(parameters: params)
40
+
41
+ # Check for error responses (Ollama and some providers return errors inline)
42
+ if raw_response.is_a?(Hash) && raw_response["error"]
43
+ error_msg = raw_response["error"]
44
+ error_msg = error_msg["message"] if error_msg.is_a?(Hash)
45
+ raise Error, "#{@provider_name} API error: #{error_msg}"
46
+ end
47
+
40
48
  parse_response(raw_response)
49
+ rescue Faraday::ClientError => e
50
+ # Extract error body from 4xx responses (ruby-openai wraps these)
51
+ body = e.response&.dig(:body) rescue nil
52
+ detail = if body.is_a?(String)
53
+ begin
54
+ parsed = JSON.parse(body)
55
+ parsed.dig("error", "message") || parsed["error"] || body
56
+ rescue JSON::ParserError
57
+ body
58
+ end
59
+ elsif body.is_a?(Hash)
60
+ body.dig("error", "message") || body["error"] || body.to_s
61
+ end
62
+ raise Error, "#{@provider_name} API error (#{e.response&.dig(:status)}): #{detail || e.message}"
41
63
  end
42
64
 
43
65
  private
@@ -15,8 +15,7 @@ module PromptObjects
15
15
  type: "string",
16
16
  description: "Optional: filter to requests from a specific PO"
17
17
  }
18
- },
19
- required: []
18
+ }
20
19
  )
21
20
 
22
21
  def self.call(po_name: nil, server_context:)
@@ -30,37 +30,7 @@ module PromptObjects
30
30
  }])
31
31
  end
32
32
 
33
- # Categorize capabilities
34
- declared_caps = po.config["capabilities"] || []
35
- universal_caps = PromptObjects::UNIVERSAL_CAPABILITIES
36
-
37
- # Resolve which are POs vs primitives
38
- delegates = []
39
- primitives = []
40
-
41
- declared_caps.each do |cap_name|
42
- cap = env.registry.get(cap_name)
43
- if cap.is_a?(PromptObjects::PromptObject)
44
- delegates << cap_name
45
- elsif cap.is_a?(PromptObjects::Primitive)
46
- primitives << cap_name
47
- end
48
- end
49
-
50
- info = {
51
- name: po.name,
52
- description: po.description,
53
- state: po.state || :idle,
54
- config: po.config,
55
- capabilities: {
56
- universal: universal_caps,
57
- primitives: primitives,
58
- delegates: delegates,
59
- all_declared: declared_caps
60
- },
61
- prompt_body: po.body,
62
- history_length: po.history.length
63
- }
33
+ info = po.to_inspect_hash(registry: env.registry)
64
34
 
65
35
  ::MCP::Tool::Response.new([{
66
36
  type: "text",
@@ -10,20 +10,14 @@ module PromptObjects
10
10
 
11
11
  input_schema(
12
12
  type: "object",
13
- properties: {},
14
- required: []
13
+ properties: {}
15
14
  )
16
15
 
17
16
  def self.call(server_context:, **_args)
18
17
  env = server_context[:env]
19
18
 
20
19
  pos = env.registry.prompt_objects.map do |po|
21
- {
22
- name: po.name,
23
- description: po.description,
24
- state: po.state || :idle,
25
- capabilities: po.config["capabilities"] || []
26
- }
20
+ po.to_summary_hash(registry: env.registry)
27
21
  end
28
22
 
29
23
  ::MCP::Tool::Response.new([{
@@ -20,8 +20,7 @@ module PromptObjects
20
20
  type: "string",
21
21
  description: "The directory path to list (defaults to current directory)"
22
22
  }
23
- },
24
- required: []
23
+ }
25
24
  }
26
25
  end
27
26
 
@@ -232,8 +232,134 @@ module PromptObjects
232
232
  @session_id
233
233
  end
234
234
 
235
+ # --- Serialization ---
236
+ # Canonical methods for converting PO state to hashes for broadcasting,
237
+ # REST API responses, and MCP tool output. All consumers should use these
238
+ # to ensure consistent capability format across WebSocket, REST, and MCP.
239
+
240
+ # Full state hash for WebSocket/broadcast consumers.
241
+ # Matches the frontend's PromptObject TypeScript type.
242
+ # @param registry [Registry, nil] Registry for resolving capability descriptions
243
+ # @return [Hash]
244
+ def to_state_hash(registry: nil)
245
+ {
246
+ status: (@state || :idle).to_s,
247
+ description: description,
248
+ capabilities: resolve_capabilities(registry: registry),
249
+ universal_capabilities: resolve_universal_capabilities(registry: registry),
250
+ current_session: serialize_current_session,
251
+ sessions: list_sessions.map { |s| self.class.serialize_session(s) },
252
+ prompt: body,
253
+ config: config
254
+ }
255
+ end
256
+
257
+ # Summary hash for list endpoints (REST API, MCP list tools).
258
+ # Lightweight: no prompt, no session messages, no universal capabilities.
259
+ # @param registry [Registry, nil] Registry for resolving capability descriptions
260
+ # @return [Hash]
261
+ def to_summary_hash(registry: nil)
262
+ {
263
+ name: name,
264
+ description: description,
265
+ status: (@state || :idle).to_s,
266
+ capabilities: resolve_capabilities(registry: registry),
267
+ session_count: list_sessions.size
268
+ }
269
+ end
270
+
271
+ # Detailed inspection hash for single-PO endpoints (REST GET, MCP inspect).
272
+ # Everything in summary plus prompt, config, sessions, and universals.
273
+ # @param registry [Registry, nil] Registry for resolving capability descriptions
274
+ # @return [Hash]
275
+ def to_inspect_hash(registry: nil)
276
+ {
277
+ name: name,
278
+ description: description,
279
+ status: (@state || :idle).to_s,
280
+ capabilities: resolve_capabilities(registry: registry),
281
+ universal_capabilities: resolve_universal_capabilities(registry: registry),
282
+ prompt: body,
283
+ config: config,
284
+ session_id: session_id,
285
+ sessions: list_sessions.map { |s| self.class.serialize_session(s) },
286
+ history_length: history.length
287
+ }
288
+ end
289
+
290
+ # Serialize a message (in-memory or from DB) to a JSON-ready hash.
291
+ # Handles both ToolCall objects and plain Hashes (from SQLite).
292
+ # @param msg [Hash] The message to serialize
293
+ # @return [Hash]
294
+ def self.serialize_message(msg)
295
+ case msg[:role]
296
+ when :user
297
+ from = msg[:from] || msg[:from_po]
298
+ { role: "user", content: msg[:content], from: from }
299
+ when :assistant
300
+ hash = { role: "assistant", content: msg[:content] }
301
+ if msg[:tool_calls]
302
+ hash[:tool_calls] = msg[:tool_calls].map do |tc|
303
+ if tc.respond_to?(:id)
304
+ { id: tc.id, name: tc.name, arguments: tc.arguments }
305
+ else
306
+ { id: tc[:id] || tc["id"], name: tc[:name] || tc["name"], arguments: tc[:arguments] || tc["arguments"] || {} }
307
+ end
308
+ end
309
+ end
310
+ hash
311
+ when :tool
312
+ results = msg[:results] || msg[:tool_results]
313
+ { role: "tool", results: results }
314
+ else
315
+ { role: msg[:role].to_s, content: msg[:content] }
316
+ end
317
+ end
318
+
319
+ # Serialize a session record to a JSON-ready hash with thread fields.
320
+ # @param session [Hash] Session record from session store
321
+ # @return [Hash]
322
+ def self.serialize_session(session)
323
+ {
324
+ id: session[:id],
325
+ name: session[:name],
326
+ message_count: session[:message_count] || 0,
327
+ updated_at: session[:updated_at]&.iso8601,
328
+ parent_session_id: session[:parent_session_id],
329
+ parent_po: session[:parent_po],
330
+ thread_type: session[:thread_type] || "root"
331
+ }
332
+ end
333
+
235
334
  private
236
335
 
336
+ # Build capability info objects for this PO's declared capabilities.
337
+ def resolve_capabilities(registry: nil)
338
+ declared = @config["capabilities"] || []
339
+ return declared.map { |n| { name: n, description: n } } unless registry
340
+
341
+ declared.map do |cap_name|
342
+ cap = registry.get(cap_name)
343
+ { name: cap_name, description: cap&.description || "Capability not found", parameters: cap&.parameters }
344
+ end
345
+ end
346
+
347
+ # Build capability info objects for universal capabilities.
348
+ def resolve_universal_capabilities(registry: nil)
349
+ return [] unless registry
350
+
351
+ UNIVERSAL_CAPABILITIES.map do |cap_name|
352
+ cap = registry.get(cap_name)
353
+ { name: cap_name, description: cap&.description || "Universal capability", parameters: cap&.parameters }
354
+ end
355
+ end
356
+
357
+ # Serialize current session with messages for real-time state.
358
+ def serialize_current_session
359
+ return nil unless session_id
360
+ { id: session_id, messages: history.map { |m| self.class.serialize_message(m) } }
361
+ end
362
+
237
363
  def normalize_message(message)
238
364
  case message
239
365
  when String
@@ -328,12 +454,30 @@ module PromptObjects
328
454
  parent_message_id: get_last_message_id
329
455
  )
330
456
 
331
- if delegation_thread
332
- # Execute in isolated thread
333
- target_po.receive_in_thread(tool_call.arguments, context: context, thread_id: delegation_thread)
334
- else
335
- # Fallback: execute in target's current session (no session store)
336
- target_po.receive(tool_call.arguments, context: context)
457
+ # Notify delegation start so WebSocket clients see the target PO activate
458
+ @env.notify_delegation(:started, {
459
+ target: target_po.name,
460
+ caller: name,
461
+ thread_id: delegation_thread,
462
+ tool_call_id: tool_call.id
463
+ })
464
+
465
+ begin
466
+ if delegation_thread
467
+ # Execute in isolated thread
468
+ target_po.receive_in_thread(tool_call.arguments, context: context, thread_id: delegation_thread)
469
+ else
470
+ # Fallback: execute in target's current session (no session store)
471
+ target_po.receive(tool_call.arguments, context: context)
472
+ end
473
+ ensure
474
+ # Notify delegation complete — target PO is done
475
+ @env.notify_delegation(:completed, {
476
+ target: target_po.name,
477
+ caller: name,
478
+ thread_id: delegation_thread,
479
+ tool_call_id: tool_call.id
480
+ })
337
481
  end
338
482
  end
339
483
 
@@ -192,13 +192,7 @@ module PromptObjects
192
192
  end
193
193
 
194
194
  sessions = po.list_sessions.map do |s|
195
- {
196
- id: s[:id],
197
- name: s[:name],
198
- message_count: s[:message_count] || 0,
199
- created_at: s[:created_at]&.iso8601,
200
- updated_at: s[:updated_at]&.iso8601
201
- }
195
+ PromptObject.serialize_session(s)
202
196
  end
203
197
 
204
198
  { sessions: sessions }
@@ -372,31 +366,11 @@ module PromptObjects
372
366
  # === Helpers ===
373
367
 
374
368
  def po_summary(po)
375
- {
376
- name: po.name,
377
- description: po.description,
378
- capabilities: po.config["capabilities"] || [],
379
- session_count: po.list_sessions.size
380
- }
369
+ po.to_summary_hash(registry: @runtime.registry)
381
370
  end
382
371
 
383
372
  def po_full(po)
384
- {
385
- name: po.name,
386
- description: po.description,
387
- capabilities: po.config["capabilities"] || [],
388
- body: po.body,
389
- config: po.config,
390
- sessions: po.list_sessions.map do |s|
391
- {
392
- id: s[:id],
393
- name: s[:name],
394
- message_count: s[:message_count] || 0
395
- }
396
- end,
397
- current_session: po.session_id,
398
- history: po.history.map { |m| format_history_message(m) }
399
- }
373
+ po.to_inspect_hash(registry: @runtime.registry)
400
374
  end
401
375
 
402
376
  def format_message(msg)
@@ -410,25 +384,6 @@ module PromptObjects
410
384
  }.compact
411
385
  end
412
386
 
413
- def format_history_message(msg)
414
- case msg[:role]
415
- when :user
416
- { role: "user", content: msg[:content], from: msg[:from] }
417
- when :assistant
418
- h = { role: "assistant", content: msg[:content] }
419
- if msg[:tool_calls]
420
- h[:tool_calls] = msg[:tool_calls].map do |tc|
421
- { id: tc.id, name: tc.name, arguments: tc.arguments }
422
- end
423
- end
424
- h
425
- when :tool
426
- { role: "tool", results: msg[:results] }
427
- else
428
- { role: msg[:role].to_s, content: msg[:content] }
429
- end
430
- end
431
-
432
387
  def path_param(path, index)
433
388
  # Extract path parameter from regex match
434
389
  # /prompt_objects/foo -> foo (index 1)
@@ -19,6 +19,15 @@ module PromptObjects
19
19
  @public_path = File.expand_path("public", __dir__)
20
20
  @connections = []
21
21
  @connections_mutex = Mutex.new
22
+
23
+ # Broadcast PO-to-PO delegation events to all connected clients.
24
+ # This makes delegated POs visible in the UI (canvas + chat).
25
+ @runtime.on_delegation_event = ->(event_type, payload) {
26
+ broadcast(
27
+ type: "po_delegation_#{event_type}",
28
+ payload: payload
29
+ )
30
+ }
22
31
  end
23
32
 
24
33
  def call(env)