dlab-cli 0.1.1__tar.gz → 0.1.3__tar.gz
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.
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/PKG-INFO +1 -1
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/__init__.py +1 -1
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/cli.py +9 -5
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/create_dpack.py +10 -2
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/docker.py +3 -2
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/js/parallel-agents.ts +4 -3
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab_cli.egg-info/PKG-INFO +1 -1
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/pyproject.toml +1 -1
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/tests/test_cli.py +189 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/tests/test_create_dpack.py +42 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/tests/test_docker.py +13 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/tests/test_integration.py +76 -0
- dlab_cli-0.1.3/tests/test_parallel_tool.py +149 -0
- dlab_cli-0.1.1/tests/test_parallel_tool.py +0 -32
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/LICENSE +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/README.md +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/config.py +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/create_dpack_wizard.py +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/create_parallel_agent_wizard.py +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/data/__init__.py +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/data/models.json +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/js/__init__.py +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/local.py +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/model_fallback.py +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/parallel_tool.py +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/session.py +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/timeline.py +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/tui/__init__.py +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/tui/app.py +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/tui/log_watcher.py +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/tui/models.py +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/tui/widgets/__init__.py +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/tui/widgets/agent_list.py +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/tui/widgets/artifacts_pane.py +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/tui/widgets/log_view.py +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/tui/widgets/search_popup.py +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/tui/widgets/status_bar.py +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab_cli.egg-info/SOURCES.txt +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab_cli.egg-info/dependency_links.txt +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab_cli.egg-info/entry_points.txt +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab_cli.egg-info/requires.txt +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab_cli.egg-info/top_level.txt +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/setup.cfg +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/tests/test_config.py +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/tests/test_create_dpack_wizard.py +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/tests/test_create_parallel_agent_wizard.py +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/tests/test_generate_dpack_integration.py +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/tests/test_model_fallback.py +0 -0
- {dlab_cli-0.1.1 → dlab_cli-0.1.3}/tests/test_session.py +0 -0
|
@@ -401,13 +401,18 @@ def cmd_run(args: argparse.Namespace) -> int:
|
|
|
401
401
|
if continue_mode:
|
|
402
402
|
continue_dir = Path(args.continue_dir).resolve()
|
|
403
403
|
if not continue_dir.exists():
|
|
404
|
-
|
|
404
|
+
console.print(f"Oops, continue directory [bold]{args.continue_dir}[/bold] not found.")
|
|
405
|
+
return 1
|
|
405
406
|
|
|
406
407
|
if args.work_dir:
|
|
407
408
|
# Copy continue-dir to work-dir, then continue from there
|
|
408
409
|
work_path = Path(args.work_dir).resolve()
|
|
409
410
|
if work_path.exists():
|
|
410
|
-
|
|
411
|
+
console.print(
|
|
412
|
+
f"Oops, work directory [bold]{args.work_dir}[/bold] already exists.\n"
|
|
413
|
+
f"You can remove it with: [cyan]rm -rf {args.work_dir}[/cyan]"
|
|
414
|
+
)
|
|
415
|
+
return 1
|
|
411
416
|
shutil.copytree(continue_dir, work_path)
|
|
412
417
|
work_dir = str(work_path)
|
|
413
418
|
print(f"Copied {continue_dir} to {work_dir}")
|
|
@@ -423,10 +428,9 @@ def cmd_run(args: argparse.Namespace) -> int:
|
|
|
423
428
|
# Overwrite .opencode with latest from decision-pack (agent prompts may have changed)
|
|
424
429
|
opencode_dir = Path(work_dir) / ".opencode"
|
|
425
430
|
if opencode_dir.exists():
|
|
426
|
-
|
|
427
|
-
# Local mode: files are user-owned
|
|
431
|
+
try:
|
|
428
432
|
shutil.rmtree(opencode_dir)
|
|
429
|
-
|
|
433
|
+
except PermissionError:
|
|
430
434
|
# Docker mode: files may be root-owned (e.g. node_modules/)
|
|
431
435
|
subprocess.run(
|
|
432
436
|
["sudo", "rm", "-rf", str(opencode_dir)],
|
|
@@ -884,8 +884,16 @@ export default tool({
|
|
|
884
884
|
args: {
|
|
885
885
|
input: tool.schema.string().describe("Input to process"),
|
|
886
886
|
},
|
|
887
|
-
async
|
|
888
|
-
|
|
887
|
+
async execute(args) {
|
|
888
|
+
// Run a CLI command with Bun shell (e.g. Python, bash, etc.)
|
|
889
|
+
const result = await Bun.$`echo "Processing: ${args.input}"`.nothrow()
|
|
890
|
+
const stdout = result.stdout.toString()
|
|
891
|
+
const stderr = result.stderr.toString()
|
|
892
|
+
|
|
893
|
+
if (result.exitCode !== 0) {
|
|
894
|
+
return `ERROR (exit code ${result.exitCode}):\\n${stderr}`
|
|
895
|
+
}
|
|
896
|
+
return stdout.trim()
|
|
889
897
|
},
|
|
890
898
|
})
|
|
891
899
|
"""
|
|
@@ -243,13 +243,14 @@ def _run_docker_build(
|
|
|
243
243
|
stderr=subprocess.STDOUT,
|
|
244
244
|
text=True,
|
|
245
245
|
)
|
|
246
|
-
|
|
246
|
+
output_lines: list[str] = []
|
|
247
247
|
for line in proc.stdout: # type: ignore[union-attr]
|
|
248
248
|
line = line.rstrip("\n")
|
|
249
|
+
output_lines.append(line)
|
|
249
250
|
if on_output:
|
|
250
251
|
on_output(line)
|
|
251
252
|
proc.wait()
|
|
252
|
-
return proc.returncode, "\n".join(
|
|
253
|
+
return proc.returncode, "\n".join(output_lines)
|
|
253
254
|
|
|
254
255
|
|
|
255
256
|
def build_image(
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { tool } from "@opencode-ai/plugin"
|
|
2
2
|
import { readFileSync, mkdirSync, writeFileSync, existsSync, appendFileSync, readdirSync, copyFileSync, cpSync, statSync } from "fs"
|
|
3
|
-
|
|
3
|
+
// Use require() for CJS package — ESM imports break under Bun's strict interop
|
|
4
|
+
const yaml = require("yaml")
|
|
4
5
|
import { join, basename } from "path"
|
|
5
6
|
|
|
6
7
|
// Helper: Copy directory contents excluding certain paths
|
|
@@ -24,7 +25,7 @@ function parseAgentFrontmatter(agentPath: string): Record<string, any> {
|
|
|
24
25
|
const content = readFileSync(agentPath, "utf-8")
|
|
25
26
|
const match = content.match(/^---\n([\s\S]*?)\n---/)
|
|
26
27
|
if (!match) return {}
|
|
27
|
-
return
|
|
28
|
+
return yaml.parse(match[1])
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
// Helper: Build permission config from agent frontmatter tools
|
|
@@ -192,7 +193,7 @@ export default tool({
|
|
|
192
193
|
if (!existsSync(configPath)) {
|
|
193
194
|
throw new Error(`No parallel config found: ${configPath}`)
|
|
194
195
|
}
|
|
195
|
-
const config =
|
|
196
|
+
const config = yaml.parse(readFileSync(configPath, "utf-8"))
|
|
196
197
|
|
|
197
198
|
// Validate instance count
|
|
198
199
|
const numInstances = args.prompts.length
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dlab-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.3"
|
|
8
8
|
description = "A harness for agentic data science — run coding agents with domain skills, parallel subagents, and frozen Docker environments"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -408,6 +408,195 @@ class TestErrorMessages:
|
|
|
408
408
|
assert "rm -rf" in captured.out
|
|
409
409
|
|
|
410
410
|
|
|
411
|
+
class TestContinueDir:
|
|
412
|
+
"""Tests for --continue-dir functionality in both Docker and local modes."""
|
|
413
|
+
|
|
414
|
+
@pytest.fixture
|
|
415
|
+
def previous_session(
|
|
416
|
+
self, dpack_config_dir: Path, data_dir: Path, tmp_path: Path,
|
|
417
|
+
) -> Path:
|
|
418
|
+
"""Create a completed session to continue from."""
|
|
419
|
+
from dlab.config import load_dpack_config
|
|
420
|
+
from dlab.session import create_session
|
|
421
|
+
|
|
422
|
+
config: dict[str, Any] = load_dpack_config(str(dpack_config_dir))
|
|
423
|
+
state: dict[str, Any] = create_session(
|
|
424
|
+
config, str(data_dir), work_dir=str(tmp_path / "prev-session"),
|
|
425
|
+
)
|
|
426
|
+
return Path(state["work_dir"])
|
|
427
|
+
|
|
428
|
+
def _run_continue(
|
|
429
|
+
self,
|
|
430
|
+
dpack_dir: Path,
|
|
431
|
+
continue_dir: Path,
|
|
432
|
+
work_dir: Path | None = None,
|
|
433
|
+
prompt: str = "continue",
|
|
434
|
+
no_sandboxing: bool = False,
|
|
435
|
+
extra_args: list[str] | None = None,
|
|
436
|
+
) -> int:
|
|
437
|
+
"""Helper to run cmd_run in continue mode, mocking agent execution."""
|
|
438
|
+
parser = create_parser()
|
|
439
|
+
cmd: list[str] = [
|
|
440
|
+
"--dpack", str(dpack_dir),
|
|
441
|
+
"--continue-dir", str(continue_dir),
|
|
442
|
+
"--prompt", prompt,
|
|
443
|
+
]
|
|
444
|
+
if work_dir:
|
|
445
|
+
cmd.extend(["--work-dir", str(work_dir)])
|
|
446
|
+
if no_sandboxing:
|
|
447
|
+
cmd.append("--no-sandboxing")
|
|
448
|
+
if extra_args:
|
|
449
|
+
cmd.extend(extra_args)
|
|
450
|
+
args = parser.parse_args(cmd)
|
|
451
|
+
|
|
452
|
+
# Mock agent execution so tests don't hang on real LLM calls
|
|
453
|
+
mock_return = (0, "", "")
|
|
454
|
+
with patch("dlab.cli.run_opencode", return_value=mock_return), \
|
|
455
|
+
patch("dlab.local.run_opencode_local", return_value=mock_return):
|
|
456
|
+
return cmd_run(args)
|
|
457
|
+
|
|
458
|
+
# --- Error handling (no mode needed, errors before execution) ---
|
|
459
|
+
|
|
460
|
+
def test_continue_nonexistent_dir(
|
|
461
|
+
self, dpack_config_dir: Path, capsys: pytest.CaptureFixture[str],
|
|
462
|
+
) -> None:
|
|
463
|
+
"""Should error cleanly when continue-dir doesn't exist."""
|
|
464
|
+
result: int = self._run_continue(
|
|
465
|
+
dpack_config_dir, Path("/nonexistent/dir"),
|
|
466
|
+
)
|
|
467
|
+
assert result == 1
|
|
468
|
+
captured = capsys.readouterr()
|
|
469
|
+
assert "not found" in captured.out
|
|
470
|
+
|
|
471
|
+
def test_continue_with_data_rejected(
|
|
472
|
+
self, dpack_config_dir: Path, data_dir: Path, previous_session: Path,
|
|
473
|
+
capsys: pytest.CaptureFixture[str],
|
|
474
|
+
) -> None:
|
|
475
|
+
"""Should reject --data combined with --continue-dir."""
|
|
476
|
+
parser = create_parser()
|
|
477
|
+
args = parser.parse_args([
|
|
478
|
+
"--dpack", str(dpack_config_dir),
|
|
479
|
+
"--continue-dir", str(previous_session),
|
|
480
|
+
"--data", str(data_dir),
|
|
481
|
+
"--prompt", "continue",
|
|
482
|
+
])
|
|
483
|
+
result: int = cmd_run(args)
|
|
484
|
+
assert result == 1
|
|
485
|
+
|
|
486
|
+
def test_continue_workdir_exists_error(
|
|
487
|
+
self, dpack_config_dir: Path, previous_session: Path, tmp_path: Path,
|
|
488
|
+
capsys: pytest.CaptureFixture[str],
|
|
489
|
+
) -> None:
|
|
490
|
+
"""Should error when --work-dir already exists in continue mode."""
|
|
491
|
+
existing: Path = tmp_path / "already-here"
|
|
492
|
+
existing.mkdir()
|
|
493
|
+
result: int = self._run_continue(
|
|
494
|
+
dpack_config_dir, previous_session,
|
|
495
|
+
work_dir=existing, no_sandboxing=True,
|
|
496
|
+
)
|
|
497
|
+
assert result == 1
|
|
498
|
+
captured = capsys.readouterr()
|
|
499
|
+
assert "already exists" in captured.out
|
|
500
|
+
|
|
501
|
+
# --- Local mode (--no-sandboxing) ---
|
|
502
|
+
|
|
503
|
+
def test_local_continue_to_new_workdir(
|
|
504
|
+
self, dpack_config_dir: Path, previous_session: Path, tmp_path: Path,
|
|
505
|
+
) -> None:
|
|
506
|
+
"""Local: --continue-dir + --work-dir should copy session."""
|
|
507
|
+
new_dir: Path = tmp_path / "local-continued"
|
|
508
|
+
self._run_continue(
|
|
509
|
+
dpack_config_dir, previous_session,
|
|
510
|
+
work_dir=new_dir, no_sandboxing=True,
|
|
511
|
+
)
|
|
512
|
+
assert new_dir.exists()
|
|
513
|
+
assert (new_dir / "data").exists()
|
|
514
|
+
assert (new_dir / ".opencode").exists()
|
|
515
|
+
assert (new_dir / "_opencode_logs").exists()
|
|
516
|
+
|
|
517
|
+
def test_local_continue_refreshes_opencode(
|
|
518
|
+
self, dpack_config_dir: Path, previous_session: Path, tmp_path: Path,
|
|
519
|
+
) -> None:
|
|
520
|
+
"""Local: continue should refresh .opencode/ from decision-pack."""
|
|
521
|
+
new_dir: Path = tmp_path / "local-refreshed"
|
|
522
|
+
marker: Path = previous_session / ".opencode" / "STALE_MARKER"
|
|
523
|
+
marker.write_text("this should be gone after continue")
|
|
524
|
+
|
|
525
|
+
self._run_continue(
|
|
526
|
+
dpack_config_dir, previous_session,
|
|
527
|
+
work_dir=new_dir, no_sandboxing=True,
|
|
528
|
+
)
|
|
529
|
+
assert not (new_dir / ".opencode" / "STALE_MARKER").exists()
|
|
530
|
+
assert (new_dir / ".opencode").exists()
|
|
531
|
+
|
|
532
|
+
def test_local_continue_refreshes_hooks(
|
|
533
|
+
self, dpack_config_dir: Path, previous_session: Path, tmp_path: Path,
|
|
534
|
+
) -> None:
|
|
535
|
+
"""Local: continue should refresh hook scripts."""
|
|
536
|
+
new_dir: Path = tmp_path / "local-hooks"
|
|
537
|
+
hooks_dir: Path = previous_session / "_hooks"
|
|
538
|
+
hooks_dir.mkdir(exist_ok=True)
|
|
539
|
+
(hooks_dir / "old_hook.sh").write_text("stale")
|
|
540
|
+
|
|
541
|
+
self._run_continue(
|
|
542
|
+
dpack_config_dir, previous_session,
|
|
543
|
+
work_dir=new_dir, no_sandboxing=True,
|
|
544
|
+
)
|
|
545
|
+
assert not (new_dir / "_hooks" / "old_hook.sh").exists()
|
|
546
|
+
|
|
547
|
+
def test_local_continue_preserves_data(
|
|
548
|
+
self, dpack_config_dir: Path, previous_session: Path, tmp_path: Path,
|
|
549
|
+
) -> None:
|
|
550
|
+
"""Local: continue should preserve data from original session."""
|
|
551
|
+
new_dir: Path = tmp_path / "local-preserved"
|
|
552
|
+
self._run_continue(
|
|
553
|
+
dpack_config_dir, previous_session,
|
|
554
|
+
work_dir=new_dir, no_sandboxing=True,
|
|
555
|
+
)
|
|
556
|
+
assert (new_dir / "data" / "sample.csv").exists()
|
|
557
|
+
assert (new_dir / "data" / "subdir" / "nested.txt").exists()
|
|
558
|
+
|
|
559
|
+
# --- Docker mode ---
|
|
560
|
+
|
|
561
|
+
def test_docker_continue_to_new_workdir(
|
|
562
|
+
self, dpack_config_dir: Path, previous_session: Path, tmp_path: Path,
|
|
563
|
+
) -> None:
|
|
564
|
+
"""Docker: --continue-dir + --work-dir should copy session."""
|
|
565
|
+
new_dir: Path = tmp_path / "docker-continued"
|
|
566
|
+
self._run_continue(
|
|
567
|
+
dpack_config_dir, previous_session, work_dir=new_dir,
|
|
568
|
+
)
|
|
569
|
+
assert new_dir.exists()
|
|
570
|
+
assert (new_dir / "data").exists()
|
|
571
|
+
assert (new_dir / ".opencode").exists()
|
|
572
|
+
assert (new_dir / "_opencode_logs").exists()
|
|
573
|
+
|
|
574
|
+
def test_docker_continue_refreshes_opencode(
|
|
575
|
+
self, dpack_config_dir: Path, previous_session: Path, tmp_path: Path,
|
|
576
|
+
) -> None:
|
|
577
|
+
"""Docker: continue should refresh .opencode/ from decision-pack."""
|
|
578
|
+
new_dir: Path = tmp_path / "docker-refreshed"
|
|
579
|
+
marker: Path = previous_session / ".opencode" / "STALE_MARKER"
|
|
580
|
+
marker.write_text("this should be gone after continue")
|
|
581
|
+
|
|
582
|
+
self._run_continue(
|
|
583
|
+
dpack_config_dir, previous_session, work_dir=new_dir,
|
|
584
|
+
)
|
|
585
|
+
assert not (new_dir / ".opencode" / "STALE_MARKER").exists()
|
|
586
|
+
assert (new_dir / ".opencode").exists()
|
|
587
|
+
|
|
588
|
+
def test_docker_continue_preserves_data(
|
|
589
|
+
self, dpack_config_dir: Path, previous_session: Path, tmp_path: Path,
|
|
590
|
+
) -> None:
|
|
591
|
+
"""Docker: continue should preserve data from original session."""
|
|
592
|
+
new_dir: Path = tmp_path / "docker-preserved"
|
|
593
|
+
self._run_continue(
|
|
594
|
+
dpack_config_dir, previous_session, work_dir=new_dir,
|
|
595
|
+
)
|
|
596
|
+
assert (new_dir / "data" / "sample.csv").exists()
|
|
597
|
+
assert (new_dir / "data" / "subdir" / "nested.txt").exists()
|
|
598
|
+
|
|
599
|
+
|
|
411
600
|
class TestCmdInstall:
|
|
412
601
|
"""Tests for cmd_install function."""
|
|
413
602
|
|
|
@@ -10,6 +10,8 @@ import pytest
|
|
|
10
10
|
import yaml
|
|
11
11
|
|
|
12
12
|
from dlab.create_dpack import (
|
|
13
|
+
EXAMPLE_TOOL_TS,
|
|
14
|
+
RUN_ON_MODAL_TS,
|
|
13
15
|
filter_models,
|
|
14
16
|
generate_dpack,
|
|
15
17
|
validate_dpack_name,
|
|
@@ -431,6 +433,46 @@ class TestSkeletonTools:
|
|
|
431
433
|
assert "exitCode" in content
|
|
432
434
|
|
|
433
435
|
|
|
436
|
+
class TestToolTemplatesApi:
|
|
437
|
+
"""Tests that tool templates use the correct OpenCode API."""
|
|
438
|
+
|
|
439
|
+
def test_example_tool_uses_execute_not_run(self) -> None:
|
|
440
|
+
"""example-tool.ts MUST use execute(), not run().
|
|
441
|
+
|
|
442
|
+
OpenCode calls def.execute(args, ctx) on custom tools.
|
|
443
|
+
Using run() causes 'def.execute is not a function' at runtime.
|
|
444
|
+
"""
|
|
445
|
+
assert "async execute(" in EXAMPLE_TOOL_TS, (
|
|
446
|
+
"EXAMPLE_TOOL_TS must use 'async execute(args)' — "
|
|
447
|
+
"OpenCode calls def.execute(), not def.run()"
|
|
448
|
+
)
|
|
449
|
+
assert "async run(" not in EXAMPLE_TOOL_TS, (
|
|
450
|
+
"EXAMPLE_TOOL_TS must NOT use 'async run()' — "
|
|
451
|
+
"this causes 'def.execute is not a function' at runtime"
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
def test_modal_tool_uses_execute_not_run(self) -> None:
|
|
455
|
+
"""run-on-modal.ts MUST use execute(), not run()."""
|
|
456
|
+
assert "async execute(" in RUN_ON_MODAL_TS
|
|
457
|
+
assert "async run(" not in RUN_ON_MODAL_TS
|
|
458
|
+
|
|
459
|
+
def test_example_tool_uses_bun_shell(self) -> None:
|
|
460
|
+
"""example-tool.ts should demonstrate Bun shell for CLI commands."""
|
|
461
|
+
assert "Bun.$" in EXAMPLE_TOOL_TS, (
|
|
462
|
+
"EXAMPLE_TOOL_TS should use Bun.$`...` for CLI commands"
|
|
463
|
+
)
|
|
464
|
+
assert ".nothrow()" in EXAMPLE_TOOL_TS, (
|
|
465
|
+
"EXAMPLE_TOOL_TS should use .nothrow() for error handling"
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
def test_generated_tool_uses_execute(self, tmp_path: Path) -> None:
|
|
469
|
+
"""Generated example-tool.ts must use execute(), not run()."""
|
|
470
|
+
generate_dpack(tmp_path, {"name": "api-test", "skeletons": {"tools": True}})
|
|
471
|
+
content: str = (tmp_path / "api-test" / "opencode" / "tools" / "example-tool.ts").read_text()
|
|
472
|
+
assert "async execute(" in content
|
|
473
|
+
assert "async run(" not in content
|
|
474
|
+
|
|
475
|
+
|
|
434
476
|
class TestSkeletonSkills:
|
|
435
477
|
"""Tests for skills skeleton."""
|
|
436
478
|
|
|
@@ -10,6 +10,7 @@ from typing import Any
|
|
|
10
10
|
import pytest
|
|
11
11
|
|
|
12
12
|
from dlab.docker import (
|
|
13
|
+
_run_docker_build,
|
|
13
14
|
build_image,
|
|
14
15
|
build_runner_script,
|
|
15
16
|
compute_docker_dir_hash,
|
|
@@ -103,6 +104,18 @@ class TestBuildImage:
|
|
|
103
104
|
with pytest.raises(ValueError, match="Docker build failed"):
|
|
104
105
|
build_image(str(tmp_path), "test-image")
|
|
105
106
|
|
|
107
|
+
def test_build_error_includes_output(self, tmp_path: Path) -> None:
|
|
108
|
+
"""Build failures should include diagnostic output, not an empty string."""
|
|
109
|
+
docker_path: Path = tmp_path / "docker"
|
|
110
|
+
docker_path.mkdir()
|
|
111
|
+
(docker_path / "Dockerfile").write_text("FROM nonexistent-image-xxxxx\n")
|
|
112
|
+
|
|
113
|
+
returncode, output = _run_docker_build(
|
|
114
|
+
["docker", "build", str(docker_path)],
|
|
115
|
+
)
|
|
116
|
+
assert returncode != 0
|
|
117
|
+
assert len(output) > 0, "Build error output should not be empty"
|
|
118
|
+
|
|
106
119
|
|
|
107
120
|
class TestContainerExists:
|
|
108
121
|
"""Tests for container_exists function."""
|
|
@@ -49,6 +49,15 @@ def _has_api_key() -> bool:
|
|
|
49
49
|
return "ANTHROPIC_API_KEY=" in content
|
|
50
50
|
|
|
51
51
|
|
|
52
|
+
def _has_google_key() -> bool:
|
|
53
|
+
"""Check if .env file exists and contains a Google AI API key."""
|
|
54
|
+
env_path: Path = Path(ENV_FILE)
|
|
55
|
+
if not env_path.exists():
|
|
56
|
+
return False
|
|
57
|
+
content: str = env_path.read_text()
|
|
58
|
+
return "GOOGLE_GENERATIVE_AI_API_KEY=" in content
|
|
59
|
+
|
|
60
|
+
|
|
52
61
|
def _remove_image(name: str) -> None:
|
|
53
62
|
"""Remove a Docker image, ignoring errors if it doesn't exist."""
|
|
54
63
|
subprocess.run(["docker", "rmi", "-f", name], capture_output=True)
|
|
@@ -386,3 +395,70 @@ class TestEndToEnd:
|
|
|
386
395
|
assert (work_dir / "final_poem.md").exists()
|
|
387
396
|
poem_content: str = (work_dir / "final_poem.md").read_text()
|
|
388
397
|
assert len(poem_content.strip()) > 0
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# ---------------------------------------------------------------------------
|
|
401
|
+
# TestOpenCodeLatest
|
|
402
|
+
# ---------------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@pytest.mark.skipif(not _has_google_key(), reason="No GOOGLE_GENERATIVE_AI_API_KEY in .env")
|
|
406
|
+
class TestOpenCodeLatest:
|
|
407
|
+
"""
|
|
408
|
+
End-to-end test with opencode latest (no pinned version).
|
|
409
|
+
|
|
410
|
+
Uses the poem decision-pack with google/gemini-2.5-flash to verify
|
|
411
|
+
that parallel-agents.ts works with the latest opencode release.
|
|
412
|
+
This catches ESM/CJS interop regressions (issue #17).
|
|
413
|
+
|
|
414
|
+
Requires GOOGLE_GENERATIVE_AI_API_KEY in the .env file at repo root.
|
|
415
|
+
"""
|
|
416
|
+
|
|
417
|
+
LATEST_IMAGE_NAME: str = "dlab-opencode-latest-test"
|
|
418
|
+
|
|
419
|
+
@pytest.fixture(scope="class")
|
|
420
|
+
def latest_dpack_dir(self, tmp_path_factory: pytest.TempPathFactory) -> Path:
|
|
421
|
+
"""Copy poem dpack with opencode_version removed (forces latest)."""
|
|
422
|
+
base: Path = tmp_path_factory.mktemp("latest_dpack")
|
|
423
|
+
dpack_copy: Path = base / "poem"
|
|
424
|
+
shutil.copytree(POEM_DPACK_DIR, str(dpack_copy))
|
|
425
|
+
|
|
426
|
+
config_path: Path = dpack_copy / "config.yaml"
|
|
427
|
+
config_data: dict[str, Any] = yaml.safe_load(config_path.read_text())
|
|
428
|
+
config_data.pop("opencode_version", None)
|
|
429
|
+
config_data["docker_image_name"] = self.LATEST_IMAGE_NAME
|
|
430
|
+
config_path.write_text(yaml.dump(config_data))
|
|
431
|
+
|
|
432
|
+
return dpack_copy
|
|
433
|
+
|
|
434
|
+
@pytest.fixture(scope="class", autouse=True)
|
|
435
|
+
def cleanup_image(self) -> Generator[None, None, None]:
|
|
436
|
+
yield
|
|
437
|
+
_remove_image(self.LATEST_IMAGE_NAME)
|
|
438
|
+
_remove_image(f"{self.LATEST_IMAGE_NAME}-base")
|
|
439
|
+
|
|
440
|
+
def test_poem_with_opencode_latest(
|
|
441
|
+
self, latest_dpack_dir: Path, tmp_path: Path,
|
|
442
|
+
) -> None:
|
|
443
|
+
"""Full poem run with opencode latest — catches yaml import issues."""
|
|
444
|
+
work_dir: Path = tmp_path / "latest-test"
|
|
445
|
+
|
|
446
|
+
parser: argparse.ArgumentParser = create_parser()
|
|
447
|
+
args: argparse.Namespace = parser.parse_args([
|
|
448
|
+
"--dpack", str(latest_dpack_dir),
|
|
449
|
+
"--prompt", "Write a haiku about the sea.",
|
|
450
|
+
"--work-dir", str(work_dir),
|
|
451
|
+
"--env-file", ENV_FILE,
|
|
452
|
+
])
|
|
453
|
+
|
|
454
|
+
exit_code: int = cmd_run(args)
|
|
455
|
+
|
|
456
|
+
assert exit_code == 0, (
|
|
457
|
+
f"Poem run with opencode latest failed (exit {exit_code}). "
|
|
458
|
+
f"Check {work_dir / '_opencode_logs'} for details."
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
# Agent ran and produced a log
|
|
462
|
+
main_log: Path = work_dir / "_opencode_logs" / "main.log"
|
|
463
|
+
assert main_log.exists()
|
|
464
|
+
assert main_log.stat().st_size > 0
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for dlab.parallel_tool module.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from dlab.parallel_tool import PARALLEL_AGENTS_SOURCE
|
|
14
|
+
|
|
15
|
+
# Node built-in modules and OpenCode packages are safe for ESM named imports
|
|
16
|
+
_ALLOWED_ESM_NAMED_IMPORTS: set[str] = {
|
|
17
|
+
"fs", "path", "os", "util", "url", "child_process", "crypto",
|
|
18
|
+
"stream", "events", "http", "https", "net", "assert",
|
|
19
|
+
"@opencode-ai/plugin",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestParallelAgentsSource:
|
|
24
|
+
"""Tests for PARALLEL_AGENTS_SOURCE loading."""
|
|
25
|
+
|
|
26
|
+
def test_loads_successfully(self) -> None:
|
|
27
|
+
"""PARALLEL_AGENTS_SOURCE should be a non-empty string."""
|
|
28
|
+
assert isinstance(PARALLEL_AGENTS_SOURCE, str)
|
|
29
|
+
assert len(PARALLEL_AGENTS_SOURCE) > 0
|
|
30
|
+
|
|
31
|
+
def test_contains_tool_export(self) -> None:
|
|
32
|
+
"""Should contain the tool description marker."""
|
|
33
|
+
assert "Spawn parallel subagents" in PARALLEL_AGENTS_SOURCE
|
|
34
|
+
|
|
35
|
+
def test_contains_key_functions(self) -> None:
|
|
36
|
+
"""Should contain key TypeScript functions from the source."""
|
|
37
|
+
assert "copyWorkDir" in PARALLEL_AGENTS_SOURCE
|
|
38
|
+
assert "setupConsolidator" in PARALLEL_AGENTS_SOURCE
|
|
39
|
+
assert "buildPermissionsFromFrontmatter" in PARALLEL_AGENTS_SOURCE
|
|
40
|
+
|
|
41
|
+
def test_matches_source_file(self) -> None:
|
|
42
|
+
"""Content should match the .ts file read directly from disk."""
|
|
43
|
+
source_file: Path = Path(__file__).parent.parent / "dlab" / "js" / "parallel-agents.ts"
|
|
44
|
+
expected: str = source_file.read_text()
|
|
45
|
+
assert PARALLEL_AGENTS_SOURCE == expected
|
|
46
|
+
|
|
47
|
+
def test_no_esm_named_imports_from_third_party(self) -> None:
|
|
48
|
+
"""Third-party packages must use default imports to avoid CJS/ESM issues.
|
|
49
|
+
|
|
50
|
+
ESM named imports (import { x } from "pkg") break when the package
|
|
51
|
+
is CommonJS. Node built-ins and @opencode-ai/* are safe.
|
|
52
|
+
"""
|
|
53
|
+
pattern: re.Pattern[str] = re.compile(
|
|
54
|
+
r'import\s*\{[^}]+\}\s*from\s*["\']([^"\']+)["\']'
|
|
55
|
+
)
|
|
56
|
+
for match in pattern.finditer(PARALLEL_AGENTS_SOURCE):
|
|
57
|
+
pkg: str = match.group(1)
|
|
58
|
+
assert pkg in _ALLOWED_ESM_NAMED_IMPORTS, (
|
|
59
|
+
f"ESM named import from third-party package '{pkg}' — "
|
|
60
|
+
f"use default import instead (import pkg from \"{pkg}\") "
|
|
61
|
+
f"to avoid CJS/ESM interop issues"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TestYamlImportRuntime:
|
|
66
|
+
"""Verify that the yaml import in parallel-agents.ts works at runtime.
|
|
67
|
+
|
|
68
|
+
Catches ESM/CJS interop issues that static analysis alone would miss.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
@pytest.fixture
|
|
72
|
+
def js_workspace(self, tmp_path: Path) -> Path:
|
|
73
|
+
"""Set up a temp workspace with yaml installed, mirroring session setup."""
|
|
74
|
+
pkg: dict[str, object] = {"dependencies": {"yaml": "^2.0.0"}}
|
|
75
|
+
(tmp_path / "package.json").write_text(json.dumps(pkg))
|
|
76
|
+
result: subprocess.CompletedProcess[str] = subprocess.run(
|
|
77
|
+
["npm", "install", "--silent"],
|
|
78
|
+
cwd=tmp_path,
|
|
79
|
+
capture_output=True,
|
|
80
|
+
text=True,
|
|
81
|
+
timeout=30,
|
|
82
|
+
)
|
|
83
|
+
if result.returncode != 0:
|
|
84
|
+
pytest.skip(f"npm install failed: {result.stderr}")
|
|
85
|
+
return tmp_path
|
|
86
|
+
|
|
87
|
+
def test_yaml_import_node(self, js_workspace: Path) -> None:
|
|
88
|
+
"""yaml import must resolve and parse() must work under Node."""
|
|
89
|
+
# Extract just the yaml import line from our source
|
|
90
|
+
import_lines: list[str] = [
|
|
91
|
+
line for line in PARALLEL_AGENTS_SOURCE.splitlines()
|
|
92
|
+
if "yaml" in line and ("import" in line or "require" in line)
|
|
93
|
+
]
|
|
94
|
+
assert import_lines, "No yaml import found in parallel-agents.ts"
|
|
95
|
+
|
|
96
|
+
# Build a minimal JS test that does what our .ts does
|
|
97
|
+
test_js: str = (
|
|
98
|
+
'const yaml = require("yaml");\n'
|
|
99
|
+
'const result = yaml.parse("key: value");\n'
|
|
100
|
+
'if (result.key !== "value") { process.exit(1); }\n'
|
|
101
|
+
'console.log("ok");\n'
|
|
102
|
+
)
|
|
103
|
+
test_file: Path = js_workspace / "test_yaml.js"
|
|
104
|
+
test_file.write_text(test_js)
|
|
105
|
+
|
|
106
|
+
result: subprocess.CompletedProcess[str] = subprocess.run(
|
|
107
|
+
["node", str(test_file)],
|
|
108
|
+
capture_output=True,
|
|
109
|
+
text=True,
|
|
110
|
+
timeout=10,
|
|
111
|
+
)
|
|
112
|
+
assert result.returncode == 0, (
|
|
113
|
+
f"yaml require() failed under Node:\n"
|
|
114
|
+
f"stdout: {result.stdout}\nstderr: {result.stderr}"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def test_yaml_import_docker(self, js_workspace: Path) -> None:
|
|
118
|
+
"""yaml import must work inside a Docker container (closer to production)."""
|
|
119
|
+
test_js: str = (
|
|
120
|
+
'const yaml = require("yaml");\n'
|
|
121
|
+
'const result = yaml.parse("key: value");\n'
|
|
122
|
+
'if (result.key !== "value") { process.exit(1); }\n'
|
|
123
|
+
'console.log("ok");\n'
|
|
124
|
+
)
|
|
125
|
+
test_file: Path = js_workspace / "test_yaml.js"
|
|
126
|
+
test_file.write_text(test_js)
|
|
127
|
+
|
|
128
|
+
result: subprocess.CompletedProcess[str] = subprocess.run(
|
|
129
|
+
[
|
|
130
|
+
"docker", "run", "--rm",
|
|
131
|
+
"-v", f"{js_workspace}:/app",
|
|
132
|
+
"-w", "/app",
|
|
133
|
+
"node:20-slim",
|
|
134
|
+
"node", "test_yaml.js",
|
|
135
|
+
],
|
|
136
|
+
capture_output=True,
|
|
137
|
+
text=True,
|
|
138
|
+
timeout=30,
|
|
139
|
+
)
|
|
140
|
+
assert result.returncode == 0, (
|
|
141
|
+
f"yaml require() failed inside Docker (node:20-slim):\n"
|
|
142
|
+
f"stdout: {result.stdout}\nstderr: {result.stderr}"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def test_import_style_matches_source(self) -> None:
|
|
146
|
+
"""The yaml import in parallel-agents.ts must use require(), not ESM import."""
|
|
147
|
+
assert 'require("yaml")' in PARALLEL_AGENTS_SOURCE, (
|
|
148
|
+
"parallel-agents.ts must use require('yaml') for CJS/ESM compatibility"
|
|
149
|
+
)
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Tests for dlab.parallel_tool module.
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
from dlab.parallel_tool import PARALLEL_AGENTS_SOURCE
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class TestParallelAgentsSource:
|
|
11
|
-
"""Tests for PARALLEL_AGENTS_SOURCE loading."""
|
|
12
|
-
|
|
13
|
-
def test_loads_successfully(self) -> None:
|
|
14
|
-
"""PARALLEL_AGENTS_SOURCE should be a non-empty string."""
|
|
15
|
-
assert isinstance(PARALLEL_AGENTS_SOURCE, str)
|
|
16
|
-
assert len(PARALLEL_AGENTS_SOURCE) > 0
|
|
17
|
-
|
|
18
|
-
def test_contains_tool_export(self) -> None:
|
|
19
|
-
"""Should contain the tool description marker."""
|
|
20
|
-
assert "Spawn parallel subagents" in PARALLEL_AGENTS_SOURCE
|
|
21
|
-
|
|
22
|
-
def test_contains_key_functions(self) -> None:
|
|
23
|
-
"""Should contain key TypeScript functions from the source."""
|
|
24
|
-
assert "copyWorkDir" in PARALLEL_AGENTS_SOURCE
|
|
25
|
-
assert "setupConsolidator" in PARALLEL_AGENTS_SOURCE
|
|
26
|
-
assert "buildPermissionsFromFrontmatter" in PARALLEL_AGENTS_SOURCE
|
|
27
|
-
|
|
28
|
-
def test_matches_source_file(self) -> None:
|
|
29
|
-
"""Content should match the .ts file read directly from disk."""
|
|
30
|
-
source_file: Path = Path(__file__).parent.parent / "dlab" / "js" / "parallel-agents.ts"
|
|
31
|
-
expected: str = source_file.read_text()
|
|
32
|
-
assert PARALLEL_AGENTS_SOURCE == expected
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|