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.
Files changed (49) hide show
  1. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/PKG-INFO +1 -1
  2. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/__init__.py +1 -1
  3. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/cli.py +9 -5
  4. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/create_dpack.py +10 -2
  5. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/docker.py +3 -2
  6. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/js/parallel-agents.ts +4 -3
  7. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab_cli.egg-info/PKG-INFO +1 -1
  8. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/pyproject.toml +1 -1
  9. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/tests/test_cli.py +189 -0
  10. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/tests/test_create_dpack.py +42 -0
  11. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/tests/test_docker.py +13 -0
  12. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/tests/test_integration.py +76 -0
  13. dlab_cli-0.1.3/tests/test_parallel_tool.py +149 -0
  14. dlab_cli-0.1.1/tests/test_parallel_tool.py +0 -32
  15. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/LICENSE +0 -0
  16. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/README.md +0 -0
  17. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/config.py +0 -0
  18. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/create_dpack_wizard.py +0 -0
  19. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/create_parallel_agent_wizard.py +0 -0
  20. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/data/__init__.py +0 -0
  21. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/data/models.json +0 -0
  22. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/js/__init__.py +0 -0
  23. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/local.py +0 -0
  24. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/model_fallback.py +0 -0
  25. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/parallel_tool.py +0 -0
  26. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/session.py +0 -0
  27. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/timeline.py +0 -0
  28. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/tui/__init__.py +0 -0
  29. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/tui/app.py +0 -0
  30. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/tui/log_watcher.py +0 -0
  31. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/tui/models.py +0 -0
  32. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/tui/widgets/__init__.py +0 -0
  33. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/tui/widgets/agent_list.py +0 -0
  34. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/tui/widgets/artifacts_pane.py +0 -0
  35. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/tui/widgets/log_view.py +0 -0
  36. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/tui/widgets/search_popup.py +0 -0
  37. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab/tui/widgets/status_bar.py +0 -0
  38. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab_cli.egg-info/SOURCES.txt +0 -0
  39. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab_cli.egg-info/dependency_links.txt +0 -0
  40. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab_cli.egg-info/entry_points.txt +0 -0
  41. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab_cli.egg-info/requires.txt +0 -0
  42. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/dlab_cli.egg-info/top_level.txt +0 -0
  43. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/setup.cfg +0 -0
  44. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/tests/test_config.py +0 -0
  45. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/tests/test_create_dpack_wizard.py +0 -0
  46. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/tests/test_create_parallel_agent_wizard.py +0 -0
  47. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/tests/test_generate_dpack_integration.py +0 -0
  48. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/tests/test_model_fallback.py +0 -0
  49. {dlab_cli-0.1.1 → dlab_cli-0.1.3}/tests/test_session.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dlab-cli
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: A harness for agentic data science — run coding agents with domain skills, parallel subagents, and frozen Docker environments
5
5
  Author: DecisionAI
6
6
  License: Apache-2.0
@@ -3,4 +3,4 @@ dlab: CLI wrapper that runs opencode in automated mode, sandboxed with Docker,
3
3
  and with parallel-agent capability.
4
4
  """
5
5
 
6
- __version__ = "0.1.1"
6
+ __version__ = "0.1.3"
@@ -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
- raise ValueError(f"Continue directory not found: {args.continue_dir}")
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
- raise ValueError(f"Work directory already exists: {args.work_dir}")
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
- if no_sandboxing:
427
- # Local mode: files are user-owned
431
+ try:
428
432
  shutil.rmtree(opencode_dir)
429
- else:
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 run({ input }) {
888
- return `Processed: ${input}`
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
- stderr_lines: list[str] = []
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(stderr_lines)
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
- import { parse as parseYaml } from "yaml"
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 parseYaml(match[1])
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 = parseYaml(readFileSync(configPath, "utf-8"))
196
+ const config = yaml.parse(readFileSync(configPath, "utf-8"))
196
197
 
197
198
  // Validate instance count
198
199
  const numInstances = args.prompts.length
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dlab-cli
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: A harness for agentic data science — run coding agents with domain skills, parallel subagents, and frozen Docker environments
5
5
  Author: DecisionAI
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dlab-cli"
7
- version = "0.1.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