pwn 0.5.761 → 0.5.762

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.
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'pry'
5
+
6
+ RSpec.describe 'Swarm solve' do
7
+ include_context 'pwn tmp sandbox'
8
+
9
+ before do
10
+ @workspace = File.join(@tmp, 'work')
11
+ FileUtils.mkdir_p(@workspace)
12
+ allow(PWN::AI::Agent::Swarm).to receive(:build_persona_prompt).and_return('fixture')
13
+ allow(PWN::AI::Agent::Swarm).to receive(:child_inbox).and_return({})
14
+ allow(PWN::AI::Agent::Swarm).to receive(:child_honesty).and_return({})
15
+ allow(PWN::AI::Agent::Swarm).to receive(:bus_append).and_return({})
16
+ @turns = []
17
+ @bad = false
18
+ @reject = false
19
+ @repair = false
20
+ allow(PWN::AI::Agent::Loop).to receive(:run) do |opts|
21
+ handoff = JSON.parse(opts[:system_role_content].split("SOLVE HANDOFF\n").last, symbolize_names: true)
22
+ @turns << [opts, handoff]
23
+ revision = handoff[:revision]
24
+ answer = case handoff[:phase]
25
+ when 'assess' then { requirements: ['The file contains the requested greeting'], risks: ['Wrong bytes'] }
26
+ when 'build'
27
+ File.write(File.join(@workspace, 'greeting.txt'), @bad ? 'wrong' : 'hello')
28
+ { artifacts: ['greeting.txt'], summary: 'candidate' }
29
+ when 'critique' then { revision: revision, objections: @reject ? ['Wrong output'] : [] }
30
+ when 'verify'
31
+ { revision: revision, checks: [{ requirements: handoff[:requirements].keys, command: 'cat greeting.txt', stdout: 'hello' }] }
32
+ when 'integrate'
33
+ @bad = false if @repair
34
+ { revision: revision, decision: handoff[:evidence].all? { |row| row[:passed] } ? 'accept' : 'repair', repair: 'Write hello', summary: 'Greeting delivered' }
35
+ end
36
+ @hook&.call(handoff, answer)
37
+ JSON.generate(answer)
38
+ end
39
+ end
40
+
41
+ it 'executes the real nested Loop with a deterministic provider and host tool' do
42
+ allow(PWN::AI::Agent::Loop).to receive(:run).and_call_original
43
+ allow(PWN::AI::Agent::TaskSummarizer).to receive(:enabled?).and_return(false)
44
+ PWN::Env[:ai][:active] = :openai
45
+ PWN::Env[:ai][:openai] = { key: 'offline-fixture-key' }
46
+ @agent_cfg[:plan_first] = false
47
+ runtime = double('enclosing request route')
48
+ expect(runtime).not_to receive(:call)
49
+ Thread.current[:pwn_request_runtime] = runtime
50
+ allow(PWN::AI::OpenAI).to receive(:chat_with_tools) do |opts|
51
+ system = opts[:messages].find { |row| row[:role].to_s == 'system' }[:content]
52
+ handoff = JSON.parse(system.split("SOLVE HANDOFF\n").last, symbolize_names: true)
53
+ phase = handoff[:phase]
54
+ revision = handoff[:revision]
55
+ content = case phase
56
+ when 'assess' then { requirements: ['Greeting bytes'], risks: [] }
57
+ when 'build'
58
+ next({ choices: [{ message: { role: 'assistant', content: nil, tool_calls: [{ id: 'write-greeting', type: 'function', function: { name: 'shell', arguments: JSON.generate(command: "printf hello > #{Shellwords.escape(File.join(@workspace, 'greeting.txt'))}", timeout: 5) } }] } }] }) unless File.exist?(File.join(@workspace, 'greeting.txt'))
59
+
60
+ { artifacts: ['greeting.txt'], summary: 'written' }
61
+ when 'critique' then { revision: revision, objections: [] }
62
+ when 'verify' then { revision: revision, checks: [{ requirements: %w[R0 R1], command: 'cat greeting.txt', stdout: 'hello' }] }
63
+ when 'integrate' then { revision: revision, decision: 'accept', summary: 'Verified greeting' }
64
+ end
65
+ { choices: [{ message: { role: 'assistant', content: JSON.generate(content), tool_calls: [] } }] }
66
+ end
67
+ require 'timeout'
68
+ controller = PWN::Plugins::REPL::AISwarm::Controller.new(session_id: 'real-loop-fixture')
69
+ out = Timeout.timeout(15) do
70
+ controller.execute(line: '/swarm mission Write hello to greeting.txt', workspace: @workspace)
71
+ controller.send(:workers).last.value
72
+ end
73
+ expect(out).to include(status: 'accepted')
74
+ expect(out[:evidence].first).to include(stdout: 'hello', exitstatus: 0)
75
+ ensure
76
+ controller&.close
77
+ Thread.current[:pwn_request_runtime] = nil
78
+ end
79
+
80
+ it 'runs all roles with the unchanged request and accepts only executed evidence for the artifact revision' do
81
+ request = 'Write the greeting hello to greeting.txt'
82
+ states = []
83
+ out = PWN::AI::Agent::Swarm.solve(
84
+ request: request, workspace: @workspace, on_state: ->(state) { states << state },
85
+ on_tool: ->(*) { expect(states.last[:roles]['verifier']).to eq('checking') }
86
+ )
87
+ expect(out).to include(ok: true, status: 'accepted', request: request)
88
+ expect(@turns.map { |_, handoff| handoff[:phase] }).to eq(%w[assess build critique verify integrate])
89
+ expect(@turns.map { |opts, _| opts[:request] }.uniq).to eq([request])
90
+ expect(out[:evidence].first).to include(passed: true, stdout: 'hello', exitstatus: 0, revision: out[:revision])
91
+ expect(File.read(File.join(@workspace, 'greeting.txt'))).to eq('hello')
92
+ record = JSON.parse(File.read(out[:record_path]))
93
+ expect(record['revision']).to eq(out[:revision])
94
+ expect(record['evidence'].first['stdout']).to eq('hello')
95
+ expect(record['records']).to include(include('phase' => 'candidate', 'revision' => out[:revision], 'artifacts' => out[:artifacts]))
96
+ expect(out[:reply]).to include('R0', 'greeting.txt', out[:revision])
97
+ end
98
+
99
+ it 'routes the legacy slash entry to an executed solve, not a normal model request' do
100
+ pry = Pry.new
101
+ controller = nil
102
+ Dir.chdir(@workspace) do
103
+ expect(PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: '/swarm mission Write hello to greeting.txt', pry: pry)).to be(true)
104
+ controller = pry.config.pwn_ai_swarm
105
+ expect(controller).to be_a(PWN::Plugins::REPL::AISwarm::Controller)
106
+ controller.send(:workers).each(&:join)
107
+ expect(controller.snapshot[:jobs].last[:state]).to eq('accepted')
108
+ end
109
+ ensure
110
+ controller&.close
111
+ end
112
+
113
+ it 'binds identical artifact bytes to the specific objective and solve run' do
114
+ first = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace)
115
+ second = PWN::AI::Agent::Swarm.solve(request: 'Write hello and inspect it', workspace: @workspace)
116
+ expect(first[:artifacts]).to eq(second[:artifacts])
117
+ expect(first[:revision]).not_to eq(second[:revision])
118
+ end
119
+
120
+ it 'rejects failing checks even when integration claims accept' do
121
+ @bad = true
122
+ @hook = ->(handoff, answer) { answer[:decision] = 'accept' if handoff[:phase] == 'integrate' }
123
+ out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, rounds: 1)
124
+ expect(out).to include(status: 'incomplete', ok: false)
125
+ expect(out[:evidence].first).to include(stdout: 'wrong', exitstatus: 0, passed: false)
126
+ end
127
+
128
+ it 'repairs through the sole writer and reruns verification against changed bytes' do
129
+ @bad = @repair = true
130
+ out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace)
131
+ expect(out[:status]).to eq('accepted')
132
+ builds = @turns.select { |_, row| row[:phase] == 'build' }
133
+ expect(builds.length).to eq(2)
134
+ expect(builds.last.last[:repair]).to include(instruction: 'Write hello')
135
+ verifications = out[:records].select { |row| row[:phase] == 'verification' }
136
+ expect(verifications.map { |row| row[:evidence].first[:passed] }).to eq([false, true])
137
+ expect(out[:records].select { |row| row[:phase] == 'verify' }.map { |row| row[:revision] }.uniq.length).to eq(2)
138
+ end
139
+
140
+ it 'does not accept unresolved objections or missing requirement coverage' do
141
+ @reject = true
142
+ out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, rounds: 1)
143
+ expect(out[:status]).to eq('incomplete')
144
+ expect(out[:objections]).to eq(['Wrong output'])
145
+ @reject = false
146
+ @hook = ->(handoff, answer) { answer[:checks].first[:requirements] = ['R1'] if handoff[:phase] == 'verify' }
147
+ out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, rounds: 1)
148
+ expect(out).to include(status: 'incomplete', uncovered: ['R0'])
149
+ end
150
+
151
+ it 'invalidates evidence if the working artifact changes during integration' do
152
+ @hook = ->(handoff, _answer) { File.write(File.join(@workspace, 'greeting.txt'), 'changed') if handoff[:phase] == 'integrate' }
153
+ expect(PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, rounds: 1)[:status]).to eq('incomplete')
154
+ end
155
+
156
+ it 'rejects stale handoffs and copy-modifying verification' do
157
+ @hook = ->(handoff, answer) { answer[:revision] = 'old' if handoff[:phase] == 'critique' }
158
+ out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace)
159
+ expect(out[:reply]).to include('stale revision')
160
+ @hook = lambda do |handoff, answer|
161
+ answer[:checks].first[:command] = 'printf wrong > greeting.txt; printf hello' if handoff[:phase] == 'verify'
162
+ end
163
+ out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, rounds: 1)
164
+ expect(out[:status]).to eq('incomplete')
165
+ expect(File.read(File.join(@workspace, 'greeting.txt'))).to eq('hello')
166
+ end
167
+
168
+ it 'propagates steering to every later role and invalidates the previous candidate' do
169
+ control = PWN::AI::Agent::Solve::Control.new(input: StringIO.new, output: StringIO.new)
170
+ steered = false
171
+ @hook = lambda do |handoff, _answer|
172
+ if handoff[:phase] == 'integrate' && !steered
173
+ control.submit('Also verify exact bytes')
174
+ steered = true
175
+ end
176
+ end
177
+ out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, steering: control)
178
+ expect(out[:status]).to eq('accepted')
179
+ expect(@turns.count { |_, row| row[:phase] == 'build' }).to eq(2)
180
+ expect(out[:requirements]).to include('S1' => 'Also verify exact bytes')
181
+ expect(@turns.last(4).all? { |opts, row| opts[:request] == 'Write hello' && row[:guidance] == ['Also verify exact bytes'] }).to be(true)
182
+ end
183
+
184
+ it 'cancels at a tool boundary without claiming completion or undoing work' do
185
+ control = PWN::AI::Agent::Solve::Control.new(input: StringIO.new, output: StringIO.new)
186
+ @hook = ->(handoff, _answer) { control.stop if handoff[:phase] == 'build' }
187
+ out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, steering: control)
188
+ expect(out[:status]).to eq('cancelled')
189
+ expect(File.read(File.join(@workspace, 'greeting.txt'))).to eq('hello')
190
+ expect(@turns.length).to eq(2)
191
+ end
192
+
193
+ it 'refuses unconfigured providers before any role or login runs' do
194
+ PWN::Env[:ai][:active] = :openai
195
+ PWN::Env[:ai][:openai] = {}
196
+ expect(PWN::AI::Agent::Loop).not_to receive(:run)
197
+ out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace)
198
+ expect(out[:reply]).to include('configure openai credentials')
199
+ end
200
+
201
+ it 'enforces read-only reviewers at Dispatch even for invented tool calls' do
202
+ @hook = lambda do |handoff, _answer|
203
+ next unless handoff[:phase] == 'critique'
204
+
205
+ raw = PWN::AI::Agent::Dispatch.call(tool_call: { function: { name: 'shell', arguments: JSON.generate(command: "touch #{Shellwords.escape(File.join(@workspace, 'forbidden'))}") } })
206
+ expect(JSON.parse(raw)['success']).to be(false)
207
+ end
208
+ PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace)
209
+ expect(File).not_to exist(File.join(@workspace, 'forbidden'))
210
+ expect(Thread.current[:pwn_solve_tools]).to be_nil
211
+ end
212
+
213
+ it 'routes configured roles without switching defaults or modifying the global registry' do
214
+ PWN::AI::Agent::Swarm.spawn(name: 'solve_antagonist', role: 'Review', engine: :ollama, model: 'Exact/Reviewer:Tag')
215
+ registry = File.binread(PWN::AI::Agent::Swarm::AGENTS_FILE)
216
+ routes = []
217
+ @hook = ->(handoff, _answer) { routes << [handoff[:role], Thread.current[:pwn_swarm_engine], Thread.current[:pwn_swarm_model]] }
218
+ out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace)
219
+ expect(out[:status]).to eq('accepted')
220
+ expect(routes.select { |row| row.first == 'antagonist' }).to all(eq(['antagonist', 'ollama', 'Exact/Reviewer:Tag']))
221
+ expect(File.binread(PWN::AI::Agent::Swarm::AGENTS_FILE)).to eq(registry)
222
+ expect(Thread.current[:pwn_swarm_model]).to be_nil
223
+ end
224
+
225
+ it 'never opens OAuth or a credential prompt during a solve provider call' do
226
+ Thread.current[:pwn_solve_tools] = []
227
+ { openai: [PWN::AI::OpenAI, :open_ai_rest_call], anthropic: [PWN::AI::Anthropic, :anthropic_rest_call], grok: [PWN::AI::Grok, :grok_rest_call] }.each do |engine, (provider, method)|
228
+ PWN::Env[:ai][engine] = {}
229
+ expect(provider).not_to receive(:obtain_oauth_bearer_token)
230
+ expect(PWN::Plugins::AuthenticationHelper).not_to receive(:mask_password)
231
+ expect(provider.send(method, rest_call: 'fixture')).to be_nil
232
+ end
233
+ ensure
234
+ Thread.current[:pwn_solve_tools] = nil
235
+ end
236
+
237
+ it 'pauses dispatch, resumes, and cancels through the coordinator command entry' do
238
+ entered = Queue.new
239
+ release = Queue.new
240
+ @hook = lambda do |handoff, _answer|
241
+ if handoff[:phase] == 'assess'
242
+ entered << true
243
+ release.pop
244
+ end
245
+ end
246
+ controller = PWN::Plugins::REPL::AISwarm::Controller.new(session_id: 'fixture')
247
+ job = controller.execute(line: '/swarm mission Write hello', workspace: @workspace)
248
+ entered.pop
249
+ expect(controller.execute(line: "/swarm pause #{job[:job_id]}")[:ok]).to be(true)
250
+ release << true
251
+ expect(controller.snapshot[:jobs].last[:state]).to eq('paused')
252
+ expect(controller.execute(line: '/swarm dm other write')[:ok]).to be(false)
253
+ expect(controller.execute(line: "/swarm steer #{job[:job_id]} Check bytes")[:ok]).to be(true)
254
+ expect(controller.execute(line: "/swarm resume #{job[:job_id]}")[:ok]).to be(true)
255
+ controller.execute(line: "/swarm cancel #{job[:job_id]}")
256
+ expect(controller.send(:workers).last.join(3)).not_to be_nil
257
+ expect(controller.snapshot[:jobs].last[:state]).to eq('cancelled')
258
+ ensure
259
+ release << true if release
260
+ controller&.close
261
+ end
262
+
263
+ it 'bounds a nonconverging role without inventing a final answer' do
264
+ control = PWN::AI::Agent::Solve::Control.new(input: StringIO.new, output: StringIO.new)
265
+ control.begin_turn
266
+ 25.times { control.model(messages: []) { :fixture } }
267
+ expect { control.model(messages: []) { :unreachable } }.to raise_error(PWN::AI::Agent::Solve::Control::Limit)
268
+ end
269
+
270
+ it 'runs a draft-preserving Mission with team through the curses confirmation entry' do
271
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
272
+ controller = PWN::Plugins::REPL::AISwarm::Controller.new(session_id: 'fixture')
273
+ console.instance_variable_set(:@swarm, controller)
274
+ editor = console.instance_variable_get(:@editor)
275
+ editor.place('Write hello to greeting.txt', 4)
276
+ Dir.chdir(@workspace) do
277
+ console.handle("\u0007")
278
+ console.handle('v')
279
+ expect(controller.snapshot[:jobs]).to be_empty
280
+ expect(console.swarm_content.join).to include('mission')
281
+ console.handle("\r")
282
+ controller.send(:workers).each(&:join)
283
+ expect(controller.snapshot[:jobs].last).to include(state: 'accepted', team: include(roles: include('verifier' => 'done')))
284
+ console.handle("\e")
285
+ expect([editor.text, editor.cursor]).to eq(['Write hello to greeting.txt', 4])
286
+ end
287
+ ensure
288
+ controller&.close
289
+ end
290
+ end
@@ -3,6 +3,42 @@
3
3
  require 'spec_helper'
4
4
 
5
5
  describe PWN::Plugins::REPL::AIConsoleCommands do
6
+ it 'advertises the renamed swarm surface in both completers' do
7
+ line = '/swarm '
8
+ labels = described_class.complete(line: line, cursor: line.length)[:items].map { |item| item[:label] }
9
+ legacy = PWN::Plugins::REPL.pwn_ai_complete_command(line: line)
10
+ [labels, legacy].each do |commands|
11
+ expect(commands).to include('mission', 'agents', 'dm')
12
+ expect(commands & %w[solve roster ask]).to be_empty
13
+ end
14
+ end
15
+
16
+ it 'sorts every command group and nested live provider by displayed label' do
17
+ values = %w[zulu alpha Alpha Bravo]
18
+ allow(PWN::Plugins::REPL).to receive(:pwn_ai_engines).and_return(values)
19
+ allow(PWN::Cron).to receive(:list).and_return(values.to_h { |value| [value, {}] })
20
+ allow(PWN::Memory).to receive(:load).and_return(values.to_h { |value| [value, {}] })
21
+ allow(PWN::Sessions).to receive(:list).and_return(values.map { |value| { id: value } })
22
+ stub_const('PWN::Skills', values.to_h { |value| [value, {}] })
23
+ allow(PWN::AI::MCP).to receive(:backends).and_return(values.map { |value| { name: value, tools: values } })
24
+ controller = double(completion_context: { agents: values, jobs: values.map { |value| { id: value } } })
25
+ allow(Dir).to receive(:exist?).with(PWN::AI::Agent::Swarm::SWARM_ROOT).and_return(true)
26
+ allow(Dir).to receive(:children).with(PWN::AI::Agent::Swarm::SWARM_ROOT).and_return(values)
27
+ lines = ['/'] + described_class::COMMANDS.map { |command| "#{command} " } +
28
+ ['/cron run ', '/memory recall ', '/sessions resume ', '/skills recall ', '/model list ', '/learning list ',
29
+ '/mcp use ', '/mcp call ', '/swarm dm ', '/swarm cancel ', '/swarm use ', '/swarm debate ']
30
+ lines.each do |line|
31
+ items = described_class.complete(line: line, cursor: line.length, swarm: controller)[:items]
32
+ labels = items.map { |item| item[:label] }
33
+ expect(labels).to eq(labels.sort_by { |label| [label.downcase, label] }), line
34
+ items.each { |item| expect(item[:text]).to eq(line == '/' ? item[:label] : "#{line}#{item[:label]}") }
35
+ end
36
+ line = '/swarm dm '
37
+ expect(described_class.complete(line: line, cursor: line.length, swarm: controller)[:items].first[:label]).to eq('Alpha')
38
+ values << 'aardvark'
39
+ expect(described_class.complete(line: line, cursor: line.length, swarm: controller)[:items].first[:label]).to eq('aardvark')
40
+ end
41
+
6
42
  it 'completes command names and parameter positions without a provider call' do
7
43
  allow(PWN::Plugins::REPL).to receive(:pwn_ai_engines).and_return(%w[openai grok])
8
44
  first = described_class.complete(line: '/mo', cursor: 3)
@@ -13,7 +49,7 @@ describe PWN::Plugins::REPL::AIConsoleCommands do
13
49
  expect(engines[:items].map { |item| item[:label] }).to include('openai', 'list')
14
50
  expect(engines[:items].map { |item| item[:text] }).to include('/model openai')
15
51
  verbose = described_class.complete(line: '/verbose ', cursor: 9)
16
- expect(verbose[:items].map { |item| item[:label] }).to eq(%w[on off])
52
+ expect(verbose[:items].map { |item| item[:label] }).to eq(%w[off on])
17
53
  free = described_class.complete(line: '/steer keep ', cursor: 12)
18
54
  expect(free[:items]).to be_empty
19
55
  expect(free[:hint]).to include('free text')
@@ -17,7 +17,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
17
17
  allow(PWN::AI::Agent::Loop).to receive(:may_finalize?).and_return(true)
18
18
  end
19
19
 
20
- def launch(keys, &tick)
20
+ def launch(keys, startup_delay: 0, render_delay: 0, &tick)
21
21
  require 'pty'
22
22
  require 'timeout'
23
23
  master, input = PTY.open
@@ -28,7 +28,10 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
28
28
  frame = []
29
29
  position = [0, 0]
30
30
  allow(window).to receive(:erase) { frame = [] }
31
- allow(window).to receive(:refresh) { @rendered = frame.dup }
31
+ allow(window).to receive(:refresh) do
32
+ sleep render_delay if render_delay.positive? && !keys.empty?
33
+ @rendered = frame.dup
34
+ end
32
35
  @positions = []
33
36
  allow(window).to receive(:setpos) do |*coords|
34
37
  position = coords
@@ -42,6 +45,10 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
42
45
  allow(window).to receive(:attron) { |_attribute, &block| block.call }
43
46
  screen = double('curses', init_screen: window, raw: nil, noecho: nil, curs_set: nil, close_screen: nil,
44
47
  lines: 30, cols: 110, stdscr: window, has_colors?: false, resizeterm: nil)
48
+ allow(screen).to receive(:init_screen) do
49
+ sleep startup_delay if startup_delay.positive?
50
+ window
51
+ end
45
52
  @pry = Pry.new
46
53
  @pry.config.pwn_ai_session_id = PWN::Sessions.create(title: 'console')[:id]
47
54
  @pry.config.pwn_ai = true
@@ -258,28 +265,51 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
258
265
  foreign&.join
259
266
  end
260
267
 
261
- it 'leaves a provider blocked in native IO without waiting for that read to return' do
262
- started = nil
263
- allow(PWN::AI::Agent::Loop).to receive(:call_engine) do
264
- Thread.handle_interrupt(Exception => :never) { sleep 8 }
265
- end
266
- keys = "what color is a lemon?\n".chars
267
- cancelled = false
268
- launch(keys) do
269
- if !cancelled && @paint.any? { |row| row.include?('Elapsed') }
270
- cancelled = true
271
- # Time cancellation, not typing, cold artwork or mock screen painting.
272
- started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
273
- "\u0003"
274
- elsif @paint.any? { |row| row.include?('Request cancelled at a safe boundary') }
275
- "\u0004"
276
- else
277
- Thread.pass
278
- nil
268
+ [0, 3.1].each do |startup_delay|
269
+ it "leaves a provider blocked in native IO without waiting for that read to return (startup #{startup_delay}s)" do
270
+ entered = Queue.new
271
+ returned = Queue.new
272
+ provider = nil
273
+ started = nil
274
+ allow(PWN::AI::Agent::Loop).to receive(:call_engine) do
275
+ Thread.handle_interrupt(Exception => :never) do
276
+ entered << Thread.current
277
+ sleep 8
278
+ returned << true
279
+ end
279
280
  end
281
+ keys = "what color is a lemon?\n".chars
282
+ launched = Process.clock_gettime(Process::CLOCK_MONOTONIC)
283
+ launch(keys, startup_delay: startup_delay, render_delay: startup_delay.positive? ? 0.02 : 0) do
284
+ provider ||= entered.pop unless entered.empty?
285
+ if !started && provider&.status == 'sleep'
286
+ # Elapsed is painted before call_engine: it is not a provider barrier.
287
+ # Time from delivered Ctrl+C, excluding startup and per-key rendering.
288
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
289
+ "\u0003"
290
+ elsif @paint.any? { |row| row.include?('Request cancelled at a safe boundary') }
291
+ "\u0004"
292
+ else
293
+ Thread.pass
294
+ nil
295
+ end
296
+ end
297
+ expect(started).not_to be_nil
298
+ expect(started - launched).to be >= startup_delay
299
+ expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be < 3
300
+ expect(returned).to be_empty
301
+ ensure
302
+ # Cancellation deliberately abandons masked IO. Release this fixture only
303
+ # after measuring return, then reap it before RSpec removes its mocks.
304
+ provider ||= entered.pop unless entered.empty?
305
+ begin
306
+ provider&.wakeup if provider&.alive?
307
+ rescue ThreadError
308
+ nil # The provider exited between alive? and wakeup.
309
+ end
310
+ provider&.join(2)
311
+ expect(provider).not_to be_alive if provider
280
312
  end
281
- expect(started).not_to be_nil
282
- expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be < 3
283
313
  end
284
314
 
285
315
  it 'forwards tool input, preserves completed evidence before steering, and locks settings while running' do
@@ -345,7 +375,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
345
375
  console.seed_request_history
346
376
  '/verbose '.each_char { |key| console.handle(key) }
347
377
  menu = console.instance_variable_get(:@menu)
348
- expect(menu.map { |item| item[:label] }).to eq(%w[on off])
378
+ expect(menu.map { |item| item[:label] }).to eq(%w[off on])
349
379
  draft = [editor.text.dup, editor.cursor]
350
380
  console.handle(:up)
351
381
  expect(console.instance_variable_get(:@menu_index)).to eq(1)
@@ -377,7 +407,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
377
407
  '/verbose '.each_char { |key| console.handle(key) }
378
408
  console.handle(:down)
379
409
  console.handle("\t")
380
- expect(editor.text.strip).to eq('/verbose off')
410
+ expect(editor.text.strip).to eq('/verbose on')
381
411
  console.handle("\u0015")
382
412
  '/verbose '.each_char { |key| console.handle(key) }
383
413
  console.handle(:up)
@@ -1490,7 +1520,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
1490
1520
  it 'keeps slash parameter completion live across spaces and clears stale menus on submit' do
1491
1521
  console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
1492
1522
  '/verbose '.chars.each { |key| console.handle(key) }
1493
- expect(console.instance_variable_get(:@menu).map { |item| item[:label] }).to eq(%w[on off])
1523
+ expect(console.instance_variable_get(:@menu).map { |item| item[:label] }).to eq(%w[off on])
1494
1524
  console.handle('o')
1495
1525
  console.handle('f')
1496
1526
  console.handle('f')
@@ -5,9 +5,69 @@ require 'spec_helper'
5
5
  describe PWN::Plugins::REPL::AISwarm::Controller do
6
6
  include_context 'pwn tmp sandbox'
7
7
 
8
+ it 'routes renamed commands and missing arguments through both console entry points' do
9
+ allow(PWN::AI::Agent::Swarm).to receive(:personas).and_return(scout: { role: 'Inspect' })
10
+ allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture')
11
+ allow(PWN::AI::Agent::Swarm).to receive(:ask).and_return(reply: 'Direct reply')
12
+ pry = Pry.new
13
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: pry, input: StringIO.new, curses: nil, getch: nil)
14
+ controller = described_class.new(session_id: 'fixture')
15
+ console.instance_variable_set(:@swarm, controller)
16
+ pry.config.pwn_ai_swarm = controller
17
+ ['/swarm mission', '/swarm dm', '/swarm dm scout'].each do |line|
18
+ usage = line.include?('mission') ? 'usage: mission REQUEST' : 'usage: dm NAME REQUEST'
19
+ console.submit(line)
20
+ expect(console.instance_variable_get(:@timeline).last[1]).to include(usage)
21
+ expect { PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: line, pry: pry) }.to output(/#{usage}/).to_stdout
22
+ end
23
+ expect(controller.snapshot[:jobs]).to be_empty
24
+ %w[solve roster ask].each do |old|
25
+ expect(controller.execute(line: "/swarm #{old}")[:error]).to eq("unknown /swarm command: #{old}")
26
+ end
27
+ console.submit('/swarm agents')
28
+ expect(console.instance_variable_get(:@timeline).last[1]).to include('scout')
29
+ expect { PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: '/swarm agents', pry: pry) }.to output(/scout/).to_stdout
30
+ expect { PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: '/swarm dm scout inspect', pry: pry) }.to output(/queued/).to_stdout
31
+ controller.send(:workers).each(&:join)
32
+ expect(controller.snapshot[:jobs].last).to include(command: 'dm', result: 'Direct reply')
33
+ ensure
34
+ controller&.close
35
+ end
36
+
37
+ it 'rejects a mission without an original request before starting work' do
38
+ controller = described_class.new(session_id: 'fixture')
39
+ expect(PWN::AI::Agent::Swarm).not_to receive(:create)
40
+ expect(controller.execute(line: '/swarm mission')).to include(ok: false, error: 'usage: mission REQUEST')
41
+ ensure
42
+ controller&.close
43
+ end
44
+
45
+ it 'returns visible errors for malformed commands and synchronous backend failures' do
46
+ controller = described_class.new(session_id: 'fixture')
47
+ expect(controller.execute(line: '/swarm pause absent')).to include(ok: false, error: 'job not found')
48
+ ensure
49
+ controller&.close
50
+ end
51
+
52
+ it 'preserves solve request bytes and exposes real team state and incomplete outcomes' do
53
+ request = "Write 'hello'\nthen verify --literal"
54
+ allow(PWN::AI::Agent::Swarm).to receive(:solve) do |opts|
55
+ expect(opts[:request]).to eq(request)
56
+ expect(opts[:steering]).to be_a(PWN::AI::Agent::Solve::Control)
57
+ opts[:on_state].call(roles: { protagonist: 'build', antagonist: 'done', verifier: 'waiting', integrator: 'waiting' })
58
+ { status: 'incomplete', reply: 'INCOMPLETE: missing check' }
59
+ end
60
+ controller = described_class.new(session_id: 'fixture')
61
+ controller.execute(line: "/swarm mission #{request}")
62
+ controller.send(:workers).each(&:join)
63
+ expect(controller.snapshot[:jobs].first).to include(state: 'incomplete', team: include(roles: include(protagonist: 'build')))
64
+ ensure
65
+ controller&.close
66
+ end
67
+
8
68
  it 'returns visible errors for malformed commands and synchronous backend failures' do
9
69
  controller = described_class.new(session_id: 'fixture')
10
- expect(controller.execute(line: '/swarm ask scout "unfinished')[:ok]).to be(false)
70
+ expect(controller.execute(line: '/swarm dm scout "unfinished')[:ok]).to be(false)
11
71
  allow(PWN::AI::Agent::Swarm).to receive(:create).and_raise(IOError, 'fixture storage unavailable')
12
72
  expect(controller.execute(line: '/swarm create fixture')).to include(ok: false, error: 'IOError: fixture storage unavailable')
13
73
  ensure
@@ -20,7 +80,7 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
20
80
  allow(PWN::AI::Agent::Swarm).to receive(:ask).and_return(reply: 'Observed fixture result')
21
81
  controller = described_class.new(session_id: 'fixture')
22
82
  expect(controller.roster).to include(include(name: 'scout', role: 'Inspect evidence', engine: 'ollama', model: 'fixture'))
23
- queued = controller.execute(line: '/swarm ask scout inspect')
83
+ queued = controller.execute(line: '/swarm dm scout inspect')
24
84
  controller.send(:workers).each(&:join)
25
85
  expect(controller.snapshot[:jobs].first).to include(state: 'completed', result: 'Observed fixture result')
26
86
  expect(controller.execute(line: "/swarm steer #{queued[:job_id]} again")[:ok]).to be(false)
@@ -40,7 +100,7 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
40
100
  { reply: 'done' }
41
101
  end
42
102
  controller = described_class.new(session_id: 'fixture')
43
- controller.execute(line: '/swarm ask scout inspect')
103
+ controller.execute(line: '/swarm dm scout inspect')
44
104
  owner, worker, local, marker = entered.pop
45
105
  expect(owner).to eq(worker)
46
106
  expect(local).to eq(:copied)
@@ -64,7 +124,7 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
64
124
  controller.send(:workers).each(&:join)
65
125
  expect(calls.first[:request]).to eq('--literal mission')
66
126
  expect(calls.last[:request]).to include('reply from scout', '--literal mission')
67
- controller.execute(line: '/swarm ask broken inspect')
127
+ controller.execute(line: '/swarm dm broken inspect')
68
128
  controller.send(:workers).each(&:join)
69
129
  expect(controller.snapshot[:jobs].last).to include(state: 'failed', error: 'IOError: fixture unavailable')
70
130
  ensure
@@ -83,7 +143,7 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
83
143
  opts[:steering].checkpoint(messages: [])
84
144
  end
85
145
  controller = described_class.new(session_id: 'fixture')
86
- job = controller.execute(line: '/swarm ask scout inspect')
146
+ job = controller.execute(line: '/swarm dm scout inspect')
87
147
  entered.pop
88
148
  expect(controller.execute(line: '/swarm create other')[:ok]).to be(false)
89
149
  expect(controller.execute(line: '/swarm use other')[:ok]).to be(false)
@@ -92,7 +152,7 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
92
152
  expect(controller.snapshot[:jobs].first).to include(state: 'cancelled')
93
153
  expect(foreign).to be_alive
94
154
  controller.close
95
- expect(controller.execute(line: '/swarm ask scout again')[:ok]).to be(false)
155
+ expect(controller.execute(line: '/swarm dm scout again')[:ok]).to be(false)
96
156
  ensure
97
157
  controller&.close
98
158
  foreign&.kill&.join
@@ -111,7 +171,7 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
111
171
  allow(PWN::AI::Agent::Swarm).to receive(:personas).and_return(scout: { role: 'scout' })
112
172
  allow(PWN::AI::Agent::Swarm).to receive(:spawn).and_return(name: 'scout')
113
173
  controller.execute(line: '/swarm spawn scout inspect evidence')
114
- queued = controller.execute(line: '/swarm ask scout inspect the fixture', on_event: ->(type, text) { events << [type, text] })
174
+ queued = controller.execute(line: '/swarm dm scout inspect the fixture', on_event: ->(type, text) { events << [type, text] })
115
175
  steering = entered.pop
116
176
  expect(queued[:ok]).to eq(true)
117
177
  expect(controller.snapshot[:jobs].first[:state]).to eq('running')