samagotchi 0.79.0 → 0.81.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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/README.md +7 -5
  4. data/docs/architecture.md +9 -1
  5. data/docs/cli.md +32 -7
  6. data/docs/configuration.md +10 -4
  7. data/docs/guardrails.md +26 -3
  8. data/docs/internals/anthropic.md +3 -1
  9. data/docs/internals/bundles.md +4 -0
  10. data/docs/internals/engine.md +13 -3
  11. data/docs/plugins.md +149 -3
  12. data/docs/releasing.md +2 -2
  13. data/docs/sessions.md +9 -1
  14. data/docs/testing.md +51 -2
  15. data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
  16. data/lib/samagotchi/bootstrap/probe.rb +153 -31
  17. data/lib/samagotchi/bootstrap_command.rb +93 -16
  18. data/lib/samagotchi/bridge.rb +1 -1
  19. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -4
  20. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  21. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/slow-commands/plugin.rb +2 -1
  23. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  25. data/lib/samagotchi/continue_offer.rb +5 -5
  26. data/lib/samagotchi/engine.rb +68 -39
  27. data/lib/samagotchi/guardrails/approval.rb +6 -4
  28. data/lib/samagotchi/guardrails/data_readers.rb +214 -0
  29. data/lib/samagotchi/guardrails/gate.rb +7 -3
  30. data/lib/samagotchi/guardrails/rules.rb +4 -2
  31. data/lib/samagotchi/guardrails/shell_lex.rb +166 -28
  32. data/lib/samagotchi/guardrails/targets.rb +14 -0
  33. data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
  34. data/lib/samagotchi/hooks/registry.rb +44 -3
  35. data/lib/samagotchi/host_config.rb +8 -8
  36. data/lib/samagotchi/host_registry.rb +12 -4
  37. data/lib/samagotchi/idle_client.rb +41 -11
  38. data/lib/samagotchi/idle_recap.rb +6 -1
  39. data/lib/samagotchi/idle_target.rb +12 -4
  40. data/lib/samagotchi/llm/adapter_common.rb +4 -2
  41. data/lib/samagotchi/llm/anthropic_binding_recovery.rb +3 -0
  42. data/lib/samagotchi/llm/anthropic_messages.rb +2 -1
  43. data/lib/samagotchi/llm/http.rb +12 -7
  44. data/lib/samagotchi/llm/usage.rb +9 -3
  45. data/lib/samagotchi/model_price.rb +16 -0
  46. data/lib/samagotchi/plugin/card.rb +44 -0
  47. data/lib/samagotchi/plugin/context.rb +61 -9
  48. data/lib/samagotchi/plugin/http.rb +154 -0
  49. data/lib/samagotchi/plugin/http_transport.rb +77 -0
  50. data/lib/samagotchi/plugin/loader.rb +29 -10
  51. data/lib/samagotchi/plugin/service.rb +24 -1
  52. data/lib/samagotchi/question_answer.rb +29 -0
  53. data/lib/samagotchi/question_desk.rb +20 -19
  54. data/lib/samagotchi/recap_setup.rb +2 -1
  55. data/lib/samagotchi/relay_verifier.rb +1 -1
  56. data/lib/samagotchi/session_metrics/side_totals.rb +110 -0
  57. data/lib/samagotchi/session_metrics.rb +46 -12
  58. data/lib/samagotchi/session_observer.rb +3 -0
  59. data/lib/samagotchi/side_usage.rb +38 -0
  60. data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
  61. data/lib/samagotchi/terminal_ui/model_segments_text.rb +27 -3
  62. data/lib/samagotchi/testing/boundary.rb +156 -0
  63. data/lib/samagotchi/testing/bundle.rb +349 -0
  64. data/lib/samagotchi/testing/fake_executable.rb +42 -0
  65. data/lib/samagotchi/testing/fake_http.rb +137 -0
  66. data/lib/samagotchi/testing/helpers.rb +56 -0
  67. data/lib/samagotchi/testing/host.rb +76 -0
  68. data/lib/samagotchi/testing/minitest.rb +31 -0
  69. data/lib/samagotchi/testing/recorder.rb +141 -0
  70. data/lib/samagotchi/testing/recording_api.rb +27 -0
  71. data/lib/samagotchi/testing/rspec.rb +31 -0
  72. data/lib/samagotchi/testing/sandbox.rb +165 -0
  73. data/lib/samagotchi/testing/sessions.rb +77 -0
  74. data/lib/samagotchi/testing/verdict.rb +28 -0
  75. data/lib/samagotchi/testing/waiting.rb +36 -0
  76. data/lib/samagotchi/testing.rb +26 -0
  77. data/lib/samagotchi/tools/delegate_relay.rb +7 -6
  78. data/lib/samagotchi/tools/tool_index.rb +3 -0
  79. data/lib/samagotchi/turn_state.rb +28 -1
  80. data/lib/samagotchi/version.rb +1 -1
  81. data/lib/samagotchi/web/app.rb +4 -1
  82. data/lib/samagotchi/web/public/app.js +47 -23
  83. data/lib/samagotchi/web/public/ctx.js +32 -6
  84. data/lib/samagotchi/web/public/images.js +33 -0
  85. data/lib/samagotchi/web/public/index.html +71 -38
  86. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  87. data/lib/samagotchi/web/public/level_chip.js +11 -11
  88. data/lib/samagotchi/web/public/lists_view.js +20 -15
  89. data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
  90. data/lib/samagotchi/web/public/model_picker.js +1 -1
  91. data/lib/samagotchi/web/public/scope.js +3 -3
  92. data/lib/samagotchi/web/public/stream_handlers.js +7 -0
  93. data/lib/samagotchi/web/public/strip.js +35 -9
  94. data/lib/samagotchi/web/public/strip_grip.js +76 -0
  95. data/lib/samagotchi/web/public/thinking_chip.js +5 -5
  96. data/lib/samagotchi/web/public/timing.js +6 -1
  97. data/lib/samagotchi/web/session_summary.rb +2 -0
  98. metadata +24 -1
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest"
4
+ require_relative "../testing"
5
+
6
+ module Samagotchi
7
+ module Testing
8
+ # The bundle test kit in Minitest (EXPERIMENTAL, docs/plugins.md): a
9
+ # test case with Helpers (load_bundle, shipped_bundle, sandbox), each
10
+ # test in its own sandbox, its bundles shut down after it.
11
+ #
12
+ # class MyBundleTest < Samagotchi::Testing::Case
13
+ # def test_denies = assert load_bundle(__dir__ + "/..").tool_call({ name: "execute", content: "rm -rf /" }).deny?
14
+ # end
15
+ class Case < Minitest::Test
16
+ include Helpers
17
+
18
+ def before_setup
19
+ super
20
+ enter_sandbox
21
+ end
22
+
23
+ def after_teardown
24
+ unmatched = leave_sandbox
25
+ super
26
+ # After the cleanup, and only over a test that passed.
27
+ flunk(FakeHttp.unmatched_message(unmatched)) if !unmatched.empty? && passed?
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../plugin/card"
4
+ require_relative "../plugin/context"
5
+
6
+ module Samagotchi
7
+ module Testing
8
+ # A line to the user (ctx.notify, event[:notify]); +hook+ is the
9
+ # plugin's or hook file's label ("plugin.rb (bundle x)").
10
+ Notice = Data.define(:text, :level, :hook, :fallback_for)
11
+ # Text put into the running turn (ctx.steer, event[:steer]).
12
+ Steer = Data.define(:text, :hook)
13
+ # A stop_turn or stop_generation (+kind+ :turn or :generation).
14
+ Stop = Data.define(:kind, :reason, :hook)
15
+ # An ask_user question.
16
+ Question = Data.define(:question, :options, :header, :allow_freeform, :hook)
17
+ # An ask_model request: the request Context built (SideQuestion), its
18
+ # limits, cancel controller and the model: it named (nil: the session's;
19
+ # the kit doesn't check it against any hosts), and +source+, the asking
20
+ # bundle's name (what chi counts its side request under).
21
+ ModelRequest = Data.define(:request, :timeout, :max_tokens, :cancel, :model, :source) do
22
+ def initialize(source: nil, **) = super
23
+ end
24
+
25
+ # What a bundle did at chi's edges, for a test to read: the Engine side
26
+ # of its Context (Testing::Host) and the hook runtime (Testing::Runtime)
27
+ # land here, file hooks and plugin hooks in the same lists. It also
28
+ # holds what those edges answer: queued ask_user / ask_model answers and
29
+ # the session's values (#values), read at each call.
30
+ class Recorder
31
+ # The session values a Host reads when the test sets none.
32
+ DEFAULTS = {
33
+ session_id: "test-session", cwd: nil, messages: [], messages_partial: false, model_name: "test:model",
34
+ model_key: "model", toolkit: "full", declared_tools: nil, frontend: :worker, scratch: false, setup: nil,
35
+ prompt_files: [], cancelled: false, state_dir: nil, running: true, toolkit_staged: true
36
+ }.freeze
37
+
38
+ attr_reader :notices, :cards, :steers, :stops, :questions, :model_requests, :toolkit_uses, :answers,
39
+ :model_answers, :values
40
+
41
+ def initialize(**values)
42
+ unknown = values.keys - DEFAULTS.keys
43
+ raise ArgumentError, "unknown host values: #{unknown.join(", ")}" unless unknown.empty?
44
+
45
+ @values = DEFAULTS.merge(values)
46
+ @notices = []
47
+ @cards = []
48
+ @steers = []
49
+ @stops = []
50
+ @questions = []
51
+ @model_requests = []
52
+ @toolkit_uses = []
53
+ @answers = []
54
+ @model_answers = []
55
+ @mutex = Mutex.new
56
+ end
57
+
58
+ # Change session values (cwd:, messages:, running:, cancelled: …).
59
+ def set(**values)
60
+ unknown = values.keys - DEFAULTS.keys
61
+ raise ArgumentError, "unknown host values: #{unknown.join(", ")}" unless unknown.empty?
62
+
63
+ @mutex.synchronize { @values.merge!(values) }
64
+ self
65
+ end
66
+
67
+ def [](key) = @mutex.synchronize { @values.fetch(key) }
68
+
69
+ # Only stop_turn's, or only stop_generation's.
70
+ def turn_stops = stops.select { |stop| stop.kind == :turn }
71
+ def generation_stops = stops.select { |stop| stop.kind == :generation }
72
+
73
+ def notify(text, level, hook, fallback_for)
74
+ record(@notices, Notice.new(text: text.to_s, level: level, hook: hook, fallback_for: fallback_for))
75
+ nil
76
+ end
77
+
78
+ # Checked as chi's cards are (Plugin::Card.build); the id it shows.
79
+ def card(**fields)
80
+ card = Plugin::Card.build(**fields).freeze
81
+ record(@cards, card)
82
+ card[:id]
83
+ end
84
+
85
+ def steer(text, hook)
86
+ return false unless self[:running]
87
+
88
+ record(@steers, Steer.new(text: text.to_s, hook: hook))
89
+ true
90
+ end
91
+
92
+ def stop(kind, reason, hook)
93
+ return false unless self[:running]
94
+
95
+ record(@stops, Stop.new(kind: kind, reason: reason.to_s, hook: hook))
96
+ true
97
+ end
98
+
99
+ # The next queued answer (#answers), nil with none (no one to ask).
100
+ def ask_user(question:, options:, header:, allow_freeform:, hook:)
101
+ record(@questions, Question.new(question: question, options: options, header: header,
102
+ allow_freeform: allow_freeform, hook: hook))
103
+ answer = @mutex.synchronize { @answers.shift }
104
+ answer.respond_to?(:call) ? answer.call(@questions.last) : answer
105
+ end
106
+
107
+ # The next queued model answer (#model_answers): a String, an
108
+ # exception to raise, or a callable given the ModelRequest. None left:
109
+ # Plugin::ModelError, as an unreachable model.
110
+ def ask_model(request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil)
111
+ entry = ModelRequest.new(request: request, timeout: timeout, max_tokens: max_tokens, cancel: cancel_controller,
112
+ model: model, source: source)
113
+ record(@model_requests, entry)
114
+ answer = @mutex.synchronize { @model_answers.empty? ? :none : @model_answers.shift }
115
+ raise Plugin::ModelError, "no model answer queued (bundle.model_answers << \"text\")" if answer == :none
116
+ raise answer if answer.is_a?(Exception) || (answer.is_a?(Class) && answer <= Exception)
117
+
118
+ answer.respond_to?(:call) ? answer.call(entry) : answer
119
+ end
120
+
121
+ # ctx.use_toolkit: +known+ the toolkits chi and the plugins declare.
122
+ # Staged (toolkit_staged:, true by default), the session is on it at
123
+ # once, as chi saves it on the session (ToolkitBinding#use).
124
+ def use_toolkit(name, known)
125
+ raise ArgumentError, "no toolkit named #{name}" unless known.include?(name)
126
+
127
+ record(@toolkit_uses, name)
128
+ return false unless self[:toolkit_staged]
129
+
130
+ set(toolkit: name)
131
+ true
132
+ end
133
+
134
+ private
135
+
136
+ def record(list, item)
137
+ @mutex.synchronize { list << item }
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../plugin/api"
4
+
5
+ module Samagotchi
6
+ module Testing
7
+ # One chi.* call a plugin's #register made, as made: its arguments
8
+ # before Plugin::Api checks and normalizes them (params: as given).
9
+ Declaration = Data.define(:name, :args, :options)
10
+
11
+ # The real Plugin::Api, also recording each declaration in order
12
+ # (#declarations), for a spec of what #register declares as written.
13
+ class RecordingApi < Plugin::Api
14
+ DECLARATIONS = %i[command annotation tool on service init toolkit tool_source peer_source].freeze
15
+
16
+ # @return [Array<Declaration>]
17
+ def declarations = (@declarations ||= [])
18
+
19
+ DECLARATIONS.each do |name|
20
+ define_method(name) do |*args, **options, &block|
21
+ declarations << Declaration.new(name: name, args: args.freeze, options: options.freeze)
22
+ super(*args, **options, &block)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/core"
4
+ require_relative "../testing"
5
+
6
+ # The bundle test kit in RSpec (EXPERIMENTAL, docs/plugins.md): example groups
7
+ # tagged :bundle get Samagotchi::Testing::Helpers (load_bundle,
8
+ # shipped_bundle, sandbox), and each of their examples runs in its own
9
+ # sandbox, its bundles shut down after it. A spec under a bundle's own spec/
10
+ # folder (bundles/<name>/spec/) is tagged :bundle by itself; elsewhere tag the
11
+ # group:
12
+ #
13
+ # RSpec.describe "my bundle", :bundle do
14
+ # it("says hi") { expect(load_bundle(BUNDLE_DIR).command("/hi")).to eq("hi") }
15
+ # end
16
+ RSpec.configure do |config|
17
+ config.define_derived_metadata(file_path: %r{(?:\A|/)bundles/[^/]+/spec/}) { |metadata| metadata[:bundle] = true }
18
+ config.include Samagotchi::Testing::Helpers, :bundle
19
+ config.around(:each, :bundle) do |example|
20
+ enter_sandbox
21
+ example.run
22
+ ensure
23
+ unmatched = leave_sandbox
24
+ # A request no stub answered fails an example that passed, after its
25
+ # cleanup (a plugin that rescued the UnstubbedRequest can't hide it); an
26
+ # example that failed keeps its own failure.
27
+ unless unmatched.empty? || example.exception
28
+ raise Samagotchi::Testing::UnstubbedRequest, Samagotchi::Testing::FakeHttp.unmatched_message(unmatched)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+ require "yaml"
6
+ require_relative "../log"
7
+ require_relative "../paths"
8
+ require_relative "fake_executable"
9
+
10
+ module Samagotchi
11
+ module Testing
12
+ # The environment a bundle test runs in: temp config, state, data and cache
13
+ # homes (config.yml, memories, ctx.data_dir, the debug log), HOME and the peer
14
+ # CLIs' folders, git without the user's config, no Keychain, none of
15
+ # the user's SAMAGOTCHI_* settings, a bin/ first on PATH for fake
16
+ # commands. ENV is put back on #leave.
17
+ #
18
+ # Samagotchi::Testing::Sandbox.with { |sandbox| sandbox.config("bundles" => {…}) … }
19
+ # sandbox = Samagotchi::Testing::Sandbox.enter # … sandbox.leave
20
+ class Sandbox
21
+ # The environment switches a run of the test keeps (as the suite does).
22
+ KEPT = %w[SAMAGOTCHI_LOG_STRICT].freeze
23
+
24
+ class << self
25
+ # The sandbox entered last and not left yet (Bundle.load uses it).
26
+ # @return [Sandbox, nil]
27
+ attr_accessor :current
28
+
29
+ # @return [Sandbox] entered
30
+ def enter = new.tap(&:enter)
31
+
32
+ # Run the block in a new sandbox, left after it.
33
+ # @yieldparam sandbox [Sandbox]
34
+ def with
35
+ sandbox = enter
36
+ yield sandbox
37
+ ensure
38
+ sandbox&.leave
39
+ end
40
+
41
+ # @api private
42
+ end
43
+
44
+ # @return [String] the temp folder holding everything
45
+ attr_reader :root
46
+
47
+ def initialize
48
+ @root = nil
49
+ @saved = nil
50
+ @outer = nil
51
+ end
52
+
53
+ # Point ENV at fresh temp folders (and remember the previous ENV).
54
+ # @return [self]
55
+ def enter
56
+ raise ArgumentError, "the sandbox was entered already" if @root
57
+
58
+ @root = File.realpath(Dir.mktmpdir("chi-bundle-test-"))
59
+ @saved = ENV.to_h
60
+ @outer = Sandbox.current
61
+ ENV.each_key.select { |key| key.start_with?("SAMAGOTCHI_") && !KEPT.include?(key) }.each { |key| ENV.delete(key) }
62
+ %w[config state data cache home work bin].each { |dir| FileUtils.mkdir_p(File.join(@root, dir)) }
63
+ File.write(File.join(@root, "gitconfig"), "")
64
+ ENV.update(
65
+ "HOME" => File.join(@root, "home"),
66
+ "XDG_CONFIG_HOME" => File.join(@root, "config"),
67
+ "XDG_STATE_HOME" => File.join(@root, "state"),
68
+ "XDG_DATA_HOME" => File.join(@root, "data"),
69
+ "XDG_CACHE_HOME" => File.join(@root, "cache"),
70
+ "CLAUDE_CONFIG_DIR" => File.join(@root, "home", ".claude"),
71
+ "CODEX_HOME" => File.join(@root, "home", ".codex"),
72
+ "GIT_CONFIG_GLOBAL" => File.join(@root, "gitconfig"),
73
+ "GIT_CONFIG_NOSYSTEM" => "1",
74
+ "SAMAGOTCHI_KEYCHAIN" => "off",
75
+ "SAMAGOTCHI_ENV" => "test",
76
+ "SAMAGOTCHI_RECAP_ENABLED" => "false",
77
+ "SAMAGOTCHI_CACHE_WARMUP" => "off",
78
+ "PATH" => [bin_dir, ENV.fetch("PATH", nil)].compact.join(File::PATH_SEPARATOR)
79
+ )
80
+ # The log resolves its file from the (now temp) state home.
81
+ Log.reset!
82
+ Sandbox.current = self
83
+ self
84
+ end
85
+
86
+ # Put ENV back as it was and remove the temp folders.
87
+ def leave
88
+ return unless @root
89
+
90
+ ENV.replace(@saved)
91
+ Log.reset!
92
+ FileUtils.rm_rf(@root)
93
+ Sandbox.current = @outer
94
+ @root = @saved = @outer = nil
95
+ end
96
+
97
+ # Write config.yml (what Config.get reads): a Hash or YAML text.
98
+ # @return [String] its path
99
+ def config(data)
100
+ path = File.join(Paths.config_home, Paths::APP_DIR, "config.yml")
101
+ FileUtils.mkdir_p(File.dirname(path))
102
+ File.write(path, data.is_a?(String) ? data : YAML.dump(stringify(data)))
103
+ path
104
+ end
105
+
106
+ # @return [String] $XDG_STATE_HOME/samagotchi (ctx.data_dir's parent)
107
+ def state_dir = Paths.state_dir
108
+
109
+ # A memory scope's folder ("system", or "project" for the checkout
110
+ # at +cwd+), as chi's memory tools and bundles find it; created.
111
+ # @return [String]
112
+ def memory_dir(scope = "system", cwd: Dir.pwd)
113
+ require_relative "../memory_paths"
114
+ MemoryPaths.scope_dir!(scope, cwd: cwd).tap { |dir| FileUtils.mkdir_p(dir) }
115
+ end
116
+
117
+ # Write a memory as memory_write does (the file and its index line,
118
+ # with +description+), for the checkout at +cwd+ (the project scope's;
119
+ # default the process's own); +source+ marks the line as a bundle's
120
+ # ("· from <source>"), as an install writes it.
121
+ # @return [String] the memory's path
122
+ # @raise [ArgumentError] what memory_write refused
123
+ def write_memory(name, content, scope: "system", description: nil, source: nil, cwd: nil)
124
+ require_relative "../tools/memory"
125
+ require_relative "../memory_bundle/index_updater"
126
+ in_dir(cwd) do
127
+ result = Tools::MemoryWrite.call(content, path: name, scope: scope, description: description)
128
+ raise ArgumentError, result if result.start_with?("Error")
129
+
130
+ MemoryBundle::IndexUpdater.update_index(scope, name, content.bytesize, description, source: source) if source
131
+ end
132
+ File.join(memory_dir(scope, cwd: cwd || Dir.pwd), "#{name}.md")
133
+ end
134
+
135
+ # @return [String] a temp folder for the session's cwd
136
+ def work_dir = File.join(@root, "work")
137
+
138
+ # @return [String] the folder first on PATH (#fake_executable's)
139
+ def bin_dir = File.join(@root, "bin")
140
+
141
+ # A command +name+ first on PATH that runs +body+ (sh) with its
142
+ # arguments; again for the same name replaces the body.
143
+ # @return [String] its path
144
+ def fake_executable(name, body) = FakeExecutable.write(bin_dir, name, body)
145
+
146
+ private
147
+
148
+ # Run the block with +dir+ as the process's cwd (the memory tools
149
+ # read the project from it); nil: as it is.
150
+ def in_dir(dir, &)
151
+ return yield if dir.nil? || File.expand_path(dir) == Dir.pwd
152
+
153
+ Dir.chdir(dir, &)
154
+ end
155
+
156
+ def stringify(value)
157
+ case value
158
+ when Hash then value.to_h { |key, item| [key.to_s, stringify(item)] }
159
+ when Array then value.map { |item| stringify(item) }
160
+ else value
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../plugin/context"
4
+
5
+ module Samagotchi
6
+ module Testing
7
+ # One ctx.sessions call: the method's name and its arguments.
8
+ SessionCall = Data.define(:name, :args, :options)
9
+
10
+ # ctx.sessions recorded (Plugin::Sessions' methods, same signatures:
11
+ # the contract spec checks): each call lands in #calls and returns a
12
+ # scripted answer (#on), or by default: fork a new id ("fork-1"…), send
13
+ # and stop the id they got, children [], read raises
14
+ # Plugin::Sessions::Error (no such session).
15
+ #
16
+ # bundle.sessions.on(:children) { |all:| [{ id: "c1", status: "running" }] }
17
+ # bundle.sessions.on(:send) { |id, text, delivery:| raise Samagotchi::Plugin::Sessions::Error, "busy" }
18
+ class Sessions
19
+ METHODS = %i[fork send read children stop].freeze
20
+
21
+ def initialize
22
+ @calls = []
23
+ @scripts = {}
24
+ @forks = 0
25
+ @mutex = Mutex.new
26
+ end
27
+
28
+ # @return [Array<SessionCall>]
29
+ def calls = @mutex.synchronize { @calls.dup }
30
+
31
+ # The calls of one method.
32
+ def calls_to(method) = calls.select { |call| call.name == method }
33
+
34
+ # Answer +method+'s calls with the block (it gets the call's
35
+ # arguments; a raise is the call's).
36
+ def on(method, &block)
37
+ raise ArgumentError, "ctx.sessions has no #{method} (#{METHODS.join(", ")})" unless METHODS.include?(method)
38
+ raise ArgumentError, "on(#{method}) needs a block" unless block
39
+
40
+ @mutex.synchronize { @scripts[method] = block }
41
+ self
42
+ end
43
+
44
+ def fork(messages:, title: nil, prompt: nil)
45
+ answer(:fork, [], { messages: messages, title: title, prompt: prompt }) do
46
+ "fork-#{@mutex.synchronize { @forks += 1 }}"
47
+ end
48
+ end
49
+
50
+ def send(id, text, delivery: nil)
51
+ answer(:send, [id, text], { delivery: delivery }) { id }
52
+ end
53
+
54
+ def read(id)
55
+ answer(:read, [id], {}) { raise Plugin::Sessions::Error, "no session #{id}" }
56
+ end
57
+
58
+ def children(all: false)
59
+ answer(:children, [], { all: all }) { [] }
60
+ end
61
+
62
+ def stop(id)
63
+ answer(:stop, [id], {}) { id }
64
+ end
65
+
66
+ private
67
+
68
+ def answer(method, args, options)
69
+ script = @mutex.synchronize do
70
+ @calls << SessionCall.new(name: method, args: args.freeze, options: options.freeze)
71
+ @scripts[method]
72
+ end
73
+ script ? script.call(*args, **options) : yield
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../guardrails/verdict"
4
+
5
+ module Samagotchi
6
+ module Testing
7
+ # One deny! or ask! a voter cast, kept or not.
8
+ Vote = Data.define(:decision, :reason, :options)
9
+
10
+ # A real Guardrails::Verdict that also records every deny! and ask!
11
+ # (#votes), for tests that count votes or check their keywords
12
+ # (advice:, rule:), even the ones a stricter earlier vote ignored.
13
+ class Verdict < Guardrails::Verdict
14
+ # @return [Array<Vote>]
15
+ def votes = (@votes ||= [])
16
+
17
+ def deny!(reason, **options)
18
+ votes << Vote.new(decision: :deny, reason: reason, options: options)
19
+ super
20
+ end
21
+
22
+ def ask!(reason, **options)
23
+ votes << Vote.new(decision: :ask, reason: reason, options: options)
24
+ super
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Testing
5
+ # Waiting with a deadline, for a test that waits on a thread or a
6
+ # process (Helpers has them).
7
+ module Waiting
8
+ # The block's value once truthy, polled every +interval+ s; after
9
+ # +timeout+ s its last (falsy) value.
10
+ def wait_until(timeout: 5, interval: 0.01)
11
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
12
+ until (value = yield)
13
+ return yield if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
14
+
15
+ sleep(interval)
16
+ end
17
+ value
18
+ end
19
+
20
+ # #wait_until, raising "timed out after N s waiting for <what>" when
21
+ # the time runs out: for a wait no check follows.
22
+ def wait_until!(what, timeout: 5, interval: 0.01, &)
23
+ wait_until(timeout: timeout, interval: interval, &) || raise("timed out after #{timeout} s waiting for #{what}")
24
+ end
25
+
26
+ # Whether no process +pid+ is left (a killed grandchild is reaped a
27
+ # moment later: wait_until { process_gone?(pid) }).
28
+ def process_gone?(pid)
29
+ Process.kill(0, pid)
30
+ false
31
+ rescue Errno::ESRCH
32
+ true
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The bundle test kit (docs/plugins.md, "Testing a bundle"): load a bundle's
4
+ # plugin and hook files through chi's own loaders, registries, Context and
5
+ # guardrail Gate, with only the edges recorded (the session's Host, the hook
6
+ # runtime, ctx.sessions, init tasks), in a sandboxed environment.
7
+ #
8
+ # EXPERIMENTAL: it may change in any release before 1.0.0; it is not part of
9
+ # "Stable for bundles".
10
+ #
11
+ # require "samagotchi/testing" # plain Ruby
12
+ # require "samagotchi/testing/rspec" # RSpec helpers
13
+ # require "samagotchi/testing/minitest" # Samagotchi::Testing::Case
14
+ require_relative "version"
15
+ require_relative "testing/sandbox"
16
+ require_relative "testing/recorder"
17
+ require_relative "testing/host"
18
+ require_relative "testing/sessions"
19
+ require_relative "testing/fake_http"
20
+ require_relative "testing/verdict"
21
+ require_relative "testing/recording_api"
22
+ require_relative "testing/waiting"
23
+ require_relative "testing/fake_executable"
24
+ require_relative "testing/bundle"
25
+ require_relative "testing/helpers"
26
+ require_relative "testing/boundary"
@@ -5,6 +5,7 @@ require_relative "../bridge_client"
5
5
  require_relative "../relay"
6
6
  require_relative "../guardrails/approval"
7
7
  require_relative "../log"
8
+ require_relative "../question_answer"
8
9
 
9
10
  module Samagotchi
10
11
  # Loaded on first use (a require cycle otherwise; see delegate.rb).
@@ -84,10 +85,10 @@ module Samagotchi
84
85
  return closed(answer[:reason].to_s, what, client: client, relay_id: relay_id, qid: qid)
85
86
  end
86
87
 
87
- dismissed = !(answer.is_a?(Hash) && answer[:selected])
88
- by = answer.is_a?(Hash) && answer[:by] == "parent_agent" ? "parent_agent" : "user"
89
- desk.record(relay_id, selected_indices: dismissed ? [] : Array(answer[:selected_indices]),
90
- freeform: dismissed ? nil : answer[:freeform], dismissed: dismissed, by: by)
88
+ dismissed = !answer.is_a?(QuestionAnswer)
89
+ by = answer.is_a?(QuestionAnswer) && answer.parent_agent? ? "parent_agent" : "user"
90
+ desk.record(relay_id, selected_indices: dismissed ? [] : answer.selected_indices,
91
+ freeform: dismissed ? nil : answer.freeform, dismissed: dismissed, by: by)
91
92
  response = post(client, "answered", relay_id, qid)
92
93
  Outcome.new(line: line(what, delivered(response, answer, fields, dismissed)), stopped: false)
93
94
  end
@@ -118,7 +119,7 @@ module Samagotchi
118
119
  # allowed once / allowed for its session / denied ("why")
119
120
  def verdict(answer, fields)
120
121
  scopes = Array(fields.dig(:approval, :scopes))
121
- index = Array(answer[:selected_indices]).first
122
+ index = answer.selected_indices.first
122
123
  if index && index < scopes.size
123
124
  return case scopes[index].to_s
124
125
  when "once" then "allowed once"
@@ -129,7 +130,7 @@ module Samagotchi
129
130
  end
130
131
  end
131
132
 
132
- why = answer[:freeform].to_s.strip
133
+ why = answer.freeform.to_s.strip
133
134
  why.empty? ? "denied" : "denied (#{why.inspect})"
134
135
  end
135
136
 
@@ -194,6 +194,9 @@ module Samagotchi
194
194
  nil
195
195
  end
196
196
 
197
+ # @return [Array<Source>] the sources added, in order
198
+ def sources = @sources.dup
199
+
197
200
  # Every group, now: the index's own (#registry_groups), then the
198
201
  # sources'. A source group with a reserved name, or one an earlier
199
202
  # group has, is left out of search, lookup and the description (said
@@ -22,6 +22,8 @@ module Samagotchi
22
22
  @running = false
23
23
  @controller = nil
24
24
  @sink = nil
25
+ @thread = nil
26
+ @ended = false
25
27
  @steers = []
26
28
  @carried_steers = []
27
29
  end
@@ -64,12 +66,15 @@ module Samagotchi
64
66
  @lock.synchronize { @running }
65
67
  end
66
68
 
67
- # A turn begins: running, with its controller and sink, in one step.
69
+ # A turn begins, on the calling thread (the turn's own): running, with
70
+ # its controller and sink, in one step.
68
71
  def begin!(controller:, sink:)
69
72
  @lock.synchronize do
70
73
  @running = true
71
74
  @controller = controller
72
75
  @sink = sink
76
+ @thread = Thread.current
77
+ @ended = false
73
78
  @steers.concat(@carried_steers)
74
79
  @carried_steers = []
75
80
  end
@@ -82,15 +87,37 @@ module Samagotchi
82
87
  @running = false
83
88
  @controller = nil
84
89
  @sink = nil
90
+ @thread = nil
91
+ @ended = false
85
92
  @steers.tap { @steers = [] }
86
93
  end
87
94
  end
88
95
 
96
+ # The turn's end is decided (its end event is out): what runs after,
97
+ # its after_turn / session_end hooks, is post-turn work, up to #finish!.
98
+ def ended!
99
+ @lock.synchronize { @ended = true }
100
+ end
101
+
89
102
  # @return [CancellationController, nil] the running turn's controller
90
103
  def controller
91
104
  @lock.synchronize { @controller }
92
105
  end
93
106
 
107
+ # The controller for work done for the turn itself (a plugin's HTTP
108
+ # call): the running turn's, on the turn's thread or +handler+ (inside a
109
+ # plugin's event handler, whatever thread fires it), until the turn has
110
+ # ended; nil for any other thread (an anytime command, a service) and
111
+ # outside a turn.
112
+ # @return [CancellationController, nil]
113
+ def own_controller(handler: false)
114
+ @lock.synchronize do
115
+ return nil unless @running && !@ended
116
+
117
+ handler || @thread.equal?(Thread.current) ? @controller : nil
118
+ end
119
+ end
120
+
94
121
  # Cancel the running turn, if any (outside the lock).
95
122
  # @return [Boolean] whether a cancellation was triggered
96
123
  def cancel!(reason)