pwn 0.5.749 → 0.5.751
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/Gemfile +2 -2
- data/documentation/Blockchain.md +107 -9
- data/documentation/pwn-ai-Agent.md +310 -4
- data/documentation/pwn_silent_help_learn_demo.gif +0 -0
- data/etc/default_skills/pwn/banner/SKILL.md +7 -3
- data/etc/default_skills/pwn/blockchain/btc/SKILL.md +8 -5
- data/etc/default_skills/pwn/blockchain/eth/SKILL.md +12 -3
- data/etc/default_skills/pwn/blockchain/eth/references/urls.md +1 -1
- data/etc/default_skills/pwn/plugins/repl/SKILL.md +5 -0
- data/etc/default_skills/pwn/plugins/repl/ai/SKILL.md +4 -0
- data/etc/default_skills/pwn/plugins/repl/ai/references/urls.md +5 -0
- data/etc/default_skills/pwn/plugins/repl/ai_console/SKILL.md +48 -0
- data/etc/default_skills/pwn/plugins/repl/ai_console_commands/SKILL.md +47 -0
- data/etc/default_skills/pwn/plugins/repl/ai_console_usage/SKILL.md +48 -0
- data/etc/default_skills/pwn/plugins/repl/ai_swarm/SKILL.md +46 -0
- data/lib/pwn/ai/agent/loop.rb +287 -18
- data/lib/pwn/ai/agent/swarm.rb +28 -4
- data/lib/pwn/ai/open_ai.rb +4 -2
- data/lib/pwn/banner.rb +743 -2
- data/lib/pwn/blockchain/btc.rb +494 -196
- data/lib/pwn/blockchain/eth.rb +440 -81
- data/lib/pwn/blockchain.rb +6 -0
- data/lib/pwn/config.rb +22 -0
- data/lib/pwn/migrate.rb +9 -1
- data/lib/pwn/plugins/repl/ai.rb +182 -17
- data/lib/pwn/plugins/repl/ai_console.rb +1581 -0
- data/lib/pwn/plugins/repl/ai_console_commands.rb +282 -0
- data/lib/pwn/plugins/repl/ai_console_usage.rb +144 -0
- data/lib/pwn/plugins/repl/ai_swarm.rb +375 -0
- data/lib/pwn/plugins/repl.rb +25 -3
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/loop_spec.rb +258 -1
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +3 -2
- data/spec/lib/pwn/ai/open_ai_spec.rb +5 -0
- data/spec/lib/pwn/banner_spec.rb +643 -0
- data/spec/lib/pwn/blockchain/btc_spec.rb +372 -1
- data/spec/lib/pwn/blockchain/eth_spec.rb +205 -0
- data/spec/lib/pwn/blockchain_spec.rb +6 -0
- data/spec/lib/pwn/config_spec.rb +10 -0
- data/spec/lib/pwn/migrate_spec.rb +40 -3
- data/spec/lib/pwn/plugins/repl/ai_console_commands_spec.rb +65 -0
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +1434 -0
- data/spec/lib/pwn/plugins/repl/ai_console_usage_spec.rb +29 -0
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +127 -0
- data/spec/lib/pwn/plugins/repl_pwn_vault_spec.rb +5 -3
- data/spec/lib/pwn/plugins/repl_spec.rb +167 -1
- data/third_party/pwn_rdoc.jsonl +229 -10
- metadata +18 -6
- data/etc/default_skills/pwn/blockchain/btc/references/urls.md +0 -3
|
@@ -0,0 +1,1434 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'stringio'
|
|
5
|
+
require 'pry'
|
|
6
|
+
|
|
7
|
+
describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- public launch, terminal lifecycle and deterministic worker integration
|
|
8
|
+
include_context 'pwn tmp sandbox'
|
|
9
|
+
|
|
10
|
+
before do
|
|
11
|
+
@history_path = File.join(@tmp, 'pwn_history')
|
|
12
|
+
@request_history = Pry::History.new(file_path: @history_path)
|
|
13
|
+
allow(Pry).to receive(:history).and_return(@request_history)
|
|
14
|
+
allow(PWN::AI::Agent::PromptBuilder).to receive(:build).and_return('offline console test')
|
|
15
|
+
allow(PWN::AI::Agent::TaskSummarizer).to receive(:enabled?).and_return(false)
|
|
16
|
+
allow(PWN::AI::Agent::Loop).to receive(:should_auto_introspect?).and_return(false)
|
|
17
|
+
allow(PWN::AI::Agent::Loop).to receive(:may_finalize?).and_return(true)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def launch(keys, &tick)
|
|
21
|
+
require 'pty'
|
|
22
|
+
require 'timeout'
|
|
23
|
+
master, input = PTY.open
|
|
24
|
+
@paint = []
|
|
25
|
+
owner = Thread.current
|
|
26
|
+
window = double('screen')
|
|
27
|
+
allow(window).to receive_messages(erase: nil, refresh: nil, setpos: nil)
|
|
28
|
+
frame = []
|
|
29
|
+
position = [0, 0]
|
|
30
|
+
allow(window).to receive(:erase) { frame = [] }
|
|
31
|
+
allow(window).to receive(:refresh) { @rendered = frame.dup }
|
|
32
|
+
@positions = []
|
|
33
|
+
allow(window).to receive(:setpos) do |*coords|
|
|
34
|
+
position = coords
|
|
35
|
+
@positions << coords
|
|
36
|
+
end
|
|
37
|
+
allow(window).to receive(:addstr) { |text|
|
|
38
|
+
expect(Thread.current).to eq(owner)
|
|
39
|
+
@paint << text
|
|
40
|
+
frame << [*position, text]
|
|
41
|
+
}
|
|
42
|
+
allow(window).to receive(:attron) { |_attribute, &block| block.call }
|
|
43
|
+
screen = double('curses', init_screen: window, raw: nil, noecho: nil, curs_set: nil, close_screen: nil,
|
|
44
|
+
lines: 30, cols: 110, stdscr: window, has_colors?: false, resizeterm: nil)
|
|
45
|
+
@pry = Pry.new
|
|
46
|
+
@pry.config.pwn_ai_session_id = PWN::Sessions.create(title: 'console')[:id]
|
|
47
|
+
@pry.config.pwn_ai = true
|
|
48
|
+
@screen = screen
|
|
49
|
+
output = double('terminal', tty?: true)
|
|
50
|
+
previous = [$stdin, $stdout, $stderr]
|
|
51
|
+
Timeout.timeout(25, Class.new(Exception)) do # rubocop:disable Lint/InheritException
|
|
52
|
+
PWN::Plugins::REPL::AIConsole.run(pry: @pry, input: input, output: output, curses: screen,
|
|
53
|
+
getch: -> { keys.empty? ? tick.call : keys.shift })
|
|
54
|
+
end
|
|
55
|
+
expect([$stdin, $stdout]).to eq(previous.take(2))
|
|
56
|
+
expect(PWN::Plugins::Log.raw_stderr).to equal(previous[2])
|
|
57
|
+
expect(input).to be_tty
|
|
58
|
+
expect(screen).to have_received(:close_screen)
|
|
59
|
+
ensure
|
|
60
|
+
master&.close
|
|
61
|
+
input&.close
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it 'edits system role in an isolated multiline modal and preserves mission cursor on save and cancel' do
|
|
65
|
+
PWN::Env[:ai] = { active: 'openai', openai: { system_role_content: "first\nsecond" } }
|
|
66
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: Curses, getch: nil)
|
|
67
|
+
editor = console.instance_variable_get(:@editor)
|
|
68
|
+
editor.place('mission draft', 4)
|
|
69
|
+
console.submit('/system-role')
|
|
70
|
+
expect(console.instance_variable_get(:@role_editor).text).to eq("first\nsecond")
|
|
71
|
+
console.handle("\u0015")
|
|
72
|
+
"new\nrole".chars.each { |key| console.handle(key) }
|
|
73
|
+
["\u0012", "\u0007", "\u000f", "\u000c"].each { |key| console.handle(key) }
|
|
74
|
+
expect(editor.text).to eq('mission draft')
|
|
75
|
+
expect(editor.cursor).to eq(4)
|
|
76
|
+
expect(PWN::Plugins::REPL).to receive(:pwn_ai_apply_system_role).with(engine: 'openai', content: "new\nrole").and_return(:saved)
|
|
77
|
+
console.handle("\u0013")
|
|
78
|
+
expect(console.instance_variable_get(:@role_editor)).to be_nil
|
|
79
|
+
console.submit('/system-role')
|
|
80
|
+
console.handle('x')
|
|
81
|
+
console.handle("\e")
|
|
82
|
+
expect(editor.text).to eq('mission draft')
|
|
83
|
+
expect(editor.cursor).to eq(4)
|
|
84
|
+
expect(PWN::Env.dig(:ai, :openai, :system_role_content)).to eq("first\nsecond")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it 'saves system role from curses to an encrypted vault and the next provider payload' do
|
|
88
|
+
previous_driver_opts = PWN::Env[:driver_opts]
|
|
89
|
+
path = File.join(@tmp, 'role.yaml')
|
|
90
|
+
key = Base64.strict_encode64('k' * 32)
|
|
91
|
+
iv = Base64.strict_encode64('i' * 16)
|
|
92
|
+
PWN::Env[:ai] = { active: 'openai', openai: { model: 'gpt-4o', system_role_content: 'original role' } }
|
|
93
|
+
File.write(path, YAML.dump(ai: PWN::Env[:ai]))
|
|
94
|
+
File.write("#{path}.decryptor", YAML.dump(key: key, iv: iv))
|
|
95
|
+
PWN::Plugins::Vault.encrypt(file: path, key: key, iv: iv)
|
|
96
|
+
ciphertext = File.binread(path)
|
|
97
|
+
PWN::Env[:driver_opts] = { pwn_env_path: path, pwn_dec_path: "#{path}.decryptor" }
|
|
98
|
+
allow(PWN::AI::Agent::PromptBuilder).to receive(:build).and_wrap_original do |method, opts|
|
|
99
|
+
method.call(opts.merge(thin: true))
|
|
100
|
+
end
|
|
101
|
+
expect(PWN::AI::OpenAI).to receive(:open_ai_rest_call) do |opts|
|
|
102
|
+
expect(opts[:http_body][:messages].find { |message| message[:role] == 'system' }[:content]).to start_with("Saved role\nsecond line")
|
|
103
|
+
{ choices: [{ message: { role: 'assistant', content: 'Role forwarded.', tool_calls: [] } }] }.to_json
|
|
104
|
+
end
|
|
105
|
+
keys = "/system-role\n".chars
|
|
106
|
+
phase = :cancel
|
|
107
|
+
launch(keys) do
|
|
108
|
+
case phase
|
|
109
|
+
when :cancel
|
|
110
|
+
expect(@paint.join).to include('SYSTEM ROLE CONTENT', 'original role', 'Ctrl+S Save', 'Esc Cancel')
|
|
111
|
+
keys.concat("\u0015discarded\e".chars)
|
|
112
|
+
phase = :reopen
|
|
113
|
+
nil
|
|
114
|
+
when :reopen
|
|
115
|
+
expect(File.binread(path)).to eq(ciphertext)
|
|
116
|
+
expect(PWN::Env.dig(:ai, :openai, :system_role_content)).to eq('original role')
|
|
117
|
+
keys.concat("\n\u0015Saved role\nsecond line\u0013".chars)
|
|
118
|
+
phase = :request
|
|
119
|
+
nil
|
|
120
|
+
when :request
|
|
121
|
+
expect(rendered_header).to include('Saved role second line')
|
|
122
|
+
keys.concat("\u0015what color is a lemon?\n".chars)
|
|
123
|
+
phase = :done
|
|
124
|
+
nil
|
|
125
|
+
else
|
|
126
|
+
@paint.any? { |row| row.include?('Role forwarded.') } ? "\u0004" : Thread.pass && nil
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
expect(File.binread(path)).not_to include('Saved role')
|
|
130
|
+
PWN::Plugins::Vault.decrypt(file: path, key: key, iv: iv)
|
|
131
|
+
expect(YAML.load_file(path, symbolize_names: true).dig(:ai, :openai, :system_role_content)).to eq("Saved role\nsecond line")
|
|
132
|
+
ensure
|
|
133
|
+
PWN::Env[:driver_opts] = previous_driver_opts
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
it 'keeps failed system role edits open and blocks request and swarm context mutation' do
|
|
137
|
+
PWN::Env[:ai] = { active: 'openai', openai: { system_role_content: 'original' } }
|
|
138
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: Curses, getch: nil)
|
|
139
|
+
console.instance_variable_set(:@worker, double(alive?: true))
|
|
140
|
+
console.submit('/system-role')
|
|
141
|
+
expect(console.instance_variable_get(:@role_editor)).to be_nil
|
|
142
|
+
console.instance_variable_set(:@worker, nil)
|
|
143
|
+
console.instance_variable_set(:@swarm, double(busy?: true))
|
|
144
|
+
console.submit('/system-role')
|
|
145
|
+
expect(console.instance_variable_get(:@role_editor)).to be_nil
|
|
146
|
+
console.instance_variable_set(:@swarm, nil)
|
|
147
|
+
console.submit('/system-role')
|
|
148
|
+
allow(PWN::Plugins::REPL).to receive(:persist_ai_selection).and_return(false)
|
|
149
|
+
console.handle('x')
|
|
150
|
+
console.handle("\u0013")
|
|
151
|
+
expect(console.instance_variable_get(:@role_editor).text).to eq('originalx')
|
|
152
|
+
expect(console.instance_variable_get(:@role_error)).to include('Not saved')
|
|
153
|
+
expect(PWN::Env.dig(:ai, :openai, :system_role_content)).to eq('original')
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it 'moves the role cursor between multiline grapheme columns without history recall' do
|
|
157
|
+
PWN::Env[:ai] = { active: 'openai', openai: { system_role_content: "αβ\nx\nlast" } }
|
|
158
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: Curses, getch: nil)
|
|
159
|
+
console.submit('/system-role')
|
|
160
|
+
role = console.instance_variable_get(:@role_editor)
|
|
161
|
+
console.handle(:up)
|
|
162
|
+
expect(role.cursor).to eq(4)
|
|
163
|
+
console.handle(:up)
|
|
164
|
+
expect(role.cursor).to eq(1)
|
|
165
|
+
console.handle(:down)
|
|
166
|
+
expect(role.cursor).to eq(4)
|
|
167
|
+
console.handle(:left)
|
|
168
|
+
console.handle(:delete)
|
|
169
|
+
console.handle('新')
|
|
170
|
+
expect(role.text).to eq("αβ\n新\nlast")
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it 'runs the real Loop from the public launch, steering a blocked model without a second input reader' do
|
|
174
|
+
loop_module = PWN::AI::Agent::Loop
|
|
175
|
+
entered = Queue.new
|
|
176
|
+
calls = []
|
|
177
|
+
Thread.current[:pwn_console_probe] = :copied
|
|
178
|
+
allow(loop_module).to receive(:call_engine) do |opts|
|
|
179
|
+
expect(Thread.current[:pwn_console_probe]).to eq(:copied)
|
|
180
|
+
expect(Thread.current[:pwn_steering_input].reader).to be_nil
|
|
181
|
+
calls << opts[:messages].map(&:dup)
|
|
182
|
+
if calls.length == 1
|
|
183
|
+
entered << true
|
|
184
|
+
Queue.new.pop
|
|
185
|
+
end
|
|
186
|
+
{ role: 'assistant', content: 'Yellow.', tool_calls: [] }
|
|
187
|
+
end
|
|
188
|
+
keys = "what color is a passion fruit?\n".chars
|
|
189
|
+
steered = false
|
|
190
|
+
launch(keys) do
|
|
191
|
+
if !steered && !entered.empty?
|
|
192
|
+
entered.pop
|
|
193
|
+
steered = true
|
|
194
|
+
keys.concat("/steer what color is a lemon?\n".chars)
|
|
195
|
+
elsif @paint.any? { |row| row.include?('Yellow.') }
|
|
196
|
+
"\u0004"
|
|
197
|
+
else
|
|
198
|
+
Thread.pass
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
expect(calls.length).to eq(2)
|
|
203
|
+
expect(calls.last.select { |row| row[:role] == 'user' }.map { |row| row[:content] }).to include('what color is a lemon?')
|
|
204
|
+
expect(@paint.join).to include('pwn-ai', 'ASSISTANT', 'MISSION', 'steering')
|
|
205
|
+
ensure
|
|
206
|
+
Thread.current[:pwn_console_probe] = nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
it 'opens a draft-preserving swarm workspace and sends only after explicit confirmation' do
|
|
210
|
+
allow(PWN::AI::Agent::Swarm).to receive(:personas).and_return(scout: { role: 'Inspect evidence', engine: 'ollama', model: 'fixture' })
|
|
211
|
+
allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture')
|
|
212
|
+
allow(PWN::AI::Agent::Swarm).to receive(:ask).and_return(reply: 'Workspace result')
|
|
213
|
+
keys = "mission draft\u0007a".chars
|
|
214
|
+
stage = 0
|
|
215
|
+
launch(keys) do
|
|
216
|
+
case stage
|
|
217
|
+
when 0
|
|
218
|
+
expect(PWN::AI::Agent::Swarm).not_to have_received(:ask)
|
|
219
|
+
expect(@paint.join).to include('SWARM WORKSPACE', 'Inspect evidence', 'fixture', 'Confirm')
|
|
220
|
+
stage = 1
|
|
221
|
+
"\r"
|
|
222
|
+
when 1
|
|
223
|
+
if @paint.join.include?('Workspace result')
|
|
224
|
+
stage = 2
|
|
225
|
+
"\e"
|
|
226
|
+
end
|
|
227
|
+
when 2
|
|
228
|
+
expect(@paint.last(100).join).to include('mission draft')
|
|
229
|
+
"\u0004"
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
expect(PWN::AI::Agent::Swarm).to have_received(:ask).with(include(name: 'scout', request: 'mission draft')).once
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
it 'cancels a blocked model on Ctrl+C, returns idle, and does not kill another worker' do
|
|
236
|
+
entered = Queue.new
|
|
237
|
+
foreign = Thread.new { sleep }
|
|
238
|
+
allow(PWN::AI::Agent::Loop).to receive(:call_engine) {
|
|
239
|
+
entered << true
|
|
240
|
+
Queue.new.pop
|
|
241
|
+
}
|
|
242
|
+
keys = "what color is a lemon?\n".chars
|
|
243
|
+
cancelled = false
|
|
244
|
+
launch(keys) do
|
|
245
|
+
if !cancelled && !entered.empty?
|
|
246
|
+
cancelled = true
|
|
247
|
+
"\u0003"
|
|
248
|
+
elsif @paint.any? { |row| row.include?('Request cancelled at a safe boundary') }
|
|
249
|
+
"\u0004"
|
|
250
|
+
else
|
|
251
|
+
Thread.pass
|
|
252
|
+
nil
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
expect(foreign).to be_alive
|
|
256
|
+
ensure
|
|
257
|
+
foreign&.kill
|
|
258
|
+
foreign&.join
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
it 'forwards tool input, preserves completed evidence before steering, and locks settings while running' do
|
|
262
|
+
entered = Queue.new
|
|
263
|
+
calls = 0
|
|
264
|
+
observed = nil
|
|
265
|
+
allow(PWN::AI::Agent::Loop).to receive(:call_engine) do
|
|
266
|
+
calls += 1
|
|
267
|
+
if calls == 1
|
|
268
|
+
{ role: 'assistant', tool_calls: %w[first stale].map { |id| { id: id, type: 'function', function: { name: 'shell', arguments: '{}' } } } }
|
|
269
|
+
else
|
|
270
|
+
{ role: 'assistant', content: 'Yellow.', tool_calls: [] }
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
expect(PWN::AI::Agent::Dispatch).to receive(:call).once do
|
|
274
|
+
entered << true
|
|
275
|
+
observed = $stdin.gets
|
|
276
|
+
JSON.generate(success: true, result: { stdout: observed, exitstatus: 0 })
|
|
277
|
+
end
|
|
278
|
+
expect(PWN::Plugins::REPL).not_to receive(:pwn_ai_run_model)
|
|
279
|
+
keys = "what color is a passion fruit?\n".chars
|
|
280
|
+
sent = false
|
|
281
|
+
launch(keys) do
|
|
282
|
+
if !sent && !entered.empty?
|
|
283
|
+
sent = true
|
|
284
|
+
keys.concat("/model openai wrong\n/steer what color is a lemon?\n/input tool answer\n".chars)
|
|
285
|
+
nil
|
|
286
|
+
elsif @paint.any? { |row| row.include?('Yellow.') }
|
|
287
|
+
"\u0004"
|
|
288
|
+
else
|
|
289
|
+
Thread.pass
|
|
290
|
+
nil
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
expect(observed).to eq("tool answer\n")
|
|
294
|
+
expect(@paint.join).to include('TOOL', 'RESULT', 'tool answer', 'Settings and new requests wait')
|
|
295
|
+
expect(@paint.join).to include('tools 1')
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
it 'closes the tool prompt pipe on busy exit and joins rather than orphaning the active request' do
|
|
299
|
+
entered = Queue.new
|
|
300
|
+
finished = Queue.new
|
|
301
|
+
allow(PWN::AI::Agent::Loop).to receive(:call_engine).and_return(
|
|
302
|
+
{ role: 'assistant', tool_calls: [{ id: 'prompt', type: 'function', function: { name: 'shell', arguments: '{}' } }] }
|
|
303
|
+
)
|
|
304
|
+
allow(PWN::AI::Agent::Dispatch).to receive(:call) do
|
|
305
|
+
entered << true
|
|
306
|
+
answer = $stdin.gets
|
|
307
|
+
finished << answer
|
|
308
|
+
JSON.generate(success: true, result: { stdout: 'prompt completed', exitstatus: 0 })
|
|
309
|
+
end
|
|
310
|
+
keys = "what color is a lemon?\n".chars
|
|
311
|
+
launch(keys) { entered.empty? ? nil : "\u0004" }
|
|
312
|
+
expect(finished.pop).to be_nil
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
it 'wraps menu selection without changing the draft or persistent history, then recalls after Escape' do
|
|
316
|
+
stored = "first request\nsecond request\n"
|
|
317
|
+
File.write(@history_path, stored)
|
|
318
|
+
@request_history.load
|
|
319
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
320
|
+
editor = console.instance_variable_get(:@editor)
|
|
321
|
+
console.seed_request_history
|
|
322
|
+
'/verbose '.each_char { |key| console.handle(key) }
|
|
323
|
+
menu = console.instance_variable_get(:@menu)
|
|
324
|
+
expect(menu.map { |item| item[:label] }).to eq(%w[on off])
|
|
325
|
+
draft = [editor.text.dup, editor.cursor]
|
|
326
|
+
console.handle(:up)
|
|
327
|
+
expect(console.instance_variable_get(:@menu_index)).to eq(1)
|
|
328
|
+
console.handle(:down)
|
|
329
|
+
expect(console.instance_variable_get(:@menu_index)).to eq(0)
|
|
330
|
+
console.handle("\u0010")
|
|
331
|
+
expect(console.instance_variable_get(:@menu_index)).to eq(1)
|
|
332
|
+
console.handle("\u000e")
|
|
333
|
+
expect(console.instance_variable_get(:@menu_index)).to eq(0)
|
|
334
|
+
expect(console.instance_variable_get(:@menu)).to eq(menu)
|
|
335
|
+
expect([editor.text, editor.cursor]).to eq(draft)
|
|
336
|
+
expect(File.read(@history_path)).to eq(stored)
|
|
337
|
+
console.handle("\e")
|
|
338
|
+
expect(console.instance_variable_get(:@menu)).to be_nil
|
|
339
|
+
console.handle(:up)
|
|
340
|
+
expect(editor.text).to eq('second request')
|
|
341
|
+
console.handle(:up)
|
|
342
|
+
expect(editor.text).to eq('first request')
|
|
343
|
+
console.handle(:down)
|
|
344
|
+
expect(editor.text).to eq('second request')
|
|
345
|
+
console.handle(:down)
|
|
346
|
+
expect([editor.text, editor.cursor]).to eq(draft)
|
|
347
|
+
expect(File.read(@history_path)).to eq(stored)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
it 'keeps typing filters live after arrow selection and accepts the selected completion with Tab' do
|
|
351
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
352
|
+
editor = console.instance_variable_get(:@editor)
|
|
353
|
+
'/verbose '.each_char { |key| console.handle(key) }
|
|
354
|
+
console.handle(:down)
|
|
355
|
+
console.handle("\t")
|
|
356
|
+
expect(editor.text.strip).to eq('/verbose off')
|
|
357
|
+
console.handle("\u0015")
|
|
358
|
+
'/verbose '.each_char { |key| console.handle(key) }
|
|
359
|
+
console.handle(:up)
|
|
360
|
+
'on'.each_char { |key| console.handle(key) }
|
|
361
|
+
expect(console.instance_variable_get(:@menu).map { |item| item[:label] }).to eq(['on'])
|
|
362
|
+
expect(console.instance_variable_get(:@menu_index)).to eq(0)
|
|
363
|
+
console.handle("\t")
|
|
364
|
+
expect(editor.text.strip).to eq('/verbose on')
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
it 'loads prior operator requests and keeps session event colors distinct' do
|
|
368
|
+
pry = Pry.new
|
|
369
|
+
pry.config.pwn_ai_session_id = 'color-history'
|
|
370
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: pry, input: StringIO.new, curses: nil, getch: nil)
|
|
371
|
+
@request_history << 'prior mission'
|
|
372
|
+
expect(PWN::Sessions).not_to receive(:load)
|
|
373
|
+
console.send(:seed_request_history)
|
|
374
|
+
console.handle(:up)
|
|
375
|
+
expect(console.instance_variable_get(:@editor).text).to eq('prior mission')
|
|
376
|
+
console.add(:operator, 'look here')
|
|
377
|
+
console.add(:assistant, 'answer')
|
|
378
|
+
console.add(:task, 'plan')
|
|
379
|
+
console.add(:tool, 'shell')
|
|
380
|
+
console.add(:result, 'evidence')
|
|
381
|
+
colors = console.timeline_rows(40).each_with_object({}) do |(color, text), found|
|
|
382
|
+
found[:operator_label] = color if text.start_with?('OPERATOR')
|
|
383
|
+
found[:operator_body] = color if text.include?('look here')
|
|
384
|
+
found[:assistant] = color if text.include?('answer')
|
|
385
|
+
found[:task] = color if text.include?('plan')
|
|
386
|
+
found[:tool] = color if text.include?('shell')
|
|
387
|
+
found[:result] = color if text.include?('evidence')
|
|
388
|
+
end
|
|
389
|
+
expect(colors).to eq(operator_label: 4, operator_body: 5, assistant: 5, task: 2, tool: 1, result: 3)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
it 'uses template colors for every unconfigured role without mutating defaults or overrides' do
|
|
393
|
+
defaults = PWN::Config.env_template.dig(:ai, :tui, :theme)
|
|
394
|
+
[nil, {}, 'invalid'].each do |configured|
|
|
395
|
+
expect(PWN::Plugins::REPL::AIConsole.theme(theme: configured)).to eq(defaults)
|
|
396
|
+
end
|
|
397
|
+
overrides = defaults.transform_values { 'blue' }.freeze
|
|
398
|
+
expect(PWN::Plugins::REPL::AIConsole.theme(theme: overrides)).to eq(overrides)
|
|
399
|
+
expect(overrides.values.uniq).to eq(['blue'])
|
|
400
|
+
PWN::Env[:ai] = {}
|
|
401
|
+
expect(PWN::Plugins::REPL::AIConsole.theme).to eq(defaults)
|
|
402
|
+
expect(PWN::Config.env_template.dig(:ai, :tui, :theme)).to eq(defaults)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
it 'applies ai.tui.theme and ignores unknown color names' do
|
|
406
|
+
previous = PWN::Env[:ai]
|
|
407
|
+
PWN::Env[:ai] = { tui: { theme: { assistant: 'blue', border: 'nope' } } }
|
|
408
|
+
theme = PWN::Plugins::REPL::AIConsole.theme
|
|
409
|
+
expect(theme[:assistant]).to eq('blue')
|
|
410
|
+
expect(theme[:border]).to eq('black')
|
|
411
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
412
|
+
console.add(:assistant, 'themed answer')
|
|
413
|
+
color = console.timeline_rows(40).find { |_pair, text| text.include?('themed answer') }&.first
|
|
414
|
+
expect(color).to eq(6)
|
|
415
|
+
console.instance_variable_set(:@height, 20)
|
|
416
|
+
console.instance_variable_set(:@width, 80)
|
|
417
|
+
painted = []
|
|
418
|
+
allow(console).to receive(:put) { |row, column, text, pair| painted << [row, column, text, pair] }
|
|
419
|
+
console.box(0, 0, 4, 24, 'MISSION')
|
|
420
|
+
expect(painted.first[3]).to eq(console.tone(:border))
|
|
421
|
+
title = painted.find { |row, _column, text, _pair| row.zero? && text.include?('MISSION') }
|
|
422
|
+
expect(title[3]).to eq(console.tone(:title))
|
|
423
|
+
expect(title[3]).not_to eq(painted.first[3])
|
|
424
|
+
ensure
|
|
425
|
+
PWN::Env[:ai] = previous
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
it 'selects one mini-banner per session and advances only from monotonic elapsed time' do
|
|
429
|
+
pry = Pry.new
|
|
430
|
+
pry.config.pwn_ai_session_id = 'banner-one'
|
|
431
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: pry, input: StringIO.new, curses: nil, getch: nil)
|
|
432
|
+
choices = PWN::Banner.mini_names.first(2)
|
|
433
|
+
allow(PWN::Banner).to receive(:mini_names).and_return(choices)
|
|
434
|
+
allow(choices).to receive(:sample).and_return(*choices)
|
|
435
|
+
now = 100.0
|
|
436
|
+
allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC) { now }
|
|
437
|
+
first = console.banner_frame(8)
|
|
438
|
+
expect(first).to eq(PWN::Banner.mini_frame(name: choices.first, frame: 0, width: 8, height: 8))
|
|
439
|
+
now += PWN::Banner::MINI_FRAME_SECONDS / 2
|
|
440
|
+
expect(console.banner_frame(8)).to eq(first)
|
|
441
|
+
now += PWN::Banner::MINI_FRAME_SECONDS * 0.75
|
|
442
|
+
expect(console.banner_frame(8)).to eq(PWN::Banner.mini_frame(name: choices.first, frame: 1, width: 8, height: 8))
|
|
443
|
+
now += PWN::Banner::MINI_FRAME_SECONDS * PWN::Banner::MINI_FRAME_COUNT
|
|
444
|
+
expect(console.banner_frame(12)).to eq(PWN::Banner.mini_frame(name: choices.first, frame: 1, width: 12, height: 12))
|
|
445
|
+
expect(choices).to have_received(:sample).once
|
|
446
|
+
pry.config.pwn_ai_session_id = 'banner-two'
|
|
447
|
+
expect(console.banner_frame(8)).to eq(PWN::Banner.mini_frame(name: choices.last, frame: 0, width: 8, height: 8))
|
|
448
|
+
expect(choices).to have_received(:sample).twice
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
it 'centers capped retro artwork inside a larger cell-square canvas' do
|
|
452
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
453
|
+
allow(PWN::Banner).to receive(:mini_names).and_return([:pong])
|
|
454
|
+
allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC).and_return(100.0)
|
|
455
|
+
size = 24
|
|
456
|
+
canvas = console.banner_frame(size)
|
|
457
|
+
art = PWN::Banner.mini_frame(name: :pong, frame: 0, width: size, height: size)
|
|
458
|
+
expect(canvas.length).to eq(size)
|
|
459
|
+
expect(canvas.map(&:length)).to all(eq(size))
|
|
460
|
+
top = (size - art.length) / 2
|
|
461
|
+
left = (size - art.first.length) / 2
|
|
462
|
+
expect(canvas.slice(top, art.length).map { |row| row[left, art.first.length] }).to eq(art)
|
|
463
|
+
expect(canvas.first(top)).to all(eq(' ' * size))
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
it 'paints falling blocks against the actual pane floor even beyond sixteen cells' do
|
|
467
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
468
|
+
allow(PWN::Banner).to receive(:mini_names).and_return([:falling_blocks])
|
|
469
|
+
now = 100.0
|
|
470
|
+
allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC) { now }
|
|
471
|
+
console.banner_frame(20, cells: true)
|
|
472
|
+
seed = console.instance_variable_get(:@banner_seed)
|
|
473
|
+
frames = PWN::Banner.send(:mini_replay, name: :falling_blocks, width: 20, height: 20, seed: seed)
|
|
474
|
+
tick = frames.index { |frame| frame[:event] == :lock }
|
|
475
|
+
now += (tick + 0.1) * PWN::Banner::MINI_FRAME_SECONDS
|
|
476
|
+
calls = []
|
|
477
|
+
allow(console).to receive(:put) { |*args| calls << args }
|
|
478
|
+
console.draw_banner(20)
|
|
479
|
+
expect(calls.select { |y, _x, text| y == 20 && text.strip != '' }).not_to be_empty
|
|
480
|
+
expect(calls).to include([1, 9, 'P', nil], [1, 10, 'W', nil], [1, 11, 'N', nil])
|
|
481
|
+
expect(calls).to all(satisfy { |y, x, text| y.between?(1, 20) && x >= 1 && x + text.length <= 21 })
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
it 'carves a themed cell-square banner pane only when all settings still fit, keeping bounds and draft' do
|
|
485
|
+
PWN::Env[:ai] = { active: :ollama, tui: { theme: { category: 'magenta', header: 'white', border: 'blue', title: 'red' } },
|
|
486
|
+
ollama: { model: 'fixture', system_role_content: 'Complete functional settings.', reasoning_effort: 'high' } }
|
|
487
|
+
screen = double(erase: nil, refresh: nil, setpos: nil, addstr: nil)
|
|
488
|
+
curses = double(lines: 36, cols: 120)
|
|
489
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: curses, getch: nil)
|
|
490
|
+
console.instance_variable_set(:@screen, screen)
|
|
491
|
+
console.instance_variable_get(:@editor).place('retained draft', 4)
|
|
492
|
+
painted = []
|
|
493
|
+
rectangles = []
|
|
494
|
+
allow(console).to receive(:put) { |*args| painted << args }
|
|
495
|
+
allow(console).to receive(:box).and_wrap_original do |method, *args|
|
|
496
|
+
rectangles << args
|
|
497
|
+
method.call(*args)
|
|
498
|
+
end
|
|
499
|
+
allow(PWN::Banner).to receive(:mini_cells).and_call_original
|
|
500
|
+
console.draw
|
|
501
|
+
expect(rectangles).to include([0, 0, 10, 10, 'PWN'])
|
|
502
|
+
expect(rectangles).to include([0, 10, 10, 109, "pwn-ai v#{PWN::VERSION}"])
|
|
503
|
+
expect(console.instance_variable_get(:@header_text_column)).to eq(12)
|
|
504
|
+
expect(painted).to include([2, 12, 'PROVIDER (ENGINE):', console.tone(:category)])
|
|
505
|
+
expect(painted.any? { |y, x, text, color| y == 0 && x == 0 && text.include?('╭') && color == console.tone(:border) }).to be(true)
|
|
506
|
+
expect(painted.any? { |y, x, text, color| y == 0 && x == 2 && text.include?('PWN') && color == console.tone(:title) }).to be(true)
|
|
507
|
+
expect(painted.select { |y, x, text, _color| y.between?(1, 8) && x == 1 && text == ' ' * 8 }.length).to eq(8)
|
|
508
|
+
expect(painted.select { |y, _x, _text, _color| y < 10 }).to all(satisfy { |y, x, text, _color| y >= 0 && x >= 0 && x + console.width(text) <= 120 })
|
|
509
|
+
expect(PWN::Banner).to have_received(:mini_cells).with(hash_including(width: 8, height: 8)).once
|
|
510
|
+
[[80, 36], [120, 24]].each do |columns, rows|
|
|
511
|
+
allow(curses).to receive_messages(cols: columns, lines: rows)
|
|
512
|
+
rectangles.clear
|
|
513
|
+
console.draw
|
|
514
|
+
expect(rectangles.map(&:last)).not_to include('PWN')
|
|
515
|
+
expect(console.instance_variable_get(:@header_text_column)).to eq(2)
|
|
516
|
+
end
|
|
517
|
+
allow(curses).to receive_messages(cols: 120, lines: 26)
|
|
518
|
+
PWN::Env[:ai][:ollama][:system_role_content] = 'operator ' * 110
|
|
519
|
+
console.draw
|
|
520
|
+
expect(console.instance_variable_get(:@header_text_column)).to eq(2)
|
|
521
|
+
expect(console.header_lines('ollama', 'fixture', 115).length).to be <= 13
|
|
522
|
+
expect(PWN::Banner).to have_received(:mini_cells).once
|
|
523
|
+
expect([console.instance_variable_get(:@editor).text, console.instance_variable_get(:@editor).cursor]).to eq(['retained draft', 4])
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
it 'grows both game canvas dimensions with the header without recursively shrinking settings' do
|
|
527
|
+
PWN::Env[:ai] = { active: :ollama, ollama: { model: 'fixture', system_role_content: 'operator ' * 100, reasoning_effort: 'high' } }
|
|
528
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
529
|
+
console.instance_variable_set(:@width, 120)
|
|
530
|
+
console.instance_variable_set(:@height, 40)
|
|
531
|
+
allow(console).to receive(:put)
|
|
532
|
+
allow(console).to receive(:box)
|
|
533
|
+
allow(PWN::Banner).to receive(:mini_cells).and_call_original
|
|
534
|
+
height = console.draw_header('ollama', 'fixture')
|
|
535
|
+
expect(height).to be > 10
|
|
536
|
+
expect(console).to have_received(:box).with(0, 0, height, height, 'PWN')
|
|
537
|
+
expect(PWN::Banner).to have_received(:mini_cells).with(hash_including(width: height - 2, height: height - 2))
|
|
538
|
+
expect(console.instance_variable_get(:@header_spans).length + 3).to be <= height
|
|
539
|
+
expect(console.header_lines('ollama', 'fixture', console.instance_variable_get(:@header_text_width)).join).to include('REASONING EFFORT: high')
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
it 'paints centered colored block cells inside the square without reusing theme pairs' do
|
|
543
|
+
curses = double(has_colors?: true, start_color: nil, use_default_colors: nil, init_pair: nil, color_pairs: 256)
|
|
544
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: curses, getch: nil)
|
|
545
|
+
allow(ENV).to receive(:key?).with('NO_COLOR').and_return(false)
|
|
546
|
+
console.setup_colors
|
|
547
|
+
expect(curses).to have_received(:init_pair).with(9, Curses::COLOR_CYAN, Curses::COLOR_BLACK)
|
|
548
|
+
expect(curses).to have_received(:init_pair).with(16, Curses::COLOR_BLACK, Curses::COLOR_BLACK)
|
|
549
|
+
allow(console).to receive(:put)
|
|
550
|
+
allow(console).to receive(:banner_frame).with(8, cells: true).and_return([[{ glyph: '█', foreground: :cyan, background: :black }]])
|
|
551
|
+
console.draw_banner(8)
|
|
552
|
+
expect(console).to have_received(:put).with(4, 4, '█', 9)
|
|
553
|
+
expect(console).to have_received(:put).with(8, 1, ' ' * 8, 16)
|
|
554
|
+
oversized = Array.new(10) { Array.new(10) { { glyph: '▀', foreground: :yellow, background: :black } } }
|
|
555
|
+
allow(console).to receive(:banner_frame).with(8, cells: true).and_return(oversized)
|
|
556
|
+
calls = []
|
|
557
|
+
allow(console).to receive(:put) { |*args| calls << args }
|
|
558
|
+
console.draw_banner(8)
|
|
559
|
+
expect(calls).to all(satisfy { |y, x, text, _pair| y.between?(1, 8) && x >= 1 && x + text.length <= 9 })
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
it 'paints both colors of touching blocks and preserves solid occupancy without color' do
|
|
563
|
+
[256, 32, 16].each do |count|
|
|
564
|
+
curses = double(has_colors?: true, start_color: nil, use_default_colors: nil, init_pair: nil, color_pairs: count)
|
|
565
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: curses, getch: nil)
|
|
566
|
+
allow(ENV).to receive(:key?).with('NO_COLOR').and_return(false)
|
|
567
|
+
console.setup_colors
|
|
568
|
+
allow(console).to receive(:put)
|
|
569
|
+
allow(console).to receive(:banner_frame).with(8, cells: true).and_return([[{ glyph: '▀', foreground: :cyan, background: :red }]])
|
|
570
|
+
console.draw_banner(8)
|
|
571
|
+
if count == 256
|
|
572
|
+
palette = PWN::Plugins::REPL::AIConsole::Console::PALETTE
|
|
573
|
+
pair = 17 + (palette.reject { |color| color == 'black' }.index('red') * 8)
|
|
574
|
+
expect(curses).to have_received(:init_pair).with(pair, Curses::COLOR_CYAN, Curses::COLOR_RED)
|
|
575
|
+
expect(console).to have_received(:put).with(4, 4, '▀', pair)
|
|
576
|
+
else
|
|
577
|
+
expect(console).to have_received(:put).with(4, 4, '█', nil)
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
it 'retains block glyphs without allocating color pairs under NO_COLOR or on limited palettes' do
|
|
583
|
+
[true, false].each do |no_color|
|
|
584
|
+
curses = double(has_colors?: true, start_color: nil, use_default_colors: nil, init_pair: nil, color_pairs: 16)
|
|
585
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: curses, getch: nil)
|
|
586
|
+
allow(ENV).to receive(:key?).with('NO_COLOR').and_return(no_color)
|
|
587
|
+
console.setup_colors
|
|
588
|
+
expect(curses).not_to have_received(:init_pair).with(9, anything, anything)
|
|
589
|
+
allow(console).to receive(:put)
|
|
590
|
+
allow(console).to receive(:banner_frame).with(8, cells: true).and_return([[{ glyph: '█', foreground: :green, background: :black }]])
|
|
591
|
+
console.draw_banner(8)
|
|
592
|
+
expect(console).to have_received(:put).with(4, 4, '█', nil)
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
it 'uses the same monotonic cadence and session selection for colored frames' do
|
|
597
|
+
pry = Pry.new
|
|
598
|
+
pry.config.pwn_ai_session_id = 'colored-session'
|
|
599
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: pry, input: StringIO.new, curses: nil, getch: nil)
|
|
600
|
+
names = [:snake]
|
|
601
|
+
allow(PWN::Banner).to receive(:mini_names).and_return(names)
|
|
602
|
+
expect(names).to receive(:sample).twice.and_return(:snake)
|
|
603
|
+
now = 100.0
|
|
604
|
+
allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC) { now }
|
|
605
|
+
console.banner_frame(8, cells: true)
|
|
606
|
+
seed = console.instance_variable_get(:@banner_seed)
|
|
607
|
+
expect(seed).to be_a(Integer)
|
|
608
|
+
expect(console.banner_frame(8, cells: true)).to eq(PWN::Banner.mini_cells(name: :snake, frame: 0, width: 8, height: 8, seed: seed))
|
|
609
|
+
now += 0.21
|
|
610
|
+
expect(console.banner_frame(8, cells: true)).to eq(PWN::Banner.mini_cells(name: :snake, frame: 2, width: 8, height: 8, seed: seed))
|
|
611
|
+
expect(console.banner_frame(12, cells: true)).to eq(PWN::Banner.mini_cells(name: :snake, frame: 2, width: 12, height: 12, seed: seed))
|
|
612
|
+
expect(console.instance_variable_get(:@banner_seed)).to eq(seed)
|
|
613
|
+
pry.config.pwn_ai_session_id = 'another-colored-session'
|
|
614
|
+
console.banner_frame(8, cells: true)
|
|
615
|
+
expect(console.instance_variable_get(:@banner_seed)).not_to eq(seed)
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
it 'samples Asteroids or Pong once and caches their faster cadence outside the key path' do
|
|
619
|
+
allow(PWN::Banner).to receive(:mini_cells).and_call_original
|
|
620
|
+
%i[pong asteroids].each do |name|
|
|
621
|
+
pry = Pry.new
|
|
622
|
+
pry.config.pwn_ai_session_id = 'fast-colored-session'
|
|
623
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: pry, input: StringIO.new, curses: nil, getch: nil)
|
|
624
|
+
names = PWN::Banner.mini_names
|
|
625
|
+
allow(PWN::Banner).to receive(:mini_names).and_return(names)
|
|
626
|
+
expect(names).to receive(:sample).once.and_return(name)
|
|
627
|
+
expect(PWN::Banner).to receive(:mini_frame_seconds).with(name: name).once.and_call_original
|
|
628
|
+
now = 100.0
|
|
629
|
+
allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC) { now }
|
|
630
|
+
console.banner_frame(8, cells: true)
|
|
631
|
+
seed = console.instance_variable_get(:@banner_seed)
|
|
632
|
+
now += 1.21
|
|
633
|
+
expect(PWN::Banner).to receive(:mini_cells).with(name: name, frame: 24, width: 8, height: 8, seed: seed).twice.and_call_original
|
|
634
|
+
2.times { console.banner_frame(8, cells: true) }
|
|
635
|
+
end
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
it 'wraps every header line and labels the system role' do
|
|
639
|
+
previous = PWN::Env[:ai]
|
|
640
|
+
PWN::Env[:ai] = { active: :ollama, ollama: { system_role_content: 'ethical operator ' * 12, temp: 0.2 } }
|
|
641
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
642
|
+
console.instance_variable_set(:@width, 36)
|
|
643
|
+
lines = console.header_lines('ollama', 'qwen')
|
|
644
|
+
expect(lines.length).to be > 2
|
|
645
|
+
joined = lines.join(' ').gsub(/\s+/, ' ')
|
|
646
|
+
expect(joined).to include('SYSTEM ROLE CONTENT: ethical operator')
|
|
647
|
+
expect(joined).to include('PROVIDER (ENGINE): ollama MODEL: qwen')
|
|
648
|
+
expect(joined).not_to match(/\brole ethical/)
|
|
649
|
+
ensure
|
|
650
|
+
PWN::Env[:ai] = previous
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
it 'refreshes text and banner geometry when a model string is changed in place' do
|
|
654
|
+
PWN::Env[:ai] = { active: +'ollama', ollama: { model: +'before' } }
|
|
655
|
+
screen = double(erase: nil, refresh: nil, setpos: nil, addstr: nil)
|
|
656
|
+
curses = double(lines: 50, cols: 120)
|
|
657
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: curses, getch: nil)
|
|
658
|
+
console.instance_variable_set(:@screen, screen)
|
|
659
|
+
console.draw
|
|
660
|
+
old_height = console.instance_variable_get(:@header_pane_height)
|
|
661
|
+
PWN::Env[:ai][:ollama][:model].replace('new-model-' * 90)
|
|
662
|
+
console.draw
|
|
663
|
+
expect(console.instance_variable_get(:@header_lines).join).to include('new-model-')
|
|
664
|
+
expect(console.instance_variable_get(:@header_pane_height)).to be > old_height
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
it 'reuses wrapped settings until the width or values change' do
|
|
668
|
+
PWN::Env[:ai] = { active: :ollama, ollama: { system_role_content: 'long role ' * 500 } }
|
|
669
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
670
|
+
console.instance_variable_set(:@width, 80)
|
|
671
|
+
lines = console.header_lines('ollama', 'fixture')
|
|
672
|
+
expect(console.header_lines('ollama', 'fixture')).to equal(lines)
|
|
673
|
+
expect(console.header_lines('ollama', 'fixture', 40)).not_to equal(lines)
|
|
674
|
+
PWN::Env[:ai][:ollama][:temp] = 0.3
|
|
675
|
+
expect(console.header_lines('ollama', 'fixture').join).to include('TEMP: 0.3')
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
it 'draws the complete settings by growing the header and preserves label colors across wraps and resize' do
|
|
679
|
+
PWN::Env[:ai] = { active: :ollama, tui: { theme: { category: 'magenta', header: 'white' } },
|
|
680
|
+
ollama: { system_role_content: 'operator ' * 35, temp: 0.2, max_tokens: 1024,
|
|
681
|
+
max_prompt_length: 2048, reasoning_effort: 'high' } }
|
|
682
|
+
screen = double(erase: nil, refresh: nil, setpos: nil, addstr: nil)
|
|
683
|
+
curses = double(lines: 48, cols: 80)
|
|
684
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: curses, getch: nil)
|
|
685
|
+
console.instance_variable_set(:@screen, screen)
|
|
686
|
+
painted = []
|
|
687
|
+
allow(console).to receive(:put).and_wrap_original do |method, *args|
|
|
688
|
+
painted << args
|
|
689
|
+
method.call(*args)
|
|
690
|
+
end
|
|
691
|
+
[[80, 48], [48, 60], [160, 48]].each do |columns, rows|
|
|
692
|
+
allow(curses).to receive_messages(lines: rows, cols: columns)
|
|
693
|
+
painted.clear
|
|
694
|
+
console.draw
|
|
695
|
+
lines = console.header_lines('ollama', PWN::Plugins::REPL.pwn_ai_engine_model(engine: 'ollama'), console.instance_variable_get(:@header_text_width))
|
|
696
|
+
text_column = console.instance_variable_get(:@header_text_column)
|
|
697
|
+
header = painted.select { |y, x, _text, _color| y >= 2 && y < lines.length + 2 && x >= text_column && x < columns - 2 }
|
|
698
|
+
actual = header.group_by(&:first).values.map { |row| row.map { |entry| entry[2] }.join }
|
|
699
|
+
expect(actual).to eq(lines)
|
|
700
|
+
expect(header.select { |entry| entry[3] == console.tone(:category) }.map { |entry| entry[2] }.join).to include('SYSTEM ROLE CONTENT:', 'MAX PROMPT LENGTH:', 'REASONING EFFORT:')
|
|
701
|
+
expect(header.select { |entry| entry[3] == console.tone(:header) }.map { |entry| entry[2] }.join).to include('operator', '1024', 'high')
|
|
702
|
+
if columns == 48
|
|
703
|
+
labels = header.select { |entry| entry[3] == console.tone(:category) }.map { |entry| entry[2] }
|
|
704
|
+
expect(labels.join).to include('MAX PROMPT LENGTH:')
|
|
705
|
+
expect(labels).not_to include(a_string_including('MAX PROMPT LENGTH:'))
|
|
706
|
+
end
|
|
707
|
+
expect(console.instance_variable_get(:@page_size)).to be >= 1
|
|
708
|
+
end
|
|
709
|
+
console.handle("\u000f")
|
|
710
|
+
console.draw
|
|
711
|
+
console.handle(:end)
|
|
712
|
+
painted.clear
|
|
713
|
+
console.draw
|
|
714
|
+
expect(painted.select { |entry| entry[3] == console.tone(:category) }.map { |entry| entry[2] }.join).to include('REQUEST', 'TOKENS', 'SYSTEM ROLE CONTENT:', 'REASONING EFFORT:')
|
|
715
|
+
expect(painted.select { |entry| entry[3] == console.tone(:header) }.map { |entry| entry[2] }.join).to include('1024', 'high')
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
it 'bounds long settings at every usable size and exposes the full text without sacrificing the draft' do
|
|
719
|
+
PWN::Env[:ai] = { active: :ollama, ollama: { system_role_content: "#{'operator ' * 500}ROLE END", temp: 0.2 } }
|
|
720
|
+
screen = double(erase: nil, refresh: nil, setpos: nil, addstr: nil)
|
|
721
|
+
curses = double(lines: 24, cols: 80)
|
|
722
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: curses, getch: nil)
|
|
723
|
+
console.instance_variable_set(:@screen, screen)
|
|
724
|
+
editor = console.instance_variable_get(:@editor)
|
|
725
|
+
editor.replace('retained draft')
|
|
726
|
+
rectangles = []
|
|
727
|
+
allow(console).to receive(:box).and_wrap_original do |method, *args|
|
|
728
|
+
rectangles << args
|
|
729
|
+
method.call(*args)
|
|
730
|
+
end
|
|
731
|
+
[[160, 48], [80, 24], [48, 14]].each do |columns, rows|
|
|
732
|
+
allow(curses).to receive_messages(lines: rows, cols: columns)
|
|
733
|
+
rectangles.clear
|
|
734
|
+
console.draw
|
|
735
|
+
expect(console.instance_variable_get(:@page_size)).to be >= 1
|
|
736
|
+
expect(rectangles).to all(satisfy { |top, _left, height| height >= 3 && top + height <= rows - 1 })
|
|
737
|
+
end
|
|
738
|
+
console.add(:assistant, 'latest evidence')
|
|
739
|
+
expect(console.timeline_rows(40).last[1]).to include('latest evidence')
|
|
740
|
+
console.handle("\u000f")
|
|
741
|
+
console.draw
|
|
742
|
+
expect(screen).to have_received(:addstr).with(a_string_including('STATUS'))
|
|
743
|
+
console.handle(:end)
|
|
744
|
+
console.draw
|
|
745
|
+
expect(screen).to have_received(:addstr).with(a_string_including('ROLE END')).at_least(:once)
|
|
746
|
+
console.handle('x')
|
|
747
|
+
console.handle("\r")
|
|
748
|
+
console.handle("\e")
|
|
749
|
+
expect(editor.text).to eq('retained draft')
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
it 'persists requests through Pry history and searches the same file in a fresh console' do
|
|
753
|
+
File.write(@history_path, "existing request\n/input legacy hidden\n")
|
|
754
|
+
@request_history.load
|
|
755
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
756
|
+
console.seed_request_history
|
|
757
|
+
allow(console).to receive(:start_request)
|
|
758
|
+
console.submit('durable alpha request')
|
|
759
|
+
expect(File.read(@history_path)).to include("durable alpha request\n")
|
|
760
|
+
console.submit('durable alpha request')
|
|
761
|
+
Pry.history << 'durable alpha request'
|
|
762
|
+
console.submit('/input excluded secret')
|
|
763
|
+
console.submit(' /input excluded secret')
|
|
764
|
+
expected = "existing request\n/input legacy hidden\ndurable alpha request\n"
|
|
765
|
+
expect(File.read(@history_path)).to eq(expected)
|
|
766
|
+
reloaded = Pry::History.new(file_path: @history_path)
|
|
767
|
+
reloaded.load
|
|
768
|
+
allow(Pry).to receive(:history).and_return(reloaded)
|
|
769
|
+
fresh = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
770
|
+
fresh.seed_request_history
|
|
771
|
+
fresh.instance_variable_get(:@editor).place('unsent draft', 2)
|
|
772
|
+
fresh.handle(:up)
|
|
773
|
+
expect(fresh.instance_variable_get(:@editor).text).to eq('durable alpha request')
|
|
774
|
+
fresh.handle(:up)
|
|
775
|
+
expect(fresh.instance_variable_get(:@editor).text).to eq('existing request')
|
|
776
|
+
fresh.handle(:down)
|
|
777
|
+
fresh.handle(:down)
|
|
778
|
+
expect([fresh.instance_variable_get(:@editor).text, fresh.instance_variable_get(:@editor).cursor]).to eq(['unsent draft', 2])
|
|
779
|
+
fresh.handle("\u0012")
|
|
780
|
+
'alpha'.each_char { |char| fresh.handle(char) }
|
|
781
|
+
fresh.handle("\r")
|
|
782
|
+
expect(fresh.instance_variable_get(:@editor).text).to eq('durable alpha request')
|
|
783
|
+
fresh.handle("\u000c")
|
|
784
|
+
expect(File.read(@history_path)).to eq(expected)
|
|
785
|
+
fresh.handle("\u0012")
|
|
786
|
+
'hidden'.each_char { |char| fresh.handle(char) }
|
|
787
|
+
expect(fresh.instance_variable_get(:@editor).search[:match]).to be_nil
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
it 'selects broadcast and debate targets explicitly, escapes missions, and restores the exact draft cursor' do
|
|
791
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
792
|
+
swarm = PWN::Plugins::REPL::AISwarm::Controller.new(session_id: 'fixture')
|
|
793
|
+
console.instance_variable_set(:@swarm, swarm)
|
|
794
|
+
allow(PWN::AI::Agent::Swarm).to receive(:personas).and_return(scout: { role: 'Inspect' }, critic: { role: 'Critique' })
|
|
795
|
+
allow(swarm).to receive(:execute).and_return(ok: true, job_id: 'fixture')
|
|
796
|
+
editor = console.instance_variable_get(:@editor)
|
|
797
|
+
editor.place("--names\nquoted 'mission'", 4)
|
|
798
|
+
console.handle("\u0007")
|
|
799
|
+
console.handle('d')
|
|
800
|
+
expect(console.instance_variable_get(:@workspace)[:notice]).to include('at least 2')
|
|
801
|
+
[' ', 'j', ' ', 'b'].each { |key| console.handle(key) }
|
|
802
|
+
expect(swarm).not_to have_received(:execute)
|
|
803
|
+
expect(console.swarm_content.join).to include("--names\nquoted 'mission'")
|
|
804
|
+
console.handle("\r")
|
|
805
|
+
expect(swarm).to have_received(:execute).with(include(line: Shellwords.join(['/swarm', 'broadcast', '--names', 'scout,critic', '--', editor.text])))
|
|
806
|
+
console.handle("\t")
|
|
807
|
+
console.handle('d')
|
|
808
|
+
console.handle("\r")
|
|
809
|
+
expect(swarm).to have_received(:execute).with(include(line: Shellwords.join(['/swarm', 'debate', 'scout,critic', '--', editor.text])))
|
|
810
|
+
console.handle("\u0007")
|
|
811
|
+
expect([editor.text, editor.cursor]).to eq(["--names\nquoted 'mission'", 4])
|
|
812
|
+
ensure
|
|
813
|
+
swarm&.close
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
it 'handles empty rosters, new persona prompts, errors and no-job navigation without submitting a mission' do
|
|
817
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
818
|
+
swarm = PWN::Plugins::REPL::AISwarm::Controller.new(session_id: 'fixture')
|
|
819
|
+
console.instance_variable_set(:@swarm, swarm)
|
|
820
|
+
allow(PWN::AI::Agent::Swarm).to receive(:personas).and_return({})
|
|
821
|
+
allow(swarm).to receive(:execute).and_return(ok: false, error: 'fixture spawn failed')
|
|
822
|
+
console.handle("\u0007")
|
|
823
|
+
expect(console.swarm_content.join).to include('No agents')
|
|
824
|
+
"nscout\rInspect evidence\r".each_char { |key| console.handle(key) }
|
|
825
|
+
expect(swarm).not_to have_received(:execute)
|
|
826
|
+
console.handle("\r")
|
|
827
|
+
expect(console.instance_variable_get(:@workspace)[:notice]).to eq('fixture spawn failed')
|
|
828
|
+
console.handle("\t")
|
|
829
|
+
expect(console.swarm_content.join).to include('No jobs yet')
|
|
830
|
+
['s', 'c', "\r", "\e", "\e"].each { |key| console.handle(key) }
|
|
831
|
+
expect(console.instance_variable_get(:@workspace)).to be_nil
|
|
832
|
+
expect(swarm).to have_received(:execute).once
|
|
833
|
+
ensure
|
|
834
|
+
swarm&.close
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
it 'clears only the displayed session on Ctrl+L even while busy' do
|
|
838
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
839
|
+
editor = console.instance_variable_get(:@editor)
|
|
840
|
+
editor.preload(['retained request'])
|
|
841
|
+
editor.replace('draft')
|
|
842
|
+
console.add(:assistant, 'visible evidence')
|
|
843
|
+
allow(console).to receive(:busy?).and_return(true)
|
|
844
|
+
allow(console).to receive(:clear_view).and_call_original
|
|
845
|
+
console.instance_variable_get(:@usage).record(usage: { input_tokens: 123, output_tokens: 45 })
|
|
846
|
+
usage = console.instance_variable_get(:@usage).snapshot
|
|
847
|
+
expect(PWN::Sessions).not_to receive(:append)
|
|
848
|
+
console.handle("\u000c")
|
|
849
|
+
expect(console).to have_received(:clear_view)
|
|
850
|
+
expect(console.timeline_rows(60).join).not_to include('visible evidence')
|
|
851
|
+
expect(console.instance_variable_get(:@usage).snapshot).to eq(usage)
|
|
852
|
+
expect(editor.text).to eq('draft')
|
|
853
|
+
console.handle(:up)
|
|
854
|
+
expect(editor.text).to eq('retained request')
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
it 'searches local request history incrementally, accepts without sending and cancels with cursor restored' do
|
|
858
|
+
screen = double(erase: nil, refresh: nil, setpos: nil, addstr: nil)
|
|
859
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: double(lines: 30, cols: 100), getch: nil)
|
|
860
|
+
console.instance_variable_set(:@screen, screen)
|
|
861
|
+
editor = console.instance_variable_get(:@editor)
|
|
862
|
+
editor.preload(['older alpha', '/input hidden alpha', 'newer alpha', 'beta'])
|
|
863
|
+
editor.place('original draft', 3)
|
|
864
|
+
expect(console).not_to receive(:submit)
|
|
865
|
+
console.handle("\u0012")
|
|
866
|
+
'alpha'.each_char { |char| console.handle(char) }
|
|
867
|
+
console.draw
|
|
868
|
+
expect(screen).to have_received(:addstr).with(a_string_including('alpha')).at_least(:once)
|
|
869
|
+
expect(screen).to have_received(:addstr).with(a_string_including('newer alpha')).at_least(:once)
|
|
870
|
+
console.handle("\u0012")
|
|
871
|
+
console.handle("\r")
|
|
872
|
+
expect(editor.text).to eq('older alpha')
|
|
873
|
+
console.handle(:up)
|
|
874
|
+
expect(editor.text).to eq('beta')
|
|
875
|
+
console.handle(:down)
|
|
876
|
+
expect(editor.text).to eq('older alpha')
|
|
877
|
+
editor.place('original draft', 3)
|
|
878
|
+
console.handle("\u0012")
|
|
879
|
+
'missing'.each_char { |char| console.handle(char) }
|
|
880
|
+
console.draw
|
|
881
|
+
expect(screen).to have_received(:addstr).with(a_string_including('no match')).at_least(:once)
|
|
882
|
+
console.handle("\u007f")
|
|
883
|
+
console.handle("\e")
|
|
884
|
+
expect([editor.text, editor.cursor]).to eq(['original draft', 3])
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
it 'reads application and kitty arrow sequences as history keys' do
|
|
888
|
+
reader, writer = IO.pipe
|
|
889
|
+
writer.write("\eOA\e[1;1B")
|
|
890
|
+
writer.close
|
|
891
|
+
keyboard = PWN::Plugins::REPL::AIConsole::Keyboard.new(reader)
|
|
892
|
+
expect(keyboard.call).to eq(:up)
|
|
893
|
+
expect(keyboard.call).to eq(:down)
|
|
894
|
+
ensure
|
|
895
|
+
reader&.close
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
it 'does not retain tool input in recall history' do
|
|
899
|
+
editor = PWN::Plugins::REPL::AIConsole::Editor.new
|
|
900
|
+
editor.replace('previous task')
|
|
901
|
+
editor.submit
|
|
902
|
+
editor.replace('/input secret tool response')
|
|
903
|
+
editor.submit
|
|
904
|
+
editor.recall(:up)
|
|
905
|
+
expect(editor.text).to eq('previous task')
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
it 'flushes an unterminated dependency prompt through the event queue without terminal control bytes' do
|
|
909
|
+
queue = Queue.new
|
|
910
|
+
output = PWN::Plugins::REPL::AIConsole::EventIO.new(queue)
|
|
911
|
+
output.write('Tool asks: value? ')
|
|
912
|
+
output.flush
|
|
913
|
+
expect(queue.size).to eq(1)
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
it 'pages above the current viewport on the first PgUp and pins that position for incoming output' do
|
|
917
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
918
|
+
console.instance_variable_set(:@total_rows, 100)
|
|
919
|
+
console.instance_variable_set(:@page_size, 20)
|
|
920
|
+
console.handle(:page_up)
|
|
921
|
+
expect(console.instance_variable_get(:@scroll)).to eq(60)
|
|
922
|
+
console.add(:notice, 'new output')
|
|
923
|
+
expect(console.instance_variable_get(:@scroll)).to eq(60)
|
|
924
|
+
console.handle(:page_down)
|
|
925
|
+
expect(console.instance_variable_get(:@scroll)).to be_nil
|
|
926
|
+
end
|
|
927
|
+
|
|
928
|
+
it 'wraps at words without discarding code whitespace or splitting graphemes' do
|
|
929
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
930
|
+
expect(console.wrap('some evidence', 12)).to eq(['some ', 'evidence'])
|
|
931
|
+
text = " code evidence\n界e\u0301🙂abcdef "
|
|
932
|
+
rows = console.wrap(text, 8)
|
|
933
|
+
expect(rows.join).to eq(text.delete("\n"))
|
|
934
|
+
expect(rows).to all(satisfy { |row| console.width(row) <= 8 })
|
|
935
|
+
expect(rows.join.scan(/\X/)).to eq(text.delete("\n").scan(/\X/))
|
|
936
|
+
expect(console.wrap('abcdefghij', 4)).to eq(%w[abcd efgh ij])
|
|
937
|
+
expect(console.wrap("a\n\n", 4)).to eq(['a', '', ''])
|
|
938
|
+
expect(console.wrap('👩💻界', 2)).to eq(['👩💻', '界'])
|
|
939
|
+
expect(console.width('👩💻')).to eq(2)
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
it 'shows observed elapsed time, completed tools, event timestamps and pinned new output' do
|
|
943
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
944
|
+
allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC).and_return(10.0, 12.5, 14.0)
|
|
945
|
+
allow(Time).to receive(:now).and_return(Time.utc(2026, 1, 2, 3, 4, 5))
|
|
946
|
+
console.begin_request_metrics
|
|
947
|
+
console.add(:task, 'task summary, not a completed tool')
|
|
948
|
+
console.add(:tool, "shell\n{}")
|
|
949
|
+
console.add(:result, 'observed result')
|
|
950
|
+
expect(console.elapsed).to eq('2.5s')
|
|
951
|
+
expect(console.operation_lines.join(' ')).to include('Completed tools 1', 'Events 3', 'LAST TOOL', 'shell')
|
|
952
|
+
expect(console.timeline_rows(40).map(&:last).join).to include('03:04:05', 'TOOL')
|
|
953
|
+
console.instance_variable_set(:@total_rows, 100)
|
|
954
|
+
console.instance_variable_set(:@page_size, 20)
|
|
955
|
+
console.handle(:page_up)
|
|
956
|
+
console.add(:notice, 'incoming')
|
|
957
|
+
expect(console.scroll_status).to include('61–80/100', '1 new')
|
|
958
|
+
console.handle(:page_down)
|
|
959
|
+
expect(console.scroll_status).to eq('live')
|
|
960
|
+
console.finish_request_metrics
|
|
961
|
+
allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC).and_return(100.0)
|
|
962
|
+
expect(console.elapsed).to eq('4.0s')
|
|
963
|
+
console.begin_request_metrics
|
|
964
|
+
expect(console.operation_lines.join(' ')).to include('Completed tools 0', 'not observed')
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
it 'keeps tokens, cost and last tool in a short operations sidebar' do
|
|
968
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
969
|
+
console.instance_variable_set(:@width, 120)
|
|
970
|
+
console.instance_variable_set(:@last_tool, 'shell')
|
|
971
|
+
allow(console).to receive(:box)
|
|
972
|
+
painted = []
|
|
973
|
+
allow(console).to receive(:put) { |_row, _column, text, _color| painted << text }
|
|
974
|
+
console.draw_sidebar(6, 94, 11)
|
|
975
|
+
expect(painted.join(' ')).to include('TOKENS', 'Cost', 'LAST TOOL', 'shell')
|
|
976
|
+
end
|
|
977
|
+
|
|
978
|
+
it 'bounds giant timeline events explicitly and caches wrapping until width or events change' do
|
|
979
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
980
|
+
console.add(:result, 'evidence ' * 20_000)
|
|
981
|
+
expect(console.instance_variable_get(:@timeline).first[1].bytesize).to be < 17_000
|
|
982
|
+
rows = console.timeline_rows(40)
|
|
983
|
+
expect(rows.map(&:last).join).to include('[display truncated')
|
|
984
|
+
expect(console).not_to receive(:wrap)
|
|
985
|
+
expect(console.timeline_rows(40)).to equal(rows)
|
|
986
|
+
end
|
|
987
|
+
|
|
988
|
+
it 'edits graphemes and restores the full-history draft and cursor' do
|
|
989
|
+
editor = PWN::Plugins::REPL::AIConsole::Editor.new
|
|
990
|
+
105.times do |index|
|
|
991
|
+
editor.replace("task #{index}")
|
|
992
|
+
editor.submit
|
|
993
|
+
end
|
|
994
|
+
editor.replace("界e\u0301🙂 draft")
|
|
995
|
+
editor.edit(:left)
|
|
996
|
+
cursor = editor.cursor
|
|
997
|
+
editor.recall(:up)
|
|
998
|
+
editor.edit("\b")
|
|
999
|
+
editor.recall(:down)
|
|
1000
|
+
expect(editor.text).to eq("界e\u0301🙂 draft")
|
|
1001
|
+
expect(editor.cursor).to eq(cursor)
|
|
1002
|
+
110.times { editor.recall(:up) }
|
|
1003
|
+
expect(editor.text).to eq('task 0')
|
|
1004
|
+
editor.replace("界e\u0301🙂")
|
|
1005
|
+
editor.edit(:home)
|
|
1006
|
+
editor.edit(:right)
|
|
1007
|
+
editor.edit(:delete)
|
|
1008
|
+
expect(editor.text).to eq('界🙂')
|
|
1009
|
+
end
|
|
1010
|
+
|
|
1011
|
+
it 'restores IO and terminal state if rendering fails' do
|
|
1012
|
+
original = [$stdin, $stdout, $stderr]
|
|
1013
|
+
keys = []
|
|
1014
|
+
expect do
|
|
1015
|
+
launch(keys) do
|
|
1016
|
+
allow(@screen.stdscr).to receive(:addstr).and_raise(IOError, 'paint failed')
|
|
1017
|
+
nil
|
|
1018
|
+
end
|
|
1019
|
+
end.to raise_error(IOError, 'paint failed')
|
|
1020
|
+
expect([$stdin, $stdout, $stderr]).to eq(original)
|
|
1021
|
+
expect(@screen).to have_received(:close_screen)
|
|
1022
|
+
end
|
|
1023
|
+
|
|
1024
|
+
it 'keeps cancellation and exit hints visible at the minimum supported width' do
|
|
1025
|
+
resized = false
|
|
1026
|
+
launch([]) do
|
|
1027
|
+
if resized
|
|
1028
|
+
expect(@paint.last).to include('^C cancel', '^D back')
|
|
1029
|
+
expect(Unicode::DisplayWidth.of(@paint.last)).to be <= 46
|
|
1030
|
+
"\u0004"
|
|
1031
|
+
else
|
|
1032
|
+
allow(@screen).to receive_messages(lines: 14, cols: 48)
|
|
1033
|
+
resized = true
|
|
1034
|
+
nil
|
|
1035
|
+
end
|
|
1036
|
+
end
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
it 'renders a visible slash menu and safely handles a tiny resize' do
|
|
1040
|
+
keys = "/\n".chars
|
|
1041
|
+
stage = 0
|
|
1042
|
+
launch(keys) do
|
|
1043
|
+
if stage.zero? && @paint.any? { |row| row.include?('COMMANDS') }
|
|
1044
|
+
stage = 1
|
|
1045
|
+
allow(@screen).to receive_messages(lines: 7, cols: 35)
|
|
1046
|
+
"\e"
|
|
1047
|
+
elsif stage == 1 && @paint.any? { |row| row.include?('terminal too small') }
|
|
1048
|
+
stage = 2
|
|
1049
|
+
allow(@screen).to receive_messages(lines: 30, cols: 125)
|
|
1050
|
+
nil
|
|
1051
|
+
elsif stage == 2 && @paint.any? { |row| row.include?('OPERATIONS') }
|
|
1052
|
+
"\u0004"
|
|
1053
|
+
end
|
|
1054
|
+
end
|
|
1055
|
+
expect(@paint.join).to include('/model', 'terminal too small', 'OPERATIONS')
|
|
1056
|
+
end
|
|
1057
|
+
|
|
1058
|
+
it 'blocks hidden composer input during tiny resize, keeps its draft and still accepts cancellation and exit' do
|
|
1059
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: double(lines: 7, cols: 35), getch: nil)
|
|
1060
|
+
screen = double(erase: nil, refresh: nil, setpos: nil, addstr: nil)
|
|
1061
|
+
console.instance_variable_set(:@screen, screen)
|
|
1062
|
+
editor = console.instance_variable_get(:@editor)
|
|
1063
|
+
editor.replace('saved draft')
|
|
1064
|
+
console.draw
|
|
1065
|
+
expect(console).not_to receive(:submit)
|
|
1066
|
+
['x', "\r", :paste_start, :newline, :up, "\t", :paste_end].each { |key| console.handle(key) }
|
|
1067
|
+
expect(editor.text).to eq('saved draft')
|
|
1068
|
+
expect(console).to receive(:cancel).once
|
|
1069
|
+
console.handle("\u0003")
|
|
1070
|
+
console.handle("\u0004")
|
|
1071
|
+
expect(console.instance_variable_get(:@leaving)).to be true
|
|
1072
|
+
expect(editor.text).to eq('saved draft')
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1075
|
+
it 'adds console-only commands to completion without changing the global dispatcher' do
|
|
1076
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
1077
|
+
allow(PWN::Plugins::REPL).to receive(:pwn_ai_complete).and_return(['/model'])
|
|
1078
|
+
console.instance_variable_get(:@editor).replace('/')
|
|
1079
|
+
console.complete
|
|
1080
|
+
expect(console.instance_variable_get(:@menu).map { |item| item[:label] }).to include('/input', '/menu', '/model')
|
|
1081
|
+
console.instance_variable_get(:@editor).replace('/in')
|
|
1082
|
+
console.complete
|
|
1083
|
+
expect(console.instance_variable_get(:@menu).map { |item| item[:label] }).to include('/input')
|
|
1084
|
+
expect(console.instance_variable_get(:@menu).map { |item| item[:label] }).not_to include('/menu')
|
|
1085
|
+
console.instance_variable_get(:@editor).replace('/menu')
|
|
1086
|
+
console.complete
|
|
1087
|
+
console.instance_variable_set(:@menu_index, console.instance_variable_get(:@menu).index { |item| item[:label] == '/menu' })
|
|
1088
|
+
console.handle("\r")
|
|
1089
|
+
expect(PWN::Plugins::REPL).not_to receive(:pwn_ai_dispatch_slash!)
|
|
1090
|
+
console.handle("\r")
|
|
1091
|
+
expect(console.instance_variable_get(:@menu).map { |item| item[:label] }).to include('/menu', '/input')
|
|
1092
|
+
end
|
|
1093
|
+
|
|
1094
|
+
it 'keeps slash parameter completion live across spaces and clears stale menus on submit' do
|
|
1095
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
1096
|
+
'/verbose '.chars.each { |key| console.handle(key) }
|
|
1097
|
+
expect(console.instance_variable_get(:@menu).map { |item| item[:label] }).to eq(%w[on off])
|
|
1098
|
+
console.handle('o')
|
|
1099
|
+
console.handle('f')
|
|
1100
|
+
console.handle('f')
|
|
1101
|
+
console.handle("\r")
|
|
1102
|
+
expect(console.instance_variable_get(:@menu)).to be_nil
|
|
1103
|
+
end
|
|
1104
|
+
|
|
1105
|
+
it 'clears every cell behind the completion popup and reverses the selected row even without colors' do
|
|
1106
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
1107
|
+
screen = double('popup screen')
|
|
1108
|
+
paint = []
|
|
1109
|
+
position = nil
|
|
1110
|
+
reversed = false
|
|
1111
|
+
allow(screen).to receive(:setpos) { |*pos| position = pos }
|
|
1112
|
+
allow(screen).to receive(:addstr) { |text| paint << [position, text, reversed] }
|
|
1113
|
+
allow(screen).to receive(:attron) do |attribute, &block|
|
|
1114
|
+
expect(attribute).to eq(Curses::A_REVERSE)
|
|
1115
|
+
reversed = true
|
|
1116
|
+
block.call(0) # ncurses yields the wattron result
|
|
1117
|
+
reversed = false
|
|
1118
|
+
end
|
|
1119
|
+
console.instance_variable_set(:@screen, screen)
|
|
1120
|
+
console.instance_variable_set(:@width, 80)
|
|
1121
|
+
console.instance_variable_set(:@height, 30)
|
|
1122
|
+
console.instance_variable_set(:@menu, ['/界', '/menu'])
|
|
1123
|
+
console.instance_variable_set(:@menu_index, 0)
|
|
1124
|
+
console.draw_menu
|
|
1125
|
+
expect(paint.take(4).map { |position_and_text| position_and_text[1] }).to eq([' ' * 65] * 4)
|
|
1126
|
+
selected = paint.find { |_pos, text, reverse| reverse && text.include?('/界') }
|
|
1127
|
+
expect(selected).not_to be_nil
|
|
1128
|
+
expect(console.width(selected[1])).to eq(61)
|
|
1129
|
+
end
|
|
1130
|
+
|
|
1131
|
+
it 'keeps the terminal cursor inside the multiline composer after painting the footer' do
|
|
1132
|
+
launch(['界', :newline, 'é']) { "\u0004" }
|
|
1133
|
+
expect(@positions.last).to eq([25, 5])
|
|
1134
|
+
end
|
|
1135
|
+
|
|
1136
|
+
it 'keeps the editing cursor in the composer while completion is open' do
|
|
1137
|
+
launch(['/']) { "\u0004" }
|
|
1138
|
+
expect(@positions.last).to eq([24, 5])
|
|
1139
|
+
end
|
|
1140
|
+
|
|
1141
|
+
it 'keeps debug output queued and restores the debug tee after leaving curses' do
|
|
1142
|
+
log = PWN::Plugins::Log
|
|
1143
|
+
allow(log).to receive(:debug_dir).and_return(File.join(@tmp, 'debug'))
|
|
1144
|
+
previous = $stdout
|
|
1145
|
+
allow(PWN::AI::Agent::Loop).to receive(:call_engine).and_return(role: 'assistant', content: 'Debug final.', tool_calls: [])
|
|
1146
|
+
PWN::Plugins::REPL.add_commands
|
|
1147
|
+
keys = "/debug\nwhat color is a lemon?\n".chars
|
|
1148
|
+
launch(keys) { @paint.any? { |row| row.include?('Debug final.') } ? "\u0004" : nil }
|
|
1149
|
+
expect(log.instance_variable_get(:@debug_tee)).to equal(previous)
|
|
1150
|
+
expect(log.raw_stderr).not_to be_a(PWN::Plugins::REPL::AIConsole::EventIO)
|
|
1151
|
+
path = log.debug_log_path
|
|
1152
|
+
expect(File.read(path)).to include('Debug final.', 'footer iter=') if path
|
|
1153
|
+
ensure
|
|
1154
|
+
log&.stop_debug
|
|
1155
|
+
end
|
|
1156
|
+
|
|
1157
|
+
it 'cancels the reasoning overlay with the original draft and cursor intact' do
|
|
1158
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
1159
|
+
editor = console.instance_variable_get(:@editor)
|
|
1160
|
+
selection = { engine: 'grok', model: 'grok-4.6', efforts: %w[low medium high], default: 'medium' }
|
|
1161
|
+
original = Marshal.dump(PWN::Env[:ai])
|
|
1162
|
+
console.instance_variable_set(:@model_prompt, selection)
|
|
1163
|
+
console.instance_variable_set(:@model_index, 1)
|
|
1164
|
+
console.instance_variable_set(:@model_draft, ['/model grok grok-4.6', 7])
|
|
1165
|
+
expect(PWN::Plugins::REPL).not_to receive(:persist_ai_selection)
|
|
1166
|
+
console.handle(:down)
|
|
1167
|
+
expect(console.instance_variable_get(:@model_index)).to eq(2)
|
|
1168
|
+
console.handle("\e")
|
|
1169
|
+
expect(editor.text).to eq('/model grok grok-4.6')
|
|
1170
|
+
expect(editor.cursor).to eq(7)
|
|
1171
|
+
expect(Marshal.dump(PWN::Env[:ai])).to eq(original)
|
|
1172
|
+
expect(console.instance_variable_get(:@model_prompt)).to be_nil
|
|
1173
|
+
end
|
|
1174
|
+
|
|
1175
|
+
def rendered_header
|
|
1176
|
+
boundary = @rendered.find { |_row, _column, text| text.start_with?(' SESSION ') }&.first || 0
|
|
1177
|
+
@rendered.select { |row, _column, _text| row < boundary }.map(&:last).join
|
|
1178
|
+
end
|
|
1179
|
+
|
|
1180
|
+
it 'repaints an asynchronous model command with all active settings before another key' do
|
|
1181
|
+
PWN::Env[:ai][:openai] = { model: 'old', system_role_content: 'new operator role', temp: 0.37,
|
|
1182
|
+
max_tokens: 1234, max_prompt_length: 5678, reasoning_effort: 'fixture-effort' }
|
|
1183
|
+
allow(PWN::Plugins::REPL).to receive(:persist_ai_selection).and_return(false)
|
|
1184
|
+
keys = "/model openai header-fixture\n".chars
|
|
1185
|
+
launch(keys) do
|
|
1186
|
+
header = rendered_header
|
|
1187
|
+
if header.include?('header-fixture')
|
|
1188
|
+
expect(header).to include('openai', 'new operator role', '0.37', '1234', '5678', 'fixture-effort')
|
|
1189
|
+
"\u0004"
|
|
1190
|
+
else
|
|
1191
|
+
Thread.pass
|
|
1192
|
+
nil
|
|
1193
|
+
end
|
|
1194
|
+
end
|
|
1195
|
+
end
|
|
1196
|
+
|
|
1197
|
+
it 'repaints settings and theme changed by a command worker without worker drawing or a wakeup key' do
|
|
1198
|
+
PWN::Env[:ai] = { active: :ollama, ollama: { model: 'settings-fixture' } }
|
|
1199
|
+
owner = Thread.current
|
|
1200
|
+
painted = []
|
|
1201
|
+
allow_any_instance_of(PWN::Plugins::REPL::AIConsole::Console).to receive(:put).and_wrap_original do |method, *args|
|
|
1202
|
+
painted << args
|
|
1203
|
+
method.call(*args)
|
|
1204
|
+
end
|
|
1205
|
+
allow(PWN::Cron).to receive(:run) do
|
|
1206
|
+
expect(Thread.current).not_to eq(owner)
|
|
1207
|
+
slot = PWN::Env[:ai][PWN::Env[:ai][:active].to_sym]
|
|
1208
|
+
slot[:system_role_content] = 'worker settings role'
|
|
1209
|
+
slot[:temp] = 0.42
|
|
1210
|
+
slot[:max_tokens] = 4321
|
|
1211
|
+
slot[:max_prompt_length] = 8765
|
|
1212
|
+
slot[:reasoning_effort] = 'worker-effort'
|
|
1213
|
+
PWN::Env[:ai][:tui] = { theme: { category: 'magenta', header: 'blue' } }
|
|
1214
|
+
'offline settings fixture completed'
|
|
1215
|
+
end
|
|
1216
|
+
keys = "/cron run settings-fixture\n".chars
|
|
1217
|
+
launch(keys) do
|
|
1218
|
+
if rendered_header.include?('worker-effort')
|
|
1219
|
+
expect(rendered_header).to include('worker settings role', '0.42', '4321', '8765')
|
|
1220
|
+
expect(painted).to include([a_kind_of(Integer), a_kind_of(Integer), 'MODEL:', 7])
|
|
1221
|
+
expect(painted).to include([a_kind_of(Integer), a_kind_of(Integer), ' worker-effort', 6])
|
|
1222
|
+
"\u0004"
|
|
1223
|
+
else
|
|
1224
|
+
Thread.pass
|
|
1225
|
+
nil
|
|
1226
|
+
end
|
|
1227
|
+
end
|
|
1228
|
+
end
|
|
1229
|
+
|
|
1230
|
+
it 'repaints accepted reasoning before another key even when persistence is session only' do
|
|
1231
|
+
PWN::Env[:ai][:openai] = { model: 'old', reasoning_effort: 'medium' }
|
|
1232
|
+
allow(PWN::Plugins::REPL).to receive(:persist_ai_selection).and_return(false)
|
|
1233
|
+
allow(PWN::AI::OpenAI).to receive(:get_models).and_return(data: [{ id: 'gpt-6-astra', supported_reasoning_levels: [{ effort: 'medium' }, { effort: 'high' }] }])
|
|
1234
|
+
keys = "/model openai gpt-6-astra\n".chars
|
|
1235
|
+
accepted = false
|
|
1236
|
+
launch(keys) do
|
|
1237
|
+
if !accepted && @rendered.any? { |row| row.last.include?('REASONING EFFORT · SELECT') }
|
|
1238
|
+
accepted = true
|
|
1239
|
+
keys.push(:down, "\n")
|
|
1240
|
+
nil
|
|
1241
|
+
elsif accepted && rendered_header.include?('gpt-6-astra')
|
|
1242
|
+
expect(rendered_header).to include('REASONING EFFORT:', 'high')
|
|
1243
|
+
"\u0004"
|
|
1244
|
+
else
|
|
1245
|
+
Thread.pass
|
|
1246
|
+
nil
|
|
1247
|
+
end
|
|
1248
|
+
end
|
|
1249
|
+
end
|
|
1250
|
+
|
|
1251
|
+
["\e", "\u0003"].each do |cancel_key|
|
|
1252
|
+
it "keeps the rendered header and banner unchanged after reasoning cancellation #{cancel_key.inspect}" do
|
|
1253
|
+
allow(PWN::Plugins::REPL).to receive(:persist_ai_selection).and_return(false)
|
|
1254
|
+
expect(PWN::Plugins::REPL).not_to receive(:pwn_ai_apply_model)
|
|
1255
|
+
original = Marshal.dump(PWN::Env[:ai])
|
|
1256
|
+
keys = "/model grok grok-4.6\n".chars
|
|
1257
|
+
cancelled = false
|
|
1258
|
+
baseline = nil
|
|
1259
|
+
allow(PWN::Banner).to receive(:mini_frame).and_return(['stable artwork'])
|
|
1260
|
+
allow(PWN::Banner).to receive(:mini_cells).and_return([[{ glyph: '█', foreground: :cyan, background: :black }]])
|
|
1261
|
+
launch(keys) do
|
|
1262
|
+
if !cancelled && @rendered.any? { |row| row.last.include?('REASONING EFFORT · SELECT') }
|
|
1263
|
+
baseline = rendered_header
|
|
1264
|
+
cancelled = true
|
|
1265
|
+
cancel_key
|
|
1266
|
+
elsif cancelled
|
|
1267
|
+
expect(Marshal.dump(PWN::Env[:ai])).to eq(original)
|
|
1268
|
+
expect(rendered_header).to eq(baseline)
|
|
1269
|
+
"\u0004"
|
|
1270
|
+
else
|
|
1271
|
+
Thread.pass
|
|
1272
|
+
nil
|
|
1273
|
+
end
|
|
1274
|
+
end
|
|
1275
|
+
end
|
|
1276
|
+
end
|
|
1277
|
+
|
|
1278
|
+
it 'renders a resumed session and reselects artwork only for an actual session change' do
|
|
1279
|
+
sid = PWN::Sessions.create(title: 'header resume')[:id]
|
|
1280
|
+
names = PWN::Banner.mini_names
|
|
1281
|
+
allow(PWN::Banner).to receive(:mini_names).and_return(names)
|
|
1282
|
+
expect(names).to receive(:sample).twice.and_return(names.first)
|
|
1283
|
+
keys = "/sessions resume #{sid}\n".chars
|
|
1284
|
+
repeated = false
|
|
1285
|
+
launch(keys) do
|
|
1286
|
+
if @rendered.any? { |row| row.last.include?("SESSION #{sid}") }
|
|
1287
|
+
if repeated
|
|
1288
|
+
"\u0004"
|
|
1289
|
+
else
|
|
1290
|
+
repeated = true
|
|
1291
|
+
keys.concat("/sessions resume #{sid}\n".chars)
|
|
1292
|
+
nil
|
|
1293
|
+
end
|
|
1294
|
+
end
|
|
1295
|
+
end
|
|
1296
|
+
end
|
|
1297
|
+
|
|
1298
|
+
it 'prompts in curses and forwards the accepted effort to the next provider request' do
|
|
1299
|
+
PWN::Env[:ai][:openai] = { model: 'old', reasoning_effort: 'medium' }
|
|
1300
|
+
allow(PWN::Plugins::REPL).to receive(:persist_ai_selection).and_return(false)
|
|
1301
|
+
allow(PWN::AI::OpenAI).to receive(:get_models).and_return(data: [{ id: 'gpt-6-astra', supported_reasoning_levels: [{ effort: 'low' }, { effort: 'medium' }, { effort: 'high' }] }])
|
|
1302
|
+
expect(PWN::AI::OpenAI).to receive(:chat_with_tools).with(hash_including(reasoning_effort: 'high')) do
|
|
1303
|
+
{ choices: [{ message: { role: 'assistant', content: 'Effort accepted.', tool_calls: [] } }] }
|
|
1304
|
+
end
|
|
1305
|
+
keys = "/model openai gpt-6-astra\n".chars
|
|
1306
|
+
accepted = false
|
|
1307
|
+
launch(keys) do
|
|
1308
|
+
if !accepted && @paint.any? { |row| row.include?('REASONING EFFORT · SELECT') }
|
|
1309
|
+
accepted = true
|
|
1310
|
+
keys.concat([:down, "\n"] + "what color is a lemon?\n".chars)
|
|
1311
|
+
nil
|
|
1312
|
+
elsif @paint.any? { |row| row.include?('Effort accepted.') }
|
|
1313
|
+
"\u0004"
|
|
1314
|
+
else
|
|
1315
|
+
Thread.pass
|
|
1316
|
+
nil
|
|
1317
|
+
end
|
|
1318
|
+
end
|
|
1319
|
+
expect(PWN::Env.dig(:ai, :openai, :reasoning_effort)).to eq('high')
|
|
1320
|
+
end
|
|
1321
|
+
|
|
1322
|
+
it 'honors locally selected model and resumed session in the actual provider call' do
|
|
1323
|
+
sid = PWN::Sessions.create(title: 'resumed console')[:id]
|
|
1324
|
+
allow(PWN::Plugins::REPL).to receive(:persist_ai_selection).and_return(false)
|
|
1325
|
+
expect(PWN::AI::OpenAI).to receive(:chat_with_tools) do
|
|
1326
|
+
expect(PWN::Env.dig(:ai, :openai, :model)).to eq('console-fixture')
|
|
1327
|
+
{ choices: [{ message: { role: 'assistant', content: 'Selected route.', tool_calls: [] } }] }
|
|
1328
|
+
end
|
|
1329
|
+
keys = "/model openai console-fixture\n/sessions resume #{sid}\nwhat color is a lemon?\n".chars
|
|
1330
|
+
launch(keys) { @paint.any? { |row| row.include?('Selected route.') } ? "\u0004" : nil }
|
|
1331
|
+
expect(@pry.config.pwn_ai_session_id).to eq(sid)
|
|
1332
|
+
expect(PWN::Sessions.load(session_id: sid).map { |row| row[:content] }.join).to include('Selected route.')
|
|
1333
|
+
end
|
|
1334
|
+
|
|
1335
|
+
it 'strips spinner controls, redacts queued secrets, and accepts binary dependency warnings' do
|
|
1336
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
1337
|
+
console.add(:notice, "\e[?25h\e[0m")
|
|
1338
|
+
expect(console.instance_variable_get(:@timeline)).to be_empty
|
|
1339
|
+
console.add(:warning, "api_key=super-secret-fixture\n\e[31mwarning\e[0m")
|
|
1340
|
+
expect(console.instance_variable_get(:@timeline).inspect).not_to include('super-secret-fixture', "\e")
|
|
1341
|
+
expect { console.add(:warning, "warning\xff".b) }.not_to raise_error
|
|
1342
|
+
end
|
|
1343
|
+
|
|
1344
|
+
it 'decodes terminal keys and Unicode without a competing stdin reader' do
|
|
1345
|
+
require 'pty'
|
|
1346
|
+
master, slave = PTY.open
|
|
1347
|
+
slave.raw do
|
|
1348
|
+
reader = PWN::Plugins::REPL::AIConsole::Keyboard.new(slave)
|
|
1349
|
+
master.write("界\e[D\e[13;2u\e[3~\e[5~")
|
|
1350
|
+
expect(Array.new(5) { reader.call }).to eq(['界', :left, :newline, :delete, :page_up])
|
|
1351
|
+
end
|
|
1352
|
+
ensure
|
|
1353
|
+
master&.close
|
|
1354
|
+
slave&.close
|
|
1355
|
+
end
|
|
1356
|
+
|
|
1357
|
+
it 'consumes valid UTF-8 prefixes immediately and recovers from malformed bytes' do
|
|
1358
|
+
source = double('incremental terminal', wait_readable: false)
|
|
1359
|
+
reader = PWN::Plugins::REPL::AIConsole::Keyboard.new(source)
|
|
1360
|
+
buffer = reader.instance_variable_get(:@buffer)
|
|
1361
|
+
buffer << "a\xe7".b
|
|
1362
|
+
expect(reader.call).to eq('a')
|
|
1363
|
+
expect(reader.call).to be_nil
|
|
1364
|
+
buffer << "\x95\x8c".b
|
|
1365
|
+
expect(reader.call).to eq('界')
|
|
1366
|
+
buffer << "\xff\xe0\x80b\xe7c".b
|
|
1367
|
+
expect(Array.new(6) { reader.call }).to eq(['�', '�', '�', 'b', '�', 'c'])
|
|
1368
|
+
buffer << "\xf0\x9f".b
|
|
1369
|
+
expect(reader.call).to be_nil
|
|
1370
|
+
buffer << "\x99\x82".b
|
|
1371
|
+
expect(reader.call).to eq('🙂')
|
|
1372
|
+
end
|
|
1373
|
+
|
|
1374
|
+
it 'keeps the grapheme cursor valid when combining characters arrive as separate terminal keys' do
|
|
1375
|
+
editor = PWN::Plugins::REPL::AIConsole::Editor.new
|
|
1376
|
+
editor.insert('e')
|
|
1377
|
+
editor.insert("\u0301")
|
|
1378
|
+
expect(editor.cursor).to eq(1)
|
|
1379
|
+
editor.edit("\b")
|
|
1380
|
+
expect(editor.text).to eq('')
|
|
1381
|
+
end
|
|
1382
|
+
|
|
1383
|
+
it 'exposes steering and cancellation as operational states rather than simulated progress' do
|
|
1384
|
+
control = PWN::Plugins::REPL::AIConsole::Control.new(input: StringIO.new, output: StringIO.new)
|
|
1385
|
+
control.submit('revised task')
|
|
1386
|
+
expect(control.phase.to_s).to include('steering')
|
|
1387
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
1388
|
+
console.instance_variable_set(:@worker, Thread.current)
|
|
1389
|
+
console.instance_variable_set(:@cancelling, true)
|
|
1390
|
+
expect(console.state).to include('cancelling')
|
|
1391
|
+
end
|
|
1392
|
+
|
|
1393
|
+
it 'keeps network slash commands off the event thread so tool prompts can receive explicit input' do
|
|
1394
|
+
entered = Queue.new
|
|
1395
|
+
expect(PWN::AI::Agent::Loop).not_to receive(:run)
|
|
1396
|
+
expect(PWN::Plugins::REPL).to receive(:pwn_ai_dispatch_slash!).with(request: '/mcp call fixture', pry: anything) do
|
|
1397
|
+
entered << true
|
|
1398
|
+
puts "local command received: #{$stdin.gets}"
|
|
1399
|
+
true
|
|
1400
|
+
end
|
|
1401
|
+
keys = "/mcp call fixture\n".chars
|
|
1402
|
+
sent = false
|
|
1403
|
+
launch(keys) do
|
|
1404
|
+
if !sent && !entered.empty?
|
|
1405
|
+
sent = true
|
|
1406
|
+
keys.concat("/input ready\n".chars)
|
|
1407
|
+
nil
|
|
1408
|
+
elsif @paint.any? { |row| row.include?('local command received: ready') }
|
|
1409
|
+
"\u0004"
|
|
1410
|
+
end
|
|
1411
|
+
end
|
|
1412
|
+
end
|
|
1413
|
+
|
|
1414
|
+
it 'routes the actual pwn-ai command into the default fullscreen console and leaves the mode on return' do
|
|
1415
|
+
repl = PWN::Plugins::REPL
|
|
1416
|
+
repl.add_commands
|
|
1417
|
+
pi = Pry.new
|
|
1418
|
+
pi.config.pwn_ai_startup_session_id = 'prepared-console-session'
|
|
1419
|
+
allow(PWN::ModuleSkills).to receive(:install)
|
|
1420
|
+
allow(PWN::Config).to receive(:load_skills)
|
|
1421
|
+
allow(PWN::Config).to receive(:load_memory)
|
|
1422
|
+
allow(PWN::Memory).to receive(:load).and_return({})
|
|
1423
|
+
allow(PWN::Cron).to receive(:list).and_return({})
|
|
1424
|
+
expect(PWN::Plugins::REPL::AIConsole).to receive(:run).with(pry: pi).and_return(:closed)
|
|
1425
|
+
expect(repl).to receive(:leave_special_mode!).with(pry: pi)
|
|
1426
|
+
Pry::Commands.find_command('pwn-ai').new(pry_instance: pi, output: StringIO.new).process
|
|
1427
|
+
expect(pi.config.pwn_ai_session_id).to eq('prepared-console-session')
|
|
1428
|
+
end
|
|
1429
|
+
|
|
1430
|
+
it 'provides a public curses console with an honest non-terminal fallback' do
|
|
1431
|
+
expect(PWN::Plugins::REPL.const_defined?(:AIConsole)).to be true
|
|
1432
|
+
expect(PWN::Plugins::REPL::AIConsole.run(pry: Pry.new, input: StringIO.new, output: StringIO.new)).to eq(:unavailable)
|
|
1433
|
+
end
|
|
1434
|
+
end
|