pwn 0.5.782 → 0.5.783

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.
@@ -5,6 +5,34 @@ require 'tmpdir'
5
5
  require 'fileutils'
6
6
 
7
7
  describe PWN::AI::Agent::Learning do
8
+ describe 'worker lock ownership' do
9
+ include_context 'pwn tmp sandbox'
10
+
11
+ it 'does not inherit learning write ownership through copied request locals' do
12
+ lock = described_class.send(:learning_lock)
13
+ inherited = Thread.current[:pwn_learning_write_lock]
14
+ entered = Queue.new
15
+ acquired = Queue.new
16
+ worker = Thread.new do
17
+ Thread.current[:pwn_learning_write_lock] = inherited
18
+ entered << true
19
+ child_lock = described_class.send(:learning_lock)
20
+ acquired << child_lock
21
+ ensure
22
+ described_class.send(:learning_unlock, lock: child_lock) if child_lock
23
+ end
24
+ Timeout.timeout(2) { entered.pop }
25
+ expect(worker.join(0.05)).to be_nil
26
+ described_class.send(:learning_unlock, lock: lock)
27
+ lock = nil
28
+ expect(worker.join(2)).to eq(worker)
29
+ expect(acquired.pop).not_to be_nil
30
+ ensure
31
+ described_class.send(:learning_unlock, lock: lock) if lock
32
+ worker&.join(2)
33
+ end
34
+ end
35
+
8
36
  describe 'failure regression tasks' do
9
37
  include_context 'pwn tmp sandbox'
10
38
 
@@ -47,6 +75,41 @@ describe PWN::AI::Agent::Learning do
47
75
 
48
76
  after { FileUtils.rm_rf(@home) }
49
77
 
78
+ it 'migrates Unicode evidence byte-exactly and rolls back the original index' do
79
+ @trace.last[:content] = "café 日本語 🔒 e\u0301"
80
+ File.binwrite(@source, "#{@trace.map(&:to_json).join("\n")}\r\n")
81
+ original_source = File.binread(@source)
82
+ @original = "#{JSON.generate(session_id: @sid, task: 'résumé 日本語')}\r\n"
83
+ File.binwrite(@learning, @original)
84
+ expect(PWN::Sessions.dependency_index[:dependencies][@sid]).to include(@learning)
85
+ result = described_class.migrate_evidence(dry_run: false)
86
+ expect(result[:migrated_rows]).to eq(1)
87
+ row = JSON.parse(File.binread(@learning), symbolize_names: true)
88
+ expect(described_class.evidence_transcript(row: row)).to eq(@trace)
89
+ expect(described_class.evidence_complete?(row: row)).to be(true)
90
+ expect(File.binread(@source)).to eq(original_source)
91
+ migration = Digest::SHA256.hexdigest(@original)
92
+ backup = File.join(@root, 'learning', 'migrations', "#{migration}.backup.jsonl")
93
+ expect(File.binread(backup)).to eq(@original.b)
94
+ expect(described_class.rollback_evidence(migration: migration)[:restored]).to be(true)
95
+ expect(File.binread(@learning)).to eq(@original.b)
96
+ end
97
+
98
+ it 'verifies exact bytes without changing input encoding or accepting altered readback' do
99
+ target = File.join(@root, 'learning', 'probe.bin')
100
+ ['ASCII', 'café 日本語 🔒', "e\u0301", "\xFF\x00".b].each do |body|
101
+ encoding = body.encoding
102
+ original = body.dup
103
+ described_class.send(:evidence_write, path: target, body: body)
104
+ expect(File.binread(target)).to eq(original.b)
105
+ expect(body).to eq(original)
106
+ expect(body.encoding).to eq(encoding)
107
+ end
108
+ allow(described_class).to receive(:evidence_read).with(path: target).and_return('altered'.b)
109
+ expect { described_class.send(:evidence_write, path: target, body: 'café') }
110
+ .to raise_error(IOError, 'Evidence write verification failed')
111
+ end
112
+
50
113
  it 'keeps original references when artifact creation is interrupted and retries safely' do
51
114
  allow(described_class).to receive(:evidence_write).and_wrap_original do |method, opts|
52
115
  raise IOError, 'injected interruption' if opts[:path].include?('/evidence/')
@@ -82,7 +145,7 @@ describe PWN::AI::Agent::Learning do
82
145
  File.open(@source, 'a') { |file| file.puts(JSON.generate(role: 'user', content: 'new turn')) }
83
146
  expect(described_class.evidence_complete?(row: row)).to be(false)
84
147
  File.delete(artifact)
85
- expect(PWN::Sessions.dependency_index[:dependencies][@sid]).to include(@learning)
148
+ expect(PWN::Sessions.dependency_index[:session_errors][@sid]).to include(@learning)
86
149
  expect(PWN::Sessions.protected_session_ids(hot_days: -1)).to include(@sid)
87
150
  expect(PWN::Sessions.load(session_id: @sid)).not_to be_empty
88
151
  end
@@ -249,7 +312,7 @@ describe PWN::AI::Agent::Learning do
249
312
  expect(described_class.migrate_evidence(dry_run: false)[:migrated_rows]).to eq(0)
250
313
  artifact = File.join(root, 'learning', 'evidence', "#{row[:evidence_ref][:sha256]}.json")
251
314
  File.write(artifact, '{}')
252
- expect(PWN::Sessions.dependency_index[:dependencies]).to have_key(sid)
315
+ expect(PWN::Sessions.dependency_index[:session_errors]).to have_key(sid)
253
316
  expect { PWN::Sessions.load(session_id: sid) }.to raise_error(IOError)
254
317
  end
255
318
  end
@@ -233,9 +233,9 @@ describe PWN::AI::Agent::Swarm do
233
233
  text_only: true
234
234
  )
235
235
  expect(PWN::Sessions.sessions_dir).to eq(File.join(@swarm_home, 'sessions'))
236
- expect(PWN::Sessions.list.length).to eq(1)
237
- expect(PWN::Sessions.active?(session_id: PWN::Sessions.list.first[:id])).to be(false)
238
- expect(PWN::Sessions.list.map { |session| session[:path] }).to all(start_with(File.join(@swarm_home, 'sessions', '')))
236
+ expect(PWN::Sessions.list(include_hidden: true).length).to eq(1)
237
+ expect(PWN::Sessions.active?(session_id: PWN::Sessions.list(include_hidden: true).first[:id])).to be(false)
238
+ expect(PWN::Sessions.list(include_hidden: true).map { |session| session[:path] }).to all(start_with(File.join(@swarm_home, 'sessions', '')))
239
239
  expect(described_class.honesty_unmet).to eq([])
240
240
  expect(Array(Thread.current[:pwn_swarm_honesty])).to eq([])
241
241
  expect(Thread.current[:pwn_swarm_id]).to be_nil
@@ -4,7 +4,64 @@ require 'spec_helper'
4
4
  require 'tmpdir'
5
5
  require 'fileutils'
6
6
 
7
- describe PWN::Memory do
7
+ RSpec.describe PWN::Memory do
8
+ describe 'store transactions' do
9
+ include_context 'pwn tmp sandbox'
10
+
11
+ it 'serializes concurrent read modify write and allows nested methods' do
12
+ threads = 4.times.map do |worker|
13
+ Thread.new { 10.times { |index| described_class.remember(key: "worker_#{worker}_#{index}", value: index) } }
14
+ end
15
+ threads.each(&:value)
16
+ expect(described_class.load.keys.grep(/worker_/).size).to eq(40)
17
+ described_class.transaction do
18
+ described_class.forget(key: 'worker_0_0')
19
+ described_class.lean!
20
+ end
21
+ expect(described_class.load.keys.map(&:to_s)).not_to include('worker_0_0')
22
+ end
23
+
24
+ it 'serializes mistake writers in separate processes on the permanent inode' do
25
+ children = 3.times.map do |worker|
26
+ fork do
27
+ 8.times do |index|
28
+ PWN::AI::Agent::Mistakes.record(tool: "fixture_#{worker}_#{index}", error: 'fixture failure')
29
+ end
30
+ exit! 0
31
+ end
32
+ end
33
+ children.each { |pid| expect(Process.wait2(pid).last.exitstatus).to eq(0) }
34
+ expect(PWN::AI::Agent::Mistakes.load.size).to eq(24)
35
+ end
36
+
37
+ it 'keeps external Learning memory rewrites inside a transaction' do
38
+ described_class.remember(key: :fixture, value: 'retained')
39
+ allow(described_class).to receive(:save).and_wrap_original do |original, options|
40
+ expect(described_class.transaction_owned?).to be(true)
41
+ original.call(options)
42
+ end
43
+ allow(PWN::MemoryIndex).to receive(:available?).and_return(false)
44
+ PWN::AI::Agent::Learning.consolidate
45
+ PWN::AI::Agent::Learning.purge_noise
46
+ end
47
+
48
+ it 'does not expire a refreshed key from an old snapshot' do
49
+ described_class.remember(key: :refresh, value: 'old')
50
+ stale = described_class.load
51
+ described_class.remember(key: :refresh, value: 'new')
52
+ described_class.send(:archive_expired!, rows: stale)
53
+ expect(described_class.load[:refresh][:value]).to eq('new')
54
+ end
55
+
56
+ it 'refuses corrupt mistake input instead of overwriting it' do
57
+ path = PWN::AI::Agent::Mistakes::MISTAKES_FILE
58
+ FileUtils.mkdir_p(File.dirname(path))
59
+ File.write(path, '{corrupt')
60
+ expect { PWN::AI::Agent::Mistakes.record(tool: 'fixture', error: 'failure') }.to raise_error(JSON::ParserError)
61
+ expect(File.read(path)).to eq('{corrupt')
62
+ end
63
+ end
64
+
8
65
  it 'should display information for authors' do
9
66
  authors_response = PWN::Memory
10
67
  expect(authors_response).to respond_to :authors
@@ -9,6 +9,7 @@ describe 'operator workspace navigation' do
9
9
 
10
10
  before do
11
11
  allow(Dir).to receive(:home).and_return(@tmp)
12
+ FileUtils.mkdir_p(File.join(@tmp, '.pwn'))
12
13
  stub_const('PWN::AI::Agent::SkillReview::LEDGER', File.join(@tmp, 'skill_review.jsonl'))
13
14
  stub_const('PWN::AI::Agent::OpenGoal::GOAL_FILE', File.join(@tmp, 'open_goal.json'))
14
15
  allow(Pry).to receive(:history).and_return(Pry::History.new(file_path: File.join(@tmp, 'pwn_history')))
@@ -249,24 +250,162 @@ describe 'operator workspace navigation' do
249
250
  text = console.send(:operator_content).join
250
251
  expect(text).to include('ruby check.rb', 'observed failure', 'tested-revision', 'cancelled')
251
252
  end
253
+ end
254
+
255
+ describe 'session maintenance command workers' do
256
+ include_context 'pwn tmp sandbox'
257
+
258
+ before do
259
+ allow(Dir).to receive(:home).and_return(@tmp)
260
+ FileUtils.mkdir_p(File.join(@tmp, '.pwn'))
261
+ allow(Pry).to receive(:history).and_return(Pry::History.new(file_path: File.join(@tmp, 'pwn_history')))
262
+ end
263
+
264
+ let(:pry) { Pry.new }
265
+ let(:console) { PWN::Plugins::REPL::AIConsole::Console.new(pry: pry, input: StringIO.new, curses: nil, getch: nil) }
266
+
267
+ def open_view(line)
268
+ console.submit(line)
269
+ Timeout.timeout(5) do
270
+ while console.instance_variable_get(:@operator_view)&.dig(:loading)
271
+ console.instance_variable_get(:@operator_worker)&.join(0.01)
272
+ console.send(:drain)
273
+ end
274
+ end
275
+ end
276
+
277
+ after { console.send(:stop_worker) }
278
+
279
+ it 'routes cleanup off the input owner and delivers completion after safe cancellation' do
280
+ require File.expand_path('../../../../../etc/default_cron/session_cleanup', __dir__)
281
+ entered = Queue.new
282
+ release = Queue.new
283
+ owner = Thread.current
284
+ reader, writer = IO.pipe
285
+ console.instance_variable_set(:@input, reader)
286
+ console.instance_variable_set(:@tool_input, writer)
287
+ allow(PWN::SessionCleanup).to receive(:maintain) do |**options|
288
+ entered << [Thread.current, options]
289
+ release.pop
290
+ { fixture: 'committed deletion' }
291
+ end
292
+ allow(PWN::SessionCleanup).to receive(:summary) { |result| result }
293
+ allow(PWN::Plugins::REPL).to receive(:pwn_ai_print_result) do |value:|
294
+ console.instance_variable_get(:@events) << [:command, value.inspect]
295
+ end
296
+ Timeout.timeout(2) { console.submit('/sessions cleanup') }
297
+ thread, options = Timeout.timeout(2) { entered.pop }
298
+ expect(thread).not_to eq(owner)
299
+ expect(options[:dry_run]).to be(false)
300
+ console.handle("\u0014")
301
+ console.handle(:page_up)
302
+ console.submit('/sessions cleanup')
303
+ expect(PWN::SessionCleanup).to have_received(:maintain).once
304
+ console.handle("\u0003")
305
+ expect(thread).to be_alive
306
+ release << true
307
+ expect(thread.join(2)).to eq(thread)
308
+ console.send(:drain)
309
+ expect(console.send(:busy?)).to be_falsey
310
+ text = console.instance_variable_get(:@timeline).map { |row| row[1] }.join
311
+ expect(text).to include('committed deletion', 'not undone')
312
+ expect(text).not_to include('Request cancelled')
313
+ ensure
314
+ release << true if release
315
+ console.instance_variable_get(:@worker)&.join(2)
316
+ reader&.close
317
+ console.instance_variable_get(:@tool_input)&.close
318
+ end
319
+
320
+ it 'keeps navigation and input alive through the real maintenance learning lock and queues errors' do
321
+ require File.expand_path('../../../../../etc/default_cron/session_cleanup', __dir__)
322
+ allow(PWN::SessionCleanup).to receive(:legacy_owners).and_return([])
323
+ stub_const('PWN::AI::Agent::Learning::LEARNING_FILE', File.join(@tmp, '.pwn', 'learning.jsonl'))
324
+ sid = PWN::Sessions.create[:id]
325
+ reader, writer = IO.pipe
326
+ console.instance_variable_set(:@input, reader)
327
+ console.instance_variable_set(:@tool_input, writer)
328
+ events = console.instance_variable_get(:@events)
329
+ output = PWN::Plugins::REPL::AIConsole::EventIO.new(events)
330
+ allow(PWN::Plugins::REPL).to receive(:pwn_ai_print_result).and_wrap_original do |method, **options|
331
+ method.call(options.merge(output: output))
332
+ end
333
+ lock = PWN::AI::Agent::Learning.send(:learning_lock)
334
+ entered = Queue.new
335
+ allow(PWN::AI::Agent::Learning).to receive(:migrate_evidence).and_wrap_original do |method, **options|
336
+ entered << true
337
+ method.call(options)
338
+ end
339
+ Timeout.timeout(2) { console.submit('/sessions cleanup') }
340
+ Timeout.timeout(2) { entered.pop }
341
+ worker = console.instance_variable_get(:@worker)
342
+ expect(worker.join(0.05)).to be_nil
343
+ console.submit('/input fixture')
344
+ expect(reader.read_nonblock(8)).to eq("fixture\n")
345
+ console.handle("\u0014")
346
+ console.handle(:page_up)
347
+ console.handle("\u0003")
348
+ expect(File).to exist(File.join(PWN::Sessions.sessions_dir, "#{sid}.jsonl"))
349
+ PWN::AI::Agent::Learning.send(:learning_unlock, lock: lock)
350
+ lock = nil
351
+ expect(worker.join(5)).to eq(worker)
352
+ console.send(:drain)
353
+ expect(File).not_to exist(File.join(PWN::Sessions.sessions_dir, "#{sid}.jsonl"))
354
+ expect(console.instance_variable_get(:@timeline).select { |row| row[0] == :command }.map { |row| row[1] }.join).to include('deleted: 1')
355
+ allow(PWN::SessionCleanup).to receive(:maintain).and_raise(IOError, 'fixture maintenance failed')
356
+ console.submit('/sessions cleanup')
357
+ expect(console.instance_variable_get(:@worker).join(2)).not_to be_nil
358
+ console.send(:drain)
359
+ expect(console.instance_variable_get(:@timeline).map { |row| row[1] }.join).to include('IOError: fixture maintenance failed')
360
+ expect(console.send(:busy?)).to be_falsey
361
+ ensure
362
+ PWN::AI::Agent::Learning.send(:learning_unlock, lock: lock) if lock
363
+ console.instance_variable_get(:@worker)&.join(5)
364
+ reader&.close
365
+ console.instance_variable_get(:@tool_input)&.close
366
+ end
252
367
 
253
368
  it 'previews cleanup and applies only unchanged explicitly approved candidates' do
254
369
  require File.expand_path('../../../../../etc/default_cron/session_cleanup', __dir__)
255
370
  allow(PWN::SessionCleanup).to receive(:legacy_owners).and_return([])
256
371
  old = PWN::Sessions.create[:id]
257
- open_view('/sessions cleanup')
372
+ open_view('/sessions cleanup --dry-run')
258
373
  expect(console.send(:operator_content).join).to include(old)
259
374
  late = PWN::Sessions.create[:id]
260
375
  console.handle('a')
261
376
  expect(File).to exist(File.join(PWN::Sessions.sessions_dir, "#{old}.jsonl"))
262
- console.handle("\r")
377
+ reader, writer = IO.pipe
378
+ console.instance_variable_set(:@input, reader)
379
+ console.instance_variable_set(:@tool_input, writer)
380
+ entered = Queue.new
381
+ release = Queue.new
382
+ allow(PWN::SessionCleanup).to receive(:run).and_wrap_original do |method, **options|
383
+ entered << true
384
+ release.pop
385
+ method.call(**options)
386
+ end
387
+ Timeout.timeout(2) { console.handle("\r") }
388
+ Timeout.timeout(2) { entered.pop }
389
+ console.handle("\e")
390
+ console.handle("\u0003")
391
+ release << true
392
+ expect(console.instance_variable_get(:@worker).join(5)).not_to be_nil
393
+ console.send(:drain)
263
394
  expect(File).not_to exist(File.join(PWN::Sessions.sessions_dir, "#{old}.jsonl"))
264
395
  expect(File).to exist(File.join(PWN::Sessions.sessions_dir, "#{late}.jsonl"))
396
+ ensure
397
+ release << true if release
398
+ console.instance_variable_get(:@worker)&.join(5)
399
+ reader&.close
400
+ console.instance_variable_get(:@tool_input)&.close
265
401
  end
266
402
 
267
403
  it 'searches sessions by stable ID and resumes the selected ID without changing a draft' do
268
404
  first = PWN::Sessions.create(title: 'Duplicate title')[:id]
269
405
  second = PWN::Sessions.create(title: 'Duplicate title')[:id]
406
+ [first, second].each do |id|
407
+ File.write(File.join(PWN::Sessions.sessions_dir, "#{id}.meta.json"), JSON.generate(title: 'Duplicate title', title_source: 'summary', summary: 'Fixture summary', summary_turns: 2))
408
+ end
270
409
  pry.config.pwn_ai_session_id = first
271
410
  editor = console.instance_variable_get(:@editor)
272
411
  editor.replace('unfinished draft')
@@ -287,6 +426,8 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
287
426
  include_context 'pwn tmp sandbox'
288
427
 
289
428
  before do
429
+ allow(Dir).to receive(:home).and_return(@tmp)
430
+ FileUtils.mkdir_p(File.join(@tmp, '.pwn'))
290
431
  @history_path = File.join(@tmp, 'pwn_history')
291
432
  @request_history = Pry::History.new(file_path: @history_path)
292
433
  allow(Pry).to receive(:history).and_return(@request_history)
@@ -373,6 +514,40 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
373
514
  FileUtils.rm_rf(workspace) if workspace
374
515
  end
375
516
 
517
+ it 'coalesces title jobs per session and never drops a different pending session' do
518
+ ids = 3.times.map { PWN::Sessions.create[:id] }
519
+ ids.each_with_index { |sid, index| PWN::Sessions.append(session_id: sid, role: 'user', content: "Session topic #{index}") }
520
+ reached = Queue.new
521
+ release = Queue.new
522
+ calls = []
523
+ allow(PWN::AI::Router).to receive(:summarize) do |opts|
524
+ calls << opts[:text]
525
+ if calls.length == 1
526
+ reached << true
527
+ release.pop
528
+ end
529
+ JSON.generate(title: "Conversation #{calls.length}", summary: opts[:text])
530
+ end
531
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
532
+ console.send(:queue_title_update, ids[0])
533
+ Timeout.timeout(5) { reached.pop }
534
+ console.send(:queue_title_update, ids[1])
535
+ console.send(:queue_title_update, ids[2])
536
+ console.send(:queue_title_update, ids[1])
537
+ release << true
538
+ Timeout.timeout(5) do
539
+ while console.instance_variable_get(:@title_worker)
540
+ console.send(:drain)
541
+ Thread.pass
542
+ end
543
+ end
544
+ expect(calls.length).to eq(3)
545
+ ids.each { |sid| expect(PWN::Sessions.title_status(session_id: sid)[:is_summarized]).to be(true) }
546
+ ensure
547
+ release << true if release
548
+ console&.instance_variable_get(:@title_worker)&.kill
549
+ end
550
+
376
551
  it 'paints provisional and asynchronously updated summary titles in the actual SESSION pane' do
377
552
  reached = Queue.new
378
553
  release = Queue.new
@@ -390,18 +565,27 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
390
565
  expect(PWN::Sessions.metadata(session_id: opts[:session_id])[:title]).to eq('Investigate login and repair all expired certificates')
391
566
  PWN::Sessions.append(session_id: opts[:session_id], content: opts[:request])
392
567
  PWN::Sessions.append(session_id: opts[:session_id], role: 'assistant', content: 'Certificate repaired')
568
+ PWN::Env[:ai] = { active: :ollama, ollama: { model: 'later-model', base_uri: 'http://fixture.invalid' } }
393
569
  'Certificate repaired'
394
570
  end
395
571
  submitted = false
396
572
  unblocked = false
397
- launch('Investigate login and repair all expired certificates today'.chars) do
573
+ listed = false
574
+ keys = 'Investigate login and repair all expired certificates today'.chars
575
+ launch(keys) do
398
576
  unless submitted
399
577
  submitted = true
400
578
  @pry.config.pwn_ai_session_id = PWN::Sessions.create[:id]
401
579
  next "\r"
402
580
  end
403
- if !reached.empty? && !unblocked
581
+ if !reached.empty? && !listed
582
+ listed = true
583
+ keys.concat("/sessions list\n".chars)
584
+ end
585
+ if listed && @rendered.map(&:last).join.include?('active: true') && !unblocked
404
586
  expect(@paint.join).to include('SESSION Investigate login and repair all expired certificates')
587
+ expect(@rendered.map(&:last).join).to include(@pry.config.pwn_ai_session_id, 'title: Investigate login and repair all expired certificates')
588
+ expect(PWN::Sessions.title_status(session_id: @pry.config.pwn_ai_session_id)[:is_summarized]).to be(false)
405
589
  unblocked = true
406
590
  release << true
407
591
  end
@@ -416,7 +600,8 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
416
600
  end
417
601
 
418
602
  it 'resumes the displayed title after async renaming through curses with surrounding whitespace and restores history' do
419
- sid = PWN::Sessions.create(id: 'displayed_title', title: 'Café "login" repair')[:id]
603
+ sid = PWN::Sessions.create(id: '20200101_010203_abcdef12', title: 'Café "login" repair')[:id]
604
+ File.write(File.join(PWN::Sessions.sessions_dir, "#{sid}.meta.json"), JSON.generate(title: 'Café "login" repair', title_source: 'summary', summary: 'Fixture summary', summary_turns: 1))
420
605
  PWN::Sessions.append(session_id: sid, role: 'user', content: 'Prior fixture request')
421
606
  PWN::Sessions.append(session_id: sid, role: 'assistant', content: 'Prior fixture answer')
422
607
  requested = false
@@ -2518,7 +2703,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
2518
2703
  it 'keeps network slash commands off the event thread so tool prompts can receive explicit input' do
2519
2704
  entered = Queue.new
2520
2705
  expect(PWN::AI::Agent::Loop).not_to receive(:run)
2521
- expect(PWN::Plugins::REPL).to receive(:pwn_ai_dispatch_slash!).with(request: '/mcp call fixture', pry: anything) do
2706
+ expect(PWN::Plugins::REPL).to receive(:pwn_ai_dispatch_slash!).with(request: '/mcp call fixture', pry: anything, raise_errors: true) do
2522
2707
  entered << true
2523
2708
  puts "local command received: #{$stdin.gets}"
2524
2709
  true
@@ -158,7 +158,7 @@ describe 'menu chronology routing' do
158
158
  let(:records) { [{ id: 'new-record', timestamp: 10 }, { id: 'old-record', timestamp: 0 }] }
159
159
 
160
160
  {
161
- '/sessions list' => ['PWN::Sessions', :list, :array],
161
+ '/sessions list' => ['PWN::Sessions', :catalog, :nested],
162
162
  '/cron list' => ['PWN::Cron', :list, :map],
163
163
  '/cron create hourly fixture' => ['PWN::Cron', :create, :nested],
164
164
  '/cron run fixture' => ['PWN::Cron', :run, :json],
@@ -176,7 +176,7 @@ describe 'menu chronology routing' do
176
176
  when :nested then { results: records }
177
177
  when :json then JSON.generate(results: records)
178
178
  end
179
- result = [{ id: 'new-record', mtime: 10 }, { id: 'old-record', mtime: 0 }] if line == '/sessions list'
179
+ result = { sessions: [{ id: 'new-record', mtime: 10 }, { id: 'old-record', mtime: 0 }] } if line == '/sessions list'
180
180
  original = Marshal.dump(result)
181
181
  allow(Object.const_get(namespace)).to receive(method).and_return(result)
182
182
  allow(PWN::AI::MCP).to receive(:backends).and_return([{ name: 'fixture' }]) if namespace == 'PWN::AI::MCP'
@@ -93,14 +93,15 @@ describe 'menu command result routes' do
93
93
  end
94
94
 
95
95
  it 'resumes by exact Unicode title or id through plural commands and lists both fields' do
96
- session = PWN::Sessions.create(id: 'resume_title', title: 'Café "login" repair')
97
- ['/sessions resume Café "login" repair', '/sessions resume \'Café "login" repair\'', '/sessions resume resume_title', ' /sessions resume Café "login" repair '].each do |line|
96
+ session = PWN::Sessions.create(id: '20200101_010203_abcdef13', title: 'Café "login" repair')
97
+ File.write(File.join(PWN::Sessions.sessions_dir, '20200101_010203_abcdef13.meta.json'), JSON.generate(title: 'Café "login" repair', title_source: 'summary', summary: 'Reviewed the login repair.', summary_turns: 1))
98
+ ['/sessions resume Café "login" repair', '/sessions resume \'Café "login" repair\'', '/sessions resume 20200101_010203_abcdef13', ' /sessions resume Café "login" repair '].each do |line|
98
99
  pry.config.pwn_ai_session_id = 'before'
99
100
  expect(PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: line, pry: pry)).to be(true)
100
101
  expect(pry.config.pwn_ai_session_id).to eq(session[:id])
101
102
  end
102
103
  ['/sessions list'].each do |line|
103
- expect { PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: line, pry: pry) }.to output(a_string_including('id: resume_title', 'title: Café "login" repair')).to_stdout
104
+ expect { PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: line, pry: pry) }.to output(a_string_including('id: 20200101_010203_abcdef13', 'title: Café "login" repair')).to_stdout
104
105
  end
105
106
  end
106
107
 
@@ -118,14 +119,14 @@ describe 'menu command result routes' do
118
119
 
119
120
  it 'lists and resumes the same seven-word legacy title with stable id fallback on collisions' do
120
121
  title = 'One two three four five six seven'
121
- PWN::Sessions.create(id: 'capped_resume')
122
- File.write(File.join(PWN::Sessions.sessions_dir, 'capped_resume.meta.json'), JSON.generate(title: "#{title} eight nine", title_source: 'summary'))
122
+ PWN::Sessions.create(id: '20200101_010203_abcdef14')
123
+ File.write(File.join(PWN::Sessions.sessions_dir, '20200101_010203_abcdef14.meta.json'), JSON.generate(title: "#{title} eight nine", title_source: 'summary', summary: 'Summarized the prior conversation.', summary_turns: 1))
123
124
  expect { PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: '/sessions list', pry: pry) }.to output(a_string_including("title: #{title}\n")).to_stdout
124
125
  PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: "/sessions resume '#{title}'", pry: pry)
125
- expect(pry.config.pwn_ai_session_id).to eq('capped_resume')
126
+ expect(pry.config.pwn_ai_session_id).to eq('20200101_010203_abcdef14')
126
127
  PWN::Sessions.create(id: 'capped_duplicate', title: "#{title} ten")
127
- expect { PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: "/sessions resume #{title}", pry: pry) }.to output(a_string_including('NOTICE', 'Ambiguous', 'capped_resume', 'capped_duplicate', '/sessions resume <ID>')).to_stdout
128
- expect(pry.config.pwn_ai_session_id).to eq('capped_resume')
128
+ expect { PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: "/sessions resume #{title}", pry: pry) }.to output(a_string_including('NOTICE', 'Ambiguous', '20200101_010203_abcdef14', 'capped_duplicate', '/sessions resume <ID>')).to_stdout
129
+ expect(pry.config.pwn_ai_session_id).to eq('20200101_010203_abcdef14')
129
130
  PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: '/sessions resume capped_duplicate', pry: pry)
130
131
  expect(pry.config.pwn_ai_session_id).to eq('capped_duplicate')
131
132
  end
@@ -139,11 +140,27 @@ describe 'menu command result routes' do
139
140
  expect(pry.config.pwn_ai_session_id).to eq('original')
140
141
  end
141
142
 
143
+ it 'keeps the current provisional session in both command routes without summary provenance' do
144
+ allow(Dir).to receive(:home).and_return(@tmp)
145
+ sid = PWN::Sessions.create[:id]
146
+ PWN::Sessions.provisional_title(session_id: sid, request: 'Investigate certificate chain')
147
+ pry.config.pwn_ai_session_id = sid
148
+ expect { PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: '/sessions list', pry: pry) }.to output(a_string_including("id: #{sid}", 'active: true', 'title: Investigate certificate chain')).to_stdout
149
+ PWN::Plugins::REPL::AI.add_commands
150
+ allow(Pry).to receive(:history).and_return(Pry::History.new(file_path: File.join(@tmp, 'active_history')))
151
+ output = StringIO.new
152
+ legacy = Pry.new(output: output, quiet: true)
153
+ legacy.config.pwn_ai_session_id = sid
154
+ legacy.eval('pwn-ai-sessions list')
155
+ expect(output.string).to include("id: #{sid}", 'active: true', 'title: Investigate certificate chain')
156
+ expect(PWN::Sessions.title_status(session_id: sid)[:is_summarized]).to be(false)
157
+ end
158
+
142
159
  it 'preserves the configured Pry output stream for legacy command aliases' do
143
160
  PWN::Plugins::REPL::AI.add_commands
144
161
  allow(Pry).to receive(:history).and_return(Pry::History.new(file_path: File.join(@tmp, 'alias_history')))
145
162
  allow(PWN::Memory).to receive(:recall).and_return(record)
146
- allow(PWN::Sessions).to receive_messages(list: record, stats: record)
163
+ allow(PWN::Sessions).to receive_messages(catalog: record, stats: record)
147
164
  allow(PWN::Cron).to receive_messages(list: record, run: record)
148
165
  ['pwn-ai-memory list', 'pwn-ai-memory recall fixture', 'pwn-ai-sessions list', 'pwn-ai-sessions stats', 'pwn-ai-cron list', 'pwn-ai-cron run fixture'].each do |line|
149
166
  output = StringIO.new
@@ -166,8 +183,8 @@ describe 'menu command result routes' do
166
183
  '/cron' => ['PWN::Cron', :list],
167
184
  '/cron list' => ['PWN::Cron', :list],
168
185
  '/cron run fixture' => ['PWN::Cron', :run],
169
- '/sessions' => ['PWN::Sessions', :list],
170
- '/sessions list' => ['PWN::Sessions', :list],
186
+ '/sessions' => ['PWN::Sessions', :catalog],
187
+ '/sessions list' => ['PWN::Sessions', :catalog],
171
188
  '/sessions stats' => ['PWN::Sessions', :stats],
172
189
  '/memory' => ['PWN::Memory', :recall],
173
190
  '/memory list' => ['PWN::Memory', :recall],
@@ -40,7 +40,7 @@ RSpec.describe 'Bundled session cleanup' do
40
40
  bytes = File.read(PWN::Cron.jobs_file)
41
41
  expect(PWN::Cron).not_to receive(:install_native_job)
42
42
  PWN::Cron.install_defaults
43
- expect(File.read(helper)).to include('# PWN managed default: session_cleanup v6')
43
+ expect(File.read(helper)).to include('# PWN managed default: session_cleanup v9')
44
44
  expect(File.read(PWN::Cron.jobs_file)).to eq(bytes)
45
45
  end
46
46
 
@@ -67,6 +67,6 @@ RSpec.describe 'Bundled session cleanup' do
67
67
  RbConfig.ruby, '-I', File.expand_path('../../../lib', __dir__), helper, '--dry-run'
68
68
  )
69
69
  expect(status.success?).to be(true), error
70
- expect(JSON.parse(output)).to include('dry_run' => true, 'scanned' => 0, 'deleted_ids' => [])
70
+ expect(JSON.parse(output)).to include('dry_run' => true, 'scanned' => 0, 'deleted' => 0)
71
71
  end
72
72
  end