luang-cli 0.3.0 → 0.3.2

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.
package/test/run-e2e.js CHANGED
@@ -117,7 +117,7 @@ function homeConfig(home) {
117
117
  // ── Skenario 1: sintaks ──
118
118
  function scenarioSyntax() {
119
119
  console.log('\n[1] node --check semua file JS + guard PATH uji (kebijakan provider opencode)');
120
- const files = ['bin/luang-cli.js', 'lib/core.js', 'lib/commands.js', 'lib/agent.js', 'lib/ws.js', 'test/run-e2e.js', 'test/ws-stub.js'];
120
+ const files = ['bin/luang-cli.js', 'lib/core.js', 'lib/commands.js', 'lib/agent.js', 'lib/models.js', 'lib/ws.js', 'test/run-e2e.js', 'test/ws-stub.js', 'test/http-stub.js'];
121
121
  for (const f of files) {
122
122
  const r = spawnSync(process.execPath, ['--check', path.join(ROOT, f)], { encoding: 'utf8' });
123
123
  assert(r.status === 0, `${f} lolos --check${r.stderr ? ' — ' + r.stderr.slice(0, 200) : ''}`);
@@ -183,6 +183,9 @@ async function scenarioRealtime() {
183
183
  assert(typeof m.data.version === 'string' && m.data.version.length > 0, 'hello menyertakan version');
184
184
  assert(Array.isArray(m.data.providers) && m.data.providers.some((p) => p.type === 'opencode'), 'hello menyertakan provider opencode');
185
185
  assert(m.data.providers.find((p) => p.type === 'opencode').exec_template.includes('{task}'), 'hello menyertakan exec_template berisi {task}');
186
+ const helloOpc = m.data.providers.find((p) => p.type === 'opencode');
187
+ assert(Array.isArray(helloOpc.models) && helloOpc.models.length <= 50, 'hello menyertakan field models (≤50 item)');
188
+ assert(helloOpc.models.every((x) => typeof x === 'string' && x.length <= 120), 'tiap model di hello ≤120 karakter');
186
189
  gotHello = true;
187
190
  // Langsung perintah server: set_model SEBELUM tugas dikirim (agar exec pakai model)
188
191
  phase = 1;
@@ -387,6 +390,13 @@ async function scenarioConsentAndModel() {
387
390
  });
388
391
  const setServerTelemetry = (v) => stub.send({ telemetry_enabled: v });
389
392
 
393
+ // Kumpulkan payload heartbeat (IPC) — daftar sebelum runCli karena pesan
394
+ // IPC yang tiba tanpa listener akan hilang.
395
+ const heartbeats = [];
396
+ stub.on('message', (m) => {
397
+ if (m && m.cmd === 'heartbeat' && m.body) heartbeats.push(m.body);
398
+ });
399
+
390
400
  const envA = { ...daemonEnv(home), LUANG_HEARTBEAT_SEC: undefined };
391
401
  writeJson(path.join(home, '.luang-cli', 'config.json'), {
392
402
  api_url: `http://127.0.0.1:${port}`,
@@ -429,6 +439,17 @@ async function scenarioConsentAndModel() {
429
439
  assert(homeConfig(home).providers.opencode.model === 'sonnet-test-9', 'config providers.opencode.model');
430
440
  r = runCli(envA, ['provider', 'list']);
431
441
  assert(r.stdout.includes('Model') && r.stdout.includes('opencode'), 'provider list menunjukkan kolom Model');
442
+
443
+ // Kontrak baru: payload heartbeat menyertakan providers[].models (≤50 × 120 char)
444
+ // Catatan: pesan IPC datang async setelah spawnSync runCli selesai → tunggu.
445
+ await until(() => heartbeats.length >= 1, 8000, 'heartbeat HTTP terkirim saat provider list');
446
+ const hb = heartbeats[heartbeats.length - 1];
447
+ assert(hb && Array.isArray(hb.providers) && hb.providers.length > 0, 'heartbeat payload memuat providers[]');
448
+ const hbOpc = hb.providers.find((p) => p.provider_type === 'opencode');
449
+ assert(!!hbOpc, 'heartbeat providers memuat opencode');
450
+ assert(Array.isArray(hbOpc.models) && hbOpc.models.length <= 50, 'heartbeat providers[].models array (≤50)');
451
+ assert(hbOpc.models.every((m) => typeof m === 'string' && m.length <= 120), 'tiap model di heartbeat ≤120 karakter');
452
+ assert(hbOpc.models.includes('stub-model-a'), 'models heartbeat datang dari deteksi dinamis (stub opencode)');
432
453
  } finally {
433
454
  try { stub.send({ cmd: 'exit' }); } catch (e) { /* abaikan */ }
434
455
  stub.kill();
@@ -437,6 +458,329 @@ async function scenarioConsentAndModel() {
437
458
  console.log(' ✅ Skenario konsent + model selesai');
438
459
  }
439
460
 
461
+ // ── Skenario 5: pesan eksekusi jujur (tanpa --exec TIDAK menyalahkan template) ──
462
+ async function scenarioExecMessages() {
463
+ console.log('\n[5] Pesan eksekusi: doExec=false → "eksekusi nonaktif"; --exec tanpa template → exec_template');
464
+ const home = mkFakeHome('execmsg');
465
+ const port = 18501 + Math.floor(Math.random() * 400);
466
+ const config = {
467
+ api_url: `http://127.0.0.1:${port}`,
468
+ app_url: 'http://127.0.0.1',
469
+ key: 'lk_test_e2e_execmsg',
470
+ default_provider: 'opencode',
471
+ providers: {
472
+ opencode: {
473
+ provider_type: 'opencode',
474
+ display_name: 'OpenCode',
475
+ path: 'stub-opencode',
476
+ exec_template: 'opencode run "{task}"',
477
+ },
478
+ },
479
+ telemetry: false,
480
+ };
481
+ writeJson(path.join(home, '.luang-cli', 'config.json'), config);
482
+ const env = daemonEnv(home);
483
+ const stub = new StubWsServer({ port, expectedKey: config.key });
484
+ await stub.start();
485
+ const task = { id: 't-m1', task_code: 'TGS-201', title: 'Tugas tanpa eksekusi', description: 'pesan harus jujur', status: 'todo' };
486
+ const send = (type, data) => stub.broadcastText(JSON.stringify({ type, ts: Date.now(), data }));
487
+ stub.onMsg = (f) => {
488
+ const m = f.obj;
489
+ if (!m) return;
490
+ if (m.type === 'hello') {
491
+ send('hello.ok', { key_id: 'kx', user_id: 'ux', tenant_id: 'tx', telemetry_enabled: false, server_time: new Date().toISOString() });
492
+ send('task.snapshot', { tasks: [task] });
493
+ }
494
+ };
495
+
496
+ try {
497
+ // A) foreground TANPA --exec → notice "eksekusi nonaktif" (bukan salahkan template)
498
+ const a = spawnDaemon(env, ['agent']);
499
+ try {
500
+ await until(() => a.cap.out.includes('eksekusi nonaktif'), 15000, 'notice "eksekusi nonaktif" tanpa --exec');
501
+ assert(a.cap.out.includes('Eksekusi : nonaktif'), 'banner: Eksekusi : nonaktif');
502
+ assert(a.cap.out.includes('luang-cli agent --exec'), 'notice menyebut cara menyalakan: luang-cli agent --exec');
503
+ assert(a.cap.out.includes('agent start'), 'notice menyebut mode otomatis: agent start');
504
+ assert(!a.cap.out.includes('default provider belum punya exec_template'), 'TANPA --exec: TIDAK menyalahkan exec_template');
505
+ assert(!a.cap.out.includes('Jalankan sekarang?'), 'TANPA --exec: tidak ada prompt y/N');
506
+ await a.proc.finish('SIGTERM');
507
+ } finally {
508
+ await a.proc.finish();
509
+ }
510
+ const st = JSON.parse(fs.readFileSync(path.join(home, '.luang-cli', 'state.json'), 'utf8'));
511
+ assert(!st.executed || !st.executed['TGS-201'], 'tugas TIDAK ditandai executed saat eksekusi nonaktif');
512
+
513
+ // B) --exec tapi exec_template kosong → BARU menyalahkan exec_template
514
+ config.providers.opencode.exec_template = null;
515
+ writeJson(path.join(home, '.luang-cli', 'config.json'), config);
516
+ const b = spawnDaemon(env, ['agent', '--exec']);
517
+ try {
518
+ await until(() => b.cap.out.includes('default provider belum punya exec_template'), 15000, 'notice exec_template saat --exec tanpa template');
519
+ assert(b.cap.out.includes('diminta tapi nonaktif'), 'banner: diminta tapi nonaktif (exec_template kosong)');
520
+ assert(!b.cap.out.includes('eksekusi nonaktif (jalankan'), '--exec: pesan tetap soal exec_template');
521
+ await b.proc.finish('SIGTERM');
522
+ } finally {
523
+ await b.proc.finish();
524
+ }
525
+ } finally {
526
+ await stub.stop();
527
+ }
528
+ passCount += 1;
529
+ console.log(' ✅ Skenario pesan eksekusi selesai');
530
+ }
531
+
532
+ // ── Skenario 6: agent start/stop/status/logs (fake HOME) + eksekusi auto ──
533
+ async function scenarioAgentLifecycle() {
534
+ console.log('\n[6] agent start/stop/status/logs (background, fake HOME) + eksekusi auto tanpa y/N');
535
+ const home = mkFakeHome('lifecycle');
536
+ const port = 18701 + Math.floor(Math.random() * 400);
537
+ const config = {
538
+ api_url: `http://127.0.0.1:${port}`,
539
+ app_url: 'http://127.0.0.1',
540
+ key: 'lk_test_e2e_lifecycle',
541
+ default_provider: 'opencode',
542
+ providers: {
543
+ opencode: {
544
+ provider_type: 'opencode',
545
+ display_name: 'OpenCode',
546
+ path: 'stub-opencode',
547
+ exec_template: 'opencode run "{task}"',
548
+ },
549
+ },
550
+ telemetry: false,
551
+ };
552
+ writeJson(path.join(home, '.luang-cli', 'config.json'), config);
553
+ const env = daemonEnv(home);
554
+ const stub = new StubWsServer({ port, expectedKey: config.key });
555
+ await stub.start();
556
+ const task = { id: 't-l1', task_code: 'TGS-301', title: 'Tugas background', description: 'jalan tanpa terminal', status: 'todo' };
557
+ const send = (type, data) => stub.broadcastText(JSON.stringify({ type, ts: Date.now(), data }));
558
+ stub.onMsg = (f) => {
559
+ const m = f.obj;
560
+ if (!m) return;
561
+ if (m.type === 'hello') {
562
+ send('hello.ok', { key_id: 'kl', user_id: 'ul', tenant_id: 'tl', telemetry_enabled: false, server_time: new Date().toISOString() });
563
+ send('task.snapshot', { tasks: [task] });
564
+ }
565
+ };
566
+
567
+ const pidFile = path.join(home, '.luang-cli', 'agent.pid');
568
+ const logFile = path.join(home, '.luang-cli', 'agent.log');
569
+ const readPid = () => JSON.parse(fs.readFileSync(pidFile, 'utf8'));
570
+
571
+ try {
572
+ let r = runCli(env, ['agent', 'start']);
573
+ assert(r.code === 0, `agent start exit 0 (dapat ${r.code}) — ${r.stderr || r.stdout}`);
574
+ assert(r.stdout.includes('berjalan di background (pid'), 'start mencetak "berjalan di background (pid …"');
575
+ assert(r.stdout.includes('log:'), 'start mencetak path log');
576
+ const info = readPid();
577
+ assert(Number.isInteger(info.pid) && info.pid > 0, `agent.pid memuat pid (dapat ${info.pid})`);
578
+ assert(info.auto === true && info.mode === 'auto', 'agent.pid: auto=true, mode="auto" (default agent start)');
579
+ assert(typeof info.started_at === 'string' && info.started_at.length > 10, 'agent.pid: started_at ISO');
580
+ assert(homeConfig(home).auto_exec === true, 'config.auto_exec = true ditulis oleh agent start');
581
+
582
+ // Eksekusi AUTO: tidak ada tulisan "y" ke stdin — tugas langsung dijalankan.
583
+ await until(() => fs.existsSync(logFile) && fs.readFileSync(logFile, 'utf8').includes('PROVIDER-RUN'), 25000, 'exec auto berjalan dari background (stub provider)');
584
+ const logTxt = fs.readFileSync(logFile, 'utf8');
585
+ assert(logTxt.includes('Realtime aktif'), 'child: mode realtime aktif (terlihat di log)');
586
+ assert(logTxt.includes('otomatis tanpa konfirmasi'), 'banner child: mode auto (tanpa konfirmasi)');
587
+ assert(!logTxt.includes('Jalankan sekarang?'), 'mode auto: TANPA prompt y/N');
588
+ assert(logTxt.includes('Konfirmasi: otomatis'), 'log mencatat konfirmasi otomatis');
589
+ await until(
590
+ () => stub.frames.some((f) => f.obj && f.obj.type === 'task.status' && f.obj.data && f.obj.data.ref === 'TGS-301' && f.obj.data.status === 'done'),
591
+ 15000,
592
+ 'task.status done dari daemon background',
593
+ );
594
+
595
+ // status: pid, uptime, mode, auto_exec, 3 baris log terakhir
596
+ r = runCli(env, ['agent', 'status']);
597
+ assert(r.code === 0, 'agent status exit 0');
598
+ assert(r.stdout.includes(String(info.pid)) && r.stdout.includes('hidup'), 'status: pid + hidup');
599
+ assert(/Uptime\s+:/.test(r.stdout), 'status: uptime tampil');
600
+ assert(r.stdout.includes('auto (otomatis tanpa konfirmasi)'), 'status: mode auto tampil');
601
+ assert(r.stdout.includes('auto_exec = true'), 'status: config auto_exec tampil');
602
+ const marker = '── log (3 terakhir) ──';
603
+ assert(r.stdout.includes(marker), 'status: 3 baris log terakhir ditampilkan');
604
+ const tail = r.stdout.split(marker)[1].split('\n').map((s) => s.trim()).filter(Boolean);
605
+ assert(tail.length === 3, `status menampilkan tepat 3 baris log (dapat ${tail.length})`);
606
+ const logNow = fs.readFileSync(logFile, 'utf8');
607
+ assert(tail.every((l) => logNow.includes(l)), 'baris yang ditampilkan benar-benar isi agent.log');
608
+
609
+ // logs [--tail N] (default 30)
610
+ r = runCli(env, ['agent', 'logs', '--tail', '5']);
611
+ assert(r.code === 0, 'agent logs --tail 5 exit 0');
612
+ const logLines = r.stdout.split('\n').filter((l) => l.trim() !== '');
613
+ assert(logLines.length <= 5, `logs --tail 5 maksimal 5 baris (dapat ${logLines.length})`);
614
+ r = runCli(env, ['agent', 'logs']);
615
+ assert(r.code === 0 && r.stdout.includes('TGS-301 selesai'), 'logs (default) memuat hasil eksekusi TGS-301');
616
+
617
+ // stop → SIGTERM graceful, pid file hilang, proses benar-benar mati
618
+ r = runCli(env, ['agent', 'stop']);
619
+ assert(r.code === 0 && r.stdout.includes('dihentikan'), 'agent stop: sukses (SIGTERM graceful)');
620
+ assert(!fs.existsSync(pidFile), 'agent.pid dihapus setelah stop');
621
+ let alive = true;
622
+ try {
623
+ process.kill(info.pid, 0);
624
+ } catch (e) {
625
+ alive = false;
626
+ }
627
+ assert(!alive, 'proses background mati setelah stop');
628
+
629
+ r = runCli(env, ['agent', 'status']);
630
+ assert(r.stdout.includes('tidak berjalan'), 'status setelah stop: tidak berjalan');
631
+ r = runCli(env, ['agent', 'stop']);
632
+ assert(r.code === 0 && r.stdout.includes('tidak berjalan'), 'stop kedua (pid file sudah hilang) tetap exit 0');
633
+
634
+ // --no-auto → mode manual + config auto_exec false
635
+ r = runCli(env, ['agent', 'start', '--no-auto']);
636
+ assert(r.code === 0, `agent start --no-auto exit 0 (dapat ${r.code})`);
637
+ const info2 = readPid();
638
+ assert(info2.auto === false && info2.mode === 'manual', 'agent.pid (--no-auto): auto=false, mode="manual"');
639
+ assert(homeConfig(home).auto_exec === false, 'config.auto_exec = false ditulis oleh --no-auto');
640
+ r = runCli(env, ['agent', 'stop']);
641
+ assert(r.code === 0, 'stop setelah --no-auto');
642
+ } finally {
643
+ // bersihkan bila skenario gagal di tengah (jangan biarkan proses liar)
644
+ try {
645
+ const info = fs.existsSync(pidFile) ? JSON.parse(fs.readFileSync(pidFile, 'utf8')) : null;
646
+ if (info) {
647
+ try {
648
+ process.kill(info.pid, 'SIGKILL');
649
+ } catch (e) { /* abaikan */ }
650
+ fs.rmSync(pidFile, { force: true });
651
+ }
652
+ } catch (e) { /* abaikan */ }
653
+ await stub.stop();
654
+ }
655
+ passCount += 1;
656
+ console.log(' ✅ Skenario lifecycle agent start/stop/status/logs selesai');
657
+ }
658
+
659
+ // ── Skenario 7: models (registry + opencode dinamis + guard binary lain) ──
660
+ async function scenarioModels() {
661
+ console.log('\n[7] models: registry statis + opencode dinamis (stub) + honeypot claude/gemini/codex');
662
+ const home = mkFakeHome('models');
663
+ const marker = path.join(home, 'honeypot-executed.log');
664
+ const binDir = mkdirp(path.join(home, 'bin'));
665
+ // Honeypot: bila binary ini dieksekusi → menulis marker. Harus TIDAK pernah terjadi.
666
+ for (const b of ['claude', 'gemini', 'codex']) {
667
+ const p = path.join(binDir, b);
668
+ fs.writeFileSync(p, '#!/usr/bin/env bash\necho "$0 $*" >> "$HONEYPOT_MARKER"\nexit 0\n');
669
+ fs.chmodSync(p, 0o755);
670
+ }
671
+ const env = {
672
+ ...process.env,
673
+ HOME: home,
674
+ LUANG_NO_OPEN: '1',
675
+ HONEYPOT_MARKER: marker,
676
+ PATH: `${binDir}:${path.join(__dirname, 'bin')}:/usr/bin:/bin`,
677
+ };
678
+
679
+ // --json: struktur benar untuk SEMUA provider
680
+ let r = runCli(env, ['models', '--json']);
681
+ assert(r.code === 0, `models --json exit 0 (dapat ${r.code}) ${r.stderr || ''}`);
682
+ let j = null;
683
+ try {
684
+ j = JSON.parse(r.stdout);
685
+ } catch (e) {
686
+ j = null;
687
+ }
688
+ assert(j !== null && typeof j === 'object', 'models --json menghasilkan JSON valid');
689
+ const types = ['claude_code', 'codex', 'opencode', 'gemini_cli', 'custom'];
690
+ assert(types.every((t) => j && j[t]), `json memuat semua provider (${types.join(', ')})`);
691
+ for (const t of types) {
692
+ const e = j[t];
693
+ assert(!!e && e.provider === t, `${t}: field provider benar`);
694
+ assert(!!e && ['static', 'binary', 'config'].includes(e.source), `${t}: source valid (${e && e.source})`);
695
+ assert(!!e && Array.isArray(e.models), `${t}: models berupa array`);
696
+ assert(!!e && e.models.length <= 50, `${t}: maksimal 50 item`);
697
+ assert(!!e && e.models.every((m) => typeof m === 'string' && m.length <= 120), `${t}: tiap model ≤120 karakter`);
698
+ }
699
+ assert(j.claude_code.source === 'static', 'claude_code: statis (binary tidak dieksekusi)');
700
+ assert(j.codex.source === 'static' && j.gemini_cli.source === 'static', 'codex & gemini_cli: statis');
701
+ assert(j.opencode.source === 'binary', `opencode: deteksi dinamis via stub (dapat ${j.opencode.source})`);
702
+ assert(j.opencode.models.includes('stub-model-a'), 'opencode: model dari `opencode models` (stub)');
703
+
704
+ // provider tunggal
705
+ r = runCli(env, ['models', 'opencode', '--json']);
706
+ j = JSON.parse(r.stdout);
707
+ assert(Object.keys(j).length === 1 && Array.isArray(j.opencode.models), 'models <provider> --json: hanya provider itu');
708
+
709
+ // provider tak dikenal → error + daftar pilihan
710
+ r = runCli(env, ['models', 'bogus_provider']);
711
+ assert(r.code === 1 && /tidak dikenal/i.test(r.stderr), 'models bogus → exit 1 + pesan pilihan');
712
+
713
+ // tampilan tabel
714
+ r = runCli(env, ['models']);
715
+ assert(r.code === 0, 'models (tabel) exit 0');
716
+ assert(r.stdout.includes('Provider') && r.stdout.includes('Jumlah'), 'tabel: header Provider | Jumlah');
717
+ assert(r.stdout.includes('claude_code') && r.stdout.includes('gemini_cli'), 'tabel: semua provider tampil');
718
+ assert(r.stdout.includes('…+'), 'tabel: wrap maks 6 baris + "…+n lagi" untuk daftar panjang');
719
+
720
+ // Guard kebijakan: claude/gemini/codex TIDAK PERNAH dieksekusi
721
+ assert(!fs.existsSync(marker), 'KEBIJAKAN: binary claude/gemini/codex tidak pernah dieksekusi (honeypot bersih)');
722
+
723
+ passCount += 1;
724
+ console.log(' ✅ Skenario models selesai');
725
+ }
726
+
727
+ // ── Skenario 8: workspace folder (set/status/reset + dipakai exec) ──
728
+ async function scenarioWorkspace() {
729
+ console.log('\n[8] workspace: set via CLI → server+lokal sinkron, status tampil, reset bersih');
730
+ const home = mkFakeHome('workspace');
731
+ const stub = fork(path.join(__dirname, 'http-stub.js'), [], { stdio: ['ignore', 'ignore', 'ignore', 'ipc'] });
732
+ const port = await new Promise((resolve, reject) => {
733
+ stub.once('message', (m) => { if (m && m.cmd === 'listening') resolve(m.port); else reject(new Error('pesan stub tak dikenal')); });
734
+ stub.once('error', reject);
735
+ setTimeout(() => reject(new Error('stub http tidak siap dalam 5s')), 5000);
736
+ });
737
+
738
+ const envA = { ...daemonEnv(home), LUANG_HEARTBEAT_SEC: undefined };
739
+ writeJson(path.join(home, '.luang-cli', 'config.json'), {
740
+ api_url: `http://127.0.0.1:${port}`,
741
+ app_url: 'http://127.0.0.1',
742
+ key: 'lk_test_e2e_workspace',
743
+ default_provider: null,
744
+ providers: {},
745
+ });
746
+ // Folder kerja valid di dalam HOME palsu.
747
+ const workdir = path.join(home, 'projek');
748
+ fs.mkdirSync(workdir, { recursive: true });
749
+
750
+ try {
751
+ // status awal: server kosong → efektif folder saat ini
752
+ let r = runCli(envA, ['workspace']);
753
+ assert(r.code === 0, 'workspace (status) exit 0');
754
+ assert(r.stdout.includes('folder saat ini'), 'status awal: efektif folder saat ini');
755
+
756
+ // set ke folder yang ada → server + lokal sinkron
757
+ r = runCli(envA, ['workspace', workdir]);
758
+ assert(r.code === 0 && r.stdout.includes('Workspace disimpan'), 'workspace set → tersimpan');
759
+ assert(homeConfig(home).workspace === workdir, 'config.workspace tersimpan lokal');
760
+
761
+ // status lagi → server menampilkan path yang sama (bukti POST /agent/workspace masuk stub)
762
+ r = runCli(envA, ['workspace']);
763
+ assert(r.code === 0 && r.stdout.includes(workdir), 'status: server menampilkan path yang diset');
764
+
765
+ // set ke folder tak ada → ditolak, config tidak berubah
766
+ r = runCli(envA, ['workspace', path.join(home, 'tidak-ada')]);
767
+ assert(r.code === 1, 'workspace path tak ada → exit 1');
768
+ assert(homeConfig(home).workspace === workdir, 'config.workspace tidak berubah saat gagal');
769
+
770
+ // reset → kosong kembali
771
+ r = runCli(envA, ['workspace', '--reset']);
772
+ assert(r.code === 0 && r.stdout.includes('direset'), 'workspace --reset → direset');
773
+ assert(!homeConfig(home).workspace, 'config.workspace kosong setelah reset');
774
+ r = runCli(envA, ['workspace']);
775
+ assert(r.code === 0 && r.stdout.includes('folder saat ini'), 'status setelah reset: folder saat ini');
776
+ } finally {
777
+ try { stub.send({ cmd: 'exit' }); } catch (e) { /* abaikan */ }
778
+ stub.kill();
779
+ }
780
+ passCount += 1;
781
+ console.log(' ✅ Skenario workspace selesai');
782
+ }
783
+
440
784
  // ── main ──
441
785
  async function main() {
442
786
  console.log(`E2E luang-cli — fakir HOME: ${baseTmp}`);
@@ -444,6 +788,10 @@ async function main() {
444
788
  await scenarioRealtime();
445
789
  await scenarioFallback();
446
790
  await scenarioConsentAndModel();
791
+ await scenarioExecMessages();
792
+ await scenarioAgentLifecycle();
793
+ await scenarioModels();
794
+ await scenarioWorkspace();
447
795
  console.log(`\nSEMIUA PASS ✅ (${passCount} assertion)`);
448
796
  }
449
797