samagotchi 0.78.0 → 0.80.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -1
  3. data/README.md +7 -5
  4. data/docs/cli.md +24 -7
  5. data/docs/configuration.md +11 -6
  6. data/docs/internals/anthropic.md +12 -5
  7. data/docs/internals/bundles.md +4 -0
  8. data/docs/internals/engine.md +15 -5
  9. data/docs/plugins.md +144 -3
  10. data/docs/releasing.md +2 -2
  11. data/docs/sessions.md +1 -1
  12. data/docs/testing.md +32 -2
  13. data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
  14. data/lib/samagotchi/bootstrap/probe.rb +153 -31
  15. data/lib/samagotchi/bootstrap_command.rb +92 -15
  16. data/lib/samagotchi/bridge.rb +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  19. data/lib/samagotchi/continue_offer.rb +5 -5
  20. data/lib/samagotchi/engine.rb +40 -37
  21. data/lib/samagotchi/guardrails/approval.rb +6 -4
  22. data/lib/samagotchi/guardrails/gate.rb +7 -3
  23. data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
  24. data/lib/samagotchi/hooks/registry.rb +44 -3
  25. data/lib/samagotchi/host_registry.rb +12 -4
  26. data/lib/samagotchi/llm/adapter_common.rb +8 -4
  27. data/lib/samagotchi/llm/anthropic_binding_recovery.rb +198 -0
  28. data/lib/samagotchi/llm/anthropic_messages.rb +39 -178
  29. data/lib/samagotchi/llm/anthropic_wire.rb +7 -1
  30. data/lib/samagotchi/llm/chat_loop.rb +21 -12
  31. data/lib/samagotchi/llm/http.rb +12 -7
  32. data/lib/samagotchi/llm/model_listing.rb +29 -1
  33. data/lib/samagotchi/llm/refusal_memo.rb +3 -2
  34. data/lib/samagotchi/plugin/card.rb +44 -0
  35. data/lib/samagotchi/plugin/context.rb +53 -8
  36. data/lib/samagotchi/plugin/http.rb +154 -0
  37. data/lib/samagotchi/plugin/http_transport.rb +77 -0
  38. data/lib/samagotchi/plugin/loader.rb +29 -10
  39. data/lib/samagotchi/question_answer.rb +29 -0
  40. data/lib/samagotchi/question_desk.rb +30 -22
  41. data/lib/samagotchi/reasoning_history.rb +2 -1
  42. data/lib/samagotchi/relay_verifier.rb +1 -1
  43. data/lib/samagotchi/testing/boundary.rb +156 -0
  44. data/lib/samagotchi/testing/bundle.rb +349 -0
  45. data/lib/samagotchi/testing/fake_executable.rb +42 -0
  46. data/lib/samagotchi/testing/fake_http.rb +137 -0
  47. data/lib/samagotchi/testing/helpers.rb +56 -0
  48. data/lib/samagotchi/testing/host.rb +76 -0
  49. data/lib/samagotchi/testing/minitest.rb +31 -0
  50. data/lib/samagotchi/testing/recorder.rb +138 -0
  51. data/lib/samagotchi/testing/recording_api.rb +27 -0
  52. data/lib/samagotchi/testing/rspec.rb +31 -0
  53. data/lib/samagotchi/testing/sandbox.rb +163 -0
  54. data/lib/samagotchi/testing/sessions.rb +77 -0
  55. data/lib/samagotchi/testing/verdict.rb +28 -0
  56. data/lib/samagotchi/testing/waiting.rb +36 -0
  57. data/lib/samagotchi/testing.rb +26 -0
  58. data/lib/samagotchi/tools/delegate_relay.rb +7 -6
  59. data/lib/samagotchi/tools/tool_index.rb +3 -0
  60. data/lib/samagotchi/turn_state.rb +28 -1
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/web/public/app.js +79 -245
  63. data/lib/samagotchi/web/public/composer_grip.js +231 -0
  64. data/lib/samagotchi/web/public/images.js +33 -0
  65. data/lib/samagotchi/web/public/index.html +73 -40
  66. data/lib/samagotchi/web/public/level_chip.js +11 -11
  67. data/lib/samagotchi/web/public/lists_view.js +19 -14
  68. data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
  69. data/lib/samagotchi/web/public/model_picker.js +5 -6
  70. data/lib/samagotchi/web/public/scope.js +3 -3
  71. data/lib/samagotchi/web/public/stage_view.js +3 -2
  72. data/lib/samagotchi/web/public/storage.js +23 -0
  73. data/lib/samagotchi/web/public/strip.js +35 -9
  74. data/lib/samagotchi/web/public/strip_grip.js +76 -0
  75. data/lib/samagotchi/web/public/thinking_chip.js +5 -5
  76. data/lib/samagotchi/web/public/toasts.js +5 -4
  77. 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,138 @@
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).
20
+ ModelRequest = Data.define(:request, :timeout, :max_tokens, :cancel, :model)
21
+
22
+ # What a bundle did at chi's edges, for a test to read: the Engine side
23
+ # of its Context (Testing::Host) and the hook runtime (Testing::Runtime)
24
+ # land here, file hooks and plugin hooks in the same lists. It also
25
+ # holds what those edges answer: queued ask_user / ask_model answers and
26
+ # the session's values (#values), read at each call.
27
+ class Recorder
28
+ # The session values a Host reads when the test sets none.
29
+ DEFAULTS = {
30
+ session_id: "test-session", cwd: nil, messages: [], messages_partial: false, model_name: "test:model",
31
+ model_key: "model", toolkit: "full", declared_tools: nil, frontend: :worker, scratch: false, setup: nil,
32
+ prompt_files: [], cancelled: false, state_dir: nil, running: true, toolkit_staged: true
33
+ }.freeze
34
+
35
+ attr_reader :notices, :cards, :steers, :stops, :questions, :model_requests, :toolkit_uses, :answers,
36
+ :model_answers, :values
37
+
38
+ def initialize(**values)
39
+ unknown = values.keys - DEFAULTS.keys
40
+ raise ArgumentError, "unknown host values: #{unknown.join(", ")}" unless unknown.empty?
41
+
42
+ @values = DEFAULTS.merge(values)
43
+ @notices = []
44
+ @cards = []
45
+ @steers = []
46
+ @stops = []
47
+ @questions = []
48
+ @model_requests = []
49
+ @toolkit_uses = []
50
+ @answers = []
51
+ @model_answers = []
52
+ @mutex = Mutex.new
53
+ end
54
+
55
+ # Change session values (cwd:, messages:, running:, cancelled: …).
56
+ def set(**values)
57
+ unknown = values.keys - DEFAULTS.keys
58
+ raise ArgumentError, "unknown host values: #{unknown.join(", ")}" unless unknown.empty?
59
+
60
+ @mutex.synchronize { @values.merge!(values) }
61
+ self
62
+ end
63
+
64
+ def [](key) = @mutex.synchronize { @values.fetch(key) }
65
+
66
+ # Only stop_turn's, or only stop_generation's.
67
+ def turn_stops = stops.select { |stop| stop.kind == :turn }
68
+ def generation_stops = stops.select { |stop| stop.kind == :generation }
69
+
70
+ def notify(text, level, hook, fallback_for)
71
+ record(@notices, Notice.new(text: text.to_s, level: level, hook: hook, fallback_for: fallback_for))
72
+ nil
73
+ end
74
+
75
+ # Checked as chi's cards are (Plugin::Card.build); the id it shows.
76
+ def card(**fields)
77
+ card = Plugin::Card.build(**fields).freeze
78
+ record(@cards, card)
79
+ card[:id]
80
+ end
81
+
82
+ def steer(text, hook)
83
+ return false unless self[:running]
84
+
85
+ record(@steers, Steer.new(text: text.to_s, hook: hook))
86
+ true
87
+ end
88
+
89
+ def stop(kind, reason, hook)
90
+ return false unless self[:running]
91
+
92
+ record(@stops, Stop.new(kind: kind, reason: reason.to_s, hook: hook))
93
+ true
94
+ end
95
+
96
+ # The next queued answer (#answers), nil with none (no one to ask).
97
+ def ask_user(question:, options:, header:, allow_freeform:, hook:)
98
+ record(@questions, Question.new(question: question, options: options, header: header,
99
+ allow_freeform: allow_freeform, hook: hook))
100
+ answer = @mutex.synchronize { @answers.shift }
101
+ answer.respond_to?(:call) ? answer.call(@questions.last) : answer
102
+ end
103
+
104
+ # The next queued model answer (#model_answers): a String, an
105
+ # exception to raise, or a callable given the ModelRequest. None left:
106
+ # Plugin::ModelError, as an unreachable model.
107
+ def ask_model(request, timeout:, max_tokens:, cancel_controller:, model: nil)
108
+ entry = ModelRequest.new(request: request, timeout: timeout, max_tokens: max_tokens, cancel: cancel_controller,
109
+ model: model)
110
+ record(@model_requests, entry)
111
+ answer = @mutex.synchronize { @model_answers.empty? ? :none : @model_answers.shift }
112
+ raise Plugin::ModelError, "no model answer queued (bundle.model_answers << \"text\")" if answer == :none
113
+ raise answer if answer.is_a?(Exception) || (answer.is_a?(Class) && answer <= Exception)
114
+
115
+ answer.respond_to?(:call) ? answer.call(entry) : answer
116
+ end
117
+
118
+ # ctx.use_toolkit: +known+ the toolkits chi and the plugins declare.
119
+ # Staged (toolkit_staged:, true by default), the session is on it at
120
+ # once, as chi saves it on the session (ToolkitBinding#use).
121
+ def use_toolkit(name, known)
122
+ raise ArgumentError, "no toolkit named #{name}" unless known.include?(name)
123
+
124
+ record(@toolkit_uses, name)
125
+ return false unless self[:toolkit_staged]
126
+
127
+ set(toolkit: name)
128
+ true
129
+ end
130
+
131
+ private
132
+
133
+ def record(list, item)
134
+ @mutex.synchronize { list << item }
135
+ end
136
+ end
137
+ end
138
+ 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,163 @@
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 and state homes
13
+ # (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 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
+ "CLAUDE_CONFIG_DIR" => File.join(@root, "home", ".claude"),
69
+ "CODEX_HOME" => File.join(@root, "home", ".codex"),
70
+ "GIT_CONFIG_GLOBAL" => File.join(@root, "gitconfig"),
71
+ "GIT_CONFIG_NOSYSTEM" => "1",
72
+ "SAMAGOTCHI_KEYCHAIN" => "off",
73
+ "SAMAGOTCHI_ENV" => "test",
74
+ "SAMAGOTCHI_RECAP_ENABLED" => "false",
75
+ "SAMAGOTCHI_CACHE_WARMUP" => "off",
76
+ "PATH" => [bin_dir, ENV.fetch("PATH", nil)].compact.join(File::PATH_SEPARATOR)
77
+ )
78
+ # The log resolves its file from the (now temp) state home.
79
+ Log.reset!
80
+ Sandbox.current = self
81
+ self
82
+ end
83
+
84
+ # Put ENV back as it was and remove the temp folders.
85
+ def leave
86
+ return unless @root
87
+
88
+ ENV.replace(@saved)
89
+ Log.reset!
90
+ FileUtils.rm_rf(@root)
91
+ Sandbox.current = @outer
92
+ @root = @saved = @outer = nil
93
+ end
94
+
95
+ # Write config.yml (what Config.get reads): a Hash or YAML text.
96
+ # @return [String] its path
97
+ def config(data)
98
+ path = File.join(Paths.config_home, Paths::APP_DIR, "config.yml")
99
+ FileUtils.mkdir_p(File.dirname(path))
100
+ File.write(path, data.is_a?(String) ? data : YAML.dump(stringify(data)))
101
+ path
102
+ end
103
+
104
+ # @return [String] $XDG_STATE_HOME/samagotchi (ctx.data_dir's parent)
105
+ def state_dir = Paths.state_dir
106
+
107
+ # A memory scope's folder ("system", or "project" for the checkout
108
+ # at +cwd+), as chi's memory tools and bundles find it; created.
109
+ # @return [String]
110
+ def memory_dir(scope = "system", cwd: Dir.pwd)
111
+ require_relative "../memory_paths"
112
+ MemoryPaths.scope_dir!(scope, cwd: cwd).tap { |dir| FileUtils.mkdir_p(dir) }
113
+ end
114
+
115
+ # Write a memory as memory_write does (the file and its index line,
116
+ # with +description+), for the checkout at +cwd+ (the project scope's;
117
+ # default the process's own); +source+ marks the line as a bundle's
118
+ # ("· from <source>"), as an install writes it.
119
+ # @return [String] the memory's path
120
+ # @raise [ArgumentError] what memory_write refused
121
+ def write_memory(name, content, scope: "system", description: nil, source: nil, cwd: nil)
122
+ require_relative "../tools/memory"
123
+ require_relative "../memory_bundle/index_updater"
124
+ in_dir(cwd) do
125
+ result = Tools::MemoryWrite.call(content, path: name, scope: scope, description: description)
126
+ raise ArgumentError, result if result.start_with?("Error")
127
+
128
+ MemoryBundle::IndexUpdater.update_index(scope, name, content.bytesize, description, source: source) if source
129
+ end
130
+ File.join(memory_dir(scope, cwd: cwd || Dir.pwd), "#{name}.md")
131
+ end
132
+
133
+ # @return [String] a temp folder for the session's cwd
134
+ def work_dir = File.join(@root, "work")
135
+
136
+ # @return [String] the folder first on PATH (#fake_executable's)
137
+ def bin_dir = File.join(@root, "bin")
138
+
139
+ # A command +name+ first on PATH that runs +body+ (sh) with its
140
+ # arguments; again for the same name replaces the body.
141
+ # @return [String] its path
142
+ def fake_executable(name, body) = FakeExecutable.write(bin_dir, name, body)
143
+
144
+ private
145
+
146
+ # Run the block with +dir+ as the process's cwd (the memory tools
147
+ # read the project from it); nil: as it is.
148
+ def in_dir(dir, &)
149
+ return yield if dir.nil? || File.expand_path(dir) == Dir.pwd
150
+
151
+ Dir.chdir(dir, &)
152
+ end
153
+
154
+ def stringify(value)
155
+ case value
156
+ when Hash then value.to_h { |key, item| [key.to_s, stringify(item)] }
157
+ when Array then value.map { |item| stringify(item) }
158
+ else value
159
+ end
160
+ end
161
+ end
162
+ end
163
+ 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)