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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -1
- data/README.md +7 -5
- data/docs/cli.md +24 -7
- data/docs/configuration.md +11 -6
- data/docs/internals/anthropic.md +12 -5
- data/docs/internals/bundles.md +4 -0
- data/docs/internals/engine.md +15 -5
- data/docs/plugins.md +144 -3
- data/docs/releasing.md +2 -2
- data/docs/sessions.md +1 -1
- data/docs/testing.md +32 -2
- data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
- data/lib/samagotchi/bootstrap/probe.rb +153 -31
- data/lib/samagotchi/bootstrap_command.rb +92 -15
- data/lib/samagotchi/bridge.rb +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/continue_offer.rb +5 -5
- data/lib/samagotchi/engine.rb +40 -37
- data/lib/samagotchi/guardrails/approval.rb +6 -4
- data/lib/samagotchi/guardrails/gate.rb +7 -3
- data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
- data/lib/samagotchi/hooks/registry.rb +44 -3
- data/lib/samagotchi/host_registry.rb +12 -4
- data/lib/samagotchi/llm/adapter_common.rb +8 -4
- data/lib/samagotchi/llm/anthropic_binding_recovery.rb +198 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +39 -178
- data/lib/samagotchi/llm/anthropic_wire.rb +7 -1
- data/lib/samagotchi/llm/chat_loop.rb +21 -12
- data/lib/samagotchi/llm/http.rb +12 -7
- data/lib/samagotchi/llm/model_listing.rb +29 -1
- data/lib/samagotchi/llm/refusal_memo.rb +3 -2
- data/lib/samagotchi/plugin/card.rb +44 -0
- data/lib/samagotchi/plugin/context.rb +53 -8
- data/lib/samagotchi/plugin/http.rb +154 -0
- data/lib/samagotchi/plugin/http_transport.rb +77 -0
- data/lib/samagotchi/plugin/loader.rb +29 -10
- data/lib/samagotchi/question_answer.rb +29 -0
- data/lib/samagotchi/question_desk.rb +30 -22
- data/lib/samagotchi/reasoning_history.rb +2 -1
- data/lib/samagotchi/relay_verifier.rb +1 -1
- data/lib/samagotchi/testing/boundary.rb +156 -0
- data/lib/samagotchi/testing/bundle.rb +349 -0
- data/lib/samagotchi/testing/fake_executable.rb +42 -0
- data/lib/samagotchi/testing/fake_http.rb +137 -0
- data/lib/samagotchi/testing/helpers.rb +56 -0
- data/lib/samagotchi/testing/host.rb +76 -0
- data/lib/samagotchi/testing/minitest.rb +31 -0
- data/lib/samagotchi/testing/recorder.rb +138 -0
- data/lib/samagotchi/testing/recording_api.rb +27 -0
- data/lib/samagotchi/testing/rspec.rb +31 -0
- data/lib/samagotchi/testing/sandbox.rb +163 -0
- data/lib/samagotchi/testing/sessions.rb +77 -0
- data/lib/samagotchi/testing/verdict.rb +28 -0
- data/lib/samagotchi/testing/waiting.rb +36 -0
- data/lib/samagotchi/testing.rb +26 -0
- data/lib/samagotchi/tools/delegate_relay.rb +7 -6
- data/lib/samagotchi/tools/tool_index.rb +3 -0
- data/lib/samagotchi/turn_state.rb +28 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +79 -245
- data/lib/samagotchi/web/public/composer_grip.js +231 -0
- data/lib/samagotchi/web/public/images.js +33 -0
- data/lib/samagotchi/web/public/index.html +73 -40
- data/lib/samagotchi/web/public/level_chip.js +11 -11
- data/lib/samagotchi/web/public/lists_view.js +19 -14
- data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
- data/lib/samagotchi/web/public/model_picker.js +5 -6
- data/lib/samagotchi/web/public/scope.js +3 -3
- data/lib/samagotchi/web/public/stage_view.js +3 -2
- data/lib/samagotchi/web/public/storage.js +23 -0
- data/lib/samagotchi/web/public/strip.js +35 -9
- data/lib/samagotchi/web/public/strip_grip.js +76 -0
- data/lib/samagotchi/web/public/thinking_chip.js +5 -5
- data/lib/samagotchi/web/public/toasts.js +5 -4
- 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 = !
|
|
88
|
-
by = answer.is_a?(
|
|
89
|
-
desk.record(relay_id, selected_indices: dismissed ? [] :
|
|
90
|
-
freeform: dismissed ? nil : answer
|
|
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 =
|
|
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
|
|
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
|
|
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)
|